7 Commits

Author SHA1 Message Date
614c38ea7c docs: plan chat-sessions fixes from real production usage data
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Inspected the live agent_sessions/agent_messages tables on mac-mini and
found silent empty responses, a canned non-English refusal after 22 tool
calls, 70-call fan-out for simple fleet questions, 100KB+ persisted
messages, and no session delete/title hygiene.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 10:09:34 +02:00
22412d2fa3 feat: group tool calls per turn + session entity graph in chat rail
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Two chat UX changes (share Chat.svelte, committed together).

Tool-call grouping (ToolCallGroup.svelte):
- Replaced the one-<details>-per-tool-call list with a single
  collapsible group per assistant turn, headed by tool count + a
  name preview and a status icon (spinning wrench in progress, check
  done, X on error).
- The group auto-collapses the instant its turn finishes streaming, so
  a completed round shows as one compact pill; historical/loaded turns
  start collapsed. The auto-collapse fires once at the streaming→done
  transition, leaving manual toggles alone afterward.

Session graph rail (SessionGraph.svelte) — replaces the old
ContextRail (fleet health / pending approvals / live events), which is
deleted:
- A force-directed graph that starts empty (animated constellation
  empty state) and grows as the conversation references entities.
  Slugs are extracted from message text and tool *arguments* only —
  never bulk result rows, so a single get_health_summary doesn't dump
  all 168 entities — then validated against the backend via fetchGraph
  (cached) with check/execution probe entities excluded. Nodes are
  colored by health; edges appear once both endpoints are present.
- Clicking a node highlights it and its neighbors and opens an inline
  detail panel below: slug/type/state, health + freshness, top
  attributes, in-graph relations (clickable to hop), and a Full detail
  button opening the entity sheet.
- The rail is resizable via a drag handle (260–620px, persisted to
  localStorage). The header's global fleet-health dots are unchanged;
  only the right-rail content was replaced.

Risk: reversible_low (UI-only). The slug extractor is scoped to
focused mentions by design; edges may be slightly incomplete since
only root-fetched entities contribute edges, which is acceptable for a
session overview.

Verification: verified in the browser preview — loading a real session
built a 4-node graph (hubris/caddy/netbird-vps/strong) with the
hubris→caddy relationship edge; clicking hubris showed
"proxmox-host · active · healthy · checked 40s ago" with attributes and
relations; dragging the handle resized 320→440px and persisted; a
34-tool historical turn renders as one collapsed pill that expands on
click. tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 09:48:21 +02:00
5686b9de40 fix: white favicon, sidebar active-state bug, app-wide pointer cursor
Three UI issues reported after the neutral-gray redesign:

- favicon.svg was still filled #58a6ff (the pre-redesign accent blue);
  changed to white to match the sidebar logo mark.
- Sidebar nav items all showed a filled background even when inactive.
  Root cause: sidebar-menu-button.svelte (and -sub-button) rendered
  `data-active="false"` as a literal attribute, but Tailwind's bare
  `data-active:` variant matches attribute *presence*, not value — so
  data-active:bg-sidebar-accent applied to every item regardless of
  state. Fixed by emitting the attribute only when active
  (`isActive || undefined`), a latent bug in the vendored shadcn
  component that read as intentional until flagged.
- Tailwind's preflight resets <button> to cursor: default, so no button
  in the app showed a pointer. Added one base rule restoring
  cursor: pointer for buttons, [role=button], links, summary, and
  select (respecting :disabled / aria-disabled) rather than annotating
  each call site — covers new interactive elements automatically.

Risk: reversible_low (UI-only).

Verification: verified in the browser preview that inactive sidebar
items are transparent (only the current page shows a background),
nav buttons report cursor: pointer via computed styles, and the
favicon renders white in the tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 09:47:47 +02:00
aa6017e0ca fix: layout overflow regression + adopt true neutral gray theme
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Problem: two issues surfaced after the dashboard-01 shell change
(cbfd09c). (1) Sidebar.Inset previously had an explicit h-svh that
hard-capped the app's height at the viewport; adding variant="inset"
put a margin on that same fixed-height box, pushing it taller than the
viewport with nothing left in the chain to cap it (Sidebar.Provider's
own wrapper only sets min-h-svh — a floor, not a ceiling). Result: the
whole page scrolled as one long document instead of each page's own
content scrolling internally with the header pinned — confirmed via
computed styles, e.g. Entities.svelte's table wrapper measured
scrollHeight 6531px against a 900px viewport, all of it spilling past
body instead of scrolling in its own rounded-border container.
(2) The color palette was GitHub-dark-inspired (blue-tinted grays:
#0d1117 bg, #58a6ff primary/accent) rather than the neutral grays the
shadcn-svelte dashboard-01 reference actually uses.

Change:
- App.svelte: moved the height cap up to Sidebar.Provider itself
  (class="h-svh") instead of Sidebar.Inset, since the cap needs to sit
  above wherever the inset variant's margin gets applied, not on the
  same box as the margin.
- app.css: replaced the core tokens (background/foreground/card/
  popover/primary/secondary/muted/accent/border/input/ring/sidebar-*)
  with shadcn's canonical dark-theme OKLCH values (0-chroma neutral
  grays), pulled directly from huntabyte/shadcn-svelte's own
  docs/src/app.css rather than approximated. --success/--warning
  deliberately kept as real, distinguishable colors — they signal
  actual health state, and desaturating them to match the neutral
  chrome would reintroduce the "can't tell what's actually happening"
  problem this whole project started from (see 279549c). --accent-blue
  now aliases --sidebar-primary (still a real blue) instead of
  --primary, so the couple of spots wanting an interactive "pop" still
  have one while buttons/links/focus rings ride the neutral --primary.

Risk: reversible_low (UI-only).

