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

@@ -1,12 +1,12 @@
<script lang="ts">
import type { Entity, EntityHealth } from '$lib/api'
import { relativeTime } from '$lib/utils'
import type { Entity } from '$lib/api'
import * as Table from '$lib/components/ui/table'
import { Badge } from '$lib/components/ui/badge'
import { Skeleton } from '$lib/components/ui/skeleton'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down'
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down'
import SortHeader from '$lib/components/data-table/sort-header.svelte'
import EmptyState from '$lib/components/EmptyState.svelte'
import HealthDotRenderer from '$lib/components/data-table/renderers/HealthDotRenderer.svelte'
import StatusBadgeRenderer from '$lib/components/data-table/renderers/StatusBadgeRenderer.svelte'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
@@ -21,15 +21,6 @@
loading: boolean
selectedSlug?: string | null
onSelect: (slug: string) => void
// child entity slug -> parent entity slug, derived from the ontology
// graph (arbitrary relationship types, not a fixed list — see
// KnowledgeBase.svelte). When set, rows nest under their parent —
// possibly several levels deep (host -> lxc -> service) — instead of
// rendering flat. Since the parent for a given child can come from
// whichever relationship happened to be processed last, a cycle across
// relationship types isn't structurally impossible; `row` tracks the
// ancestor chain and drops a child that would re-enter it, rather than
// recursing forever.
childToParent?: Map<string, string> | null
} = $props()
@@ -55,11 +46,16 @@
}
}
const healthRank: Record<EntityHealth, number> = { down: 0, degraded: 1, stale: 2, unknown: 3, healthy: 4 }
function getSortState(key: SortKey) {
if (sortKey !== key) return { sorted: false, direction: 'asc' as const }
return { sorted: true, direction: sortDir }
}
const healthRank: Record<string, number> = { down: 0, degraded: 1, stale: 2, unknown: 3, healthy: 4 }
function sortValue(entity: Entity, key: SortKey): string | number {
if (key === 'health') return entity.health ? healthRank[entity.health] : -1
return (entity[key] ?? '').toString().toLowerCase()
if (key === 'health') return entity.health ? (healthRank[entity.health] ?? -1) : -1
return (entity[key as keyof Entity] ?? '').toString().toLowerCase()
}
const sortedEntities = $derived.by(() => {
@@ -74,12 +70,6 @@
return sorted
})
// ─── treegrid grouping: nest entities under their parent (per
// childToParent — host->lxc via `hosts`, lxc/vm/host->service via
// `provides`, chained to whatever depth the relationships form). An entity
// whose parent got filtered out of `entities` (e.g. by the type dropdown)
// has no parent row to nest under, so it falls back to rendering top-level
// rather than disappearing.
const childrenByParent = $derived.by(() => {
const map = new Map<string, Entity[]>()
if (!childToParent) return map
@@ -104,27 +94,6 @@
childToParent ? sortedEntities.filter((e) => !nestedSlugs.has(e.slug)) : sortedEntities
)
function stateVariant(state?: string | null): 'default' | 'secondary' | 'outline' {
if (!state) return 'outline'
if (state === 'active' || state === 'healthy') return 'default'
return 'secondary'
}
const healthDot: Record<EntityHealth, string> = {
healthy: 'bg-success',
degraded: 'bg-warning',
down: 'bg-destructive',
stale: 'bg-warning/50',
unknown: 'bg-muted-foreground/40'
}
function healthTitle(entity: Entity): string {
if (!entity.health) return 'not monitored'
if (entity.health === 'stale') return `stale — last checked ${relativeTime(entity.last_check_at)}`
return `${entity.health} — checked ${relativeTime(entity.last_check_at)}`
}
// Widths vary per row so the skeleton reads as text, not a stack of identical bars.
const skeletonSlugWidths = ['w-24', 'w-20', 'w-28', 'w-16', 'w-24', 'w-20', 'w-28', 'w-16']
const skeletonNameWidths = ['w-32', 'w-40', 'w-24', 'w-36', 'w-28', 'w-40', 'w-24', 'w-32']
</script>
@@ -160,22 +129,6 @@
</Table.Root>
</div>
{:else}
{#snippet sortHead(key: SortKey, label: string)}
<Table.Head>
<button type="button" class="flex items-center gap-1 hover:text-foreground" onclick={() => sortBy(key)}>
{label}
{#if sortKey === key}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 text-muted-foreground/50" />
{/if}
</button>
</Table.Head>
{/snippet}
{#snippet row(entity: Entity, level: number, ancestors: Set<string>)}
{@const ancestorsWithSelf = new Set(ancestors).add(entity.slug)}
{@const children = (childrenByParent.get(entity.slug) ?? []).filter((c) => !ancestorsWithSelf.has(c.slug))}
@@ -215,21 +168,10 @@
<Table.Cell><Badge variant="outline">{entity.type}</Badge></Table.Cell>
<Table.Cell>{entity.name}</Table.Cell>
<Table.Cell>
{#if entity.state}
<Badge variant={stateVariant(entity.state)}>{entity.state}</Badge>
{:else}
<span class="text-muted-foreground"></span>
{/if}
<StatusBadgeRenderer value={entity.state ?? ''} kind="state" />
</Table.Cell>
<Table.Cell>
{#if entity.health}
<span class="flex items-center gap-1.5 text-xs" title={healthTitle(entity)}>
<span class="size-2 shrink-0 rounded-full {healthDot[entity.health]}"></span>
<span class="text-muted-foreground">{relativeTime(entity.last_check_at)}</span>
</span>
{:else}
<span class="text-xs text-muted-foreground"></span>
{/if}
<HealthDotRenderer row={entity} value={null} />
</Table.Cell>
</Table.Row>
{#if children.length > 0 && !collapsedNodes.has(entity.slug)}
@@ -242,22 +184,33 @@
<Table.Root role={childToParent ? 'treegrid' : undefined}>
<Table.Header>
<Table.Row>
{@render sortHead('slug', 'Slug')}
{@render sortHead('type', 'Type')}
{@render sortHead('name', 'Name')}
{@render sortHead('state', 'State')}
{@render sortHead('health', 'Health')}
{@const ssSlug = getSortState('slug')}
<Table.Head>
<SortHeader label="Slug" sorted={ssSlug.sorted} direction={ssSlug.direction} onclick={() => sortBy('slug')} />
</Table.Head>
{@const ssType = getSortState('type')}
<Table.Head>
<SortHeader label="Type" sorted={ssType.sorted} direction={ssType.direction} onclick={() => sortBy('type')} />
</Table.Head>
{@const ssName = getSortState('name')}
<Table.Head>
<SortHeader label="Name" sorted={ssName.sorted} direction={ssName.direction} onclick={() => sortBy('name')} />
</Table.Head>
{@const ssState = getSortState('state')}
<Table.Head>
<SortHeader label="State" sorted={ssState.sorted} direction={ssState.direction} onclick={() => sortBy('state')} />
</Table.Head>
{@const ssHealth = getSortState('health')}
<Table.Head>
<SortHeader label="Health" sorted={ssHealth.sorted} direction={ssHealth.direction} onclick={() => sortBy('health')} />
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each topLevelEntities as entity (entity.id)}
{@render row(entity, 1, new Set())}
{:else}
<Table.Row>
<Table.Cell colspan={5} class="text-center text-muted-foreground"
>No entities in this layer match the filter.</Table.Cell
>
</Table.Row>
<EmptyState message="No entities in this layer match the filter." colspan={5} />
{/each}
</Table.Body>
</Table.Root>