Introduce a full-screen AppLoader component and corresponding styles, and replace simple loading placeholders across App.jsx with the new loader. Revamp WordPressDetail: add comprehensive skeleton UI (topbar, repo pill, tab strip, KPI/panel/list/activity skeletons), generic PanelSkeleton and OverviewGridSkeleton utilities, and swap multiple tab-loading messages to use the new skeletons. Update layout and wordpress SCSS files to style the app-level loader and the WP detail skeletons. Improves perceived load UX and unifies loading states across the app.
The .ssl-status-* classes were borrowed "from _domains.scss", but that
borrow broke when Domains migrated to MetricCard - leaving the Certbot /
Certificates / Expiring Soon cards as an unstyled vertical stack at the
top-left. Define them locally in _ssl.scss as a 3-up grid of stat cards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/dns and /dynamic-dns duplicated the Domains portfolio + drawer and led
with a misleading "0 ServerKit records" view for Cloudflare zones.
Collapse DNS to one surface: both pages now redirect to /domains and
DomainDnsPanel becomes the full records manager.
- Per-record "Make dynamic" on A/AAAA rows (adopt-on-demand ->
createDdnsHost; one-time token via shared DdnsTokenCallout; Dynamic
badge + regenerate/stop)
- Export + Check propagation moved into the drawer; dead "Full DNS page"
button removed
- DOMAIN_TABS trimmed to Domains + SSL; /domains?open=<domain> deep-link
- Relocate shared .dns-rtype + .ddns-token-callout into _domains.scss
- Delete DNSZones.jsx, DynamicDns.jsx, _dns-zones.scss, _dynamic-dns.scss
Frontend-only; DDNS/portfolio backend untouched (20 tests green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- /domains is one table: app-linked domains + live zones from connected DNS
providers (Cloudflare, ...) with provider badges. New GET /dns/portfolio
(aggregates provider zones, marks which are adopted) and idempotent
POST /dns/adopt (lazy-materialize a managed zone on demand).
- Every row opens one drawer = the unified DNS records surface
(components/domains/DomainDnsPanel): live Cloudflare records via
GET /dns/provider-records (no adopt needed), type badges, proxied=>HTTPS hint,
inline Add record, deep-links to Cloudflare ops / full DNS page. Fixed-layout
table (no horizontal scroll); actions moved into the drawer header.
- Registration expiry/registrar from Cloudflare Registrar + lazy RDAP fallback
(GET /dns/registration), persisted/cached in the new domain_registrations
table and merged into the portfolio so it survives a refresh. New
frontend/src/utils/expiry.js formatExpiry() date util.
- Connections moved to admin-only (Settings Admin group + isAdmin gate;
@admin_required on /api/v1/connections); CF connection card links to Domains.
- Cloudflare client: expose each zone's account_id; add list_registrar_domains.
- Tests: backend/tests/test_dns_portfolio.py.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_canonical_site_url feeds clone/URL-swap search-replace, so it must
return the URL actually baked into the WordPress DB. A domain-less site
is installed at localhost:<port>, not the panel apex; the panel_origin
branch (added in 652d903a) made it resolve to the base domain, so
clones/swaps would search-replace nothing and silently leave stale URLs.
Drop that branch, keep the ssl_enabled-aware scheme. Fixes the
test_canonical_site_url_prefers_primary_domain regression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Storage tab listing and managing Cloudflare's account-scoped storage
primitives — R2 buckets, KV namespaces, and D1 databases. Management only
(list/create/delete); the object/key/row data planes are out of scope.
- CloudflareClient: r2/buckets, storage/kv/namespaces, d1/database list+create
+delete.
- CloudflareService: combined list_storage (each product fetched independently so
a missing token scope degrades to a per-product error, not a dead tab),
validated create/delete for each. R2 bucket names validated S3-style.
- /api/v1/cloudflare/zones/:id/storage* routes (read any user; writes admin).
- StoragePanel (three generic create/list/delete sections; R2 notes its
S3-compatibility for future backup targeting); api methods; SCSS.
No new tables — wiring an R2 bucket as an S3 backup backend (minting scoped R2
keys) is a deliberate follow-up, noted in the UI.
Tests: aggregate listing + per-product error degradation, R2 name validation,
create/delete, KV/D1 required-field guards (6).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Tunnels tab for creating and managing Cloudflare Tunnels (cloudflared) —
exposing a local/private service through Cloudflare's edge with no public IP.
Clearly distinct from ServerKit's WireGuard remote-access tunnels (always
labelled "Cloudflare Tunnel" in the UI). Account-scoped; account resolved from
the zone.
- CloudflareTunnel model: records the connector token (encrypted at rest) and
owning connection per (account, tunnel_id).
- CloudflareClient: cfd_tunnel create (config_src=cloudflare) / list
(is_deleted=false) / delete / token / connections / configurations get+put.
- CloudflareService: create (returns + stores the one-time token, yields the
cloudflared install command), list (managed-flagged), delete, fetch install,
and public-hostname routing — sets the ingress config (always re-appends the
required http_status:404 catch-all) and best-effort upserts the proxied CNAME
hostname→<id>.cfargotunnel.com via the shared ownership-guarded DNS path.
- /api/v1/cloudflare/zones/:id/tunnels* routes (read any user; writes admin).
- TunnelsPanel (create form + one-time install box w/ copy, tunnel list with
per-tunnel install reveal and public-hostname add/remove); api methods; SCSS.
Tests: list is_deleted param, create config_src, name required, token stored
encrypted + install string, managed-flagging, delete prunes local row, ingress
built with catch-all last (7).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Workers tab to the Cloudflare Zone Settings page for deploying edge
scripts and managing Worker routes. Workers are account-scoped, so the owning
account is resolved from the zone — the whole feature reuses the existing
Cloudflare DNS connection (no new credential).
- CloudflareWorker model: records the source ServerKit uploaded per (account,
name) so a worker can be tracked and re-deployed (created via create_all).
- CloudflareClient: zone→account lookup, list/delete scripts, and a stable
multipart PUT /scripts/{name} module upload (the still-beta resource-oriented
Workers API is deliberately avoided per the roadmap's fallback note), plus
zone Worker routes list/add/delete.
- CloudflareService: list (live scripts flagged when ServerKit-managed + routes),
deploy (validated lowercase name, records source, optional route attach),
delete, route add/delete.
- /api/v1/cloudflare/zones/:id/workers* routes (read any user; writes admin).
- WorkersPanel component (deploy form with a starter script, worker list, routes
list); api methods; SCSS.
Tests: multipart upload shape, name validation, deploy records source, list
managed-flagging, delete prunes the local row (5).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a WAF tab to the Cloudflare Zone Settings page for managing custom firewall
rules (the http_request_firewall_custom phase entry-point ruleset).
- CloudflareClient: ruleset list/get + phase-entrypoint create (PUT) + per-rule
POST/PATCH/DELETE.
- CloudflareService: list rules (empty when no custom ruleset yet, distinguished
from an auth error by listing rulesets first), add rule (creates the ruleset on
first use), update/delete, and three one-click presets — lock WordPress admin
to an IP, block common exploit paths, challenge suspicious bots. The IP in the
lock preset is validated with ipaddress so it can't inject expression syntax;
rule actions are constrained to a safe terminal subset.
- /api/v1/cloudflare/zones/:id/waf/* routes (list any user; add/preset/update/
delete admin).
- WafPanel component (preset picker + custom-rule form + active-rules list with
enable toggle and delete); api methods; SCSS.
Tests: ruleset discovery, empty vs auth-error, create-on-first-rule vs append,
action validation, preset IP validation + safe expression, update/delete (10).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Cloudflare cache management to the Zone Settings page. Purge everything (with
a confirm) or purge specific URLs (one per line, capped at 30 to match Free/Pro
limits); hosts/prefixes/tags are accepted by the API for Enterprise plans and
their plan errors are surfaced verbatim.
- CloudflareClient.purge_cache(zone_id, payload).
- CloudflareService.purge_cache: validates intent (raises on empty), caps files
at 30, normalizes the payload.
- POST /api/v1/cloudflare/zones/:id/purge-cache (admin).
- Actions tab gains a "Purge cache" card (purge-everything button + URL list);
api method; SCSS.
Tests: client payload, purge-everything, 30-file cap, empty-raises, provider
error surfaced (5 tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Cloudflare Zone Settings surface on top of the existing Cloudflare DNS
connection — SSL/TLS, Speed, Caching and Security toggles plus a one-click
"recommended hardening" preset. Reuses the connected DNSProviderConfig
credential and shared CloudflareClient, so auth/encryption-at-rest are not
re-implemented.
Backend:
- CloudflareClient: generic v4 request() helper + zone-settings get/update
(envelope normalized to {success, error?, result?}).
- CloudflareService: resolve zone+credential by ServerKit DNS zone id, curated
setting groups (UI metadata), get/update setting, apply recommended preset
with per-setting reporting.
- /api/v1/cloudflare blueprint: settings GET / GET-one / PATCH + apply-preset
(reads any user, writes admin; mutations captured by the audit fallback).
Frontend:
- CloudflareZoneSettings page (tabbed SSL/TLS · Speed · Caching · Security ·
Actions) with declarative toggle/select/HSTS controls and optimistic save.
- Route /cloudflare/zones/:zoneId, "Cloudflare" link from Cloudflare-managed
DNS zones, api module, SCSS.
Tests: client envelope/zone-settings + service resolution guards, indexing,
and preset reporting (11 tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator-facing guide to the Protection panel: scheduling, smart/incremental backups, retention, storage cost (free local disk vs real S3/B2), and one-click restore.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "common misconfiguration" notices stacked every hint as a banner at the top of
the dashboard while the notification bell sat empty. Now:
- SystemNotices (top banner) shows only urgent notices (error/critical, or an
explicit urgent flag), at most one — the dashboard stays clear.
- The live notices fold into the notification center (bell + /notifications) via the
shared NotificationsContext: they show with their Fix action, bump the unread
badge, and can be dismissed. Dismissal syncs across both surfaces (shared
localStorage).
New util utils/dismissedNotices.js (shared dismiss state + notice->item mapper).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces sites_dns_mode in the Managed Sites Domain & HTTPS card: a select to choose
Wildcard (point *.base once, every site instant) or Per-site (each new site gets its
own auto-created, ownership-tracked A record). Reads dns_mode from the sites-https
status and saves via updateSystemSetting.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ToastContext wrapped the 2nd arg as { duration }, so the ~30 call sites that pass a
full sonner options object (toast.info(msg, { duration: 4000 })) got it
double-wrapped ({ duration: { duration } }) and their custom durations were silently
dropped. Normalize at the wrapper: a number becomes { duration }, an object passes
through. Backward-compatible with the few numeric callers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Replace rsync with tar-based copy to avoid missing rsync on Windows/Git Bash
- Exclude backend/venv, backend/.venv, backend/.venv-wsl, and broken WSL symlinks
- Exclude ./backups, backend/dev-data/backups, backend/instance/backups, and scripts/test/output
- Exclude scratch images (PNG/JPEG/JPG), logs, and tmp files from the tarball
- Pack with tar --transform so /opt/serverkit prefix is created without cross-device moves
- Reduce release tarball from 13 GB back to ~6 MB
#12 SiteDomainService.give_subdomain(app, label): one-click publish an app at
<slug>.<base> — primary Domain row + nginx vhost + (per-site mode) an auto-created,
guarded/logged A record. Endpoints: POST /domains/give-subdomain and
GET /domains/suggest-subdomain.
#14 GET /api/v1/dns/managed: every record ServerKit owns across all provider zones,
enriched with the app that triggered each (from the ownership ledger).
#13 inline propagation reuses the existing /dns/propagation/<domain> check.
Tests: test_dns_give_subdomain.py.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#9 sites_dns_mode setting (wildcard | per-site) via SiteDomainService.dns_mode();
surfaced in SitesHttpsService.status().
#10 One-click wildcard already exists (SitesHttpsService.setup creates *.<base> +
issues the wildcard cert); it now also pins the mode to wildcard, and the wildcard
A record flows through the Phase 1 guarded/logged path.
#11 SiteDomainService.ensure_site_dns(): in per-site mode a new managed site gets
its own A record auto-created via a connected provider (ownership-guarded + logged),
wired into WordPressService._provision_routing. Wildcard mode stays a no-op.
Tests: test_dns_subdomain_modes.py.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#7 Surface the Phase 2 backend:
- DnsActivity: a per-connection "Recent changes" feed (action, record, result
badge, error) from GET /dns/changes, expandable on each DNS connection in
Settings -> Connections.
- DNS Zones page: a "ServerKit records / In your provider" toggle; the provider
view (GET /dns/<id>/mirror) lists every live record badged ServerKit vs External,
external records read-only/muted ("ServerKit never modifies external records").
- api: getDnsChanges() + getZoneMirror().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#6 dns_changes audit log (migration 027) — every record write ServerKit sends to a
connected provider is recorded at the write choke point (guarded_upsert /
guarded_delete): create/update/delete, source, result (ok|error|conflict|skipped),
and the value set. Powers the "Changes to your Cloudflare" activity feed.
GET /api/v1/dns/changes lists it (filter by config_id / zone / result).
#8 A real write failure now surfaces an admin notice via the Notification Bus
(dns.sync_failed) instead of being swallowed to the log. Best-effort — surfacing
never breaks the write it describes.
Tests: test_dns_change_log.py. The frontend activity/mirror UI lands separately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#3 managed_dns_records ledger (migration 026) — the single source of truth for
records ServerKit created in a connected provider zone, written by every write
path (zones page, DDNS, WordPress auto-DNS, email).
#4 DnsOwnershipService.guarded_upsert/guarded_delete: automatic paths refuse to
overwrite or delete a record ServerKit didn't create (the user's own records),
while the explicit Zones page adopts a matching record and takes ownership.
Wired into both CF paths (DNSZoneService._cloudflare_sync and
DNSProviderService._cloudflare_set_record/_delete_record). ensure_a_record now
surfaces a 'foreign_record' reason instead of clobbering.
#5 Live zone mirror: CloudflareClient.list_records +
DNSZoneService.list_provider_records classify every live record as serverkit vs
external; GET /api/v1/dns/<id>/mirror.
Tests: test_dns_ownership.py. Existing set_record mocks updated for the new
source kwarg.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 0 of the DNS roadmap.
#2 Shared Cloudflare client (app/services/dns/): DnsCredential + DnsRecordSpec +
CloudflareClient. Both DNSProviderService and DNSZoneService delegate to it, so
auth (scoped token / global key), the CAA structured-data wire format, MX/SRV
priority parsing, and idempotent upsert live in one place. Fixes dup-on-recreate,
silent update/delete no-op, and missing proxied/priority on the provider path.
#1 Single credential source: new FK dns_zones.dns_provider_config_id (migration
025) + DNSZoneService._resolve_credential (linked -> auto-discover-by-domain ->
legacy inline-token). create_zone accepts a connection id; link_legacy_zones()
converges inline tokens onto encrypted connections at boot. The /dns create-zone
modal now picks a connection instead of pasting a token. DDNS rides it for free.
Tests: test_dns_cloudflare_client.py, test_dns_credential_resolution.py.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Fall back to cp + manual cleanup when rsync is unavailable
- Exclude .venv-wsl, test output, scratch images, and logs from the tarball
- Use tar --transform to avoid moving directories across filesystems
- Stop including root-level PNG/JPEG/JPG/log/tmp artifacts in releases
- Add GET /api/v1/system/notices endpoint (admin-only)
- Detect missing canonical domain, sites base domain, server public IP, and wildcard HTTPS
- Add SystemNotices React component with dismiss persistence in localStorage
- Render notices inside DashboardLayout so they appear on every page
- Add scoped SCSS for warning/info/error notice banners
Make email handling case-insensitive (queries + model normalization) to prevent duplicate accounts. Add SiteDomainService.panel_origin() helper and use it across services to build public URLs (Gitea, WordPress, Roundcube) and Django ALLOWED_HOSTS. Add optional custom-domain support when creating WordPress sites and propagate domain to Roundcube install. Improve environment domain fallback to prefer panel/base domains over .localhost. Small Dockerfile healthcheck tweak (use 127.0.0.1). Update install/update/build scripts to always (re)create virtualenv locally for release installs. Remove unused frontend CreateSiteModal and expose domain field in WordPress page.
Introduce a GitHub Actions workflow (release-smoke.yml) that builds the release tarball on pushes/PRs to dev/main for relevant paths, catching release-script issues early. Adjust scripts/build-release.sh rsync excludes to explicitly target backups directories (root/backups and backend instance/dev-data backups) instead of a broader exclude, preventing accidental omission of needed files during release packaging.
Telemetry: ensure sanitized payloads bigger than the 32KiB threshold are replaced with a {_truncated: True} marker before storage, and add a unit test covering that behavior.
Backend API: adjust imports in secrets_webhooks to use WorkspaceService and include User model.
Frontend: make Sidebar resilient to malformed active_workspace JSON in localStorage (try/catch), and ensure socketService.disconnect() is called when user becomes unauthenticated in NotificationsProvider to clear notifications and unread count.
Files: backend/services/telemetry_service.py, backend/tests/test_telemetry.py, backend/app/api/secrets_webhooks.py, frontend/src/components/Sidebar.jsx, frontend/src/contexts/NotificationsContext.jsx
Rename service settings 'repository' → 'git' (routes, labels, and links) and update App route comment. Reorder sidebar footer so user mini and notification bell share a single row and add accessible user-mini button. Adjust notification panel positioning, add show animation, and responsive layout tweaks. Replace Workspaces card grid with a dense table view: add search, status segment control, bulk select checkboxes, and selection state. Minor SCSS tweaks for sidebar button sizing and WordPress action width. These changes improve discoverability and bulk management UX.
- Rename service Environment tab to Env Vars and settings Environment to Environment Type
- Add Domain & SSL section to service settings with attach domain + Let's Encrypt flow
- Surface primary domain from domains array in service subtitle
- Add prominent repo connection pill to WordPress detail header
- Apply WordPress list layout to Services page (drop KPIs, tabs+search toolbar)
WorkflowEventBus.emit no longer spawns a per-event daemon thread to match +
trigger workflows. It now enqueues a single 'workflow.dispatch' job; the new
WorkflowEventBus.dispatch_event handler does the matching (active event-trigger
workflows whose eventType matches, honoring the 60s cooldown) and enqueues a
workflow.execute job per match. emit is a non-blocking enqueue, best-effort
(never raises into the caller), and the whole event -> dispatch -> execute path
now flows through the bus (observable, persistent across restart). Removes the
last ad-hoc thread on the workflow path.
Registered alongside workflow.execute in WorkflowEngine.register_jobs.
tests: dispatch handler registered; emit enqueues a dispatch job (not a thread);
dispatch fans out only to the matching event workflow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire scheduled backups onto the unified job system and retire the orphaned
backup scheduler thread.
- BackupService.start_scheduler/_scheduler_loop were defined but NEVER called,
so scheduled backups never actually ran. Replace with check_backup_schedules()
(enqueues a backup.run job per due schedule) driven by a new
builtin.backup_scheduler tick, plus a run_backup_job handler that executes one
schedule via the existing _run_scheduled_backup. max_attempts=1 (backups
aren't idempotent); a failed run marks the job failed. This activates
scheduled backups, but stays gated by the existing backup config.enabled
(default False), so installs without configured backups are unaffected.
- Fix a latent bug in _send_backup_notification: it called
NotificationService.send_all(title=, message=, severity=) but send_all takes
a list of alert dicts — corrected (faithful: still routes to system channels
via the queue-backed bus).
- Monitoring intentionally left as-is: a user-toggled continuous stream whose
alert delivery already goes through the queued send_all. Phase 8 orchestration
is already met — send_all is a non-blocking shim over broadcast_system.
- tests: backup.run register/handler success+failure; scheduler tick enqueues
only due+enabled schedules and is inert when disabled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>