Verification: npx tsc --noEmit clean (excluding pre-existing unrelated
.svelte type-resolution warnings). go build/vet clean (backend
untouched, sanity check only). Manually verified in the browser
preview at 1400px: document.body.scrollHeight now exactly matches
window.innerHeight on both Overview and the 193-row Entities table
(previously 6531px vs 900px); scrolled the Entities table wrapper to
row ~60 and confirmed the header/filter bar/column headers stay
pinned while only the table body scrolls; confirmed neutral gray
rendering across Overview's stat cards, the event-rate chart, and
Chat's tool-call list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 01:12:47 +02:00
cbfd09c5df feat: redesign toward shadcn-svelte dashboard-01 (inset sidebar, gradient stat cards)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Problem: requested visual alignment with the shadcn-svelte dashboard-01
reference block (shadcn-svelte.com/blocks#dashboard-01) — the app's
sidebar/header shell and Overview stat cards looked plain by comparison.

Change: pulled the actual reference source (app-sidebar.svelte,
nav-main.svelte, site-header.svelte, section-cards.svelte from
huntabyte/shadcn-svelte) rather than approximating from screenshots.

- App.svelte: Sidebar.Root now uses variant="inset" (the floating,
  rounded, shadowed content panel — already fully built into the
  existing Sidebar.Inset component via peer-data selectors, just never
  enabled). Brand mark is now a proper Sidebar.MenuButton matching the
  reference's padding/hover treatment; "New chat" uses the reference's
  primary-colored button styling. Header matches the reference exactly:
  h-(--header-height) (48px, was 44px), vertical separator after the
  sidebar trigger, right-aligned actions group.
- Overview.svelte: stat cards rebuilt to match section-cards.svelte —
  gradient background, Card.Action badge, Card.Footer with a bold line
  + muted context line, tabular-nums, responsive @container grid
  (1/2/4 columns). Deliberately did NOT copy the reference's fake
  trend-percentage badges (Oikos doesn't track historical trends, and
  this project's whole thrust has been eliminating dishonest UI state —
  see 279549c). Badges instead reflect real current-state signals
  (healthy/degraded/down, clear/needs-review) computed from the actual
  dashboard summary.
- EntityDetailContent.svelte + Entities/Signals/Ops/Events/Agent/
  Audit/Knowledge pages: normalized root padding to p-4 md:p-6 (was a
  flat p-6) to match the reference's responsive py-4 md:py-6 convention.

Risk: reversible_low (UI-only, no data or behavior changes).

Verification: npx tsc --noEmit clean (excluding pre-existing unrelated
.svelte type-resolution warnings, same as prior commits). go build/vet
clean (backend untouched, sanity check only). Manually verified in the
browser preview at 1400px: inset sidebar's margin/rounded-corner/shadow
classes confirmed applied via computed styles; Overview cards render
with real live numbers from the now-fixed dashboard summary endpoint;
Signals/Ops pages confirmed visually consistent with the new spacing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 00:54:51 +02:00
851b5dce67 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>
2026-07-09 00:26:37 +02:00
279549c8c9 fix: scheduler wrote health/metrics/events to probe entities, not targets
Problem: every host/service/lxc/etc. entity_status row was permanently
stuck at 'unknown' since creation. Verified against the live DB:
metric_samples had 17,559 rows, 100% attached to type='check' probe
entities and 0% to any real monitored entity; only 25 check entities
ever had real health written. check_defs.entity_id (the probe's own
bookkeeping entity) and check_defs.target_id (the host/service actually
being observed) were both real fields, but the scheduler wrote
UpsertEntityStatus/InsertMetricSample/emitSchedulerEvent keyed by
entity_id instead of target_id — so every check ran and every result
was real, it just landed on the wrong row. This is the mechanism behind
observed drift: the agent's dashboard/health tools reported the
internal probes' state, never the actual fleet.

Change:
- scheduler.go: runCheck/resolveSignal now resolve targetID from
  cd.TargetID (falling back to the check's own id if unset) and write
  status/metrics/events there. Signals stay keyed by the check entity,
  unchanged, matching their existing resolution logic.
- Added a staleness sweep to housekeeping(): an entity whose last
  observation is older than 3x its fastest enabled check's interval
  (floor 5m) is marked 'stale' and emits health.stale, so a stalled
  scheduler or disabled check_def can no longer look like current data
  forever.
- migrations/016: deletes the now-orphaned check-entity entity_status
  rows so dashboard/fleet-health rollups stop double-counting probes as
  monitored entities. Historical metric_samples on check entities are
  left as-is (time-series data, not safe to reattribute).
- openapi.yaml + regenerated gen code: Entity gains health/last_check_at;
  'stale' added to the health enum everywhere it's used.
- dashboard.go / GetFleetHealth / nomos's get_health_summary MCP tool:
  exclude type='check' entities from rollups.
- nomos/agent.go: replay prior turns' tool_use/tool_result pairs into
  the conversation instead of dropping them (previously only final text
  was replayed, forcing the agent to re-derive fleet state every turn),
  and inject a compact live fleet-health snapshot into the system prompt
  each turn so it starts oriented instead of spending an iteration on
  discovery.

Risk: config_mutation (schema-adjacent — new migration, no destructive
DDL, additive DELETE only on orphaned rows). No behavior change until
oikos-api/oikos-scheduler/nomos are rebuilt and redeployed.

Verification: go build/vet clean across the repo. Ran this worktree's
own API binary against the live dev Postgres on an alternate port
(read-only from the live containers' perspective) and confirmed
/api/v1/entities now returns health/last_check_at, and the dashboard
health rollup dropped from double-counting to an honest 168 unmonitored
entities (matches reality pre-deploy — the live scheduler hasn't run
the fixed code yet). Confirmed check_defs.target_id correctly maps
multiple checks to host:hubris via direct psql query.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 00:26:04 +02:00
40 changed files with 2228 additions and 684 deletions

View File

@@ -1914,6 +1914,21 @@ components:
updated_at:
type: string
format: date-time
health:
type: string
description: last observed health, when the entity is monitored
nullable: true
enum:
- healthy
- degraded
- down
- unknown
- stale
last_check_at:
type: string
format: date-time
nullable: true
description: when health was last observed
EntityCreate:
type: object
required:
@@ -2020,6 +2035,7 @@ components:
- degraded
- down
- unknown
- stale
EntityType:
type: object
required:
@@ -3031,6 +3047,9 @@ components:
type: integer
unknown:
type: integer
stale:
type: integer
description: last observation older than the check's expected cadence
entities:
type: array
items:
@@ -3051,6 +3070,7 @@ components:
- degraded
- down
- unknown
- stale
trend:
type: string
enum:
@@ -3100,6 +3120,9 @@ components:
type: integer
unknown:
type: integer
stale:
type: integer
description: last observation older than the check's expected cadence
signals_by_severity:
type: object
description: open (non-resolved) signal counts keyed by severity

View File

@@ -108,11 +108,16 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
// Rebuild conversation context from persisted history so sessions are
// multi-turn. The current user turn is saved by the HTTP handler before
// this runs, so it is already included in the history for real sessions.
// Intermediate tool_use/tool_result pairs are not replayed (their ids
// must match exactly or the API rejects them); prior final answers carry
// the salient context. Ephemeral sessions (no store) fall back to the
// single incoming message.
messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(a.system)}
// Prior tool_use/tool_result pairs are replayed as a tool-calling
// assistant message followed by matching tool-role results, so the agent
// starts each turn already knowing what it already checked instead of
// re-querying the same tools from scratch. Ephemeral sessions (no store)
// fall back to the single incoming message.
system := a.system
if snapshot := a.fleetSnapshot(); snapshot != "" {
system += "\n\n" + snapshot
}
messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(system)}
history, _ := a.store.getMessages(ctx, sessionID)
for _, m := range history {
text := extractText(m.Content)
@@ -120,6 +125,12 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
case "user":
messages = append(messages, openai.UserMessage(text))
case "assistant":
if calls := extractToolCalls(m.Content); len(calls) > 0 {
messages = append(messages, assistantToolCallMessage(calls))
for _, c := range calls {
messages = append(messages, openai.ToolMessage(c.resultText(), c.id))
}
}
if text != "" {
messages = append(messages, openai.AssistantMessage(text))
}
@@ -243,6 +254,140 @@ func extractText(content json.RawMessage) string {
return m.Text
}
// persistedCall is one merged tool_use+tool_result pair from a persisted
// assistant message's tool_calls array. The store keeps them as two entries
// sharing the same id (mirroring the SSE event pair); replay needs one
// entry per id to build a valid tool-calling assistant message.
type persistedCall struct {
id string
name string
args json.RawMessage
result json.RawMessage
errMsg string
}
func (c persistedCall) resultText() string {
if c.errMsg != "" {
return c.errMsg
}
if len(c.result) > 0 {
return string(c.result)
}
return "null"
}
// extractToolCalls parses and merges a persisted message's tool_calls array,
// preserving first-seen order across ids.
func extractToolCalls(content json.RawMessage) []persistedCall {
var m struct {
ToolCalls []struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Args json.RawMessage `json:"args"`
Result json.RawMessage `json:"result"`
Error string `json:"error"`
} `json:"tool_calls"`
}
if err := json.Unmarshal(content, &m); err != nil || len(m.ToolCalls) == 0 {
return nil
}
byID := make(map[string]*persistedCall, len(m.ToolCalls))
var order []string
for _, tc := range m.ToolCalls {
if tc.ID == "" {
continue
}
pc, ok := byID[tc.ID]
if !ok {
pc = &persistedCall{id: tc.ID}
byID[tc.ID] = pc
order = append(order, tc.ID)
}
if tc.Name != "" {
pc.name = tc.Name
}
if len(tc.Args) > 0 && string(tc.Args) != "null" {
pc.args = tc.Args
}
if tc.Type == "tool_result" {
pc.errMsg = tc.Error
pc.result = tc.Result
}
}
calls := make([]persistedCall, 0, len(order))
for _, id := range order {
calls = append(calls, *byID[id])
}
return calls
}
// assistantToolCallMessage builds the tool-calling assistant message that
// must precede the tool-role results being replayed.
func assistantToolCallMessage(calls []persistedCall) openai.ChatCompletionMessageParamUnion {
toolCalls := make([]openai.ChatCompletionMessageToolCallParam, 0, len(calls))
for _, c := range calls {
args := string(c.args)
if args == "" {
args = "{}"
}
toolCalls = append(toolCalls, openai.ChatCompletionMessageToolCallParam{
ID: c.id,
Function: openai.ChatCompletionMessageToolCallFunctionParam{
Name: c.name,
Arguments: args,
},
})
}
return openai.ChatCompletionMessageParamUnion{
OfAssistant: &openai.ChatCompletionAssistantMessageParam{ToolCalls: toolCalls},
}
}
// fleetSnapshot returns a compact, current-as-of-now fleet health line for
// the system prompt so the agent starts each turn already oriented instead
// of spending its first iteration rediscovering topology it already has
// tools to query. Best-effort: an empty string on any failure just means no
// snapshot, not an error for the turn.
func (a *agent) fleetSnapshot() string {
result, err := a.client.callTool("get_health_summary", map[string]any{})
if err != nil {
return ""
}
rows, ok := result.([]any)
if !ok {
return ""
}
counts := map[string]int{}
var attention []string
for _, r := range rows {
row, ok := r.(map[string]any)
if !ok {
continue
}
health, _ := row["health"].(string)
counts[health]++
if health != "healthy" && health != "" {
if slug, ok := row["slug"].(string); ok && len(attention) < 10 {
attention = append(attention, fmt.Sprintf("%s(%s)", slug, health))
}
}
}
if len(counts) == 0 {
return ""
}
summary := fmt.Sprintf("Current fleet snapshot (as of now): healthy=%d degraded=%d down=%d stale=%d unknown=%d.",
counts["healthy"], counts["degraded"], counts["down"], counts["stale"], counts["unknown"])
if len(attention) > 0 {
summary += " Needs attention: " + fmt.Sprintf("%v", attention) + "."
}
return summary
}
func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) {
defs, err := a.client.listToolsFull()
if err != nil {

View File

@@ -54,10 +54,17 @@ func (s *Server) GetDashboardSummary(ctx context.Context, req gen.GetDashboardSu
return nil, err
}
rows, err = s.pool.Query(ctx, `SELECT health, count(*) FROM entity_status GROUP BY health`)
// Exclude 'check' entities (internal probes) — only entities actually
// being monitored should count toward the fleet health rollup.
rows, err = s.pool.Query(ctx, `
SELECT st.health, count(*)
FROM entity_status st JOIN entities e ON e.id = st.entity_id
WHERE e.type <> 'check'
GROUP BY st.health`)
if err != nil {
return nil, err
}
stale := 0
for rows.Next() {
var health string
var n int
@@ -72,6 +79,8 @@ func (s *Server) GetDashboardSummary(ctx context.Context, req gen.GetDashboardSu
resp.Health.Degraded = n
case "down":
resp.Health.Down = n
case "stale":
stale = n
default:
resp.Health.Unknown = n
}
@@ -80,6 +89,9 @@ func (s *Server) GetDashboardSummary(ctx context.Context, req gen.GetDashboardSu
if err := rows.Err(); err != nil {
return nil, err
}
if stale > 0 {
resp.Health.Stale = &stale
}
rows, err = s.pool.Query(ctx, `
SELECT severity, count(*) FROM signals

View File

@@ -104,6 +104,15 @@ const (
ClassificationRouteHold ClassificationRoute = "hold"
)
// Defines values for EntityHealth.
const (
EntityHealthDegraded EntityHealth = "degraded"
EntityHealthDown EntityHealth = "down"
EntityHealthHealthy EntityHealth = "healthy"
EntityHealthStale EntityHealth = "stale"
EntityHealthUnknown EntityHealth = "unknown"
)
// Defines values for EntityTypeLayer.
const (
EntityTypeLayerCognition EntityTypeLayer = "cognition"
@@ -160,6 +169,7 @@ const (
GraphViewHealthDegraded GraphViewHealth = "degraded"
GraphViewHealthDown GraphViewHealth = "down"
GraphViewHealthHealthy GraphViewHealth = "healthy"
GraphViewHealthStale GraphViewHealth = "stale"
GraphViewHealthUnknown GraphViewHealth = "unknown"
)
@@ -168,6 +178,7 @@ const (
HealthSummaryEntitiesHealthDegraded HealthSummaryEntitiesHealth = "degraded"
HealthSummaryEntitiesHealthDown HealthSummaryEntitiesHealth = "down"
HealthSummaryEntitiesHealthHealthy HealthSummaryEntitiesHealth = "healthy"
HealthSummaryEntitiesHealthStale HealthSummaryEntitiesHealth = "stale"
HealthSummaryEntitiesHealthUnknown HealthSummaryEntitiesHealth = "unknown"
)
@@ -262,10 +273,10 @@ const (
// Defines values for TrendDirection.
const (
TrendDirectionDegrading TrendDirection = "degrading"
TrendDirectionImproving TrendDirection = "improving"
TrendDirectionStable TrendDirection = "stable"
TrendDirectionUnknown TrendDirection = "unknown"
Degrading TrendDirection = "degrading"
Improving TrendDirection = "improving"
Stable TrendDirection = "stable"
Unknown TrendDirection = "unknown"
)
// Defines values for ListApprovalsParamsStatus.
@@ -557,7 +568,10 @@ type DashboardSummary struct {
Degraded int `json:"degraded"`
Down int `json:"down"`
Healthy int `json:"healthy"`
Unknown int `json:"unknown"`
// Stale last observation older than the check's expected cadence
Stale *int `json:"stale,omitempty"`
Unknown int `json:"unknown"`
} `json:"health"`
// SignalsBySeverity open (non-resolved) signal counts keyed by severity
@@ -596,18 +610,27 @@ type EnrollResponse struct {
// Entity defines model for Entity.
type Entity struct {
Attributes *map[string]interface{} `json:"attributes,omitempty"`
CreatedAt time.Time `json:"created_at"`
Id openapi_types.UUID `json:"id"`
MaintenanceUntil *time.Time `json:"maintenance_until"`
Name string `json:"name"`
Slug string `json:"slug"`
State *string `json:"state"`
Type string `json:"type"`
UpdatedAt time.Time `json:"updated_at"`
Version int `json:"version"`
Attributes *map[string]interface{} `json:"attributes,omitempty"`
CreatedAt time.Time `json:"created_at"`
// Health last observed health, when the entity is monitored
Health *EntityHealth `json:"health"`
Id openapi_types.UUID `json:"id"`
// LastCheckAt when health was last observed
LastCheckAt *time.Time `json:"last_check_at"`
MaintenanceUntil *time.Time `json:"maintenance_until"`
Name string `json:"name"`
Slug string `json:"slug"`
State *string `json:"state"`
Type string `json:"type"`
UpdatedAt time.Time `json:"updated_at"`
Version int `json:"version"`
}
// EntityHealth last observed health, when the entity is monitored
type EntityHealth string
// EntityCreate defines model for EntityCreate.
type EntityCreate struct {
// Attributes Validated against the type's attribute_schema
@@ -765,7 +788,10 @@ type HealthSummary struct {
Degraded int `json:"degraded"`
Down int `json:"down"`
Healthy int `json:"healthy"`
Unknown int `json:"unknown"`
// Stale last observation older than the check's expected cadence
Stale *int `json:"stale,omitempty"`
Unknown int `json:"unknown"`
} `json:"summary"`
}
@@ -8182,173 +8208,175 @@ func (sh *strictHandler) GetTrends(w http.ResponseWriter, r *http.Request, entit
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
"7gAA",
}
// GetSwagger returns the content of the embedded swagger specification file

View File

@@ -75,22 +75,31 @@ func (s *Server) resolveEntityID(ctx context.Context, idOrSlug string) (uuid.UUI
return id, err
}
// entityCols requires the entities table to be aliased as `e`.
// entityCols requires the entities table to be aliased as `e`, with
// entity_status left-joined and aliased as `st` (see withEntityStatus).
const entityCols = `e.id, e.slug, e.type, e.name, e.state, e.attributes,
e.maintenance_until, e.version, e.created_at, e.updated_at`
e.maintenance_until, e.version, e.created_at, e.updated_at,
st.health, st.last_check_at`
func scanEntity(row pgx.Row) (gen.Entity, error) {
var e gen.Entity
var state *string
var attrsJSON []byte
var maint *time.Time
var health *string
var lastCheckAt *time.Time
err := row.Scan(&e.Id, &e.Slug, &e.Type, &e.Name, &state, &attrsJSON,
&maint, &e.Version, &e.CreatedAt, &e.UpdatedAt)
&maint, &e.Version, &e.CreatedAt, &e.UpdatedAt, &health, &lastCheckAt)
if err != nil {
return e, err
}
e.State = state
e.MaintenanceUntil = maint
if health != nil {
h := gen.EntityHealth(*health)
e.Health = &h
}
e.LastCheckAt = lastCheckAt
var attrs map[string]any
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
e.Attributes = &attrs
@@ -113,6 +122,7 @@ func (s *Server) ListEntities(ctx context.Context, req gen.ListEntitiesRequestOb
)
SELECT ` + entityCols + ` FROM entities e
JOIN entity_types et ON et.name = e.type
LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.type IN (SELECT name FROM tt)
AND ($2::text IS NULL OR e.state = $2)
AND ($3::text IS NULL OR et.domain = $3)
@@ -159,7 +169,7 @@ func (s *Server) GetEntity(ctx context.Context, req gen.GetEntityRequestObject)
return nil, err
}
e, err := scanEntity(s.pool.QueryRow(ctx,
"SELECT "+entityCols+" FROM entities e WHERE e.id = $1", id))
"SELECT "+entityCols+" FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.id = $1", id))
if err != nil {
return nil, err
}
@@ -231,6 +241,7 @@ func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusReque
SELECT `+entityCols+`, b.depth
FROM blast_radius($1, $2) b
JOIN entities e ON e.id = b.entity_id
LEFT JOIN entity_status st ON st.entity_id = e.id
ORDER BY b.depth, e.slug`, id, depth)
if err != nil {
return nil, err
@@ -246,13 +257,20 @@ func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusReque
var state *string
var attrsJSON []byte
var maint *time.Time
var health *string
var lastCheckAt *time.Time
var d int
if err := rows.Scan(&e.Id, &e.Slug, &e.Type, &e.Name, &state, &attrsJSON,
&maint, &e.Version, &e.CreatedAt, &e.UpdatedAt, &d); err != nil {
&maint, &e.Version, &e.CreatedAt, &e.UpdatedAt, &health, &lastCheckAt, &d); err != nil {
return nil, err
}
e.State = state
e.MaintenanceUntil = maint
if health != nil {
h := gen.EntityHealth(*health)
e.Health = &h
}
e.LastCheckAt = lastCheckAt
var attrs map[string]any
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
e.Attributes = &attrs
@@ -283,10 +301,13 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
nodes, err = s.queryEntities(ctx, `
SELECT `+entityCols+`
FROM blast_radius($1, $2, $3) b JOIN entities e ON e.id = b.entity_id
LEFT JOIN entity_status st ON st.entity_id = e.id
ORDER BY e.slug`, rootID, depth, req.Params.RelType)
} else {
nodes, err = s.queryEntities(ctx, `
SELECT `+entityCols+` FROM entities e ORDER BY e.slug LIMIT $1`,
SELECT `+entityCols+` FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id
ORDER BY e.slug LIMIT $1`,
graphNodeCap+1)
if err == nil && len(nodes) > graphNodeCap {
nodes = nodes[:graphNodeCap]
@@ -523,14 +544,18 @@ func (s *Server) GetFleetHealth(ctx context.Context, req gen.GetFleetHealthReque
Type string `json:"type"`
}{}
// Exclude 'check' entities (internal probes) — only entities actually
// being monitored should count toward fleet health.
rows, err := s.pool.Query(ctx, `
SELECT e.slug, e.type, st.health, st.last_check_at
FROM entity_status st JOIN entities e ON e.id = st.entity_id
WHERE e.type <> 'check'
ORDER BY e.slug`)
if err != nil {
return nil, err
}
defer rows.Close()
stale := 0
for rows.Next() {
var slug, typ, health string
var lastCheck *time.Time
@@ -544,6 +569,8 @@ func (s *Server) GetFleetHealth(ctx context.Context, req gen.GetFleetHealthReque
resp.Summary.Degraded++
case "down":
resp.Summary.Down++
case "stale":
stale++
default:
resp.Summary.Unknown++
}
@@ -560,6 +587,9 @@ func (s *Server) GetFleetHealth(ctx context.Context, req gen.GetFleetHealthReque
Type: typ,
})
}
if stale > 0 {
resp.Summary.Stale = &stale
}
return resp, rows.Err()
}

