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 { 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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user