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