restore: remaining reconstructed July 5-7 changes (clean recovered files not attributable to one plan)

This commit is contained in:
Juan Denis committed 2026-07-07 20:29:31 -04:00
1 parent e08fe94b21
commit 6e32519f7e
101 files changed
+3539 -2376

No files matched your search

+2
View File
@@ -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:
+6
View File
@@ -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
+1 -1
View File
@@ -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
+11 -1
View File
@@ -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)
+2
View File
@@ -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
+2
View File
@@ -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:
+12 -1
View File
@@ -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)
+7
View File
@@ -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
View File
@@ -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(
+6
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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",
+12 -1
View File
@@ -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',
]
+14
View File
@@ -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
+4
View File
@@ -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)}
+13 -1
View File
@@ -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')
+93
View File
@@ -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)
+17
View File
@@ -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.
+31
View File
@@ -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"
}
}
}
+39
View File
@@ -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()
+56
View File
@@ -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
+125
View File
@@ -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.
+17 -4
View File
@@ -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.
+206
View File
@@ -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.
+127
View File
@@ -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)
```
+123
View File
@@ -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;
+3
View File
@@ -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;
+41
View File
@@ -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;
+41 -4
View File
@@ -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>
);
}
+1
View File
@@ -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&apos;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 ? (
+2
View File
@@ -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 },
+39
View File
@@ -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]);
}
+2
View File
@@ -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 -1
View File
@@ -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;
+3
View File
@@ -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'
+3 -3
View File
@@ -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 -3
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+21 -27
View File
@@ -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
View File
@@ -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
+31 -6
View File
@@ -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);
+18 -3
View File
@@ -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;
})
+35
View File
@@ -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.
+6
View File
@@ -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',
+10
View File
@@ -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;
+4
View File
@@ -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)) {
+23
View File
@@ -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() {
+10
View File
@@ -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'
+21
View File
@@ -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;
}
+6 -79
View File
@@ -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;
}
}
+2 -11
View File
@@ -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 -15
View File
@@ -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 {
+1
View File
@@ -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';
+56 -11
View File
@@ -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;
}
}
}
+2 -60
View File
@@ -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));
-39
View File
@@ -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;
+9
View File
@@ -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 });
});
}
+72
View File
@@ -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
View File
@@ -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)),
+1 -1
View File
@@ -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 / {
+1 -1
View File
@@ -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
View File
@@ -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