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:
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
|
||||
import ContextRail from '$lib/components/ContextRail.svelte'
|
||||
import SessionRail from '$lib/components/SessionRail.svelte'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
@@ -62,6 +63,11 @@
|
||||
</script>
|
||||
|
||||
<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="min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
<script lang="ts">
|
||||
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 { relativeTime } from '$lib/utils'
|
||||
import * as Table from '$lib/components/ui/table'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import * as Select from '$lib/components/ui/select'
|
||||
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 loading = $state(true)
|
||||
@@ -47,6 +57,20 @@
|
||||
if (state === 'active' || state === 'healthy') return 'default'
|
||||
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>
|
||||
|
||||
<div class="flex h-full flex-col gap-4 p-6">
|
||||
@@ -85,14 +109,14 @@
|
||||
<Table.Head>Type</Table.Head>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head>State</Table.Head>
|
||||
<Table.Head>Updated</Table.Head>
|
||||
<Table.Head>Health</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each filtered as entity (entity.id)}
|
||||
<Table.Row
|
||||
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><Badge variant="outline">{entity.type}</Badge></Table.Cell>
|
||||
@@ -104,9 +128,16 @@
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-xs text-muted-foreground"
|
||||
>{new Date(entity.updated_at).toLocaleString()}</Table.Cell
|
||||
>
|
||||
<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>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
@@ -120,3 +151,5 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<EntitySheet slug={selectedSlug} bind:open={sheetOpen} />
|
||||
|
||||
@@ -1,226 +1,7 @@
|
||||
<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,
|
||||
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'
|
||||
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
|
||||
|
||||
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>
|
||||
|
||||
<div class="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-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>
|
||||
<EntityDetailContent {slug} />
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
<button
|
||||
class="session-card"
|
||||
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-meta">
|
||||
|
||||
Reference in New Issue
Block a user