feat(web): replace 3D graph with fleet map, add desktop background patterns, rename Knowledge Base to Fleet
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

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 20:04:53 +02:00
parent c151a66627
commit 4e4e2c169c
16 changed files with 1754 additions and 996 deletions

View File

@@ -9,9 +9,22 @@
import { Label } from '$lib/components/ui/label'
import { Button } from '$lib/components/ui/button'
import { Separator } from '$lib/components/ui/separator'
import { Checkbox } from '$lib/components/ui/checkbox'
import { Slider } from '$lib/components/ui/slider'
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig, type OikosConfig } from '$lib/config'
import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc'
import { getTheme, setTheme, THEME_LABELS, type Theme } from '$lib/stores/theme.svelte'
import {
getBackground,
setBackgroundPattern,
setPatternColor,
setFillColor,
setBackgroundOpacity,
setBackgroundFade,
setBackgroundScale,
setBackgroundRotation
} from '$lib/stores/background.svelte'
import { PATTERNS, patternCss, type BackgroundPatternId } from '$lib/desktop-patterns'
import { VERSION } from '$lib/version'
import { toast } from 'svelte-sonner'
import PlugIcon from '@lucide/svelte/icons/plug'
@@ -22,12 +35,12 @@
import CheckIcon from '@lucide/svelte/icons/check'
const SECTIONS = [
{ id: 'connection', label: 'Connection', icon: PlugIcon },
{ id: 'appearance', label: 'Appearance', icon: PaletteIcon }
{ id: 'appearance', label: 'Appearance', icon: PaletteIcon },
{ id: 'connection', label: 'Connection', icon: PlugIcon }
] as const
type SectionId = (typeof SECTIONS)[number]['id']
let section = $state<SectionId>('connection')
let section = $state<SectionId>('appearance')
const existing = getConfig()
let apiUrl = $state(existing.apiUrl ?? '')
@@ -91,6 +104,37 @@
function pickTheme(t: Theme) {
setTheme(t)
}
// Swatch previews render every pattern at a fixed, higher-contrast opacity
// against a neutral tile so the shape reads clearly in the picker — the
// live opacity slider (often tuned low, e.g. 0.08, so it reads as texture
// rather than noise) would make most patterns nearly invisible here.
const SWATCH_PREVIEW_OPACITY = 0.55
function swatchStyle(id: BackgroundPatternId, color: string): string {
const css = patternCss(id, color)
return css ? `opacity:${SWATCH_PREVIEW_OPACITY};${css}` : ''
}
// Remembers the last custom fill color locally so toggling "Custom" off
// and back on doesn't lose the pick — the store itself only ever holds
// null (auto/theme) or the active custom color, not a disabled draft.
let fillDraft = $state(getBackground().fillColor ?? '#ffffff')
function onFillToggle(checked: boolean) {
setFillColor(checked ? fillDraft : null)
}
function onFillColorInput(e: Event) {
const val = (e.currentTarget as HTMLInputElement).value
fillDraft = val
setFillColor(val)
}
// Size/rotation act on the pattern image itself — meaningless with no
// pattern selected, unlike opacity/fade which also apply to a plain fill
// color wash (pattern 'none' + a custom fill).
const noPattern = $derived(getBackground().pattern === 'none')
</script>
<div class="flex h-full min-h-0 flex-col">
@@ -170,20 +214,154 @@
<p class="mt-0.5 text-xs text-muted-foreground">Pick the theme for the whole desktop.</p>
</div>
<div class="flex flex-col gap-2">
<div class="grid grid-cols-2 gap-2">
{#each Object.entries(THEME_LABELS) as [id, label] (id)}
<button
type="button"
class="flex items-center justify-between rounded-lg border px-3.5 py-2.5 text-left text-sm transition-colors {getTheme() === (id as Theme)
class="flex items-center justify-between gap-1.5 rounded-lg border px-2.5 py-1.5 text-left text-sm transition-colors {getTheme() === (id as Theme)
? 'border-primary/50 bg-primary/5 text-foreground'
: 'text-muted-foreground hover:bg-muted/50'}"
onclick={() => pickTheme(id as Theme)}
>
{label}
{#if getTheme() === (id as Theme)}<CheckIcon class="size-4 text-primary" />{/if}
{#if getTheme() === (id as Theme)}<CheckIcon class="size-3.5 shrink-0 text-primary" />{/if}
</button>
{/each}
</div>
<Separator decorative />
<div>
<h3 class="text-sm font-semibold">Desktop background</h3>
<p class="mt-0.5 text-xs text-muted-foreground">
A subtle CSS pattern behind your icons, in the style of
<a href="https://www.magicpattern.design/tools/css-backgrounds" target="_blank" rel="noreferrer">magicpattern.design</a>.
</p>
</div>
<div class="grid grid-cols-4 gap-2">
{#each PATTERNS as p (p.id)}
{@const active = getBackground().pattern === p.id}
<button
type="button"
class="flex flex-col items-center gap-1.5 rounded-lg border p-2 transition-colors {active
? 'border-primary/60 bg-primary/5'
: 'border-border hover:bg-muted/50'}"
onclick={() => setBackgroundPattern(p.id)}
title={p.label}
>
<span
class="h-10 w-full rounded-md border border-border/60 bg-muted"
style={swatchStyle(p.id, getBackground().color)}
></span>
<span class="flex items-center gap-1 text-[11px] {active ? 'font-medium text-foreground' : 'text-muted-foreground'}">
{p.label}
{#if active}<CheckIcon class="size-3 text-primary" />{/if}
</span>
</button>
{/each}
</div>
<!-- Colors: pattern (foreground shapes) + an optional fill behind
them. Fill defaults to "Auto", i.e. transparent, so the app's
own theme background shows through the gaps — same as before
this control existed. -->
<div class="flex items-center justify-between gap-4">
<Label for="bg-color" class="text-xs font-medium">Pattern color</Label>
<div class="flex items-center gap-2">
<input
id="bg-color"
type="color"
class="size-7 cursor-pointer rounded-md border border-border bg-transparent p-0.5"
value={getBackground().color}
oninput={(e) => setPatternColor((e.currentTarget as HTMLInputElement).value)}
/>
<span class="font-mono text-xs text-muted-foreground">{getBackground().color}</span>
</div>
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<Checkbox
id="bg-fill-toggle"
checked={getBackground().fillColor !== null}
onCheckedChange={(v) => onFillToggle(!!v)}
/>
<Label for="bg-fill-toggle" class="text-xs font-medium">Custom background fill</Label>
</div>
<div class="flex items-center gap-2">
<input
id="bg-fill-color"
type="color"
class="size-7 cursor-pointer rounded-md border border-border bg-transparent p-0.5 disabled:cursor-not-allowed disabled:opacity-40"
value={getBackground().fillColor ?? fillDraft}
disabled={getBackground().fillColor === null}
oninput={onFillColorInput}
/>
<span class="font-mono text-xs text-muted-foreground">{getBackground().fillColor ?? 'Auto'}</span>
</div>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<Label for="bg-opacity" class="text-xs font-medium">Opacity</Label>
<span class="font-mono text-xs text-muted-foreground">{Math.round(getBackground().opacity * 100)}%</span>
</div>
<Slider
id="bg-opacity"
type="single"
min={0}
max={1}
step={0.01}
value={getBackground().opacity}
onValueChange={setBackgroundOpacity}
/>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<Label for="bg-fade" class="text-xs font-medium">Fade mask</Label>
<span class="font-mono text-xs text-muted-foreground">
{getBackground().fade === 0 ? 'Off' : `${Math.round(getBackground().fade * 100)}%`}
</span>
</div>
<Slider id="bg-fade" type="single" min={0} max={1} step={0.01} value={getBackground().fade} onValueChange={setBackgroundFade} />
<p class="text-[11px] text-muted-foreground">Fades the pattern out toward the edges, like a vignette.</p>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<Label for="bg-scale" class="text-xs font-medium">Size</Label>
<span class="font-mono text-xs text-muted-foreground">{Math.round(getBackground().scale * 100)}%</span>
</div>
<Slider
id="bg-scale"
type="single"
min={0.4}
max={3}
step={0.05}
value={getBackground().scale}
onValueChange={setBackgroundScale}
disabled={noPattern}
/>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<Label for="bg-rotation" class="text-xs font-medium">Rotation</Label>
<span class="font-mono text-xs text-muted-foreground">{Math.round(getBackground().rotation)}°</span>
</div>
<Slider
id="bg-rotation"
type="single"
min={0}
max={359}
step={1}
value={getBackground().rotation}
onValueChange={setBackgroundRotation}
disabled={noPattern}
/>
</div>
</div>
{/if}
</div>