Merge pull request #51 from jhd3197/dev
Refactor: unify core surfaces and overhaul the SCSS frontend
No files matched your search
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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.',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 816 B After Width: | Height: | Size: 678 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 7.7 KiB |
@@ -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 |
@@ -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."
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,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,4 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
export function DangerZone({ title, description, action, children, className = '' }) {
|
||||
if (children) {
|
||||
|
||||
@@ -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('')}>×</button>
|
||||
<button type="button" className="filter-clear" onClick={() => setFilter('')}>×</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"
|
||||
|
||||
@@ -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,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
||||
@@ -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,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,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)}
|
||||
|
||||
@@ -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,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,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,4 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
export function MetricRow({ children, className = '' }) {
|
||||
return <div className={`metric-row ${className}`.trim()}>{children}</div>;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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,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));
|
||||
|
||||
@@ -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,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,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export function ServicesGrid({ children, className = '' }) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,4 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
export function Spinner({ size = 'md', className = '' }) {
|
||||
const sizeClasses = {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Stat strip — a compact, instrument-panel readout bar.
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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">×</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'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">×</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 & 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 "Run Diagnostics" 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>
|
||||
);
|
||||
}
|
||||
@@ -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}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="credentials-box">
|
||||
<p>Save these credentials — the password won'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'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}>×</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}>×</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}>×</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}>×</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>
|
||||
);
|
||||
}
|
||||
|
||||