mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 03:27:43 +09:00
restore: remaining reconstructed July 5-7 changes (clean recovered files not attributable to one plan)
This commit is contained in:
1 parent
e08fe94b21
commit
6e32519f7e
101 files changed
+3539
-2376
No files matched your search
@@ -76,6 +76,7 @@ jobs:
|
||||
bash scripts/test/test_lib.sh
|
||||
bash scripts/test/test_cli.sh
|
||||
bash scripts/test/test_agent_install.sh
|
||||
bash scripts/test/test_stage.sh
|
||||
|
||||
- name: nginx -t smoke (shipped vhosts parse on a real nginx)
|
||||
run: |
|
||||
@@ -132,6 +133,7 @@ jobs:
|
||||
bash scripts/test/test_lib.sh
|
||||
bash scripts/test/test_cli.sh
|
||||
bash scripts/test/test_agent_install.sh
|
||||
bash scripts/test/test_stage.sh
|
||||
'
|
||||
|
||||
update-e2e:
|
||||
|
||||
@@ -150,3 +150,9 @@ docs/NOTIFICATIONS_ROADMAP.md
|
||||
docs/ORCHESTRATION_ROADMAP.md
|
||||
docs/DNS_ROADMAP.md
|
||||
docs/SERVICES_ROADMAP.md
|
||||
|
||||
# Staging target profiles (scripts/stage.sh) — real profiles carry a host alias
|
||||
# + paths and must never enter the repo. The committed template is the sole
|
||||
# exception so `cp example.env <name>.env` always has a starting point.
|
||||
/deploy/targets/*.env
|
||||
!/deploy/targets/example.env
|
||||
@@ -48,7 +48,7 @@ Flask app factory in `app/__init__.py` using `create_app()`. Three-layer archite
|
||||
|
||||
Other backend components:
|
||||
- `app/sockets.py` — Socket.IO event handlers for real-time metrics, logs, terminal
|
||||
- `app/agent_gateway.py` — Defines the Socket.IO `/agent` namespace for the multi-server agent fleet, backed by the in-memory singleton `app/services/agent_registry.py` (HMAC auth, heartbeat/metrics, command routing); `app/api/agent_poll.py` is the HTTP long-poll fallback. The native agent itself is a Go program under `agent/`.
|
||||
- `app/agent_gateway.py` — Defines the Socket.IO `/agent` namespace for the multi-server agent fleet, backed by the in-memory singleton `app/services/agent_registry.py` (HMAC auth, heartbeat/metrics, command routing); `app/api/agent_poll.py` is the HTTP long-poll fallback. The native agent itself is a Go program maintained in the separate `serverkit-agent` repo (extracted from this repo's former `agent/` dir) — agent-side capability changes ship there, so panel↔agent protocol changes are no longer atomic in one commit.
|
||||
- `app/middleware/security.py` — Security headers middleware
|
||||
- `config.py` — Environment-based config (development/production/testing)
|
||||
- `run.py` — Entry point
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
# Bucket: PER-APP read (plan 29 #9). Per-app bandwidth is scoped to callers who
|
||||
# can access the app (can_access_app); the cross-app overview stays panel-wide.
|
||||
"""REST surface for per-domain bandwidth accounting.
|
||||
|
||||
Mounted at /api/v1/bandwidth (registered in app/__init__.py).
|
||||
"""
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask_jwt_extended import get_jwt_identity
|
||||
|
||||
from ..middleware.rbac import admin_required, viewer_required
|
||||
from ..models import User, Application
|
||||
from ..services.bandwidth_service import BandwidthService
|
||||
from ..services.resource_grant_service import ResourceGrantService
|
||||
|
||||
bandwidth_bp = Blueprint('bandwidth', __name__)
|
||||
|
||||
@@ -26,7 +31,12 @@ def get_apps_bandwidth():
|
||||
@bandwidth_bp.route('/apps/<int:app_id>', methods=['GET'])
|
||||
@viewer_required
|
||||
def get_app_bandwidth(app_id):
|
||||
"""Full daily series (default 90 days) + current-month total for one app."""
|
||||
"""Full daily series (default 90 days) + current-month total for one app,
|
||||
scoped to the app's workspace visibility (plan 29 #9 — foreign caller 404)."""
|
||||
app = Application.query.get(app_id)
|
||||
user = User.query.get(get_jwt_identity())
|
||||
if app is None or not ResourceGrantService.can_access_app(user, app):
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
try:
|
||||
days = request.args.get('days', 90, type=int)
|
||||
series = BandwidthService.series(app_id=app_id, days=days)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Bucket: PER-APP (plan 29 #9). Per-app build routes gate via the app-grant
|
||||
# family (can_access_app / can_edit_app). See docs/WORKSPACE_SCOPING.md.
|
||||
"""
|
||||
Build and Deployment API Endpoints
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Bucket: PER-APP read (plan 29 #9). Per-app history gates via _access →
|
||||
# can_access_app. See docs/WORKSPACE_SCOPING.md.
|
||||
"""Unified deployment surface (§3 unification).
|
||||
|
||||
One read namespace over the deploy records that today live in three places:
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
# Bucket: PER-APP (plan 29 #9). The registry-check trigger stays admin; the
|
||||
# latest-check read is scoped to callers who can access the app (can_access_app).
|
||||
from flask import Blueprint, jsonify
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from app.models.user import User
|
||||
from app.models.application import Application
|
||||
from app.services.image_update_service import ImageUpdateService
|
||||
from app.services.resource_grant_service import ResourceGrantService
|
||||
|
||||
image_updates_bp = Blueprint('image_updates', __name__)
|
||||
|
||||
@@ -27,6 +31,13 @@ def check(app_id):
|
||||
@image_updates_bp.route('/applications/<int:app_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def latest(app_id):
|
||||
"""Return the most recent image-update check for the application (or null)."""
|
||||
"""Return the most recent image-update check for the application (or null).
|
||||
|
||||
Scoped to the app's workspace visibility (plan 29 #9) — a foreign caller
|
||||
gets a sealed 404."""
|
||||
app = Application.query.get(app_id)
|
||||
user = User.query.get(get_jwt_identity())
|
||||
if app is None or not ResourceGrantService.can_access_app(user, app):
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
check_row = ImageUpdateService.latest_check(app_id)
|
||||
return jsonify(check_row.to_dict() if check_row else None)
|
||||
@@ -51,6 +51,13 @@ def install_registry(slug):
|
||||
target_id=plugin.id,
|
||||
details={'name': plugin.name, 'version': plugin.version, 'source': 'registry'},
|
||||
)
|
||||
# Opt-in anonymous install ping (default OFF; #17). Best-effort — never
|
||||
# affects the install result.
|
||||
try:
|
||||
from app.services import registry_service
|
||||
registry_service.record_install(plugin.slug, plugin.version)
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify(plugin.to_dict()), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
+23
-12
@@ -1,23 +1,34 @@
|
||||
# Bucket: PER-APP (plan 29 #9). Reads require app access (can_access_app); the
|
||||
# enable/sync/redeploy/destroy mutations require write access (can_edit_app),
|
||||
# replacing the old panel-wide @developer_required.
|
||||
"""PR Preview Environments API.
|
||||
|
||||
Mounted by the host at ``/api/v1/apps`` so every route here is nested under an
|
||||
application id, e.g. ``GET /api/v1/apps/<app_id>/previews``.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_jwt_extended import jwt_required
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
|
||||
from app.middleware.rbac import developer_required
|
||||
from app.models import Application
|
||||
from app.models import Application, User
|
||||
from app.models.application_preview import ApplicationPreview
|
||||
from app.services.preview_service import PreviewService
|
||||
from app.services.resource_grant_service import ResourceGrantService
|
||||
|
||||
previews_bp = Blueprint('previews', __name__)
|
||||
|
||||
|
||||
def _get_app_or_404(app_id):
|
||||
def _get_app_or_404(app_id, write=False):
|
||||
"""Resolve the app and enforce per-app access (plan 29 #9). ``write`` requires
|
||||
can_edit_app (owner/admin/editor); otherwise can_access_app (adds workspace
|
||||
members). A caller without the needed tier gets a sealed 404 (no leak)."""
|
||||
app = Application.query.get(app_id)
|
||||
if not app:
|
||||
return None, (jsonify({'error': 'Application not found'}), 404)
|
||||
user = User.query.get(get_jwt_identity())
|
||||
ok = ResourceGrantService.can_edit_app(user, app) if write \
|
||||
else ResourceGrantService.can_access_app(user, app)
|
||||
if not ok:
|
||||
return None, (jsonify({'error': 'Application not found'}), 404)
|
||||
return app, None
|
||||
|
||||
|
||||
@@ -47,10 +58,10 @@ def get_preview_settings(app_id):
|
||||
|
||||
|
||||
@previews_bp.route('/<int:app_id>/previews/settings', methods=['PUT'])
|
||||
@developer_required
|
||||
@jwt_required()
|
||||
def update_preview_settings(app_id):
|
||||
"""Enable/disable previews and set the domain template + TTL for an app."""
|
||||
app, err = _get_app_or_404(app_id)
|
||||
app, err = _get_app_or_404(app_id, write=True)
|
||||
if err:
|
||||
return err
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -62,10 +73,10 @@ def update_preview_settings(app_id):
|
||||
|
||||
|
||||
@previews_bp.route('/<int:app_id>/previews/sync', methods=['POST'])
|
||||
@developer_required
|
||||
@jwt_required()
|
||||
def sync_previews(app_id):
|
||||
"""Reconcile this app's previews against its open PRs (best-effort)."""
|
||||
app, err = _get_app_or_404(app_id)
|
||||
app, err = _get_app_or_404(app_id, write=True)
|
||||
if err:
|
||||
return err
|
||||
result = PreviewService.sync_previews(app_id)
|
||||
@@ -74,10 +85,10 @@ def sync_previews(app_id):
|
||||
|
||||
|
||||
@previews_bp.route('/<int:app_id>/previews/<int:preview_id>/redeploy', methods=['POST'])
|
||||
@developer_required
|
||||
@jwt_required()
|
||||
def redeploy_preview(app_id, preview_id):
|
||||
"""Re-provision a preview for the same PR (idempotent update-in-place)."""
|
||||
app, err = _get_app_or_404(app_id)
|
||||
app, err = _get_app_or_404(app_id, write=True)
|
||||
if err:
|
||||
return err
|
||||
preview = ApplicationPreview.query.filter_by(
|
||||
@@ -95,10 +106,10 @@ def redeploy_preview(app_id, preview_id):
|
||||
|
||||
|
||||
@previews_bp.route('/<int:app_id>/previews/<int:preview_id>', methods=['DELETE'])
|
||||
@developer_required
|
||||
@jwt_required()
|
||||
def destroy_preview(app_id, preview_id):
|
||||
"""Tear down and mark a preview destroyed."""
|
||||
app, err = _get_app_or_404(app_id)
|
||||
app, err = _get_app_or_404(app_id, write=True)
|
||||
if err:
|
||||
return err
|
||||
preview = ApplicationPreview.query.filter_by(
|
||||
|
||||
@@ -183,6 +183,11 @@ def health_check():
|
||||
canonical_domain = SettingsService.get('canonical_domain', '') or ''
|
||||
https_enabled = bool(SettingsService.get('canonical_https_enabled', False))
|
||||
|
||||
# Staging instances (plan 37 testbed) run with SERVERKIT_STAGING set. Echo
|
||||
# it here so the verify step can prove WHICH instance answered and the UI
|
||||
# can show its STAGING banner.
|
||||
staging = os.environ.get('SERVERKIT_STAGING', '').strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'service': 'serverkit-api',
|
||||
@@ -190,6 +195,7 @@ def health_check():
|
||||
'canonical_domain': canonical_domain,
|
||||
'canonical_https_enabled': https_enabled,
|
||||
'canonical_origin': canonical_origin(canonical_domain, https_enabled) if canonical_domain else None,
|
||||
'staging': staging,
|
||||
}), 200
|
||||
|
||||
|
||||
|
||||
+11
-3
@@ -1,14 +1,17 @@
|
||||
# Bucket: PER-APP (plan 29 #9). Reads are scoped to callers who can access the
|
||||
# app (can_access_app); mutations + install stay admin-only.
|
||||
"""Per-application WAF (ModSecurity v3 + OWASP CRS) API.
|
||||
|
||||
Routes are mounted at ``/api/v1/waf``. Reads require a valid JWT; mutations and
|
||||
install require an admin user (mirrors ``app/api/dns_zones.py``). Service
|
||||
``ValueError``s map to HTTP 400.
|
||||
Routes are mounted at ``/api/v1/waf``. Reads require access to the target app;
|
||||
mutations and install require an admin user (mirrors ``app/api/dns_zones.py``).
|
||||
Service ``ValueError``s map to HTTP 400.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from app.models.application import Application
|
||||
from app.services.waf_service import WafService
|
||||
from app.services.resource_grant_service import ResourceGrantService
|
||||
|
||||
waf_bp = Blueprint('waf', __name__)
|
||||
|
||||
@@ -28,9 +31,14 @@ def _require_admin():
|
||||
|
||||
|
||||
def _get_application_or_404(app_id):
|
||||
"""Resolve the app and enforce read access (plan 29 #9): owner / admin / grant /
|
||||
workspace member. A caller who can't access it gets the same 404 as a missing
|
||||
app (sealed-from-open, no existence leak)."""
|
||||
application = Application.query.get(app_id)
|
||||
if not application:
|
||||
return None, (jsonify({'error': 'Application not found'}), 404)
|
||||
if not ResourceGrantService.can_access_app(get_current_user(), application):
|
||||
return None, (jsonify({'error': 'Application not found'}), 404)
|
||||
return application, None
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"permissions": ["agent.command:gui:screenshot", "agent.command:gui:capabilities"],
|
||||
"min_panel_version": "1.7.0",
|
||||
"max_panel_version": null,
|
||||
"sdk_version": "^1.0.0",
|
||||
"source": "https://github.com/jhd3197/serverkit-gui",
|
||||
"sha256": null,
|
||||
"homepage": "https://github.com/jhd3197/serverkit-gui",
|
||||
|
||||
@@ -60,15 +60,21 @@ from app.models.cloudflare_worker import CloudflareWorker
|
||||
from app.models.cloudflare_tunnel import CloudflareTunnel
|
||||
from app.models.backup_policy import BackupPolicy
|
||||
from app.models.backup_run import BackupRun
|
||||
from app.models.restore_drill import RestoreDrill
|
||||
from app.queue_bus.models import QueueGroup, Queue, QueueMessage
|
||||
from app.notifications.models import Notification, NotificationDelivery
|
||||
from app.models.email_provider import EmailProviderConnection
|
||||
from app.models.chat_webhook import ChatWebhookConnection
|
||||
from app.models.system_event import SystemEvent
|
||||
from app.models.domain_registration import DomainRegistration
|
||||
from app.models.login_link import LoginLink
|
||||
from app.models.managed_database_user import ManagedDatabaseUser
|
||||
from app.models.site_import import SiteImport
|
||||
from app.models.site_bandwidth import SiteBandwidthDaily
|
||||
from app.models.cron_run import CronRun
|
||||
from app.models.fleet_doctor_result import FleetDoctorResult
|
||||
from app.models.server_survey import ServerSurvey
|
||||
from app.models.dns_cutover_snapshot import DnsCutoverSnapshot
|
||||
|
||||
__all__ = [
|
||||
'User', 'Application', 'Domain', 'EnvironmentVariable', 'EnvironmentVariableHistory',
|
||||
@@ -98,10 +104,11 @@ __all__ = [
|
||||
'WebhookEndpoint', 'WebhookDelivery',
|
||||
'WafPolicy',
|
||||
'CloudflareWorker', 'CloudflareTunnel',
|
||||
'BackupPolicy', 'BackupRun',
|
||||
'BackupPolicy', 'BackupRun', 'RestoreDrill',
|
||||
'QueueGroup', 'Queue', 'QueueMessage',
|
||||
'Notification', 'NotificationDelivery',
|
||||
'EmailProviderConnection',
|
||||
'ChatWebhookConnection',
|
||||
'SystemEvent',
|
||||
'DomainRegistration',
|
||||
'Project', 'Environment',
|
||||
@@ -112,5 +119,9 @@ __all__ = [
|
||||
'LoginLink',
|
||||
'ManagedDatabaseUser',
|
||||
'SiteImport',
|
||||
'CronRun',
|
||||
'SiteBandwidthDaily',
|
||||
'FleetDoctorResult',
|
||||
'ServerSurvey',
|
||||
'DnsCutoverSnapshot',
|
||||
]
|
||||
@@ -206,3 +206,17 @@ class Application(db.Model):
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Application {self.name}>'
|
||||
|
||||
|
||||
@db.event.listens_for(Application, 'before_delete')
|
||||
def _clear_cron_association(mapper, connection, target):
|
||||
"""Cron jobs are never silently deleted with an app — they fall back to the
|
||||
System bucket. Clearing the association here (one place) covers every delete
|
||||
path (apps/docker/python/git/workflow), since cron metadata lives in a JSON
|
||||
store outside the DB and can't ride a FK cascade. Best-effort: a failure here
|
||||
must never block deleting the app."""
|
||||
try:
|
||||
from app.services.cron_service import CronService
|
||||
CronService.clear_application(target.id)
|
||||
except Exception: # noqa: BLE001 - cleanup must not block app deletion
|
||||
pass
|
||||
@@ -45,6 +45,10 @@ class User(db.Model):
|
||||
# Sidebar preferences: { preset: 'full'|'web'|'email'|'devops'|'minimal'|'custom', hiddenItems: [...] }
|
||||
sidebar_config = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Per-user Setup Health item snoozes: JSON { item_key: iso_expiry } (plan 22
|
||||
# Phase 6). Panel-item snoozes live in a SettingsService map instead.
|
||||
setup_snoozes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
applications = db.relationship('Application', backref='owner', lazy='dynamic')
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""ServerKit Email extension backend package."""
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "serverkit-email",
|
||||
"display_name": "Email Server",
|
||||
"version": "1.0.0",
|
||||
"description": "Full mail-server stack — Postfix/Dovecot, domains, mailboxes, aliases, DKIM/SPF/DMARC, SpamAssassin filtering, and Roundcube webmail. Most panels never run a mail server, so it ships as an opt-in extension.",
|
||||
"author": "ServerKit",
|
||||
"license": "MIT",
|
||||
"category": "integration",
|
||||
"homepage": "https://github.com/jhd3197/ServerKit",
|
||||
"permissions": [
|
||||
"shell",
|
||||
"filesystem",
|
||||
"network"
|
||||
],
|
||||
"min_panel_version": "1.7.0",
|
||||
"entry_point": "email:email_bp",
|
||||
"url_prefix": "/api/v1/email",
|
||||
"icon": "<path d=\"M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z\"/><polyline points=\"22,6 12,13 2,6\"/>",
|
||||
"contributions": {
|
||||
"nav": [
|
||||
{
|
||||
"id": "email",
|
||||
"label": "Email Server",
|
||||
"route": "/email",
|
||||
"category": "operations",
|
||||
"icon": "<path d=\"M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z\"/><polyline points=\"22,6 12,13 2,6\"/>"
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"path": "email",
|
||||
"component": "EmailPage"
|
||||
},
|
||||
{
|
||||
"path": "email/:tab",
|
||||
"component": "EmailPage"
|
||||
}
|
||||
],
|
||||
"page_titles": {
|
||||
"/email": "Email Server"
|
||||
},
|
||||
"command_palette": [
|
||||
{
|
||||
"label": "Email",
|
||||
"path": "/email",
|
||||
"category": "Pages",
|
||||
"keywords": "smtp postfix dovecot mail imap dkim webmail"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Roundcube webmail management service (Docker-based)."""
|
||||
import subprocess
|
||||
from typing import Dict
|
||||
|
||||
from app.utils.system import run_privileged, is_command_available
|
||||
|
||||
|
||||
class RoundcubeService:
|
||||
"""Service for managing Roundcube webmail via Docker."""
|
||||
|
||||
CONTAINER_NAME = 'serverkit-roundcube'
|
||||
VOLUME_NAME = 'roundcube_data'
|
||||
IMAGE = 'roundcube/roundcubemail:latest'
|
||||
HOST_PORT = 9000
|
||||
|
||||
@classmethod
|
||||
def get_status(cls) -> Dict:
|
||||
"""Get Roundcube container status."""
|
||||
if not is_command_available('docker'):
|
||||
return {'installed': False, 'running': False, 'error': 'Docker not available'}
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['docker', 'inspect', '--format', '{{.State.Status}}', cls.CONTAINER_NAME],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return {'installed': False, 'running': False}
|
||||
|
||||
status = result.stdout.strip()
|
||||
return {
|
||||
'installed': True,
|
||||
'running': status == 'running',
|
||||
'status': status,
|
||||
'port': cls.HOST_PORT,
|
||||
}
|
||||
except Exception as e:
|
||||
return {'installed': False, 'running': False, 'error': str(e)}
|
||||
|
||||
@classmethod
|
||||
def install(cls, imap_host: str = 'host.docker.internal',
|
||||
smtp_host: str = 'host.docker.internal',
|
||||
domain: str = None) -> Dict:
|
||||
"""Install Roundcube via Docker container."""
|
||||
if not is_command_available('docker'):
|
||||
return {'success': False, 'error': 'Docker is not installed'}
|
||||
|
||||
try:
|
||||
# Remove existing container if any
|
||||
subprocess.run(
|
||||
['docker', 'rm', '-f', cls.CONTAINER_NAME],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
# Create volume
|
||||
subprocess.run(
|
||||
['docker', 'volume', 'create', cls.VOLUME_NAME],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
# Run container
|
||||
result = subprocess.run([
|
||||
'docker', 'run', '-d',
|
||||
'--name', cls.CONTAINER_NAME,
|
||||
'--restart', 'unless-stopped',
|
||||
'--add-host', 'host.docker.internal:host-gateway',
|
||||
'-p', f'{cls.HOST_PORT}:80',
|
||||
'-e', f'ROUNDCUBEMAIL_DEFAULT_HOST=ssl://{imap_host}',
|
||||
'-e', f'ROUNDCUBEMAIL_SMTP_SERVER=tls://{smtp_host}',
|
||||
'-e', 'ROUNDCUBEMAIL_DEFAULT_PORT=993',
|
||||
'-e', 'ROUNDCUBEMAIL_SMTP_PORT=587',
|
||||
'-e', 'ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE=25M',
|
||||
'-e', 'ROUNDCUBEMAIL_SKIN=elastic',
|
||||
'-v', f'{cls.VOLUME_NAME}:/var/roundcube/db',
|
||||
cls.IMAGE,
|
||||
], capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
return {'success': False, 'error': result.stderr or 'Failed to start container'}
|
||||
|
||||
# Build a public URL: prefer a supplied domain, then auto-generate
|
||||
# webmail.<panel_domain>, and only fall back to localhost:port.
|
||||
from app.services.site_domain_service import SiteDomainService
|
||||
public_url = None
|
||||
warning = None
|
||||
if domain:
|
||||
public_url = f'http://{domain}'
|
||||
proxy_res = cls.configure_nginx_proxy(domain)
|
||||
if not proxy_res.get('success'):
|
||||
warning = proxy_res.get('error')
|
||||
else:
|
||||
panel_origin = SiteDomainService.panel_origin()
|
||||
panel_host = None
|
||||
if panel_origin:
|
||||
from urllib.parse import urlparse
|
||||
panel_host = urlparse(panel_origin).hostname
|
||||
if panel_host:
|
||||
webmail_domain = f'webmail.{panel_host}'
|
||||
public_url = f'http://{webmail_domain}'
|
||||
proxy_res = cls.configure_nginx_proxy(webmail_domain)
|
||||
if not proxy_res.get('success'):
|
||||
warning = proxy_res.get('error')
|
||||
|
||||
if not public_url:
|
||||
public_url = f'http://localhost:{cls.HOST_PORT}'
|
||||
|
||||
result = {
|
||||
'success': True,
|
||||
'message': 'Roundcube installed successfully',
|
||||
'port': cls.HOST_PORT,
|
||||
'url': public_url,
|
||||
}
|
||||
if warning:
|
||||
result['warning'] = warning
|
||||
return result
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls) -> Dict:
|
||||
"""Stop and remove Roundcube container."""
|
||||
try:
|
||||
subprocess.run(['docker', 'rm', '-f', cls.CONTAINER_NAME], capture_output=True, text=True)
|
||||
return {'success': True, 'message': 'Roundcube uninstalled'}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
@classmethod
|
||||
def start(cls) -> Dict:
|
||||
"""Start Roundcube container."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['docker', 'start', cls.CONTAINER_NAME],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return {'success': True, 'message': 'Roundcube started'}
|
||||
return {'success': False, 'error': result.stderr or 'Start failed'}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
@classmethod
|
||||
def stop(cls) -> Dict:
|
||||
"""Stop Roundcube container."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['docker', 'stop', cls.CONTAINER_NAME],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return {'success': True, 'message': 'Roundcube stopped'}
|
||||
return {'success': False, 'error': result.stderr or 'Stop failed'}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
@classmethod
|
||||
def restart(cls) -> Dict:
|
||||
"""Restart Roundcube container."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['docker', 'restart', cls.CONTAINER_NAME],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return {'success': True, 'message': 'Roundcube restarted'}
|
||||
return {'success': False, 'error': result.stderr or 'Restart failed'}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
@classmethod
|
||||
def configure_nginx_proxy(cls, domain: str) -> Dict:
|
||||
"""Create Nginx reverse proxy config for Roundcube."""
|
||||
try:
|
||||
from app.services.nginx_service import NginxService
|
||||
|
||||
config = f"""# Roundcube Webmail - Managed by ServerKit
|
||||
server {{
|
||||
listen 80;
|
||||
server_name {domain};
|
||||
|
||||
location / {{
|
||||
proxy_pass http://127.0.0.1:{cls.HOST_PORT};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 25m;
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
site_name = f'roundcube-{domain.replace(".", "-")}'
|
||||
config_path = f'/etc/nginx/sites-available/{site_name}'
|
||||
enabled_path = f'/etc/nginx/sites-enabled/{site_name}'
|
||||
|
||||
run_privileged(['tee', config_path], input=config)
|
||||
run_privileged(['ln', '-sf', config_path, enabled_path])
|
||||
|
||||
# Test and reload
|
||||
test = run_privileged(['nginx', '-t'])
|
||||
if test.returncode != 0:
|
||||
# Rollback
|
||||
run_privileged(['rm', '-f', enabled_path])
|
||||
return {'success': False, 'error': f'Nginx config test failed: {test.stderr}'}
|
||||
|
||||
run_privileged(['systemctl', 'reload', 'nginx'])
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'Nginx proxy configured for {domain}',
|
||||
'url': f'http://{domain}',
|
||||
}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
@@ -40,7 +40,7 @@ class ConnectionRegistry:
|
||||
@classmethod
|
||||
def list_all(cls, user_id=None):
|
||||
out = []
|
||||
for fn in (cls._source, cls._dns, cls._infra, cls._registrar, cls._storage, cls._email, cls._registries):
|
||||
for fn in (cls._source, cls._dns, cls._infra, cls._registrar, cls._storage, cls._email, cls._chat, cls._registries):
|
||||
try:
|
||||
out += fn(user_id) if fn is cls._source else fn()
|
||||
except Exception as e: # one failing store never breaks the whole list
|
||||
@@ -103,6 +103,18 @@ class ConnectionRegistry:
|
||||
encrypted=True, created_at=c.created_at))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _chat():
|
||||
from app.models.chat_webhook import ChatWebhookConnection
|
||||
out = []
|
||||
for c in ChatWebhookConnection.query.all():
|
||||
scope = ', '.join(c.categories()) if c.categories() else 'All categories'
|
||||
if c.is_default:
|
||||
scope += ' · default'
|
||||
out.append(_entry('chat', c.kind, c.name, id=c.id, scope=scope,
|
||||
encrypted=True, created_at=c.created_at))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _registries():
|
||||
from app.models.container_registry import ContainerRegistry
|
||||
|
||||
@@ -154,6 +154,40 @@ def _collect_job_failures():
|
||||
return {'recent_failures': failures}
|
||||
|
||||
|
||||
def _collect_restore_confidence():
|
||||
"""Per-policy restore-proof summary (plan 23): last run + verify level, last
|
||||
drill age/status, cadence, offsite evidence level. Counts and states only —
|
||||
no paths, no secrets, matching the bundle's scrub discipline."""
|
||||
from datetime import datetime
|
||||
from app.models.backup_policy import BackupPolicy
|
||||
from app.models.backup_run import BackupRun
|
||||
|
||||
policies = []
|
||||
for policy in BackupPolicy.query.order_by(BackupPolicy.id.asc()).all():
|
||||
latest = (BackupRun.query
|
||||
.filter_by(policy_id=policy.id, status='success')
|
||||
.order_by(BackupRun.started_at.desc()).first())
|
||||
offsite = (latest.get_metadata() or {}).get('offsite') if latest else None
|
||||
last_drill_age_days = None
|
||||
if policy.last_drill_at:
|
||||
last_drill_age_days = (datetime.utcnow() - policy.last_drill_at).days
|
||||
policies.append({
|
||||
'target_type': policy.target_type,
|
||||
'target_id': policy.target_id,
|
||||
'enabled': policy.enabled,
|
||||
'remote_copy': policy.remote_copy,
|
||||
'drill_cadence': policy.drill_cadence or 'off',
|
||||
'last_run_status': policy.last_status,
|
||||
'latest_verify_level': latest.effective_verify_level() if latest else 'none',
|
||||
'latest_verify_error': bool(latest and latest.verify_error),
|
||||
'last_drill_status': policy.last_drill_status,
|
||||
'last_drill_age_days': last_drill_age_days,
|
||||
'offsite_evidence': (offsite or {}).get('evidence') if offsite else None,
|
||||
'offsite_deep_evidence': (offsite or {}).get('deep_evidence') if offsite else None,
|
||||
})
|
||||
return {'policies': policies, 'total': len(policies)}
|
||||
|
||||
|
||||
def _collect_doctor():
|
||||
"""Last doctor report, if the doctor service (built separately) exposes one."""
|
||||
try:
|
||||
@@ -206,6 +240,7 @@ def build(out_path=None, passphrase=None):
|
||||
'settings_shapes.json': _safe('settings_shapes', _collect_settings_shapes),
|
||||
'jobs.json': _safe('jobs', _collect_job_failures),
|
||||
'doctor.json': _safe('doctor', _collect_doctor),
|
||||
'restore_confidence.json': _safe('restore_confidence', _collect_restore_confidence),
|
||||
}
|
||||
log_tail = _safe('log_tail', _collect_log_tail, default='# log collection failed\n')
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Frontend SDK compatibility contract (backend mirror).
|
||||
|
||||
The canonical value lives in ``frontend/src/plugins/sdk/index.js`` as the exported
|
||||
``SDK_VERSION`` constant. The backend can't import JS at runtime, so this module
|
||||
mirrors it — the two are kept in lock-step and asserted equal by
|
||||
``backend/tests/test_sdk_contract.py``.
|
||||
|
||||
Purpose:
|
||||
- ``GET /api/v1/plugins/contributions`` reports ``sdk_version`` so the panel UI
|
||||
and tooling know what SDK surface the running panel offers.
|
||||
- An extension's ``plugin.json`` may declare ``sdk_version`` — a semver RANGE the
|
||||
extension is built against. It's checked at install (manifest lint) and at load
|
||||
(the runtime loader refuses an incompatible bundle and explains why).
|
||||
|
||||
The range grammar accepted by ``sdk_version_satisfies`` is intentionally small
|
||||
(the subset extension authors actually write):
|
||||
|
||||
- empty / ``*`` / ``x`` → matches anything
|
||||
- ``1.2.3`` → exact
|
||||
- ``^1.2.3`` → >=1.2.3 and <2.0.0 (caret; 0.x pins the minor)
|
||||
- ``~1.2.3`` → >=1.2.3 and <1.3.0 (tilde)
|
||||
- ``>=1.2.0``, ``>1.0``, ``<=2``, ``<2.0.0``, ``=1.2.3``
|
||||
- a comma- or space-separated AND of the comparators above
|
||||
"""
|
||||
import re
|
||||
|
||||
from app.utils.version import compare_versions
|
||||
|
||||
# Keep in lock-step with frontend/src/plugins/sdk/index.js `SDK_VERSION`.
|
||||
SDK_VERSION = '1.0.0'
|
||||
|
||||
|
||||
def _version_tuple(v):
|
||||
parts = []
|
||||
for chunk in str(v).split('-')[0].split('+')[0].split('.'):
|
||||
num = ''.join(ch for ch in chunk if ch.isdigit())
|
||||
parts.append(int(num) if num else 0)
|
||||
while len(parts) < 3:
|
||||
parts.append(0)
|
||||
return tuple(parts[:3])
|
||||
|
||||
|
||||
def _satisfies_one(current, comparator):
|
||||
"""Evaluate ``current`` against a single comparator token (already trimmed)."""
|
||||
comparator = comparator.strip()
|
||||
if not comparator or comparator in ('*', 'x', 'X'):
|
||||
return True
|
||||
|
||||
m = re.match(r'^(\^|~|>=|<=|>|<|=)?\s*v?(.+)$', comparator)
|
||||
if not m:
|
||||
return True # fail open on anything we don't understand
|
||||
op, ver = m.group(1) or '=', m.group(2).strip()
|
||||
|
||||
if op == '^':
|
||||
lo = _version_tuple(ver)
|
||||
if lo[0] > 0:
|
||||
hi = (lo[0] + 1, 0, 0)
|
||||
elif lo[1] > 0:
|
||||
hi = (0, lo[1] + 1, 0)
|
||||
else:
|
||||
hi = (0, 0, lo[2] + 1)
|
||||
cur = _version_tuple(current)
|
||||
return lo <= cur < hi
|
||||
if op == '~':
|
||||
lo = _version_tuple(ver)
|
||||
hi = (lo[0], lo[1] + 1, 0)
|
||||
cur = _version_tuple(current)
|
||||
return lo <= cur < hi
|
||||
|
||||
cmp = compare_versions(current, ver)
|
||||
if op == '>=':
|
||||
return cmp >= 0
|
||||
if op == '<=':
|
||||
return cmp <= 0
|
||||
if op == '>':
|
||||
return cmp > 0
|
||||
if op == '<':
|
||||
return cmp < 0
|
||||
return cmp == 0 # '=' or bare
|
||||
|
||||
|
||||
def sdk_version_satisfies(range_str, current=None):
|
||||
"""True iff ``current`` (defaults to this panel's SDK_VERSION) satisfies the
|
||||
semver ``range_str``. Empty/None range matches anything (an extension that
|
||||
doesn't pin a range installs everywhere). Malformed comparators fail open —
|
||||
a bad range must never hard-block for the wrong reason."""
|
||||
if current is None:
|
||||
current = SDK_VERSION
|
||||
if not range_str or not str(range_str).strip():
|
||||
return True
|
||||
# AND of comma- or whitespace-separated comparators.
|
||||
tokens = [t for t in re.split(r'[\s,]+', str(range_str).strip()) if t]
|
||||
return all(_satisfies_one(current, t) for t in tokens)
|
||||
@@ -0,0 +1,17 @@
|
||||
# runtime-extension fixture
|
||||
|
||||
A tiny **runtime-loadable** ServerKit extension (plan 25 #12). It ships a
|
||||
prebuilt, externalized `frontend/dist/index.mjs` and no source/build tooling, so
|
||||
it doubles as:
|
||||
|
||||
- a **backend test** fixture (`test_runtime_frontend.py`) — install it and assert
|
||||
the dist bundle survives a local install, its sha256 is recorded, and it shows
|
||||
up in the `/plugins/contributions` `frontends` map;
|
||||
- a **render smoke test** target for the screenshots skill (a real extension page
|
||||
that loads through the runtime path).
|
||||
|
||||
The bundle imports its shared libraries (`react/jsx-runtime`, `serverkit-sdk`) as
|
||||
**bare specifiers** — exactly what a `vite build` with those libs externalized
|
||||
emits — so the panel resolves them to its own singletons via the import map. It
|
||||
bundles no React of its own. Hand-checked-in on purpose: no `npm install`/build
|
||||
is needed to run the tests.
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "serverkit-runtime-demo",
|
||||
"display_name": "Runtime Demo Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "A tiny runtime-loadable ESM extension fixture (plan 25 #12). Ships a prebuilt, externalized frontend/dist/index.mjs used by backend tests and the screenshots render smoke test. Hand-checked-in — no build step needed.",
|
||||
"author": "ServerKit",
|
||||
"license": "MIT",
|
||||
"category": "utility",
|
||||
"sdk_version": "^1.0.0",
|
||||
"frontend_entry": "dist/index.mjs",
|
||||
"contributions": {
|
||||
"nav": [
|
||||
{
|
||||
"id": "serverkit-runtime-demo",
|
||||
"label": "Runtime Demo",
|
||||
"route": "/serverkit-runtime-demo",
|
||||
"category": "system",
|
||||
"icon": "<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 8v8M8 12h8\"/>"
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"path": "serverkit-runtime-demo",
|
||||
"component": "RuntimeDemoPage"
|
||||
}
|
||||
],
|
||||
"page_titles": {
|
||||
"/serverkit-runtime-demo": "Runtime Demo"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Prove the /api/v1/system/health `staging` flag (plan 37).
|
||||
|
||||
The staging testbed runs the panel with SERVERKIT_STAGING set so the verify
|
||||
step (and the UI banner) can tell a staging instance from the live panel.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app = create_app('testing')
|
||||
with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
def test_health_staging_false_by_default(client, monkeypatch):
|
||||
monkeypatch.delenv('SERVERKIT_STAGING', raising=False)
|
||||
resp = client.get('/api/v1/system/health')
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json().get('staging') is False
|
||||
|
||||
|
||||
def test_health_staging_true_when_env_set(client, monkeypatch):
|
||||
monkeypatch.setenv('SERVERKIT_STAGING', '1')
|
||||
resp = client.get('/api/v1/system/health')
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json().get('staging') is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize('val,expected', [
|
||||
('1', True), ('true', True), ('YES', True), ('on', True),
|
||||
('0', False), ('', False), ('no', False), ('off', False),
|
||||
])
|
||||
def test_health_staging_truthiness(client, monkeypatch, val, expected):
|
||||
monkeypatch.setenv('SERVERKIT_STAGING', val)
|
||||
resp = client.get('/api/v1/system/health')
|
||||
assert resp.get_json().get('staging') is expected
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Regression gate for the "unguarded background writer" bug class.
|
||||
|
||||
create_app() must not spawn background threads under the testing config.
|
||||
The app fixture is function-scoped, so any thread started per create_app()
|
||||
call piles up one immortal copy per test — and if that thread writes to the
|
||||
DB (analytics flush, metrics collection), the copies race the running test's
|
||||
own transactions on the shared SQLite file and the suite fails with
|
||||
intermittent "database is locked" errors in unrelated tests.
|
||||
|
||||
Background workers must either be gated on ``app.config['TESTING']`` (like
|
||||
the queue-bus consumers) or started only by an explicit runtime trigger
|
||||
(socket connect, API call), never as an unconditional create_app side effect.
|
||||
"""
|
||||
import threading
|
||||
|
||||
|
||||
def _thread_ids():
|
||||
return {t.ident for t in threading.enumerate()}
|
||||
|
||||
|
||||
def _is_allowed(thread):
|
||||
"""Library-internal housekeeping we can't gate: flask-limiter's in-memory
|
||||
storage arms a threading.Timer to expire rate-limit events. Memory-only."""
|
||||
fn = getattr(thread, 'function', None) # threading.Timer callback
|
||||
return getattr(fn, '__module__', '').startswith('limits.')
|
||||
|
||||
|
||||
def test_create_app_testing_spawns_no_background_threads():
|
||||
from app import create_app, db
|
||||
|
||||
before = _thread_ids()
|
||||
app = create_app('testing')
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
try:
|
||||
# Exercise the request hooks too — a per-request spawn (analytics
|
||||
# buffers, audit trails) must also stay inert under testing.
|
||||
client = app.test_client()
|
||||
client.get('/api/v1/system/health')
|
||||
|
||||
leaked = [t.name for t in threading.enumerate()
|
||||
if t.ident not in before and not _is_allowed(t)]
|
||||
assert leaked == [], (
|
||||
f'create_app(testing) spawned background thread(s): {leaked}. '
|
||||
'Gate the spawn on app.config["TESTING"] (see the queue-bus '
|
||||
'consumers) so per-test app instances do not leak writers '
|
||||
'that race the suite\'s shared database.')
|
||||
finally:
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
@@ -0,0 +1,56 @@
|
||||
# ServerKit staging target profile — TEMPLATE (safe to commit).
|
||||
#
|
||||
# Copy this to deploy/targets/<name>.env and fill it in for a real box:
|
||||
#
|
||||
# cp deploy/targets/example.env deploy/targets/testbox.env
|
||||
# $EDITOR deploy/targets/testbox.env
|
||||
# bash scripts/stage.sh push testbox && \
|
||||
# bash scripts/stage.sh deploy testbox && \
|
||||
# bash scripts/stage.sh verify testbox
|
||||
#
|
||||
# Real profiles are GIT-IGNORED (deploy/targets/*.env except this template), so
|
||||
# hosts, keys and paths never enter the repo or a chat transcript. There are NO
|
||||
# credentials here on purpose: STAGE_HOST is an alias from your ~/.ssh/config,
|
||||
# and key auth comes from there. The staging scripts NEVER prompt — a missing
|
||||
# value below is a hard error naming the key, not a question.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STAGE_HOST (required) — an alias defined in your ~/.ssh/config.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Define the box ONCE in ~/.ssh/config so no host/user/key ever lives here:
|
||||
#
|
||||
# Host testbox
|
||||
# HostName 203.0.113.10
|
||||
# User root
|
||||
# IdentityFile ~/.ssh/testbox_ed25519
|
||||
#
|
||||
# Then set the alias — `ssh testbox` must work non-interactively (key auth, host
|
||||
# key already trusted). An agent driving through an SSH channel uses the same
|
||||
# alias, so credentials stay out of the conversation.
|
||||
STAGE_HOST=testbox
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STAGE_DIR (required) — where the PARALLEL staging instance lives on the box.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Must NOT be the live install dir (/opt/serverkit) in the default parallel
|
||||
# mode — bootstrap hard-errors on that unless STAGE_MODE=replace. The uploaded
|
||||
# payload lands in "<STAGE_DIR>.src".
|
||||
STAGE_DIR=/opt/serverkit-staging
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STAGE_PORT (required) — the loopback port the staging backend binds.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Something other than the live panel's 5000. Staging binds 127.0.0.1 only
|
||||
# (verification runs on-box); nothing half-tested is exposed unless you pass
|
||||
# --expose to `push`.
|
||||
STAGE_PORT=5100
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STAGE_MODE (optional, default: parallel)
|
||||
# ---------------------------------------------------------------------------
|
||||
# parallel — own dir, own venv, own .env/keys/SQLite, own systemd unit
|
||||
# (serverkit-staging), loopback-bound. The live panel is never
|
||||
# touched. This is the default and the safe choice.
|
||||
# replace — DANGER: deploy directly over STAGE_DIR even if it is the live
|
||||
# install. Only for dedicated scratch boxes / throwaway VMs.
|
||||
STAGE_MODE=parallel
|
||||
@@ -0,0 +1,125 @@
|
||||
# Extension CI recipe (build → hash → publish)
|
||||
|
||||
For a **runtime-ESM** extension (one that ships a prebuilt `frontend/dist/index.mjs`
|
||||
and loads with no panel rebuild — see [EXTENSIONS.md](EXTENSIONS.md) → *Runtime
|
||||
frontend bundles*), automate the release so every published artifact is built,
|
||||
integrity-hashed, and linted the same way.
|
||||
|
||||
> Mirror this file into the `serverkit-extensions` registry repo's contributor
|
||||
> guide so publishers find it where they submit.
|
||||
|
||||
---
|
||||
|
||||
## The pipeline
|
||||
|
||||
1. **Build** the runtime bundle with the shared libs externalized
|
||||
(`react`, `react-dom`, `react/jsx-runtime`, `react-router-dom`, `serverkit-sdk`).
|
||||
The `--template frontend-esm` scaffold's `vite.config.mjs` already does this.
|
||||
2. **Hash** the built bundle (`dist/hashes.json` via `hash-bundle.mjs`) and the
|
||||
release **zip** (the `sha256` the registry entry pins).
|
||||
3. **Lint** the manifest + bundle (rejects embedded React / un-externalized libs).
|
||||
4. **Attach** the zip to a GitHub release.
|
||||
5. **Open the registry PR** bumping your `index.json` entry (`version`, `source`,
|
||||
`sha256`, `sdk_version`).
|
||||
|
||||
---
|
||||
|
||||
## GitHub Actions example
|
||||
|
||||
`.github/workflows/release.yml` in the extension repo:
|
||||
|
||||
```yaml
|
||||
name: release
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # create the release + upload the asset
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '20' }
|
||||
|
||||
# 1. Build the runtime bundle (externalized).
|
||||
- name: Build frontend bundle
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build # → frontend/dist/index.mjs (+ dist/hashes.json)
|
||||
|
||||
# 2. Lint manifest + bundle against the panel's own rules.
|
||||
# Vendored copy of scripts/new-extension.mjs, or fetch it from the panel
|
||||
# repo at a pinned tag.
|
||||
- name: Validate manifest + bundle
|
||||
run: node tools/new-extension.mjs --validate .
|
||||
|
||||
# 3. Package the extension (plugin.json + frontend/dist + backend/…).
|
||||
- name: Package zip
|
||||
id: pkg
|
||||
run: |
|
||||
SLUG=$(node -p "require('./plugin.json').name")
|
||||
VER=$(node -p "require('./plugin.json').version")
|
||||
ZIP="${SLUG}-${VER}.zip"
|
||||
zip -r "$ZIP" plugin.json frontend/dist backend 2>/dev/null || \
|
||||
zip -r "$ZIP" plugin.json frontend/dist
|
||||
echo "zip=$ZIP" >> "$GITHUB_OUTPUT"
|
||||
echo "sha256=$(sha256sum "$ZIP" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# 4. Attach to the release.
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ${{ steps.pkg.outputs.zip }}
|
||||
body: |
|
||||
sha256: `${{ steps.pkg.outputs.sha256 }}`
|
||||
|
||||
- name: Registry entry to paste
|
||||
run: |
|
||||
echo "Add/bump this in serverkit-extensions/index.json:"
|
||||
echo ' "version": "'"$(node -p "require('./plugin.json').version")"'",'
|
||||
echo ' "source": "<release-zip-url>",'
|
||||
echo ' "sha256": "${{ steps.pkg.outputs.sha256 }}",'
|
||||
echo ' "sdk_version": "'"$(node -p "require('./plugin.json').sdk_version||''")"'"'
|
||||
```
|
||||
|
||||
The printed `sha256` is exactly what the registry `index.json` entry must pin — the
|
||||
panel re-verifies it before extraction, and the runtime loader re-verifies the
|
||||
bundle's own sha256 at every load.
|
||||
|
||||
## What the panel checks at load time
|
||||
|
||||
Two guarantees apply to your runtime bundle on every panel, and you don't have to
|
||||
do anything extra for them — but knowing them explains the failure cards users may
|
||||
see:
|
||||
|
||||
- **Integrity works on HTTP-only panels too.** ServerKit's SSL is optional, so
|
||||
some panels run over plain HTTP where the browser's `crypto.subtle` is
|
||||
unavailable. The loader falls back to a bundled pure-JS sha256 that computes the
|
||||
**same** digest — there is no unverified path. Practical note: JS hashing is
|
||||
slower than native, so keep the bundle lean (it's size-capped regardless).
|
||||
|
||||
- **Declare `sdk_version`.** Pin the SDK range your bundle was built against in
|
||||
`plugin.json` (e.g. `"sdk_version": "^1.0.0"`). The loader compares it against
|
||||
the panel's SDK **before** fetching your bundle and refuses an incompatible one
|
||||
with a clear "needs SDK x, panel has y" card instead of a cryptic import error.
|
||||
Omitting the range gets a one-release grace (warn-and-load), but declare it —
|
||||
the Marketplace also uses it to flag incompatible installs/updates up front.
|
||||
|
||||
---
|
||||
|
||||
## Local one-shot (no CI)
|
||||
|
||||
```bash
|
||||
cd frontend && npm ci && npm run build && cd ..
|
||||
node <serverkit>/scripts/new-extension.mjs --validate .
|
||||
zip -r my-ext-1.0.0.zip plugin.json frontend/dist backend
|
||||
sha256sum my-ext-1.0.0.zip # → the index.json `sha256`
|
||||
```
|
||||
|
||||
Then open the registry PR. See [EXTENSIONS_REGISTRY.md](EXTENSIONS_REGISTRY.md) for
|
||||
the index format and the maintainer review checklist.
|
||||
@@ -53,8 +53,10 @@ Relevant endpoints:
|
||||
"permissions": ["network"], // declared host permissions (honesty is reviewed)
|
||||
"min_panel_version": "1.7.0", // optional compat gate (inclusive)
|
||||
"max_panel_version": null, // optional
|
||||
"sdk_version": "^1.0.0", // optional (additive) — frontend SDK range the bundle targets
|
||||
"source": "https://github.com/owner/repo", // repo URL (latest release), release URL, or direct .zip
|
||||
"sha256": "…", // sha256 of the release zip — STRONGLY recommended
|
||||
"release_notes": "…", // optional (additive) — shown in the update-diff modal
|
||||
"homepage": "https://…",
|
||||
"icon": "<svg-inner-markup/>", // rendered on the detail view
|
||||
"screenshots": ["https://…/1.png"] // rendered on the detail view
|
||||
@@ -71,16 +73,22 @@ Notes:
|
||||
while prototyping.
|
||||
- `min_panel_version`/`max_panel_version` are compared against the panel's
|
||||
`VERSION`. An incompatible entry can be listed but not installed/updated.
|
||||
- `sdk_version` (additive) is the semver range of the frontend SDK a **runtime
|
||||
bundle** targets — see [EXTENSIONS.md](EXTENSIONS.md) → *The SDK contract*. Checked
|
||||
at install and at load. Older panels ignore it; older indexes omit it.
|
||||
- `release_notes` (additive) is free text shown in the Marketplace **update-diff**
|
||||
modal alongside the version + permissions delta. Old panels ignore it.
|
||||
|
||||
---
|
||||
|
||||
## Publishing an extension
|
||||
|
||||
1. **Structure the repo** per [`docs/EXTENSIONS.md`](EXTENSIONS.md): a `plugin.json`
|
||||
at the archive root, plus `backend/` and/or `frontend/`. Remember the production
|
||||
frontend constraint — a third-party extension that ships a frontend won't render
|
||||
on a prebuilt panel until the Phase 3 delivery mechanism lands, so ship
|
||||
backend-only (or a `bare`/custom-layout escape hatch) for now.
|
||||
at the archive root, plus `backend/` and/or `frontend/`. A third-party extension
|
||||
that ships a **prebuilt runtime bundle** (`frontend_entry: dist/index.mjs`,
|
||||
externalized shared libs) now renders on a prebuilt panel **with no rebuild** —
|
||||
see [`docs/EXTENSIONS_CI.md`](EXTENSIONS_CI.md) for the build→hash→publish recipe.
|
||||
(Backend-only extensions need no frontend at all.)
|
||||
|
||||
2. **Cut a release.** Tag a version and attach a plugin `.zip` as a release asset
|
||||
(the installer prefers a `.zip` asset over the source zipball). Record the
|
||||
@@ -102,6 +110,11 @@ Notes:
|
||||
- **Checksum** — `sha256` present and matches the release asset.
|
||||
- **License** — a real OSS license (`license` field + a LICENSE in the repo).
|
||||
- **Compat** — `min_panel_version` reflects the oldest panel actually tested.
|
||||
- **Frontend bundle** — if the extension ships UI, it's a **runtime ESM bundle**
|
||||
(`frontend_entry: dist/index.mjs`) that passes `--validate` (no embedded React,
|
||||
shared libs externalized). Raw `.jsx` frontends don't render on prebuilt panels.
|
||||
- **SDK range** — `sdk_version` present for any extension shipping a runtime bundle
|
||||
and reflects the SDK it was actually built + tested against.
|
||||
- **Brand-neutral** — no competitor names in the name/description (project policy).
|
||||
|
||||
Free/OSS project: there are **no paid extensions, quotas, or billing** — ever.
|
||||
@@ -0,0 +1,206 @@
|
||||
# Fleet Contract — "runs where?" answered once
|
||||
|
||||
> Status: LIVE (plan 26, Fleet Parity Sweep).
|
||||
> Audience: anyone adding a panel feature that could touch more than the
|
||||
> primary host. Cite this instead of re-deciding fleet-awareness ad hoc.
|
||||
|
||||
ServerKit manages a *fleet* of servers: the panel host plus any number of
|
||||
connected agents (the Go agent under `agent/`, reached over the Socket.IO
|
||||
`/agent` gateway or the HTTP long-poll fallback). Historically most panel
|
||||
features assumed a single host, and each new feature re-decided — usually
|
||||
implicitly — whether it was fleet-aware. This document is the written
|
||||
contract so that decision is made once, on purpose.
|
||||
|
||||
If you are onboarding a feature to the fleet, skip to
|
||||
[the checklist](#onboarding-a-feature-to-the-fleet).
|
||||
|
||||
---
|
||||
|
||||
## The six rules (locked)
|
||||
|
||||
These are the load-bearing decisions every fleet feature inherits.
|
||||
|
||||
1. **Address by `server_id`, always.** A job that targets a server sets
|
||||
`owner_type='server'`, `owner_id=<server_id>`, and repeats the id in
|
||||
`payload.server_id` (the manifest precedent). UI surfaces select a
|
||||
target with `TargetPicker`, which emits `{kind:'agent', server_id, …}`.
|
||||
The panel host itself is addressed as `server_id=None`/`'local'`.
|
||||
|
||||
2. **Capability-gate, never error.** Every fleet feature names a capability
|
||||
string (e.g. `docker`, `systemd.restart`, `doctor.probe`). An agent that
|
||||
doesn't advertise it is shown/recorded as **`unsupported on this agent`**
|
||||
(a skipped status) — never an error, never a crash. Older agents keep
|
||||
working forever. Check with `agent_registry.has_capability(server_id,
|
||||
feature)`; the `TargetPicker` filters non-capable agents out of the list.
|
||||
|
||||
3. **Per-server results are ROWS, not blobs.** New fleet result tables key
|
||||
by `(server_id, check_key)` (see `FleetDoctorResult`). The legacy
|
||||
panel-host blob (e.g. the `doctor_last_report` settings key) stays for
|
||||
compatibility; the API/UI merges the two. Never widen a per-host blob
|
||||
into an ad-hoc `{server_id: …}` map — add rows.
|
||||
|
||||
4. **Sweeps are jobs with a budget.** Fan-out over agents happens **inside
|
||||
job handlers only**, never on a request thread — the gateway registry is
|
||||
single-worker and in-memory (see `CLAUDE.md`, `docs/ARCHITECTURE.md`,
|
||||
`SECURITY.md`). Use the one primitive: `fleet_sweep()` in
|
||||
`app/services/fleet_sweep.py`. It gives you a bounded pool (≤4 concurrent
|
||||
agents by default), a per-agent timeout, and a hard wall-clock budget. A
|
||||
slow or hung agent yields a `timeout` result row, not a stalled panel.
|
||||
|
||||
5. **Compose from today's commands first.** Prefer composing a feature from
|
||||
the commands every already-deployed agent understands
|
||||
(`systemd:status`, `system:metrics`, registry/heartbeat data) so it works
|
||||
on the whole fleet on day one. Add a new batched agent command only as a
|
||||
capability-gated *optimisation* the panel negotiates to when present, with
|
||||
the composed path as the permanent fallback. The worked example is the
|
||||
doctor: v1 composes `systemd:status` + `system:metrics`; v2 prefers a single
|
||||
`doctor:probe` round trip when the agent advertises `doctor.probe`, and
|
||||
falls back to the composed calls if the probe errors — both paths emit
|
||||
identical rows. Full spec + negotiation code in
|
||||
[`docs/AGENT_DOCTOR_PROBE_SPEC.md`](AGENT_DOCTOR_PROBE_SPEC.md). The
|
||||
read-only Observe-mode survey (`survey:read`, capability `survey`) follows the
|
||||
same shape — a catalog of read-only primitives the agent enforces, gated by
|
||||
`survey:read` + the `survey` capability, degrading to "unsupported" on old
|
||||
agents. Full contract in
|
||||
[`docs/AGENT_SURVEY_SPEC.md`](AGENT_SURVEY_SPEC.md).
|
||||
|
||||
6. **Remote mutations are allowlisted + audited.** Anything that changes
|
||||
state on a remote box goes through an explicit allowlist that maps the
|
||||
operation → agent command + required permission scope + required
|
||||
capability. Anything not on the allowlist is refused server-side. Every
|
||||
remote mutation writes an `AuditService.log(...)` entry. The v1 repair
|
||||
allowlist is **service restart only** (`fleet.service` →
|
||||
`systemd:restart`).
|
||||
|
||||
---
|
||||
|
||||
## Capability classes × runs-where
|
||||
|
||||
Each panel capability falls into one of these classes. The class dictates
|
||||
where it runs and how results aggregate.
|
||||
|
||||
| Class | Runs where | Aggregation | Addressing |
|
||||
|---|---|---|---|
|
||||
| **Host probe** (read a fact about a box: is nginx up, disk headroom, agent version) | Per-agent when connected; panel-side for facts the panel already knows (heartbeat age, version-vs-latest, DNS resolution) | Rows keyed `(server_id, check_key)`; UI shows an "All servers" rollup + per-server drill-down | `server_id` |
|
||||
| **Config write** (write a managed file / restart a unit) | Per-agent, allowlisted + audited (rule 6) | Per-item repair result rows | `server_id` + permission scope + capability |
|
||||
| **Scheduled sweep** (run a probe across the fleet on a cadence) | Job handler only, via `fleet_sweep()` (rule 4) | Rows; the schedule piggybacks an existing cadence where possible | iterate connected/fleet servers |
|
||||
| **Metric collection** (CPU/mem/disk time-series) | Per-agent → `ServerMetrics` rows on every heartbeat | Existing rows; FleetMonitor reads them | `server_id` |
|
||||
| **UI surface** (a page/panel) | Panel; zero-agent installs see no fleet chrome at all; single-box view is unchanged | Consumes the rollup + rows | `TargetPicker` |
|
||||
|
||||
**Panel-scoped by design** (do *not* fan these out): anything that is a
|
||||
property of the control plane itself — the panel's own DB, the panel's
|
||||
canonical domain, org-wide setup items. Setup-health items carry a `scope`
|
||||
field (`panel` | `per-server`) precisely so this stays explicit (plan 22 /
|
||||
plan 26 #15).
|
||||
|
||||
---
|
||||
|
||||
## Addressing conventions
|
||||
|
||||
- **A connected agent:** `server_id` = the `Server.id` UUID. Enumerate live
|
||||
agents with `agent_registry.get_connected_servers()`, map back with
|
||||
`Server.query.get(server_id)`, read live capabilities with
|
||||
`agent_registry.get_capabilities(server_id)`.
|
||||
- **The panel host:** `server_id` is `None` or the literal `'local'`. Remote
|
||||
services (`remote_docker_service`, etc.) treat `not server_id or server_id
|
||||
== 'local'` as "run the local service".
|
||||
- **A server-targeted job:** `owner_type='server'`, `owner_id=server_id`,
|
||||
`payload={'server_id': server_id, …}`.
|
||||
- **A fleet-wide sweep job:** no single server; the handler enumerates and
|
||||
calls `fleet_sweep()`.
|
||||
|
||||
---
|
||||
|
||||
## The sweep primitive
|
||||
|
||||
`app/services/fleet_sweep.py`:
|
||||
|
||||
```python
|
||||
from app.services.fleet_sweep import fleet_sweep
|
||||
|
||||
def compose(server_id, per_agent_timeout):
|
||||
# runs on a worker thread, inside an app context; may send_command + query DB
|
||||
caps = agent_registry.get_capabilities(server_id) or {}
|
||||
if not caps.get('systemd'):
|
||||
return {'status': 'unsupported', 'checks': []}
|
||||
res = agent_registry.send_command(server_id, 'systemd:status',
|
||||
{'unit': 'nginx'}, timeout=per_agent_timeout)
|
||||
...
|
||||
return {'status': 'ok', 'checks': [...]}
|
||||
|
||||
results = fleet_sweep(compose, servers, pool=4, per_agent_timeout=15, budget=90)
|
||||
# results == {server_id: {'status': 'ok'|'failed'|'offline'|'unsupported'|'timeout', ...}}
|
||||
```
|
||||
|
||||
- Every row carries a `status` from `ok`/`failed`/`offline`/`unsupported`/`timeout`.
|
||||
- A composer exception → `failed`. A budget overrun → `timeout`. Neither
|
||||
raises out of the sweep.
|
||||
- The composer runs inside a Flask app context (the helper pushes one per
|
||||
worker) so it may query the DB and the registry freely.
|
||||
- **Never call `fleet_sweep()` from a request handler** — only from a job.
|
||||
|
||||
---
|
||||
|
||||
## Onboarding a feature to the fleet
|
||||
|
||||
A short checklist. If you can answer all of these, the feature is contract-compliant.
|
||||
|
||||
1. **Which capability class is it?** (host probe / config write / sweep /
|
||||
metric / UI). That fixes where it runs and how it aggregates.
|
||||
2. **What capability string does it require?** Gate on it with
|
||||
`agent_registry.has_capability`; agents without it record `unsupported`.
|
||||
3. **Does it read or write?** A write must be on an allowlist (rule 6): map
|
||||
operation → agent command + permission scope + capability, refuse
|
||||
anything else server-side, and audit-log every call.
|
||||
4. **Does it fan out?** Then it is a job (rule 4). Use `fleet_sweep()`, pick
|
||||
a pool/timeout/budget, and piggyback an existing schedule cadence if one
|
||||
fits.
|
||||
5. **How do results aggregate?** Rows keyed `(server_id, check_key)`, merged
|
||||
with any legacy panel blob (rule 3). The UI gets an "All servers" rollup +
|
||||
per-server drill-down; a zero-agent install must look exactly as it did
|
||||
before.
|
||||
6. **Can it be composed from today's commands?** Prefer that (rule 5). If a
|
||||
new agent command genuinely pays for itself, ship it capability-gated with
|
||||
the composed path as the permanent fallback.
|
||||
7. **Update the parity ledger below** with the feature's today-state + verdict.
|
||||
|
||||
---
|
||||
|
||||
## Parity ledger (Appendix A — living)
|
||||
|
||||
Where each capability stands. Update the verdict when you move the line.
|
||||
Snapshot baseline: 2026-07-05 (Cron/Doctor/Survey rows moved 2026-07-06,
|
||||
plan 28 — the agent v2 capability pack lit up the batched doctor probe,
|
||||
the read-only survey, and in-place remote cron edit on agent ≥1.2.0).
|
||||
|
||||
| Capability | Fleet story today | Verdict |
|
||||
|---|---|---|
|
||||
| Metrics/heartbeat | per-agent → `ServerMetrics` rows, FleetMonitor | fleet-aware |
|
||||
| Docker ops | `remote_docker_service`, full command set | fleet-aware |
|
||||
| Cron | `remote_cron_service` — status/list/add/update/remove/toggle; in-place edit via `cron:update` (capability `cron.update`, agent ≥1.2.0); no run-now op yet | fleet-aware (edit shipped; run-now open) |
|
||||
| Files | `remote_file_service` (allowed_paths-gated) | fleet-aware |
|
||||
| Systemd | read (list/status/logs); restart is capability-gated `systemd.restart` | read + gated restart |
|
||||
| Packages/runtimes | full remote support | fleet-aware |
|
||||
| Terminal | remote via gateway (WS only) | fleet-aware |
|
||||
| WireGuard/tunnels | pairing + publish rails | fleet-aware |
|
||||
| Agent upgrades | staged rollouts, offline queue | fleet-aware |
|
||||
| **Doctor** | **fleet sweep: services/disk/agent-freshness/site-DNS per server; DNS compares each box's IP. Per-server DNS is bounded to 10 domains/sweep but the checked window ROTATES per sweep (persistent counter) so all domains are eventually verified (plan 31 #13). Batched `doctor:probe` path lit up by agent ≥1.2.0 (capability `doctor.probe`), else composed v1 fallback** | **fleet-aware (v1 + gated v2 probe)** |
|
||||
| Survey / Observe | read-only `survey:read` executor shipped in agent ≥1.2.0 (capability `survey`); fixed primitive allowlist, catalog-as-untrusted-input; degrades to "unsupported" on older agents | fleet-aware (agent ≥1.2.0) |
|
||||
| Drift | panel-local file checks | deferred (templates — `ServerTemplates` owns config compliance) |
|
||||
| Backups | policies execute on panel host only | deferred (plan 23) |
|
||||
| Setup health | `scope` field: panel items panel-scoped, per-server items evaluated in the fleet sweep | fleet-aware (per-server hook) |
|
||||
| Proxy stacks | per-server via FleetProxy UI | partial by design |
|
||||
| Manifest apply | `server:` targeting resolves id; remote dispatch deferred | partial (plan 17) |
|
||||
| Mail live delivery | preflight shipped; runbook `docs/runbooks/mail-live-delivery.md` | see runbook |
|
||||
| Remote access multi-host | shipped; runbook `docs/runbooks/remote-access-two-host.md` | see runbook |
|
||||
|
||||
### Explicitly out of scope (marked so nobody re-opens them ad hoc)
|
||||
|
||||
- **Fleet drift / config compliance** — config-template territory;
|
||||
`ServerTemplates` owns it.
|
||||
- **Cross-server backups / DR restores** — deferred (plan 23).
|
||||
- **Workspace scoping of servers/fleet surfaces** — fleet stays admin (plan 19).
|
||||
- **Gateway horizontal scaling** — the single-worker constraint stands
|
||||
(a `HORIZONTAL_SCALING_SPEC` exists; explicitly not adopted).
|
||||
- **Streaming over the long-poll transport** — by design; sweeps are
|
||||
request/response only.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Runbook — Extension release cut + runtime-bundle tamper e2e
|
||||
|
||||
> Plan 32 #9 (carries plan 25 #13/#14). The no-rebuild runtime-frontend loader
|
||||
> ships with real sha256 integrity, a load-time SDK gate, an HTTP-only digest
|
||||
> fallback, and a Marketplace failure badge — all proven by unit/backend tests.
|
||||
> What is **not** yet proven on a live box is the full **release → registry
|
||||
> install → on-disk tamper → visible failure** loop, because it needs push/publish
|
||||
> authority and a real server. This runbook is that proof.
|
||||
>
|
||||
> **Status: NOT YET EXECUTED.** Requires: (a) permission to cut a GitHub release
|
||||
> and open a registry PR (publish authority), and (b) the DO test box (or any
|
||||
> Linux VPS running the panel). Execute the steps, paste the scrubbed transcript
|
||||
> into "Result", and the proving debt from plan 25 is closed.
|
||||
|
||||
## What this proves
|
||||
|
||||
1. A first-party extension can be shipped as a **released artifact** (zip +
|
||||
sha256) and installed **from the registry** onto a box — no repo checkout.
|
||||
2. Its prebuilt runtime frontend loads through the hardened loader:
|
||||
integrity-verified, SDK-gated, blob-imported — with **no panel rebuild**.
|
||||
3. If the on-disk bundle is **modified after install**, the next load **fails
|
||||
closed**: a failure card on the extension's routes and a `failed` runtime
|
||||
badge on the Marketplace installed row (plan 32 #5), never a silent execution.
|
||||
|
||||
`serverkit-gui` is the vehicle (it is already the first runtime-bundle builtin,
|
||||
CORE_SLIM Tier A). The same recipe applies to any runtime-ESM extension.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The **serverkit-gui** source (sibling repo) builds a runtime bundle:
|
||||
`frontend/dist/index.mjs` via the externalized Vite lib build
|
||||
(see [../EXTENSIONS_CI.md](../EXTENSIONS_CI.md)).
|
||||
- A Linux box running the panel over HTTPS **and**, for step 5, reachable over
|
||||
plain HTTP (to prove the pure-JS digest fallback also catches tampering).
|
||||
- Admin JWT (`$TOKEN`) and the panel base URL (`$PANEL`).
|
||||
- Registry write access (a PR against the public `serverkit-extensions` index).
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Cut the release artifact
|
||||
|
||||
In the `serverkit-gui` repo, on a clean tag:
|
||||
|
||||
```bash
|
||||
cd frontend && npm ci && npm run build && cd .. # → frontend/dist/index.mjs (+ dist/hashes.json)
|
||||
node <serverkit>/scripts/new-extension.mjs --validate . # manifest + bundle lint (rejects embedded React)
|
||||
zip -r serverkit-gui-<VERSION>.zip plugin.json frontend/dist backend
|
||||
sha256sum serverkit-gui-<VERSION>.zip # ← the registry `sha256`
|
||||
```
|
||||
|
||||
Attach the zip to a GitHub release for the tag. Record the release URL + sha256.
|
||||
|
||||
### 2. Swap the registry entry to the released artifact
|
||||
|
||||
Open a PR against the registry `index.json` changing the `serverkit-gui` entry
|
||||
`source` to the release zip URL and pinning the printed `sha256` + the manifest's
|
||||
`sdk_version`. Merge it. (The panel re-verifies the zip sha256 before extraction,
|
||||
independently of the loader's per-load bundle check.)
|
||||
|
||||
### 3. Install from the registry on the test box
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$PANEL/api/v1/plugins/registry/serverkit-gui/install" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
```
|
||||
|
||||
Confirm in the panel: the extension is **active**, its page renders, and the
|
||||
Marketplace installed row shows a **Runtime loaded** badge (plan 32 #5). Capture
|
||||
the recorded bundle hash:
|
||||
|
||||
```bash
|
||||
curl -s "$PANEL/api/v1/plugins/contributions" -H "Authorization: Bearer $TOKEN" \
|
||||
| jq '.frontends["serverkit-gui"]'
|
||||
# → { entry, sdk_version, hashes: { "dist/index.mjs": "<sha256>" } }
|
||||
```
|
||||
|
||||
### 4. Tamper with the on-disk bundle → verify fail-closed
|
||||
|
||||
Flip a single byte in the **installed** bundle on the box (not the source):
|
||||
|
||||
```bash
|
||||
# Locate the installed dist (frontend plugins dir), then mutate one byte:
|
||||
BUNDLE="$(find / -path '*serverkit-gui/dist/index.mjs' 2>/dev/null | head -1)"
|
||||
printf '\0' | dd of="$BUNDLE" bs=1 seek=0 count=1 conv=notrunc
|
||||
```
|
||||
|
||||
Reload the panel (hard refresh). Expected:
|
||||
|
||||
- The extension's routes render the **ExtensionFailureCard** — "integrity check
|
||||
failed … the on-disk bundle was modified" — not the real UI, and no white
|
||||
screen.
|
||||
- The Marketplace installed row shows a **Failed to load** runtime badge; its
|
||||
popover carries the integrity error string.
|
||||
- The rest of the panel is unaffected.
|
||||
|
||||
Restore the bundle (reinstall from the registry) and confirm the card clears.
|
||||
|
||||
### 5. Repeat over plain HTTP (digest fallback)
|
||||
|
||||
Point a browser at the panel over **HTTP** (insecure context, no `crypto.subtle`)
|
||||
and repeat step 4. The tamper must still be caught — the loader's bundled pure-JS
|
||||
sha256 computes the same digest (plan 32 #3). This closes the optional-SSL gap:
|
||||
integrity holds with or without TLS.
|
||||
|
||||
### 6. (Optional) SDK-gate refusal on a live box
|
||||
|
||||
Temporarily pin the registry entry's `sdk_version` to a range the panel can't
|
||||
satisfy (e.g. `^99.0.0`) and reinstall. Expected: the loader **refuses before
|
||||
fetching**, the row shows an **SDK incompatible** badge, and the failure card
|
||||
reads "needs SDK … panel has …". Revert the pin afterward.
|
||||
|
||||
## Result
|
||||
|
||||
> _NOT YET EXECUTED._ Paste the scrubbed transcript (install response, the
|
||||
> `frontends` hash, the failure-card + badge screenshots for both HTTPS and HTTP,
|
||||
> and the restore) here once run on a real box, and mark this runbook EXECUTED
|
||||
> with the date.
|
||||
@@ -0,0 +1,127 @@
|
||||
# Runbook — Mail live delivery (closing the serverkit-mail proving debt)
|
||||
|
||||
> Plan 26 Phase 6 #16. The serverkit-mail extension shipped its blocking
|
||||
> preflight (PTR / port-25 egress / RBL) and all the DNS/DKIM/cert/jail rails,
|
||||
> but **live outbound delivery has never been proven on a real box** — dev is
|
||||
> Windows + WSL with port 25 blocked. This runbook is the verification: run it
|
||||
> on a real internet-facing Linux server, paste the scrubbed transcript into the
|
||||
> "Result" section, and the debt is closed.
|
||||
>
|
||||
> **Status: NOT YET EXECUTED.** Requires a real box with a clean IP, a real
|
||||
> domain, and unblocked outbound port 25 — none of which exist in the dev
|
||||
> environment. Execute on the DO test box (or any VPS whose provider permits
|
||||
> SMTP) and record the result below.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- A Linux server reachable on a public IP, running the panel with the
|
||||
**serverkit-mail** extension installed and its Docker engine up.
|
||||
- A domain you control (call it `example.com`) with the ability to add DNS
|
||||
records (ideally a connected DNS provider so the panel can deploy them).
|
||||
- The VPS provider must **not** block outbound TCP 25. Many providers block it
|
||||
by default and require a support ticket to open — do that first.
|
||||
- A reverse-DNS (PTR) record for the server IP pointing at the mail hostname
|
||||
(e.g. `mail.example.com`). PTR is set at the **provider/IP-owner** level, not
|
||||
in your zone.
|
||||
- An external mailbox you can read (Gmail/Fastmail/etc.) as the delivery target.
|
||||
|
||||
## Steps
|
||||
|
||||
All API calls are admin-authenticated; `$PANEL` is the panel base URL and
|
||||
`$TOKEN` an admin JWT.
|
||||
|
||||
### 1. Run and pass preflight
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$PANEL/api/v1/mail/preflight" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"hostname":"mail.example.com"}' | jq .
|
||||
```
|
||||
|
||||
Expected: `passed: true` with `ptr.ok`, `port25.ok`, `rbl.ok` all true. If any
|
||||
critical check fails, fix it before continuing (PTR at the provider; port 25 via
|
||||
a provider ticket; RBL delisting if the IP is on a blocklist). **Do not** use
|
||||
`force=true` for this runbook — the point is a clean, un-forced send.
|
||||
|
||||
### 2. Add the domain, generate DKIM, deploy DNS
|
||||
|
||||
```bash
|
||||
# Add the mail domain
|
||||
curl -s -X POST "$PANEL/api/v1/mail/domains" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"domain":"example.com"}' | jq . # note the returned domain id -> $DID
|
||||
|
||||
# Generate the DKIM keypair
|
||||
curl -s -X POST "$PANEL/api/v1/mail/domains/$DID/dkim" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
|
||||
# Fetch the DNS records to publish (MX, SPF, DKIM, DMARC)
|
||||
curl -s "$PANEL/api/v1/mail/domains/$DID/dns" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
|
||||
# Deploy them through the connected provider (or add them by hand)
|
||||
curl -s -X POST "$PANEL/api/v1/mail/domains/$DID/dns/deploy" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
```
|
||||
|
||||
Wait for propagation, then confirm:
|
||||
|
||||
```bash
|
||||
dig +short MX example.com
|
||||
dig +short TXT example.com # SPF
|
||||
dig +short TXT default._domainkey.example.com # DKIM
|
||||
dig +short TXT _dmarc.example.com # DMARC
|
||||
```
|
||||
|
||||
### 3. Activate the domain (the preflight gate)
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH "$PANEL/api/v1/mail/domains/$DID" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"is_active":true}' | jq .
|
||||
```
|
||||
|
||||
Expected: `200` with the domain now active. If preflight had NOT passed this
|
||||
returns **409** ("blocked by preflight; re-run … or activate with force") —
|
||||
that gate is the whole point of the preflight; it working is itself a check.
|
||||
|
||||
### 4. Create a mailbox and send a real message
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$PANEL/api/v1/mail/domains/$DID/mailboxes" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"local_part":"postmaster","password":"<strong-password>"}' | jq .
|
||||
```
|
||||
|
||||
Send an authenticated message from that mailbox to your external address using
|
||||
`swaks` (submission on 587 with STARTTLS):
|
||||
|
||||
```bash
|
||||
swaks --server mail.example.com:587 --tls \
|
||||
--auth LOGIN --auth-user postmaster@example.com --auth-password '<strong-password>' \
|
||||
--from postmaster@example.com --to you@gmail.com \
|
||||
--h-Subject 'ServerKit live-delivery proof' \
|
||||
--body 'If you can read this with a passing DKIM/SPF/DMARC, the debt is closed.'
|
||||
```
|
||||
|
||||
### 5. Verify the received message
|
||||
|
||||
In the target mailbox, open the message and inspect the **raw headers**. Confirm:
|
||||
|
||||
- `Authentication-Results:` shows `spf=pass`, `dkim=pass`, `dmarc=pass`.
|
||||
- `Received:` chain shows the message came from `mail.example.com` at your IP.
|
||||
- The message landed in the **inbox**, not spam.
|
||||
|
||||
Optionally cross-check with a scoring service (e.g. mail-tester) for a 10/10.
|
||||
|
||||
## Result (paste scrubbed transcript here)
|
||||
|
||||
> Fill this in after executing. Scrub the real domain, IP, and any passwords.
|
||||
> Include: the preflight JSON (`passed: true`), the `swaks` send transcript
|
||||
> (250 accepted), and the received message's `Authentication-Results` header
|
||||
> line. Once this section is filled with a real pass, mark plan 26 #16 ✅ and
|
||||
> update the [Fleet Contract](../FLEET_CONTRACT.md) mail row.
|
||||
|
||||
```
|
||||
(pending real-box execution)
|
||||
```
|
||||
@@ -0,0 +1,123 @@
|
||||
# Runbook — Remote access, two hosts (closing the remote-access proving debt)
|
||||
|
||||
> Plan 26 Phase 6 #17. The remote-access feature (WireGuard tunnels via agent
|
||||
> pairing — `/remote-access`, `TunnelBrokerService`, `TunnelPublishService`)
|
||||
> shipped and is unit + panel↔agent e2e tested, but **the full two-host flow
|
||||
> has never been proven end-to-end on real infrastructure**: pair an edge box to
|
||||
> a NAT'd home box and publish a home-only service to the public internet
|
||||
> through the tunnel. This runbook is that verification.
|
||||
>
|
||||
> **Status: NOT YET EXECUTED.** Requires two real machines — a public-IP edge
|
||||
> VPS and a NAT'd home host running a service (e.g. Jellyfin) — with agents
|
||||
> paired to the same panel. Dev is a single Windows box; execute on real infra
|
||||
> and paste the scrubbed transcript into "Result".
|
||||
|
||||
## Topology
|
||||
|
||||
```
|
||||
internet ──▶ edge VPS (public IP, agent "edge")
|
||||
│ WireGuard tunnel
|
||||
▼
|
||||
home host (behind NAT, agent "home")
|
||||
│
|
||||
▼
|
||||
Jellyfin :8096 (home-only service)
|
||||
```
|
||||
|
||||
The edge box terminates public traffic and forwards it over the WireGuard
|
||||
tunnel to the home box's service. Keys stay on the hosts; the panel is
|
||||
authoritative for config only (see `docs/REMOTE_ACCESS_ROADMAP.md`).
|
||||
|
||||
## Preconditions
|
||||
|
||||
- **Edge**: a VPS with a public IP, the agent installed and paired (server id
|
||||
`$EDGE`), capable of running WireGuard (the agent embeds wireguard-go) and
|
||||
opening its listen port in the firewall.
|
||||
- **Home**: a machine behind NAT running Jellyfin (or any HTTP service) on
|
||||
`:8096`, agent installed and paired (server id `$HOME`).
|
||||
- Both agents **online** in the panel (Servers page shows both green).
|
||||
- A domain/subdomain for the public URL (e.g. `media.example.com`) — ideally via
|
||||
a connected DNS provider so the panel can create the record and cert.
|
||||
- `$PANEL` = panel base URL, `$TOKEN` = an admin/developer JWT.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Confirm both agents are connected
|
||||
|
||||
```bash
|
||||
curl -s "$PANEL/api/v1/servers/available" -H "Authorization: Bearer $TOKEN" \
|
||||
| jq '.servers[] | {id, name, status}'
|
||||
```
|
||||
|
||||
Both the edge and home servers must be `online`. Note their ids as `$EDGE`
|
||||
(public) and `$HOME` (NAT'd).
|
||||
|
||||
### 2. Pair the two boxes into a WireGuard tunnel
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$PANEL/api/v1/tunnels/" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d "{\"edge_server_id\":\"$EDGE\",\"private_server_id\":\"$HOME\",\"name\":\"home-media\"}" \
|
||||
| jq .
|
||||
```
|
||||
|
||||
Expected: `201` with a tunnel object (note its `id` → `$TID`) and a `firewall`
|
||||
hint. If `firewall.auto_open` is present it shows whether the agent opened the
|
||||
edge listen port automatically; otherwise open it by hand per the hint.
|
||||
|
||||
### 3. Confirm the tunnel is up (handshake on both peers)
|
||||
|
||||
```bash
|
||||
curl -s "$PANEL/api/v1/tunnels/$TID" -H "Authorization: Bearer $TOKEN" | jq .
|
||||
```
|
||||
|
||||
Expected: live status shows both peers with a recent WireGuard **handshake** and
|
||||
a non-zero transfer once traffic flows. A missing handshake means the edge
|
||||
listen port isn't reachable (firewall) or a key/config mismatch — fix before
|
||||
publishing.
|
||||
|
||||
### 4. Publish the home service through the tunnel
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$PANEL/api/v1/tunnels/$TID/services" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"jellyfin","upstream_host":"127.0.0.1","upstream_port":8096,"public_hostname":"media.example.com"}' \
|
||||
| jq .
|
||||
```
|
||||
|
||||
This wires the edge nginx to reverse-proxy `media.example.com` over the tunnel to
|
||||
the home box's `127.0.0.1:8096`, and (with a DNS provider) creates the A record +
|
||||
best-effort cert. Confirm the DNS record and, if issued, HTTPS.
|
||||
|
||||
### 5. Verify end-to-end from the public internet
|
||||
|
||||
From a machine **outside** both networks (e.g. a phone on cellular):
|
||||
|
||||
```bash
|
||||
curl -sI https://media.example.com/System/Info/Public
|
||||
# or open https://media.example.com in a browser and confirm Jellyfin loads
|
||||
```
|
||||
|
||||
Expected: the home-only Jellyfin responds through the edge over the tunnel. To
|
||||
prove the traffic really traverses the tunnel and not some other path, stop the
|
||||
tunnel (`DELETE /api/v1/tunnels/$TID`) and confirm the public URL goes dead, then
|
||||
re-create it and confirm it recovers.
|
||||
|
||||
### 6. Tear down (leave infra clean)
|
||||
|
||||
```bash
|
||||
curl -s -X DELETE "$PANEL/api/v1/tunnels/$TID" -H "Authorization: Bearer $TOKEN" | jq .
|
||||
```
|
||||
|
||||
## Result (paste scrubbed transcript here)
|
||||
|
||||
> Fill this in after executing. Scrub real hostnames/IPs/keys. Include: both
|
||||
> agents online, the `201` tunnel create response, the live status showing a
|
||||
> WireGuard handshake on both peers, the publish response, and a public `curl`
|
||||
> to `media.example.com` returning the home service (plus the stop→dead,
|
||||
> restart→recovers proof). Once filled with a real pass, mark plan 26 #17 ✅ and
|
||||
> update the [Fleet Contract](../FLEET_CONTRACT.md) remote-access row.
|
||||
|
||||
```
|
||||
(pending two-host execution)
|
||||
```
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 388 KiB After Width: | Height: | Size: 408 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
// Runtime-extension shim for `react-dom/client` — re-exports the HOST instance.
|
||||
const m = (globalThis.__SK_VENDOR__ || {})['react-dom/client'];
|
||||
if (!m) {
|
||||
throw new Error('[serverkit] host react-dom/client unavailable — vendorShare did not run');
|
||||
}
|
||||
export default m.default ?? m;
|
||||
export const createRoot = m.createRoot;
|
||||
export const hydrateRoot = m.hydrateRoot;
|
||||
@@ -0,0 +1,13 @@
|
||||
// Runtime-extension shim for `react-dom` — re-exports the HOST instance.
|
||||
const m = (globalThis.__SK_VENDOR__ || {})['react-dom'];
|
||||
if (!m) {
|
||||
throw new Error('[serverkit] host react-dom unavailable — vendorShare did not run');
|
||||
}
|
||||
export default m.default ?? m;
|
||||
export const createPortal = m.createPortal;
|
||||
export const flushSync = m.flushSync;
|
||||
export const findDOMNode = m.findDOMNode;
|
||||
export const render = m.render;
|
||||
export const hydrate = m.hydrate;
|
||||
export const unmountComponentAtNode = m.unmountComponentAtNode;
|
||||
export const version = m.version;
|
||||
@@ -0,0 +1,11 @@
|
||||
// Runtime-extension shim for `react/jsx-runtime` — re-exports the HOST instance.
|
||||
// Every JSX-automatic-runtime extension bundle imports this; the shim keeps it
|
||||
// on the panel's single React copy.
|
||||
const m = (globalThis.__SK_VENDOR__ || {})['react/jsx-runtime'];
|
||||
if (!m) {
|
||||
throw new Error('[serverkit] host react/jsx-runtime unavailable — vendorShare did not run');
|
||||
}
|
||||
export const Fragment = m.Fragment;
|
||||
export const jsx = m.jsx;
|
||||
export const jsxs = m.jsxs;
|
||||
export const jsxDEV = m.jsxDEV;
|
||||
@@ -0,0 +1,40 @@
|
||||
// Runtime-extension shim for `serverkit-sdk` — re-exports the HOST's SDK
|
||||
// instance (captured in globalThis.__SK_VENDOR__ by vendorShare.js). This IS
|
||||
// the sanctioned SDK surface; keep it in lock-step with
|
||||
// src/plugins/sdk/index.js (asserted by the frontend build + backend
|
||||
// test_sdk_contract). A runtime extension imports everything it needs from
|
||||
// here, so a host restructure behind the SDK never breaks it.
|
||||
const m = (globalThis.__SK_VENDOR__ || {})['serverkit-sdk'];
|
||||
if (!m) {
|
||||
throw new Error('[serverkit] host serverkit-sdk unavailable — vendorShare did not run');
|
||||
}
|
||||
export const SDK_VERSION = m.SDK_VERSION;
|
||||
// api
|
||||
export const api = m.api;
|
||||
export const defaultApi = m.defaultApi;
|
||||
// design-system primitives
|
||||
export const KpiBand = m.KpiBand;
|
||||
export const MetricCard = m.MetricCard;
|
||||
export const Gauge = m.Gauge;
|
||||
export const ScoreGauge = m.ScoreGauge;
|
||||
export const Sparkline = m.Sparkline;
|
||||
export const Pill = m.Pill;
|
||||
export const PageTopbar = m.PageTopbar;
|
||||
export const DataTable = m.DataTable;
|
||||
export const ResourceCard = m.ResourceCard;
|
||||
export const ResourceList = m.ResourceList;
|
||||
export const Drawer = m.Drawer;
|
||||
export const SchedulePicker = m.SchedulePicker;
|
||||
// contexts / hooks
|
||||
export const useToast = m.useToast;
|
||||
export const useAuth = m.useAuth;
|
||||
export const useTheme = m.useTheme;
|
||||
export const useServerkitAI = m.useServerkitAI;
|
||||
export const AIContext = m.AIContext;
|
||||
// routing helpers
|
||||
export const Link = m.Link;
|
||||
export const NavLink = m.NavLink;
|
||||
export const useNavigate = m.useNavigate;
|
||||
export const useLocation = m.useLocation;
|
||||
export const useParams = m.useParams;
|
||||
export const Outlet = m.Outlet;
|
||||
@@ -27,6 +27,7 @@ import Security from './pages/Security';
|
||||
import Services from './pages/Services';
|
||||
import NewService from './pages/NewService';
|
||||
import ImportWizard from './pages/ImportWizard';
|
||||
import Coexistence from './pages/Coexistence';
|
||||
import ServiceDetail from './pages/ServiceDetail';
|
||||
import Templates from './pages/Templates';
|
||||
import Servers from './pages/Servers';
|
||||
@@ -81,6 +82,7 @@ const PAGE_TITLES = {
|
||||
'/services': 'Services',
|
||||
'/services/new': 'New Service',
|
||||
'/imports': 'Import a Site',
|
||||
'/imports/coexistence': 'Running Alongside Another Panel',
|
||||
'/projects': 'Projects',
|
||||
'/shared-variables': 'Shared Variables',
|
||||
'/fleet-proxy': 'Fleet Proxy',
|
||||
@@ -312,6 +314,7 @@ function AppRoutes() {
|
||||
so the Settings left-nav is shareable and survives a refresh. */}
|
||||
<Route path="services/:id/:tab/:section" element={<ServiceDetail />} />
|
||||
<Route path="imports" element={<ImportWizard />} />
|
||||
<Route path="imports/coexistence" element={<Coexistence />} />
|
||||
<Route path="apps" element={<Navigate to="/services" replace />} />
|
||||
<Route path="apps/:id" element={<LegacyAppRedirect />} />
|
||||
<Route path="apps/:id/:tab" element={<LegacyAppRedirect />} />
|
||||
|
||||
@@ -33,6 +33,7 @@ const STATIC_PAGES = [
|
||||
// contributed by the serverkit-status / serverkit-cloud-provision /
|
||||
// serverkit-remote-access extensions.
|
||||
{ label: 'Marketplace', path: '/marketplace', category: 'Pages', keywords: 'extensions plugins' },
|
||||
{ label: 'Import a Site', path: '/imports', category: 'Pages', keywords: 'import migrate cpanel directadmin hestia backup adoption move existing' },
|
||||
{ label: 'Downloads', path: '/downloads', category: 'Pages', keywords: 'agent installer' },
|
||||
{ label: 'Projects', path: '/projects', category: 'Pages', keywords: 'organization group' },
|
||||
{ label: 'Shared Variables', path: '/shared-variables', category: 'Pages', keywords: 'env secrets shared' },
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Clock, CheckCircle2, AlertCircle, History } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { Pill, Drawer } from '@/components/ds';
|
||||
|
||||
// Read-only summary of an app's scheduled (cron) jobs. Rendered on the service
|
||||
// detail and WordPress detail Overview tabs. The backend endpoint is
|
||||
// workspace-scoped, so on any error (including 403 for non-members) we render
|
||||
// nothing to keep the page clean.
|
||||
const ScheduledTasksCard = ({ appId }) => {
|
||||
const [jobs, setJobs] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
// Run-history drawer (#2): member-visible, read-only subset for one job.
|
||||
const [runsJob, setRunsJob] = useState(null);
|
||||
const [runsData, setRunsData] = useState(null);
|
||||
const [runsLoading, setRunsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appId) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
api.getCronJobsForApp(appId)
|
||||
.then((data) => {
|
||||
if (!cancelled) setJobs(data?.jobs || []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [appId]);
|
||||
|
||||
const openRuns = (job) => {
|
||||
setRunsJob(job);
|
||||
setRunsData(null);
|
||||
setRunsLoading(true);
|
||||
api.getCronJobRunsForApp(appId, job.id)
|
||||
.then((data) => setRunsData({ runs: data?.runs || [], stats: data?.stats || {} }))
|
||||
.catch(() => setRunsData({ runs: [], stats: {} }))
|
||||
.finally(() => setRunsLoading(false));
|
||||
};
|
||||
|
||||
// Render nothing on error / no access so non-members' pages stay clean.
|
||||
if (error) return null;
|
||||
|
||||
return (
|
||||
<div className="scheduled-tasks-card">
|
||||
<div className="scheduled-tasks-card__header">
|
||||
<Clock size={14} />
|
||||
<span>Scheduled tasks</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="scheduled-tasks-card__loading">Loading…</div>
|
||||
) : !jobs || jobs.length === 0 ? (
|
||||
<div className="scheduled-tasks-card__empty">No scheduled tasks for this app.</div>
|
||||
) : (
|
||||
<ul className="scheduled-tasks-card__list">
|
||||
{jobs.map((job) => (
|
||||
<li key={job.id} className="scheduled-tasks-card__row">
|
||||
<div className="scheduled-tasks-card__main">
|
||||
<span className="scheduled-tasks-card__name">{job.name || 'Unnamed job'}</span>
|
||||
<span className="scheduled-tasks-card__sched">{job.schedule_human || job.schedule}</span>
|
||||
{job.schedule_human && job.schedule && (
|
||||
<span className="scheduled-tasks-card__cron">{job.schedule}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="scheduled-tasks-card__meta">
|
||||
<span className="scheduled-tasks-card__next">{formatWhen(job.next_run)}</span>
|
||||
<StatusHint job={job} />
|
||||
<button
|
||||
type="button"
|
||||
className="scheduled-tasks-card__runs-btn"
|
||||
onClick={() => openRuns(job)}
|
||||
title="View recent runs"
|
||||
>
|
||||
<History size={13} />
|
||||
</button>
|
||||
<Pill kind={job.enabled ? 'green' : 'gray'}>
|
||||
{job.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Pill>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Drawer
|
||||
open={!!runsJob}
|
||||
onOpenChange={(open) => { if (!open) setRunsJob(null); }}
|
||||
title={runsJob?.name || 'Scheduled task'}
|
||||
subtitle="recent runs"
|
||||
icon={<History size={16} />}
|
||||
width={520}
|
||||
>
|
||||
<div className="scheduled-tasks-runs">
|
||||
{runsLoading ? (
|
||||
<div className="scheduled-tasks-runs__empty">Loading…</div>
|
||||
) : !runsData || runsData.runs.length === 0 ? (
|
||||
<div className="scheduled-tasks-runs__empty">
|
||||
{runsJob && !runsJob.tracked
|
||||
? 'Run tracking is off for this task — no history to show.'
|
||||
: 'No runs recorded yet.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{runsData.stats?.success_rate != null && (
|
||||
<div className="scheduled-tasks-runs__rate">
|
||||
{Math.round(runsData.stats.success_rate * 100)}% success
|
||||
<span className="scheduled-tasks-runs__count"> · last {runsData.stats.total} runs</span>
|
||||
</div>
|
||||
)}
|
||||
<ul className="scheduled-tasks-runs__list">
|
||||
{runsData.runs.map((r) => (
|
||||
<li key={r.id} className="scheduled-tasks-runs__item">
|
||||
<Pill kind={r.status === 'success' ? 'green' : 'red'}>{r.status}</Pill>
|
||||
<span className="scheduled-tasks-runs__when">{formatWhen(r.finished_at)}</span>
|
||||
{r.duration_seconds != null && (
|
||||
<span className="scheduled-tasks-runs__dur">{Math.round(r.duration_seconds)}s</span>
|
||||
)}
|
||||
{r.exit_code != null && (
|
||||
<span className="scheduled-tasks-runs__exit">exit {r.exit_code}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// The status cell: a real icon when a run has been recorded, otherwise an honest
|
||||
// hint so the field is never a silent blank (the dead-field the review flagged).
|
||||
function StatusHint({ job }) {
|
||||
if (job.last_status) {
|
||||
return job.last_status === 'success' ? (
|
||||
<CheckCircle2 size={14} className="scheduled-tasks-card__status scheduled-tasks-card__status--ok" />
|
||||
) : (
|
||||
<AlertCircle size={14} className="scheduled-tasks-card__status scheduled-tasks-card__status--bad" />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="scheduled-tasks-card__status-hint">
|
||||
{job.tracked ? 'no runs yet' : 'tracking off'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Format an ISO timestamp; guard invalid / missing → em dash.
|
||||
function formatWhen(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
return d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export default ScheduledTasksCard;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
|
||||
/**
|
||||
* STAGING banner — a full-width amber strip shown only on the plan-37 staging
|
||||
* testbed. Driven by the public /system/health endpoint echoing `staging:true`
|
||||
* (set via the SERVERKIT_STAGING env var on the staging instance). Renders
|
||||
* nothing on a normal instance and never throws on fetch errors.
|
||||
*/
|
||||
const StagingBanner = () => {
|
||||
const [isStaging, setIsStaging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api.healthCheck()
|
||||
.then((health) => {
|
||||
if (active && health && health.staging === true) {
|
||||
setIsStaging(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Health probe failed — treat as a normal (non-staging) instance.
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isStaging) return null;
|
||||
|
||||
return (
|
||||
<div className="staging-banner" role="status">
|
||||
<span className="staging-banner__label">Staging</span>
|
||||
<span className="staging-banner__text">
|
||||
Staging instance — not the live panel
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StagingBanner;
|
||||
@@ -22,7 +22,18 @@ const SettingsTab = ({ app, onUpdate }) => {
|
||||
};
|
||||
|
||||
async function handleDelete() {
|
||||
const firstConfirm = await confirmAppSettings({ title: 'Delete Application', message: `Delete ${app.name}? This action cannot be undone.` });
|
||||
// Surface the schedules this delete will strand (kept but detached) so the
|
||||
// operator decides with eyes open — the honest-delete half of plan 34 #3.
|
||||
let cronNote = '';
|
||||
try {
|
||||
const jobs = (await api.getCronJobsForApp(app.id))?.jobs || [];
|
||||
if (jobs.length) {
|
||||
const names = jobs.map((j) => j.name || 'Unnamed').join(', ');
|
||||
cronNote = ` ${jobs.length} scheduled task${jobs.length > 1 ? 's' : ''} will be kept but detached from this app: ${names}.`;
|
||||
}
|
||||
} catch { /* cron visibility is best-effort — never block the delete */ }
|
||||
|
||||
const firstConfirm = await confirmAppSettings({ title: 'Delete Application', message: `Delete ${app.name}? This action cannot be undone.${cronNote}` });
|
||||
if (!firstConfirm) return;
|
||||
const secondConfirm = await confirmAppSettings({ title: 'Confirm Deletion', message: 'Are you sure? This will permanently delete the application and all its data.' });
|
||||
if (!secondConfirm) return;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ShieldAlert, X } from 'lucide-react';
|
||||
|
||||
// Per-user "secure your account" nudge (plan 22 #12). Shows when the requesting
|
||||
// user has no second factor, deep-linking to Settings → Security where the full
|
||||
// guided enrolment lives (passkey-first, then TOTP with its QR). Dismissible;
|
||||
// the dismissal is persisted to the account's `setup_snoozes` (plan 29 #8) so it
|
||||
// follows the user across browsers, with localStorage kept only as a fast cache
|
||||
// so the nudge doesn't flash before the account fetch resolves.
|
||||
|
||||
const DISMISS_KEY = 'account_security_nudge_dismissed';
|
||||
const ITEM_KEY = 'setup.account_security';
|
||||
const SNOOZE_DAYS = 30;
|
||||
|
||||
const AccountSecurityNudge = () => {
|
||||
const navigate = useNavigate();
|
||||
const [item, setItem] = useState(null);
|
||||
const [dismissed, setDismissed] = useState(() => {
|
||||
try { return localStorage.getItem(DISMISS_KEY) === '1'; } catch { return false; }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (dismissed) return;
|
||||
let cancelled = false;
|
||||
api.getAccountSecurity()
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
const acct = (res.items || []).find((c) => c.key === ITEM_KEY);
|
||||
// The account row is the source of truth: a server-side snooze
|
||||
// (set from any browser) means "dismissed" here too.
|
||||
if (acct && acct.snoozed) {
|
||||
try { localStorage.setItem(DISMISS_KEY, '1'); } catch { /* ignore */ }
|
||||
setDismissed(true);
|
||||
return;
|
||||
}
|
||||
if (acct && acct.status !== 'ok') setItem(acct);
|
||||
})
|
||||
.catch(() => { /* stay quiet */ });
|
||||
return () => { cancelled = true; };
|
||||
}, [dismissed]);
|
||||
|
||||
if (dismissed || !item) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
// Cache locally for an instant hide, then persist to the account so the
|
||||
// dismissal survives across browsers/devices. Best-effort: a failed
|
||||
// persist still hides it for this session.
|
||||
try { localStorage.setItem(DISMISS_KEY, '1'); } catch { /* ignore */ }
|
||||
setDismissed(true);
|
||||
api.snoozeSetupItem(ITEM_KEY, SNOOZE_DAYS).catch(() => { /* keep the local hide */ });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="account-security-nudge" role="status">
|
||||
<ShieldAlert size={18} className="account-security-nudge__icon" />
|
||||
<div className="account-security-nudge__body">
|
||||
<span className="account-security-nudge__title">Secure your account</span>
|
||||
<span className="account-security-nudge__detail">{item.detail}</span>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => navigate(item.fix?.to || '/settings/security')}>
|
||||
Add a passkey or 2FA
|
||||
</Button>
|
||||
<button type="button" className="account-security-nudge__close" onClick={dismiss} aria-label="Dismiss">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountSecurityNudge;
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
import { Pill } from '@/components/ds';
|
||||
import { ShieldCheck, AlertTriangle, Info, ChevronRight } from 'lucide-react';
|
||||
|
||||
// Compact "how set-up is this panel" card for the dashboard (admin-only).
|
||||
// Reads GET /setup-health and shows the score + the top open items, each
|
||||
// deep-linking to its fix. Collapses to a slim "all set" line when clean —
|
||||
// no dead card.
|
||||
|
||||
const TOP_N = 3;
|
||||
const STATUS_TONE = { fail: 'red', warn: 'amber', ok: 'green' };
|
||||
|
||||
// Critical (fail) first, then recommended (warn); ok items never show.
|
||||
function openItems(items) {
|
||||
const rank = { fail: 0, warn: 1 };
|
||||
return items
|
||||
.filter((i) => i.status !== 'ok')
|
||||
.sort((a, b) => (rank[a.status] ?? 9) - (rank[b.status] ?? 9));
|
||||
}
|
||||
|
||||
const SetupHealthWidget = () => {
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.getSetupHealth()
|
||||
.then((res) => { if (!cancelled) setData(res); })
|
||||
.catch(() => { /* non-admin or error — widget stays quiet */ })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (loading || !data || !data.summary) {
|
||||
return (
|
||||
<div className="setup-health-widget setup-health-widget--loading">
|
||||
<div className="setup-health-widget__head">
|
||||
<ShieldCheck size={16} />
|
||||
<span>Setup Health</span>
|
||||
</div>
|
||||
<p className="setup-health-widget__muted">
|
||||
{loading ? 'Checking…' : 'Unavailable'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary } = data;
|
||||
const open = openItems(data.items || []);
|
||||
|
||||
// Clean → slim "all set" line, not a big empty card.
|
||||
if (open.length === 0) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="setup-health-widget setup-health-widget--clean"
|
||||
onClick={() => navigate('/monitoring/doctor')}
|
||||
>
|
||||
<ShieldCheck size={16} />
|
||||
<span className="setup-health-widget__cleanlabel">
|
||||
All set — {summary.score}% setup health
|
||||
</span>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="setup-health-widget">
|
||||
<div className="setup-health-widget__head">
|
||||
<span className="setup-health-widget__title">
|
||||
<ShieldCheck size={16} />
|
||||
Setup Health
|
||||
</span>
|
||||
<span className="setup-health-widget__score">{summary.score}%</span>
|
||||
</div>
|
||||
|
||||
<div className="setup-health-widget__summary">
|
||||
{summary.critical_open > 0 && (
|
||||
<Pill kind="red">
|
||||
<AlertTriangle size={12} /> {summary.critical_open} critical
|
||||
</Pill>
|
||||
)}
|
||||
{summary.recommended_open > 0 && (
|
||||
<Pill kind="amber">
|
||||
<Info size={12} /> {summary.recommended_open} recommended
|
||||
</Pill>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="setup-health-widget__list">
|
||||
{open.slice(0, TOP_N).map((item) => (
|
||||
<li key={item.key}>
|
||||
<button
|
||||
type="button"
|
||||
className="setup-health-widget__item"
|
||||
onClick={() => item.fix?.to && navigate(item.fix.to)}
|
||||
>
|
||||
<Pill kind={STATUS_TONE[item.status] || 'gray'}>{item.status}</Pill>
|
||||
<span className="setup-health-widget__itemtitle">{item.title}</span>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{open.length > TOP_N && (
|
||||
<button
|
||||
type="button"
|
||||
className="setup-health-widget__more"
|
||||
onClick={() => navigate('/monitoring/doctor')}
|
||||
>
|
||||
{open.length - TOP_N} more in Doctor
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupHealthWidget;
|
||||
@@ -4,22 +4,40 @@ import { cn } from '@/lib/utils';
|
||||
// optional trend delta in the top-right.
|
||||
// tone: 'accent' | 'green' | 'cyan' | 'amber' | 'red' | 'violet'
|
||||
// trendDir: 'up' | 'down' | 'flat'
|
||||
//
|
||||
// When `onClick` is provided the tile renders as a semantic <button> (keyboard
|
||||
// accessible, design-system focus ring) so KPIs can drive a filter/navigation.
|
||||
// `kind` is accepted as a deprecated alias for `tone` (dev-only console.warn) —
|
||||
// this heals silent API misuse where a page passed a prop the tile dropped.
|
||||
// `secondary` is read by KpiBand to force-fold a tile into the compact strip;
|
||||
// it is not a visual prop on the tile itself.
|
||||
export function MetricCard({
|
||||
icon,
|
||||
tone = 'accent',
|
||||
tone,
|
||||
kind,
|
||||
value,
|
||||
unit,
|
||||
label,
|
||||
trend,
|
||||
trendDir = 'flat',
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
secondary: _secondary, // consumed by KpiBand, kept out of DOM props
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('sk-kpi', className)} {...props}>
|
||||
if (import.meta.env.DEV && kind && !tone) {
|
||||
console.warn(
|
||||
`[MetricCard] \`kind="${kind}"\` is deprecated — use \`tone\` instead ` +
|
||||
'(accent|green|cyan|amber|red|violet).'
|
||||
);
|
||||
}
|
||||
const resolvedTone = tone || kind || 'accent';
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<div className="sk-kpi__top">
|
||||
{icon && <span className={cn('sk-kpi__icon', `sk-kpi__icon--${tone}`)}>{icon}</span>}
|
||||
{icon && <span className={cn('sk-kpi__icon', `sk-kpi__icon--${resolvedTone}`)}>{icon}</span>}
|
||||
{trend != null && (
|
||||
<span className={cn('sk-kpi__trend', `sk-kpi__trend--${trendDir}`)}>{trend}</span>
|
||||
)}
|
||||
@@ -30,6 +48,25 @@ export function MetricCard({
|
||||
</div>
|
||||
{label && <div className="sk-kpi__label">{label}</div>}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn('sk-kpi', 'sk-kpi--clickable', className)}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('sk-kpi', className)} {...props}>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export { Pill } from './Pill';
|
||||
export { EnvTag } from './EnvTag';
|
||||
export { SegControl } from './SegControl';
|
||||
export { MetricCard } from './MetricCard';
|
||||
export { KpiBand } from './KpiBand';
|
||||
export { Gauge } from './Gauge';
|
||||
export { ServiceTile } from './ServiceTile';
|
||||
export { Sparkline } from './Sparkline';
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
import {
|
||||
SiGithub, SiGitlab, SiBitbucket, SiCloudflare, SiDigitalocean,
|
||||
SiHetzner, SiVultr, SiAkamai, SiGodaddy, SiNamecheap, SiBackblaze, SiDocker,
|
||||
SiDiscord, SiSlack, SiTelegram,
|
||||
} from 'react-icons/si';
|
||||
import { FaAws } from 'react-icons/fa';
|
||||
import { Mail, HardDrive, Plug } from 'lucide-react';
|
||||
import { Mail, HardDrive, Plug, Webhook } from 'lucide-react';
|
||||
|
||||
// provider id (matches providerCatalog) -> brand icon component. Several ids map
|
||||
// to the same brand wearing two hats (e.g. DigitalOcean as both a cloud host and
|
||||
@@ -34,6 +35,10 @@ const PROVIDER_ICONS = {
|
||||
smtp: Mail,
|
||||
s3: HardDrive,
|
||||
b2: SiBackblaze,
|
||||
chat_discord: SiDiscord,
|
||||
chat_slack: SiSlack,
|
||||
chat_telegram: SiTelegram,
|
||||
chat_webhook: Webhook,
|
||||
};
|
||||
|
||||
// Renders the brand icon for a provider, falling back to the generic lucide Plug
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import api from '../../services/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Pill, ScoreGauge } from '@/components/ds';
|
||||
import { Pill, ScoreGauge, KpiBand, MetricCard } from '@/components/ds';
|
||||
import {
|
||||
ShieldCheck,
|
||||
RefreshCw,
|
||||
@@ -126,7 +126,7 @@ const OverviewTab = ({ status, clamavStatus, clamavLoading, onRefresh, onNavigat
|
||||
const kpis = [
|
||||
{ key: 'alerts', icon: Siren, value: alerts.total || 0, label: 'Alerts · 24h', tone: alerts.total > 0 ? 'amber' : 'green' },
|
||||
{ key: 'malware', icon: Bug, value: alerts.malware_detections || 0, label: 'Malware', tone: alerts.malware_detections > 0 ? 'red' : 'green' },
|
||||
{ key: 'scan', icon: Radar, value: capitalize(status?.scan_status) || 'Idle', label: 'Scan', tone: scanRunning ? 'cyan' : 'muted', text: true },
|
||||
{ key: 'scan', icon: Radar, value: capitalize(status?.scan_status) || 'Idle', label: 'Scan', tone: scanRunning ? 'cyan' : 'accent' },
|
||||
];
|
||||
|
||||
const defsUpdated = clamavStatus?.last_update ? new Date(clamavStatus.last_update).toLocaleDateString() : null;
|
||||
@@ -162,18 +162,20 @@ const OverviewTab = ({ status, clamavStatus, clamavLoading, onRefresh, onNavigat
|
||||
? 'All hardening checks pass.'
|
||||
: `${warnCount} ${warnCount === 1 ? 'check needs' : 'checks need'} attention.`}
|
||||
</p>
|
||||
<div className="sec-posture__kpis">
|
||||
<KpiBand dense className="sec-posture__kpis">
|
||||
{kpis.map((k) => {
|
||||
const Icon = k.icon;
|
||||
return (
|
||||
<div key={k.key} className={`sec-kpi sec-kpi--${k.tone}`}>
|
||||
<Icon size={13} className="sec-kpi__icon" />
|
||||
<span className={`sec-kpi__val${k.text ? ' sec-kpi__val--text' : ''}`}>{k.value}</span>
|
||||
<span className="sec-kpi__label">{k.label}</span>
|
||||
</div>
|
||||
<MetricCard
|
||||
key={k.key}
|
||||
icon={<Icon size={16} />}
|
||||
tone={k.tone}
|
||||
value={k.value}
|
||||
label={k.label}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</KpiBand>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Pill } from '../ds';
|
||||
import { Pill, KpiBand } from '../ds';
|
||||
import OnboardingWizard from '../server/OnboardingWizard';
|
||||
import SystemStatusCard from './SystemStatusCard';
|
||||
import { formatBytes } from '@/utils/formatBytes';
|
||||
@@ -67,7 +67,7 @@ const ServerOverviewTab = ({ server, metrics, systemInfo, onRefreshServer }) =>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="server-stats-strip">
|
||||
<KpiBand max={5}>
|
||||
<KpiTile
|
||||
icon={<PulseIcon />}
|
||||
label="Status"
|
||||
@@ -101,7 +101,7 @@ const ServerOverviewTab = ({ server, metrics, systemInfo, onRefreshServer }) =>
|
||||
color="var(--green)"
|
||||
sub={totalDisk ? formatBytes(totalDisk) : null}
|
||||
/>
|
||||
</div>
|
||||
</KpiBand>
|
||||
|
||||
{!isOnline && (
|
||||
<div className="info-card offline-card">
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Activity, Rocket, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Activity, Rocket, CheckCircle2, XCircle, Globe, Database, ShieldCheck } from 'lucide-react';
|
||||
import api from '../../services/api';
|
||||
import { useDeployments } from '../../hooks/useDeployments';
|
||||
import { getDeployStatus, formatRelativeTime, formatDuration } from '../../utils/serviceTypes';
|
||||
import { formatBytes } from '../../utils/formatBytes';
|
||||
import BandwidthSparkline from '../BandwidthSparkline';
|
||||
import { MetricCard, Pill, Gauge, EnvTag } from '@/components/ds';
|
||||
import ScheduledTasksCard from '../ScheduledTasksCard';
|
||||
import { KpiBand, MetricCard, Pill, Gauge, EnvTag } from '@/components/ds';
|
||||
|
||||
// Deployment status → semantic tone (ds Pill kind / dot modifier)
|
||||
const DEPLOY_TONE = {
|
||||
@@ -27,6 +29,7 @@ const OverviewTab = ({ app, deployConfig }) => {
|
||||
const [metrics, setMetrics] = useState(null);
|
||||
const [metricsLoading, setMetricsLoading] = useState(true);
|
||||
const [bandwidth, setBandwidth] = useState(null);
|
||||
const [related, setRelated] = useState(null);
|
||||
const { deployments, loading: deploymentsLoading } = useDeployments(app.id);
|
||||
|
||||
const isDocker = app.app_type === 'docker';
|
||||
@@ -47,6 +50,15 @@ const OverviewTab = ({ app, deployConfig }) => {
|
||||
return () => { cancelled = true; };
|
||||
}, [app.id]);
|
||||
|
||||
useEffect(() => {
|
||||
// Related resources (domains, DBs, backups, deploys). Member-visible.
|
||||
let cancelled = false;
|
||||
api.getAppRelatedResources(app.id)
|
||||
.then((data) => { if (!cancelled) setRelated(data); })
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [app.id]);
|
||||
|
||||
async function loadMetrics() {
|
||||
try {
|
||||
if (isDocker) {
|
||||
@@ -88,7 +100,7 @@ const OverviewTab = ({ app, deployConfig }) => {
|
||||
return (
|
||||
<div className="overview-tab">
|
||||
{/* KPI Strip */}
|
||||
<div className="overview-tab__kpis">
|
||||
<KpiBand>
|
||||
<MetricCard
|
||||
tone={app.isRunning ? 'green' : 'amber'}
|
||||
icon={<Activity size={16} />}
|
||||
@@ -113,7 +125,7 @@ const OverviewTab = ({ app, deployConfig }) => {
|
||||
value={failedDeploys.length}
|
||||
label="Failed"
|
||||
/>
|
||||
</div>
|
||||
</KpiBand>
|
||||
|
||||
<div className="overview-tab__grid">
|
||||
{/* Service Info Card */}
|
||||
@@ -263,6 +275,12 @@ const OverviewTab = ({ app, deployConfig }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Related Resources Card (member-visible: domains, DBs, backups, deploys) */}
|
||||
<RelatedResourcesCard app={app} related={related} />
|
||||
|
||||
{/* Scheduled tasks (read-only cron summary; renders nothing for non-members) */}
|
||||
<ScheduledTasksCard appId={app.id} />
|
||||
</div>
|
||||
|
||||
{/* Bandwidth (daily nginx rollups; hidden until data exists) */}
|
||||
@@ -335,6 +353,80 @@ const OverviewTab = ({ app, deployConfig }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// One member-visible card summarizing the app's related resources. Each row
|
||||
// links only to a surface the caller can open; the backend already scopes what
|
||||
// it returns to the caller's access, so an empty section simply renders nothing.
|
||||
const RelatedResourcesCard = ({ app, related }) => {
|
||||
const domains = related?.domains || [];
|
||||
const databases = related?.databases || [];
|
||||
const backup = related?.backup;
|
||||
const deployments = related?.deployments;
|
||||
|
||||
const hasAny = domains.length || databases.length || backup?.enabled || deployments?.count;
|
||||
|
||||
return (
|
||||
<div className="overview-tab__card">
|
||||
<h3 className="overview-tab__card-title">Related Resources</h3>
|
||||
{!related ? (
|
||||
<div className="overview-tab__loading">Loading...</div>
|
||||
) : !hasAny ? (
|
||||
<div className="overview-tab__no-metrics">
|
||||
<p>No related resources yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overview-tab__related">
|
||||
{domains.length > 0 && (
|
||||
<div className="overview-tab__related-group">
|
||||
<span className="overview-tab__related-label">
|
||||
<Globe size={14} /> Domains
|
||||
</span>
|
||||
<div className="overview-tab__related-items">
|
||||
{domains.map((d) => (
|
||||
<Link key={d.id} to={`/services/${app.id}/settings/domain`} className="overview-tab__related-chip">
|
||||
{d.name}
|
||||
{d.ssl_enabled && <ShieldCheck size={12} className="overview-tab__related-ssl" />}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{databases.length > 0 && (
|
||||
<div className="overview-tab__related-group">
|
||||
<span className="overview-tab__related-label">
|
||||
<Database size={14} /> Databases
|
||||
</span>
|
||||
<div className="overview-tab__related-items">
|
||||
{databases.map((m) => (
|
||||
<span key={m.id} className="overview-tab__related-chip">
|
||||
{m.name} <span className="overview-tab__related-muted">{m.engine}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{backup?.enabled && (
|
||||
<div className="sk-info-row">
|
||||
<span className="k">Backups</span>
|
||||
<span className="v">
|
||||
{backup.frequency || 'scheduled'}
|
||||
{backup.last_status && <Pill kind={backup.last_status === 'success' ? 'green' : 'amber'}>{backup.last_status}</Pill>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{deployments?.count > 0 && (
|
||||
<div className="sk-info-row">
|
||||
<span className="k">Deployments</span>
|
||||
<span className="v">
|
||||
<Link to={`/services/${app.id}/events`}>{deployments.count} total</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function extractRepoDisplay(url) {
|
||||
if (!url) return '';
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../services/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// Admin control for the member-action escape hatch (plan 19 Phase 5 / plan 29 #13).
|
||||
// Curated, parameterized actions a workspace member may run for an app they can
|
||||
// reach (no free text ever reaches a shell; every run is audit-logged). On by
|
||||
// default — this surface just makes the flag visible so an operator can turn the
|
||||
// whole thing off. The templates are deliberately conservative, so the default
|
||||
// stays ON.
|
||||
|
||||
const MemberActionsCard = () => {
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSystemSettings()
|
||||
.then((data) => {
|
||||
if (data.security_member_actions_enabled != null) {
|
||||
setEnabled(Boolean(data.security_member_actions_enabled));
|
||||
}
|
||||
})
|
||||
.catch(() => { /* leave default */ })
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.updateSystemSettings({ security_member_actions_enabled: enabled });
|
||||
setMessage({ type: 'success', text: 'Member actions setting saved.' });
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: err.message || 'Failed to save setting' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setTimeout(() => setMessage(null), 6000);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<div className="settings-card">
|
||||
<h3>Member Actions</h3>
|
||||
<p className="form-help" style={{ marginTop: 0 }}>
|
||||
Let workspace members run a small set of curated, parameterized
|
||||
actions (like adjusting an app's backup frequency) for apps they
|
||||
can reach. Every action is validated against a fixed schema and
|
||||
audit-logged — no free text ever reaches the server shell. Turn this
|
||||
off to disable the whole member-action surface.
|
||||
</p>
|
||||
|
||||
<div className="form-group">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>Allow members to run curated actions</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<Button variant="default" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
{message && (
|
||||
<span className={`timezone-message ${message.type}`} style={{ marginLeft: '0.75rem' }}>
|
||||
{message.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemberActionsCard;
|
||||
@@ -16,6 +16,7 @@ export const CONNECTION_CATEGORIES = [
|
||||
{ key: 'dns', label: 'DNS & domains', blurb: 'Let ServerKit manage DNS records and issue wildcard certificates automatically.' },
|
||||
{ key: 'registrar', label: 'Registrars & ownership', blurb: 'Track the domains you own and when their registration expires.' },
|
||||
{ key: 'email', label: 'Email & delivery', blurb: 'Outbound relays and deliverability for the mail server.' },
|
||||
{ key: 'chat', label: 'Chat & webhooks', blurb: 'Send notifications to a shared chat room or webhook, filtered by category.' },
|
||||
{ key: 'storage', label: 'Storage & backups', blurb: 'Off-site destinations for backups and large assets.' },
|
||||
];
|
||||
|
||||
@@ -108,6 +109,30 @@ export const CONNECTION_PROVIDERS = [
|
||||
blurb: 'Send outbound mail through a provider like Postmark, SES or Mailgun.',
|
||||
},
|
||||
|
||||
// ── Chat & webhooks ──
|
||||
// Org-level notification destinations. One card per kind; each holds many
|
||||
// connections (e.g. an #ops room and a #security room), routed by category.
|
||||
{
|
||||
id: 'chat_discord', category: 'chat', name: 'Discord', kind: 'chatwebhook', chatKind: 'discord',
|
||||
blurb: 'Post notifications to a Discord channel via an incoming webhook.',
|
||||
docUrl: 'https://support.discord.com/hc/en-us/articles/228383668', manageHref: '/settings/notifications',
|
||||
},
|
||||
{
|
||||
id: 'chat_slack', category: 'chat', name: 'Slack', kind: 'chatwebhook', chatKind: 'slack',
|
||||
blurb: 'Post notifications to a Slack channel via an incoming webhook.',
|
||||
docUrl: 'https://api.slack.com/messaging/webhooks', manageHref: '/settings/notifications',
|
||||
},
|
||||
{
|
||||
id: 'chat_telegram', category: 'chat', name: 'Telegram', kind: 'chatwebhook', chatKind: 'telegram',
|
||||
blurb: 'Send notifications to a Telegram chat through a bot.',
|
||||
docUrl: 'https://core.telegram.org/bots#how-do-i-create-a-bot', manageHref: '/settings/notifications',
|
||||
},
|
||||
{
|
||||
id: 'chat_webhook', category: 'chat', name: 'Generic webhook', kind: 'chatwebhook', chatKind: 'webhook',
|
||||
blurb: 'POST a signed JSON payload to any endpoint. Optional HMAC signature.',
|
||||
docUrl: null, manageHref: '/settings/notifications',
|
||||
},
|
||||
|
||||
// ── Storage & backups ──
|
||||
{
|
||||
id: 's3', category: 'storage', name: 'S3 / object storage', kind: 'storage', storageProvider: 's3',
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import wordpressApi from '../../../services/wordpress';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
import { MetricCard } from '../../ds';
|
||||
import { KpiBand, MetricCard } from '../../ds';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OverviewGridSkeleton } from './wpDetailShared';
|
||||
|
||||
@@ -72,7 +72,7 @@ const VulnerabilitiesTab = ({ siteId }) => {
|
||||
</div>
|
||||
|
||||
{data?.scanned_at && (
|
||||
<div className="wp-kpis">
|
||||
<KpiBand>
|
||||
<MetricCard icon={<AlertTriangle size={16} />} tone="red" value={summary.critical ?? 0} label="Critical" />
|
||||
<MetricCard icon={<AlertTriangle size={16} />} tone="red" value={summary.high ?? 0} label="High" />
|
||||
<MetricCard icon={<AlertTriangle size={16} />} tone="amber" value={summary.medium ?? 0} label="Medium" />
|
||||
@@ -80,7 +80,7 @@ const VulnerabilitiesTab = ({ siteId }) => {
|
||||
{summary.unknown > 0 && (
|
||||
<MetricCard icon={<AlertTriangle size={16} />} tone="violet" value={summary.unknown} label="Unrated" />
|
||||
)}
|
||||
</div>
|
||||
</KpiBand>
|
||||
)}
|
||||
|
||||
{vulns.length === 0 ? (
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useState, useCallback } from 'react';
|
||||
const STORAGE_KEY = 'dashboard_layout';
|
||||
|
||||
const DEFAULT_WIDGETS = [
|
||||
// Admin-only: renders nothing for non-admins (see Dashboard WIDGET_RENDERERS).
|
||||
{ id: 'setup-health', label: 'Setup Health', visible: true },
|
||||
{ id: 'cpu', label: 'CPU', visible: true },
|
||||
{ id: 'ram', label: 'RAM', visible: true },
|
||||
{ id: 'network', label: 'Network', visible: true },
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Opens a page's existing drawer / panel / modal from a notification deep link.
|
||||
*
|
||||
* Notification links follow the fragment contract `path?focus=<kind>:<id>`
|
||||
* (e.g. `/backups?focus=policy:12`). A destination page calls this hook once
|
||||
* with the kind it handles and a handler that opens the matching surface; the
|
||||
* hook fires the handler a single time, then clears the `focus` param so a
|
||||
* refresh or back-nav doesn't re-trigger it.
|
||||
*
|
||||
* @param {string} kind - the focus kind this page handles ('policy'|'domain'|'job'|…)
|
||||
* @param {(id: string) => void} handler - opens the surface for the given id
|
||||
*/
|
||||
export default function useFocusParam(kind, handler) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const handledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (handledRef.current) return;
|
||||
const focus = searchParams.get('focus');
|
||||
if (!focus) return;
|
||||
|
||||
const sep = focus.indexOf(':');
|
||||
if (sep === -1) return;
|
||||
const focusKind = focus.slice(0, sep);
|
||||
const id = focus.slice(sep + 1);
|
||||
if (focusKind !== kind || !id) return;
|
||||
|
||||
handledRef.current = true;
|
||||
handler(id);
|
||||
|
||||
// Clear the param so a refresh doesn't re-open the surface.
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete('focus');
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [searchParams, kind, handler, setSearchParams]);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import useMediaQuery from '../hooks/useMediaQuery';
|
||||
import { useLockBodyScroll } from '../hooks/useLockBodyScroll';
|
||||
import api from '../services/api';
|
||||
import SystemNotices from '../components/SystemNotices';
|
||||
import StagingBanner from '../components/StagingBanner';
|
||||
|
||||
// /workflow is contributed by the serverkit-workflows extension with
|
||||
// layout:'full', so it's picked up dynamically via fullPagePaths below.
|
||||
@@ -82,6 +83,7 @@ const DashboardLayout = () => {
|
||||
<AIProvider>
|
||||
<ConfirmProvider>
|
||||
<div className="dashboard-layout">
|
||||
<StagingBanner />
|
||||
<MobileTopBar navOpen={navOpen} onToggle={() => setNavOpen(prev => !prev)} />
|
||||
<Sidebar
|
||||
mobileOpen={navOpen}
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
* enforced by the parent PrivateRoute in App.jsx.
|
||||
*/
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import StagingBanner from '../components/StagingBanner';
|
||||
|
||||
const StandaloneLayout = () => <Outlet />;
|
||||
const StandaloneLayout = () => (
|
||||
<>
|
||||
<StagingBanner />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
|
||||
export default StandaloneLayout;
|
||||
@@ -1,3 +1,6 @@
|
||||
// Capture the host's singleton vendor instances for runtime-loaded extension
|
||||
// bundles BEFORE anything else runs (plan 25 Decision 2).
|
||||
import './plugins/runtime/vendorShare'
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Send, RefreshCw, Inbox } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { PageTopbar, MetricCard } from '@/components/ds';
|
||||
import { PageTopbar, MetricCard, KpiBand } from '@/components/ds';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
@@ -81,12 +81,12 @@ export default function DeliveryLog() {
|
||||
<div className="sk-dlog">
|
||||
<EmailProviders />
|
||||
|
||||
<div className="sk-dlog__stats">
|
||||
<KpiBand>
|
||||
<MetricCard label="Total" value={stats?.total ?? 0} tone="accent" />
|
||||
<MetricCard label="Sent" value={byStatus.sent ?? 0} tone="green" />
|
||||
<MetricCard label="Pending" value={byStatus.pending ?? 0} tone="amber" />
|
||||
<MetricCard label="Failed" value={byStatus.failed ?? 0} tone="red" />
|
||||
</div>
|
||||
</KpiBand>
|
||||
|
||||
<div className="sk-dlog__filters">
|
||||
<label>
|
||||
|
||||
@@ -3,7 +3,7 @@ import useTabParam from '../hooks/useTabParam';
|
||||
import api from '../services/api';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MetricCard } from '@/components/ds';
|
||||
import { MetricCard, KpiBand } from '@/components/ds';
|
||||
import {
|
||||
Box, Layers, HardDrive, Network as NetworkIcon,
|
||||
Trash2, Activity, Package, Server as ServerIcon,
|
||||
@@ -318,7 +318,7 @@ const Docker = () => {
|
||||
</aside>
|
||||
|
||||
<main className="dx-main">
|
||||
<div className="dx-kpi-strip">
|
||||
<KpiBand>
|
||||
<MetricCard tone="accent" icon={<Box size={16} />} value={stats.containers.total} label="Containers">
|
||||
<div className="sk-kpi__sub"><span>{stats.containers.running} running</span></div>
|
||||
</MetricCard>
|
||||
@@ -327,7 +327,7 @@ const Docker = () => {
|
||||
</MetricCard>
|
||||
<MetricCard tone="violet" icon={<HardDrive size={16} />} value={stats.volumes.total} label="Volumes" />
|
||||
<MetricCard tone="green" icon={<NetworkIcon size={16} />} value={stats.networks.total} label="Networks" />
|
||||
</div>
|
||||
</KpiBand>
|
||||
<div className="dx-workbar">
|
||||
<div className="dx-workbar-title">
|
||||
<span>Docker</span>
|
||||
|
||||
+229
-1086
File diff suppressed because it is too large.
Load diff
@@ -1,11 +1,11 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AlertTriangle, Boxes, Network, RefreshCw, Server as ServerIcon } from 'lucide-react';
|
||||
import { AlertTriangle, Boxes, Network, RefreshCw, Router, Server as ServerIcon, Waypoints } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useTopbarActions } from '@/hooks/useTopbarActions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DataTable, Pill } from '@/components/ds';
|
||||
import { DataTable, KpiBand, MetricCard, Pill } from '@/components/ds';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
|
||||
// Fleet-wide reverse-proxy dashboard (Phase 4 of C6). Aggregates every
|
||||
@@ -106,18 +106,20 @@ const FleetProxy = () => {
|
||||
return counts;
|
||||
}, [rows]);
|
||||
|
||||
// Summary KPIs. Tone mirrors each proxy type's semantic color (Traefik→cyan,
|
||||
// Caddy→violet from PROXY_TYPE_META); the neutral fleet counts read accent,
|
||||
// and the mismatch tile turns amber only when there is something to act on.
|
||||
const summaryTiles = [
|
||||
{ key: 'total', label: 'Servers', value: summary.total, icon: ServerIcon, meta: typeMeta('nginx') },
|
||||
{ key: 'apps', label: 'Apps', value: summary.apps, icon: Boxes, meta: typeMeta('nginx') },
|
||||
{ key: 'traefik', label: 'Traefik', value: summary.traefik, meta: typeMeta('traefik') },
|
||||
{ key: 'caddy', label: 'Caddy', value: summary.caddy, meta: typeMeta('caddy') },
|
||||
{ key: 'total', label: 'Servers', value: summary.total, icon: <ServerIcon size={16} />, tone: 'accent' },
|
||||
{ key: 'apps', label: 'Apps', value: summary.apps, icon: <Boxes size={16} />, tone: 'accent' },
|
||||
{ key: 'traefik', label: 'Traefik', value: summary.traefik, icon: <Router size={16} />, tone: 'cyan' },
|
||||
{ key: 'caddy', label: 'Caddy', value: summary.caddy, icon: <Waypoints size={16} />, tone: 'violet' },
|
||||
{
|
||||
key: 'mismatches',
|
||||
label: 'Ingress mismatches',
|
||||
value: summary.mismatches,
|
||||
icon: AlertTriangle,
|
||||
meta: typeMeta('nginx'),
|
||||
warn: summary.mismatches > 0,
|
||||
icon: <AlertTriangle size={16} />,
|
||||
tone: summary.mismatches > 0 ? 'amber' : 'green',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -148,25 +150,17 @@ const FleetProxy = () => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="fleet-proxy__summary">
|
||||
{summaryTiles.map((tile) => {
|
||||
const Icon = tile.icon;
|
||||
return (
|
||||
<div
|
||||
<KpiBand max={5}>
|
||||
{summaryTiles.map((tile) => (
|
||||
<MetricCard
|
||||
key={tile.key}
|
||||
className={`fleet-proxy__summary-tile${tile.warn ? ' fleet-proxy__summary-tile--warn' : ''}`}
|
||||
>
|
||||
<span className="fleet-proxy__summary-label">
|
||||
{Icon ? <Icon size={14} /> : (
|
||||
<span className={`fleet-proxy__swatch fleet-proxy__swatch--${tile.meta.kind}`} />
|
||||
)}
|
||||
{tile.label}
|
||||
</span>
|
||||
<strong className="fleet-proxy__summary-value">{tile.value}</strong>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
icon={tile.icon}
|
||||
tone={tile.tone}
|
||||
value={tile.value}
|
||||
label={tile.label}
|
||||
/>
|
||||
))}
|
||||
</KpiBand>
|
||||
|
||||
{error ? (
|
||||
<div className="fleet-proxy__error">
|
||||
|
||||
+98
-320
@@ -1,320 +1,98 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Layers, RefreshCw, Inbox, Play, X, RotateCcw, ChevronRight, Clock,
|
||||
} from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { PageTopbar, MetricCard, Pill } from '@/components/ds';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { timeAgo } from '../utils/timeAgo';
|
||||
|
||||
const STATUSES = ['all', 'pending', 'running', 'succeeded', 'failed', 'cancelled'];
|
||||
const POLL_MS = 5000;
|
||||
|
||||
// Job status -> Pill colour.
|
||||
const PILL_KIND = {
|
||||
succeeded: 'green',
|
||||
running: 'cyan',
|
||||
pending: 'amber',
|
||||
failed: 'red',
|
||||
cancelled: 'gray',
|
||||
};
|
||||
|
||||
function StatusPill({ status }) {
|
||||
return <Pill kind={PILL_KIND[status] || 'gray'}>{status}</Pill>;
|
||||
}
|
||||
|
||||
function describeSchedule(s) {
|
||||
if (s.schedule_kind === 'cron' && s.cron) return s.cron;
|
||||
const sec = s.interval_seconds;
|
||||
if (!sec) return '—';
|
||||
if (sec % 86400 === 0) return `every ${sec / 86400}d`;
|
||||
if (sec % 3600 === 0) return `every ${sec / 3600}h`;
|
||||
if (sec % 60 === 0) return `every ${sec / 60}m`;
|
||||
return `every ${sec}s`;
|
||||
}
|
||||
|
||||
function JobDetail({ detail }) {
|
||||
if (!detail) return <div className="jobs-detail jobs-detail--loading">Loading details…</div>;
|
||||
const payload = detail.payload && Object.keys(detail.payload).length > 0 ? detail.payload : null;
|
||||
return (
|
||||
<div className="jobs-detail">
|
||||
<div className="jobs-detail__grid">
|
||||
<span>ID</span><code>{detail.id}</code>
|
||||
<span>Correlation</span><code>{detail.correlation_id || '—'}</code>
|
||||
<span>Started</span><span>{detail.started_at ? new Date(detail.started_at).toLocaleString() : '—'}</span>
|
||||
<span>Completed</span><span>{detail.completed_at ? new Date(detail.completed_at).toLocaleString() : '—'}</span>
|
||||
<span>Duration</span><span>{detail.duration != null ? `${detail.duration.toFixed(2)}s` : '—'}</span>
|
||||
</div>
|
||||
{detail.error_message && (
|
||||
<div className="jobs-detail__block jobs-detail__block--error">
|
||||
<span>Error</span>
|
||||
<pre>{detail.error_message}</pre>
|
||||
</div>
|
||||
)}
|
||||
{payload && (
|
||||
<div className="jobs-detail__block">
|
||||
<span>Payload</span>
|
||||
<pre>{JSON.stringify(payload, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
{detail.result != null && (
|
||||
<div className="jobs-detail__block">
|
||||
<span>Result</span>
|
||||
<pre>{JSON.stringify(detail.result, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JobRow({ job, expanded, detail, onToggle, onCancel, onRetry }) {
|
||||
const canCancel = job.status === 'pending' || job.status === 'running';
|
||||
const canRetry = job.status === 'failed' || job.status === 'cancelled';
|
||||
return (
|
||||
<>
|
||||
<tr className={`jobs-row${expanded ? ' is-open' : ''}`} onClick={onToggle}>
|
||||
<td className="jobs-caret">
|
||||
<ChevronRight size={14} className={expanded ? 'is-open' : ''} />
|
||||
</td>
|
||||
<td><StatusPill status={job.status} /></td>
|
||||
<td className="jobs-mono">{job.kind}</td>
|
||||
<td className="jobs-owner">
|
||||
{job.owner_type ? `${job.owner_type}${job.owner_id ? `:${job.owner_id}` : ''}` : '—'}
|
||||
</td>
|
||||
<td>{job.attempts}/{job.max_attempts}</td>
|
||||
<td className="jobs-when">{timeAgo(job.created_at)}</td>
|
||||
<td className="jobs-actions" onClick={(e) => e.stopPropagation()}>
|
||||
{canCancel && (
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}><X size={14} /> Cancel</Button>
|
||||
)}
|
||||
{canRetry && (
|
||||
<Button variant="ghost" size="sm" onClick={onRetry}><RotateCcw size={14} /> Retry</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="jobs-detail-row">
|
||||
<td colSpan={7}><JobDetail detail={detail} /></td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Jobs() {
|
||||
const { isAdmin } = useAuth();
|
||||
const toast = useToast();
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [kinds, setKinds] = useState([]);
|
||||
const [scheduled, setScheduled] = useState([]);
|
||||
const [status, setStatus] = useState('all');
|
||||
const [kind, setKind] = useState('all');
|
||||
const [expandedId, setExpandedId] = useState(null);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pollRef = useRef(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const params = {};
|
||||
if (status !== 'all') params.status = status;
|
||||
if (kind !== 'all') params.kind = kind;
|
||||
const [jobsData, statsData, schedData] = await Promise.all([
|
||||
api.getJobs(params),
|
||||
api.getJobStats(),
|
||||
api.getScheduledJobs(),
|
||||
]);
|
||||
setJobs(jobsData.jobs || []);
|
||||
setStats(statsData || null);
|
||||
setScheduled(schedData.scheduled || []);
|
||||
} catch {
|
||||
// leave the last good state on screen
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [status, kind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return undefined;
|
||||
load();
|
||||
pollRef.current = setInterval(load, POLL_MS);
|
||||
return () => clearInterval(pollRef.current);
|
||||
}, [isAdmin, load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
api.getJobKinds().then((d) => setKinds(d.kinds || [])).catch(() => {});
|
||||
}, [isAdmin]);
|
||||
|
||||
const toggleDetail = async (job) => {
|
||||
if (expandedId === job.id) {
|
||||
setExpandedId(null);
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
setExpandedId(job.id);
|
||||
setDetail(null);
|
||||
try {
|
||||
const data = await api.getJob(job.id);
|
||||
setDetail(data.job || null);
|
||||
} catch {
|
||||
setDetail(null);
|
||||
}
|
||||
};
|
||||
|
||||
const action = async (fn, okMsg, failMsg) => {
|
||||
try {
|
||||
await fn();
|
||||
toast.success(okMsg);
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error(e?.message || failMsg);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<>
|
||||
<PageTopbar icon={<Layers size={18} />} title="Jobs" />
|
||||
<div className="jobs-page"><div className="jobs-empty">Admins only.</div></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const byStatus = stats?.by_status || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTopbar
|
||||
icon={<Layers size={18} />}
|
||||
title="Jobs"
|
||||
meta="Background work across the Queue Bus"
|
||||
actions={(
|
||||
<Button variant="outline" size="sm" onClick={load}>
|
||||
<RefreshCw size={14} /> Refresh
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="jobs-page">
|
||||
<div className="jobs-stats">
|
||||
<MetricCard label="Total" value={stats?.total ?? 0} tone="accent" />
|
||||
<MetricCard label="Running" value={byStatus.running ?? 0} />
|
||||
<MetricCard label="Pending" value={byStatus.pending ?? 0} tone="amber" />
|
||||
<MetricCard label="Succeeded" value={byStatus.succeeded ?? 0} tone="green" />
|
||||
<MetricCard label="Failed" value={byStatus.failed ?? 0} tone="red" />
|
||||
</div>
|
||||
|
||||
<div className="jobs-filters">
|
||||
<label>
|
||||
Status
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Kind
|
||||
<select value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||
<option value="all">all</option>
|
||||
{kinds.map((k) => <option key={k} value={k}>{k}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{loading && jobs.length === 0 ? (
|
||||
<div className="jobs-empty">Loading…</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="jobs-empty">
|
||||
<Inbox size={24} aria-hidden="true" />
|
||||
<p>No jobs match these filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="jobs-table-wrap">
|
||||
<table className="jobs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th aria-label="expand" />
|
||||
<th>Status</th>
|
||||
<th>Kind</th>
|
||||
<th>Owner</th>
|
||||
<th>Tries</th>
|
||||
<th>Created</th>
|
||||
<th aria-label="Actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((j) => (
|
||||
<JobRow
|
||||
key={j.id}
|
||||
job={j}
|
||||
expanded={expandedId === j.id}
|
||||
detail={expandedId === j.id ? detail : null}
|
||||
onToggle={() => toggleDetail(j)}
|
||||
onCancel={() => action(() => api.cancelJob(j.id), 'Job cancelled', 'Cancel failed')}
|
||||
onRetry={() => action(() => api.retryJob(j.id), 'Job re-queued', 'Retry failed')}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="jobs-section-head">
|
||||
<Clock size={16} aria-hidden="true" />
|
||||
<h2>Scheduled jobs</h2>
|
||||
</div>
|
||||
|
||||
{scheduled.length === 0 ? (
|
||||
<div className="jobs-empty"><p>No scheduled jobs.</p></div>
|
||||
) : (
|
||||
<div className="jobs-table-wrap">
|
||||
<table className="jobs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Kind</th>
|
||||
<th>Schedule</th>
|
||||
<th>Next run</th>
|
||||
<th>Last run</th>
|
||||
<th aria-label="Actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scheduled.map((s) => (
|
||||
<tr key={s.id} className={s.enabled ? '' : 'is-disabled'}>
|
||||
<td className="jobs-mono">{s.name}</td>
|
||||
<td className="jobs-mono">{s.kind}</td>
|
||||
<td>{describeSchedule(s)}</td>
|
||||
<td>
|
||||
{s.enabled
|
||||
? (s.next_run_at ? timeAgo(s.next_run_at) : '—')
|
||||
: <Pill kind="gray">paused</Pill>}
|
||||
</td>
|
||||
<td className="jobs-when">{s.last_run_at ? timeAgo(s.last_run_at) : '—'}</td>
|
||||
<td className="jobs-actions">
|
||||
<Button variant="ghost" size="sm" title="Run now"
|
||||
onClick={() => action(() => api.runScheduledJob(s.id), `Enqueued "${s.name}"`, 'Run failed')}>
|
||||
<Play size={14} /> Run
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => action(
|
||||
() => api.setScheduledJobEnabled(s.id, !s.enabled),
|
||||
s.enabled ? `Paused "${s.name}"` : `Enabled "${s.name}"`,
|
||||
'Update failed',
|
||||
)}>
|
||||
{s.enabled ? 'Pause' : 'Enable'}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
parsed: Unknown
|
||||
2026-07-07 14:12:50.8|Debug|QualityParser|Trying to parse quality for '[Erai-raws] Kimi ga Shinu made Koi wo Shitai - 01 [720p CR WEB-DL AVC AAC][MultiSub][4796E17B]'
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Quality parsed: WEBDL-720p v1
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Release Group parsed: Erai-raws
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Release Hash parsed: 4796E17B
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Parsing string '[Erai-raws] Kimi ga Shinu made Koi wo Shitai - 01 [720p CR WEB-DL AVC AAC][MultiSub][4796E17B]'
|
||||
2026-07-07 14:12:50.8|Debug|SceneMappingService|Found scene mapping for: Kimi ga Shinu made Koi wo Shitai. TVDB ID for mapping: 460899
|
||||
2026-07-07 14:12:50.8|Debug|ParsingService|No matching series Kimi ga Shinu made Koi wo Shitai
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Parsing string '[Erai-raws] Kimi ga Shinu made Koi wo Shitai - 01 [720p CR WEB-DL AVC AAC][MultiSub][4796E17B]'
|
||||
2026-07-07 14:12:50.8|Debug|SceneMappingService|Found scene mapping for: Kimi ga Shinu made Koi wo Shitai. TVDB ID for mapping: 460899
|
||||
2026-07-07 14:12:50.8|Debug|DownloadDecisionMaker|Release '[Erai-raws] Kimi ga Shinu made Koi wo Shitai - 01 [720p CR WEB-DL AVC AAC][MultiSub][4796E17B]' from 'Nyaa.si (Prowlarr)' rejected for the following reasons: [Permanent] Kimi ga Shinu made Koi wo Shitai matches an alias for series with TVDB ID: 460899
|
||||
2026-07-07 14:12:50.8|Debug|DownloadDecisionMaker|Processing release '[Erai-raws] Kimi ga Shinu made Koi wo Shitai - 01 [1080p CR WEB-DL AVC AAC][MultiSub][0846EAB5]' from 'Nyaa.si (Prowlarr)'
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Parsing string '[Erai-raws] Kimi ga Shinu made Koi wo Shitai - 01 [1080p CR WEB-DL AVC AAC][MultiSub][0846EAB5]'
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Episode Parsed. Kimi ga Shinu made Koi wo Shitai - 001
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Languages parsed: Unknown
|
||||
2026-07-07 14:12:50.8|Debug|QualityParser|Trying to parse quality for '[Erai-raws] Kimi ga Shinu made Koi wo Shitai - 01 [1080p CR WEB-DL AVC AAC][MultiSub][0846EAB5]'
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Quality parsed: WEBDL-1080p v1
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Release Group parsed: Erai-raws
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Release Hash parsed: 0846EAB5
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Parsing string '[Erai-raws] Kimi ga Shinu made Koi wo Shitai - 01 [1080p CR WEB-DL AVC AAC][MultiSub][0846EAB5]'
|
||||
2026-07-07 14:12:50.8|Debug|SceneMappingService|Found scene mapping for: Kimi ga Shinu made Koi wo Shitai. TVDB ID for mapping: 460899
|
||||
2026-07-07 14:12:50.8|Debug|ParsingService|No matching series Kimi ga Shinu made Koi wo Shitai
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Parsing string '[Erai-raws] Kimi ga Shinu made Koi wo Shitai - 01 [1080p CR WEB-DL AVC AAC][MultiSub][0846EAB5]'
|
||||
2026-07-07 14:12:50.8|Debug|SceneMappingService|Found scene mapping for: Kimi ga Shinu made Koi wo Shitai. TVDB ID for mapping: 460899
|
||||
2026-07-07 14:12:50.8|Debug|DownloadDecisionMaker|Release '[Erai-raws] Kimi ga Shinu made Koi wo Shitai - 01 [1080p CR WEB-DL AVC AAC][MultiSub][0846EAB5]' from 'Nyaa.si (Prowlarr)' rejected for the following reasons: [Permanent] Kimi ga Shinu made Koi wo Shitai matches an alias for series with TVDB ID: 460899
|
||||
2026-07-07 14:12:50.8|Debug|DownloadDecisionMaker|Processing release '[Erai-raws] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 01 [480p CR WEB-DL AVC AAC][MultiSub][C9E3BE4F]' from 'Nyaa.si (Prowlarr)'
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Parsing string '[Erai-raws] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 01 [480p CR WEB-DL AVC AAC][MultiSub][C9E3BE4F]'
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Episode Parsed. Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 001
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Languages parsed: Unknown
|
||||
2026-07-07 14:12:50.8|Debug|QualityParser|Trying to parse quality for '[Erai-raws] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 01 [480p CR WEB-DL AVC AAC][MultiSub][C9E3BE4F]'
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Quality parsed: WEBDL-480p v1
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Release Group parsed: Erai-raws
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Release Hash parsed: C9E3BE4F
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Parsing string '[Erai-raws] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 01 [480p CR WEB-DL AVC AAC][MultiSub][C9E3BE4F]'
|
||||
2026-07-07 14:12:50.8|Debug|SceneMappingService|Found scene mapping for: Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai. TVDB ID for mapping: 402642
|
||||
2026-07-07 14:12:50.8|Debug|ParsingService|No matching series Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Parsing string '[Erai-raws] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 01 [480p CR WEB-DL AVC AAC][MultiSub][C9E3BE4F]'
|
||||
2026-07-07 14:12:50.8|Debug|SceneMappingService|Found scene mapping for: Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai. TVDB ID for mapping: 402642
|
||||
2026-07-07 14:12:50.8|Debug|DownloadDecisionMaker|Release '[Erai-raws] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 01 [480p CR WEB-DL AVC AAC][MultiSub][C9E3BE4F]' from 'Nyaa.si (Prowlarr)' rejected for the following reasons: [Permanent] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai matches an alias for series with TVDB ID: 402642
|
||||
2026-07-07 14:12:50.8|Debug|DownloadDecisionMaker|Processing release '[Erai-raws] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 01 [720p CR WEB-DL AVC AAC][MultiSub][66703EA3]' from 'Nyaa.si (Prowlarr)'
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Parsing string '[Erai-raws] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 01 [720p CR WEB-DL AVC AAC][MultiSub][66703EA3]'
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Episode Parsed. Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 001
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Languages parsed: Unknown
|
||||
2026-07-07 14:12:50.8|Debug|QualityParser|Trying to parse quality for '[Erai-raws] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 01 [720p CR WEB-DL AVC AAC][MultiSub][66703EA3]'
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Quality parsed: WEBDL-720p v1
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Release Group parsed: Erai-raws
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Release Hash parsed: 66703EA3
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Parsing string '[Erai-raws] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 01 [720p CR WEB-DL AVC AAC][MultiSub][66703EA3]'
|
||||
2026-07-07 14:12:50.8|Debug|SceneMappingService|Found scene mapping for: Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai. TVDB ID for mapping: 402642
|
||||
2026-07-07 14:12:50.8|Debug|ParsingService|No matching series Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Parsing string '[Erai-raws] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 01 [720p CR WEB-DL AVC AAC][MultiSub][66703EA3]'
|
||||
2026-07-07 14:12:50.8|Debug|SceneMappingService|Found scene mapping for: Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai. TVDB ID for mapping: 402642
|
||||
2026-07-07 14:12:50.8|Debug|DownloadDecisionMaker|Release '[Erai-raws] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 01 [720p CR WEB-DL AVC AAC][MultiSub][66703EA3]' from 'Nyaa.si (Prowlarr)' rejected for the following reasons: [Permanent] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai matches an alias for series with TVDB ID: 402642
|
||||
2026-07-07 14:12:50.8|Debug|DownloadDecisionMaker|Processing release '[Erai-raws] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 01 [1080p CR WEB-DL AVC AAC][MultiSub][908325CE]' from 'Nyaa.si (Prowlarr)'
|
||||
2026-07-07 14:12:50.8|Debug|Parser|Parsing string '[Erai-raws] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 01 [1080p CR WEB-DL AVC AAC][MultiSub][908325CE]'
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Episode Parsed. Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 001
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Languages parsed: Unknown
|
||||
2026-07-07 14:12:50.9|Debug|QualityParser|Trying to parse quality for '[Erai-raws] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 01 [1080p CR WEB-DL AVC AAC][MultiSub][908325CE]'
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Quality parsed: WEBDL-1080p v1
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Release Group parsed: Erai-raws
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Release Hash parsed: 908325CE
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Parsing string '[Erai-raws] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 01 [1080p CR WEB-DL AVC AAC][MultiSub][908325CE]'
|
||||
2026-07-07 14:12:50.9|Debug|SceneMappingService|Found scene mapping for: Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai. TVDB ID for mapping: 402642
|
||||
2026-07-07 14:12:50.9|Debug|ParsingService|No matching series Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Parsing string '[Erai-raws] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 01 [1080p CR WEB-DL AVC AAC][MultiSub][908325CE]'
|
||||
2026-07-07 14:12:50.9|Debug|SceneMappingService|Found scene mapping for: Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai. TVDB ID for mapping: 402642
|
||||
2026-07-07 14:12:50.9|Debug|DownloadDecisionMaker|Release '[Erai-raws] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai - 01 [1080p CR WEB-DL AVC AAC][MultiSub][908325CE]' from 'Nyaa.si (Prowlarr)' rejected for the following reasons: [Permanent] Tai Ari Deshita Ojou-sama wa Kakutou Game Nante Shinai matches an alias for series with TVDB ID: 402642
|
||||
2026-07-07 14:12:50.9|Debug|DownloadDecisionMaker|Processing release '[ToonsHub] Saved by the Ice Cold Princes Embrace S01E01 1080p AMZN WEB-DL DDP2.0 H.264 (Migawari Reijou wo Sukutta no wa Reikoku Mujihi na Koori no Ouji no Ai deshita, Multi-Subs)' from 'Nyaa.si (Prowlarr)'
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Parsing string '[ToonsHub] Saved by the Ice Cold Princes Embrace S01E01 1080p AMZN WEB-DL DDP2.0 H.264 (Migawari Reijou wo Sukutta no wa Reikoku Mujihi na Koori no Ouji no Ai deshita, Multi-Subs)'
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Episode Parsed. Saved by the Ice Cold Princes Embrace - S01E01
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Languages parsed: Unknown
|
||||
2026-07-07 14:12:50.9|Debug|QualityParser|Trying to parse quality for '[ToonsHub] Saved by the Ice Cold Princes Embrace S01E01 1080p AMZN WEB-DL DDP2.0 H.264 (Migawari Reijou wo Sukutta no wa Reikoku Mujihi na Koori no Ouji no Ai deshita, Multi-Subs)'
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Quality parsed: WEBDL-1080p v1
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Release Group parsed: ToonsHub
|
||||
2026-07-07 14:12:50.9|Debug|ParsingService|No matching series Saved by the Ice Cold Princes Embrace
|
||||
2026-07-07 14:12:50.9|Debug|DownloadDecisionMaker|Release '[ToonsHub] Saved by the Ice Cold Princes Embrace S01E01 1080p AMZN WEB-DL DDP2.0 H.264 (Migawari Reijou wo Sukutta no wa Reikoku Mujihi na Koori no Ouji no Ai deshita, Multi-Subs)' from 'Nyaa.si (Prowlarr)' rejected for the following reasons: [Permanent] Unknown Series
|
||||
2026-07-07 14:12:50.9|Debug|DownloadDecisionMaker|Processing release '[ASW] Kimi ga Shinu made Koi wo Shitai - 01 [1080p HEVC x265 10Bit][AAC]' from 'Nyaa.si (Prowlarr)'
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Parsing string '[ASW] Kimi ga Shinu made Koi wo Shitai - 01 [1080p HEVC x265 10Bit][AAC]'
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Episode Parsed. Kimi ga Shinu made Koi wo Shitai - 001
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Languages parsed: Unknown
|
||||
2026-07-07 14:12:50.9|Debug|QualityParser|Trying to parse quality for '[ASW] Kimi ga Shinu made Koi wo Shitai - 01 [1080p HEVC x265 10Bit][AAC]'
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Quality parsed: HDTV-1080p v1
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Release Group parsed: ASW
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Parsing string '[ASW] Kimi ga Shinu made Koi wo Shitai - 01 [1080p HEVC x265 10Bit][AAC]'
|
||||
2026-07-07 14:12:50.9|Debug|SceneMappingService|Found scene mapping for: Kimi ga Shinu made Koi wo Shitai. TVDB ID for mapping: 460899
|
||||
2026-07-07 14:12:50.9|Debug|ParsingService|No matching series Kimi ga Shinu made Koi wo Shitai
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Parsing string '[ASW] Kimi ga Shinu made Koi wo Shitai - 01 [1080p HEVC x265 10Bit][AAC]'
|
||||
2026-07-07 14:12:50.9|Debug|SceneMappingService|Found scene mapping for: Kimi ga Shinu made Koi wo Shitai. TVDB ID for mapping: 460899
|
||||
2026-07-07 14:12:50.9|Debug|DownloadDecisionMaker|Release '[ASW] Kimi ga Shinu made Koi wo Shitai - 01 [1080p HEVC x265 10Bit][AAC]' from 'Nyaa.si (Prowlarr)' rejected for the following reasons: [Permanent] Kimi ga Shinu made Koi wo Shitai matches an alias for series with TVDB ID: 460899
|
||||
2026-07-07 14:12:50.9|Debug|DownloadDecisionMaker|Processing release 'Red River S01E01 VOSTFR 1080p WEB x264 AAC -Tsundere-Raws (CR) (Sora wa Akai Kawa no Hotori)' from 'Knaben (Prowlarr)'
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Parsing string 'Red River S01E01 VOSTFR 1080p WEB x264 AAC -Tsundere-Raws (CR) (Sora wa Akai Kawa no Hotori)'
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Episode Parsed. Red River - S01E01
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Languages parsed: Unknown
|
||||
2026-07-07 14:12:50.9|Debug|QualityParser|Trying to parse quality for 'Red River S01E01 VOSTFR 1080p WEB x264 AAC -Tsundere-Raws (CR) (Sora wa Akai Kawa no Hotori)'
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Quality parsed: WEBDL-1080p v1
|
||||
2026-07-07 14:12:50.9|Debug|Parser|Release Group parsed: Tsundere-Raws
|
||||
2026-07-07 14:12:50.9|Debug|ParsingService|No matching series Red River
|
||||
2026-07-07 14:12:50.9|Debug|DownloadDecisionMaker|Release 'Red River S01E01 VOSTFR 1080p WEB x264 AAC -Tsundere-Raws (CR) (Sora wa Akai Kawa no Hotori)' from 'Knaben (Prowlarr)' rejected for the following reasons: [Permanen
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
resolveCustomLayout,
|
||||
isInsideDashboard,
|
||||
} from './contributions';
|
||||
import { getRuntimeLoadState, isRuntimeFrontend } from './runtime/loader';
|
||||
import { ExtensionErrorBoundary, ExtensionFailureCard } from './runtime/ExtensionBoundary';
|
||||
|
||||
function devWarn(msg) {
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -39,9 +41,28 @@ function devWarn(msg) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildRoute(contrib, key) {
|
||||
function buildRoute(contrib, key, frontends) {
|
||||
const Component = resolveComponent(contrib.plugin, contrib.component);
|
||||
if (!Component) {
|
||||
// Fail soft, loudly (Decision 5): if the slug is delivered as a runtime
|
||||
// bundle that failed to load, render a failure card on its routes rather
|
||||
// than dropping them silently. Unknown (non-runtime) components still
|
||||
// just skip + warn — a misconfigured builtin shouldn't card every route.
|
||||
if (isRuntimeFrontend(frontends, contrib.plugin)) {
|
||||
const state = getRuntimeLoadState(contrib.plugin);
|
||||
return (
|
||||
<Route
|
||||
key={key}
|
||||
path={contrib.path}
|
||||
element={(
|
||||
<ExtensionFailureCard
|
||||
slug={contrib.plugin}
|
||||
message={state?.error || `component "${contrib.component}" not found in bundle`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
devWarn(
|
||||
`Cannot resolve component "${contrib.component}" for plugin `
|
||||
+ `"${contrib.plugin}" (route ${contrib.path})`
|
||||
@@ -52,7 +73,11 @@ function buildRoute(contrib, key) {
|
||||
<Route
|
||||
key={key}
|
||||
path={contrib.path}
|
||||
element={<Component />}
|
||||
element={(
|
||||
<ExtensionErrorBoundary slug={contrib.plugin}>
|
||||
<Component />
|
||||
</ExtensionErrorBoundary>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -64,7 +89,7 @@ const BUILTIN_LAYOUT_COMPONENTS = {
|
||||
};
|
||||
|
||||
export default function useExtensionRoutes() {
|
||||
const { routes, layouts } = useContributions();
|
||||
const { routes, layouts, frontends } = useContributions();
|
||||
|
||||
const dashboardRoutes = [];
|
||||
const groupRoutes = {};
|
||||
@@ -77,7 +102,7 @@ export default function useExtensionRoutes() {
|
||||
// Tab-group nested route (#43): collected per core group id and
|
||||
// mounted by App.jsx inside that group's TabGroupLayout <Route>.
|
||||
if (contrib.group) {
|
||||
const node = buildRoute(contrib, key);
|
||||
const node = buildRoute(contrib, key, frontends);
|
||||
if (node) {
|
||||
if (!groupRoutes[contrib.group]) groupRoutes[contrib.group] = [];
|
||||
groupRoutes[contrib.group].push(node);
|
||||
@@ -86,7 +111,7 @@ export default function useExtensionRoutes() {
|
||||
}
|
||||
|
||||
if (isInsideDashboard(layoutId)) {
|
||||
const node = buildRoute(contrib, key);
|
||||
const node = buildRoute(contrib, key, frontends);
|
||||
if (node) dashboardRoutes.push(node);
|
||||
return;
|
||||
}
|
||||
@@ -103,7 +128,7 @@ export default function useExtensionRoutes() {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = buildRoute(contrib, key);
|
||||
const node = buildRoute(contrib, key, frontends);
|
||||
if (!node) return;
|
||||
|
||||
let group = groupsByLayout.get(layoutId);
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../services/api';
|
||||
import pluginsManifest from './plugins-manifest.json';
|
||||
import { loadRuntimeFrontends, getRuntimeModule } from './runtime/loader';
|
||||
|
||||
// Discover every plugin module at build time. Each plugin is expected to
|
||||
// expose its components from src/plugins/<slug>/index.{js,jsx}.
|
||||
@@ -53,7 +54,8 @@ const localManifestBySlug = (() => {
|
||||
})();
|
||||
|
||||
export function getPluginModule(slug) {
|
||||
return moduleBySlug[slug] || null;
|
||||
// Build-time (baked) module first, then a runtime-loaded bundle (Decision 4).
|
||||
return moduleBySlug[slug] || getRuntimeModule(slug) || null;
|
||||
}
|
||||
|
||||
// Resolve a contribution's `component` string to an actual React component.
|
||||
@@ -62,7 +64,9 @@ export function getPluginModule(slug) {
|
||||
// - "Foo" → mod.Foo (named export)
|
||||
// Returns null if no match; caller decides whether to skip + warn.
|
||||
export function resolveComponent(slug, name) {
|
||||
const mod = moduleBySlug[slug];
|
||||
// Baked module (build-time glob) first; fall back to a runtime-loaded
|
||||
// bundle's namespace (Decision 4). Same name-matching for both.
|
||||
const mod = moduleBySlug[slug] || getRuntimeModule(slug);
|
||||
if (!mod) return null;
|
||||
if (!name || name === 'default') return mod.default || null;
|
||||
return mod[name] || null;
|
||||
@@ -84,6 +88,10 @@ const EMPTY = {
|
||||
// tool-result renderers. Consumed by the core AIAssistant via
|
||||
// useContributions().ai.
|
||||
ai: { suggested_prompts: [], tool_renderers: [] },
|
||||
// Runtime-frontend descriptor map { slug: { entry, hashes } } (plan 25).
|
||||
// Present only for installed extensions shipping a prebuilt ESM bundle;
|
||||
// empty for baked builtins and when the kill switch is off.
|
||||
frontends: {},
|
||||
};
|
||||
|
||||
// Merge a raw contribution envelope onto the empty shape so every
|
||||
@@ -188,8 +196,15 @@ function notify(value) {
|
||||
|
||||
export function refreshContributions() {
|
||||
cachedPromise = api.getPluginContributions()
|
||||
.then((data) => {
|
||||
.then(async (data) => {
|
||||
const merged = normalizeContributions({ ...EMPTY, ...(data || {}) });
|
||||
// Load any runtime ESM bundles BEFORE notifying, so contributed
|
||||
// routes render with their components already available (fail-soft:
|
||||
// a bundle that can't load records an error state, never throws).
|
||||
// Pass the panel's SDK version so the loader can refuse a bundle
|
||||
// built against an incompatible SDK before fetching it (plan 32 #1).
|
||||
await loadRuntimeFrontends(
|
||||
merged.frontends, merged.panel_sdk_version || merged.sdk_version);
|
||||
notify(merged);
|
||||
return merged;
|
||||
})
|
||||
|
||||
@@ -28,8 +28,43 @@
|
||||
* is skipped (and logged in dev).
|
||||
*/
|
||||
|
||||
// The SDK compatibility contract. Bump this (semver) whenever the exported
|
||||
// surface below changes in a way extensions can observe:
|
||||
// MAJOR — a removed/renamed export or a breaking signature change.
|
||||
// MINOR — a new export added (older extensions keep working).
|
||||
// PATCH — a non-surface fix.
|
||||
// Extensions pin a compatible range in plugin.json (`sdk_version`, e.g. "^1.0.0"),
|
||||
// checked at install (manifest lint) and reported at runtime via
|
||||
// GET /api/v1/plugins/contributions. The backend mirror lives in
|
||||
// backend/app/utils/sdk.py — keep the two in lock-step (asserted by
|
||||
// backend/tests/test_sdk_contract.py).
|
||||
export const SDK_VERSION = '1.0.0';
|
||||
|
||||
export { api, default as defaultApi } from '../../services/api';
|
||||
|
||||
// Design-system primitives — the sanctioned building blocks for plugin pages.
|
||||
// A plugin page should look like a core page: PageTopbar + KpiBand +
|
||||
// DataTable/ResourceList. Extensions previously deep-imported '@/components/ds'
|
||||
// (still works, same modules); this is the blessed surface (Plan 20 Decision 4).
|
||||
export {
|
||||
KpiBand,
|
||||
MetricCard,
|
||||
Gauge,
|
||||
ScoreGauge,
|
||||
Sparkline,
|
||||
Pill,
|
||||
PageTopbar,
|
||||
DataTable,
|
||||
ResourceCard,
|
||||
ResourceList,
|
||||
Drawer,
|
||||
} from '../../components/ds';
|
||||
|
||||
// Scheduling — extensions schedule things (cert windows, report runs, cleanup
|
||||
// jobs) with the same friendly cron picker as core (Presets/Builder/Advanced +
|
||||
// server-side preview). Props: value, onChange(cronString), presets?, compact?.
|
||||
export { default as SchedulePicker } from '../../components/SchedulePicker';
|
||||
|
||||
// Common UI primitives plugins are likely to want. Re-exports kept thin
|
||||
// on purpose — plugins can still reach for niche components directly,
|
||||
// but the everyday surface lives here.
|
||||
|
||||
@@ -46,6 +46,12 @@ export async function getApp(id) {
|
||||
return this.request(`/apps/${id}`);
|
||||
}
|
||||
|
||||
// Member-visible summary of an app's related resources (domains, managed DBs,
|
||||
// backup status, deployments). Read-only; any workspace member can load it.
|
||||
export async function getAppRelatedResources(id) {
|
||||
return this.request(`/apps/${id}/related`);
|
||||
}
|
||||
|
||||
export async function createApp(appData) {
|
||||
return this.request('/apps', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -21,6 +21,16 @@ export async function analyzeImport(importId) {
|
||||
return this.request(`/imports/${importId}/analyze`, { method: 'POST' });
|
||||
}
|
||||
|
||||
// SSH-source preflight (plan 31 #8): probe a reachable box before pulling it.
|
||||
// Returns {reachable, docroot_exists, entry_count, detected_stack, sample:[...]}
|
||||
// or 501 {error, code:'LINUX_ONLY'} off a Linux panel host (e.g. Windows dev).
|
||||
export async function probeSshImportSource(source) {
|
||||
return this.request('/imports/ssh/probe', {
|
||||
method: 'POST',
|
||||
body: { source },
|
||||
});
|
||||
}
|
||||
|
||||
export async function runImport(importId, fromStep = null, options = null) {
|
||||
const body = {};
|
||||
if (fromStep) body.from_step = fromStep;
|
||||
|
||||
@@ -40,6 +40,8 @@ import * as doctorMethods from './doctor.js';
|
||||
import * as bandwidthMethods from './bandwidth.js';
|
||||
import * as htaccessToolMethods from './htaccessTools.js';
|
||||
import * as manifestMethods from './manifests.js';
|
||||
import * as surveyMethods from './survey.js';
|
||||
import * as dnsCutoverMethods from './dnsCutover.js';
|
||||
|
||||
class ApiService extends ApiClient {
|
||||
constructor() {
|
||||
@@ -87,6 +89,8 @@ class ApiService extends ApiClient {
|
||||
bandwidthMethods,
|
||||
htaccessToolMethods,
|
||||
manifestMethods,
|
||||
surveyMethods,
|
||||
dnsCutoverMethods,
|
||||
];
|
||||
for (const mod of modules) {
|
||||
for (const [key, fn] of Object.entries(mod)) {
|
||||
|
||||
@@ -62,6 +62,29 @@ export async function getPluginContributions() {
|
||||
return this.request('/plugins/contributions');
|
||||
}
|
||||
|
||||
// Fetch a plugin frontend asset (e.g. a runtime ESM bundle) as raw bytes, with
|
||||
// auth. Used by the runtime loader (fetch → sha256 verify → blob-import). Unlike
|
||||
// request(), this returns the body verbatim (ArrayBuffer) instead of JSON-parsing
|
||||
// it, so the digest matches the backend's byte-for-byte sha256.
|
||||
export async function getPluginAssetBytes(slug, assetPath) {
|
||||
const clean = String(assetPath)
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map(encodeURIComponent)
|
||||
.join('/');
|
||||
const url = `${this.baseUrl}/plugins/${encodeURIComponent(slug)}/assets/${clean}`;
|
||||
const token = this.getToken();
|
||||
const resp = await fetch(url, {
|
||||
headers: { ...(token && { Authorization: `Bearer ${token}` }) },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = new Error(`Failed to fetch ${slug} asset ${assetPath} (${resp.status})`);
|
||||
err.status = resp.status;
|
||||
throw err;
|
||||
}
|
||||
return resp.arrayBuffer();
|
||||
}
|
||||
|
||||
// Returns extensions bundled with the repo at builtin-extensions/.
|
||||
// Each entry: { folder, path, slug, manifest, installed, install_id, status }.
|
||||
export async function getBuiltinExtensions() {
|
||||
|
||||
@@ -219,6 +219,16 @@ export async function addRemoteCronJob(serverId, body) {
|
||||
});
|
||||
}
|
||||
|
||||
// Edit an existing remote cron entry. Agent ids are content-derived, so a
|
||||
// schedule/command change returns a fresh Entry (new id) the caller adopts.
|
||||
// Gated on the agent's cron.update capability (agent >= 1.2.0).
|
||||
export async function updateRemoteCronJob(serverId, jobId, body) {
|
||||
return this.request(`/servers/${serverId}/cron/jobs/${encodeURIComponent(jobId)}`, {
|
||||
method: 'PUT',
|
||||
body
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeRemoteCronJob(serverId, jobId) {
|
||||
return this.request(`/servers/${serverId}/cron/jobs/${encodeURIComponent(jobId)}`, {
|
||||
method: 'DELETE'
|
||||
|
||||
@@ -661,6 +661,27 @@ export async function getCronPresets() {
|
||||
return this.request('/cron/presets');
|
||||
}
|
||||
|
||||
export async function previewCronSchedule(schedule) {
|
||||
return this.request('/cron/preview', {
|
||||
method: 'POST',
|
||||
body: { schedule }
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCronJobsForApp(appId) {
|
||||
return this.request(`/cron/jobs/for-app/${appId}`);
|
||||
}
|
||||
|
||||
export async function setCronTracking(jobId, { enabled, alertOnFailure } = {}) {
|
||||
const body = { enabled };
|
||||
if (alertOnFailure !== undefined) body.alert_on_failure = alertOnFailure;
|
||||
return this.request(`/cron/jobs/${jobId}/track`, { method: 'POST', body });
|
||||
}
|
||||
|
||||
export async function getCronJobRuns(jobId) {
|
||||
return this.request(`/cron/jobs/${jobId}/runs`);
|
||||
}
|
||||
|
||||
// Uptime Tracking endpoints
|
||||
export async function getCurrentUptime() {
|
||||
return this.request('/uptime/current');
|
||||
|
||||
@@ -189,36 +189,6 @@
|
||||
gap: $space-1;
|
||||
}
|
||||
|
||||
.api-settings__stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: $space-3;
|
||||
padding: $space-4;
|
||||
}
|
||||
|
||||
.api-settings__stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-1;
|
||||
padding: $space-3;
|
||||
background: $bg-elevated;
|
||||
border-radius: $radius-md;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.api-settings__stat-value {
|
||||
font-size: $font-size-xl;
|
||||
font-weight: 700;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.api-settings__stat-label {
|
||||
font-size: $font-size-xs;
|
||||
color: $text-tertiary;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.api-settings__chart {
|
||||
padding: $space-4;
|
||||
|
||||
@@ -512,10 +482,6 @@
|
||||
// ─── Responsive ────────────────────────────────────────
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
.api-settings__stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.api-settings__rate-limits {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -177,34 +177,9 @@
|
||||
.stat-strip__value { font-size: $font-size-md; }
|
||||
}
|
||||
|
||||
// Legacy stats grid layout (retained for widgets that still use raw .stat-* inner
|
||||
// classes; the StatsGrid component now renders .stat-strip instead).
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: $space-4;
|
||||
margin-bottom: $space-6;
|
||||
|
||||
@media (max-width: $breakpoint-xl) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
// Stat cards (dashboard)
|
||||
.stat-card {
|
||||
background: $bg-card;
|
||||
padding: $space-5;
|
||||
border-radius: $radius-xl;
|
||||
border: 1px solid $border-subtle;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-4;
|
||||
}
|
||||
|
||||
// Legacy `.stats-grid` / `.stat-card` KPI tile system removed (Plan 20 #20) —
|
||||
// pages use the shared KpiBand primitive (`.sk-kpiband`). `.stat-icon` below is
|
||||
// kept: it is a standalone icon chip still used by StyleGuide demos.
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
@@ -257,57 +232,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-tertiary;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-primary;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-suffix {
|
||||
font-size: $font-size-base;
|
||||
color: $text-tertiary;
|
||||
font-weight: $font-weight-normal;
|
||||
}
|
||||
|
||||
.stat-detail {
|
||||
font-size: $font-size-xs;
|
||||
color: $text-tertiary;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
// Clickable stat-card variant: behaves as a filter button
|
||||
.stat-card--clickable {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
transition: transform $transition-base, border-color $transition-base, box-shadow $transition-base;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
transform: translateY(-1px);
|
||||
border-color: $border-active;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: $accent-primary;
|
||||
}
|
||||
}
|
||||
// Legacy `.stat-content` / `.stat-label` / `.stat-value` / `.stat-suffix` /
|
||||
// `.stat-detail` / `.stat-card--clickable` removed (Plan 20 #20) — superseded by
|
||||
// the MetricCard tile (`.sk-kpi`) inside KpiBand.
|
||||
|
||||
// Progress bar
|
||||
.progress-bar {
|
||||
|
||||
@@ -31,6 +31,30 @@
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
|
||||
// Stacked primary action + a subtle secondary cross-link (e.g. "Import them")
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
|
||||
&__link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-tertiary;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
|
||||
&:hover {
|
||||
color: $primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
// Large variant — for full-page "not installed" / "unavailable" states
|
||||
&--lg {
|
||||
padding: $space-16 $space-8;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Runtime-extension failure card (plan 25 Phase 2 #7). Rendered on an
|
||||
// extension's routes when its bundle fails to load or its component crashes.
|
||||
// Deliberately dependency-free styling — it must render even when the extension
|
||||
// (or the design system it leans on) is the thing that broke.
|
||||
|
||||
.sk-ext-failcard {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
align-items: flex-start;
|
||||
max-width: 640px;
|
||||
margin: $spacing-xl auto;
|
||||
padding: $spacing-lg;
|
||||
background: $bg-card;
|
||||
border: 1px solid $border-color;
|
||||
border-left: 3px solid $color-danger;
|
||||
border-radius: $radius-md;
|
||||
|
||||
&__icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
&__body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0 0 $spacing-xs;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin: 0 0 $spacing-sm;
|
||||
color: $text-secondary;
|
||||
|
||||
code {
|
||||
padding: 0 4px;
|
||||
background: $bg-hover;
|
||||
border-radius: $radius-sm;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
&__reason {
|
||||
color: $text-primary;
|
||||
font-family: $font-mono;
|
||||
font-size: 0.85em;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
margin: 0;
|
||||
color: $text-muted;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// ------------------------------------------------------------
|
||||
// SchedulePicker — one friendly cron schedule picker (Presets / Builder /
|
||||
// Advanced) with a shared server-computed preview footer. Used by CronJobs,
|
||||
// Backups ScheduleCard, and the server-detail Cron tab.
|
||||
// ------------------------------------------------------------
|
||||
.schedule-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
|
||||
&--compact {
|
||||
gap: 10px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
// --- Presets: chip grid ---
|
||||
&__presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__preset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
text-align: left;
|
||||
padding: 9px 11px;
|
||||
border-radius: var(--radius, 8px);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: 0.14s;
|
||||
|
||||
&:hover { border-color: var(--border-strong); color: var(--text); }
|
||||
|
||||
&.is-active {
|
||||
border-color: var(--accent-bright);
|
||||
background: var(--accent-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
|
||||
&__preset-label { font-weight: 600; font-size: 12.5px; }
|
||||
&__preset-desc { font-size: 11px; color: var(--text-faint); }
|
||||
|
||||
// --- Builder ---
|
||||
&__builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
> label {
|
||||
min-width: 74px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
select {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
color: var(--text);
|
||||
padding: 6px 10px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
input { max-width: 160px; }
|
||||
}
|
||||
|
||||
&__days {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__day {
|
||||
min-width: 40px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: 0.14s;
|
||||
|
||||
&:hover { color: var(--text-dim); }
|
||||
&.is-active {
|
||||
border-color: var(--accent-bright);
|
||||
background: var(--accent-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Advanced ---
|
||||
&__advanced {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__cron-input {
|
||||
font-family: var(--mono);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
&__fieldhints {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__fieldhint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
min-width: 48px;
|
||||
|
||||
code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 2px 7px;
|
||||
min-width: 34px;
|
||||
text-align: center;
|
||||
}
|
||||
em { font-style: normal; font-size: 10px; color: var(--text-faint); }
|
||||
}
|
||||
|
||||
// --- Shared preview footer ---
|
||||
&__footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 11px 12px;
|
||||
border-radius: var(--radius, 8px);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
&__cronline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
|
||||
&__human {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
&--ok { color: var(--green); }
|
||||
&--error { color: var(--red); }
|
||||
}
|
||||
|
||||
&__nextruns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
&__nextruns-label { color: var(--text-faint); }
|
||||
|
||||
&__nextrun {
|
||||
font-family: var(--mono);
|
||||
color: var(--text-dim);
|
||||
background: var(--surface-2);
|
||||
border-radius: 5px;
|
||||
padding: 1px 7px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// ============================================
|
||||
// SETUP HEALTH — dashboard widget
|
||||
// Compact "how set-up is this panel" card: score + top open items, each
|
||||
// deep-linking to its fix. Collapses to a slim "all set" line when clean.
|
||||
// ============================================
|
||||
|
||||
.setup-health-widget {
|
||||
grid-column: span 6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
padding: $space-4;
|
||||
border: 1px solid var(--border-soft, #{$border-default});
|
||||
border-radius: var(--radius, #{$radius-lg});
|
||||
background: var(--surface, #{$bg-card});
|
||||
min-width: 0;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
&__score {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: $text-primary;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
&__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-1;
|
||||
}
|
||||
|
||||
&__item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
padding: $space-2 $space-2;
|
||||
border: 0;
|
||||
border-radius: $radius-md;
|
||||
background: transparent;
|
||||
color: $text-primary;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover, #{$bg-hover});
|
||||
}
|
||||
}
|
||||
|
||||
&__itemtitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12.5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__more {
|
||||
align-self: flex-start;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $primary-color;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:hover { text-decoration: underline; }
|
||||
}
|
||||
|
||||
&__muted {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
// Slim "all set" state — a single clickable line, not a dead card.
|
||||
&--clean {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
cursor: pointer;
|
||||
color: $success;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--border-default, #{$border-default});
|
||||
}
|
||||
}
|
||||
|
||||
&__cleanlabel {
|
||||
flex: 1;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
.setup-health-widget { grid-column: span 12; }
|
||||
}
|
||||
|
||||
// Per-user "secure your account" nudge banner (dashboard top).
|
||||
.account-security-nudge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-3;
|
||||
padding: $space-3 $space-4;
|
||||
margin-bottom: $space-4;
|
||||
border: 1px solid var(--warning-border, rgba(245, 179, 66, 0.35));
|
||||
border-radius: var(--radius, #{$radius-lg});
|
||||
background: var(--warning-bg, rgba(245, 179, 66, 0.08));
|
||||
|
||||
&__icon {
|
||||
color: $warning;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
&__detail {
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
&__close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $text-tertiary;
|
||||
cursor: pointer;
|
||||
padding: $space-1;
|
||||
display: inline-flex;
|
||||
|
||||
&:hover { color: $text-primary; }
|
||||
}
|
||||
}
|
||||
@@ -375,126 +375,36 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-4;
|
||||
padding: $space-4;
|
||||
background: $bg-card;
|
||||
border: 1px solid $border-subtle;
|
||||
border-radius: $radius-md;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
color: $text-tertiary;
|
||||
font-size: $font-size-sm;
|
||||
text-align: center;
|
||||
padding: $space-8;
|
||||
}
|
||||
|
||||
&__detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: $space-4;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__desc {
|
||||
color: $text-tertiary;
|
||||
font-size: $font-size-sm;
|
||||
margin: $space-1 0 0;
|
||||
}
|
||||
|
||||
&__subhead {
|
||||
margin: 0;
|
||||
color: $text-secondary;
|
||||
font-size: $font-size-sm;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
&__add-var,
|
||||
&__attach {
|
||||
display: flex;
|
||||
gap: $space-2;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__var-key {
|
||||
width: 160px;
|
||||
font-family: $font-mono;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__var-value {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
&__var-target {
|
||||
width: 150px;
|
||||
flex-shrink: 0;
|
||||
font-family: $font-mono;
|
||||
}
|
||||
|
||||
&__secret {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $space-1;
|
||||
color: $text-tertiary;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
&__attach-type,
|
||||
&__attach-id {
|
||||
padding: $space-2 $space-3;
|
||||
background: $bg-body;
|
||||
border: 1px solid $border-subtle;
|
||||
border-radius: $radius-sm;
|
||||
color: $text-primary;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
&__attach-id {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
&__attachments {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
&__attachment {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $space-2 $space-3;
|
||||
background: $bg-hover;
|
||||
border: 1px solid $border-subtle;
|
||||
border-radius: $radius-sm;
|
||||
|
||||
&-label {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Embedded section (resource detail pages) ----------
|
||||
// Spacing for the reusable panels when dropped into a settings/overview
|
||||
// form-section (e.g. ServerDetail). The panels carry their own internal
|
||||
// layout; this just gives the section a sensible top gap.
|
||||
|
||||
.shared-resources-section {
|
||||
.shared-tags {
|
||||
margin-top: $space-2;
|
||||
}
|
||||
}
|
||||
ty for '[Anime Time] Attack on Titan Season 4 - Finale 2'
|
||||
2026-07-07 12:00:05.1|Debug|Parser|Quality parsed: Unknown v1
|
||||
2026-07-07 12:00:05.1|Debug|Parser|Release Group parsed: Anime Time
|
||||
2026-07-07 12:00:05.1|Debug|Parser|Parsing string '[Anime Time] Attack on Titan Season 4 - Finale 2'
|
||||
2026-07-07 12:00:05.1|Debug|Parser|Episode Parsed. Attack on Titan - Season 04
|
||||
2026-07-07 12:00:05.1|Debug|Parser|Languages parsed: Unknown
|
||||
2026-07-07 12:00:05.1|Debug|QualityParser|Trying to parse quality for '[Anime Time] Attack on Titan Season 4 - Finale 2'
|
||||
2026-07-07 12:00:05.1|Debug|Parser|Quality parsed: Unknown v1
|
||||
2026-07-07 12:00:05.1|Debug|Parser|Release Group parsed: Anime Time
|
||||
2026-07-07 12:00:05.1|Debug|Parser|Parsing string '[Anime Time] Attack on Titan Season 4 - Finale 2'
|
||||
2026-07-07 12:00:05.1|Debug|Parser|Episode Parsed. Attack on Titan - Season 04
|
||||
2026-07-07 12:00:05.1|Debug|Parser|Languages parsed: Unknown
|
||||
2026-07-07 12:00:05.1|Debug|QualityParser|Trying to parse quality for '[Anime Time] Attack on Titan Season 4 - Finale 2'
|
||||
2026-07-07 12:00:05.1|Debug|Parser|Quality parsed: Unknown v1
|
||||
2026-07-07 12:00:05.1|Debug|Parser|Release Group parsed: Anime Time
|
||||
2026-07-07 12:00:05.1|Debug|Parser|Parsing string '[Anime Time] Attack on Titan Season 4 - Finale 2'
|
||||
2026-07-07 12:00:05.1|Debug|Parser|Episode Parsed. Attack on Titan - Season 04
|
||||
2026-07-07 12:00:05.2|Debug|Parser|Languages parsed: Unknown
|
||||
2026-07-07 12:00:05.2|Debug|QualityParser|Trying to parse quality for '[Anime Time] Attack on Titan Season 4 - Finale 2'
|
||||
2026-07-07 12:00:05.2|Debug|Parser|Quality parsed: Unknown v1
|
||||
2026-07-07 12:00:05.2|Debug|Parser|Release Group parsed: Anime Time
|
||||
2026-07-07 12:00:05.2|Debug|Parser|Parsing string '[Anime Time] Attack on Titan Season 4 - Finale 2'
|
||||
2026-07-07 12:00:05.2|Debug|Parser|Episode Parsed. Attack on Titan - Season 04
|
||||
2026-07-07 12:00:05.2|Debug|Parser|Languages parsed: Unknown
|
||||
2026-07-07 12:00:05.2|Debug|QualityParser|Trying to parse quality for '[Anime Time] Attack on Titan Season 4 - Finale 2'
|
||||
2026-07-07 12:00:05.2|Debug|Parser|Quality parsed: Unknown v1
|
||||
2026-07-07 12:00:05.2|Debug|Parser|Release Group parsed: Anime Time
|
||||
2026-07-07 12:00:05.2|Debug|Parser|Parsing string '[Anime Time] Attack on Titan Season 4 - Finale 2'
|
||||
2026-07-07 12:00:05.2|Debug|Parser|Episode Parsed. Attack on Titan - Season 04
|
||||
2026-07-07 12:00:05.2|Debug|Parser|Languages parsed: Unknown
|
||||
2026-07-07 12:00:05.2|Debug|QualityParser|Trying to parse quality for '[Anime Time] Attack on Titan Season 4 - Finale 2'
|
||||
2026-07-07 12:00:05.2|Debug|Parser|Quality parsed: Unknown v1
|
||||
2026-07-07 12:00:05.2|Debug|Pars
|
||||
@@ -0,0 +1,35 @@
|
||||
// STAGING banner (plan 37 testbed)
|
||||
// Full-width amber strip pinned to the very top of the app shell. Only shown on
|
||||
// the staging instance (backend /system/health echoes `staging:true`). Uses the
|
||||
// theme-agnostic $warning* tokens so it reads clearly in light and dark themes,
|
||||
// with dark text on amber for legibility.
|
||||
.staging-banner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: $z-fixed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-sm;
|
||||
width: 100%;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
background-color: $warning;
|
||||
border-bottom: 1px solid $warning-border;
|
||||
color: #1a1300; // dark text on amber — legible in both themes
|
||||
font-size: $font-size-sm;
|
||||
line-height: $line-height-tight;
|
||||
text-align: center;
|
||||
|
||||
&__label {
|
||||
font-weight: $font-weight-bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 2px $spacing-sm;
|
||||
border-radius: $radius-sm;
|
||||
background-color: rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
}
|
||||
@@ -316,13 +316,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Stats Grid
|
||||
.uptime-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: $space-3;
|
||||
}
|
||||
|
||||
// Legacy `.uptime-stats-grid` (double-defined) removed (Plan 20 #21) — unused;
|
||||
// summary metric grids use the KpiBand primitive (`.sk-kpiband`).
|
||||
.uptime-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -434,10 +429,6 @@
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.uptime-stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.uptime-legend {
|
||||
flex-wrap: wrap;
|
||||
gap: $space-3;
|
||||
|
||||
@@ -2,21 +2,8 @@
|
||||
// GRID LAYOUTS
|
||||
// ============================================
|
||||
|
||||
// Stats strip (4-column grid)
|
||||
.stats-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: $space-6;
|
||||
margin-bottom: $space-8;
|
||||
|
||||
@media (max-width: $breakpoint-xl) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
// Legacy `.stats-strip` 4-column grid removed (Plan 20 #20) — unused; pages use
|
||||
// the KpiBand primitive (`.sk-kpiband`) for summary metric grids.
|
||||
|
||||
// Dashboard grid (auto-fit cards)
|
||||
.dashboard-grid {
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
@import 'components/_logs-drawer';
|
||||
@import 'components/_ai-assistant';
|
||||
@import 'components/_ui';
|
||||
@import 'components/_staging-banner';
|
||||
@import 'components/_design-system'; // redesign shared primitives (sk-* namespace)
|
||||
@import 'components/_system-notices';
|
||||
@import 'components/container-status';
|
||||
|
||||
@@ -2,17 +2,6 @@
|
||||
// FLEET PAGES (Agent Fleet + Fleet Monitor)
|
||||
// ============================================
|
||||
|
||||
// KPI strip (MetricCard grid) on the Agent Fleet dashboard
|
||||
.fleet-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: $space-3;
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
// Amber warning row (queued commands, forecast errors)
|
||||
.fleet-warnrow {
|
||||
display: flex;
|
||||
@@ -166,3 +155,59 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fleet doctor rollup card (plan 26) — checks-by-status per server on the
|
||||
// Agent Fleet dashboard, consuming the same rows as the Doctor panel.
|
||||
.fleet-doctor-rollup {
|
||||
margin-bottom: $space-4;
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
margin-bottom: $space-3;
|
||||
}
|
||||
|
||||
&__note {
|
||||
margin-left: auto;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
&__servers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
&__server {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-3;
|
||||
width: 100%;
|
||||
padding: $space-2 $space-3;
|
||||
background: $bg-elevated;
|
||||
border: 1px solid $border-subtle;
|
||||
border-radius: $radius-md;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.12s ease;
|
||||
|
||||
&:hover { border-color: $border-default; }
|
||||
|
||||
&-name { font-weight: 600; }
|
||||
|
||||
&-counts {
|
||||
margin-left: auto;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,38 +44,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Summary strip: counts by proxy type, fleet app total, and ingress
|
||||
// mismatches. Auto-fit so the row reflows as tiles are added.
|
||||
&__summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: $space-3;
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
&__summary-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-1;
|
||||
padding: $space-3;
|
||||
background: $bg-elevated;
|
||||
border: 1px solid $border-subtle;
|
||||
border-radius: $radius-md;
|
||||
|
||||
// Mismatch tile when the fleet has at least one ingress mismatch.
|
||||
&--warn {
|
||||
background: $warning-bg;
|
||||
border-color: $warning-border;
|
||||
|
||||
.fleet-proxy__summary-label,
|
||||
.fleet-proxy__summary-value {
|
||||
color: $warning;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Summary KPIs now render via the shared KpiBand / MetricCard primitives;
|
||||
// no bespoke tile styling lives here.
|
||||
|
||||
// "Apps" cell: count plus an optional amber mismatch pill.
|
||||
&__apps {
|
||||
@@ -89,34 +59,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__summary-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
&__summary-value {
|
||||
font-size: $font-size-xl;
|
||||
font-weight: 700;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
// Small color swatch standing in for a type icon in the summary labels.
|
||||
&__swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: $text-secondary;
|
||||
|
||||
&--cyan { background: var(--cyan, #06b6d4); }
|
||||
&--violet { background: var(--violet, #8b5cf6); }
|
||||
&--gray { background: $text-secondary; }
|
||||
&--green { background: $success; }
|
||||
}
|
||||
|
||||
&__table {
|
||||
background: $bg-card;
|
||||
border: 1px solid $border-subtle;
|
||||
|
||||
@@ -6,17 +6,6 @@
|
||||
|
||||
.ftp-server {
|
||||
|
||||
// ─── KPI strip (replaces the old StatStrip) ─────────
|
||||
.ftp-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: $space-4;
|
||||
margin: $space-5 0 $space-4;
|
||||
|
||||
@media (max-width: $breakpoint-lg) { grid-template-columns: repeat(2, 1fr); }
|
||||
@media (max-width: $breakpoint-sm) { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
// ─── Mono eyebrow section headers (Users / Connections / Logs) ───
|
||||
.section-header {
|
||||
display: flex;
|
||||
|
||||
@@ -82,16 +82,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── KPI strip ───────────────────────────────────────────
|
||||
.mon-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: $space-4;
|
||||
|
||||
@media (max-width: $breakpoint-lg) { grid-template-columns: repeat(2, 1fr); }
|
||||
@media (max-width: $breakpoint-sm) { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
// ── section heads + panels ──────────────────────────────
|
||||
.mon-section-head {
|
||||
display: flex;
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid $border-default;
|
||||
|
||||
&__stats {
|
||||
display: flex;
|
||||
gap: $space-8;
|
||||
.sk-kpiband-wrap {
|
||||
margin-bottom: $space-4;
|
||||
}
|
||||
|
||||
@@ -29,26 +27,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 0.8rem;
|
||||
color: $text-tertiary;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&--success .stat-item__value { color: $success; }
|
||||
&--danger .stat-item__value { color: $danger; }
|
||||
&--muted .stat-item__value { color: $text-tertiary; }
|
||||
}
|
||||
|
||||
.templates-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
|
||||
@@ -2,45 +2,6 @@
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.telemetry-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: $spacing-md;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.telemetry-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-md;
|
||||
border-radius: $radius-md;
|
||||
background: $bg-card;
|
||||
border: 1px solid $border-default;
|
||||
|
||||
&__count {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: $text-tertiary;
|
||||
margin-top: $spacing-xs;
|
||||
}
|
||||
|
||||
&--critical { border-top: 3px solid $danger; }
|
||||
&--error { border-top: 3px solid $danger-hover; }
|
||||
&--warning { border-top: 3px solid $warning; }
|
||||
&--info { border-top: 3px solid $info; }
|
||||
&--debug { border-top: 3px solid $text-tertiary; }
|
||||
&--total { border-top: 3px solid $accent-primary; }
|
||||
}
|
||||
|
||||
.telemetry-filters {
|
||||
background: $bg-card;
|
||||
border: 1px solid $border-default;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Surface a create response's structured `warnings: []` (publishing gaps + host
|
||||
// resolvability) as toasts naming the exact missing piece. Each warning is
|
||||
// { code, message, fix:{kind:'link', to} } — mirrors a Setup Health item, so the
|
||||
// same standing gap also shows on the dashboard card and the Doctor.
|
||||
export function showCreationWarnings(toast, warnings) {
|
||||
(warnings || []).forEach((w) => {
|
||||
if (w && w.message) toast.warning(w.message, { duration: 9000 });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Tiny semver-range satisfier — the JS mirror of backend/app/utils/sdk.py.
|
||||
*
|
||||
* Supports the small grammar extension authors actually write for `sdk_version`:
|
||||
* ''/'*'/'x' → matches anything
|
||||
* '1.2.3' → exact
|
||||
* '^1.2.3' → >=1.2.3 <2.0.0 (0.x pins the minor)
|
||||
* '~1.2.3' → >=1.2.3 <1.3.0
|
||||
* '>=1.2.0' '>1' '<=2' '<2.0.0' '=1.2.3'
|
||||
* a comma/space-separated AND of the above.
|
||||
*
|
||||
* Malformed comparators fail OPEN (return true) — a bad range must never wrongly
|
||||
* mark a working extension incompatible.
|
||||
*/
|
||||
|
||||
function toTuple(v) {
|
||||
const parts = String(v).split('-')[0].split('+')[0].split('.').map((chunk) => {
|
||||
const digits = (chunk.match(/\d+/) || ['0'])[0];
|
||||
return parseInt(digits, 10) || 0;
|
||||
});
|
||||
while (parts.length < 3) parts.push(0);
|
||||
return parts.slice(0, 3);
|
||||
}
|
||||
|
||||
function cmp(a, b) {
|
||||
const ta = toTuple(a);
|
||||
const tb = toTuple(b);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
if (ta[i] < tb[i]) return -1;
|
||||
if (ta[i] > tb[i]) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function satisfiesOne(current, comparator) {
|
||||
const c = comparator.trim();
|
||||
if (!c || c === '*' || c === 'x' || c === 'X') return true;
|
||||
|
||||
const m = c.match(/^(\^|~|>=|<=|>|<|=)?\s*v?(.+)$/);
|
||||
if (!m) return true;
|
||||
const op = m[1] || '=';
|
||||
const ver = m[2].trim();
|
||||
const lo = toTuple(ver);
|
||||
|
||||
if (op === '^') {
|
||||
let hi;
|
||||
if (lo[0] > 0) hi = [lo[0] + 1, 0, 0];
|
||||
else if (lo[1] > 0) hi = [0, lo[1] + 1, 0];
|
||||
else hi = [0, 0, lo[2] + 1];
|
||||
return cmp(current, ver) >= 0 && cmp(current, hi.join('.')) < 0;
|
||||
}
|
||||
if (op === '~') {
|
||||
const hi = [lo[0], lo[1] + 1, 0];
|
||||
return cmp(current, ver) >= 0 && cmp(current, hi.join('.')) < 0;
|
||||
}
|
||||
const d = cmp(current, ver);
|
||||
if (op === '>=') return d >= 0;
|
||||
if (op === '<=') return d <= 0;
|
||||
if (op === '>') return d > 0;
|
||||
if (op === '<') return d < 0;
|
||||
return d === 0;
|
||||
}
|
||||
|
||||
export function satisfiesRange(range, current) {
|
||||
if (!range || !String(range).trim()) return true;
|
||||
if (!current) return true;
|
||||
return String(range)
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.filter(Boolean)
|
||||
.every((token) => satisfiesOne(current, token));
|
||||
}
|
||||
+35
-1
@@ -3,6 +3,40 @@ import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
// Runtime-extension import map (plan 25 Decision 2). A runtime-loaded extension
|
||||
// bundle externalizes exactly these bare specifiers and resolves them at load
|
||||
// time to the panel's own singleton instances via the `/serverkit-vendor/*.mjs`
|
||||
// shims (fed by src/plugins/runtime/vendorShare.js). Injected into index.html at
|
||||
// BUILD time only: Vite's dev server does its own bare-specifier resolution, and
|
||||
// runtime loading is a production concern (dev uses the build-time glob path).
|
||||
// The map is inert for the host's own code (bundled to hashed relative chunks,
|
||||
// never bare specifiers) — it only affects the extension blobs.
|
||||
const VENDOR_IMPORTMAP = {
|
||||
imports: {
|
||||
'react': '/serverkit-vendor/react.mjs',
|
||||
'react-dom': '/serverkit-vendor/react-dom.mjs',
|
||||
'react-dom/client': '/serverkit-vendor/react-dom-client.mjs',
|
||||
'react/jsx-runtime': '/serverkit-vendor/react-jsx-runtime.mjs',
|
||||
'react-router-dom': '/serverkit-vendor/react-router-dom.mjs',
|
||||
'serverkit-sdk': '/serverkit-vendor/serverkit-sdk.mjs',
|
||||
},
|
||||
}
|
||||
|
||||
function serverkitImportmap() {
|
||||
return {
|
||||
name: 'serverkit-vendor-importmap',
|
||||
apply: 'build',
|
||||
transformIndexHtml() {
|
||||
return [{
|
||||
tag: 'script',
|
||||
attrs: { type: 'importmap' },
|
||||
children: JSON.stringify(VENDOR_IMPORTMAP),
|
||||
injectTo: 'head-prepend',
|
||||
}]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const frontendPort = Number(env.SERVERKIT_FRONTEND_PORT) || 41921
|
||||
@@ -17,7 +51,7 @@ export default defineConfig(({ mode }) => {
|
||||
}
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
plugins: [react(), serverkitImportmap()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
|
||||
@@ -58,7 +58,7 @@ server {
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
|
||||
|
||||
# Option 1: Proxy to a local app (Docker container, Node.js, etc.)
|
||||
location / {
|
||||
|
||||
@@ -102,7 +102,7 @@ server {
|
||||
# Content Security Policy
|
||||
# Note: 'unsafe-inline'/'unsafe-eval' are kept because many SPAs need them.
|
||||
# Once your build is verified to work without inline scripts, remove them.
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ServerKit frontend — served directly by host nginx from the built SPA
|
||||
|
||||
+80
-459
@@ -1,459 +1,80 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# ServerKit Agent Installation Script
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://your-serverkit.com/install.sh | sudo bash -s -- --token "TOKEN" --server "URL"
|
||||
#
|
||||
# Options:
|
||||
# --token, -t Registration token (required)
|
||||
# --server, -s ServerKit server URL (required)
|
||||
# --name, -n Display name for this server (optional)
|
||||
# --version, -v Specific agent version to install (optional, defaults to latest)
|
||||
# --help, -h Show this help message
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Palette (truecolor violet gradient)
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != "dumb" ]; then
|
||||
NC=$'\033[0m'; BOLD=$'\033[1m'
|
||||
fg() { printf '\033[38;2;%s;%s;%sm' "$1" "$2" "$3"; }
|
||||
else
|
||||
NC=''; BOLD=''
|
||||
fg() { :; }
|
||||
fi
|
||||
|
||||
V1="$(fg 196 181 253)"; V2="$(fg 167 139 250)"; V3="$(fg 139 92 246)"
|
||||
V4="$(fg 124 58 237)"; V5="$(fg 109 40 217)"
|
||||
WHITE="$(fg 237 233 254)"; MUTED="$(fg 165 160 190)"; FAINT="$(fg 113 108 140)"
|
||||
GREEN="$(fg 52 211 153)"; RED="$(fg 248 113 113)"; YELLOW="$(fg 250 204 21)"
|
||||
CYAN="$(fg 103 232 249)"
|
||||
|
||||
# Configuration
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
CONFIG_DIR="/etc/serverkit-agent"
|
||||
LOG_DIR="/var/log/serverkit-agent"
|
||||
SERVICE_USER="serverkit-agent"
|
||||
GITHUB_REPO="jhd3197/ServerKit"
|
||||
AGENT_BINARY="serverkit-agent"
|
||||
|
||||
# Arguments
|
||||
TOKEN=""
|
||||
SERVER_URL=""
|
||||
SERVER_NAME=""
|
||||
VERSION="latest"
|
||||
|
||||
# Version injected by the panel when it serves this script (the panel already
|
||||
# resolves the latest agent release for its update endpoints). Empty means
|
||||
# discover the version via the GitHub API in get_latest_version().
|
||||
SERVERKIT_AGENT_VERSION=""
|
||||
|
||||
print_banner() {
|
||||
echo
|
||||
printf " ${V1}${BOLD}╔═╗┌─┐┌─┐┌┬┐┌─┐┬┌─┐${NC}\n"
|
||||
printf " ${V2}${BOLD}╚═╗│ ││ │ ├┤ │├┤ ${NC} ${WHITE}${BOLD}ServerKit Agent${NC}\n"
|
||||
printf " ${V3}${BOLD}╚═╝└─┘└─┘ ┴ └─┘┴└──┘${NC} ${FAINT}v%s${NC}\n" "$VERSION"
|
||||
echo
|
||||
}
|
||||
|
||||
log_info() {
|
||||
echo -e " ${CYAN}›${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e " ${GREEN}●${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e " ${YELLOW}▲${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e " ${RED}■${NC} $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
show_help() {
|
||||
echo "ServerKit Agent Installation Script"
|
||||
echo ""
|
||||
echo "Usage: install.sh [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --token, -t Registration token (required)"
|
||||
echo " --server, -s ServerKit server URL (required)"
|
||||
echo " --name, -n Display name for this server (optional)"
|
||||
echo " --version, -v Specific agent version (optional, defaults to latest)"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
echo "Example:"
|
||||
echo " curl -fsSL https://your-serverkit.com/install.sh | sudo bash -s -- \\"
|
||||
echo " --token 'sk_reg_xxx' \\"
|
||||
echo " --server 'https://your-serverkit.com'"
|
||||
exit 0
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--token|-t)
|
||||
[[ $# -ge 2 ]] || log_error "Option $1 requires a value"
|
||||
TOKEN="$2"
|
||||
shift 2
|
||||
;;
|
||||
--server|-s)
|
||||
[[ $# -ge 2 ]] || log_error "Option $1 requires a value"
|
||||
SERVER_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--name|-n)
|
||||
[[ $# -ge 2 ]] || log_error "Option $1 requires a value"
|
||||
SERVER_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--version|-v)
|
||||
[[ $# -ge 2 ]] || log_error "Option $1 requires a value"
|
||||
VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate required arguments
|
||||
if [[ -z "$TOKEN" ]]; then
|
||||
log_error "Registration token is required. Use --token or -t"
|
||||
fi
|
||||
|
||||
if [[ -z "$SERVER_URL" ]]; then
|
||||
log_error "Server URL is required. Use --server or -s"
|
||||
fi
|
||||
}
|
||||
|
||||
check_root() {
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
log_error "This script must be run as root (use sudo)"
|
||||
fi
|
||||
}
|
||||
|
||||
detect_platform() {
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
ARCH=$(uname -m)
|
||||
|
||||
case "$ARCH" in
|
||||
x86_64|amd64)
|
||||
ARCH="amd64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
ARCH="arm64"
|
||||
;;
|
||||
*)
|
||||
log_error "Unsupported architecture: $ARCH"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$OS" != "linux" ]]; then
|
||||
log_error "This script only supports Linux. For Windows, use install.ps1"
|
||||
fi
|
||||
|
||||
log_info "Detected platform: ${OS}-${ARCH}"
|
||||
}
|
||||
|
||||
check_dependencies() {
|
||||
log_info "Checking dependencies..."
|
||||
|
||||
# Check for required tools
|
||||
for cmd in curl tar; do
|
||||
if ! command -v "$cmd" &> /dev/null; then
|
||||
log_error "$cmd is required but not installed"
|
||||
fi
|
||||
done
|
||||
|
||||
# Check for systemd
|
||||
if ! command -v systemctl &> /dev/null; then
|
||||
log_warn "systemd not found. You will need to start the agent manually."
|
||||
fi
|
||||
}
|
||||
|
||||
get_latest_version() {
|
||||
if [[ "$VERSION" != "latest" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# The panel injects the version it already resolved when serving this
|
||||
# script — use it and keep GitHub out of the enrollment path entirely.
|
||||
if [[ -n "$SERVERKIT_AGENT_VERSION" ]]; then
|
||||
VERSION="$SERVERKIT_AGENT_VERSION"
|
||||
log_info "Using panel-resolved version: v${VERSION}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Fetching latest version..."
|
||||
|
||||
# Agent tags share this repo with panel releases, which can push every
|
||||
# agent-v* tag off the first page — so page through instead of trusting
|
||||
# the default (30-entry) first page. Capped at 3 pages of 100.
|
||||
local page releases
|
||||
VERSION=""
|
||||
for page in 1 2 3; do
|
||||
releases=$(curl -fsSL "https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=100&page=${page}") || break
|
||||
VERSION=$(printf '%s\n' "$releases" | grep -o '"tag_name": *"agent-v[^"]*"' | head -1 | sed -e 's/.*agent-v//' -e 's/"$//') || true
|
||||
[[ -n "$VERSION" ]] && break
|
||||
# A page with no tags at all means the release list is exhausted.
|
||||
printf '%s' "$releases" | grep -q '"tag_name"' || break
|
||||
done
|
||||
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
log_error "Failed to fetch latest version"
|
||||
fi
|
||||
log_info "Latest version: v${VERSION}"
|
||||
}
|
||||
|
||||
# Verify the downloaded archive against the release's checksums.txt.
|
||||
# Best-effort when checksums.txt is unavailable (older releases); a present-
|
||||
# but-mismatching checksum is a hard failure.
|
||||
verify_checksum() {
|
||||
local tmp_dir="$1" asset_name="$2"
|
||||
local checksums_url="https://github.com/${GITHUB_REPO}/releases/download/agent-v${VERSION}/checksums.txt"
|
||||
|
||||
if ! command -v sha256sum &> /dev/null; then
|
||||
log_warn "sha256sum not available — skipping checksum verification"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! curl -fsSL "$checksums_url" -o "${tmp_dir}/checksums.txt"; then
|
||||
log_warn "Could not download checksums.txt — skipping verification"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! grep -q "$asset_name" "${tmp_dir}/checksums.txt"; then
|
||||
log_warn "checksums.txt has no entry for ${asset_name} — skipping verification"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! (cd "$tmp_dir" && sha256sum -c <(grep "$asset_name" checksums.txt) >/dev/null 2>&1); then
|
||||
rm -rf "$tmp_dir"
|
||||
log_error "Checksum verification FAILED for ${asset_name} — aborting install"
|
||||
fi
|
||||
|
||||
log_success "Checksum verified"
|
||||
}
|
||||
|
||||
download_agent() {
|
||||
log_info "Downloading ServerKit Agent v${VERSION}..."
|
||||
|
||||
ASSET_NAME="serverkit-agent-${VERSION}-linux-${ARCH}.tar.gz"
|
||||
DOWNLOAD_URL="https://github.com/${GITHUB_REPO}/releases/download/agent-v${VERSION}/${ASSET_NAME}"
|
||||
TMP_DIR=$(mktemp -d)
|
||||
ARCHIVE="${TMP_DIR}/${ASSET_NAME}"
|
||||
|
||||
if ! curl -fsSL "$DOWNLOAD_URL" -o "$ARCHIVE"; then
|
||||
rm -rf "$TMP_DIR"
|
||||
log_error "Failed to download agent from: $DOWNLOAD_URL"
|
||||
fi
|
||||
|
||||
verify_checksum "$TMP_DIR" "$ASSET_NAME"
|
||||
|
||||
# Extract binary
|
||||
log_info "Extracting agent..."
|
||||
tar -xzf "$ARCHIVE" -C "$TMP_DIR"
|
||||
|
||||
# Install binary
|
||||
mv "${TMP_DIR}/serverkit-agent-linux-${ARCH}" "${INSTALL_DIR}/${AGENT_BINARY}"
|
||||
chmod +x "${INSTALL_DIR}/${AGENT_BINARY}"
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$TMP_DIR"
|
||||
|
||||
log_success "Agent installed to ${INSTALL_DIR}/${AGENT_BINARY}"
|
||||
}
|
||||
|
||||
create_user() {
|
||||
if id "$SERVICE_USER" &>/dev/null; then
|
||||
log_info "User $SERVICE_USER already exists"
|
||||
else
|
||||
log_info "Creating service user: $SERVICE_USER"
|
||||
if command -v useradd &> /dev/null; then
|
||||
useradd -r -s /bin/false -d /nonexistent "$SERVICE_USER"
|
||||
elif command -v adduser &> /dev/null; then
|
||||
# Alpine/busybox ships adduser instead of useradd
|
||||
adduser -S -D -H -s /bin/false "$SERVICE_USER"
|
||||
else
|
||||
log_error "Cannot create user: neither useradd nor adduser found"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Add to docker group if it exists
|
||||
if getent group docker > /dev/null 2>&1; then
|
||||
if command -v usermod &> /dev/null; then
|
||||
usermod -aG docker "$SERVICE_USER"
|
||||
else
|
||||
# busybox equivalent of usermod -aG
|
||||
addgroup "$SERVICE_USER" docker
|
||||
fi
|
||||
log_info "Added $SERVICE_USER to docker group"
|
||||
fi
|
||||
}
|
||||
|
||||
create_config_dir() {
|
||||
log_info "Creating configuration and log directories..."
|
||||
mkdir -p "$CONFIG_DIR" "$LOG_DIR"
|
||||
chown "$SERVICE_USER:$SERVICE_USER" "$CONFIG_DIR" "$LOG_DIR"
|
||||
chmod 750 "$CONFIG_DIR" "$LOG_DIR"
|
||||
}
|
||||
|
||||
register_agent() {
|
||||
log_info "Registering agent with ServerKit..."
|
||||
|
||||
REGISTER_CMD=(
|
||||
"${INSTALL_DIR}/${AGENT_BINARY}"
|
||||
--config "${CONFIG_DIR}/config.yaml"
|
||||
register
|
||||
--token "${TOKEN}"
|
||||
--server "${SERVER_URL}"
|
||||
)
|
||||
|
||||
if [[ -n "$SERVER_NAME" ]]; then
|
||||
REGISTER_CMD+=(--name "${SERVER_NAME}")
|
||||
fi
|
||||
|
||||
if ! "${REGISTER_CMD[@]}"; then
|
||||
log_error "Agent registration failed"
|
||||
fi
|
||||
|
||||
# Fix permissions on config files
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$CONFIG_DIR" "$LOG_DIR"
|
||||
[[ -f "${CONFIG_DIR}/config.yaml" ]] && chmod 600 "${CONFIG_DIR}/config.yaml"
|
||||
[[ -f "${CONFIG_DIR}/agent.key" ]] && chmod 600 "${CONFIG_DIR}/agent.key"
|
||||
|
||||
log_success "Agent registered successfully"
|
||||
}
|
||||
|
||||
install_systemd_service() {
|
||||
if ! command -v systemctl &> /dev/null; then
|
||||
log_warn "Skipping systemd service installation (systemd not found)"
|
||||
return
|
||||
fi
|
||||
|
||||
log_info "Installing systemd service..."
|
||||
|
||||
cat > /etc/systemd/system/serverkit-agent.service << EOF
|
||||
[Unit]
|
||||
Description=ServerKit Agent
|
||||
Documentation=https://github.com/${GITHUB_REPO}
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${SERVICE_USER}
|
||||
Group=${SERVICE_USER}
|
||||
ExecStart=${INSTALL_DIR}/${AGENT_BINARY} --config ${CONFIG_DIR}/config.yaml start
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=serverkit-agent
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=${CONFIG_DIR} ${LOG_DIR}
|
||||
PrivateTmp=true
|
||||
ProtectKernelLogs=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectClock=yes
|
||||
ProtectHostname=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
MemoryMax=512M
|
||||
LimitNOFILE=65535
|
||||
|
||||
# Environment
|
||||
Environment=HOME=/nonexistent
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable serverkit-agent
|
||||
|
||||
log_success "Systemd service installed"
|
||||
}
|
||||
|
||||
start_service() {
|
||||
if ! command -v systemctl &> /dev/null; then
|
||||
log_warn "Cannot auto-start without systemd"
|
||||
echo ""
|
||||
echo "To start the agent manually:"
|
||||
echo " ${INSTALL_DIR}/${AGENT_BINARY} --config ${CONFIG_DIR}/config.yaml start"
|
||||
return
|
||||
fi
|
||||
|
||||
log_info "Starting ServerKit Agent..."
|
||||
systemctl start serverkit-agent
|
||||
|
||||
sleep 2
|
||||
|
||||
if systemctl is-active --quiet serverkit-agent; then
|
||||
log_success "Agent is running"
|
||||
else
|
||||
log_error "Failed to start agent. Check logs with: journalctl -u serverkit-agent"
|
||||
fi
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo ""
|
||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Installation completed successfully! ║${NC}"
|
||||
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo "Agent Status:"
|
||||
echo " Binary: ${INSTALL_DIR}/${AGENT_BINARY}"
|
||||
echo " Config: ${CONFIG_DIR}/config.yaml"
|
||||
echo " Service: serverkit-agent"
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " Check status: systemctl status serverkit-agent"
|
||||
echo " View logs: journalctl -u serverkit-agent -f"
|
||||
echo " Restart agent: systemctl restart serverkit-agent"
|
||||
echo " Stop agent: systemctl stop serverkit-agent"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Main execution
|
||||
main() {
|
||||
print_banner
|
||||
parse_args "$@"
|
||||
check_root
|
||||
detect_platform
|
||||
check_dependencies
|
||||
get_latest_version
|
||||
download_agent
|
||||
create_user
|
||||
create_config_dir
|
||||
register_agent
|
||||
install_systemd_service
|
||||
start_service
|
||||
print_success
|
||||
}
|
||||
|
||||
# Sourcing this file (e.g. from scripts/test/test_agent_install.sh) defines
|
||||
# every function above for unit testing without running an install. Piped
|
||||
# execution (curl | bash) has no BASH_SOURCE[0], so only a real `source`
|
||||
# takes the early return.
|
||||
if [ -n "${BASH_SOURCE[0]:-}" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
main "$@"
|
||||
3f2fcac205964e9c563ed15b81d2b6f07a6e9f01 986909a9559525d7e4c51e3fb04bc3bfc93cc53a Juan Denis <jhd3197@gmail.com> 1780803001 -0400 update by push
|
||||
986909a9559525d7e4c51e3fb04bc3bfc93cc53a 9e8513c121b47409c7862871098e007ff1ad545c Juan Denis <jhd3197@gmail.com> 1780804941 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
9e8513c121b47409c7862871098e007ff1ad545c 733f866f1c148319311604ffdad3e1c9383e76c2 Juan Denis <jhd3197@gmail.com> 1781577398 -0400 update by push
|
||||
733f866f1c148319311604ffdad3e1c9383e76c2 5885b425b5c61376920c9e40a617408de58556d8 Juan Denis <jhd3197@gmail.com> 1781579309 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
5885b425b5c61376920c9e40a617408de58556d8 49360806d2fb5e858f50335db0337b60b0e73350 Juan Denis <jhd3197@gmail.com> 1781580655 -0400 update by push
|
||||
49360806d2fb5e858f50335db0337b60b0e73350 267066dce03a1502dd74ccbfac86646972ad8bc7 Juan Denis <jhd3197@gmail.com> 1781581157 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
267066dce03a1502dd74ccbfac86646972ad8bc7 a9c22c27c159ebe4cc02d29d8faaa725f7420fb0 Juan Denis <jhd3197@gmail.com> 1781581167 -0400 update by push
|
||||
a9c22c27c159ebe4cc02d29d8faaa725f7420fb0 3cf19059f2942b1a2b55e009418ea7a65ffebabc Juan Denis <jhd3197@gmail.com> 1781581742 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
3cf19059f2942b1a2b55e009418ea7a65ffebabc bedbd06bff67539f27661a07e236dfb87ad11986 Juan Denis <jhd3197@gmail.com> 1781581760 -0400 update by push
|
||||
bedbd06bff67539f27661a07e236dfb87ad11986 ad7e38c203679d85f6f1f43151673e19285e2cab Juan Denis <jhd3197@gmail.com> 1781581886 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
ad7e38c203679d85f6f1f43151673e19285e2cab 248354135657ce3a498a2fbbccbe37957acafcbb Juan Denis <jhd3197@gmail.com> 1781582212 -0400 update by push
|
||||
248354135657ce3a498a2fbbccbe37957acafcbb a053df2b341abcf28a23a292e9187dfb95aa8741 Juan Denis <jhd3197@gmail.com> 1781584245 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
a053df2b341abcf28a23a292e9187dfb95aa8741 76dce677ca3755aff569060f5d76442ae023d0c3 Juan Denis <jhd3197@gmail.com> 1781584253 -0400 update by push
|
||||
76dce677ca3755aff569060f5d76442ae023d0c3 59f3df02da664cbb04077d26e44b4f7aaafc6331 Juan Denis <jhd3197@gmail.com> 1781586241 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
59f3df02da664cbb04077d26e44b4f7aaafc6331 4fdb51004b9c6af2b74774f2da68aa0e7dbf19d6 Juan Denis <jhd3197@gmail.com> 1781587610 -0400 update by push
|
||||
4fdb51004b9c6af2b74774f2da68aa0e7dbf19d6 d772896281cb6d2709d29027b2fe7a59d1b4eda8 Juan Denis <jhd3197@gmail.com> 1781589545 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
d772896281cb6d2709d29027b2fe7a59d1b4eda8 7a1303ae44618b7ff5f092f00c6cfa2ddfc63b08 Juan Denis <jhd3197@gmail.com> 1781835768 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
7a1303ae44618b7ff5f092f00c6cfa2ddfc63b08 866c8376981c41e2f006c7ec6ce28cc70b5b513d Juan Denis <jhd3197@gmail.com> 1781835921 -0400 update by push
|
||||
866c8376981c41e2f006c7ec6ce28cc70b5b513d 210f699de1fe0c9c812e4278cae76de5c8edff04 Juan Denis <jhd3197@gmail.com> 1781835924 -0400 update by push
|
||||
210f699de1fe0c9c812e4278cae76de5c8edff04 a82d3de2acbd628571c0a7fbf0b546674e0c89b8 Juan Denis <jhd3197@gmail.com> 1781836657 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
a82d3de2acbd628571c0a7fbf0b546674e0c89b8 428dc58f095381215b5fb408fff7116906f82105 Juan Denis <jhd3197@gmail.com> 1781836668 -0400 update by push
|
||||
428dc58f095381215b5fb408fff7116906f82105 4e579e6f94a50cc57b81ba5ee0081e539516c5a4 Juan Denis <jhd3197@gmail.com> 1781836747 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
4e579e6f94a50cc57b81ba5ee0081e539516c5a4 ba9ec0de8a6e193043021360cdc2db0845f3c118 Juan Denis <jhd3197@gmail.com> 1781837331 -0400 update by push
|
||||
ba9ec0de8a6e193043021360cdc2db0845f3c118 6857ff2e8e0160804f7097e57b1b2e86f4bb5cef Juan Denis <jhd3197@gmail.com> 1781837816 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
6857ff2e8e0160804f7097e57b1b2e86f4bb5cef bdb786ba52f013a5688e56a11b20fd7b864b36f8 Juan Denis <jhd3197@gmail.com> 1781837824 -0400 update by push
|
||||
bdb786ba52f013a5688e56a11b20fd7b864b36f8 400c43e087fc116a110a70abdd399b8ef0cc46ff Juan Denis <jhd3197@gmail.com> 1781839937 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
400c43e087fc116a110a70abdd399b8ef0cc46ff 6d8d8dfa31a4e071be77e66f344839e525ad1e77 Juan Denis <jhd3197@gmail.com> 1781904642 -0400 update by push
|
||||
6d8d8dfa31a4e071be77e66f344839e525ad1e77 60b2c3eea5c2b45463ba03239c7bc1b26339ec00 Juan Denis <jhd3197@gmail.com> 1781906565 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
60b2c3eea5c2b45463ba03239c7bc1b26339ec00 4dc71d57743a2a92f7b67a80fe41afd6be69999b Juan Denis <jhd3197@gmail.com> 1782023162 -0400 update by push
|
||||
4dc71d57743a2a92f7b67a80fe41afd6be69999b 38073a543cc618714b5d74eb5f498bd86caeb80d Juan Denis <jhd3197@gmail.com> 1782024663 -0400 fetch origin: fast-forward
|
||||
38073a543cc618714b5d74eb5f498bd86caeb80d bb30b2b4be662e04f66fad0cb0adad55e6b68122 Juan Denis <jhd3197@gmail.com> 1782061559 -0400 update by push
|
||||
bb30b2b4be662e04f66fad0cb0adad55e6b68122 2f75f3fadc4b5a2455cfa832e92d0fcf2cb78972 Juan Denis <jhd3197@gmail.com> 1782061623 -0400 fetch origin --quiet: fast-forward
|
||||
2f75f3fadc4b5a2455cfa832e92d0fcf2cb78972 6f99bef3a91ef4737c9d268f7f57bae1ae116f22 Juan Denis <jhd3197@gmail.com> 1782094274 -0400 update by push
|
||||
6f99bef3a91ef4737c9d268f7f57bae1ae116f22 c9d50788e39ca4a3a5f00423b8d4ad9a9cad4df6 Juan Denis <jhd3197@gmail.com> 1782096115 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
c9d50788e39ca4a3a5f00423b8d4ad9a9cad4df6 10ca0b3970aa7d5caf3a48904e533965d222bc02 Juan Denis <jhd3197@gmail.com> 1782106714 -0400 update by push
|
||||
10ca0b3970aa7d5caf3a48904e533965d222bc02 f31edb8c2f6d8ef9bc3f0d43537b3cb2ad22baf8 Juan Denis <jhd3197@gmail.com> 1782107105 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
f31edb8c2f6d8ef9bc3f0d43537b3cb2ad22baf8 0e29978e7bcaca3673426e835fb0a10225146ac3 Juan Denis <jhd3197@gmail.com> 1782107118 -0400 update by push
|
||||
0e29978e7bcaca3673426e835fb0a10225146ac3 aafbc39332cc1d95080797a3bd580b64d14e7ea2 Juan Denis <jhd3197@gmail.com> 1782108964 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
aafbc39332cc1d95080797a3bd580b64d14e7ea2 d3b41f4f2a3fbdca51dfdf704f4477d235e626ba Juan Denis <jhd3197@gmail.com> 1782130273 -0400 update by push
|
||||
d3b41f4f2a3fbdca51dfdf704f4477d235e626ba e192250ef11bd04b85299fb55bcd1126ea3bf9b1 Juan Denis <jhd3197@gmail.com> 1782147981 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
e192250ef11bd04b85299fb55bcd1126ea3bf9b1 edf6260deece0a47eab148ce134538934cf27e48 Juan Denis <jhd3197@gmail.com> 1782161591 -0400 update by push
|
||||
edf6260deece0a47eab148ce134538934cf27e48 f0e92b3f8e28fa082743017ec64f82ee16a16894 Juan Denis <jhd3197@gmail.com> 1782161613 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
f0e92b3f8e28fa082743017ec64f82ee16a16894 5836294c4d7e1ec3a4e92175e23e580fdbacea29 Juan Denis <jhd3197@gmail.com> 1782178688 -0400 update by push
|
||||
5836294c4d7e1ec3a4e92175e23e580fdbacea29 83a88b1649da80a0a6dfbfba060e7da073b9aa49 Juan Denis <jhd3197@gmail.com> 1782179327 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
83a88b1649da80a0a6dfbfba060e7da073b9aa49 84ee2ce789ceb8499e1eb5a81a49726a163b57bc Juan Denis <jhd3197@gmail.com> 1782179561 -0400 update by push
|
||||
84ee2ce789ceb8499e1eb5a81a49726a163b57bc 7cbc4700aa10ca81079e5e782a26ea029adf3b87 Juan Denis <jhd3197@gmail.com> 1782181647 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
7cbc4700aa10ca81079e5e782a26ea029adf3b87 dea4749679dd66f68407586825cd89169a262c9c Juan Denis <jhd3197@gmail.com> 1782219912 -0400 update by push
|
||||
dea4749679dd66f68407586825cd89169a262c9c 34dacbff50073fd5435583697bf02d016b8525f4 Juan Denis <jhd3197@gmail.com> 1782221771 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
34dacbff50073fd5435583697bf02d016b8525f4 807ca7b05e0641f4e4c09dfc83b686d5d4ee7d28 Juan Denis <jhd3197@gmail.com> 1782273800 -0400 update by push
|
||||
807ca7b05e0641f4e4c09dfc83b686d5d4ee7d28 51425ee6091c0f299f4d922741cf7b37754586f4 Juan Denis <jhd3197@gmail.com> 1782275841 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
51425ee6091c0f299f4d922741cf7b37754586f4 e1088aff71f8abc351a3960ab0aebc6d6ff6cf9c Juan Denis <jhd3197@gmail.com> 1782352025 -0400 update by push
|
||||
e1088aff71f8abc351a3960ab0aebc6d6ff6cf9c 347655599654565e5d77f182e16fba6370b44c9e Juan Denis <jhd3197@gmail.com> 1782354082 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
347655599654565e5d77f182e16fba6370b44c9e db16100a421b2b77fc6312594e20bbf0c4491394 Juan Denis <jhd3197@gmail.com> 1782393021 -0400 update by push
|
||||
db16100a421b2b77fc6312594e20bbf0c4491394 c6d75f148c7a953bfdda61fbcb0ac8026509f614 Juan Denis <jhd3197@gmail.com> 1782395839 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
c6d75f148c7a953bfdda61fbcb0ac8026509f614 31330a09be21d3d33e84e20f01822bc6d2620481 Juan Denis <jhd3197@gmail.com> 1782418842 -0400 update by push
|
||||
31330a09be21d3d33e84e20f01822bc6d2620481 74661fa7b20c23c321e4f714f6ecf8b884f3be62 Juan Denis <jhd3197@gmail.com> 1782420695 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
74661fa7b20c23c321e4f714f6ecf8b884f3be62 58e80b2e18e6da97855ea2dda4a7f0c123ce3050 Juan Denis <jhd3197@gmail.com> 1782916827 -0400 update by push
|
||||
58e80b2e18e6da97855ea2dda4a7f0c123ce3050 182476a7ebf401c51b7d5139f5b402cd8d644c62 Juan Denis <jhd3197@gmail.com> 1782917192 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
182476a7ebf401c51b7d5139f5b402cd8d644c62 b6f5e3122f75fbe4f54f15277ea9ebfa047f76c3 Juan Denis <jhd3197@gmail.com> 1782917217 -0400 update by push
|
||||
b6f5e3122f75fbe4f54f15277ea9ebfa047f76c3 189670f24554f651968a8874a2d31050af8ec832 Juan Denis <jhd3197@gmail.com> 1782919120 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
189670f24554f651968a8874a2d31050af8ec832 fe152ae6f2506bd3f19e4fb037efd52774ae95f7 Juan Denis <jhd3197@gmail.com> 1782962569 -0400 update by push
|
||||
fe152ae6f2506bd3f19e4fb037efd52774ae95f7 63aea544468e72aeb9fda59dd2bdc686faa33636 Juan Denis <jhd3197@gmail.com> 1782962601 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
63aea544468e72aeb9fda59dd2bdc686faa33636 0ec55accbe770fef6e158e04c5397c177949cdff Juan Denis <jhd3197@gmail.com> 1782974279 -0400 update by push
|
||||
0ec55accbe770fef6e158e04c5397c177949cdff 59c9227916975050bbd5d838fe55a18c5fb53e1e Juan Denis <jhd3197@gmail.com> 1782976150 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
59c9227916975050bbd5d838fe55a18c5fb53e1e 90d252fbada2acee8331cd210675407ba3539d48 Juan Denis <jhd3197@gmail.com> 1783042258 -0400 update by push
|
||||
90d252fbada2acee8331cd210675407ba3539d48 1296a5c82684dd0112c33717e35efca47f84de9d Juan Denis <jhd3197@gmail.com> 1783044172 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
1296a5c82684dd0112c33717e35efca47f84de9d b57f9041541d00db468183cf6315c1d35ab5e1a2 Juan Denis <jhd3197@gmail.com> 1783044575 -0400 update by push
|
||||
b57f9041541d00db468183cf6315c1d35ab5e1a2 180df2c29df3c025d4e9279e97a90e9511152191 Juan Denis <jhd3197@gmail.com> 1783045873 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
180df2c29df3c025d4e9279e97a90e9511152191 e93faac2269ae80cd9e6cc4742cef783a0c2c986 Juan Denis <jhd3197@gmail.com> 1783046356 -0400 update by push
|
||||
e93faac2269ae80cd9e6cc4742cef783a0c2c986 c301224844a51af46edcb1c9ed5ad5c8207db14b Juan Denis <jhd3197@gmail.com> 1783048428 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
c301224844a51af46edcb1c9ed5ad5c8207db14b a63b75f16249253a6919412ad2d7934c779371e5 Juan Denis <jhd3197@gmail.com> 1783052222 -0400 update by push
|
||||
a63b75f16249253a6919412ad2d7934c779371e5 2fab4ac108bec92742e35a4ffd7b3001d879ad2c Juan Denis <jhd3197@gmail.com> 1783052286 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
2fab4ac108bec92742e35a4ffd7b3001d879ad2c 5e22aa85109fef115bf668da9e6705f3fba5ca7b Juan Denis <jhd3197@gmail.com> 1783130411 -0400 update by push
|
||||
5e22aa85109fef115bf668da9e6705f3fba5ca7b 6e9543047c0fe2c5034851fa6f5886ca3f04379c Juan Denis <jhd3197@gmail.com> 1783131795 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
6e9543047c0fe2c5034851fa6f5886ca3f04379c 03ede7b97a0110a344352726ed83e4b3489ce6d9 Juan Denis <jhd3197@gmail.com> 1783131814 -0400 update by push
|
||||
03ede7b97a0110a344352726ed83e4b3489ce6d9 145d6b15060874d73f395b6e1418596ee61ff2be Juan Denis <jhd3197@gmail.com> 1783132963 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
145d6b15060874d73f395b6e1418596ee61ff2be 57ca8617dadbbfa579aacd47e870009b4171232c Juan Denis <jhd3197@gmail.com> 1783144906 -0400 update by push
|
||||
57ca8617dadbbfa579aacd47e870009b4171232c 5fe5473ba092861123589f8e250d8ab75925b243 Juan Denis <jhd3197@gmail.com> 1783145129 -0400 fetch --progress --prune --recurse-submodules=on-demand origin: fast-forward
|
||||
5fe5473ba092861123589f8e250d8ab75925b243 3c34bd64db91977ec95781fd05263aaba6eb1fc2 Juan Denis <jhd3197@gmail.com> 1783145236 -0400 update by push
|
||||
3c34bd64db91977ec95781fd05263aa
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user