mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
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:
1 parent
a1e43524f5
commit
18fe56f948
5 files changed
+285
-15
No files matched your search
+44
-15
@@ -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
|
||||||
|
|||||||
@@ -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 → 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user