From 4e4e2c169cc0845acb5c8fbfe526ecf078b17950 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sat, 25 Jul 2026 20:04:53 +0200 Subject: [PATCH] feat(web): replace 3D graph with fleet map, add desktop background patterns, rename Knowledge Base to Fleet - FleetMap: service-centric host -> container -> service graph replacing the WebGL 3D force graph, with health coloring, hover-to-trace blast radius, and click-to-open - Desktop background: configurable CSS pattern picker in Settings -> Appearance (8 patterns, color/fill/opacity/fade/size/rotation), replacing the hardcoded ambient graph background - Fix missing data-orientation/data-disabled Tailwind custom variants so the shadcn Slider's track actually renders - Rename "Knowledge Base" app to "Fleet"; scope its table to the same fleet entities as the graph (compute-entity descendants + service) instead of all entities - Remove dead code: EntityGraph, GraphBackground, categories.ts, MultiSelectFilter (all superseded by the above) Co-Authored-By: Claude Opus 4.8 --- web/src/app.css | 11 + web/src/lib/apps.ts | 8 +- web/src/lib/categories.ts | 44 - web/src/lib/components/EntityGraph.svelte | 504 ------- web/src/lib/components/FleetMap.svelte | 1201 +++++++++++++++++ web/src/lib/components/GraphBackground.svelte | 266 ---- .../lib/components/MultiSelectFilter.svelte | 64 - .../components/desktop-shell/Desktop.svelte | 34 +- .../desktop-shell/WindowLayer.svelte | 16 + web/src/lib/components/ui/slider/index.ts | 7 + .../lib/components/ui/slider/slider.svelte | 52 + web/src/lib/desktop-patterns.ts | 82 ++ web/src/lib/mascot/Mascot.svelte | 19 +- web/src/lib/stores/background.svelte.ts | 118 ++ web/src/pages/KnowledgeBase.svelte | 134 +- web/src/pages/Settings.svelte | 190 ++- 16 files changed, 1754 insertions(+), 996 deletions(-) delete mode 100644 web/src/lib/categories.ts delete mode 100644 web/src/lib/components/EntityGraph.svelte create mode 100644 web/src/lib/components/FleetMap.svelte delete mode 100644 web/src/lib/components/GraphBackground.svelte delete mode 100644 web/src/lib/components/MultiSelectFilter.svelte create mode 100644 web/src/lib/components/ui/slider/index.ts create mode 100644 web/src/lib/components/ui/slider/slider.svelte create mode 100644 web/src/lib/desktop-patterns.ts create mode 100644 web/src/lib/stores/background.svelte.ts diff --git a/web/src/app.css b/web/src/app.css index 04c50cc..a99b7d4 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -2,6 +2,17 @@ @custom-variant dark (&:is(.dark *)); +/* bits-ui components (Slider, and any future orientation/disabled-aware + primitive) style themselves via shorthand data-* variants that Tailwind + v4 doesn't ship — it only auto-generates variants for bare boolean data + attributes (data-disabled), not attribute=value pairs like + data-orientation="horizontal". Without these, e.g. Slider's track silently + collapses to 0 height (no h-1.5 class survives), leaving only the thumb + visible with no visible rail. */ +@custom-variant data-horizontal (&[data-orientation='horizontal']); +@custom-variant data-vertical (&[data-orientation='vertical']); +@custom-variant data-disabled (&[data-disabled]); + @theme inline { --font-sans: 'DM Sans', system-ui, sans-serif; --font-mono: 'DM Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; diff --git a/web/src/lib/apps.ts b/web/src/lib/apps.ts index 86b3020..07c0777 100644 --- a/web/src/lib/apps.ts +++ b/web/src/lib/apps.ts @@ -25,7 +25,7 @@ import type { DashboardSummary } from '$lib/api' import { openSignalCount } from '$lib/stores/context' import { catalogById, type AppManifest, type AppPermission, type CatalogEntry } from '$lib/app-store/catalog' import ListTodoIcon from '@lucide/svelte/icons/list-todo' -import DatabaseIcon from '@lucide/svelte/icons/database' +import BoxesIcon from '@lucide/svelte/icons/boxes' import ShieldCheckIcon from '@lucide/svelte/icons/shield-check' import SirenIcon from '@lucide/svelte/icons/siren' import SearchIcon from '@lucide/svelte/icons/search' @@ -88,9 +88,11 @@ export const builtinApps: AppDef[] = [ source: 'builtin' }, { + // id stays 'kb' so persisted window geometry / desktop-icon position / + // the 'oikos-kb-view' preference survive the rename to "Fleet". id: 'kb', - title: 'Knowledge Base', - icon: DatabaseIcon, + title: 'Fleet', + icon: BoxesIcon, component: () => import('../pages/KnowledgeBase.svelte'), width: 1000, height: 700, diff --git a/web/src/lib/categories.ts b/web/src/lib/categories.ts deleted file mode 100644 index 86e7566..0000000 --- a/web/src/lib/categories.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Browsing categories for the Knowledge Base — a coarser, more useful axis -// than the ontology's own `layer` (infrastructure/governance/cognition), -// which lumps very different things (an LXC and a DNS record and a storage -// volume) into one "infrastructure" bucket. Built from the ontology's -// `domain` field instead, which already draws these lines; this just -// groups the domains into browsing-sized buckets. The Knowledge Base shows -// every entity at once now (filtered by the type multiselect, not by a -// fetch-time category), but "fleet" still names the default type selection. -export type Category = 'network' | 'fleet' | 'identity' | 'knowledge' - -// entity_types.domain -> Category. `external` folds into Network (isp-link, -// domain-registration are network-adjacent); `physical`, `software`, and -// `storage` fold into Fleet (ups/sensor/site support compute, services/apps -// nest under the compute entity that provides them, and pools/volumes/ -// datasets nest under their compute entity or pool, all via EntityTable's -// treegrid) — browsing them separately fragments "what's running where". -// `meta` (the abstract root "entity" type) and `cognition` (see -// KNOWLEDGE_TYPES below) are handled outside this map. -const DOMAIN_TO_CATEGORY: Record = { - network: 'network', - external: 'network', - compute: 'fleet', - physical: 'fleet', - software: 'fleet', - storage: 'fleet', - identity: 'identity' -} - -// `cognition` is not one thing: document/investigation/runbook are genuine -// long-form knowledge, but the domain also holds execution/check/task/ -// signal/approval/pattern/skill/classification/feedback — operational -// telemetry with its own pages (Operations, Signals, Learning). Mapping the -// whole domain to Knowledge pulled in 245 execution + 25 check entities that -// fan out to a handful of compute nodes via `targets`/`checks` edges, -// flooding the graph. Only the true knowledge types get a category; the -// rest are excluded from Knowledge Base browsing entirely (returns -// undefined, same treatment as the abstract `entity` root type). -const KNOWLEDGE_TYPES = new Set(['document', 'investigation', 'runbook']) - -export function typeToCategory(type: string, domain: string): Category | undefined { - if (KNOWLEDGE_TYPES.has(type)) return 'knowledge' - if (domain === 'cognition') return undefined - return DOMAIN_TO_CATEGORY[domain] -} diff --git a/web/src/lib/components/EntityGraph.svelte b/web/src/lib/components/EntityGraph.svelte deleted file mode 100644 index 64ff304..0000000 --- a/web/src/lib/components/EntityGraph.svelte +++ /dev/null @@ -1,504 +0,0 @@ - - -{#if loading && !nodes.length} - -{:else} -
- - - - - - {#each allRelTypes as type} - - - - {/each} - - - - - {#each links as link} - {@const s = endpoint(link.source)} - {@const t = endpoint(link.target)} - {#if s?.x != null && t?.x != null && s?.y != null && t?.y != null && activeRelTypes.has(link.type) && visibleNodeIds.has(s.id) && visibleNodeIds.has(t.id)} - {@const vs = linkVisualState(link)} - {@const dx = t.x - s.x} - {@const dy = t.y - s.y} - {@const len = Math.max(Math.hypot(dx, dy), 1)} - {@const curve = Math.min(len * 0.15, 40)} - {@const cx = (s.x + t.x) / 2 - (dy / len) * curve} - {@const cy = (s.y + t.y) / 2 + (dx / len) * curve} - {@const cdx = t.x - cx} - {@const cdy = t.y - cy} - {@const clen = Math.max(Math.hypot(cdx, cdy), 1)} - {@const tr = nodeRadius(t) + 3} - {@const ex = t.x - (cdx / clen) * tr} - {@const ey = t.y - (cdy / clen) * tr} - {@const mx = 0.25 * s.x + 0.5 * cx + 0.25 * ex} - {@const my = 0.25 * s.y + 0.5 * cy + 0.25 * ey} - - {link.type} - - {#if vs.emphasized && view.k >= 0.7} - - {link.type} - - {/if} - {/if} - {/each} - - - {#each nodes as node (node.id)} - {#if node.x != null && node.y != null && visibleNodeIds.has(node.id)} - {@const r = nodeRadius(node)} - {@const op = nodeOpacity(node)} - {@const isFocus = hoveredId === node.id || selectedId === node.id} - {@const isMatch = matchedIds !== null && matchedIds.has(node.id)} - onNodePointerDown(e, node)} - onpointerenter={() => (hoveredId = node.id)} - onpointerleave={() => (hoveredId = null)} - onkeydown={(e) => e.key === 'Enter' && selectNode(node)} - ondblclick={() => rerootTo(node)} - > - {#if isFocus || isMatch} - - {/if} - - {#if view.k >= 0.8 || isFocus || isMatch || op === 1 && focusIds !== null} - - {node.slug} - - {/if} - - {/if} - {/each} - - - -
- scroll to zoom · drag background to pan · drag nodes · click to inspect · double-click to re-root -
-
-{/if} diff --git a/web/src/lib/components/FleetMap.svelte b/web/src/lib/components/FleetMap.svelte new file mode 100644 index 0000000..7e8a691 --- /dev/null +++ b/web/src/lib/components/FleetMap.svelte @@ -0,0 +1,1201 @@ + + + + +{#if loading && !model} + +{:else if model} +
+ +
+ {#each HEALTH_ORDER as h} + {#if model.counts[h]} + + {/if} + {/each} +
+ + {#if problemMode || healthFilter} + + {/if} +
+
+ +
+ +
+
+ + + +
+ {#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} +
+ {label} · {count} +
+ {/each} +
+ + + + {#each model.edges as e} + + {/each} + + + + {#each model.nodes as n (n.slug)} + + {/each} +
+
+ + + +
+ {#if !detailNode && problemMode} +
+
status
+
Problems
+
+
{problemCount}
+
{problemCount === 1 ? 'entity' : 'entities'} not healthy
+
+ {#each problemGroups as g} +
+

{HEALTH_LABEL[g.h]} · {g.items.length}

+ {#each g.items as it} + + {/each} +
+ {/each} +
+ {:else if !detailNode && healthFilter} +
+
status
+
{HEALTH_LABEL[healthFilter]}
+
+
{healthItems.length}
+
{healthItems.length === 1 ? 'entity' : 'entities'}
+
+
+

{HEALTH_LABEL[healthFilter]}

+ {#each healthItems as it} + + {/each} +
+
+ {:else if detailNode} +
+
{detailNode.slug}
+
{detailNode.name}
+ + {HEALTH_LABEL[detailNode.health]} + + +
+
0}>{detailBlast.length}
+
+ {detailNode.kind === 'service' ? 'downstream service' : 'service'}{detailBlast.length === 1 ? '' : 's'} + affected if this goes down +
+
+ +
+

Attributes

+ {#if detailNode.kind === 'container'} +
type{detailNode.type}
+ {/if} + {#if detailNode.role}
role{detailNode.role}
{/if} + {#if detailNode.ip}
ip{detailNode.ip}
{/if} + {#if detailNode.publicHost} +
url{detailNode.publicHost}
+
exposure{detailNode.fauth ? 'forward-auth gated' : detailNode.fauth === false ? 'no auth gate' : 'public'}
+ {:else if detailNode.url} +
url{detailNode.url}
+ {/if} + {#if detailNode.mounts.length}
mounts{detailNode.mounts.join(', ')}
{/if} + {#if detailNode.repo}
config{detailNode.repo}
{/if} +
+ + {#if detailRunsOn.length} +
+

Runs on

+ {#each detailRunsOn as it} + + {/each} +
+ {/if} + {#if detailDeps.length} +
+

Depends on

+ {#each detailDeps as it} + + {/each} +
+ {/if} + {#if detailProvides.length} +
+

{detailNode.kind === 'host' ? 'Hosts' : 'Provides'}

+ {#each detailProvides as it} + + {/each} +
+ {/if} + {#if detailNode.kind === 'service' && detailBlast.length} +
+

Depended on by

+ {#each detailBlast as it} + + {/each} +
+ {/if} +
+ {:else} + +
+

Health

+ {#each HEALTH_ORDER as h} +
{HEALTH_LABEL[h]}
+ {/each} +
+

Lanes

+
Hosts — physical machines
+
Containers — LXCs & VMs
+
Services — what you actually use
+
+

+ 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. +

+
+ {/if} +
+
+
+{:else} +
Failed to load graph
+{/if} + + diff --git a/web/src/lib/components/GraphBackground.svelte b/web/src/lib/components/GraphBackground.svelte deleted file mode 100644 index 023b267..0000000 --- a/web/src/lib/components/GraphBackground.svelte +++ /dev/null @@ -1,266 +0,0 @@ - - -
- -
diff --git a/web/src/lib/components/MultiSelectFilter.svelte b/web/src/lib/components/MultiSelectFilter.svelte deleted file mode 100644 index 0c811bd..0000000 --- a/web/src/lib/components/MultiSelectFilter.svelte +++ /dev/null @@ -1,64 +0,0 @@ - - - - - {#snippet child({ props })} - - {/snippet} - - - { selected = allSelected ? new Set() : new Set(options) }} - class="text-xs text-muted-foreground" - > - {allSelected ? 'Deselect all' : 'Select all'} - - - {#each options as opt} - toggle(opt)} - class="text-xs" - > - {#if colorFor} - - {/if} - {opt} - - {/each} - {#if options.length === 0} -

No types loaded yet.

- {/if} -
-
diff --git a/web/src/lib/components/desktop-shell/Desktop.svelte b/web/src/lib/components/desktop-shell/Desktop.svelte index e4da1cd..dbb3e02 100644 --- a/web/src/lib/components/desktop-shell/Desktop.svelte +++ b/web/src/lib/components/desktop-shell/Desktop.svelte @@ -9,7 +9,8 @@ import { iconPositions, resetIconLayout } from '$lib/stores/icons' import { wm, openAppWindow, toggleShowDesktop } from '$lib/stores/windows' import { summary } from '$lib/stores/context' - import GraphBackground from '../GraphBackground.svelte' + import { getBackground } from '$lib/stores/background.svelte' + import { patternCss } from '$lib/desktop-patterns' import DesktopIcon from './DesktopIcon.svelte' import TaskLauncher from './TaskLauncher.svelte' import WindowLayer from './WindowLayer.svelte' @@ -51,13 +52,42 @@ if (e.shiftKey) wm.redo() else wm.undo() } + + // Configurable in Settings → Appearance (see background.svelte.ts). Two + // layers, not one, because rotation and the fade mask need different + // geometry: + // - outer: exactly the viewport box. Carries the fade mask, since a + // vignette has to be centered on what's actually visible. + // - inner: oversized (200%) and centered before rotating, so turning the + // pattern doesn't pull its straight edges into view at the corners — + // a viewport-sized box rotated in place would do exactly that. + const bgActive = $derived.by(() => { + const bg = getBackground() + return bg.pattern !== 'none' || bg.fillColor !== null + }) + const bgOuterStyle = $derived.by(() => { + const bg = getBackground() + if (bg.fade <= 0) return '' + const stop = Math.round(100 - bg.fade * 70) + const mask = `radial-gradient(circle at 50% 50%, black 0%, black ${stop}%, transparent 100%)` + return `mask-image:${mask};-webkit-mask-image:${mask};` + }) + const bgInnerStyle = $derived.by(() => { + const bg = getBackground() + const css = patternCss(bg.pattern, bg.color, bg.scale) + return `inset:-50%;width:200%;height:200%;opacity:${bg.opacity};background-color:${bg.fillColor ?? 'transparent'};transform:rotate(${bg.rotation}deg);${css}` + })