Monitoring coverage was 3 of 89 active entities. Three bugs, each hidden by discarded errors in checkdefaults: - writeCheck generated a fresh uuid, inserted the check entity ON CONFLICT (slug) DO NOTHING, then wrote a check_defs row referencing it. On any re-seed the slug already existed, the entity insert no-oped, and the FK violated — aborting the ingest transaction and surfacing as an unrelated failure several entities later. Re-seeding has been broken since; prod's coverage was frozen at its first successful seed. This is what TestSeedIngestIdempotentAndNoDuplicateEdges had been reporting. - shortSlug truncated to the last 8 chars, so all 21 ingress routes collapsed to ".network" and overwrote each other; service:jellyfin collided with lxc:jellyfin. - The ssh-script checker never read the `args` config checkdefaults wrote, so process_check.sh always ran without its unit name and returned "unknown". Coverage is now 75/89. Monitoring is declared per entity type in seeds/ontology.yaml and resolved through the is-a hierarchy, so a type can say it warrants nothing (site, lan, mesh, cluster) and never be reported as a gap. coverageSweep raises an `unmonitored` signal only where a type declares monitoring it lacks — 8 real gaps, no false positives. Also: - entity_types.attribute_schema was never ingested: the seed loader read "attribute_schema" but the YAML says "attributes", so all 60 types stored JSON null. - ListExecutions ignored its declared target/action/correlation_id filters and paginated on a non-unique target slug, dropping and repeating rows. - started_at was captured but only written at terminal state, so a running execution reported NULL for its whole life. The three MCP auto-run copies wrote no timing at all; they are now one autoRun helper. - SSH output was buffered to completion and discarded entirely on timeout. Both sshExec copies now stream through a shared execlog sink into execution_logs, and keep partial output when a command is cancelled. - executions.correlation_id was a random per-execution uuid that correlated nothing; it is now the chat session id, which is what lets the chat tail live output. - reversible_low had no auto-run branch despite policy declaring it unattended. Since computeCommandRisk never returns it, the class only arises when an agent declares it over a read_only command — so gating it penalised candor without adding safety. - backup-target gains a backup-freshness checker (portable find -mmin, since the first target is on macOS), resolving its host by walking backs-up-to backwards. The pre-deploy pg_dump is now a tracked backup target. UI: an Executions section on entity detail with live output tailing, and streamed output under a running `run` call in the chat timeline. Migrations 022-024. Ops.svelte and context.ts exclude execution.output from their refetch triggers, which would otherwise fire once a second per command. Co-Authored-By: Claude <noreply@anthropic.com>
65 lines
2.0 KiB
TypeScript
65 lines
2.0 KiB
TypeScript
import { writable, get } from 'svelte/store'
|
|
import { fetchDashboardSummary, fetchApprovals, type DashboardSummary } from '$lib/api'
|
|
import { liveEvents, subscribeEvents, type OikosEvent } from './events'
|
|
|
|
// Shared operational context: dashboard summary + pending approvals,
|
|
// refreshed on a slow poll and eagerly on relevant SSE events. Ref-counted
|
|
// so the poll only runs while something on screen displays it.
|
|
|
|
export const summary = writable<DashboardSummary | null>(null)
|
|
|
|
let refs = 0
|
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
let unsubscribeSSE: (() => void) | null = null
|
|
let unsubscribeStore: (() => void) | null = null
|
|
let lastSeenEventId = 0
|
|
|
|
export async function refreshContext() {
|
|
const [s] = await Promise.all([fetchDashboardSummary(), fetchApprovals('pending')])
|
|
if (s) summary.set(s)
|
|
}
|
|
|
|
function onEvent(ev: OikosEvent) {
|
|
if (ev.id <= lastSeenEventId) return
|
|
lastSeenEventId = ev.id
|
|
// execution.output carries no summary-level change — it just signals that a
|
|
// running command printed more. Excluded so a single noisy command doesn't
|
|
// refresh the dashboard summary once a second.
|
|
if (
|
|
ev.type.startsWith('approval.') ||
|
|
ev.type.startsWith('signal.') ||
|
|
(ev.type.startsWith('execution.') && ev.type !== 'execution.output') ||
|
|
ev.type === 'health.changed'
|
|
) {
|
|
refreshContext()
|
|
}
|
|
}
|
|
|
|
export function subscribeContext(): () => void {
|
|
refs++
|
|
if (refs === 1) {
|
|
refreshContext()
|
|
pollTimer = setInterval(refreshContext, 30000)
|
|
unsubscribeSSE = subscribeEvents()
|
|
unsubscribeStore = liveEvents.subscribe((events) => {
|
|
if (events[0]) onEvent(events[0])
|
|
})
|
|
}
|
|
return () => {
|
|
refs--
|
|
if (refs === 0) {
|
|
if (pollTimer) clearInterval(pollTimer)
|
|
pollTimer = null
|
|
unsubscribeSSE?.()
|
|
unsubscribeSSE = null
|
|
unsubscribeStore?.()
|
|
unsubscribeStore = null
|
|
}
|
|
}
|
|
}
|
|
|
|
export function openSignalCount(s: DashboardSummary | null): number {
|
|
if (!s) return 0
|
|
return Object.values(s.signals_by_severity).reduce((a, b) => a + b, 0)
|
|
}
|