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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, Category> = {
|
||||
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]
|
||||
}
|
||||
@@ -1,504 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
|
||||
import { fetchGraph, type GraphView, type Entity, type Health } from '$lib/api'
|
||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
|
||||
export interface GraphInfo {
|
||||
allRelTypes: string[]
|
||||
relColors: Map<string, string>
|
||||
visibleCount: number
|
||||
truncated: boolean
|
||||
zoomPct: number
|
||||
}
|
||||
|
||||
let {
|
||||
selectedSlug = null,
|
||||
onSelect,
|
||||
root = $bindable(''),
|
||||
depth,
|
||||
search,
|
||||
reloadToken,
|
||||
resetToken,
|
||||
// Owned by the parent (shared with the entity table's type filter) —
|
||||
// this graph only reads it to decide what's in focus, never writes it.
|
||||
activeNodeTypes,
|
||||
activeRelTypes = $bindable(new Set<string>()),
|
||||
info = $bindable<GraphInfo>({ allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
|
||||
}: {
|
||||
selectedSlug?: string | null
|
||||
onSelect: (slug: string | null) => void
|
||||
root?: string
|
||||
depth: number
|
||||
search: string
|
||||
// Bumped by the parent toolbar to request a data reload / view reset —
|
||||
// these controls live in the shared page toolbar (not squeezed inside
|
||||
// this resizable pane), so they can't call load()/resetView() directly.
|
||||
reloadToken: number
|
||||
resetToken: number
|
||||
activeNodeTypes: Set<string>
|
||||
activeRelTypes?: Set<string>
|
||||
info?: GraphInfo
|
||||
} = $props()
|
||||
|
||||
interface Node extends Entity {
|
||||
x?: number
|
||||
y?: number
|
||||
vx?: number
|
||||
vy?: number
|
||||
fx?: number | null
|
||||
fy?: number | null
|
||||
degree: number
|
||||
}
|
||||
interface Link {
|
||||
source: string | Node
|
||||
target: string | Node
|
||||
type: string
|
||||
}
|
||||
|
||||
// SVG ids are document-global, not scoped to this <svg> — see
|
||||
// SessionGraph.svelte's dotGridId for why this needs a per-instance suffix
|
||||
// (also covers the per-relationship-type arrow markers below, which were
|
||||
// keyed only by type name and would collide the same way across two
|
||||
// mounted EntityGraph instances).
|
||||
const uid = crypto.randomUUID().slice(0, 8)
|
||||
const dotGridId = `dot-grid-${uid}`
|
||||
|
||||
let graph = $state<GraphView | null>(null)
|
||||
let loading = $state(true)
|
||||
let nodes = $state<Node[]>([])
|
||||
let links = $state<Link[]>([])
|
||||
let sim: Simulation<Node, Link> | null = null
|
||||
|
||||
let hoveredId = $state<string | null>(null)
|
||||
|
||||
// viewport transform: translate(x, y) scale(k)
|
||||
let view = $state({ x: 0, y: 0, k: 1 })
|
||||
let svgEl = $state<SVGSVGElement | null>(null)
|
||||
|
||||
const width = 1200
|
||||
const height = 800
|
||||
|
||||
const healthColor: Record<Health, string> = {
|
||||
healthy: '#3fb950',
|
||||
degraded: '#d29922',
|
||||
down: '#f85149',
|
||||
unknown: '#8b949e'
|
||||
}
|
||||
|
||||
const relPalette = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#39c5cf', '#f0883e', '#db61a2']
|
||||
const relColorByType = $derived.by(() => {
|
||||
const map = new Map<string, string>()
|
||||
const types = Array.from(new Set(links.map((l) => l.type))).sort()
|
||||
types.forEach((t, i) => map.set(t, relPalette[i % relPalette.length]))
|
||||
return map
|
||||
})
|
||||
|
||||
function relColor(type: string): string {
|
||||
return relColorByType.get(type) ?? '#30363d'
|
||||
}
|
||||
|
||||
function markerId(type: string): string {
|
||||
return `arrow-${uid}-` + type.replace(/[^a-z0-9]/gi, '_')
|
||||
}
|
||||
|
||||
function endpoint(end: string | Node): Node | undefined {
|
||||
return typeof end === 'object' ? end : nodes.find((n) => n.id === end)
|
||||
}
|
||||
function endpointId(end: string | Node): string {
|
||||
return typeof end === 'object' ? end.id : end
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true
|
||||
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
|
||||
loading = false
|
||||
if (!graph) return
|
||||
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]))
|
||||
const degree = new Map<string, number>()
|
||||
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
|
||||
for (const e of graph.edges) {
|
||||
const s = idBySlug.get(e.source) ?? e.source
|
||||
const t = idBySlug.get(e.target) ?? e.target
|
||||
degree.set(s, (degree.get(s) ?? 0) + 1)
|
||||
degree.set(t, (degree.get(t) ?? 0) + 1)
|
||||
}
|
||||
|
||||
nodes = graph.nodes.map((n) => {
|
||||
const prev = byId.get(n.id)
|
||||
return { ...n, x: prev?.x, y: prev?.y, degree: degree.get(n.id) ?? 0 }
|
||||
})
|
||||
links = graph.edges.map((e) => ({
|
||||
source: idBySlug.get(e.source) ?? e.source,
|
||||
target: idBySlug.get(e.target) ?? e.target,
|
||||
type: e.type
|
||||
}))
|
||||
|
||||
// Edge-type toggles default to everything present — node-type toggles
|
||||
// are owned by the parent (activeNodeTypes) and persist across reloads.
|
||||
activeRelTypes = new Set(links.map((l) => l.type))
|
||||
|
||||
sim?.stop()
|
||||
sim = forceSimulation(nodes)
|
||||
.force('link', forceLink<Node, Link>(links).id((n) => n.id).distance(70).strength(0.6))
|
||||
.force('charge', forceManyBody().strength(-240).distanceMax(400))
|
||||
.force('center', forceCenter(width / 2, height / 2))
|
||||
.force('collide', forceCollide<Node>((n) => nodeRadius(n) + 8))
|
||||
.force('x', forceX(width / 2).strength(0.04))
|
||||
.force('y', forceY(height / 2).strength(0.04))
|
||||
.velocityDecay(0.32)
|
||||
.alphaDecay(0.035)
|
||||
.on('tick', () => {
|
||||
nodes = [...nodes]
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load()
|
||||
const unsubscribe = subscribeEvents()
|
||||
return () => {
|
||||
unsubscribe()
|
||||
sim?.stop()
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => sim?.stop())
|
||||
|
||||
$effect(() => {
|
||||
const ev = $liveEvents[0]
|
||||
if (!ev) return
|
||||
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
// Toolbar-driven reload/reset — mirrors the old onchange={load} behavior:
|
||||
// typing freely doesn't refetch, only a committed change (Enter/blur in the
|
||||
// parent's inputs, or the Reset button) bumps the token.
|
||||
let lastReloadToken = $state(0)
|
||||
$effect(() => {
|
||||
if (reloadToken !== lastReloadToken) {
|
||||
lastReloadToken = reloadToken
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
let lastResetToken = $state(0)
|
||||
$effect(() => {
|
||||
if (resetToken !== lastResetToken) {
|
||||
lastResetToken = resetToken
|
||||
view = { x: 0, y: 0, k: 1 }
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
function selectNode(node: Node) {
|
||||
onSelect(node.slug)
|
||||
}
|
||||
|
||||
function rerootTo(node: Node) {
|
||||
root = node.slug
|
||||
load()
|
||||
}
|
||||
|
||||
function nodeColor(node: Node): string {
|
||||
const h = graph?.health?.[node.id]
|
||||
return h ? healthColor[h] : '#58a6ff'
|
||||
}
|
||||
|
||||
function nodeRadius(node: Node): number {
|
||||
return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
|
||||
}
|
||||
|
||||
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
|
||||
|
||||
// Publish status/legend info up to the parent toolbar.
|
||||
$effect(() => {
|
||||
info = {
|
||||
allRelTypes,
|
||||
relColors: relColorByType,
|
||||
visibleCount: visibleNodeIds.size,
|
||||
truncated: !!graph?.truncated,
|
||||
zoomPct: Math.round(view.k * 100)
|
||||
}
|
||||
})
|
||||
|
||||
const matchedIds = $derived.by(() => {
|
||||
if (!search.trim()) return null
|
||||
const q = search.trim().toLowerCase()
|
||||
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id))
|
||||
})
|
||||
|
||||
// Focus = the shared type multiselect (activeNodeTypes) says this type is
|
||||
// visible — same control the entity table filters its rows by.
|
||||
const focusNodeIds = $derived(new Set(nodes.filter((n) => activeNodeTypes.has(n.type)).map((n) => n.id)))
|
||||
|
||||
// Real infra relationships mostly cross type lines (a service sits on a
|
||||
// network, uses storage, runs on an lxc). Hard-hiding any edge whose
|
||||
// other end isn't in the active type set left focus nodes looking like
|
||||
// disconnected dots. Rooted views (the user is exploring out from one
|
||||
// entity) pull in 1-hop neighbors of any type, dimmed, so the edges — and
|
||||
// what they connect to — stay visible. Unscoped "browse everything" views
|
||||
// (no root) skip this: with dozens of focus nodes that touch nearly
|
||||
// everything, 1-hop expansion floods in most of the graph (measured: 417
|
||||
// of 479 total entities for an unrooted Fleet-typed view) — worse than
|
||||
// the isolated-dot problem it was meant to fix. There, same-type-only
|
||||
// edges stay.
|
||||
const neighborNodeIds = $derived.by(() => {
|
||||
const neighbors = new Set<string>()
|
||||
if (!root.trim()) return neighbors
|
||||
for (const l of links) {
|
||||
if (!activeRelTypes.has(l.type)) continue
|
||||
const s = endpointId(l.source)
|
||||
const t = endpointId(l.target)
|
||||
if (focusNodeIds.has(s) && !focusNodeIds.has(t)) neighbors.add(t)
|
||||
else if (focusNodeIds.has(t) && !focusNodeIds.has(s)) neighbors.add(s)
|
||||
}
|
||||
return neighbors
|
||||
})
|
||||
|
||||
const visibleNodeIds = $derived(new Set([...focusNodeIds, ...neighborNodeIds]))
|
||||
|
||||
const selectedId = $derived(nodes.find((n) => n.slug === selectedSlug)?.id ?? null)
|
||||
|
||||
const adjacency = $derived.by(() => {
|
||||
const adj = new Map<string, Set<string>>()
|
||||
for (const l of links) {
|
||||
const s = endpointId(l.source)
|
||||
const t = endpointId(l.target)
|
||||
if (!adj.has(s)) adj.set(s, new Set())
|
||||
if (!adj.has(t)) adj.set(t, new Set())
|
||||
adj.get(s)!.add(t)
|
||||
adj.get(t)!.add(s)
|
||||
}
|
||||
return adj
|
||||
})
|
||||
|
||||
const focusIds = $derived.by(() => {
|
||||
const focus = hoveredId ?? selectedId
|
||||
if (!focus) return null
|
||||
const set = new Set<string>([focus])
|
||||
for (const n of adjacency.get(focus) ?? []) set.add(n)
|
||||
return set
|
||||
})
|
||||
|
||||
function nodeOpacity(node: Node): number {
|
||||
const base = focusNodeIds.has(node.id) ? 1 : 0.4
|
||||
if (matchedIds !== null) return matchedIds.has(node.id) ? base : 0.1
|
||||
if (focusIds !== null) return focusIds.has(node.id) ? 1 : Math.min(base, 0.15)
|
||||
return base
|
||||
}
|
||||
|
||||
function linkVisualState(link: Link): { opacity: number; emphasized: boolean } {
|
||||
const s = endpointId(link.source)
|
||||
const t = endpointId(link.target)
|
||||
const focus = hoveredId ?? selectedId
|
||||
if (focus && (s === focus || t === focus)) return { opacity: 0.95, emphasized: true }
|
||||
if (focusIds !== null || matchedIds !== null) return { opacity: 0.08, emphasized: false }
|
||||
return { opacity: 0.45, emphasized: false }
|
||||
}
|
||||
|
||||
// ─── pan / zoom / drag ───────────────────────────────────────────────
|
||||
|
||||
function toViewBox(clientX: number, clientY: number): { x: number; y: number } {
|
||||
const rect = svgEl!.getBoundingClientRect()
|
||||
return {
|
||||
x: ((clientX - rect.left) / rect.width) * width,
|
||||
y: ((clientY - rect.top) / rect.height) * height
|
||||
}
|
||||
}
|
||||
|
||||
function toWorld(clientX: number, clientY: number): { x: number; y: number } {
|
||||
const p = toViewBox(clientX, clientY)
|
||||
return { x: (p.x - view.x) / view.k, y: (p.y - view.y) / view.k }
|
||||
}
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
const factor = e.deltaY < 0 ? 1.18 : 1 / 1.18
|
||||
const k = Math.min(6, Math.max(0.25, view.k * factor))
|
||||
const p = toViewBox(e.clientX, e.clientY)
|
||||
const wx = (p.x - view.x) / view.k
|
||||
const wy = (p.y - view.y) / view.k
|
||||
view = { k, x: p.x - wx * k, y: p.y - wy * k }
|
||||
}
|
||||
|
||||
let panState = $state<{ startX: number; startY: number; viewX: number; viewY: number; moved: boolean } | null>(null)
|
||||
let dragState: { node: Node; moved: boolean } | null = null
|
||||
|
||||
function onBackgroundPointerDown(e: PointerEvent) {
|
||||
if (dragState) return
|
||||
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||
const p = toViewBox(e.clientX, e.clientY)
|
||||
panState = { startX: p.x, startY: p.y, viewX: view.x, viewY: view.y, moved: false }
|
||||
}
|
||||
|
||||
function onNodePointerDown(e: PointerEvent, node: Node) {
|
||||
e.stopPropagation()
|
||||
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||
dragState = { node, moved: false }
|
||||
sim?.alphaTarget(0.25).restart()
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (dragState) {
|
||||
const w = toWorld(e.clientX, e.clientY)
|
||||
dragState.node.fx = w.x
|
||||
dragState.node.fy = w.y
|
||||
dragState.moved = true
|
||||
return
|
||||
}
|
||||
if (panState) {
|
||||
const p = toViewBox(e.clientX, e.clientY)
|
||||
const dx = p.x - panState.startX
|
||||
const dy = p.y - panState.startY
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) panState.moved = true
|
||||
view = { ...view, x: panState.viewX + dx, y: panState.viewY + dy }
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (dragState) {
|
||||
const { node, moved } = dragState
|
||||
node.fx = null
|
||||
node.fy = null
|
||||
sim?.alphaTarget(0)
|
||||
dragState = null
|
||||
if (!moved) selectNode(node)
|
||||
return
|
||||
}
|
||||
if (panState && !panState.moved) {
|
||||
// Plain click on empty background (not a drag-pan) — clear selection.
|
||||
onSelect(null)
|
||||
}
|
||||
panState = null
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading && !nodes.length}
|
||||
<Skeleton class="h-full min-h-0" />
|
||||
{:else}
|
||||
<div class="relative h-full min-h-0 overflow-hidden rounded-lg border">
|
||||
<svg
|
||||
bind:this={svgEl}
|
||||
viewBox="0 0 {width} {height}"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
class="h-full w-full touch-none {panState ? 'cursor-grabbing' : 'cursor-grab'}"
|
||||
role="application"
|
||||
aria-label="Entity graph"
|
||||
onwheel={onWheel}
|
||||
onpointerdown={onBackgroundPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
>
|
||||
<defs>
|
||||
<pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
|
||||
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
|
||||
</pattern>
|
||||
{#each allRelTypes as type}
|
||||
<marker id={markerId(type)} viewBox="0 -4 8 8" refX="8" refY="0" markerWidth="7" markerHeight="7" orient="auto">
|
||||
<path d="M0,-3.5L8,0L0,3.5" fill={relColor(type)} />
|
||||
</marker>
|
||||
{/each}
|
||||
</defs>
|
||||
<rect x="0" y="0" width={width} height={height} fill="url(#{dotGridId})" />
|
||||
<g transform="translate({view.x},{view.y}) scale({view.k})">
|
||||
<g>
|
||||
{#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}
|
||||
<path
|
||||
d="M {s.x},{s.y} Q {cx},{cy} {ex},{ey}"
|
||||
fill="none"
|
||||
stroke={relColor(link.type)}
|
||||
stroke-width={vs.emphasized ? 2 : 1.2}
|
||||
opacity={vs.opacity}
|
||||
marker-end="url(#{markerId(link.type)})"
|
||||
>
|
||||
<title>{link.type}</title>
|
||||
</path>
|
||||
{#if vs.emphasized && view.k >= 0.7}
|
||||
<text
|
||||
x={mx}
|
||||
y={my - 4}
|
||||
text-anchor="middle"
|
||||
font-size={10 / view.k}
|
||||
fill={relColor(link.type)}
|
||||
opacity="0.95"
|
||||
paint-order="stroke"
|
||||
stroke="var(--background)"
|
||||
stroke-width={3 / view.k}
|
||||
>
|
||||
{link.type}
|
||||
</text>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
<g>
|
||||
{#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)}
|
||||
<g
|
||||
transform="translate({node.x},{node.y})"
|
||||
opacity={op}
|
||||
class="cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onpointerdown={(e) => onNodePointerDown(e, node)}
|
||||
onpointerenter={() => (hoveredId = node.id)}
|
||||
onpointerleave={() => (hoveredId = null)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectNode(node)}
|
||||
ondblclick={() => rerootTo(node)}
|
||||
>
|
||||
{#if isFocus || isMatch}
|
||||
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
|
||||
{/if}
|
||||
<circle r={r} fill={nodeColor(node)} stroke={isFocus || isMatch ? 'var(--foreground)' : 'var(--background)'} stroke-width={isFocus || isMatch ? 2 : 1.25} />
|
||||
{#if view.k >= 0.8 || isFocus || isMatch || op === 1 && focusIds !== null}
|
||||
<text
|
||||
y={r + 12}
|
||||
text-anchor="middle"
|
||||
font-size={isFocus ? 12 / view.k : 10 / Math.max(view.k, 1)}
|
||||
fill={isFocus ? 'var(--foreground)' : 'var(--muted-foreground)'}
|
||||
paint-order="stroke"
|
||||
stroke="var(--background)"
|
||||
stroke-width={3 / view.k}
|
||||
class="pointer-events-none select-none"
|
||||
>
|
||||
{node.slug}
|
||||
</text>
|
||||
{/if}
|
||||
</g>
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="pointer-events-none absolute bottom-2 left-2 rounded bg-background/80 px-2 py-1 text-[10px] text-muted-foreground">
|
||||
scroll to zoom · drag background to pan · drag nodes · click to inspect · double-click to re-root
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
1201
web/src/lib/components/FleetMap.svelte
Normal file
1201
web/src/lib/components/FleetMap.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,266 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, type Simulation } from 'd3-force'
|
||||
import { fetchGraph, type Health } from '$lib/api'
|
||||
import { getTheme } from '$lib/stores/theme.svelte'
|
||||
|
||||
// Ambient, non-interactive knowledge-graph backdrop. Purely decorative: the
|
||||
// host places this behind the page with pointer-events:none, so it never
|
||||
// steals clicks. The "alive" feeling comes entirely from the camera (slow
|
||||
// autonomous drift + mouse parallax + per-node depth), NOT from a live force
|
||||
// sim — we warm the layout up once, freeze it, then just pan a static field.
|
||||
|
||||
interface SimNode {
|
||||
id: string
|
||||
slug: string
|
||||
degree: number
|
||||
z: number // depth in [0,1] for parallax
|
||||
x?: number
|
||||
y?: number
|
||||
fx?: number | null
|
||||
fy?: number | null
|
||||
}
|
||||
interface SimLink {
|
||||
source: string | SimNode
|
||||
target: string | SimNode
|
||||
}
|
||||
|
||||
let host = $state<HTMLDivElement | null>(null)
|
||||
let canvas = $state<HTMLCanvasElement | null>(null)
|
||||
|
||||
let nodes: SimNode[] = []
|
||||
let links: SimLink[] = []
|
||||
let health: Record<string, Health> = {}
|
||||
|
||||
// World bounds the layout is centered in; camera pans within.
|
||||
const WORLD = 1400
|
||||
const MAX_NODES = 260
|
||||
|
||||
const healthColor: Record<Health, string> = {
|
||||
healthy: '#3fb950',
|
||||
degraded: '#d29922',
|
||||
down: '#f85149',
|
||||
unknown: '#8b949e'
|
||||
}
|
||||
|
||||
function nodeRadius(n: SimNode): number {
|
||||
return 3 + Math.min(Math.sqrt(n.degree) * 1.4, 7)
|
||||
}
|
||||
|
||||
async function loadGraph() {
|
||||
const graph = await fetchGraph({ depth: 3, includeStatus: true })
|
||||
if (!graph) return
|
||||
health = graph.health ?? {}
|
||||
|
||||
// degree by id, edges reference slugs
|
||||
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
|
||||
const degree = new Map<string, number>()
|
||||
for (const e of graph.edges) {
|
||||
const s = idBySlug.get(e.source) ?? e.source
|
||||
const t = idBySlug.get(e.target) ?? e.target
|
||||
degree.set(s, (degree.get(s) ?? 0) + 1)
|
||||
degree.set(t, (degree.get(t) ?? 0) + 1)
|
||||
}
|
||||
|
||||
let all: SimNode[] = graph.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
slug: n.slug,
|
||||
degree: degree.get(n.id) ?? 0,
|
||||
z: Math.random()
|
||||
}))
|
||||
// Cap to the most-connected nodes so large graphs stay cheap.
|
||||
if (all.length > MAX_NODES) {
|
||||
all = [...all].sort((a, b) => b.degree - a.degree).slice(0, MAX_NODES)
|
||||
}
|
||||
const keep = new Set(all.map((n) => n.id))
|
||||
nodes = all
|
||||
links = graph.edges
|
||||
.map((e) => ({ source: idBySlug.get(e.source) ?? e.source, target: idBySlug.get(e.target) ?? e.target }))
|
||||
.filter((l) => keep.has(l.source as string) && keep.has(l.target as string))
|
||||
|
||||
warmUpLayout()
|
||||
}
|
||||
|
||||
// Run the sim to a settled state without rendering each tick, then freeze.
|
||||
function warmUpLayout() {
|
||||
const sim: Simulation<SimNode, SimLink> = forceSimulation(nodes)
|
||||
.force('link', forceLink<SimNode, SimLink>(links).id((n) => n.id).distance(60).strength(0.5))
|
||||
.force('charge', forceManyBody().strength(-140).distanceMax(360))
|
||||
.force('center', forceCenter(0, 0))
|
||||
.force('collide', forceCollide<SimNode>((n) => nodeRadius(n) + 6))
|
||||
.stop()
|
||||
const ticks = Math.min(400, Math.max(120, nodes.length * 2))
|
||||
for (let i = 0; i < ticks; i++) sim.tick()
|
||||
sim.stop()
|
||||
}
|
||||
|
||||
// ─── camera + render loop ───────────────────────────────────────────────
|
||||
|
||||
let cam = { x: 0, y: 0 } // eased mouse-parallax offset
|
||||
let targetCam = { x: 0, y: 0 }
|
||||
let timer: ReturnType<typeof setTimeout> | 0 = 0
|
||||
let dpr = 1
|
||||
let w = 0
|
||||
let h = 0
|
||||
let dotCanvas: HTMLCanvasElement | null = null
|
||||
let lastDotDark: boolean | null = null
|
||||
|
||||
function drawDots(dark: boolean) {
|
||||
if (!dotCanvas) {
|
||||
dotCanvas = document.createElement('canvas')
|
||||
}
|
||||
dotCanvas.width = Math.round(w * dpr)
|
||||
dotCanvas.height = Math.round(h * dpr)
|
||||
const dctx = dotCanvas.getContext('2d')!
|
||||
dctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
dctx.clearRect(0, 0, w, h)
|
||||
dctx.fillStyle = dark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)'
|
||||
const spacing = 12
|
||||
for (let x = spacing; x < w; x += spacing) {
|
||||
for (let y = spacing; y < h; y += spacing) {
|
||||
dctx.beginPath()
|
||||
dctx.arc(x, y, 0.7, 0, Math.PI * 2)
|
||||
dctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!host) return
|
||||
const rect = host.getBoundingClientRect()
|
||||
const nx = (e.clientX - rect.left) / rect.width - 0.5 // -0.5..0.5
|
||||
const ny = (e.clientY - rect.top) / rect.height - 0.5
|
||||
targetCam = { x: -nx * 90, y: -ny * 90 } // small parallax nudge
|
||||
}
|
||||
|
||||
function resize() {
|
||||
if (!host || !canvas) return
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
w = host.clientWidth
|
||||
h = host.clientHeight
|
||||
canvas.width = Math.round(w * dpr)
|
||||
canvas.height = Math.round(h * dpr)
|
||||
dotCanvas = null // force redraw on next frame
|
||||
}
|
||||
|
||||
function colorForNode(n: SimNode): string {
|
||||
return healthColor[health[n.id] ?? 'unknown']
|
||||
}
|
||||
|
||||
// Driven by setTimeout rather than requestAnimationFrame: some embedding
|
||||
// contexts (iframed previews, backgrounded-but-visible panes) report
|
||||
// document.hidden = true and browsers fully suspend rAF callbacks there,
|
||||
// which would freeze this canvas forever. setTimeout keeps ticking
|
||||
// regardless, and ~30fps is plenty for a slow ambient drift.
|
||||
function draw(t: number) {
|
||||
timer = setTimeout(() => draw(performance.now()), 33)
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// ease parallax toward target
|
||||
cam.x += (targetCam.x - cam.x) * 0.05
|
||||
cam.y += (targetCam.y - cam.y) * 0.05
|
||||
|
||||
// autonomous drift (Lissajous pan + breathing zoom)
|
||||
const ts = t / 1000
|
||||
const driftX = Math.sin(ts * 0.05) * 70 + Math.sin(ts * 0.017) * 40
|
||||
const driftY = Math.cos(ts * 0.043) * 60 + Math.sin(ts * 0.023) * 30
|
||||
const zoom = 0.82 + Math.sin(ts * 0.03) * 0.03
|
||||
|
||||
const dark = getTheme() !== 'light'
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
if (lastDotDark !== dark) { dotCanvas = null; lastDotDark = dark }
|
||||
if (!dotCanvas) drawDots(dark)
|
||||
ctx.drawImage(dotCanvas!, 0, 0)
|
||||
|
||||
const cx = w / 2
|
||||
const cy = h / 2
|
||||
|
||||
// project a world point to screen, applying per-depth parallax
|
||||
function project(px: number, py: number, z: number) {
|
||||
const par = 0.5 + z // nearer nodes (higher z) move more
|
||||
const ox = (driftX + cam.x) * par
|
||||
const oy = (driftY + cam.y) * par
|
||||
return { x: cx + (px + ox) * zoom, y: cy + (py + oy) * zoom }
|
||||
}
|
||||
|
||||
// edges
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeStyle = dark ? 'rgba(140,175,230,0.28)' : 'rgba(60,90,140,0.22)'
|
||||
ctx.beginPath()
|
||||
for (const l of links) {
|
||||
const s = l.source as SimNode
|
||||
const tg = l.target as SimNode
|
||||
if (s.x == null || tg.x == null) continue
|
||||
const z = (s.z + tg.z) / 2
|
||||
const a = project(s.x, s.y!, z)
|
||||
const b = project(tg.x, tg.y!, z)
|
||||
const dx = b.x - a.x
|
||||
const dy = b.y - a.y
|
||||
const len = Math.max(Math.hypot(dx, dy), 1)
|
||||
const curve = Math.min(len * 0.15, 40)
|
||||
const mx = (a.x + b.x) / 2 - (dy / len) * curve
|
||||
const my = (a.y + b.y) / 2 + (dx / len) * curve
|
||||
ctx.moveTo(a.x, a.y)
|
||||
ctx.quadraticCurveTo(mx, my, b.x, b.y)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
// nodes (glow via radial gradient, cheap enough at this count)
|
||||
for (const n of nodes) {
|
||||
if (n.x == null || n.y == null) continue
|
||||
const p = project(n.x, n.y, n.z)
|
||||
const r = nodeRadius(n) * zoom * (0.7 + n.z * 0.6)
|
||||
const col = colorForNode(n)
|
||||
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 3.2)
|
||||
glow.addColorStop(0, hexA(col, dark ? 0.45 : 0.32))
|
||||
glow.addColorStop(1, hexA(col, 0))
|
||||
ctx.fillStyle = glow
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, r * 3.2, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.fillStyle = hexA(col, dark ? 0.7 : 0.55)
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, r, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// legibility scrim: dim only the center band where the UI sits, taper to
|
||||
// ~nothing at the edges so the graph (and its connections) stay visible
|
||||
// in the margins instead of being crushed everywhere equally.
|
||||
const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
|
||||
const base = dark ? '13,17,23' : '255,255,255'
|
||||
scrim.addColorStop(0, `rgba(${base},0.68)`)
|
||||
scrim.addColorStop(0.45, `rgba(${base},0.32)`)
|
||||
scrim.addColorStop(1, `rgba(${base},0.02)`)
|
||||
ctx.fillStyle = scrim
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
}
|
||||
|
||||
// "#rrggbb" + alpha -> rgba()
|
||||
function hexA(hex: string, a: number): string {
|
||||
const n = parseInt(hex.slice(1), 16)
|
||||
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadGraph()
|
||||
resize()
|
||||
const ro = new ResizeObserver(resize)
|
||||
if (host) ro.observe(host)
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
timer = setTimeout(() => draw(performance.now()), 33)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
ro.disconnect()
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div bind:this={host} class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<canvas bind:this={canvas} class="h-full w-full"></canvas>
|
||||
</div>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
|
||||
let {
|
||||
label,
|
||||
options,
|
||||
selected = $bindable(),
|
||||
colorFor
|
||||
}: {
|
||||
label: string
|
||||
options: string[]
|
||||
selected: Set<string>
|
||||
colorFor?: (option: string) => string
|
||||
} = $props()
|
||||
|
||||
function toggle(opt: string) {
|
||||
const next = new Set(selected)
|
||||
if (next.has(opt)) next.delete(opt)
|
||||
else next.add(opt)
|
||||
selected = next
|
||||
}
|
||||
|
||||
const allSelected = $derived(options.length > 0 && options.every((o) => selected.has(o)))
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline" size="sm" class="h-8 gap-1.5">
|
||||
{label}
|
||||
<span class="text-muted-foreground">{selected.size}/{options.length}</span>
|
||||
<ChevronDownIcon class="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content class="max-h-80 w-56 overflow-y-auto" align="start">
|
||||
<DropdownMenu.Item
|
||||
closeOnSelect={false}
|
||||
onSelect={() => { selected = allSelected ? new Set() : new Set(options) }}
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{allSelected ? 'Deselect all' : 'Select all'}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{#each options as opt}
|
||||
<DropdownMenu.CheckboxItem
|
||||
closeOnSelect={false}
|
||||
checked={selected.has(opt)}
|
||||
onCheckedChange={() => toggle(opt)}
|
||||
class="text-xs"
|
||||
>
|
||||
{#if colorFor}
|
||||
<span class="size-2 shrink-0 rounded-full" style="background: {colorFor(opt)}"></span>
|
||||
{/if}
|
||||
{opt}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
{/each}
|
||||
{#if options.length === 0}
|
||||
<p class="px-2 py-1.5 text-xs text-muted-foreground">No types loaded yet.</p>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
@@ -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}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onWindowKeydown} />
|
||||
|
||||
<div class="fixed inset-0 flex flex-col">
|
||||
<div class="relative min-h-0 flex-1 overflow-hidden" role="presentation">
|
||||
<GraphBackground />
|
||||
{#if bgActive}
|
||||
<div class="pointer-events-none absolute inset-0 z-0 overflow-hidden" aria-hidden="true" style={bgOuterStyle}>
|
||||
<div class="absolute" style={bgInnerStyle}></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ContextMenu.Root {onOpenChange}>
|
||||
<!-- The bare-desktop hit area. Placed before the icons/windows layers
|
||||
|
||||
@@ -32,6 +32,22 @@
|
||||
if (appId && !idx.has(appId)) wm.close(id)
|
||||
}
|
||||
})
|
||||
|
||||
// Keep an open app window's title in sync with its registry entry. The
|
||||
// title is copied into the window at open time and then persisted, so a
|
||||
// rename (e.g. "Knowledge Base" -> "Fleet") would otherwise stay stuck in
|
||||
// the titlebar/taskbar of any already-open or hydrated window until it was
|
||||
// closed and reopened. Mirrors the task-window title sync in windows.ts.
|
||||
$effect(() => {
|
||||
const idx = $appById
|
||||
for (const id of $wmState.order) {
|
||||
const appId = appIdFromWindowId(id)
|
||||
const app = appId ? idx.get(appId) : undefined
|
||||
if (app && $wmState.windows[id]?.title !== app.title) {
|
||||
wm.update(id, { title: app.title })
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div use:dk.desktop class="absolute inset-0 z-40 pointer-events-none">
|
||||
|
||||
7
web/src/lib/components/ui/slider/index.ts
Normal file
7
web/src/lib/components/ui/slider/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./slider.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Slider,
|
||||
};
|
||||
52
web/src/lib/components/ui/slider/slider.svelte
Normal file
52
web/src/lib/components/ui/slider/slider.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { Slider as SliderPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
orientation = "horizontal",
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Discriminated Unions + Destructing (required for bindable) do not
|
||||
get along, so we shut typescript up by casting `value` to `never`.
|
||||
-->
|
||||
<SliderPrimitive.Root
|
||||
bind:ref
|
||||
bind:value={value as never}
|
||||
data-slot="slider"
|
||||
{orientation}
|
||||
class={cn(
|
||||
"data-vertical:min-h-40 relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:w-auto data-vertical:flex-col",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ thumbItems })}
|
||||
<span
|
||||
data-slot="slider-track"
|
||||
data-orientation={orientation}
|
||||
class={cn(
|
||||
"bg-muted rounded-full data-horizontal:h-1.5 data-horizontal:w-full data-vertical:h-full data-vertical:w-1.5 bg-muted relative grow overflow-hidden data-horizontal:w-full data-vertical:h-full"
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
class={cn(
|
||||
"bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full"
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
{#each thumbItems as thumb (thumb.index)}
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
index={thumb.index}
|
||||
class="border-primary ring-ring/50 size-4 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden block shrink-0 select-none disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</SliderPrimitive.Root>
|
||||
82
web/src/lib/desktop-patterns.ts
Normal file
82
web/src/lib/desktop-patterns.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Desktop background patterns — CSS-only (no images), in the spirit of
|
||||
// magicpattern.design's "CSS backgrounds" gallery. Each pattern is written
|
||||
// with a single foreground color against `transparent` gaps rather than a
|
||||
// baked-in second color, so it composes correctly over the app's own
|
||||
// terracotta/carbon theme background (and follows the theme swap for free)
|
||||
// instead of needing its own light/dark variant. A separate, optional fill
|
||||
// color (see background.svelte.ts) sits underneath as `background-color`,
|
||||
// showing through the transparent gaps when the user wants one.
|
||||
|
||||
export type BackgroundPatternId = 'none' | 'dots' | 'bubbles' | 'grid' | 'stripes' | 'checks' | 'zigzag' | 'rings'
|
||||
|
||||
export interface PatternDef {
|
||||
id: BackgroundPatternId
|
||||
label: string
|
||||
// Returns a CSS declaration string (background-image/-size/-position/
|
||||
// -repeat) for the given foreground color and size multiplier (1 = the
|
||||
// pattern's natural/default tile size). Empty string = no pattern.
|
||||
css: (color: string, scale: number) => string
|
||||
}
|
||||
|
||||
// Rounds to 1 decimal — enough precision for a smooth size slider without
|
||||
// producing long floating-point tails in the generated CSS.
|
||||
function px(base: number, scale: number): string {
|
||||
return `${Math.round(Math.max(0.5, base * scale) * 10) / 10}px`
|
||||
}
|
||||
|
||||
export const PATTERNS: PatternDef[] = [
|
||||
{ id: 'none', label: 'None', css: () => '' },
|
||||
{
|
||||
id: 'dots',
|
||||
label: 'Dots',
|
||||
css: (c, s) =>
|
||||
`background-image: radial-gradient(${c} ${px(1.6, s)}, transparent ${px(1.6, s)}); background-size: ${px(18, s)} ${px(18, s)};`
|
||||
},
|
||||
{
|
||||
id: 'bubbles',
|
||||
label: 'Bubbles',
|
||||
css: (c, s) =>
|
||||
`background-image: radial-gradient(${c} 17%, transparent 18% 35%, transparent 36.5%), radial-gradient(${c} 17%, transparent 18% 35%, transparent 36.5%), radial-gradient(transparent 34%, ${c} 36% 68%, transparent 70%), repeating-linear-gradient(45deg, ${c} -12.5% 12.5%, transparent 0 37.5%); background-position: -${px(20, s)} -${px(20, s)}, ${px(20, s)} ${px(20, s)}, 0 0, 0 0; background-size: ${px(80, s)} ${px(80, s)}, ${px(80, s)} ${px(80, s)}, ${px(40, s)} ${px(40, s)}, ${px(80, s)} ${px(80, s)};`
|
||||
},
|
||||
{
|
||||
id: 'grid',
|
||||
label: 'Grid',
|
||||
css: (c, s) =>
|
||||
`background-image: linear-gradient(${c} 1px, transparent 1px), linear-gradient(90deg, ${c} 1px, transparent 1px); background-size: ${px(24, s)} ${px(24, s)};`
|
||||
},
|
||||
{
|
||||
id: 'stripes',
|
||||
label: 'Stripes',
|
||||
css: (c, s) => `background-image: repeating-linear-gradient(45deg, ${c} 0 ${px(2, s)}, transparent ${px(2, s)} ${px(14, s)});`
|
||||
},
|
||||
{
|
||||
id: 'checks',
|
||||
label: 'Checks',
|
||||
css: (c, s) =>
|
||||
`background-image: conic-gradient(${c} 90deg, transparent 90deg 180deg, ${c} 180deg 270deg, transparent 270deg); background-size: ${px(24, s)} ${px(24, s)};`
|
||||
},
|
||||
{
|
||||
id: 'zigzag',
|
||||
label: 'Zigzag',
|
||||
css: (c, s) =>
|
||||
`background-image: linear-gradient(135deg, ${c} 25%, transparent 25%), linear-gradient(225deg, ${c} 25%, transparent 25%), linear-gradient(45deg, ${c} 25%, transparent 25%), linear-gradient(315deg, ${c} 25%, transparent 25%); background-position: ${px(20, s)} 0, ${px(20, s)} 0, 0 0, 0 0; background-size: ${px(40, s)} ${px(40, s)}; background-repeat: repeat;`
|
||||
},
|
||||
{
|
||||
id: 'rings',
|
||||
label: 'Rings',
|
||||
css: (c, s) =>
|
||||
`background-image: repeating-radial-gradient(circle at 50% 50%, ${c} 0, ${c} ${px(1, s)}, transparent ${px(1, s)}, transparent ${px(14, s)});`
|
||||
}
|
||||
]
|
||||
|
||||
const byId = new Map(PATTERNS.map((p) => [p.id, p]))
|
||||
|
||||
export function patternDef(id: BackgroundPatternId): PatternDef {
|
||||
return byId.get(id) ?? PATTERNS[0]
|
||||
}
|
||||
|
||||
// Full inline-style CSS text for a pattern + color + size, ready to drop
|
||||
// into a `style` attribute. Empty string for 'none' (or unknown ids).
|
||||
export function patternCss(id: BackgroundPatternId, color: string, scale = 1): string {
|
||||
return patternDef(id).css(color, scale)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
// The desktop mascot sprite: a small canvas that renders the current
|
||||
// animation frame at ~30fps (via setTimeout, not rAF — matches
|
||||
// GraphBackground.svelte's convention for hidden-tab embedding safety),
|
||||
// and handles pointer drag, plain-click (pet), and right-click (open
|
||||
// the radial menu). Position/physics live in MascotRuntime, owned
|
||||
// here; long-lived tamagotchi state lives in state.svelte.ts.
|
||||
// animation frame at ~30fps (via setTimeout, not rAF, for hidden-tab
|
||||
// embedding safety — see LOOP_MS below), and handles pointer drag,
|
||||
// plain-click (pet), and right-click (open the radial menu).
|
||||
// Position/physics live in MascotRuntime, owned here; long-lived
|
||||
// tamagotchi state lives in state.svelte.ts.
|
||||
//
|
||||
// The mascot renders above the window layer (z-45 via MascotLayer) but
|
||||
// its pointer hitbox is exactly the canvas element — no oversized
|
||||
@@ -72,11 +72,10 @@
|
||||
const TOSS_MAX_VX = 900 // px/s
|
||||
const TOSS_MAX_UPWARD_VY = 700 // px/s (a hard upward flick can toss it up a bit before gravity wins)
|
||||
const TOSS_MAX_DOWNWARD_VY = 400 // px/s (don't let a fast downward fling outrun the fall's own gravity feel)
|
||||
// 60fps for the sprite loop: the mascot has faster motion (drag, fall)
|
||||
// than GraphBackground's slow ambient drift, and 30fps position updates
|
||||
// look choppy on 60Hz+ displays. setTimeout (not rAF) per the repo
|
||||
// convention — some embedding contexts report document.hidden=true and
|
||||
// suspend rAF; setTimeout keeps ticking. dt is clamped below so a
|
||||
// 60fps for the sprite loop: the mascot has fast motion (drag, fall), and
|
||||
// 30fps position updates look choppy on 60Hz+ displays. setTimeout, not
|
||||
// rAF: some embedding contexts report document.hidden=true and suspend
|
||||
// rAF; setTimeout keeps ticking. dt is clamped below so a
|
||||
// throttled/backgrounded tab doesn't produce a physics-breaking huge
|
||||
// step on resume.
|
||||
const LOOP_MS = 16
|
||||
|
||||
118
web/src/lib/stores/background.svelte.ts
Normal file
118
web/src/lib/stores/background.svelte.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { PATTERNS, type BackgroundPatternId } from '$lib/desktop-patterns'
|
||||
|
||||
export interface BackgroundConfig {
|
||||
pattern: BackgroundPatternId
|
||||
color: string // pattern foreground color, hex
|
||||
fillColor: string | null // fill behind the pattern (its "gaps"); null = transparent, i.e. the app's own theme background shows through
|
||||
opacity: number // 0..1
|
||||
fade: number // 0..1 — 0 disables the vignette mask entirely
|
||||
scale: number // pattern tile-size multiplier; 1 = natural size
|
||||
rotation: number // degrees, 0..359
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'oikos-desktop-bg'
|
||||
const VALID_IDS = new Set(PATTERNS.map((p) => p.id))
|
||||
|
||||
// Matches the previous hardcoded desktop background (bubbles, brand blue,
|
||||
// tuned low so it reads as texture rather than noise) so this feature ships
|
||||
// as "now configurable" rather than a visual regression on first load.
|
||||
const DEFAULTS: BackgroundConfig = {
|
||||
pattern: 'bubbles',
|
||||
color: '#444cf7',
|
||||
fillColor: null,
|
||||
opacity: 0.08,
|
||||
fade: 0,
|
||||
scale: 1,
|
||||
rotation: 0
|
||||
}
|
||||
|
||||
function clamp01(v: unknown, fallback: number): number {
|
||||
const n = typeof v === 'number' ? v : Number(v)
|
||||
if (!Number.isFinite(n)) return fallback
|
||||
return Math.min(1, Math.max(0, n))
|
||||
}
|
||||
|
||||
function clampScale(v: unknown): number {
|
||||
const n = typeof v === 'number' ? v : Number(v)
|
||||
if (!Number.isFinite(n)) return DEFAULTS.scale
|
||||
return Math.min(3, Math.max(0.4, n))
|
||||
}
|
||||
|
||||
function clampRotation(v: unknown): number {
|
||||
const n = typeof v === 'number' ? v : Number(v)
|
||||
if (!Number.isFinite(n)) return DEFAULTS.rotation
|
||||
return ((n % 360) + 360) % 360
|
||||
}
|
||||
|
||||
function isHexColor(c: unknown): c is string {
|
||||
return typeof c === 'string' && /^#[0-9a-fA-F]{6}$/.test(c)
|
||||
}
|
||||
|
||||
function storedConfig(): BackgroundConfig {
|
||||
if (typeof localStorage === 'undefined') return DEFAULTS
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return DEFAULTS
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return {
|
||||
pattern: VALID_IDS.has(parsed.pattern) ? parsed.pattern : DEFAULTS.pattern,
|
||||
color: isHexColor(parsed.color) ? parsed.color : DEFAULTS.color,
|
||||
fillColor: parsed.fillColor === null ? null : isHexColor(parsed.fillColor) ? parsed.fillColor : DEFAULTS.fillColor,
|
||||
opacity: clamp01(parsed.opacity, DEFAULTS.opacity),
|
||||
fade: clamp01(parsed.fade, DEFAULTS.fade),
|
||||
scale: clampScale(parsed.scale),
|
||||
rotation: clampRotation(parsed.rotation)
|
||||
}
|
||||
} catch {
|
||||
return DEFAULTS
|
||||
}
|
||||
}
|
||||
|
||||
let config: BackgroundConfig = $state(storedConfig())
|
||||
|
||||
function persist(): void {
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||
}
|
||||
|
||||
export function getBackground(): BackgroundConfig {
|
||||
return config
|
||||
}
|
||||
|
||||
export function setBackgroundPattern(pattern: BackgroundPatternId): void {
|
||||
config = { ...config, pattern }
|
||||
persist()
|
||||
}
|
||||
|
||||
export function setPatternColor(color: string): void {
|
||||
if (!isHexColor(color)) return
|
||||
config = { ...config, color }
|
||||
persist()
|
||||
}
|
||||
|
||||
// null clears back to "auto" (transparent — the app's theme background
|
||||
// shows through the pattern's gaps).
|
||||
export function setFillColor(color: string | null): void {
|
||||
if (color !== null && !isHexColor(color)) return
|
||||
config = { ...config, fillColor: color }
|
||||
persist()
|
||||
}
|
||||
|
||||
export function setBackgroundOpacity(opacity: number): void {
|
||||
config = { ...config, opacity: clamp01(opacity, DEFAULTS.opacity) }
|
||||
persist()
|
||||
}
|
||||
|
||||
export function setBackgroundFade(fade: number): void {
|
||||
config = { ...config, fade: clamp01(fade, DEFAULTS.fade) }
|
||||
persist()
|
||||
}
|
||||
|
||||
export function setBackgroundScale(scale: number): void {
|
||||
config = { ...config, scale: clampScale(scale) }
|
||||
persist()
|
||||
}
|
||||
|
||||
export function setBackgroundRotation(rotation: number): void {
|
||||
config = { ...config, rotation: clampRotation(rotation) }
|
||||
persist()
|
||||
}
|
||||
@@ -3,9 +3,7 @@
|
||||
import { fetchAllEntities, fetchOntology, fetchGraph, type Entity, type EntityType, type Ontology } from '$lib/api'
|
||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||
import EntityTable from '$lib/components/EntityTable.svelte'
|
||||
import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte'
|
||||
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte'
|
||||
import { typeToCategory, type Category } from '$lib/categories'
|
||||
import FleetMap from '$lib/components/FleetMap.svelte'
|
||||
import { openEntityWindow, wmState } from '$lib/stores/windows'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
@@ -13,7 +11,6 @@
|
||||
import { Label } from '$lib/components/ui/label'
|
||||
import NetworkIcon from '@lucide/svelte/icons/share-2'
|
||||
import TableIcon from '@lucide/svelte/icons/table-2'
|
||||
import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed'
|
||||
|
||||
type View = 'graph' | 'table'
|
||||
|
||||
@@ -49,22 +46,17 @@
|
||||
if (lastOpened && !$wmState.windows[lastOpened]) lastOpened = null
|
||||
})
|
||||
|
||||
// ─── entities: fetched here (not inside EntityTable) so the search/type
|
||||
// toolbar lives in the shared page toolbar instead of the resizable browse
|
||||
// pane, where its width is at the mercy of the divider and it would
|
||||
// truncate. Both views now share the same full entity set — there's no
|
||||
// more per-category server-side scoping, only the client-side type
|
||||
// multiselect (activeTypes) below, which both the table (row visibility)
|
||||
// and the graph (node visibility) read from.
|
||||
// ─── entities: fetched here (not inside EntityTable) so the search toolbar
|
||||
// lives in the shared page toolbar instead of the resizable browse pane,
|
||||
// where its width is at the mercy of the divider and it would truncate.
|
||||
// Both views show the same set: the *fleet* — the exact scope of the fleet
|
||||
// map (see isFleetType), not a generic browse-everything entity list.
|
||||
let allEntities = $state<Entity[]>([])
|
||||
let entitiesLoading = $state(true)
|
||||
let showInactive = $state(false)
|
||||
// child entity slug -> parent entity slug, derived from the ontology graph
|
||||
// (see loadGrouping). Feeds EntityTable's treegrid grouping, nesting e.g.
|
||||
// host -> lxc -> service, or storage-pool -> volume -> dataset — computed
|
||||
// over the whole entity set so the hierarchy doesn't reshuffle as the type
|
||||
// filter is toggled (EntityTable falls back a filtered-out parent's
|
||||
// children to top-level rather than dropping them).
|
||||
// (see loadGrouping). Feeds EntityTable's treegrid grouping, nesting the
|
||||
// fleet exactly as the fleet map's lanes do: host -> lxc/vm -> service.
|
||||
let childToParent = $state<Map<string, string> | null>(null)
|
||||
|
||||
let ontologyPromise: Promise<Ontology> | null = null
|
||||
@@ -73,9 +65,21 @@
|
||||
return ontologyPromise
|
||||
}
|
||||
|
||||
// type -> browsing category (see categories.ts), used only to seed the
|
||||
// type multiselect's default selection ("fleet") — not to scope any fetch.
|
||||
let typeCategory = $state<Map<string, Category | undefined>>(new Map())
|
||||
// The fleet = the same three kinds the fleet map shows: machines, VMs and
|
||||
// containers (everything under the abstract `compute-entity` root) plus
|
||||
// `service`. Derived from the ontology's own parent chain, not a hardcoded
|
||||
// type list, so any new compute/container subtype is included for free —
|
||||
// and generic software-domain types (config-repo, deploy-pipeline,
|
||||
// cluster, ...) that aren't fleet topology stay out.
|
||||
function isFleetType(byName: Map<string, EntityType>, typeName: string): boolean {
|
||||
if (typeName === 'service') return true
|
||||
let t = byName.get(typeName)
|
||||
for (let i = 0; t && i < 10; i++) {
|
||||
if (t.name === 'compute-entity') return true
|
||||
t = t.parent_type ? byName.get(t.parent_type) : undefined
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Distance from the ontology's abstract root ("entity") down to typeName —
|
||||
// 0 for entity itself, 1 for its direct subtypes, etc. Used as a
|
||||
@@ -99,8 +103,9 @@
|
||||
// parent (e.g. many hosts are located-at one site). many-to-many
|
||||
// relationships (mounts, stores-on, backs-up-to, ...) have no single
|
||||
// parent, so they're excluded from tree nesting. A candidate parent that
|
||||
// isn't actually part of the set being browsed (e.g. `cluster`, filtered
|
||||
// out below) is dropped rather than kept as a dangling pointer — that's
|
||||
// isn't actually part of the set being browsed (e.g. `cluster` or `site`,
|
||||
// outside the fleet scope) is dropped rather than kept as a dangling
|
||||
// pointer — that's
|
||||
// also what lets `located-at` surface as a host's parent instead of
|
||||
// `member-of` without any special-cased priority: with cluster absent,
|
||||
// member-of simply has nothing valid to point at. An entity can still be
|
||||
@@ -142,10 +147,13 @@
|
||||
|
||||
async function loadEntities() {
|
||||
entitiesLoading = true
|
||||
// cluster entities are dropped so a host's `member-of` edge has no valid
|
||||
// parent to point at, leaving `located-at` (site) as the only remaining
|
||||
// tree-parent candidate (see loadGrouping).
|
||||
const fetched = (await fetchAllEntities()).filter((e) => e.type !== 'cluster')
|
||||
const { entityTypes } = await getOntology()
|
||||
const byName = new Map(entityTypes.map((t) => [t.name, t]))
|
||||
// Scope to the fleet up front. This also drops non-fleet parents (site,
|
||||
// cluster) from the grouping input, so a host's `located-at`/`member-of`
|
||||
// edge has no valid parent to point at and the host stays top-level —
|
||||
// which is exactly the fleet map's arrangement (hosts anchor the tree).
|
||||
const fetched = (await fetchAllEntities()).filter((e) => isFleetType(byName, e.type))
|
||||
childToParent = await loadGrouping(fetched)
|
||||
allEntities = fetched
|
||||
entitiesLoading = false
|
||||
@@ -153,9 +161,6 @@
|
||||
|
||||
onMount(() => {
|
||||
loadEntities()
|
||||
getOntology().then((o) => {
|
||||
typeCategory = new Map(o.entityTypes.map((t) => [t.name, typeToCategory(t.name, t.domain)]))
|
||||
})
|
||||
const unsubscribe = subscribeEvents()
|
||||
return unsubscribe
|
||||
})
|
||||
@@ -166,25 +171,9 @@
|
||||
loadEntities()
|
||||
})
|
||||
|
||||
const allTypes = $derived(Array.from(new Set(allEntities.map((e) => e.type))).sort())
|
||||
|
||||
// Shared show/hide-by-type filter — governs both the table's row
|
||||
// visibility and the graph's node visibility. Seeded once (not
|
||||
// re-derived) to "fleet" types as soon as both the entity set and the
|
||||
// ontology's type->category map are loaded, so it doesn't clobber the
|
||||
// user's own toggles on a later reload.
|
||||
let activeTypes = $state<Set<string>>(new Set())
|
||||
let typesSeeded = false
|
||||
$effect(() => {
|
||||
if (typesSeeded || allTypes.length === 0 || typeCategory.size === 0) return
|
||||
activeTypes = new Set(allTypes.filter((t) => typeCategory.get(t) === 'fleet'))
|
||||
typesSeeded = true
|
||||
})
|
||||
|
||||
const filteredEntities = $derived.by(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
return allEntities.filter((e) => {
|
||||
if (!activeTypes.has(e.type)) return false
|
||||
if (q && !e.slug.toLowerCase().includes(q) && !e.name.toLowerCase().includes(q)) return false
|
||||
// entities with no tracked lifecycle state (state is null) aren't
|
||||
// "destroyed or inactive" — only hide ones whose tracked state has
|
||||
@@ -193,41 +182,14 @@
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// ─── graph controls: same reasoning as the table toolbar above — these
|
||||
// live here instead of inside EntityGraph so they render at full toolbar
|
||||
// width instead of being squeezed by the resizable browse pane.
|
||||
let graphRoot = $state('')
|
||||
let graphDepth = $state(2)
|
||||
let graphReloadToken = $state(0)
|
||||
let graphResetToken = $state(0)
|
||||
let graphActiveRelTypes = $state<Set<string>>(new Set())
|
||||
let graphInfo = $state<GraphInfo>({ allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
|
||||
|
||||
function commitGraphQuery() {
|
||||
graphReloadToken++
|
||||
}
|
||||
|
||||
function resetGraph() {
|
||||
graphRoot = ''
|
||||
search = ''
|
||||
graphResetToken++
|
||||
}
|
||||
|
||||
function relColorFor(type: string): string {
|
||||
return graphInfo.relColors.get(type) ?? '#30363d'
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-3 p-4">
|
||||
<!-- single toolbar row: search + type filter are shared by both views
|
||||
(one multiselect instead of a category tab, a single-select "All
|
||||
types" dropdown, and a separate graph node-type toggle), the rest is
|
||||
view-specific, and the graph/table switch sits inline with the rest
|
||||
instead of floating in its own row. -->
|
||||
<!-- single toolbar row: the search box is shared by both views (graph
|
||||
highlights nodes, table filters rows); the Inactive toggle + count are
|
||||
table-only; the graph/table switch sits inline on the right. -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Input placeholder="Filter / highlight by slug or name…" bind:value={search} class="h-8 max-w-xs text-xs" />
|
||||
<MultiSelectFilter label="Types" options={allTypes} bind:selected={activeTypes} />
|
||||
|
||||
{#if view === 'table'}
|
||||
<div class="flex items-center gap-1.5">
|
||||
@@ -235,17 +197,6 @@
|
||||
<Label for="show-inactive" class="text-xs font-normal text-muted-foreground">Inactive</Label>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">{filteredEntities.length} of {allEntities.length}</span>
|
||||
{:else}
|
||||
<Input placeholder="Root entity…" bind:value={graphRoot} class="h-8 max-w-40 text-xs" onchange={commitGraphQuery} />
|
||||
<Input type="number" min="1" max="5" bind:value={graphDepth} class="h-8 w-14 text-xs" onchange={commitGraphQuery} />
|
||||
<Button variant="outline" size="sm" class="h-8" onclick={resetGraph}>
|
||||
<LocateFixedIcon class="mr-1 size-3.5" />
|
||||
Reset
|
||||
</Button>
|
||||
<MultiSelectFilter label="Edges" options={graphInfo.allRelTypes} bind:selected={graphActiveRelTypes} colorFor={relColorFor} />
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{graphInfo.visibleCount} nodes{graphInfo.truncated ? ' · truncated' : ''} · {graphInfo.zoomPct}%
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<div class="ml-auto inline-flex overflow-hidden rounded-md border">
|
||||
@@ -276,18 +227,7 @@
|
||||
(WindowLayer, mounted globally inside Desktop.svelte) instead of a sidebar. -->
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{#if view === 'graph'}
|
||||
<EntityGraph
|
||||
selectedSlug={lastOpened}
|
||||
onSelect={select}
|
||||
bind:root={graphRoot}
|
||||
depth={graphDepth}
|
||||
{search}
|
||||
reloadToken={graphReloadToken}
|
||||
resetToken={graphResetToken}
|
||||
activeNodeTypes={activeTypes}
|
||||
bind:activeRelTypes={graphActiveRelTypes}
|
||||
bind:info={graphInfo}
|
||||
/>
|
||||
<FleetMap selectedSlug={lastOpened} onSelect={select} {search} />
|
||||
{:else}
|
||||
<EntityTable entities={filteredEntities} loading={entitiesLoading} selectedSlug={lastOpened} onSelect={select} {childToParent} />
|
||||
{/if}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user