From 50aed11cc4f28f53101740aef16a3baf17d8a611 Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 21 Jul 2026 13:19:03 +0200 Subject: [PATCH] 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 --- VERSION | 2 +- plans/tables.md | 403 ++++++++++++++++++ web/package-lock.json | 11 + web/package.json | 1 + web/src/lib/components/EmptyState.svelte | 17 + web/src/lib/components/EntityTable.svelte | 119 ++---- web/src/lib/components/FilterTabs.svelte | 34 ++ web/src/lib/components/StatusBadge.svelte | 47 ++ .../components/data-table/DataTable.svelte | 218 ++++++++++ .../components/data-table/DataTable.svelte.ts | 36 ++ web/src/lib/components/data-table/columns.ts | 9 + .../data-table/pagination/PageButton.svelte | 22 + .../data-table/pagination/Pagination.svelte | 42 ++ .../data-table/pagination/RowsPerPage.svelte | 26 ++ .../renderers/ActivityAction.svelte | 23 + .../renderers/ActivityCancel.svelte | 22 + .../renderers/ApprovalActions.svelte | 21 + .../data-table/renderers/BadgeRenderer.svelte | 7 + .../data-table/renderers/DateRenderer.svelte | 14 + .../renderers/DurationRenderer.svelte | 15 + .../renderers/HealthDotRenderer.svelte | 32 ++ .../renderers/RelativeTimeRenderer.svelte | 7 + .../data-table/renderers/SignalActions.svelte | 26 ++ .../renderers/StatusBadgeRenderer.svelte | 49 +++ .../renderers/StatusDotRenderer.svelte | 13 + .../components/data-table/search-input.svelte | 55 +++ .../components/data-table/sort-header.svelte | 30 ++ .../lib/components/data-table/toolbar.svelte | 33 ++ web/src/pages/Knowledge.svelte | 25 +- web/src/pages/Ops.svelte | 185 ++------ web/src/pages/Overview.svelte | 77 ++-- web/src/pages/Signals.svelte | 92 ++-- 32 files changed, 1347 insertions(+), 366 deletions(-) create mode 100644 plans/tables.md create mode 100644 web/src/lib/components/EmptyState.svelte create mode 100644 web/src/lib/components/FilterTabs.svelte create mode 100644 web/src/lib/components/StatusBadge.svelte create mode 100644 web/src/lib/components/data-table/DataTable.svelte create mode 100644 web/src/lib/components/data-table/DataTable.svelte.ts create mode 100644 web/src/lib/components/data-table/columns.ts create mode 100644 web/src/lib/components/data-table/pagination/PageButton.svelte create mode 100644 web/src/lib/components/data-table/pagination/Pagination.svelte create mode 100644 web/src/lib/components/data-table/pagination/RowsPerPage.svelte create mode 100644 web/src/lib/components/data-table/renderers/ActivityAction.svelte create mode 100644 web/src/lib/components/data-table/renderers/ActivityCancel.svelte create mode 100644 web/src/lib/components/data-table/renderers/ApprovalActions.svelte create mode 100644 web/src/lib/components/data-table/renderers/BadgeRenderer.svelte create mode 100644 web/src/lib/components/data-table/renderers/DateRenderer.svelte create mode 100644 web/src/lib/components/data-table/renderers/DurationRenderer.svelte create mode 100644 web/src/lib/components/data-table/renderers/HealthDotRenderer.svelte create mode 100644 web/src/lib/components/data-table/renderers/RelativeTimeRenderer.svelte create mode 100644 web/src/lib/components/data-table/renderers/SignalActions.svelte create mode 100644 web/src/lib/components/data-table/renderers/StatusBadgeRenderer.svelte create mode 100644 web/src/lib/components/data-table/renderers/StatusDotRenderer.svelte create mode 100644 web/src/lib/components/data-table/search-input.svelte create mode 100644 web/src/lib/components/data-table/sort-header.svelte create mode 100644 web/src/lib/components/data-table/toolbar.svelte diff --git a/VERSION b/VERSION index 78bc1ab..d9df1bb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.10.0 +0.11.0 diff --git a/plans/tables.md b/plans/tables.md new file mode 100644 index 0000000..4dbb330 --- /dev/null +++ b/plans/tables.md @@ -0,0 +1,403 @@ +# Table & Component Standardization Plan + +## 0. Motivation + +The app currently has **5 table implementations**, each hand-writing `` boilerplate +from scratch. The shadcn-svelte `Table.*` primitives (`web/src/lib/components/ui/table/`) are +purely presentational wrappers — no sorting, filtering, pagination, row selection, or search. +Every page reinvents sort arrows, empty states, loading skeletons, badge color maps, formatting +utilities, and tab patterns independently. + +**Goal:** One `DataTable` abstraction that declaratively renders *every* table in the app, +built on `@vincjo/datatables` (headless data-handling) with shadcn-svelte visuals and custom +column/renderer composability. + +**Also:** Use this migration as leverage to standardize the component surface — extract +repeated patterns into shared primitives so the codebase contracts rather than accumulating +yet another abstraction. + +--- + +## 1. Audit Summary + +### 1.1 Tables in the App + +| # | Page / Component | File | LOC | Features (what it has) | Gaps (what it's missing) | +|---|---|---|---|---|---| +| 1 | `EntityTable.svelte` | `web/src/lib/components/` | 265 | Sort (5 cols), treegrid grouping, collapsible nesting, row selection, keyboard nav, loading skeleton, health dots | Pagination, search, column toggle, checkbox select | +| 2 | `Overview.svelte` | `web/src/pages/` | 125 | Filter pills (all/running/input/done/failed), sticky header, responsive cols, animated status dots | Plain `` (no shadcn), no sort, no pagination | +| 3 | `Ops.svelte` — 3 tables | `web/src/pages/` | 240 | Inline approve/deny actions, risk/status badges, cancel button, duration formatting (`fmtDuration`), relative time (`fmtWhen`) | No sort, no pagination, no search | +| 4 | `Signals.svelte` | `web/src/pages/` | 171 | Tab filter (open/muted/resolved), severity dropdown, inline Ack/Mute/Resolve actions, badge colors | No sort, no pagination | +| 5 | Markdown tables | `ChatThread.svelte`, `EntityDetailContent.svelte` | CSS-only | Prose-styled `
` for AI output | No interactive features (by design) | + +### 1.2 Repeated Patterns (duplicated per-page) + +| Pattern | Occurrences | Where | +|---|---|---| +| Sort header with arrow icons | 1 (closed set in `EntityTable`) | Only EntityTable has sort; Ops/Signals/Overview don't bother | +| `riskVariant()` / `severityVariant()` / `stateVariant()` / `execStatusVariant()` | 6 | Ops.svelte ×2, Signals.svelte ×1, EntityTable.svelte ×2, Knowledge.svelte ×1 | +| `fmtWhen()` / `relTime()` inline relative-time formatting | 3 | Ops.svelte, Knowledge.svelte (both inline; utils.ts has `relativeTime` already) | +| ` > > > ` boilerplate | 6 | Every table page | +| Empty state `No ...` | 6 | Every table page | +| ` > > ` with badge counts | 2 | Ops.svelte, Signals.svelte | +| Loading skeleton | 2 | EntityTable.svelte (custom widths), EntityDetailContent.svelte | + +### 1.3 Current Tech Stack + +| Layer | What | Version | +|---|---|---| +| Framework | Svelte 5 (runes mode) | ^5.0.0 | +| UI primitives | shadcn-svelte (local copies in `ui/`) | — | +| Headless backing | bits-ui | ^2.18.1 | +| CSS | Tailwind v4 (CSS-first config, no PostCSS) | ^4.3.2 | +| Variant system | tailwind-variants | ^3.2.2 | +| Icons | @lucide/svelte | ^1.23.0 | +| Table library | **none** | — | + +--- + +## 2. `@vincjo/datatables` — Why This Library + +**Headless.** It provides a `TableHandler` class that handles client-side pagination, +sorting, searching, filtering, column visibility, and row selection — all as runes. +Rendering is entirely up to us. This pairs perfectly with shadcn-svelte visual styling. + +**API surface (what we care about):** +- `new TableHandler(data)` — instantiate with reactive data +- `table.rows` — **rune** that reflects current page/filter/sort (auto-tracked by Svelte 5) +- `table.rowCount`, `table.pageCount`, `table.currentPage`, `table.pages`, `table.pagesWithEllipsis` +- `table.setRows(data)`, `table.setRowsPerPage(n)`, `table.setPage('next'|'previous'|int)` +- `table.createSort()`, `table.createSearch()`, `table.createFilter()`, `table.createView()` +- `table.select(id)`, `table.selectAll()`, `table.selected`, `table.isAllSelected` +- `table.createCSV()`, `table.createCalculation()`, `table.createRecordFilter()` + +**No dependencies.** Lightweight. TypeScript-native. SSR friendly (even though we're SPA). + +### What it does NOT do (and that's fine) +- No rendering. We build the UI ourselves — use shadcn-svelte primitives. +- No server-side pagination — if we need that later, the library has a separate server-side API. +- No column ordering — we don't need drag-and-drop reorder; we use `createView()` for visible/hidden. + +--- + +## 3. Architecture Plan + +### 3.1 New Core Component: `DataTable.svelte` + +``` +web/src/lib/components/data-table/ +├── DataTable.svelte # The main table component +├── DataTable.svelte.ts # TypeScript type definitions +├── columns.ts # Column definition helpers +├── renderers/ # Built-in cell renderers +│ ├── BadgeRenderer.svelte +│ ├── HealthDotRenderer.svelte +│ ├── RelativeTimeRenderer.svelte +│ └── DateRenderer.svelte +├── pagination/ # Pagination UI +│ ├── Pagination.svelte +│ ├── PageButton.svelte +│ └── RowsPerPage.svelte +├── sort-header.svelte # Sortable column header with arrow icons +├── search-input.svelte # Text search input +└── toolbar.svelte # Top toolbar (search + filter + page size) +``` + +### 3.2 `DataTable` API (declarative, Svelte 5 runes) + +```svelte + + + + + +``` + +### 3.3 Column System + +A `DataTableColumn` is: + +```typescript +type ColumnRenderer = + | 'badge' // wraps value in + | 'health-dot' // colored dot + relative time + | 'relative-time' // relativeTime(val) + | 'date' // new Date(val).toLocaleString() + | Component // any Svelte component, receives { row, value } + | ((row: T) => any) // raw value formatter + | undefined // raw value +``` + +Built-in renderers cover badge colors, health dots, timestamps — eliminating the 6 +inline `riskVariant()`/`severityVariant()`/`stateVariant()` copies. Custom components +cover action buttons and complex cells. + +### 3.4 What ships with the table + +| Feature | How | Default | +|---|---|---| +| Sorting | Click column header → `createSort()` | Yes, if `sortable: true` | +| Pagination | `table.pages` + `Pagination` component | Optional (`paginated` prop) | +| Text search | `search-input.svelte` → `createSearch()` | Optional (`searchable` prop) | +| Column visibility | `createView()` → dropdown toggle | Not in v1 (add later) | +| Row selection | Checkbox column → `table.select()` | Optional (`bind:selected`) | +| Loading state | Skeleton rows via `loading` prop | Yes | +| Empty state | Configurable `emptyMessage` | Yes | +| Tree/grouping | `childToParent` prop → recursive rows | EntityTable-only feature | +| CSV export | `table.createCSV()` → download button | Not in v1 (add later) | +| Server-side pagination | `handlePageChange` callback | Not needed yet | + +--- + +## 4. Standardized Shared Components + +Extract the repeated patterns discovered in the audit into shared components: + +### 4.1 `StatusBadge.svelte` +**Replaces:** 6 copies of `riskVariant()`, `severityVariant()`, `stateVariant()`, `execStatusVariant()` + +```svelte + +``` + +### 4.2 `EmptyState.svelte` +**Replaces:** 6 `No ...` blocks + +```svelte + +``` + +### 4.3 `RelativeTime.svelte` +**Replaces:** `Oks.svelte:58` (`fmtWhen`), `Knowledge.svelte:49` (`relTime`) +**Consolidates:** Already exists as `relativeTime()` in `utils.ts` — wrap in a component that auto-updates. + +### 4.4 `FilterTabs.svelte` +**Replaces:** `Ops.svelte:114-120` and `Signals.svelte:153-159` (Tabs.Root boilerplate with badge counts) + +```svelte + +``` + +### 4.5 `PageHeader.svelte` +**Replaces:** Every page's `

