Files
oikos/web/src/lib/components/FleetMap.svelte
dtoro 757ef2f34b
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
feat(web): adopt cyberspace terminal aesthetic + dithered images
Rebrand light/dark themes to the cyberspace.online look: warm cream-on-black
palette (light/dark are exact inverses), self-hosted JetBrains Mono + VT323,
square corners, border-driven surfaces with no soft shadows. Adds a terminal
design-system CSS layer (DOS double-border modals with hatched corner, fg focus,
inversion-on-hover), a theme-aware <RasterImage> (Atkinson-dithered canvas with
img fallback), and unifies desktop icons, taskbar, window controls, pills and
links under one idiom. Pins window titlebars to a fixed height and switches chat
auto-scroll off scrollIntoView to avoid titlebar reflow.

VERSION 0.15.1 -> 0.16.0
2026-08-03 22:03:24 +02:00

1312 lines
41 KiB
Svelte

<script lang="ts" module>
export type Health = 'healthy' | 'degraded' | 'down' | 'unknown'
export interface FleetMapProps {
// Shared with the entity table — highlights matching nodes, dims the rest.
search?: string
// The entity currently open in a floating window, mirrored as a ring.
selectedSlug?: string | null
onSelect: (slug: string | null) => void
}
</script>
<script lang="ts">
import { onMount, onDestroy, untrack } from 'svelte'
import { fetchGraph, type GraphView, type Entity } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { isHealthEvent, applyHealthEvent, healthFromEvent } from '$lib/health'
import { Skeleton } from '$lib/components/ui/skeleton'
import { Button } from '$lib/components/ui/button'
import GlobeIcon from '@lucide/svelte/icons/globe'
import LockIcon from '@lucide/svelte/icons/lock'
import LockOpenIcon from '@lucide/svelte/icons/lock-open'
import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert'
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'
let { search = '', selectedSlug = null, onSelect }: FleetMapProps = $props()
// Health palette matches the rest of the app's graph/status surfaces.
const HEALTH_COLOR: Record<Health, string> = {
healthy: '#3fb950',
degraded: '#d29922',
down: '#f85149',
unknown: '#8b949e'
}
const HEALTH_LABEL: Record<Health, string> = {
healthy: 'Healthy',
degraded: 'Degraded',
down: 'Down',
unknown: 'Unknown'
}
const HEALTH_ORDER: Health[] = ['healthy', 'degraded', 'down', 'unknown']
// ─── data ──────────────────────────────────────────────────────────────
let graph = $state<GraphView | null>(null)
let loading = $state(true)
async function load() {
loading = true
// No root = the whole fleet (server caps at the 500 most-connected
// entities, well above our ~170). include=status attaches live health.
graph = await fetchGraph({ includeStatus: true })
loading = false
}
onMount(() => {
load()
return subscribeEvents()
})
// Structural changes need a refetch — they change which nodes and edges
// exist. Health does not: the event carries the new value, so the node is
// patched in place instead of pulling the entire fleet graph (and its
// layout) down again for one colour change.
// untrack: this effect must depend ONLY on liveEvents. It both reads and
// writes `graph`, and the health patch builds a new object every time — so
// tracking that read made each write re-trigger the effect, which wrote
// again, until Svelte aborted with effect_update_depth_exceeded.
$effect(() => {
const ev = $liveEvents[0]
if (!ev) return
untrack(() => {
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.')) {
load()
return
}
if (isHealthEvent(ev) && graph) {
const health = healthFromEvent(ev)
if (!health || !ev.entity_id) return
// healthOf() reads graph.health[id] in preference to the node's own
// field (include=status attaches it as a side map), so patching only
// the nodes would leave the rendered colour unchanged. Patch both.
const nodes = applyHealthEvent(graph.nodes, ev)
graph = {
...graph,
nodes,
health: { ...(graph.health ?? {}), [ev.entity_id]: health as Health }
}
}
})
})
// ─── interaction state ─────────────────────────────────────────────────
// Highlight + detail follow the *hover* — a click is reserved for opening
// the entity window, so pointing is enough to trace a chain. A short grace
// timer keeps the focus alive while the cursor crosses into the detail
// panel, so its links stay clickable instead of vanishing on mouse-out.
let focus = $state<string | null>(null)
let problemMode = $state(false)
let healthFilter = $state<Health | null>(null)
let clearTimer: ReturnType<typeof setTimeout> | null = null
function setFocus(slug: string) {
if (clearTimer) {
clearTimeout(clearTimer)
clearTimer = null
}
focus = slug
}
function keepFocus() {
if (clearTimer) {
clearTimeout(clearTimer)
clearTimer = null
}
}
function scheduleClear() {
if (clearTimer) clearTimeout(clearTimer)
clearTimer = setTimeout(() => {
focus = null
clearTimer = null
}, 90)
}
function clearFocusNow() {
if (clearTimer) {
clearTimeout(clearTimer)
clearTimer = null
}
focus = null
}
onDestroy(() => {
if (clearTimer) clearTimeout(clearTimer)
})
// ─── helpers ───────────────────────────────────────────────────────────
function attr(n: Entity | undefined, key: string): string | undefined {
const v = n?.attributes?.[key]
return typeof v === 'string' ? v : typeof v === 'number' ? String(v) : undefined
}
function boolAttr(n: Entity | undefined, key: string): boolean | undefined {
const v = n?.attributes?.[key]
return typeof v === 'boolean' ? v : undefined
}
function healthOf(n: Entity): Health {
const raw = graph?.health?.[n.id] ?? n.health ?? 'unknown'
if (raw === 'stale') return 'degraded'
return (HEALTH_ORDER as string[]).includes(raw) ? (raw as Health) : 'unknown'
}
function active(n: Entity): boolean {
return !n.state || n.state === 'active'
}
// ─── layout constants ──────────────────────────────────────────────────
const LANE_X = [16, 214, 470]
const LANE_W = [150, 176, 214]
const ROW_H = 52
const NODE_H = 42
const TOP0 = 40
const BOT_PAD = 40
type Kind = 'host' | 'container' | 'service'
interface LaidNode {
slug: string
id: string
name: string
type: string
kind: Kind
health: Health
lane: number
x: number
y: number
w: number
h: number
meta: string
publicHost?: string
fauth?: boolean
role?: string
ip?: string
url?: string
provider?: string
deps: string[]
mounts: string[]
repo?: string
}
interface LaidEdge {
d: string
kind: 'prov' | 'dep'
s: string
t: string
}
interface Model {
nodes: LaidNode[]
bySlug: Map<string, LaidNode>
edges: LaidEdge[]
width: number
height: number
counts: Record<Health, number>
providesMap: Map<string, string[]>
providedBy: Map<string, string>
dependents: Map<string, string[]>
depsOf: Map<string, string[]>
}
const model = $derived.by<Model | null>(() => {
const g = graph
if (!g) return null
const bySlugEntity = new Map<string, Entity>()
for (const n of g.nodes) if (active(n)) bySlugEntity.set(n.slug, n)
const typeOf = (slug: string) => bySlugEntity.get(slug)?.type
const isContainer = (slug: string) => {
const t = typeOf(slug)
return t === 'lxc' || t === 'vm'
}
const isService = (slug: string) => typeOf(slug) === 'service'
// relationship-derived maps
const containerHost = new Map<string, string>() // container -> host
const serviceProvider = new Map<string, string>() // service -> provider (host or container)
const depsOf = new Map<string, string[]>() // service -> [service]
const dependents = new Map<string, string[]>() // service -> [dependent services]
const routesTo = new Map<string, string>() // service -> ingress slug
const repoOf = new Map<string, string>() // service -> repo slug
const mountsOf = new Map<string, string[]>() // container -> [volume slug]
const hostCandidates = new Set<string>()
const pushMap = (m: Map<string, string[]>, k: string, v: string) => {
const a = m.get(k)
if (a) a.push(v)
else m.set(k, [v])
}
for (const e of g.edges) {
switch (e.type) {
case 'hosts':
if (bySlugEntity.has(e.source) && bySlugEntity.has(e.target)) {
containerHost.set(e.target, e.source)
hostCandidates.add(e.source)
}
break
case 'provides':
if (bySlugEntity.has(e.source) && bySlugEntity.has(e.target)) {
serviceProvider.set(e.target, e.source)
if (!isContainer(e.source) && !isService(e.source)) hostCandidates.add(e.source)
}
break
case 'depends-on':
if (bySlugEntity.has(e.source) && bySlugEntity.has(e.target)) {
pushMap(depsOf, e.source, e.target)
pushMap(dependents, e.target, e.source)
}
break
case 'routes-to':
if (bySlugEntity.has(e.target)) routesTo.set(e.target, e.source)
break
case 'configured-by':
if (bySlugEntity.has(e.source)) repoOf.set(e.source, e.target)
break
case 'mounts':
if (bySlugEntity.has(e.source)) pushMap(mountsOf, e.source, e.target)
break
}
}
// lane membership
const services = g.nodes.filter((n) => active(n) && n.type === 'service')
const containers = g.nodes.filter((n) => active(n) && (n.type === 'lxc' || n.type === 'vm'))
// A host is any provider/hoster that isn't itself a container or service,
// and that actually anchors at least one child in the map.
const hosts = g.nodes.filter(
(n) => active(n) && hostCandidates.has(n.slug) && !isContainer(n.slug) && !isService(n.slug)
)
// ── ordering ──
// Hosts: biggest subtree first (most infrastructure hangs off it).
const childCount = (slug: string) =>
containers.filter((c) => containerHost.get(c.slug) === slug).length +
services.filter((s) => serviceProvider.get(s.slug) === slug).length
hosts.sort((a, b) => childCount(b.slug) - childCount(a.slug) || a.name.localeCompare(b.name))
const hostRank = new Map(hosts.map((h, i) => [h.slug, i]))
// Containers: grouped by host order, alphabetical within a host.
containers.sort((a, b) => {
const ra = hostRank.get(containerHost.get(a.slug) ?? '') ?? 999
const rb = hostRank.get(containerHost.get(b.slug) ?? '') ?? 999
return ra - rb || a.name.localeCompare(b.name)
})
// ── vertical positions ──
const yById = new Map<string, number>()
containers.forEach((c, i) => yById.set(c.slug, TOP0 + i * ROW_H))
// Host y = mean of its containers (empty hosts handled after services).
for (const h of hosts) {
const kids = containers.filter((c) => containerHost.get(c.slug) === h.slug)
if (kids.length) {
yById.set(h.slug, kids.reduce((s, c) => s + (yById.get(c.slug) ?? 0), 0) / kids.length)
}
}
// Services: seed y from provider, sort, then de-overlap preserving order.
const svcRaw = new Map<string, number>()
for (const s of services) {
const p = serviceProvider.get(s.slug)
svcRaw.set(s.slug, p && yById.has(p) ? yById.get(p)! : TOP0)
}
services.sort(
(a, b) => svcRaw.get(a.slug)! - svcRaw.get(b.slug)! || a.name.localeCompare(b.name)
)
let prevY = TOP0 - ROW_H
for (const s of services) {
const y = Math.max(svcRaw.get(s.slug)!, prevY + ROW_H)
yById.set(s.slug, y)
prevY = y
}
// Hosts with no containers: center on the services they provide directly.
for (const h of hosts) {
if (yById.has(h.slug)) continue
const provided = services.filter((s) => serviceProvider.get(s.slug) === h.slug)
yById.set(
h.slug,
provided.length
? provided.reduce((s, x) => s + (yById.get(x.slug) ?? TOP0), 0) / provided.length
: TOP0
)
}
// ── build laid nodes ──
const laid: LaidNode[] = []
const bySlug = new Map<string, LaidNode>()
const counts: Record<Health, number> = { healthy: 0, degraded: 0, down: 0, unknown: 0 }
const makeNode = (n: Entity, kind: Kind, lane: number): LaidNode => {
const h = healthOf(n)
counts[h]++
const ingress = routesTo.get(n.slug)
const ingressEntity = ingress ? bySlugEntity.get(ingress) : undefined
const publicHost = ingress
? (ingressEntity?.name ?? ingress.replace(/^ingress:/, ''))
: undefined
const fauth = ingressEntity ? boolAttr(ingressEntity, 'forward_auth') : undefined
const provider = kind === 'service' ? serviceProvider.get(n.slug) : containerHost.get(n.slug)
const mnt = (kind === 'container' ? mountsOf.get(n.slug) : mountsOf.get(provider ?? '')) ?? []
const role = attr(n, 'role')
const ip = attr(n, 'lan_ip') ?? attr(n, 'public_ipv4')
const url = attr(n, 'url')
let meta: string
if (kind === 'container') {
const pve = attr(n, 'pve_id')
meta = `${n.type.toUpperCase()}${pve ? ' ' + pve : ''}${role ? ' · ' + role : ''}`
} else if (kind === 'service') {
meta = publicHost ?? role ?? n.slug
} else {
meta = `${n.type}${ip ? ' · ' + ip : ''}`
}
const node: LaidNode = {
slug: n.slug,
id: n.id,
name: n.name,
type: n.type,
kind,
health: h,
lane,
x: LANE_X[lane],
y: yById.get(n.slug) ?? TOP0,
w: LANE_W[lane],
h: NODE_H,
meta,
publicHost,
fauth,
role,
ip,
url,
provider,
deps: depsOf.get(n.slug) ?? [],
mounts: mnt,
repo: repoOf.get(n.slug)
}
laid.push(node)
bySlug.set(node.slug, node)
return node
}
hosts.forEach((n) => makeNode(n, 'host', 0))
containers.forEach((n) => makeNode(n, 'container', 1))
services.forEach((n) => makeNode(n, 'service', 2))
// ── edges ──
const cx = (slug: string, side: 'l' | 'r') => {
const n = bySlug.get(slug)!
return LANE_X[n.lane] + (side === 'r' ? LANE_W[n.lane] : 0)
}
const cy = (slug: string) => bySlug.get(slug)!.y + NODE_H / 2
const edges: LaidEdge[] = []
const provChildren = new Map<string, string[]>() // provider -> [child]
// provision edges: host->container, provider->service
for (const c of containers) {
const host = containerHost.get(c.slug)
if (host && bySlug.has(host)) {
const x1 = cx(host, 'r'),
y1 = cy(host),
x2 = cx(c.slug, 'l'),
y2 = cy(c.slug)
const mx = (x1 + x2) / 2
edges.push({
d: `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`,
kind: 'prov',
s: host,
t: c.slug
})
pushMap(provChildren, host, c.slug)
}
}
for (const s of services) {
const p = serviceProvider.get(s.slug)
if (p && bySlug.has(p)) {
const x1 = cx(p, 'r'),
y1 = cy(p),
x2 = cx(s.slug, 'l'),
y2 = cy(s.slug)
const mx = (x1 + x2) / 2
edges.push({
d: `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`,
kind: 'prov',
s: p,
t: s.slug
})
pushMap(provChildren, p, s.slug)
}
}
// dependency arcs bulge to the right of the services lane
for (const [srcSlug, targets] of depsOf) {
if (!bySlug.has(srcSlug)) continue
for (const t of targets) {
if (!bySlug.has(t)) continue
const x1 = cx(srcSlug, 'r'),
y1 = cy(srcSlug),
x2 = cx(t, 'r'),
y2 = cy(t)
const bulge = Math.max(x1, x2) + 34 + Math.min(70, Math.abs(y1 - y2) * 0.32)
edges.push({
d: `M${x1},${y1} C${bulge},${y1} ${bulge},${y2} ${x2},${y2}`,
kind: 'dep',
s: srcSlug,
t
})
}
}
const height =
Math.max(
containers.length ? (yById.get(containers[containers.length - 1].slug) ?? 0) + ROW_H : 0,
services.length ? (yById.get(services[services.length - 1].slug) ?? 0) + ROW_H : 0,
TOP0 + ROW_H
) + BOT_PAD
const width = LANE_X[2] + LANE_W[2] + 84
return {
nodes: laid,
bySlug,
edges,
width,
height,
counts,
providesMap: provChildren,
providedBy: new Map(
[...containerHost.entries(), ...serviceProvider.entries()].filter(([, p]) => bySlug.has(p))
),
dependents,
depsOf
}
})
// ─── graph traversals ──────────────────────────────────────────────────
function servicesUnder(m: Model, id: string): Set<string> {
const out = new Set<string>()
const stack = [id]
while (stack.length) {
const cur = stack.pop()!
for (const t of m.providesMap.get(cur) ?? []) {
if (m.bySlug.get(t)?.kind === 'service') out.add(t)
stack.push(t)
}
}
return out
}
function transitiveDependents(m: Model, seed: Iterable<string>): Set<string> {
const out = new Set<string>()
const stack = [...seed]
while (stack.length) {
const cur = stack.pop()!
for (const d of m.dependents.get(cur) ?? []) {
if (!out.has(d)) {
out.add(d)
stack.push(d)
}
}
}
return out
}
function blastRadius(m: Model, id: string): Set<string> {
const node = m.bySlug.get(id)
const direct = node?.kind === 'service' ? new Set([id]) : servicesUnder(m, id)
const set = new Set<string>([...direct, ...transitiveDependents(m, direct)])
if (node?.kind === 'service') set.delete(id)
return set
}
function upstream(m: Model, id: string): string[] {
const out: string[] = []
let up = m.providedBy.get(id)
let guard = 0
while (up && guard++ < 8) {
out.push(up)
up = m.providedBy.get(up)
}
return out
}
function chainOf(m: Model, id: string): Set<string> {
const set = new Set<string>([id])
for (const u of upstream(m, id)) set.add(u)
const n = m.bySlug.get(id)
if (n) for (const d of n.deps) set.add(d)
for (const s of blastRadius(m, id)) {
set.add(s)
const p = m.providedBy.get(s)
if (p) set.add(p)
}
return set
}
function problemSet(m: Model): Set<string> {
const set = new Set<string>()
for (const n of m.nodes) {
if (n.health === 'healthy') continue
set.add(n.slug)
for (const u of upstream(m, n.slug)) set.add(u)
for (const s of blastRadius(m, n.slug)) {
set.add(s)
const p = m.providedBy.get(s)
if (p) set.add(p)
}
}
return set
}
function healthSet(m: Model, h: Health): Set<string> {
const set = new Set<string>()
for (const n of m.nodes) {
if (n.health !== h) continue
set.add(n.slug)
for (const u of upstream(m, n.slug)) set.add(u)
}
return set
}
// ─── derived highlight + detail ────────────────────────────────────────
const activeSet = $derived.by<Set<string> | null>(() => {
const m = model
if (!m) return null
if (focus && m.bySlug.has(focus)) return chainOf(m, focus)
if (problemMode) return problemSet(m)
if (healthFilter) return healthSet(m, healthFilter)
const q = search.trim().toLowerCase()
if (q) {
const s = new Set<string>()
for (const n of m.nodes)
if (n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)) s.add(n.slug)
return s
}
return null
})
function nodeState(slug: string): '' | 'dim' | 'hl' | 'sel' {
if (focus === slug) return 'sel'
const s = activeSet
if (!s) return ''
return s.has(slug) ? 'hl' : 'dim'
}
function edgeState(e: LaidEdge): '' | 'dim' | 'hl' {
const s = activeSet
if (!s) return ''
return s.has(e.s) && s.has(e.t) ? 'hl' : 'dim'
}
// ─── actions ───────────────────────────────────────────────────────────
// A click opens the entity in a floating window; hover already handled the
// highlight, so clicking never has to double as "select".
function openNode(slug: string) {
onSelect(slug)
}
function reset() {
clearFocusNow()
problemMode = false
healthFilter = null
onSelect(null)
}
function toggleProblems() {
clearFocusNow()
healthFilter = null
problemMode = !problemMode
}
function toggleHealth(h: Health) {
clearFocusNow()
problemMode = false
healthFilter = healthFilter === h ? null : h
}
// Detail-panel target derived from mode.
const detailNode = $derived(focus && model?.bySlug.get(focus) ? model!.bySlug.get(focus)! : null)
const detailBlast = $derived.by(() => {
const m = model
if (!m || !detailNode) return [] as LaidNode[]
return [...blastRadius(m, detailNode.slug)]
.map((s) => m.bySlug.get(s))
.filter((n): n is LaidNode => !!n)
})
const detailRunsOn = $derived.by(() => {
const m = model
if (!m || !detailNode) return [] as LaidNode[]
return upstream(m, detailNode.slug)
.map((s) => m.bySlug.get(s))
.filter((n): n is LaidNode => !!n)
})
const detailProvides = $derived.by(() => {
const m = model
if (!m || !detailNode) return [] as LaidNode[]
return (m.providesMap.get(detailNode.slug) ?? [])
.map((s) => m.bySlug.get(s))
.filter((n): n is LaidNode => !!n)
})
const detailDeps = $derived.by(() => {
const m = model
if (!m || !detailNode) return [] as LaidNode[]
return detailNode.deps.map((s) => m.bySlug.get(s)).filter((n): n is LaidNode => !!n)
})
const problemGroups = $derived.by(() => {
const m = model
if (!m) return [] as { h: Health; items: LaidNode[] }[]
return (['down', 'degraded', 'unknown'] as Health[])
.map((h) => ({ h, items: m.nodes.filter((n) => n.health === h) }))
.filter((g) => g.items.length)
})
const healthItems = $derived.by(() => {
const m = model
if (!m || !healthFilter) return [] as LaidNode[]
return m.nodes.filter((n) => n.health === healthFilter)
})
const problemCount = $derived(
model ? model.nodes.filter((n) => n.health !== 'healthy').length : 0
)
</script>
{#if loading && !model}
<Skeleton class="h-full min-h-0" />
{:else if model}
<div class="fleet flex h-full min-h-0 flex-col overflow-hidden rounded-lg border">
<!-- toolbar: health chips + problem/reset -->
<div class="flex shrink-0 flex-wrap items-center gap-2 border-b bg-card/60 px-3 py-2">
{#each HEALTH_ORDER as h}
{#if model.counts[h]}
<button
class="chip"
class:on={healthFilter === h}
onclick={() => toggleHealth(h)}
type="button"
>
<span class="dot" style="background:{HEALTH_COLOR[h]}"></span>
<b>{model.counts[h]}</b>
{HEALTH_LABEL[h].toLowerCase()}
</button>
{/if}
{/each}
<div class="ml-auto flex items-center gap-2">
<Button
variant={problemMode ? 'default' : 'outline'}
size="sm"
class="h-7 text-xs"
onclick={toggleProblems}
>
<AlertTriangleIcon class="mr-1 size-3.5" />
Show problems
</Button>
{#if problemMode || healthFilter}
<Button variant="ghost" size="sm" class="h-7 text-xs" onclick={reset}>
<RotateCcwIcon class="mr-1 size-3.5" />
Reset
</Button>
{/if}
</div>
</div>
<div class="flex min-h-0 flex-1">
<!-- stage -->
<div class="stage relative min-w-0 flex-1 overflow-auto">
<div class="relative" style="width:{model.width}px;height:{model.height}px">
<!-- background click target: clears selection -->
<button type="button" class="reset-layer" aria-label="Clear selection" onclick={reset}
></button>
<!-- lane headers -->
<div class="pointer-events-none absolute inset-x-0 top-0 z-10">
{#each [['Hosts', model.nodes.filter((n) => n.kind === 'host').length], ['Containers', model.nodes.filter((n) => n.kind === 'container').length], ['Services', model.nodes.filter((n) => n.kind === 'service').length]] as [label, count], i}
<div class="lane-head" style="left:{LANE_X[i]}px;width:{LANE_W[i]}px">
{label} · <b>{count}</b>
</div>
{/each}
</div>
<!-- edges -->
<svg
class="pointer-events-none absolute inset-0"
width={model.width}
height={model.height}
>
{#each model.edges as e}
<path d={e.d} class="edge {e.kind} {edgeState(e)}" />
{/each}
</svg>
<!-- nodes -->
{#each model.nodes as n (n.slug)}
<button
type="button"
class="node {nodeState(n.slug)}"
class:svc={n.kind === 'service'}
class:is-open={selectedSlug === n.slug}
class:pulse={n.health === 'down'}
style="left:{n.x}px;top:{n.y}px;width:{n.w}px;height:{n.h}px;--nc:{HEALTH_COLOR[
n.health
]}"
onmouseenter={() => setFocus(n.slug)}
onmouseleave={scheduleClear}
onfocus={() => setFocus(n.slug)}
onblur={scheduleClear}
onclick={(ev) => {
ev.stopPropagation()
openNode(n.slug)
}}
title={n.slug}
>
{#if n.publicHost}
<span
class="badge"
title={n.publicHost +
(n.fauth ? ' · forward-auth' : n.fauth === false ? ' · no auth gate' : '')}
>
{#if n.fauth}
<LockIcon class="size-3" />
{:else if n.fauth === false}
<LockOpenIcon class="size-3" />
{:else}
<GlobeIcon class="size-3" />
{/if}
</span>
{/if}
<span class="nm">{n.name}</span>
<span class="meta">{n.meta}</span>
</button>
{/each}
</div>
</div>
<!-- detail panel -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="panel shrink-0 overflow-auto border-l bg-card/40"
onmouseenter={keepFocus}
onmouseleave={scheduleClear}
>
{#if !detailNode && problemMode}
<div class="p-4">
<div class="det-kind">status</div>
<div class="det-name">Problems</div>
<div class="blast">
<div class="n warn">{problemCount}</div>
<div class="lbl">{problemCount === 1 ? 'entity' : 'entities'} not healthy</div>
</div>
{#each problemGroups as g}
<div class="det-sec">
<h4>{HEALTH_LABEL[g.h]} · {g.items.length}</h4>
{#each g.items as it}
<button type="button" class="link" onclick={() => openNode(it.slug)}>
<span class="d" style="background:{HEALTH_COLOR[it.health]}"></span>
<span class="t">{it.name}</span>
<span class="sub">{it.kind}</span>
</button>
{/each}
</div>
{/each}
</div>
{:else if !detailNode && healthFilter}
<div class="p-4">
<div class="det-kind">status</div>
<div class="det-name">{HEALTH_LABEL[healthFilter]}</div>
<div class="blast">
<div class="n">{healthItems.length}</div>
<div class="lbl">{healthItems.length === 1 ? 'entity' : 'entities'}</div>
</div>
<div class="det-sec">
<h4>{HEALTH_LABEL[healthFilter]}</h4>
{#each healthItems as it}
<button type="button" class="link" onclick={() => openNode(it.slug)}>
<span class="d" style="background:{HEALTH_COLOR[it.health]}"></span>
<span class="t">{it.name}</span>
<span class="sub">{it.kind}</span>
</button>
{/each}
</div>
</div>
{:else if detailNode}
<div class="p-4">
<div class="det-kind">{detailNode.slug}</div>
<div class="det-name">{detailNode.name}</div>
<span class="det-health" style="--hc:{HEALTH_COLOR[detailNode.health]}">
<span class="d"></span>{HEALTH_LABEL[detailNode.health]}
</span>
<div class="blast">
<div class="n" class:warn={detailBlast.length > 0}>{detailBlast.length}</div>
<div class="lbl">
{detailNode.kind === 'service'
? 'downstream service'
: 'service'}{detailBlast.length === 1 ? '' : 's'}
affected if this goes down
</div>
</div>
<div class="det-sec">
<h4>Attributes</h4>
{#if detailNode.kind === 'container'}
<div class="row">
<span class="k">type</span><span class="v">{detailNode.type}</span>
</div>
{/if}
{#if detailNode.role}<div class="row">
<span class="k">role</span><span class="v">{detailNode.role}</span>
</div>{/if}
{#if detailNode.ip}<div class="row">
<span class="k">ip</span><span class="v">{detailNode.ip}</span>
</div>{/if}
{#if detailNode.publicHost}
<div class="row">
<span class="k">url</span><span class="v">{detailNode.publicHost}</span>
</div>
<div class="row">
<span class="k">exposure</span><span class="v"
>{detailNode.fauth
? 'forward-auth gated'
: detailNode.fauth === false
? 'no auth gate'
: 'public'}</span
>
</div>
{:else if detailNode.url}
<div class="row">
<span class="k">url</span><span class="v">{detailNode.url}</span>
</div>
{/if}
{#if detailNode.mounts.length}<div class="row">
<span class="k">mounts</span><span class="v">{detailNode.mounts.join(', ')}</span>
</div>{/if}
{#if detailNode.repo}<div class="row">
<span class="k">config</span><span class="v">{detailNode.repo}</span>
</div>{/if}
</div>
{#if detailRunsOn.length}
<div class="det-sec">
<h4>Runs on</h4>
{#each detailRunsOn as it}
<button type="button" class="link" onclick={() => openNode(it.slug)}>
<span class="d" style="background:{HEALTH_COLOR[it.health]}"></span>
<span class="t">{it.name}</span>
<span class="sub">{it.kind}</span>
</button>
{/each}
</div>
{/if}
{#if detailDeps.length}
<div class="det-sec">
<h4>Depends on</h4>
{#each detailDeps as it}
<button type="button" class="link" onclick={() => openNode(it.slug)}>
<span class="d" style="background:{HEALTH_COLOR[it.health]}"></span>
<span class="t">{it.name}</span>
<span class="sub">{it.kind}</span>
</button>
{/each}
</div>
{/if}
{#if detailProvides.length}
<div class="det-sec">
<h4>{detailNode.kind === 'host' ? 'Hosts' : 'Provides'}</h4>
{#each detailProvides as it}
<button type="button" class="link" onclick={() => openNode(it.slug)}>
<span class="d" style="background:{HEALTH_COLOR[it.health]}"></span>
<span class="t">{it.name}</span>
<span class="sub">{it.kind}</span>
</button>
{/each}
</div>
{/if}
{#if detailNode.kind === 'service' && detailBlast.length}
<div class="det-sec">
<h4>Depended on by</h4>
{#each detailBlast as it}
<button type="button" class="link" onclick={() => openNode(it.slug)}>
<span class="d" style="background:{HEALTH_COLOR[it.health]}"></span>
<span class="t">{it.name}</span>
<span class="sub">{it.kind}</span>
</button>
{/each}
</div>
{/if}
</div>
{:else}
<!-- legend -->
<div class="p-4">
<h3 class="legend-h">Health</h3>
{#each HEALTH_ORDER as h}
<div class="legend-row">
<span class="sw" style="background:{HEALTH_COLOR[h]}"></span>{HEALTH_LABEL[h]}
</div>
{/each}
<div class="sep"></div>
<h3 class="legend-h">Lanes</h3>
<div class="legend-row"><span class="lane-swatch"></span>Hosts — physical machines</div>
<div class="legend-row">
<span class="lane-swatch"></span>Containers — LXCs &amp; VMs
</div>
<div class="legend-row">
<span class="lane-swatch"></span>Services — what you actually use
</div>
<div class="sep"></div>
<p class="hint">
Every service flows right from the machine that runs it. Hover any node to trace its
chain and blast radius — what breaks if it goes down. Click to open it in a window.
Dashed arcs are service-to-service dependencies.
</p>
</div>
{/if}
</div>
</div>
</div>
{:else}
<div class="flex h-full items-center justify-center text-xs text-muted-foreground">
Failed to load graph
</div>
{/if}
<style>
.stage {
background-image: radial-gradient(circle at 1px 1px, var(--border) 1px, transparent 0);
background-size: 26px 26px;
}
.reset-layer {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
padding: 0;
background: transparent;
border: 0;
cursor: default;
}
.lane-head {
position: absolute;
top: 0;
padding: 8px 0 6px 4px;
font-size: 10.5px;
font-weight: 500;
letter-spacing: 0.9px;
text-transform: uppercase;
color: var(--muted-foreground);
}
.lane-head b {
font-weight: 500;
color: var(--foreground);
font-variant-numeric: tabular-nums;
}
.edge {
fill: none;
stroke: var(--border);
stroke-width: 1.4;
opacity: 0.6;
transition:
opacity 0.16s,
stroke 0.16s;
}
.edge.dep {
stroke-dasharray: 4 3;
}
.edge.dim {
opacity: 0.08;
}
.edge.hl {
stroke: var(--primary);
stroke-width: 2;
opacity: 0.95;
stroke-dasharray: 5 4;
animation: dash 1s linear infinite;
}
@keyframes dash {
to {
stroke-dashoffset: -18;
}
}
.node {
position: absolute;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
padding: 6px 10px 6px 13px;
text-align: left;
background: var(--card);
border: 1px solid var(--border);
border-radius: 9px;
overflow: hidden;
cursor: pointer;
transition:
opacity 0.16s,
border-color 0.14s,
transform 0.12s,
box-shadow 0.14s;
}
.node::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--nc);
}
.node .nm {
font-size: 12.5px;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--foreground);
}
.node .meta {
font-family: var(--font-mono);
font-size: 10px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--muted-foreground);
}
.node.svc .nm {
font-size: 13px;
}
.node .badge {
position: absolute;
top: 5px;
right: 6px;
color: var(--muted-foreground);
display: flex;
}
.node:hover {
border-color: var(--primary);
transform: translateY(-1px);
}
.node.dim {
opacity: 0.24;
}
.node.hl {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary);
}
.node.sel {
border-color: var(--primary);
box-shadow: 0 0 0 2px var(--primary);
}
.node.is-open {
border-color: var(--primary);
}
.node.pulse::before {
animation: pulse 1.8s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 9px;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
border-radius: 0;
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
transition:
color 0.12s,
border-color 0.12s,
background 0.12s;
}
.chip:hover {
border-color: var(--primary);
}
.chip.on {
color: var(--background);
background: var(--foreground);
border-color: var(--foreground);
}
.chip.on b {
color: var(--background);
}
.chip .dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.chip b {
font-weight: 500;
color: var(--foreground);
font-variant-numeric: tabular-nums;
}
.panel {
width: 300px;
font-size: 13px;
}
.legend-h {
margin: 0 0 8px;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--muted-foreground);
}
.legend-row {
display: flex;
align-items: center;
gap: 9px;
padding: 4px 0;
color: var(--muted-foreground);
font-size: 12.5px;
}
.legend-row .sw {
width: 12px;
height: 12px;
border-radius: 3px;
}
.lane-swatch {
position: relative;
width: 20px;
height: 14px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--card);
}
.lane-swatch::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
border-radius: 4px 0 0 4px;
background: var(--muted-foreground);
}
.sep {
height: 1px;
background: var(--border);
margin: 14px 0;
}
.hint {
color: var(--muted-foreground);
font-size: 12px;
line-height: 1.6;
}
.det-kind {
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted-foreground);
word-break: break-all;
}
.det-name {
font-family: var(--font-sans);
font-size: 17px;
font-weight: 500;
margin: 2px 0 8px;
}
.det-health {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 3px 10px;
font-size: 12.5px;
font-weight: 500;
border-radius: 999px;
color: var(--hc);
background: color-mix(in oklab, var(--hc) 14%, transparent);
}
.det-health .d {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--hc);
}
.blast {
margin: 14px 0;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--secondary);
}
.blast .n {
font-size: 25px;
font-weight: 500;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.blast .n.warn {
color: #f85149;
}
.blast .lbl {
margin-top: 4px;
font-size: 12px;
color: var(--muted-foreground);
}
.det-sec {
margin-top: 14px;
}
.det-sec h4 {
margin: 0 0 6px;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.7px;
text-transform: uppercase;
color: var(--muted-foreground);
}
.row {
display: flex;
gap: 8px;
padding: 3px 0;
font-size: 12.5px;
}
.row .k {
min-width: 66px;
color: var(--muted-foreground);
}
.row .v {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--foreground);
word-break: break-word;
}
.link {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 5px 8px;
border-radius: 7px;
background: transparent;
cursor: pointer;
text-align: left;
}
.link:hover {
background: var(--secondary);
}
.link .d {
width: 8px;
height: 8px;
border-radius: 50%;
flex: none;
}
.link .t {
font-size: 12.5px;
color: var(--foreground);
}
.link .sub {
margin-left: auto;
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--muted-foreground);
}
@media (prefers-reduced-motion: reduce) {
.edge.hl {
animation: none;
}
.node.pulse::before {
animation: none;
}
.node,
.edge {
transition: none;
}
}
</style>