Knowledge Base / Fleet browsing: - EntityTable renders as a treegrid (arbitrary depth, expand/collapse, ARIA row/level/expanded), grouped by parent-child relationships derived entirely from the live ontology graph (cardinality -> direction; typeDepth specificity for ties) rather than a hardcoded relationship list — see loadFleetGrouping in KnowledgeBase.svelte. - Fold Services and Storage categories into Fleet (services/pools/ volumes/datasets now nest under the compute entity or pool that provides/contains them instead of having their own browsing tabs). - Drop `cluster` entities from Fleet browsing so a host's `located-at` (site) relationship wins the tree-parent slot without needing a hardcoded priority override — member-of simply has no valid target left to point at. - Add a "show destroyed/inactive" Switch (default off) filtering on entity.state, replacing an always-on checkbox. Entity detail panel: - Split the Relations section into Outgoing/Incoming groups (relative to the viewed entity), and scope the section's count to edges actually incident to it rather than the whole depth-1 neighborhood. Dev experience: - Auto-fill the SPA's token from the dev server's own OIKOS_API_TOKEN (vite.config.ts define + main.ts, dev-only, only when unconfigured) so the "Connect to Oikos" prompt doesn't reappear on every reload. - .claude/launch.json: autoPort, since port 5173 is often already claimed by another worktree's dev server. Adds ui/checkbox and ui/switch (bits-ui primitives, following the existing shadcn-svelte wrapper pattern) and fetchOntology()/ RelationshipTypeDef to api.ts. Also fixes a missing types.ts import in api.ts (ChatEvent/MessageContent) that predates this branch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
799 lines
24 KiB
TypeScript
799 lines
24 KiB
TypeScript
import { fetchWithAuth } from './config'
|
|
import type { ChatEvent, MessageContent } from './types'
|
|
|
|
export type { ChatEvent }
|
|
|
|
// Path prefixes only — NOT resolved URLs. fetchWithAuth resolves the actual
|
|
// origin (relative vs. configured apiUrl) fresh on every call via
|
|
// config.ts's apiBase(), so these can't be pre-resolved once at module load
|
|
// (the config may not be known yet at import time, e.g. before Config.svelte
|
|
// or a Wails-injected __OIKOS_CONFIG__ runs).
|
|
const BASE = '/agent'
|
|
const API = '/api/v1'
|
|
|
|
// A session IS a task: a goal-structured unit of work with a lifecycle status
|
|
// and an outcome. goal/outcome/summary/entity_id are empty until the agent sets
|
|
// them (see the task-board plan). status defaults to 'active'.
|
|
export interface Session {
|
|
id: string
|
|
title: string
|
|
actor: string
|
|
goal?: string
|
|
status?: string // active | planning | executing | awaiting_input | done | failed | abandoned
|
|
outcome?: string // success | failure | partial
|
|
summary?: string
|
|
entity_id?: string
|
|
pending_approvals?: number
|
|
created_at: string
|
|
last_active_at: string
|
|
}
|
|
|
|
export interface Message {
|
|
id: string
|
|
session_id: string
|
|
role: string
|
|
content: MessageContent | string
|
|
created_at: string
|
|
}
|
|
|
|
export async function fetchSessions(): Promise<Session[]> {
|
|
const res = await fetchWithAuth(`${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 fetchWithAuth(`${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 fetchWithAuth(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
|
|
return res.ok
|
|
}
|
|
|
|
export async function resumeSession(sessionId: string): Promise<boolean> {
|
|
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/resume`, { method: 'POST' })
|
|
return res.ok
|
|
}
|
|
|
|
export interface PlanStep {
|
|
id: string
|
|
seq: number
|
|
title: string
|
|
detail: string
|
|
status: 'pending' | 'running' | 'done' | 'failed' | 'skipped' | 'blocked' | 'replaced'
|
|
generation?: number
|
|
execution_id?: string
|
|
target_slug?: string
|
|
started_at?: string
|
|
finished_at?: string
|
|
}
|
|
|
|
export async function fetchPlan(sessionId: string): Promise<PlanStep[]> {
|
|
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/plan`)
|
|
if (!res.ok) return []
|
|
const data = await res.json()
|
|
return data.steps ?? []
|
|
}
|
|
|
|
export interface SessionQuestion {
|
|
id: string
|
|
prompt: string
|
|
context: { why?: string; options?: string[]; entities?: string[] }
|
|
status: 'open' | 'answered' | 'dismissed'
|
|
answer?: string
|
|
created_at: string
|
|
answered_at?: string
|
|
}
|
|
|
|
export async function fetchQuestions(sessionId: string): Promise<SessionQuestion[]> {
|
|
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/questions`)
|
|
if (!res.ok) return []
|
|
const data = await res.json()
|
|
return data.questions ?? []
|
|
}
|
|
|
|
export async function answerQuestion(sessionId: string, questionId: string, answer: string): Promise<boolean> {
|
|
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ answer })
|
|
})
|
|
return res.ok
|
|
}
|
|
|
|
export function streamChat(
|
|
message: string,
|
|
sessionId: string | null,
|
|
onEvent: (ev: ChatEvent) => void,
|
|
onError: (err: string) => void,
|
|
onDone: () => void
|
|
): AbortController {
|
|
const controller = new AbortController()
|
|
|
|
fetchWithAuth(`${BASE}/chat`, {
|
|
method: 'POST',
|
|
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 fetchWithAuth(`${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
|
|
maintenance_until?: string | null
|
|
}
|
|
|
|
export interface EntityFilters {
|
|
type?: string
|
|
state?: string
|
|
domain?: string
|
|
layer?: 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.domain) params.set('domain', filters.domain)
|
|
if (filters.layer) params.set('layer', filters.layer)
|
|
if (filters.q) params.set('q', filters.q)
|
|
params.set('limit', '200')
|
|
const res = await fetchWithAuth(`${API}/entities?${params}`)
|
|
if (!res.ok) return []
|
|
const data = await res.json()
|
|
return data.items ?? []
|
|
}
|
|
|
|
export type OntologyLayer = 'meta' | 'infrastructure' | 'governance' | 'cognition'
|
|
|
|
export interface EntityType {
|
|
name: string
|
|
parent_type?: string | null
|
|
is_abstract: boolean
|
|
domain: string
|
|
layer: OntologyLayer
|
|
description?: string | null
|
|
}
|
|
|
|
export type RelationshipCardinality = 'one-to-one' | 'one-to-many' | 'many-to-one' | 'many-to-many'
|
|
|
|
export interface RelationshipTypeDef {
|
|
name: string
|
|
inverse?: string | null
|
|
source_type: string
|
|
target_type: string
|
|
cardinality: RelationshipCardinality
|
|
description?: string | null
|
|
}
|
|
|
|
export interface Ontology {
|
|
entityTypes: EntityType[]
|
|
relationshipTypes: RelationshipTypeDef[]
|
|
}
|
|
|
|
export async function fetchOntology(): Promise<Ontology> {
|
|
const res = await fetchWithAuth(`${API}/ontology`)
|
|
if (!res.ok) return { entityTypes: [], relationshipTypes: [] }
|
|
const data = await res.json()
|
|
return { entityTypes: data.entity_types ?? [], relationshipTypes: data.relationship_types ?? [] }
|
|
}
|
|
|
|
// The graph endpoint has no layer param, so callers build a type→layer map from
|
|
// this to scope the graph client-side (the entities table filters server-side).
|
|
export async function fetchEntityTypes(): Promise<EntityType[]> {
|
|
return (await fetchOntology()).entityTypes
|
|
}
|
|
|
|
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 fetchWithAuth(`${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 fetchWithAuth(`${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 fetchWithAuth(`${API}/approvals/${id}/decision`, {
|
|
method: 'POST',
|
|
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 fetchWithAuth(`${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 fetchWithAuth(`${API}/executions/${id}`)
|
|
if (!res.ok) return null
|
|
return res.json()
|
|
}
|
|
|
|
export async function cancelExecution(id: string): Promise<Execution | null> {
|
|
const res = await fetchWithAuth(`${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 fetchWithAuth(`${API}/activity/recent?limit=${limit}`)
|
|
if (!res.ok) return []
|
|
const data = await res.json()
|
|
return data.items ?? []
|
|
}
|
|
|
|
export interface CapabilityTimelineItem {
|
|
verb: string
|
|
first_success: string | null
|
|
successes: number
|
|
total: number
|
|
}
|
|
|
|
export async function fetchLearningTimeline(): Promise<CapabilityTimelineItem[]> {
|
|
const res = await fetchWithAuth(`${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 fetchWithAuth(`${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 fetchWithAuth(`${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 fetchWithAuth(`${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 fetchWithAuth(`${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 fetchWithAuth(`${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 fetchWithAuth(`${API}/signals/${id}/resolve`, {
|
|
method: 'POST',
|
|
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 fetchWithAuth(`${API}/signals/${id}/mute`, {
|
|
method: 'POST',
|
|
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 fetchWithAuth(`${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 fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}/blast-radius`)
|
|
if (!res.ok) return []
|
|
const data = await res.json()
|
|
return data.items ?? []
|
|
}
|
|
|
|
// id is commonly a slug, not a UUID (e.g. "document:infrastructure/network") —
|
|
// slugs can contain '/', which must be percent-encoded or it splits the path
|
|
// into extra segments the router won't match.
|
|
export async function fetchEntity(id: string): Promise<Entity | null> {
|
|
const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(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 fetchWithAuth(`${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 fetchWithAuth(`${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 fetchWithAuth(`${API}/knowledge/${entityId}`)
|
|
if (!res.ok) return []
|
|
const data = await res.json()
|
|
return data.items ?? []
|
|
}
|
|
|
|
export interface KnowledgeContent {
|
|
title: string
|
|
content: string
|
|
source: string
|
|
tags: string[]
|
|
updated_at: string
|
|
}
|
|
|
|
// Full markdown body for a document/investigation/runbook entity — distinct
|
|
// from fetchEntityKnowledge, which returns knowledge that references OTHER
|
|
// entities, not this entity's own content.
|
|
export async function fetchKnowledgeContent(id: string): Promise<KnowledgeContent | null> {
|
|
const res = await fetchWithAuth(`${API}/knowledge/content/${encodeURIComponent(id)}`)
|
|
if (!res.ok) return null
|
|
return res.json()
|
|
}
|
|
|
|
export async function fetchEntityEvents(entityId: string): Promise<import('./stores/events').OikosEvent[]> {
|
|
const params = new URLSearchParams({ entity_id: entityId, limit: '50' })
|
|
const res = await fetchWithAuth(`${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 fetchWithAuth(`${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 fetchWithAuth(`${API}/executions?${params}`)
|
|
if (!res.ok) return []
|
|
const data = await res.json()
|
|
return data.items ?? []
|
|
}
|
|
|
|
export interface EntityTask {
|
|
task: Entity
|
|
executionCount: number
|
|
}
|
|
|
|
// Executions aren't directly browsable from an entity in a useful way — what
|
|
// matters is which task/session acted on it, and how many times. There's no
|
|
// "incoming relationships" endpoint (blast_radius/graph only walks outgoing
|
|
// edges), so this composes it client-side: find executions targeting this
|
|
// entity, then check each task's own outgoing edges (task --involves--> entity
|
|
// directly, or task --involves--> execution) to attribute them. Cheap while
|
|
// the task count is small; would want a dedicated query if that changes.
|
|
export async function fetchEntityTasks(entity: Entity): Promise<EntityTask[]> {
|
|
const [executions, tasks] = await Promise.all([
|
|
fetchEntityExecutions(entity.id),
|
|
fetchEntities({ type: 'task' })
|
|
])
|
|
const executionIds = new Set(executions.map((e) => e.id))
|
|
|
|
const results = await Promise.all(
|
|
tasks.map(async (task): Promise<EntityTask | null> => {
|
|
const g = await fetchGraph({ root: task.slug, depth: 1 })
|
|
if (!g) return null
|
|
const involvesThisEntity = g.edges.some((e) => e.type === 'involves' && e.target === entity.slug)
|
|
const nodeTypeById = new Map(g.nodes.map((n) => [n.id, n.type]))
|
|
const idBySlug = new Map(g.nodes.map((n) => [n.slug, n.id]))
|
|
const executionCount = g.edges.filter((e) => {
|
|
if (e.type !== 'involves') return false
|
|
const targetId = idBySlug.get(e.target)
|
|
return targetId != null && nodeTypeById.get(targetId) === 'execution' && executionIds.has(targetId)
|
|
}).length
|
|
if (!involvesThisEntity && executionCount === 0) return null
|
|
return { task, executionCount }
|
|
})
|
|
)
|
|
return results.filter((r): r is EntityTask => r !== null)
|
|
}
|
|
|
|
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 fetchWithAuth(`${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 fetchWithAuth(`${API}/checks/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { '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 fetchWithAuth(`${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 fetchWithAuth(`${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 fetchWithAuth(`${API}/audit?${params}`)
|
|
if (!res.ok) return []
|
|
const data = await res.json()
|
|
return data.items ?? []
|
|
}
|