feat: global activity feed + session digest
Ops "Executions" tab showed raw target UUIDs, alphabetical (not
recency) order, and a stale status vocabulary from an earlier schema
iteration — never actually usable as a live "what's happening" view.
Replaced with a new recency-ordered /api/v1/activity/recent endpoint
and matching table (human-readable action summaries, risk/status
badges, duration, inline error preview).
Also added /api/v1/activity/session/{id} + a collapsible SessionDigest
panel in the chat rail, answering "what did this session actually do"
(executions by status, entities touched, knowledge written) — the
missing piece for proactive outcome reporting to be visible in the UI,
not just in the chat transcript.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
215
internal/httpapi/activity.go
Normal file
215
internal/httpapi/activity.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// activityItem is one row in the global activity feed — a human-readable
|
||||||
|
// projection of an execution, independent of the paginated/alphabetically-
|
||||||
|
// sorted ListExecutions (which orders by target slug for entity-scoped
|
||||||
|
// browsing, not recency — wrong shape for "what just happened").
|
||||||
|
type activityItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
Verb string `json:"verb"` // e.g. "run", "pct_create", "systemctl"
|
||||||
|
Summary string `json:"summary"` // human-readable: the command, or purpose, or action detail
|
||||||
|
RiskClass string `json:"risk_class"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
DurationMs *int `json:"duration_ms"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
CompletedAt *string `json:"completed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitAction parses the "verb:params" encoding used throughout executions.action
|
||||||
|
// (see internal/mcp/server.go) into a verb and a human-readable summary. For
|
||||||
|
// `run`, params is JSON {command, purpose} — show the purpose if present
|
||||||
|
// (it's written for a human), falling back to the raw command. For other
|
||||||
|
// actions (pct_create, systemctl, apt_upgrade, pct_exec), params is either a
|
||||||
|
// JSON blob or a short flag string — truncate either as a fallback summary.
|
||||||
|
func splitAction(action string) (verb, summary string) {
|
||||||
|
idx := strings.IndexByte(action, ':')
|
||||||
|
if idx < 0 {
|
||||||
|
return action, ""
|
||||||
|
}
|
||||||
|
verb, params := action[:idx], action[idx+1:]
|
||||||
|
if verb == "run" {
|
||||||
|
var p struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Purpose string `json:"purpose"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(params), &p) == nil {
|
||||||
|
if p.Purpose != "" {
|
||||||
|
return verb, p.Purpose
|
||||||
|
}
|
||||||
|
return verb, p.Command
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if verb == "pct_create" {
|
||||||
|
var p struct {
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(params), &p) == nil && p.Hostname != "" {
|
||||||
|
return verb, "provision " + p.Hostname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(params) > 140 {
|
||||||
|
params = params[:140] + "…"
|
||||||
|
}
|
||||||
|
return verb, params
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveRecentActivity backs the Operations page's live activity feed — the
|
||||||
|
// global "what is the system doing / what did it just do" view, recency-
|
||||||
|
// ordered (unlike ListExecutions, which sorts by target for pagination).
|
||||||
|
// Custom route, same shape/rationale as serveRecentKnowledge.
|
||||||
|
func (s *Server) serveRecentActivity(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx := req.Context()
|
||||||
|
limit := 50
|
||||||
|
if l := req.URL.Query().Get("limit"); l != "" {
|
||||||
|
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 200 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT e.entity_id, te.slug, e.action, e.risk_class, e.status,
|
||||||
|
e.duration_ms, e.result, e.created_at::text, e.completed_at::text
|
||||||
|
FROM executions e
|
||||||
|
JOIN entities te ON te.id = e.target_entity_id
|
||||||
|
ORDER BY e.created_at DESC
|
||||||
|
LIMIT $1`, limit)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := []activityItem{}
|
||||||
|
for rows.Next() {
|
||||||
|
var it activityItem
|
||||||
|
var action string
|
||||||
|
var resultBytes []byte
|
||||||
|
var completedAt *string
|
||||||
|
if err := rows.Scan(&it.ID, &it.Target, &action, &it.RiskClass, &it.Status,
|
||||||
|
&it.DurationMs, &resultBytes, &it.CreatedAt, &completedAt); err != nil {
|
||||||
|
slog.Error("httpapi: activity/recent row scan failed", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
it.Verb, it.Summary = splitAction(action)
|
||||||
|
it.CompletedAt = completedAt
|
||||||
|
if len(resultBytes) > 0 {
|
||||||
|
var result map[string]any
|
||||||
|
if json.Unmarshal(resultBytes, &result) == nil {
|
||||||
|
if e, ok := result["error"].(string); ok && e != "" {
|
||||||
|
if len(e) > 200 {
|
||||||
|
e = e[:200] + "…"
|
||||||
|
}
|
||||||
|
it.Error = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = append(items, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionDigestItem summarizes one execution for the session digest.
|
||||||
|
type sessionDigestItem struct {
|
||||||
|
Target string `json:"target"`
|
||||||
|
Verb string `json:"verb"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
RiskClass string `json:"risk_class"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveSessionDigest answers "what did THIS chat session actually do" —
|
||||||
|
// commands run (grouped by outcome), distinct entities touched, and knowledge
|
||||||
|
// written during the session's time window. Uses nomos_plan_executions (the
|
||||||
|
// session<->execution link added for auto-continuation) as the source of
|
||||||
|
// truth for which executions belong to this session; knowledge correlation is
|
||||||
|
// a best-effort time-window match since knowledge_entities has no session_id.
|
||||||
|
func (s *Server) serveSessionDigest(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx := req.Context()
|
||||||
|
sessionID := chi.URLParam(req, "id")
|
||||||
|
if sessionID == "" {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "missing session id", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT te.slug, e.action, e.risk_class, e.status
|
||||||
|
FROM nomos_plan_executions l
|
||||||
|
JOIN executions e ON e.entity_id = l.execution_id
|
||||||
|
JOIN entities te ON te.id = e.target_entity_id
|
||||||
|
WHERE l.session_id = $1
|
||||||
|
ORDER BY e.created_at`, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := []sessionDigestItem{}
|
||||||
|
byStatus := map[string]int{}
|
||||||
|
targets := map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var it sessionDigestItem
|
||||||
|
var action string
|
||||||
|
if err := rows.Scan(&it.Target, &action, &it.RiskClass, &it.Status); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
it.Verb, it.Summary = splitAction(action)
|
||||||
|
items = append(items, it)
|
||||||
|
byStatus[it.Status]++
|
||||||
|
targets[it.Target] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
entityList := make([]string, 0, len(targets))
|
||||||
|
for t := range targets {
|
||||||
|
entityList = append(entityList, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort knowledge correlation: notes the agent wrote during this
|
||||||
|
// session's active window. Not exact (no session_id on knowledge_entities)
|
||||||
|
// but close enough to show "you learned N things in this session".
|
||||||
|
var knowledgeTitles []string
|
||||||
|
krows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT ke.title FROM knowledge_entities ke
|
||||||
|
WHERE ke.source = 'nomos-agent'
|
||||||
|
AND ke.updated_at BETWEEN
|
||||||
|
(SELECT COALESCE(MIN(created_at), now()) FROM agent_messages WHERE session_id = $1)
|
||||||
|
AND
|
||||||
|
(SELECT COALESCE(MAX(created_at), now()) + interval '2 minutes' FROM agent_messages WHERE session_id = $1)
|
||||||
|
ORDER BY ke.updated_at`, sessionID)
|
||||||
|
if err == nil {
|
||||||
|
defer krows.Close()
|
||||||
|
for krows.Next() {
|
||||||
|
var t string
|
||||||
|
if krows.Scan(&t) == nil {
|
||||||
|
knowledgeTitles = append(knowledgeTitles, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if knowledgeTitles == nil {
|
||||||
|
knowledgeTitles = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"total_executions": len(items),
|
||||||
|
"by_status": byStatus,
|
||||||
|
"entities_touched": entityList,
|
||||||
|
"executions": items,
|
||||||
|
"knowledge_created": knowledgeTitles,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -144,6 +144,12 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
|
|||||||
// HandlerWithOptions so it wins over any generated catch-all.
|
// HandlerWithOptions so it wins over any generated catch-all.
|
||||||
r.With(combinedAuth(cfg)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge)
|
r.With(combinedAuth(cfg)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge)
|
||||||
|
|
||||||
|
// Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
|
||||||
|
// unlike ListExecutions which sorts by target for pagination) and the
|
||||||
|
// per-session "what did this session do" digest.
|
||||||
|
r.With(combinedAuth(cfg)).Get("/api/v1/activity/recent", s.serveRecentActivity)
|
||||||
|
r.With(combinedAuth(cfg)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest)
|
||||||
|
|
||||||
// Mount MCP at /mcp (plan R3-10)
|
// Mount MCP at /mcp (plan R3-10)
|
||||||
nomosAgentID := uuid.Nil
|
nomosAgentID := uuid.Nil
|
||||||
if cfg.NomosAgentID != "" {
|
if cfg.NomosAgentID != "" {
|
||||||
|
|||||||
@@ -242,6 +242,41 @@ export async function cancelExecution(id: string): Promise<Execution | null> {
|
|||||||
return res.json()
|
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 Signal {
|
export interface Signal {
|
||||||
id: string
|
id: string
|
||||||
slug: string
|
slug: string
|
||||||
|
|||||||
100
web/src/lib/components/SessionDigest.svelte
Normal file
100
web/src/lib/components/SessionDigest.svelte
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fetchSessionDigest, type SessionDigest } from '$lib/api'
|
||||||
|
import { currentSession, streaming } from '$lib/stores/chat'
|
||||||
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||||
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||||
|
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||||
|
|
||||||
|
let digest = $state<SessionDigest | null>(null)
|
||||||
|
let open = $state(false)
|
||||||
|
let loadedFor = $state<string | null>(null)
|
||||||
|
|
||||||
|
// Reload the digest whenever the session changes or a stream finishes —
|
||||||
|
// "what did this session actually do" is only meaningful once executions
|
||||||
|
// have had a chance to land.
|
||||||
|
$effect(() => {
|
||||||
|
const sid = $currentSession
|
||||||
|
const busy = $streaming
|
||||||
|
if (!sid || busy) return
|
||||||
|
if (loadedFor === sid) return
|
||||||
|
loadedFor = sid
|
||||||
|
fetchSessionDigest(sid).then((d) => (digest = d))
|
||||||
|
})
|
||||||
|
|
||||||
|
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||||
|
if (['failed', 'denied', 'revoked', 'cancelled'].includes(status)) return 'destructive'
|
||||||
|
if (status === 'completed') return 'default'
|
||||||
|
if (['running', 'approved'].includes(status)) return 'secondary'
|
||||||
|
return 'outline'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if digest && digest.total_executions > 0}
|
||||||
|
<div class="border-b">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-muted/50"
|
||||||
|
onclick={() => (open = !open)}
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
{#if open}<ChevronDownIcon class="size-3.5" />{:else}<ChevronRightIcon class="size-3.5" />{/if}
|
||||||
|
This session
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
{digest.total_executions} action{digest.total_executions === 1 ? '' : 's'}
|
||||||
|
{#if digest.knowledge_created.length}
|
||||||
|
<span class="flex items-center gap-0.5 text-primary">
|
||||||
|
<SparklesIcon class="size-3" />{digest.knowledge_created.length}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="flex flex-col gap-3 px-3 pb-3 text-xs">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each Object.entries(digest.by_status) as [status, count]}
|
||||||
|
<Badge variant={statusVariant(status)}>{status} × {count}</Badge>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if digest.entities_touched.length}
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 text-muted-foreground">Entities touched</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each digest.entities_touched as target}
|
||||||
|
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">{target}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
{#each digest.executions as ex}
|
||||||
|
<div class="flex items-start justify-between gap-2 rounded border px-2 py-1">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-mono text-[11px] text-muted-foreground">{ex.target}</div>
|
||||||
|
<div class="truncate">{ex.summary || ex.verb}</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={statusVariant(ex.status)} class="shrink-0">{ex.status}</Badge>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if digest.knowledge_created.length}
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 flex items-center gap-1 text-primary">
|
||||||
|
<SparklesIcon class="size-3" />Learned this session
|
||||||
|
</div>
|
||||||
|
<ul class="list-inside list-disc">
|
||||||
|
{#each digest.knowledge_created as title}
|
||||||
|
<li>{title}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
|
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
|
||||||
import SessionRail from '$lib/components/SessionRail.svelte'
|
import SessionRail from '$lib/components/SessionRail.svelte'
|
||||||
import SessionGraph from '$lib/components/SessionGraph.svelte'
|
import SessionGraph from '$lib/components/SessionGraph.svelte'
|
||||||
|
import SessionDigest from '$lib/components/SessionDigest.svelte'
|
||||||
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
|
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
|
||||||
import InlineApproval from '$lib/components/InlineApproval.svelte'
|
import InlineApproval from '$lib/components/InlineApproval.svelte'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
@@ -188,8 +189,11 @@
|
|||||||
: 'bg-border group-hover/rz:bg-primary/50'}"
|
: 'bg-border group-hover/rz:bg-primary/50'}"
|
||||||
></span>
|
></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
<SessionGraph />
|
<SessionDigest />
|
||||||
|
<div class="min-h-0 flex-1">
|
||||||
|
<SessionGraph />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
import {
|
import {
|
||||||
fetchApprovals,
|
fetchApprovals,
|
||||||
decideApproval,
|
decideApproval,
|
||||||
fetchExecutions,
|
fetchRecentActivity,
|
||||||
cancelExecution,
|
cancelExecution,
|
||||||
type Approval,
|
type Approval,
|
||||||
type Execution
|
type ActivityItem
|
||||||
} from '$lib/api'
|
} from '$lib/api'
|
||||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||||
import * as Tabs from '$lib/components/ui/tabs'
|
import * as Tabs from '$lib/components/ui/tabs'
|
||||||
@@ -16,30 +16,55 @@
|
|||||||
import { toast } from 'svelte-sonner'
|
import { toast } from 'svelte-sonner'
|
||||||
|
|
||||||
let approvals = $state<Approval[]>([])
|
let approvals = $state<Approval[]>([])
|
||||||
let executions = $state<Execution[]>([])
|
let activity = $state<ActivityItem[]>([])
|
||||||
let deciding = $state<string | null>(null)
|
let deciding = $state<string | null>(null)
|
||||||
|
|
||||||
async function loadApprovals() {
|
async function loadApprovals() {
|
||||||
approvals = await fetchApprovals()
|
approvals = await fetchApprovals()
|
||||||
}
|
}
|
||||||
async function loadExecutions() {
|
async function loadActivity() {
|
||||||
executions = await fetchExecutions()
|
activity = await fetchRecentActivity()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadApprovals()
|
loadApprovals()
|
||||||
loadExecutions()
|
loadActivity()
|
||||||
const unsubscribe = subscribeEvents()
|
const unsubscribe = subscribeEvents()
|
||||||
return unsubscribe
|
// The activity feed has no dedicated SSE event type yet — a light poll
|
||||||
|
// keeps it live without waiting for that wiring. Cheap: one query, only
|
||||||
|
// while this page is open.
|
||||||
|
const interval = setInterval(loadActivity, 5000)
|
||||||
|
return () => {
|
||||||
|
unsubscribe()
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const ev = $liveEvents[0]
|
const ev = $liveEvents[0]
|
||||||
if (!ev) return
|
if (!ev) return
|
||||||
if (ev.type.startsWith('approval.')) loadApprovals()
|
if (ev.type.startsWith('approval.')) loadApprovals()
|
||||||
if (ev.type.startsWith('execution.')) loadExecutions()
|
if (ev.type.startsWith('execution.')) loadActivity()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function fmtDuration(ms: number | null): string {
|
||||||
|
if (ms == null) return '—'
|
||||||
|
if (ms < 1000) return `${ms}ms`
|
||||||
|
const s = Math.round(ms / 1000)
|
||||||
|
if (s < 60) return `${s}s`
|
||||||
|
return `${Math.floor(s / 60)}m ${s % 60}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtWhen(iso: string): string {
|
||||||
|
const d = new Date(iso).getTime()
|
||||||
|
if (!d) return ''
|
||||||
|
const s = Math.round((Date.now() - d) / 1000)
|
||||||
|
if (s < 60) return 'just now'
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}m ago`
|
||||||
|
if (s < 86400) return `${Math.floor(s / 3600)}h ago`
|
||||||
|
return `${Math.floor(s / 86400)}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
async function decide(id: string, decision: 'approve' | 'deny') {
|
async function decide(id: string, decision: 'approve' | 'deny') {
|
||||||
deciding = id
|
deciding = id
|
||||||
const result = await decideApproval(id, decision)
|
const result = await decideApproval(id, decision)
|
||||||
@@ -56,22 +81,26 @@
|
|||||||
const result = await cancelExecution(id)
|
const result = await cancelExecution(id)
|
||||||
if (result) {
|
if (result) {
|
||||||
toast.success('Execution cancelled')
|
toast.success('Execution cancelled')
|
||||||
loadExecutions()
|
loadActivity()
|
||||||
} else {
|
} else {
|
||||||
toast.error('Cancel failed')
|
toast.error('Cancel failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function riskVariant(risk: string): 'default' | 'secondary' | 'destructive' {
|
function riskVariant(risk: string): 'default' | 'secondary' | 'destructive' {
|
||||||
if (risk === 'high' || risk === 'critical') return 'destructive'
|
if (risk === 'destructive') return 'destructive'
|
||||||
if (risk === 'medium') return 'secondary'
|
if (risk === 'config_mutation') return 'secondary'
|
||||||
return 'default'
|
return 'default'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Real status vocabulary (internal/httpapi/phase3.go, cmd/nomos): the
|
||||||
|
// previous version checked statuses ('proposed', 'auto_approved',
|
||||||
|
// 'verified', 'executing'...) that don't exist anywhere in the actual
|
||||||
|
// schema — this table was never actually color-coding correctly.
|
||||||
function execStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
function execStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||||
if (['failed', 'timed_out', 'rollback_failed', 'denied'].includes(status)) return 'destructive'
|
if (['failed', 'denied', 'revoked', 'cancelled'].includes(status)) return 'destructive'
|
||||||
if (['verified', 'auto_approved'].includes(status)) return 'default'
|
if (status === 'completed') return 'default'
|
||||||
if (['executing', 'verifying'].includes(status)) return 'secondary'
|
if (['running', 'approved'].includes(status)) return 'secondary'
|
||||||
return 'outline'
|
return 'outline'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +116,7 @@
|
|||||||
<Tabs.Trigger value="approvals">
|
<Tabs.Trigger value="approvals">
|
||||||
Approvals {#if pendingApprovals.length}<Badge variant="destructive" class="ml-1">{pendingApprovals.length}</Badge>{/if}
|
Approvals {#if pendingApprovals.length}<Badge variant="destructive" class="ml-1">{pendingApprovals.length}</Badge>{/if}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger value="executions">Executions</Tabs.Trigger>
|
<Tabs.Trigger value="executions">Activity</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Content value="approvals" class="flex-1 overflow-auto">
|
<Tabs.Content value="approvals" class="flex-1 overflow-auto">
|
||||||
@@ -168,31 +197,39 @@
|
|||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head>Target</Table.Head>
|
<Table.Head>Target</Table.Head>
|
||||||
<Table.Head>Action</Table.Head>
|
<Table.Head>Action</Table.Head>
|
||||||
|
<Table.Head>Risk</Table.Head>
|
||||||
<Table.Head>Status</Table.Head>
|
<Table.Head>Status</Table.Head>
|
||||||
<Table.Head>Correlation</Table.Head>
|
<Table.Head>Duration</Table.Head>
|
||||||
<Table.Head>Started</Table.Head>
|
<Table.Head>When</Table.Head>
|
||||||
<Table.Head class="text-right">Actions</Table.Head>
|
<Table.Head class="text-right">Actions</Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each executions as execution (execution.id)}
|
{#each activity as item (item.id)}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="font-mono text-xs">{execution.target ?? '—'}</Table.Cell>
|
<Table.Cell class="font-mono text-xs">{item.target ?? '—'}</Table.Cell>
|
||||||
<Table.Cell>{execution.action}</Table.Cell>
|
<Table.Cell>
|
||||||
<Table.Cell><Badge variant={execStatusVariant(execution.status)}>{execution.status}</Badge></Table.Cell>
|
<div>{item.verb}</div>
|
||||||
<Table.Cell class="font-mono text-xs text-muted-foreground">{execution.correlation_id}</Table.Cell>
|
{#if item.summary}
|
||||||
<Table.Cell class="text-xs text-muted-foreground"
|
<div class="text-xs text-muted-foreground">{item.summary}</div>
|
||||||
>{execution.started_at ? new Date(execution.started_at).toLocaleString() : '—'}</Table.Cell
|
{/if}
|
||||||
>
|
{#if item.error}
|
||||||
|
<div class="text-xs text-destructive">{item.error}</div>
|
||||||
|
{/if}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell><Badge variant={riskVariant(item.risk_class)}>{item.risk_class}</Badge></Table.Cell>
|
||||||
|
<Table.Cell><Badge variant={execStatusVariant(item.status)}>{item.status}</Badge></Table.Cell>
|
||||||
|
<Table.Cell class="text-xs text-muted-foreground">{fmtDuration(item.duration_ms)}</Table.Cell>
|
||||||
|
<Table.Cell class="text-xs text-muted-foreground">{fmtWhen(item.created_at)}</Table.Cell>
|
||||||
<Table.Cell class="text-right">
|
<Table.Cell class="text-right">
|
||||||
{#if ['proposed', 'approved', 'auto_approved', 'executing'].includes(execution.status)}
|
{#if ['pending_approval', 'approved', 'running'].includes(item.status)}
|
||||||
<Button size="sm" variant="outline" onclick={() => cancel(execution.id)}>Cancel</Button>
|
<Button size="sm" variant="outline" onclick={() => cancel(item.id)}>Cancel</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{:else}
|
{:else}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell colspan={6} class="text-center text-muted-foreground">No executions yet.</Table.Cell>
|
<Table.Cell colspan={7} class="text-center text-muted-foreground">No activity yet.</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
|
|||||||
Reference in New Issue
Block a user