Hardening plan §8. A stdlib-only, import-safe, unit-tested Python package that
ports the stabilized bash install logic into testable orchestration. Per the
plan's migration path this is ADDITIVE — install.sh stays canonical and this is
NOT wired into it yet; it is the validated foundation a future thin-bootstrap
install.sh can call.
backend/serverkit_installer/:
- _proc.py — sudo-aware subprocess helpers, mirroring app/utils/system.py
WITHOUT importing app (the bootstrap runs before the venv
exists, so it must not pull in Flask).
- distro.py — OS family mapping (a faithful port of install.sh's
os_family_from, rhel-before-fedora) + container/WSL/systemd
predicates (mirroring scripts/lib/env.sh).
- deps.py — loads scripts/deps/manifest.yaml (its primary consumer) and
resolves base/python/node/docker/pkg-manager per family.
- firewall.py — detect + pure open/close command builders (port of
scripts/lib/firewall.sh).
- service.py — init detect + pure start/stop/enable/reload builders (port of
scripts/lib/init.sh).
- main.py — composes a READ-ONLY install "plan" + `python -m
serverkit_installer plan|detect` CLI. No host mutation.
Tests: backend/tests/test_serverkit_installer.py — 46 pure-logic cases
(family mapping vs os_family_from, real-manifest resolution for all six
families, firewall/service command builders, env predicates, composed plan).
46 passed; py_compile + pyflakes clean. The CLI proves the manifest is genuinely
consumed (Debian -> apt + python3.11 + base packages), not dead config.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hardening plan §7. Catches distro-specific regressions before release.
- scripts/test/vm-update.sh: install → forced local update (full blue/green
deploy/migrate/switch/health) → re-run to confirm the "Already up to date"
version-gate. Same VM harness contract as vm-install.sh (log + status file).
- scripts/test/vm-uninstall.sh: install + seed data → default uninstall asserts
data preserved (DB snapshot in backups, /var/lib survives) → reinstall →
--purge asserts data removed. End-to-end proof of the unified uninstaller.
- scripts-ci.yml: now runs all three serverless suites (update+install+lib),
lints the new lib/ + test/ files (error severity) and keeps scripts/lib/*
warning-clean, and expands the cross-distro unit matrix to Ubuntu 22.04/24.04,
Debian 12, Rocky 9, AlmaLinux 9, Fedora 40, and openSUSE Leap 15.5.
- test_install.sh: +choose_pkg_manager graceful-failure case (7.4); a no-op
docker stub so the uninstall-matrix cases run where docker isn't installed.
- test_lib.sh: neutralize the ambient container=oci env var (RPM/SUSE images)
and force an empty PATH for the "no package manager" case — both bugs the new
matrix surfaced (the tests, not the code).
- README: fixed the stale Fedora box, documented the new VM scripts + suites.
Validated by running the exact CI container logic locally across all seven
images: test_update 13/13, test_install 15-16/0, test_lib 13/13 everywhere.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hardening plan §6. Reduces the distro-specific if/else surface and handles
edge environments:
- Dependency manifest (scripts/deps/manifest.yaml): declarative per-family
package/python/node/docker/init spec. Consumed directly by the Phase 5
Python bootstrap; install.sh mirrors it inline (it can't parse YAML before
Python exists in the curl|bash path).
- New sourceable libs (built in parallel, each shellcheck-clean + unit-tested):
- scripts/lib/pkg.sh — pkg_detect/refresh/install/is_installed across
apt/dnf/yum/zypper/pacman/apk (PKG_MGR_OVERRIDE + PKG_DRY_RUN for tests).
- scripts/lib/init.sh — init_detect + start/stop/enable/disable/reload/
is_active across systemd/openrc/runit/sysvinit/none (INIT_OVERRIDE/DRY_RUN).
- scripts/lib/env.sh — is_container/is_wsl/has_systemd (override-friendly).
- ID_LIKE handling (G5/6.5): install.sh's os_family_from() (now a pure,
testable function reading ${SERVERKIT_OS_RELEASE}) maps unknown IDs via
ID_LIKE to debian/rhel/fedora/suse/arch/alpine — rhel before fedora so RHEL
clones pick the RHEL Docker repo.
- zypper + pacman + apk (G6/6.6): choose_pkg_manager/refresh_pkg_index/pkg_add,
provision_docker and provision_python gained suse/arch/alpine branches;
Docker enable/start is now init-aware (systemd OR OpenRC) so it no longer
aborts on Alpine.
- Templated systemd unit (6.7): serverkit-backend.service → templates/
serverkit-backend.service.in, rendered (render_service_unit) at install and
in refresh_config so a custom SERVERKIT_DIR/venv/log path is honored.
install_service warns-and-skips enable when has_systemd is false (G5).
- nginx TLS floor as a reversible conf.d snippet (6.8): install.sh + update.sh
prefer /etc/nginx/conf.d/serverkit-tls.conf when nginx.conf doesn't already
declare ssl_protocols (RHEL/Fedora/SUSE), falling back to the in-place edit
on Debian/Ubuntu to avoid a 'duplicate ssl_protocols' error. uninstall
removes only that snippet.
Tests: new scripts/test/test_lib.sh (13) + 4 new test_install.sh cases
(os_family_from, render_service_unit, harden_global_tls A/B). 13+15+10 pass;
shellcheck error+warning clean across all new/changed shell.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Updater robustness from the hardening plan §5:
- Version comparison before updating (G3): is_already_current compares the
local VERSION to the target release tag (release mode) or the local checkout
HEAD to the remote branch HEAD via `git ls-remote` (branch/main mode). When
current, both the hybrid and docker flows print "Already up to date" and exit
0 — unless --force. Offline/indeterminate always proceeds rather than wrongly
blocking.
- Dry-run fidelity: new run_in_dir reports the working directory a command
would run in, replacing the `run_or_dry cd X && run_or_dry compose ...` chain
that printed compose commands with no cwd context.
- Rollback hardening: migrate_database now runs against the NEW slot's DB copy
(slot-absolute path) instead of the /opt/serverkit symlink that still
resolves to the live old slot — so a rollback hands the old code a
byte-for-byte-unchanged database (PostgreSQL/shared DBs migrate directly, as
before). rollback_docker guards `git reset` on a non-empty SHA with a clear
warning. Both rollback paths now run a post-rollback health check and say
plainly when even the rollback is unhealthy.
- Telemetry/logging: halt() always prints the full log path; both summaries
gained an "old → new", mode (release/branch/main, +docker), and duration
line, plus the log path.
Tests: test_update.sh gains versions_equal, is_already_current force/offline
short-circuits, and a flask-stub proof that migrate_database targets the new
slot's DB (T10). 10 passed / 2 skipped locally; shellcheck error+warning clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Critical install/update/uninstall hotfixes from the hardening plan
(docs/plans/INSTALLER_UPDATER_UNINSTALLER_HARDENING_PLAN.md §4):
- Firewall automation (G1): new sourceable scripts/lib/firewall.sh detects
firewalld/ufw/nftables/iptables and opens 80/443; install.sh runs
configure_firewall, update.sh re-ensures it idempotently. Exactly what we
open is recorded in /etc/serverkit/install-state.json (scripts/lib/state.sh)
so uninstall undoes only our rules. Fixes the #1 "installed but unreachable"
failure on fresh RHEL-family boxes.
- Accept Python 3.11 OR 3.12 (G2): locate_python now iterates
python3.12/3.11/3 and provision_python installs the distro package
(python3.11 on Debian 12, 3.12 on Ubuntu/Fedora, 3.11/3.12 on RHEL) before
ever falling back to a source compile.
- Use $PYTHON_BIN for secret generation instead of a bare python3.
- Distro-first Node 20 (replacing unconditional NodeSource): install the
distro nodejs+npm, fall back to NodeSource only when it's missing or < 18,
and validate node>=18 before continuing.
- Unify uninstall (G4): new scripts/lib/uninstall.sh is the single canonical
teardown routine shared by uninstall.sh and `serverkit uninstall` (they had
drifted — one ran `down -v`, one didn't). Default preserves user data
(volumes kept, DB snapshotted to backups); --purge/--delete-volumes removes
volumes+DB+data dirs; --keep-data preserves /etc + /var/lib + backups;
--yes/--dry-run added; honors SERVERKIT_DIR. Removes the apt lock-wait
drop-in on uninstall.
Tests: new scripts/test/test_install.sh (11 cases, source-able install.sh)
covers python/node detection, firewall dry-run + state, and the uninstall
data-handling matrix. Existing test_update.sh still green. shellcheck clean
(error + warning) on all new/changed shell.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The updater's logic had zero test coverage on any distro, and ServerKit
targets several Linux families — so a portability regression only ever showed
up on a server. Add a matrix job that runs bash -n + scripts/test/test_update.sh
inside debian:12 and rockylinux:9 containers (Ubuntu is already covered by the
lint job). Verified locally: 9/9 pass on bash 5.2 (Debian) and 5.1 (Rocky).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two reliability gaps remained after the failure cascade:
1. Stale-updater trap. The updater that runs is whatever is installed, so an
old update.sh fails in ways the current code already handles and can't fix
itself — exactly what left a box stuck. maybe_reexec_latest_updater fetches
the newest update.sh for the target ref and re-execs into it (guarded by
SERVERKIT_UPDATER_REEXECED, skipped offline/dry-run/opt-out, only after a
bash -n check). From this version on, 'serverkit update' is reliable no
matter how stale the box is.
2. No concurrency guard. acquire_update_lock takes an flock so two updates
can't clobber the same blue/green slot or race docker compose; the lock
auto-releases on exit and degrades gracefully where flock/the path is
unavailable.
Both are covered by scripts/test/test_update.sh (guard no-ops; lock
refuses a held lock, succeeds when free).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Harden update.sh against the class of failure that left a box silently stuck
on the old version, and make it testable without a real deploy.
Robustness (cascade):
- L5 loud failures: `set -Eeuo pipefail` (the -E/errtrace makes the ERR trap
fire inside functions too) + a report_failure ERR trap that names the phase,
exit code, line and command, plus always-on logging to
/var/log/serverkit/update-*.log. No more silent drop-to-prompt.
- L4 docker-path rollback: snapshot the previous git SHA, :rollback image tags
and dist.rollback before mutating; if the new backend is not healthy within
90s, revert to the snapshot and halt (was: warn and continue).
- L6 self-heal: pin COMPOSE_PROJECT_NAME (kills the symlink-rename warning) and
reclaim a stale inactive blue/green slot left by a prior failed hybrid run.
Testability:
- Parameterize the system dirs (SERVERKIT_NGINX_DIR / _LETSENCRYPT_DIR /
_SYSTEMD_DIR / _CONFIG_DIR) and add SERVERKIT_NO_LOG, so refresh_config can
run against fixtures instead of the host /etc.
- Make update.sh source-able via a BASH_SOURCE guard so functions can be
exercised directly.
- scripts/test/test_update.sh: plain-bash unit tests (no deps, no server) that
reproduce the missing-serverkit.conf regression and cover the
detection/slot/report logic.
- .github/workflows/scripts-ci.yml: bash -n + shellcheck + the unit tests on
changes to scripts/**, install.sh, serverkit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The panel-domain recovery greps /etc/nginx/sites-available/serverkit.conf,
but HTTP-only boxes never have that file. Under `set -euo pipefail` the
no-match/missing-file grep (exit 1/2) propagated and silently killed the
updater at the "Refreshing Configuration" phase — before the atomic switch
and before PREVIOUS_DIR is set, so no error and no rollback. The update
appeared to "do nothing" and the version never advanced.
Guard the command substitution with `|| true` (matching the idiom used a
few lines below for panel-domain).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace spinners with skeleton placeholders across page/section loaders
(EmptyState, DataTable, and ~page surfaces), add a reusable Skeleton
component + _skeleton.scss, rework AppLoader into a branded "server
powering on" loader, and refresh the favicon set (incl. SVG icon link).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend:
- The monitoring / metrics / telemetry(events) / uptime / fleet / status-page
read surfaces are re-mounted under /api/v1/observability/<domain> as true
aliases (same blueprints, distinct names). Original prefixes stay; the public
status page route /api/v1/status/public/<slug> is untouched.
Frontend:
- The Monitoring tab group becomes "Observability": Monitoring · Events
(the SystemEvent stream, formerly the standalone "Telemetry" page) · Status
Pages, sharing one PageTopbar. /observability lands on Monitoring.
- The standalone Telemetry route + sidebar item are folded into the group
(sidebar "Monitoring" → "Observability"; Events covered by matchPrefixes;
removed from ADVANCED_ITEM_IDS). Command palette updated.
- Fleet Monitor stays under Servers (server-scoped) and GPU stays its own
hardware page — intentional, per the plan.
Deferred (plan §4 deep merge, documented): the unified events/metrics/checks/
alerts/thresholds tables + collector migration into the job system. The
read-surface namespace + IA consolidation ship now; the schema merge is a large
data migration best done against a live metrics store.
Verified: 3 backend namespace tests + telemetry suite pass (22); frontend build
+ eslint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Additive foundation + one read namespace for the three parallel deploy records
(versioned build Deployment, webhook GitDeployment, DeploymentJob executions).
Backend:
- DeploymentJob gains deployment_id / git_deployment_id / webhook_id FKs +
commit_hash / image_tag / container_id, so it can be the canonical execution
record pointing at the release ledgers it produces (surfaced in to_dict).
Migration 039 (idempotent, additive).
- New /api/v1/deployments surface:
GET /apps/:id/history?source=build|webhook|all → federated, source-tagged
GET /:id → build Deployment detail
and the DeploymentJob surface re-mounted at /api/v1/deployments/jobs (alias of
/deployment-jobs). Per-system blueprints (/builds, /deploy, /git) unchanged.
Deliberately NOT done (plan §3 Phase 1/2 deep merge, with rationale): folding
GitDeployment into Deployment+DeploymentJob and async-ifying the currently
synchronous build/webhook deploys. Recon confirmed those paths are synchronous
with distinct failure/timeout semantics and GitDeployment carries rollback state
(snapshot_data, rolled_back_to_id); flipping them to the queue can't be verified
without a live build/runner, so it's deferred behind the additive links above.
Verified: 6 new tests + previews/config-snapshot regression pass (35).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Additive API consolidation (plan §7 Phases 3–4) that exposes a WordPress site
through its Application id, treating WordPressSite as the 1:1 extension table it
already is:
- GET /apps/:id/wordpress → {is_wordpress, site_id, env_type, ...}
- GET /apps/:id/db-snapshots → WP DB snapshots (was §8-deferred)
- POST /apps/:id/db-snapshots → create
- DELETE /apps/:id/db-snapshots/:id → delete
All grant-aware via the apps.py access seam (ResourceGrantService).
Deliberately NOT done (plan §7 Phase 1/2, with rationale): blanket-setting
app_type='wordpress' on WordPressSite-linked apps. Recon showed many WP sites are
app_type='docker', and 14+ docker-compose/nginx/scale/sleep code paths key on
app_type=='docker' — changing it would silently break start/stop/restart/logs/
status/domain config for containerized WP. The codebase already treats the
WordPressSite record (not app_type) as the source of truth (wordpress.py
_is_wp_app), which is the correct architecture; flipping app_type would regress
it. Frontend WordPress→Services merge likewise deferred (the /wordpress/:id route
is site-id-keyed).
Verified: 6 new surface tests + WP/resource-grant regression suites pass (23).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unifies "SSL Certificates" and "Advanced SSL" into one lifecycle surface.
Backend:
- The advanced cert operations (wildcard DNS-01, SAN, custom upload, TLS
profiles, health, expiry alerts) now mount under /api/v1/ssl alongside the
basic certbot routes — no path collisions. /api/v1/ssl/advanced kept as a
deprecated alias (same blueprint, second registration).
Frontend:
- api client advanced methods repointed to the unified /ssl/* paths.
- SSLCertificates.jsx (already the single SSL page) gains the two cert-creation
paths that were previously UI-inaccessible: a "Wildcard (DNS-01)" mode in the
New Certificate modal (Cloudflare token / Route 53 keys) and an "Upload"
action + modal for bring-your-own PEM cert/key/chain.
Deferred (documented follow-up): a unified Certificate registry model + disk
scan, and surfacing health/profiles panels — the API is unified and the client
methods exist; these are read-side enhancements.
Verified: 3 backend route-unification tests + existing SSL suites pass (19);
frontend build + eslint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the email unification: the outbound relay is now one smtp
EmailProviderConnection flagged uses_relay, so a single table drives both the
Notification Bus and the Postfix relay. The legacy single-row email_relay_config
is folded in on first boot and kept only as a read fallback.
- EmailRelayService rewritten over the unified table: get/save/disable/test all
operate on the uses_relay smtp connection; password stays Fernet-encrypted in
credentials_json; masked round-trips preserve the stored secret. Postfix apply
unchanged (no-op when Postfix absent).
- migrate_legacy_config(): idempotent fold of email_relay_config → connection
(password copied verbatim — same Fernet key); wired into the boot-time legacy
migration block.
- API: PUT /notifications/admin/email-providers/:id toggles usage flags
(uses_notifications/uses_relay/relay_priority); uses_relay honored for SMTP only.
Verified: 6 new relay tests + usage-flag + notification-bus suites pass (30 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation for folding the single-row email_relay_config into the multi-row
EmailProviderConnection so one table drives notifications and the Postfix relay.
- EmailProviderConnection gains uses_notifications (default true), uses_relay
(default false), relay_priority (default 0); surfaced in to_dict.
- Notification selection (default_provider) now restricted to uses_notifications
rows, so relay-only connections are never used to send notifications.
- New relay_provider() selects the active SMTP connection by relay_priority.
- add_provider() accepts the flags; uses_relay honored only for provider=smtp
(API providers can't be a Postfix smarthost).
- Migration 038 (idempotent additive columns).
Additive — existing providers default to uses_notifications=true / no relay.
Verified: 4 new tests + notification-bus suite pass (24 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the §8 API consolidation: one admin surface drives a BackupPolicy for
ANY target — application, wordpress_site, database, files, server — by
(target_type, target_id):
GET/PUT /api/v1/backups/policies/<type>/<id> (PUT accepts
target_subtype + target_meta to describe database/files targets)
POST /api/v1/backups/policies/<type>/<id>/run
GET /api/v1/backups/policies/<type>/<id>/runs
POST .../runs/<run_id>/restore | /verify
DELETE .../runs/<run_id>
This is the only way to drive the database/files/server targets (which have no
per-resource page). The grant-aware per-resource mounts
(/apps/:id/backup-policy, /wordpress/sites/:id/backup-policy) are unchanged.
Deferred to §7 (WordPress-as-app): surfacing WP DatabaseSnapshot under
/apps/:id/db-snapshots, and the frontend Backups.jsx rewrite, both of which
depend on the app↔site unification.
Verified: 5 new tests; full backup/snapshot suite green (63 passed, 2 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Recorded §8 decision: a deployment config snapshot is shown as a "Config
Checkpoint" in the UI to end the overloaded "snapshot" wording. Distinct from
data-protection backups and from WordPress DB snapshots, which are untouched.
Backend:
- snapshots blueprint serves every route under /apps/:id/config-checkpoints
in addition to /apps/:id/snapshots (dual @route — both resolve to one view).
Frontend:
- DeployTab section "Deployment Timeline" → "Config Checkpoints" with reworded
hint; ConfigDiffModal + DeploymentTimeline empty/peer copy use "checkpoint".
- api/snapshots.js gains getConfigCheckpoint(s)/Diff/restore aliases.
- ProtectionStatusCard: "Snapshots run on the configured schedule" → "Backups"
(that card describes backups, not config checkpoints).
Verified: 2 alias tests + existing config-snapshot suite pass; frontend build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the cross-target queries the unified Backups page needs:
- GET /api/v1/backups/policies (filters: target_type, enabled) — replaces the
legacy filesystem schedule list with one view of every BackupPolicy.
- GET /api/v1/backups/runs (filters: target_type, status, policy_id, limit≤500)
— every BackupRun across all policies, each augmented with its target's
type/id/subtype so the UI filters without a second lookup.
Both admin-guarded like the rest of /backups.
Verified: 6 new API tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Folds the legacy BackupService database/files capabilities into the unified
BackupPolicyService so one policy/run system performs every backup kind.
- _resolve_target() now handles database (connection descriptor from
target_meta + target_subtype engine), files (path list), and rejects
server (not yet implemented) with a clear message.
- _execute_backup() routes database → BackupService.backup_database and
files → BackupService.backup_files, building consistent run metadata
(kind=full, includes, primary_archive).
- Restore: _restore_database (symmetric with restore_database) and
_restore_files (safe extract into a restores/ staging dir, never blind
overwrite of a basename-only path-list archive); run_restore_job dispatches
by target_type.
Note: remote-copy upload still applies to dir-based runs (app/WP); single-file
database/files runs are local-only for now (documented follow-up).
Verified: 8 new executor tests (BackupService mocked) + B1 + existing
backup-protection suite pass (26 passed, 2 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First slice of the backups/snapshots unification. Makes the policy-driven
BackupPolicy/BackupRun system able to describe every data-protection target,
not just application/wordpress_site — the foundation for folding the legacy
BackupService (database/files) into one system.
- BackupPolicy gains target_subtype + target_meta_json (+ get/set helpers,
surfaced in to_dict). VALID_TARGET_TYPES = application, wordpress_site,
database, files, server.
- BackupPolicyService.validate_target_type(); get_or_create_policy() accepts
and (idempotently) refreshes target_subtype/target_meta for non-resource
targets like database/files.
- Migration 037 (idempotent, additive columns).
Additive only — no behavior change for existing app/WP policies.
Verified: 6 new tests + existing backup-protection suite pass (18 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Feature-unification plan §2. Ends the naming collision between generic
/projects (workspace organization) and WordPress "Projects" (environment
pipelines). Label + URL change only; deeper WP/app unification is §7.
Frontend:
- Pipeline list moves to /wordpress/pipelines; detail to
/wordpress/pipelines/:id[/:tab]. WORDPRESS_TABS, command palette, page
titles, breadcrumbs, empty-state copy, and the WordPressProject tab base
all updated to "Pipelines".
- Legacy /wordpress/projects[...] URLs redirect to the pipelines space via
LegacyWpPipelineRedirect (deep links preserved).
- wordpressApi.getPipelines() added; getProjects() kept as back-compat alias.
Backend:
- environment_pipeline_bp mounted a second time under
/api/v1/wordpress/pipelines as a true alias of /wordpress/projects.
- Test: /pipelines resolves identically to /projects.
Verified: frontend build clean, 5 alias tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Feature-unification plan §1 (Services UI = Applications backend). Ends the
"Applications vs Services" terminology split by collapsing the two detail
pages into one canonical surface and aliasing the backend.
Frontend:
- Merge ApplicationDetail's exclusive tabs (Container Ops, WAF, Build, Deploy,
Previews) into the canonical ServiceDetail (PageTopbar chrome). Type-gating
preserved exactly: ops=Docker, waf=Docker+Python, build/deploy/previews=all.
- serviceTypes.js tab lists extended for the merged tabs.
- /apps/:id and /apps/:id/:tab now redirect to /services/:id[/:tab] via
LegacyAppRedirect (deep links preserved); ApplicationDetail.jsx deleted.
- Drop the /apps page-title entry and "Application Details" dynamic title.
- api.getServices()/getService() aliases over getApps()/getApp().
Backend:
- Mount apps_bp a second time under /api/v1/services as a true alias of
/api/v1/apps (Flask blueprint re-registration with name='services').
- Test: /api/v1/services resolves identically to /api/v1/apps.
Verified: frontend build clean, eslint 0 errors, 3 alias tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refactor UI to split the legacy "Secrets & Webhooks" page into two focused pages: Vaults (organization tab) and Webhooks (standalone page). Added new pages frontend/src/pages/Vaults.jsx and frontend/src/pages/Webhooks.jsx, removed frontend/src/pages/SecretsWebhooks.jsx, and updated routes in App.jsx to register /vaults and /webhooks while redirecting legacy /secrets routes to the new locations. Updated CommandPalette, organization tabs, and sidebar items to reflect Vaults/Webhooks and adjusted ADVANCED_ITEM_IDS. UI comments explain intent and migration. This keeps secret storage under Organization and makes webhooks a dedicated console.
The no-restricted-syntax `.card` selector used `\bcard\b`, which flagged any
className containing "card" as a word — including unrelated compound classes like
`settings-card`, `sk-spec-card`, `auth-card`, and `wp-site-card-skeleton`, none of
which are the legacy `.card` class the rule's message targets. Narrowed it to the
legacy card family as a LEADING token (`card`, `card-header`, `card-body`, …),
removing ~220 false-positive warnings while still flagging real `.card` usage
(225 remain). Frontend ESLint warnings: 1616 (plan baseline) → 1190; 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a shared git hook (.githooks/pre-commit) that lints staged frontend JS/JSX
and blocks the commit on ESLint ERRORS only (warnings allowed). It exits early
for commits with no frontend JS/JSX (backend, SCSS, docs), so it never disrupts
those. Enable once per clone: `git config core.hooksPath .githooks` (or
`sh frontend/setup-hooks.sh`).
Chose a committed .githooks script over husky/lint-staged: this is a monorepo
(git root has the Python backend; only frontend/ is a node package with no root
package.json), so a dependency-free hook avoids an npm-install-time git-config
mutation and keeps backend committers unaffected. Documented in AGENTS.md.
Tested: blocks a staged file with a parse error (exit 123), passes when clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Raw <button> elements default to type="submit" inside a form, causing accidental
submits. Added an explicit type="button" to the 234 raw buttons (across 60
files) that have an onClick and no type — i.e. action buttons, which are never
the intended default-submit. Default-submit buttons (no onClick) and the shadcn
ui/* wrappers were left untouched. Applied via a brace/quote-aware scan so
multi-line tags and style={{...}} attrs are handled correctly.
Verified: npm run build passes; dashboard/services/settings screenshots render
unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second ResourceListPage consumer (the page Services' chrome was copied from).
Replaced WordPress.jsx's duplicated .wp-list toolbar/bulk-bar + inline sk-dtable
table with ResourceListPage; the table is now DataTable columns. Added a `header`
slot to ResourceListPage for the one-time credentials banner; the tag-filter
chips go through `toolbarExtra`. Kept the custom loading skeleton and lite-tier
ResourceGate as early returns, and the Import/Create modals as children. All
behavior preserved (filter chips, search, tag filter, select-all + per-row
checkboxes, bulk ops, open/wp-admin links, row-click routing).
Verified: npm run build passes, 0 lint errors; WordPress list screenshot (mock
data) renders identically to before.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs/plans/FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md §6.4 (ResourceListPage) and
§6.2 (table → DataTable).
- Add components/layouts/ResourceListPage.jsx: the shared list-page chrome —
status-filter SegControl + search toolbar, bulk-actions bar, the DataTable, and
the loading / empty / filtered-empty states. Markup mirrors the existing
.wp-list design so SCSS applies unchanged.
- Services.jsx: replaced its hand-rolled .wp-list chrome + inline sk-dtable table
with ResourceListPage. The table is now DataTable columns; interactive cells
(checkbox, row actions) stop click propagation so they don't trigger the row
navigate. All behavior preserved (filter chips with counts, search, select-all
+ per-row checkboxes, bulk bar, source/status/env cells, row-click routing).
Verified: npm run build passes, 0 lint errors; Services screenshot (mock data)
renders identically to before (also fixed the section's stale wait-selector).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs/plans/FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md §7.3, following the Docker
split. Pure move-plus-imports refactors (no logic/JSX/state changes); the tabs
were already top-level + prop-driven, so the build validates every import.
- ServerDetail.jsx 2459 → 390 lines → components/serverdetail/ (serverDetailShared
+ ServerOverviewTab/AlertsTab/ServerDockerTab/CronTab/CloudflaredTab/
ServerMetricsTab/ServerSettingsTab; Server* prefixes avoid collisions with the
folder's existing PackagesTab/ServicesTab/SystemStatusCard).
- ApplicationDetail.jsx 1737 → 275 lines → new components/appdetail/ (appDetailShared
+ Overview/Packages/Gunicorn/Commands/Build/Logs/Settings/DeployTab).
- WordPressDetail.jsx 3992 → 464 lines → new components/wordpress/detail/
(wpDetailShared + 15 tab files; cross-tab modals re-exported, e.g. OverviewTab
imports CreateEnvironmentModal/SearchReplaceModal from their owning tabs).
Verified: combined npm run build passes, 0 lint errors; rendered all three detail
shells under Playwright (mock data) — header, full tab nav, and Overview content
render correctly (no pageerror/error-boundary). Combined with the build (which
resolves every tab import) this confirms the shell→tab wiring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs/plans/FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md §7.3. Pure refactor (move +
imports only, no logic change). Docker.jsx goes from 2521 → 334 lines (a thin
shell: status/server/stats/tab state + layout). Extracted to components/docker/:
- dockerHelpers.js — ServerContext/useServer + the pure container helpers/constants
- dockerShared.jsx — IconAction, TrashIcon, ContainerResourceBars, DownloadIcon
- ContainersTab.jsx — ContainersTab + ContainerInspector + Run button/modal + logs/exec modals
- ComposeTab.jsx, ImagesTab.jsx, NetworksTab.jsx, VolumesTab.jsx, PruneButton.jsx
The tabs were already decoupled (shared ServerContext + onStatsChange prop, no
closures over the shell), so the split is mechanical.
Verified: npm run build passes, 0 lint errors, and drove ALL FIVE tabs
(Containers/Compose/Images/Networks/Volumes) open under Playwright with mock data
— each renders correctly (tables, status/port pills, resource bars, row actions,
toolbar buttons) identical to pre-split.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Converts the remaining hand-rolled .modal-overlay modals in the big pages to the
shared <Modal>: Docker (6: compose logs, run, exec, pull, create network/volume),
WordPressDetail (5: clone, search-replace, delete, create env, snapshot),
Domains (2: add domain, enable SSL), Servers (1: manage groups),
ApplicationDetail (2: build config, deploy config), WordPressProject (1: create
env). Same pattern: forms+actions in the body, sizes mapped, close-animation
null-guards where the body derefs open-state.
Only 2 .modal-overlay remain, both intentionally left — they are custom
right-side slide-in DRAWERS (Servers AddServerModal/.server-setup-modal and the
Templates detail panel) that need the Drawer primitive, not a centered Modal.
Verified: npm run build passes (each agent built independently + final combined
build).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs/plans/FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md §6.1. Converted the
.modal-overlay/.modal boilerplate to the shared Radix-backed <Modal> (focus
trap, Escape, backdrop, built-in close) across 20 files: AttachDomainModal,
ChangeUrlModal, ConfigDiffModal (xl), SettingsTab, Templates, WordPress (lg),
Workspaces, WorkspaceDetail, StatusPages, ServerTemplates, CloudProvision,
CronJobs, SSLCertificates, FTPServer, Email, FileManager, Git (lg/xl), Backups,
QueueDetail, QueueOperations. Forms keep their submit button + .modal-actions in
the Modal body; bodies that deref open-state objects are guarded for the close
animation; sizes mapped from old .modal-lg/.modal-xl.
Also hoisted .status-modal/.status-modal-grid out of .status-pages-page in
_status-pages.scss — the shared Modal portals to <body>, so page-scoped rules
would not have reached it.
Left intentionally: the Templates detail panel (custom slide-in drawer) and
ProjectDetail (already uses the Radix Dialog primitive directly).
Verified: npm run build passes; databases-modal pilot confirmed rendering via
Playwright; eslint adds no new errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs/plans/FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md §6.1 (priority #1 cluster).
Converted the 5 hand-rolled .modal-overlay modals in databases/modals.jsx
(CredentialsResult + Create MySQL/PostgreSQL Database/User) to the shared
components/Modal.jsx (Radix-backed: focus trap, Escape, backdrop). The form +
.modal-actions stay in the Modal body so the type=submit button remains inside
<form>. Logic unchanged.
Verified: npm run build passes; drove the Create MySQL database modal open under
Playwright (mock data) — renders correctly via .sk-modal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs/plans/FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md §8.4. Replaced the hand-rolled
two-<button> role=tablist 'My Account / Admin' switch in Settings.jsx with the
shared components/ds/SegControl, so settings uses the same segmented-control
primitive as the rest of the app.
Verified: npm run build passes; settings screenshot confirms the segmented
control renders correctly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the Sidebar XSS fix (c3eb51f) — the Sidebar customization preview
in SidebarSettings.jsx injected item.icon via dangerouslySetInnerHTML without
sanitizing. Routed it through sanitizeSvgInner like the main Sidebar. (The
SqlEditor and file-preview highlighters were checked and already escape-first,
so A5/A6 need no change.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follows the ConfirmProvider checkpoint. Removed the now-inert per-page
<ConfirmDialog> instances that were driven by useConfirm's confirmState across
18 files (ApplicationDetail, Docker, ServerDetail, Terminal, WordPressDetail,
Backups, CronJobs, Databases, Queue*, SSLCertificates, ServiceDetail,
ContainerOpsPanel, ProtectionPanel, BackupsTab, PreviewList, PackagesTab,
MigrationHistoryTab); simplified each useConfirm() destructure to { confirm } and
dropped the unused ConfirmDialog imports. confirm() now renders only via the
app-level provider.
~20 other files keep their own local-useState-controlled ConfirmDialogs (not
useConfirm-driven) — intentionally left; migrating those is a separate behavior
change.
Verified: npm run build passes; no dangling confirmState refs; Docker page
renders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs/plans/FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md §6.6. Adds a single
app-level <ConfirmDialog> via ConfirmProvider (wraps the dashboard). useConfirm
is now context-aware: with the provider present, confirm() routes to the one
dialog and the legacy confirmState/handleConfirm/handleCancel become inert
no-ops (so the 39 pages' own <ConfirmDialog> render closed/harmless until
removed). Falls back to local state outside a provider so nothing breaks.
Backward-compatible checkpoint; the per-page dialog removal follows.
Verified: npm run build passes; 0 lint errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs/plans/FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md §8.5. React 18's automatic
JSX runtime makes 'import React' unnecessary; removed it from the 142 files
eslint flagged as 'React defined but never used' (named imports like useState
preserved). Driven by an eslint --format json pass so only flagged files were
touched.
Lint warnings: 1603 -> 1461 (0 errors). Verified: npm run build passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs/plans/FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md §8.2 (A1/A8, A2).
- Sidebar icons were injected via dangerouslySetInnerHTML. Core icons are
trusted constants, but plugin-contributed nav items flow through the same path
and could supply arbitrary SVG. Added utils/sanitizeSvg.js: an allowlist
sanitizer (DOMParser-based, caches results) that keeps only safe SVG shape
elements/attributes and drops scripts/event handlers/external refs. Wired both
Sidebar icon sites through it — zero visual change (verified via screenshot).
- AI Alt+A shortcut: added a visible title tooltip + aria-keyshortcuts on the
chat bubble (discoverable), and made the global handler skippable via
localStorage 'sk_ai_shortcut' = 'off'.
Verified: npm run build passes; 0 lint errors; dashboard screenshot confirms
sidebar icons still render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs/plans/FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md. Two different components
were both named WafPanel.jsx (app-level vs Cloudflare-zone WAF), a real source
of confusion. Renamed:
- components/apps/WafPanel.jsx -> AppWafPanel.jsx
- components/cloudflare/WafPanel.jsx -> CloudflareWafPanel.jsx
Updated the single importer of each (ApplicationDetail, CloudflareZoneSettings).
Verified: npm run build passes; no stray WafPanel references remain.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs/plans/FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md (acceptance: no
<div className="loading"> remains). Replaced all 14 ad-hoc loading divs across
13 files with the shared <EmptyState loading title="..." /> (centered spinner +
label), preserving each message verbatim. EmptyState was already imported in
Docker/Services; added the default import elsewhere.
Verified: npm run build passes; grep for className="loading" returns nothing;
eslint adds no new errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs/plans/FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md. STATIC_PAGES had drifted
from the real route table:
- Drop the 'DNS Zones' /dns entry (it redirects to /domains); fold its
dns/records/nameserver/zones keywords into the Domains entry.
- Add the missing pages: Projects, Shared Variables, Workspaces, GPU Monitor,
Queue, Jobs, Telemetry, Secrets (all verified present in App.jsx).
Verified: npm run build passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs/plans/FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md Phase 5.
- Add hooks/useLockBodyScroll.js: a ref-counted body-scroll lock that composes
nested locks correctly (a modal over an already-locked drawer no longer
restores scroll prematurely). Replaces the duplicated
document.body.style.overflow toggles in DashboardLayout (mobile nav drawer),
PreviewDrawer (file preview), and ChatDrawer (mobile AI sheet).
- lib/ai/markdown.js: escape quotes in link URLs. The renderer already
HTML-escapes all input and only emits allowlisted tags, but `"`/`'` in a
crafted URL could break out of the href attribute — now percent-encoded.
Verified: npm run build passes; eslint adds no new errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs/plans/FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md Phase 4.
- pages/Applications.jsx: orphaned. /apps already redirects to /services and
/apps/:id renders ApplicationDetail; nothing imported this 481-line page.
- pages/Firewall.jsx: orphaned. /firewall already redirects to
/security/firewall; the live UI is components/security/FirewallTab.jsx.
- components/status/ContainerStatusPill.jsx: only consumed by dead Applications.
Kept _applications.scss and _firewall.scss — verified both are shared by live
components (ApplicationDetail/ServiceDetail/ContainerOpsPanel and FirewallTab),
so not orphaned. App.jsx route redirects retained so bookmarks don't 404.
Verified: npm run build passes; no remaining references to deleted files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs/plans/FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md Phase 3 utility consolidation.
Replaces the scattered, drifted-apart local helpers with the canonical utils
added in Phase 1.
formatBytes (was 14 local copies):
- utils/formatBytes.js realigned to the codebase convention (1024 divisor +
KB/MB/GB labels; `iec` opt switches labels to KiB/MiB). It was unused until
now, so displayed values are preserved.
- Removed local formatBytes/formatSize/formatMemory from ApplicationDetail,
Backups, Databases, Monitoring, Terminal, ServerDetail (x2), BackupsTab,
QuarantineTab, SystemTab, DiskUsageBar, SnapshotTable. Decimals matched per
call site (e.g. BackupsTab keeps 2 decimals); empty/falsy fallbacks preserved
via defaultValue. fileTypes.js and logHelpers.js now re-export the canonical
(their named exports are consumed elsewhere).
formatRelativeTime (was 6 local copies):
- ActivityFeed, PipelineView (x2), Dashboard migrated to utils/time.js.
- serviceTypes.js and logHelpers.js re-export the canonical.
- EnvironmentCard left as-is (intentionally distinct "Today/Yesterday/X days
ago" vocabulary, not the "Xm ago" format).
Behavioral deltas are cosmetic only: trailing-zero trim ("1.0 GB"->"1 GB"), the
date-fallback threshold normalized to 30d, and sub-hour times now show precise
"Xm ago". Units and numeric values are unchanged.
Verified: npm run build passes; eslint adds no new errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>