feat(web): redesign Overview as the homepage with a living graph backdrop
Overview replaces Tasks as the default route: a centered new-task entry with live fleet metrics, a scrollable/filterable task table, and an ambient canvas rendering of the real entity graph (autonomous camera drift + mouse parallax) behind it. Tasks sidebar entry is removed; its status-bucketing logic moves to lib/tasks.ts for reuse. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,64 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { fetchDashboardSummary, type DashboardSummary } from '$lib/api'
|
||||
import { sessions, loadSessions, loadSessionMessages, newChat, sendMessage } from '$lib/stores/chat'
|
||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||
import * as Card from '$lib/components/ui/card'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||
import { bucket, statusStyle, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import GraphBackground from '$lib/components/GraphBackground.svelte'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
||||
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert'
|
||||
import OctagonXIcon from '@lucide/svelte/icons/octagon-x'
|
||||
|
||||
// ── Dashboard metrics (ported from the old Overview cards) ──────────────
|
||||
let summary = $state<DashboardSummary | null>(null)
|
||||
let loading = $state(true)
|
||||
|
||||
async function load() {
|
||||
async function loadSummary() {
|
||||
summary = await fetchDashboardSummary()
|
||||
loading = false
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load()
|
||||
const unsubscribe = subscribeEvents()
|
||||
const interval = setInterval(load, 15000)
|
||||
return () => {
|
||||
unsubscribe()
|
||||
clearInterval(interval)
|
||||
}
|
||||
})
|
||||
|
||||
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
|
||||
if (sev === 'critical') return 'destructive'
|
||||
if (sev === 'warning') return 'secondary'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
const degradedTypes = $derived(
|
||||
summary
|
||||
? [
|
||||
{ label: 'Degraded', count: summary.health.degraded, tone: 'text-warning' as const },
|
||||
{ label: 'Down', count: summary.health.down, tone: 'text-destructive' as const }
|
||||
].filter((t) => t.count > 0)
|
||||
: []
|
||||
)
|
||||
|
||||
const maxEventRate = $derived(
|
||||
summary?.event_rate.length ? Math.max(...summary.event_rate.map((b) => b.count), 1) : 1
|
||||
)
|
||||
|
||||
const totalEntities = $derived(
|
||||
summary ? Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0) : 0
|
||||
)
|
||||
const topTypes = $derived(
|
||||
summary
|
||||
? Object.entries(summary.entities_by_type)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
: []
|
||||
)
|
||||
const entityTypeCount = $derived(summary ? Object.keys(summary.entities_by_type).length : 0)
|
||||
|
||||
const totalMonitored = $derived(
|
||||
summary
|
||||
? summary.health.healthy + summary.health.degraded + summary.health.down + summary.health.unknown
|
||||
@@ -67,7 +31,6 @@
|
||||
const healthTone = $derived(
|
||||
!summary ? 'ok' : summary.health.down > 0 ? 'down' : summary.health.degraded > 0 ? 'degraded' : 'ok'
|
||||
)
|
||||
|
||||
const totalSignals = $derived(
|
||||
summary ? Object.values(summary.signals_by_severity).reduce((a, b) => a + b, 0) : 0
|
||||
)
|
||||
@@ -79,174 +42,221 @@
|
||||
: 'none'
|
||||
)
|
||||
|
||||
const executionsRunning = $derived(summary?.executions_by_state.running ?? 0)
|
||||
const executionsFailed = $derived(summary?.executions_by_state.failed ?? 0)
|
||||
// ── Task board ──────────────────────────────────────────────────────────
|
||||
let filter = $state<'all' | Bucket>('all')
|
||||
|
||||
const counts = $derived.by(() => {
|
||||
const c: Record<string, number> = { all: $sessions.length, running: 0, input: 0, done: 0, failed: 0 }
|
||||
for (const s of $sessions) c[bucket(s)]++
|
||||
return c
|
||||
})
|
||||
const visible = $derived(
|
||||
filter === 'all' ? $sessions : $sessions.filter((s) => bucket(s) === filter)
|
||||
)
|
||||
|
||||
function openTask(id: string) {
|
||||
loadSessionMessages(id)
|
||||
location.hash = '#/chat'
|
||||
}
|
||||
|
||||
// ── New task entry ──────────────────────────────────────────────────────
|
||||
let input = $state('')
|
||||
function submit() {
|
||||
const text = input.trim()
|
||||
if (!text) return
|
||||
input = ''
|
||||
newChat()
|
||||
sendMessage(text)
|
||||
location.hash = '#/chat'
|
||||
}
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadSummary()
|
||||
loadSessions()
|
||||
const unsubStream = subscribeEvents()
|
||||
const summaryTimer = setInterval(loadSummary, 15000)
|
||||
|
||||
// Refetch the board when a task's lifecycle changes anywhere. Scan all
|
||||
// events newer than the last seen (entity.touched fires constantly and
|
||||
// buries task.status); debounce a burst into one refetch.
|
||||
let lastSeenId = 0
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const unsub = liveEvents.subscribe((evs) => {
|
||||
if (evs.length === 0) return
|
||||
const maxId = evs[0].id
|
||||
if (maxId <= lastSeenId) return
|
||||
const relevant = evs.some((e) => e.id > lastSeenId && TASK_EVENTS.has(e.type))
|
||||
lastSeenId = maxId
|
||||
if (relevant) {
|
||||
if (refreshTimer) clearTimeout(refreshTimer)
|
||||
refreshTimer = setTimeout(() => loadSessions(), 400)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsub()
|
||||
unsubStream()
|
||||
clearInterval(summaryTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="@container/main flex h-full flex-col gap-4 overflow-y-auto p-4 md:p-6">
|
||||
<h1 class="text-lg font-semibold">Overview</h1>
|
||||
<div class="relative h-full overflow-hidden">
|
||||
<GraphBackground />
|
||||
|
||||
{#if loading}
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{#each Array(4) as _}
|
||||
<Skeleton class="h-28 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if summary}
|
||||
<div
|
||||
class="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs @xl/main:grid-cols-2 @5xl/main:grid-cols-4"
|
||||
>
|
||||
<Card.Root class="@container/card">
|
||||
<Card.Header>
|
||||
<Card.Description>Entities</Card.Description>
|
||||
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{totalEntities}
|
||||
</Card.Title>
|
||||
<Card.Action>
|
||||
<Badge variant="outline">{entityTypeCount} types</Badge>
|
||||
</Card.Action>
|
||||
</Card.Header>
|
||||
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
|
||||
<div class="line-clamp-1 flex flex-wrap gap-x-1.5 font-medium">
|
||||
{#each topTypes as [type, count]}
|
||||
<span class="text-muted-foreground">{type}: <span class="text-foreground">{count}</span></span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="text-muted-foreground">Across the fleet</div>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root class="@container/card">
|
||||
<Card.Header>
|
||||
<Card.Description>Fleet health</Card.Description>
|
||||
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{summary.health.healthy} / {totalMonitored}
|
||||
</Card.Title>
|
||||
<Card.Action>
|
||||
{#if healthTone === 'ok'}
|
||||
<Badge variant="outline"><CircleCheckIcon class="text-success" />healthy</Badge>
|
||||
{:else if healthTone === 'degraded'}
|
||||
<Badge variant="outline"><TriangleAlertIcon class="text-warning" />degraded</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline"><OctagonXIcon class="text-destructive" />down</Badge>
|
||||
{/if}
|
||||
</Card.Action>
|
||||
</Card.Header>
|
||||
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
|
||||
<div class="line-clamp-1 flex gap-2 font-medium">
|
||||
{summary.health.degraded} degraded · {summary.health.down} down · {summary.health.unknown} unmonitored
|
||||
</div>
|
||||
<div class="text-muted-foreground">Healthy entities as observed by the scheduler</div>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
<button type="button" class="text-left" onclick={() => (location.hash = '#/signals')}>
|
||||
<Card.Root class="@container/card transition-colors hover:border-primary/50">
|
||||
<Card.Header>
|
||||
<Card.Description>Open signals</Card.Description>
|
||||
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{totalSignals}
|
||||
</Card.Title>
|
||||
<Card.Action>
|
||||
{#if worstSeverity === 'critical'}
|
||||
<Badge variant="destructive"><TriangleAlertIcon />critical</Badge>
|
||||
{:else if worstSeverity === 'warning'}
|
||||
<Badge variant="secondary"><TriangleAlertIcon />warning</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
|
||||
{/if}
|
||||
</Card.Action>
|
||||
</Card.Header>
|
||||
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
|
||||
<div class="line-clamp-1 flex flex-wrap gap-x-1.5 font-medium">
|
||||
{#each Object.entries(summary.signals_by_severity) as [severity, count]}
|
||||
<span class="text-muted-foreground">{severity}: <span class="text-foreground">{count}</span></span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="text-muted-foreground">Unresolved right now</div>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</button>
|
||||
|
||||
<button type="button" class="text-left" onclick={() => (location.hash = '#/ops')}>
|
||||
<Card.Root class="@container/card transition-colors hover:border-primary/50">
|
||||
<Card.Header>
|
||||
<Card.Description>Pending approvals</Card.Description>
|
||||
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{summary.approvals_pending}
|
||||
</Card.Title>
|
||||
<Card.Action>
|
||||
{#if summary.approvals_pending > 0}
|
||||
<Badge variant="destructive">needs review</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
|
||||
{/if}
|
||||
</Card.Action>
|
||||
</Card.Header>
|
||||
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
|
||||
<div class="line-clamp-1 flex gap-2 font-medium">
|
||||
{executionsRunning} running · {executionsFailed} failed
|
||||
</div>
|
||||
<div class="text-muted-foreground">Executions in the last 24h</div>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if degradedTypes.length}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Attention needed</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex gap-2">
|
||||
{#each degradedTypes as t}
|
||||
<Badge variant={t.tone === 'text-destructive' ? 'destructive' : 'secondary'}
|
||||
>{t.label}: {t.count}</Badge
|
||||
>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Event rate (6h, 5m buckets)</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="flex h-16 items-end gap-0.5">
|
||||
{#each summary.event_rate as bucket}
|
||||
<div
|
||||
class="flex-1 rounded-t bg-primary/60"
|
||||
style="height: {Math.max((bucket.count / maxEventRate) * 100, 2)}%"
|
||||
title="{bucket.bucket}: {bucket.count} events"
|
||||
></div>
|
||||
{/each}
|
||||
<div class="relative z-10 h-full overflow-y-auto">
|
||||
<!-- Hero: fills the viewport. The input is pinned to true vertical center via the
|
||||
grid's middle 1fr row; the metrics strip and scroll hint sit in the auto rows
|
||||
above/below without shifting it off-center. Scrolling lifts the whole hero to
|
||||
reveal the table. -->
|
||||
<section class="grid min-h-full grid-rows-[auto_1fr_auto] gap-6 px-4 py-16">
|
||||
<!-- Metrics strip -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 text-xs">
|
||||
<div class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur">
|
||||
<span class="text-muted-foreground">Entities</span>
|
||||
<span class="font-semibold tabular-nums">{totalEntities}</span>
|
||||
{#if entityTypeCount}<span class="text-muted-foreground">· {entityTypeCount} types</span>{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<Card.Root class="flex-1">
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Live event ticker</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="p-0">
|
||||
<ScrollArea class="h-64 px-4 pb-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each $liveEvents as ev (ev.id)}
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<Badge variant={severityVariant(ev.severity)} class="shrink-0"
|
||||
>{ev.severity}</Badge
|
||||
>
|
||||
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleTimeString()}</span>
|
||||
<span>{ev.type}</span>
|
||||
<span class="truncate text-muted-foreground">{ev.source}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur">
|
||||
<span
|
||||
class="size-2 rounded-full {healthTone === 'ok' ? 'bg-success' : healthTone === 'degraded' ? 'bg-warning' : 'bg-destructive'}"
|
||||
></span>
|
||||
<span class="text-muted-foreground">Health</span>
|
||||
<span class="font-semibold tabular-nums">{summary?.health.healthy ?? 0} / {totalMonitored}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (location.hash = '#/signals')}
|
||||
class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur transition-colors hover:border-primary/50"
|
||||
>
|
||||
{#if worstSeverity === 'critical'}
|
||||
<TriangleAlertIcon class="size-3.5 text-destructive" />
|
||||
{:else if worstSeverity === 'warning'}
|
||||
<TriangleAlertIcon class="size-3.5 text-warning" />
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">Waiting for events…</p>
|
||||
<CircleCheckIcon class="size-3.5 text-success" />
|
||||
{/if}
|
||||
<span class="text-muted-foreground">Signals</span>
|
||||
<span class="font-semibold tabular-nums">{totalSignals}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (location.hash = '#/ops')}
|
||||
class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur transition-colors hover:border-primary/50"
|
||||
>
|
||||
<span class="text-muted-foreground">Approvals</span>
|
||||
<span class="font-semibold tabular-nums {summary?.approvals_pending ? 'text-destructive' : ''}"
|
||||
>{summary?.approvals_pending ?? 0}</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- New task entry: centered in the middle (1fr) row -->
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="w-full max-w-2xl text-center">
|
||||
<h1 class="mb-1 text-2xl font-semibold tracking-tight">What should Nomos do?</h1>
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
Describe a goal — Nomos will plan it, execute it, and report the outcome.
|
||||
</p>
|
||||
<form
|
||||
class="relative rounded-2xl border bg-card/70 shadow-lg backdrop-blur focus-within:border-primary/60"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
bind:value={input}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="e.g. Roll the staging database back to last night's snapshot and verify the app is healthy…"
|
||||
rows={3}
|
||||
class="max-h-52 min-h-24 resize-none border-0 bg-transparent px-4 py-3.5 text-base shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
<div class="flex items-center justify-between px-3 pb-3">
|
||||
<span class="text-[11px] text-muted-foreground">Enter to start · Shift+Enter for newline</span>
|
||||
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Start task">
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="justify-self-center text-[11px] text-muted-foreground/70">Scroll to see all tasks ↓</span>
|
||||
</section>
|
||||
|
||||
<!-- Task table -->
|
||||
<section class="mx-auto w-full max-w-5xl px-4 pb-16">
|
||||
<div class="rounded-xl border bg-card/70 backdrop-blur">
|
||||
<div class="flex flex-wrap items-center gap-1.5 border-b p-3">
|
||||
{#each FILTERS as f}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (filter = f.id)}
|
||||
class="rounded-full border px-2.5 py-1 text-xs transition-colors {filter === f.id
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border text-muted-foreground hover:bg-muted/50'}"
|
||||
>
|
||||
{f.label}
|
||||
<span class="ml-1 opacity-60">{counts[f.id] ?? 0}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
{#if visible.length === 0}
|
||||
<div class="flex flex-col items-center gap-2 px-4 py-16 text-center">
|
||||
<p class="max-w-sm text-sm text-muted-foreground">
|
||||
{filter === 'all'
|
||||
? 'No tasks yet. Start one above and Nomos will plan it, execute it, and report the outcome.'
|
||||
: `No ${FILTERS.find((f) => f.id === filter)?.label.toLowerCase()} tasks.`}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-xs text-muted-foreground">
|
||||
<th class="w-36 px-4 py-2 font-medium">Status</th>
|
||||
<th class="px-4 py-2 font-medium">Task</th>
|
||||
<th class="hidden px-4 py-2 font-medium md:table-cell">Summary</th>
|
||||
<th class="w-28 px-4 py-2 text-right font-medium">Last active</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each visible as s (s.id)}
|
||||
{@const st = statusStyle(s)}
|
||||
<tr
|
||||
class="cursor-pointer border-b last:border-0 transition-colors hover:bg-muted/40"
|
||||
onclick={() => openTask(s.id)}
|
||||
>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
|
||||
{st.label}
|
||||
</span>
|
||||
</td>
|
||||
<td class="max-w-0 px-4 py-2.5">
|
||||
<span class="line-clamp-1 font-medium">{heading(s)}</span>
|
||||
</td>
|
||||
<td class="hidden max-w-0 px-4 py-2.5 md:table-cell">
|
||||
<span class="line-clamp-1 text-xs text-muted-foreground">{s.summary || '—'}</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-2.5 text-right text-[11px] text-muted-foreground">
|
||||
{relativeTime(s.last_active_at)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user