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.
This commit is contained in:
2026-08-10 16:28:02 +01:00
parent c1ad85b65c
commit b76c02f807
6 changed files with 172 additions and 8 deletions
+26 -2
View File
@@ -20,13 +20,15 @@ import { useContainerStore } from '@lib/hooks/useContainerStore'
import { useMasterRegistry } from '@lib/hooks/useMasterRegistry' import { useMasterRegistry } from '@lib/hooks/useMasterRegistry'
import { mergeServices } from '@lib/domain/merge' import { mergeServices } from '@lib/domain/merge'
import { filterServices, serviceCounts } from '@lib/domain/filter' 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 { Header } from '@components/layout/header'
import { Toolbar } from '@components/layout/toolbar' import { Toolbar } from '@components/layout/toolbar'
import { ServiceGrid } from '@components/service/service-grid' import { ServiceGrid } from '@components/service/service-grid'
import { Toaster } from '@components/ui/sonner' import { Toaster } from '@components/ui/sonner'
import { EmptyState, LoadingState, RegistryErrorState } from '@components/layout/status-states' import { EmptyState, LoadingState, RegistryErrorState } from '@components/layout/status-states'
const DATABASE_NAME = 'dcntrlized-db'
export function App() { export function App() {
const { containers, status } = useContainerStore() const { containers, status } = useContainerStore()
const registry = useMasterRegistry() const registry = useMasterRegistry()
@@ -44,6 +46,28 @@ export function App() {
[services, query, filter] [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 loading = registry.state.kind === 'loading' && containers.length === 0
const registryError = registry.state.kind === 'error' const registryError = registry.state.kind === 'error'
@@ -89,7 +113,7 @@ export function App() {
{visible.length === 0 ? ( {visible.length === 0 ? (
<EmptyState query={query} filter={filter} total={counts.total} onClear={clearFilters} /> <EmptyState query={query} filter={filter} total={counts.total} onClear={clearFilters} />
) : ( ) : (
<ServiceGrid services={visible} /> <ServiceGrid services={visible} db={db} />
)} )}
</> </>
)} )}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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 (
<div data-slot="db-chip" className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-2 py-1.5">
<Database aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">Database</span>
<span className={chipFor(db.status)}>
{meta.ping && (
<span className="relative inline-flex size-1.5" aria-hidden="true">
<span className={cn('absolute inline-flex size-1.5 animate-status-ping rounded-full opacity-70', meta.dot)} />
<span className={cn('relative inline-flex size-1.5 rounded-full', meta.dot)} />
</span>
)}
{meta.label}
</span>
{db.status === 'uninstalled' && db.item && (
<Button
data-slot="db-pull"
size="sm"
className="ml-auto"
isDisabled={pull.pending}
onPress={pull.run}
>
{pull.pending ? <LoaderCircle aria-hidden="true" className="size-3 animate-spin" /> : <Download aria-hidden="true" className="size-3" />}
Pull
</Button>
)}
</div>
)
}
@@ -24,9 +24,12 @@ import { installContainer, startContainer, stopContainer, uninstallContainer } f
import type { Service } from '@lib/types' import type { Service } from '@lib/types'
import { cn } from '@lib/utils' import { cn } from '@lib/utils'
import { useAsyncAction } from '@lib/hooks/useAsyncAction' import { useAsyncAction } from '@lib/hooks/useAsyncAction'
import { DatabaseChip } from '@components/infra/database-chip'
import type { DatabaseState } from './service-grid'
interface ServiceCardProps { interface ServiceCardProps {
service: Service service: Service
db: DatabaseState
} }
function withV(s?: string): string | null { function withV(s?: string): string | null {
@@ -43,7 +46,7 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error' 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 meta = statusMeta(service.status)
const installed = service.origin !== 'remote' const installed = service.origin !== 'remote'
const running = const running =
@@ -216,6 +219,8 @@ export function ServiceCard({ service }: ServiceCardProps) {
</a> </a>
)} )}
</div> </div>
{service.capabilities.includes('database') && <DatabaseChip db={db} />}
</CardContent> </CardContent>
<CardFooter className="justify-end pt-4"> <CardFooter className="justify-end pt-4">
@@ -15,18 +15,24 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type { Service } from '@lib/types' import type { DatabaseStatus, RegistryItem, Service } from '@lib/types'
import { ServiceCard } from './service-card' import { ServiceCard } from './service-card'
export interface DatabaseState {
status: DatabaseStatus
item: RegistryItem | null
}
interface ServiceGridProps { interface ServiceGridProps {
services: Service[] services: Service[]
db: DatabaseState
} }
export function ServiceGrid({ services }: ServiceGridProps) { export function ServiceGrid({ services, db }: ServiceGridProps) {
return ( return (
<div data-slot="service-grid" className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3"> <div data-slot="service-grid" className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{services.map((service) => ( {services.map((service) => (
<ServiceCard key={service.id} service={service} /> <ServiceCard key={service.id} service={service} db={db} />
))} ))}
</div> </div>
) )
+12 -2
View File
@@ -37,6 +37,14 @@ function isMaestro(name?: string): boolean {
return (name ?? '').toUpperCase().includes('MAESTRO') 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 { function toService(c: Container): Service {
return { return {
id: c.id || c.name, id: c.id || c.name,
@@ -47,6 +55,7 @@ function toService(c: Container): Service {
image: c.image, image: c.image,
version: c.version || versionFromImage(c.image), version: c.version || versionFromImage(c.image),
hasUpdate: false, hasUpdate: false,
capabilities: c.capabilities ?? [],
} }
} }
@@ -76,7 +85,7 @@ export function mergeServices(containers: Container[], registry: RegistryItem[])
const map = new Map<string, Service>() const map = new Map<string, Service>()
for (const c of containers) { for (const c of containers) {
if (isMaestro(c.name)) continue if (isMaestro(c.name) || isHiddenContainer(c)) continue
const key = c.name.toLowerCase() const key = c.name.toLowerCase()
const next = toService(c) const next = toService(c)
const existing = map.get(key) const existing = map.get(key)
@@ -86,7 +95,7 @@ export function mergeServices(containers: Container[], registry: RegistryItem[])
} }
for (const r of registry) { for (const r of registry) {
if (isMaestro(r.name)) continue if (isMaestro(r.name) || isHiddenRegistry(r)) continue
const key = r.name.toLowerCase() const key = r.name.toLowerCase()
const existing = findMatch(map, key, r.image) const existing = findMatch(map, key, r.image)
@@ -122,6 +131,7 @@ export function mergeServices(containers: Container[], registry: RegistryItem[])
author: r.author, author: r.author,
latestVersion: r.version, latestVersion: r.version,
hasUpdate: false, hasUpdate: false,
capabilities: [],
}) })
} }
} }
+13
View File
@@ -38,6 +38,12 @@ export interface Container {
version?: string version?: string
status: string status: string
network?: NetworkConfig 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. */ /** A remote catalog entry, as served by GET /api/registry. */
@@ -56,6 +62,8 @@ export interface RegistryItem {
url?: string url?: string
documentation?: string documentation?: string
created_at?: 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. */ /** Merged view model: one card per service, local state and remote catalog fused. */
@@ -76,8 +84,13 @@ export interface Service {
version?: string version?: string
latestVersion?: string latestVersion?: string
hasUpdate: boolean 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 type ServiceFilter = 'all' | 'running' | 'updates' | 'available'
export interface FilterOption { export interface FilterOption {