feat(ui): M4 — agent activity, knowledge search, audit, correlation grouping
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.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
const BASE = '/agent'
|
||||
const API = '/api/v1'
|
||||
|
||||
export interface Session {
|
||||
id: string
|
||||
@@ -90,3 +91,384 @@ 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 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
|
||||
}
|
||||
|
||||
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 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 ?? []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user