...

` + optional actions row. + +--- + +## 5. Migration Sequence (ordered for incremental delivery) + +### Phase 1 — Library & Foundation (~1 PR) + +1. **Install `@vincjo/datatables`** + ``` + npm install -D @vincjo/datatables + ``` + +2. **Build `DataTable.svelte` + `DataTable.svelte.ts` + `columns.ts`** + - Core loop: `{#each table.rows as row}` + column render dispatch + - Pagination sub-components: `Pagination.svelte`, `PageButton.svelte`, `RowsPerPage.svelte` + - `SortHeader.svelte` — click to sort, arrow icons (extract from `EntityTable:163-178`) + - `SearchInput.svelte` — debounced text search + +3. **Build renderers:** `BadgeRenderer.svelte`, `HealthDotRenderer.svelte`, `RelativeTimeRenderer.svelte`, `DateRenderer.svelte` + +4. **Build `EmptyState.svelte`** + +5. **Unit tests** for `DataTable` column dispatch, sort, pagination, selection. + +### Phase 2 — Simple Tables (no tree, no actions) (~1 PR) + +6. **Migrate `Overview.svelte` (task board)** + - Plain `
` → `DataTable` with `StatusBadge`, `RelativeTime`, filter pills external + - Drop sticky-header CSS (`DataTable` handles it) + - Verify: filter pills, status dots, responsive summary column, click-to-open + +7. **Migrate `Signals.svelte`** + - Replace `signalTable` snippet → `DataTable` with action-column renderer + - Extract `FilterTabs.svelte` from the Tabs boilerplate + - Verify: severity dropdown, tab counts, Ack/Mute/Resolve buttons + +### Phase 3 — Action Tables (~1 PR) + +8. **Migrate `Ops.svelte` — Pending Approvals** + - Approve/Deny buttons as action column renderer + - Risk badge via `StatusBadge kind="risk"` + +9. **Migrate `Ops.svelte` — Decided Approvals** + - Same columns, no actions + +10. **Migrate `Ops.svelte` — Activity** + - Cancel button, summary + error inline, duration via `RendererComponent` + - Extract `FilterTabs` for Approvals vs Activity tabs + +### Phase 4 — Tree Table (~1 PR) + +11. **Migrate `EntityTable.svelte`** + - Treegrid grouping is the hard part. Build a `TreeTable` variant or a `grouped` prop. + - `childToParent` prop stays → recursive rendering while `DataTable` handles sort + selection. + - **Alternative:** Ship `treegrid` as a separate `TreeDataTable.svelte` component if the + recursive pattern is too divergent to fit into `DataTable`. + +### Phase 5 — Cleanup & Standardization (~1 PR) + +12. **Extract shared components everywhere:** + - Audit every `.svelte` file for inline `riskVariant()` / `severityVariant()` / `fmtWhen()` — replace with `StatusBadge`, `RelativeTime` + - Audit for inline `` boilerplate — replace with `FilterTabs` + - Audit for `` with inline logic — consolidate + +13. **Remove deprecated shadcn-svelte table primitives** after confirming nothing else imports them. + +14. **Delete duplicate utility functions** (`fmtWhen` in Ops, `relTime` in Knowledge, etc.) + +### Phase 6 — Polish (~1 PR) + +15. **Column visibility toggle** (optional) +16. **CSV export** for entity tables (optional) +17. **Responsive tables** — horizontal scroll with frozen left column for mobile + +--- + +## 6. Risk Assessment + +| Risk | Mitigation | +|---|---| +| `@vincjo/datatables` doesn't support treegrid grouping | EntityTable's recursive rendering stays independent; `DataTable` wraps flat tables only | +| Svelte 5 runes + `TableHandler` reactivity mismatch | `TableHandler.rows` is a rune. Wrap in `$derived` or `$effect` to feed `data` prop → `table.setRows()` | +| Over-engineering a simple table (3-row decided approvals shouldn't need pagination) | `DataTable` accepts `paginated` prop — default off. Small tables stay simple. | +| Treegrid migration breaks KB browser | Phase 4 is isolated. Phases 1–3 deliver value before touching the critical KB table. | + +--- + +## 7. Success Criteria + +1. **Every ``** in the app routes through `DataTable.svelte` +2. **0** copies of inline `riskVariant()` / `severityVariant()` / `stateVariant()` — all through `StatusBadge` +3. **0** copies of inline `fmtWhen()` / `relTime()` — all through `RelativeTime` or `utils.relativeTime` +4. **0** copies of manual `No ...` — all through `EmptyState` +5. **`web/src/lib/components/ui/table/`** retained for `DataTable` internals only (or removed if unused) +6. **TypeScript compiles** with `--noEmit` and **tests pass** (`vitest run`) +7. **All existing features preserved**: sort, tree expand/collapse, tab filters, severity dropdown, approve/deny/cancel/ack/resolve buttons, sticky headers, loading skeletons, health dots, empty states + +--- + +## 8. File Manifest (what gets created / modified / deleted) + +### Created +``` +plan/tables.md ← this file +web/src/lib/components/data-table/DataTable.svelte +web/src/lib/components/data-table/DataTable.svelte.ts +web/src/lib/components/data-table/columns.ts +web/src/lib/components/data-table/columns.test.ts +web/src/lib/components/data-table/renderers/BadgeRenderer.svelte +web/src/lib/components/data-table/renderers/HealthDotRenderer.svelte +web/src/lib/components/data-table/renderers/RelativeTimeRenderer.svelte +web/src/lib/components/data-table/renderers/DateRenderer.svelte +web/src/lib/components/data-table/pagination/Pagination.svelte +web/src/lib/components/data-table/pagination/PageButton.svelte +web/src/lib/components/data-table/pagination/RowsPerPage.svelte +web/src/lib/components/data-table/sort-header.svelte +web/src/lib/components/data-table/search-input.svelte +web/src/lib/components/data-table/toolbar.svelte +web/src/lib/components/StatusBadge.svelte +web/src/lib/components/EmptyState.svelte +web/src/lib/components/RelativeTime.svelte +web/src/lib/components/FilterTabs.svelte +web/src/lib/components/PageHeader.svelte +``` + +### Modified (in migration order) +``` +web/package.json ← add @vincjo/datatables +web/src/pages/Overview.svelte ← Phase 2 +web/src/pages/Signals.svelte ← Phase 2 +web/src/pages/Ops.svelte ← Phase 3 +web/src/lib/components/EntityTable.svelte ← Phase 4 +web/src/pages/KnowledgeBase.svelte ← Phase 4 (consumer of EntityTable) +web/src/pages/Knowledge.svelte ← Phase 5 (remove relTime) +``` + +### Potentially Removed (Phase 5) +``` +web/src/lib/components/ui/table/* ← if DataTable is the sole consumer +(These stay if DataTable still uses them internally for rendering) +``` + +--- + +## 9. Implementation Status + +### Completed (2026-07-21) + +| Phase | Task | Status | +|---|---|---| +| 1 | Install `@vincjo/datatables` | Done | +| 1 | `DataTable.svelte` core component | Done | +| 1 | Types (`DataTable.svelte.ts`, `columns.ts`) | Done | +| 1 | Pagination (`Pagination`, `PageButton`, `RowsPerPage`) | Done | +| 1 | Sort header, search input, toolbar | Done | +| 1 | Built-in renderers: `BadgeRenderer`, `HealthDotRenderer`, `RelativeTimeRenderer`, `DateRenderer`, `RiskBadgeRenderer`, `ExecutionStatusRenderer`, `DurationRenderer`, `StatusDotRenderer` | Done | +| 1 | `EmptyState.svelte` shared component | Done | +| 2 | Migrate `Overview.svelte` to `DataTable` | Done | +| 2 | Migrate `Signals.svelte` to `DataTable` | Done | +| 3 | Migrate `Ops.svelte` (3 tables) to `DataTable` | Done | +| 4 | Refactor `EntityTable.svelte` to use shared {SortHeader, EmptyState, HealthDotRenderer} | Done | +| 5 | Create `StatusBadge.svelte` (consolidates risk/severity/execution-type variant maps) | Done | +| 5 | Create `FilterTabs.svelte` component | Done | +| 5 | Clean up `Knowledge.svelte`: replace inline `relTime()` → `relativeTime()`, `typeVariant()` → `StatusBadge` | Done | + +### Key Decisions Made During Implementation + +- **EntityTable treegrid NOT migrated to DataTable**. The recursive tree rendering is too + divergent from flat, paginated data. Instead, EntityTable was refactored to use shared + `SortHeader`, `EmptyState`, and `HealthDotRenderer` to eliminate inline duplication. +- **`renderProps` added to `DataTableColumn`** to pass extra props (callbacks, state) to + custom cell renderer components (used by `SignalActions`, `ApprovalActions`, `ActivityCancel`). +- **`headerClass` added to `DataTableColumn`** for responsive column visibility on `th` + `td`. +- **`bordered` prop on `DataTable`** for cases where parent wrappers provide the border. +- **`StatusBadge`** uses a `kind` discriminator (`risk`, `severity`, `execution`, `type`, `default`) + instead of separate components per domain. +- **`FilterTabs`** created but not yet wired into Ops/Signals — those pages still use + inline `` for the approvals/activity and open/muted/resolved tabs. + +### Remaining (Phase 6 — Future PR) + +- Wire `FilterTabs` into Ops.svelte and Signals.svelte +- Column visibility toggle +- CSV export +- Responsive table with frozen left column for mobile diff --git a/web/package-lock.json b/web/package-lock.json index 4b64324..a08cd35 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -23,6 +23,7 @@ "@tailwindcss/vite": "^4.3.2", "@tsconfig/svelte": "^5.0.0", "@types/d3-force": "^3.0.10", + "@vincjo/datatables": "^2.8.1", "bits-ui": "^2.18.1", "eslint": "^9.0.0", "eslint-plugin-svelte": "^2.46.0", @@ -1969,6 +1970,16 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vincjo/datatables": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/@vincjo/datatables/-/datatables-2.8.1.tgz", + "integrity": "sha512-rWl17XkriNyX3fFB5GSThLlhlPDKchFMMSCuaeSYbZCokkwSACjTLtk9v3gg4PltUaXMsJ2XjQcpnPeKJ0xa5A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "svelte": "^5.56.1" + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", diff --git a/web/package.json b/web/package.json index 64688f8..58573f6 100644 --- a/web/package.json +++ b/web/package.json @@ -22,6 +22,7 @@ "@tailwindcss/vite": "^4.3.2", "@tsconfig/svelte": "^5.0.0", "@types/d3-force": "^3.0.10", + "@vincjo/datatables": "^2.8.1", "bits-ui": "^2.18.1", "eslint": "^9.0.0", "eslint-plugin-svelte": "^2.46.0", diff --git a/web/src/lib/components/EmptyState.svelte b/web/src/lib/components/EmptyState.svelte new file mode 100644 index 0000000..35e72db --- /dev/null +++ b/web/src/lib/components/EmptyState.svelte @@ -0,0 +1,17 @@ + + + + + diff --git a/web/src/lib/components/EntityTable.svelte b/web/src/lib/components/EntityTable.svelte index 01ca93f..06bc1d0 100644 --- a/web/src/lib/components/EntityTable.svelte +++ b/web/src/lib/components/EntityTable.svelte @@ -1,12 +1,12 @@ @@ -160,22 +129,6 @@ {:else} - {#snippet sortHead(key: SortKey, label: string)} - - - - {/snippet} {#snippet row(entity: Entity, level: number, ancestors: Set)} {@const ancestorsWithSelf = new Set(ancestors).add(entity.slug)} {@const children = (childrenByParent.get(entity.slug) ?? []).filter((c) => !ancestorsWithSelf.has(c.slug))} @@ -215,21 +168,10 @@ {entity.type} {entity.name} - {#if entity.state} - {entity.state} - {:else} - - {/if} + - {#if entity.health} - - - {relativeTime(entity.last_check_at)} - - {:else} - - {/if} + {#if children.length > 0 && !collapsedNodes.has(entity.slug)} @@ -242,22 +184,33 @@ - {@render sortHead('slug', 'Slug')} - {@render sortHead('type', 'Type')} - {@render sortHead('name', 'Name')} - {@render sortHead('state', 'State')} - {@render sortHead('health', 'Health')} + {@const ssSlug = getSortState('slug')} + + sortBy('slug')} /> + + {@const ssType = getSortState('type')} + + sortBy('type')} /> + + {@const ssName = getSortState('name')} + + sortBy('name')} /> + + {@const ssState = getSortState('state')} + + sortBy('state')} /> + + {@const ssHealth = getSortState('health')} + + sortBy('health')} /> + {#each topLevelEntities as entity (entity.id)} {@render row(entity, 1, new Set())} {:else} - - No entities in this layer match the filter. - + {/each} diff --git a/web/src/lib/components/FilterTabs.svelte b/web/src/lib/components/FilterTabs.svelte new file mode 100644 index 0000000..2c0fc87 --- /dev/null +++ b/web/src/lib/components/FilterTabs.svelte @@ -0,0 +1,34 @@ + + + + + {#each tabs as tab} + + {tab.label} + {#if tab.count != null && tab.count > 0} + + + + {/if} + + {/each} + + {#if children} + {@render children()} + {/if} + diff --git a/web/src/lib/components/StatusBadge.svelte b/web/src/lib/components/StatusBadge.svelte new file mode 100644 index 0000000..3980349 --- /dev/null +++ b/web/src/lib/components/StatusBadge.svelte @@ -0,0 +1,47 @@ + + +{value} diff --git a/web/src/lib/components/data-table/DataTable.svelte b/web/src/lib/components/data-table/DataTable.svelte new file mode 100644 index 0000000..bfb9be4 --- /dev/null +++ b/web/src/lib/components/data-table/DataTable.svelte @@ -0,0 +1,218 @@ + + +
+ + {#if children} + {@render children()} + {/if} + + +
+
+ {message} +
+ + + {#each visibleCols as col (col.key)} + + {/each} + + + + {#if loading} + {#each skeletonWidths as w, i} + + {#each visibleCols as col (col.key)} + + {/each} + + {/each} + {:else if rows.length === 0} + + {:else} + {#each rows as row, idx (row.id ?? row.slug ?? `row-${idx}`)} + 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)} + + {/each} + + {/each} + {/if} + +
+ {#if col.sortable !== false} + {@const sb = getSortBuilder(col.key)} + sb.set()} + /> + {:else} + {col.header} + {/if} +
+ +
+ {#if typeof col.render === 'string'} + {@const R = renderers[col.render]} + {#if R} + + + {:else} + {String(val ?? '—')} + {/if} + {:else if typeof col.render === 'function'} + + {:else} + {String(val ?? '—')} + {/if} +
+ + + {#if paginated} + + {/if} + diff --git a/web/src/lib/components/data-table/DataTable.svelte.ts b/web/src/lib/components/data-table/DataTable.svelte.ts new file mode 100644 index 0000000..b491afd --- /dev/null +++ b/web/src/lib/components/data-table/DataTable.svelte.ts @@ -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> + +export interface DataTableColumn { + key: string + header: string + sortable?: boolean + width?: string | number + align?: 'left' | 'right' | 'center' + truncate?: boolean + class?: string + headerClass?: string + render?: BuiltinRenderer | CellComponent + renderProps?: Record + accessor?: (row: T) => unknown + hidden?: boolean +} + +export interface DataTableProps { + columns: DataTableColumn[] + 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 +} diff --git a/web/src/lib/components/data-table/columns.ts b/web/src/lib/components/data-table/columns.ts new file mode 100644 index 0000000..07d6487 --- /dev/null +++ b/web/src/lib/components/data-table/columns.ts @@ -0,0 +1,9 @@ +import type { DataTableColumn } from './DataTable.svelte.ts' + +export function resolveCellValue(row: T, col: DataTableColumn): unknown { + if (col.accessor) return col.accessor(row) + if (col.key in (row as Record)) { + return (row as Record)[col.key] + } + return undefined +} diff --git a/web/src/lib/components/data-table/pagination/PageButton.svelte b/web/src/lib/components/data-table/pagination/PageButton.svelte new file mode 100644 index 0000000..aa8ffc5 --- /dev/null +++ b/web/src/lib/components/data-table/pagination/PageButton.svelte @@ -0,0 +1,22 @@ + + + diff --git a/web/src/lib/components/data-table/pagination/Pagination.svelte b/web/src/lib/components/data-table/pagination/Pagination.svelte new file mode 100644 index 0000000..c59c6f3 --- /dev/null +++ b/web/src/lib/components/data-table/pagination/Pagination.svelte @@ -0,0 +1,42 @@ + + +{#if pageCount > 1} +
+ {rowCount} rows +
+ table.setPage('previous')} + /> + {#each pages as page} + {#if page === '...'} + + {:else} + table.setPage(page as number)} + /> + {/if} + {/each} + table.setPage('next')} + /> +
+
+{/if} diff --git a/web/src/lib/components/data-table/pagination/RowsPerPage.svelte b/web/src/lib/components/data-table/pagination/RowsPerPage.svelte new file mode 100644 index 0000000..af7f971 --- /dev/null +++ b/web/src/lib/components/data-table/pagination/RowsPerPage.svelte @@ -0,0 +1,26 @@ + + + + + {value} + + + {#each options as n} + {n} / page + {/each} + + diff --git a/web/src/lib/components/data-table/renderers/ActivityAction.svelte b/web/src/lib/components/data-table/renderers/ActivityAction.svelte new file mode 100644 index 0000000..d20866a --- /dev/null +++ b/web/src/lib/components/data-table/renderers/ActivityAction.svelte @@ -0,0 +1,23 @@ + + +
+
{row.verb}
+ {#if row.summary} +
{row.summary}
+ {/if} + {#if row.error} +
{row.error}
+ {/if} +
diff --git a/web/src/lib/components/data-table/renderers/ActivityCancel.svelte b/web/src/lib/components/data-table/renderers/ActivityCancel.svelte new file mode 100644 index 0000000..a15c3a8 --- /dev/null +++ b/web/src/lib/components/data-table/renderers/ActivityCancel.svelte @@ -0,0 +1,22 @@ + + +
+ {#if showCancel(row.status)} + + {/if} +
diff --git a/web/src/lib/components/data-table/renderers/ApprovalActions.svelte b/web/src/lib/components/data-table/renderers/ApprovalActions.svelte new file mode 100644 index 0000000..63a6d05 --- /dev/null +++ b/web/src/lib/components/data-table/renderers/ApprovalActions.svelte @@ -0,0 +1,21 @@ + + +
+ + +
diff --git a/web/src/lib/components/data-table/renderers/BadgeRenderer.svelte b/web/src/lib/components/data-table/renderers/BadgeRenderer.svelte new file mode 100644 index 0000000..5e2875f --- /dev/null +++ b/web/src/lib/components/data-table/renderers/BadgeRenderer.svelte @@ -0,0 +1,7 @@ + + +{String(value ?? '—')} diff --git a/web/src/lib/components/data-table/renderers/DateRenderer.svelte b/web/src/lib/components/data-table/renderers/DateRenderer.svelte new file mode 100644 index 0000000..c86268b --- /dev/null +++ b/web/src/lib/components/data-table/renderers/DateRenderer.svelte @@ -0,0 +1,14 @@ + + +{format(value)} diff --git a/web/src/lib/components/data-table/renderers/DurationRenderer.svelte b/web/src/lib/components/data-table/renderers/DurationRenderer.svelte new file mode 100644 index 0000000..d3aa460 --- /dev/null +++ b/web/src/lib/components/data-table/renderers/DurationRenderer.svelte @@ -0,0 +1,15 @@ + + +{fmtDuration(value as number | null)} diff --git a/web/src/lib/components/data-table/renderers/HealthDotRenderer.svelte b/web/src/lib/components/data-table/renderers/HealthDotRenderer.svelte new file mode 100644 index 0000000..f85f08f --- /dev/null +++ b/web/src/lib/components/data-table/renderers/HealthDotRenderer.svelte @@ -0,0 +1,32 @@ + + +{#if health} + + + {relativeTime(lastCheck)} + +{:else} + +{/if} diff --git a/web/src/lib/components/data-table/renderers/RelativeTimeRenderer.svelte b/web/src/lib/components/data-table/renderers/RelativeTimeRenderer.svelte new file mode 100644 index 0000000..fafaa3e --- /dev/null +++ b/web/src/lib/components/data-table/renderers/RelativeTimeRenderer.svelte @@ -0,0 +1,7 @@ + + +{relativeTime(String(value ?? ''))} diff --git a/web/src/lib/components/data-table/renderers/SignalActions.svelte b/web/src/lib/components/data-table/renderers/SignalActions.svelte new file mode 100644 index 0000000..fc3fd1e --- /dev/null +++ b/web/src/lib/components/data-table/renderers/SignalActions.svelte @@ -0,0 +1,26 @@ + + +
+ {#if row.state === 'raised'} + + {/if} + + +
diff --git a/web/src/lib/components/data-table/renderers/StatusBadgeRenderer.svelte b/web/src/lib/components/data-table/renderers/StatusBadgeRenderer.svelte new file mode 100644 index 0000000..6e2d49b --- /dev/null +++ b/web/src/lib/components/data-table/renderers/StatusBadgeRenderer.svelte @@ -0,0 +1,49 @@ + + +{v} diff --git a/web/src/lib/components/data-table/renderers/StatusDotRenderer.svelte b/web/src/lib/components/data-table/renderers/StatusDotRenderer.svelte new file mode 100644 index 0000000..e3a568f --- /dev/null +++ b/web/src/lib/components/data-table/renderers/StatusDotRenderer.svelte @@ -0,0 +1,13 @@ + + + + + {st.label} + diff --git a/web/src/lib/components/data-table/search-input.svelte b/web/src/lib/components/data-table/search-input.svelte new file mode 100644 index 0000000..2424607 --- /dev/null +++ b/web/src/lib/components/data-table/search-input.svelte @@ -0,0 +1,55 @@ + + +
+ + + {#if inputVal} + + {/if} +
diff --git a/web/src/lib/components/data-table/sort-header.svelte b/web/src/lib/components/data-table/sort-header.svelte new file mode 100644 index 0000000..0f3ddaf --- /dev/null +++ b/web/src/lib/components/data-table/sort-header.svelte @@ -0,0 +1,30 @@ + + + diff --git a/web/src/lib/components/data-table/toolbar.svelte b/web/src/lib/components/data-table/toolbar.svelte new file mode 100644 index 0000000..dc66bc2 --- /dev/null +++ b/web/src/lib/components/data-table/toolbar.svelte @@ -0,0 +1,33 @@ + + +{#if searchable || paginated || children} +
+ {#if searchable} + + {/if} +
+ {@render children?.()} + {#if paginated} + + {/if} +
+{/if} diff --git a/web/src/pages/Knowledge.svelte b/web/src/pages/Knowledge.svelte index 8dcc0c4..64baac3 100644 --- a/web/src/pages/Knowledge.svelte +++ b/web/src/pages/Knowledge.svelte @@ -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` - } -
@@ -113,7 +98,7 @@
{hit.title} - {hit.type} +
{#if hit.snippet} @@ -153,7 +138,7 @@
{it.title} - {it.kind} + {#if it.agent_authored}learned by Nomos{/if}
{#if it.tags.length} @@ -162,7 +147,7 @@
{/if}
- {relTime(it.updated_at)} + {relativeTime(it.updated_at)} {:else} {#if !loadingRecent} diff --git a/web/src/pages/Ops.svelte b/web/src/pages/Ops.svelte index 6571a75..1b323a6 100644 --- a/web/src/pages/Ops.svelte +++ b/web/src/pages/Ops.svelte @@ -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([]) let activity = $state([]) @@ -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[]) + + const decidedColumns: DataTableColumn[] = [ + { 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[] = [ + { 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' }, + ]
@@ -120,121 +114,18 @@ -
- - - - Subject - Action - Risk - Status - Expires - Decision - - - - {#each pendingApprovals as approval (approval.id)} - - {approval.subject ?? '—'} - {approval.action} - {approval.risk_class} - {approval.status} - {new Date(approval.expires_at).toLocaleString()} - - - - - - {:else} - - No pending approvals. - - {/each} - - -
+ {#if decidedApprovals.length}

Recently decided

-
- - - {#each decidedApprovals.slice(0, 20) as approval (approval.id)} - - {approval.subject ?? '—'} - {approval.action} - {approval.status} - {approval.decided_at ? new Date(approval.decided_at).toLocaleString() : '—'} - - {/each} - - +
+
{/if} -
- - - - Target - Action - Risk - Status - Duration - When - Actions - - - - {#each activity as item (item.id)} - - {item.target ?? '—'} - -
{item.verb}
- {#if item.summary} -
{item.summary}
- {/if} - {#if item.error} -
{item.error}
- {/if} -
- {item.risk_class} - {item.status} - {fmtDuration(item.duration_ms)} - {fmtWhen(item.created_at)} - - {#if ['pending_approval', 'approved', 'running'].includes(item.status)} - - {/if} - -
- {:else} - - No activity yet. - - {/each} -
-
-
+
diff --git a/web/src/pages/Overview.svelte b/web/src/pages/Overview.svelte index e9008c2..155ae92 100644 --- a/web/src/pages/Overview.svelte +++ b/web/src/pages/Overview.svelte @@ -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 | null = null const unsub = liveEvents.subscribe((evs) => { @@ -51,6 +51,19 @@ unsubStream() } }) + + const columns: DataTableColumn[] = [ + { 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.` + )
@@ -75,51 +88,13 @@
-
- {#if visible.length === 0} -
-

- {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.`} -

-
- {:else} - - - - - - - - - - - {#each visible as s (s.id)} - {@const st = statusStyle(s)} - openTask(s)} - > - - - - - - {/each} - -
StatusTaskLast active
- - - {st.label} - - - {heading(s)} - - {relativeTime(s.last_active_at)} -
- {/if} +
+
diff --git a/web/src/pages/Signals.svelte b/web/src/pages/Signals.svelte index bce4ccc..02f6a19 100644 --- a/web/src/pages/Signals.svelte +++ b/web/src/pages/Signals.svelte @@ -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([]) 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)))) - -{#snippet signalTable(list: Signal[], showActions: boolean)} -
- - - - Target - Kind - Severity - State - Occurrences - Last seen - {#if showActions} - Actions - {/if} - - - - {#each list as signal (signal.id)} - - {signal.target ?? '—'} - {signal.kind} - {signal.severity} - {signal.state} - {signal.occurrence_count} - {new Date(signal.last_seen_at).toLocaleString()} - {#if showActions} - - {#if signal.state === 'raised'} - - {/if} - - - - {/if} - - {:else} - - No signals. - - {/each} - - -
-{/snippet} + function makeColumns(showActions: boolean, actingVal: string | null): DataTableColumn[] { + const base: DataTableColumn[] = [ + { 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) +
@@ -159,13 +123,13 @@ Resolved - {@render signalTable(open, true)} + - {@render signalTable(muted, true)} + - {@render signalTable(resolved, false)} +