From b76c02f8076a9b15eeb46d5dd2f3417ae5cbe55b Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Mon, 10 Aug 2026 16:28:02 +0100 Subject: [PATCH] feat(frontend): show database status chip on capability cards Service cards that declare the database capability render a status chip for the shared dcntrlized-db infra service, with a Pull action to install and start the database when it is not installed yet. - Merge logic hides db-role containers and hidden registry items from the grid. - Service model carries capabilities; a new DatabaseChip maps the live database state onto the existing status metadata. --- src/frontend/src/app.tsx | 28 ++++- .../src/components/infra/database-chip.tsx | 106 ++++++++++++++++++ .../src/components/service/service-card.tsx | 7 +- .../src/components/service/service-grid.tsx | 12 +- src/frontend/src/lib/domain/merge.ts | 14 ++- src/frontend/src/lib/types.ts | 13 +++ 6 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 src/frontend/src/components/infra/database-chip.tsx diff --git a/src/frontend/src/app.tsx b/src/frontend/src/app.tsx index 032d4ce..475e3fe 100644 --- a/src/frontend/src/app.tsx +++ b/src/frontend/src/app.tsx @@ -20,13 +20,15 @@ import { useContainerStore } from '@lib/hooks/useContainerStore' import { useMasterRegistry } from '@lib/hooks/useMasterRegistry' import { mergeServices } from '@lib/domain/merge' import { filterServices, serviceCounts } from '@lib/domain/filter' -import type { ServiceFilter } from '@lib/types' +import type { DatabaseStatus, ServiceFilter } from '@lib/types' import { Header } from '@components/layout/header' import { Toolbar } from '@components/layout/toolbar' import { ServiceGrid } from '@components/service/service-grid' import { Toaster } from '@components/ui/sonner' import { EmptyState, LoadingState, RegistryErrorState } from '@components/layout/status-states' +const DATABASE_NAME = 'dcntrlized-db' + export function App() { const { containers, status } = useContainerStore() const registry = useMasterRegistry() @@ -44,6 +46,28 @@ export function App() { [services, query, filter] ) + const db = useMemo(() => { + const item = + registry.state.kind === 'ok' + ? (registry.state.items.find((r) => r.name.toLowerCase() === DATABASE_NAME) ?? null) + : null + + const container = containers.find( + (c) => (c.role ?? '').toLowerCase() === 'db' || c.name.toLowerCase() === DATABASE_NAME + ) + + let dbStatus: DatabaseStatus = 'uninstalled' + if (container) { + const s = container.status.toLowerCase() + if (s === 'running' || s === 'restarting') dbStatus = 'running' + else if (s === 'starting') dbStatus = 'starting' + else if (s === 'installed' || s === 'created') dbStatus = 'installed' + else dbStatus = 'stopped' + } + + return { status: dbStatus, item } + }, [containers, registry.state]) + const loading = registry.state.kind === 'loading' && containers.length === 0 const registryError = registry.state.kind === 'error' @@ -89,7 +113,7 @@ export function App() { {visible.length === 0 ? ( ) : ( - + )} )} diff --git a/src/frontend/src/components/infra/database-chip.tsx b/src/frontend/src/components/infra/database-chip.tsx new file mode 100644 index 0000000..103a3cb --- /dev/null +++ b/src/frontend/src/components/infra/database-chip.tsx @@ -0,0 +1,106 @@ +/** + * Copyright (C) 2026 Dcntrlized + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Database, Download, LoaderCircle } from 'lucide-react' +import { Button } from '@components/ui/button' +import { statusMeta } from '@components/container/status' +import { toast } from 'sonner' +import { installContainer, startContainer } from '@lib/api/containers' +import { useAsyncAction } from '@lib/hooks/useAsyncAction' +import { cn } from '@lib/utils' +import type { DatabaseStatus, RegistryItem, Service } from '@lib/types' +import type { DatabaseState } from '@components/service/service-grid' + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error' +} + +function dbService(item: RegistryItem): Service { + return { + id: `registry-${item.name}`, + name: item.name, + status: 'uninstalled', + origin: 'remote', + title: item.title || item.name, + image: item.image, + registryImage: item.image, + source: item.source, + registryUrl: item.url, + latestVersion: item.version, + hasUpdate: false, + capabilities: [], + } +} + +function chipFor(status: DatabaseStatus): string { + const meta = statusMeta(status) + return cn('inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium', meta.chip) +} + +/** + * Status chip for the shared database infrastructure service. Rendered on + * service cards that declare the `database` capability; lets the user pull + * the database image when it has not been installed yet. + */ +export function DatabaseChip({ db }: { db: DatabaseState }) { + const pull = useAsyncAction(async () => { + if (!db.item) return + try { + const service = dbService(db.item) + await installContainer(service) + try { + await startContainer(service) + toast.success('Shared database installed and started') + } catch { + toast.success('Shared database installed', { description: 'Start it from the database card when ready' }) + } + } catch (error) { + toast.error('Failed to install shared database', { description: errorMessage(error) }) + } + }) + + const meta = statusMeta(db.status) + + return ( +
+
+ ) +} diff --git a/src/frontend/src/components/service/service-card.tsx b/src/frontend/src/components/service/service-card.tsx index a40ca44..c3f87f1 100644 --- a/src/frontend/src/components/service/service-card.tsx +++ b/src/frontend/src/components/service/service-card.tsx @@ -24,9 +24,12 @@ import { installContainer, startContainer, stopContainer, uninstallContainer } f import type { Service } from '@lib/types' import { cn } from '@lib/utils' import { useAsyncAction } from '@lib/hooks/useAsyncAction' +import { DatabaseChip } from '@components/infra/database-chip' +import type { DatabaseState } from './service-grid' interface ServiceCardProps { service: Service + db: DatabaseState } function withV(s?: string): string | null { @@ -43,7 +46,7 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : 'Unknown error' } -export function ServiceCard({ service }: ServiceCardProps) { +export function ServiceCard({ service, db }: ServiceCardProps) { const meta = statusMeta(service.status) const installed = service.origin !== 'remote' const running = @@ -216,6 +219,8 @@ export function ServiceCard({ service }: ServiceCardProps) { )} + + {service.capabilities.includes('database') && } diff --git a/src/frontend/src/components/service/service-grid.tsx b/src/frontend/src/components/service/service-grid.tsx index b897389..a3f1cb7 100644 --- a/src/frontend/src/components/service/service-grid.tsx +++ b/src/frontend/src/components/service/service-grid.tsx @@ -15,18 +15,24 @@ * along with this program. If not, see . */ -import type { Service } from '@lib/types' +import type { DatabaseStatus, RegistryItem, Service } from '@lib/types' import { ServiceCard } from './service-card' +export interface DatabaseState { + status: DatabaseStatus + item: RegistryItem | null +} + interface ServiceGridProps { services: Service[] + db: DatabaseState } -export function ServiceGrid({ services }: ServiceGridProps) { +export function ServiceGrid({ services, db }: ServiceGridProps) { return (
{services.map((service) => ( - + ))}
) diff --git a/src/frontend/src/lib/domain/merge.ts b/src/frontend/src/lib/domain/merge.ts index 6d2aa67..5c8182b 100644 --- a/src/frontend/src/lib/domain/merge.ts +++ b/src/frontend/src/lib/domain/merge.ts @@ -37,6 +37,14 @@ function isMaestro(name?: string): boolean { return (name ?? '').toUpperCase().includes('MAESTRO') } +function isHiddenContainer(c: Container): boolean { + return Boolean(c.hidden) || (c.role ?? '').toLowerCase() === 'db' +} + +function isHiddenRegistry(r: RegistryItem): boolean { + return Boolean(r.hidden) +} + function toService(c: Container): Service { return { id: c.id || c.name, @@ -47,6 +55,7 @@ function toService(c: Container): Service { image: c.image, version: c.version || versionFromImage(c.image), hasUpdate: false, + capabilities: c.capabilities ?? [], } } @@ -76,7 +85,7 @@ export function mergeServices(containers: Container[], registry: RegistryItem[]) const map = new Map() for (const c of containers) { - if (isMaestro(c.name)) continue + if (isMaestro(c.name) || isHiddenContainer(c)) continue const key = c.name.toLowerCase() const next = toService(c) const existing = map.get(key) @@ -86,7 +95,7 @@ export function mergeServices(containers: Container[], registry: RegistryItem[]) } for (const r of registry) { - if (isMaestro(r.name)) continue + if (isMaestro(r.name) || isHiddenRegistry(r)) continue const key = r.name.toLowerCase() const existing = findMatch(map, key, r.image) @@ -122,6 +131,7 @@ export function mergeServices(containers: Container[], registry: RegistryItem[]) author: r.author, latestVersion: r.version, hasUpdate: false, + capabilities: [], }) } } diff --git a/src/frontend/src/lib/types.ts b/src/frontend/src/lib/types.ts index 69f69b1..50f3853 100644 --- a/src/frontend/src/lib/types.ts +++ b/src/frontend/src/lib/types.ts @@ -38,6 +38,12 @@ export interface Container { version?: string status: string network?: NetworkConfig + /** Infrastructure role (e.g. "db"); absent for regular services. */ + role?: string + /** Infrastructure services managed by Maestro but hidden from the grid. */ + hidden?: boolean + /** Capabilities declared by the service manifest (e.g. ["database"]). */ + capabilities?: string[] } /** A remote catalog entry, as served by GET /api/registry. */ @@ -56,6 +62,8 @@ export interface RegistryItem { url?: string documentation?: string created_at?: string + /** Infrastructure images (e.g. the shared database) excluded from the grid. */ + hidden?: boolean } /** Merged view model: one card per service, local state and remote catalog fused. */ @@ -76,8 +84,13 @@ export interface Service { version?: string latestVersion?: string hasUpdate: boolean + /** Capabilities declared by the service manifest (e.g. ["database"]). */ + capabilities: string[] } +/** Live status of the shared database infra service. */ +export type DatabaseStatus = 'running' | 'starting' | 'stopped' | 'installed' | 'uninstalled' + export type ServiceFilter = 'all' | 'running' | 'updates' | 'available' export interface FilterOption {