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

@@ -0,0 +1,17 @@
<script lang="ts">
let {
message = 'No items.',
colspan = 999,
class: className
}: {
message?: string
colspan?: number
class?: string
} = $props()
</script>
<tr>
<td {colspan} class={['py-8 text-center text-muted-foreground', className].filter(Boolean).join(' ')}>
{message}
</td>
</tr>

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>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import * as Tabs from '$lib/components/ui/tabs'
let {
value = $bindable(''),
tabs,
class: className,
children
}: {
value?: string
tabs: { value: string; label: string; count?: number; variant?: 'destructive' | 'default' | 'secondary' | 'outline' }[]
class?: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children?: any
} = $props()
</script>
<Tabs.Root bind:value class={['flex flex-1 flex-col overflow-hidden', className].filter(Boolean).join(' ')}>
<Tabs.List>
{#each tabs as tab}
<Tabs.Trigger value={tab.value}>
{tab.label}
{#if tab.count != null && tab.count > 0}
<slot name="badge-{tab.value}">
<!-- slot for custom badge rendering -->
</slot>
{/if}
</Tabs.Trigger>
{/each}
</Tabs.List>
{#if children}
{@render children()}
{/if}
</Tabs.Root>

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge'
type StatusKind = 'risk' | 'severity' | 'execution' | 'type' | 'default'
let {
value,
kind = 'default',
class: className
}: {
value: string
kind?: StatusKind
class?: string
} = $props()
const variantMap: Record<StatusKind, Record<string, 'default' | 'secondary' | 'destructive' | 'outline'>> = {
risk: {
destructive: 'destructive',
config_mutation: 'secondary',
},
severity: {
critical: 'destructive',
warning: 'secondary',
info: 'default',
},
execution: {
failed: 'destructive',
denied: 'destructive',
revoked: 'destructive',
cancelled: 'destructive',
completed: 'default',
running: 'secondary',
approved: 'secondary',
},
type: {
runbook: 'secondary',
investigation: 'default',
},
default: {},
}
function variant(): 'default' | 'secondary' | 'destructive' | 'outline' {
return variantMap[kind]?.[value] ?? (kind === 'default' ? 'default' : 'outline')
}
</script>
<Badge variant={variant()} class={className}>{value}</Badge>

View File

@@ -0,0 +1,218 @@
<script lang="ts">
import { TableHandler } from '@vincjo/datatables'
import { Skeleton } from '$lib/components/ui/skeleton'
import SortHeader from './sort-header.svelte'
import Toolbar from './toolbar.svelte'
import Pagination from './pagination/Pagination.svelte'
import EmptyState from '$lib/components/EmptyState.svelte'
import BadgeRenderer from './renderers/BadgeRenderer.svelte'
import HealthDotRenderer from './renderers/HealthDotRenderer.svelte'
import RelativeTimeRenderer from './renderers/RelativeTimeRenderer.svelte'
import DateRenderer from './renderers/DateRenderer.svelte'
import StatusBadgeRenderer from './renderers/StatusBadgeRenderer.svelte'
import { resolveCellValue } from './columns'
import type { DataTableColumn, BuiltinRenderer } from './DataTable.svelte.ts'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Row = Record<string, any>
const renderers: Record<string, unknown> = {
'badge': BadgeRenderer,
'health-dot': HealthDotRenderer,
'relative-time': RelativeTimeRenderer,
'date': DateRenderer,
'status-badge': StatusBadgeRenderer,
}
let {
columns,
data = [],
pageSize = 20,
paginated = false,
searchable = false,
bordered = true,
loading = false,
emptyMessage = 'No items.',
selected = $bindable(null),
onRowClick = undefined,
class: className,
children
}: {
columns: DataTableColumn<Row>[]
data: Row[]
pageSize?: number
paginated?: boolean
searchable?: boolean
bordered?: boolean
loading?: boolean
emptyMessage?: string
selected?: string | null
onRowClick?: (row: Row) => void
class?: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children?: any
} = $props()
const table = new TableHandler([], { pageSize: 20 })
// One SortBuilder per sortable column — each tracks its own direction/isActive
// via $derived runes internally.
const sortBuilders = new Map<string, ReturnType<typeof table.createSort>>()
function getSortBuilder(key: string) {
if (!sortBuilders.has(key)) {
sortBuilders.set(key, table.createSort(key))
}
return sortBuilders.get(key)!
}
let search = $state.raw(
table.createSearch({
filterFunction: (row: Row, q: string) => {
if (!q) return true
const lower = q.toLowerCase()
for (const col of columns) {
if (col.hidden) continue
const val = String(resolveCellValue(row, col) ?? '').toLowerCase()
if (val.includes(lower)) return true
}
return false
}
})
)
$effect(() => {
table.setRowsPerPage(pageSize)
})
$effect(() => {
table.setRows(data)
})
function handleSearch(q: string) {
search.set(q)
if (paginated) table.setPage(1)
}
function colAlignClass(col: DataTableColumn<Row>): string {
if (col.align === 'right') return 'text-right'
if (col.align === 'center') return 'text-center'
return ''
}
function colTruncateClass(col: DataTableColumn<Row>): string {
return col.truncate ? 'min-w-0 overflow-hidden text-ellipsis' : ''
}
function colStyle(col: DataTableColumn<Row>): string | undefined {
if (!col.width) return undefined
const w = typeof col.width === 'number' ? col.width + 'px' : col.width
return `width: ${w}; min-width: ${w}`
}
const visibleCols = $derived(columns.filter((c) => !c.hidden))
const rows = $derived(table.rows as Row[])
const skeletonWidths = ['w-24', 'w-20', 'w-28', 'w-16', 'w-24', 'w-20', 'w-28', 'w-16']
</script>
<div class={['flex flex-col h-full min-h-0', className].filter(Boolean).join(' ')}>
<Toolbar {table} {searchable} {paginated} onSearchChange={handleSearch}>
{#if children}
{@render children()}
{/if}
</Toolbar>
<div class={['min-h-0 flex-1 overflow-auto relative', bordered ? 'rounded-xl border' : ''].filter(Boolean).join(' ')}>
<table class="w-full caption-bottom text-sm table-fixed">
<thead class="[&_tr]:border-b">
<tr>
{#each visibleCols as col (col.key)}
<th
class={[
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap',
'sticky top-0 z-10 bg-card/95 backdrop-blur',
col.headerClass,
colAlignClass(col)
].filter(Boolean).join(' ')}
style={colStyle(col)}
>
{#if col.sortable !== false}
{@const sb = getSortBuilder(col.key)}
<SortHeader
label={col.header}
sorted={sb.isActive}
direction={sb.direction ?? 'asc'}
onclick={() => sb.set()}
/>
{:else}
{col.header}
{/if}
</th>
{/each}
</tr>
</thead>
<tbody class="[&_tr:last-child]:border-0">
{#if loading}
{#each skeletonWidths as w, i}
<tr class="border-b transition-colors hover:bg-transparent">
{#each visibleCols as col (col.key)}
<td class={[col.class, colAlignClass(col), colTruncateClass(col)].filter(Boolean).join(' ')} style={colStyle(col)}>
<Skeleton class="h-4 {skeletonWidths[(i + visibleCols.indexOf(col)) % skeletonWidths.length]}" />
</td>
{/each}
</tr>
{/each}
{:else if rows.length === 0}
<EmptyState message={emptyMessage} colspan={visibleCols.length} />
{:else}
{#each rows as row, idx (row.id ?? row.slug ?? `row-${idx}`)}
<tr
class={[
'border-b transition-colors hover:bg-muted/50',
onRowClick ? 'cursor-pointer' : '',
selected === (row.id ?? row.slug) ? 'bg-muted' : ''
].filter(Boolean).join(' ')}
tabindex={onRowClick ? 0 : undefined}
onclick={onRowClick ? () => onRowClick(row) : undefined}
onkeydown={onRowClick
? (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onRowClick(row) } }
: undefined}
>
{#each visibleCols as col (col.key)}
{@const val = resolveCellValue(row, col)}
<td
class={[
'p-2 align-middle whitespace-nowrap',
col.class,
colAlignClass(col),
colTruncateClass(col)
].filter(Boolean).join(' ')}
style={colStyle(col)}
>
{#if typeof col.render === 'string'}
{@const R = renderers[col.render]}
{#if R}
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
<R value={val} {row} {...(col.renderProps ?? {})} />
{:else}
{String(val ?? '—')}
{/if}
{:else if typeof col.render === 'function'}
<col.render {row} value={val} {...(col.renderProps ?? {})} />
{:else}
{String(val ?? '—')}
{/if}
</td>
{/each}
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
{#if paginated}
<Pagination {table} />
{/if}
</div>

View File

@@ -0,0 +1,36 @@
import type { ComponentType, SvelteComponent } from 'svelte'
export type BuiltinRenderer = 'badge' | 'health-dot' | 'relative-time' | 'date' | 'status-badge'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type CellComponent = ComponentType<SvelteComponent<{ row: any; value: unknown }>>
export interface DataTableColumn<T> {
key: string
header: string
sortable?: boolean
width?: string | number
align?: 'left' | 'right' | 'center'
truncate?: boolean
class?: string
headerClass?: string
render?: BuiltinRenderer | CellComponent
renderProps?: Record<string, unknown>
accessor?: (row: T) => unknown
hidden?: boolean
}
export interface DataTableProps<T> {
columns: DataTableColumn<T>[]
data: T[]
pageSize?: number
paginated?: boolean
searchable?: boolean
loading?: boolean
emptyMessage?: string
selected?: string[]
onRowClick?: (row: T) => void
class?: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children?: any
}

View File

@@ -0,0 +1,9 @@
import type { DataTableColumn } from './DataTable.svelte.ts'
export function resolveCellValue<T>(row: T, col: DataTableColumn<T>): unknown {
if (col.accessor) return col.accessor(row)
if (col.key in (row as Record<string, unknown>)) {
return (row as Record<string, unknown>)[col.key]
}
return undefined
}

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import type { ButtonSize } from '$lib/components/ui/button'
let {
page,
active,
disabled = false,
size = 'xs' as ButtonSize,
onclick
}: {
page: number | string
active?: boolean
disabled?: boolean
size?: ButtonSize
onclick?: () => void
} = $props()
</script>
<Button {size} variant={active ? 'default' : 'outline'} {disabled} {onclick}>
{String(page)}
</Button>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import PageButton from './PageButton.svelte'
import type { TableHandler } from '@vincjo/datatables'
let { table }: { table: TableHandler<Record<string, unknown>> } = $props()
const pages = $derived(table.pagesWithEllipsis as (number | '...')[])
const currentPage = $derived(table.currentPage)
const pageCount = $derived(table.pageCount)
const rowCount = $derived(table.rowCount)
</script>
{#if pageCount > 1}
<div class="flex items-center justify-between gap-2 px-2 py-1.5">
<span class="text-xs text-muted-foreground">{rowCount} rows</span>
<div class="flex items-center gap-1">
<PageButton
page={ChevronLeftIcon}
disabled={currentPage === 1}
onclick={() => table.setPage('previous')}
/>
{#each pages as page}
{#if page === '...'}
<span class="px-1 text-xs text-muted-foreground"></span>
{:else}
<PageButton
{page}
active={page === currentPage}
onclick={() => table.setPage(page as number)}
/>
{/if}
{/each}
<PageButton
page={ChevronRightIcon}
disabled={currentPage === pageCount}
onclick={() => table.setPage('next')}
/>
</div>
</div>
{/if}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import * as Select from '$lib/components/ui/select'
import type { TableHandler } from '@vincjo/datatables'
let { table, class: className }: { table: TableHandler<Record<string, unknown>>; class?: string } = $props()
const options = [10, 20, 50, 100]
let value = $state('20')
function handleChange(newValue: string | undefined) {
if (!newValue) return
value = newValue
table.setRowsPerPage(parseInt(newValue))
}
</script>
<Select.Root type="single" {value} onValueChange={handleChange}>
<Select.Trigger size="sm" class={className}>
{value}
</Select.Trigger>
<Select.Content>
{#each options as n}
<Select.Item value={String(n)}>{n} / page</Select.Item>
{/each}
</Select.Content>
</Select.Root>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { ActivityItem } from '$lib/api'
let { row }: { row: ActivityItem } = $props()
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`
}
</script>
<div>
<div>{row.verb}</div>
{#if row.summary}
<div class="text-xs text-muted-foreground">{row.summary}</div>
{/if}
{#if row.error}
<div class="text-xs text-destructive">{row.error}</div>
{/if}
</div>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import type { ActivityItem } from '$lib/api'
let {
row,
onCancel
}: {
row: ActivityItem
onCancel?: (id: string) => void
} = $props()
function showCancel(status: string): boolean {
return ['pending_approval', 'approved', 'running'].includes(status)
}
</script>
<div class="flex justify-end">
{#if showCancel(row.status)}
<Button size="sm" variant="outline" onclick={() => onCancel?.(row.id)}>Cancel</Button>
{/if}
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import type { Approval } from '$lib/api'
let {
row,
deciding = null,
onApprove,
onDeny
}: {
row: Approval
deciding?: string | null
onApprove?: (id: string) => void
onDeny?: (id: string) => void
} = $props()
</script>
<div class="flex justify-end gap-2">
<Button size="sm" disabled={deciding === row.id} onclick={() => onApprove?.(row.id)}>Approve</Button>
<Button size="sm" variant="destructive" disabled={deciding === row.id} onclick={() => onDeny?.(row.id)}>Deny</Button>
</div>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Badge, type BadgeVariant } from '$lib/components/ui/badge'
let { value, variant = 'outline' as BadgeVariant }: { value: unknown; variant?: BadgeVariant } = $props()
</script>
<Badge {variant}>{String(value ?? '—')}</Badge>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
let { value }: { value: unknown } = $props()
function format(val: unknown): string {
if (!val) return '—'
try {
return new Date(String(val)).toLocaleString()
} catch {
return String(val)
}
}
</script>
<span class="text-xs text-muted-foreground">{format(value)}</span>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { ActivityItem } from '$lib/api'
let { value }: { value: unknown } = $props()
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`
}
</script>
<span class="text-xs text-muted-foreground">{fmtDuration(value as number | null)}</span>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { relativeTime } from '$lib/utils'
import type { Entity } from '$lib/api'
let { row, value }: { row: Entity; value: unknown } = $props()
const dot: Record<string, string> = {
healthy: 'bg-success',
degraded: 'bg-warning',
down: 'bg-destructive',
stale: 'bg-warning/50',
unknown: 'bg-muted-foreground/40'
}
const health = $derived(row.health)
const lastCheck = $derived(row.last_check_at)
const title = $derived.by(() => {
if (!row.health) return 'not monitored'
if (row.health === 'stale') return `stale — last checked ${relativeTime(row.last_check_at)}`
return `${row.health} — checked ${relativeTime(row.last_check_at)}`
})
</script>
{#if health}
<span class="flex items-center gap-1.5 text-xs" title={title}>
<span class="size-2 shrink-0 rounded-full {dot[row.health ?? ''] ?? ''}"></span>
<span class="text-muted-foreground">{relativeTime(lastCheck)}</span>
</span>
{:else}
<span class="text-xs text-muted-foreground"></span>
{/if}

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { relativeTime } from '$lib/utils'
let { value }: { value: unknown } = $props()
</script>
<span class="text-xs text-muted-foreground">{relativeTime(String(value ?? ''))}</span>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import type { Signal } from '$lib/api'
let {
row,
acting = null,
onAck,
onMute,
onResolve
}: {
row: Signal
acting?: string | null
onAck?: (id: string) => void
onMute?: (id: string) => void
onResolve?: (id: string) => void
} = $props()
</script>
<div class="flex justify-end gap-2">
{#if row.state === 'raised'}
<Button size="sm" variant="outline" disabled={acting === row.id} onclick={() => onAck?.(row.id)}>Ack</Button>
{/if}
<Button size="sm" variant="outline" disabled={acting === row.id} onclick={() => onMute?.(row.id)}>Mute 1h</Button>
<Button size="sm" disabled={acting === row.id} onclick={() => onResolve?.(row.id)}>Resolve</Button>
</div>

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge'
let { value, kind = 'default' }: { value: unknown; kind?: 'risk' | 'severity' | 'execution' | 'state' | 'type' | 'default' } = $props()
const v = $derived(String(value ?? ''))
const variantMap: Record<string, Record<string, 'default' | 'secondary' | 'destructive' | 'outline'>> = {
risk: {
destructive: 'destructive',
config_mutation: 'secondary',
default: 'default',
},
severity: {
critical: 'destructive',
warning: 'secondary',
info: 'default',
default: 'default',
},
execution: {
failed: 'destructive',
denied: 'destructive',
revoked: 'destructive',
cancelled: 'destructive',
completed: 'default',
running: 'secondary',
approved: 'secondary',
default: 'outline',
},
state: {
active: 'default',
healthy: 'default',
default: 'outline',
},
type: {
runbook: 'secondary',
investigation: 'default',
default: 'outline',
},
default: { default: 'default' },
}
const variant = $derived.by(() => {
const map = variantMap[kind] ?? variantMap.default
return (map[v] ?? map.default) as 'default' | 'secondary' | 'destructive' | 'outline'
})
</script>
<Badge {variant}>{v}</Badge>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import { statusStyle } from '$lib/tasks'
import type { Session } from '$lib/api'
let { row }: { row: Session } = $props()
const st = $derived(statusStyle(row))
</script>
<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>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input'
import SearchIcon from '@lucide/svelte/icons/search'
import XIcon from '@lucide/svelte/icons/x'
import { debounce } from '$lib/utils'
let {
value = '',
placeholder = 'Search...',
class: className,
onSearch
}: {
value?: string
placeholder?: string
class?: string
onSearch?: (q: string) => void
} = $props()
let inputVal = $state('')
const debouncedSearch = debounce((q: string) => {
onSearch?.(q)
}, 200)
function handleInput(e: Event) {
const target = e.target as HTMLInputElement
inputVal = target.value
debouncedSearch(inputVal)
}
function clear() {
inputVal = ''
onSearch?.('')
}
</script>
<div class={['relative', className].filter(Boolean).join(' ')}>
<SearchIcon class="absolute left-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
{placeholder}
value={inputVal}
oninput={handleInput}
class="h-8 pl-8 pr-8 text-xs"
/>
{#if inputVal}
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onclick={clear}
>
<XIcon class="size-3" />
</button>
{/if}
</div>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
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'
let {
label,
sorted = false,
direction = 'asc',
onclick
}: {
label: string
sorted?: boolean
direction?: 'asc' | 'desc'
onclick?: () => void
} = $props()
</script>
<button type="button" class="flex items-center gap-1 hover:text-foreground" {onclick}>
{label}
{#if sorted}
{#if direction === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 text-muted-foreground/50" />
{/if}
</button>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import SearchInput from './search-input.svelte'
import RowsPerPage from './pagination/RowsPerPage.svelte'
import type { TableHandler } from '@vincjo/datatables'
let {
table,
searchable = false,
paginated = false,
onSearchChange,
children
}: {
table: TableHandler<Record<string, unknown>>
searchable?: boolean
paginated?: boolean
onSearchChange?: (q: string) => void
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children?: any
} = $props()
</script>
{#if searchable || paginated || children}
<div class="flex items-center gap-2 px-1 py-2">
{#if searchable}
<SearchInput placeholder="Search..." onSearch={onSearchChange} class="w-64" />
{/if}
<div class="flex-1"></div>
{@render children?.()}
{#if paginated}
<RowsPerPage {table} />
{/if}
</div>
{/if}