The plan's "learning view" (runbook success-rate trends, promoted skills, capability timeline) assumes the patterns/skills/feedback pipeline is populated. It isn't: all three tables are empty in production and nothing in the codebase ever writes to feedback, so building the UI against them today would ship a permanently-empty page. Scoped instead around data that's real and growing — executions — while still wiring up /patterns and /skills so the page needs no rework once that pipeline exists. New /api/v1/learning/timeline (per-verb first-success date + success rate, parsed via the existing splitAction helper) and /api/v1/learning/trend (30-day daily success/fail counts), both read-only queries against executions. Patterns and skills sections call the existing (untouched) ListPatterns/ListSkills endpoints and render an explanatory empty state instead of nothing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
653 lines
18 KiB
TypeScript
653 lines
18 KiB
TypeScript
const BASE = '/agent'
|
|
const API = '/api/v1'
|
|
|
|
export interface Session {
|
|
id: string
|
|
title: string
|
|
actor: string
|
|
created_at: string
|
|
last_active_at: string
|
|
}
|
|
|
|
export interface Message {
|
|
id: string
|
|
session_id: string
|
|
role: string
|
|
content: any
|
|
created_at: string
|
|
}
|
|
|
|
export async function fetchSessions(): Promise<Session[]> {
|
|
const res = await fetch(`${BASE}/sessions`)
|
|
if (!res.ok) return []
|
|
const data = await res.json()
|
|
return data.sessions ?? []
|
|
}
|
|
|
|
export async function fetchMessages(sessionId: string): Promise<Message[]> {
|
|
const res = await fetch(`${BASE}/sessions/${sessionId}`)
|
|
if (!res.ok) return []
|
|
const data = await res.json()
|
|
return data.messages ?? []
|
|
}
|
|
|
|
export async function deleteSession(sessionId: string): Promise<boolean> {
|
|
const res = await fetch(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
|
|
return res.ok
|
|
}
|
|
|
|
export interface ChatEvent {
|
|
type: string
|
|
data: any
|
|
session_id?: string
|
|
iteration?: number
|
|
}
|
|
|
|
export function streamChat(
|
|
message: string,
|
|
sessionId: string | null,
|
|
onEvent: (ev: ChatEvent) => void,
|
|
onError: (err: string) => void,
|
|
onDone: () => void
|
|
): AbortController {
|
|
const controller = new AbortController()
|
|
|
|
fetch(`${BASE}/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ message, session_id: sessionId ?? undefined }),
|
|
signal: controller.signal
|
|
}).then(async (res) => {
|
|
if (!res.ok) {
|
|
onError(`HTTP ${res.status}`)
|
|
return
|
|
}
|
|
const reader = res.body?.getReader()
|
|
if (!reader) {
|
|
onError('no response body')
|
|
return
|
|
}
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const lines = buffer.split('\n')
|
|
buffer = lines.pop() ?? ''
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ')) {
|
|
try {
|
|
const ev: ChatEvent = JSON.parse(line.slice(6))
|
|
onEvent(ev)
|
|
} catch {
|
|
// skip malformed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}).catch((err) => {
|
|
onError(err.message)
|
|
}).finally(() => {
|
|
onDone()
|
|
})
|
|
|
|
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 getExecution(id: string): Promise<Execution | null> {
|
|
const res = await fetch(`${API}/executions/${id}`)
|
|
if (!res.ok) return null
|
|
return res.json()
|
|
}
|
|
|
|
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 ActivityItem {
|
|
id: string
|
|
target: string
|
|
verb: string
|
|
summary: string
|
|
risk_class: string
|
|
status: string
|
|
duration_ms: number | null
|
|
error?: string
|
|
created_at: string
|
|
completed_at: string | null
|
|
}
|
|
|
|
export async function fetchRecentActivity(limit = 50): Promise<ActivityItem[]> {
|
|
const res = await fetch(`${API}/activity/recent?limit=${limit}`)
|
|
if (!res.ok) return []
|
|
const data = await res.json()
|
|
return data.items ?? []
|
|
}
|
|
|
|
export interface SessionDigest {
|
|
session_id: string
|
|
total_executions: number
|
|
by_status: Record<string, number>
|
|
entities_touched: string[]
|
|
executions: { target: string; verb: string; summary: string; risk_class: string; status: string }[]
|
|
knowledge_created: string[]
|
|
}
|
|
|
|
export async function fetchSessionDigest(sessionId: string): Promise<SessionDigest | null> {
|
|
const res = await fetch(`${API}/activity/session/${sessionId}`)
|
|
if (!res.ok) return null
|
|
return res.json()
|
|
}
|
|
|
|
export interface CapabilityTimelineItem {
|
|
verb: string
|
|
first_success: string | null
|
|
successes: number
|
|
total: number
|
|
}
|
|
|
|
export async function fetchLearningTimeline(): Promise<CapabilityTimelineItem[]> {
|
|
const res = await fetch(`${API}/learning/timeline`)
|
|
if (!res.ok) return []
|
|
const data = await res.json()
|
|
return data.items ?? []
|
|
}
|
|
|
|
export interface TrendBucket {
|
|
day: string
|
|
successes: number
|
|
failures: number
|
|
}
|
|
|
|
export async function fetchLearningTrend(): Promise<TrendBucket[]> {
|
|
const res = await fetch(`${API}/learning/trend`)
|
|
if (!res.ok) return []
|
|
const data = await res.json()
|
|
return data.items ?? []
|
|
}
|
|
|
|
export interface Pattern {
|
|
id: string
|
|
slug: string
|
|
applies_type: string
|
|
action: string
|
|
pattern: string
|
|
confidence: number
|
|
evidence_count: number
|
|
success_count?: number
|
|
failure_count?: number
|
|
status: string
|
|
quarantined?: boolean
|
|
}
|
|
|
|
export async function fetchPatterns(): Promise<Pattern[]> {
|
|
const res = await fetch(`${API}/patterns`)
|
|
if (!res.ok) return []
|
|
const data = await res.json()
|
|
return data.items ?? []
|
|
}
|
|
|
|
export interface Skill {
|
|
id: string
|
|
slug: string
|
|
name: string
|
|
applies_type?: string | null
|
|
action: string
|
|
status: string
|
|
success_rate?: number | null
|
|
last_used_at?: string | null
|
|
}
|
|
|
|
export async function fetchSkills(): Promise<Skill[]> {
|
|
const res = await fetch(`${API}/skills`)
|
|
if (!res.ok) return []
|
|
const data = await res.json()
|
|
return data.items ?? []
|
|
}
|
|
|
|
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
|
|
snippet?: string
|
|
linked_entities?: string[]
|
|
}
|
|
|
|
export interface KnowledgeItem {
|
|
slug: string
|
|
title: string
|
|
kind: 'document' | 'runbook' | 'investigation'
|
|
source: string
|
|
tags: string[]
|
|
updated_at: string
|
|
agent_authored: boolean
|
|
}
|
|
|
|
export interface RecentKnowledge {
|
|
stats: {
|
|
total: number
|
|
by_kind: Record<string, number>
|
|
agent_authored: number
|
|
last_7d: number
|
|
}
|
|
items: KnowledgeItem[]
|
|
}
|
|
|
|
export async function fetchRecentKnowledge(source?: string): Promise<RecentKnowledge> {
|
|
const params = new URLSearchParams()
|
|
if (source) params.set('source', source)
|
|
const res = await fetch(`${API}/knowledge/recent?${params}`)
|
|
if (!res.ok) return { stats: { total: 0, by_kind: {}, agent_authored: 0, last_7d: 0 }, items: [] }
|
|
return res.json()
|
|
}
|
|
|
|
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 ?? []
|
|
}
|