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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user