Add Databases management page and roadmap coverage audit

/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>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 17:15:11 +02:00
1 parent a1e43524f5
commit 18fe56f948
5 files changed
+285 -15

No files matched your search

+44 -15
View File
@@ -1,6 +1,6 @@
# Containr — Project Roadmap # 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 Goal: bring Containr from "working core + large headless backend" to a
finished, installable, documented v1.0 self-hosted platform. finished, installable, documented v1.0 self-hosted platform.
@@ -27,8 +27,8 @@ Everything below was run, not assumed.
### Feature inventory — backend vs frontend ### Feature inventory — backend vs frontend
The backend exposes **~90 route registrations**; the frontend consumes roughly The backend exposes **~95 route registrations**; the frontend consumes
**25** of them. The gap below is the real roadmap. **52** of them via `api-client.ts`. The gap below is the real roadmap.
| Feature | Backend | Frontend | Docs | | 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 — 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 | | 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 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 | | 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 | partial — install-url/connect not surfaced | partial | | GitHub App install flow | done (`install_url` returned) | partial — install URL not surfaced in UI | partial |
| Cron jobs (CRUD, trigger, executions) | done | **missing** | not documented | | 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 | **missing** | mentioned in README | | 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 | | Preview environments (CRUD, promote, cleanup) | done | **missing** | not documented |
| Security scans, vulnerabilities, compliance/GDPR reports | done | **missing** | README lists it | | Security scans, vulnerabilities, compliance/GDPR reports | done | **missing** | README lists it |
| Audit logs | done | done — `/settings/audit-logs` filterable page | done | | 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 harnesses, `migrations/0001_init.sql` + `.bak`. Correction to the earlier
audit: `feature_response.go` is *live* (`respondDependencyUnavailable` is audit: `feature_response.go` is *live* (`respondDependencyUnavailable` is
used by `logs.go`) and stays. used by `logs.go`) and stays.
- **Stale health data**: `.desloppify/state-*.json` is from February — rerun. - **Test coverage**: frontend has focused tests (`variable-utils`) but no
- **Test coverage**: frontend has one smoke test; backend coverage canvas/flow coverage; backend coverage concentrated in `internal/api` and
concentrated in `internal/api` and infra packages; deployment engine, infra packages; deployment engine, docker client, metrics, scaling,
docker client, metrics, scaling, agents have no tests. 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. All of these have working APIs and no UI. Cheapest feature wins in the repo.
- [ ] **Databases** (`/databases/*` + backup/restore): service type on canvas, - [x] **Databases** (`/databases/*` + backup/restore): `/databases` page
detail page section for connection info, actions (start/stop/restart), (nav item) — expandable cards with connection URL + copy, start/stop/
backup create/list/restore. Verify against real Postgres/Redis/MySQL restart actions, metrics summary, backup list + New backup + Restore
images. (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 - [x] **Cron jobs** (`/cron-jobs/*`): real engine + UI. Backend was a stub
(`calculateNextRun` returned now+1h, `executeCronJob` slept 2s and wrote (`calculateNextRun` returned now+1h, `executeCronJob` slept 2s and wrote
"success" without running anything). Now: `robfig/cron` parses the "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. databases natively.
- [ ] **Notifications depth**: notification center persistence (read/unread), - [ ] **Notifications depth**: notification center persistence (read/unread),
not just a transient bell feed. 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 **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 "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. with provider badge, VM/LXC lifecycle actions.
- [ ] **Environments** (per §2.4): env switcher, per-env variables/domains, - [ ] **Environments** (per §2.4): env switcher, per-env variables/domains,
promote flow — ships with preview environments. 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; **Gate**: scaling and failover demonstrably work against a two-node lab;
security scans produce real reports, not empty tables. 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 install wizard, git connect); backend table-driven tests for
deployments, templates import, webhooks; Playwright e2e suite for the deployments, templates import, webhooks; Playwright e2e suite for the
Phase-1 golden path. 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 - [ ] **CI**: GitHub Actions — build, lint, typecheck, tests, compose boot
smoke; publish container images to GHCR with semver tags. smoke; publish container images to GHCR with semver tags.
- [ ] **Release**: `v1.0.0` tag, changelog, release notes, screenshots refresh - [ ] **Release**: `v1.0.0` tag, changelog, release notes, screenshots refresh
+2
View File
@@ -12,6 +12,7 @@ import {
PeoplePage, PeoplePage,
SettingsPage, SettingsPage,
UsagePage, UsagePage,
DatabasesPage,
ComponentShowcase, ComponentShowcase,
} from '@/features/secondary/pages'; } from '@/features/secondary/pages';
import { SignInPage, SignUpPage } from '@/features/auth/pages'; import { SignInPage, SignUpPage } from '@/features/auth/pages';
@@ -61,6 +62,7 @@ export default function App() {
<Route path="/templates" element={<TemplatesPage />} /> <Route path="/templates" element={<TemplatesPage />} />
<Route path="/usage" element={<UsagePage />} /> <Route path="/usage" element={<UsagePage />} />
<Route path="/databases" element={<DatabasesPage />} />
<Route path="/people" element={<PeoplePage />} /> <Route path="/people" element={<PeoplePage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/audit-logs" element={<AuditLogsPage />} /> <Route path="/settings/audit-logs" element={<AuditLogsPage />} />
@@ -7,6 +7,7 @@ import {
ChartBar, ChartBar,
CheckCircle2, CheckCircle2,
Container, Container,
Database,
FolderKanban, FolderKanban,
LayoutTemplate, LayoutTemplate,
LogOut, LogOut,
@@ -31,6 +32,7 @@ import { useToast } from '@/shared/components';
const navItems = [ const navItems = [
{ label: 'Projects', href: '/projects', icon: FolderKanban }, { label: 'Projects', href: '/projects', icon: FolderKanban },
{ label: 'Templates', href: '/templates', icon: LayoutTemplate }, { label: 'Templates', href: '/templates', icon: LayoutTemplate },
{ label: 'Databases', href: '/databases', icon: Database },
{ label: 'Usage', href: '/usage', icon: ChartBar }, { label: 'Usage', href: '/usage', icon: ChartBar },
{ label: 'People', href: '/people', icon: Users }, { label: 'People', href: '/people', icon: Users },
{ label: 'Settings', href: '/settings', icon: Settings }, { label: 'Settings', href: '/settings', icon: Settings },
@@ -18,8 +18,13 @@ import {
createAgentToken, createAgentToken,
listAgentTokens, listAgentTokens,
revokeAgentToken, revokeAgentToken,
listDatabases,
databaseAction,
createDatabaseBackup,
restoreDatabaseBackup,
updateCurrentUserProfile, updateCurrentUserProfile,
type AgentAuthTokenCreated, type AgentAuthTokenCreated,
type DatabaseEntity,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client'; import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client';
@@ -57,7 +62,11 @@ import {
MemoryStick, MemoryStick,
ScrollText, ScrollText,
ChevronRight, ChevronRight,
ChevronDown,
Copy, Copy,
Play,
Square,
Archive,
} from 'lucide-react'; } from 'lucide-react';
function SecondaryPageHeader({ title, description }: { title: string; description: string }) { 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'; export { ComponentShowcase } from './pages/ComponentShowcase';
const dbStatusClass: Record<string, string> = {
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<string | null>(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 (
<div>
<SecondaryPageHeader
title="Databases"
description="Managed database services — connection info, runtime actions and backups"
/>
<div className="w-full px-8 py-6">
{databasesQuery.isLoading ? (
<div className="flex items-center justify-center py-16 text-[var(--text-muted)]">
<Loader2 size={20} className="animate-spin" />
</div>
) : databases.length === 0 ? (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-10 text-center">
<Database size={28} className="mx-auto text-[var(--text-muted)]" />
<p className="mt-3 text-sm text-[var(--text-secondary)]">No managed databases yet.</p>
<p className="mt-1 text-xs text-[var(--text-muted)]">
Create one via Add Service &rarr; Database in a project workspace.
</p>
</div>
) : (
<div className="space-y-3">
{databases.map((db: DatabaseEntity) => {
const expanded = expandedId === db.id;
const running = db.status === 'running';
const backups = db.backups?.backups ?? [];
return (
<div key={db.id} className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)]">
<button
onClick={() => setExpandedId(expanded ? null : (db.id ?? null))}
className="flex w-full items-center gap-3 p-4 text-left"
>
<div className="w-9 h-9 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center shrink-0">
<Database size={17} className="text-[var(--accent-primary)]" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--text-primary)]">{db.name}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-secondary)] mono">{db.type}{db.version ? ` ${db.version}` : ''}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${dbStatusClass[db.status ?? ''] ?? 'bg-[var(--surface-muted)] text-[var(--text-muted)]'}`}>{db.status}</span>
</div>
<p className="mt-0.5 text-xs text-[var(--text-muted)]">{db.plan} · {db.region}</p>
</div>
<ChevronDown size={15} className={`shrink-0 text-[var(--text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`} />
</button>
{expanded && (
<div className="border-t border-[var(--border-subtle)] p-4 space-y-4">
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Connection URL</p>
<div className="flex items-center gap-2">
<code className="mono flex-1 truncate rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs text-[var(--text-primary)]">
{db.connection_url || '—'}
</code>
{db.connection_url && (
<button
onClick={() => void navigator.clipboard.writeText(db.connection_url ?? '')}
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
>
<Copy size={11} /> Copy
</button>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mr-1">Actions</span>
<button
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })}
disabled={running || actionMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
>
<Play size={11} /> Start
</button>
<button
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'stop' })}
disabled={!running || actionMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
>
<Square size={11} /> Stop
</button>
<button
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'restart' })}
disabled={!running || actionMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
>
<RefreshCw size={11} /> Restart
</button>
{actionMutation.isError && (
<span className="text-xs text-[var(--error)]">
{actionMutation.error instanceof Error ? actionMutation.error.message : 'Action failed'}
</span>
)}
</div>
{db.metrics && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{([
['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]) => (
<div key={label} className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2">
<p className="text-xs text-[var(--text-muted)]">{label}</p>
<p className="mono text-sm text-[var(--text-primary)]">{value}</p>
</div>
))}
</div>
)}
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Backups</p>
<button
onClick={() => backupMutation.mutate(db.id ?? '')}
disabled={!running || backupMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] disabled:opacity-40 transition-all"
style={{ background: 'var(--accent-primary)' }}
>
<Archive size={11} />
{backupMutation.isPending ? 'Starting…' : 'New backup'}
</button>
</div>
{backups.length === 0 ? (
<p className="text-xs text-[var(--text-muted)]">No backups yet.</p>
) : (
<div className="space-y-1.5">
{backups.map((b) => (
<div key={b.id} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs">
<span className="text-[var(--text-secondary)]">{formatRelative(b.created_at)}</span>
<span className="mono text-[var(--text-muted)]">{b.size}</span>
<span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span>
{b.status === 'completed' && (
<button
onClick={() => { if (window.confirm(`Restore ${db.name} from this backup? Current data will be overwritten.`)) restoreMutation.mutate({ id: db.id ?? '', backupId: b.id ?? '' }); }}
disabled={restoreMutation.isPending}
className="ml-auto text-[var(--accent-primary)] hover:underline disabled:opacity-40"
>
Restore
</button>
)}
</div>
))}
</div>
)}
{restoreMutation.isError && (
<p className="mt-2 text-xs text-[var(--error)]">
{restoreMutation.error instanceof Error ? restoreMutation.error.message : 'Restore failed'}
</p>
)}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
+31
View File
@@ -1277,6 +1277,37 @@ export async function listGitBranches(
export type CreateDatabaseInput = components['schemas']['CreateDatabaseRequest']; export type CreateDatabaseInput = components['schemas']['CreateDatabaseRequest'];
export type DatabaseEntity = components['schemas']['Database']; export type DatabaseEntity = components['schemas']['Database'];
export type DatabaseBackupEntity = components['schemas']['DatabaseBackup'];
export async function listDatabases(): Promise<DatabaseEntity[]> {
const payload = await requestJson<{ databases?: DatabaseEntity[] }>('/databases');
return payload.databases ?? [];
}
export async function getDatabase(id: string): Promise<DatabaseEntity> {
return requestJson<DatabaseEntity>(`/databases/${encodeURIComponent(id)}`);
}
export async function databaseAction(id: string, action: 'start' | 'stop' | 'restart'): Promise<void> {
await requestJson(`/databases/${encodeURIComponent(id)}/action`, {
method: 'POST',
body: JSON.stringify({ action }),
});
}
export async function createDatabaseBackup(id: string): Promise<void> {
await requestJson(`/databases/${encodeURIComponent(id)}/backup`, {
method: 'POST',
body: JSON.stringify({ database_id: id }),
});
}
export async function restoreDatabaseBackup(id: string, backupId: string): Promise<void> {
await requestJson(`/databases/${encodeURIComponent(id)}/restore`, {
method: 'POST',
body: JSON.stringify({ database_id: id, backup_id: backupId }),
});
}
export async function createManagedDatabase( export async function createManagedDatabase(
input: CreateDatabaseInput, input: CreateDatabaseInput,