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
1312 lines
41 KiB
Svelte
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 & 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>
|