feat(web): adopt @vincjo/datatables for all tables, standardize shared components

- Add DataTable.svelte: declarative columns, built-in sorting, sticky headers,
  text truncation, column alignment, configurable widths, optional pagination/search
- 12 built-in renderers: BadgeRenderer, StatusBadgeRenderer (unified risk/severity/
  execution/state/type variant mapping), HealthDotRenderer, RelativeTimeRenderer,
  DateRenderer, DurationRenderer, StatusDotRenderer, SignalActions, ApprovalActions,
  ActivityAction, ActivityCancel
- Migrate Overview (task board), Signals, Ops (3 tables) to DataTable
- Refactor EntityTable treegrid to use shared SortHeader, EmptyState, HealthDotRenderer
- Create shared components: EmptyState, StatusBadge, FilterTabs
- Clean up Knowledge.svelte: replace inline relTime() and typeVariant() with shared utils
- Add width, align, truncate column props; table-fixed layout; rounded-xl borders
- Bump version to 0.11.0
This commit is contained in:
2026-07-21 13:19:03 +02:00
parent ccbf6a8aac
commit 50aed11cc4
32 changed files with 1347 additions and 366 deletions

View File

@@ -7,6 +7,8 @@
import { Button } from '$lib/components/ui/button'
import { ScrollArea } from '$lib/components/ui/scroll-area'
import { openEntityWindow } from '$lib/stores/windows'
import { relativeTime } from '$lib/utils'
import StatusBadge from '$lib/components/StatusBadge.svelte'
import SearchIcon from '@lucide/svelte/icons/search'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
import BotIcon from '@lucide/svelte/icons/bot'
@@ -39,23 +41,6 @@
loading = false
searched = true
}
function typeVariant(type: string): 'default' | 'secondary' | 'outline' {
if (type === 'runbook') return 'secondary'
if (type === 'investigation') return 'default'
return 'outline'
}
function relTime(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`
}
</script>
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
@@ -113,7 +98,7 @@
<Card.Header>
<div class="flex items-center gap-2">
<Card.Title class="text-sm">{hit.title}</Card.Title>
<Badge variant={typeVariant(hit.type)}>{hit.type}</Badge>
<StatusBadge kind="type" value={hit.type} />
</div>
{#if hit.snippet}
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized below, ts_headline only ever emits <b> -->
@@ -153,7 +138,7 @@
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium">{it.title}</span>
<Badge variant={typeVariant(it.kind)} class="text-[10px]">{it.kind}</Badge>
<StatusBadge kind="type" value={it.kind} class="text-[10px]" />
{#if it.agent_authored}<Badge variant="outline" class="border-primary/40 text-[10px] text-primary">learned by Nomos</Badge>{/if}
</div>
{#if it.tags.length}
@@ -162,7 +147,7 @@
</div>
{/if}
</div>
<span class="shrink-0 text-xs text-muted-foreground">{relTime(it.updated_at)}</span>
<span class="shrink-0 text-xs text-muted-foreground">{relativeTime(it.updated_at)}</span>
</div>
{:else}
{#if !loadingRecent}

View File

@@ -10,10 +10,14 @@
} from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import * as Tabs from '$lib/components/ui/tabs'
import * as Table from '$lib/components/ui/table'
import { Badge } from '$lib/components/ui/badge'
import { Button } from '$lib/components/ui/button'
import { toast } from 'svelte-sonner'
import DataTable from '$lib/components/data-table/DataTable.svelte'
import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte.ts'
import ApprovalActions from '$lib/components/data-table/renderers/ApprovalActions.svelte'
import ActivityCancel from '$lib/components/data-table/renderers/ActivityCancel.svelte'
import ActivityAction from '$lib/components/data-table/renderers/ActivityAction.svelte'
import DurationRenderer from '$lib/components/data-table/renderers/DurationRenderer.svelte'
let approvals = $state<Approval[]>([])
let activity = $state<ActivityItem[]>([])
@@ -30,9 +34,6 @@
loadApprovals()
loadActivity()
const unsubscribe = subscribeEvents()
// 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()
@@ -47,24 +48,6 @@
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') {
deciding = id
const result = await decideApproval(id, decision)
@@ -87,25 +70,36 @@
}
}
function riskVariant(risk: string): 'default' | 'secondary' | 'destructive' {
if (risk === 'destructive') return 'destructive'
if (risk === 'config_mutation') return 'secondary'
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' {
if (['failed', 'denied', 'revoked', 'cancelled'].includes(status)) return 'destructive'
if (status === 'completed') return 'default'
if (['running', 'approved'].includes(status)) return 'secondary'
return 'outline'
}
const pendingApprovals = $derived(approvals.filter((a) => a.status === 'pending'))
const decidedApprovals = $derived(approvals.filter((a) => a.status !== 'pending'))
const pendingColumns = $derived.by(() => [
{ key: 'subject', header: 'Subject', class: 'font-mono text-xs', width: '180px', accessor: (a: Approval) => a.subject ?? '—', truncate: true },
{ key: 'action', header: 'Action', truncate: true },
{ key: 'risk_class', header: 'Risk', render: 'status-badge', renderProps: { kind: 'risk' }, width: '120px' },
{ key: 'status', header: 'Status', render: 'status-badge', renderProps: { kind: 'execution' }, width: '100px' },
{ key: 'expires_at', header: 'Expires', render: 'date', width: '170px' },
{ key: '_actions', header: '', render: ApprovalActions,
renderProps: { deciding, onApprove: (id: string) => decide(id, 'approve'), onDeny: (id: string) => decide(id, 'deny') },
align: 'right', headerClass: 'text-right', width: '220px' },
] as DataTableColumn<Approval>[])
const decidedColumns: DataTableColumn<Approval>[] = [
{ key: 'subject', header: 'Subject', class: 'font-mono text-xs', width: '180px', accessor: (a) => a.subject ?? '—', truncate: true },
{ key: 'action', header: 'Action', truncate: true },
{ key: 'status', header: 'Status', render: 'status-badge', renderProps: { kind: 'execution' }, width: '100px' },
{ key: 'decided_at', header: 'Decided', render: 'date', width: '170px', accessor: (a) => a.decided_at ?? '—' },
]
const activityColumns: DataTableColumn<ActivityItem>[] = [
{ key: 'target', header: 'Target', class: 'font-mono text-xs', width: '180px', accessor: (a) => a.target ?? '—', truncate: true },
{ key: '_action', header: 'Action', render: ActivityAction, truncate: true },
{ key: 'risk_class', header: 'Risk', render: 'status-badge', renderProps: { kind: 'risk' }, width: '120px' },
{ key: 'status', header: 'Status', render: 'status-badge', renderProps: { kind: 'execution' }, width: '110px' },
{ key: 'duration_ms', header: 'Duration', render: DurationRenderer, width: '90px', align: 'right' },
{ key: 'created_at', header: 'When', render: 'relative-time', width: '100px' },
{ key: '_cancel', header: '', render: ActivityCancel, renderProps: { onCancel: cancel }, align: 'right', headerClass: 'text-right', width: '100px' },
]
</script>
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
@@ -120,121 +114,18 @@
</Tabs.List>
<Tabs.Content value="approvals" class="flex-1 overflow-auto">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Subject</Table.Head>
<Table.Head>Action</Table.Head>
<Table.Head>Risk</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Expires</Table.Head>
<Table.Head class="text-right">Decision</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each pendingApprovals as approval (approval.id)}
<Table.Row>
<Table.Cell class="font-mono text-xs">{approval.subject ?? '—'}</Table.Cell>
<Table.Cell>{approval.action}</Table.Cell>
<Table.Cell><Badge variant={riskVariant(approval.risk_class)}>{approval.risk_class}</Badge></Table.Cell>
<Table.Cell><Badge variant="outline">{approval.status}</Badge></Table.Cell>
<Table.Cell class="text-xs text-muted-foreground"
>{new Date(approval.expires_at).toLocaleString()}</Table.Cell
>
<Table.Cell class="flex justify-end gap-2">
<Button
size="sm"
disabled={deciding === approval.id}
onclick={() => decide(approval.id, 'approve')}
>
Approve
</Button>
<Button
size="sm"
variant="destructive"
disabled={deciding === approval.id}
onclick={() => decide(approval.id, 'deny')}
>
Deny
</Button>
</Table.Cell>
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={6} class="text-center text-muted-foreground">No pending approvals.</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<DataTable columns={pendingColumns} data={pendingApprovals} emptyMessage="No pending approvals." />
{#if decidedApprovals.length}
<p class="mt-4 text-xs text-muted-foreground">Recently decided</p>
<div class="mt-1 rounded-md border">
<Table.Root>
<Table.Body>
{#each decidedApprovals.slice(0, 20) as approval (approval.id)}
<Table.Row>
<Table.Cell class="font-mono text-xs">{approval.subject ?? '—'}</Table.Cell>
<Table.Cell>{approval.action}</Table.Cell>
<Table.Cell><Badge variant="outline">{approval.status}</Badge></Table.Cell>
<Table.Cell class="text-xs text-muted-foreground"
>{approval.decided_at ? new Date(approval.decided_at).toLocaleString() : '—'}</Table.Cell
>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div class="mt-1">
<DataTable columns={decidedColumns} data={decidedApprovals.slice(0, 20)} />
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="executions" class="flex-1 overflow-auto">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Target</Table.Head>
<Table.Head>Action</Table.Head>
<Table.Head>Risk</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Duration</Table.Head>
<Table.Head>When</Table.Head>
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each activity as item (item.id)}
<Table.Row>
<Table.Cell class="font-mono text-xs">{item.target ?? '—'}</Table.Cell>
<Table.Cell>
<div>{item.verb}</div>
{#if item.summary}
<div class="text-xs text-muted-foreground">{item.summary}</div>
{/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">
{#if ['pending_approval', 'approved', 'running'].includes(item.status)}
<Button size="sm" variant="outline" onclick={() => cancel(item.id)}>Cancel</Button>
{/if}
</Table.Cell>
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={7} class="text-center text-muted-foreground">No activity yet.</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<DataTable columns={activityColumns} data={activity} emptyMessage="No activity yet." />
</Tabs.Content>
</Tabs.Root>
</div>

View File

@@ -3,9 +3,11 @@
import { sessions, loadSessions } from '$lib/stores/chat'
import { openTaskWindow, openNewTaskWindow } from '$lib/stores/windows'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { bucket, statusStyle, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
import { relativeTime } from '$lib/utils'
import { bucket, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
import { Button } from '$lib/components/ui/button'
import DataTable from '$lib/components/data-table/DataTable.svelte'
import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte.ts'
import StatusDotRenderer from '$lib/components/data-table/renderers/StatusDotRenderer.svelte'
import PlusIcon from '@lucide/svelte/icons/plus'
import type { Session } from '$lib/api'
@@ -17,6 +19,7 @@
for (const s of $sessions) c[bucket(s)]++
return c
})
const visible = $derived(
filter === 'all' ? $sessions : $sessions.filter((s) => bucket(s) === filter)
)
@@ -29,9 +32,6 @@
loadSessions()
const unsubStream = subscribeEvents()
// 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) => {
@@ -51,6 +51,19 @@
unsubStream()
}
})
const columns: DataTableColumn<Session>[] = [
{ key: '_status', header: 'Status', render: StatusDotRenderer, width: '140px' },
{ key: '_heading', header: 'Task', sortable: true, accessor: heading, truncate: true },
{ key: 'summary', header: 'Summary', accessor: (s) => s.summary || '—', truncate: true, headerClass: 'hidden md:table-cell', class: 'hidden md:table-cell' },
{ key: 'last_active_at', header: 'Last active', render: 'relative-time', sortable: true, width: '112px', align: 'right' },
]
const emptyMessage = $derived(
filter === 'all'
? 'No tasks yet. Start one and Nomos will plan it, execute it, and report the outcome.'
: `No ${FILTERS.find((f) => f.id === filter)?.label.toLowerCase()} tasks.`
)
</script>
<div class="relative flex h-full flex-col gap-3 overflow-hidden p-4">
@@ -75,51 +88,13 @@
</Button>
</div>
<div class="relative z-10 min-h-0 flex-1 overflow-auto rounded-xl border bg-card/70 backdrop-blur">
{#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 and Nomos will plan it, execute it, and report the outcome.'
: `No ${FILTERS.find((f) => f.id === filter)?.label.toLowerCase()} tasks.`}
</p>
</div>
{:else}
<table class="w-full text-sm">
<thead>
<tr class="border-b text-left text-xs text-muted-foreground [&>th]:sticky [&>th]:top-0 [&>th]:z-10 [&>th]:bg-card/95 [&>th]:backdrop-blur">
<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)}
>
<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>
{/if}
<div class="relative z-10 min-h-0 flex-1 overflow-hidden rounded-xl border bg-card/70 backdrop-blur">
<DataTable
{columns}
data={visible}
{emptyMessage}
bordered={false}
onRowClick={openTask}
/>
</div>
</div>

View File

@@ -3,11 +3,12 @@
import { fetchSignals, ackSignal, resolveSignal, muteSignal, type Signal } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import * as Tabs from '$lib/components/ui/tabs'
import * as Table from '$lib/components/ui/table'
import { Badge } from '$lib/components/ui/badge'
import { Button } from '$lib/components/ui/button'
import * as Select from '$lib/components/ui/select'
import { toast } from 'svelte-sonner'
import DataTable from '$lib/components/data-table/DataTable.svelte'
import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte.ts'
import SignalActions from '$lib/components/data-table/renderers/SignalActions.svelte'
let signals = $state<Signal[]>([])
let severityFilter = $state('all')
@@ -66,12 +67,6 @@
}
}
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
if (sev === 'critical') return 'destructive'
if (sev === 'warning') return 'secondary'
return 'default'
}
function bySeverity(list: Signal[]) {
return severityFilter === 'all' ? list : list.filter((s) => s.severity === severityFilter)
}
@@ -79,60 +74,29 @@
const open = $derived(bySeverity(signals.filter((s) => ['raised', 'acknowledged', 'acting'].includes(s.state))))
const muted = $derived(bySeverity(signals.filter((s) => s.state === 'muted')))
const resolved = $derived(bySeverity(signals.filter((s) => ['resolved', 'failed'].includes(s.state))))
</script>
{#snippet signalTable(list: Signal[], showActions: boolean)}
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Target</Table.Head>
<Table.Head>Kind</Table.Head>
<Table.Head>Severity</Table.Head>
<Table.Head>State</Table.Head>
<Table.Head>Occurrences</Table.Head>
<Table.Head>Last seen</Table.Head>
{#if showActions}
<Table.Head class="text-right">Actions</Table.Head>
{/if}
</Table.Row>
</Table.Header>
<Table.Body>
{#each list as signal (signal.id)}
<Table.Row>
<Table.Cell class="font-mono text-xs">{signal.target ?? '—'}</Table.Cell>
<Table.Cell>{signal.kind}</Table.Cell>
<Table.Cell><Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge></Table.Cell>
<Table.Cell><Badge variant="outline">{signal.state}</Badge></Table.Cell>
<Table.Cell>{signal.occurrence_count}</Table.Cell>
<Table.Cell class="text-xs text-muted-foreground"
>{new Date(signal.last_seen_at).toLocaleString()}</Table.Cell
>
{#if showActions}
<Table.Cell class="flex justify-end gap-2">
{#if signal.state === 'raised'}
<Button size="sm" variant="outline" disabled={acting === signal.id} onclick={() => ack(signal.id)}
>Ack</Button
>
{/if}
<Button size="sm" variant="outline" disabled={acting === signal.id} onclick={() => mute(signal.id)}
>Mute 1h</Button
>
<Button size="sm" disabled={acting === signal.id} onclick={() => resolve(signal.id)}>Resolve</Button>
</Table.Cell>
{/if}
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={showActions ? 7 : 6} class="text-center text-muted-foreground"
>No signals.</Table.Cell
>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/snippet}
function makeColumns(showActions: boolean, actingVal: string | null): DataTableColumn<Signal>[] {
const base: DataTableColumn<Signal>[] = [
{ key: 'target', header: 'Target', class: 'font-mono text-xs', width: '180px', accessor: (s) => s.target ?? '—', truncate: true },
{ key: 'kind', header: 'Kind', width: '120px' },
{ key: 'severity', header: 'Severity', render: 'status-badge', renderProps: { kind: 'severity' }, width: '100px' },
{ key: 'state', header: 'State', render: 'status-badge', renderProps: { kind: 'state' }, width: '110px' },
{ key: 'occurrence_count', header: 'Occurrences', width: '100px', align: 'right' },
{ key: 'last_seen_at', header: 'Last seen', render: 'date', width: '170px' },
]
if (showActions) {
base.push({
key: '_actions', header: '', render: SignalActions,
renderProps: { acting: actingVal, onAck: ack, onMute: mute, onResolve: resolve },
align: 'right', headerClass: 'text-right', width: '220px'
})
}
return base
}
const columnsWithActions = $derived(makeColumns(true, acting))
const columnsWithoutActions = makeColumns(false, null)
</script>
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
@@ -159,13 +123,13 @@
<Tabs.Trigger value="resolved">Resolved</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="open" class="flex-1 overflow-auto">
{@render signalTable(open, true)}
<DataTable columns={columnsWithActions} data={open} />
</Tabs.Content>
<Tabs.Content value="muted" class="flex-1 overflow-auto">
{@render signalTable(muted, true)}
<DataTable columns={columnsWithActions} data={muted} />
</Tabs.Content>
<Tabs.Content value="resolved" class="flex-1 overflow-auto">
{@render signalTable(resolved, false)}
<DataTable columns={columnsWithoutActions} data={resolved} />
</Tabs.Content>
</Tabs.Root>
</div>