Merge pull request #51 from jhd3197/dev

Refactor: unify core surfaces and overhaul the SCSS frontend
This commit is contained in:
Juan Denis authored and GitHub committed 2026-06-24 21:53:49 -04:00
commit a06960df0b
340 files changed
+16486 -15031

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# ServerKit pre-commit hook — lints staged frontend JS/JSX and blocks the commit
# on ESLint ERRORS only (warnings are allowed). It exits early for commits that
# touch no frontend JS/JSX (backend, SCSS, docs), so it never gets in the way of
# those. Enable once per clone with:
#
# git config core.hooksPath .githooks
#
# (Or run frontend/setup-hooks.sh.)
files=$(git diff --cached --name-only --diff-filter=ACM | grep -E '^frontend/src/.*\.(js|jsx)$' || true)
[ -z "$files" ] && exit 0
cd frontend || exit 0
# Skip silently if deps aren't installed yet (don't fetch/block).
[ -d node_modules ] || exit 0
rel=$(echo "$files" | sed 's#^frontend/##')
echo "$rel" | xargs npx eslint
status=$?
if [ "$status" -ne 0 ]; then
echo ""
echo "✖ Commit blocked: fix the ESLint error(s) above (warnings are allowed)."
echo " To bypass for an emergency commit: git commit --no-verify"
fi
exit $status
+44
View File
@@ -101,6 +101,50 @@ ServerKit deploys on Linux (bare metal, VPS, or Docker). Development may happen
- One logical change per commit
- Minimal, focused diffs — don't silently refactor surrounding code
- Branch naming: `feature/`, `fix/`, `docs/`, `refactor/` prefixes
- **Pre-commit hook**: a shared git hook (`.githooks/pre-commit`) lints staged
frontend JS/JSX and blocks the commit on ESLint **errors** (warnings allowed;
backend/SCSS-only commits are skipped). Enable once per clone:
`git config core.hooksPath .githooks` (or `sh frontend/setup-hooks.sh`). Keep
the repo at **0 ESLint errors**.
## Styling Standard
**SCSS only.** ServerKit standardizes on its internal SCSS design system. Do not
add Tailwind utility classes, CSS-in-JS, or new inline styles.
- **Forbidden**: Tailwind utility strings (`flex gap-3 px-4`, `text-sm`,
`bg-card`, `border-border`, `rounded-full`, etc.), `tailwindcss`/`tailwind-merge`
imports, and CSS-in-JS. Inline `style={{ ... }}` is allowed **only** for true
dynamic values (a width computed from a prop, chart dimensions).
- **Class namespaces**:
- `.sk-*` — redesign "infra console" design-system primitives.
- Legacy BEM (`.page-container`, `.card`, `.modal-*`, `.form-*`,
`.empty-state__title`) — allowed during migration; deprecate gradually.
- No utility-class strings.
- **Shared primitives** (reuse before rewrite):
- Buttons — `styles/components/_buttons.scss` (`.btn`, `.btn-primary`,
`.btn-danger`, `.btn-ghost`, `.btn-sm`, `.btn-icon`).
- Cards — `styles/components/_cards.scss` (`.card`, `.card-header`,
`.stat-strip`).
- Modals — `styles/components/_modals.scss` (`.modal-overlay`, `.modal`,
`.modal-lg`, `.modal-header`, `.modal-body`, `.modal-actions`).
- Forms — `styles/components/_forms.scss` (`.form-group`, `.form-field`,
`.form-row`, `.error-message`); prefer the `FormField` component.
- Tables — `.sk-dtable` in `styles/components/_design-system.scss`; prefer the
`components/ds/DataTable.jsx` primitive.
- Pills/badges — `.sk-pill`, `.sk-state`, `.sk-tag`.
- Tokens — `styles/_variables.scss`, `styles/_theme-variables.scss`.
- **Shared utilities** (reuse before rewrite):
- `utils/formatBytes.js` — byte/size formatting (no local `formatBytes`/
`formatSize`/`formatMemory`).
- `utils/time.js` — `timeAgo` (compact) and `formatRelativeTime` (verbose).
- `utils/clipboard.js` + `hooks/useClipboard.js` — copy-to-clipboard with
toast feedback (no raw `navigator.clipboard.writeText` call sites).
- **Fonts**: `IBM Plex Sans` / `IBM Plex Mono` (the established SCSS stack).
**Migration status**: Tailwind removal is in progress (see
`docs/plans/FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md`). Do not introduce new Tailwind
usage; convert any you touch to the SCSS equivalents above.
## Adding a New Feature (Full Stack)
+1 -1
View File
@@ -1 +1 @@
1.7.0
1.7.1
+42
View File
@@ -116,6 +116,10 @@ def create_app(config_name=None):
from app.api.tunnels import tunnels_bp
from app.api.private_urls import private_urls_bp
app.register_blueprint(apps_bp, url_prefix='/api/v1/apps')
# "Services" is the user-facing term for Applications (§1 unification).
# Mount the same blueprint under /api/v1/services as a true alias so the
# canonical `apps` routes and any `services` callers resolve identically.
app.register_blueprint(apps_bp, url_prefix='/api/v1/services', name='services')
app.register_blueprint(domains_bp, url_prefix='/api/v1/domains')
app.register_blueprint(private_urls_bp, url_prefix='/api/v1/apps')
@@ -142,6 +146,10 @@ def create_app(config_name=None):
app.register_blueprint(wordpress_bp, url_prefix='/api/v1/wordpress')
app.register_blueprint(wordpress_sites_bp, url_prefix='/api/v1/wordpress')
app.register_blueprint(environment_pipeline_bp, url_prefix='/api/v1/wordpress/projects')
# "WordPress Projects" was renamed to "Pipelines" (§2 unification). Mount the
# same blueprint under /wordpress/pipelines as a true alias so both URL
# spaces resolve identically during the deprecation window.
app.register_blueprint(environment_pipeline_bp, url_prefix='/api/v1/wordpress/pipelines', name='environment_pipeline_pipelines')
# Register blueprints - Python
from app.api.python import python_bp
@@ -206,8 +214,15 @@ def create_app(config_name=None):
# Register blueprints - Builds & Deployments
from app.api.builds import builds_bp
from app.api.deployment_jobs import deployment_jobs_bp
from app.api.deployments import deployments_bp
app.register_blueprint(builds_bp, url_prefix='/api/v1/builds')
app.register_blueprint(deployment_jobs_bp, url_prefix='/api/v1/deployment-jobs')
# §3 unification: one /api/v1/deployments surface. Federated history/detail
# live in deployments_bp; the canonical execution records (DeploymentJob)
# are also mounted here under /deployments/jobs (alias of /deployment-jobs).
app.register_blueprint(deployments_bp, url_prefix='/api/v1/deployments')
app.register_blueprint(deployment_jobs_bp, url_prefix='/api/v1/deployments/jobs',
name='deployment_jobs_unified')
# Register blueprints - Templates
from app.api.templates import templates_bp
@@ -328,8 +343,13 @@ def create_app(config_name=None):
app.register_blueprint(workspaces_bp, url_prefix='/api/v1/workspaces')
# Register blueprints - Advanced SSL
# §5 unification: one SSL surface. The advanced cert operations (wildcard,
# SAN, custom upload, profiles, health, expiry alerts) mount under the same
# /api/v1/ssl prefix as the basic certbot routes (no path collisions). The
# original /api/v1/ssl/advanced prefix is kept as a deprecated alias.
from app.api.advanced_ssl import advanced_ssl_bp
app.register_blueprint(advanced_ssl_bp, url_prefix='/api/v1/ssl/advanced')
app.register_blueprint(advanced_ssl_bp, url_prefix='/api/v1/ssl', name='advanced_ssl_unified')
# Register blueprints - DNS Zones
from app.api.dns_zones import dns_zones_bp
@@ -399,6 +419,19 @@ def create_app(config_name=None):
from app.api.telemetry import telemetry_bp
app.register_blueprint(telemetry_bp, url_prefix='/api/v1/telemetry')
# §4 unification: one observability namespace. The monitoring / metrics /
# telemetry / uptime / fleet / status-page read surfaces are re-mounted under
# /api/v1/observability/<domain> as true aliases (same blueprints, distinct
# names) so callers have a single front door. The original prefixes remain,
# and the PUBLIC status page route (/api/v1/status/public/<slug>) is
# unchanged — its canonical mount is untouched.
app.register_blueprint(monitoring_bp, url_prefix='/api/v1/observability/monitoring', name='obs_monitoring')
app.register_blueprint(metrics_bp, url_prefix='/api/v1/observability/metrics', name='obs_metrics')
app.register_blueprint(telemetry_bp, url_prefix='/api/v1/observability/events', name='obs_events')
app.register_blueprint(uptime_bp, url_prefix='/api/v1/observability/uptime', name='obs_uptime')
app.register_blueprint(fleet_monitor_bp, url_prefix='/api/v1/observability/fleet', name='obs_fleet')
app.register_blueprint(status_pages_bp, url_prefix='/api/v1/observability/status-pages', name='obs_status_pages')
# Register blueprints - Agent Pairing (RustDesk-style short-code flow)
from app.api.pairing import pairing_bp
app.register_blueprint(pairing_bp, url_prefix='/api/v1/pairing')
@@ -432,6 +465,15 @@ def create_app(config_name=None):
# connection store (idempotent), so every zone resolves creds the same way.
from app.services.dns_zone_service import DNSZoneService
n_zones = DNSZoneService.link_legacy_zones()
# Fold a legacy single-row email relay config into the unified
# EmailProviderConnection table (§6); idempotent, best-effort.
try:
from app.services.email_relay_service import EmailRelayService
EmailRelayService.migrate_legacy_config()
except Exception as _relay_exc: # never block boot on this
import logging as _logging
_logging.getLogger(__name__).warning(
f'Email relay legacy migration skipped: {_relay_exc}')
if n_dns or n_store or n_cloud or n_settings or n_zones:
import logging as _logging
_logging.getLogger(__name__).info(
+110
View File
@@ -2023,3 +2023,113 @@ def delete_app_backup(app_id, run_id):
except BackupPolicyError as e:
return jsonify({'error': str(e)}), 400
return jsonify({'success': True})
# --------------------------------------------------------------------------- #
# WordPress reachable through the generic application surface (§7 unification).
# A WordPress site is a 1:1 extension of an Application (WordPressSite.
# application_id); the WordPressSite record — not app_type — is the source of
# truth (see wordpress.py:_is_wp_app). These additive routes expose the WP
# linkage and its DB snapshots under /apps/:id without changing app_type, so the
# docker-compose code paths that key on app_type=='docker' keep working.
# --------------------------------------------------------------------------- #
def _wp_site_for_app(app):
from app.models.wordpress_site import WordPressSite
return WordPressSite.query.filter_by(application_id=app.id).first()
@apps_bp.route('/<int:app_id>/wordpress', methods=['GET'])
@jwt_required()
def app_wordpress_info(app_id):
"""Whether this app is a WordPress site, and the linked site id. Lets the
generic Services surface discover the WordPress extension."""
app, err = _load_app_for_backup(app_id)
if err:
return err
site = _wp_site_for_app(app)
if not site:
return jsonify({'is_wordpress': False, 'site_id': None}), 200
return jsonify({
'is_wordpress': True,
'site_id': site.id,
'environment_type': site.environment_type,
'is_production': site.is_production,
'wp_version': site.wp_version,
}), 200
@apps_bp.route('/<int:app_id>/db-snapshots', methods=['GET'])
@jwt_required()
def list_app_db_snapshots(app_id):
"""List the WordPress DB snapshots for an app (unified app-id surface)."""
app, err = _load_app_for_backup(app_id)
if err:
return err
from app.models.wordpress_site import DatabaseSnapshot
site = _wp_site_for_app(app)
if not site:
return jsonify({'error': 'This application is not a WordPress site'}), 400
snaps = (DatabaseSnapshot.query.filter_by(site_id=site.id)
.order_by(DatabaseSnapshot.created_at.desc()).all())
return jsonify({'snapshots': [s.to_dict() for s in snaps], 'total': len(snaps)}), 200
@apps_bp.route('/<int:app_id>/db-snapshots', methods=['POST'])
@jwt_required()
def create_app_db_snapshot(app_id):
"""Create a WordPress DB snapshot for an app."""
app, err = _load_app_for_backup(app_id, edit=True)
if err:
return err
from app.models.wordpress_site import DatabaseSnapshot
from app.services.db_sync_service import DatabaseSyncService
from app.services.wordpress_env_service import WordPressEnvService
site = _wp_site_for_app(app)
if not site:
return jsonify({'error': 'This application is not a WordPress site'}), 400
data = request.get_json() or {}
result = DatabaseSyncService.create_snapshot(
db_name=site.db_name,
name=data.get('name', f'{app.name}_{site.id}'),
tag=data.get('tag'),
commit_sha=site.last_deploy_commit,
host=site.db_host, user=site.db_user,
password=WordPressEnvService._get_db_password(site),
exclude_tables=data.get('exclude_tables', []),
)
if not result.get('success'):
return jsonify(result), 500
snap_meta = result['snapshot']
snapshot = DatabaseSnapshot(
site_id=site.id, name=snap_meta['name'], tag=data.get('tag'),
file_path=snap_meta['file_path'], size_bytes=snap_meta['size_bytes'],
compressed=snap_meta['compressed'], commit_sha=site.last_deploy_commit,
tables_included=json.dumps(snap_meta.get('tables', [])),
row_count=snap_meta.get('row_count', 0), status='completed',
)
db.session.add(snapshot)
db.session.commit()
DatabaseSyncService.upload_snapshot_offsite(snapshot.file_path)
return jsonify({'success': True, 'snapshot': snapshot.to_dict()}), 201
@apps_bp.route('/<int:app_id>/db-snapshots/<int:snapshot_id>', methods=['DELETE'])
@jwt_required()
def delete_app_db_snapshot(app_id, snapshot_id):
"""Delete a WordPress DB snapshot for an app."""
app, err = _load_app_for_backup(app_id, edit=True)
if err:
return err
from app.models.wordpress_site import DatabaseSnapshot
from app.services.db_sync_service import DatabaseSyncService
site = _wp_site_for_app(app)
if not site:
return jsonify({'error': 'This application is not a WordPress site'}), 400
snapshot = DatabaseSnapshot.query.filter_by(id=snapshot_id, site_id=site.id).first()
if not snapshot:
return jsonify({'error': 'Snapshot not found'}), 404
DatabaseSyncService.delete_snapshot(snapshot.file_path)
db.session.delete(snapshot)
db.session.commit()
return jsonify({'success': True, 'message': 'Snapshot deleted'}), 200
+169
View File
@@ -4,6 +4,7 @@ from flask_jwt_extended import jwt_required
from app.middleware.rbac import admin_required
from app.models import Application
from app.services.backup_service import BackupService
from app.services.backup_policy_service import BackupPolicyService, BackupPolicyError
from app.services.storage_provider_service import StorageProviderService
from app import db, paths
@@ -29,6 +30,174 @@ def get_stats():
return jsonify(stats), 200
@backups_bp.route('/policies', methods=['GET'])
@jwt_required()
@admin_required
def list_policies():
"""Global list of policy-driven backup schedules across every target type
(§8 unification). Optional filters: ?target_type=, ?enabled=true|false.
Replaces the legacy filesystem schedule list (/backups/schedules) for the
unified Backups page — one queryable view of every BackupPolicy.
"""
from app.models.backup_policy import BackupPolicy, VALID_TARGET_TYPES
query = BackupPolicy.query
target_type = request.args.get('target_type')
if target_type:
if target_type not in VALID_TARGET_TYPES:
return jsonify({'error': f'invalid target_type {target_type!r}'}), 400
query = query.filter_by(target_type=target_type)
enabled = request.args.get('enabled')
if enabled is not None:
query = query.filter_by(enabled=str(enabled).lower() in ('1', 'true', 'yes'))
policies = query.order_by(BackupPolicy.target_type, BackupPolicy.target_id).all()
return jsonify({'policies': [p.to_dict() for p in policies]}), 200
@backups_bp.route('/runs', methods=['GET'])
@jwt_required()
@admin_required
def list_runs():
"""Global, queryable list of backup runs across every policy (§8).
Optional filters: ?target_type=, ?status=, ?policy_id=, ?limit= (<=500).
Each run is augmented with its policy's target_type/target_id so the unified
Backups page can render and filter without a second lookup.
"""
from app.models.backup_policy import BackupPolicy
from app.models.backup_run import BackupRun
query = db.session.query(BackupRun, BackupPolicy).join(
BackupPolicy, BackupRun.policy_id == BackupPolicy.id
)
target_type = request.args.get('target_type')
if target_type:
query = query.filter(BackupPolicy.target_type == target_type)
status = request.args.get('status')
if status:
query = query.filter(BackupRun.status == status)
policy_id = request.args.get('policy_id', type=int)
if policy_id:
query = query.filter(BackupRun.policy_id == policy_id)
try:
limit = min(int(request.args.get('limit', 200)), 500)
except (TypeError, ValueError):
limit = 200
rows = query.order_by(BackupRun.started_at.desc()).limit(limit).all()
runs = []
for run, policy in rows:
item = run.to_dict()
item['target_type'] = policy.target_type
item['target_id'] = policy.target_id
item['target_subtype'] = policy.target_subtype
runs.append(item)
return jsonify({'runs': runs}), 200
# --------------------------------------------------------------------------- #
# Generic policy-driven backup CRUD by (target_type, target_id) (§8).
# One surface for every BackupPolicy target — apps, WordPress sites, databases,
# file path-lists, servers. The per-resource mounts
# (/apps/:id/backup-policy, /wordpress/sites/:id/backup-policy) remain as
# grant-aware aliases; this mount is admin-only and is the only way to drive the
# database/files/server targets that have no per-resource page.
# --------------------------------------------------------------------------- #
@backups_bp.route('/policies/<target_type>/<int:target_id>', methods=['GET'])
@jwt_required()
@admin_required
def get_target_policy(target_type, target_id):
"""Protection policy + status for any target (creates a default if absent)."""
try:
BackupPolicyService.validate_target_type(target_type)
policy = BackupPolicyService.get_or_create_policy(target_type, target_id)
except BackupPolicyError as e:
return jsonify({'error': str(e)}), 400
return jsonify(BackupPolicyService.serialize_policy_view(policy)), 200
@backups_bp.route('/policies/<target_type>/<int:target_id>', methods=['PUT'])
@jwt_required()
@admin_required
def update_target_policy(target_type, target_id):
"""Update a policy. Body may include target_subtype/target_meta (needed to
describe database/files targets) plus the usual schedule/retention fields."""
data = request.get_json() or {}
subtype = data.pop('target_subtype', None)
meta = data.pop('target_meta', None)
try:
BackupPolicyService.validate_target_type(target_type)
policy = BackupPolicyService.get_or_create_policy(target_type, target_id, subtype, meta)
BackupPolicyService.update_policy(policy, data)
except BackupPolicyError as e:
return jsonify({'error': str(e)}), 400
return jsonify(BackupPolicyService.serialize_policy_view(policy)), 200
@backups_bp.route('/policies/<target_type>/<int:target_id>/run', methods=['POST'])
@jwt_required()
@admin_required
def run_target_policy(target_type, target_id):
"""Enqueue a one-off backup for any target."""
try:
BackupPolicyService.validate_target_type(target_type)
policy = BackupPolicyService.get_or_create_policy(target_type, target_id)
job = BackupPolicyService.run_policy_now(policy, manual=True)
except BackupPolicyError as e:
return jsonify({'error': str(e)}), 409
return jsonify({'success': True, 'job_id': job.id}), 202
@backups_bp.route('/policies/<target_type>/<int:target_id>/runs', methods=['GET'])
@jwt_required()
@admin_required
def list_target_runs(target_type, target_id):
try:
BackupPolicyService.validate_target_type(target_type)
policy = BackupPolicyService.get_or_create_policy(target_type, target_id)
except BackupPolicyError as e:
return jsonify({'error': str(e)}), 400
return jsonify({'runs': BackupPolicyService.list_runs(policy)}), 200
@backups_bp.route('/policies/<target_type>/<int:target_id>/runs/<int:run_id>/restore', methods=['POST'])
@jwt_required()
@admin_required
def restore_target_run(target_type, target_id, run_id):
try:
BackupPolicyService.validate_target_type(target_type)
policy = BackupPolicyService.get_or_create_policy(target_type, target_id)
job = BackupPolicyService.request_restore(policy, run_id, request.get_json() or {})
except BackupPolicyError as e:
return jsonify({'error': str(e)}), 400
return jsonify({'success': True, 'job_id': job.id}), 202
@backups_bp.route('/policies/<target_type>/<int:target_id>/runs/<int:run_id>/verify', methods=['POST'])
@jwt_required()
@admin_required
def verify_target_run(target_type, target_id, run_id):
try:
BackupPolicyService.validate_target_type(target_type)
policy = BackupPolicyService.get_or_create_policy(target_type, target_id)
result = BackupPolicyService.verify_run(policy, run_id)
except BackupPolicyError as e:
return jsonify({'error': str(e)}), 400
return jsonify(result), 200
@backups_bp.route('/policies/<target_type>/<int:target_id>/runs/<int:run_id>', methods=['DELETE'])
@jwt_required()
@admin_required
def delete_target_run(target_type, target_id, run_id):
try:
BackupPolicyService.validate_target_type(target_type)
policy = BackupPolicyService.get_or_create_policy(target_type, target_id)
BackupPolicyService.delete_run(policy, run_id)
except BackupPolicyError as e:
return jsonify({'error': str(e)}), 400
return jsonify({'success': True}), 200
@backups_bp.route('/config', methods=['GET'])
@jwt_required()
@admin_required
+74
View File
@@ -0,0 +1,74 @@
"""Unified deployment surface (§3 unification).
One read namespace over the deploy records that today live in three places:
versioned build ``Deployment`` rows, webhook-triggered ``GitDeployment`` rows,
and the canonical ``DeploymentJob`` execution records (mounted here under
``/jobs``). The per-system blueprints (/builds, /deploy, /git, /deployment-jobs)
are unchanged; this is an additive, federated view so the frontend can read one
history instead of merging two sources.
"""
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.models import Application, User
from app.models.deployment import Deployment
from app.models.webhook import GitDeployment
deployments_bp = Blueprint('deployments', __name__)
def _access(app_id):
from app.services.resource_grant_service import ResourceGrantService
user = User.query.get(get_jwt_identity())
app = Application.query.get(app_id)
if not app:
return None, (jsonify({'error': 'Application not found'}), 404)
if not ResourceGrantService.can_access_app(user, app):
return None, (jsonify({'error': 'Access denied'}), 403)
return app, None
@deployments_bp.route('/apps/<int:app_id>/history', methods=['GET'])
@jwt_required()
def app_history(app_id):
"""Federated deploy history for an app: versioned build Deployments +
webhook GitDeployments, each tagged with ``source``, newest first.
Query: ?source=build|webhook|all (default all), ?limit= (<=500)."""
app, err = _access(app_id)
if err:
return err
source = (request.args.get('source') or 'all').lower()
try:
limit = min(int(request.args.get('limit', 100)), 500)
except (TypeError, ValueError):
limit = 100
items = []
if source in ('all', 'build'):
for d in (Deployment.query.filter_by(app_id=app_id)
.order_by(Deployment.created_at.desc()).limit(limit).all()):
row = d.to_dict()
row['source'] = 'build'
items.append(row)
if source in ('all', 'webhook'):
for g in (GitDeployment.query.filter_by(app_id=app_id)
.order_by(GitDeployment.created_at.desc()).limit(limit).all()):
row = g.to_dict()
row['source'] = 'webhook'
items.append(row)
items.sort(key=lambda r: r.get('created_at') or '', reverse=True)
items = items[:limit]
return jsonify({'deployments': items, 'count': len(items)}), 200
@deployments_bp.route('/<int:deployment_id>', methods=['GET'])
@jwt_required()
def get_deployment(deployment_id):
"""A versioned build Deployment by id (the release ledger detail)."""
d = Deployment.query.get(deployment_id)
if not d:
return jsonify({'error': 'Deployment not found'}), 404
_, err = _access(d.app_id)
if err:
return err
return jsonify({'deployment': d.to_dict(include_logs=True)}), 200
+24 -11
View File
@@ -114,11 +114,28 @@ def start_container(container_id):
return jsonify(result), 200 if result['success'] else 400
def _reject_if_protected(container_id):
"""Return a 403 response if the container is ServerKit's own, else None.
Stopping/restarting/removing the panel's own containers would take
ServerKit offline, so these lifecycle actions are blocked outright.
"""
if DockerService.is_protected_container(container_id):
return jsonify({
'error': 'This is a ServerKit system container and cannot be '
'controlled from here. Managing it would take the panel offline.'
}), 403
return None
@docker_bp.route('/containers/<container_id>/stop', methods=['POST'])
@jwt_required()
@admin_required
def stop_container(container_id):
"""Stop a container."""
blocked = _reject_if_protected(container_id)
if blocked:
return blocked
data = request.get_json() or {}
timeout = data.get('timeout', 10)
result = DockerService.stop_container(container_id, timeout)
@@ -130,27 +147,23 @@ def stop_container(container_id):
@admin_required
def restart_container(container_id):
"""Restart a container."""
blocked = _reject_if_protected(container_id)
if blocked:
return blocked
data = request.get_json() or {}
timeout = data.get('timeout', 10)
result = DockerService.restart_container(container_id, timeout)
return jsonify(result), 200 if result['success'] else 400
PROTECTED_CONTAINERS = ['serverkit-frontend', 'serverkit_frontend', 'serverkit']
@docker_bp.route('/containers/<container_id>', methods=['DELETE'])
@jwt_required()
@admin_required
def remove_container(container_id):
"""Remove a container."""
# Protect ServerKit containers from deletion
container = DockerService.get_container(container_id)
if container:
container_name = container.get('name', '').lower().replace('/', '')
if any(protected in container_name for protected in PROTECTED_CONTAINERS):
return jsonify({
'error': 'Cannot delete ServerKit system container. This container is required for the panel to function.'
}), 403
blocked = _reject_if_protected(container_id)
if blocked:
return blocked
data = request.get_json() or {}
force = data.get('force', False)
@@ -485,7 +498,7 @@ def docker_cleanup():
parts = line.split(' ', 1)
if len(parts) == 2:
container_id, name = parts
if any(p in name.lower() for p in PROTECTED_CONTAINERS):
if DockerService.is_protected_name(name):
protected_ids.add(container_id)
except Exception:
pass
+12
View File
@@ -403,6 +403,18 @@ def test_email_provider(provider_id):
return jsonify(result), 200 if result.get('success') else 400
@notifications_bp.route('/admin/email-providers/<int:provider_id>', methods=['PUT'])
@jwt_required()
@admin_required
def update_email_provider(provider_id):
"""Update a provider's usage flags (uses_notifications / uses_relay /
relay_priority) — §6 unification. uses_relay applies only to SMTP."""
row = EmailProviderService.update_usage(provider_id, request.get_json() or {})
if row is None:
return jsonify({'error': 'Provider not found'}), 404
return jsonify({'success': True, 'provider': row.to_dict()}), 200
@notifications_bp.route('/admin/email-providers/<int:provider_id>/default', methods=['POST'])
@jwt_required()
@admin_required
+7
View File
@@ -36,7 +36,11 @@ def _get_snapshot_or_404(app_id, snap_id):
return snap, None
# "Config Checkpoint" is the user-facing name for a deployment config snapshot
# (§8 decision: rename in the UI; keep /snapshots working). Every route below is
# also reachable under /config-checkpoints so the API matches the new wording.
@snapshots_bp.route('/<int:app_id>/snapshots', methods=['GET'])
@snapshots_bp.route('/<int:app_id>/config-checkpoints', methods=['GET'])
@jwt_required()
def list_snapshots(app_id):
"""List config snapshots for an app, newest first."""
@@ -62,6 +66,7 @@ def list_snapshots(app_id):
@snapshots_bp.route('/<int:app_id>/snapshots/<int:snap_id>', methods=['GET'])
@snapshots_bp.route('/<int:app_id>/config-checkpoints/<int:snap_id>', methods=['GET'])
@jwt_required()
def get_snapshot(app_id, snap_id):
"""Fetch a single snapshot, including its resolved config."""
@@ -75,6 +80,7 @@ def get_snapshot(app_id, snap_id):
@snapshots_bp.route('/<int:app_id>/snapshots/<int:snap_id>/diff', methods=['GET'])
@snapshots_bp.route('/<int:app_id>/config-checkpoints/<int:snap_id>/diff', methods=['GET'])
@jwt_required()
def diff_snapshot(app_id, snap_id):
"""Diff a snapshot against another (default: the previous snapshot).
@@ -123,6 +129,7 @@ def diff_snapshot(app_id, snap_id):
@snapshots_bp.route('/<int:app_id>/snapshots/<int:snap_id>/restore', methods=['POST'])
@snapshots_bp.route('/<int:app_id>/config-checkpoints/<int:snap_id>/restore', methods=['POST'])
@jwt_required()
@developer_required
def restore_snapshot(app_id, snap_id):
+30 -1
View File
@@ -1,8 +1,15 @@
import json
from datetime import datetime
from decimal import Decimal
from app import db
# Every data-protection target the unified policy system can back up (§8).
# 'application' and 'wordpress_site' are the original targets; 'database',
# 'files', and 'server' were folded in from the legacy BackupService so a
# single BackupPolicy/BackupRun system covers all data-protection backups.
VALID_TARGET_TYPES = ('application', 'wordpress_site', 'database', 'files', 'server')
def _num(value):
"""Serialize a Numeric/Decimal column to a JSON-friendly float (or None)."""
@@ -28,8 +35,15 @@ class BackupPolicy(db.Model):
__tablename__ = 'backup_policies'
id = db.Column(db.Integer, primary_key=True)
target_type = db.Column(db.String(40), nullable=False, index=True) # 'wordpress_site' | 'application'
target_type = db.Column(db.String(40), nullable=False, index=True) # see VALID_TARGET_TYPES
target_id = db.Column(db.Integer, nullable=False, index=True)
# Optional finer target classification, e.g. the engine for a 'database'
# target ('mysql' | 'postgresql' | 'mongodb') or 'pathlist' for 'files'.
target_subtype = db.Column(db.String(40), nullable=True)
# Per-target details that aren't a first-class column: for 'database' the
# connection descriptor (db_type/db_name/user/host); for 'files' the path
# list; for 'server' the scope. JSON object, never secrets in plaintext.
target_meta_json = db.Column(db.Text, default='{}', nullable=True)
enabled = db.Column(db.Boolean, default=False, nullable=False)
# Schedule (cron expression fired by the unified scheduler)
@@ -71,11 +85,26 @@ class BackupPolicy(db.Model):
__table_args__ = (db.UniqueConstraint('target_type', 'target_id', name='uq_backup_policy_target'),)
def get_target_meta(self):
"""Parsed target_meta_json (always a dict)."""
if not self.target_meta_json:
return {}
try:
data = json.loads(self.target_meta_json)
return data if isinstance(data, dict) else {}
except (ValueError, TypeError):
return {}
def set_target_meta(self, value):
self.target_meta_json = json.dumps(value or {})
def to_dict(self):
return {
'id': self.id,
'target_type': self.target_type,
'target_id': self.target_id,
'target_subtype': self.target_subtype,
'target_meta': self.get_target_meta(),
'enabled': self.enabled,
'schedule_cron': self.schedule_cron,
'retention_count': self.retention_count,
+17
View File
@@ -24,6 +24,17 @@ class DeploymentJob(db.Model):
requested_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
trigger = db.Column(db.String(30), default='manual')
# Links to the release ledgers (§3 unification): the DeploymentJob is the
# canonical execution record; Deployment / GitDeployment remain the
# version/release ledgers it produces. All nullable — a template-install job
# has none of these set.
deployment_id = db.Column(db.Integer, db.ForeignKey('deployments.id'), nullable=True, index=True)
git_deployment_id = db.Column(db.Integer, db.ForeignKey('git_deployments.id'), nullable=True, index=True)
webhook_id = db.Column(db.Integer, db.ForeignKey('git_webhooks.id'), nullable=True, index=True)
commit_hash = db.Column(db.String(40), nullable=True)
image_tag = db.Column(db.String(255), nullable=True)
container_id = db.Column(db.String(100), nullable=True)
# Execution progress
total_steps = db.Column(db.Integer, default=0)
current_step = db.Column(db.Integer, default=0)
@@ -96,6 +107,12 @@ class DeploymentJob(db.Model):
'app_name': self.app.name if self.app else None,
'requested_by': self.requested_by,
'trigger': self.trigger,
'deployment_id': self.deployment_id,
'git_deployment_id': self.git_deployment_id,
'webhook_id': self.webhook_id,
'commit_hash': self.commit_hash,
'image_tag': self.image_tag,
'container_id': self.container_id,
'total_steps': self.total_steps,
'current_step': self.current_step,
'current_step_name': self.current_step_name,
+10
View File
@@ -29,6 +29,13 @@ class EmailProviderConnection(db.Model):
is_default = db.Column(db.Boolean, default=False, index=True)
is_active = db.Column(db.Boolean, default=True)
# Usage flags (§6 unification): one connection can power the Notification Bus
# and/or the Postfix outbound relay. API providers (SendGrid/Postmark/SES/
# Mailgun) can't be a Postfix smarthost, so only 'smtp' rows set uses_relay.
uses_notifications = db.Column(db.Boolean, default=True, nullable=False)
uses_relay = db.Column(db.Boolean, default=False, nullable=False)
relay_priority = db.Column(db.Integer, default=0, nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -59,6 +66,9 @@ class EmailProviderConnection(db.Model):
'from_name': self.from_name,
'is_default': self.is_default,
'is_active': self.is_active,
'uses_notifications': self.uses_notifications,
'uses_relay': self.uses_relay,
'relay_priority': self.relay_priority,
'configured_fields': sorted(creds.keys()),
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_tested_at': self.last_tested_at.isoformat() if self.last_tested_at else None,
+42 -3
View File
@@ -75,14 +75,28 @@ class EmailProviderService:
@classmethod
def default_provider(cls):
"""The active default provider, or the first active one, or None."""
row = EmailProviderConnection.query.filter_by(is_default=True, is_active=True).first()
"""The provider the Notification Bus should send through: the active
default, else the first active one — both restricted to providers
flagged ``uses_notifications`` so relay-only rows are never picked (§6)."""
row = EmailProviderConnection.query.filter_by(
is_default=True, is_active=True, uses_notifications=True).first()
if row:
return row
return EmailProviderConnection.query.filter_by(is_active=True).order_by(
return EmailProviderConnection.query.filter_by(
is_active=True, uses_notifications=True).order_by(
EmailProviderConnection.created_at.asc()
).first()
@classmethod
def relay_provider(cls):
"""The active SMTP connection that should drive the Postfix relay:
highest relay_priority first, then oldest. None if no relay is flagged."""
return EmailProviderConnection.query.filter_by(
uses_relay=True, is_active=True).order_by(
EmailProviderConnection.relay_priority.desc(),
EmailProviderConnection.created_at.asc(),
).first()
@classmethod
def add_provider(cls, data, user_id=None):
provider = (data.get('provider') or '').lower().strip()
@@ -106,6 +120,9 @@ class EmailProviderService:
if make_default:
EmailProviderConnection.query.update({EmailProviderConnection.is_default: False})
# Usage flags (§6). API providers cannot be a Postfix smarthost, so
# uses_relay is only honored for SMTP.
uses_relay = bool(data.get('uses_relay')) and provider == 'smtp'
row = EmailProviderConnection(
provider=provider,
name=data.get('name') or spec['name'],
@@ -114,12 +131,34 @@ class EmailProviderService:
from_name=(data.get('from_name') or 'ServerKit').strip(),
is_default=make_default,
is_active=data.get('is_active', True),
uses_notifications=bool(data.get('uses_notifications', True)),
uses_relay=uses_relay,
relay_priority=int(data.get('relay_priority') or 0),
created_by=user_id,
)
db.session.add(row)
db.session.commit()
return row
@classmethod
def update_usage(cls, provider_id, fields):
"""Toggle a provider's usage flags (§6): uses_notifications, uses_relay,
relay_priority. uses_relay is only honored for SMTP connections."""
row = cls.get_provider(provider_id)
if not row:
return None
if 'uses_notifications' in fields:
row.uses_notifications = bool(fields['uses_notifications'])
if 'uses_relay' in fields:
row.uses_relay = bool(fields['uses_relay']) and row.provider == 'smtp'
if 'relay_priority' in fields and fields['relay_priority'] is not None:
try:
row.relay_priority = int(fields['relay_priority'])
except (TypeError, ValueError):
pass
db.session.commit()
return row
@classmethod
def set_default(cls, provider_id):
row = cls.get_provider(provider_id)
+132 -2
View File
@@ -49,13 +49,35 @@ class BackupPolicyService:
target_type=target_type, target_id=int(target_id)
).first()
@staticmethod
def validate_target_type(target_type):
"""Reject unknown target types (maps to HTTP 400)."""
from app.models.backup_policy import VALID_TARGET_TYPES
if target_type not in VALID_TARGET_TYPES:
raise BackupPolicyError(
f"target_type must be one of {VALID_TARGET_TYPES}, got {target_type!r}"
)
@classmethod
def get_or_create_policy(cls, target_type, target_id):
def get_or_create_policy(cls, target_type, target_id, target_subtype=None, target_meta=None):
from app.models.backup_policy import BackupPolicy
cls.validate_target_type(target_type)
policy = cls.get_policy(target_type, target_id)
if policy:
# Refresh target descriptors for non-resource targets (database/files)
# whose connection/path details can change between calls.
if target_subtype is not None:
policy.target_subtype = target_subtype
if target_meta is not None:
policy.set_target_meta(target_meta)
if target_subtype is not None or target_meta is not None:
db.session.commit()
return policy
policy = BackupPolicy(target_type=target_type, target_id=int(target_id))
if target_subtype is not None:
policy.target_subtype = target_subtype
if target_meta is not None:
policy.set_target_meta(target_meta)
db.session.add(policy)
db.session.commit()
cls.ensure_scheduled_job(policy)
@@ -180,7 +202,8 @@ class BackupPolicyService:
def _resolve_target(cls, policy):
"""Return a dict describing the live target, or raise if missing.
keys: name, root_path, target_type, site (wp only), app, db_config (wp).
keys: name, root_path, target_type, site (wp only), app, plus
db_config (database) / file_paths (files) for the non-resource targets.
"""
if policy.target_type == 'wordpress_site':
from app.models.wordpress_site import WordPressSite
@@ -194,6 +217,38 @@ class BackupPolicyService:
'name': app.name, 'root_path': app.root_path,
'target_type': 'wordpress_site', 'site': site, 'app': app,
}
if policy.target_type == 'database':
meta = policy.get_target_meta()
db_name = meta.get('db_name')
if not db_name:
raise BackupPolicyError('Database target is missing db_name')
return {
'name': db_name, 'root_path': None,
'target_type': 'database', 'site': None, 'app': None,
'db_config': {
'db_type': policy.target_subtype or meta.get('db_type') or 'mysql',
'db_name': db_name,
'user': meta.get('user'),
'password': meta.get('password'),
'host': meta.get('host', 'localhost'),
},
}
if policy.target_type == 'files':
meta = policy.get_target_meta()
file_paths = meta.get('paths') or []
if not file_paths:
raise BackupPolicyError('Files target has no paths configured')
return {
'name': meta.get('label') or f'files-{policy.target_id}',
'root_path': None, 'target_type': 'files',
'site': None, 'app': None, 'file_paths': list(file_paths),
}
if policy.target_type == 'server':
raise BackupPolicyError('Whole-server backups are not yet implemented')
# application
from app.models.application import Application
app = Application.query.get(policy.target_id)
@@ -281,6 +336,42 @@ class BackupPolicyService:
})
return storage_path, size, meta
if target['target_type'] == 'database':
from app.services.backup_service import BackupService
cfg = target['db_config']
result = BackupService.backup_database(
db_type=cfg['db_type'], db_name=cfg['db_name'],
user=cfg.get('user'), password=cfg.get('password'),
host=cfg.get('host', 'localhost'),
)
if not result.get('success'):
raise BackupPolicyError(result.get('error') or 'Database backup failed')
backup = result.get('backup') or {}
storage_path = backup.get('path')
size = backup.get('size') or cls._path_size(storage_path)
meta.update({
'kind': 'full', 'compression': 'gzip', 'incremental': False,
'includes': ['database'], 'backup_name': backup.get('name'),
'primary_archive': storage_path,
'db_type': cfg['db_type'], 'db_name': cfg['db_name'],
})
return storage_path, size, meta
if target['target_type'] == 'files':
from app.services.backup_service import BackupService
result = BackupService.backup_files(target['file_paths'], target['name'])
if not result.get('success'):
raise BackupPolicyError(result.get('error') or 'File backup failed')
backup = result.get('backup') or {}
storage_path = result.get('path') or backup.get('path')
size = backup.get('size') or cls._path_size(storage_path)
meta.update({
'kind': 'full', 'compression': 'gzip', 'incremental': False,
'includes': ['files'], 'backup_name': backup.get('name'),
'primary_archive': storage_path, 'paths': target['file_paths'],
})
return storage_path, size, meta
# application — smart incremental + compression tiering
from app.models.backup_run import BackupRun
from app.services.backup_service import BackupService
@@ -567,6 +658,10 @@ class BackupPolicyService:
if target['target_type'] == 'wordpress_site':
cls._restore_wordpress(target, run, scope, payload)
elif target['target_type'] == 'database':
cls._restore_database(target, run)
elif target['target_type'] == 'files':
cls._restore_files(target, run, payload)
else:
cls._restore_application(policy, target, run, scope)
@@ -637,6 +732,41 @@ class BackupPolicyService:
if not result.get('success'):
raise BackupPolicyError(result.get('error') or 'Restore failed')
@classmethod
def _restore_database(cls, target, run):
"""Restore a standalone database from its dump (mysql/postgres/etc.)."""
from app.services.backup_service import BackupService
cfg = target['db_config']
backup_path = (run.get_metadata() or {}).get('primary_archive') or run.storage_path
if not backup_path or not os.path.exists(backup_path):
raise BackupPolicyError('Backup archive not found')
result = BackupService.restore_database(
backup_path=backup_path, db_type=cfg['db_type'], db_name=cfg['db_name'],
user=cfg.get('user'), password=cfg.get('password'),
host=cfg.get('host', 'localhost'),
)
if not result.get('success'):
raise BackupPolicyError(result.get('error') or 'Database restore failed')
@classmethod
def _restore_files(cls, target, run, payload):
"""Extract a files backup. Restores into an explicit ``restore_path``
(from options) or a safe ``restores/`` staging dir — never blindly over
the originals, since a path-list archive only preserves basenames."""
import tarfile
from app.services.backup_service import BackupService
backup_path = (run.get_metadata() or {}).get('primary_archive') or run.storage_path
if not backup_path or not os.path.exists(backup_path):
raise BackupPolicyError('Backup archive not found')
dest = (payload or {}).get('restore_path')
if not dest:
dest = os.path.join(BackupService.BACKUP_BASE_DIR, 'restores',
f'files-{run.id}')
os.makedirs(dest, exist_ok=True)
with tarfile.open(backup_path, 'r:gz') as tar:
tar.extractall(dest, filter='data')
return {'restore_path': dest}
@classmethod
def _chain_archives(cls, policy, run):
"""Ordered list of local archive paths needed to restore ``run`` — the
+36 -1
View File
@@ -66,6 +66,37 @@ class DockerService:
# ==================== CONTAINER MANAGEMENT ====================
# ServerKit's own infrastructure containers run the panel itself, so their
# lifecycle must never be controllable from the Docker page — stopping,
# restarting or removing one would take ServerKit offline. Matched as a
# substring (case-insensitive) so both compose project names and the bare
# service names ('serverkit-backend', 'serverkit_frontend', ...) are caught.
PROTECTED_CONTAINER_NAMES = (
'serverkit-frontend', 'serverkit_frontend',
'serverkit-backend', 'serverkit_backend',
'serverkit',
)
@staticmethod
def is_protected_name(name):
"""True if a container name belongs to ServerKit's own infrastructure."""
if not name:
return False
normalized = str(name).lower().replace('/', '')
return any(p in normalized for p in DockerService.PROTECTED_CONTAINER_NAMES)
@staticmethod
def is_protected_container(container_id):
"""Resolve a container id/name to its real name and check protection.
Accepts either an id or a name. Uses `docker inspect`, whose top-level
``Name`` key is the canonical name (e.g. ``/serverkit-backend``).
"""
container = DockerService.get_container(container_id)
name = (container or {}).get('Name', '') if container else ''
# Fall back to the id itself when the user passed a name directly.
return DockerService.is_protected_name(name) or DockerService.is_protected_name(container_id)
@staticmethod
def list_containers(all_containers=True):
"""List Docker containers."""
@@ -82,15 +113,19 @@ class DockerService:
for line in result.stdout.strip().split('\n'):
if line:
container = json.loads(line)
name = container.get('Names')
containers.append({
'id': container.get('ID'),
'name': container.get('Names'),
'name': name,
'image': container.get('Image'),
'status': container.get('Status'),
'state': container.get('State'),
'ports': container.get('Ports'),
'created': container.get('CreatedAt'),
'size': container.get('Size'),
# Flag ServerKit's own containers so the UI can hide
# lifecycle controls; the API rejects them regardless.
'protected': DockerService.is_protected_name(name),
})
return containers
except Exception as e:
+146 -47
View File
@@ -1,23 +1,33 @@
"""Outbound SMTP relay (smarthost) for the mail server.
Persists a single relay configuration (host/port/credentials, password
Fernet-encrypted) and applies it to Postfix when present. The config is stored
platform-agnostically so the dev panel can hold it; the Postfix apply is a no-op
(with a note) on hosts where Postfix isn't installed. `test()` opens a real SMTP
connection so credentials are validated on any OS.
The relay is now one row of the unified ``EmailProviderConnection`` table — an
``smtp`` connection flagged ``uses_relay`` (§6 unification) — so a single store
drives both the Notification Bus and the Postfix relay. The legacy single-row
``email_relay_config`` is migrated in on first boot and kept only as a read
fallback during the deprecation window.
Config is held platform-agnostically so the dev panel can hold it; the Postfix
apply is a no-op (with a note) where Postfix isn't installed. ``test()`` opens a
real SMTP connection so credentials validate on any OS.
"""
import ssl
import json
import smtplib
import logging
from app import db
from app.models.email import EmailRelayConfig
from app.models.email_provider import EmailProviderConnection
from app.notifications.providers import EmailProviderService
from app.services.postfix_service import PostfixService
from app.utils.crypto import encrypt_secret, decrypt_secret
from app.utils.crypto import encrypt_secret
logger = logging.getLogger(__name__)
# The relay connection is created/owned by this service.
RELAY_PROVIDER_NAME = 'Postfix Relay'
def _is_masked(value):
"""True for our masking sentinels, so a round-tripped masked value never
@@ -27,84 +37,138 @@ def _is_masked(value):
return value.startswith('****') or set(value) == {'*'}
def _coerce_port(value, default=587):
try:
return int(value or default)
except (TypeError, ValueError):
return default
def _coerce_tls(value):
if isinstance(value, str):
return value.lower() not in ('0', 'false', 'no')
return bool(value) if value is not None else True
class EmailRelayService:
DEFAULT = {
'enabled': False, 'host': '', 'port': 587, 'username': '',
'use_tls': True, 'provider_hint': None, 'password_set': False,
}
# ------------------------------------------------------------------ #
# The relay connection (EmailProviderConnection, uses_relay=True)
# ------------------------------------------------------------------ #
@staticmethod
def _row():
return EmailRelayConfig.query.first()
def _relay_row():
"""The canonical relay connection regardless of active state (for
display/edit). Active-only selection for *applying* lives in
EmailProviderService.relay_provider()."""
return (EmailProviderConnection.query
.filter_by(uses_relay=True)
.order_by(EmailProviderConnection.relay_priority.desc(),
EmailProviderConnection.created_at.asc())
.first())
@staticmethod
def _view_from_provider(row):
"""Public relay view from a connection — never exposes the password."""
creds = row.credentials() # decrypted; non-secret fields pass through
return {
'enabled': bool(row.is_active),
'host': creds.get('host') or '',
'port': _coerce_port(creds.get('port')),
'username': creds.get('username') or '',
'use_tls': _coerce_tls(creds.get('use_tls')),
'provider_hint': creds.get('provider_hint'),
'password_set': bool(creds.get('password')),
'updated_at': row.updated_at.isoformat() if row.updated_at else None,
'provider_id': row.id,
}
@classmethod
def get_config(cls):
row = cls._row()
return row.to_dict() if row else dict(cls.DEFAULT)
row = cls._relay_row()
if row:
return cls._view_from_provider(row)
# Back-compat: a legacy single-row config not yet migrated.
legacy = EmailRelayConfig.query.first()
if legacy:
return legacy.to_dict()
return dict(cls.DEFAULT)
@classmethod
def save_config(cls, data):
row = cls._row() or EmailRelayConfig()
row.host = (data.get('host') or '').strip()
try:
row.port = int(data.get('port') or 587)
except (TypeError, ValueError):
row.port = 587
row.username = (data.get('username') or '').strip()
row.use_tls = bool(data.get('use_tls', True))
row.enabled = bool(data.get('enabled', True))
row.provider_hint = data.get('provider_hint')
row = cls._relay_row()
creds = row.raw_credentials() if row else {}
creds['host'] = (data.get('host') or '').strip()
creds['port'] = _coerce_port(data.get('port'))
creds['username'] = (data.get('username') or '').strip()
creds['use_tls'] = bool(data.get('use_tls', True))
if data.get('provider_hint') is not None:
creds['provider_hint'] = data.get('provider_hint')
pw = data.get('password')
if pw and not _is_masked(pw):
row.password_encrypted = encrypt_secret(pw)
if row.id is None:
creds['password'] = encrypt_secret(pw)
enabled = bool(data.get('enabled', True))
if row is None:
row = EmailProviderConnection(
provider='smtp', name=RELAY_PROVIDER_NAME,
uses_relay=True, uses_notifications=False, relay_priority=0,
)
db.session.add(row)
row.credentials_json = json.dumps(creds)
row.is_active = enabled
row.uses_relay = True
db.session.commit()
return {'config': row.to_dict(), 'apply': cls.apply(row)}
return {'config': cls._view_from_provider(row), 'apply': cls.apply()}
@classmethod
def apply(cls, row=None):
"""Push the stored config to Postfix (if installed)."""
row = row or cls._row()
if not row:
return {'applied': False, 'note': 'No relay configured'}
def apply(cls):
"""Push the active relay connection to Postfix (if installed)."""
provider = EmailProviderService.relay_provider() # active relay only
status = PostfixService.get_status()
if not status.get('installed'):
return {'applied': False, 'note': 'Saved. Postfix is not installed here — the relay applies on a configured mail server.'}
if row.enabled and row.host:
password = decrypt_secret(row.password_encrypted) if row.password_encrypted else ''
result = PostfixService.configure_relay(row.host, row.port, row.username, password, row.use_tls)
else:
result = PostfixService.disable_relay()
return {'applied': False,
'note': 'Saved. Postfix is not installed here — the relay applies on a configured mail server.'}
if provider:
creds = provider.credentials()
host = creds.get('host')
if host:
result = PostfixService.configure_relay(
host, _coerce_port(creds.get('port')), creds.get('username') or '',
creds.get('password') or '', _coerce_tls(creds.get('use_tls')))
result['applied'] = result.get('success', False)
return result
result = PostfixService.disable_relay()
result['applied'] = result.get('success', False)
return result
@classmethod
def disable(cls):
row = cls._row()
row = cls._relay_row()
if row:
row.enabled = False
row.is_active = False
db.session.commit()
return cls.apply(row)
return cls.apply()
@classmethod
def test(cls, data):
"""Open a real SMTP connection to validate host/port/credentials."""
row = cls._row()
host = (data.get('host') or (row.host if row else '') or '').strip()
row = cls._relay_row()
creds = row.credentials() if row else {}
host = (data.get('host') or creds.get('host') or '').strip()
if not host:
return {'success': False, 'error': 'A relay host is required'}
try:
port = int(data.get('port') or (row.port if row else 587))
except (TypeError, ValueError):
port = 587
username = data.get('username') or (row.username if row else '')
use_tls = data.get('use_tls', row.use_tls if row else True)
port = _coerce_port(data.get('port') or creds.get('port'))
username = data.get('username') or creds.get('username') or ''
use_tls = _coerce_tls(data.get('use_tls', creds.get('use_tls', True)))
password = data.get('password')
if password and _is_masked(password):
password = None
if not password and row and row.password_encrypted:
password = decrypt_secret(row.password_encrypted)
if not password:
password = creds.get('password') # already decrypted by credentials()
try:
if port == 465:
@@ -121,3 +185,38 @@ class EmailRelayService:
return {'success': True, 'message': f'Connected to {host}:{port}'}
except Exception as e:
return {'success': False, 'error': str(e)}
# ------------------------------------------------------------------ #
# One-time legacy migration
# ------------------------------------------------------------------ #
@classmethod
def migrate_legacy_config(cls):
"""Idempotently fold a legacy ``email_relay_config`` row into a
``uses_relay`` EmailProviderConnection. No-op if a relay connection
already exists or there is no usable legacy row. The legacy password is
already Fernet-encrypted with the same key, so it copies verbatim."""
if cls._relay_row() is not None:
return False
legacy = EmailRelayConfig.query.first()
if not legacy or not (legacy.host or '').strip():
return False
creds = {
'host': legacy.host or '',
'port': legacy.port or 587,
'username': legacy.username or '',
'use_tls': bool(legacy.use_tls),
}
if legacy.provider_hint:
creds['provider_hint'] = legacy.provider_hint
if legacy.password_encrypted:
creds['password'] = legacy.password_encrypted # already encrypted
row = EmailProviderConnection(
provider='smtp', name=RELAY_PROVIDER_NAME,
credentials_json=json.dumps(creds),
uses_relay=True, uses_notifications=False,
is_active=bool(legacy.enabled), relay_priority=0,
)
db.session.add(row)
db.session.commit()
logger.info('Migrated legacy email relay config into EmailProviderConnection %s', row.id)
return True
@@ -0,0 +1,52 @@
"""Generalize BackupPolicy to all data-protection targets (§8 unification).
Adds:
- backup_policies.target_subtype (e.g. 'mysql' for a database target)
- backup_policies.target_meta_json (per-target details: db descriptor, path list)
These let the single BackupPolicy/BackupRun system cover database, files, and
server targets in addition to application/wordpress_site, folding in the legacy
BackupService responsibilities.
Idempotent: MigrationService runs _fix_missing_columns() + db.create_all() on
boot before Alembic, so guard on the live schema.
Revision ID: 037_backup_target_types
Revises: 036_env_target_service
Create Date: 2026-06-24
"""
from alembic import op
import sqlalchemy as sa
revision = '037_backup_target_types'
down_revision = '036_env_target_service'
branch_labels = None
depends_on = None
def _add_col(inspector, table, col, column):
if table in inspector.get_table_names():
cols = {c['name'] for c in inspector.get_columns(table)}
if col not in cols:
op.add_column(table, column)
def _drop_col(inspector, table, col):
if table in inspector.get_table_names():
cols = {c['name'] for c in inspector.get_columns(table)}
if col in cols:
op.drop_column(table, col)
def upgrade():
inspector = sa.inspect(op.get_bind())
_add_col(inspector, 'backup_policies', 'target_subtype',
sa.Column('target_subtype', sa.String(40), nullable=True))
_add_col(inspector, 'backup_policies', 'target_meta_json',
sa.Column('target_meta_json', sa.Text(), nullable=True, server_default='{}'))
def downgrade():
inspector = sa.inspect(op.get_bind())
_drop_col(inspector, 'backup_policies', 'target_meta_json')
_drop_col(inspector, 'backup_policies', 'target_subtype')
@@ -0,0 +1,57 @@
"""Email provider usage flags (§6 unification).
Adds to email_provider_connections:
- uses_notifications (bool, default true)
- uses_relay (bool, default false)
- relay_priority (int, default 0)
So a single connection can drive both the Notification Bus and the Postfix
outbound relay, retiring the separate single-row email_relay_config.
Idempotent: MigrationService runs _fix_missing_columns() + db.create_all() on
boot before Alembic, so guard on the live schema.
Revision ID: 038_email_provider_usage_flags
Revises: 037_backup_target_types
Create Date: 2026-06-24
"""
from alembic import op
import sqlalchemy as sa
revision = '038_email_provider_usage_flags'
down_revision = '037_backup_target_types'
branch_labels = None
depends_on = None
TABLE = 'email_provider_connections'
def _add_col(inspector, col, column):
if TABLE in inspector.get_table_names():
cols = {c['name'] for c in inspector.get_columns(TABLE)}
if col not in cols:
op.add_column(TABLE, column)
def _drop_col(inspector, col):
if TABLE in inspector.get_table_names():
cols = {c['name'] for c in inspector.get_columns(TABLE)}
if col in cols:
op.drop_column(TABLE, col)
def upgrade():
inspector = sa.inspect(op.get_bind())
_add_col(inspector, 'uses_notifications',
sa.Column('uses_notifications', sa.Boolean(), nullable=False, server_default=sa.true()))
_add_col(inspector, 'uses_relay',
sa.Column('uses_relay', sa.Boolean(), nullable=False, server_default=sa.false()))
_add_col(inspector, 'relay_priority',
sa.Column('relay_priority', sa.Integer(), nullable=False, server_default='0'))
def downgrade():
inspector = sa.inspect(op.get_bind())
_drop_col(inspector, 'relay_priority')
_drop_col(inspector, 'uses_relay')
_drop_col(inspector, 'uses_notifications')
@@ -0,0 +1,60 @@
"""Link DeploymentJob to the release ledgers (§3 unification).
Adds to deployment_jobs:
- deployment_id (FK deployments.id)
- git_deployment_id (FK git_deployments.id)
- webhook_id (FK git_webhooks.id)
- commit_hash, image_tag, container_id
Makes DeploymentJob the canonical execution record that can point at the
Deployment / GitDeployment release rows it produces. All nullable, additive.
Idempotent: MigrationService runs _fix_missing_columns() + db.create_all() on
boot before Alembic, so guard on the live schema. SQLite can't add FK
constraints after the fact, so the columns are added as plain integers — the
ORM-side relationships still resolve, matching how create_all builds a fresh DB.
Revision ID: 039_deployment_job_links
Revises: 038_email_provider_usage_flags
Create Date: 2026-06-24
"""
from alembic import op
import sqlalchemy as sa
revision = '039_deployment_job_links'
down_revision = '038_email_provider_usage_flags'
branch_labels = None
depends_on = None
TABLE = 'deployment_jobs'
def _add_col(inspector, col, column):
if TABLE in inspector.get_table_names():
cols = {c['name'] for c in inspector.get_columns(TABLE)}
if col not in cols:
op.add_column(TABLE, column)
def _drop_col(inspector, col):
if TABLE in inspector.get_table_names():
cols = {c['name'] for c in inspector.get_columns(TABLE)}
if col in cols:
op.drop_column(TABLE, col)
def upgrade():
inspector = sa.inspect(op.get_bind())
_add_col(inspector, 'deployment_id', sa.Column('deployment_id', sa.Integer(), nullable=True))
_add_col(inspector, 'git_deployment_id', sa.Column('git_deployment_id', sa.Integer(), nullable=True))
_add_col(inspector, 'webhook_id', sa.Column('webhook_id', sa.Integer(), nullable=True))
_add_col(inspector, 'commit_hash', sa.Column('commit_hash', sa.String(40), nullable=True))
_add_col(inspector, 'image_tag', sa.Column('image_tag', sa.String(255), nullable=True))
_add_col(inspector, 'container_id', sa.Column('container_id', sa.String(100), nullable=True))
def downgrade():
inspector = sa.inspect(op.get_bind())
for col in ('container_id', 'image_tag', 'commit_hash', 'webhook_id',
'git_deployment_id', 'deployment_id'):
_drop_col(inspector, col)
@@ -0,0 +1,87 @@
"""§7 unification — WordPress reachable through the generic /apps surface.
A WordPress site is a 1:1 extension of an Application. These additive routes
expose the linkage (/apps/:id/wordpress) and the WP DB snapshots
(/apps/:id/db-snapshots) under the app id WITHOUT changing app_type, so the
docker-compose paths that key on app_type=='docker' are unaffected.
"""
import pytest
def _seed_app(app, *, wordpress=True):
from app import db
from app.models import User
from app.models.application import Application
from app.models.wordpress_site import WordPressSite
with app.app_context():
admin = User.query.filter_by(username='testadmin').first()
a = Application(name='wp-app', app_type='docker', status='running',
root_path='/srv/wp', user_id=admin.id if admin else 1)
db.session.add(a)
db.session.commit()
site_id = None
if wordpress:
site = WordPressSite(application_id=a.id, db_name='wpdb', db_user='wpuser',
db_host='localhost', is_production=True,
environment_type='production', wp_version='6.5')
db.session.add(site)
db.session.commit()
site_id = site.id
return a.id, site_id
def test_app_wordpress_info_true(app, client, auth_headers):
app_id, site_id = _seed_app(app, wordpress=True)
res = client.get(f'/api/v1/apps/{app_id}/wordpress', headers=auth_headers)
assert res.status_code == 200
body = res.get_json()
assert body['is_wordpress'] is True
assert body['site_id'] == site_id
assert body['is_production'] is True
def test_app_wordpress_info_false_for_plain_app(app, client, auth_headers):
app_id, _ = _seed_app(app, wordpress=False)
res = client.get(f'/api/v1/apps/{app_id}/wordpress', headers=auth_headers)
assert res.status_code == 200
assert res.get_json()['is_wordpress'] is False
def test_db_snapshots_rejects_non_wordpress(app, client, auth_headers):
app_id, _ = _seed_app(app, wordpress=False)
res = client.get(f'/api/v1/apps/{app_id}/db-snapshots', headers=auth_headers)
assert res.status_code == 400
def test_db_snapshots_list_empty(app, client, auth_headers):
app_id, _ = _seed_app(app, wordpress=True)
res = client.get(f'/api/v1/apps/{app_id}/db-snapshots', headers=auth_headers)
assert res.status_code == 200
assert res.get_json()['snapshots'] == []
def test_db_snapshot_create_and_delete(app, client, auth_headers, monkeypatch):
from app.services.db_sync_service import DatabaseSyncService
from app.services.wordpress_env_service import WordPressEnvService
monkeypatch.setattr(WordPressEnvService, '_get_db_password', staticmethod(lambda site: 'pw'))
monkeypatch.setattr(DatabaseSyncService, 'create_snapshot', staticmethod(
lambda **kw: {'success': True, 'snapshot': {
'name': kw.get('name'), 'file_path': '/var/backups/snap.sql.gz',
'size_bytes': 1234, 'compressed': True, 'tables': ['wp_posts'], 'row_count': 10,
}}))
monkeypatch.setattr(DatabaseSyncService, 'upload_snapshot_offsite', staticmethod(lambda p: None))
monkeypatch.setattr(DatabaseSyncService, 'delete_snapshot', staticmethod(lambda p: True))
app_id, _ = _seed_app(app, wordpress=True)
created = client.post(f'/api/v1/apps/{app_id}/db-snapshots',
json={'name': 'nightly', 'tag': 'manual'}, headers=auth_headers)
assert created.status_code == 201, created.get_json()
snap_id = created.get_json()['snapshot']['id']
listed = client.get(f'/api/v1/apps/{app_id}/db-snapshots', headers=auth_headers)
assert listed.get_json()['total'] == 1
deleted = client.delete(f'/api/v1/apps/{app_id}/db-snapshots/{snap_id}', headers=auth_headers)
assert deleted.status_code == 200
assert client.get(f'/api/v1/apps/{app_id}/db-snapshots', headers=auth_headers).get_json()['total'] == 0
@@ -0,0 +1,125 @@
"""§8 Phase B2 — database + files executors in the unified backup service.
Verifies that BackupPolicyService routes database/files targets to the right
BackupService primitives and builds correct run metadata, without needing a
live database or real archives (BackupService is mocked).
"""
import pytest
from app.services.backup_policy_service import BackupPolicyService, BackupPolicyError
# --- target resolution ----------------------------------------------------- #
def test_resolve_database_target(app):
with app.app_context():
policy = BackupPolicyService.get_or_create_policy(
'database', 7, target_subtype='postgresql',
target_meta={'db_name': 'shop', 'user': 'app', 'host': 'db1'},
)
target = BackupPolicyService._resolve_target(policy)
assert target['target_type'] == 'database'
assert target['name'] == 'shop'
assert target['db_config']['db_type'] == 'postgresql' # from subtype
assert target['db_config']['db_name'] == 'shop'
assert target['db_config']['host'] == 'db1'
def test_resolve_database_requires_db_name(app):
with app.app_context():
policy = BackupPolicyService.get_or_create_policy('database', 8, target_meta={})
with pytest.raises(BackupPolicyError):
BackupPolicyService._resolve_target(policy)
def test_resolve_files_target(app):
with app.app_context():
policy = BackupPolicyService.get_or_create_policy(
'files', 3, target_meta={'paths': ['/etc/nginx', '/srv/data'], 'label': 'configs'},
)
target = BackupPolicyService._resolve_target(policy)
assert target['target_type'] == 'files'
assert target['name'] == 'configs'
assert target['file_paths'] == ['/etc/nginx', '/srv/data']
def test_resolve_files_requires_paths(app):
with app.app_context():
policy = BackupPolicyService.get_or_create_policy('files', 4, target_meta={'paths': []})
with pytest.raises(BackupPolicyError):
BackupPolicyService._resolve_target(policy)
def test_resolve_server_not_implemented(app):
with app.app_context():
policy = BackupPolicyService.get_or_create_policy('server', 1)
with pytest.raises(BackupPolicyError):
BackupPolicyService._resolve_target(policy)
# --- execution routing (BackupService mocked) ------------------------------ #
def test_execute_database_backup_routes_and_builds_meta(app, monkeypatch):
from app.services.backup_service import BackupService
calls = {}
def fake_backup_database(db_type, db_name, user=None, password=None, host='localhost'):
calls['db'] = (db_type, db_name, user, host)
return {'success': True, 'backup': {'name': f'{db_name}.sql.gz',
'path': '/var/backups/databases/shop.sql.gz',
'size': 4096}}
monkeypatch.setattr(BackupService, 'backup_database', staticmethod(fake_backup_database))
monkeypatch.setattr(BackupPolicyService, '_path_size', staticmethod(lambda p: 4096))
with app.app_context():
policy = BackupPolicyService.get_or_create_policy(
'database', 11, target_subtype='mysql', target_meta={'db_name': 'shop'},
)
target = BackupPolicyService._resolve_target(policy)
storage_path, size, meta = BackupPolicyService._execute_backup(policy, target, 'full')
assert calls['db'][0] == 'mysql'
assert calls['db'][1] == 'shop'
assert storage_path.endswith('shop.sql.gz')
assert size == 4096
assert meta['includes'] == ['database']
assert meta['kind'] == 'full'
assert meta['primary_archive'] == storage_path
def test_execute_files_backup_routes_and_builds_meta(app, monkeypatch):
from app.services.backup_service import BackupService
calls = {}
def fake_backup_files(file_paths, backup_name=None):
calls['files'] = (list(file_paths), backup_name)
return {'success': True, 'path': '/var/backups/files/configs_x.tar.gz',
'backup': {'name': 'configs_x.tar.gz', 'size': 2048}}
monkeypatch.setattr(BackupService, 'backup_files', staticmethod(fake_backup_files))
with app.app_context():
policy = BackupPolicyService.get_or_create_policy(
'files', 12, target_meta={'paths': ['/etc/nginx'], 'label': 'configs'},
)
target = BackupPolicyService._resolve_target(policy)
storage_path, size, meta = BackupPolicyService._execute_backup(policy, target, 'full')
assert calls['files'][0] == ['/etc/nginx']
assert storage_path.endswith('configs_x.tar.gz')
assert size == 2048
assert meta['includes'] == ['files']
assert meta['paths'] == ['/etc/nginx']
def test_execute_database_backup_propagates_failure(app, monkeypatch):
from app.services.backup_service import BackupService
monkeypatch.setattr(BackupService, 'backup_database',
staticmethod(lambda **kw: {'success': False, 'error': 'mysqldump missing'}))
with app.app_context():
policy = BackupPolicyService.get_or_create_policy(
'database', 13, target_subtype='mysql', target_meta={'db_name': 'x'})
target = BackupPolicyService._resolve_target(policy)
with pytest.raises(BackupPolicyError):
BackupPolicyService._execute_backup(policy, target, 'full')
+51
View File
@@ -0,0 +1,51 @@
"""§8 Phase B5 — generic backup-policy CRUD by (target_type, target_id).
One admin surface drives policies for every target type, including the
database/files/server targets that have no per-resource page. The existing
/apps/:id/backup-policy and /wordpress/sites/:id/backup-policy mounts are
unchanged.
"""
def test_put_then_get_database_policy(app, client, auth_headers):
body = {
'enabled': True,
'schedule_cron': '0 3 * * *',
'target_subtype': 'mysql',
'target_meta': {'db_name': 'shop', 'host': 'localhost'},
}
put = client.put('/api/v1/backups/policies/database/5', json=body, headers=auth_headers)
assert put.status_code == 200, put.get_json()
view = put.get_json()
assert view['policy']['target_type'] == 'database'
assert view['policy']['target_subtype'] == 'mysql'
assert view['policy']['target_meta']['db_name'] == 'shop'
assert view['policy']['enabled'] is True
get = client.get('/api/v1/backups/policies/database/5', headers=auth_headers)
assert get.status_code == 200
assert get.get_json()['policy']['schedule_cron'] == '0 3 * * *'
def test_get_creates_default_files_policy(app, client, auth_headers):
get = client.get('/api/v1/backups/policies/files/9', headers=auth_headers)
assert get.status_code == 200
assert get.get_json()['policy']['target_type'] == 'files'
assert get.get_json()['policy']['enabled'] is False
def test_invalid_target_type_rejected(app, client, auth_headers):
res = client.get('/api/v1/backups/policies/bogus/1', headers=auth_headers)
assert res.status_code == 400
def test_list_runs_empty_for_new_policy(app, client, auth_headers):
res = client.get('/api/v1/backups/policies/files/77/runs', headers=auth_headers)
assert res.status_code == 200
assert res.get_json()['runs'] == []
def test_generic_surface_requires_admin(app, client):
# No auth → rejected (matches the rest of /backups).
res = client.get('/api/v1/backups/policies/database/1')
assert res.status_code in (401, 422)
+72
View File
@@ -0,0 +1,72 @@
"""§8 Phase B3 — global, queryable backup policy/run listing endpoints.
GET /api/v1/backups/policies and /api/v1/backups/runs give the unified Backups
page one cross-target view, replacing the legacy per-type filesystem scans.
"""
from datetime import datetime
def _seed(app):
from app import db
from app.models.backup_policy import BackupPolicy
from app.models.backup_run import BackupRun
with app.app_context():
app_policy = BackupPolicy(target_type='application', target_id=1, enabled=True)
db_policy = BackupPolicy(target_type='database', target_id=2, target_subtype='mysql',
enabled=False)
db.session.add_all([app_policy, db_policy])
db.session.commit()
db.session.add(BackupRun(policy_id=app_policy.id, kind='full', status='success',
started_at=datetime.utcnow(), size_local=100))
db.session.add(BackupRun(policy_id=db_policy.id, kind='full', status='failed',
started_at=datetime.utcnow(), size_local=0))
db.session.commit()
return app_policy.id, db_policy.id
def test_list_policies_returns_all(app, client, auth_headers):
_seed(app)
res = client.get('/api/v1/backups/policies', headers=auth_headers)
assert res.status_code == 200
policies = res.get_json()['policies']
types = {p['target_type'] for p in policies}
assert {'application', 'database'} <= types
def test_list_policies_filters_by_target_type(app, client, auth_headers):
_seed(app)
res = client.get('/api/v1/backups/policies?target_type=database', headers=auth_headers)
assert res.status_code == 200
policies = res.get_json()['policies']
assert policies and all(p['target_type'] == 'database' for p in policies)
def test_list_policies_filters_by_enabled(app, client, auth_headers):
_seed(app)
res = client.get('/api/v1/backups/policies?enabled=true', headers=auth_headers)
assert res.status_code == 200
policies = res.get_json()['policies']
assert policies and all(p['enabled'] for p in policies)
def test_list_policies_rejects_bad_target_type(app, client, auth_headers):
_seed(app)
res = client.get('/api/v1/backups/policies?target_type=bogus', headers=auth_headers)
assert res.status_code == 400
def test_list_runs_augments_target_info(app, client, auth_headers):
_seed(app)
res = client.get('/api/v1/backups/runs', headers=auth_headers)
assert res.status_code == 200
runs = res.get_json()['runs']
assert len(runs) == 2
assert all('target_type' in r and 'target_id' in r for r in runs)
def test_list_runs_filters_by_status_and_type(app, client, auth_headers):
_seed(app)
res = client.get('/api/v1/backups/runs?target_type=database&status=failed', headers=auth_headers)
assert res.status_code == 200
runs = res.get_json()['runs']
assert runs and all(r['target_type'] == 'database' and r['status'] == 'failed' for r in runs)
+69
View File
@@ -0,0 +1,69 @@
"""§8 unification — BackupPolicy generalized to all data-protection targets.
Covers the additive model foundation (target_subtype + target_meta_json) and
target-type validation that lets a single BackupPolicy/BackupRun system back up
databases, file path-lists, and servers alongside apps and WordPress sites.
"""
import pytest
from app.models.backup_policy import BackupPolicy, VALID_TARGET_TYPES
from app.services.backup_policy_service import BackupPolicyService, BackupPolicyError
def test_valid_target_types_include_new_targets():
for t in ('application', 'wordpress_site', 'database', 'files', 'server'):
assert t in VALID_TARGET_TYPES
def test_target_meta_round_trips(app):
with app.app_context():
p = BackupPolicy(target_type='database', target_id=5, target_subtype='mysql')
p.set_target_meta({'db_type': 'mysql', 'db_name': 'wp', 'host': 'localhost'})
from app import db
db.session.add(p)
db.session.commit()
reloaded = BackupPolicy.query.get(p.id)
assert reloaded.target_subtype == 'mysql'
assert reloaded.get_target_meta()['db_name'] == 'wp'
d = reloaded.to_dict()
assert d['target_subtype'] == 'mysql'
assert d['target_meta']['db_type'] == 'mysql'
def test_get_target_meta_defaults_to_empty_dict(app):
with app.app_context():
p = BackupPolicy(target_type='files', target_id=1)
assert p.get_target_meta() == {}
def test_validate_target_type_rejects_unknown():
with pytest.raises(BackupPolicyError):
BackupPolicyService.validate_target_type('nonsense')
# Accepts every known type without raising.
for t in VALID_TARGET_TYPES:
BackupPolicyService.validate_target_type(t)
def test_get_or_create_persists_subtype_and_meta(app):
with app.app_context():
policy = BackupPolicyService.get_or_create_policy(
'database', 42, target_subtype='postgresql',
target_meta={'db_type': 'postgresql', 'db_name': 'app'},
)
assert policy.id is not None
assert policy.target_subtype == 'postgresql'
assert policy.get_target_meta()['db_name'] == 'app'
# Second call updates descriptors on the same row (no duplicate).
again = BackupPolicyService.get_or_create_policy(
'database', 42, target_meta={'db_type': 'postgresql', 'db_name': 'renamed'},
)
assert again.id == policy.id
assert again.get_target_meta()['db_name'] == 'renamed'
def test_get_or_create_rejects_bad_target_type(app):
with app.app_context():
with pytest.raises(BackupPolicyError):
BackupPolicyService.get_or_create_policy('bogus', 1)
@@ -0,0 +1,25 @@
"""§8 Phase B4 — /config-checkpoints aliases /snapshots.
"Config Checkpoint" is the user-facing name for a deployment config snapshot.
The snapshots blueprint serves every route under both /snapshots and
/config-checkpoints so the API matches the new wording while old callers keep
working.
"""
def test_config_checkpoints_resolves_same_handler(app, client, auth_headers):
# Both prefixes hit the same view, so a missing app produces the identical
# 404 response on either — proof the alias resolves to the same handler.
snaps = client.get('/api/v1/apps/999999/snapshots', headers=auth_headers)
ckpts = client.get('/api/v1/apps/999999/config-checkpoints', headers=auth_headers)
assert snaps.status_code == 404
assert ckpts.status_code == 404
assert ckpts.get_json() == snaps.get_json()
def test_both_route_spaces_registered():
from app import create_app
application = create_app('testing')
rules = {r.rule for r in application.url_map.iter_rules()}
assert any('/snapshots' in r for r in rules)
assert any('/config-checkpoints' in r for r in rules)
+79
View File
@@ -0,0 +1,79 @@
"""§3 unification — DeploymentJob links + the unified /api/v1/deployments surface.
DeploymentJob is the canonical execution record (now able to point at the
Deployment/GitDeployment release rows). The /api/v1/deployments namespace gives
one federated read over versioned build deploys and webhook git deploys, and
re-mounts the deployment-jobs surface under /deployments/jobs.
"""
import uuid
def test_deployment_job_link_columns_round_trip(app):
from app import db
from app.models.deployment_job import DeploymentJob
with app.app_context():
job = DeploymentJob(
id=str(uuid.uuid4()), kind='deploy.install', status='succeeded',
deployment_id=None, commit_hash='abc123', image_tag='img:1', container_id='c1',
)
db.session.add(job)
db.session.commit()
d = DeploymentJob.query.get(job.id).to_dict()
assert d['commit_hash'] == 'abc123'
assert d['image_tag'] == 'img:1'
assert d['container_id'] == 'c1'
assert 'deployment_id' in d and 'git_deployment_id' in d and 'webhook_id' in d
def _seed_app_with_deploys(app):
from app import db
from app.models import User
from app.models.application import Application
from app.models.deployment import Deployment
from app.models.webhook import GitDeployment
with app.app_context():
admin = User.query.filter_by(username='testadmin').first()
a = Application(name='dep-app', app_type='docker', status='running',
root_path='/srv/dep', user_id=admin.id if admin else 1)
db.session.add(a)
db.session.commit()
db.session.add(Deployment(app_id=a.id, version=1, status='live'))
db.session.add(GitDeployment(app_id=a.id, version=1, status='success', branch='main'))
db.session.commit()
return a.id
def test_unified_history_federates_sources(app, client, auth_headers):
app_id = _seed_app_with_deploys(app)
res = client.get(f'/api/v1/deployments/apps/{app_id}/history', headers=auth_headers)
assert res.status_code == 200
deployments = res.get_json()['deployments']
sources = {d['source'] for d in deployments}
assert sources == {'build', 'webhook'}
def test_unified_history_source_filter(app, client, auth_headers):
app_id = _seed_app_with_deploys(app)
res = client.get(f'/api/v1/deployments/apps/{app_id}/history?source=webhook', headers=auth_headers)
assert res.status_code == 200
deployments = res.get_json()['deployments']
assert deployments and all(d['source'] == 'webhook' for d in deployments)
def test_deployment_detail(app, client, auth_headers):
from app import db
from app.models.deployment import Deployment
app_id = _seed_app_with_deploys(app)
with app.app_context():
dep_id = Deployment.query.filter_by(app_id=app_id).first().id
res = client.get(f'/api/v1/deployments/{dep_id}', headers=auth_headers)
assert res.status_code == 200
assert res.get_json()['deployment']['id'] == dep_id
def test_jobs_alias_mounted_under_deployments(app, client, auth_headers):
canonical = client.get('/api/v1/deployment-jobs', headers=auth_headers)
alias = client.get('/api/v1/deployments/jobs', headers=auth_headers)
assert canonical.status_code == 200
assert alias.status_code == 200
assert alias.get_json() == canonical.get_json()
+97
View File
@@ -0,0 +1,97 @@
"""Tests for ServerKit system-container protection on the Docker page.
ServerKit's own containers (serverkit-frontend / serverkit-backend) run the
panel itself, so the Docker API must refuse to stop/restart/remove them and the
container list must flag them so the UI can hide lifecycle controls.
The predicate and the list annotation are pure (subprocess is mocked); the
route guard tests drive the real blueprint with an admin JWT.
"""
from unittest.mock import patch
import pytest
from app.services.docker_service import DockerService
class TestIsProtectedName:
@pytest.mark.parametrize('name', [
'serverkit-frontend',
'serverkit_frontend',
'serverkit-backend',
'serverkit_backend',
'serverkit',
'/serverkit-backend', # docker inspect Name has a leading slash
'SERVERKIT-FRONTEND', # case-insensitive
])
def test_serverkit_names_are_protected(self, name):
assert DockerService.is_protected_name(name) is True
@pytest.mark.parametrize('name', [
'nginx',
'my-app_web_1',
'postgres',
'',
None,
])
def test_other_names_are_not_protected(self, name):
assert DockerService.is_protected_name(name) is False
class TestListContainersAnnotation:
def _fake_ps(self, names):
lines = '\n'.join(
'{"ID": "abc%d", "Names": "%s", "Image": "img", "Status": "Up", '
'"State": "running", "Ports": "", "CreatedAt": "now", "Size": "0B"}'
% (i, n)
for i, n in enumerate(names)
)
class _Result:
returncode = 0
stdout = lines
return _Result()
def test_protected_flag_set_per_container(self):
with patch('subprocess.run', return_value=self._fake_ps(['serverkit-backend', 'nginx'])):
containers = DockerService.list_containers()
by_name = {c['name']: c for c in containers}
assert by_name['serverkit-backend']['protected'] is True
assert by_name['nginx']['protected'] is False
class TestIsProtectedContainer:
def test_resolves_name_from_inspect(self):
# docker inspect returns the canonical Name with a leading slash.
with patch.object(DockerService, 'get_container', return_value={'Name': '/serverkit-frontend'}):
assert DockerService.is_protected_container('abc123') is True
def test_unknown_container_not_protected(self):
with patch.object(DockerService, 'get_container', return_value=None):
assert DockerService.is_protected_container('abc123') is False
class TestRouteGuards:
"""The destructive lifecycle routes must 403 on a protected container."""
@pytest.mark.parametrize('method,path', [
('post', '/api/v1/docker/containers/abc123/stop'),
('post', '/api/v1/docker/containers/abc123/restart'),
('delete', '/api/v1/docker/containers/abc123'),
])
def test_protected_container_is_rejected(self, client, auth_headers, method, path):
with patch.object(DockerService, 'is_protected_container', return_value=True):
resp = getattr(client, method)(path, headers=auth_headers, json={})
assert resp.status_code == 403
assert 'system container' in resp.get_json()['error'].lower()
def test_normal_container_stop_passes_through(self, client, auth_headers):
with patch.object(DockerService, 'is_protected_container', return_value=False), \
patch.object(DockerService, 'stop_container', return_value={'success': True}) as stop:
resp = client.post(
'/api/v1/docker/containers/abc123/stop', headers=auth_headers, json={}
)
assert resp.status_code == 200
stop.assert_called_once()
@@ -0,0 +1,61 @@
"""§6 Phase C6a — email provider usage flags.
One EmailProviderConnection can drive the Notification Bus and/or the Postfix
relay. Notification selection ignores relay-only rows; a relay selector picks
the flagged SMTP connection by priority.
"""
from app.models.email_provider import EmailProviderConnection
from app.notifications.providers import EmailProviderService
def test_usage_flags_default_and_serialize(app):
with app.app_context():
p = EmailProviderService.add_provider({
'provider': 'smtp', 'name': 'Main SMTP',
'host': 'smtp.example.com', 'port': 587, 'username': 'u', 'password': 'pw',
})
d = p.to_dict()
assert d['uses_notifications'] is True
assert d['uses_relay'] is False
assert d['relay_priority'] == 0
def test_default_provider_ignores_relay_only(app):
with app.app_context():
# A relay-only row must never be chosen for notifications.
EmailProviderService.add_provider({
'provider': 'smtp', 'name': 'Relay only',
'host': 'relay.example.com', 'port': 587, 'username': 'u', 'password': 'pw',
'uses_notifications': False, 'uses_relay': True, 'is_default': True,
})
assert EmailProviderService.default_provider() is None
notif = EmailProviderService.add_provider({
'provider': 'sendgrid', 'name': 'SG', 'api_key': 'sg-key',
})
chosen = EmailProviderService.default_provider()
assert chosen is not None and chosen.id == notif.id
def test_relay_provider_selection_by_priority(app):
with app.app_context():
low = EmailProviderService.add_provider({
'provider': 'smtp', 'name': 'low', 'host': 'a', 'username': 'u', 'password': 'p',
'uses_relay': True, 'relay_priority': 1, 'uses_notifications': False,
})
high = EmailProviderService.add_provider({
'provider': 'smtp', 'name': 'high', 'host': 'b', 'username': 'u', 'password': 'p',
'uses_relay': True, 'relay_priority': 5, 'uses_notifications': False,
})
chosen = EmailProviderService.relay_provider()
assert chosen.id == high.id
assert low.id != high.id
def test_api_provider_cannot_be_relay(app):
with app.app_context():
# uses_relay requested on a non-SMTP provider is ignored.
p = EmailProviderService.add_provider({
'provider': 'sendgrid', 'name': 'SG', 'api_key': 'k', 'uses_relay': True,
})
assert p.uses_relay is False
+99
View File
@@ -0,0 +1,99 @@
"""§6 Phase C6b — EmailRelayService backed by the unified provider table.
The relay is an smtp EmailProviderConnection flagged uses_relay. Verifies
save/read/disable round-trips and the idempotent legacy migration. Postfix
apply is a no-op in tests (not installed), so we assert config state, not
Postfix side effects.
"""
from app.services.email_relay_service import EmailRelayService
from app.notifications.providers import EmailProviderService
from app.models.email_provider import EmailProviderConnection
from app.utils.crypto import decrypt_secret_safe
def test_save_creates_relay_provider(app):
with app.app_context():
EmailRelayService.save_config({
'enabled': True, 'host': 'smtp.relay.io', 'port': 587,
'username': 'relayuser', 'password': 'secretpw', 'use_tls': True,
'provider_hint': 'mailgun',
})
view = EmailRelayService.get_config()
assert view['enabled'] is True
assert view['host'] == 'smtp.relay.io'
assert view['username'] == 'relayuser'
assert view['password_set'] is True
assert view['provider_hint'] == 'mailgun'
# Stored as a uses_relay smtp connection; password encrypted at rest.
row = EmailProviderConnection.query.filter_by(uses_relay=True).first()
assert row is not None and row.provider == 'smtp'
assert row.uses_notifications is False
raw_pw = row.raw_credentials().get('password')
assert raw_pw and raw_pw != 'secretpw'
assert decrypt_secret_safe(raw_pw) == 'secretpw'
def test_relay_provider_not_used_for_notifications(app):
with app.app_context():
EmailRelayService.save_config({
'enabled': True, 'host': 'smtp.relay.io', 'username': 'u', 'password': 'p',
})
# The relay connection must not be picked as the notification default.
assert EmailProviderService.default_provider() is None
def test_masked_password_preserved_on_resave(app):
with app.app_context():
EmailRelayService.save_config({'enabled': True, 'host': 'h', 'username': 'u', 'password': 'orig'})
# Re-save with a masked password (UI round-trip) — secret must persist.
EmailRelayService.save_config({'enabled': True, 'host': 'h2', 'username': 'u', 'password': '****'})
row = EmailProviderConnection.query.filter_by(uses_relay=True).first()
assert decrypt_secret_safe(row.raw_credentials().get('password')) == 'orig'
assert EmailRelayService.get_config()['host'] == 'h2'
def test_disable_marks_inactive(app):
with app.app_context():
EmailRelayService.save_config({'enabled': True, 'host': 'h', 'username': 'u', 'password': 'p'})
EmailRelayService.disable()
assert EmailRelayService.get_config()['enabled'] is False
# Inactive relay is not selected for applying.
assert EmailProviderService.relay_provider() is None
def test_migrate_legacy_config_idempotent(app):
from app import db
from app.models.email import EmailRelayConfig
from app.utils.crypto import encrypt_secret
with app.app_context():
legacy = EmailRelayConfig(
enabled=True, host='legacy.smtp', port=2525, username='legacyuser',
password_encrypted=encrypt_secret('legacypw'), use_tls=True, provider_hint='ses',
)
db.session.add(legacy)
db.session.commit()
assert EmailRelayService.migrate_legacy_config() is True
row = EmailProviderConnection.query.filter_by(uses_relay=True).first()
assert row is not None
creds = row.credentials()
assert creds['host'] == 'legacy.smtp'
assert creds['password'] == 'legacypw' # decrypts cleanly (same key)
assert EmailRelayService.get_config()['host'] == 'legacy.smtp'
# Idempotent: a second run is a no-op (relay row already exists).
assert EmailRelayService.migrate_legacy_config() is False
def test_update_usage_smtp_only_relay(app):
with app.app_context():
sg = EmailProviderService.add_provider({'provider': 'sendgrid', 'name': 'SG', 'api_key': 'k'})
EmailProviderService.update_usage(sg.id, {'uses_relay': True})
assert EmailProviderConnection.query.get(sg.id).uses_relay is False # API provider rejected
smtp = EmailProviderService.add_provider({
'provider': 'smtp', 'name': 'S', 'host': 'h', 'username': 'u', 'password': 'p'})
EmailProviderService.update_usage(smtp.id, {'uses_relay': True, 'relay_priority': 3})
updated = EmailProviderConnection.query.get(smtp.id)
assert updated.uses_relay is True and updated.relay_priority == 3
@@ -0,0 +1,37 @@
"""§4 unification — one /api/v1/observability namespace.
The monitoring / metrics / telemetry(events) / uptime / fleet / status-page read
surfaces are re-mounted under /api/v1/observability/<domain> as true aliases.
The original prefixes and the public status page route stay intact.
"""
def test_observability_aliases_registered():
from app import create_app
application = create_app('testing')
rules = {r.rule for r in application.url_map.iter_rules()}
assert any(r.startswith('/api/v1/observability/monitoring') for r in rules)
assert any(r.startswith('/api/v1/observability/metrics') for r in rules)
assert any(r.startswith('/api/v1/observability/events') for r in rules)
assert any(r.startswith('/api/v1/observability/uptime') for r in rules)
assert any(r.startswith('/api/v1/observability/fleet') for r in rules)
assert any(r.startswith('/api/v1/observability/status-pages') for r in rules)
def test_original_prefixes_still_present():
from app import create_app
application = create_app('testing')
rules = {r.rule for r in application.url_map.iter_rules()}
assert any(r.startswith('/api/v1/monitoring') for r in rules)
assert any(r.startswith('/api/v1/telemetry') for r in rules)
assert any(r.startswith('/api/v1/fleet-monitor') for r in rules)
# Public status page route untouched.
assert '/api/v1/status/public/<slug>' in rules
def test_events_alias_matches_telemetry(app, client, auth_headers):
canonical = client.get('/api/v1/telemetry/events', headers=auth_headers)
alias = client.get('/api/v1/observability/events/events', headers=auth_headers)
assert canonical.status_code == 200
assert alias.status_code == 200
assert alias.get_json() == canonical.get_json()
+39
View File
@@ -0,0 +1,39 @@
"""§1 unification: /api/v1/services is a true alias of /api/v1/apps.
"Services" is the user-facing term for the backend's Applications. The same
`apps_bp` blueprint is mounted under both prefixes, so every route must resolve
identically regardless of which prefix a caller uses.
"""
def test_services_alias_matches_apps_list(client, auth_headers):
apps_res = client.get('/api/v1/apps', headers=auth_headers)
services_res = client.get('/api/v1/services', headers=auth_headers)
assert apps_res.status_code == 200
assert services_res.status_code == 200
# Same handler, same (empty) data shape.
assert services_res.get_json() == apps_res.get_json()
def test_services_alias_requires_auth_like_apps():
# Both prefixes are JWT-protected; an unauthenticated call is rejected the
# same way on either mount.
import app as _app_pkg # noqa: F401
from app import create_app
application = create_app('testing')
with application.test_client() as c:
apps_res = c.get('/api/v1/apps')
services_res = c.get('/api/v1/services')
assert apps_res.status_code == services_res.status_code
assert apps_res.status_code in (401, 422)
def test_both_prefixes_registered_in_url_map():
from app import create_app
application = create_app('testing')
rules = {r.rule for r in application.url_map.iter_rules()}
assert '/api/v1/apps/' in rules or '/api/v1/apps' in rules
assert '/api/v1/services/' in rules or '/api/v1/services' in rules
+36
View File
@@ -0,0 +1,36 @@
"""§5 unification — one /ssl surface.
The advanced certificate operations (wildcard, SAN, upload, profiles, health,
expiry alerts) are mounted under /api/v1/ssl alongside the basic certbot routes,
with /api/v1/ssl/advanced kept as a deprecated alias. Both resolve to the same
handlers.
"""
def test_advanced_routes_mounted_under_ssl():
from app import create_app
application = create_app('testing')
rules = {r.rule for r in application.url_map.iter_rules()}
# Unified surface
assert '/api/v1/ssl/profiles' in rules
assert '/api/v1/ssl/wildcard' in rules
assert '/api/v1/ssl/upload' in rules
assert '/api/v1/ssl/expiry-alerts' in rules
# Deprecated alias still present
assert '/api/v1/ssl/advanced/profiles' in rules
def test_basic_ssl_routes_still_present():
from app import create_app
application = create_app('testing')
rules = {r.rule for r in application.url_map.iter_rules()}
assert '/api/v1/ssl/certificates' in rules
assert '/api/v1/ssl/status' in rules
def test_profiles_resolves_on_both_prefixes(app, client, auth_headers):
unified = client.get('/api/v1/ssl/profiles', headers=auth_headers)
alias = client.get('/api/v1/ssl/advanced/profiles', headers=auth_headers)
assert unified.status_code == 200
assert alias.status_code == 200
assert unified.get_json() == alias.get_json()
+27
View File
@@ -0,0 +1,27 @@
"""§2 unification: /api/v1/wordpress/pipelines aliases /wordpress/projects.
"WordPress Projects" was renamed to "Pipelines" to end the collision with the
generic /projects surface. The environment-pipeline blueprint is mounted under
both prefixes, so every route resolves identically during the deprecation
window.
"""
def test_pipelines_alias_matches_projects_list(client, auth_headers):
projects_res = client.get('/api/v1/wordpress/projects', headers=auth_headers)
pipelines_res = client.get('/api/v1/wordpress/pipelines', headers=auth_headers)
assert projects_res.status_code == 200
assert pipelines_res.status_code == 200
assert pipelines_res.get_json() == projects_res.get_json()
def test_both_pipeline_prefixes_registered():
from app import create_app
application = create_app('testing')
rules = {r.rule for r in application.url_map.iter_rules()}
has_projects = any(r.startswith('/api/v1/wordpress/projects') for r in rules)
has_pipelines = any(r.startswith('/api/v1/wordpress/pipelines') for r in rules)
assert has_projects
assert has_pipelines
+5 -1
View File
@@ -45,7 +45,11 @@ export default [
message: 'Use the shared Button component (or IconButton for icon-only actions).',
},
{
selector: 'JSXOpeningElement[name.name="div"] > JSXAttribute[name.name="className"] > Literal[value=/\\bcard\\b/]',
// Match the legacy card family (.card, .card-header, .card-body, …) as a
// LEADING class token — not unrelated compounds like `settings-card`,
// `sk-spec-card`, or `wp-site-card-skeleton`, which the old `\bcard\b`
// pattern flagged as false positives.
selector: 'JSXOpeningElement[name.name="div"] > JSXAttribute[name.name="className"] > Literal[value=/(^|\\s)card(\\s|$|-)/]',
message: 'Use the shared Card component instead of the legacy .card class.',
},
],
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
+115 -335
View File
File diff suppressed because it is too large. Load diff
+1 -5
View File
@@ -29,7 +29,6 @@
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.2.4",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
@@ -44,10 +43,7 @@
"react-router-dom": "^6.22.3",
"recharts": "^2.12.3",
"socket.io-client": "^4.7.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"tailwindcss-animate": "^1.0.7"
"sonner": "^2.0.7"
},
"devDependencies": {
"@types/react": "^18.3.3",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 816 B

After

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

+13 -3
View File
@@ -1,5 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#6366f1"/>
<path d="M8 10h16M8 16h16M8 22h12" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="24" cy="22" r="2" fill="#22c55e"/>
<defs>
<linearGradient id="skFavTile" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop stop-color="#6d7cff"/>
<stop offset="1" stop-color="#5a67e8"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="7" fill="url(#skFavTile)"/>
<g transform="translate(6 6) scale(0.8333)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18" stroke="#3ddc97"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 280 B

After

Width:  |  Height:  |  Size: 735 B

+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# Enable the repo's shared git hooks (pre-commit ESLint guard).
# Run once after cloning: sh frontend/setup-hooks.sh
cd "$(git rev-parse --show-toplevel)" || exit 1
git config core.hooksPath .githooks
echo "✓ core.hooksPath set to .githooks — pre-commit ESLint guard is active."
+54 -19
View File
@@ -13,7 +13,6 @@ import Dashboard from './pages/Dashboard';
import Login from './pages/Login';
import Register from './pages/Register';
import Setup from './pages/Setup';
import ApplicationDetail from './pages/ApplicationDetail';
import Docker from './pages/Docker';
import Databases from './pages/Databases';
import Domains from './pages/Domains';
@@ -45,6 +44,7 @@ import { MARKET_TABS } from './components/marketplace/marketTabs';
import { WORDPRESS_TABS } from './components/wordpress/wordpressTabs';
import { BACKUP_TABS } from './components/backups/backupTabs';
import { SECURITY_TABS } from './components/security/securityTabs';
import { ORG_TABS } from './components/organization/organizationTabs';
import Downloads from './pages/Downloads';
import WordPress from './pages/WordPress';
import WordPressDetail from './pages/WordPressDetail';
@@ -68,7 +68,8 @@ import StatusPages from './pages/StatusPages';
import PublicStatusPage from './pages/PublicStatusPage';
import CloudProvision from './pages/CloudProvision';
import Marketplace from './pages/Marketplace';
import SecretsWebhooks from './pages/SecretsWebhooks';
import Vaults from './pages/Vaults';
import Webhooks from './pages/Webhooks';
import StyleGuide from './pages/StyleGuide';
import AppMap from './pages/AppMap';
import Documentation from './pages/Documentation';
@@ -94,9 +95,8 @@ const PAGE_TITLES = {
'/projects': 'Projects',
'/shared-variables': 'Shared Variables',
'/fleet-proxy': 'Fleet Proxy',
'/apps': 'Applications',
'/wordpress': 'WordPress Sites',
'/wordpress/projects': 'WordPress Projects',
'/wordpress/pipelines': 'WordPress Pipelines',
'/templates': 'Templates',
'/deployments': 'Deployment Activity',
'/workflow': 'Workflow Builder',
@@ -108,6 +108,7 @@ const PAGE_TITLES = {
'/downloads': 'Downloads',
'/files': 'File Manager',
'/ftp': 'FTP Server',
'/observability': 'Observability',
'/monitoring': 'Monitoring',
'/backups': 'Backups',
'/cron': 'Cron Jobs',
@@ -135,8 +136,8 @@ const PAGE_TITLES = {
'/status-pages': 'Status Pages',
'/cloud': 'Cloud Provisioning',
'/marketplace': 'Marketplace',
'/secrets': 'Secrets & Webhooks',
'/secrets/:tab': 'Secrets & Webhooks',
'/vaults': 'Vaults',
'/webhooks': 'Webhooks',
'/style-guide': 'Style Guide',
'/app-map': 'App Map',
'/documentation': 'Documentation',
@@ -149,6 +150,23 @@ const PAGE_TITLES = {
'/jobs': 'Jobs',
};
// /apps/* is the legacy URL space for what is now "Services" (§1 unification).
// The list route already redirects; this preserves deep links to a single app
// (and its active tab) by forwarding to the matching /services/* path.
function LegacyAppRedirect() {
const { id, tab } = useParams();
const suffix = [id, tab].filter(Boolean).join('/');
return <Navigate to={`/services/${suffix}`} replace />;
}
// "WordPress Projects" was renamed to "Pipelines" (§2 unification) to end the
// collision with generic /projects. Forward old deep links to the new space.
function LegacyWpPipelineRedirect() {
const { id, tab } = useParams();
const suffix = [id, tab].filter(Boolean).join('/');
return <Navigate to={`/wordpress/pipelines/${suffix}`} replace />;
}
function PageTitleUpdater() {
const location = useLocation();
const { page_titles: pluginTitles } = useContributions();
@@ -179,9 +197,8 @@ function PageTitleUpdater() {
} else if (pluginTitles && pluginTitles[basePath]) {
title = pluginTitles[basePath];
} else if (path.startsWith('/services/')) title = 'Service Details';
else if (path.startsWith('/apps/')) title = 'Application Details';
else if (path.startsWith('/servers/')) title = 'Server Details';
else if (path.startsWith('/wordpress/projects/')) title = 'WordPress Pipeline';
else if (path.startsWith('/wordpress/pipelines/') || path.startsWith('/wordpress/projects/')) title = 'WordPress Pipeline';
else if (path.startsWith('/wordpress/')) title = 'WordPress Site';
else title = 'ServerKit';
}
@@ -320,14 +337,18 @@ function AppRoutes() {
so the Settings left-nav is shareable and survives a refresh. */}
<Route path="services/:id/:tab/:section" element={<ServiceDetail />} />
<Route path="apps" element={<Navigate to="/services" replace />} />
<Route path="apps/:id" element={<ApplicationDetail />} />
<Route path="apps/:id/:tab" element={<ApplicationDetail />} />
<Route path="apps/:id" element={<LegacyAppRedirect />} />
<Route path="apps/:id/:tab" element={<LegacyAppRedirect />} />
<Route element={<TabGroupLayout tabs={WORDPRESS_TABS} />}>
<Route path="wordpress" element={<WordPress />} />
<Route path="wordpress/projects" element={<WordPressProjects />} />
<Route path="wordpress/pipelines" element={<WordPressProjects />} />
</Route>
<Route path="wordpress/projects/:id" element={<WordPressProject />} />
<Route path="wordpress/projects/:id/:tab" element={<WordPressProject />} />
<Route path="wordpress/pipelines/:id" element={<WordPressProject />} />
<Route path="wordpress/pipelines/:id/:tab" element={<WordPressProject />} />
{/* Legacy "WordPress Projects" URLs → Pipelines (§2). */}
<Route path="wordpress/projects" element={<Navigate to="/wordpress/pipelines" replace />} />
<Route path="wordpress/projects/:id" element={<LegacyWpPipelineRedirect />} />
<Route path="wordpress/projects/:id/:tab" element={<LegacyWpPipelineRedirect />} />
<Route path="wordpress/:id" element={<WordPressDetail />} />
<Route path="wordpress/:id/:tab" element={<WordPressDetail />} />
{/* Settings sub-section in the URL (e.g. .../settings/git) so the
@@ -362,11 +383,17 @@ function AppRoutes() {
<Route path="servers/:id" element={<ServerDetail />} />
<Route path="servers/:id/:tab" element={<ServerDetail />} />
<Route path="agent-plugins" element={<Navigate to="/marketplace" replace />} />
<Route path="projects" element={<Projects />} />
{/* Organization tab group — Projects / Shared Variables /
Workspaces share one PageTopbar + tabs (ORG_TABS) instead of
a collapsible sidebar sub-menu. Detail routes stay outside. */}
<Route element={<TabGroupLayout tabs={ORG_TABS} />}>
<Route path="projects" element={<Projects />} />
<Route path="shared-variables" element={<SharedVariables />} />
<Route path="vaults" element={<Vaults />} />
<Route path="workspaces" element={<Workspaces />} />
</Route>
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="projects/:id/:tab" element={<ProjectDetail />} />
<Route path="shared-variables" element={<SharedVariables />} />
<Route path="workspaces" element={<Workspaces />} />
<Route path="workspaces/:id" element={<WorkspaceDetail />} />
<Route path="workspaces/:id/:tab" element={<WorkspaceDetail />} />
<Route path="workspaces/:id/:tab/:section" element={<WorkspaceDetail />} />
@@ -387,11 +414,15 @@ function AppRoutes() {
<Route path="ftp" element={<FTPServer />} />
<Route path="ftp/:tab" element={<FTPServer />} />
</Route>
{/* Observability tab group (§4): Monitoring / Events / Status
Pages share one PageTopbar. /observability lands on Monitoring. */}
<Route element={<TabGroupLayout tabs={MONITOR_TABS} />}>
<Route path="monitoring" element={<Monitoring />} />
<Route path="monitoring/:tab" element={<Monitoring />} />
<Route path="telemetry" element={<Telemetry />} />
<Route path="status-pages" element={<StatusPages />} />
</Route>
<Route path="observability" element={<Navigate to="/monitoring" replace />} />
<Route path="gpu" element={<GpuMonitor />} />
<Route element={<TabGroupLayout tabs={BACKUP_TABS} />}>
<Route path="backups" element={<Backups />} />
@@ -407,13 +438,17 @@ function AppRoutes() {
<Route path="terminal" element={<Terminal />} />
<Route path="terminal/terminal" element={<Navigate to="/terminal/shell" replace />} />
<Route path="terminal/:tab" element={<Terminal />} />
<Route path="secrets" element={<SecretsWebhooks />} />
<Route path="secrets/:tab" element={<SecretsWebhooks />} />
<Route path="webhooks" element={<Webhooks />} />
{/* Retired "Secrets & Webhooks" page: Vaults moved into the
Organization tab group (/vaults), Webhooks got its own page.
Redirect old links/bookmarks to their new homes. */}
<Route path="secrets/webhooks" element={<Navigate to="/webhooks" replace />} />
<Route path="secrets" element={<Navigate to="/vaults" replace />} />
<Route path="secrets/:tab" element={<Navigate to="/vaults" replace />} />
<Route path="queue" element={<QueueOperations />} />
<Route path="queue/:groupSlug/:queueSlug" element={<QueueDetail />} />
<Route path="notifications" element={<Notifications />} />
<Route path="admin/notifications" element={<DeliveryLog />} />
<Route path="telemetry" element={<Telemetry />} />
<Route path="jobs" element={<Jobs />} />
<Route path="settings" element={<Settings />} />
<Route path="settings/:tab" element={<Settings />} />
+27 -11
View File
@@ -1,18 +1,34 @@
import ServerKitLogo from './ServerKitLogo';
/**
* Minimal full-screen initial loader shown while auth/setup state is resolved.
* A centered ServerKit logo with a soft pulsing ring — clean for the ~1s
* the user sees it on first paint.
* Full-screen initial loader shown while auth/setup state is resolved.
*
* A ServerKit-branded tile holding the server glyph whose status LEDs blink like
* a machine booting, plus a slim indeterminate progress bar. No spinner — the
* server "powering on" is the loading metaphor. Matches the sidebar brand mark
* and the favicon so first paint feels of-a-piece.
*/
export function AppLoader() {
return (
<div className="app-loader">
<div className="app-loader__stage" aria-busy="true" aria-label="Loading ServerKit">
<div className="app-loader__ring" />
<div className="app-loader__logo">
<ServerKitLogo width={52} height={52} />
</div>
<div className="app-loader" role="status" aria-live="polite" aria-label="Loading ServerKit">
<div className="app-loader__logo">
<svg
className="app-loader__glyph"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
<rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
<line className="app-loader__led app-loader__led--1" x1="6" y1="6" x2="6.01" y2="6" />
<line className="app-loader__led app-loader__led--2" x1="6" y1="18" x2="6.01" y2="18" />
</svg>
</div>
<div className="app-loader__bar" aria-hidden="true">
<span className="app-loader__bar-fill" />
</div>
<span className="app-loader__label">Loading ServerKit</span>
</div>
+13 -5
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../services/api';
import {
@@ -15,16 +15,16 @@ const STATIC_PAGES = [
{ label: 'Services', path: '/services', category: 'Pages', keywords: 'apps containers' },
{ label: 'Docker', path: '/docker', category: 'Pages', keywords: 'containers images' },
{ label: 'Databases', path: '/databases', category: 'Pages', keywords: 'mysql postgres sql' },
{ label: 'Domains', path: '/domains', category: 'Pages', keywords: 'dns nginx' },
{ label: 'Domains', path: '/domains', category: 'Pages', keywords: 'dns nginx records nameserver zones' },
{ label: 'SSL Certificates', path: '/ssl', category: 'Pages', keywords: 'https tls' },
{ label: 'Templates', path: '/templates', category: 'Pages', keywords: 'deploy one-click' },
{ label: 'Deployments', path: '/deployments', category: 'Pages', keywords: 'deploy jobs status logs' },
{ label: 'Workflow Builder', path: '/workflow', category: 'Pages', keywords: 'automation pipeline' },
{ label: 'WordPress', path: '/wordpress', category: 'Pages', keywords: 'wp sites' },
{ label: 'WordPress Projects', path: '/wordpress/projects', category: 'Pages', keywords: 'wp environments' },
{ label: 'WordPress Pipelines', path: '/wordpress/pipelines', category: 'Pages', keywords: 'wp environments projects pipeline' },
{ label: 'Files', path: '/files', category: 'Pages', keywords: 'file manager explorer' },
{ label: 'FTP Server', path: '/ftp', category: 'Pages', keywords: 'sftp upload' },
{ label: 'Monitoring', path: '/monitoring', category: 'Pages', keywords: 'metrics uptime' },
{ label: 'Observability', path: '/monitoring', category: 'Pages', keywords: 'metrics uptime monitoring alerts' },
{ label: 'Backups', path: '/backups', category: 'Pages', keywords: 'snapshots restore' },
{ label: 'Cron Jobs', path: '/cron', category: 'Pages', keywords: 'schedule tasks' },
{ label: 'Security', path: '/security', category: 'Pages', keywords: 'firewall fail2ban' },
@@ -32,11 +32,19 @@ const STATIC_PAGES = [
{ label: 'Terminal', path: '/terminal', category: 'Pages', keywords: 'shell ssh console' },
{ label: 'Servers', path: '/servers', category: 'Pages', keywords: 'fleet agents' },
{ label: 'Fleet Monitor', path: '/fleet-monitor', category: 'Pages', keywords: 'agents status' },
{ label: 'DNS Zones', path: '/dns', category: 'Pages', keywords: 'records nameserver' },
{ label: 'Status Pages', path: '/status-pages', category: 'Pages', keywords: 'uptime incidents' },
{ label: 'Cloud Provision', path: '/cloud', category: 'Pages', keywords: 'vps deploy' },
{ label: 'Marketplace', path: '/marketplace', category: 'Pages', keywords: 'extensions plugins' },
{ 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' },
{ label: 'Workspaces', path: '/workspaces', category: 'Pages', keywords: 'organization team' },
{ label: 'GPU Monitor', path: '/gpu', category: 'Pages', keywords: 'nvidia cuda metrics' },
{ label: 'Queue', path: '/queue', category: 'Pages', keywords: 'bus operations tasks' },
{ label: 'Jobs', path: '/jobs', category: 'Pages', keywords: 'scheduler background work' },
{ label: 'Events', path: '/telemetry', category: 'Pages', keywords: 'telemetry metrics observability system events' },
{ label: 'Vaults', path: '/vaults', category: 'Pages', keywords: 'secrets tokens credentials vault' },
{ label: 'Webhooks', path: '/webhooks', category: 'Pages', keywords: 'webhook receive forward delivery' },
{ label: 'Settings', path: '/settings', category: 'Settings', keywords: 'profile preferences' },
{ label: 'Settings: Users', path: '/settings/users', category: 'Settings', keywords: 'accounts team' },
{ label: 'Settings: API Keys', path: '/settings/api', category: 'Settings', keywords: 'tokens access' },
+14 -25
View File
@@ -12,11 +12,9 @@ import {
} from '@/components/ui/alert-dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
// Styling lives in styles/components/_ui.scss (.sk-confirm*).
const iconMap = { danger: AlertTriangle, warning: AlertCircle, info: Info };
const iconColor = { danger: 'text-destructive', warning: 'text-yellow-400', info: 'text-blue-400' };
const iconBg = { danger: 'bg-destructive/10', warning: 'bg-yellow-500/10', info: 'bg-blue-500/10' };
export function ConfirmDialog({
isOpen,
@@ -38,32 +36,23 @@ export function ConfirmDialog({
const Icon = iconMap[variant] || AlertTriangle;
const isConfirmDisabled = requireConfirmation && inputValue !== requireConfirmation;
return (
<AlertDialog open={isOpen} onOpenChange={(v) => !v && onCancel()}>
<AlertDialogContent className="sm:max-w-xl">
<AlertDialogHeader className="items-center text-center sm:text-center">
<div className="flex flex-col items-center gap-3">
<div
className={cn(
'flex size-12 shrink-0 items-center justify-center rounded-full border',
iconBg[variant] || iconBg.danger,
iconColor[variant] || 'text-destructive',
variant === 'danger' && 'border-destructive/20',
variant === 'warning' && 'border-yellow-500/20',
variant === 'info' && 'border-blue-500/20'
)}
>
return (
<AlertDialog open={isOpen} onOpenChange={(v) => !v && onCancel()}>
<AlertDialogContent className="sk-confirm">
<AlertDialogHeader>
<div className="sk-confirm__head">
<div className={`sk-confirm__icon sk-confirm__icon--${variant}`}>
<Icon size={24} />
</div>
<div className="min-w-0">
<div className="sk-confirm__body">
<AlertDialogTitle>{title}</AlertDialogTitle>
{message && <AlertDialogDescription className="mt-1.5">{message}</AlertDialogDescription>}
{details && <p className="text-sm text-muted-foreground mt-1.5">{details}</p>}
{message && <AlertDialogDescription>{message}</AlertDialogDescription>}
{details && <p className="sk-confirm__details">{details}</p>}
</div>
</div>
{requireConfirmation && (
<div className="space-y-2 mt-2 w-full text-left">
<Label className="text-muted-foreground">
<div className="sk-confirm__confirm-field">
<Label>
Type <strong className="text-foreground">{requireConfirmation}</strong> to confirm:
</Label>
<Input
@@ -76,12 +65,12 @@ export function ConfirmDialog({
</div>
)}
</AlertDialogHeader>
<AlertDialogFooter className="sm:justify-center">
<AlertDialogFooter className="sk-confirm__footer">
<AlertDialogCancel onClick={onCancel}>{cancelText}</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
disabled={isConfirmDisabled}
className={cn(variant !== 'danger' && 'bg-primary hover:bg-primary/90')}
variant={variant === 'danger' ? 'destructive' : 'primary'}
>
{confirmText}
</AlertDialogAction>
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import { useState, useCallback } from 'react';
import { Copy, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
-1
View File
@@ -1,4 +1,3 @@
import React from 'react';
export function DangerZone({ title, description, action, children, className = '' }) {
if (children) {
+28 -5
View File
@@ -1,6 +1,5 @@
import React from 'react';
import { Inbox } from 'lucide-react';
import { Spinner } from './Spinner';
import { Skeleton } from './Skeleton';
export default function EmptyState({
icon: Icon = Inbox,
@@ -11,10 +10,34 @@ export default function EmptyState({
loading = false
}) {
if (loading) {
// Skeleton placeholder (not a spinner): a generic panel shape that reads
// as "content is loading" across the ~40 page/section loaders that use it.
return (
<div className={`empty-state empty-state--${size}`}>
<Spinner size={size === 'lg' ? 'lg' : 'md'} />
{title && <h3 className="empty-state__title">{title}</h3>}
<div
className={`empty-state empty-state--${size} empty-state--loading`}
role="status"
aria-busy="true"
aria-label={title || 'Loading'}
>
<div className="skeleton-panel">
<div className="skeleton-panel__head">
<Skeleton variant="avatar" />
<div className="skeleton-panel__head-text">
<Skeleton variant="title" width="42%" />
<Skeleton variant="line" width="26%" />
</div>
</div>
<div className="skeleton-panel__cards">
<Skeleton variant="card" />
<Skeleton variant="card" />
<Skeleton variant="card" />
</div>
<div className="skeleton-panel__rows">
<Skeleton variant="line" width="100%" />
<Skeleton variant="line" width="92%" />
<Skeleton variant="line" width="76%" />
</div>
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef } from 'react';
import api from '../services/api';
import { useToast } from '../contexts/ToastContext';
import { Button } from '@/components/ui/button';
@@ -7,6 +7,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import Modal from './Modal';
import EmptyState from './EmptyState';
const EnvironmentVariables = ({ appId }) => {
const toast = useToast();
@@ -255,7 +256,7 @@ const EnvironmentVariables = ({ appId }) => {
: envVars;
if (loading) {
return <div className="loading">Loading environment variables...</div>;
return <EmptyState loading title="Loading environment variables..." />;
}
return (
@@ -371,7 +372,7 @@ const EnvironmentVariables = ({ appId }) => {
placeholder="Filter variables..."
/>
{filter && (
<button className="filter-clear" onClick={() => setFilter('')}>&times;</button>
<button type="button" className="filter-clear" onClick={() => setFilter('')}>&times;</button>
)}
</div>
)}
@@ -406,7 +407,7 @@ const EnvironmentVariables = ({ appId }) => {
)}
</span>
<div className="env-item-actions">
<button
<button type="button"
className="btn-icon"
onClick={() => toggleShowValue(envVar.id)}
title={showValues[envVar.id] ? 'Hide value' : 'Show value'}
@@ -423,7 +424,7 @@ const EnvironmentVariables = ({ appId }) => {
</svg>
)}
</button>
<button
<button type="button"
className="btn-icon"
onClick={() => copyToClipboard(envVar.value)}
title="Copy value"
@@ -433,7 +434,7 @@ const EnvironmentVariables = ({ appId }) => {
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<button
<button type="button"
className="btn-icon"
onClick={() => startEditing(envVar)}
title="Edit"
@@ -443,7 +444,7 @@ const EnvironmentVariables = ({ appId }) => {
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button
<button type="button"
className="btn-icon"
onClick={() => handleToggleSecret(envVar)}
title={envVar.is_secret ? 'Mark as non-secret' : 'Mark as secret'}
@@ -460,7 +461,7 @@ const EnvironmentVariables = ({ appId }) => {
</svg>
)}
</button>
<button
<button type="button"
className="btn-icon btn-danger"
onClick={() => handleDelete(envVar.key)}
title="Delete"
+4 -4
View File
@@ -1,4 +1,4 @@
import React, { Component } from 'react';
import { Component } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
// Class-based Error Boundary for catching React errors
@@ -33,7 +33,7 @@ export class ErrorBoundary extends Component {
<p className="error-boundary-message">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button className="btn btn-primary" onClick={this.handleRetry}>
<button type="button" className="btn btn-primary" onClick={this.handleRetry}>
<RefreshCw size={14} />
Try Again
</button>
@@ -61,7 +61,7 @@ export function ErrorState({
<AlertTriangle size={16} />
<span>{errorMessage}</span>
{onRetry && (
<button className="btn btn-ghost btn-sm" onClick={onRetry}>
<button type="button" className="btn btn-ghost btn-sm" onClick={onRetry}>
<RefreshCw size={14} /> Retry
</button>
)}
@@ -77,7 +77,7 @@ export function ErrorState({
<h3 className="error-state-title">{title}</h3>
<p className="error-state-message">{errorMessage}</p>
{onRetry && (
<button className="btn btn-primary" onClick={onRetry}>
<button type="button" className="btn btn-primary" onClick={onRetry}>
<RefreshCw size={14} />
Try Again
</button>
-1
View File
@@ -1,4 +1,3 @@
import React from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
-1
View File
@@ -1,4 +1,3 @@
import React from 'react';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
-1
View File
@@ -1,4 +1,3 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
-1
View File
@@ -1,4 +1,3 @@
import React from 'react';
export function InfoList({ children, className = '', style }) {
return <div className={`info-list ${className}`.trim()} style={style}>{children}</div>;
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import Modal from './Modal';
import { Button } from '@/components/ui/button';
import socketService from '../services/socket';
+1 -2
View File
@@ -1,4 +1,3 @@
import React from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@@ -45,7 +44,7 @@ export function JournalControls({
{showQuickUnits && quickUnits.length > 0 && (
<div className="quick-units">
{quickUnits.map(u => (
<button
<button type="button"
key={u}
className={`unit-chip ${unit === u ? 'active' : ''}`}
onClick={() => onUnitChange?.(unit === u ? '' : u)}
+2 -2
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { X, Link2, GitBranch, AlertCircle, Check } from 'lucide-react';
import api from '../services/api';
import Modal from './Modal';
@@ -88,7 +88,7 @@ const LinkAppModal = ({ app, onClose, onLinked }) => {
Create another {app.app_type} app first, or ensure existing apps
are not already linked.
</p>
<button className="btn btn-secondary" onClick={onClose}>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Close
</button>
</div>
@@ -1,4 +1,3 @@
import React from 'react';
import { GitBranch, Link2, Unlink, ExternalLink, Server } from 'lucide-react';
const LinkedAppsSection = ({
@@ -39,7 +38,7 @@ const LinkedAppsSection = ({
Environment Linking
</h3>
{!app.has_linked_app && (
<button
<button type="button"
className="btn btn-secondary btn-sm"
onClick={onLink}
disabled={loading}
@@ -88,14 +87,14 @@ const LinkedAppsSection = ({
</div>
</div>
<div className="linked-app-actions">
<button
<button type="button"
className="btn btn-secondary btn-sm btn-icon"
onClick={() => onNavigate(linkedApp.id)}
title="View app"
>
<ExternalLink size={14} />
</button>
<button
<button type="button"
className="btn btn-secondary btn-sm btn-icon"
onClick={onUnlink}
disabled={loading}
@@ -110,7 +109,7 @@ const LinkedAppsSection = ({
) : app.environment_type !== 'standalone' ? (
<div className="linked-apps-empty">
<p>No linked apps. Link another app to share database resources.</p>
<button
<button type="button"
className="btn btn-secondary btn-sm"
onClick={onLink}
disabled={loading}
-1
View File
@@ -1,4 +1,3 @@
import React from 'react';
import { Search, RefreshCw, FileText } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useLogsDrawer } from '../contexts/LogsDrawerContext';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
-1
View File
@@ -1,4 +1,3 @@
import React from 'react';
export function MetricRow({ children, className = '' }) {
return <div className={`metric-row ${className}`.trim()}>{children}</div>;
+6 -6
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo } from 'react';
import {
XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer, Area, AreaChart
@@ -155,7 +155,7 @@ const MetricsGraph = ({ compact = false, timezone, serverId }) => {
</div>
<div className="period-selector">
{periods.map(p => (
<button
<button type="button"
key={p}
className={`period-btn ${period === p ? 'active' : ''}`}
onClick={() => setPeriod(p)}
@@ -213,19 +213,19 @@ const MetricsGraph = ({ compact = false, timezone, serverId }) => {
<span>Real-time Performance</span>
</div>
<div className="metrics-filter-legend">
<button
<button type="button"
className={`filter-btn cpu ${visibleMetrics.cpu ? 'active' : ''}`}
onClick={() => toggleMetric('cpu')}
>
CPU
</button>
<button
<button type="button"
className={`filter-btn memory ${visibleMetrics.memory ? 'active' : ''}`}
onClick={() => toggleMetric('memory')}
>
RAM
</button>
<button
<button type="button"
className={`filter-btn disk ${visibleMetrics.disk ? 'active' : ''}`}
onClick={() => toggleMetric('disk')}
>
@@ -234,7 +234,7 @@ const MetricsGraph = ({ compact = false, timezone, serverId }) => {
</div>
<div className="period-selector">
{periods.map(p => (
<button
<button type="button"
key={p}
className={`period-btn ${period === p ? 'active' : ''}`}
onClick={() => setPeriod(p)}
+9 -33
View File
@@ -1,21 +1,9 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
// Size → Tailwind max-width on >=sm screens. Mobile is always nearly full-width.
const SIZE_CLASS = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-lg',
lg: 'sm:max-w-2xl',
xl: 'sm:max-w-4xl',
'2xl': 'sm:max-w-6xl',
};
// Generic modal built on the Radix Dialog primitive. SCSS owns all geometry
// (styles/components/_ui.scss → .sk-modal*). `size` picks a max-width on
// >=sm screens; mobile is always near full-width.
export default function Modal({
open,
onClose,
@@ -25,30 +13,18 @@ export default function Modal({
className = '',
size = 'md',
}) {
const sizeClass = SIZE_CLASS[size] || SIZE_CLASS.md;
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent
className={cn(
'max-h-[90vh] overflow-hidden p-0 flex flex-col gap-0',
sizeClass,
className
)}
>
<DialogContent className={cn('sk-modal', `sk-modal--${size}`, className)}>
{title && (
<DialogHeader className="px-6 py-4 pr-12 border-b border-border space-y-0">
<div className="sk-modal__header">
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
</div>
)}
<div className="flex-1 overflow-y-auto px-6 py-4">{children}</div>
<div className="sk-modal__body">{children}</div>
{footer && (
<DialogFooter className="px-6 py-3 border-t border-border bg-card sm:justify-end">
{footer}
</DialogFooter>
)}
{footer && <div className="sk-modal__footer">{footer}</div>}
</DialogContent>
</Dialog>
);
+3 -4
View File
@@ -1,14 +1,13 @@
import React from 'react';
import { Spinner } from './Spinner';
import EmptyState from './EmptyState';
/**
* Full-page loader for tab-group pages.
* Centers a spinner inside the standard tab-group inner area.
* Shows a skeleton placeholder inside the standard tab-group inner area.
*/
export function PageLoader({ className = '' }) {
return (
<div className={`sk-tabgroup__inner ${className}`.trim()}>
<Spinner />
<EmptyState loading title="Loading" />
</div>
);
}
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
import api from '../services/api';
import { useToast } from '../contexts/ToastContext';
@@ -136,7 +136,7 @@ const PrivateURLSection = ({ app, onUpdate }) => {
<code className="url-value">{privateUrl}</code>
</div>
<div className="url-actions">
<button
<button type="button"
className="btn btn-secondary btn-sm"
onClick={copyToClipboard}
title="Copy to clipboard"
@@ -147,7 +147,7 @@ const PrivateURLSection = ({ app, onUpdate }) => {
</svg>
Copy
</button>
<button
<button type="button"
className="btn btn-secondary btn-sm"
onClick={handleRegenerate}
disabled={loading}
@@ -197,7 +197,7 @@ const PrivateURLSection = ({ app, onUpdate }) => {
</button>
</form>
) : (
<button
<button type="button"
className="btn-link"
onClick={() => setEditMode(true)}
>
@@ -206,7 +206,7 @@ const PrivateURLSection = ({ app, onUpdate }) => {
)}
<div className="private-url-footer">
<button
<button type="button"
className="btn btn-danger btn-sm"
onClick={handleDisable}
disabled={loading}
-1
View File
@@ -1,4 +1,3 @@
import React from 'react';
import { X, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
-1
View File
@@ -1,4 +1,3 @@
import React from 'react';
export function ProgressBar({ percent = 0, color, className = '' }) {
const clamped = Math.max(0, Math.min(100, Number(percent) || 0));
+1 -1
View File
@@ -259,7 +259,7 @@ export default function RemoteTerminal({ serverId, onClose }) {
{sessionId && <span className="session-id">{sessionId}</span>}
</div>
<div className="terminal-actions">
<button
<button type="button"
className="terminal-close-btn"
onClick={handleClose}
title="Close terminal"
-1
View File
@@ -1,4 +1,3 @@
import React from 'react';
import { AlertTriangle, Cpu, HardDrive, Server, ArrowUpCircle } from 'lucide-react';
import { useResourceTier } from '../contexts/ResourceTierContext';
@@ -1,4 +1,3 @@
import React from 'react';
import { Key } from 'lucide-react';
const GoogleIcon = () => (
@@ -1,4 +1,3 @@
import React from 'react';
import { Search, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
-1
View File
@@ -1,4 +1,3 @@
import React from 'react';
import { Badge } from '@/components/ui/badge';
export function ServicesGrid({ children, className = '' }) {
+3 -2
View File
@@ -9,6 +9,7 @@ import WorkspaceSwitcher from './WorkspaceSwitcher';
import NotificationBell from './NotificationBell';
import { SIDEBAR_CATEGORIES, CATEGORY_LABELS, SIDEBAR_PRESETS, getHiddenItemIds, getVisibleItems, applyWorkspaceNavPermissions } from './sidebarItems';
import { useContributions } from '../plugins/contributions';
import { sanitizeSvgInner } from '../utils/sanitizeSvg';
const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {} }) => {
const { user, logout, updateUser } = useAuth();
@@ -222,7 +223,7 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
>
<svg className="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
aria-hidden="true" focusable="false"
dangerouslySetInnerHTML={{ __html: item.icon }}
dangerouslySetInnerHTML={{ __html: sanitizeSvgInner(item.icon) }}
/>
{item.label}
</NavLink>
@@ -246,7 +247,7 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
>
<svg className="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
aria-hidden="true" focusable="false"
dangerouslySetInnerHTML={{ __html: sub.icon }}
dangerouslySetInnerHTML={{ __html: sanitizeSvgInner(sub.icon) }}
/>
{sub.label}
</NavLink>
+34
View File
@@ -0,0 +1,34 @@
/**
* Lightweight shimmer placeholder. Compose these to mirror real content while it
* loads instead of showing a spinner.
*
* <Skeleton variant="title" width="40%" />
* <Skeleton variant="line" />
* <Skeleton variant="block" height={120} />
*
* Variants: line | title | avatar | card | block | circle.
*/
export function Skeleton({
variant = 'line',
width,
height,
radius,
className = '',
style = {},
}) {
const css = { ...style };
const toCss = (v) => (typeof v === 'number' ? `${v}px` : v);
if (width != null) css.width = toCss(width);
if (height != null) css.height = toCss(height);
if (radius != null) css.borderRadius = toCss(radius);
return (
<span
className={`skeleton skeleton--${variant} ${className}`.trim()}
style={css}
aria-hidden="true"
/>
);
}
export default Skeleton;
-1
View File
@@ -1,4 +1,3 @@
import React from 'react';
export function Spinner({ size = 'md', className = '' }) {
const sizeClasses = {
-1
View File
@@ -1,4 +1,3 @@
import React from 'react';
// ─────────────────────────────────────────────────────────────────────────────
// Stat strip — a compact, instrument-panel readout bar.
+5 -1
View File
@@ -11,8 +11,12 @@ const AIAssistant = () => {
const { isAuthenticated } = useAuth();
const { isOpen, toggle, isStreaming, unread, pageContext } = useServerkitAI();
// Alt+A toggles the assistant (ignored while typing in a field).
// Alt+A toggles the assistant (ignored while typing in a field). Users who
// find the global shortcut intrusive can disable it by setting
// localStorage 'sk_ai_shortcut' to 'off'; the bubble's title/aria-label
// keeps the binding discoverable for everyone else.
useEffect(() => {
if (localStorage.getItem('sk_ai_shortcut') === 'off') return undefined;
const onKeyDown = (e) => {
if (!e.altKey || (e.key !== 'a' && e.key !== 'A')) return;
const t = document.activeElement;
@@ -13,6 +13,8 @@ const ChatBubble = ({ open, unread, streaming, raised, onToggle }) => (
].filter(Boolean).join(' ')}
onClick={onToggle}
aria-label={open ? 'Close assistant (Alt+A)' : 'Open assistant (Alt+A)'}
title={open ? 'Close assistant (Alt+A)' : 'Open assistant (Alt+A)'}
aria-keyshortcuts="Alt+A"
aria-expanded={open}
aria-controls="sk-ai-drawer"
>
+3 -7
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { useServerkitAI } from '../../contexts/AIContext';
import useMediaQuery from '../../hooks/useMediaQuery';
import { useLockBodyScroll } from '../../hooks/useLockBodyScroll';
import useFocusTrap from '../../hooks/ai/useFocusTrap';
import DrawerHeader from './DrawerHeader';
import ContextChip from './ContextChip';
@@ -27,12 +28,7 @@ const ChatDrawer = () => {
useFocusTrap(panelRef, { active: true, onEscape: close });
// Lock body scroll on mobile (full-screen sheet).
useEffect(() => {
if (!isMobile) return undefined;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [isMobile]);
useLockBodyScroll(isMobile);
const startResize = useCallback((e) => {
if (isMobile) return;
@@ -0,0 +1,366 @@
import { useState, useEffect } from 'react';
import api from '../../services/api';
import EmptyState from '../EmptyState';
import { useToast } from '../../contexts/ToastContext';
import { useConfirm } from '../../hooks/useConfirm';
import { InfoList, InfoItem } from '../InfoList';
import BuildpackPreview from '../buildpack/BuildpackPreview';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Pill } from '@/components/ds';
import Modal from '@/components/Modal';
const BuildTab = ({ appId, appPath, app }) => {
const toast = useToast();
const { confirm: confirmBuild } = useConfirm();
const [buildConfig, setBuildConfig] = useState(null);
const [detection, setDetection] = useState(null);
const [deployments, setDeployments] = useState([]);
const [currentDeployment, setCurrentDeployment] = useState(null);
const [loading, setLoading] = useState(true);
const [building, setBuilding] = useState(false);
const [deploying, setDeploying] = useState(false);
const [showConfigModal, setShowConfigModal] = useState(false);
const [showLogsModal, setShowLogsModal] = useState(false);
const [selectedLog, setSelectedLog] = useState(null);
const [buildLogs, setBuildLogs] = useState([]);
const [error, setError] = useState(null);
const [bpDockerfile, setBpDockerfile] = useState(null);
// When the app was created from a detected build pack, fetch a read-only
// preview of the generated Dockerfile for transparency.
useEffect(() => {
let active = true;
if (app?.buildpack_plan) {
api.generateBuildpack(app.buildpack_plan, app.buildpack_overrides || {}, app.name)
.then((res) => { if (active) setBpDockerfile(res?.dockerfile || null); })
.catch(() => {});
}
return () => { active = false; };
}, [app?.buildpack_plan, app?.buildpack_overrides, app?.name]);
const [configForm, setConfigForm] = useState({
buildMethod: 'auto',
dockerfilePath: 'Dockerfile',
customBuildCmd: '',
customStartCmd: '',
cacheEnabled: true,
timeout: 600,
keepDeployments: 5
});
useEffect(() => {
loadData();
}, [appId]);
async function loadData() {
try {
setLoading(true);
const [configRes, detectRes, deploymentsRes] = await Promise.all([
api.getBuildConfig(appId),
api.detectBuildMethod(appId),
api.getDeployments(appId, 10)
]);
setDetection(detectRes);
if (configRes.configured) {
setBuildConfig(configRes.config);
setConfigForm({
buildMethod: configRes.config.build_method || 'auto',
dockerfilePath: configRes.config.dockerfile_path || 'Dockerfile',
customBuildCmd: configRes.config.custom_build_cmd || '',
customStartCmd: configRes.config.custom_start_cmd || '',
cacheEnabled: configRes.config.cache_enabled !== false,
timeout: configRes.config.timeout || 600,
keepDeployments: configRes.config.keep_deployments || 5
});
}
setDeployments(deploymentsRes.deployments || []);
setCurrentDeployment(deploymentsRes.current);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleConfigureBuild(e) {
e.preventDefault();
try {
await api.configureBuild(appId, {
build_method: configForm.buildMethod,
dockerfile_path: configForm.dockerfilePath,
custom_build_cmd: configForm.customBuildCmd || null,
custom_start_cmd: configForm.customStartCmd || null,
cache_enabled: configForm.cacheEnabled,
timeout: configForm.timeout,
keep_deployments: configForm.keepDeployments
});
setShowConfigModal(false);
toast.success('Build configuration saved');
loadData();
} catch (err) {
setError(err.message);
}
}
async function handleBuild(noCache = false) {
setBuilding(true);
setError(null);
try {
const result = await api.triggerBuild(appId, noCache);
if (result.success) {
toast.success('Build completed successfully');
} else {
setError(result.error || 'Build failed');
}
loadData();
} catch (err) {
setError(err.message);
} finally {
setBuilding(false);
}
}
async function handleDeploy(noCache = false) {
setDeploying(true);
setError(null);
try {
const result = await api.deployApp(appId, { no_cache: noCache });
if (result.success) {
toast.success(`Deployment v${result.deployment.version} successful!`);
} else {
setError(result.error || 'Deployment failed');
}
loadData();
} catch (err) {
setError(err.message);
} finally {
setDeploying(false);
}
}
async function handleRollback(version = null) {
const rollbackMsg = version
? `Rollback to version ${version}? This will replace the current deployment.`
: 'Rollback to previous deployment?';
const confirmed = await confirmBuild({ title: 'Rollback', message: rollbackMsg, variant: 'warning' });
if (!confirmed) return;
setDeploying(true);
setError(null);
try {
const result = await api.rollback(appId, version);
if (result.success) {
toast.success('Rollback successful');
} else {
setError(result.error || 'Rollback failed');
}
loadData();
} catch (err) {
setError(err.message);
} finally {
setDeploying(false);
}
}
function getStatusPillKind(status) {
switch (status) {
case 'live': return 'green';
case 'building':
case 'deploying':
case 'pending': return 'amber';
case 'failed': return 'red';
case 'rolled_back': return 'gray';
default: return 'gray';
}
}
function formatDuration(seconds) {
if (!seconds) return '-';
if (seconds < 60) return `${Math.round(seconds)}s`;
return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
}
if (loading) {
return <EmptyState loading title="Loading build configuration..." />;
}
return (
<div className="build-tab">
{error && (
<div className="alert alert-danger">
{error}
<button type="button" onClick={() => setError(null)} className="alert-close">&times;</button>
</div>
)}
{detection && (
<div className="card">
<h3>Auto-Detection Results</h3>
<div className="detection-results">
<div className="detection-item">
<span className="detection-label">Detected Method:</span>
<span className="detection-value">{detection.detected_method || 'None'}</span>
</div>
{detection.dockerfile_exists && (
<div className="detection-item">
<span className="detection-label">Dockerfile:</span>
<span className="detection-value">Found</span>
</div>
)}
{detection.docker_compose_exists && (
<div className="detection-item">
<span className="detection-label">Docker Compose:</span>
<span className="detection-value">Found</span>
</div>
)}
</div>
</div>
)}
{app?.buildpack_plan && (
<div className="card">
<h3>Build Pack</h3>
<BuildpackPreview
plan={app.buildpack_plan}
dockerfile={bpDockerfile}
overrides={app.buildpack_overrides || {}}
/>
</div>
)}
<div className="card">
<div className="card-header-row">
<h3>Build Configuration</h3>
<Button variant="outline" size="sm" onClick={() => setShowConfigModal(true)}>
Configure
</Button>
</div>
{buildConfig ? (
<InfoList>
<InfoItem label="Method" value={buildConfig.build_method} />
<InfoItem label="Timeout" value={`${buildConfig.timeout}s`} />
</InfoList>
) : (
<p className="hint">No build configuration. Click Configure to set up.</p>
)}
<div className="card-actions">
<Button
onClick={() => handleDeploy(false)}
disabled={deploying || building}
>
{deploying ? 'Deploying...' : 'Build & Deploy'}
</Button>
<Button
variant="outline"
onClick={() => handleBuild(false)}
disabled={building || deploying}
>
{building ? 'Building...' : 'Build Only'}
</Button>
</div>
</div>
{deployments.length > 0 && (
<div className="card">
<h3>Deployment History</h3>
<div className="deployments-list">
{deployments.map(dep => (
<div key={dep.version} className={`deployment-item ${dep.status === 'live' ? 'current' : ''}`}>
<div className="deployment-info">
<span className="deployment-version">v{dep.version}</span>
<Pill kind={getStatusPillKind(dep.status)}>{dep.status}</Pill>
</div>
<div className="deployment-meta">
<span>{new Date(dep.created_at).toLocaleString()}</span>
<span>{formatDuration(dep.build_duration)}</span>
</div>
{dep.status !== 'live' && (
<Button
variant="outline"
size="sm"
onClick={() => handleRollback(dep.version)}
disabled={deploying}
>
Rollback
</Button>
)}
</div>
))}
</div>
</div>
)}
<Modal open={showConfigModal} onClose={() => setShowConfigModal(false)} title="Build Configuration">
<form onSubmit={handleConfigureBuild}>
<div className="form-group">
<label>Build Method</label>
<select
value={configForm.buildMethod}
onChange={e => setConfigForm({...configForm, buildMethod: e.target.value})}
>
<option value="auto">Auto-detect</option>
<option value="dockerfile">Dockerfile</option>
<option value="docker-compose">Docker Compose</option>
<option value="custom">Custom</option>
</select>
</div>
{configForm.buildMethod === 'dockerfile' && (
<div className="form-group">
<label>Dockerfile Path</label>
<Input
type="text"
value={configForm.dockerfilePath}
onChange={e => setConfigForm({...configForm, dockerfilePath: e.target.value})}
/>
</div>
)}
{configForm.buildMethod === 'custom' && (
<>
<div className="form-group">
<label>Build Command</label>
<Input
type="text"
value={configForm.customBuildCmd}
onChange={e => setConfigForm({...configForm, customBuildCmd: e.target.value})}
placeholder="npm run build"
/>
</div>
<div className="form-group">
<label>Start Command</label>
<Input
type="text"
value={configForm.customStartCmd}
onChange={e => setConfigForm({...configForm, customStartCmd: e.target.value})}
placeholder="npm start"
/>
</div>
</>
)}
<div className="form-group">
<label>Timeout (seconds)</label>
<Input
type="number"
value={configForm.timeout}
onChange={e => setConfigForm({...configForm, timeout: parseInt(e.target.value)})}
/>
</div>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={() => setShowConfigModal(false)}>
Cancel
</Button>
<Button type="submit">
Save Configuration
</Button>
</div>
</form>
</Modal>
</div>
);
};
export default BuildTab;
@@ -0,0 +1,89 @@
import { useState } from 'react';
import api from '../../services/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
const CommandsTab = ({ appId, appType }) => {
const [command, setCommand] = useState('');
const [output, setOutput] = useState(null);
const [running, setRunning] = useState(false);
const quickCommands = appType === 'django' ? [
{ label: 'Run Migrations', cmd: 'python manage.py migrate' },
{ label: 'Collect Static', cmd: 'python manage.py collectstatic --noinput' },
{ label: 'Create Superuser', cmd: 'python manage.py createsuperuser' },
{ label: 'Shell', cmd: 'python manage.py shell' },
{ label: 'Check', cmd: 'python manage.py check' },
] : [
{ label: 'Flask Routes', cmd: 'flask routes' },
{ label: 'Flask Shell', cmd: 'flask shell' },
{ label: 'DB Upgrade', cmd: 'flask db upgrade' },
{ label: 'DB Migrate', cmd: 'flask db migrate' },
];
async function handleRun(cmd) {
const commandToRun = cmd || command;
if (!commandToRun.trim()) return;
setRunning(true);
setOutput(null);
try {
const result = await api.runPythonCommand(appId, commandToRun);
setOutput(result);
} catch (err) {
setOutput({ success: false, stderr: err.message });
} finally {
setRunning(false);
}
}
return (
<div>
<h3 className="app-eyebrow">Run Commands</h3>
<p className="hint">Commands run in the app&apos;s virtual environment context.</p>
<div className="quick-commands">
{quickCommands.map(({ label, cmd }) => (
<Button
key={cmd}
variant="outline"
size="sm"
onClick={() => handleRun(cmd)}
disabled={running}
>
{label}
</Button>
))}
</div>
<div className="command-input">
<Input
type="text"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="Enter command..."
onKeyDown={(e) => e.key === 'Enter' && handleRun()}
/>
<Button
onClick={() => handleRun()}
disabled={running}
>
{running ? 'Running...' : 'Run'}
</Button>
</div>
{output && (
<div className={`command-output ${output.success ? '' : 'error'}`}>
{output.stdout && <pre>{output.stdout}</pre>}
{output.stderr && <pre className="stderr">{output.stderr}</pre>}
{!output.stdout && !output.stderr && (
<pre>{output.success ? 'Command completed successfully' : 'Command failed'}</pre>
)}
</div>
)}
</div>
);
};
export default CommandsTab;
@@ -0,0 +1,348 @@
import { useState, useEffect } from 'react';
import { GitMerge } from 'lucide-react';
import api from '../../services/api';
import EmptyState from '../EmptyState';
import { useToast } from '../../contexts/ToastContext';
import { useConfirm } from '../../hooks/useConfirm';
import { InfoList, InfoItem } from '../InfoList';
import DeploymentTimeline from '../deployments/DeploymentTimeline';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Pill } from '@/components/ds';
import Modal from '@/components/Modal';
// `embedded` renders this inside the Settings → Git & Deploy section, where the
// shared RepoConnectForm already owns the connect/disconnect + repo identity. In
// that mode we drop the empty-state CTA and the repo-config fields (repo/branch/
// auto-deploy) and surface only the deploy pipeline: run actions, deploy scripts,
// history and config checkpoints.
const DeployTab = ({ appId, appPath, embedded = false }) => {
const toast = useToast();
const { confirm: confirmDeploy } = useConfirm();
const [config, setConfig] = useState(null);
const [gitStatus, setGitStatus] = useState(null);
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(true);
const [deploying, setDeploying] = useState(false);
const [showConfigModal, setShowConfigModal] = useState(false);
const [loadingBranches, setLoadingBranches] = useState(false);
const [branches, setBranches] = useState([]);
const [error, setError] = useState(null);
const [configForm, setConfigForm] = useState({
repoUrl: '',
branch: 'main',
autoDeploy: true,
preDeployScript: '',
postDeployScript: ''
});
useEffect(() => {
loadData();
}, [appId]);
async function loadData() {
try {
setLoading(true);
const [configRes, historyRes] = await Promise.all([
api.getDeployConfig(appId),
api.getDeploymentHistory(appId, 20)
]);
if (configRes.configured) {
setConfig(configRes.config);
setConfigForm({
repoUrl: configRes.config.repo_url || '',
branch: configRes.config.branch || 'main',
autoDeploy: configRes.config.auto_deploy !== false,
preDeployScript: configRes.config.pre_deploy_script || '',
postDeployScript: configRes.config.post_deploy_script || ''
});
try {
const statusRes = await api.getAppGitStatus(appId);
setGitStatus(statusRes);
} catch { /* git status is optional context for the deploy tab */ }
} else {
setConfig(null);
}
setHistory(historyRes.deployments || []);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleConfigureDeployment(e) {
e.preventDefault();
try {
await api.configureDeployment(
appId,
configForm.repoUrl,
configForm.branch,
configForm.autoDeploy,
configForm.preDeployScript || null,
configForm.postDeployScript || null
);
setShowConfigModal(false);
loadData();
} catch (err) {
setError(err.message);
}
}
async function handleRemoveDeployment() {
const confirmed = await confirmDeploy({ title: 'Remove Deployment', message: 'Remove deployment configuration? This will not delete the repository files.', variant: 'warning' });
if (!confirmed) return;
try {
await api.removeDeployment(appId);
setConfig(null);
setGitStatus(null);
loadData();
} catch (err) {
setError(err.message);
}
}
async function handleDeploy(force = false) {
setDeploying(true);
setError(null);
try {
const result = await api.triggerAppDeploy(appId, force);
if (result.success) {
toast.success('Deployment completed successfully!');
} else {
setError(result.error || 'Deployment failed');
}
loadData();
} catch (err) {
setError(err.message);
} finally {
setDeploying(false);
}
}
async function handlePull() {
setDeploying(true);
setError(null);
try {
const result = await api.pullChanges(appId);
if (result.success) {
toast.success('Changes pulled successfully!');
} else {
setError(result.error || 'Pull failed');
}
loadData();
} catch (err) {
setError(err.message);
} finally {
setDeploying(false);
}
}
function getStatusPillKind(status) {
switch (status) {
case 'success': return 'green';
case 'failed': return 'red';
case 'in_progress': return 'amber';
default: return 'gray';
}
}
if (loading) {
return <EmptyState loading title="Loading deployment configuration..." />;
}
return (
<div className="deploy-tab">
{error && (
<div className="alert alert-danger">
{error}
<button type="button" onClick={() => setError(null)} className="alert-close">&times;</button>
</div>
)}
{!config ? (
// Embedded: RepoConnectForm above handles connecting, so don't
// duplicate the CTA — just show nothing until a repo is linked.
embedded ? null : (
<div className="deploy-setup">
<EmptyState
icon={GitMerge}
title="Git Deployment Not Configured"
description="Connect a Git repository to enable automatic deployments via webhooks or manual triggers."
action={<Button onClick={() => setShowConfigModal(true)}>Configure Deployment</Button>}
/>
</div>
)
) : (
<>
<div className="deploy-header">
<div className="deploy-status-card">
<div className="deploy-repo-info">
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" fill="none" strokeWidth="2">
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/>
</svg>
{/* Embedded: the connect form above already shows the
repo, so label the action instead of repeating it. */}
{embedded ? (
<div>
<span className="repo-url">Manual deploy</span>
<span className="repo-branch">Pull latest &amp; redeploy {config.branch}</span>
</div>
) : (
<div>
<span className="repo-url">{config.repo_url}</span>
<span className="repo-branch">Branch: {config.branch}</span>
</div>
)}
</div>
<div className="deploy-actions">
<Button
variant="outline"
size="sm"
onClick={handlePull}
disabled={deploying}
>
Pull Only
</Button>
<Button
onClick={() => handleDeploy(false)}
disabled={deploying}
>
{deploying ? 'Deploying...' : 'Deploy Now'}
</Button>
</div>
</div>
</div>
<div className="deploy-grid">
<div className="card">
<h3>{embedded ? 'Deploy Scripts' : 'Configuration'}</h3>
{embedded ? (
<InfoList>
<InfoItem label="Pre-deploy" value={config.pre_deploy_script || '—'} mono />
<InfoItem label="Post-deploy" value={config.post_deploy_script || '—'} mono />
</InfoList>
) : (
<InfoList>
<InfoItem label="Repository" value={config.repo_url} mono />
<InfoItem label="Branch" value={config.branch} />
<InfoItem label="Auto Deploy" value={config.auto_deploy ? 'Enabled' : 'Disabled'} />
</InfoList>
)}
<div className="card-actions">
<Button variant="outline" size="sm" onClick={() => setShowConfigModal(true)}>
{embedded ? 'Edit Scripts' : 'Edit'}
</Button>
{!embedded && (
<Button variant="destructive" size="sm" onClick={handleRemoveDeployment}>
Remove
</Button>
)}
</div>
</div>
{history.length > 0 && (
<div className="card">
<h3>Deployment History</h3>
<div className="deployments-list">
{history.slice(0, 5).map((dep, idx) => (
<div key={idx} className="deployment-item">
<Pill kind={getStatusPillKind(dep.status)}>{dep.status}</Pill>
<span className="deployment-date">{new Date(dep.timestamp).toLocaleString()}</span>
</div>
))}
</div>
</div>
)}
</div>
</>
)}
{/* Config snapshot timeline + diff — additive, independent of git
config so it shows the deploy history & config changes for any app. */}
<div className="card deploy-timeline-card">
<h3>Config Checkpoints</h3>
<p className="deploy-timeline-card__hint">
An immutable config checkpoint (env keys, domains, image, build
method, volumes) is captured before each deployment. Secret values are
masked. Open a checkpoint to diff it against the previous one or restore it.
</p>
<DeploymentTimeline appId={appId} />
</div>
<Modal open={showConfigModal} onClose={() => setShowConfigModal(false)} title={embedded ? 'Edit Deploy Scripts' : 'Configure Deployment'}>
<form onSubmit={handleConfigureDeployment}>
{/* In embedded mode repo/branch/auto-deploy are owned by the
RepoConnectForm above; only the deploy scripts are edited
here. The hidden fields stay seeded from `config` so saving
preserves them. */}
{!embedded && (
<>
<div className="form-group">
<label>Repository URL</label>
<Input
type="text"
value={configForm.repoUrl}
onChange={e => setConfigForm({...configForm, repoUrl: e.target.value})}
placeholder="https://github.com/user/repo.git"
required
/>
</div>
<div className="form-group">
<label>Branch</label>
<Input
type="text"
value={configForm.branch}
onChange={e => setConfigForm({...configForm, branch: e.target.value})}
placeholder="main"
/>
</div>
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={configForm.autoDeploy}
onChange={e => setConfigForm({...configForm, autoDeploy: e.target.checked})}
/>
<span>Enable auto-deploy on push</span>
</label>
</div>
</>
)}
<div className="form-group">
<label>Pre-deploy Script</label>
<Textarea
value={configForm.preDeployScript}
onChange={e => setConfigForm({...configForm, preDeployScript: e.target.value})}
placeholder="npm install"
rows={3}
/>
</div>
<div className="form-group">
<label>Post-deploy Script</label>
<Textarea
value={configForm.postDeployScript}
onChange={e => setConfigForm({...configForm, postDeployScript: e.target.value})}
placeholder="npm run build"
rows={3}
/>
</div>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={() => setShowConfigModal(false)}>
Cancel
</Button>
<Button type="submit">
Save Configuration
</Button>
</div>
</form>
</Modal>
</div>
);
};
export default DeployTab;
@@ -0,0 +1,63 @@
import { useState, useEffect } from 'react';
import api from '../../services/api';
import EmptyState from '../EmptyState';
import { useToast } from '../../contexts/ToastContext';
import { Button } from '@/components/ui/button';
const GunicornTab = ({ appId }) => {
const toast = useToast();
const [config, setConfig] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
loadConfig();
}, [appId]);
async function loadConfig() {
try {
const data = await api.getGunicornConfig(appId);
setConfig(data.content || '');
} catch (err) {
console.error('Failed to load config:', err);
} finally {
setLoading(false);
}
}
async function handleSave() {
setSaving(true);
try {
await api.updateGunicornConfig(appId, config);
toast.success('Configuration saved. Restart the app to apply changes.');
} catch (err) {
toast.error('Failed to save configuration');
console.error('Failed to save config:', err);
} finally {
setSaving(false);
}
}
if (loading) {
return <EmptyState loading title="Loading Gunicorn configuration..." />;
}
return (
<div>
<div className="section-header">
<h3>Gunicorn Configuration</h3>
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
<textarea
className="code-editor"
value={config}
onChange={(e) => setConfig(e.target.value)}
spellCheck={false}
/>
</div>
);
};
export default GunicornTab;
@@ -0,0 +1,71 @@
import { useState, useEffect } from 'react';
import api from '../../services/api';
import { Button } from '@/components/ui/button';
const LogsTab = ({ app }) => {
const [logs, setLogs] = useState('');
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(false);
const isDockerApp = app.app_type === 'docker';
const isPythonApp = ['flask', 'django'].includes(app.app_type);
useEffect(() => {
loadLogs();
}, [app.id]);
useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(loadLogs, 5000);
return () => clearInterval(interval);
}, [autoRefresh, app.id]);
async function loadLogs() {
try {
let data;
if (isDockerApp) {
data = await api.getDockerAppLogs(app.id, 200);
} else if (isPythonApp) {
data = await api.getPythonAppLogs(app.id, 200);
} else {
data = { logs: 'Logs not available for this app type.' };
}
setLogs(data.logs || 'No logs available');
} catch (err) {
console.error('Failed to load logs:', err);
setLogs('Failed to load logs');
} finally {
setLoading(false);
}
}
return (
<div className="logs-tab">
<div className="logs-header">
<h3>Application Logs</h3>
<div className="logs-controls">
{isPythonApp && (
<span className="hint">Gunicorn/systemd Logs</span>
)}
{isDockerApp && (
<span className="hint">Docker Compose Logs</span>
)}
<label className="checkbox-inline">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
/>
Auto-refresh
</label>
<Button variant="outline" size="sm" onClick={loadLogs}>
Refresh
</Button>
</div>
</div>
<pre className="log-viewer">{loading ? 'Loading...' : logs}</pre>
</div>
);
};
export default LogsTab;
@@ -0,0 +1,374 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { formatBytes } from '@/utils/formatBytes';
import { useConfirm } from '../../hooks/useConfirm';
import PrivateURLSection from '../PrivateURLSection';
import LinkedAppsSection from '../LinkedAppsSection';
import LinkAppModal from '../LinkAppModal';
import TagsPanel from '../shared/TagsPanel';
import EnvironmentVariablesPanel from '../shared/EnvironmentVariablesPanel';
import { Button } from '@/components/ui/button';
// Overview Tab with new grid layout
const OverviewTab = ({ app, onUpdate }) => {
const navigate = useNavigate();
const { confirm: confirmOverview } = useConfirm();
const [status, setStatus] = useState(null);
const [appStatus, setAppStatus] = useState(null);
const [linkedApps, setLinkedApps] = useState([]);
const [showLinkModal, setShowLinkModal] = useState(false);
const [linkLoading, setLinkLoading] = useState(false);
const [containerStats, setContainerStats] = useState(null);
useEffect(() => {
if (['flask', 'django'].includes(app.app_type)) {
loadStatus();
}
loadAppStatus();
loadLinkedApps();
if (app.app_type === 'docker') {
loadContainerStats();
}
}, [app]);
async function loadStatus() {
try {
const data = await api.getPythonAppStatus(app.id);
setStatus(data);
} catch (err) {
console.error('Failed to load status:', err);
}
}
async function loadAppStatus() {
try {
const data = await api.getAppStatus(app.id);
setAppStatus(data);
} catch (err) {
console.error('Failed to load app status:', err);
}
}
async function loadLinkedApps() {
try {
const data = await api.getLinkedApps(app.id);
setLinkedApps(data.linked_apps || []);
} catch (err) {
console.error('Failed to load linked apps:', err);
}
}
async function loadContainerStats() {
try {
// Try to get container stats for docker apps
const containers = await api.getContainers(true);
const appContainer = containers.containers?.find(c =>
c.name?.includes(app.name) || c.name?.includes(app.slug)
);
if (appContainer && appContainer.state === 'running') {
const stats = await api.getContainerStats(appContainer.id);
setContainerStats(stats.stats);
}
} catch (err) {
console.error('Failed to load container stats:', err);
}
}
async function handleUnlink() {
const confirmed = await confirmOverview({ title: 'Unlink Apps', message: 'Are you sure you want to unlink these apps? Database credentials will remain unchanged.', variant: 'warning' });
if (!confirmed) {
return;
}
setLinkLoading(true);
try {
await api.unlinkApp(app.id);
onUpdate();
loadLinkedApps();
} catch (err) {
console.error('Failed to unlink apps:', err);
} finally {
setLinkLoading(false);
}
}
function handleLinked() {
onUpdate();
loadLinkedApps();
}
function parseResourceValue(value) {
if (!value) return 0;
return parseFloat(value.replace('%', '')) || 0;
}
return (
<div className="app-overview-grid">
{/* Left Column */}
<div className="app-overview-left">
{/* Application Info Panel */}
<div className="app-panel">
<div className="app-panel-header">Application Info</div>
<div className="app-panel-body">
<div className="app-info-grid">
<div className="app-info-item">
<span className="app-info-label">Type</span>
<span className="app-info-value">{app.app_type === 'docker' ? 'Docker Container' : app.app_type.toUpperCase()}</span>
</div>
<div className="app-info-item">
<span className="app-info-label">Port</span>
<span className="app-info-value">
{app.port || '-'}
{appStatus && app.port && (
<span className={`port-indicator ${appStatus.port_accessible ? 'accessible' : ''}`}>
{appStatus.port_accessible ? ' (accessible)' : ' (not accessible)'}
</span>
)}
</span>
</div>
{app.python_version && (
<div className="app-info-item">
<span className="app-info-label">Python Version</span>
<span className="app-info-value">{app.python_version}</span>
</div>
)}
{app.php_version && (
<div className="app-info-item">
<span className="app-info-label">PHP Version</span>
<span className="app-info-value">{app.php_version}</span>
</div>
)}
<div className="app-info-item full-width">
<span className="app-info-label">Root Path</span>
<div><span className="app-path-value">{app.root_path || `/var/serverkit/apps/${app.name}`}</span></div>
</div>
</div>
</div>
</div>
{/* Routing Diagnostics Panel (Docker apps only) */}
{app.app_type === 'docker' && (
<RoutingDiagnosticsPanel appId={app.id} />
)}
{/* Environment Linking Panel */}
<div className="app-panel">
<div className="app-panel-header">Environment Linking</div>
<div className="app-panel-body">
<p className="app-panel-hint">
Link this app to another to create a production/development pair. Linked apps can share database credentials.
</p>
<LinkedAppsSection
app={app}
linkedApps={linkedApps}
onLink={() => setShowLinkModal(true)}
onUnlink={handleUnlink}
onNavigate={(appId) => navigate(`/apps/${appId}`)}
loading={linkLoading}
compact
/>
</div>
</div>
{/* Process Status (Python apps) */}
{status && (
<div className="app-panel">
<div className="app-panel-header">Process Status</div>
<div className="app-panel-body">
<div className="app-info-grid">
<div className="app-info-item">
<span className="app-info-label">Service</span>
<span className="app-info-value mono">{status.service_name}</span>
</div>
<div className="app-info-item">
<span className="app-info-label">State</span>
<span className="app-info-value">{status.active_state} ({status.sub_state})</span>
</div>
{status.main_pid !== '0' && (
<div className="app-info-item">
<span className="app-info-label">PID</span>
<span className="app-info-value mono">{status.main_pid}</span>
</div>
)}
{status.memory && status.memory !== '0' && (
<div className="app-info-item">
<span className="app-info-label">Memory</span>
<span className="app-info-value">{formatBytes(parseInt(status.memory))}</span>
</div>
)}
</div>
</div>
</div>
)}
{/* Domains Panel */}
{app.domains && app.domains.length > 0 && (
<div className="app-panel">
<div className="app-panel-header">Domains</div>
<div className="app-panel-body">
<div className="domains-list">
{app.domains.map(domain => (
<div key={domain.id} className="domain-item">
<a href={`https://${domain.name}`} target="_blank" rel="noopener noreferrer">
{domain.name}
</a>
{domain.ssl_enabled && (
<span className="ssl-badge">SSL</span>
)}
</div>
))}
</div>
</div>
</div>
)}
</div>
{/* Right Column */}
<div className="app-overview-right">
{/* Private URL Panel (Docker apps with port) */}
{app.app_type === 'docker' && app.port && (
<PrivateURLSection app={app} onUpdate={onUpdate} />
)}
{/* Live Resources Panel */}
{app.app_type === 'docker' && (
<div className="app-panel">
<div className="app-panel-header">Live Resources</div>
<div className="app-panel-body">
<div className="resource-bar-container">
<div className="resource-bar-header">
<span className="resource-bar-label">CPU Load</span>
<span className="resource-bar-value">
{containerStats ? `${parseResourceValue(containerStats.CPUPerc).toFixed(0)}%` : '-'}
</span>
</div>
<div className="resource-bar-track">
<div
className="resource-bar-fill cpu"
style={{ width: `${containerStats ? parseResourceValue(containerStats.CPUPerc) : 0}%` }}
/>
</div>
</div>
<div className="resource-bar-container">
<div className="resource-bar-header">
<span className="resource-bar-label">RAM Usage</span>
<span className="resource-bar-value">
{containerStats?.MemUsage || '-'}
</span>
</div>
<div className="resource-bar-track">
<div
className="resource-bar-fill ram"
style={{ width: `${containerStats ? parseResourceValue(containerStats.MemPerc) : 0}%` }}
/>
</div>
</div>
{!containerStats && app.status !== 'running' && (
<p className="resource-hint">Start the container to see live resources.</p>
)}
</div>
</div>
)}
{/* Tags Panel (polymorphic shared resource) */}
<div className="app-panel">
<div className="app-panel-header">Tags</div>
<div className="app-panel-body">
<TagsPanel resourceType="application" resourceId={app.id} />
</div>
</div>
{/* Shared Variables Panel (resolved from attached groups) */}
<div className="app-panel">
<div className="app-panel-body">
<EnvironmentVariablesPanel resourceType="application" resourceId={app.id} />
</div>
</div>
</div>
{showLinkModal && (
<LinkAppModal
app={app}
onClose={() => setShowLinkModal(false)}
onLinked={handleLinked}
/>
)}
</div>
);
};
// Routing Diagnostics Panel
const RoutingDiagnosticsPanel = ({ appId }) => {
const [diagnostics, setDiagnostics] = useState(null);
const [loading, setLoading] = useState(false);
const [lastChecked, setLastChecked] = useState(null);
async function runDiagnostics() {
setLoading(true);
try {
const response = await fetch(`/api/v1/domains/debug/diagnose/${appId}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
}
});
const data = await response.json();
setDiagnostics(data);
setLastChecked(new Date());
} catch (err) {
console.error('Failed to run diagnostics:', err);
} finally {
setLoading(false);
}
}
const isHealthy = diagnostics?.health?.overall;
return (
<div className="app-panel">
<div className="app-panel-header">
<span>Routing Diagnostics</span>
<span className="app-panel-header-actions">
<Button
variant="ghost"
size="sm"
onClick={runDiagnostics}
disabled={loading}
>
{loading ? 'Checking...' : 'Run Diagnostics'}
</Button>
</span>
</div>
<div className="app-panel-body">
{diagnostics ? (
<>
<div className={`diag-status ${isHealthy ? 'healthy' : 'unhealthy'}`}>
<div className={`diag-icon ${isHealthy ? 'healthy' : 'unhealthy'}`}>
{isHealthy ? '✓' : '✗'}
</div>
<div className="diag-text">
<h4>{isHealthy ? 'Configuration Healthy' : 'Issues Detected'}</h4>
<p>
{lastChecked && `Last checked: ${Math.round((Date.now() - lastChecked) / 60000)} minutes ago. `}
{isHealthy ? 'No routing issues detected.' : 'Some checks failed.'}
</p>
</div>
</div>
{diagnostics.health?.issues?.length > 0 && (
<ul className="diag-issues">
{diagnostics.health.issues.map((issue, i) => (
<li key={i}>{issue}</li>
))}
</ul>
)}
</>
) : (
<p className="app-panel-hint">
Click &quot;Run Diagnostics&quot; to check routing configuration and identify issues.
</p>
)}
</div>
</div>
);
};
export default OverviewTab;
@@ -0,0 +1,93 @@
import { useState, useEffect } from 'react';
import api from '../../services/api';
import EmptyState from '../EmptyState';
import { useToast } from '../../contexts/ToastContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
const PackagesTab = ({ appId }) => {
const toast = useToast();
const [packages, setPackages] = useState([]);
const [loading, setLoading] = useState(true);
const [installing, setInstalling] = useState(false);
const [newPackage, setNewPackage] = useState('');
useEffect(() => {
loadPackages();
}, [appId]);
async function loadPackages() {
try {
const data = await api.getPythonPackages(appId);
setPackages(data.packages || []);
} catch (err) {
console.error('Failed to load packages:', err);
} finally {
setLoading(false);
}
}
async function handleInstall(e) {
e.preventDefault();
if (!newPackage.trim()) return;
setInstalling(true);
try {
await api.installPythonPackages(appId, [newPackage.trim()]);
setNewPackage('');
loadPackages();
} catch (err) {
console.error('Failed to install package:', err);
} finally {
setInstalling(false);
}
}
async function handleFreeze() {
try {
await api.freezePythonRequirements(appId);
toast.success('requirements.txt updated');
} catch (err) {
toast.error('Failed to freeze requirements');
console.error('Failed to freeze requirements:', err);
}
}
if (loading) {
return <EmptyState loading title="Loading packages..." />;
}
return (
<div>
<div className="section-header">
<h3>Installed Packages</h3>
<Button variant="outline" size="sm" onClick={handleFreeze}>
Freeze to requirements.txt
</Button>
</div>
<form className="install-form" onSubmit={handleInstall}>
<Input
type="text"
value={newPackage}
onChange={(e) => setNewPackage(e.target.value)}
placeholder="Package name (e.g., requests, flask==2.0.0)"
/>
<Button type="submit" disabled={installing}>
{installing ? 'Installing...' : 'Install'}
</Button>
</form>
<div className="packages-list">
{packages.map(pkg => (
<div key={pkg.name} className="package-item">
<span className="package-name">{pkg.name}</span>
<span className="package-version">{pkg.version}</span>
</div>
))}
</div>
</div>
);
};
export default PackagesTab;
@@ -0,0 +1,142 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useConfirm } from '../../hooks/useConfirm';
import { DangerZone } from '../DangerZone';
import { Button } from '@/components/ui/button';
import { EnvTag } from '@/components/ds';
const SettingsTab = ({ app, onUpdate }) => {
const navigate = useNavigate();
const { confirm: confirmAppSettings } = useConfirm();
const [deleting, setDeleting] = useState(false);
const [environmentType, setEnvironmentType] = useState(app.environment_type || 'standalone');
const [savingEnvironment, setSavingEnvironment] = useState(false);
const [unlinking, setUnlinking] = useState(false);
const envLabels = {
standalone: 'Standalone',
production: 'Production',
development: 'Development',
staging: 'Staging'
};
async function handleDelete() {
const firstConfirm = await confirmAppSettings({ title: 'Delete Application', message: `Delete ${app.name}? This action cannot be undone.` });
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;
setDeleting(true);
try {
await api.deleteApp(app.id);
navigate('/apps');
} catch (err) {
console.error('Failed to delete app:', err);
setDeleting(false);
}
}
async function handleEnvironmentChange(newType) {
if (newType === app.environment_type) return;
setSavingEnvironment(true);
try {
await api.updateAppEnvironment(app.id, newType);
setEnvironmentType(newType);
onUpdate();
} catch (err) {
console.error('Failed to update environment:', err);
setEnvironmentType(app.environment_type || 'standalone');
} finally {
setSavingEnvironment(false);
}
}
async function handleUnlink() {
const confirmed = await confirmAppSettings({ title: 'Unlink Application', message: `Unlink ${app.name} from its linked application? Both apps will become standalone.`, variant: 'warning' });
if (!confirmed) return;
setUnlinking(true);
try {
await api.unlinkApp(app.id);
onUpdate();
} catch (err) {
console.error('Failed to unlink app:', err);
} finally {
setUnlinking(false);
}
}
return (
<div>
<h3 className="app-eyebrow">Application Settings</h3>
<div className="card settings-section">
<h4>Environment Configuration</h4>
<div className="settings-row">
<div className="settings-label">
<span>Environment Type</span>
<span className="settings-hint">
{app.has_linked_app
? 'This app is linked. Unlink to change environment type.'
: 'Set how this application is used in your workflow.'}
</span>
</div>
<div className="settings-control">
{app.has_linked_app ? (
<EnvTag env={app.environment_type}>
{envLabels[app.environment_type] || app.environment_type}
</EnvTag>
) : (
<select
value={environmentType}
onChange={(e) => handleEnvironmentChange(e.target.value)}
disabled={savingEnvironment}
className="settings-select"
>
<option value="standalone">Standalone</option>
<option value="development">Development</option>
<option value="staging">Staging</option>
<option value="production">Production</option>
</select>
)}
{savingEnvironment && <span className="settings-saving">Saving...</span>}
</div>
</div>
{app.has_linked_app && (
<div className="settings-row settings-linked-warning">
<div className="settings-label">
<span>Linked Application</span>
<span className="settings-hint">
This app is linked to another application. Unlinking will reset both apps to standalone mode.
</span>
</div>
<div className="settings-control">
<Button
variant="outline"
onClick={handleUnlink}
disabled={unlinking}
>
{unlinking ? 'Unlinking...' : 'Unlink Application'}
</Button>
</div>
</div>
)}
</div>
<DangerZone
title="Danger Zone"
description="Once you delete an application, there is no going back."
action={
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
{deleting ? 'Deleting...' : 'Delete Application'}
</Button>
}
/>
</div>
);
};
export default SettingsTab;
@@ -0,0 +1,16 @@
// statusInfo.dotClass → ds Pill kind
export const STATUS_PILL = {
live: 'green',
stopped: 'gray',
deploying: 'amber',
building: 'amber',
failed: 'red',
};
// Ingress plane → label + Pill kind. Host Nginx is the neutral default;
// a managed proxy stack reads as the accent (cyan) choice. NULL/undefined
// reads as host Nginx, matching the backend.
export const INGRESS_META = {
proxy_stack: { label: 'Proxy stack', kind: 'cyan' },
nginx: { label: 'Nginx', kind: 'gray' },
};
@@ -50,7 +50,7 @@ const DEFAULT_POLICY = {
disabled_rule_ids: [],
};
const WafPanel = ({ app, onChanged }) => {
const AppWafPanel = ({ app, onChanged }) => {
const toast = useToast();
const [policy, setPolicy] = useState(DEFAULT_POLICY);
@@ -399,4 +399,4 @@ const WafPanel = ({ app, onChanged }) => {
);
};
export default WafPanel;
export default AppWafPanel;
@@ -3,7 +3,6 @@ import { RefreshCw, ArrowUpCircle, Moon, Sun, Gauge as GaugeIcon } from 'lucide-
import api from '../../services/api';
import { useToast } from '../../contexts/ToastContext';
import { useConfirm } from '../../hooks/useConfirm';
import { ConfirmDialog } from '../ConfirmDialog';
import { Pill } from '../ds';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -35,7 +34,7 @@ function formatStatusLabel(status) {
// ============================================================
const ImageUpdateSection = ({ app, onChanged }) => {
const toast = useToast();
const { confirm, confirmState, handleConfirm, handleCancel } = useConfirm();
const { confirm } = useConfirm();
const [info, setInfo] = useState(app.image_update || null);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
@@ -152,17 +151,6 @@ const ImageUpdateSection = ({ app, onChanged }) => {
)}
</div>
</div>
<ConfirmDialog
isOpen={confirmState.isOpen}
title={confirmState.title}
message={confirmState.message}
confirmText={confirmState.confirmText}
cancelText={confirmState.cancelText}
variant={confirmState.variant}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
</div>
);
};
@@ -9,7 +9,6 @@ import { History, RefreshCw, Loader2, List, CalendarDays, X } from 'lucide-react
import api from '@/services/api';
import { useToast } from '@/contexts/ToastContext';
import { useConfirm } from '@/hooks/useConfirm';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { Button } from '@/components/ui/button';
import ProtectionStatusCard from './ProtectionStatusCard';
@@ -30,7 +29,7 @@ function sameLocalDay(iso, date) {
export default function ProtectionPanel({ targetType, targetId, targetName, showMaintenanceModeOption = false }) {
const toast = useToast();
const navigate = useNavigate();
const { confirm, confirmState, handleConfirm, handleCancel } = useConfirm();
const { confirm } = useConfirm();
const [view, setView] = useState(null); // policy view payload
const [runs, setRuns] = useState([]);
@@ -266,17 +265,6 @@ export default function ProtectionPanel({ targetType, targetId, targetName, show
targetType={targetType}
showMaintenanceModeOption={showMaintenanceModeOption}
/>
<ConfirmDialog
isOpen={confirmState.isOpen}
title={confirmState.title}
message={confirmState.message}
confirmText={confirmState.confirmText}
cancelText={confirmState.cancelText}
variant={confirmState.variant}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
</div>
);
}
@@ -62,7 +62,7 @@ const ProtectionStatusCard = ({
const statusPill = <Pill kind={kind}>{label}</Pill>;
const subtitle = enabled
? 'Snapshots run on the configured schedule.'
? 'Backups run on the configured schedule.'
: 'Turn on to back up this site automatically.';
// Inline status line (and, for the failed state, a deep-link to Jobs).
@@ -81,6 +81,47 @@ function buildCron(frequency, time, days) {
}
const ScheduleCard = ({ policy, remoteConfigured, onSave, saving }) => {
// All hooks run unconditionally, before any early return (Rules of Hooks).
// The form is only shown once a policy exists, so the seed values used when
// `policy` is null are throwaway and get re-seeded by the effect on load.
const initial = deriveFromCron(policy?.schedule_cron);
const [frequency, setFrequency] = useState(initial.frequency);
const [time, setTime] = useState(initial.time);
const [days, setDays] = useState(initial.days);
const [retentionCount, setRetentionCount] = useState(String(policy?.retention_count ?? ''));
const [retentionDays, setRetentionDays] = useState(String(policy?.retention_days ?? ''));
const [fullEvery, setFullEvery] = useState(String(policy?.full_every_n_days ?? ''));
const [compression, setCompression] = useState(policy?.compression ?? 'balanced');
const [remoteCopy, setRemoteCopy] = useState(!!policy?.remote_copy);
// Re-seed the form when the saved policy changes (e.g. external reload after
// a successful save). Keyed on the persisted values so it only fires when
// the source of truth actually moves, not on every render.
useEffect(() => {
if (!policy) return;
const next = deriveFromCron(policy.schedule_cron);
setFrequency(next.frequency);
setTime(next.time);
setDays(next.days);
setRetentionCount(String(policy.retention_count));
setRetentionDays(String(policy.retention_days));
setFullEvery(String(policy.full_every_n_days));
setCompression(policy.compression);
setRemoteCopy(!!policy.remote_copy);
}, [
policy,
policy?.schedule_cron,
policy?.retention_count,
policy?.retention_days,
policy?.full_every_n_days,
policy?.compression,
policy?.remote_copy,
]);
const currentCron = useMemo(() => buildCron(frequency, time, days), [frequency, time, days]);
// Conditional render AFTER all hooks have run.
if (!policy) {
return (
<div className="app-panel schedule-card">
@@ -95,41 +136,6 @@ const ScheduleCard = ({ policy, remoteConfigured, onSave, saving }) => {
);
}
const initial = deriveFromCron(policy.schedule_cron);
const [frequency, setFrequency] = useState(initial.frequency);
const [time, setTime] = useState(initial.time);
const [days, setDays] = useState(initial.days);
const [retentionCount, setRetentionCount] = useState(String(policy.retention_count));
const [retentionDays, setRetentionDays] = useState(String(policy.retention_days));
const [fullEvery, setFullEvery] = useState(String(policy.full_every_n_days));
const [compression, setCompression] = useState(policy.compression);
const [remoteCopy, setRemoteCopy] = useState(!!policy.remote_copy);
// Re-seed the form when the saved policy changes (e.g. external reload after
// a successful save). Keyed on the persisted values so it only fires when
// the source of truth actually moves, not on every render.
useEffect(() => {
const next = deriveFromCron(policy.schedule_cron);
setFrequency(next.frequency);
setTime(next.time);
setDays(next.days);
setRetentionCount(String(policy.retention_count));
setRetentionDays(String(policy.retention_days));
setFullEvery(String(policy.full_every_n_days));
setCompression(policy.compression);
setRemoteCopy(!!policy.remote_copy);
}, [
policy.schedule_cron,
policy.retention_count,
policy.retention_days,
policy.full_every_n_days,
policy.compression,
policy.remote_copy,
]);
const currentCron = useMemo(() => buildCron(frequency, time, days), [frequency, time, days]);
const toggleDay = (i) => {
setDays((prev) =>
prev.includes(i) ? prev.filter((d) => d !== i) : [...prev, i].sort((a, b) => a - b)
@@ -19,7 +19,7 @@ const ACTION_OPTIONS = [
const EXPRESSION_PLACEHOLDER = '(http.request.uri.path contains "/admin")';
export default function WafPanel({ zoneId, isAdmin }) {
export default function CloudflareWafPanel({ zoneId, isAdmin }) {
const toast = useToast();
const [loading, setLoading] = useState(true);
@@ -3,15 +3,7 @@ import { Archive, Trash2, RefreshCw } from 'lucide-react';
import api from '../../services/api';
import EmptyState from '../EmptyState';
import { useConfirm } from '../../hooks/useConfirm';
import { ConfirmDialog } from '../ConfirmDialog';
function formatBytes(bytes) {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
import { formatBytes } from '@/utils/formatBytes';
const FILTERS = [
{ id: 'all', label: 'All' },
@@ -20,7 +12,7 @@ const FILTERS = [
];
export default function BackupsTab() {
const { confirm, confirmState, handleConfirm, handleCancel } = useConfirm();
const { confirm } = useConfirm();
const [backups, setBackups] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('all');
@@ -91,7 +83,7 @@ export default function BackupsTab() {
<span className={`dbx-engine-tag is-${b.type}`}>{b.type === 'mysql' ? 'MySQL' : 'PostgreSQL'}</span>
</td>
<td className="dbx-mono">{b.filename}</td>
<td className="dbx-mono">{formatBytes(b.size)}</td>
<td className="dbx-mono">{formatBytes(b.size, { decimals: 2, defaultValue: '0 B' })}</td>
<td className="dbx-mono">{new Date(b.created_at).toLocaleString()}</td>
<td className="dbx-grid-actions">
<button type="button" className="dbx-icon-btn is-danger" onClick={() => remove(b.filename)} aria-label={`Delete ${b.filename}`}>
@@ -104,17 +96,6 @@ export default function BackupsTab() {
</table>
)}
</div>
<ConfirmDialog
isOpen={confirmState.isOpen}
title={confirmState.title}
message={confirmState.message}
confirmText={confirmState.confirmText}
cancelText={confirmState.cancelText}
variant={confirmState.variant}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
</div>
);
}
+132 -160
View File
@@ -1,35 +1,31 @@
import { useState } from 'react';
import api from '../../services/api';
import Modal from '@/components/Modal';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
// Create / credentials modals for the explorer. Logic is unchanged from the
// original Databases page; the explorer wires them to its toolbar and tree.
// Each modal component is mounted only while open, so Modal `open` is constant.
// The submit button stays inside <form>, so actions live in the body (not the
// Modal footer slot).
function CredentialsResult({ title, rows, onDone }) {
return (
<div className="modal-overlay" onClick={onDone}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>{title}</h2>
<button className="modal-close" onClick={onDone}>&times;</button>
</div>
<div className="modal-body">
<div className="credentials-box">
<p>Save these credentials — the password won&apos;t be shown again.</p>
{rows.map(([label, value]) => (
<div className="credential-item" key={label}>
<label>{label}:</label>
<code>{value}</code>
</div>
))}
<Modal open onClose={onDone} title={title}>
<div className="credentials-box">
<p>Save these credentials — the password won&apos;t be shown again.</p>
{rows.map(([label, value]) => (
<div className="credential-item" key={label}>
<label>{label}:</label>
<code>{value}</code>
</div>
</div>
<div className="modal-actions">
<Button onClick={onDone}>Done</Button>
</div>
))}
</div>
</div>
<div className="modal-actions">
<Button onClick={onDone}>Done</Button>
</div>
</Modal>
);
}
@@ -67,49 +63,43 @@ export function CreateMySQLDatabaseModal({ onClose, onCreated }) {
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Create MySQL database</h2>
<button className="modal-close" onClick={onClose}>&times;</button>
<Modal open onClose={onClose} title="Create MySQL database">
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Database name *</label>
<Input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="my_database" required pattern="[a-zA-Z0-9_]+" autoFocus />
</div>
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-row">
<div className="form-group">
<label>Database name *</label>
<Input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="my_database" required pattern="[a-zA-Z0-9_]+" autoFocus />
</div>
<div className="form-row">
<div className="form-group">
<label>Character set</label>
<select value={formData.charset} onChange={(e) => setFormData({ ...formData, charset: e.target.value })}>
<option value="utf8mb4">utf8mb4</option>
<option value="utf8">utf8</option>
<option value="latin1">latin1</option>
</select>
</div>
<div className="form-group">
<label>Collation</label>
<select value={formData.collation} onChange={(e) => setFormData({ ...formData, collation: e.target.value })}>
<option value="utf8mb4_unicode_ci">utf8mb4_unicode_ci</option>
<option value="utf8mb4_general_ci">utf8mb4_general_ci</option>
<option value="utf8_general_ci">utf8_general_ci</option>
</select>
</div>
<label>Character set</label>
<select value={formData.charset} onChange={(e) => setFormData({ ...formData, charset: e.target.value })}>
<option value="utf8mb4">utf8mb4</option>
<option value="utf8">utf8</option>
<option value="latin1">latin1</option>
</select>
</div>
<div className="form-group">
<label className="checkbox-label">
<input type="checkbox" checked={formData.create_user} onChange={(e) => setFormData({ ...formData, create_user: e.target.checked })} />
Create user with same name and full privileges
</label>
<label>Collation</label>
<select value={formData.collation} onChange={(e) => setFormData({ ...formData, collation: e.target.value })}>
<option value="utf8mb4_unicode_ci">utf8mb4_unicode_ci</option>
<option value="utf8mb4_general_ci">utf8mb4_general_ci</option>
<option value="utf8_general_ci">utf8_general_ci</option>
</select>
</div>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={loading}>{loading ? 'Creating…' : 'Create database'}</Button>
</div>
</form>
</div>
</div>
</div>
<div className="form-group">
<label className="checkbox-label">
<input type="checkbox" checked={formData.create_user} onChange={(e) => setFormData({ ...formData, create_user: e.target.checked })} />
Create user with same name and full privileges
</label>
</div>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={loading}>{loading ? 'Creating…' : 'Create database'}</Button>
</div>
</form>
</Modal>
);
}
@@ -153,47 +143,41 @@ export function CreateMySQLUserModal({ databases, onClose, onCreated }) {
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Create MySQL user</h2>
<button className="modal-close" onClick={onClose}>&times;</button>
<Modal open onClose={onClose} title="Create MySQL user">
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Username *</label>
<Input type="text" value={formData.username} onChange={(e) => setFormData({ ...formData, username: e.target.value })} placeholder="db_user" required autoFocus />
</div>
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Username *</label>
<Input type="text" value={formData.username} onChange={(e) => setFormData({ ...formData, username: e.target.value })} placeholder="db_user" required autoFocus />
<div className="form-group">
<label>Password</label>
<div className="input-with-button">
<Input type="text" value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value })} placeholder="Leave empty to auto-generate" />
<Button type="button" variant="outline" size="sm" onClick={generatePassword}>Generate</Button>
</div>
<div className="form-group">
<label>Password</label>
<div className="input-with-button">
<Input type="text" value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value })} placeholder="Leave empty to auto-generate" />
<Button type="button" variant="outline" size="sm" onClick={generatePassword}>Generate</Button>
</div>
</div>
<div className="form-group">
<label>Host</label>
<select value={formData.host} onChange={(e) => setFormData({ ...formData, host: e.target.value })}>
<option value="localhost">localhost</option>
<option value="%">% (any host)</option>
<option value="127.0.0.1">127.0.0.1</option>
</select>
</div>
<div className="form-group">
<label>Grant privileges on database</label>
<select value={formData.database} onChange={(e) => setFormData({ ...formData, database: e.target.value })}>
<option value="">— None —</option>
{databases.map((db) => <option key={db.name} value={db.name}>{db.name}</option>)}
</select>
</div>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={loading}>{loading ? 'Creating…' : 'Create user'}</Button>
</div>
</form>
</div>
</div>
</div>
<div className="form-group">
<label>Host</label>
<select value={formData.host} onChange={(e) => setFormData({ ...formData, host: e.target.value })}>
<option value="localhost">localhost</option>
<option value="%">% (any host)</option>
<option value="127.0.0.1">127.0.0.1</option>
</select>
</div>
<div className="form-group">
<label>Grant privileges on database</label>
<select value={formData.database} onChange={(e) => setFormData({ ...formData, database: e.target.value })}>
<option value="">— None —</option>
{databases.map((db) => <option key={db.name} value={db.name}>{db.name}</option>)}
</select>
</div>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={loading}>{loading ? 'Creating…' : 'Create user'}</Button>
</div>
</form>
</Modal>
);
}
@@ -231,39 +215,33 @@ export function CreatePostgreSQLDatabaseModal({ onClose, onCreated }) {
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Create PostgreSQL database</h2>
<button className="modal-close" onClick={onClose}>&times;</button>
<Modal open onClose={onClose} title="Create PostgreSQL database">
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Database name *</label>
<Input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="my_database" required autoFocus />
</div>
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Database name *</label>
<Input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="my_database" required autoFocus />
</div>
<div className="form-group">
<label>Encoding</label>
<select value={formData.encoding} onChange={(e) => setFormData({ ...formData, encoding: e.target.value })}>
<option value="UTF8">UTF8</option>
<option value="LATIN1">LATIN1</option>
<option value="SQL_ASCII">SQL_ASCII</option>
</select>
</div>
<div className="form-group">
<label className="checkbox-label">
<input type="checkbox" checked={formData.create_user} onChange={(e) => setFormData({ ...formData, create_user: e.target.checked })} />
Create user with same name and full privileges
</label>
</div>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={loading}>{loading ? 'Creating…' : 'Create database'}</Button>
</div>
</form>
</div>
</div>
<div className="form-group">
<label>Encoding</label>
<select value={formData.encoding} onChange={(e) => setFormData({ ...formData, encoding: e.target.value })}>
<option value="UTF8">UTF8</option>
<option value="LATIN1">LATIN1</option>
<option value="SQL_ASCII">SQL_ASCII</option>
</select>
</div>
<div className="form-group">
<label className="checkbox-label">
<input type="checkbox" checked={formData.create_user} onChange={(e) => setFormData({ ...formData, create_user: e.target.checked })} />
Create user with same name and full privileges
</label>
</div>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={loading}>{loading ? 'Creating…' : 'Create database'}</Button>
</div>
</form>
</Modal>
);
}
@@ -307,38 +285,32 @@ export function CreatePostgreSQLUserModal({ databases, onClose, onCreated }) {
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Create PostgreSQL user</h2>
<button className="modal-close" onClick={onClose}>&times;</button>
<Modal open onClose={onClose} title="Create PostgreSQL user">
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Username *</label>
<Input type="text" value={formData.username} onChange={(e) => setFormData({ ...formData, username: e.target.value })} placeholder="db_user" required autoFocus />
</div>
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Username *</label>
<Input type="text" value={formData.username} onChange={(e) => setFormData({ ...formData, username: e.target.value })} placeholder="db_user" required autoFocus />
<div className="form-group">
<label>Password</label>
<div className="input-with-button">
<Input type="text" value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value })} placeholder="Leave empty to auto-generate" />
<Button type="button" variant="outline" size="sm" onClick={generatePassword}>Generate</Button>
</div>
<div className="form-group">
<label>Password</label>
<div className="input-with-button">
<Input type="text" value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value })} placeholder="Leave empty to auto-generate" />
<Button type="button" variant="outline" size="sm" onClick={generatePassword}>Generate</Button>
</div>
</div>
<div className="form-group">
<label>Grant privileges on database</label>
<select value={formData.database} onChange={(e) => setFormData({ ...formData, database: e.target.value })}>
<option value="">— None —</option>
{databases.map((db) => <option key={db.name} value={db.name}>{db.name}</option>)}
</select>
</div>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={loading}>{loading ? 'Creating…' : 'Create user'}</Button>
</div>
</form>
</div>
</div>
</div>
<div className="form-group">
<label>Grant privileges on database</label>
<select value={formData.database} onChange={(e) => setFormData({ ...formData, database: e.target.value })}>
<option value="">— None —</option>
{databases.map((db) => <option key={db.name} value={db.name}>{db.name}</option>)}
</select>
</div>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={loading}>{loading ? 'Creating…' : 'Create user'}</Button>
</div>
</form>
</Modal>
);
}
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { RotateCcw, X, Info } from 'lucide-react';
import { RotateCcw, Info } from 'lucide-react';
import api from '../../services/api';
import Modal from '@/components/Modal';
import { Button } from '@/components/ui/button';
import { useToast } from '../../contexts/ToastContext';
@@ -114,19 +115,8 @@ const ConfigDiffModal = ({ appId, snapId, against = 'previous', onClose, onResto
}
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal config-diff"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h2>Configuration diff</h2>
<button className="modal-close" onClick={onClose} aria-label="Close">
<X size={18} />
</button>
</div>
<div className="config-diff__body">
<Modal open onClose={onClose} title="Configuration diff" size="xl" className="config-diff">
<div className="config-diff__body">
{loading && <p className="config-diff__loading">Loading diff…</p>}
{error && <div className="alert alert-danger">{error}</div>}
@@ -142,7 +132,7 @@ const ConfigDiffModal = ({ appId, snapId, against = 'previous', onClose, onResto
<p className="config-diff__summary">
{meta.hasChanges && meta.summary
? meta.summary
: 'No configuration changes vs the compared snapshot.'}
: 'No configuration changes vs the compared checkpoint.'}
</p>
</div>
</div>
@@ -210,8 +200,7 @@ const ConfigDiffModal = ({ appId, snapId, against = 'previous', onClose, onResto
</>
)}
</div>
</div>
</div>
</Modal>
);
};
@@ -62,7 +62,7 @@ const DeploymentTimeline = ({ appId }) => {
return (
<div className="deploy-timeline deploy-timeline--empty">
<Clock size={20} />
<p>No configuration snapshots yet. One is captured before each deployment.</p>
<p>No config checkpoints yet. One is captured before each deployment.</p>
</div>
);
}
Loaded 100 of 340 files, more files were not shown because too many files have changed in this diff. Show more