feat: master-detail entity sheet, freshness in Entities table, live logo
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

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:
2026-07-09 00:26:37 +02:00
parent 279549c8c9
commit 851b5dce67
15 changed files with 496 additions and 237 deletions

View 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>

View 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>

View 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>

View File

@@ -12,6 +12,6 @@
<SheetPrimitive.Overlay
bind:ref
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}
/>