24 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
a39e67b6e9 adr: convert all diagrams to Mermaid (sequenceDiagram, stateDiagram-v2, flowchart, erDiagram, graph)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
0013-signal-triggers.md:
- Thermals query: sequenceDiagram (Nomos→API→Scheduler→Hubris→TimescaleDB)
- Script deployment: sequenceDiagram
- Signal lifecycle: stateDiagram-v2
- DB data flow: flowchart

0014-entity-model.md:
- Entity type hierarchy: graph (56 types, 3 layers, 7 domains)
- Machine onboarding: sequenceDiagram
- OODA loop (5 phases): flowchart with color-coded subgraphs
- Infrastructure topology: graph
- Network relationships: graph
- Service dependencies: graph
- Cognition OODA edges: graph
- Governance: graph
- Infrastructure lifecycle: stateDiagram-v2
- Signal lifecycle: stateDiagram-v2
- Execution lifecycle: stateDiagram-v2
- Approval lifecycle: stateDiagram-v2
- DB physical schema: erDiagram
- Thermals query trace: sequenceDiagram
2026-07-08 22:38:19 +02:00
551497e0b3 adr: move to docs/adr/, renumber 0013 + 0014, update README index
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-08 22:18:58 +02:00
4a5e68bafe adr: full entity model — types, relationships, state machines, OODA loop
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Covers:
- 56 entity types with full hierarchy (abstract/concrete, domain, layer)
- 34 relationship types with cardinality and OODA phase mapping
- 88 concrete entity instances with key attributes
- Lifecycle state machines (infrastructure, signal, execution, approval,
  pattern, skill) with which preconditions are code-real vs schema-only
- Sequence diagrams: machine onboarding, OODA loop, thermals query
- What's fully implemented vs schema-defined-but-not-wired
- Database physical schema with FK relationships
- Policy: risk classes, approval rules, per-entity overrides, autonomy
- Blast radius via recursive CTE over depends-on/hosts/routes-to edges
2026-07-08 22:15:11 +02:00
ef00e5b8e4 fix: disk_usage_check.sh handles inode '-' (vfat/efi), checkdefaults sets target_id
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
- disk_usage_check.sh: sed 's/-/0/' for filesystems without inodes
- checkdefaults.Ensure: includes target_id in check_defs INSERT so
  signals get proper target slug instead of null
2026-07-08 21:58:44 +02:00
a512d40669 onboarding: auto-create default checks when entity is created
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Three insertion points:
- CreateEntity (POST /api/v1/entities)
- EnrollClient (POST /api/v1/clients/enroll)
- seed.go (seed ingest at deploy time)

Shared logic in internal/checkdefaults — resolves host IP from
lan_ip > mesh.netbird.ip > mesh_ip, SSH user/port from attributes.

Default checks per entity type:
- proxmox-host/standalone-server: ping + cpu + memory + load + disk + updates
- workstation: ping + cpu + memory + load
- lxc: cpu + memory + load + disk
- vm: ping
- service: process_check.sh

All idempotent (ON CONFLICT DO NOTHING). New machines now get
monitoring automatically — no manual curl calls needed.
2026-07-08 21:53:50 +02:00
b8bb29464b checks: deploy scripts + define checks for strong and netbird-vps
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Coverage:
- host:hubris (192.168.8.77)   ✓ cpu, memory, load, disk, ping, cert-expiry
- host:strong (192.168.178.181) ✓ cpu, memory, load, disk, ping
- host:netbird-vps (82.165.190.79) ✓ cpu, memory, load, disk, ping
- ws:mac-mini                  ~ local disk/ping only (no SSH key on macOS host)
- ws:republic-laptop           ✗ offline / not reachable

Move architecture doc to adr/signal-triggers.md
2026-07-08 21:45:09 +02:00
81acadec1d docs: signal trigger architecture sequence diagram + full explanation
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Add docs/signal-triggers.md covering:
- End-to-end sequence diagram (Nomos → API → Scheduler → target host)
- Two paths: autonomous collection (scheduler) + query (MCP)
- All 6 check kinds and 17 available scripts
- Script deployment flow via sync timer
- Signal lifecycle, threshold evaluation, data flow through DB tables
- Prerequisites for SSH checks in Docker
2026-07-08 21:37:31 +02:00
291b45565b fix: metric samples timestamp + ssh-script port/user handling
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
- InsertMetricSample now includes ts=now() (TimescaleDB hypertable requires it)
- ssh-script: pass host and port separately (ssh uses -p flag, not host:port)
- ssh-script: use OIKOS_SSH_USER from config/env, default root
- Add -o LogLevel=ERROR to suppress SSH warnings polluting JSON output
- Use Output() (stdout only) instead of CombinedOutput()
- Set OIKOS_SSH_USER=root in scheduler docker-compose service
2026-07-08 21:36:05 +02:00
d45f2326b6 fix: CreateCheck uses type "check" not "check_def"
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
FK violation — entity_types table has name "check" but
phase3.go:320 was inserting type "check_def" causing:
entities_type_fkey (SQLSTATE 23503)
2026-07-08 21:21:44 +02:00
4bf811a383 docker: alpine base with openssh-client, mount SSH key + NET_RAW for scheduler
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
- Switch Dockerfile from distroless/static to alpine:3.21
- Install openssh-client-default in runtime image
- Mount SSH key in scheduler service (docker-compose)
- Add NET_RAW capability for ping checks
- Wire OIKOS_SSH_KEY_PATH and OIKOS_SSH_USER env vars in scheduler
- sshExec uses configured key path with StrictHostKeyChecking=no
2026-07-08 21:15:02 +02:00
35feada286 scheduler: add ping + ssh-script check kinds, metrics refactor, 17 host check scripts
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
- Refactor executeCheck to return checkResult struct with metrics map
- Add ping check kind (ICMP reachability via system ping, macOS+Linux)
- Add ssh-script check kind (remote host exec via SSH, allowlisted scripts)
- Add threshold evaluation (warn/crit per metric from check config JSONB)
- Add inode tracking to disk check
- All 4 existing checks now return structured metrics
- 17 check scripts: cpu, memory, load, swap, disk_usage, disk_smart,
  updates, zfs, process, uptime, oom, journal, time, fd, docker_health,
  caddy_error_rate, backup_freshness
- Auto-deploy via tools/setup-checks.sh -> checks/install.sh on git pull
- Add ping to OpenAPI CheckKind enum and generated Go types
2026-07-08 21:05:53 +02:00
cca2ae4621 fix: properly convert icns to PNG for favicon data URI
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-08 18:06:35 +02:00
d13f6991b1 fix: use inline base64 favicon to work in both dev and production
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-08 18:02:11 +02:00
2de3602ebc feat: replace inline favicon with extracted app icon from DMG
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-08 17:34:58 +02:00
2908b0a377 feat(ui): M4 — agent activity, knowledge search, audit, correlation grouping
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Add three new pages completing the control-room web UI:
- Agent activity: polls /agent-activity every 5s, filterable by type/agent
- Knowledge search: FTS over /knowledge/search with snippet + entity links
- Audit trail: browseable audit log with actor/action/entity filters

Enhanced live events page with correlation-id clustering (Groups toggle).
Added fetchAgentActivity/searchKnowledge/fetchAudit to the API client.
11 nav items now cover all planned control-room views.
2026-07-08 17:02:09 +02:00
cff05c0768 fix(nomos): auto-reconnect stale MCP session; enlarge SSE scan buffer
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
After an api (MCP server) restart, nomos held a dead session id and every
tool call failed with "unexpected end of JSON input" until nomos was manually
restarted — which happens on every deploy. The MCP client now detects a
rejected session (4xx or empty body) and transparently re-initializes and
retries once. Also raise the SSE scanner buffer to 4MB so large tool results
don't exceed the 64KB default token limit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:55:10 +02:00
5d02126e16 fix(ui): serve embedded SPA via ServeContent to avoid index.html redirect loop
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
http.FileServer canonicalizes /index.html -> "./", which for /ui/ produced a
301 redirect loop and made the control room unreachable. Serve embedded files
directly with http.ServeContent instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:28:54 +02:00
212 changed files with 11723 additions and 670 deletions

11
.claude/launch.json Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "web",
"runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "web", "run", "dev"],
"port": 5173
}
]
}

View File

