feat: master-detail entity sheet, freshness in Entities table, live logo
Problem: the web UI felt dead and hard to navigate — the Entities table
had no health/freshness signal (just a meaningless row-mutation
timestamp), no way to see what was actually monitoring an entity,
sessions couldn't be reopened, and every drill-down was a full page
navigation that lost the list.
Change:
- Entities table: Updated column replaced with a health dot + relative
"checked Xm ago", sourced from the backend's new health/last_check_at
fields.
- New EntityDetailContent.svelte extracted from EntityDetail.svelte and
shared between the full #/entity/:slug page and a new EntitySheet.svelte
opened from the Entities table (master-detail, row click opens a panel
instead of navigating away). Adds a Monitoring card listing the
entity's check_defs (kind, interval, enabled/disabled with
click-to-toggle via the existing PatchCheck endpoint) and renders
attributes as key/value pairs instead of raw JSON.
- Sessions: fixed a bug where clicking a session loaded it into the
chat store but never navigated to the chat page, so nothing appeared
to happen. Added a SessionRail inside Chat so switching sessions
never leaves the chat surface.
- Fixed the local dev proxy (vite.config.ts): production Caddy strips
the /agent prefix before forwarding to nomos; the dev proxy didn't,
so every session/chat fetch 404'd locally while working in prod.
- Found and fixed a real latent bug while testing the session fix:
chat.ts's loadSessionMessages passed the persisted tool_calls array
straight through, but nomos stores the tool_use and tool_result as
two entries sharing one id. Chat.svelte's keyed {#each tool (tool.id)}
throws on the duplicate key, which silently blanked the entire
message list — invisible until sessions were actually clickable.
Fixed by merging tool_calls by id before rendering, matching the
shape the live-streaming path already produces.
- UI polish: sidebar logo is now just the omicron mark in white (was
icon+text in the accent color); removed the sheet overlay's
backdrop-blur (distracting per feedback); the Attributes/Relations/
Signals grids used viewport-based lg:/3xl: breakpoints, which forced
multi-column layouts based on browser width regardless of the sheet's
actual rendered width — switched to Tailwind v4 container queries
(@lg:/@2xl:/@3xl:) so layout responds to the real available width in
both the full page and the narrower sheet.
Risk: reversible_low (UI-only; no destructive operations; the tool_calls
merge and dev-proxy fix are corrections to broken paths, not behavior
changes to working ones).
Verification: npx tsc --noEmit clean (excluding pre-existing unrelated
.svelte type-resolution warnings). Manually verified in the browser
preview against the live dev API: Entities table health column renders
correctly; clicking a row opens the EntitySheet with a populated
Monitoring card (16 checks for host:hubris, verified via psql that
check_defs.target_id links them correctly); clicking a session now
loads its full transcript inline (was blank before the tool_calls fix);
sheet has no blur and lays out single/multi-column correctly at the
sheet's actual width.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
4
web/public/favicon.svg
Normal file
4
web/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="91" height="100" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="#58a6ff" d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 666 B |
@@ -22,7 +22,6 @@
|
|||||||
import { Toaster } from '$lib/components/ui/sonner'
|
import { Toaster } from '$lib/components/ui/sonner'
|
||||||
import PlusIcon from '@lucide/svelte/icons/plus'
|
import PlusIcon from '@lucide/svelte/icons/plus'
|
||||||
import MessageSquareIcon from '@lucide/svelte/icons/message-square'
|
import MessageSquareIcon from '@lucide/svelte/icons/message-square'
|
||||||
import ListIcon from '@lucide/svelte/icons/list'
|
|
||||||
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'
|
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'
|
||||||
import DatabaseIcon from '@lucide/svelte/icons/database'
|
import DatabaseIcon from '@lucide/svelte/icons/database'
|
||||||
import ActivityIcon from '@lucide/svelte/icons/activity'
|
import ActivityIcon from '@lucide/svelte/icons/activity'
|
||||||
@@ -71,8 +70,7 @@
|
|||||||
{ id: 'events', label: 'Events', icon: ActivityIcon },
|
{ id: 'events', label: 'Events', icon: ActivityIcon },
|
||||||
{ id: 'agent', label: 'Agent', icon: BotIcon },
|
{ id: 'agent', label: 'Agent', icon: BotIcon },
|
||||||
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
|
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
|
||||||
{ id: 'audit', label: 'Audit', icon: ScrollTextIcon },
|
{ id: 'audit', label: 'Audit', icon: ScrollTextIcon }
|
||||||
{ id: 'sessions', label: 'Sessions', icon: ListIcon }
|
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -81,8 +79,11 @@
|
|||||||
<Sidebar.Provider>
|
<Sidebar.Provider>
|
||||||
<Sidebar.Root collapsible="icon">
|
<Sidebar.Root collapsible="icon">
|
||||||
<Sidebar.Header>
|
<Sidebar.Header>
|
||||||
<div class="flex items-center justify-between px-1">
|
<div class="flex items-center px-1">
|
||||||
<span class="text-sm font-bold text-primary">Oikos</span>
|
<svg viewBox="0 0 91 100" class="size-5 shrink-0 fill-white" aria-hidden="true" role="img">
|
||||||
|
<title>Oikos</title>
|
||||||
|
<path d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z" />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<Sidebar.Menu>
|
<Sidebar.Menu>
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ export async function fetchDashboardSummary(): Promise<DashboardSummary | null>
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EntityHealth = 'healthy' | 'degraded' | 'down' | 'unknown' | 'stale'
|
||||||
|
|
||||||
export interface Entity {
|
export interface Entity {
|
||||||
id: string
|
id: string
|
||||||
slug: string
|
slug: string
|
||||||
@@ -118,6 +120,8 @@ export interface Entity {
|
|||||||
version: number
|
version: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
health?: EntityHealth | null
|
||||||
|
last_check_at?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntityFilters {
|
export interface EntityFilters {
|
||||||
@@ -396,6 +400,38 @@ export async function fetchEntityExecutions(entityId: string): Promise<Execution
|
|||||||
return data.items ?? []
|
return data.items ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Check {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
kind: 'http' | 'tcp' | 'disk' | 'cert-expiry' | 'drift' | 'ping' | 'ssh-script'
|
||||||
|
target?: string | null
|
||||||
|
target_type?: string | null
|
||||||
|
config?: Record<string, unknown>
|
||||||
|
interval_s: number
|
||||||
|
timeout_s: number
|
||||||
|
zone?: string | null
|
||||||
|
enabled: boolean
|
||||||
|
version: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchChecksForTarget(targetSlug: string): Promise<Check[]> {
|
||||||
|
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<Check | null> {
|
||||||
|
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 {
|
export interface AgentActivity {
|
||||||
id: number
|
id: number
|
||||||
ts: string
|
ts: string
|
||||||
|
|||||||
297
web/src/lib/components/EntityDetailContent.svelte
Normal file
297
web/src/lib/components/EntityDetailContent.svelte
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, tick } from 'svelte'
|
||||||
|
import uPlot from 'uplot'
|
||||||
|
import 'uplot/dist/uPlot.min.css'
|
||||||
|
import {
|
||||||
|
fetchEntity,
|
||||||
|
fetchGraph,
|
||||||
|
fetchMetrics,
|
||||||
|
fetchEntityEvents,
|
||||||
|
fetchEntitySignals,
|
||||||
|
fetchEntityExecutions,
|
||||||
|
fetchEntityKnowledge,
|
||||||
|
fetchChecksForTarget,
|
||||||
|
patchCheck,
|
||||||
|
type Entity,
|
||||||
|
type Relationship,
|
||||||
|
type MetricSeries,
|
||||||
|
type Signal,
|
||||||
|
type Execution,
|
||||||
|
type KnowledgeHit,
|
||||||
|
type Check
|
||||||
|
} from '$lib/api'
|
||||||
|
import { relativeTime } from '$lib/utils'
|
||||||
|
import type { OikosEvent } from '$lib/stores/events'
|
||||||
|
import * as Card from '$lib/components/ui/card'
|
||||||
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
|
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||||
|
import { toast } from 'svelte-sonner'
|
||||||
|
|
||||||
|
let { slug }: { slug: string } = $props()
|
||||||
|
|
||||||
|
let entity = $state<Entity | null>(null)
|
||||||
|
let relations = $state<Relationship[]>([])
|
||||||
|
let metrics = $state<MetricSeries[]>([])
|
||||||
|
let events = $state<OikosEvent[]>([])
|
||||||
|
let signals = $state<Signal[]>([])
|
||||||
|
let executions = $state<Execution[]>([])
|
||||||
|
let knowledge = $state<KnowledgeHit[]>([])
|
||||||
|
let checks = $state<Check[]>([])
|
||||||
|
let loading = $state(true)
|
||||||
|
let chartContainers: Record<string, HTMLDivElement> = {}
|
||||||
|
|
||||||
|
async function load(s: string) {
|
||||||
|
loading = true
|
||||||
|
entity = await fetchEntity(s)
|
||||||
|
if (!entity) {
|
||||||
|
loading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [graphView, m, ev, sig, exec, kh, ch] = await Promise.all([
|
||||||
|
fetchGraph({ root: entity.id, depth: 1 }),
|
||||||
|
fetchMetrics(entity.id),
|
||||||
|
fetchEntityEvents(entity.id),
|
||||||
|
fetchEntitySignals(entity.id),
|
||||||
|
fetchEntityExecutions(entity.id),
|
||||||
|
fetchEntityKnowledge(entity.id),
|
||||||
|
fetchChecksForTarget(entity.slug)
|
||||||
|
])
|
||||||
|
relations = graphView?.edges ?? []
|
||||||
|
metrics = m
|
||||||
|
events = ev
|
||||||
|
signals = sig
|
||||||
|
executions = exec
|
||||||
|
knowledge = kh
|
||||||
|
checks = ch
|
||||||
|
loading = false
|
||||||
|
|
||||||
|
await tick()
|
||||||
|
renderCharts()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
load(slug)
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (slug) load(slug)
|
||||||
|
})
|
||||||
|
|
||||||
|
function renderCharts() {
|
||||||
|
for (const series of metrics) {
|
||||||
|
const el = chartContainers[series.metric]
|
||||||
|
if (!el) continue
|
||||||
|
el.innerHTML = ''
|
||||||
|
const xs = series.samples.map((s) => new Date(s.ts).getTime() / 1000)
|
||||||
|
const ys = series.samples.map((s) => s.value ?? s.avg ?? null)
|
||||||
|
new uPlot(
|
||||||
|
{
|
||||||
|
width: el.clientWidth || 400,
|
||||||
|
height: 160,
|
||||||
|
series: [{}, { label: series.metric, stroke: '#58a6ff', width: 2 }],
|
||||||
|
axes: [{ stroke: '#8b949e' }, { stroke: '#8b949e' }],
|
||||||
|
scales: { x: { time: true } },
|
||||||
|
legend: { show: false }
|
||||||
|
},
|
||||||
|
[xs, ys],
|
||||||
|
el
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
|
||||||
|
if (sev === 'critical') return 'destructive'
|
||||||
|
if (sev === 'warning') return 'secondary'
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthDot: Record<string, string> = {
|
||||||
|
healthy: 'bg-success',
|
||||||
|
degraded: 'bg-warning',
|
||||||
|
down: 'bg-destructive',
|
||||||
|
stale: 'bg-warning/50',
|
||||||
|
unknown: 'bg-muted-foreground/40'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCheck(check: Check) {
|
||||||
|
const updated = await patchCheck(check.id, check.version, { enabled: !check.enabled })
|
||||||
|
if (updated) {
|
||||||
|
checks = checks.map((c) => (c.id === check.id ? updated : c))
|
||||||
|
toast.success(`${check.slug} ${updated.enabled ? 'enabled' : 'disabled'}`)
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to update check')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="@container flex h-full flex-col gap-4 overflow-y-auto p-6">
|
||||||
|
{#if loading}
|
||||||
|
<Skeleton class="h-8 w-48" />
|
||||||
|
<div class="grid grid-cols-1 gap-4 @lg:grid-cols-2">
|
||||||
|
<Skeleton class="h-40 w-full" />
|
||||||
|
<Skeleton class="h-40 w-full" />
|
||||||
|
</div>
|
||||||
|
{:else if !entity}
|
||||||
|
<p class="text-sm text-muted-foreground">Entity "{slug}" not found.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h1 class="font-mono text-lg font-semibold">{entity.slug}</h1>
|
||||||
|
<Badge variant="outline">{entity.type}</Badge>
|
||||||
|
{#if entity.state}<Badge>{entity.state}</Badge>{/if}
|
||||||
|
{#if entity.health}
|
||||||
|
<span class="flex items-center gap-1.5 text-xs text-muted-foreground" title="{entity.health} — checked {relativeTime(entity.last_check_at)}">
|
||||||
|
<span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"></span>
|
||||||
|
{entity.health} · checked {relativeTime(entity.last_check_at)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="text-sm">Monitoring ({checks.length})</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="flex flex-col gap-1.5">
|
||||||
|
{#each checks as check (check.id)}
|
||||||
|
<div class="flex items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-xs">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" class="font-mono">{check.kind}</Badge>
|
||||||
|
<span class="text-muted-foreground">every {check.interval_s}s</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cursor-pointer"
|
||||||
|
onclick={() => toggleCheck(check)}
|
||||||
|
title={check.enabled ? 'Click to disable' : 'Click to enable'}
|
||||||
|
>
|
||||||
|
<Badge variant={check.enabled ? 'default' : 'secondary'}>{check.enabled ? 'enabled' : 'disabled'}</Badge>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">No checks configured for this entity.</p>
|
||||||
|
{/each}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="text-sm">Attributes</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if entity.attributes && Object.keys(entity.attributes).length}
|
||||||
|
<dl class="flex flex-col gap-1.5 text-xs">
|
||||||
|
{#each Object.entries(entity.attributes) as [key, value]}
|
||||||
|
<div class="flex items-start justify-between gap-3 border-b pb-1.5 last:border-0">
|
||||||
|
<dt class="shrink-0 font-mono text-muted-foreground">{key}</dt>
|
||||||
|
<dd class="min-w-0 flex-1 truncate text-right">
|
||||||
|
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">No attributes.</p>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="text-sm">Relations ({relations.length})</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="flex flex-col gap-1">
|
||||||
|
{#each relations as rel}
|
||||||
|
<div class="flex items-center gap-1 font-mono text-xs">
|
||||||
|
<span>{rel.source}</span>
|
||||||
|
<span class="text-muted-foreground">—{rel.type}→</span>
|
||||||
|
<span>{rel.target}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">No direct relations.</p>
|
||||||
|
{/each}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if metrics.length}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="text-sm">Metrics</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||||
|
{#each metrics as series (series.metric)}
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 text-xs text-muted-foreground">{series.metric} ({series.rollup})</p>
|
||||||
|
<div bind:this={chartContainers[series.metric]}></div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="text-sm">Open signals</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="flex flex-col gap-1">
|
||||||
|
{#each signals as signal (signal.id)}
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<span>{signal.kind}</span>
|
||||||
|
<Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">None.</p>
|
||||||
|
{/each}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="text-sm">Executions</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="flex flex-col gap-1">
|
||||||
|
{#each executions as execution (execution.id)}
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<span>{execution.action}</span>
|
||||||
|
<Badge variant="outline">{execution.status}</Badge>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">None.</p>
|
||||||
|
{/each}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="text-sm">Knowledge</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="flex flex-col gap-1">
|
||||||
|
{#each knowledge as hit (hit.id)}
|
||||||
|
<div class="text-xs">
|
||||||
|
<Badge variant="outline" class="mr-1">{hit.type}</Badge>{hit.title}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">None linked.</p>
|
||||||
|
{/each}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="text-sm">Recent events</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="flex flex-col gap-1">
|
||||||
|
{#each events as ev (ev.id)}
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
|
||||||
|
<span>{ev.type}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">No events yet.</p>
|
||||||
|
{/each}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
18
web/src/lib/components/EntitySheet.svelte
Normal file
18
web/src/lib/components/EntitySheet.svelte
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
|
||||||
|
import * as Sheet from '$lib/components/ui/sheet'
|
||||||
|
|
||||||
|
let { slug, open = $bindable(false) }: { slug: string | null; open?: boolean } = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sheet.Root bind:open>
|
||||||
|
<Sheet.Content side="right" class="w-full p-0 sm:max-w-2xl">
|
||||||
|
<Sheet.Header class="sr-only">
|
||||||
|
<Sheet.Title>{slug ?? 'Entity detail'}</Sheet.Title>
|
||||||
|
<Sheet.Description>Entity detail panel</Sheet.Description>
|
||||||
|
</Sheet.Header>
|
||||||
|
{#if slug}
|
||||||
|
<EntityDetailContent {slug} />
|
||||||
|
{/if}
|
||||||
|
</Sheet.Content>
|
||||||
|
</Sheet.Root>
|
||||||
41
web/src/lib/components/SessionRail.svelte
Normal file
41
web/src/lib/components/SessionRail.svelte
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { sessions, currentSession, loadSessions, loadSessionMessages, newChat } from '$lib/stores/chat'
|
||||||
|
import { relativeTime } from '$lib/utils'
|
||||||
|
import { Button } from '$lib/components/ui/button'
|
||||||
|
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||||
|
import PlusIcon from '@lucide/svelte/icons/plus'
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadSessions()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pick up sessions created/renamed elsewhere (e.g. after a turn completes).
|
||||||
|
$effect(() => {
|
||||||
|
void $currentSession
|
||||||
|
loadSessions()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="flex h-full w-56 shrink-0 flex-col gap-2 overflow-y-auto border-r bg-card/50 p-2">
|
||||||
|
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => newChat()}>
|
||||||
|
<PlusIcon class="size-3.5" />
|
||||||
|
New chat
|
||||||
|
</Button>
|
||||||
|
<ScrollArea class="min-h-0 flex-1">
|
||||||
|
<div class="flex flex-col gap-1 pr-2">
|
||||||
|
{#each $sessions as session (session.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex flex-col items-start gap-0.5 rounded-md border px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/60 {$currentSession === session.id ? 'border-primary bg-muted/50' : 'border-transparent'}"
|
||||||
|
onclick={() => loadSessionMessages(session.id)}
|
||||||
|
>
|
||||||
|
<span class="w-full truncate font-medium">{session.title || 'Untitled'}</span>
|
||||||
|
<span class="text-[11px] text-muted-foreground">{relativeTime(session.last_active_at)}</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<p class="px-2 py-4 text-center text-xs text-muted-foreground">No sessions yet.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</aside>
|
||||||
@@ -12,6 +12,6 @@
|
|||||||
<SheetPrimitive.Overlay
|
<SheetPrimitive.Overlay
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="sheet-overlay"
|
data-slot="sheet-overlay"
|
||||||
class={cn("bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
|
class={cn("bg-black/20 fixed inset-0 z-50", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -36,6 +36,23 @@ export async function loadSessions() {
|
|||||||
sessions.set(list)
|
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<string, ToolCallResult>()
|
||||||
|
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) {
|
export async function loadSessionMessages(sessionId: string) {
|
||||||
currentSession.set(sessionId)
|
currentSession.set(sessionId)
|
||||||
const msgs = await fetchMessages(sessionId)
|
const msgs = await fetchMessages(sessionId)
|
||||||
@@ -44,7 +61,7 @@ export async function loadSessionMessages(sessionId: string) {
|
|||||||
id: m.id,
|
id: m.id,
|
||||||
role: m.role as 'user' | 'assistant',
|
role: m.role as 'user' | 'assistant',
|
||||||
text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''),
|
text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''),
|
||||||
tools: m.content?.tool_calls ?? []
|
tools: mergeToolCalls(m.content?.tool_calls)
|
||||||
}))
|
}))
|
||||||
messages.set(chatMsgs)
|
messages.set(chatMsgs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,22 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs));
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
|
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
|
||||||
import ContextRail from '$lib/components/ContextRail.svelte'
|
import ContextRail from '$lib/components/ContextRail.svelte'
|
||||||
|
import SessionRail from '$lib/components/SessionRail.svelte'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
import { Textarea } from '$lib/components/ui/textarea'
|
import { Textarea } from '$lib/components/ui/textarea'
|
||||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||||
@@ -62,6 +63,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full min-h-0">
|
<div class="flex h-full min-h-0">
|
||||||
|
{#if showRail}
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<SessionRail />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
|
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { fetchEntities, type Entity } from '$lib/api'
|
import { fetchEntities, type Entity, type EntityHealth } from '$lib/api'
|
||||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||||
|
import { relativeTime } from '$lib/utils'
|
||||||
import * as Table from '$lib/components/ui/table'
|
import * as Table from '$lib/components/ui/table'
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
import { Input } from '$lib/components/ui/input'
|
import { Input } from '$lib/components/ui/input'
|
||||||
import * as Select from '$lib/components/ui/select'
|
import * as Select from '$lib/components/ui/select'
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||||
|
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
||||||
|
|
||||||
|
let sheetOpen = $state(false)
|
||||||
|
let selectedSlug = $state<string | null>(null)
|
||||||
|
|
||||||
|
function openEntity(slug: string) {
|
||||||
|
selectedSlug = slug
|
||||||
|
sheetOpen = true
|
||||||
|
}
|
||||||
|
|
||||||
let entities = $state<Entity[]>([])
|
let entities = $state<Entity[]>([])
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
@@ -47,6 +57,20 @@
|
|||||||
if (state === 'active' || state === 'healthy') return 'default'
|
if (state === 'active' || state === 'healthy') return 'default'
|
||||||
return 'secondary'
|
return 'secondary'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const healthDot: Record<EntityHealth, string> = {
|
||||||
|
healthy: 'bg-success',
|
||||||
|
degraded: 'bg-warning',
|
||||||
|
down: 'bg-destructive',
|
||||||
|
stale: 'bg-warning/50',
|
||||||
|
unknown: 'bg-muted-foreground/40'
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthTitle(entity: Entity): string {
|
||||||
|
if (!entity.health) return 'not monitored'
|
||||||
|
if (entity.health === 'stale') return `stale — last checked ${relativeTime(entity.last_check_at)}`
|
||||||
|
return `${entity.health} — checked ${relativeTime(entity.last_check_at)}`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col gap-4 p-6">
|
<div class="flex h-full flex-col gap-4 p-6">
|
||||||
@@ -85,14 +109,14 @@
|
|||||||
<Table.Head>Type</Table.Head>
|
<Table.Head>Type</Table.Head>
|
||||||
<Table.Head>Name</Table.Head>
|
<Table.Head>Name</Table.Head>
|
||||||
<Table.Head>State</Table.Head>
|
<Table.Head>State</Table.Head>
|
||||||
<Table.Head>Updated</Table.Head>
|
<Table.Head>Health</Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each filtered as entity (entity.id)}
|
{#each filtered as entity (entity.id)}
|
||||||
<Table.Row
|
<Table.Row
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
onclick={() => (location.hash = '#/entity/' + encodeURIComponent(entity.slug))}
|
onclick={() => openEntity(entity.slug)}
|
||||||
>
|
>
|
||||||
<Table.Cell class="font-mono text-xs">{entity.slug}</Table.Cell>
|
<Table.Cell class="font-mono text-xs">{entity.slug}</Table.Cell>
|
||||||
<Table.Cell><Badge variant="outline">{entity.type}</Badge></Table.Cell>
|
<Table.Cell><Badge variant="outline">{entity.type}</Badge></Table.Cell>
|
||||||
@@ -104,9 +128,16 @@
|
|||||||
<span class="text-muted-foreground">—</span>
|
<span class="text-muted-foreground">—</span>
|
||||||
{/if}
|
{/if}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="text-xs text-muted-foreground"
|
<Table.Cell>
|
||||||
>{new Date(entity.updated_at).toLocaleString()}</Table.Cell
|
{#if entity.health}
|
||||||
>
|
<span class="flex items-center gap-1.5 text-xs" title={healthTitle(entity)}>
|
||||||
|
<span class="size-2 shrink-0 rounded-full {healthDot[entity.health]}"></span>
|
||||||
|
<span class="text-muted-foreground">{relativeTime(entity.last_check_at)}</span>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-muted-foreground">—</span>
|
||||||
|
{/if}
|
||||||
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{:else}
|
{:else}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
@@ -120,3 +151,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EntitySheet slug={selectedSlug} bind:open={sheetOpen} />
|
||||||
|
|||||||
@@ -1,226 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, tick } from 'svelte'
|
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
|
||||||
import uPlot from 'uplot'
|
|
||||||
import 'uplot/dist/uPlot.min.css'
|
|
||||||
import {
|
|
||||||
fetchEntity,
|
|
||||||
fetchGraph,
|
|
||||||
fetchMetrics,
|
|
||||||
fetchEntityEvents,
|
|
||||||
fetchEntitySignals,
|
|
||||||
fetchEntityExecutions,
|
|
||||||
fetchEntityKnowledge,
|
|
||||||
type Entity,
|
|
||||||
type Relationship,
|
|
||||||
type MetricSeries,
|
|
||||||
type Signal,
|
|
||||||
type Execution,
|
|
||||||
type KnowledgeHit
|
|
||||||
} from '$lib/api'
|
|
||||||
import type { OikosEvent } from '$lib/stores/events'
|
|
||||||
import * as Card from '$lib/components/ui/card'
|
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
|
||||||
|
|
||||||
let { slug }: { slug: string } = $props()
|
let { slug }: { slug: string } = $props()
|
||||||
|
|
||||||
let entity = $state<Entity | null>(null)
|
|
||||||
let relations = $state<Relationship[]>([])
|
|
||||||
let metrics = $state<MetricSeries[]>([])
|
|
||||||
let events = $state<OikosEvent[]>([])
|
|
||||||
let signals = $state<Signal[]>([])
|
|
||||||
let executions = $state<Execution[]>([])
|
|
||||||
let knowledge = $state<KnowledgeHit[]>([])
|
|
||||||
let loading = $state(true)
|
|
||||||
let chartContainers: Record<string, HTMLDivElement> = {}
|
|
||||||
|
|
||||||
async function load(s: string) {
|
|
||||||
loading = true
|
|
||||||
entity = await fetchEntity(s)
|
|
||||||
if (!entity) {
|
|
||||||
loading = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const [graphView, m, ev, sig, exec, kh] = await Promise.all([
|
|
||||||
fetchGraph({ root: entity.id, depth: 1 }),
|
|
||||||
fetchMetrics(entity.id),
|
|
||||||
fetchEntityEvents(entity.id),
|
|
||||||
fetchEntitySignals(entity.id),
|
|
||||||
fetchEntityExecutions(entity.id),
|
|
||||||
fetchEntityKnowledge(entity.id)
|
|
||||||
])
|
|
||||||
relations = graphView?.edges ?? []
|
|
||||||
metrics = m
|
|
||||||
events = ev
|
|
||||||
signals = sig
|
|
||||||
executions = exec
|
|
||||||
knowledge = kh
|
|
||||||
loading = false
|
|
||||||
|
|
||||||
await tick()
|
|
||||||
renderCharts()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
load(slug)
|
|
||||||
})
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (slug) load(slug)
|
|
||||||
})
|
|
||||||
|
|
||||||
function renderCharts() {
|
|
||||||
for (const series of metrics) {
|
|
||||||
const el = chartContainers[series.metric]
|
|
||||||
if (!el) continue
|
|
||||||
el.innerHTML = ''
|
|
||||||
const xs = series.samples.map((s) => new Date(s.ts).getTime() / 1000)
|
|
||||||
const ys = series.samples.map((s) => s.value ?? s.avg ?? null)
|
|
||||||
new uPlot(
|
|
||||||
{
|
|
||||||
width: el.clientWidth || 400,
|
|
||||||
height: 160,
|
|
||||||
series: [{}, { label: series.metric, stroke: '#58a6ff', width: 2 }],
|
|
||||||
axes: [{ stroke: '#8b949e' }, { stroke: '#8b949e' }],
|
|
||||||
scales: { x: { time: true } },
|
|
||||||
legend: { show: false }
|
|
||||||
},
|
|
||||||
[xs, ys],
|
|
||||||
el
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
|
|
||||||
if (sev === 'critical') return 'destructive'
|
|
||||||
if (sev === 'warning') return 'secondary'
|
|
||||||
return 'default'
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col gap-4 overflow-y-auto p-6">
|
<EntityDetailContent {slug} />
|
||||||
{#if loading}
|
|
||||||
<Skeleton class="h-8 w-48" />
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<Skeleton class="h-40 w-full" />
|
|
||||||
<Skeleton class="h-40 w-full" />
|
|
||||||
</div>
|
|
||||||
{:else if !entity}
|
|
||||||
<p class="text-sm text-muted-foreground">Entity "{slug}" not found.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<h1 class="font-mono text-lg font-semibold">{entity.slug}</h1>
|
|
||||||
<Badge variant="outline">{entity.type}</Badge>
|
|
||||||
{#if entity.state}<Badge>{entity.state}</Badge>{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
||||||
<Card.Root>
|
|
||||||
<Card.Header>
|
|
||||||
<Card.Title class="text-sm">Attributes</Card.Title>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content>
|
|
||||||
<pre class="overflow-x-auto rounded bg-muted p-2 text-xs">{JSON.stringify(entity.attributes, null, 2)}</pre>
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
|
|
||||||
<Card.Root>
|
|
||||||
<Card.Header>
|
|
||||||
<Card.Title class="text-sm">Relations ({relations.length})</Card.Title>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content class="flex flex-col gap-1">
|
|
||||||
{#each relations as rel}
|
|
||||||
<div class="flex items-center gap-1 font-mono text-xs">
|
|
||||||
<span>{rel.source}</span>
|
|
||||||
<span class="text-muted-foreground">—{rel.type}→</span>
|
|
||||||
<span>{rel.target}</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-xs text-muted-foreground">No direct relations.</p>
|
|
||||||
{/each}
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if metrics.length}
|
|
||||||
<Card.Root>
|
|
||||||
<Card.Header>
|
|
||||||
<Card.Title class="text-sm">Metrics</Card.Title>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
||||||
{#each metrics as series (series.metric)}
|
|
||||||
<div>
|
|
||||||
<p class="mb-1 text-xs text-muted-foreground">{series.metric} ({series.rollup})</p>
|
|
||||||
<div bind:this={chartContainers[series.metric]}></div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
|
||||||
<Card.Root>
|
|
||||||
<Card.Header>
|
|
||||||
<Card.Title class="text-sm">Open signals</Card.Title>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content class="flex flex-col gap-1">
|
|
||||||
{#each signals as signal (signal.id)}
|
|
||||||
<div class="flex items-center justify-between text-xs">
|
|
||||||
<span>{signal.kind}</span>
|
|
||||||
<Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-xs text-muted-foreground">None.</p>
|
|
||||||
{/each}
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
|
|
||||||
<Card.Root>
|
|
||||||
<Card.Header>
|
|
||||||
<Card.Title class="text-sm">Executions</Card.Title>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content class="flex flex-col gap-1">
|
|
||||||
{#each executions as execution (execution.id)}
|
|
||||||
<div class="flex items-center justify-between text-xs">
|
|
||||||
<span>{execution.action}</span>
|
|
||||||
<Badge variant="outline">{execution.status}</Badge>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-xs text-muted-foreground">None.</p>
|
|
||||||
{/each}
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
|
|
||||||
<Card.Root>
|
|
||||||
<Card.Header>
|
|
||||||
<Card.Title class="text-sm">Knowledge</Card.Title>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content class="flex flex-col gap-1">
|
|
||||||
{#each knowledge as hit (hit.id)}
|
|
||||||
<div class="text-xs">
|
|
||||||
<Badge variant="outline" class="mr-1">{hit.type}</Badge>{hit.title}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-xs text-muted-foreground">None linked.</p>
|
|
||||||
{/each}
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card.Root>
|
|
||||||
<Card.Header>
|
|
||||||
<Card.Title class="text-sm">Recent events</Card.Title>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content class="flex flex-col gap-1">
|
|
||||||
{#each events as ev (ev.id)}
|
|
||||||
<div class="flex items-center gap-2 text-xs">
|
|
||||||
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
|
|
||||||
<span>{ev.type}</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-xs text-muted-foreground">No events yet.</p>
|
|
||||||
{/each}
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -14,7 +14,10 @@
|
|||||||
<button
|
<button
|
||||||
class="session-card"
|
class="session-card"
|
||||||
class:active={$currentSession === session.id}
|
class:active={$currentSession === session.id}
|
||||||
onclick={() => loadSessionMessages(session.id)}
|
onclick={() => {
|
||||||
|
loadSessionMessages(session.id)
|
||||||
|
location.hash = '#/chat'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="session-title">{session.title || 'Untitled'}</div>
|
<div class="session-title">{session.title || 'Untitled'}</div>
|
||||||
<div class="session-meta">
|
<div class="session-meta">
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:8090',
|
'/api': 'http://localhost:8090',
|
||||||
'/agent': 'http://localhost:8092'
|
// Production Caddy strips /agent before forwarding to nomos
|
||||||
|
// (compose/caddy/Caddyfile.oikos handle_path /agent/*); match that
|
||||||
|
// here so dev and prod agree on nomos's actual route paths.
|
||||||
|
'/agent': {
|
||||||
|
target: 'http://localhost:8092',
|
||||||
|
rewrite: (path) => path.replace(/^\/agent/, '')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user