View File

@@ -1974,14 +1974,14 @@ func (s *Server) GetTrends(ctx context.Context, req gen.GetTrendsRequestObject)
f, _ := slopeNum.Float64Value()
t.Slope = float32Ptr(float32(f.Float64))
if f.Float64 > 0.01 {
t.Direction = gen.TrendDirectionImproving
t.Direction = gen.Improving
} else if f.Float64 < -0.01 {
t.Direction = gen.TrendDirectionDegrading
t.Direction = gen.Degrading
} else {
t.Direction = gen.TrendDirectionStable
t.Direction = gen.Stable
}
} else {
t.Direction = gen.TrendDirectionUnknown
t.Direction = gen.Unknown
}
items = append(items, t)
}

View File

@@ -125,6 +125,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
return queryRows(ctx, pool, `
SELECT e.slug, e.type, st.health, st.last_check_at
FROM entity_status st JOIN entities e ON e.id = st.entity_id
WHERE e.type <> 'check'
ORDER BY e.slug`), nil
})

View File

@@ -94,6 +94,13 @@ func runCheckPass(ctx context.Context, pool *db.Pool) {
}
// runCheck executes a single check and processes the result.
//
// check_defs.entity_id identifies the *check* (probe) entity itself;
// check_defs.target_id identifies the entity actually being observed (the
// host/service/etc). Health, metrics, and events must attach to the target
// so the observed entity's own record reflects reality — not the internal
// probe. Signals stay keyed by the check entity (cd.EntityID), matching how
// they are created below and resolved elsewhere.
func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDefsRow) {
q := sqlcgen.New(pool)
start := time.Now()
@@ -107,9 +114,14 @@ func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDef
}
result.metrics["probe_latency_ms"] = float64(latency)
targetID := cd.EntityID
if cd.TargetID != nil {
targetID = *cd.TargetID
}
for metric, value := range result.metrics {
_ = q.InsertMetricSample(ctx, sqlcgen.InsertMetricSampleParams{
EntityID: cd.EntityID,
EntityID: targetID,
Metric: metric,
Value: value,
Tags: []byte(`{}`),
@@ -121,18 +133,18 @@ func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDef
"entity", cd.EntitySlug, "kind", cd.Kind, "error", result.err)
}
prevHealth := currentHealth(ctx, pool, cd.EntityID)
prevHealth := currentHealth(ctx, pool, targetID)
if result.signalKind == "" || result.health == "healthy" {
resolveSignal(ctx, pool, cd.EntityID, cd.EntitySlug)
resolveSignal(ctx, pool, cd.EntityID, targetID, cd.EntitySlug)
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
EntityID: cd.EntityID,
EntityID: targetID,
Health: "healthy",
LastCheckAt: &[]time.Time{time.Now()}[0],
Details: []byte(`{}`),
})
if prevHealth != "" && prevHealth != "healthy" {
emitSchedulerEvent(ctx, pool, "health.changed", cd.EntityID, "info",
emitSchedulerEvent(ctx, pool, "health.changed", targetID, "info",
map[string]any{"slug": cd.EntitySlug, "from": prevHealth, "to": "healthy"})
}
return
@@ -156,7 +168,7 @@ func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDef
}
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
EntityID: cd.EntityID,
EntityID: targetID,
Health: result.health,
LastCheckAt: &[]time.Time{time.Now()}[0],
Details: []byte(`{}`),
@@ -164,11 +176,11 @@ func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDef
_ = sig
if prevHealth == "" || prevHealth == "healthy" {
emitSchedulerEvent(ctx, pool, "signal.raised", cd.EntityID, severity,
emitSchedulerEvent(ctx, pool, "signal.raised", targetID, severity,
map[string]any{"slug": cd.EntitySlug, "kind": result.signalKind, "evidence": result.evidence})
}
if prevHealth != result.health {
emitSchedulerEvent(ctx, pool, "health.changed", cd.EntityID, severity,
emitSchedulerEvent(ctx, pool, "health.changed", targetID, severity,
map[string]any{"slug": cd.EntitySlug, "from": prevHealth, "to": result.health})
}
}
@@ -188,21 +200,23 @@ func emitSchedulerEvent(ctx context.Context, pool *db.Pool, eventType string, en
_ = observability.Event(ctx, sqlcgen.New(pool), eventType, &entityID, severity, "scheduler", "", data)
}
// resolveSignal resolves any open signal for the given check entity.
func resolveSignal(ctx context.Context, pool *db.Pool, entityID uuid.UUID, slug string) {
// resolveSignal resolves any open signal raised by the given check entity.
// checkID matches how signals are keyed (UpsertSignal uses the check's own
// entity id); targetID is the observed entity whose status this affects.
func resolveSignal(ctx context.Context, pool *db.Pool, checkID, targetID uuid.UUID, slug string) {
q := sqlcgen.New(pool)
// Check if there's an open signal on this entity
tag, err := pool.Exec(ctx, `UPDATE signals SET state = 'resolved', updated_at = now()
WHERE entity_id = $1 AND state = 'raised'`, entityID)
WHERE entity_id = $1 AND state = 'raised'`, checkID)
if err != nil {
return
}
if tag.RowsAffected() > 0 {
emitSchedulerEvent(ctx, pool, "signal.resolved", entityID, "info",
emitSchedulerEvent(ctx, pool, "signal.resolved", targetID, "info",
map[string]any{"slug": slug})
}
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
EntityID: entityID,
EntityID: targetID,
Health: "healthy",
LastCheckAt: &[]time.Time{time.Now()}[0],
Details: []byte(`{}`),
@@ -250,10 +264,74 @@ func housekeeping(ctx context.Context, pool *db.Pool) {
slog.Error("scheduler: prune idempotency keys", "error", err)
}
staleSweep(ctx, pool)
// Log housekeeping completion
slog.Debug("scheduler: housekeeping done", "pruned_idempotency_before", cutoff.Format(time.RFC3339))
}
// staleMultiplier and staleFloor bound how long an entity can go unobserved
// before its last-known health is no longer trusted. An entity is stale once
// it has gone longer than staleMultiplier times its fastest enabled check's
// interval (or staleFloor, whichever is larger) without a fresh observation —
// covering both a stalled scheduler and a disabled/broken check_def.
const (
staleMultiplier = 3
staleFloor = 5 * time.Minute
)
// staleSweep marks entities whose last observation has aged past their
// check's expected cadence as 'stale', so the system never reports an old
// health value as if it were current. Runs once per housekeeping pass.
func staleSweep(ctx context.Context, pool *db.Pool) {
rows, err := pool.Query(ctx, `
SELECT e.id, e.slug, st.health
FROM entity_status st
JOIN entities e ON e.id = st.entity_id
JOIN (
SELECT target_id, MIN(interval_s) AS min_interval
FROM check_defs
WHERE enabled AND target_id IS NOT NULL
GROUP BY target_id
) iv ON iv.target_id = st.entity_id
WHERE st.health <> 'stale'
AND (st.last_check_at IS NULL
OR st.last_check_at < now() - make_interval(secs => GREATEST(iv.min_interval * $1, $2)))`,
staleMultiplier, int(staleFloor.Seconds()))
if err != nil {
slog.Error("scheduler: stale sweep query", "error", err)
return
}
defer rows.Close()
type staleEntity struct {
id uuid.UUID
slug string
health string
}
var stale []staleEntity
for rows.Next() {
var se staleEntity
if err := rows.Scan(&se.id, &se.slug, &se.health); err != nil {
continue
}
stale = append(stale, se)
}
rows.Close()
for _, se := range stale {
_, err := pool.Exec(ctx,
`UPDATE entity_status SET health = 'stale', updated_at = now() WHERE entity_id = $1`, se.id)
if err != nil {
slog.Error("scheduler: mark stale", "entity", se.slug, "error", err)
continue
}
slog.Warn("scheduler: entity stale", "entity", se.slug, "prev_health", se.health)
emitSchedulerEvent(ctx, pool, "health.stale", se.id, "warning",
map[string]any{"slug": se.slug, "from": se.health, "to": "stale"})
}
}
// checkHTTP performs an HTTP health check.
func checkHTTP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
cfg := struct {

View File

@@ -0,0 +1,16 @@
-- 016_fix_check_status_misattribution.up.sql
-- The scheduler historically wrote entity_status and metric_samples keyed by
-- the *check* entity (check_defs.entity_id) instead of the entity being
-- monitored (check_defs.target_id). Every real host/service/lxc/etc. entity
-- was therefore permanently stuck at health='unknown' while all observed
-- health and metrics accumulated on the internal probe entities instead.
-- The Go fix (scheduler.go) starts writing to target_id going forward; this
-- migration removes the now-orphaned check-entity rows so dashboard/fleet
-- health rollups stop double-counting probes as if they were monitored
-- entities. Historical metric_samples on check entities are left in place
-- (time-series data, not safe to reattribute retroactively) but are no
-- longer queried through any entity-scoped view once checks stop being
-- written to.
DELETE FROM entity_status
WHERE entity_id IN (SELECT id FROM entities WHERE type = 'check');

View File

@@ -0,0 +1,253 @@
# 2026-07-08 — Liveness, drift, and UX cohesion
**Status:** Code complete for Phases 14 core scope; not yet deployed to the
live containers (pending explicit go-ahead — see below). Phase 5 partially
covered by pre-existing endpoints; full CRUD UI deferred.
- **Phase 1 (drift/staleness):** done. Health/metrics/events misattribution
fix, staleness sweep, `/entities` health+freshness, dashboard/fleet-health
`check`-entity exclusion, Entities table health column. Migration 016
applied to the live dev DB (safe cleanup, additive-only).
- **Phase 2 (detail sidebar + legibility):** done. `EntityDetailContent`
extracted and shared between the full `#/entity/:slug` page and a new
`EntitySheet` opened from the Entities table (master-detail, no
navigation). Adds a **Monitoring** card (per-entity `check_defs`: kind,
interval, enabled/disabled with click-to-toggle) and renders attributes as
a key/value list instead of raw JSON.
- **Phase 3 (sessions rejoin chat):** done. Fixed the click-does-nothing bug,
added a session rail inside Chat, fixed the local dev proxy to match
production's `/agent` prefix-stripping. **Also found and fixed a real
latent bug**: persisted `tool_calls` store the `tool_use`/`tool_result` as
two entries sharing one `id`; Chat.svelte's keyed `{#each tool (tool.id)}`
threw on the duplicate key and silently blanked the entire message list.
This had presumably never been noticed because sessions were never
clickable before this fix. Fixed in `chat.ts` by merging tool_calls by id
before rendering.
- **Phase 4 (agent efficiency):** core piece done — prior turns' tool
calls/results are now replayed into the conversation (previously dropped
entirely), and a compact live fleet-health snapshot is injected into the
system prompt each turn so the agent starts oriented. Prompt caching and
reconsidering the default model are **not done** (lower priority, no
measured regression without them).
- **Phase 5 (CRUD):** `PatchEntity` and a full `/checks` CRUD API
(list/create/patch, including enable/disable) already existed server-side;
the new Monitoring card's toggle uses `PatchCheck`. **Not done**: a
"run check now" endpoint (no scheduler on-demand entrypoint exists yet),
relationship editing, and an entity attribute editor UI.
**Not yet deployed** — the live `oikos-api`/`oikos-scheduler`/nomos
containers still run the pre-fix binaries; rebuilding and restarting them
needs an explicit go-ahead since it touches the running homelab control
plane.
Addresses five felt problems with the current system: (1) the agent reports
stale machine state as if it were fresh, (2) sessions can't be opened and feel
disconnected from chat, (3) the Nomos agent re-derives state every turn and
wastes iterations, (4) the UI feels dead — tables with no context, no sense of
what is monitored, (5) no way to inspect or customize entities and their checks.
The unifying UX principle for this plan: **master-detail with a detail
sidebar**, not full-page navigation. Selecting an entity, session, or signal
opens a right-hand detail panel over the current list, so the operator keeps
context and drills in without losing their place. Full pages remain
addressable (deep links) but are no longer the primary way to inspect a row.
Sequencing is driven by pain: **drift/staleness is Phase 1.**
---
## Root causes (verified in code)
### Drift / staleness — root cause was worse than a missing TTL
Live-DB inspection (`oikos-postgres-1`) found the real cause: `check_defs` has
two entity references — `entity_id` (the internal probe/"check" entity) and
`target_id` (the host/service actually being observed). The scheduler wrote
`entity_status`, `metric_samples`, and scheduler-sourced `events` keyed by
`cd.EntityID` (the probe) instead of `cd.TargetID` (the target) —
[scheduler.go:110-171](../internal/scheduler/scheduler.go) (pre-fix). Verified
against the live database:
```
entity_status by type: only type='check' rows ever had real health (24
healthy, 1 down); every host/service/lxc/vm/proxmox-host was frozen at
'unknown' since creation.
metric_samples: 17,559 rows, 100% attached to type='check' entities — zero
attached to any real host or service.
events: 45 of 46 scheduler-sourced rows attached to type='check' entities.
```
So this wasn't staleness in the TTL sense — the entities you actually care
about (`host:hubris`, `service:authentik`, etc.) **never received an
observation at all**. Every health check, metric, and event the scheduler
produced was filed under an internal bookkeeping entity the UI doesn't even
surface distinctly. This is the literal mechanism behind "the agent tells me
stale/wrong state."
**Fixed** (this session): `runCheck`/`resolveSignal` now resolve
`targetID := cd.TargetID` and write status/metrics/events there, falling back
to the check's own id only if `target_id` is unset. Signals remain keyed by
the check entity (unchanged, matches their existing resolution logic). A new
migration ([016_fix_check_status_misattribution](../migrations/016_fix_check_status_misattribution.up.sql))
deletes the orphaned check-entity `entity_status` rows so rollups stop
double-counting probes as monitored entities; historical `metric_samples` on
check entities are left as-is (time-series data, not safe to reattribute).
On top of the misattribution fix, a genuine staleness gap also existed and is
now closed: `entity_status.health` was written only when a check ran, with no
TTL — a stalled scheduler or disabled check_def would leave the last health
value looking current forever.
- `last_check_at` is recorded but was never surfaced. The Entities table
showed `entity.updated_at` (row mutation time), not observation time
([Entities.svelte:108](../web/src/pages/Entities.svelte), pre-fix).
- The `/entities` list endpoint returned neither `health` nor `last_check_at`
— only `/graph?include=status` and `/fleet/health` did
([impl.go:344](../internal/httpapi/impl.go), pre-fix).
### Sessions
- Clicking a session calls `loadSessionMessages()` but never navigates to the
chat page ([Sessions.svelte:17](../web/src/pages/Sessions.svelte)); it mutates
the chat store while the user stays on the session list, so nothing appears to
happen. There is also no session switcher inside Chat.
### Agent efficiency
- Multi-turn history replay **drops all `tool_use`/`tool_result` pairs**; only
prior final text is replayed ([agent.go:108-127](../cmd/nomos/agent.go)). Each
new turn re-discovers the fleet from scratch, re-calling tools already run.
- Cold start: the system prompt injects no fleet snapshot
([agent.go:81](../cmd/nomos/agent.go)); default model is
`deepseek/deepseek-v4-flash` ([agent.go:34](../cmd/nomos/agent.go)); tool
schema + system prompt are rebuilt each call with no prompt caching.
### Dead UI / no inspection
- Entities table = slug/type/name/state/updated; no health, no last-seen, no
signal count.
- EntityDetail dumps `JSON.stringify(attributes)` raw
([EntityDetail.svelte:123](../web/src/pages/EntityDetail.svelte)) and never
shows the entity's `check_defs` — the operator cannot see *what is monitored*,
when it last ran, or what it returned.
- No CRUD anywhere: no entity editor, no check management (enable/disable/edit/
run-now), no relationship editing. `check_defs` do not appear in the web app.
---
## Phase 1 — Kill the drift (highest priority)
Goal: the system never presents stale observations as fresh, and freshness is
visible everywhere health is.
**Backend**
- Add a staleness sweep to `housekeeping()`
([scheduler.go:243](../internal/scheduler/scheduler.go)): for each
`entity_status` where `now() - last_check_at > staleAfter` (default
`max(3 × check interval, 5m)`), transition health to a new `stale` value and
emit a `health.stale` event once (not every pass).
- Treat `stale` as a first-class health in dashboard rollups
([dashboard.go:57](../internal/httpapi/dashboard.go)) and fleet health
([impl.go:516](../internal/httpapi/impl.go)) — do not fold it into `unknown`.
- Extend the `/entities` list response with `health` and `last_check_at`
(join `entity_status`), so the table can show freshness without N graph calls.
- Nomos: when answering about state, tool results should carry `last_check_at`
and a stale flag so the agent can hedge ("healthy as of 4m ago") instead of
asserting stale data. (Verify the MCP topology/health tools include it.)
**Frontend**
- Entities table: replace the `Updated` column with **health dot + relative
"checked 2m ago"**, and add an **open-signal count** badge per row. Stale rows
get a distinct muted/amber treatment, not a green dot.
- Global header: add an "as of {time}" and make the SSE connection dot a real
liveness indicator (last event received, reconnect state).
**Acceptance:** disable a check or stop the scheduler → within one stale window
the affected entity shows `stale` in the table and dashboard, an event fires,
and asking Nomos "is X healthy?" yields a freshness-qualified answer.
---
## Phase 2 — Detail sidebar + entity legibility (less navigation)
Goal: inspect any row in place; make an entity's monitoring self-evident.
- Introduce a reusable **DetailSheet** (right-side panel) used across Entities,
Signals, Sessions, Executions. Row click opens the sheet; URL hash updates for
deep-linking; Esc / click-away closes. Full `#/entity/:slug` page remains for
direct links but reuses the same detail component.
- Entity detail content (in the sheet):
- Header: slug, type, **health + freshness** ("checked 2m ago" / "stale
18m").
- **Monitoring card**: the entity's `check_defs` — kind, schedule, enabled,
last result + evidence, next run. This is the missing "what is watched."
- Attributes rendered as a key/value panel, not raw JSON.
- Relations, open signals, recent executions, metrics sparklines (reuse
existing EntityDetail sections).
- Backend: endpoint to list `check_defs` for an entity with last-result join
(currently checks are only visible to the scheduler).
**Acceptance:** from the Entities list, one click reveals what an entity is,
what's monitoring it, when it was last seen, and its open signals — without a
full page load or losing the list.
---
## Phase 3 — Sessions rejoin chat
Goal: sessions are openable and live next to the conversation.
- Fix: clicking a session navigates to `#/chat` and loads it
([Sessions.svelte:17](../web/src/pages/Sessions.svelte)).
- Add a **session rail inside Chat** (collapsible left list: title, last-active,
active highlight) so switching sessions never leaves the chat surface. The
standalone Sessions page becomes a thin wrapper / can be retired from nav.
- Show session metadata (message count, last actor) and allow rename/delete.
**Acceptance:** clicking any past session opens its transcript in the chat view;
starting a new chat and switching back and forth works without navigation.
---
## Phase 4 — Agent efficiency
Goal: stop re-deriving state; start each turn already oriented.
- Persist and replay tool evidence across turns
([agent.go:108-127](../cmd/nomos/agent.go)): either replay `tool_use`/
`tool_result` pairs with consistent ids, or persist a compacted per-turn
"evidence summary" and replay that. Removes redundant re-querying.
- Inject a compact fleet snapshot (counts by health, open signals, stale set)
into the system prompt ([agent.go:81](../cmd/nomos/agent.go)) so the agent
starts oriented instead of spending iterations on discovery.
- Add prompt caching for the system prompt + tool schema (rebuilt every call
today); revisit the default model
([agent.go:34](../cmd/nomos/agent.go)) — evaluate a stronger default for
fewer, better tool calls.
- Surface per-turn iteration/token/cost in the chat UI (data already logged to
`agent_activity`) so inefficiency is visible and measurable.
**Acceptance:** a 3-turn conversation about the same entity does not re-call the
same read tools each turn; median iterations-per-answer drops.
---
## Phase 5 — Customize & inspect (CRUD)
Goal: manage the system from the UI, not just observe it.
- Entity editor (attributes, state) via existing mutation endpoints.
- Check management from the entity detail sheet: enable/disable, edit config/
thresholds, and **run-now** (trigger a single check pass on demand — new
scheduler entrypoint).
- Relationship add/remove.
- Raw DB-row view toggle in the detail sheet for power inspection.
---
## Suggested order of work
1. Phase 1 backend (staleness sweep + `/entities` health/freshness) →
Phase 1 frontend (table freshness + liveness header).
2. Phase 2 DetailSheet + entity monitoring card.
3. Phase 3 sessions fix (small; can slot in earlier if desired).
4. Phase 4 agent efficiency.
5. Phase 5 CRUD.
Phases 13 are the ones that most directly turn "the system feels dead and I
don't trust it" into "it's alive and I can see and act on it."

View File

@@ -0,0 +1,160 @@
# 2026-07-09 — Chat sessions: reliability, cost, and session-management fixes
**Status:** Planned
## Goal
Fix concrete problems found by inspecting the *actual* production Nomos chat
data (`agent_sessions`/`agent_messages` on the `oikos` Postgres, 24 sessions /
52 messages as of 2026-07-09), not a code-review of the UI in the abstract.
Findings below are backed by real rows, not hypotheticals.
## How this was investigated
Queried `oikos-postgres-1` directly (`docker exec oikos-postgres-1 psql -U oikos
-d oikos`) since this runs on mac-mini, the same host as the production
containers. Pulled session list, per-message content sizes, tool-call
breakdowns, and cross-checked against `cmd/nomos/agent.go` to explain what was
observed.
---
## Findings
### 1. ~17% of turns come back completely empty, silently
4 of 24 sessions ("hi" ×3, "what services are healthy?") have an assistant
message with `text=""` and zero tool calls — the model returned a blank
completion. `cmd/nomos/agent.go:175-183` treats `len(msg.ToolCalls) == 0` as a
normal final answer and emits `text: ""` + `done`. No `error` event fires, so
[chat.ts](../web/src/lib/stores/chat.ts) never sets `$error`, and
[Chat.svelte](../web/src/pages/Chat.svelte) renders a permanently-blank
assistant bubble (the "…" typing dots only show while `$streaming` is true;
once `done` fires they vanish, leaving nothing). The user has no idea the turn
failed and no obvious way to retry — they have to notice the silence and
retype.
**Fix:** in `agent.chat()`, if `msg.Content == "" && len(msg.ToolCalls) == 0`,
treat it as a retryable failure: log it, retry once against the provider
before giving up, and if still empty, emit a real `error` event instead of an
empty `text`/`done` pair. On the frontend, surface a "Nomos didn't respond —
retry?" affordance on empty assistant messages rather than a silent blank
bubble.
### 2. A tool-heavy turn came back as a canned non-English refusal
The session "what are the termals of hubris?" ran 22 tool calls and then
returned, verbatim: `关于这个问题,我没有相关信息,您可以尝试问我其它问题,我会尽力为您解答~`
("I don't have relevant information on this, try asking me something else").
This is after successfully gathering data via tools — the model discarded its
own tool results and emitted a boilerplate deflection in the wrong language.
`NOMOS_MODEL` is currently a DeepSeek flash-tier model on OpenRouter, which is
consistent with this kind of degraded-tier fallback text leaking through.
**Fix:** add a response-quality guard in `agent.chat()` — if the final text
doesn't match the conversation's language/looks like a canned refusal (simple
heuristic: non-ASCII-majority reply to an ASCII-majority conversation, or
matches a small denylist of known refusal boilerplate), treat it like the
empty-response case (retry, then surface an error rather than showing it to
the operator as a real answer). Separately, reconsider whether the flash-tier
model is worth the latency/cost tradeoff given it's producing failures like
this in a small sample — worth an eval pass against a couple of alternative
OpenRouter models on the same 24 real prompts before deciding.
### 3. Simple fleet questions fan out into dozens of individual tool calls
"Are any of the proxmox hosts saturated?" (2 hosts) triggered **70 tool
calls** in one turn, 21 of them individual `get_lxc_state` calls — one per LXC
container — instead of using the already-available `list_lxcs()` bulk tool.
Similar pattern in "What should be updated with high priority?" (68 calls) and
"What needs updating?" (54 calls). Each `get_lxc_state` is a live `pct status`
SSH round-trip to the Proxmox host, so this is 21 sequential SSH round trips
to answer a question `list_lxcs()` already answers in one call. This is the
direct cause of both slow responses and the huge persisted payloads in
finding 4.
**Fix:** two angles, not mutually exclusive:
- **Prompt-level**: tighten the Nomos system prompt (`nomos/SOUL.md`) to
explicitly prefer bulk tools (`list_lxcs`, `get_state_snapshot`,
`query_metrics`) over per-entity tools when the question is fleet-wide, and
only fall back to `get_lxc_state`/`tail_log` for a specific named entity.
- **Tool-level**: `get_lxc_state` already exists per-slug; consider whether
`list_lxcs()`'s summary is actually sufficient for "saturated" (CPU/mem %
per container) — if it's missing that field, that's *why* the model loops
per-container, and the real fix is enriching `list_lxcs()` rather than
prompting around the gap.
### 4. Tool results are persisted raw and unbounded, inflating messages to 100KB+
Message content sizes in `agent_messages.content` (JSONB) range up to
**106KB** for a single assistant turn. Even a plain "hi" greeting produced a
44KB message, because `get_state_snapshot()`'s full result — every entity in
the DB, including ~15 `document:containers/*` rows that are all
`state: <nil>, health: unknown` and contribute nothing — gets embedded
verbatim in the `tool_calls[].result` field and stored as-is
(`cmd/nomos/store.go:68-76` just JSON-inserts whatever the tool returned).
This bloats the DB, and every time a session is opened via
[loadSessionMessages](../web/src/lib/stores/chat.ts#L56) or the
[SessionRail](../web/src/lib/components/SessionRail.svelte)/
[Sessions](../web/src/pages/Sessions.svelte) page loads history, the browser
downloads and parses all of it just to render a collapsed tool-call summary.
**Fix:**
- Filter `get_state_snapshot()`'s result server-side (in the MCP tool, not
the agent) to drop entities with no meaningful state/health signal, or add
a `type` filter param the agent can pass.
- In `store.saveMessage`, cap persisted tool-result size (e.g. truncate to a
few KB with a `"...truncated, N bytes"` marker) — the full result already
served its purpose informing that turn's answer; historical replay
(`agent.chat()`'s history-replay loop at `agent.go:122-137`) doesn't need
the full blob, just enough for the model to know what it already checked.
### 5. No session hygiene: duplicate/typo'd titles, no delete/archive
Session titles are the raw, unprocessed first user message
(`store.createSession`), with no dedup, normalization, or cleanup. Real
production titles include **6 sessions titled "hi"**, **2 titled "say ok"**,
and typos preserved verbatim ("what are the **termals** of hubris?", "whats
the **termans** of strong", "Are any of the **proxomox** hosts saturated?").
Neither [Sessions.svelte](../web/src/pages/Sessions.svelte) nor
[SessionRail.svelte](../web/src/lib/components/SessionRail.svelte) nor the
`store`/API layer (`cmd/nomos/store.go`, `web/src/lib/api.ts`) has any delete
or archive path — grepped the whole stack, confirmed absent. Throwaway test
sessions accumulate forever with no way to clean them from the UI.
**Fix:**
- Add `DELETE /sessions/{id}` to the nomos gateway + a matching store method
and wire a delete affordance into `SessionRail`/`Sessions` (hover trash
icon, confirm on click).
- Generate titles from the assistant's actual answer once the turn completes
(or a cheap follow-up summarization call) instead of the raw first message,
so distinct "hi" sessions become distinguishable by what was actually
discussed.
---
## Implementation order
1. **Empty-response + refusal-leak guard** (`cmd/nomos/agent.go`) — highest
user-visible impact, smallest change, no schema/API changes.
2. **Bulk-tool prompting fix** (`nomos/SOUL.md`) — cheap, directly cuts
latency and tool-call volume; re-run the same 24 real prompts against the
updated prompt to confirm `get_lxc_state` fan-out drops.
3. **Tool-result truncation on persist** (`cmd/nomos/store.go`) — bounds
future DB growth; pair with a one-off cleanup pass on the 52 existing rows
if the table needs to be shrunk immediately.
4. **`get_state_snapshot` filtering** — coordinate with whichever MCP tool
file defines it; verify with `jsonb_pretty` on a fresh "hi" session that
payload drops well below the current ~44KB.
5. **Session delete + title generation** — UI + gateway change, lowest risk,
can ship independently of 1-4.
## Verification
- Re-run the same 24 real user prompts (recorded in this plan's investigation)
against the patched agent; confirm zero empty/refusal-leak responses and
`get_lxc_state`-style fan-out drops to O(hosts) not O(containers).
- `docker exec oikos-postgres-1 psql -U oikos -d oikos -c "SELECT max(length(content::text)) FROM agent_messages;"`
before/after — expect the ceiling to move from ~106KB to low single-digit KB.
- Manually delete a test session via the new UI affordance, confirm it's gone
from both `SessionRail` and the `agent_sessions` table.

View File

@@ -13,6 +13,7 @@ went sideways, open an investigation.
| 2026-07-08 | [Oikos gaps, broken things, and improvements](2026-07-08-oikos-gaps-and-improvements.md) | Planned |
| 2026-07-08 | [Control room web UI](2026-07-08-control-room-webui.md) | In Progress |
| 2026-07-08 | [Nomos resident agent (renames Hermes)](2026-07-08-nomos-resident-agent.md) | In Progress |
| 2026-07-09 | [Chat sessions: reliability, cost, and session-management fixes](2026-07-09-chat-sessions-improvements.md) | Planned |
## Done

File diff suppressed because one or more lines are too long

4
web/public/favicon.svg Normal file
View 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="#ffffff" 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

View File

@@ -19,10 +19,10 @@
import * as Sheet from '$lib/components/ui/sheet'
import { Button } from '$lib/components/ui/button'
import { Badge } from '$lib/components/ui/badge'
import { Separator } from '$lib/components/ui/separator'
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,22 +71,40 @@
{ 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 }
]
</script>
<Toaster />
<Sidebar.Provider>
<Sidebar.Root collapsible="icon">
<Sidebar.Provider class="h-svh" style="--header-height: calc(var(--spacing) * 12);">
<Sidebar.Root collapsible="icon" variant="inset">
<Sidebar.Header>
<div class="flex items-center justify-between px-1">
<span class="text-sm font-bold text-primary">Oikos</span>
</div>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton onclick={() => { newChat(); navigate('chat') }} tooltipContent="New chat">
<Sidebar.MenuButton
class="data-[slot=sidebar-menu-button]:!p-1.5"
onclick={() => navigate('overview')}
tooltipContent="Oikos"
>
{#snippet child({ props })}
<button {...props}>
<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>
</button>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton
class="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
onclick={() => { newChat(); navigate('chat') }}
tooltipContent="New chat"
>
{#snippet child({ props })}
<button {...props}>
<PlusIcon />
@@ -142,11 +160,12 @@
</Sidebar.Footer>
</Sidebar.Root>
<Sidebar.Inset class="h-svh min-h-0">
<header class="flex h-11 shrink-0 items-center gap-3 border-b px-3">
<Sidebar.Trigger />
<span class="text-sm font-medium capitalize">{page === 'entity' ? routeParam : page}</span>
<div class="flex-1"></div>
<Sidebar.Inset class="min-h-0 overflow-hidden">
<header class="flex h-(--header-height) shrink-0 items-center gap-1 border-b px-4 lg:gap-2 lg:px-6">
<Sidebar.Trigger class="-ms-1" />
<Separator orientation="vertical" class="mx-2 data-[orientation=vertical]:h-4" />
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page}</span>
<div class="ms-auto flex items-center gap-2.5">
{#if $summary}
<div class="hidden items-center gap-2.5 text-xs text-muted-foreground sm:flex">
<span class="flex items-center gap-1" title="healthy"><span class="size-2 rounded-full bg-success"></span>{$summary.health.healthy}</span>
@@ -168,6 +187,7 @@
class="size-2 rounded-full {$connectionState === 'open' ? 'bg-success' : $connectionState === 'connecting' ? 'animate-pulse bg-warning' : 'bg-destructive'}"
title="event stream: {$connectionState}"
></span>
</div>
</header>
<main class="min-h-0 flex-1 overflow-hidden">
{#if page === 'overview'}

View File

@@ -2,59 +2,61 @@
@custom-variant dark (&:is(.dark *));
/* Neutral gray theme matching shadcn/ui's canonical dark palette (0-chroma
OKLCH grays) — the app is dark-only, so :root carries the dark values
directly rather than gating behind a .dark class. --success/--warning are
Oikos-specific semantic status colors (real health state), kept
distinguishable rather than desaturated to match the neutral chrome. */
:root {
--radius: 0.5rem;
--background: #0d1117;
--foreground: #e6edf3;
--card: #161b22;
--card-foreground: #e6edf3;
--popover: #161b22;
--popover-foreground: #e6edf3;
--primary: #58a6ff;
--primary-foreground: #0d1117;
--secondary: #21262d;
--secondary-foreground: #e6edf3;
--muted: #21262d;
--muted-foreground: #8b949e;
--accent: #292e36;
--accent-foreground: #e6edf3;
--destructive: #f85149;
--destructive-foreground: #ffffff;
--radius: 0.625rem;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--success: #3fb950;
--warning: #d29922;
--border: #30363d;
--input: #30363d;
--ring: #58a6ff;
--sidebar: #161b22;
--sidebar-foreground: #e6edf3;
--sidebar-primary: #58a6ff;
--sidebar-primary-foreground: #0d1117;
--sidebar-accent: #21262d;
--sidebar-accent-foreground: #e6edf3;
--sidebar-border: #30363d;
--sidebar-ring: #58a6ff;
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
/* legacy aliases still referenced by Chat/Sessions/App */
--bg: var(--background);
--bg-surface: var(--card);
--bg-deeper: #0a0e13;
--bg-deeper: oklch(0.11 0 0);
--bg-hover: var(--secondary);
--bg-active: var(--accent);
--text: var(--foreground);
--text-muted: var(--muted-foreground);
--accent-blue: var(--primary);
/* accent-blue stays a real blue (matches --sidebar-primary) for the few
spots that want an interactive "pop" — everything else (buttons,
links, focus rings) rides the neutral --primary now. */
--accent-blue: var(--sidebar-primary);
--accent-green: var(--success);
--accent-red: var(--destructive);
--accent-orange: var(--warning);
}
/* the app is dark-only; treat root as the dark theme unconditionally */
.dark {
--background: #0d1117;
--foreground: #e6edf3;
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
@@ -105,6 +107,16 @@
line-height: 1.4;
-webkit-font-smoothing: antialiased;
}
/* Tailwind's preflight resets <button> to cursor: default; every button
and native interactive element in this app is clickable, so restore
the pointer cursor app-wide instead of annotating each one. */
button:not(:disabled),
[role='button']:not([aria-disabled='true']),
a[href],
summary,
select {
cursor: pointer;
}
}
#app {

View File

@@ -108,6 +108,8 @@ export async function fetchDashboardSummary(): Promise<DashboardSummary | null>
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<Execution
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 {
id: number
ts: string

View File

@@ -1,109 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { summary, pendingApprovals, subscribeContext, refreshContext } from '$lib/stores/context'
import { liveEvents } from '$lib/stores/events'
import { decideApproval } from '$lib/api'
import { Badge } from '$lib/components/ui/badge'
import { Button } from '$lib/components/ui/button'
import { Separator } from '$lib/components/ui/separator'
import { ScrollArea } from '$lib/components/ui/scroll-area'
import { toast } from 'svelte-sonner'
let deciding = $state<string | null>(null)
onMount(() => subscribeContext())
async function decide(id: string, decision: 'approve' | 'deny') {
deciding = id
const result = await decideApproval(id, decision)
deciding = null
if (result) {
toast.success(`Approval ${decision === 'approve' ? 'approved' : 'denied'}`)
refreshContext()
} else {
toast.error('Decision failed')
}
}
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
if (sev === 'critical') return 'destructive'
if (sev === 'warning') return 'secondary'
return 'default'
}
const recentEvents = $derived($liveEvents.slice(0, 10))
</script>
<aside class="flex h-full w-72 shrink-0 flex-col gap-3 overflow-y-auto border-l bg-card/50 p-3">
{#if $summary}
<div>
<p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Fleet health</p>
<div class="flex items-center gap-3 text-xs">
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-success"></span>{$summary.health.healthy}</span>
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-warning"></span>{$summary.health.degraded}</span>
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-destructive"></span>{$summary.health.down}</span>
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-muted-foreground"></span>{$summary.health.unknown}</span>
</div>
</div>
<Separator />
{/if}
<div>
<div class="mb-1.5 flex items-center justify-between">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Pending approvals</p>
{#if $pendingApprovals.length}
<Badge variant="destructive" class="h-4 px-1.5 text-[10px]">{$pendingApprovals.length}</Badge>
{/if}
</div>
<div class="flex flex-col gap-2">
{#each $pendingApprovals.slice(0, 5) as approval (approval.id)}
<div class="rounded-md border bg-background p-2">
<p class="truncate font-mono text-[11px]">{approval.subject ?? approval.slug}</p>
<p class="mb-1.5 text-xs">{approval.action} <Badge variant="outline" class="ml-1 h-4 px-1 text-[10px]">{approval.risk_class}</Badge></p>
<div class="flex gap-1.5">
<Button size="sm" class="h-6 flex-1 text-xs" disabled={deciding === approval.id} onclick={() => decide(approval.id, 'approve')}>Approve</Button>
<Button size="sm" variant="destructive" class="h-6 flex-1 text-xs" disabled={deciding === approval.id} onclick={() => decide(approval.id, 'deny')}>Deny</Button>
</div>
</div>
{:else}
<p class="text-xs text-muted-foreground">Nothing waiting on you.</p>
{/each}
{#if $pendingApprovals.length > 5}
<button type="button" class="text-left text-xs text-primary hover:underline" onclick={() => (location.hash = '#/ops')}>
+{$pendingApprovals.length - 5} more in Operations
</button>
{/if}
</div>
</div>
{#if $summary && Object.keys($summary.signals_by_severity).length}
<Separator />
<div>
<p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Open signals</p>
<div class="flex flex-wrap gap-1">
{#each Object.entries($summary.signals_by_severity) as [severity, count]}
<button type="button" onclick={() => (location.hash = '#/signals')}>
<Badge variant={severityVariant(severity)} class="cursor-pointer">{severity}: {count}</Badge>
</button>
{/each}
</div>
</div>
{/if}
<Separator />
<div class="flex min-h-0 flex-1 flex-col">
<p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Live events</p>
<ScrollArea class="min-h-0 flex-1">
<div class="flex flex-col gap-1.5 pr-2">
{#each recentEvents as ev (ev.id)}
<div class="text-[11px] leading-tight">
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleTimeString()}</span>
<span class="ml-1 {ev.severity === 'critical' ? 'text-destructive' : ev.severity === 'warning' ? 'text-warning' : ''}">{ev.type}</span>
</div>
{:else}
<p class="text-xs text-muted-foreground">Quiet for now.</p>
{/each}
</div>
</ScrollArea>
</div>
</aside>

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-4 md: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,441 @@
<script lang="ts">
import { onDestroy, untrack } from 'svelte'
import {
forceSimulation,
forceLink,
forceManyBody,
forceCenter,
forceCollide,
forceX,
forceY,
type Simulation
} from 'd3-force'
import { fetchGraph, type Entity } from '$lib/api'
import { messages } from '$lib/stores/chat'
import { relativeTime } from '$lib/utils'
import { Badge } from '$lib/components/ui/badge'
import { Button } from '$lib/components/ui/button'
import EntitySheet from '$lib/components/EntitySheet.svelte'
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
interface Node extends Entity {
x?: number
y?: number
vx?: number
vy?: number
fx?: number | null
fy?: number | null
degree: number
}
interface Edge {
source: string | Node
target: string | Node
type: string
}
// Probe/bookkeeping entity types are excluded — a health conversation
// mentions dozens of check:… slugs that would swamp the fleet topology.
const EXCLUDED = new Set(['check', 'execution'])
// Slug shape: lowercase type prefix, then one or more colon-separated
// segments (host:hubris, check:ping:8cf, lxc:caddy, investigation:foo/bar).
const SLUG_RE = /\b[a-z][a-z-]*:[a-z0-9][a-z0-9._/-]*(?::[a-z0-9._/-]+)*/g
let nodes = $state<Node[]>([])
let links = $state<Edge[]>([])
let selected = $state<Node | null>(null)
let sheetSlug = $state<string | null>(null)
let sheetOpen = $state(false)
let sim: Simulation<Node, Edge> | null = null
// Non-reactive caches (persist across message deltas). resolvedVersion is a
// reactive counter bumped when async resolution finishes, so the reconcile
// effect re-runs once entities come back.
const resolvedCache = new Map<string, Node | null>()
const edgeCache: { source: string; target: string; type: string }[] = []
const edgeKeys = new Set<string>()
const resolving = new Set<string>()
let resolvedVersion = $state(0)
// container size drives the simulation coordinate space (1:1 with pixels so
// node dragging maps cleanly regardless of the resizable panel width).
let container = $state<HTMLDivElement | null>(null)
let cw = $state(300)
let ch = $state(300)
function collectSlugs(value: unknown, out: Set<string>) {
if (typeof value === 'string') {
const m = value.match(SLUG_RE)
if (m) for (const s of m) out.add(s.replace(/[.,;)\]]+$/, ''))
} else if (Array.isArray(value)) {
for (const v of value) collectSlugs(v, out)
} else if (value && typeof value === 'object') {
for (const v of Object.values(value)) collectSlugs(v, out)
}
}
// Only pull from what the conversation is *about*: message text and the
// arguments the agent passed to tools — never bulk result rows (a single
// get_health_summary would otherwise dump all 168 entities into the graph).
const candidateSlugs = $derived.by(() => {
const out = new Set<string>()
for (const m of $messages) {
collectSlugs(m.text, out)
for (const t of m.tools) collectSlugs(t.args, out)
}
return out
})
async function resolveSlugs(slugs: string[]) {
const todo = slugs.filter((s) => !resolvedCache.has(s) && !resolving.has(s))
if (!todo.length) return
for (const s of todo) resolving.add(s)
await Promise.all(
todo.map(async (s) => {
try {
const g = await fetchGraph({ root: s, depth: 1 })
const root = g?.nodes.find((n) => n.slug === s) ?? null
resolvedCache.set(s, root ? { ...root, degree: 0 } : null)
if (g && root) {
for (const e of g.edges) {
const k = `${e.source}|${e.target}|${e.type}`
if (!edgeKeys.has(k)) {
edgeKeys.add(k)
edgeCache.push({ source: e.source, target: e.target, type: e.type })
}
}
}
} catch {
resolvedCache.set(s, null)
} finally {
resolving.delete(s)
}
})
)
resolvedVersion++
}
function reconcile(cands: Set<string>) {
const desired: Node[] = []
const seen = new Set<string>()
for (const s of cands) {
const e = resolvedCache.get(s)
if (e && !EXCLUDED.has(e.type) && !seen.has(e.slug)) {
seen.add(e.slug)
desired.push(e)
}
}
const desiredSlugs = new Set(desired.map((e) => e.slug))
const current = nodes
const curSlugs = new Set(current.map((n) => n.slug))
let changed = desiredSlugs.size !== curSlugs.size
if (!changed) for (const s of desiredSlugs) if (!curSlugs.has(s)) { changed = true; break }
if (!changed) return
const bySlug = new Map(current.map((n) => [n.slug, n]))
const ls = edgeCache
.filter((e) => desiredSlugs.has(e.source) && desiredSlugs.has(e.target))
.map((e) => ({ ...e }))
const deg = new Map<string, number>()
for (const l of ls) {
deg.set(l.source as string, (deg.get(l.source as string) ?? 0) + 1)
deg.set(l.target as string, (deg.get(l.target as string) ?? 0) + 1)
}
const next = desired.map((e) => {
const p = bySlug.get(e.slug)
return { ...e, x: p?.x, y: p?.y, vx: p?.vx, vy: p?.vy, degree: deg.get(e.slug) ?? 0 }
})
nodes = next
links = ls
if (selected && !desiredSlugs.has(selected.slug)) selected = null
buildSim()
}
$effect(() => {
const cands = candidateSlugs
void resolvedVersion
const missing = [...cands].filter((s) => !resolvedCache.has(s))
if (missing.length) resolveSlugs(missing)
untrack(() => reconcile(cands))
})
function buildSim() {
sim?.stop()
if (!nodes.length) {
sim = null
return
}
sim = forceSimulation(nodes)
.force('link', forceLink<Node, Edge>(links).id((n) => n.slug).distance(48).strength(0.5))
.force('charge', forceManyBody().strength(-150).distanceMax(240))
.force('center', forceCenter(cw / 2, ch / 2))
.force('collide', forceCollide<Node>((n) => nodeRadius(n) + 6))
.force('x', forceX(cw / 2).strength(0.06))
.force('y', forceY(ch / 2).strength(0.06))
.velocityDecay(0.34)
.alphaDecay(0.045)
.on('tick', () => {
nodes = [...nodes]
})
}
// keep the layout centred as the panel resizes
$effect(() => {
const w = cw
const h = ch
if (sim) {
sim.force('center', forceCenter(w / 2, h / 2))
sim.force('x', forceX(w / 2).strength(0.06))
sim.force('y', forceY(h / 2).strength(0.06))
sim.alpha(0.3).restart()
}
})
$effect(() => {
if (!container) return
const ro = new ResizeObserver((entries) => {
const r = entries[0].contentRect
cw = Math.max(r.width, 1)
ch = Math.max(r.height, 1)
})
ro.observe(container)
return () => ro.disconnect()
})
onDestroy(() => sim?.stop())
const healthColor: Record<string, string> = {
healthy: 'var(--success)',
degraded: 'var(--warning)',
down: 'var(--destructive)',
stale: 'var(--warning)',
unknown: 'var(--muted-foreground)'
}
function nodeColor(n: Node): string {
return n.health ? healthColor[n.health] ?? 'var(--muted-foreground)' : 'var(--muted-foreground)'
}
function nodeRadius(n: Node): number {
return 6 + Math.min(Math.sqrt(n.degree) * 1.5, 6)
}
function shortName(slug: string): string {
return slug.split(':').pop() ?? slug
}
function endpoint(end: string | Node): Node | undefined {
return typeof end === 'object' ? end : nodes.find((n) => n.slug === end)
}
function endpointSlug(end: string | Node): string {
return typeof end === 'object' ? end.slug : end
}
// ─── drag / select ───────────────────────────────────────────────────
let dragState: { node: Node; moved: boolean } | null = null
function toLocal(clientX: number, clientY: number) {
const rect = container!.getBoundingClientRect()
return { x: clientX - rect.left, y: clientY - rect.top }
}
function onNodeDown(e: PointerEvent, node: Node) {
e.stopPropagation()
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
dragState = { node, moved: false }
sim?.alphaTarget(0.2).restart()
}
function onMove(e: PointerEvent) {
if (!dragState) return
const p = toLocal(e.clientX, e.clientY)
dragState.node.fx = p.x
dragState.node.fy = p.y
dragState.moved = true
nodes = [...nodes]
}
function onUp() {
if (!dragState) return
const { node, moved } = dragState
node.fx = null
node.fy = null
sim?.alphaTarget(0)
dragState = null
if (!moved) selected = selected?.slug === node.slug ? null : node
}
const selectedRelations = $derived(
selected
? links
.filter((l) => endpointSlug(l.source) === selected!.slug || endpointSlug(l.target) === selected!.slug)
.map((l) => {
const outgoing = endpointSlug(l.source) === selected!.slug
return { dir: outgoing ? '→' : '←', type: l.type, other: outgoing ? endpointSlug(l.target) : endpointSlug(l.source) }
})
: []
)
function openFull() {
if (!selected) return
sheetSlug = selected.slug
sheetOpen = true
}
</script>
<aside class="flex h-full min-h-0 flex-col bg-card/40">
<div class="flex shrink-0 items-center justify-between border-b px-3 py-2">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Session graph</p>
{#if nodes.length}
<span class="text-[11px] text-muted-foreground">{nodes.length} {nodes.length === 1 ? 'entity' : 'entities'}</span>
{/if}
</div>
<div bind:this={container} class="relative min-h-0 flex-1 overflow-hidden">
{#if nodes.length === 0}
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 text-center">
<svg viewBox="0 0 120 120" class="size-24 text-muted-foreground/40" fill="none">
<circle cx="60" cy="60" r="6" fill="currentColor">
<animate attributeName="opacity" values="0.4;1;0.4" dur="2.4s" repeatCount="indefinite" />
</circle>
<g stroke="currentColor" stroke-width="1" opacity="0.5">
<line x1="60" y1="60" x2="26" y2="34"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3s" repeatCount="indefinite" /></line>
<line x1="60" y1="60" x2="96" y2="40"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3.4s" repeatCount="indefinite" /></line>
<line x1="60" y1="60" x2="34" y2="92"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="2.8s" repeatCount="indefinite" /></line>
<line x1="60" y1="60" x2="92" y2="90"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3.1s" repeatCount="indefinite" /></line>
</g>
<g fill="currentColor">
<circle cx="26" cy="34" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3s" repeatCount="indefinite" /></circle>
<circle cx="96" cy="40" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3.4s" repeatCount="indefinite" /></circle>
<circle cx="34" cy="92" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="2.8s" repeatCount="indefinite" /></circle>
<circle cx="92" cy="90" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3.1s" repeatCount="indefinite" /></circle>
</g>
</svg>
<p class="max-w-[16rem] text-xs leading-relaxed text-muted-foreground">
Entities Nomos explores in this conversation appear here, wired up by their relationships.
</p>
</div>
{:else}
<svg
width={cw}
height={ch}
viewBox="0 0 {cw} {ch}"
class="h-full w-full touch-none select-none"
role="application"
aria-label="Session entity graph"
onpointermove={onMove}
onpointerup={onUp}
onpointercancel={onUp}
>
<g>
{#each links as link}
{@const s = endpoint(link.source)}
{@const t = endpoint(link.target)}
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null}
{@const focus = selected && (s.slug === selected.slug || t.slug === selected.slug)}
<line
x1={s.x}
y1={s.y}
x2={t.x}
y2={t.y}
stroke="var(--muted-foreground)"
stroke-width={focus ? 1.6 : 1}
opacity={selected ? (focus ? 0.7 : 0.12) : 0.35}
>
<title>{link.type}</title>
</line>
{/if}
{/each}
</g>
<g>
{#each nodes as node (node.slug)}
{#if node.x != null && node.y != null}
{@const r = nodeRadius(node)}
{@const isSel = selected?.slug === node.slug}
{@const dim = selected && !isSel && !selectedRelations.some((rel) => rel.other === node.slug)}
<g
transform="translate({node.x},{node.y})"
class="cursor-pointer"
opacity={dim ? 0.35 : 1}
role="button"
tabindex="0"
onpointerdown={(e) => onNodeDown(e, node)}
onkeydown={(e) => e.key === 'Enter' && (selected = node)}
>
{#if isSel}
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
{/if}
<circle r={r} fill={nodeColor(node)} stroke={isSel ? 'var(--foreground)' : 'var(--background)'} stroke-width={isSel ? 2 : 1.5} />
<text
y={r + 10}
text-anchor="middle"
font-size="9"
fill={isSel ? 'var(--foreground)' : 'var(--muted-foreground)'}
paint-order="stroke"
stroke="var(--background)"
stroke-width="2.5"
class="pointer-events-none"
>
{shortName(node.slug)}
</text>
</g>
{/if}
{/each}
</g>
</svg>
{/if}
</div>
{#if selected}
<div class="max-h-[55%] shrink-0 space-y-3 overflow-y-auto border-t p-3 text-xs">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-mono text-sm font-semibold">{selected.slug}</span>
<Badge variant="outline">{selected.type}</Badge>
{#if selected.state}<Badge variant="secondary">{selected.state}</Badge>{/if}
</div>
{#if selected.health}
<div class="flex items-center gap-1.5 text-muted-foreground">
<span class="size-2 rounded-full" style="background: {nodeColor(selected)}"></span>
{selected.health} · checked {relativeTime(selected.last_check_at)}
</div>
{/if}
{#if selected.attributes && Object.keys(selected.attributes).length}
<div>
<p class="mb-1 font-medium text-muted-foreground">Attributes</p>
<dl class="flex flex-col gap-1">
{#each Object.entries(selected.attributes).slice(0, 6) as [key, value]}
<div class="flex items-start justify-between gap-3 border-b pb-1 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>
</div>
{/if}
{#if selectedRelations.length}
<div>
<p class="mb-1 font-medium text-muted-foreground">Relations ({selectedRelations.length})</p>
<div class="flex flex-col gap-1">
{#each selectedRelations as rel}
<div class="flex items-center gap-1 font-mono">
<span class="text-muted-foreground">{rel.dir} {rel.type}</span>
<button type="button" class="truncate hover:underline" onclick={() => { const n = nodes.find((x) => x.slug === rel.other); if (n) selected = n }}>
{rel.other}
</button>
</div>
{/each}
</div>
</div>
{/if}
<Button variant="outline" size="sm" class="w-full" onclick={openFull}>
<ExternalLinkIcon class="mr-1 size-3.5" />
Full detail
</Button>
</div>
{/if}
</aside>
<EntitySheet slug={sheetSlug} bind:open={sheetOpen} />

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

@@ -0,0 +1,79 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import WrenchIcon from '@lucide/svelte/icons/wrench'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
// active = this message is the one currently streaming a round of tool
// calls. The group starts open while active (so progress is visible live)
// and auto-collapses the moment that round finishes; a loaded/historical
// message is never active, so it starts collapsed. Once the effect below
// fires the one-time auto-collapse, manual toggles are left alone.
let { tools, active = false }: { tools: ToolCallResult[]; active?: boolean } = $props()
let open = $state(active)
let wasActive = active
$effect(() => {
if (wasActive && !active) {
open = false
}
wasActive = active
})
const doneCount = $derived(tools.filter((t) => t.type === 'tool_result').length)
const hasError = $derived(tools.some((t) => t.type === 'tool_result' && t.error))
const inProgress = $derived(active && doneCount < tools.length)
const names = $derived(tools.map((t) => t.name).join(', '))
function toolSummary(args: unknown): string {
if (!args || typeof args !== 'object') return ''
return Object.entries(args as Record<string, unknown>)
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join(' ')
.slice(0, 80)
}
</script>
{#if tools.length}
<details bind:open class="group w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
<summary class="flex cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50 [&::-webkit-details-marker]:hidden">
{#if inProgress}
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" />
{:else if hasError}
<XIcon class="size-3 shrink-0 text-destructive" />
{:else}
<CheckIcon class="size-3 shrink-0 text-success" />
{/if}
<span class="font-medium">{tools.length} tool{tools.length === 1 ? '' : 's'}</span>
<span class="max-w-64 truncate font-mono text-muted-foreground">{names}</span>
<ChevronDownIcon class="size-3 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
</summary>
<div class="flex flex-col divide-y border-t">
{#each tools as tool (tool.id)}
<div class="p-2">
<div class="flex items-center gap-2">
{#if tool.type === 'tool_result' && tool.error}
<XIcon class="size-3 shrink-0 text-destructive" />
{:else if tool.type === 'tool_result'}
<CheckIcon class="size-3 shrink-0 text-success" />
{:else}
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" />
{/if}
<span class="font-mono font-medium">{tool.name}</span>
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>
</div>
<div class="mt-1 max-h-48 overflow-y-auto rounded bg-background/60 p-2">
{#if tool.args}
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{JSON.stringify(tool.args, null, 2)}</pre>
{/if}
{#if tool.type === 'tool_result'}
<pre class="mt-1 whitespace-pre-wrap break-all font-mono text-[11px] {tool.error ? 'text-destructive' : ''}">{tool.error ?? JSON.stringify(tool.result, null, 2)}</pre>
{/if}
</div>
</div>
{/each}
</div>
</details>
{/if}

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}
/>

View File

@@ -61,7 +61,10 @@
"data-slot": "sidebar-menu-button",
"data-sidebar": "menu-button",
"data-size": size,
"data-active": isActive,
// Tailwind's bare `data-active:` variant matches attribute *presence*,
// not its value — omit the attribute entirely when false instead of
// rendering data-active="false" (which the variant still matches).
"data-active": isActive || undefined,
...restProps,
});
</script>

View File

@@ -25,7 +25,10 @@
"data-slot": "sidebar-menu-sub-button",
"data-sidebar": "menu-sub-button",
"data-size": size,
"data-active": isActive,
// Tailwind's bare `data-active:` variant matches attribute *presence*,
// not its value — omit the attribute entirely when false instead of
// rendering data-active="false" (which the variant still matches).
"data-active": isActive || undefined,
...restProps,
});
</script>

View File

@@ -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<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) {
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)
}

View File

@@ -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> = T extends { child?: any } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -49,7 +49,7 @@
}
</script>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Agent activity</h1>
<span class="text-xs text-muted-foreground">{activities.length} entries</span>

View File

@@ -49,7 +49,7 @@
}
</script>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Audit trail</h1>
<span class="text-xs text-muted-foreground">{entries.length} entries</span>

View File

@@ -1,13 +1,12 @@
<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 SessionGraph from '$lib/components/SessionGraph.svelte'
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
import SquareIcon from '@lucide/svelte/icons/square'
import WrenchIcon from '@lucide/svelte/icons/wrench'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
@@ -16,6 +15,35 @@
let input = $state('')
let messagesEnd = $state<HTMLDivElement | null>(null)
// Resizable right rail (session graph). Persisted so it survives reloads.
const RAIL_MIN = 260
const RAIL_MAX = 620
function loadRailWidth(): number {
if (typeof localStorage === 'undefined') return 320
const v = Number(localStorage.getItem('oikos-rail-width'))
return v >= RAIL_MIN && v <= RAIL_MAX ? v : 320
}
let railWidth = $state(loadRailWidth())
let resizing = $state(false)
function startResize(e: PointerEvent) {
e.preventDefault()
resizing = true
const startX = e.clientX
const startW = railWidth
function move(ev: PointerEvent) {
railWidth = Math.min(RAIL_MAX, Math.max(RAIL_MIN, startW + (startX - ev.clientX)))
}
function up() {
resizing = false
localStorage.setItem('oikos-rail-width', String(railWidth))
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
$effect(() => {
void $messages
void $streaming
@@ -51,17 +79,14 @@
if ($streaming) return
sendMessage(q)
}
function toolSummary(args: unknown): string {
if (!args || typeof args !== 'object') return ''
return Object.entries(args as Record<string, unknown>)
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join(' ')
.slice(0, 80)
}
</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">
@@ -81,35 +106,13 @@
</div>
{/if}
{#each $messages as msg (msg.id)}
{#each $messages as msg, i (msg.id)}
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
{#if msg.role === 'user'}
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap">{msg.text}</div>
{:else}
<div class="flex w-full flex-col gap-2">
{#each msg.tools as tool (tool.id)}
<details class="w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
<summary class="flex cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50 [&::-webkit-details-marker]:hidden">
{#if tool.type === 'tool_result' && tool.error}
<XIcon class="size-3 shrink-0 text-destructive" />
{:else if tool.type === 'tool_result'}
<CheckIcon class="size-3 shrink-0 text-success" />
{:else}
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" />
{/if}
<span class="font-mono font-medium">{tool.name}</span>
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>
</summary>
<div class="max-h-48 overflow-y-auto border-t bg-background/60 p-2">
{#if tool.args}
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{JSON.stringify(tool.args, null, 2)}</pre>
{/if}
{#if tool.type === 'tool_result'}
<pre class="mt-1 whitespace-pre-wrap break-all font-mono text-[11px] {tool.error ? 'text-destructive' : ''}">{tool.error ?? JSON.stringify(tool.result, null, 2)}</pre>
{/if}
</div>
</details>
{/each}
<ToolCallGroup tools={msg.tools} active={$streaming && i === $messages.length - 1} />
{#if msg.text}
<div class="prose-chat max-w-none text-sm leading-relaxed">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
@@ -168,8 +171,22 @@
</div>
{#if showRail}
<div class="hidden xl:block">
<ContextRail />
<div class="hidden shrink-0 xl:flex" style="width: {railWidth}px">
<button
type="button"
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
onpointerdown={startResize}
aria-label="Resize session graph"
>
<span
class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors {resizing
? 'bg-primary/60'
: 'bg-border group-hover/rz:bg-primary/50'}"
></span>
</button>
<div class="min-w-0 flex-1">
<SessionGraph />
</div>
</div>
{/if}
</div>

View File

@@ -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,9 +57,23 @@
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">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Entities</h1>
<span class="text-xs text-muted-foreground">{filtered.length} of {entities.length}</span>
@@ -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} />

View File

@@ -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} />

View File

@@ -80,7 +80,7 @@
}
</script>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Live event feed</h1>
<span class="text-xs text-muted-foreground">

View File

@@ -27,7 +27,7 @@
}
</script>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<h1 class="text-lg font-semibold">Knowledge search</h1>
<form

View File

@@ -79,7 +79,7 @@
const decidedApprovals = $derived(approvals.filter((a) => a.status !== 'pending'))
</script>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<h1 class="text-lg font-semibold">Operations ledger</h1>
<Tabs.Root value="approvals" class="flex flex-1 flex-col overflow-hidden">

View File

@@ -6,6 +6,9 @@
import { Badge } from '$lib/components/ui/badge'
import { Skeleton } from '$lib/components/ui/skeleton'
import { ScrollArea } from '$lib/components/ui/scroll-area'
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert'
import OctagonXIcon from '@lucide/svelte/icons/octagon-x'
let summary = $state<DashboardSummary | null>(null)
let loading = $state(true)
@@ -47,9 +50,44 @@
function formatEventLabel(ev: OikosEvent) {
return ev.type
}
const totalEntities = $derived(
summary ? Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0) : 0
)
const topTypes = $derived(
summary
? Object.entries(summary.entities_by_type)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
: []
)
const entityTypeCount = $derived(summary ? Object.keys(summary.entities_by_type).length : 0)
const totalMonitored = $derived(
summary
? summary.health.healthy + summary.health.degraded + summary.health.down + summary.health.unknown
: 0
)
const healthTone = $derived(
!summary ? 'ok' : summary.health.down > 0 ? 'down' : summary.health.degraded > 0 ? 'degraded' : 'ok'
)
const totalSignals = $derived(
summary ? Object.values(summary.signals_by_severity).reduce((a, b) => a + b, 0) : 0
)
const worstSeverity = $derived(
summary?.signals_by_severity.critical
? 'critical'
: summary?.signals_by_severity.warning
? 'warning'
: 'none'
)
const executionsRunning = $derived(summary?.executions_by_state.running ?? 0)
const executionsFailed = $derived(summary?.executions_by_state.failed ?? 0)
</script>
<div class="flex h-full flex-col gap-4 overflow-y-auto p-6">
<div class="@container/main flex h-full flex-col gap-4 overflow-y-auto p-4 md:p-6">
<h1 class="text-lg font-semibold">Overview</h1>
{#if loading}
@@ -59,58 +97,99 @@
{/each}
</div>
{:else if summary}
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card.Root>
<div
class="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs @xl/main:grid-cols-2 @5xl/main:grid-cols-4"
>
<Card.Root class="@container/card">
<Card.Header>
<Card.Description>Entities</Card.Description>
<Card.Title class="text-2xl">
{Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0)}
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{totalEntities}
</Card.Title>
<Card.Action>
<Badge variant="outline">{entityTypeCount} types</Badge>
</Card.Action>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs text-muted-foreground">
{#each Object.entries(summary.entities_by_type) as [type, count]}
<Badge variant="outline">{type}: {count}</Badge>
{/each}
</Card.Content>
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
<div class="line-clamp-1 flex flex-wrap gap-x-1.5 font-medium">
{#each topTypes as [type, count]}
<span class="text-muted-foreground">{type}: <span class="text-foreground">{count}</span></span>
{/each}
</div>
<div class="text-muted-foreground">Across the fleet</div>
</Card.Footer>
</Card.Root>
<Card.Root>
<Card.Root class="@container/card">
<Card.Header>
<Card.Description>Health</Card.Description>
<Card.Title class="text-2xl">{summary.health.healthy} healthy</Card.Title>
<Card.Description>Fleet health</Card.Description>
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{summary.health.healthy} / {totalMonitored}
</Card.Title>
<Card.Action>
{#if healthTone === 'ok'}
<Badge variant="outline"><CircleCheckIcon class="text-success" />healthy</Badge>
{:else if healthTone === 'degraded'}
<Badge variant="outline"><TriangleAlertIcon class="text-warning" />degraded</Badge>
{:else}
<Badge variant="outline"><OctagonXIcon class="text-destructive" />down</Badge>
{/if}
</Card.Action>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs">
<Badge>healthy: {summary.health.healthy}</Badge>
<Badge variant="secondary">degraded: {summary.health.degraded}</Badge>
<Badge variant="destructive">down: {summary.health.down}</Badge>
<Badge variant="outline">unknown: {summary.health.unknown}</Badge>
</Card.Content>
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
<div class="line-clamp-1 flex gap-2 font-medium">
{summary.health.degraded} degraded · {summary.health.down} down · {summary.health.unknown} unmonitored
</div>
<div class="text-muted-foreground">Healthy entities as observed by the scheduler</div>
</Card.Footer>
</Card.Root>
<Card.Root>
<Card.Root class="@container/card">
<Card.Header>
<Card.Description>Open signals</Card.Description>
<Card.Title class="text-2xl">
{Object.values(summary.signals_by_severity).reduce((a, b) => a + b, 0)}
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{totalSignals}
</Card.Title>
<Card.Action>
{#if worstSeverity === 'critical'}
<Badge variant="destructive"><TriangleAlertIcon />critical</Badge>
{:else if worstSeverity === 'warning'}
<Badge variant="secondary"><TriangleAlertIcon />warning</Badge>
{:else}
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
{/if}
</Card.Action>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs">
{#each Object.entries(summary.signals_by_severity) as [severity, count]}
<Badge variant={severityVariant(severity)}>{severity}: {count}</Badge>
{/each}
</Card.Content>
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
<div class="line-clamp-1 flex flex-wrap gap-x-1.5 font-medium">
{#each Object.entries(summary.signals_by_severity) as [severity, count]}
<span class="text-muted-foreground">{severity}: <span class="text-foreground">{count}</span></span>
{/each}
</div>
<div class="text-muted-foreground">Unresolved right now</div>
</Card.Footer>
</Card.Root>
<Card.Root>
<Card.Root class="@container/card">
<Card.Header>
<Card.Description>Pending approvals</Card.Description>
<Card.Title class="text-2xl">{summary.approvals_pending}</Card.Title>
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{summary.approvals_pending}
</Card.Title>
<Card.Action>
{#if summary.approvals_pending > 0}
<Badge variant="destructive">needs review</Badge>
{:else}
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
{/if}
</Card.Action>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs">
{#each Object.entries(summary.executions_by_state) as [state, count]}
<Badge variant="outline">{state}: {count}</Badge>
{/each}
</Card.Content>
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
<div class="line-clamp-1 flex gap-2 font-medium">
{executionsRunning} running · {executionsFailed} failed
</div>
<div class="text-muted-foreground">Executions in the last 24h</div>
</Card.Footer>
</Card.Root>
</div>

View File

@@ -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">

View File

@@ -134,7 +134,7 @@
</div>
{/snippet}
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Signals</h1>
<Select.Root type="single" bind:value={severityFilter}>

View File

@@ -15,7 +15,13 @@ export default defineConfig({
server: {
proxy: {
'/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/, '')
}
}
}
})