diff --git a/web/index.html b/web/index.html index dcb97f4..ad0475a 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ Oikos — Control Room - + diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..0f9d502 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/src/App.svelte b/web/src/App.svelte index c1e3bbe..f272cef 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -22,7 +22,6 @@ import { Toaster } from '$lib/components/ui/sonner' import PlusIcon from '@lucide/svelte/icons/plus' import MessageSquareIcon from '@lucide/svelte/icons/message-square' - import ListIcon from '@lucide/svelte/icons/list' import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard' import DatabaseIcon from '@lucide/svelte/icons/database' import ActivityIcon from '@lucide/svelte/icons/activity' @@ -71,8 +70,7 @@ { id: 'events', label: 'Events', icon: ActivityIcon }, { id: 'agent', label: 'Agent', icon: BotIcon }, { id: 'knowledge', label: 'Knowledge', icon: SearchIcon }, - { id: 'audit', label: 'Audit', icon: ScrollTextIcon }, - { id: 'sessions', label: 'Sessions', icon: ListIcon } + { id: 'audit', label: 'Audit', icon: ScrollTextIcon } ] @@ -81,8 +79,11 @@ -
- Oikos +
+
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a2b0b86..924b44f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -108,6 +108,8 @@ export async function fetchDashboardSummary(): Promise return res.json() } +export type EntityHealth = 'healthy' | 'degraded' | 'down' | 'unknown' | 'stale' + export interface Entity { id: string slug: string @@ -118,6 +120,8 @@ export interface Entity { version: number created_at: string updated_at: string + health?: EntityHealth | null + last_check_at?: string | null } export interface EntityFilters { @@ -396,6 +400,38 @@ export async function fetchEntityExecutions(entityId: string): Promise + interval_s: number + timeout_s: number + zone?: string | null + enabled: boolean + version: number +} + +export async function fetchChecksForTarget(targetSlug: string): Promise { + const params = new URLSearchParams({ target: targetSlug, limit: '50' }) + const res = await fetch(`${API}/checks?${params}`) + if (!res.ok) return [] + const data = await res.json() + return data.items ?? [] +} + +export async function patchCheck(id: string, version: number, patch: { enabled?: boolean; interval_s?: number; timeout_s?: number }): Promise { + const res = await fetch(`${API}/checks/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', 'If-Match': `"${version}"` }, + body: JSON.stringify(patch) + }) + if (!res.ok) return null + return res.json() +} + export interface AgentActivity { id: number ts: string diff --git a/web/src/lib/components/EntityDetailContent.svelte b/web/src/lib/components/EntityDetailContent.svelte new file mode 100644 index 0000000..f0f8552 --- /dev/null +++ b/web/src/lib/components/EntityDetailContent.svelte @@ -0,0 +1,297 @@ + + +
+ {#if loading} + +
+ + +
+ {:else if !entity} +

Entity "{slug}" not found.

+ {:else} +
+

{entity.slug}

+ {entity.type} + {#if entity.state}{entity.state}{/if} + {#if entity.health} + + + {entity.health} · checked {relativeTime(entity.last_check_at)} + + {/if} +
+ + + + Monitoring ({checks.length}) + + + {#each checks as check (check.id)} +
+
+ {check.kind} + every {check.interval_s}s +
+ +
+ {:else} +

No checks configured for this entity.

+ {/each} +
+
+ +
+ + + Attributes + + + {#if entity.attributes && Object.keys(entity.attributes).length} +
+ {#each Object.entries(entity.attributes) as [key, value]} +
+
{key}
+
+ {typeof value === 'object' ? JSON.stringify(value) : String(value)} +
+
+ {/each} +
+ {:else} +

No attributes.

+ {/if} +
+
+ + + + Relations ({relations.length}) + + + {#each relations as rel} +
+ {rel.source} + —{rel.type}→ + {rel.target} +
+ {:else} +

No direct relations.

+ {/each} +
+
+
+ + {#if metrics.length} + + + Metrics + + + {#each metrics as series (series.metric)} +
+

{series.metric} ({series.rollup})

+
+
+ {/each} +
+
+ {/if} + +
+ + + Open signals + + + {#each signals as signal (signal.id)} +
+ {signal.kind} + {signal.severity} +
+ {:else} +

None.

+ {/each} +
+
+ + + + Executions + + + {#each executions as execution (execution.id)} +
+ {execution.action} + {execution.status} +
+ {:else} +

None.

+ {/each} +
+
+ + + + Knowledge + + + {#each knowledge as hit (hit.id)} +
+ {hit.type}{hit.title} +
+ {:else} +

None linked.

+ {/each} +
+
+
+ + + + Recent events + + + {#each events as ev (ev.id)} +
+ {new Date(ev.ts).toLocaleString()} + {ev.type} +
+ {:else} +

No events yet.

+ {/each} +
+
+ {/if} +
diff --git a/web/src/lib/components/EntitySheet.svelte b/web/src/lib/components/EntitySheet.svelte new file mode 100644 index 0000000..f74aaf1 --- /dev/null +++ b/web/src/lib/components/EntitySheet.svelte @@ -0,0 +1,18 @@ + + + + + + {slug ?? 'Entity detail'} + Entity detail panel + + {#if slug} + + {/if} + + diff --git a/web/src/lib/components/SessionRail.svelte b/web/src/lib/components/SessionRail.svelte new file mode 100644 index 0000000..6ce7dc4 --- /dev/null +++ b/web/src/lib/components/SessionRail.svelte @@ -0,0 +1,41 @@ + + + diff --git a/web/src/lib/components/ui/sheet/sheet-overlay.svelte b/web/src/lib/components/ui/sheet/sheet-overlay.svelte index 8d33847..e0fc3c9 100644 --- a/web/src/lib/components/ui/sheet/sheet-overlay.svelte +++ b/web/src/lib/components/ui/sheet/sheet-overlay.svelte @@ -12,6 +12,6 @@ diff --git a/web/src/lib/stores/chat.ts b/web/src/lib/stores/chat.ts index aa72857..a7cd090 100644 --- a/web/src/lib/stores/chat.ts +++ b/web/src/lib/stores/chat.ts @@ -36,6 +36,23 @@ export async function loadSessions() { sessions.set(list) } +// mergeToolCalls collapses a persisted tool_calls array into one entry per +// call id. Nomos persists the tool_use and tool_result as two separate +// entries sharing the same id (matching the SSE event pair); Chat.svelte +// renders tools in a keyed {#each ... (tool.id)}, which throws on duplicate +// keys and silently aborts the whole message list. Live-streamed messages +// never hit this because sendMessage() merges tool_result into the existing +// tool_use entry in place rather than appending a second one. +function mergeToolCalls(raw: ToolCallResult[] | undefined): ToolCallResult[] { + const byId = new Map() + for (const tc of raw ?? []) { + const key = tc.id ?? crypto.randomUUID() + const existing = byId.get(key) + byId.set(key, existing ? { ...existing, ...tc, id: key } : { ...tc, id: key }) + } + return Array.from(byId.values()) +} + export async function loadSessionMessages(sessionId: string) { currentSession.set(sessionId) const msgs = await fetchMessages(sessionId) @@ -44,7 +61,7 @@ export async function loadSessionMessages(sessionId: string) { id: m.id, role: m.role as 'user' | 'assistant', text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''), - tools: m.content?.tool_calls ?? [] + tools: mergeToolCalls(m.content?.tool_calls) })) messages.set(chatMsgs) } diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 55b3a91..e4a5890 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -5,6 +5,22 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +// relativeTime renders a compact "Xs/Xm/Xh/Xd ago" label for freshness +// indicators (health checks, live event timestamps, etc). +export function relativeTime(iso: string | null | undefined): string { + if (!iso) return "never"; + const ms = Date.now() - new Date(iso).getTime(); + if (ms < 0) return "just now"; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + return `${d}d ago`; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export type WithoutChild = T extends { child?: any } ? Omit : T; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/web/src/pages/Chat.svelte b/web/src/pages/Chat.svelte index 57dcf31..eda5a3a 100644 --- a/web/src/pages/Chat.svelte +++ b/web/src/pages/Chat.svelte @@ -1,6 +1,7 @@
+ {#if showRail} + + {/if}
diff --git a/web/src/pages/Entities.svelte b/web/src/pages/Entities.svelte index 4f647c7..2dcd76c 100644 --- a/web/src/pages/Entities.svelte +++ b/web/src/pages/Entities.svelte @@ -1,12 +1,22 @@
@@ -85,14 +109,14 @@ Type Name State - Updated + Health {#each filtered as entity (entity.id)} (location.hash = '#/entity/' + encodeURIComponent(entity.slug))} + onclick={() => openEntity(entity.slug)} > {entity.slug} {entity.type} @@ -104,9 +128,16 @@ {/if} - {new Date(entity.updated_at).toLocaleString()} + + {#if entity.health} + + + {relativeTime(entity.last_check_at)} + + {:else} + + {/if} + {:else} @@ -120,3 +151,5 @@
{/if}
+ + diff --git a/web/src/pages/EntityDetail.svelte b/web/src/pages/EntityDetail.svelte index 97c51b6..7e9b822 100644 --- a/web/src/pages/EntityDetail.svelte +++ b/web/src/pages/EntityDetail.svelte @@ -1,226 +1,7 @@ -
- {#if loading} - -
- - -
- {:else if !entity} -

Entity "{slug}" not found.

- {:else} -
-

{entity.slug}

- {entity.type} - {#if entity.state}{entity.state}{/if} -
- -
- - - Attributes - - -
{JSON.stringify(entity.attributes, null, 2)}
-
-
- - - - Relations ({relations.length}) - - - {#each relations as rel} -
- {rel.source} - —{rel.type}→ - {rel.target} -
- {:else} -

No direct relations.

- {/each} -
-
-
- - {#if metrics.length} - - - Metrics - - - {#each metrics as series (series.metric)} -
-

{series.metric} ({series.rollup})

-
-
- {/each} -
-
- {/if} - -
- - - Open signals - - - {#each signals as signal (signal.id)} -
- {signal.kind} - {signal.severity} -
- {:else} -

None.

- {/each} -
-
- - - - Executions - - - {#each executions as execution (execution.id)} -
- {execution.action} - {execution.status} -
- {:else} -

None.

- {/each} -
-
- - - - Knowledge - - - {#each knowledge as hit (hit.id)} -
- {hit.type}{hit.title} -
- {:else} -

None linked.

- {/each} -
-
-
- - - - Recent events - - - {#each events as ev (ev.id)} -
- {new Date(ev.ts).toLocaleString()} - {ev.type} -
- {:else} -

No events yet.

- {/each} -
-
- {/if} -
+ diff --git a/web/src/pages/Sessions.svelte b/web/src/pages/Sessions.svelte index 4db2b62..671b4cb 100644 --- a/web/src/pages/Sessions.svelte +++ b/web/src/pages/Sessions.svelte @@ -14,7 +14,10 @@