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:
@@ -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 ? (
|
||||
<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 { 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) {
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{service.capabilities.includes('database') && <DatabaseChip db={db} />}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="justify-end pt-4">
|
||||
|
||||
@@ -15,18 +15,24 @@
|
||||
* 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'
|
||||
|
||||
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 (
|
||||
<div data-slot="service-grid" className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{services.map((service) => (
|
||||
<ServiceCard key={service.id} service={service} />
|
||||
<ServiceCard key={service.id} service={service} db={db} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<string, Service>()
|
||||
|
||||
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: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user