[fix](maestro): pair services by declared identity and show web/source chips

Image-name comparison split on the first colon, so every image on a registry with a port looked like a different service (or the same one); split on the last colon before the tag and strip the registry host when present. Pair a catalog entry with a container by org.dcntrlized.service first, then by repository name, and never with a registry-only placeholder. The card gains the resolved Web app chip and renders a source link only when the server validated it, with the raw value inert otherwise.
This commit is contained in:
2026-10-19 02:40:26 +01:00
parent 4038334c28
commit b7fa29849d
7 changed files with 278 additions and 24 deletions
+1
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "bun test tests",
"preview": "vite preview"
},
"dependencies": {
@@ -39,6 +39,7 @@ function dbService(item: RegistryItem): Service {
image: item.image,
registryImage: item.image,
source: item.source,
sourceHref: item.source_href,
registryUrl: item.url,
latestVersion: item.version,
hasUpdate: false,
@@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { ExternalLink, Power, Trash2, ArrowUp, LoaderCircle, Copy, Download, SlidersHorizontal } from 'lucide-react'
import { ExternalLink, Power, Trash2, ArrowUp, LoaderCircle, Copy, Download, SlidersHorizontal, Globe } from 'lucide-react'
import { Button } from '@components/ui/button'
import { Card, CardContent, CardFooter } from '@components/ui/card'
import { statusMeta } from '@components/container/status'
@@ -25,6 +25,7 @@ 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 { sourceLink } from '@lib/domain/source-link'
import type { DatabaseState } from './service-grid'
interface ServiceCardProps {
@@ -47,9 +48,36 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error'
}
/**
* The "Web app" chip (contract rev 8.2 §16.5). `href` is resolved SERVER-SIDE
* and is the only thing this component may open — the console must never build
* a URL from service strings. The caller renders it only when `href` is
* non-empty, so a service without a usable web app shows no chip at all.
*/
function WebAppChip({ href, name }: { href: string; name: string }) {
return (
<a
data-slot="card-web"
href={href}
target="_blank"
rel="noopener noreferrer"
title={`Open the ${name} web app — ${href}`}
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border bg-muted/40 px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:border-border/90 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<Globe aria-hidden="true" className="size-3" />
Web app
<ExternalLink aria-hidden="true" className="size-3" />
</a>
)
}
export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
const meta = statusMeta(service.status)
const installed = service.origin !== 'remote'
// Externally managed containers (e.g. a repo's own debug compose) are not
// Maestro's to run: the console may stop them but must not start, uninstall,
// update or reconfigure them.
const managed = installed && !service.external
const running =
service.status.toLowerCase() === 'running' ||
service.status.toLowerCase() === 'starting' ||
@@ -60,6 +88,9 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
const shortContainerId = service.id.slice(0, 12)
const shortImage = service.image ? service.image.split('/').slice(-2).join('/') : service.name
// Registry `source` is only linked through its server-validated href; an
// unvalidated value renders as inert text (audit N7).
const source = sourceLink(service)
const copyId = useAsyncAction(async () => {
try {
@@ -193,6 +224,15 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
{latest}
</span>
)}
{service.external && (
<span
data-slot="card-external"
title="Started outside Maestro — stop it here, start and manage it from its own compose project"
className="inline-flex shrink-0 items-center rounded-md border border-border bg-muted/40 px-1.5 py-0.5 font-mono text-xs text-muted-foreground"
>
external
</span>
)}
{running && installed && (
<button
data-slot="card-container-id"
@@ -206,26 +246,37 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
<Copy aria-hidden="true" className="size-3" />
</button>
)}
{service.source && (
{source?.kind === 'link' ? (
<a
data-slot="card-source"
href={service.source}
href={source.href}
target="_blank"
rel="noreferrer"
title={`Source — ${service.source}`}
title={`Source — ${source.href}`}
className="ml-auto inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
Source
<ExternalLink aria-hidden="true" className="size-3" />
</a>
)}
) : source?.kind === 'text' ? (
<span
data-slot="card-source"
title={`Source (not a valid http(s) URL, shown as text) — ${source.text}`}
className="ml-auto max-w-48 truncate text-xs font-medium text-muted-foreground"
>
{source.text}
</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{service.capabilities.includes('database') && <DatabaseChip db={db} />}
{service.web?.href ? <WebAppChip href={service.web.href} name={service.name} /> : null}
</div>
</CardContent>
<CardFooter className="justify-end pt-4">
{installed && (
{managed && (
<Button
data-slot="card-uninstall"
variant="ghost"
@@ -239,7 +290,7 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
</Button>
)}
{installed && onOpenConfig && (
{managed && onOpenConfig && (
<Button
data-slot="card-config"
variant="ghost"
@@ -253,7 +304,7 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
</Button>
)}
{installed && service.hasUpdate && (
{managed && service.hasUpdate && (
<Button
data-slot="card-update-action"
size="sm"
@@ -277,7 +328,7 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
{stop.pending ? <LoaderCircle aria-hidden="true" className="size-3 animate-spin" /> : <Power aria-hidden="true" className="size-3" />}
Stop
</Button>
) : installed ? (
) : managed ? (
<Button
data-slot="card-start"
size="sm"
@@ -290,7 +341,7 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
{start.pending ? <LoaderCircle aria-hidden="true" className="size-3 animate-spin" /> : <Power aria-hidden="true" className="size-3" />}
Start
</Button>
) : (
) : installed ? null : (
<Button data-slot="card-install" className="" size="sm" isDisabled={busy} onPress={install.run}>
{install.pending ? <LoaderCircle aria-hidden="true" className="size-3 animate-spin" /> : <Download aria-hidden="true" className="size-3" />}
Install
+53 -8
View File
@@ -16,7 +16,7 @@
*/
import type { Container, RegistryItem, Service } from '../types'
import { baseImageOf, compareVersions, normalizeVersion, versionFromImage } from './version'
import { compareVersions, normalizeVersion, repoNameOf, versionFromImage } from './version'
/** Statuses that should win the merge when a name maps to several containers. */
const STATUS_PRIORITY: Record<string, number> = {
@@ -46,6 +46,25 @@ function isHiddenRegistry(r: RegistryItem): boolean {
return Boolean(r.hidden)
}
/** Statuses that count as up (mirrors the card's running predicate). */
function isRunning(status?: string): boolean {
const s = (status ?? '').toLowerCase()
return s === 'running' || s === 'starting' || s === 'restarting'
}
/**
* Containers Maestro did not install are transient dev processes, not services:
* a stopped one has no actions left (no start, no uninstall, no config), so it
* does not deserve a card. The grid shows only services Maestro knows are
* installed (an install record in the data dir) or that are still up, plus the
* remote catalog. Dropping these *before* the registry merge matters: the
* catalog entry then stays unmatched and comes back as an installable remote
* card instead of being absorbed by a dead container.
*/
function isStoppedExternal(c: Container): boolean {
return Boolean(c.external) && !isRunning(c.status)
}
function toService(c: Container): Service {
return {
id: c.id || c.name,
@@ -53,27 +72,45 @@ function toService(c: Container): Service {
status: c.status,
origin: 'local',
title: c.service_name || c.name,
serviceName: c.service_name,
external: c.external,
image: c.image,
version: c.version || versionFromImage(c.image),
hasUpdate: false,
capabilities: c.capabilities ?? [],
config: c.config,
web: c.web,
}
}
function findMatch(
local: Service[],
map: Map<string, Service>,
name: string,
registryImage: string
registryImage: string,
registryService?: string
): Service | undefined {
const byName = map.get(name)
if (byName) return byName
const base = baseImageOf(registryImage)
if (!base) return undefined
// The declared identity (org.dcntrlized.service) is the contract: pair the
// catalog entry with the container that declares the same service.
if (registryService) {
const target = registryService.toLowerCase()
for (const item of local) {
if ((item.serviceName ?? '').toLowerCase() === target) return item
}
}
for (const item of map.values()) {
if (baseImageOf(item.image) === base) return item
const repo = repoNameOf(registryImage)
if (!repo) return undefined
// Only container-backed services are candidates. Registry-only placeholders
// are deliberately excluded: matching against them let one catalog entry
// absorb another (two images on the same registry compared equal), so one
// service's metadata ended up rendered under another service's image.
for (const item of local) {
if (repoNameOf(item.image) === repo) return item
}
return undefined
}
@@ -87,7 +124,7 @@ export function mergeServices(containers: Container[], registry: RegistryItem[])
const map = new Map<string, Service>()
for (const c of containers) {
if (isMaestro(c.name) || isHiddenContainer(c)) continue
if (isMaestro(c.name) || isHiddenContainer(c) || isStoppedExternal(c)) continue
const key = c.name.toLowerCase()
const next = toService(c)
const existing = map.get(key)
@@ -96,17 +133,23 @@ export function mergeServices(containers: Container[], registry: RegistryItem[])
}
}
// Snapshot the container-backed services before any registry item is
// inserted, so registry items can only ever pair up with real containers.
const local = Array.from(map.values())
for (const r of registry) {
if (isMaestro(r.name) || isHiddenRegistry(r)) continue
const key = r.name.toLowerCase()
const existing = findMatch(map, key, r.image)
const existing = findMatch(local, map, key, r.image, r.service_name)
if (existing) {
existing.origin = 'both'
existing.registryImage = r.image
existing.registryUrl = r.url || r.source
existing.source = r.source || existing.source
existing.sourceHref = r.source_href || existing.sourceHref
existing.title = r.title || existing.title
existing.serviceName = r.service_name || existing.serviceName
existing.vendor = r.vendor || existing.vendor
existing.author = r.author || existing.author
existing.description = r.description || existing.description
@@ -124,9 +167,11 @@ export function mergeServices(containers: Container[], registry: RegistryItem[])
status: 'uninstalled',
origin: 'remote',
title: r.title || r.name,
serviceName: r.service_name,
image: r.image,
registryImage: r.image,
source: r.source,
sourceHref: r.source_href,
registryUrl: r.url,
description: r.description,
vendor: r.vendor,
@@ -0,0 +1,42 @@
/**
* 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/>.
*/
/**
* How a service card should render an image's `source`.
*
* `source` comes verbatim from the image's OCI annotations, so it is
* attacker-influenced for anyone who can push to the operator's registry.
* `sourceHref` is the server's allowlisted projection of it (the Go side runs
* `types.ValidWebBase` — http/https exactly, no userinfo, no control chars,
* non-empty host). The frontend therefore never builds an href from `source`:
* a value without a validated href is shown as inert text (audit finding N7).
*/
export type SourceLink =
| { kind: 'link'; href: string; text: string }
| { kind: 'text'; text: string }
| null
export function sourceLink(item: {
source?: string
sourceHref?: string
}): SourceLink {
const href = item.sourceHref?.trim() ?? ''
const text = item.source?.trim() ?? ''
if (href) return { kind: 'link', href, text: text || href }
if (text) return { kind: 'text', text }
return null
}
+38 -5
View File
@@ -23,16 +23,49 @@
* Everything here normalizes to a comparable X.Y.Z[-pre] string.
*/
/** Base image reference without registry host, tag, or digest. */
/**
* Repository path of an image reference: registry host, tag and digest removed
* (e.g. `192.168.122.183:3000/dcntrlized/keepnote:1` → `dcntrlized/keepnote`).
*
* The tag separator is the LAST colon — a colon before the last slash is part
* of a registry host with a port, not a tag. Splitting naively on the first
* colon would reduce every image on such a registry to the bare host and make
* unrelated services compare equal.
*/
export function baseImageOf(imageRef?: string): string {
if (!imageRef) return ''
return imageRef.split('@')[0].split(':')[0].toLowerCase()
const noDigest = imageRef.split('@')[0]
const slash = noDigest.lastIndexOf('/')
const colon = noDigest.lastIndexOf(':')
const repo = colon > slash ? noDigest.slice(0, colon) : noDigest
const parts = repo.split('/')
const host = parts[0]
if (parts.length > 1 && (host.includes('.') || host.includes(':') || host === 'localhost')) {
parts.shift()
}
return parts.join('/').toLowerCase()
}
/** The tag of an image reference, "" when it is `latest` or absent. */
/**
* Repository name (final path segment) of an image reference
* (e.g. `…/dcntrlized/keepnote:1` → `keepnote`). Used to pair a locally built
* image (`calendar:debug`) with its catalog entry regardless of registry path.
*/
export function repoNameOf(imageRef?: string): string {
const base = baseImageOf(imageRef)
if (!base) return ''
const slash = base.lastIndexOf('/')
return slash >= 0 ? base.slice(slash + 1) : base
}
/** The tag of an image reference, "" when untagged, digest-pinned, or `latest`. */
export function versionFromImage(imageRef?: string): string {
if (!imageRef) return ''
const tag = imageRef.split('@')[0].split(':').pop() ?? ''
if (!imageRef || imageRef.includes('@')) return ''
const slash = imageRef.lastIndexOf('/')
const colon = imageRef.lastIndexOf(':')
if (colon <= slash) return ''
const tag = imageRef.slice(colon + 1)
return tag === 'latest' ? '' : tag
}
+81
View File
@@ -0,0 +1,81 @@
/**
* 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 { expect, test } from 'bun:test'
import { sourceLink } from '../src/lib/domain/source-link'
// Audit finding N7: `source` arrives verbatim from the image's OCI
// annotations, so the console must never turn it into an href on trust alone.
// The href can only come from the server's allowlisted `source_href`.
test('a validated http(s) source renders as a link with the server href', () => {
const link = sourceLink({
source: 'https://github.com/Dcntrlized/Maestro',
sourceHref: 'https://github.com/Dcntrlized/Maestro',
})
expect(link).toEqual({
kind: 'link',
href: 'https://github.com/Dcntrlized/Maestro',
text: 'https://github.com/Dcntrlized/Maestro',
})
})
test('an invalid source never becomes an href — it renders as plain text', () => {
const hostile = [
'javascript:alert(1)',
'JAVASCRIPT:alert(1)',
'data:text/html,<script>alert(1)</script>',
'vbscript:msgbox(1)',
'file:///etc/passwd',
'https://user:[email protected]/repo',
'github.com/Dcntrlized/Maestro',
'//example.com/repo',
'/repo',
'https://example.com/re po',
'https://example.com/repo\njavascript:alert(1)',
'ftp://example.com/repo',
]
for (const source of hostile) {
const link = sourceLink({ source })
expect(link).toEqual({ kind: 'text', text: source })
expect(link && 'href' in link).toBe(false)
}
})
test('an empty href is ignored even when the server sends one as whitespace', () => {
expect(sourceLink({ source: 'javascript:alert(1)', sourceHref: ' ' })).toEqual({
kind: 'text',
text: 'javascript:alert(1)',
})
expect(sourceLink({ source: 'javascript:alert(1)', sourceHref: '' })).toEqual({
kind: 'text',
text: 'javascript:alert(1)',
})
})
test('no source renders nothing', () => {
expect(sourceLink({})).toBeNull()
expect(sourceLink({ source: ' ' })).toBeNull()
})
test('a link without a display source still gets a title', () => {
expect(sourceLink({ sourceHref: 'https://example.com/repo' })).toEqual({
kind: 'link',
href: 'https://example.com/repo',
text: 'https://example.com/repo',
})
})