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 (
+
+
+ Database
+
+ {meta.ping && (
+
+
+
+
+ )}
+ {meta.label}
+
+
+ {db.status === 'uninstalled' && db.item && (
+
+ )}
+
+ )
+}
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 {