diff --git a/web/.prettierrc.json b/web/.prettierrc.json index 80e397b..9372aab 100644 --- a/web/.prettierrc.json +++ b/web/.prettierrc.json @@ -1,6 +1,7 @@ { "useTabs": false, "tabWidth": 2, + "semi": false, "singleQuote": true, "trailingComma": "none", "printWidth": 100, diff --git a/web/components.json b/web/components.json index 0094c85..a2b1686 100644 --- a/web/components.json +++ b/web/components.json @@ -1,17 +1,17 @@ { - "$schema": "https://shadcn-svelte.com/schema.json", - "style": "vega", - "tailwind": { - "css": "src/app.css", - "baseColor": "zinc" - }, - "aliases": { - "components": "$lib/components", - "utils": "$lib/utils", - "ui": "$lib/components/ui", - "hooks": "$lib/hooks", - "lib": "$lib" - }, - "typescript": true, - "registry": "https://shadcn-svelte.com/registry" + "$schema": "https://shadcn-svelte.com/schema.json", + "style": "vega", + "tailwind": { + "css": "src/app.css", + "baseColor": "zinc" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry" } diff --git a/web/index.html b/web/index.html index 6d405e6..63aff8c 100644 --- a/web/index.html +++ b/web/index.html @@ -1,4 +1,4 @@ - + @@ -6,7 +6,10 @@ Oikos - + @@ -15,11 +18,20 @@
- + diff --git a/web/src/app.css b/web/src/app.css index a99b7d4..48e26ed 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -84,7 +84,7 @@ --chart-3: oklch(0.5 0.08 30); --chart-4: oklch(0.6 0.06 90); --chart-5: oklch(0.4 0.04 45); - --sidebar: oklch(0.90 0.025 55); + --sidebar: oklch(0.9 0.025 55); --sidebar-foreground: oklch(0.18 0.03 45); --sidebar-primary: oklch(0.55 0.14 45); --sidebar-primary-foreground: oklch(0.95 0.02 55); @@ -159,7 +159,6 @@ --accent-orange: var(--warning); } - /* Terminal-style block cursor — outside @layer so it overrides CodeMirror */ .cm-cursor, .cm-cursor-primary { @@ -181,7 +180,12 @@ -webkit-font-smoothing: antialiased; } - h1, h2, h3, h4, h5, h6 { + h1, + h2, + h3, + h4, + h5, + h6 { font-family: var(--font-heading); } @@ -274,7 +278,6 @@ outline: none; } - [data-wm-window][data-wm-focused] { border-color: var(--ring); box-shadow: 0 12px 32px oklch(0 0 0 / 0.28); @@ -289,7 +292,6 @@ display: none; } - [data-wm-resize] { position: absolute; } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 36ee6b8..b754962 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -112,7 +112,11 @@ export async function fetchQuestions(sessionId: string): Promise { +export async function answerQuestion( + sessionId: string, + questionId: string, + answer: string +): Promise { const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, { method: 'POST', body: JSON.stringify({ answer }) @@ -133,42 +137,45 @@ export function streamChat( method: 'POST', body: JSON.stringify({ message, session_id: sessionId ?? undefined }), signal: controller.signal - }).then(async (res) => { - if (!res.ok) { - onError(`HTTP ${res.status}`) - return - } - const reader = res.body?.getReader() - if (!reader) { - onError('no response body') - return - } - const decoder = new TextDecoder() - let buffer = '' + }) + .then(async (res) => { + if (!res.ok) { + onError(`HTTP ${res.status}`) + return + } + const reader = res.body?.getReader() + if (!reader) { + onError('no response body') + return + } + const decoder = new TextDecoder() + let buffer = '' - while (true) { - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() ?? '' + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' - for (const line of lines) { - if (line.startsWith('data: ')) { - try { - const ev: ChatEvent = JSON.parse(line.slice(6)) - onEvent(ev) - } catch { - // skip malformed + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const ev: ChatEvent = JSON.parse(line.slice(6)) + onEvent(ev) + } catch { + // skip malformed + } } } } - } - }).catch((err) => { - onError(err.message) - }).finally(() => { - onDone() - }) + }) + .catch((err) => { + onError(err.message) + }) + .finally(() => { + onDone() + }) return controller } @@ -291,7 +298,9 @@ export interface EventFilters { severity?: string } -export async function fetchEvents(filters: EventFilters = {}): Promise { +export async function fetchEvents( + filters: EventFilters = {} +): Promise { const params = new URLSearchParams() if (filters.type) params.set('type', filters.type) if (filters.severity) params.set('severity', filters.severity) @@ -483,7 +492,9 @@ export interface Signal { last_seen_at: string } -export async function fetchSignals(filters: { state?: string; severity?: string } = {}): Promise { +export async function fetchSignals( + filters: { state?: string; severity?: string } = {} +): Promise { const params = new URLSearchParams() if (filters.state) params.set('state', filters.state) if (filters.severity) params.set('severity', filters.severity) @@ -509,7 +520,11 @@ export async function resolveSignal(id: string, note?: string): Promise { +export async function muteSignal( + id: string, + muteUntil: string, + note?: string +): Promise { const res = await fetchWithAuth(`${API}/signals/${id}/mute`, { method: 'POST', body: JSON.stringify({ mute_until: muteUntil, note }) @@ -533,7 +548,9 @@ export interface Relationship { // reachable going forward from here), this hits a dedicated endpoint that // matches on source_id OR target_id directly. export async function fetchEntityRelations(id: string): Promise { - const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}/relations?direction=both`) + const res = await fetchWithAuth( + `${API}/entities/${encodeURIComponent(id)}/relations?direction=both` + ) if (!res.ok) return [] const data = await res.json() return data.items ?? [] @@ -671,7 +688,9 @@ export async function fetchKnowledgeContent(id: string): Promise { +export async function fetchEntityEvents( + entityId: string +): Promise { const params = new URLSearchParams({ entity_id: entityId, limit: '50' }) const res = await fetchWithAuth(`${API}/events?${params}`) if (!res.ok) return [] @@ -718,13 +737,19 @@ export async function fetchEntityTasks(entity: Entity): Promise { tasks.map(async (task): Promise => { const g = await fetchGraph({ root: task.slug, depth: 1 }) if (!g) return null - const involvesThisEntity = g.edges.some((e) => e.type === 'involves' && e.target === entity.slug) + const involvesThisEntity = g.edges.some( + (e) => e.type === 'involves' && e.target === entity.slug + ) const nodeTypeById = new Map(g.nodes.map((n) => [n.id, n.type])) const idBySlug = new Map(g.nodes.map((n) => [n.slug, n.id])) const executionCount = g.edges.filter((e) => { if (e.type !== 'involves') return false const targetId = idBySlug.get(e.target) - return targetId != null && nodeTypeById.get(targetId) === 'execution' && executionIds.has(targetId) + return ( + targetId != null && + nodeTypeById.get(targetId) === 'execution' && + executionIds.has(targetId) + ) }).length if (!involvesThisEntity && executionCount === 0) return null return { task, executionCount } @@ -755,7 +780,11 @@ export async function fetchChecksForTarget(targetSlug: string): Promise return data.items ?? [] } -export async function patchCheck(id: string, version: number, patch: { enabled?: boolean; interval_s?: number; timeout_s?: number }): Promise { +export async function patchCheck( + id: string, + version: number, + patch: { enabled?: boolean; interval_s?: number; timeout_s?: number } +): Promise { const res = await fetchWithAuth(`${API}/checks/${id}`, { method: 'PATCH', headers: { 'If-Match': `"${version}"` }, @@ -781,12 +810,14 @@ export interface AgentActivity { correlation_id?: string | null } -export async function fetchAgentActivity(filters: { - agent_id?: string - activity_type?: string - entity_id?: string - limit?: number -} = {}): Promise { +export async function fetchAgentActivity( + filters: { + agent_id?: string + activity_type?: string + entity_id?: string + limit?: number + } = {} +): Promise { const params = new URLSearchParams() if (filters.agent_id) params.set('agent_id', filters.agent_id) if (filters.activity_type) params.set('activity_type', filters.activity_type) @@ -821,14 +852,16 @@ export interface AuditEntry { correlation_id?: string | null } -export async function fetchAudit(filters: { - actor_type?: string - actor_id?: string - entity_id?: string - action?: string - correlation_id?: string - limit?: number -} = {}): Promise { +export async function fetchAudit( + filters: { + actor_type?: string + actor_id?: string + entity_id?: string + action?: string + correlation_id?: string + limit?: number + } = {} +): Promise { const params = new URLSearchParams() if (filters.actor_type) params.set('actor_type', filters.actor_type) if (filters.actor_id) params.set('actor_id', filters.actor_id) diff --git a/web/src/lib/app-store/apps/Notes.svelte b/web/src/lib/app-store/apps/Notes.svelte index 5884a5d..37b6582 100644 --- a/web/src/lib/app-store/apps/Notes.svelte +++ b/web/src/lib/app-store/apps/Notes.svelte @@ -50,8 +50,8 @@ class="min-h-0 flex-1 resize-none rounded-md border bg-background p-3 font-mono text-sm leading-relaxed focus-visible:outline-2 focus-visible:outline-ring" >

- A demo installable app — uninstall it from the App Store to remove its - icon and window. Its notes persist in localStorage under + A demo installable app — uninstall it from the App Store to remove its icon and window. Its + notes persist in localStorage under {storageKey}.

diff --git a/web/src/lib/apps.ts b/web/src/lib/apps.ts index 07c0777..3ba1d6f 100644 --- a/web/src/lib/apps.ts +++ b/web/src/lib/apps.ts @@ -23,7 +23,12 @@ import type { Component } from 'svelte' import { writable, derived, get, type Readable } from 'svelte/store' 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 { + catalogById, + type AppManifest, + type AppPermission, + type CatalogEntry +} from '$lib/app-store/catalog' import ListTodoIcon from '@lucide/svelte/icons/list-todo' import BoxesIcon from '@lucide/svelte/icons/boxes' import ShieldCheckIcon from '@lucide/svelte/icons/shield-check' @@ -237,8 +242,9 @@ export const apps: Readable = derived(installedIds, (ids) => { return [...builtinApps, ...installed] }) -export const appById: Readable> = derived(apps, (list) => - new Map(list.map((a) => [a.id, a])) +export const appById: Readable> = derived( + apps, + (list) => new Map(list.map((a) => [a.id, a])) ) // Install/uninstall. Idempotent — installing an already-installed app or diff --git a/web/src/lib/components/AgentTrace.svelte b/web/src/lib/components/AgentTrace.svelte index d6a17cc..facb27c 100644 --- a/web/src/lib/components/AgentTrace.svelte +++ b/web/src/lib/components/AgentTrace.svelte @@ -37,14 +37,23 @@ }) -
+
@@ -82,7 +93,9 @@ {/each} {:else} -

Nothing recorded for this turn yet.

+

+ Nothing recorded for this turn yet. +

{/if}
{/if} diff --git a/web/src/lib/components/ChatThread.svelte b/web/src/lib/components/ChatThread.svelte index 5837de1..7e753b8 100644 --- a/web/src/lib/components/ChatThread.svelte +++ b/web/src/lib/components/ChatThread.svelte @@ -60,10 +60,16 @@ let wasStreaming = $state(false) $effect(() => { - if (streaming) { indicatorDone = false; wasStreaming = true } + if (streaming) { + indicatorDone = false + wasStreaming = true + } if (!streaming && wasStreaming) { indicatorDone = true - const t = setTimeout(() => { indicatorDone = false; wasStreaming = false }, 3000) + const t = setTimeout(() => { + indicatorDone = false + wasStreaming = false + }, 3000) return () => clearTimeout(t) } }) @@ -91,7 +97,10 @@ const lineHeight = parseFloat(taCs.lineHeight) if (!Number.isFinite(lineHeight)) return const taBoxY = - parseFloat(taCs.paddingTop) + parseFloat(taCs.paddingBottom) + parseFloat(taCs.borderTopWidth) + parseFloat(taCs.borderBottomWidth) + parseFloat(taCs.paddingTop) + + parseFloat(taCs.paddingBottom) + + parseFloat(taCs.borderTopWidth) + + parseFloat(taCs.borderBottomWidth) // The wrapper's own padding/border (space around the textarea, not part // of it) also has to fit inside the minimum, or the textarea gets // squeezed below one line once the pane is dragged down to it. @@ -144,7 +153,9 @@ } renderer.table = function (token) { const header = token.header.map((c: { text: string }) => `${c.text}`).join('') - const body = token.rows.map((r: { text: string }[]) => `${r.map((c) => `${c.text}`).join('')}`).join('') + const body = token.rows + .map((r: { text: string }[]) => `${r.map((c) => `${c.text}`).join('')}`) + .join('') return `
${header}${body}
` } return DOMPurify.sanitize(marked.parse(text, { async: false, renderer }) as string) @@ -181,126 +192,172 @@
- (userResizedInput = true)}> + (userResizedInput = true)} + > -
-
- {#if messages.length === 0} -
-
-

Nomos

-

Your resident operator. Ask about the fleet, or tell it to act.

-
- {#if suggestions.length} -
- {#each suggestions as q} - - {/each} -
- {/if} -
- {/if} - - {#each messages as msg, idx (msg.id)} -
- {#if msg.role === 'user'} -
- You - {#if msg.created_at} - {formatTime(msg.created_at)} - {/if} -
-
{msg.text}
- {:else} - {@const isLast = idx === messages.length - 1} - {@const traceStatus = !isLast - ? 'idle' - : error - ? 'error' - : streaming - ? 'running' - : indicatorDone - ? 'done' - : 'idle'} -
-
- Nomos - {#if msg.created_at} - {formatTime(msg.created_at)} - {/if} +
+
+ {#if messages.length === 0} +
+
+

Nomos

+

+ Your resident operator. Ask about the fleet, or tell it to act. +

- - {#if msg.tools.length > 0 || traceStatus !== 'idle'} - - {/if} - {#if msg.text} -
- - {@html render(msg.text)} - {#if isLast && streaming} - - {/if} + {#if suggestions.length} +
+ {#each suggestions as q} + + {/each}
{/if}
{/if} + + {#each messages as msg, idx (msg.id)} +
+ {#if msg.role === 'user'} +
+ You + {#if msg.created_at} + {formatTime(msg.created_at)} + {/if} +
+
+ {msg.text} +
+ {:else} + {@const isLast = idx === messages.length - 1} + {@const traceStatus = !isLast + ? 'idle' + : error + ? 'error' + : streaming + ? 'running' + : indicatorDone + ? 'done' + : 'idle'} +
+
+ Nomos + {#if msg.created_at} + {formatTime(msg.created_at)} + {/if} +
+ + {#if msg.tools.length > 0 || traceStatus !== 'idle'} + + {/if} + {#if msg.text} +
+ + {@html render(msg.text)} + {#if isLast && streaming} + + {/if} +
+ {/if} +
+ {/if} +
+ {/each} + {#if question} + + {/if} +
+
+
+ + {#if connectionState === 'disconnected'} +
+
+
+
+ {:else if connectionState === 'reconnecting'} +
+
+
+
+ {/if} + + {#if error} +
+
+ {error} +
+
+ {/if} + + {#each chatErrors as err (err.id)} +
+
+ {err.message} + {#if err.action} + + {/if} + +
{/each} - {#if question} - - {/if} -
-
-
- - {#if connectionState === 'disconnected'} -
-
-
-
- {:else if connectionState === 'reconnecting'} -
-
-
-
- {/if} - - {#if error} -
-
- {error} -
-
- {/if} - - {#each chatErrors as err (err.id)} -
-
- {err.message} - {#if err.action} - - {/if} - -
-
- {/each} -
+
{ @@ -514,7 +571,13 @@ border: none; height: 1px; margin: 0.75rem 0; - background: linear-gradient(to right, transparent, var(--border) 20%, var(--border) 80%, transparent); + background: linear-gradient( + to right, + transparent, + var(--border) 20%, + var(--border) 80%, + transparent + ); } /* Bold is emphasis, not color — dark weight reads cleanly and lets the @@ -559,7 +622,9 @@ border-radius: 0.375rem; color: var(--muted-foreground); opacity: 0; - transition: opacity 0.15s, color 0.15s; + transition: + opacity 0.15s, + color 0.15s; cursor: pointer; border: none; background: transparent; @@ -586,7 +651,12 @@ } @keyframes cursor-blink { - 0%, 100% { opacity: 0.75; } - 50% { opacity: 0; } + 0%, + 100% { + opacity: 0.75; + } + 50% { + opacity: 0; + } } diff --git a/web/src/lib/components/ConfigBackground.svelte b/web/src/lib/components/ConfigBackground.svelte index 442d856..55fb7e1 100644 --- a/web/src/lib/components/ConfigBackground.svelte +++ b/web/src/lib/components/ConfigBackground.svelte @@ -21,7 +21,9 @@ let canvas = $state(null) let particles: Particle[] = [] let mouse = { x: -500, y: -500 } - let w = 0, h = 0, dpr = 1 + let w = 0, + h = 0, + dpr = 1 let timer: ReturnType | 0 = 0 function spawn() { @@ -73,15 +75,15 @@ // update + draw particles for (const p of particles) { // autonomous drift - p.vx += (Math.sin(t * 0.4 + p.phase) * 0.003) * 0.15 - p.vy += (Math.cos(t * 0.35 + p.phase) * 0.003) * 0.15 + p.vx += Math.sin(t * 0.4 + p.phase) * 0.003 * 0.15 + p.vy += Math.cos(t * 0.35 + p.phase) * 0.003 * 0.15 // mouse interaction const dx = p.x - mouse.x const dy = p.y - mouse.y const dist = Math.sqrt(dx * dx + dy * dy) if (dist < MOUSE_RADIUS && dist > 0) { - const force = (MOUSE_RADIUS - dist) / MOUSE_RADIUS * MOUSE_FORCE + const force = ((MOUSE_RADIUS - dist) / MOUSE_RADIUS) * MOUSE_FORCE p.vx += (dx / dist) * force * 0.6 p.vy += (dy / dist) * force * 0.6 } @@ -104,9 +106,7 @@ // pulse brightness const alpha = p.pulse * (0.35 + 0.15 * Math.sin(t * 1.2 + p.phase)) - ctx.fillStyle = dark - ? `rgba(140,175,230,${alpha})` - : `rgba(60,90,140,${alpha})` + ctx.fillStyle = dark ? `rgba(140,175,230,${alpha})` : `rgba(60,90,140,${alpha})` ctx.beginPath() ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2) ctx.fill() @@ -123,9 +123,7 @@ const dist = dx * dx + dy * dy if (dist < CONNECT_DIST * CONNECT_DIST) { const alpha = (1 - Math.sqrt(dist) / CONNECT_DIST) * 0.18 - ctx.strokeStyle = dark - ? `rgba(140,175,230,${alpha})` - : `rgba(60,90,140,${alpha})` + ctx.strokeStyle = dark ? `rgba(140,175,230,${alpha})` : `rgba(60,90,140,${alpha})` ctx.beginPath() ctx.moveTo(a.x, a.y) ctx.lineTo(b.x, b.y) @@ -135,7 +133,8 @@ } // radial scrim to keep center legible - const cx = w / 2, cy = h / 2 + const cx = w / 2, + cy = h / 2 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.72)`) diff --git a/web/src/lib/components/DetailSection.svelte b/web/src/lib/components/DetailSection.svelte index 346c25c..fc2b3e8 100644 --- a/web/src/lib/components/DetailSection.svelte +++ b/web/src/lib/components/DetailSection.svelte @@ -22,14 +22,20 @@ - + {title}{count !== undefined ? ` (${count})` : ''} - +
{@render children()}
diff --git a/web/src/lib/components/EmptyState.svelte b/web/src/lib/components/EmptyState.svelte index 35e72db..91d8bbc 100644 --- a/web/src/lib/components/EmptyState.svelte +++ b/web/src/lib/components/EmptyState.svelte @@ -11,7 +11,10 @@ - + {message} diff --git a/web/src/lib/components/EntityDetailContent.svelte b/web/src/lib/components/EntityDetailContent.svelte index 6a29358..ce9cbee 100644 --- a/web/src/lib/components/EntityDetailContent.svelte +++ b/web/src/lib/components/EntityDetailContent.svelte @@ -62,7 +62,9 @@ // (source OR target = entity, both directions), so a plain split by which // side matches is enough — no risk of an unrelated sibling-to-sibling edge // sneaking into either group. - const outgoingRelations = $derived(entity ? relations.filter((r) => r.source === entity!.slug) : []) + const outgoingRelations = $derived( + entity ? relations.filter((r) => r.source === entity!.slug) : [] + ) const incomingRelations = $derived( entity ? relations.filter((r) => r.target === entity!.slug && r.source !== entity!.slug) : [] ) @@ -203,13 +205,23 @@ body?: string } - const LONG_TEXT_KEYS = new Set(['description', 'content', 'summary', 'notes', 'note', 'body', 'details']) + const LONG_TEXT_KEYS = new Set([ + 'description', + 'content', + 'summary', + 'notes', + 'note', + 'body', + 'details' + ]) function isChangelog(value: unknown): value is ChangelogEntry[] { return ( Array.isArray(value) && value.length > 0 && - value.every((v) => v && typeof v === 'object' && !Array.isArray(v) && ('title' in v || 'body' in v)) + value.every( + (v) => v && typeof v === 'object' && !Array.isArray(v) && ('title' in v || 'body' in v) + ) ) } @@ -263,14 +275,22 @@
State
-
{#if entity.state}{entity.state}{:else}{/if}
+
+ {#if entity.state}{entity.state}{:else}{/if} +
Health
{#if entity.health} - - + + {entity.health} · checked {relativeTime(entity.last_check_at)} {:else} @@ -286,7 +306,11 @@
Created
{relativeTime(entity.created_at)}
-
+
Updated
{relativeTime(entity.updated_at)}
@@ -313,7 +337,9 @@ onclick={() => toggleCheck(check)} title={check.enabled ? 'Click to disable' : 'Click to enable'} > - {check.enabled ? 'enabled' : 'disabled'} + {check.enabled ? 'enabled' : 'disabled'}
{:else} @@ -339,7 +365,9 @@ {#if row.kind === 'long-text'}

{row.key}

-

{row.value}

+

+ {row.value} +

{:else if row.kind === 'changelog'}
@@ -348,10 +376,16 @@ {#each row.value as entry}
- {#if entry.date}{entry.date}{/if} + {#if entry.date}{entry.date}{/if} {#if entry.title}{entry.title}{/if}
- {#if entry.body}

{entry.body}

{/if} + {#if entry.body}

+ {entry.body} +

{/if}
{/each}
@@ -373,7 +407,12 @@
{row.key}
{#if row.value !== null && typeof row.value === 'object'} -
{JSON.stringify(row.value, null, 2)}
+
{JSON.stringify(
+                        row.value,
+                        null,
+                        2
+                      )}
{:else} {String(row.value)} {/if} @@ -390,13 +429,27 @@ {#snippet relationRow(rel: Relationship)}
{#if onSelectEntity} - + —{rel.type}→ - + {:else} - {truncateMiddle(rel.source)} + {truncateMiddle(rel.source)} —{rel.type}→ - {truncateMiddle(rel.target)} + {truncateMiddle(rel.target)} {/if}
{/snippet} @@ -408,7 +461,11 @@
{#if outgoingRelations.length}
-
Outgoing ({outgoingRelations.length})
+
+ Outgoing ({outgoingRelations.length}) +
{#each outgoingRelations as rel} {@render relationRow(rel)} @@ -418,7 +475,11 @@ {/if} {#if incomingRelations.length}
-
Incoming ({incomingRelations.length})
+
+ Incoming ({incomingRelations.length}) +
{#each incomingRelations as rel} {@render relationRow(rel)} @@ -492,21 +553,32 @@ {#snippet tasksContent()}
{#each tasks as { task, executionCount } (task.id)} - {@const title = typeof task.attributes?.title === 'string' ? task.attributes.title : task.name} - {@const outcome = typeof task.attributes?.outcome === 'string' ? task.attributes.outcome : undefined} -
+ {@const title = + typeof task.attributes?.title === 'string' ? task.attributes.title : task.name} + {@const outcome = + typeof task.attributes?.outcome === 'string' ? task.attributes.outcome : undefined} +
{#if onSelectEntity} - {:else} - {title} + {title} {/if}
{#if outcome} {outcome} {/if} - {executionCount} action{executionCount === 1 ? '' : 's'} + {executionCount} action{executionCount === 1 ? '' : 's'}
{:else} @@ -545,8 +617,12 @@ {#each agentActivity as activity (activity.id)}
- {new Date(activity.ts).toLocaleString()} - {activity.activity_type} + {new Date(activity.ts).toLocaleString()} + {activity.activity_type}
{activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''}
- {new Date(entry.ts).toLocaleString()} + {new Date(entry.ts).toLocaleString()} {entry.actor_type}
- {entry.actor_id ?? '—'} · {entry.action} + {entry.actor_id ?? '—'} · {entry.action}
{:else}

No audit entries.

@@ -575,17 +655,34 @@ {/snippet} {@const sections = [ - ...(ownContent ? [{ key: 'content', title: 'Content', count: 1, content: contentContent }] : []), + ...(ownContent + ? [{ key: 'content', title: 'Content', count: 1, content: contentContent }] + : []), { key: 'details', title: 'Details', count: 1, content: detailsContent }, { key: 'monitoring', title: 'Monitoring', count: checks.length, content: monitoringContent }, - { key: 'attributes', title: 'Attributes', count: Object.keys(entity.attributes ?? {}).length, content: attributesContent }, - { key: 'relations', title: 'Relations', count: outgoingRelations.length + incomingRelations.length, content: relationsContent }, + { + key: 'attributes', + title: 'Attributes', + count: Object.keys(entity.attributes ?? {}).length, + content: attributesContent + }, + { + key: 'relations', + title: 'Relations', + count: outgoingRelations.length + incomingRelations.length, + content: relationsContent + }, { key: 'metrics', title: 'Metrics', count: metrics.length, content: metricsContent }, { key: 'signals', title: 'Signals', count: signals.length, content: signalsContent }, { key: 'tasks', title: 'Tasks', count: tasks.length, content: tasksContent }, { key: 'knowledge', title: 'Knowledge', count: knowledge.length, content: knowledgeContent }, { key: 'events', title: 'Recent events', count: events.length, content: eventsContent }, - { key: 'agentActivity', title: 'Agent activity', count: agentActivity.length, content: agentActivityContent }, + { + key: 'agentActivity', + title: 'Agent activity', + count: agentActivity.length, + content: agentActivityContent + }, { key: 'audit', title: 'Audit trail', count: auditEntries.length, content: auditContent } ].sort((a, b) => (b.count > 0 ? 1 : 0) - (a.count > 0 ? 1 : 0))} diff --git a/web/src/lib/components/EntityTable.svelte b/web/src/lib/components/EntityTable.svelte index 5781755..6ea6ff6 100644 --- a/web/src/lib/components/EntityTable.svelte +++ b/web/src/lib/components/EntityTable.svelte @@ -51,7 +51,13 @@ return { sorted: true, direction: sortDir } } - const healthRank: Record = { down: 0, degraded: 1, stale: 2, unknown: 3, healthy: 4 } + const healthRank: Record = { + down: 0, + degraded: 1, + stale: 2, + unknown: 3, + healthy: 4 + } function sortValue(entity: Entity, key: SortKey): string | number { if (key === 'health') return entity.health ? (healthRank[entity.health] ?? -1) : -1 @@ -131,7 +137,9 @@ {:else} {#snippet row(entity: Entity, level: number, ancestors: Set)} {@const ancestorsWithSelf = new Set(ancestors).add(entity.slug)} - {@const children = (childrenByParent.get(entity.slug) ?? []).filter((c) => !ancestorsWithSelf.has(c.slug))} + {@const children = (childrenByParent.get(entity.slug) ?? []).filter( + (c) => !ancestorsWithSelf.has(c.slug) + )} 0 ? !collapsedNodes.has(entity.slug) : undefined} tabindex={0} onclick={() => onSelect(entity.slug)} - onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(entity.slug) } }} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelect(entity.slug) + } + }} > @@ -149,7 +162,9 @@ type="button" class="rounded text-muted-foreground hover:text-foreground" onclick={(e) => toggleNode(entity.slug, e)} - aria-label={collapsedNodes.has(entity.slug) ? `Expand ${entity.slug}` : `Collapse ${entity.slug}`} + aria-label={collapsedNodes.has(entity.slug) + ? `Expand ${entity.slug}` + : `Collapse ${entity.slug}`} > {#if collapsedNodes.has(entity.slug)} @@ -186,23 +201,48 @@ {@const ssSlug = getSortState('slug')} - sortBy('slug')} /> + sortBy('slug')} + /> {@const ssType = getSortState('type')} - sortBy('type')} /> + sortBy('type')} + /> {@const ssName = getSortState('name')} - sortBy('name')} /> + sortBy('name')} + /> {@const ssState = getSortState('state')} - sortBy('state')} /> + sortBy('state')} + /> {@const ssHealth = getSortState('health')} - sortBy('health')} /> + sortBy('health')} + /> diff --git a/web/src/lib/components/FilterTabs.svelte b/web/src/lib/components/FilterTabs.svelte index 2c0fc87..0952e72 100644 --- a/web/src/lib/components/FilterTabs.svelte +++ b/web/src/lib/components/FilterTabs.svelte @@ -8,14 +8,22 @@ children }: { value?: string - tabs: { value: string; label: string; count?: number; variant?: 'destructive' | 'default' | 'secondary' | 'outline' }[] + tabs: { + value: string + label: string + count?: number + variant?: 'destructive' | 'default' | 'secondary' | 'outline' + }[] class?: string // eslint-disable-next-line @typescript-eslint/no-explicit-any children?: any } = $props() - + {#each tabs as tab} diff --git a/web/src/lib/components/FleetMap.svelte b/web/src/lib/components/FleetMap.svelte index 7e8a691..c48f52d 100644 --- a/web/src/lib/components/FleetMap.svelte +++ b/web/src/lib/components/FleetMap.svelte @@ -60,7 +60,11 @@ $effect(() => { const ev = $liveEvents[0] if (!ev) return - if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') { + if ( + ev.type.startsWith('entity.') || + ev.type.startsWith('relationship.') || + ev.type === 'health.changed' + ) { load() } }) @@ -276,9 +280,11 @@ const svcRaw = new Map() for (const s of services) { const p = serviceProvider.get(s.slug) - svcRaw.set(s.slug, (p && yById.has(p) ? yById.get(p)! : TOP0)) + svcRaw.set(s.slug, p && yById.has(p) ? yById.get(p)! : TOP0) } - services.sort((a, b) => (svcRaw.get(a.slug)! - svcRaw.get(b.slug)!) || a.name.localeCompare(b.name)) + services.sort( + (a, b) => svcRaw.get(a.slug)! - svcRaw.get(b.slug)! || a.name.localeCompare(b.name) + ) let prevY = TOP0 - ROW_H for (const s of services) { const y = Math.max(svcRaw.get(s.slug)!, prevY + ROW_H) @@ -308,7 +314,9 @@ counts[h]++ const ingress = routesTo.get(n.slug) const ingressEntity = ingress ? bySlugEntity.get(ingress) : undefined - const publicHost = ingress ? (ingressEntity?.name ?? ingress.replace(/^ingress:/, '')) : undefined + const publicHost = ingress + ? (ingressEntity?.name ?? ingress.replace(/^ingress:/, '')) + : undefined const fauth = ingressEntity ? boolAttr(ingressEntity, 'forward_auth') : undefined const provider = kind === 'service' ? serviceProvider.get(n.slug) : containerHost.get(n.slug) const mnt = (kind === 'container' ? mountsOf.get(n.slug) : mountsOf.get(provider ?? '')) ?? [] @@ -361,25 +369,41 @@ const n = bySlug.get(slug)! return LANE_X[n.lane] + (side === 'r' ? LANE_W[n.lane] : 0) } - const cy = (slug: string) => (bySlug.get(slug)!.y) + NODE_H / 2 + const cy = (slug: string) => bySlug.get(slug)!.y + NODE_H / 2 const edges: LaidEdge[] = [] const provChildren = new Map() // provider -> [child] // provision edges: host->container, provider->service for (const c of containers) { const host = containerHost.get(c.slug) if (host && bySlug.has(host)) { - const x1 = cx(host, 'r'), y1 = cy(host), x2 = cx(c.slug, 'l'), y2 = cy(c.slug) + const x1 = cx(host, 'r'), + y1 = cy(host), + x2 = cx(c.slug, 'l'), + y2 = cy(c.slug) const mx = (x1 + x2) / 2 - edges.push({ d: `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`, kind: 'prov', s: host, t: c.slug }) + edges.push({ + d: `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`, + kind: 'prov', + s: host, + t: c.slug + }) pushMap(provChildren, host, c.slug) } } for (const s of services) { const p = serviceProvider.get(s.slug) if (p && bySlug.has(p)) { - const x1 = cx(p, 'r'), y1 = cy(p), x2 = cx(s.slug, 'l'), y2 = cy(s.slug) + const x1 = cx(p, 'r'), + y1 = cy(p), + x2 = cx(s.slug, 'l'), + y2 = cy(s.slug) const mx = (x1 + x2) / 2 - edges.push({ d: `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`, kind: 'prov', s: p, t: s.slug }) + edges.push({ + d: `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`, + kind: 'prov', + s: p, + t: s.slug + }) pushMap(provChildren, p, s.slug) } } @@ -388,9 +412,17 @@ if (!bySlug.has(srcSlug)) continue for (const t of targets) { if (!bySlug.has(t)) continue - const x1 = cx(srcSlug, 'r'), y1 = cy(srcSlug), x2 = cx(t, 'r'), y2 = cy(t) + const x1 = cx(srcSlug, 'r'), + y1 = cy(srcSlug), + x2 = cx(t, 'r'), + y2 = cy(t) const bulge = Math.max(x1, x2) + 34 + Math.min(70, Math.abs(y1 - y2) * 0.32) - edges.push({ d: `M${x1},${y1} C${bulge},${y1} ${bulge},${y2} ${x2},${y2}`, kind: 'dep', s: srcSlug, t }) + edges.push({ + d: `M${x1},${y1} C${bulge},${y1} ${bulge},${y2} ${x2},${y2}`, + kind: 'dep', + s: srcSlug, + t + }) } } @@ -508,7 +540,8 @@ const q = search.trim().toLowerCase() if (q) { const s = new Set() - for (const n of m.nodes) if (n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)) s.add(n.slug) + for (const n of m.nodes) + if (n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)) s.add(n.slug) return s } return null @@ -589,7 +622,9 @@ if (!m || !healthFilter) return [] as LaidNode[] return m.nodes.filter((n) => n.health === healthFilter) }) - const problemCount = $derived(model ? model.nodes.filter((n) => n.health !== 'healthy').length : 0) + const problemCount = $derived( + model ? model.nodes.filter((n) => n.health !== 'healthy').length : 0 + ) {#if loading && !model} @@ -636,7 +671,8 @@
- +
{#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} @@ -647,7 +683,11 @@
- + {#each model.edges as e} {/each} @@ -661,7 +701,9 @@ class:svc={n.kind === 'service'} class:is-open={selectedSlug === n.slug} class:pulse={n.health === 'down'} - style="left:{n.x}px;top:{n.y}px;width:{n.w}px;height:{n.h}px;--nc:{HEALTH_COLOR[n.health]}" + style="left:{n.x}px;top:{n.y}px;width:{n.w}px;height:{n.h}px;--nc:{HEALTH_COLOR[ + n.health + ]}" onmouseenter={() => setFocus(n.slug)} onmouseleave={scheduleClear} onfocus={() => setFocus(n.slug)} @@ -673,7 +715,11 @@ title={n.slug} > {#if n.publicHost} - + {#if n.fauth} {:else if n.fauth === false} @@ -748,7 +794,9 @@
0}>{detailBlast.length}
- {detailNode.kind === 'service' ? 'downstream service' : 'service'}{detailBlast.length === 1 ? '' : 's'} + {detailNode.kind === 'service' + ? 'downstream service' + : 'service'}{detailBlast.length === 1 ? '' : 's'} affected if this goes down
@@ -756,18 +804,40 @@

Attributes

{#if detailNode.kind === 'container'} -
type{detailNode.type}
+
+ type{detailNode.type} +
{/if} - {#if detailNode.role}
role{detailNode.role}
{/if} - {#if detailNode.ip}
ip{detailNode.ip}
{/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'}
+
+ url{detailNode.publicHost} +
+
+ exposure{detailNode.fauth + ? 'forward-auth gated' + : detailNode.fauth === false + ? 'no auth gate' + : 'public'} +
{:else if detailNode.url} -
url{detailNode.url}
+
+ url{detailNode.url} +
{/if} - {#if detailNode.mounts.length}
mounts{detailNode.mounts.join(', ')}
{/if} - {#if detailNode.repo}
config{detailNode.repo}
{/if} + {#if detailNode.mounts.length}
+ mounts{detailNode.mounts.join(', ')} +
{/if} + {#if detailNode.repo}
+ config{detailNode.repo} +
{/if}
{#if detailRunsOn.length} @@ -824,18 +894,24 @@

Health

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

Lanes

Hosts — physical machines
-
Containers — LXCs & VMs
-
Services — what you actually use
+
+ 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. + 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} @@ -843,7 +919,9 @@
{:else} -
Failed to load graph
+
+ Failed to load graph +
{/if}