From 18fe56f9488567464014cbb2cffe77864bb7edeb Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 17 Sep 2026 17:15:11 +0200 Subject: [PATCH] Add Databases management page and roadmap coverage audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /databases page (new nav item): expandable managed-DB cards with connection URL + copy, start/stop/restart actions, metrics summary, and backup list with create/restore. Verified e2e against a real postgres:16 container — provision, psql connect, stop/start, seed, 6.4MB backup, drop, restore with data recovered. ROADMAP: marked databases/cron/git-connect/agents done with evidence, corrected stale inventory rows (frontend now consumes 52 API ops), added uncovered items — GitHub App install surfacing, database→service binding, backup export + scheduled backups, service exec console, OAuth e2e w/ real creds, remaining sqlc port, demo-data parity. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP.md | 59 +++-- app/frontend/src/app/App.tsx | 2 + app/frontend/src/app/layout/PlatformShell.tsx | 2 + app/frontend/src/features/secondary/pages.tsx | 206 ++++++++++++++++++ app/frontend/src/lib/api-client.ts | 31 +++ 5 files changed, 285 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 627fa19..39ad868 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Containr — Project Roadmap -Last verified: **2026-09-17** against `main` (`9e3aa3a`). +Last verified: **2026-09-17** against `main` (`a1e4352`). Goal: bring Containr from "working core + large headless backend" to a finished, installable, documented v1.0 self-hosted platform. @@ -27,8 +27,8 @@ Everything below was run, not assumed. ### Feature inventory — backend vs frontend -The backend exposes **~90 route registrations**; the frontend consumes roughly -**25** of them. The gap below is the real roadmap. +The backend exposes **~95 route registrations**; the frontend consumes +**52** of them via `api-client.ts`. The gap below is the real roadmap. | Feature | Backend | Frontend | Docs | |---|---|---|---| @@ -44,10 +44,10 @@ The backend exposes **~90 route registrations**; the frontend consumes roughly | Templates — list/detail/deploy | done | done (catalog page) | done | | Templates — **Compose catalog** (GitHub import, paste YAML, `x-containr` metadata, community source) | **missing** | **missing** | spec written, not built | | Git providers (PAT add/remove, repo list, branches) | done | done (Settings → Git Providers) | done | -| Git repo connect + webhook auto-deploy | done (`/git/repositories/connect`, `/git/webhooks`) | **missing** — no connect flow, no webhook status | guides claim it works | -| GitHub App install flow | done | partial — install-url/connect not surfaced | partial | -| Cron jobs (CRUD, trigger, executions) | done | **missing** | not documented | -| Managed databases (CRUD, actions, backup/restore) | done | **missing** | mentioned in README | +| Git repo connect + webhook auto-deploy | done (`/git/repositories/connect`, `/git/webhooks`, `/api/git/webhooks/:id` receiver) | done — provider→repo→branch in Add Service, connect+webhook on create | guides claim it works | +| GitHub App install flow | done (`install_url` returned) | partial — install URL not surfaced in UI | partial | +| Cron jobs (CRUD, trigger, executions) | done — real scheduler + `docker exec` capture | done — Cron tab on service detail | not documented | +| Managed databases (CRUD, actions, backup/restore) | done | done — `/databases` page (conn info, actions, backups) | mentioned in README | | Preview environments (CRUD, promote, cleanup) | done | **missing** | not documented | | Security scans, vulnerabilities, compliance/GDPR reports | done | **missing** | README lists it | | Audit logs | done | done — `/settings/audit-logs` filterable page | done | @@ -98,10 +98,10 @@ The backend exposes **~90 route registrations**; the frontend consumes roughly harnesses, `migrations/0001_init.sql` + `.bak`. Correction to the earlier audit: `feature_response.go` is *live* (`respondDependencyUnavailable` is used by `logs.go`) and stays. -- **Stale health data**: `.desloppify/state-*.json` is from February — rerun. -- **Test coverage**: frontend has one smoke test; backend coverage - concentrated in `internal/api` and infra packages; deployment engine, - docker client, metrics, scaling, agents have no tests. +- **Test coverage**: frontend has focused tests (`variable-utils`) but no + canvas/flow coverage; backend coverage concentrated in `internal/api` and + infra packages; deployment engine, docker client, metrics, scaling, + agents have no tests. --- @@ -258,10 +258,12 @@ calls. Template install from pasted Compose works the same way. All of these have working APIs and no UI. Cheapest feature wins in the repo. -- [ ] **Databases** (`/databases/*` + backup/restore): service type on canvas, - detail page section for connection info, actions (start/stop/restart), - backup create/list/restore. Verify against real Postgres/Redis/MySQL - images. +- [x] **Databases** (`/databases/*` + backup/restore): `/databases` page + (nav item) — expandable cards with connection URL + copy, start/stop/ + restart actions, metrics summary, backup list + New backup + Restore + (with confirm). Verified e2e against a real `postgres:16` image: + provision → `psql` connect → stop/start → seed → backup (6.4MB + archive) → drop table → restore → data recovered. - [x] **Cron jobs** (`/cron-jobs/*`): real engine + UI. Backend was a stub (`calculateNextRun` returned now+1h, `executeCronJob` slept 2s and wrote "success" without running anything). Now: `robfig/cron` parses the @@ -315,6 +317,19 @@ All of these have working APIs and no UI. Cheapest feature wins in the repo. databases natively. - [ ] **Notifications depth**: notification center persistence (read/unread), not just a transient bell feed. +- [ ] **GitHub App install surfacing**: backend returns `install_url` for + `github_app` providers — the UI never shows it. Surface an "Install app" + action on GitHub-App providers in Settings → Git Providers. +- [ ] **Database → service binding**: creating a managed DB does not wire it + into a service. Add a bind action that injects the resolved + `connection_url` as an env var (e.g. `DATABASE_URL`) on a chosen + service, so apps can actually consume provisioned databases. +- [ ] **Backup export**: `database_backups.backup_path` is server-local only — + no download endpoint. Add `GET /databases/:id/backups/:bid/download` + streaming the archive, plus a Download action in the backups list. +- [ ] **Scheduled database backups**: backups are manual-only despite a + `retention` field. Add a `schedule` column + fold due database backups + into the cron scheduler tick. **Gate**: every registered route is reachable from the UI or has an explicit "API-only" note in the spec. A request to `/g/{service}` with a valid key @@ -353,6 +368,9 @@ reaches the upstream container and appears in the analytics tables. with provider badge, VM/LXC lifecycle actions. - [ ] **Environments** (per §2.4): env switcher, per-env variables/domains, promote flow — ships with preview environments. +- [ ] **Service exec console**: `docker exec` machinery now exists + (`Client.ExecRun` for cron) — expose a per-service web terminal or + one-off command runner on the service detail page. **Gate**: scaling and failover demonstrably work against a two-node lab; security scans produce real reports, not empty tables. @@ -374,6 +392,17 @@ security scans produce real reports, not empty tables. install wizard, git connect); backend table-driven tests for deployments, templates import, webhooks; Playwright e2e suite for the Phase-1 golden path. +- [ ] **OAuth e2e with real credentials**: GitHub/Google providers are + env-gated and config-verified only — run the happy path once real + provider apps exist (bootstrap gate already proven: OAuth cannot bypass + it via `disableImplicitSignUp`). +- [ ] **sqlc port — remaining raw SQL**: ~140 hand-written `database/sql` + call sites across ~14 handler files remain (agents + tokens are sqlc). + Mechanical port to `sqlc/queries/`; the consolidated baseline schema is + now the codegen source of truth. +- [ ] **Demo-data parity for new pages**: `demo-data.ts` covers Projects/ + Templates but the new Databases page and service Cron tab have no demo + fixtures — add them so `?demo=1` doesn't show empty sections. - [ ] **CI**: GitHub Actions — build, lint, typecheck, tests, compose boot smoke; publish container images to GHCR with semver tags. - [ ] **Release**: `v1.0.0` tag, changelog, release notes, screenshots refresh diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx index de51f38..995d8f8 100644 --- a/app/frontend/src/app/App.tsx +++ b/app/frontend/src/app/App.tsx @@ -12,6 +12,7 @@ import { PeoplePage, SettingsPage, UsagePage, + DatabasesPage, ComponentShowcase, } from '@/features/secondary/pages'; import { SignInPage, SignUpPage } from '@/features/auth/pages'; @@ -61,6 +62,7 @@ export default function App() { } /> } /> + } /> } /> } /> } /> diff --git a/app/frontend/src/app/layout/PlatformShell.tsx b/app/frontend/src/app/layout/PlatformShell.tsx index e03ecd6..330f5d2 100644 --- a/app/frontend/src/app/layout/PlatformShell.tsx +++ b/app/frontend/src/app/layout/PlatformShell.tsx @@ -7,6 +7,7 @@ import { ChartBar, CheckCircle2, Container, + Database, FolderKanban, LayoutTemplate, LogOut, @@ -31,6 +32,7 @@ import { useToast } from '@/shared/components'; const navItems = [ { label: 'Projects', href: '/projects', icon: FolderKanban }, { label: 'Templates', href: '/templates', icon: LayoutTemplate }, + { label: 'Databases', href: '/databases', icon: Database }, { label: 'Usage', href: '/usage', icon: ChartBar }, { label: 'People', href: '/people', icon: Users }, { label: 'Settings', href: '/settings', icon: Settings }, diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index bb76b02..41ac217 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -18,8 +18,13 @@ import { createAgentToken, listAgentTokens, revokeAgentToken, + listDatabases, + databaseAction, + createDatabaseBackup, + restoreDatabaseBackup, updateCurrentUserProfile, type AgentAuthTokenCreated, + type DatabaseEntity, } from '@/lib/api-client'; import { formatRelative } from '@/lib/time'; import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client'; @@ -57,7 +62,11 @@ import { MemoryStick, ScrollText, ChevronRight, + ChevronDown, Copy, + Play, + Square, + Archive, } from 'lucide-react'; function SecondaryPageHeader({ title, description }: { title: string; description: string }) { @@ -1434,3 +1443,200 @@ cd app/backend && go build -o bin/containr-agent ./cmd/agent`} export { ComponentShowcase } from './pages/ComponentShowcase'; + +const dbStatusClass: Record = { + running: 'bg-[var(--success-soft)] text-[var(--success)]', + stopped: 'bg-[var(--surface-muted)] text-[var(--text-muted)]', + building: 'bg-[var(--warning-soft)] text-[var(--warning)]', + error: 'bg-[var(--error-soft)] text-[var(--error)]', +}; + +export function DatabasesPage() { + const queryClient = useQueryClient(); + const [expandedId, setExpandedId] = useState(null); + const databasesQuery = useQuery({ + queryKey: ['databases'], + queryFn: listDatabases, + refetchInterval: 15_000, + }); + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['databases'] }); + const actionMutation = useMutation({ + mutationFn: ({ id, action }: { id: string; action: 'start' | 'stop' | 'restart' }) => + databaseAction(id, action), + onSuccess: invalidate, + }); + const backupMutation = useMutation({ + mutationFn: (id: string) => createDatabaseBackup(id), + onSuccess: () => window.setTimeout(invalidate, 3000), + }); + const restoreMutation = useMutation({ + mutationFn: ({ id, backupId }: { id: string; backupId: string }) => + restoreDatabaseBackup(id, backupId), + onSuccess: () => window.setTimeout(invalidate, 3000), + }); + + const databases = databasesQuery.data ?? []; + + return ( +
+ +
+ {databasesQuery.isLoading ? ( +
+ +
+ ) : databases.length === 0 ? ( +
+ +

No managed databases yet.

+

+ Create one via Add Service → Database in a project workspace. +

+
+ ) : ( +
+ {databases.map((db: DatabaseEntity) => { + const expanded = expandedId === db.id; + const running = db.status === 'running'; + const backups = db.backups?.backups ?? []; + return ( +
+ + + {expanded && ( +
+
+

Connection URL

+
+ + {db.connection_url || '—'} + + {db.connection_url && ( + + )} +
+
+ +
+ Actions + + + + {actionMutation.isError && ( + + {actionMutation.error instanceof Error ? actionMutation.error.message : 'Action failed'} + + )} +
+ + {db.metrics && ( +
+ {([ + ['CPU', `${db.metrics.cpu ?? 0}%`], + ['Memory', `${db.metrics.memory ?? 0}%`], + ['Storage', `${db.metrics.storage ?? 0}%`], + ['Connections', `${db.metrics.connections ?? 0}`], + ] as const).map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
+ )} + +
+
+

Backups

+ +
+ {backups.length === 0 ? ( +

No backups yet.

+ ) : ( +
+ {backups.map((b) => ( +
+ {formatRelative(b.created_at)} + {b.size} + {b.status} + {b.status === 'completed' && ( + + )} +
+ ))} +
+ )} + {restoreMutation.isError && ( +

+ {restoreMutation.error instanceof Error ? restoreMutation.error.message : 'Restore failed'} +

+ )} +
+
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 2aca6dc..0cbd491 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1277,6 +1277,37 @@ export async function listGitBranches( export type CreateDatabaseInput = components['schemas']['CreateDatabaseRequest']; export type DatabaseEntity = components['schemas']['Database']; +export type DatabaseBackupEntity = components['schemas']['DatabaseBackup']; + +export async function listDatabases(): Promise { + const payload = await requestJson<{ databases?: DatabaseEntity[] }>('/databases'); + return payload.databases ?? []; +} + +export async function getDatabase(id: string): Promise { + return requestJson(`/databases/${encodeURIComponent(id)}`); +} + +export async function databaseAction(id: string, action: 'start' | 'stop' | 'restart'): Promise { + await requestJson(`/databases/${encodeURIComponent(id)}/action`, { + method: 'POST', + body: JSON.stringify({ action }), + }); +} + +export async function createDatabaseBackup(id: string): Promise { + await requestJson(`/databases/${encodeURIComponent(id)}/backup`, { + method: 'POST', + body: JSON.stringify({ database_id: id }), + }); +} + +export async function restoreDatabaseBackup(id: string, backupId: string): Promise { + await requestJson(`/databases/${encodeURIComponent(id)}/restore`, { + method: 'POST', + body: JSON.stringify({ database_id: id, backup_id: backupId }), + }); +} export async function createManagedDatabase( input: CreateDatabaseInput,