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
@@ -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() {
|
||||
|
||||
<Route path="/templates" element={<TemplatesPage />} />
|
||||
<Route path="/usage" element={<UsagePage />} />
|
||||
<Route path="/databases" element={<DatabasesPage />} />
|
||||
<Route path="/people" element={<PeoplePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/audit-logs" element={<AuditLogsPage />} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<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 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(
|
||||
input: CreateDatabaseInput,
|
||||
|
||||
Reference in new issue
Block a user