- Plan/Activity panels: humanize step titles, richer icons, empty states matching Scope's illustration style, pretty-printed expandable detail - Activity log renamed to Event log; every step now expandable - Chat: middle-truncate header title, remove redundant task-list rail and header stat cluster (duplicated in the sidebar), simplify markdown styling - Fix --font-mono actually being a monospace font (was aliased to DM Sans) - Replace rotating loader-circle spinner with a smoother fading-blade Spinner - SessionGraph entity detail panel: resizable and self-clamping against its live container size (was overflowing into sibling sections), close button - Dev launch config: fetch bearer token from the running api container so `npm run dev` works against the local compose stack without a hardcoded secret in a tracked file Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
262 lines
12 KiB
Svelte
262 lines
12 KiB
Svelte
<script lang="ts">
|
|
import type { PendingApproval } from '$lib/stores/chat'
|
|
import { decideApproval, getExecution, fetchBlastRadius, type Execution } from '$lib/api'
|
|
import { Button } from '$lib/components/ui/button'
|
|
import { SvelteMap } from 'svelte/reactivity'
|
|
import CheckIcon from '@lucide/svelte/icons/check'
|
|
import XIcon from '@lucide/svelte/icons/x'
|
|
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
|
import Spinner from './Spinner.svelte'
|
|
import NetworkIcon from '@lucide/svelte/icons/network'
|
|
|
|
let { approvals }: { approvals: PendingApproval[] } = $props()
|
|
|
|
// Downstream entities the target affects, keyed by executionId — fetched
|
|
// once per approval so the operator sees the graph-walk impact ("this
|
|
// affects 3 downstream") before deciding, not after. depth 0 is the target
|
|
// itself, excluded here since it's already shown as "on {target}".
|
|
const blastRadius = new SvelteMap<string, string[]>()
|
|
const blastRadiusFetched = new Set<string>()
|
|
async function loadBlastRadius(a: PendingApproval) {
|
|
if (blastRadiusFetched.has(a.executionId) || a.target === 'unknown') return
|
|
blastRadiusFetched.add(a.executionId)
|
|
const items = await fetchBlastRadius(a.target)
|
|
const affected = items.filter((i) => i.depth > 0).map((i) => i.entity.slug)
|
|
if (affected.length) blastRadius.set(a.executionId, affected)
|
|
}
|
|
|
|
// Per-execution UI phase, keyed by executionId. A resolved phase hides the
|
|
// action buttons permanently so the banner clears after a click and can
|
|
// never re-POST /decision.
|
|
type Phase = 'deciding' | 'running' | 'completed' | 'failed' | 'denied' | 'stalled'
|
|
const phase = new SvelteMap<string, Phase>()
|
|
// Latest execution row (for status/result display), keyed by executionId.
|
|
const exec = new SvelteMap<string, Execution>()
|
|
|
|
const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'denied'])
|
|
|
|
function errorText(e: Execution | undefined): string {
|
|
const r = e?.result as Record<string, unknown> | undefined | null
|
|
const v = r?.error
|
|
return typeof v === 'string' && v ? v : 'Execution failed.'
|
|
}
|
|
|
|
function outputText(e: Execution | undefined): string {
|
|
const r = e?.result as Record<string, unknown> | undefined | null
|
|
const v = r?.output
|
|
return typeof v === 'string' ? v.trim() : ''
|
|
}
|
|
|
|
function elapsedSeconds(e: Execution | undefined): number | null {
|
|
if (!e?.created_at) return null
|
|
return Math.max(0, Math.round((now - new Date(e.created_at).getTime()) / 1000))
|
|
}
|
|
|
|
function fmtDuration(s: number): string {
|
|
if (s < 60) return `${s}s`
|
|
const m = Math.floor(s / 60)
|
|
return `${m}m ${s % 60}s`
|
|
}
|
|
|
|
// Live clock for the elapsed-time display on running cards. Tied to
|
|
// component lifecycle via $effect so the interval is guaranteed cleared on
|
|
// unmount — a bare setInterval field here would leak a 1Hz timer for the
|
|
// lifetime of the page every time this component was mounted.
|
|
let now = $state(Date.now())
|
|
$effect(() => {
|
|
const t = setInterval(() => { now = Date.now() }, 1000)
|
|
return () => clearInterval(t)
|
|
})
|
|
|
|
// Backend commands are hard-capped at 10 minutes (internal sshExec
|
|
// timeout) before the execution is force-finalized as failed — so polling
|
|
// must outlast that with margin, or the UI gives up and goes stale before
|
|
// the backend ever resolves. Poll for 14 minutes; anything still running
|
|
// past that is a genuine anomaly worth surfacing distinctly rather than
|
|
// silently going quiet.
|
|
const POLL_CEILING_MS = 14 * 60 * 1000
|
|
|
|
// Poll the execution until it reaches a terminal state, so the operator sees
|
|
// provisioning progress and the final outcome without leaving the chat.
|
|
async function track(id: string) {
|
|
const deadline = Date.now() + POLL_CEILING_MS
|
|
while (Date.now() < deadline) {
|
|
const e = await getExecution(id)
|
|
if (e) {
|
|
exec.set(id, e)
|
|
if (e.status === 'completed') { phase.set(id, 'completed'); return }
|
|
if (e.status === 'failed' || e.status === 'cancelled') { phase.set(id, 'failed'); return }
|
|
if (e.status === 'denied' || e.status === 'revoked') { phase.set(id, 'denied'); return }
|
|
}
|
|
await new Promise((r) => setTimeout(r, 2500))
|
|
}
|
|
// Genuinely outlasted the backend's own hard timeout — this means
|
|
// something is wrong beyond a slow command (e.g. the API is down).
|
|
// Say so explicitly instead of freezing on "running" with no signal.
|
|
if (!TERMINAL.has(exec.get(id)?.status ?? '')) phase.set(id, 'stalled')
|
|
}
|
|
|
|
async function decide(approval: PendingApproval, decision: 'approve' | 'deny') {
|
|
const id = approval.executionId
|
|
const cur = phase.get(id)
|
|
if (cur && cur !== 'failed') return // no resubmit once decided/in-flight
|
|
phase.set(id, 'deciding')
|
|
const ok = await decideApproval(id, decision)
|
|
if (!ok) { phase.set(id, 'failed'); return }
|
|
if (decision === 'deny') { phase.set(id, 'denied'); return }
|
|
phase.set(id, 'running')
|
|
void track(id)
|
|
}
|
|
|
|
// Self-heal: a pending approval can be decided somewhere other than this
|
|
// button — chat assent ("go ahead" in the next message), the Ops page, or
|
|
// Matrix. Without this, the banner would sit showing Approve/Deny forever
|
|
// while the action was already running or done behind the scenes. Poll
|
|
// every card that's still showing buttons; the moment its execution leaves
|
|
// pending_approval, adopt that outcome exactly as if the button had been
|
|
// clicked. Stops immediately if the operator clicks the button first
|
|
// (phase becomes non-empty, ending this loop's reason to exist).
|
|
const watching = new Set<string>()
|
|
async function watchExternal(id: string) {
|
|
if (watching.has(id)) return
|
|
watching.add(id)
|
|
for (let i = 0; i < 200; i++) { // ~10min ceiling at 3s
|
|
if (phase.get(id)) return // resolved locally (button click) or already picked up
|
|
const e = await getExecution(id)
|
|
if (e && e.status !== 'pending_approval') {
|
|
exec.set(id, e)
|
|
if (e.status === 'completed') { phase.set(id, 'completed'); return }
|
|
if (e.status === 'failed' || e.status === 'cancelled') { phase.set(id, 'failed'); return }
|
|
if (e.status === 'denied' || e.status === 'revoked') { phase.set(id, 'denied'); return }
|
|
// 'approved' or 'running': someone said yes elsewhere — switch to
|
|
// the same tracking the button click would have started.
|
|
phase.set(id, 'running')
|
|
void track(id)
|
|
return
|
|
}
|
|
await new Promise((r) => setTimeout(r, 3000))
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
for (const a of approvals) {
|
|
if (!phase.get(a.executionId)) {
|
|
void watchExternal(a.executionId)
|
|
void loadBlastRadius(a)
|
|
}
|
|
}
|
|
})
|
|
</script>
|
|
|
|
{#each approvals as approval (approval.executionId)}
|
|
{@const p = phase.get(approval.executionId)}
|
|
{@const e = exec.get(approval.executionId)}
|
|
{#if p === 'completed'}
|
|
<div class="my-2 flex flex-col gap-1 rounded-lg border border-success/40 bg-success/5 px-3 py-2 text-xs text-success">
|
|
<div class="flex items-center gap-2">
|
|
<CheckIcon class="size-4 shrink-0" />
|
|
<span>Completed{e?.duration_ms ? ` in ${Math.round(e.duration_ms / 1000)}s` : ''} on {approval.target}.</span>
|
|
</div>
|
|
{#if outputText(e)}
|
|
<pre class="max-h-32 overflow-y-auto whitespace-pre-wrap break-words pl-6 opacity-80">{outputText(e)}</pre>
|
|
{/if}
|
|
</div>
|
|
{:else if p === 'failed'}
|
|
<div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
|
<div class="flex items-center gap-2">
|
|
<XIcon class="size-4 shrink-0" />
|
|
<span class="font-medium">Execution failed</span>
|
|
<Button size="sm" variant="outline" class="ml-auto h-6 px-2 text-xs" onclick={() => decide(approval, 'approve')}>Retry</Button>
|
|
</div>
|
|
<pre class="whitespace-pre-wrap break-words pl-6 opacity-90">{errorText(e)}</pre>
|
|
</div>
|
|
{:else if p === 'denied'}
|
|
<div class="my-2 flex items-center gap-2 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
|
<XIcon class="size-4" /><span>Denied.</span>
|
|
</div>
|
|
{:else if p === 'running' || p === 'deciding'}
|
|
{@const secs = elapsedSeconds(e)}
|
|
<div class="my-2 flex flex-col gap-1 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2 text-xs text-muted-foreground">
|
|
<div class="flex items-center gap-2">
|
|
<Spinner class="size-4 shrink-0 text-warning" />
|
|
<span>
|
|
{#if p === 'deciding'}
|
|
Submitting approval…
|
|
{:else}
|
|
Running on {approval.target}{secs !== null ? ` — ${fmtDuration(secs)} elapsed` : '…'}
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
{#if p === 'running' && approval.command}
|
|
<code class="ml-6 block truncate opacity-70">{approval.command}</code>
|
|
{/if}
|
|
{#if p === 'running'}
|
|
<span class="ml-6 opacity-60">
|
|
Execution <code>{approval.executionId.slice(0, 8)}</code> — long installs can take several minutes; this
|
|
will resolve on its own (capped at 10 min) or you can check the Operations page for live output.
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{:else if p === 'stalled'}
|
|
<div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
|
<div class="flex items-center gap-2">
|
|
<XIcon class="size-4 shrink-0" />
|
|
<span class="font-medium">No update from the server in over 14 minutes.</span>
|
|
<Button size="sm" variant="outline" class="ml-auto h-6 px-2 text-xs" onclick={() => { phase.delete(approval.executionId); void track(approval.executionId) }}>
|
|
Check again
|
|
</Button>
|
|
</div>
|
|
<span class="pl-6 opacity-90">
|
|
The command itself is capped at 10 minutes server-side, so this is unusual — the API may be unreachable.
|
|
Execution <code>{approval.executionId}</code>. Check the Operations page directly.
|
|
</span>
|
|
</div>
|
|
{:else if approval.destructive}
|
|
{@const affected = blastRadius.get(approval.executionId)}
|
|
<div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2">
|
|
<div class="flex items-center gap-2">
|
|
<ShieldCheckIcon class="size-4 shrink-0 text-destructive" />
|
|
<span class="flex-1 text-xs text-destructive">
|
|
<strong>DESTRUCTIVE</strong> — {approval.action} on {approval.target}. Type
|
|
"I confirm" in chat, or use the button.
|
|
</span>
|
|
<Button size="sm" variant="destructive" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
|
|
<CheckIcon class="size-3" /><span class="ml-1">Confirm</span>
|
|
</Button>
|
|
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'deny')}>
|
|
<XIcon class="size-3" /><span class="ml-1">Deny</span>
|
|
</Button>
|
|
</div>
|
|
{#if approval.command}
|
|
<code class="ml-6 block truncate text-xs text-destructive/80">{approval.command}</code>
|
|
{/if}
|
|
{#if affected}
|
|
<div class="ml-6 flex items-start gap-1.5 text-xs text-destructive/90">
|
|
<NetworkIcon class="mt-0.5 size-3 shrink-0" />
|
|
<span>Affects {affected.length} downstream: {affected.join(', ')}</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
{@const affected = blastRadius.get(approval.executionId)}
|
|
<div class="my-2 flex flex-col gap-1 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2">
|
|
<div class="flex items-center gap-2">
|
|
<ShieldCheckIcon class="size-4 shrink-0 text-warning" />
|
|
<span class="flex-1 text-xs text-muted-foreground">{approval.action} on {approval.target} requires approval</span>
|
|
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
|
|
<CheckIcon class="size-3" /><span class="ml-1">Approve</span>
|
|
</Button>
|
|
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'deny')}>
|
|
<XIcon class="size-3" /><span class="ml-1">Deny</span>
|
|
</Button>
|
|
</div>
|
|
{#if affected}
|
|
<div class="ml-6 flex items-start gap-1.5 text-xs text-warning">
|
|
<NetworkIcon class="mt-0.5 size-3 shrink-0" />
|
|
<span>Affects {affected.length} downstream: {affected.join(', ')}</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/each}
|