Replace accidental old-palette literals (old indigo/green/amber/zinc surfaces+borders, white/black washes) with design tokens so they theme correctly in light mode and track a custom accent. Intentional literals (brand colors, categorical log/node/file-type palettes, theme-preview mockups, white-on-color, scrims) were preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 0 of the ServerKit UI redesign (see docs/REDESIGN_MAP.md):
- Remap design tokens to the new infra-console palette behind existing names; add prototype token names (surface ramp, cyan/violet, accent-bright/-bg). Dark/light/system.
- Self-host IBM Plex (woff2 vendored via scripts/fetch-fonts.mjs); no Google Fonts CDN.
- Runtime-derive the full accent ramp in ThemeContext.
- Add shared design-system primitives in components/ds (Pill, EnvTag, SegControl, MetricCard, Gauge, ServiceTile, Sparkline, AreaChart, ScoreGauge, Feed, Drawer).
- Restyle the sidebar (gradient rail, mono section headers, left accent bar) and switch the brand to the demo's gradient-tile logo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expose and surface containerised databases and introduce managed app base port control. Backend: add endpoint to list databases across all Docker apps, robustly resolve compose container names, include docker DB user when querying, compute migration needs from live heads and self-heal orphaned alembic_version entries by stamping to head, and add the managed_app_base_port system setting. Template service now honours the managed_app_base_port and WordPress template default HTTP port updated to 8300. Frontend: add react-icons brand icons and DatabaseBrands component, show Docker-hosted DBs under their engine nodes with a docker badge, platform-aware console shortcut label, pass DB user to docker table/query API calls, add API client header merge fix, UI for managing base port in settings, enhanced migration history UI (backup/apply/resync flows, confirm dialog, toasts), and various style tweaks and color additions.
Re-reading python.py showed every mutating endpoint is independently
@admin_required (runs before the shared get_app_or_404 gate), so that gate only
governs the 4 read GETs (packages/env/status/gunicorn-config). Pointing it at
can_access_app safely opens those to grantees while the admin-only mutations stay
untouched — no per-method split needed.
With this, every app-related blueprint is grant-aware. Also fixes the int-vs-str
identity comparison in the helper.
Verified: a python read-gate test (stranger 403, grantee passes), full suite
132 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A grant now reaches every app-related blueprint, not just apps.py + WordPress, so
a shared application is fully manageable by its grantees.
- Promoted the access helpers to a shared seam: ResourceGrantService.can_access_app
(owner/admin/any grant -> reads) and can_edit_app (owner/admin/editor grant ->
mutations); apps.py now delegates to them.
- Swept 33 ownership checks across 6 blueprints (categorized read vs mutate via a
parallel mapping workflow): builds.py (12), domains.py (7, incl. SSL + domain
CRUD), deploy.py (6), private_urls.py (5), templates.py (2), databases.py (1).
Reads -> any grant; mutates -> editor grant.
- Fixed the same latent int-vs-str ownership bug in all 33 (now user.id).
Deferred: python.py's single get_app_or_404 helper gates read+mutate+delete
together and needs per-method splitting before it's safe to make grant-aware.
Verified: cross-blueprint integration test (grantee reaches builds deployments;
stranger 403), full suite 131 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the chosen access-level ACL design: a grant now lets the user actually
operate the resource, gated by a real role.
- Grants carry a role: editor (view + operate) or viewer (read-only). grant POST
validates role in {viewer, editor} (default editor); ResourceGrantService gains
grant_role(); the Sharing UI gains a role selector.
- Two access helpers split the app endpoints: _can_access_app (owner/admin/ANY
grant) gates reads (get_app, /linked, /logs, /container-logs, /containers,
/status); _can_edit_app (owner/admin/EDITOR grant) gates mutations (/link,
/unlink, /environment, PUT, /start, /stop, /restart).
- DELETE stays owner-or-admin (an editor can't delete — tested).
- The same sweep fixed a latent int-vs-str ownership bug across ~14 app endpoints
(compared app.user_id to the stringified get_jwt_identity(); now user.id).
Verified: 3 more ACL tests (viewer read-only + can't operate, editor operates +
can't delete, role validation/default), full suite 130 passed, lint + build.
Still owner-only: env-vars/databases/domains blueprints (follow-up sweep).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the per-site ACL feature with UI on top of the backend (d9a5ec7).
- A per-app 'Share' action in the Workspaces Resources modal opens an inline
sub-view: current grantees with Revoke, plus a grant-access list of users
(the owner and existing grantees are filtered out).
- Client methods getAppGrants / grantAppAccess / revokeAppAccess.
Verified: frontend lint-clean (no new warnings) + production build. The grant
endpoints are covered by backend tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An owner/admin can share a specific application (transitively its WordPress
site / DB / domains) with a specific user who doesn't own it. Access-level for
now; per-grant view/edit roles are reserved via a role column.
- New ResourceGrant model (Alembic 016, unique per user x resource) +
ResourceGrantService (grant/revoke/list/user_has_grant/granted_ids).
- Enforcement at the cleanest seams: scope_query gained grant_resource_type so
granted apps appear in the apps list (own OR granted, then workspace-narrowed);
get_app and the WordPress _owner_or_admin_app helper became grant-aware, so one
change opens every WP per-site route to a grantee.
- Grant-management API (GET/POST/DELETE /apps/<id>/grants, owner-or-admin; can't
grant to the owner; revoke is resource-scoped so you can't revoke another app's
grant by id). Uses user.id (int), not the stringified get_jwt_identity().
Verified: 4 new ACL tests (visibility, get_app, WP route access, management perms
+ idempotency + revoke), full suite 127 passed, end-to-end alembic upgrade to 016.
Deferred: a Sharing UI (next), and broadening grant-access to the generic app
operate endpoints (start/stop/update stay owner-or-admin; delete stays owner-only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the reassignment feature symmetrically — the server reassign endpoint
shipped earlier (e89415f) was API-only; now it has UI.
- getServers gains an allWorkspaces bypass (empty X-Workspace-Id header) and a
setServerWorkspace client method (mirrors apps).
- The per-workspace modal is now a 'Resources' modal with both Applications and
Servers sections, each with move-in / move-out.
Verified: frontend lint-clean (no new warnings) + production build. The server
endpoint itself is already covered by backend tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the inconsistency where the workspace switcher filtered apps/servers but
not the WordPress hub.
- WP sites carry no workspace_id of their own, so get_sites(workspace_id=...)
scopes join-based through the site's parent Application.workspace_id; list_sites
resolves the active workspace and passes it.
- The hub is global today, so with no workspace context behavior is unchanged
(additive, narrow-only).
- No frontend change: the WP hub already fetches through the header-injecting
api.request, so it auto-scopes on the ambient X-Workspace-Id.
Verified: a new API test (global vs workspace-scoped), full suite 123 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the organizing loop — existing resources were stuck wherever the
backfill put them (Default); now they can be moved into client workspaces.
- PUT /apps/<id>/workspace (owner-or-admin on the app) and
PUT /servers/<id>/workspace (developer+), both requiring the TARGET be a
workspace the caller can access (member or admin). A null target moves the
resource back to Default.
- UI: an 'Apps' management modal on each Workspaces card (mirrors the Members
modal) with move-in / move-out. It fetches with allWorkspaces (an empty
X-Workspace-Id header) so it can see apps in other workspaces to move them in,
sidestepping the ambient scope.
- Fixed a real bug caught by tests: the app ownership check compared app.user_id
to the stringified get_jwt_identity() (int != str -> spurious 403); now uses
user.id.
Verified: 2 new endpoint tests (ownership + target-membership + null->Default +
role gates), full suite 122 passed, frontend lint-clean + production build.
Servers reassignment is API-only for now; the modal is apps-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Selecting a client workspace now recolors the whole panel to that client's brand
— the visible payoff of the workspace effort.
- A workspace's primary_color (already on the model) is now editable via a color
picker added to the Workspaces create modal.
- WorkspaceSwitcher mirrors the active workspace's color into a workspace_accent
localStorage key; ThemeContext applies it via the existing applyAccentToDOM with
precedence over the user's personal accent (workspace_accent || accentColor),
recoloring the logo gradient, buttons, and highlights.
- Deterministic (no effect-ordering races): switching reloads and the value is
read once on mount. Cleared on logout and on a stale selection; reverts to the
user accent on 'All workspaces'. Default color is the app accent, so a workspace
is unbranded unless a custom color is chosen.
Verified: frontend lint-clean (no new warnings) + production build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Makes the workspace scoping foundation user-visible — the research flagged the missing 'current workspace' context as the core gap #33 had to close.
- WorkspaceSwitcher (sidebar): self-contained selector that reads/writes an
active_workspace_id in localStorage, fetched from the user's workspaces. Hides
for single-workspace installs and drops a stale selection on load.
- services/api/client.js: sends the active workspace ambiently as X-Workspace-Id
on every request (endpoints that don't honor it ignore it); cleared on logout.
- Switching reloads so all lists re-fetch under the new scope.
Backend resolve_workspace_id is lenient (prior commit) so a stale header can
never break the apps/servers lists. Verified: frontend lint-clean + production
build; the header->scoping path is covered by the backend API tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A stale or forbidden X-Workspace-Id (deleted workspace, removed member) now degrades to no-scope instead of 403/404, so an ambient workspace-context header can never break the apps/servers lists. Safe by construction: falling back to no-scope only ever shows the user their own resources and stamps the default workspace on create — it never grants access to, or creates in, a foreign workspace. Also drops the error-tuple from the four call sites.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cross-cutting backbone the rest of the #33 agency-scale milestone needs,
landed as a contained, narrow-only foundation rather than a 36-route rewrite.
- Nullable workspace_id FK on applications + servers (Alembic 015 off 014) with
an idempotent raw-SQL backfill: create a Default workspace, attach every
workspace-less row, and make every existing user a member so nobody loses
visibility once scoping is activated. Also creates the workspace_id indexes
that upgraded installs would otherwise lack.
- Centralized scope helper in WorkspaceService: ensure_default_workspace,
resolve_workspace_id (validates a requested workspace; membership-checked with
admin bypass; rejects deactivated accounts on the workspace path), and
scope_query.
- Opt-in + narrow-only: a request filters by workspace only when it carries an
X-Workspace-Id header / ?workspace_id=. scope_query INTERSECTS the workspace
filter with the existing per-user ownership filter, so it can only narrow
visibility, never broaden it — with no context the behavior is byte-identical
to before (admin->all, owner->own, servers global). This closes the footgun
where the backfill would otherwise let a non-admin enumerate all resources via
the Default workspace.
- Wired into apps.py + servers.py (list + create): replaces the duplicated
ownership branch and stamps new resources with the active-or-default workspace.
Deferred to follow-on slices: broad route adoption, per-site ACL, the
workspace<->User role reconciliation, server-side branding, and a frontend
workspace selector.
Verified: 10 new pytest tests (scope-narrowing, a can't-broaden-access
regression, deactivated-user gate, raw-SQL backfill against real SQLite +
idempotency), full suite 120 passed, a real end-to-end alembic upgrade to 015 on
a fresh DB (columns + indexes), and a 2-lens adversarial review (3 findings, all
fixed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The self-contained reports slice of the #33 agency-scale milestone, built
without the high-blast-radius workspace_id/ACL/branding rework.
- WpReportsService aggregates one calendar month per site: uptime % + a daily
series + incidents (re-aggregated from #26 HealthCheck samples for the exact
month), update runs (#29), DatabaseSnapshot backups, and current vulnerability
posture (#28) + health/disk — each labelled persisted-history vs point-in-time.
- Persisted (WordPressReport, Alembic 014 off 013_ai_assistant) + synchronous
upsert per (site, YYYY-MM), so point-in-time sources (vulns, health) are
captured as-of generation while true-historical sources are re-queried per
month. Client grouping derives from the client-* tag (#20) — no workspace_id
FK. Model lives in wordpress_site.py + lazy-imported in the service so the
concurrent AI agent's models/__init__.py is untouched.
- Routes GET /reports, POST /reports/generate (admin), DELETE /reports/<id>;
client getReports/generateReport/deleteReport; a Reports tab with a recharts
daily-uptime chart, summary cards, Print->Save-as-PDF (scoped @media print,
no PDF dep) and JSON export.
- 9 pytest tests; 3-lens adversarial review (4 of 5 findings fixed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworks Git, Services, Firewall, FTP, Deployments, Monitoring, and
Marketplace pages onto the shared StatCard/card system and removes their
now-redundant page-specific SCSS (net large reduction in duplicated
styles).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Moves Security page and its Overview tab onto the shared StatCard/card
styles and trims the now-redundant per-page SCSS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Grows the shared card stylesheet and StatCard component so page-level
surfaces can drop their bespoke card SCSS and reuse one vocabulary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the five-tab Databases page with a DataGrip-style, full-bleed
Database Explorer built on the file-manager shell:
- unified source tree for all engines with inline + right-click actions
(open console, back up, drop, query, copy name)
- tabbed workspace: click a table to open a paginated data-grid tab,
open SQL consoles per database, and a Backups tab
- toolbar New menu (console / create database / create user, disabled
when the engine is not installed) and a live status bar
- frontend-only: table browsing runs SELECT ... LIMIT/OFFSET via the
existing query endpoints; no API changes
Rewrites _databases.scss into the explorer design system (tree, tabs,
results grid, console, context menu, status bar) using existing tokens;
respects prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AI is a core ServerKit primitive (not a plugin): an Intercom-style bubble + slide-in drawer on every dashboard page, with a tools+page-context assistant mode and a plain simple chat mode, SSE streaming, conversation persistence, and guarded write actions (the model proposes; the user approves).
Plugins EXTEND the assistant rather than rebuilding it: backend via app.plugins_sdk.ai (reg.tool + register_context_provider, discovered from a plugin.json ai block with install/enable/disable hooks); frontend via useServerkitAI() and a contributions.ai category (suggested_prompts, tool_renderers). The marketplace gains an ai category for discovery.
Backend: ai_service (Prompture Conversation + ProviderEnvironment + guardrails), ai_tool_registry singleton, ai_tools_builtin (core.* tools), the /api/v1/ai blueprint (SSE via a worker-thread + queue bridge, single-gevent-worker safe; in-thread confirmation gate), AiConversation/AiMessage/AiPendingAction models + migration 013, encrypted ai_* settings. Frontend: AIContext + components/ai/*, AI settings tab. Powered by Prompture (installed editable; requirements pinned).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document why the final three roadmap tasks are deferred rather than shipped
rushed/blind, each with the concrete Docker-correct path + the specific blocker:
- #32 per-site SFTP: Docker-correct design is a per-site SFTP sidecar (atmoz/sftp
mounting wordpress_html, injected like #23 redis), but it exposes a
security-sensitive, unverifiable-from-dev file-access surface + a port/container
per site. Deferred for a real-host pass.
- #33 agency scale: XL epic (workspace_id FKs + per-site ACL + RBAC rework +
workspace branding + monthly reports) — cross-cutting, high-blast-radius;
split into its own milestone. The report data sources (#26/#28/#29) now exist.
- #34 horizontal scaling: XL AND blocked on the same missing per-site
reverse-proxy/upstream layer as #21, plus a stateless-WP refactor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add WordPress lifecycle events to EVENT_CATALOG (wordpress.created / deleted /
backup_completed / updated / update_rolled_back / deployed / deploy_failed,
joining #27's site_down/up) + a reusable EventService.emit_wp(event_type, site,
**extra) helper (best-effort; never breaks the WP op).
Emitted at concrete service points: create_site (wordpress.created), the #29
safe-update (wordpress.updated / update_rolled_back), and git deploy
(wordpress.deployed / deploy_failed + the pre-deploy backup_completed).
The catalog drives the subscription UI and EventSubscription.matches_event
supports a wordpress.* wildcard, so these are immediately subscribable as
outbound webhooks (HMAC + retry) and fire event-triggered workflows via the
existing WorkflowEventBus — the trigger half of the Done-when.
Deferred: WordPress workflow ACTION nodes (needs the workflow node-type
registry + builder UI) and the serverkit wp CLI binary. Verified: py_compile.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Background safe update (Docker-correct, via wp_cli): record pre-update versions
-> wp db export a DB snapshot (600s timeout via a new wp_cli timeout param so a
large DB is never truncated; ABORTS if the snapshot fails) -> apply updates only
to components with an available update minus an exclusion list -> a quiet,
side-effect-free health probe (wp eval + HTTP probe; does NOT fire #27 alerts)
-> if the update regressed a previously-healthy site, AUTO-ROLLBACK: version-pin
each updated component back (--force --skip-plugins --skip-themes, so it runs
even on a fatally-broken site) + re-import the snapshot -> re-check -> persist a
WordPressUpdateRun report. The snapshot is kept for manual restore whenever the
site does not end verified-healthy.
A per-site cron schedule + exclusion list is driven by a new daemon-thread
scheduler bounded by a concurrency semaphore (a shared 3am cron can't stampede
the host). New model WordPressUpdateRun + auto_update_schedule/auto_update_exclude
columns (Alembic 012). New Updates tab (run-now, schedule, exclusions, history).
Also fixes a latent #26/#27 bug: EnvironmentHealthService.check_health returned
'unknown' for every production site (_get_compose_path needs a container_prefix
they lack), making uptime/incidents/alerts inert for the primary site type — now
falls back to the Application root_path compose.
Deferred: staging-first promotion (needs a staging env + env-pipeline
orchestration). Verified: backend py_compile, frontend lint-clean (full build was
blocked only by a concurrent agent's in-progress AI import, not #29 code),
2-lens adversarial review (blockers fixed: production health gating, DB-import
timeout; majors fixed: scheduler stampede, snapshot-abort).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A per-site Security tab (Docker-correct, all via the WP-CLI bridge):
- File integrity: a background wp core verify-checksums + wp plugin
verify-checksums --all (in-container, reaches the volume; off the request
thread, UI polls) flagging tampered/unexpected files.
- Debug toggle: WP_DEBUG/SCRIPT_DEBUG with WP_DEBUG_DISPLAY=false and
WP_DEBUG_LOG=/tmp/wp-debug.log — OUTSIDE the web root, so the log is never
publicly fetchable (the default wp-content/debug.log is apache-served, a real
info leak this avoids). The write is gated so a read-only/stopped site reports
an honest failure.
- WP-Cron: DISABLE_WP_CRON status + due-event list, run-due-now, enable/disable
(with a clear "needs a real system cron" warning).
Routes mirror the per-site pattern (owner-or-admin GET, @admin_required
mutations). Deferred: the brute-force wp-login/xmlrpc rate-limit jail —
Fail2ban/nginx limit_req both need a host-side per-site access log the
localhost:PORT apache-container model does not expose (same gap as #21/#25).
Verified: backend py_compile, frontend lint + build, focused security review
(debug-log web-exposure caught + fixed). Not runtime-exercised on a real host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cross-reference a site's installed plugin/theme/core slug+version against the
keyless community WPVulnerability API and surface findings per site.
WpVulnerabilityService reads installed versions via the Docker-aware WP-CLI
bridge (wp core version + get_plugins/get_themes directly — not the
host-filesystem-gated get_wordpress_info, which fails for volume-backed Docker
sites), queries https://www.wpvulnerability.net/{plugin,theme,core}/ (no key,
1h in-memory cache, descriptive UA, 8s timeout), matches the installed version
against each advisory operator bounds (core advisories all apply — the feed is
version-scoped), and extracts CVE id / severity (cvss3 -> cvss short-code ->
score) / fixed-in / reference.
Persisted as a new WordPressVulnerability child model (FK -> wordpress_sites.id)
+ a last_vuln_scan_at column (Alembic 011, idempotent). The scan runs in a
background thread (Lynis-style) so the single worker never blocks; a new
Vulnerabilities tab polls for status and renders findings with severity badges,
CVE links, and a summary. On-demand only.
Security (3-lens review): reference_url restricted to http(s) (blocks
javascript: hrefs); feed slug percent-encoded; unrecognized version operator
skips rather than over-flags; the major Docker volume-backed core-read gating
was fixed so normal sites scan. Verified: backend py_compile, frontend lint +
build. Not runtime-exercised on a real host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PromoteModal already had files-only (code), db-only (database), and
specific-folders (include_plugins/themes/mu_plugins/uploads). The only missing
selective-push control was specific-tables, now added to the database section
as two comma-separated inputs:
- Exclude tables — omitted from the promotion entirely
- Truncate tables — structure promoted, rows dropped
Both split into arrays on submit. The backend already honored them end-to-end
(POST .../promote forwards config.exclude_tables/truncate_tables ->
promote_database/promote_full -> clone_options -> _transform_dump), so this is
pure UI wiring, no backend change. Verified: frontend lint + build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworks the Settings > Activity tab away from stat-card tiles and icon'd
log entries toward a tighter, terminal-adjacent readout:
- Activity summary becomes a single inline stats line (active today /
actions this week / total users) instead of three stat cards.
- Audit log entries render as compact log rows (role="list") with inline
key=value details, dropping the per-row action icon and detail cards.
- _users.scss sheds the old card/icon styling for the leaner row layout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ServerKitLogo SVG hardcoded id="skBrandGradient" for its fill gradient.
Two instances render at once (sidebar + mobile top bar), so the id collides
in the DOM. url(#skBrandGradient) resolves to the first match, which is the
mobile top bar's logo — hidden at 0x0 on desktop. A gradient with the
default objectBoundingBox units and a zero-size owner has no coordinate
space, so the fill painted nothing and the sidebar logo showed only its
white background square.
Generate a unique gradient id per instance with useId() so each logo binds
to its own gradient. The glyph now paints the --accent-primary master color
again across the sidebar, mobile bar, and auth screens.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A per-site Analytics tab showing traffic and error analytics, computed
on-demand from the site's Apache access log — Docker-correct.
Architectural pivot: managed WP sites are wordpress:*-apache containers on
localhost:PORT with NO per-site nginx, so the roadmap's "nginx access log"
premise does not hold. The official image symlinks Apache's access log to the
container's stdout, so `docker logs` is the source.
New WpAnalyticsService.get_traffic(container_name, hours) pulls
`docker logs --tail 20000 --since <h>` (hard 15s timeout; reads only stdout to
separate the access log from the stderr error_log; returncode-checked so a
stopped/missing/remote-agent container degrades to an accurate note) and parses
the Apache combined log on-demand — no store, collector, or migration. Returns
requests, unique visitors, bandwidth, status distribution (2xx/3xx/4xx/5xx),
404s, bot %, error rate, top URLs (grouped by route; query strings stripped so
visitor tokens are never surfaced), and a continuous hourly requests/errors
series.
Route GET /sites/<id>/analytics?hours= (owner/admin-guarded), client
getSiteAnalytics, and a new Analytics tab on the site detail (stat cards + a
recharts requests/errors AreaChart + status codes + Top URLs) with graceful
empty/timeout/unavailable states. Also supplies the 5xx/error-rate signal
#27's "error spike" rule needs.
Deferred (not in the default access log): PHP fatals (need wp-content/debug.log
via a WP_DEBUG toggle, #30), response time / slow pages (need a %D LogFormat),
cache hit ratio (#22/#23). History is point-in-time over the retained container
log; a persistent time-series collector is a clean follow-up.
Review fixes (3-lens): docker-logs timeout + memory cap (single-worker safety);
accurate unavailable note via returncode; query-string token stripping.
Verified: backend py_compile, frontend lint + build. Not runtime-exercised on a
real Docker host (Windows dev).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/docker is a FULL_PAGE_ROUTE, so .main-content drops its padding and the
page manages its own. The workspace shell and the "Docker Not Available"
state both relied on that missing padding, leaving their headers flush
against the viewport top.
- .docker-page (unavailable state): add the standard route padding
($space-10, $space-5 on mobile) so the header gets the same breathing
room as every other page.
- .docker-page-new.dx-page (workspace shell): bump the top from $space-3
to a $space-6 / $space-5 frame.
Verified in-browser on the unavailable state; build passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make a managed WordPress site appear on a status page with a real uptime %
and auto-incidents, driven by an autonomous server-side health poller.
Scheduler: a single-worker-guarded daemon thread (_start_health_check_
scheduler, 300s) runs EnvironmentHealthService.check_health for every RUNNING
production site (skips archived/stopped so an intentional stop is not an
outage). This keeps health_status fresh autonomously — so #27's transition
alerts fire without the browser health card open — and drives bound
status-page components. HealthCheck samples are pruned to 90 days (once/day)
to bound growth.
Schema (Alembic 010 + the boot-time _fix_missing_columns auto-add):
- status_components.wordpress_site_id (-> wordpress_sites)
- status_incidents.component_id (-> status_components)
The existing HealthCheck table is reused as the uptime sample store.
StatusPageService.sync_component_from_health maps the health verdict
(healthy->operational, degraded->degraded, unhealthy->major_outage; unknown
skipped), records a HealthCheck, recomputes a real COUNT-based uptime %
(24h/7d/30d/90d; only fully-healthy checks count), and auto-opens an incident
on ENTERING a major outage / auto-resolves on LEAVING it (operational OR
degraded, so a degraded intermediate never strands the incident).
Routes GET/POST/DELETE /sites/<id>/status-page read the binding + attach
(production-only) / detach (resolves & unlinks the incident first -> no
dangling FK on PostgreSQL, no stale public incident). New Uptime tab on the
site detail (health + uptime % + attach/detach).
Security: internal probe config (check_target, intervals) is stripped from the
unauthenticated public-page projection, and a health-driven component stores
no internal localhost:port target.
Review fixes (3-lens adversarial pass): incident-resolve edge for the
degraded-recovery path; detach FK/orphan cleanup in delete_component; public
check_target leak; production-only attach; uptime-prune; degraded-uptime
semantics documented. Verified: backend py_compile, frontend lint + build.
Not runtime-exercised on a real Docker host (Windows dev).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second pass, applying the same treatment as the infra pages to the rest of
the app. Migrate hand-rolled empty states across Applications,
ApplicationDetail, Backups, CronJobs, DNSZones, Email, Firewall, FTPServer,
SSLCertificates, Security, FileManager, Marketplace, Templates,
ServerTemplates, Workspaces, StatusPages, Monitoring, CloudProvision,
ServerDetail, ServiceDetail, WordPressProject, and WordPressProjects onto
the shared <EmptyState> component (icon + title + description + optional
action, plus its loading variant for "Loading..." placeholders). Inline
SVGs and ad-hoc divs are replaced with consistent lucide icons.
Strip hard-ban design tells found in the audit:
- Decorative gradients on icon/avatar backgrounds and hero/card surfaces
(Marketplace hero + rainbow stripe, extension/featured/template cards,
monitoring hero, status-page background, WordPress project icon) ->
solid tokens.
- Side-stripe borders (>1px colored left-borders, a named ban) on Firewall/
FTP status cards, Security event/findings items, status-page incident
rows + public incidents, template variable items, WordPress env/pipeline
cards -> full borders tinted with the same status color.
- Decorative backdrop-filter blur on an extension badge; kept it on real
modal/drawer/drag backdrop overlays where it is purposeful.
Rich "feature not installed / unavailable" full-page states (with help
lists, resource requirements, troubleshooting) left as a deliberate tier.
Frontend build verified. WordPressDetail.jsx intentionally excluded here
(entangled with an in-progress feature on the working tree).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Standardize empty states across Servers, Domains, Databases, Docker, and
Git onto the shared <EmptyState> component, matching WordPress/Services.
Replaces five divergent idioms (docker-empty, raw empty-state divs, inline
SVGs, servers-empty-workspace) so every "nothing here" layout reads the
same: icon + title + description + optional action.
Remove hard-ban design tells flagged in the audit:
- Decorative gradients on icons (WordPress site/detail icons, Databases
hero + engine status icons) -> solid brand colors.
- Glassmorphism backdrop-filter blur on the Servers health panel and
command bar (no-op over opaque surfaces).
- Side-stripe borders (>1px colored left-borders, a named ban) on Servers
alerts/notifications, Docker container cards, and Git status/deployment
cards + SSH note -> full borders tinted with the same status color.
No behavior changes; frontend build verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route WordPress health transitions to the configured notification channels
and the outbound webhook bus, edge-triggered so an autonomous poller never
spams.
EnvironmentHealthService.check_health now captures the prior health_status
and, ONLY on a state transition (up<->down edge), fires _notify_health_
transition -> _dispatch_health_alert which:
- preserves the existing WorkflowEventBus 'health_check_failed' emit
(still fires every failing poll; back-compat for event-trigger workflows);
- NEW: NotificationService.send_all to every enabled channel
(Discord/Slack/Telegram/email/generic). unhealthy->critical,
degraded->warning (delivered by default), recovery->info (delivered only
to channels that opt into info). Sent off-thread because send_all does
blocking HTTP/SMTP and must not stall the health check;
- NEW: emits wordpress.site_down / wordpress.site_up via EventService, added
to EVENT_CATALOG, so user webhook subscriptions (incl. wordpress.*) deliver
with HMAC + retry and the events auto-appear in the subscription UI.
Edge-triggering is the dedup (no in-memory cooldown, which would be
single-worker-fragile): a continuously-down site alerts once, recovery once.
No schema/migration and no frontend — channel delivery is governed by the
existing Notifications settings. Also seeds #35's WP lifecycle-event registry.
Deferred: the "error spike" rule needs per-site error metrics that #25 will
provide. Verified: backend py_compile.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Dashboard connection state is no longer color-alone: the green/amber dot
now pairs with a text label (LIVE / RECONNECTING) in the server readout
and is announced via role="status". Label uses a readable neutral
(contrast-safe in both themes); the dot carries the status color.
Removes the orphaned .status-dot-live rules.
- StatusBadge: destructive status dot was bg-white (off-pattern); now
bg-red-400 to match the semantic hue of the other status dots.
- _variables.scss: document that the $text-*-raw values feed compile-time
fade() washes (neutral zinc ramp) and are intentionally distinct from the
lightened runtime --text-* tokens, so the split reads as deliberate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make WordPressService.create_site a single one-click orchestrator. It now
accepts php_version / enable_page_cache / enable_object_cache and:
- Bakes the chosen PHP version into the INITIAL image tag (concrete
6.4-php<x.y>-apache) so the site boots on the right PHP with no
post-create container recreate, then finalizes + _harden_docker_site,
then best-effort enables page cache (#22) and Redis object cache (#23)
AFTER `wp core install` (redis already ships in the stack). Cache
enablement never fails the create — failures degrade to a non-fatal
warning; the one-time admin password is still surfaced once.
- The live create modal (inline in WordPress.jsx) gains a PHP version
selector (Default / 8.1 / 8.2 / 8.3) and page-cache / object-cache
toggles, threaded through createForm -> POST /sites as phpVersion /
enablePageCache / enableObjectCache (PHP validated server-side).
Mechanism: declare a hidden VERSION template var and switch the image
line to bare ${VERSION} in wordpress.yaml + wordpress-external-db.yaml so
a concrete tag is written at install. This also fixes a latent #24 bug
where the unresolved ${VERSION:-...} literal made set_php_version drop the
WP-core pin; set_php_version now also falls back to the known core for
legacy compose files. No DB migration — PHP/object-cache/SSL read live;
the page-cache flag rides the existing sync_config column.
Deferred: SSL/TLS + custom-domain at create. Managed sites are
http://localhost:PORT containers with no per-site reverse proxy and no
domain attached at create — the same missing public-domain/routing infra
that blocks #21 and that #8 left frontend-only. TLS remains operable
post-create via SiteSSLPanel (#8) once a domain is routed.
Verified: backend py_compile, frontend lint-clean + production build, a
3-lens adversarial review (no blockers), and Docker Hub confirms all
6.4-php8.{1,2,3}-apache tags exist. Not runtime-exercised on a real
Docker host (Windows dev).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two refinement passes on the dashboard shell, committed together
because their edits interleave in Sidebar.jsx and _sidebar.scss.
Accessibility hardening:
- App-wide focus-visible baseline in _reset.scss, replacing the blanket
`* { outline: none }` that suppressed all focus rings. Pointer focus
stays ringless; keyboard focus gets a consistent accent ring.
- User-menu trigger is now a real <button> (aria-haspopup/expanded,
aria-controls, Escape-to-close with focus return).
- Nav expand buttons get aria-expanded + labels; theme/view buttons get
aria-pressed + accessible names; menu items get type="button".
- Dashboard app rows expose a real <Link> for keyboard/screen-reader
navigation (row stays mouse-clickable).
- Decorative icons marked aria-hidden; per-component focus rings on nav
items, menu items, and dashboard actions.
Quieter brand chrome:
- Removed gradient-text from the wordmark and white-label text; now
solid $text-primary (drops the background-clip:text ban).
- Replaced the auto-firing, bounce-eased star celebration (particle
burst, rings, shine sweep, bg flash) with a calm hover affordance;
deleted the animation effect/state and 8 dead keyframes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase C #24. The roadmap referenced the host PHP-FPM API, but managed sites are
wordpress:*-apache containers (PHP baked into the image tag), so this is done the
Docker-correct way via the wp_cli bridge.
New "PHP" tab on the site detail page showing the LIVE PHP version and key ini
limits (memory_limit, upload_max_filesize, post_max_size, max_execution_time,
max_input_time) read from inside the running container via `wp eval` (read-only,
zero risk). A "Change PHP Version" panel switches PHP by rewriting the compose
image tag to wordpress:<core>-php<x.y>-apache and recreating the container
(DB/files preserved, brief downtime) behind a confirm; supported set 8.1/8.2/8.3.
New routes GET/POST /sites/<id>/php; getPhpInfo/setPhpVersion client methods. The
panel reuses existing app-panel/app-info-grid markup — no new SCSS (no conflict
with the concurrent style session). Application.php_version is left untouched (not
meaningful for Docker sites); the live container value is the source of truth.
Writing arbitrary PHP limits is explicitly deferred (needs a conf.d ini + a
compose bind-mount for durability across recreates).
Verified: backend boots, both /php routes register, service methods load;
frontend builds; changed JS lints with 0 errors. No DB migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase C batch 1 — roadmap #22 and #23. The roadmap assumed nginx fastcgi/proxy
cache + host php-fpm, but managed sites are self-contained wordpress:*-apache
containers on localhost:port with no nginx in front — so both are implemented
the Docker-correct way, entirely in-container via the wp_cli bridge.
#22 Full-page cache via the cache-enabler plugin (Apache disk cache), installed
+ activated through wp_cli with WP-aware skip rules (wp-admin/login, logged-in
+ cart/checkout/account cookies, query strings). New routes GET/POST/DELETE
/sites/<id>/page-cache; the enable flag is stored in WordPressSite.sync_config
(no migration); the existing Purge Cache button now also purges the page cache.
#23 Per-site Redis object cache. enable_object_cache ensures a redis service in
the site's compose stack (NEW sites ship redis via the template; EXISTING sites
get a redis service injected into docker-compose.yml + an additive `compose up
-d` with no downtime), then installs the redis-cache plugin (bundled Predis, no
phpredis needed) and points WP at host 'redis'. Status derived live from
`wp redis status`. New routes GET/POST/DELETE /sites/<id>/object-cache; Purge
Cache now also runs `wp redis flush`.
UI: "Enable Page Cache" and "Enable Object Cache" toggles in the site Quick
Actions, reusing the existing .quick-action-btn class (no new/shared SCSS).
Verified: backend boots, all 6 routes register, all 8 service methods load,
template YAML valid; frontend builds; changed JS lints with 0 errors. No DB
migration. (Container-level behavior not runtime-tested — no Docker here.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The app had no reduced-motion handling despite ~60 keyframe animations
and pervasive transitions (auto-firing star burst, etc.). Honor the OS
"reduce motion" setting globally while keeping loading state legible.
- Global @media (prefers-reduced-motion: reduce) block collapses
transitions and decorative animations to ~instant
- Loading spinners (.spinner-ring, [class*="spinner"]) stay turning at a
steady 0.8s infinite rotation, since the spin IS the status cue
- Gate the sidebar star's auto-firing celebration behind a reactive
useMediaQuery('(prefers-reduced-motion: reduce)') check
- Add $ease-out-quart/quint/expo easing tokens (no bounce/elastic) and
point the mobile drawer transition at $ease-out-quint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Strategic design context for the impeccable workflow: product register,
users/purpose, "precise, trustworthy, controlled" personality,
anti-references, design principles, and WCAG 2.1 AA targets.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Below 768px the persistent sidebar was set to display:none with no
replacement, leaving every route unreachable on phones/tablets. It now
collapses into an off-canvas drawer driven by a fixed mobile top bar.
- MobileTopBar: fixed header with hamburger toggle + brand (white-label
aware, solid wordmark), shown only < 768px
- Sidebar becomes a translateX drawer at min(280px, 86vw); focus trap,
Escape-to-close, inert when closed (React 18 safe), 44px close button,
aria-label + id wired to the toggle's aria-controls
- DashboardLayout owns open state via useMediaQuery, closes on route
change, locks body scroll, renders the scrim
- Semantic z-index ladder (scrim/drawer/header), 100dvh + safe-area
insets, viewport-fit=cover, prefers-reduced-motion fallback
- Main content offset below the fixed header for normal and full-page
routes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase B batch 2 — roadmap #17 and #18 (#21 deferred: blocked without a public base domain).
#17 Existing-site import (.sql-first MVP). New "Import Site" action on the
WordPress hub opens a modal (name + original URL + .sql/.sql.gz dump). The
backend POST /sites/import stands up a fresh WordPress+MySQL Docker stack via
create_site, streams the dump into the container DB via
DatabaseSyncService.import_to_container (root user, gzip-aware), rewrites the URL
(wp option update home/siteurl + wp search-replace --all-tables), flushes caches,
re-detects multisite, and clears the stale generated admin. wp-content-zip and
SFTP pull are explicitly out of scope for the MVP.
#18 Clone a site to a NEW independent top-level site with FRESH credentials. New
WordPressService.clone_site stands up a brand-new stack via create_site,
container-to-container clones the source DB with a URL search-replace, best-effort
copies wp-content via docker cp, then provisions a fresh administrator (new
generated password) so the clone does not share the source's creds (the DB import
overwrites users). POST /sites/<id>/clone + a "Clone" button in the site topbar +
a one-time credentials banner (with an "Open new site" link). No production_site_id
=> truly independent.
Both reuse the existing create/clone/wp-cli primitives; DB creds read from each
stack's .env (root user, literal db name 'wordpress' since the columns are NULL
for Docker sites). FormData upload uses the api client's existing multipart path.
Verified: backend boots, both routes register, all service methods load; frontend
builds; changed JS lints with 0 errors. No DB migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase B batch 1 — roadmap #16, #19, #20.
#16 Passwordless "Auto Login" into wp-admin (the signature managed-WP feature),
no heavy plugin. New WordPressService.create_login_url uses the Docker-aware
wp_cli bridge to install the wp-cli-login package (idempotent) and mint a
one-time magic URL (wp login create --url-only); login slug resolved via
wp eval rather than hardcoding /wp-admin. New POST /sites/<id>/login
(admin-gated) resolves or provisions a managed admin tied to the operator's
panel email (create_user role=administrator), persists admin_user, writes a
'wordpress.admin_login' AuditLog, and returns the URL. A prominent primary
"Auto Login" button in the site topbar opens it; Dashboard remains as fallback.
#19 Multisite is now detected for real via `wp core is-multisite` (new
is_multisite helper): surfaced in get_wordpress_info, set at create_site, and
refreshed+persisted on the single-site detail load (never on the hot list
endpoint).
#20 Site tags/labels for agency organization. New JSON-in-TEXT tags column on
WordPressSite (auto-added on boot by _fix_missing_columns; plus an idempotent
009 Alembic revision for determinism), exposed via to_dict, set via
PATCH /sites/<id>/tags (normalized/deduped). Frontend: tag chips on each site
card and a client-side tag filter bar on the WordPress list.
Verified: backend boots (009 migration applies, tags column live, routes +
methods present); frontend builds; changed JS lints with 0 errors. No frontend
or backend regressions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>