@@ -312,6 +312,17 @@ paths:
type: string
style: form
explode: true
- name: include
in: query
schema:
type: array
items:
type: string
enum:
- status
style: form
explode: true
description: include=status joins entity_status and populates GraphView.health
responses:
'200':
description: Graph view
@@ -1696,6 +1707,22 @@ paths:
$ref: '#/components/schemas/HealthSummary'
default:
$ref: '#/components/responses/Problem'
/dashboard/summary:
get:
tags:
- observability
operationId: getDashboardSummary
summary: One-round-trip overview for the control room home page
x-required-scope: viewer
responses:
'200':
description: Dashboard summary
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardSummary'
default:
$ref: '#/components/responses/Problem'
/export:
get:
tags:
@@ -1887,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:
@@ -1983,6 +2025,17 @@ components:
truncated:
type: boolean
description: True if node cap was hit
health:
type: object
description: entity id -> health, present when include=status was requested
additionalProperties:
type: string
enum:
- healthy
- degraded
- down
- unknown
- stale
EntityType:
type: object
required:
@@ -2207,6 +2260,7 @@ components:
- disk
- cert-expiry
- drift
- ping
- ssh-script
target:
type: string
@@ -2246,6 +2300,7 @@ components:
- disk
- cert-expiry
- drift
- ping
- ssh-script
target:
type: string
@@ -2992,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:
@@ -3012,6 +3070,7 @@ components:
- degraded
- down
- unknown
- stale
trend:
type: string
enum:
@@ -3024,6 +3083,72 @@ components:
type: string
format: date-time
nullable: true
DashboardSummary:
type: object
required:
- entities_by_type
- entities_by_state
- health
- signals_by_severity
- approvals_pending
- executions_by_state
- event_rate
properties:
entities_by_type:
type: object
description: entity counts keyed by type
additionalProperties:
type: integer
entities_by_state:
type: object
description: entity counts keyed by state
additionalProperties:
type: integer
health:
type: object
required:
- healthy
- degraded
- down
- unknown
properties:
healthy:
type: integer
degraded:
type: integer
down:
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
additionalProperties:
type: integer
approvals_pending:
type: integer
executions_by_state:
type: object
description: execution counts keyed by state, last 24h
additionalProperties:
type: integer
event_rate:
type: array
description: event counts bucketed by 5-minute interval, most recent last
items:
type: object
required:
- bucket
- count
properties:
bucket:
type: string
format: date-time
count:
type: integer
EnrollRequest:
type: object
required:

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# backup_freshness.sh — check that backups exist and are recent.
set -euo pipefail
BACKUP_DIRS="${OIKOS_BACKUP_DIRS:-/var/backups /opt/backups /mnt/backups}"
GRACE_HOURS="${OIKOS_BACKUP_GRACE:-48}"
STALE=""
for dir in $BACKUP_DIRS; do
[ -d "$dir" ] || continue
NEWEST=$(find "$dir" -type f -mmin -$((GRACE_HOURS * 60)) 2>/dev/null | head -1 || true)
if [ -z "$NEWEST" ]; then
LATEST_TS=$(find "$dir" -type f -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | awk '{print $1}' || echo "0")
LATEST_HOURS=$(awk "BEGIN {printf \"%.0f\", ($(date +%s) - ${LATEST_TS:-0})/3600}")
STALE="$STALE $dir(${LATEST_HOURS}h)"
fi
done
if [ -n "$STALE" ]; then
echo "{\"health\":\"degraded\",\"signalKind\":\"backup-stale\",\"evidence\":\"stale backups:$(echo "$STALE" | sed 's/ /, /g')\"}"
else
echo '{"health":"healthy"}'
fi

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# caddy_error_rate.sh — 5xx error rate from Caddy JSON access logs.
set -euo pipefail
LOG_DIR=""
if [ -d /var/log/caddy ]; then
LOG_DIR="/var/log/caddy"
elif [ -d /var/lib/caddy/logs ]; then
LOG_DIR="/var/lib/caddy/logs"
elif [ -d /opt/caddy/logs ]; then
LOG_DIR="/opt/caddy/logs"
fi
if [ -z "$LOG_DIR" ]; then
echo '{"health":"healthy"}'
exit 0
fi
CUTOFF=$(date -u -d "5 minutes ago" +%Y-%m-%dT%H:%M 2>/dev/null || date -u -v-5M +%Y-%m-%dT%H:%M 2>/dev/null || echo "")
TOTAL=0
ERR_5XX=0
for LOG in "$LOG_DIR"/access*.log "$LOG_DIR"/access*.json "$LOG_DIR"/*.log 2>/dev/null; do
[ -f "$LOG" ] || continue
[ -r "$LOG" ] || continue
if [ -n "$CUTOFF" ]; then
NEW=$(awk -v cutoff="$CUTOFF" '$0 >= cutoff {print}' "$LOG" 2>/dev/null | wc -l | tr -d ' ' || echo 0)
if [ "$NEW" -gt 0 ]; then
TOTAL=$((TOTAL + NEW))
ERR_5XX=$((ERR_5XX + $(awk -v cutoff="$CUTOFF" '$0 >= cutoff && /"status":5[0-9][0-9]/ {print}' "$LOG" 2>/dev/null | wc -l | tr -d ' ' || echo 0)))
fi
fi
done
if [ "$TOTAL" -gt 100 ]; then
RATE=$(awk "BEGIN {printf \"%.1f\", $ERR_5XX*100/$TOTAL}")
if [ "$(echo "$RATE > 5" | bc 2>/dev/null || echo 0)" = "1" ]; then
echo "{\"health\":\"degraded\",\"signalKind\":\"caddy-errors\",\"evidence\":\"${RATE}% 5xx rate ($ERR_5XX/$TOTAL)\",\"metrics\":{\"caddy_5xx_rate\":$RATE,\"caddy_requests\":$TOTAL,\"caddy_5xx\":$ERR_5XX}}"
exit 0
fi
echo "{\"health\":\"healthy\",\"metrics\":{\"caddy_5xx_rate\":$RATE,\"caddy_requests\":$TOTAL,\"caddy_5xx\":$ERR_5XX}}"
else
echo '{"health":"healthy"}'
fi

20
checks/cpu_check.sh Normal file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# cpu_check.sh — CPU usage % and thermal temperature.
set -euo pipefail
USAGE=$(top -bn1 2>/dev/null | awk '/^%Cpu/ {print 100 - $8}' || true)
if [ -z "$USAGE" ]; then
CORES=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1)
USAGE=$(awk -v cores="$CORES" '{print ($1+$2+$3)*100/cores}' /proc/loadavg 2>/dev/null || echo "0")
fi
TEMP=""
if [ -f /sys/class/thermal/thermal_zone0/temp ]; then
TEMP=$(awk '{printf "%.1f", $1/1000}' /sys/class/thermal/thermal_zone0/temp 2>/dev/null || true)
fi
if [ -n "$TEMP" ]; then
echo "{\"health\":\"healthy\",\"metrics\":{\"cpu_pct\":$USAGE,\"cpu_temp\":$TEMP}}"
else
echo "{\"health\":\"healthy\",\"metrics\":{\"cpu_pct\":$USAGE}}"
fi

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# disk_smart_check.sh — SMART pre-failure indicators for physical disks.
set -euo pipefail
if ! command -v smartctl >/dev/null 2>&1; then
echo '{"health":"healthy"}'
exit 0
fi
DISKS=$(lsblk -ndo NAME,TYPE 2>/dev/null | awk '$2=="disk"{print "/dev/"$1}' || true)
if [ -z "$DISKS" ]; then
echo '{"health":"healthy"}'
exit 0
fi
FAILED=""
for dev in $DISKS; do
INFO=$(smartctl -H "$dev" 2>/dev/null || true)
if ! echo "$INFO" | grep -q "PASSED\|OK"; then
MODEL=$(smartctl -i "$dev" 2>/dev/null | awk -F': ' '/Device Model|Product/{print $2; exit}' || echo "$dev")
FAILED="$FAILED $MODEL"
fi
done
if [ -n "$FAILED" ]; then
echo "{\"health\":\"degraded\",\"signalKind\":\"disk-smart-fail\",\"evidence\":\"SMART check failed for:$(echo "$FAILED" | sed 's/ /, /g')\"}"
else
echo '{"health":"healthy"}'
fi

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# disk_usage_check.sh — disk usage and inode usage per mountpoint.
set -euo pipefail
MOUNTS=$(df -k 2>/dev/null | awk 'NR>1 && $1 ~ /^\// && $NF !~ /^\/(snap|dev|proc|sys|run|private)/ {print $NF}' || true)
FIRST=1
echo -n '{"health":"healthy","metrics":{'
for m in $MOUNTS; do
LINE=$(df -k "$m" 2>/dev/null | awk 'NR==2 {print $3, $4, $5, $7}' | tr -d '%' || true)
if [ -z "$LINE" ]; then continue; fi
USED=$(echo "$LINE" | awk '{print $1}')
FREE=$(echo "$LINE" | awk '{print $2}')
PCT=$(echo "$LINE" | awk '{print $3}')
INODE_LINE=$(df -i "$m" 2>/dev/null | awk 'NR==2 {print $5}' | tr -d '%' || echo "0")
INODE_PCT=$(echo "${INODE_LINE:-0}" | sed 's/-/0/')
KEY=$(echo "$m" | sed 's|/|_|g' | sed 's|^_||')
[ -z "$KEY" ] && KEY="root"
if [ $FIRST -eq 0 ]; then echo -n ','; fi
FIRST=0
echo -n "\"disk_${KEY}_pct\":$PCT,\"inode_${KEY}_pct\":$INODE_PCT"
done
echo '}}'

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# docker_health_check.sh — detect unhealthy Docker containers.
set -euo pipefail
if ! command -v docker >/dev/null 2>&1; then
echo '{"health":"healthy"}'
exit 0
fi
UNHEALTHY=$(docker ps --filter "health=unhealthy" --format "{{.Names}}" 2>/dev/null || true)
if [ -n "$UNHEALTHY" ]; then
COUNT=$(echo "$UNHEALTHY" | wc -l | tr -d ' ')
NAMES=$(echo "$UNHEALTHY" | tr '\n' ',' | sed 's/,$//')
echo "{\"health\":\"degraded\",\"signalKind\":\"docker-unhealthy\",\"evidence\":\"$COUNT unhealthy container(s): $NAMES\",\"metrics\":{\"docker_unhealthy\":$COUNT}}"
else
TOTAL=$(docker ps -q 2>/dev/null | wc -l | tr -d ' ' || echo 0)
echo "{\"health\":\"healthy\",\"metrics\":{\"docker_unhealthy\":0,\"docker_total\":$TOTAL}}"
fi

13
checks/fd_check.sh Normal file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# fd_check.sh — open file descriptor usage ratio.
set -euo pipefail
FD_PCT=0
if [ -r /proc/sys/fs/file-nr ]; then
read -r ALLOC _ LIMIT < /proc/sys/fs/file-nr
if [ "$LIMIT" -gt 0 ]; then
FD_PCT=$(awk "BEGIN {printf \"%.1f\", $ALLOC*100/$LIMIT}")
fi
fi
echo "{\"health\":\"healthy\",\"metrics\":{\"fd_pct\":$FD_PCT}}"

29
checks/install.sh Normal file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# install all check scripts into the canonical directory.
# Auto-setup hook called by tools/post-pull.sh.
set -euo pipefail
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}"
CHECK_SRC="$CLONE_DIR/checks"
CHECK_DST="${OIKOS_CHECK_DIR:-/opt/oikos/checks}"
if [ ! -d "$CHECK_SRC" ]; then
exit 0
fi
mkdir -p "$CHECK_DST"
for script in "$CHECK_SRC"/*.sh; do
name=$(basename "$script")
if [ "$name" = "install.sh" ]; then continue; fi
if [ -f "$CHECK_DST/$name" ]; then
if cmp -s "$script" "$CHECK_DST/$name"; then
continue
fi
fi
cp "$script" "$CHECK_DST/$name"
chmod 755 "$CHECK_DST/$name"
echo "[setup-checks] installed $name"
done
echo "[setup-checks] done"

15
checks/journal_check.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# journal_check.sh — count journal errors in the last check interval.
set -euo pipefail
ERRORS=0
if command -v journalctl >/dev/null 2>&1; then
ERRORS=$(journalctl -p err --since "-5min" --no-pager 2>/dev/null | wc -l | tr -d ' ' || echo 0)
fi
if [ "$ERRORS" -gt 0 ]; then
echo "{\"health\":\"degraded\",\"signalKind\":\"journal-errors\",\"evidence\":\"$ERRORS error entries in last 5min\",\"metrics\":{\"journal_errors\":$ERRORS}}"
else
echo '{"health":"healthy","metrics":{"journal_errors":0}}'
fi

8
checks/load_check.sh Normal file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# load_check.sh — system load average scaled by CPU count.
set -euo pipefail
LOAD=$(awk '{print $1}' /proc/loadavg 2>/dev/null || sysctl -n vm.loadavg 2>/dev/null | awk '{print $2}' || echo "0")
CORES=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1)
echo "{\"health\":\"healthy\",\"metrics\":{\"load1\":$LOAD,\"cores\":$CORES}}"

27
checks/memory_check.sh Normal file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# memory_check.sh — RAM usage percentage.
set -euo pipefail
TOTAL=1
AVAIL=1
if [ -r /proc/meminfo ]; then
TOTAL=$(awk '/^MemTotal:/ {printf "%d", $2}' /proc/meminfo)
AVAIL=$(awk '/^MemAvailable:/ {printf "%d", $2}' /proc/meminfo)
elif [ "$(uname)" = "Darwin" ]; then
MEM=$(vm_stat 2>/dev/null | awk '
/page size/ {ps=$8}
/Pages free/ {free+=$NF}
/Pages active/ {active+=$NF}
/Pages wired/ {wired+=$NF}
END {printf "%.2f %.2f", ps*(free+active+wired)/1048576, ps*wired/1048576}')
TOTAL=$(echo "$MEM" | awk '{printf "%.0f", $1}')
AVAIL=$(echo "$MEM" | awk '{printf "%.0f", $1 - $2}')
fi
if [ "$TOTAL" -eq 0 ]; then TOTAL=1; fi
if [ "$AVAIL" -lt 0 ]; then AVAIL=0; fi
USED_PCT=$(awk "BEGIN {printf \"%.1f\", (1 - $AVAIL/$TOTAL)*100}")
echo "{\"health\":\"healthy\",\"metrics\":{\"mem_pct\":$USED_PCT}}"

17
checks/oom_check.sh Normal file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# oom_check.sh — detect OOM kills since last boot.
set -euo pipefail
OOM_COUNT=0
if command -v dmesg >/dev/null 2>&1; then
OOM_COUNT=$(dmesg 2>/dev/null | grep -ci 'out of memory\|oom-killer\|Killed process' || echo 0)
elif command -v journalctl >/dev/null 2>&1; then
OOM_COUNT=$(journalctl -k --no-pager 2>/dev/null | grep -ci 'out of memory\|oom-killer\|Killed process' || echo 0)
fi
if [ "$OOM_COUNT" -gt 0 ]; then
echo "{\"health\":\"degraded\",\"signalKind\":\"oom-kills\",\"evidence\":\"$OOM_COUNT OOM events detected since boot\",\"metrics\":{\"oom_count\":$OOM_COUNT}}"
else
echo '{"health":"healthy","metrics":{"oom_count":0}}'
fi

22
checks/process_check.sh Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# process_check.sh — systemd service liveness.
set -euo pipefail
SERVICE="${1:-}"
if [ -z "$SERVICE" ]; then
echo '{"health":"unknown","signalKind":"process-check","evidence":"no service name provided"}'
exit 0
fi
if ! command -v systemctl >/dev/null 2>&1; then
echo '{"health":"unknown","signalKind":"process-check","evidence":"systemctl not found"}'
exit 0
fi
STATE=$(systemctl is-active "$SERVICE" 2>/dev/null || echo "unknown")
if [ "$STATE" = "active" ]; then
echo "{\"health\":\"healthy\"}"
else
echo "{\"health\":\"degraded\",\"signalKind\":\"$SERVICE\",\"evidence\":\"$SERVICE is $STATE\"}"
fi

22
checks/swap_check.sh Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# swap_check.sh — swap usage percentage.
set -euo pipefail
if [ -r /proc/meminfo ]; then
SWAP_TOTAL=$(awk '/^SwapTotal:/ {printf "%d", $2}' /proc/meminfo)
SWAP_FREE=$(awk '/^SwapFree:/ {printf "%d", $2}' /proc/meminfo)
elif [ "$(uname)" = "Darwin" ]; then
SP=$(sysctl vm.swapusage 2>/dev/null | awk '{print $4, $9}' | tr -d 'M' || echo "0 0")
SWAP_TOTAL=$(echo "$SP" | awk '{printf "%.0f", $1*1024}')
SWAP_FREE=$(echo "$SP" | awk '{printf "%.0f", ($1-$2)*1024}')
else
SWAP_TOTAL=0
SWAP_FREE=0
fi
if [ "$SWAP_TOTAL" -eq 0 ]; then
echo "{\"health\":\"healthy\",\"metrics\":{\"swap_pct\":0}}"
else
USED_PCT=$(awk "BEGIN {printf \"%.1f\", (1 - $SWAP_FREE/$SWAP_TOTAL)*100}")
echo "{\"health\":\"healthy\",\"metrics\":{\"swap_pct\":$USED_PCT}}"
fi

26
checks/time_check.sh Normal file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# time_check.sh — NTP synchronization status and clock drift.
set -euo pipefail
DRIFT_S=0
SYNCED=true
if command -v chronyc >/dev/null 2>&1; then
TRACKING=$(chronyc tracking 2>/dev/null || true)
DRIFT_NS=$(echo "$TRACKING" | awk '/System time/ {print $4}' | sed 's/-//' || echo "0")
DRIFT_S=$(awk "BEGIN {printf \"%.6f\", $DRIFT_NS/1e9}")
# chronyc returns a very small value when synced (nanoseconds)
elif command -v timedatectl >/dev/null 2>&1; then
STATUS=$(timedatectl show 2>/dev/null || true)
if echo "$STATUS" | grep -q "NTPSynchronized=no"; then
SYNCED=false
fi
fi
if ! $SYNCED; then
echo '{"health":"degraded","signalKind":"time-drift","evidence":"NTP not synchronized"}'
elif [ "$(echo "$DRIFT_S > 1" | bc 2>/dev/null || echo 0)" = "1" ]; then
echo "{\"health\":\"degraded\",\"signalKind\":\"time-drift\",\"evidence\":\"clock drift ${DRIFT_S}s exceeds 1s threshold\",\"metrics\":{\"clock_drift_s\":$DRIFT_S}}"
else
echo "{\"health\":\"healthy\",\"metrics\":{\"clock_drift_s\":$DRIFT_S}}"
fi

17
checks/updates_check.sh Normal file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# updates_check.sh — pending apt security updates and reboot-required flag.
set -euo pipefail
SECURITY=0
REBOOT=0
if command -v apt >/dev/null 2>&1; then
apt update -qq >/dev/null 2>&1 || true
SECURITY=$(apt list --upgradable 2>/dev/null | grep -c '\-security' || true)
fi
if [ -f /var/run/reboot-required ]; then
REBOOT=1
fi
echo "{\"health\":\"healthy\",\"metrics\":{\"security_updates\":$SECURITY,\"reboot_required\":$REBOOT}}"

7
checks/uptime_check.sh Normal file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# uptime_check.sh — detect unexpected reboots by monitoring uptime.
set -euo pipefail
UPTIME=$(awk '{printf "%.0f", $1}' /proc/uptime 2>/dev/null || sysctl -n kern.boottime 2>/dev/null | awk '{print $4}' | tr -d ',' || echo "0")
echo "{\"health\":\"healthy\",\"metrics\":{\"uptime_seconds\":$UPTIME}}"

29
checks/zfs_check.sh Normal file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# zfs_check.sh — ZFS pool health.
set -euo pipefail
if ! command -v zpool >/dev/null 2>&1; then
echo '{"health":"healthy"}'
exit 0
fi
STATUS=$(zpool status -x 2>&1 || true)
SCRUB_OVERDUE=""
if echo "$STATUS" | grep -q "all pools are healthy"; then
for pool in $(zpool list -Ho name 2>/dev/null || true); do
LAST=$(zpool status "$pool" 2>/dev/null | awk '/scan:/{print $0}' || true)
if [ -z "$LAST" ] || echo "$LAST" | grep -q "scrub repaired"; then
SCRUB_OVERDUE="$pool"
break
fi
done
if [ -n "$SCRUB_OVERDUE" ]; then
echo "{\"health\":\"degraded\",\"signalKind\":\"zfs-scrub-overdue\",\"evidence\":\"pool $SCRUB_OVERDUE scrub has errors or is overdue\"}"
else
echo '{"health":"healthy"}'
fi
else
HEALTH=$(echo "$STATUS" | grep "state:" | awk '{print $2}' | head -1)
echo "{\"health\":\"degraded\",\"signalKind\":\"zfs-degraded\",\"evidence\":\"pool state: $HEALTH\"}"
fi

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

@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
@@ -351,9 +352,51 @@ type mcpJSONRPCResponse struct {
Error json.RawMessage `json:"error"`
}
// errStaleSession signals that the MCP server rejected our session id (e.g.
// after an api/MCP restart), so the client should re-initialize and retry.
var errStaleSession = fmt.Errorf("mcp session stale")
// doRequest serializes MCP calls and transparently re-initializes the session
// if the server has forgotten it (common after an api redeploy), retrying the
// original call once. Without this, an api restart permanently breaks nomos
// until it is itself restarted.
func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
c.mu.Lock()
defer c.mu.Unlock()
resp, err := c.send(method, params)
if err != nil && method != "initialize" && errors.Is(err, errStaleSession) {
slog.Warn("nomos: mcp session stale, reconnecting")
if rerr := c.reconnectLocked(); rerr != nil {
return nil, fmt.Errorf("mcp reconnect: %w (original: %v)", rerr, err)
}
return c.send(method, params)
}
return resp, err
}
// reconnectLocked re-initializes the MCP session. The caller must hold c.mu.
func (c *mcpClient) reconnectLocked() error {
c.sessionID = ""
resp, err := c.send("initialize", map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "nomos", "version": "2.0"},
})
if err != nil {
return err
}
if resp.sessionID == "" {
return fmt.Errorf("no session ID on re-initialize")
}
c.sessionID = resp.sessionID
_, _ = c.send("notifications/initialized", map[string]any{})
slog.Info("nomos: mcp reconnected", "session", c.sessionID[:16]+"...")
return nil
}
// send performs one MCP round-trip. It does not lock; callers hold c.mu.
func (c *mcpClient) send(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
c.nextID++
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
@@ -378,13 +421,21 @@ func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPC
}
defer resp.Body.Close()
// A rejected/unknown session comes back as 4xx (commonly 400/404).
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest {
return nil, errStaleSession
}
result := &mcpJSONRPCResponse{}
result.sessionID = resp.Header.Get("Mcp-Session-Id")
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
gotData := false
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "data: ") {
gotData = true
data := line[6:]
if err := json.Unmarshal([]byte(data), result); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
@@ -396,6 +447,12 @@ func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPC
return nil, fmt.Errorf("rpc error: %s", string(result.Error))
}
// Empty body with no result and a session set: the server likely dropped
// our session. Notifications legitimately return no data, so exempt them.
if !gotData && result.Result == nil && method != "notifications/initialized" {
return nil, errStaleSession
}
if result.sessionID != "" {
c.sessionID = result.sessionID
}

View File

@@ -1,14 +1,17 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db"
@@ -24,30 +27,38 @@ import (
// uiHandler serves the control-room SPA from assets embedded at build time
// (web/embed.go), with SPA fallback to index.html. Requests arrive as /ui/*;
// the /ui prefix is stripped to index into the embedded dist/ tree.
// the /ui prefix is stripped to index into the embedded dist/ tree. Files are
// written via http.ServeContent (not http.FileServer) to avoid its
// index.html -> "./" canonical redirect, which loops for /ui/.
func uiHandler() http.Handler {
dist, err := web.DistFS()
if err != nil {
slog.Warn("ui: embedded assets unavailable", "error", err)
return http.NotFoundHandler()
}
fileServer := http.FileServer(http.FS(dist))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/ui"), "/")
if path == "" {
path = "index.html"
serve := func(w http.ResponseWriter, r *http.Request, name string) bool {
f, err := dist.Open(name)
if err != nil {
return false
}
if f, err := dist.Open(path); err == nil {
f.Close()
r.URL.Path = "/" + path
fileServer.ServeHTTP(w, r)
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return false
}
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(data))
return true
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/ui"), "/")
if name == "" {
name = "index.html"
}
if serve(w, r, name) {
return
}
// SPA fallback: serve index.html for unknown client-side routes.
if f, err := dist.Open("index.html"); err == nil {
f.Close()
r.URL.Path = "/index.html"
fileServer.ServeHTTP(w, r)
if serve(w, r, "index.html") {
return
}
http.NotFound(w, r)

View File

@@ -23,8 +23,10 @@ COPY --from=ui-builder /web/dist ./web/dist
RUN CGO_ENABLED=0 go build -o /oikos -tags timetzdata -ldflags="-s -w" ./cmd/oikos
# --- Runtime: distroless static ---
FROM gcr.io/distroless/static:nonroot
# --- Runtime: alpine with SSH + ping for scheduler checks ---
FROM alpine:3.21
RUN apk add --no-cache ca-certificates openssh-client-default
COPY --from=builder /oikos /oikos
COPY --from=builder /build/seeds /seeds

View File

@@ -83,6 +83,12 @@ services:
OIKOS_DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
OIKOS_DEBUG: "true"
OIKOS_SCHEDULER_INTERVAL: "30s"
OIKOS_SSH_KEY_PATH: /etc/oikos/ssh_key
OIKOS_SSH_USER: root
volumes:
- ${OIKOS_SSH_KEY_PATH:-~/.ssh/id_ed25519}:/etc/oikos/ssh_key:ro
cap_add:
- NET_RAW
command: ["scheduler"]
stop_signal: SIGTERM
stop_grace_period: 30s

View File

@@ -0,0 +1,197 @@
# Signal Trigger Architecture
## Overview
When Nomos is asked "what are the thermals of hubris?", here is exactly what happens:
```mermaid
sequenceDiagram
participant N as Nomos (Agent)
participant A as Oikos API
participant S as Scheduler (Docker)
participant H as Hubris (Proxmox)
participant T as TimescaleDB
Note over S,H: Every 60s (autonomous loop)
S->>H: SSH exec /opt/oikos/checks/cpu_check.sh
H-->>S: {"health":"ok","metrics":{"cpu_pct":2.5,"cpu_temp":48}}
S->>T: INSERT metric_samples (cpu_pct, cpu_temp)
S->>T: UPSERT entity_status (health)
alt unhealthy
S->>T: UPSERT signal (dedup by target+kind)
end
Note over N,T: User asks "what are the thermals of hubris?"
N->>A: MCP query_metrics(metric=["cpu_pct","cpu_temp"])
A->>T: SELECT time_bucket(…) FROM metric_samples
T-->>A: cpu_pct=15%, cpu_temp=48°C
A-->>N: {avg, min, max} per bucket
```
## Two Paths
### Path A — Autonomous Collection (Scheduler)
1. Operator creates a check via REST API: `POST /api/v1/checks`
2. Scheduler loads enabled checks every 30s from `check_defs` table
3. For `ssh-script` checks, scheduler SSHs to target host and runs `/opt/oikos/checks/<script>.sh`
4. Script returns JSON with `health`, `signalKind`, `evidence`, and `metrics`
5. Metrics written to TimescaleDB `metric_samples` table **every cycle** (healthy or not)
6. If unhealthy: a signal is raised (deduplicated by target_entity_id + kind)
7. If healthy again: the signal is auto-resolved, entity_status health updated
8. All state changes emit SSE events for real-time UI updates
### Path B — Query (Nomos via MCP)
1. Nomos calls `query_metrics(metric=["cpu_pct","cpu_temp"])` MCP tool
2. API runs time-bucketed aggregation over `metric_samples`
3. Returns latest readings with avg/min/max per bucket
4. Nomos formats them and presents to the user
## Check Kinds
| Kind | Where it runs | Protocol | Example |
|------|--------------|----------|---------|
| `ping` | Scheduler container | ICMP (`ping` binary) | Reachability + latency |
| `http` | Scheduler container | HTTP GET | Service endpoint health |
| `tcp` | Scheduler container | TCP dial | Port open check |
| `disk` | Scheduler container | `unix.Statfs` | Local disk usage + inodes |
| `cert-expiry` | Scheduler container | TLS dial | Certificate days remaining |
| `ssh-script` | Remote target via SSH | SSH exec + JSON | Any script in `/opt/oikos/checks/` |
## Available Check Scripts
All scripts live in `/opt/oikos/checks/` on target hosts. They output JSON:
```json
{"health":"healthy","metrics":{"cpu_pct":2.5,"cpu_temp":48.0}}
```
or on failure:
```json
{"health":"degraded","signalKind":"disk-smart-fail","evidence":"SMART failed for Samsung 990"}
```
| Script | Metrics | Signal (on failure) |
|--------|---------|---------------------|
| `cpu_check.sh` | `cpu_pct`, `cpu_temp` | threshold-based |
| `memory_check.sh` | `mem_pct` | threshold-based |
| `load_check.sh` | `load1`, `cores` | threshold-based |
| `swap_check.sh` | `swap_pct` | threshold-based |
| `disk_usage_check.sh` | `disk_*_pct`, `inode_*_pct` | threshold-based |
| `disk_smart_check.sh` | — | `disk-smart-fail` |
| `updates_check.sh` | `security_updates`, `reboot_required` | threshold-based |
| `zfs_check.sh` | — | `zfs-degraded`, `zfs-scrub-overdue` |
| `process_check.sh` | — | `<service-name>` |
| `uptime_check.sh` | `uptime_seconds` | threshold-based |
| `oom_check.sh` | `oom_count` | `oom-kills` |
| `journal_check.sh` | `journal_errors` | `journal-errors` |
| `time_check.sh` | `clock_drift_s` | `time-drift` |
| `fd_check.sh` | `fd_pct` | threshold-based |
| `docker_health_check.sh` | `docker_unhealthy`, `docker_total` | `docker-unhealthy` |
| `caddy_error_rate.sh` | `caddy_5xx_rate`, `caddy_requests`, `caddy_5xx` | `caddy-errors` |
| `backup_freshness.sh` | — | `backup-stale` |
## Script Deployment
```mermaid
sequenceDiagram
participant R as Git Repo
participant T as Sync Timer (5min)
participant H as Target Host
Note over R,T: Operator pushes scripts
R->>T: git pull (homelab-context)
T->>T: tools/post-pull.sh
T->>T: → tools/setup-checks.sh
T->>T: → checks/install.sh
T->>H: cp *.sh → /opt/oikos/checks/
```
## Defining a Check
```bash
curl -X POST http://oikos:8090/api/v1/checks \
-H 'Content-Type: application/json' \
-d '{
"kind": "ssh-script",
"target": "host:hubris",
"config": {
"host": "192.168.8.77",
"script": "cpu_check.sh",
"thresholds": {
"cpu_pct": {"warn": 90, "crit": 95},
"cpu_temp": {"crit": 85}
}
},
"interval_s": 60
}'
```
## Signal Lifecycle
```mermaid
stateDiagram-v2
[*] --> raised
raised --> acknowledged
raised --> muted: mute_until set
raised --> resolved: condition cleared
acknowledged --> acting: classification exists
acknowledged --> muted
acknowledged --> resolved
acting --> resolved: verification passed
acting --> raised: retry budget remaining
acting --> failed
failed --> acknowledged: operator retry
muted --> raised: mute_until expired
resolved --> [*]
```
Signals deduplicate: **one open signal per (target_entity_id, kind)**.
Repeated failures increment `occurrence_count` instead of creating duplicates.
## Threshold Evaluation
Each check config can define per-metric thresholds in the `config` JSONB:
```json
{
"thresholds": {
"cpu_temp": {"crit": 85},
"cpu_pct": {"warn": 90, "crit": 95}
}
}
```
Severity mapping:
- metric >= `crit` → severity = `critical`
- metric >= `warn` → severity = `warning`
- `health == "down"` with no thresholds → severity = `critical`
- Otherwise → severity = `warning`
## Data Flow (DB Tables)
```mermaid
flowchart TD
CD[check_defs] -->|scheduler reads| EC[executeCheck]
EC -->|healthy?| RS[resolve signal + upsert entity_status]
EC -->|unhealthy?| US[UpsertSignal dedup by target+kind]
EC -->|every cycle| IM[INSERT metric_samples]
US --> S[signals]
RS --> ES[entity_status]
IM --> MS[(metric_samples)]
MS --> R1H[metric_rollups_1h continuous aggregate]
MS --> R1D[metric_rollups_1d continuous aggregate]
```
## Prerequisites for SSH Checks
1. **Key**: SSH private key mounted at `/etc/oikos/ssh_key` in the scheduler container
2. **User**: `OIKOS_SSH_USER=root` (or set `"user"` in check config)
3. **Scripts**: Deployed on target host at `/opt/oikos/checks/`
4. **Network**: Scheduler container must reach target host (bridge → LAN works)
5. **Container**: Scheduler needs `openssh-client` (alpine base) + `CAP_NET_RAW` (for ping)

View File

@@ -0,0 +1,613 @@
# Oikos Entity Model — Types, Relationships & Interactions
**Status:** Adopted
**Date:** 2026-07-08
**Scope:** Full inventory of every entity type, relationship, state machine, and
cognition pipeline — with clear markers for what is **code-real** vs **schema-only**.
---
## 1. Entity Type Hierarchy (56 types)
```mermaid
graph TD
subgraph meta["layer: meta"]
entity["★ entity"]
end
subgraph infrastructure["layer: infrastructure"]
subgraph physical["domain: physical"]
site
ups
sensor
peripheral
end
subgraph compute["domain: compute"]
ce["★ compute-entity"]
machine["★ machine"]
proxmox-host
standalone-server
workstation
appliance
vm
container["★ container"]
lxc
docker-container
hypervisor
end
subgraph network["domain: network"]
net["★ network"]
lan
mesh
vlan
network-interface
dns-zone
dns-record
ingress-route
certificate
firewall-rule
end
subgraph storage["domain: storage"]
storage-pool
volume
backup-target
dataset
end
subgraph software["domain: software"]
service
application
config-repo
deploy-pipeline
package-set
cluster
compose-stack
end
subgraph external["domain: external"]
domain-registration
cloud-service
isp-link
vendor-dependency
end
end
subgraph governance["layer: governance"]
subgraph identity["domain: identity"]
person
agent
identity-provider
account
secret
key
access-grant
end
end
subgraph cognition["layer: cognition"]
check
signal
classification
execution
feedback
pattern
skill
approval
document
runbook
investigation
end
entity --> ce
entity --> machine
entity --> net
entity --> container
entity --> site
entity --> ups
entity --> sensor
entity --> peripheral
entity --> vm
entity --> hypervisor
entity --> network-interface
entity --> dns-zone
entity --> dns-record
entity --> ingress-route
entity --> certificate
entity --> firewall-rule
entity --> storage-pool
entity --> volume
entity --> backup-target
entity --> dataset
entity --> service
entity --> application
entity --> config-repo
entity --> deploy-pipeline
entity --> package-set
entity --> cluster
entity --> compose-stack
entity --> domain-registration
entity --> cloud-service
entity --> isp-link
entity --> vendor-dependency
entity --> person
entity --> agent
entity --> identity-provider
entity --> account
entity --> secret
entity --> key
entity --> access-grant
entity --> check
entity --> signal
entity --> classification
entity --> execution
entity --> feedback
entity --> pattern
entity --> skill
entity --> approval
entity --> document
entity --> runbook
entity --> investigation
ce --> machine
ce --> container
machine --> proxmox-host
machine --> standalone-server
machine --> workstation
machine --> appliance
container --> lxc
container --> docker-container
net --> lan
net --> mesh
net --> vlan
```
★ = abstract (cannot be instantiated; polymorphic target for relationships)
### Concrete instances (88 active entities)
| Type | Count | Examples |
|------|-------|---------|
| `lxc` | 19 | jellyfin, caddy, dns, gitea, nextcloud, matrix, arriman… |
| `service` | 25 | caddy, authentik, dns, jellyfin, paperless, matrix… |
| `ingress-route` | 21 | *.hubris.network |
| `config-repo` | 6 | caddy-conf, gitea-customizations, mule-image… |
| `proxmox-host` | 2 | hubris, strong |
| `workstation` | 2 | mac-mini, republic-laptop |
| `standalone-server` | 1 | netbird-vps |
| `vm` | 2 | zimaos, haos |
| `storage-pool` | 3 | local-lvm-hubris, library-hubris, ludo-lvm |
| `volume` | 2 | library, media-local |
| + sites, networks, agents, documents, destroyed… | | |
---
## 2. Core Sequence: Machine Onboarding
```mermaid
sequenceDiagram
participant O as Operator
participant A as Oikos API
participant D as DB
participant S as Scheduler
participant T as Target Machine
O->>A: POST /api/v1/entities {type:proxmox-host, slug:host:new, …}
A->>D: INSERT INTO entities
A->>A: ensureDefaultChecks().resolveHost() → lan_ip
A->>D: INSERT check_defs × 6 (target_id set)
A-->>O: 201 Created
Note over S,T: 30s scheduler tick
S->>D: ListEnabledCheckDefs
S->>T: SSH exec /opt/oikos/checks/cpu_check.sh
T-->>S: {"health":"ok","metrics":{"cpu_pct":2.5,"cpu_temp":48}}
S->>D: INSERT metric_samples
S->>D: UPSERT entity_status (health)
```
**What's code-real here:**
- `CreateEntity()` at `internal/httpapi/impl.go:811` — handles POST, validates type, calls `ensureDefaultChecks()`
- `ensureDefaultChecks()``internal/checkdefaults/defaults.go:144` — resolves host IP, SSH user, creates 6 check_defs rows with target_id
- Scheduler at `internal/scheduler/scheduler.go:26` — loads `ListEnabledCheckDefs`, dispatches by kind, writes metrics + signals
---
## 3. Core Sequence: The OODA Loop (observe → orient → decide → act → learn)
```mermaid
flowchart TB
subgraph OBSERVE["🔍 OBSERVE (Scheduler every 30s)"]
direction TB
S1["ListEnabledCheckDefs"]
S2["ping → exec.Command(ping, host)"]
S3["http → http.Get(url)"]
S4["tcp → net.DialTimeout(tcp, addr)"]
S5["disk → unix.Statfs(path)"]
S6["cert-expiry → tls.Dial + cert.NotAfter"]
S7["ssh-script → exec.Command(ssh, host, script)"]
S8["checkResult{health, signalKind, evidence, metrics}"]
S1 --> S2 & S3 & S4 & S5 & S6 & S7
S2 & S3 & S4 & S5 & S6 & S7 --> S8
S8 -->|INSERT| MS[("metric_samples")]
S8 -->|UPSERT| SG["signals (dedup by target+kind)"]
S8 -->|UPSERT| ES["entity_status (health)"]
end
subgraph ORIENT["🧭 ORIENT (Classification)"]
direction TB
C1["policy.ClassifySignal(signal, entity, blast_radius)"]
C2["read_only → route: auto_act"]
C3["reversible_low → route: auto_act"]
C4["config_mutation → route: escalate"]
C5["destructive → route: hold"]
C1 --> C2 & C3 & C4 & C5
end
subgraph DECIDE["⚖️ DECIDE (Approval Gate)"]
direction TB
D1["auto_act → execute immediately"]
D2["escalate → INSERT approval (pending)"]
D3["notifier → Matrix alert + HMAC token"]
D4["operator replies ✅ or ❌"]
D5["hold → queued, never auto-executed"]
D2 --> D3 --> D4
end
subgraph ACT["⚡ ACT (Execution)"]
direction TB
A1["Nomos → MCP request_execution"]
A2["actuator.Execute() → SSH exec"]
A3["systemctl restart / apt upgrade / pct exec"]
A1 --> A2 --> A3
end
subgraph LEARN["🧠 LEARN (Patterns + Skills)"]
direction TB
L1["execution → produces → feedback"]
L2["feedback → contributes-to → pattern"]
L3["pattern → informs → skill"]
L1 --> L2 --> L3
end
SG --> ORIENT
ORIENT --> DECIDE
DECIDE --> ACT
ACT --> LEARN
style OBSERVE fill:#e3f2fd
style ORIENT fill:#fff3e0
style DECIDE fill:#fce4ec
style ACT fill:#e8f5e9
style LEARN fill:#f3e5f5
```
### What's code-real in the OODA loop
| Phase | Table | Code | Status |
|-------|-------|------|--------|
| Observe | `check_defs`, `metric_samples` | `scheduler.go:26-215` | ✅ fully wired, 6 probe kinds |
| Observe → Orient | `signals` | `scheduler.go:130-141` (UpsertSignal) | ✅ dedup, severity, events |
| Orient | `classifications` | `policy/classify.go` (function exists) | ⚠ function defined but scheduler never calls it |
| Decide | `approvals` | `server.go:311-367` (request_execution) | ✅ escalation gate works |
| Act | `executions` | `actuator/exec.go` (SSH exec) | ✅ systemctl, apt, pct |
| Learn | `feedback`, `patterns`, `skills` | tables + list endpoints only | ⚠ schema only, no write path |
---
## 4. Relationship Types — The Edge Catalog (34 edges)
### Infrastructure Topology
```mermaid
graph LR
HH["host:hubris"] -->|hosts| LX1["lxc:jellyfin"]
HH -->|hosts| LX2["lxc:caddy"]
HH -->|hosts| LX3["lxc:dns"]
HH -->|hosts| LX4["lxc:gitea"]
HH -->|hosts| LX5["lxc:…"]
HS["host:strong"] -->|hosts| LX6["lxc:jellyfin"]
HS -->|hosts| LX7["lxc:arriman"]
HH -->|member-of| CL["cluster:homelab"]
HS -->|member-of| CL
LX2 -->|provides| SV1["service:caddy"]
LX4 -->|provides| SV2["service:gitea"]
LX3 -->|provides| SV3["service:dns"]
HH -->|mounts| VL["volume:library"]
HH -->|stores-on| PL["pool:library-hubris"]
```
### Network
```mermaid
graph LR
IG["ingress:paperless.hubris.network"] -->|routes-to| SP["service:paperless"]
IG -->|secured-by| IDP["idp:authentik"]
IG -->|uses-certificate| CRT["cert:*.hubris.network"]
SJ["service:jellyfin"] -->|authenticates-via| IDP
DNS["dns:paperless"] -->|in-zone| ZN["zone:hubris.network"]
DNS -->|resolves-to| LX["lxc:caddy"]
HH["host:hubris"] -->|connects-via| LL["lan:lab"]
HS["host:strong"] -->|connects-via| LH["lan:household"]
```
### Service Dependencies (feeds blast_radius CTE)
```mermaid
graph LR
JF["service:jellyfin"] -->|depends-on| AK["service:authentik"]
PP["service:paperless"] -->|depends-on| AK
AR["service:arr-stack"] -->|depends-on| JF
AK -->|depends-on| CD["service:caddy"]
AK -->|depends-on| DNS["service:dns"]
```
### Cognition (OODA edges)
```mermaid
graph LR
CK["check:ssh-script:d419257d"] -->|checks| HH["host:hubris"]
CK -->|raises| SG["signal:cpu-pressure"]
SG -->|about| HH
CL["classification:xyz"] -->|classifies| SG
CL -->|precedes| EX["execution:restart-xyz"]
EX -->|targets| HH
EX -->|performs| AG["agent:nomos"]
```
### Governance
```mermaid
graph LR
DT["person:dtoro"] -->|owns| NO["agent:nomos"]
DT -->|decides| AP["approval:xyz"]
AK["idp:authentik"] -->|authenticates| DT
```
---
## 5. Lifecycle State Machines
### Infrastructure (15 concrete types use this)
```mermaid
stateDiagram-v2
[*] --> planned
planned --> provisioning
planned --> destroyed: cancelled
provisioning --> active
provisioning --> failed
active --> migrating
active --> failed
active --> deprecated
migrating --> active: post-verify
migrating --> failed
failed --> active: recovery verified
failed --> deprecated: write-off
deprecated --> active: un-deprecate
deprecated --> destroyed
destroyed --> [*]
```
**Real precondition checks** (code in `impl.go:1494-1579`):
| Transition | Precondition | How it's checked |
|------------|-------------|-----------------|
| provisioning→active | `health-check-answering` | `SELECT health FROM entity_status WHERE entity_id=$1` — must be healthy |
| provisioning→active | `age-key-enrolled-if-needed` | Checks `attributes->>'age_pubkey'` (workstation only) |
| provisioning→active | `mesh-joined-if-needed` | Checks `attributes->>'mesh_ip'` (workstation only) |
| provisioning→active | `doc-page-complete` | `SELECT count(*) FROM relationships WHERE target_id=$1 AND type='documents'` |
| deprecated→destroyed | `no-inbound-edges` | `SELECT count(*) FROM relationships WHERE target_id=$1 AND valid_to IS NULL` |
| any → terminated | `backups-verified` | Checks flag in entity attributes |
| any → terminated | `secrets-revoked` | Checks flag in entity attributes |
**Soft preconditions** (always pass — operator-confirmed): `inventory-entry`, `ip-reserved`, `preflight-passed`, `backup-verified`, `replacement-live`, `caddy-backends-checked`, `un-deprecate-note`, etc.
### Signal
```mermaid
stateDiagram-v2
[*] --> raised
raised --> acknowledged
raised --> muted: mute_until set
raised --> resolved: condition cleared
acknowledged --> acting: classification exists
acknowledged --> muted
acknowledged --> resolved
acting --> resolved: verification passed
acting --> raised: retry budget
acting --> failed
failed --> acknowledged: operator retry
muted --> raised: mute_until expired
resolved --> [*]
```
**Implemented preconditions:**
- `raised → muted`: requires `mute_until` set (MuteSignal handler, `impl.go:615-689`)
- `acting → resolved`: requires `verification-passed` (soft — operator confirms)
**Dedup mechanism:** `UNIQUE INDEX uq_signals_open ON signals(target_entity_id, kind) WHERE state NOT IN ('resolved','failed')` — at most one open signal per (entity, kind). Repeated failures call `UpsertSignal` which increments `occurrence_count` on the existing row.
### Execution
```mermaid
stateDiagram-v2
[*] --> proposed
proposed --> approved
proposed --> auto_approved
proposed --> denied
proposed --> expired
approved --> executing
auto_approved --> executing
expired --> [*]
denied --> [*]
executing --> verified
executing --> failed
executing --> timed_out
failed --> rolled_back
rolled_back --> verified
rolled_back --> rollback_failed
verified --> [*]
rollback_failed --> [*]
timed_out --> [*]
```
### Approval
```mermaid
stateDiagram-v2
[*] --> pending
pending --> approved
pending --> denied
approved --> revoked
approved --> expired
denied --> [*]
revoked --> [*]
expired --> [*]
```
---
## 6. What's Code-Real vs Schema-Only
### ✅ Fully Implemented (code exists, running in production)
| Component | File(s) | What it does |
|-----------|---------|-------------|
| Entity CRUD | `impl.go:811-966` | Create, read, patch, list entities |
| Lifecycle transitions | `impl.go:1494-1579` | Precondition checks + state transitions |
| Relationship management | `seed.go` (ingest) | Create edges with `valid_from/valid_to` |
| Client enrollment | `impl.go:1134-1236` | `POST /clients/enroll` — age keypair, Infisical, state: provisioning |
| Check definitions | `phase3.go:265-376` | CreateCheck, ListChecks, PatchCheck |
| Scheduler observe | `scheduler.go:26-215` | 6 probe kinds, metric_samples, signals, entity_status |
| Signals | `scheduler.go:81-161` | UpsertSignal (dedup), ResolveSignal, severity evaluation |
| Executions | `server.go:286-411` | request_execution MCP tool — reversible_low/config_mutation/destructive |
| Approvals | `server.go:970-1052` | createApproval, DecideApproval → executeApprovedAction |
| Notifier | `notifier/notifier.go` | Matrix alerts for pending approvals |
| Patterns | `phase3.go:1100+` | ListPatterns, PatchPattern (status/quarantine) |
| Skills | `phase3.go:1300+` | ListSkills, PatchSkill, ListSkillVersions |
| Default checks | `checkdefaults/defaults.go` | Auto-create checks on entity creation/enrollment/seed |
| TimescaleDB metrics | `metric_samples` table | Hypertable with 1h/1d continuous aggregates, 90-day retention |
| Events + SSE | `events` table + pg_notify | Real-time UI updates via SSE endpoint |
| Audit log | `audit_log` hypertable | Every mutation with actor + action |
| Knowledge entities | `knowledge_entities` | Documents, runbooks, investigations with FTS |
| MCP tools | `server.go` | 24 tools for observe/orient/decide/act |
| Blast radius | `blast_radius()` fn | Recursive CTE — depends-on + hosts + routes-to edges |
### ⚠ Schema Defined, Not Yet Wired (table exists, no active code path creates rows)
| Component | What's Missing |
|-----------|---------------|
| `classifications` auto-creation | `policy.ClassifySignal()` exists but scheduler never calls it. Signals are raised but never automatically classified. |
| `feedback` records | No code writes to the `feedback` table. Execution results are not analyzed for patterns. |
| Pattern auto-learning | No code transitions patterns from `hypothesized → validated`. Requires `evidence-count≥5 + confidence≥0.7` but no aggregation runs. |
| Skill execution | Skill entities carry a JSON `procedure` field but no execution engine reads or runs it. |
| `drift` check kind | Defined in OpenAPI and `check_defs.kind` enum, but no scheduler implementation exists. |
### 📋 Defined in Seeds Only (ontology.yaml references, no DB schema)
| Item | Notes |
|------|-------|
| Relationship type `cluster` | Mentioned in inventory but not in ontology relationship_types |
| `certificate` entity type | Referenced in `uses-certificate` edges but no concrete certificates in inventory |
| Relationship type `powers` / `monitors` | Not defined in relationship_types |
---
## 7. Database Physical Schema
```mermaid
erDiagram
lifecycle_defs ||--o{ entity_types : "lifecycle_id FK"
entity_types ||--o{ entities : "type FK"
entity_types ||--o| entity_types : "parent_type FK (self-ref)"
entities ||--o| entity_status : "dual entity (shared PK)"
entities ||--o| check_defs : "dual entity (shared PK)"
entities ||--o| signals : "dual entity (shared PK)"
entities ||--o| classifications : "dual entity (shared PK)"
entities ||--o| executions : "dual entity (shared PK)"
entities ||--o| feedback : "dual entity (shared PK)"
entities ||--o| patterns : "dual entity (shared PK)"
entities ||--o| skills : "dual entity (shared PK)"
entities ||--o| approvals : "dual entity (shared PK)"
entities ||--o| knowledge_entities : "dual entity (shared PK)"
entities ||--o{ relationships : "source_id FK"
entities ||--o{ relationships : "target_id FK"
check_defs }o--|| entities : "target_id FK"
signals }o--|| entities : "target_entity_id FK"
executions }o--|| entities : "target_entity_id FK"
approvals }o--|| entities : "subject_entity_id FK"
entity_types ||--o{ relationship_types : "source_type FK"
entity_types ||--o{ relationship_types : "target_type FK"
relationship_types ||--o{ relationships : "type FK"
entity_types ||--o{ approval_rules : "entity_type FK"
signals ||--o| check_defs : "check_id FK"
```
**Key architectural patterns:**
- **Dual entities:** `check_defs`, `signals`, `classifications`, `executions`, `feedback`, `patterns`, `skills`, `approvals`, `knowledge_entities` — all have `entity_id UUID PK REFERENCES entities(id)`. Every row is also an entity.
- **Partial unique indexes:** `relationships` (current edges), `signals` (open signals), `patterns` (per-type action) — all use `WHERE` clauses for snapshot semantics.
- **TimescaleDB hypertables:** `metric_samples`, `events`, `audit_log`, `agent_activity` — with continuous aggregates and retention policies.
- **SSE fan-out:** `pg_notify('oikos_events', ...)` trigger on `events` INSERT → Go listener fan-out → SSE connections.
---
## 8. How Nomos Queries Thermals — End-to-End Trace
```mermaid
sequenceDiagram
participant U as User
participant N as Nomos (Agent)
participant A as Oikos API
participant T as TimescaleDB
participant S as Scheduler
participant H as Hubris
Note over S,H: Autonomous collection (every 60s)
S->>H: SSH exec cpu_check.sh
H-->>S: {"metrics":{"cpu_pct":15,"cpu_temp":48}}
S->>T: INSERT metric_samples (cpu_pct, cpu_temp)
Note over U,N: User asks question
U->>N: "what are the thermals of hubris?"
N->>A: MCP query_metrics(metric=["cpu_pct","cpu_temp"])
A->>T: SELECT time_bucket('1h', ts) … FROM metric_samples
T-->>A: {avg:15, min:2, max:40} (cpu_pct)
T-->>A: {avg:48, min:42, max:85} (cpu_temp)
A-->>N: time-bucketed metrics
N-->>U: "CPU at 15%, temp 48°C — normal range"
```
**What made this possible (chronologically):**
1. `scheduler.go` refactored to return metrics map → `checkResult{metrics}`
2. `ssh-script` check kind implemented → SSH exec to remote host
3. `cpu_check.sh` deployed to hubris → returns `{"metrics":{"cpu_pct":2.5,"cpu_temp":48}}`
4. Check created: `POST /checks {"kind":"ssh-script","target":"host:hubris","config":{"host":"192.168.8.77","script":"cpu_check.sh"}}`
5. Fixed: `InsertMetricSample` missing `ts` column → `now()` literal
6. Fixed: SSH port/user parsing bugs
7. Fixed: SSH warnings polluting JSON output
8. Scheduler loop → metrics written to TimescaleDB every 60s
9. MCP `query_metrics` reads from TimescaleDB → Nomos gets live data

View File

@@ -5,7 +5,7 @@ after acceptance — superseding decisions get a new ADR that links back.
Statuses: proposed | accepted | superseded-by-NNNN.
| ADR | Title |
|---|---|
|---|---|---|
| [0001](0001-go-single-binary.md) | Go with single-binary role packaging |
| [0002](0002-postgres-timescale-only-datastore.md) | PostgreSQL + TimescaleDB as the only datastore |
| [0003](0003-db-native-ontology-yaml-seeds.md) | DB-native ontology with YAML seed manifests |
@@ -16,3 +16,7 @@ Statuses: proposed | accepted | superseded-by-NNNN.
| [0008](0008-forward-only-migrations.md) | Forward-only migrations |
| [0009](0009-sse-over-websocket.md) | SSE over WebSocket for the event stream |
| [0010](0010-infisical-with-sops-fallback.md) | Infisical secrets with SOPS DR fallback |
| [0011](0011-client-lifecycle-flows.md) | Client lifecycle flows — enrollment, bootstrap, sync |
| [0012](0012-hermes-oikos-interactions.md) | HermesOikos interactions — agent/OS contract |
| [0013](0013-signal-triggers.md) | Signal triggers — host health checks via scheduler |
| [0014](0014-entity-model.md) | Entity model — types, relationships, state machines, OODA loop |

View File

@@ -0,0 +1,204 @@
package checkdefaults
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type CheckDef struct {
Kind string
Script string
Host string
User string
Port int
Thresholds map[string]any
Extra map[string]any
}
func ResolveHost(attrs map[string]any) string {
if ip, ok := attrs["lan_ip"].(string); ok && ip != "" {
return ip
}
if mesh, ok := attrs["mesh"].(map[string]any); ok {
if nb, ok := mesh["netbird"].(map[string]any); ok {
if ip, ok := nb["ip"].(string); ok && ip != "" {
return ip
}
}
}
if ip, ok := attrs["mesh_ip"].(string); ok && ip != "" {
return ip
}
return ""
}
func resolveSSHUser(attrs map[string]any) string {
if ssh, ok := attrs["ssh"].(map[string]any); ok {
if u, ok := ssh["user"].(string); ok && u != "" {
return u
}
}
return "root"
}
func resolveSSHPort(attrs map[string]any) int {
if ssh, ok := attrs["ssh"].(map[string]any); ok {
switch p := ssh["port"].(type) {
case float64:
return int(p)
case int:
return p
}
}
return 22
}
func ForEntityType(entityType string, attrs map[string]any) []CheckDef {
host := ResolveHost(attrs)
user := resolveSSHUser(attrs)
port := resolveSSHPort(attrs)
ssh := func(script string) CheckDef {
return CheckDef{Kind: "ssh-script", Script: script, Host: host, User: user, Port: port}
}
switch entityType {
case "proxmox-host", "standalone-server":
if host == "" {
return nil
}
return []CheckDef{
{Kind: "ping", Host: host},
ssh("cpu_check.sh"),
ssh("memory_check.sh"),
ssh("load_check.sh"),
ssh("disk_usage_check.sh"),
ssh("updates_check.sh"),
}
case "workstation":
if host == "" {
return nil
}
return []CheckDef{
{Kind: "ping", Host: host},
ssh("cpu_check.sh"),
ssh("memory_check.sh"),
ssh("load_check.sh"),
}
case "lxc":
if host == "" {
return nil
}
return []CheckDef{
ssh("cpu_check.sh"),
ssh("memory_check.sh"),
ssh("load_check.sh"),
ssh("disk_usage_check.sh"),
}
case "vm":
if host == "" {
return nil
}
return []CheckDef{
{Kind: "ping", Host: host},
}
case "service":
if host == "" {
return nil
}
n, _ := attrs["name"].(string)
if n == "" {
return nil
}
return []CheckDef{
{Kind: "ssh-script", Script: "process_check.sh", Host: host, User: user, Port: port,
Extra: map[string]any{"args": n}},
}
}
return nil
}
func ShortSlug(slug string) string {
const n = 8
if len(slug) > n {
return slug[len(slug)-n:]
}
return slug
}
func DefaultInterval(kind string) int32 {
switch kind {
case "ping":
return 30
case "ssh-script":
return 60
default:
return 300
}
}
func Ensure(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType string, attrsJSON []byte) {
_, _ = tx.Exec(ctx,
`INSERT INTO entity_status (entity_id, health, updated_at)
VALUES ($1, 'unknown', now())
ON CONFLICT (entity_id) DO NOTHING`,
entityID)
var attrs map[string]any
if len(attrsJSON) > 0 {
json.Unmarshal(attrsJSON, &attrs)
}
if attrs == nil {
attrs = map[string]any{}
}
defs := ForEntityType(entityType, attrs)
if len(defs) == 0 {
return
}
for i, def := range defs {
checkID, err := uuid.NewV7()
if err != nil {
checkID = uuid.New()
}
checkSlug := fmt.Sprintf("check:%s:%s:%d", def.Kind, ShortSlug(slug), i)
_, _ = tx.Exec(ctx,
`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
VALUES ($1, $2, 'check', $2, 'active', '{}', 1, now(), now())
ON CONFLICT (slug) DO NOTHING`,
checkID, checkSlug)
configMap := map[string]any{}
if def.Script != "" {
configMap["script"] = def.Script
}
if def.Host != "" {
configMap["host"] = def.Host
}
if def.User != "" && def.User != "root" {
configMap["user"] = def.User
}
if def.Port != 0 && def.Port != 22 {
configMap["port"] = def.Port
}
if def.Thresholds != nil {
configMap["thresholds"] = def.Thresholds
}
for k, v := range def.Extra {
configMap[k] = v
}
configJSON, _ := json.Marshal(configMap)
_, _ = tx.Exec(ctx,
`INSERT INTO check_defs (entity_id, target_id, kind, config, interval_s, timeout_s, enabled)
VALUES ($1, $2, $3, $4, $5, 30, true)
ON CONFLICT (entity_id) DO NOTHING`,
checkID, entityID, def.Kind, configJSON, DefaultInterval(def.Kind))
}
}

View File

@@ -240,8 +240,8 @@ SELECT * FROM risk_classes ORDER BY name;
SELECT * FROM approval_rules ORDER BY entity_type, action;
-- name: InsertMetricSample :exec
INSERT INTO metric_samples (entity_id, metric, value, tags)
VALUES ($1, $2, $3, $4);
INSERT INTO metric_samples (entity_id, metric, value, tags, ts)
VALUES ($1, $2, $3, $4, now());
-- name: QueryMetrics :many
SELECT time_bucket(sqlc.arg('bucket_interval')::interval, ts) AS bucket,

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"github.com/dtoro/oikos/internal/checkdefaults"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
@@ -143,6 +144,8 @@ func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*
return nil, fmt.Errorf("entity_status %s: %w", slug, err)
}
checkdefaults.Ensure(ctx, tx, entityID, slug, typeName, attrsBytes)
r.Entities++
}

View File

@@ -26,6 +26,22 @@ type AgentActivity struct {
CorrelationID *string
}
type AgentMessage struct {
ID uuid.UUID
SessionID uuid.UUID
Role string
Content []byte
CreatedAt time.Time
}
type AgentSession struct {
ID uuid.UUID
Title string
Actor string
CreatedAt time.Time
LastActiveAt time.Time
}
type Approval struct {
EntityID uuid.UUID
SubjectEntityID *uuid.UUID

View File

@@ -560,8 +560,8 @@ func (q *Queries) InsertFeedback(ctx context.Context, arg InsertFeedbackParams)
}
const insertMetricSample = `-- name: InsertMetricSample :exec
INSERT INTO metric_samples (entity_id, metric, value, tags)
VALUES ($1, $2, $3, $4)
INSERT INTO metric_samples (entity_id, metric, value, tags, ts)
VALUES ($1, $2, $3, $4, now())
`
type InsertMetricSampleParams struct {

View File

@@ -0,0 +1,172 @@
package httpapi
import (
"context"
"time"
"github.com/dtoro/oikos/internal/httpapi/gen"
)
// GetDashboardSummary returns one round-trip overview for the control room
// home page: entity counts, health rollup, open signals, pending approvals,
// executions in the last 24h, and an event-rate sparkline.
func (s *Server) GetDashboardSummary(ctx context.Context, req gen.GetDashboardSummaryRequestObject) (gen.GetDashboardSummaryResponseObject, error) {
resp := gen.DashboardSummary{
EntitiesByType: map[string]int{},
EntitiesByState: map[string]int{},
SignalsBySeverity: map[string]int{},
ExecutionsByState: map[string]int{},
}
rows, err := s.pool.Query(ctx, `SELECT type, count(*) FROM entities GROUP BY type`)
if err != nil {
return nil, err
}
for rows.Next() {
var typ string
var n int
if err := rows.Scan(&typ, &n); err != nil {
rows.Close()
return nil, err
}
resp.EntitiesByType[typ] = n
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
rows, err = s.pool.Query(ctx, `SELECT coalesce(state, 'unknown'), count(*) FROM entities GROUP BY state`)
if err != nil {
return nil, err
}
for rows.Next() {
var state string
var n int
if err := rows.Scan(&state, &n); err != nil {
rows.Close()
return nil, err
}
resp.EntitiesByState[state] = n
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
// 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
if err := rows.Scan(&health, &n); err != nil {
rows.Close()
return nil, err
}
switch health {
case "healthy":
resp.Health.Healthy = n
case "degraded":
resp.Health.Degraded = n
case "down":
resp.Health.Down = n
case "stale":
stale = n
default:
resp.Health.Unknown = n
}
}
rows.Close()
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
WHERE state NOT IN ('resolved', 'failed')
GROUP BY severity`)
if err != nil {
return nil, err
}
for rows.Next() {
var severity string
var n int
if err := rows.Scan(&severity, &n); err != nil {
rows.Close()
return nil, err
}
resp.SignalsBySeverity[severity] = n
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
if err := s.pool.QueryRow(ctx,
`SELECT count(*) FROM approvals WHERE status = 'pending'`,
).Scan(&resp.ApprovalsPending); err != nil {
return nil, err
}
rows, err = s.pool.Query(ctx, `
SELECT status, count(*) FROM executions
WHERE created_at > now() - interval '24 hours'
GROUP BY status`)
if err != nil {
return nil, err
}
for rows.Next() {
var status string
var n int
if err := rows.Scan(&status, &n); err != nil {
rows.Close()
return nil, err
}
resp.ExecutionsByState[status] = n
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
rows, err = s.pool.Query(ctx, `
SELECT date_trunc('hour', ts) + (extract(minute FROM ts)::int / 5) * interval '5 minutes' AS bucket,
count(*)
FROM events
WHERE ts > now() - interval '6 hours'
GROUP BY bucket
ORDER BY bucket`)
if err != nil {
return nil, err
}
for rows.Next() {
var bucket time.Time
var n int
if err := rows.Scan(&bucket, &n); err != nil {
rows.Close()
return nil, err
}
resp.EventRate = append(resp.EventRate, struct {
Bucket time.Time `json:"bucket"`
Count int `json:"count"`
}{Bucket: bucket, Count: n})
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
return gen.GetDashboardSummary200JSONResponse(resp), nil
}

View File

@@ -0,0 +1,13 @@
package httpapi
import (
"context"
"github.com/dtoro/oikos/internal/checkdefaults"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func ensureDefaultChecks(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType string, attrsJSON []byte) {
checkdefaults.Ensure(ctx, tx, entityID, slug, entityType, attrsJSON)
}

View File

@@ -81,6 +81,7 @@ const (
CheckKindDisk CheckKind = "disk"
CheckKindDrift CheckKind = "drift"
CheckKindHttp CheckKind = "http"
CheckKindPing CheckKind = "ping"
CheckKindSshScript CheckKind = "ssh-script"
CheckKindTcp CheckKind = "tcp"
)
@@ -91,6 +92,7 @@ const (
CheckCreateKindDisk CheckCreateKind = "disk"
CheckCreateKindDrift CheckCreateKind = "drift"
CheckCreateKindHttp CheckCreateKind = "http"
CheckCreateKindPing CheckCreateKind = "ping"
CheckCreateKindSshScript CheckCreateKind = "ssh-script"
CheckCreateKindTcp CheckCreateKind = "tcp"
)
@@ -102,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"
@@ -153,11 +164,21 @@ const (
ExecutionStatusVerifying ExecutionStatus = "verifying"
)
// Defines values for GraphViewHealth.
const (
GraphViewHealthDegraded GraphViewHealth = "degraded"
GraphViewHealthDown GraphViewHealth = "down"
GraphViewHealthHealthy GraphViewHealth = "healthy"
GraphViewHealthStale GraphViewHealth = "stale"
GraphViewHealthUnknown GraphViewHealth = "unknown"
)
// Defines values for HealthSummaryEntitiesHealth.
const (
HealthSummaryEntitiesHealthDegraded HealthSummaryEntitiesHealth = "degraded"
HealthSummaryEntitiesHealthDown HealthSummaryEntitiesHealth = "down"
HealthSummaryEntitiesHealthHealthy HealthSummaryEntitiesHealth = "healthy"
HealthSummaryEntitiesHealthStale HealthSummaryEntitiesHealth = "stale"
HealthSummaryEntitiesHealthUnknown HealthSummaryEntitiesHealth = "unknown"
)
@@ -295,6 +316,11 @@ const (
Out GetEntityRelationsParamsDirection = "out"
)
// Defines values for GetGraphParamsInclude.
const (
Status GetGraphParamsInclude = "status"
)
// Defines values for QueryMetricsParamsRollup.
const (
QueryMetricsParamsRollupAuto QueryMetricsParamsRollup = "auto"
@@ -520,6 +546,38 @@ type ClientSecrets struct {
Keys []string `json:"keys"`
}
// DashboardSummary defines model for DashboardSummary.
type DashboardSummary struct {
ApprovalsPending int `json:"approvals_pending"`
// EntitiesByState entity counts keyed by state
EntitiesByState map[string]int `json:"entities_by_state"`
// EntitiesByType entity counts keyed by type
EntitiesByType map[string]int `json:"entities_by_type"`
// EventRate event counts bucketed by 5-minute interval, most recent last
EventRate []struct {
Bucket time.Time `json:"bucket"`
Count int `json:"count"`
} `json:"event_rate"`
// ExecutionsByState execution counts keyed by state, last 24h
ExecutionsByState map[string]int `json:"executions_by_state"`
Health struct {
Degraded int `json:"degraded"`
Down int `json:"down"`
Healthy int `json:"healthy"`
// 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
SignalsBySeverity map[string]int `json:"signals_by_severity"`
}
// EnrollRequest defines model for EnrollRequest.
type EnrollRequest struct {
// Hostname Actual hostname of the enrolling machine
@@ -552,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
@@ -696,12 +763,18 @@ type ExecutionRequest struct {
// GraphView defines model for GraphView.
type GraphView struct {
Edges []Relationship `json:"edges"`
Nodes []Entity `json:"nodes"`
// Health entity id -> health, present when include=status was requested
Health *map[string]GraphViewHealth `json:"health,omitempty"`
Nodes []Entity `json:"nodes"`
// Truncated True if node cap was hit
Truncated *bool `json:"truncated,omitempty"`
}
// GraphViewHealth defines model for GraphView.Health.
type GraphViewHealth string
// HealthSummary defines model for HealthSummary.
type HealthSummary struct {
Entities []struct {
@@ -715,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"`
}
@@ -1234,8 +1310,14 @@ type GetGraphParams struct {
Root *string `form:"root,omitempty" json:"root,omitempty"`
Depth *int `form:"depth,omitempty" json:"depth,omitempty"`
RelType *[]string `form:"rel_type,omitempty" json:"rel_type,omitempty"`
// Include include=status joins entity_status and populates GraphView.health
Include *[]GetGraphParamsInclude `form:"include,omitempty" json:"include,omitempty"`
}
// GetGraphParamsInclude defines parameters for GetGraph.
type GetGraphParamsInclude string
// SearchKnowledgeParams defines parameters for SearchKnowledge.
type SearchKnowledgeParams struct {
Q string `form:"q" json:"q"`
@@ -1469,6 +1551,9 @@ type ServerInterface interface {
// List secrets accessible to this client
// (GET /clients/{slug}/secrets)
GetClientSecrets(w http.ResponseWriter, r *http.Request, slug EntitySlug)
// One-round-trip overview for the control room home page
// (GET /dashboard/summary)
GetDashboardSummary(w http.ResponseWriter, r *http.Request)
// List entities
// (GET /entities)
ListEntities(w http.ResponseWriter, r *http.Request, params ListEntitiesParams)
@@ -1664,6 +1749,12 @@ func (_ Unimplemented) GetClientSecrets(w http.ResponseWriter, r *http.Request,
w.WriteHeader(http.StatusNotImplemented)
}
// One-round-trip overview for the control room home page
// (GET /dashboard/summary)
func (_ Unimplemented) GetDashboardSummary(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
// List entities
// (GET /entities)
func (_ Unimplemented) ListEntities(w http.ResponseWriter, r *http.Request, params ListEntitiesParams) {
@@ -2537,6 +2628,26 @@ func (siw *ServerInterfaceWrapper) GetClientSecrets(w http.ResponseWriter, r *ht
handler.ServeHTTP(w, r)
}
// GetDashboardSummary operation middleware
func (siw *ServerInterfaceWrapper) GetDashboardSummary(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetDashboardSummary(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// ListEntities operation middleware
func (siw *ServerInterfaceWrapper) ListEntities(w http.ResponseWriter, r *http.Request) {
@@ -3305,6 +3416,14 @@ func (siw *ServerInterfaceWrapper) GetGraph(w http.ResponseWriter, r *http.Reque
return
}
// ------------- Optional query parameter "include" -------------
err = runtime.BindQueryParameter("form", true, false, "include", r.URL.Query(), &params.Include)
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "include", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetGraph(w, r, params)
}))
@@ -4547,6 +4666,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/clients/{slug}/secrets", wrapper.GetClientSecrets)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/dashboard/summary", wrapper.GetDashboardSummary)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/entities", wrapper.ListEntities)
})
@@ -5020,6 +5142,34 @@ func (response GetClientSecretsdefaultApplicationProblemPlusJSONResponse) VisitG
return json.NewEncoder(w).Encode(response.Body)
}
type GetDashboardSummaryRequestObject struct {
}
type GetDashboardSummaryResponseObject interface {
VisitGetDashboardSummaryResponse(w http.ResponseWriter) error
}
type GetDashboardSummary200JSONResponse DashboardSummary
func (response GetDashboardSummary200JSONResponse) VisitGetDashboardSummaryResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type GetDashboardSummarydefaultApplicationProblemPlusJSONResponse struct {
Body Problem
StatusCode int
}
func (response GetDashboardSummarydefaultApplicationProblemPlusJSONResponse) VisitGetDashboardSummaryResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type ListEntitiesRequestObject struct {
Params ListEntitiesParams
}
@@ -6401,6 +6551,9 @@ type StrictServerInterface interface {
// List secrets accessible to this client
// (GET /clients/{slug}/secrets)
GetClientSecrets(ctx context.Context, request GetClientSecretsRequestObject) (GetClientSecretsResponseObject, error)
// One-round-trip overview for the control room home page
// (GET /dashboard/summary)
GetDashboardSummary(ctx context.Context, request GetDashboardSummaryRequestObject) (GetDashboardSummaryResponseObject, error)
// List entities
// (GET /entities)
ListEntities(ctx context.Context, request ListEntitiesRequestObject) (ListEntitiesResponseObject, error)
@@ -6870,6 +7023,30 @@ func (sh *strictHandler) GetClientSecrets(w http.ResponseWriter, r *http.Request
}
}
// GetDashboardSummary operation middleware
func (sh *strictHandler) GetDashboardSummary(w http.ResponseWriter, r *http.Request) {
var request GetDashboardSummaryRequestObject
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.GetDashboardSummary(ctx, request.(GetDashboardSummaryRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetDashboardSummary")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(GetDashboardSummaryResponseObject); ok {
if err := validResponse.VisitGetDashboardSummaryResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// ListEntities operation middleware
func (sh *strictHandler) ListEntities(w http.ResponseWriter, r *http.Request, params ListEntitiesParams) {
var request ListEntitiesRequestObject
@@ -8031,166 +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]
@@ -322,9 +343,44 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
if truncated {
resp.Truncated = &truncated
}
if req.Params.Include != nil {
for _, inc := range *req.Params.Include {
if inc == gen.Status {
health, herr := s.entityHealthByID(ctx, ids)
if herr != nil {
return nil, herr
}
resp.Health = &health
break
}
}
}
return resp, nil
}
// entityHealthByID returns entity_status.health keyed by entity id, for the
// given id set (used by GetGraph's include=status).
func (s *Server) entityHealthByID(ctx context.Context, ids []uuid.UUID) (map[string]gen.GraphViewHealth, error) {
health := make(map[string]gen.GraphViewHealth, len(ids))
rows, err := s.pool.Query(ctx,
`SELECT entity_id, health FROM entity_status WHERE entity_id = ANY($1)`, ids)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id uuid.UUID
var h string
if err := rows.Scan(&id, &h); err != nil {
return nil, err
}
health[id.String()] = gen.GraphViewHealth(h)
}
return health, rows.Err()
}
func (s *Server) queryEntities(ctx context.Context, query string, args ...any) ([]gen.Entity, error) {
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
@@ -488,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
@@ -509,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++
}
@@ -525,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()
}
@@ -920,6 +985,8 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
return nil, eventErr
}
ensureDefaultChecks(ctx, tx, inserted.ID, slug, req.Body.Type, attrsJSON)
if err := tx.Commit(ctx); err != nil {
return nil, err
}
@@ -1184,6 +1251,8 @@ func (s *Server) EnrollClient(ctx context.Context, req gen.EnrollClientRequestOb
"info", "oikos-api", "",
map[string]any{"slug": req.Body.Slug, "type": current.Type})
ensureDefaultChecks(ctx, tx, id, req.Body.Slug, current.Type, attrsJSON)
if err := tx.Commit(ctx); err != nil {
return nil, err
}

View File

@@ -317,7 +317,7 @@ func (s *Server) CreateCheck(ctx context.Context, req gen.CreateCheckRequestObje
entity, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
ID: id,
Slug: slug,
Type: "check_def",
Type: "check",
Name: slug,
Attributes: []byte("{}"),
})
@@ -1835,10 +1835,6 @@ func (s *Server) QueryMetrics(ctx context.Context, req gen.QueryMetricsRequestOb
if req.Params.EntityId == nil || *req.Params.EntityId == "" {
return nil, fmt.Errorf("%w: entity_id is required", domain.ErrInvalidInput)
}
if req.Params.Metric == nil || len(*req.Params.Metric) == 0 {
return nil, fmt.Errorf("%w: metric is required", domain.ErrInvalidInput)
}
entityID, err := s.resolveEntityID(ctx, *req.Params.EntityId)
if err != nil {
return nil, err
@@ -1853,8 +1849,33 @@ func (s *Server) QueryMetrics(ctx context.Context, req gen.QueryMetricsRequestOb
to = *req.Params.To
}
var metricNames []string
if req.Params.Metric != nil && len(*req.Params.Metric) > 0 {
metricNames = *req.Params.Metric
} else {
// metric omitted: report every metric recorded for this entity in range.
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT metric FROM metric_samples
WHERE entity_id = $1 AND ts >= $2 AND ts <= $3
ORDER BY metric`, entityID, from, to)
if err != nil {
return nil, err
}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
rows.Close()
return nil, err
}
metricNames = append(metricNames, name)
}
if err := rows.Err(); err != nil {
return nil, err
}
}
items := []gen.MetricSeries{}
for _, metricName := range *req.Params.Metric {
for _, metricName := range metricNames {
series := gen.MetricSeries{
EntityId: entityID.String(),
Metric: metricName,

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

@@ -7,10 +7,15 @@ import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os/exec"
"regexp"
"runtime"
"strconv"
"time"
"github.com/dtoro/oikos/internal/config"
@@ -22,6 +27,11 @@ import (
"golang.org/x/sys/unix"
)
var (
sshKeyPath string
sshUser string
)
// Run starts the scheduler loop. Blocks until ctx is cancelled.
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
slog.Info("scheduler: starting", "interval", cfg.SchedulerInterval)
@@ -30,6 +40,12 @@ func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
interval = 30 * time.Second
}
sshKeyPath = cfg.SSHKeyPath
sshUser = cfg.SSHUser
if sshUser == "" {
sshUser = "root"
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
@@ -78,85 +94,94 @@ 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()
health, signalKind, evidence, checkErr := executeCheck(ctx, cd)
result := executeCheck(ctx, cd)
latency := time.Since(start).Milliseconds()
// Write metric
_ = q.InsertMetricSample(ctx, sqlcgen.InsertMetricSampleParams{
EntityID: cd.EntityID,
Metric: "probe_latency_ms",
Value: float64(latency),
Tags: []byte(`{}`),
})
if result.metrics == nil {
result.metrics = make(map[string]float64)
}
result.metrics["probe_latency_ms"] = float64(latency)
if checkErr != nil {
slog.Warn("scheduler: check failed",
"entity", cd.EntitySlug, "kind", cd.Kind, "error", checkErr)
targetID := cd.EntityID
if cd.TargetID != nil {
targetID = *cd.TargetID
}
prevHealth := currentHealth(ctx, pool, cd.EntityID)
for metric, value := range result.metrics {
_ = q.InsertMetricSample(ctx, sqlcgen.InsertMetricSampleParams{
EntityID: targetID,
Metric: metric,
Value: value,
Tags: []byte(`{}`),
})
}
if signalKind == "" || health == "healthy" {
// Recovery: resolve any open signal for this check
resolveSignal(ctx, pool, cd.EntityID, cd.EntitySlug)
// Update entity_status to healthy
if result.err != nil {
slog.Warn("scheduler: check failed",
"entity", cd.EntitySlug, "kind", cd.Kind, "error", result.err)
}
prevHealth := currentHealth(ctx, pool, targetID)
if result.signalKind == "" || result.health == "healthy" {
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
}
// Failure: upsert signal (dedup via partial unique index)
slog.Warn("scheduler: raising signal",
"entity", cd.EntitySlug, "kind", signalKind, "evidence", evidence)
"entity", cd.EntitySlug, "kind", result.signalKind, "evidence", result.evidence)
severity := "warning"
if signalKind == "down" {
severity = "critical"
}
severity := evaluateSeverity(cd.Kind, result.signalKind, cd.Config, result.metrics)
sig, err := q.UpsertSignal(ctx, sqlcgen.UpsertSignalParams{
EntityID: cd.EntityID,
Kind: signalKind,
Kind: result.signalKind,
Severity: severity,
TargetEntityID: cd.TargetID,
Evidence: &evidence,
Evidence: &result.evidence,
})
if err != nil {
slog.Error("scheduler: upsert signal", "error", err)
return
}
// Update entity_status
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
EntityID: cd.EntityID,
Health: health,
EntityID: targetID,
Health: result.health,
LastCheckAt: &[]time.Time{time.Now()}[0],
Details: []byte(`{}`),
})
_ = sig // used for flap detection below
_ = sig
// Emit only on transition into failure so a persistently-down entity
// doesn't flood the stream every tick.
if prevHealth == "" || prevHealth == "healthy" {
emitSchedulerEvent(ctx, pool, "signal.raised", cd.EntityID, severity,
map[string]any{"slug": cd.EntitySlug, "kind": signalKind, "evidence": evidence})
emitSchedulerEvent(ctx, pool, "signal.raised", targetID, severity,
map[string]any{"slug": cd.EntitySlug, "kind": result.signalKind, "evidence": result.evidence})
}
if prevHealth != health {
emitSchedulerEvent(ctx, pool, "health.changed", cd.EntityID, severity,
map[string]any{"slug": cd.EntitySlug, "from": prevHealth, "to": health})
if prevHealth != result.health {
emitSchedulerEvent(ctx, pool, "health.changed", targetID, severity,
map[string]any{"slug": cd.EntitySlug, "from": prevHealth, "to": result.health})
}
}
@@ -175,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(`{}`),
@@ -197,8 +224,17 @@ func resolveSignal(ctx context.Context, pool *db.Pool, entityID uuid.UUID, slug
slog.Info("scheduler: signal resolved", "entity", slug)
}
// checkResult bundles the outcome of a single check execution.
type checkResult struct {
health string
signalKind string
evidence string
metrics map[string]float64
err error
}
// executeCheck dispatches to the appropriate checker by kind.
func executeCheck(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (health string, signalKind string, evidence string, err error) {
func executeCheck(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
switch cd.Kind {
case "http":
return checkHTTP(ctx, cd)
@@ -208,8 +244,12 @@ func executeCheck(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (heal
return checkDisk(ctx, cd)
case "cert-expiry":
return checkCertExpiry(ctx, cd)
case "ping":
return checkPing(ctx, cd)
case "ssh-script":
return checkSSHScript(ctx, cd)
default:
return "unknown", "", "", nil
return checkResult{health: "unknown"}
}
}
@@ -224,12 +264,76 @@ 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) (string, string, string, error) {
func checkHTTP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
cfg := struct {
URL string `json:"url"`
ExpectedStatus int `json:"expected_status"`
@@ -241,7 +345,7 @@ func checkHTTP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string,
_ = json.Unmarshal(cd.Config, &cfg)
}
if cfg.URL == "" {
return "healthy", "", "", nil
return checkResult{health: "healthy"}
}
timeout := time.Duration(cd.TimeoutS) * time.Second
@@ -258,25 +362,35 @@ func checkHTTP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string,
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.URL, nil)
if err != nil {
return "down", "http", fmt.Sprintf("invalid URL %q: %v", cfg.URL, err), err
return checkResult{
health: "down", signalKind: "http",
evidence: fmt.Sprintf("invalid URL %q: %v", cfg.URL, err),
err: err,
}
}
resp, err := client.Do(req)
if err != nil {
return "down", "http", fmt.Sprintf("GET %s: %v", cfg.URL, err), err
return checkResult{
health: "down", signalKind: "http",
evidence: fmt.Sprintf("GET %s: %v", cfg.URL, err),
err: err,
}
}
defer resp.Body.Close()
if resp.StatusCode != cfg.ExpectedStatus {
return "degraded", "http",
fmt.Sprintf("GET %s returned %d (expected %d)", cfg.URL, resp.StatusCode, cfg.ExpectedStatus), nil
return checkResult{
health: "degraded", signalKind: "http",
evidence: fmt.Sprintf("GET %s returned %d (expected %d)", cfg.URL, resp.StatusCode, cfg.ExpectedStatus),
}
}
return "healthy", "", "", nil
return checkResult{health: "healthy"}
}
// checkTCP performs a TCP dial check.
func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string, string, string, error) {
func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
cfg := struct {
Host string `json:"host"`
Port int `json:"port"`
@@ -285,7 +399,7 @@ func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string,
_ = json.Unmarshal(cd.Config, &cfg)
}
if cfg.Host == "" || cfg.Port == 0 {
return "healthy", "", "", nil
return checkResult{health: "healthy"}
}
timeout := time.Duration(cd.TimeoutS) * time.Second
@@ -296,14 +410,18 @@ func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string,
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return "down", "tcp", fmt.Sprintf("dial %s: %v", addr, err), err
return checkResult{
health: "down", signalKind: "tcp",
evidence: fmt.Sprintf("dial %s: %v", addr, err),
err: err,
}
}
conn.Close()
return "healthy", "", "", nil
return checkResult{health: "healthy"}
}
// checkDisk performs a disk usage check via local or SSH.
func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string, string, string, error) {
// checkDisk performs a disk usage check.
func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
cfg := struct {
Path string `json:"path"`
ThresholdPct int `json:"threshold_pct"`
@@ -315,29 +433,45 @@ func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string,
_ = json.Unmarshal(cd.Config, &cfg)
}
// Use unix.Statfs for disk usage.
var stat unix.Statfs_t
if err := unix.Statfs(cfg.Path, &stat); err != nil {
return "down", "disk", fmt.Sprintf("statfs %s: %v", cfg.Path, err), err
return checkResult{
health: "down", signalKind: "disk",
evidence: fmt.Sprintf("statfs %s: %v", cfg.Path, err),
err: err,
}
}
total := stat.Blocks * uint64(stat.Bsize)
free := stat.Bfree * uint64(stat.Bsize)
if total == 0 {
return "healthy", "", "", nil
return checkResult{health: "healthy"}
}
usedPct := float64(total-free) / float64(total) * 100
if usedPct > float64(cfg.ThresholdPct) {
return "degraded", "disk",
fmt.Sprintf("%s %.1f%% full (threshold %d%%)", cfg.Path, usedPct, cfg.ThresholdPct), nil
inodePct := 0.0
if stat.Files > 0 {
inodePct = float64(stat.Files-stat.Ffree) / float64(stat.Files) * 100
}
return "healthy", "", "", nil
metrics := map[string]float64{
"disk_used_pct": usedPct,
"disk_inode_pct": inodePct,
}
if usedPct > float64(cfg.ThresholdPct) {
return checkResult{
health: "degraded", signalKind: "disk",
evidence: fmt.Sprintf("%s %.1f%% full (threshold %d%%)", cfg.Path, usedPct, cfg.ThresholdPct),
metrics: metrics,
}
}
return checkResult{health: "healthy", metrics: metrics}
}
// checkCertExpiry checks TLS certificate expiry.
func checkCertExpiry(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string, string, string, error) {
func checkCertExpiry(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
cfg := struct {
Host string `json:"host"`
Port int `json:"port"`
@@ -352,7 +486,7 @@ func checkCertExpiry(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (s
_ = json.Unmarshal(cd.Config, &cfg)
}
if cfg.Host == "" {
return "healthy", "", "", nil
return checkResult{health: "healthy"}
}
timeout := time.Duration(cd.TimeoutS) * time.Second
@@ -365,31 +499,264 @@ func checkCertExpiry(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (s
d := tls.Dialer{Config: &tls.Config{InsecureSkipVerify: true}}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return "down", "cert-expiry", fmt.Sprintf("TLS dial %s: %v", addr, err), err
return checkResult{
health: "down", signalKind: "cert-expiry",
evidence: fmt.Sprintf("TLS dial %s: %v", addr, err),
err: err,
}
}
defer conn.Close()
tlsConn := conn.(*tls.Conn)
// Use crypto/tls ConnectionState to get verified chains
cs := tlsConn.ConnectionState()
if len(cs.PeerCertificates) == 0 {
return "down", "cert-expiry", "no peer certificates", nil
return checkResult{
health: "down", signalKind: "cert-expiry",
evidence: "no peer certificates",
}
}
cert := cs.PeerCertificates[0]
daysLeft := int(time.Until(cert.NotAfter).Hours() / 24)
if daysLeft <= cfg.CritDays {
return "down", "cert-expiry",
fmt.Sprintf("%s expires in %d days (crit=%d)", cfg.Host, daysLeft, cfg.CritDays), nil
}
if daysLeft <= cfg.WarnDays {
return "degraded", "cert-expiry",
fmt.Sprintf("%s expires in %d days (warn=%d)", cfg.Host, daysLeft, cfg.WarnDays), nil
metrics := map[string]float64{
"cert_days_left": float64(daysLeft),
}
return "healthy", "", "", nil
if daysLeft <= cfg.CritDays {
return checkResult{
health: "down", signalKind: "cert-expiry",
evidence: fmt.Sprintf("%s expires in %d days (crit=%d)", cfg.Host, daysLeft, cfg.CritDays),
metrics: metrics,
}
}
if daysLeft <= cfg.WarnDays {
return checkResult{
health: "degraded", signalKind: "cert-expiry",
evidence: fmt.Sprintf("%s expires in %d days (warn=%d)", cfg.Host, daysLeft, cfg.WarnDays),
metrics: metrics,
}
}
return checkResult{health: "healthy", metrics: metrics}
}
// checkPing performs an ICMP ping check using the system ping command.
func checkPing(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
cfg := struct {
Host string `json:"host"`
Count int `json:"count"`
}{}
if len(cd.Config) > 0 {
_ = json.Unmarshal(cd.Config, &cfg)
}
if cfg.Host == "" {
return checkResult{health: "healthy"}
}
if cfg.Count <= 0 {
cfg.Count = 1
}
timeout := time.Duration(cd.TimeoutS) * time.Second
if timeout <= 0 {
timeout = 10 * time.Second
}
deadline := time.Duration(cfg.Count+1) * timeout
ctx, cancel := context.WithTimeout(ctx, deadline)
defer cancel()
countStr := strconv.Itoa(cfg.Count)
timeoutSec := strconv.Itoa(int(timeout.Seconds()))
if timeoutSec == "0" {
timeoutSec = "1"
}
cmd := exec.CommandContext(ctx, "ping", "-c", countStr, "-W", timeoutSec, cfg.Host)
if runtime.GOOS == "darwin" {
cmd = exec.CommandContext(ctx, "ping", "-c", countStr, "-t", timeoutSec, cfg.Host)
}
output, err := cmd.Output()
if err != nil {
return checkResult{
health: "down", signalKind: "ping",
evidence: fmt.Sprintf("ping %s: %v", cfg.Host, err),
err: err,
}
}
latency := parsePingLatency(output)
metrics := map[string]float64{}
if latency > 0 {
metrics["ping_latency_ms"] = latency
}
return checkResult{health: "healthy", metrics: metrics}
}
var pingRttRe = regexp.MustCompile(`(?:rtt\s+min\/avg\/max\/mdev|round-trip\s+min\/avg\/max\/stddev)\s*=\s*[\d.]+\/([\d.]+)\/`)
func parsePingLatency(output []byte) float64 {
matches := pingRttRe.FindSubmatch(output)
if len(matches) < 2 {
return 0
}
val, err := strconv.ParseFloat(string(matches[1]), 64)
if err != nil {
return 0
}
return val
}
// checkSSHScript executes an allowlisted script on a remote host via SSH.
func checkSSHScript(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
cfg := struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Script string `json:"script"`
}{}
if len(cd.Config) > 0 {
_ = json.Unmarshal(cd.Config, &cfg)
}
if cfg.Host == "" || cfg.Script == "" {
return checkResult{health: "healthy"}
}
if cfg.Port == 0 {
cfg.Port = 22
}
if cfg.User == "" {
cfg.User = sshUser
}
if !allowlistedScript(cfg.Script) {
return checkResult{
health: "unknown", signalKind: "ssh-script",
evidence: fmt.Sprintf("script %q not allowlisted", cfg.Script),
}
}
timeout := time.Duration(cd.TimeoutS) * time.Second
if timeout <= 0 {
timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
scriptPath := "/opt/oikos/checks/" + cfg.Script
port := strconv.Itoa(cfg.Port)
output, err := sshExec(ctx, cfg.Host, port, cfg.User, scriptPath, timeout)
if err != nil {
return checkResult{
health: "down", signalKind: "ssh-script",
evidence: fmt.Sprintf("ssh %s:%s %s: %v", cfg.Host, strconv.Itoa(cfg.Port), cfg.Script, err),
err: err,
}
}
type scriptOutput struct {
Health string `json:"health"`
SignalKind string `json:"signalKind"`
Evidence string `json:"evidence"`
Metrics map[string]float64 `json:"metrics"`
}
var so scriptOutput
if err := json.Unmarshal(output, &so); err != nil {
return checkResult{
health: "down", signalKind: "ssh-script",
evidence: fmt.Sprintf("invalid script output from %s: %v", cfg.Script, err),
err: err,
}
}
health := so.Health
if health == "" {
health = "healthy"
}
metrics := so.Metrics
if metrics == nil {
metrics = make(map[string]float64)
}
return checkResult{
health: health,
signalKind: so.SignalKind,
evidence: so.Evidence,
metrics: metrics,
}
}
var scriptNameRe = regexp.MustCompile(`^[a-z][a-z0-9_-]+\.sh$`)
func allowlistedScript(name string) bool {
return scriptNameRe.MatchString(name)
}
func sshExec(ctx context.Context, host, port, user, cmd string, timeout time.Duration) ([]byte, error) {
args := []string{
"-o", "ConnectTimeout=" + strconv.Itoa(int(timeout.Seconds())),
"-o", "StrictHostKeyChecking=no",
"-o", "BatchMode=yes",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
}
if sshKeyPath != "" {
args = append(args, "-i", sshKeyPath)
}
if port != "" && port != "22" {
args = append(args, "-p", port)
}
args = append(args, "-l", user, host, cmd)
c := exec.CommandContext(ctx, "ssh", args...)
out, err := c.Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return nil, fmt.Errorf("ssh %s: %v (stderr: %s)", host, err, string(ee.Stderr))
}
return nil, fmt.Errorf("ssh %s: %v", host, err)
}
return out, nil
}
// metricThreshold defines warn/crit thresholds for a single metric.
type metricThreshold struct {
Warn float64 `json:"warn"`
Crit float64 `json:"crit"`
}
// thresholdsConfig is parsed from check_defs.config.thresholds JSONB.
type thresholdsConfig map[string]metricThreshold
// evaluateSeverity determines signal severity from check result and thresholds.
func evaluateSeverity(kind string, signalKind string, config []byte, metrics map[string]float64) string {
var thresholds thresholdsConfig
if len(config) > 0 {
_ = json.Unmarshal(config, &thresholds)
}
for metric, value := range metrics {
t, ok := thresholds[metric]
if !ok {
continue
}
if t.Crit > 0 && value >= t.Crit {
return "critical"
}
if t.Warn > 0 && value >= t.Warn {
return "warning"
}
}
if signalKind == "down" {
return "critical"
}
return "warning"
}
var _ = uuid.UUID{} // ensure uuid import stays

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

@@ -1,6 +1,20 @@
# 2026-07-08 — Control room web UI
**Status:** Planned
**Status:** In Progress — N0-N3 (Nomos amendment: chat home + sessions), M1
(dashboard/summary, Overview, Entities table, live event feed, shadcn-svelte
component system), M2 (Operations ledger with approve/deny + cancel, Signals
page with ack/resolve/mute, live nav badges), and M3 (graph explorer with
`include=status` health coloring, per-relationship-type edge coloring +
legend/filter, node-type filter, node search/highlight, entity detail page
with uPlot metric charts, `#/entity/:slug` route) complete 2026-07-08. Event
gap-fill (M2's
other half) landed earlier in commit e8e230b, and approval creation's FK bug
(gaps-plan A1) was already fixed, unblocking M2. While building M3, also
fixed `GET /metrics` to make the `metric` query param genuinely optional
(server now reports every metric recorded for the entity in range) — the
implementation previously 400'd when it was omitted, contradicting its own
documented-optional spec. M4 (agent activity, knowledge search page, audit,
correlation grouping, polish) remains.
## Goal
@@ -31,8 +45,18 @@ Dependencies kept minimal:
- `d3-force` — graph physics only; render SVG/canvas by hand
- `uPlot` — ~45 KB canvas time-series, ideal for `/metrics` rollups
- `openapi-typescript` — dev-only, generates `api-types.d.ts`
- No SvelteKit (no SSR wanted — the Go binary is the server), no component
framework; hash router; hand-rolled dark-theme CSS.
- No SvelteKit (no SSR wanted — the Go binary is the server); hash router.
*Amendment 2026-07-08 (M1):* component library is
[shadcn-svelte](https://www.shadcn-svelte.com/) over Tailwind CSS v4
(`@tailwindcss/vite`), not hand-rolled CSS — Table, Card, Badge, Sidebar,
Sheet, Select, Input, Button, Tabs, ScrollArea, Tooltip, Dialog,
Dropdown-menu, Sonner installed via `npx shadcn-svelte add`. The existing
dark GitHub-style palette (`app.css`) was ported into shadcn's CSS-variable
theme contract (`--background`, `--card`, `--primary`, etc. under
`@theme inline`) so old and new components share one palette. `d3-force` /
`uPlot` remain the plan for the graph/charts milestones (M3), unaffected by
this change.
Build integration: commit a placeholder `web/dist/index.html` so backend-only
`go build` never breaks; `make ui` runs the Vite build; add a node stage to

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,387 @@
# 2026-07-08 — Signal triggers: host health checks
**Status:** Implemented (Phases 1-5 complete)
## Goal
Add a comprehensive set of signal triggers for host-level monitoring —
network reachability, CPU/memory/disk pressure, thermal state, pending
updates, disk health, ZFS pool status, and process liveness. Every check
raises properly deduped signals through the existing `UpsertSignal` path
and feeds the OODA pipeline (observe → classify → decide → act).
---
## 1. New check kinds
| Kind | What it measures | Signal kind | Severity mapping |
|------|-----------------|-------------|------------------|
| `ping` | ICMP reachability + RTT | `ping-unreachable` | down → critical |
| `cpu` | Usage % + thermal (Linux only) | `cpu-pressure`, `cpu-thermal` | >90% → warning, >95% → critical; temp >85°C → critical |
| `memory` | RAM usage % | `memory-pressure` | >90% → warning, >95% → critical |
| `load` | Load avg / CPU count | `load-pressure` | >CPU×2 → warning, >CPU×4 → critical |
| `swap` | Swap usage % | `swap-pressure` | >50% → warning, >80% → critical |
| `disk-usage` | Already exists as `disk` — enhance with inode %, per-mountpoint | `disk-full` | >85% → warning, >95% → critical |
| `disk-smart` | SMART pre-failure attributes | `disk-smart-fail` | any fail → critical |
| `updates` | Pending apt updates (security, critical) | `updates-pending` | security >0 → warning, critical-reboot >0 → critical |
| `zfs` | Pool health + scrub status | `zfs-degraded`, `zfs-scrub-overdue` | degraded → critical, scrub >30d → warning |
| `process` | Process/service running | `process-down` | not running → critical |
| `uptime` | Detect unexpected reboots | `uptime-bounce` | < previous → warning |
---
## 2. Execution model
Two tiers based on where the check runs:
### Tier A: Local (scheduler host)
`ping`, `http`, `tcp`, `cert-expiry` — run directly from the scheduler process. Already implemented for `http`/`tcp`/`disk`/`cert-expiry`. Add `ping` here.
### Tier B: Remote via SSH (`ssh-script`)
`cpu`, `memory`, `load`, `swap`, `disk-smart`, `updates`, `zfs`, `process`, `uptime` — run on the target host via SSH. The existing `ssh-script` kind (defined in OpenAPI, not implemented in scheduler) is the one generic mechanism.
### Why one `ssh-script` kind instead of separate kinds per metric
The check runs a small allowlisted shell snippet on the target host via SSH.
The script outputs JSON with `health`, `signalKind`, `evidence`, and optional
`metrics` (name→value map). This keeps the scheduler simple — one code path for
all remote checks — while the check definition's `config.script` field encodes
what to run.
### SSH config shape
```json
{
"host": "192.168.30.10",
"port": 22,
"user": "root",
"script": "cpu_check.sh",
"timeout_s": 10
}
```
Scripts live in `/opt/oikos/checks/` on each host, deployed by the Homelab sync
timer alongside the AGENTS.md context. They are allowlisted — the scheduler only
executes scripts whose names match `^[a-z][a-z0-9_-]+\.sh$` and that exist in the
check directory.
---
## 3. Check scripts (per host, `/opt/oikos/checks/`)
### `cpu_check.sh`
```sh
#!/usr/bin/env bash
set -euo pipefail
USAGE=$(top -bn1 | awk '/^%Cpu/ {print 100 - $8}')
CORES=$(nproc)
TEMP=""
if [ -f /sys/class/thermal/thermal_zone0/temp ]; then
TEMP=$(echo "scale=1; $(cat /sys/class/thermal/thermal_zone0/temp) / 1000" | bc)
fi
echo "{\"health\":\"healthy\",\"metrics\":{\"cpu_pct\":$USAGE,\"cpu_temp\":$TEMP}}"
```
Signal raised by scheduler logic when `cpu_pct > threshold_pct` or `cpu_temp > threshold_temp`.
### `memory_check.sh`
```sh
#!/usr/bin/env bash
set -euo pipefail
MEMINFO=$(awk '/MemTotal|MemAvailable|SwapTotal|SwapFree/ {printf "\"%s\":%d,", tolower($1), $2}' /proc/meminfo | sed 's/,$//')
USED_PCT=$(python3 -c "print(round((1 - ${memavailable:-0}/${memtotal:-1})*100, 1))")
echo "{\"health\":\"healthy\",\"metrics\":{\"mem_pct\":$USED_PCT}}"
```
Actual implementation would precompute from `/proc/meminfo` in bash directly (avoid python dependency).
### `load_check.sh`
```sh
#!/usr/bin/env bash
set -euo pipefail
LOAD=$(awk '{print $1}' /proc/loadavg)
CORES=$(nproc)
echo "{\"health\":\"healthy\",\"metrics\":{\"load1\":$LOAD,\"cores\":$CORES}}"
```
Scheduler computes `load_pct = load1 / cores` and thresholds on that.
### `swap_check.sh`
Reports swap used / swap total from `/proc/meminfo`.
### `disk_smart_check.sh`
```sh
#!/usr/bin/env bash
set -euo pipefail
FAIL=0
for dev in $(lsblk -ndo NAME,TYPE | awk '$2=="disk"{print "/dev/"$1}'); do
smartctl -H "$dev" | grep -q "PASSED" || { FAIL=1; break; }
done
[ $FAIL -eq 0 ] && echo '{"health":"healthy"}' || echo '{"health":"degraded","signalKind":"disk-smart-fail","evidence":"SMART health check failed"}'
```
### `updates_check.sh`
```sh
#!/usr/bin/env bash
set -euo pipefail
apt update -qq >/dev/null 2>&1
SECURITY=$(apt list --upgradable 2>/dev/null | grep -c '\-security' || true)
REBOOT=$(test -f /var/run/reboot-required && echo 1 || echo 0)
echo "{\"health\":\"healthy\",\"metrics\":{\"security_updates\":$SECURITY,\"reboot_required\":$REBOOT}}"
```
Scheduler thresholds: `security_updates > 0` → warning signal, `reboot_required > 0` → critical.
### `zfs_check.sh`
```sh
#!/usr/bin/env bash
set -euo pipefail
STATUS=$(zpool status -x 2>&1)
if echo "$STATUS" | grep -q "all pools are healthy"; then
echo '{"health":"healthy"}'
else
echo "{\"health\":\"degraded\",\"signalKind\":\"zfs-degraded\",\"evidence\":\"$(echo $STATUS | head -1)\"}"
fi
```
### `process_check.sh`
Runs `systemctl is-active <service>` for a service name passed in config.
Signal on `inactive`/`failed`.
### `uptime_check.sh`
Reads current uptime, compares to last stored value (in a temp file or via metric).
Signal if uptime went backward (reboot detected).
---
## 4. Scheduler changes
### `internal/scheduler/scheduler.go`
Add two new cases to `executeCheck()`:
```go
case "ping":
return checkPing(ctx, cd)
case "ssh-script":
return checkSSHScript(ctx, cd)
```
Additional changes:
- **Metric recording**: stop hardcoding `probe_latency_ms`. Each check returns
a `map[string]float64` of metrics, and the scheduler writes all of them.
Change `executeCheck` signature from `(health, signalKind, evidence, err)`
to also return `metrics map[string]float64`.
- **Threshold evaluation for metric-based checks** (`cpu`, `memory`, `load`,
`swap`, `updates`): the scheduler reads `config.thresholds` from check config
JSONB and evaluates metrics against them.
### `checkPing()`
Uses `golang.org/x/net/icmp` + `golang.org/x/net/ipv4` for non-privileged
ICMP echo (or falls back to `net.DialTimeout("ip4:icmp", ...)`).
On macOS, `ping -c 1 -t <timeout>` via exec as fallback (ICMP raw sockets
require root on macOS). Returns `probe_latency_ms` metric.
Config:
```json
{
"host": "192.168.30.10",
"count": 1,
"timeout_s": 5
}
```
### `checkSSHScript()`
Connects via `golang.org/x/crypto/ssh` using agent forwarding or key file.
Executes the allowlisted script path, validates JSON output, returns metrics
and health.
Config:
```json
{
"host": "192.168.30.10",
"port": 22,
"user": "root",
"script": "cpu_check.sh",
"thresholds": {
"cpu_pct": {"warn": 90, "crit": 95},
"cpu_temp": {"crit": 85}
},
"timeout_s": 10
}
```
### Threshold config schema
Each check kind with metric-based thresholds adds a `thresholds` key:
```json
{
"thresholds": {
"<metric_name>": {"warn": <float>, "crit": <float>}
}
}
```
Missing thresholds → no signal raised; metrics still recorded.
---
## 5. DB and migration
No schema migration needed. `check_defs.config` is JSONB — new check kinds
use it with their own config shapes. `signals` table handles any `kind` string.
One small addition: add the new signal kinds to the OpenAPI `CheckKind` enum
and the generated code (`api/openapi.yaml` line 2247, `internal/httpapi/gen/api.gen.go` line 84).
---
## 6. Seed data: default checks per host
Add to `seeds/inventory.yaml` or a new `seeds/checks.yaml` — a set of default
checks per entity type:
- Every `machine`, `workstation`, `proxmox-host`, `lxc` gets:
- `cpu` (via ssh-script)
- `memory` (via ssh-script)
- `load` (via ssh-script)
- `swap` (via ssh-script)
- `disk-usage` (local or ssh-script for remote)
- `updates` (via ssh-script)
- `uptime` (via ssh-script)
- Every `machine`, `proxmox-host`, `standalone-server` additionally gets:
- `disk-smart` (via ssh-script)
- `zfs` (via ssh-script) if `storage-pool` edges exist
- Every `service` gets:
- `process` (via ssh-script, `systemctl is-active`)
Default intervals:
- `cpu`, `memory`, `load`: 60s
- `disk-usage`, `swap`: 300s
- `ping`: 30s
- `updates`: 3600s (hourly)
- `disk-smart`, `zfs`: 86400s (daily)
- `process`: 30s
---
## 7. Policy integration
New approval rule for `ssh-script` checks:
```yaml
# ssh-script execution is read_only on the target — it only reads metrics
- entity_type: machine
action: health-check
risk_class: read_only
autonomy: auto
```
The `ping` kind is `read_only` (no mutation). All new checks are `read_only`
— they observe state, no action taken automatically. The *response* to signals
(restart service, clear cache, apt upgrade) goes through the existing
classification → approval → execution pipeline separately.
---
## 8. Other common important signals (per user request)
Beyond the core checks above, these are worth including:
| # | Trigger | Why important |
|---|---------|---------------|
| 11 | **Inode exhaustion** | Filesystem can be "full" with free space but zero inodes (Docker overlay, mail queues). Distinct from disk-usage. |
| 12 | **OOM kills** | `dmesg | grep -i 'out of memory'` count since last boot. Indicates memory pressure beyond usage %. |
| 13 | **Journal errors** | `journalctl -p err -S -1h --no-pager | wc -l`. Catches kernel panics, segfaults, service failures. |
| 14 | **Docker/container health** | `docker ps --filter health=unhealthy`. Catches containers in unhealthy state. |
| 15 | **Caddy/nginx error rate** | Parse access logs for 5xx rate over last 5min. Expensive, do at 300s interval. |
| 16 | **Time drift** | `chronyc tracking | grep 'System time'`. NTP offset > 1s → warning (affects TLS, auth, DB). |
| 17 | **Open file descriptors** | `/proc/sys/fs/file-nr` ratio used/total. >80% → warning (service exhaustion). |
| 18 | **Backup freshness** | Check timestamp of last backup file. >schedule+grace → critical. |
All of these (1118) are implemented as `ssh-script` checks with
corresponding scripts in `/opt/oikos/checks/`.
---
## 9. Implementation order
### Phase 1: Foundation (2-3 days)
1. **Refactor `executeCheck`** to return metrics map. Update all existing check
functions (`http`, `tcp`, `disk`, `cert-expiry`).
2. **Add `ping` kind** — ICMP reachability on the scheduler host.
3. **Add `ssh-script` kind** — SSH execution engine, script allowlisting,
JSON output parsing.
4. **Add threshold evaluation** — scheduler reads `config.thresholds` and
compares against returned metrics to decide health/signal.
### Phase 2: Host check scripts (1-2 days)
5. Write and test each script in `/opt/oikos/checks/`:
`cpu_check.sh`, `memory_check.sh`, `load_check.sh`, `swap_check.sh`,
`disk_smart_check.sh`, `updates_check.sh`, `zfs_check.sh`,
`process_check.sh`, `uptime_check.sh`, `oom_check.sh`, `journal_check.sh`,
`time_check.sh`, `fd_check.sh`.
6. Add `tools/setup-checks.sh` to auto-deploy scripts via the sync timer
(same pattern as `tools/setup-caveman.sh`).
### Phase 3: Seed data + API (1 day)
7. Add `seeds/checks.yaml` with default check definitions per entity type.
8. Add new check kinds to OpenAPI spec and regenerate Go types.
9. Add a `/api/v1/checks/defaults/{entity_type}` endpoint that returns
recommended checks for a given entity type (convenience for operators).
### Phase 4: Policy + observability (1 day)
10. Add `read_only` policy rules for `ping` and `ssh-script` actions.
11. Add per-check-kind metric recording (not just `probe_latency_ms`).
12. Wire `cpu_temp`, `mem_pct`, `load_pct`, etc. into `query_metrics` and
the MCP `get_trend` tool.
### Phase 5: Docker + service signals (1 day)
13. `docker_health_check.sh``docker ps --filter health=unhealthy`.
14. `caddy_error_rate.sh` — parse Caddy JSON logs for 5xx.
15. `backup_freshness.sh` — check last backup timestamp.
---
## 10. Dependencies
| Dependency | For | Risk |
|------------|-----|------|
| `golang.org/x/crypto/ssh` | SSH client in scheduler | Already in `go.mod` (used by deployer) |
| `golang.org/x/net/icmp` + `ipv4` | ICMP ping | New dep; macOS needs root for raw sockets → exec fallback |
| `smartmontools` on hosts | `disk_smart_check.sh` | Already installed on Proxmox; add to LXCs |
| `zfsutils-linux` on hosts | `zfs_check.sh` | Already on Proxmox; add to LXCs with ZFS |
| SSH key on scheduler | `ssh-script` to all hosts | Already deployed (Homelab sync SSH keys) |
---
## 11. Risks and mitigations
| Risk | Mitigation |
|------|------------|
| `ssh-script` is a remote exec vector | Scripts are allowlisted by name (`^[a-z][a-z0-9_-]+\.sh$`), deployed via git (auditable), and read-only (no mutation). SSH key restricted to a dedicated `oikos-check` user with sudo only for `systemctl is-active`. |
| ICMP requires root on macOS | Fallback to `ping` CLI via `os/exec`. Production runs on Linux (strong) where raw sockets work. |
| Metrics cardinality explosion | Metrics are per-check-definition, not per-script-output. The script returns a fixed set of known metric names. TimescaleDB handles the volume. |
| Check script drift between hosts | Scripts deploy via `tools/setup-checks.sh` in the sync timer — same mechanism that keeps AGENTS.md in sync. Checksum validation before execution. |
---
## 12. Verification
- **Unit tests**: each check function (`checkPing`, `checkSSHScript`) tested
with mock SSH server and mock ICMP responses.
- **Integration test**: deploy to `strong` (macOS scheduler host), define
checks for `hubris` (Proxmox), `dns` (LXC), `caddy` (LXC), run scheduler
with `--check-interval 10s`, verify signals appear in `get_signal_history`.
- **MCP smoke test**: `search_knowledge("signal triggers")`, `get_signal_history`,
`query_metrics(metric=["cpu_pct", "mem_pct"])`, `get_trend`.
- **Policy smoke test**: `preflight(service:caddy, restart)` still returns
correct risk class despite new check kinds in the DB.

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

@@ -11,8 +11,9 @@ went sideways, open an investigation.
| 2026-07-05 | [Oikos Prometheus LXC](2026-07-05-oikos-prometheus-lxc.md) | Planned |
| 2026-07-08 | [Plan vs implementation cross-reference](2026-07-08-plan-implementation-audit.md) | Planned |
| 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) | 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

13
tools/setup-checks.sh Normal file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# setup-checks.sh — deploy check scripts to /opt/oikos/checks on each host.
# Auto-setup hook: tools/*.setup.sh runs after every git pull.
set -euo pipefail
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}"
CHECK_SETUP="$CLONE_DIR/checks/install.sh"
if [ -f "$CHECK_SETUP" ]; then
bash "$CHECK_SETUP" || echo "[setup-checks] WARNING: install.sh exited with code $?"
else
echo "[setup-checks] no checks/install.sh found, skipping"
fi

17
web/components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "vega",
"tailwind": {
"css": "src/app.css",
"baseColor": "zinc"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}

View File

@@ -4,7 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Oikos — Control Room</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏠</text></svg>" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="android-chrome-512.png" />
</head>
<body>
<div id="app"></div>

997
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,27 @@
"preview": "vite preview"
},
"devDependencies": {
"@internationalized/date": "^3.12.2",
"@lucide/svelte": "^1.23.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.3.2",
"@tsconfig/svelte": "^5.0.0",
"@types/d3-force": "^3.0.10",
"bits-ui": "^2.18.1",
"mode-watcher": "^1.1.0",
"svelte": "^5.0.0",
"svelte-sonner": "^1.1.1",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.2",
"typescript": "^5.5.0",
"vite": "^6.0.0"
},
"dependencies": {
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
"dompurify": "^3.4.11",
"marked": "^18.0.5",
"tailwind-merge": "^3.6.0",
"uplot": "^1.6.32"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
web/public/favicon.png Normal file

Binary file not shown.

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

@@ -1,155 +1,232 @@
<script lang="ts">
import Chat from '$lib/../pages/Chat.svelte'
import Sessions from '$lib/../pages/Sessions.svelte'
import Chat from './pages/Chat.svelte'
import Sessions from './pages/Sessions.svelte'
import Overview from './pages/Overview.svelte'
import Entities from './pages/Entities.svelte'
import Events from './pages/Events.svelte'
import Ops from './pages/Ops.svelte'
import Signals from './pages/Signals.svelte'
import Graph from './pages/Graph.svelte'
import EntityDetail from './pages/EntityDetail.svelte'
import Agent from './pages/Agent.svelte'
import Knowledge from './pages/Knowledge.svelte'
import Audit from './pages/Audit.svelte'
import { newChat } from '$lib/stores/chat'
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
import { connectionState } from '$lib/stores/events'
import { onMount } from 'svelte'
import { slide } from 'svelte/transition'
import * as Sidebar from '$lib/components/ui/sidebar'
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 LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'
import DatabaseIcon from '@lucide/svelte/icons/database'
import ActivityIcon from '@lucide/svelte/icons/activity'
import PanelRightIcon from '@lucide/svelte/icons/panel-right'
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
import SirenIcon from '@lucide/svelte/icons/siren'
import NetworkIcon from '@lucide/svelte/icons/share-2'
import BotIcon from '@lucide/svelte/icons/bot'
import SearchIcon from '@lucide/svelte/icons/search'
import ScrollTextIcon from '@lucide/svelte/icons/scroll-text'
let page = $state('chat')
let routeParam = $state('')
let drawerOpen = $state(false)
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
const openSignals = $derived(openSignalCount($summary))
onMount(() => {
function sync() {
page = location.hash.slice(2) || 'chat'
const path = location.hash.slice(2) || 'chat'
const [head, ...rest] = path.split('/')
page = head || 'chat'
routeParam = rest.join('/')
}
sync()
window.addEventListener('hashchange', sync)
return () => window.removeEventListener('hashchange', sync)
const unsubscribeCtx = subscribeContext()
return () => {
window.removeEventListener('hashchange', sync)
unsubscribeCtx()
}
})
function navigate(p: string) {
location.hash = '#/' + p
}
const navItems = [
{ id: 'overview', label: 'Overview', icon: LayoutDashboardIcon },
{ id: 'entities', label: 'Entities', icon: DatabaseIcon },
{ id: 'graph', label: 'Graph', icon: NetworkIcon },
{ id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending },
{ id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals },
{ id: 'events', label: 'Events', icon: ActivityIcon },
{ id: 'agent', label: 'Agent', icon: BotIcon },
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
{ id: 'audit', label: 'Audit', icon: ScrollTextIcon }
]
</script>
<div class="app">
<nav class="sidebar">
<div class="logo">Oikos</div>
<button class="nav-btn" onclick={() => { newChat(); navigate('chat') }}>
<span class="nav-icon"></span>
<span>New</span>
</button>
<button class="nav-btn" class:active={page === 'chat'} onclick={() => navigate('chat')}>
<span class="nav-icon">💬</span>
<span>Chat</span>
</button>
<button class="nav-btn" class:active={page === 'sessions'} onclick={() => navigate('sessions')}>
<span class="nav-icon">📋</span>
<span>Sessions</span>
</button>
<Toaster />
<div class="spacer"></div>
<Sidebar.Provider class="h-svh" style="--header-height: calc(var(--spacing) * 12);">
<Sidebar.Root collapsible="icon" variant="inset">
<Sidebar.Header>
<Sidebar.Menu>
<Sidebar.MenuItem>
<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 />
<span>New chat</span>
</button>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Header>
<button class="drawer-toggle" onclick={() => drawerOpen = !drawerOpen}>
Chat {drawerOpen ? '▼' : '▲'}
</button>
</nav>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={page === 'chat'} onclick={() => navigate('chat')} tooltipContent="Chat">
{#snippet child({ props })}
<button {...props}>
<MessageSquareIcon />
<span>Chat</span>
</button>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{#each navItems as item}
<Sidebar.MenuItem>
<Sidebar.MenuButton
isActive={page === item.id}
onclick={() => navigate(item.id)}
tooltipContent={item.label}
>
{#snippet child({ props })}
<button {...props}>
<item.icon />
<span>{item.label}</span>
</button>
{/snippet}
</Sidebar.MenuButton>
{#if item.badge?.()}
<Sidebar.MenuBadge>{item.badge()}</Sidebar.MenuBadge>
{/if}
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.Group>
</Sidebar.Content>
<main class="main">
{#if page === 'chat'}
<Chat />
{:else if page === 'sessions'}
<Sessions />
{:else}
<Chat />
{/if}
</main>
<Sidebar.Footer>
<Button variant="ghost" size="sm" class="justify-start gap-2" onclick={() => (drawerOpen = true)}>
<PanelRightIcon />
<span>Chat drawer</span>
</Button>
</Sidebar.Footer>
</Sidebar.Root>
{#if drawerOpen}
<aside class="drawer" transition:slide={{ axis: 'x' }}>
<Chat />
</aside>
{/if}
</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>
<span class="flex items-center gap-1" title="degraded"><span class="size-2 rounded-full bg-warning"></span>{$summary.health.degraded}</span>
<span class="flex items-center gap-1" title="down"><span class="size-2 rounded-full bg-destructive"></span>{$summary.health.down}</span>
</div>
{#if approvalsPending}
<button type="button" onclick={() => navigate('ops')}>
<Badge variant="destructive" class="cursor-pointer">{approvalsPending} approval{approvalsPending === 1 ? '' : 's'}</Badge>
</button>
{/if}
{#if openSignals}
<button type="button" onclick={() => navigate('signals')}>
<Badge variant="secondary" class="cursor-pointer">{openSignals} signal{openSignals === 1 ? '' : 's'}</Badge>
</button>
{/if}
{/if}
<span
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'}
<Overview />
{:else if page === 'entities'}
<Entities />
{:else if page === 'graph'}
<Graph />
{:else if page === 'entity' && routeParam}
<EntityDetail slug={routeParam} />
{:else if page === 'ops'}
<Ops />
{:else if page === 'signals'}
<Signals />
{:else if page === 'events'}
<Events />
{:else if page === 'sessions'}
<Sessions />
{:else if page === 'agent'}
<Agent />
{:else if page === 'knowledge'}
<Knowledge />
{:else if page === 'audit'}
<Audit />
{:else}
<Chat />
{/if}
</main>
</Sidebar.Inset>
</Sidebar.Provider>
<style>
.app {
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: 56px;
background: var(--bg-surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem 0;
gap: 0.5rem;
}
.logo {
font-size: 1.25rem;
font-weight: 700;
color: var(--accent-blue);
margin-bottom: 0.5rem;
user-select: none;
}
.nav-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 0.5rem;
border: none;
background: none;
color: var(--text-muted);
font-family: inherit;
font-size: 0.625rem;
cursor: pointer;
border-radius: 6px;
width: 44px;
transition: background 0.15s, color 0.15s;
}
.nav-btn:hover {
background: var(--bg-hover);
color: var(--text);
}
.nav-btn.active {
background: var(--bg-active);
color: var(--accent-blue);
}
.nav-icon {
font-size: 1rem;
}
.spacer {
flex: 1;
}
.drawer-toggle {
border: none;
background: none;
color: var(--text-muted);
font-family: inherit;
font-size: 0.625rem;
cursor: pointer;
padding: 0.5rem;
}
.drawer-toggle:hover {
color: var(--text);
}
.main {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.drawer {
width: 380px;
border-left: 1px solid var(--border);
background: var(--bg);
overflow: hidden;
display: flex;
flex-direction: column;
}
</style>
<Sheet.Root bind:open={drawerOpen}>
<Sheet.Content side="right" class="w-[400px] p-0 sm:max-w-[400px]">
<Sheet.Header class="sr-only">
<Sheet.Title>Nomos chat</Sheet.Title>
<Sheet.Description>Persistent chat drawer</Sheet.Description>
</Sheet.Header>
<div class="flex h-full flex-col">
<Chat showRail={false} />
</div>
</Sheet.Content>
</Sheet.Root>

View File

@@ -1,33 +1,122 @@
@import 'tailwindcss';
@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 {
--bg: #0d1117;
--bg-surface: #161b22;
--bg-deeper: #0a0e13;
--bg-hover: #21262d;
--bg-active: #292e36;
--border: #30363d;
--text: #e6edf3;
--text-muted: #8b949e;
--accent-blue: #58a6ff;
--accent-green: #3fb950;
--accent-red: #f85149;
--accent-orange: #d29922;
--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: 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: oklch(0.11 0 0);
--bg-hover: var(--secondary);
--bg-active: var(--accent);
--text: var(--foreground);
--text-muted: var(--muted-foreground);
/* 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);
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-warning: var(--warning);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-mono: var(--font-mono);
}
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.4;
-webkit-font-smoothing: antialiased;
@layer base {
* {
@apply border-border outline-ring/50;
}
html,
body {
@apply bg-background text-foreground;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
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

@@ -1,4 +1,5 @@
const BASE = '/agent'
const API = '/api/v1'
export interface Session {
id: string
@@ -90,3 +91,420 @@ export function streamChat(
return controller
}
export interface DashboardSummary {
entities_by_type: Record<string, number>
entities_by_state: Record<string, number>
health: { healthy: number; degraded: number; down: number; unknown: number }
signals_by_severity: Record<string, number>
approvals_pending: number
executions_by_state: Record<string, number>
event_rate: { bucket: string; count: number }[]
}
export async function fetchDashboardSummary(): Promise<DashboardSummary | null> {
const res = await fetch(`${API}/dashboard/summary`)
if (!res.ok) return null
return res.json()
}
export type EntityHealth = 'healthy' | 'degraded' | 'down' | 'unknown' | 'stale'
export interface Entity {
id: string
slug: string
type: string
name: string
state?: string | null
attributes: Record<string, unknown>
version: number
created_at: string
updated_at: string
health?: EntityHealth | null
last_check_at?: string | null
}
export interface EntityFilters {
type?: string
state?: string
q?: string
}
export async function fetchEntities(filters: EntityFilters = {}): Promise<Entity[]> {
const params = new URLSearchParams()
if (filters.type) params.set('type', filters.type)
if (filters.state) params.set('state', filters.state)
if (filters.q) params.set('q', filters.q)
params.set('limit', '200')
const res = await fetch(`${API}/entities?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export interface EventFilters {
type?: string
severity?: string
}
export async function fetchEvents(filters: EventFilters = {}): Promise<import('./stores/events').OikosEvent[]> {
const params = new URLSearchParams()
if (filters.type) params.set('type', filters.type)
if (filters.severity) params.set('severity', filters.severity)
params.set('limit', '100')
const res = await fetch(`${API}/events?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export interface Approval {
id: string
slug: string
subject?: string | null
action: string
risk_class: string
kind: 'execution' | 'policy-change' | 'pattern-activation'
payload?: Record<string, unknown> | null
status: 'pending' | 'approved' | 'denied' | 'expired' | 'revoked'
expires_at: string
decided_at?: string | null
decided_by?: string | null
created_at: string
}
export async function fetchApprovals(status?: string): Promise<Approval[]> {
const params = new URLSearchParams()
if (status) params.set('status', status)
params.set('limit', '200')
const res = await fetch(`${API}/approvals?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export async function decideApproval(
id: string,
decision: 'approve' | 'deny' | 'revoke',
note?: string
): Promise<Approval | null> {
const res = await fetch(`${API}/approvals/${id}/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision, note })
})
if (!res.ok) return null
return res.json()
}
export interface Execution {
id: string
slug: string
target?: string | null
action: string
risk_class: string
status: string
approval_id?: string | null
agent_id?: string | null
result?: Record<string, unknown> | null
duration_ms?: number | null
verified: boolean
correlation_id: string
started_at?: string | null
completed_at?: string | null
created_at: string
}
export async function fetchExecutions(status?: string): Promise<Execution[]> {
const params = new URLSearchParams()
if (status) params.set('status', status)
params.set('limit', '200')
const res = await fetch(`${API}/executions?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export async function cancelExecution(id: string): Promise<Execution | null> {
const res = await fetch(`${API}/executions/${id}/cancel`, { method: 'POST' })
if (!res.ok) return null
return res.json()
}
export interface Signal {
id: string
slug: string
kind: string
severity: 'info' | 'warning' | 'critical'
state: 'raised' | 'acknowledged' | 'acting' | 'muted' | 'resolved' | 'failed'
target?: string | null
evidence?: string | null
likely_cause?: string | null
occurrence_count: number
flap_count: number
hold_down_until?: string | null
mute_until?: string | null
first_seen_at: string
last_seen_at: string
}
export async function fetchSignals(filters: { state?: string; severity?: string } = {}): Promise<Signal[]> {
const params = new URLSearchParams()
if (filters.state) params.set('state', filters.state)
if (filters.severity) params.set('severity', filters.severity)
params.set('limit', '200')
const res = await fetch(`${API}/signals?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export async function ackSignal(id: string): Promise<Signal | null> {
const res = await fetch(`${API}/signals/${id}/ack`, { method: 'POST' })
if (!res.ok) return null
return res.json()
}
export async function resolveSignal(id: string, note?: string): Promise<Signal | null> {
const res = await fetch(`${API}/signals/${id}/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note })
})
if (!res.ok) return null
return res.json()
}
export async function muteSignal(id: string, muteUntil: string, note?: string): Promise<Signal | null> {
const res = await fetch(`${API}/signals/${id}/mute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mute_until: muteUntil, note })
})
if (!res.ok) return null
return res.json()
}
export interface Relationship {
source: string
target: string
type: string
attributes?: Record<string, unknown> | null
valid_from: string
valid_to?: string | null
}
export type Health = 'healthy' | 'degraded' | 'down' | 'unknown'
export interface GraphView {
nodes: Entity[]
edges: Relationship[]
truncated?: boolean
health?: Record<string, Health>
}
export interface GraphFilters {
root?: string
depth?: number
relType?: string[]
includeStatus?: boolean
}
export async function fetchGraph(filters: GraphFilters = {}): Promise<GraphView | null> {
const params = new URLSearchParams()
if (filters.root) params.set('root', filters.root)
if (filters.depth) params.set('depth', String(filters.depth))
for (const rt of filters.relType ?? []) params.append('rel_type', rt)
if (filters.includeStatus) params.append('include', 'status')
const res = await fetch(`${API}/graph?${params}`)
if (!res.ok) return null
return res.json()
}
export interface BlastRadiusItem {
entity: Entity
depth: number
}
export async function fetchBlastRadius(id: string): Promise<BlastRadiusItem[]> {
const res = await fetch(`${API}/entities/${id}/blast-radius`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export async function fetchEntity(id: string): Promise<Entity | null> {
const res = await fetch(`${API}/entities/${id}`)
if (!res.ok) return null
return res.json()
}
export interface MetricSample {
ts: string
value?: number | null
avg?: number | null
min?: number | null
max?: number | null
}
export interface MetricSeries {
entity_id: string
metric: string
rollup: 'raw' | '1h' | '1d'
samples: MetricSample[]
}
export async function fetchMetrics(entityId: string): Promise<MetricSeries[]> {
const params = new URLSearchParams({ entity_id: entityId, rollup: 'auto' })
const res = await fetch(`${API}/metrics?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export interface KnowledgeHit {
id: string
slug: string
type: 'document' | 'runbook' | 'investigation'
title: string
}
export async function fetchEntityKnowledge(entityId: string): Promise<KnowledgeHit[]> {
const res = await fetch(`${API}/knowledge/${entityId}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export async function fetchEntityEvents(entityId: string): Promise<import('./stores/events').OikosEvent[]> {
const params = new URLSearchParams({ entity_id: entityId, limit: '50' })
const res = await fetch(`${API}/events?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export async function fetchEntitySignals(entityId: string): Promise<Signal[]> {
const params = new URLSearchParams({ entity_id: entityId, limit: '50' })
const res = await fetch(`${API}/signals?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export async function fetchEntityExecutions(entityId: string): Promise<Execution[]> {
const params = new URLSearchParams({ target: entityId, limit: '50' })
const res = await fetch(`${API}/executions?${params}`)
if (!res.ok) return []
const data = await res.json()
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
agent_id: string
session_id?: string | null
activity_type: 'tool_call' | 'reasoning' | 'decision' | 'mcp_query' | 'escalation'
tool_name?: string | null
entity_id?: string | null
input_summary?: string | null
output_summary?: string | null
duration_ms?: number | null
token_count?: number | null
success?: boolean | null
correlation_id?: string | null
}
export async function fetchAgentActivity(filters: {
agent_id?: string
activity_type?: string
entity_id?: string
limit?: number
} = {}): Promise<AgentActivity[]> {
const params = new URLSearchParams()
if (filters.agent_id) params.set('agent_id', filters.agent_id)
if (filters.activity_type) params.set('activity_type', filters.activity_type)
if (filters.entity_id) params.set('entity_id', filters.entity_id)
params.set('limit', String(filters.limit ?? 200))
const res = await fetch(`${API}/agent-activity?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export async function searchKnowledge(q: string, limit = 50): Promise<KnowledgeHit[]> {
const params = new URLSearchParams({ q, limit: String(limit) })
const res = await fetch(`${API}/knowledge/search?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export interface AuditEntry {
id: number
ts: string
actor_type: 'agent' | 'operator' | 'system' | 'scheduler'
actor_id?: string | null
action: string
entity_id?: string | null
method?: string | null
path?: string | null
status_code?: number | null
detail?: Record<string, unknown>
source_ip?: string | null
correlation_id?: string | null
}
export async function fetchAudit(filters: {
actor_type?: string
actor_id?: string
entity_id?: string
action?: string
correlation_id?: string
limit?: number
} = {}): Promise<AuditEntry[]> {
const params = new URLSearchParams()
if (filters.actor_type) params.set('actor_type', filters.actor_type)
if (filters.actor_id) params.set('actor_id', filters.actor_id)
if (filters.entity_id) params.set('entity_id', filters.entity_id)
if (filters.action) params.set('action', filters.action)
if (filters.correlation_id) params.set('correlation_id', filters.correlation_id)
params.set('limit', String(filters.limit ?? 200))
const res = await fetch(`${API}/audit?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}

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

@@ -0,0 +1,49 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:ring-[3px] [&>svg]:pointer-events-none",
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View File

@@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-9",
"icon-xs": "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn(
"cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-content"
class={cn("px-6 group-data-[size=sm]/card:px-4", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4 flex items-center", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"gap-1 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("text-base leading-normal font-medium group-data-[size=sm]/card:text-sm", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
</script>
<div
bind:this={ref}
data-slot="card"
data-size={size}
class={cn("ring-foreground/10 bg-card text-card-foreground gap-6 overflow-hidden rounded-xl py-6 text-sm shadow-xs ring-1 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
</script>
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
</script>
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let {
ref = $bindable(null),
open = $bindable(false),
...restProps
}: CollapsiblePrimitive.RootProps = $props();
</script>
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />

View File

@@ -0,0 +1,13 @@
import Root from "./collapsible.svelte";
import Trigger from "./collapsible-trigger.svelte";
import Content from "./collapsible-content.svelte";
export {
Root,
Content,
Trigger,
//
Root as Collapsible,
Content as CollapsibleContent,
Trigger as CollapsibleTrigger,
};

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let {
ref = $bindable(null),
type = "button",
...restProps
}: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {type} {...restProps} />

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import DialogPortal from "./dialog-portal.svelte";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Button } from "$lib/components/ui/button/index.js";
import XIcon from '@lucide/svelte/icons/x';
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<DialogPortal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-6 rounded-xl p-6 text-sm ring-1 duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close data-slot="dialog-close">
{#snippet child({ props })}
<Button variant="ghost" class="absolute top-4 right-4" size="icon-sm" {...props}>
<XIcon />
<span class="sr-only">Close</span>
</Button>
{/snippet}
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</DialogPortal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)}
{...restProps}
/>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { Dialog as DialogPrimitive } from "bits-ui";
import { Button } from "$lib/components/ui/button/index.js";
let {
ref = $bindable(null),
class: className,
children,
showCloseButton = false,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
showCloseButton?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Close</Button>
{/snippet}
</DialogPrimitive.Close>
{/if}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("gap-2 flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ...restProps }: DialogPrimitive.PortalProps = $props();
</script>
<DialogPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("leading-none font-medium", className)}
{...restProps}
/>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let {
ref = $bindable(null),
type = "button",
...restProps
}: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {type} {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />

View File

@@ -0,0 +1,34 @@
import Root from "./dialog.svelte";
import Portal from "./dialog-portal.svelte";
import Title from "./dialog-title.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
import Overlay from "./dialog-overlay.svelte";
import Content from "./dialog-content.svelte";
import Description from "./dialog-description.svelte";
import Trigger from "./dialog-trigger.svelte";
import Close from "./dialog-close.svelte";
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
};

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable([]),
...restProps
}: DropdownMenuPrimitive.CheckboxGroupProps = $props();
</script>
<DropdownMenuPrimitive.CheckboxGroup
bind:ref
bind:value
data-slot="dropdown-menu-checkbox-group"
{...restProps}
/>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import MinusIcon from '@lucide/svelte/icons/minus';
import CheckIcon from '@lucide/svelte/icons/check';
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
{#if indeterminate}
<MinusIcon />
{:else if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import DropdownMenuPortal from "./dropdown-menu-portal.svelte";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
sideOffset = 4,
align = "start",
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
} = $props();
</script>
<DropdownMenuPortal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
{align}
class={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-md p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 w-(--bits-dropdown-menu-anchor-width) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden",
className
)}
{...restProps}
/>
</DropdownMenuPortal>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
</script>
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
inset,
variant = "default",
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: "default" | "destructive";
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-sm px-2 py-1.5 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
class={cn("text-muted-foreground px-2 py-1.5 text-xs font-medium data-inset:pl-8 data-[inset]:pl-8", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ...restProps }: DropdownMenuPrimitive.PortalProps = $props();
</script>
<DropdownMenuPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
</script>
<DropdownMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
/>

Some files were not shown because too many files have changed in this diff Show More