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