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:
403
plans/tables.md
Normal file
403
plans/tables.md
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
# Table & Component Standardization Plan
|
||||||
|
|
||||||
|
## 0. Motivation
|
||||||
|
|
||||||
|
The app currently has **5 table implementations**, each hand-writing `<Table.Root>` 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 `<table>` (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 `<table>` 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) |
|
||||||
|
| `<Table.Root> > <Table.Header> > <Table.Row> > <Table.Head>` boilerplate | 6 | Every table page |
|
||||||
|
| Empty state `<Table.Cell colspan={N}>No ...</Table.Cell>` | 6 | Every table page |
|
||||||
|
| `<Tabs.Root> > <Tabs.List> > <Tabs.Trigger>` 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
|
||||||
|
<script lang="ts">
|
||||||
|
import DataTable from '$lib/components/data-table/DataTable.svelte'
|
||||||
|
import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte'
|
||||||
|
|
||||||
|
let data = $state<MyRow[]>([])
|
||||||
|
let selected = $state<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const columns: DataTableColumn<MyRow>[] = [
|
||||||
|
{ key: 'slug', header: 'Slug', sortable: true, class: 'font-mono text-xs' },
|
||||||
|
{ key: 'type', header: 'Type', sortable: true, render: 'badge' },
|
||||||
|
{ key: 'health', header: 'Health', sortable: true, render: 'health-dot', accessor: (r) => r },
|
||||||
|
{ key: 'actions', header: '', sortable: false, render: (row) => component /* snippet or component */ },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
{columns}
|
||||||
|
{data}
|
||||||
|
bind:selected
|
||||||
|
pageSize={20}
|
||||||
|
searchable
|
||||||
|
paginated
|
||||||
|
sortKey="slug"
|
||||||
|
sortDir="asc"
|
||||||
|
loading
|
||||||
|
emptyMessage="No items."
|
||||||
|
>
|
||||||
|
<!-- optional slot for toolbar actions -->
|
||||||
|
</DataTable>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Column System
|
||||||
|
|
||||||
|
A `DataTableColumn<T>` is:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ColumnRenderer<T> =
|
||||||
|
| 'badge' // wraps value in <Badge variant="outline">
|
||||||
|
| '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
|
||||||
|
<script lang="ts">
|
||||||
|
let { value, kind = 'state' }: { value: string; kind?: 'risk' | 'severity' | 'state' | 'execution' } = $props()
|
||||||
|
// Resolves variant mapping from kind + value
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 `EmptyState.svelte`
|
||||||
|
**Replaces:** 6 `<Table.Cell colspan={N}>No ...</Table.Cell>` blocks
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
let { message = 'No items.', colspan = 999, icon = null } = $props()
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
<script lang="ts">
|
||||||
|
let { tabs, value = $bindable(''), class, children }: {
|
||||||
|
tabs: { value: string; label: string; count?: number }[];
|
||||||
|
value?: string;
|
||||||
|
class?: string;
|
||||||
|
children?: any;
|
||||||
|
} = $props()
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 `PageHeader.svelte`
|
||||||
|
**Replaces:** Every page's `<h1 class="text-lg font-semibold">...</h1>` + 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 `<table>` → `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 `<Tabs.Root>` boilerplate — replace with `FilterTabs`
|
||||||
|
- Audit for `<Badge variant={...}>` 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 `<Table.Root>`** 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 `<Table.Cell colspan={N}>No ...</Table.Cell>` — 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 `<Tabs.Root>` 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
|
||||||
11
web/package-lock.json
generated
11
web/package-lock.json
generated
@@ -23,6 +23,7 @@
|
|||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@tsconfig/svelte": "^5.0.0",
|
"@tsconfig/svelte": "^5.0.0",
|
||||||
"@types/d3-force": "^3.0.10",
|
"@types/d3-force": "^3.0.10",
|
||||||
|
"@vincjo/datatables": "^2.8.1",
|
||||||
"bits-ui": "^2.18.1",
|
"bits-ui": "^2.18.1",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-plugin-svelte": "^2.46.0",
|
"eslint-plugin-svelte": "^2.46.0",
|
||||||
@@ -1969,6 +1970,16 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"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": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "2.1.9",
|
"version": "2.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@tsconfig/svelte": "^5.0.0",
|
"@tsconfig/svelte": "^5.0.0",
|
||||||
"@types/d3-force": "^3.0.10",
|
"@types/d3-force": "^3.0.10",
|
||||||
|
"@vincjo/datatables": "^2.8.1",
|
||||||
"bits-ui": "^2.18.1",
|
"bits-ui": "^2.18.1",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-plugin-svelte": "^2.46.0",
|
"eslint-plugin-svelte": "^2.46.0",
|
||||||
|
|||||||
17
web/src/lib/components/EmptyState.svelte
Normal file
17
web/src/lib/components/EmptyState.svelte
Normal 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>
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Entity, EntityHealth } from '$lib/api'
|
import type { Entity } from '$lib/api'
|
||||||
import { relativeTime } from '$lib/utils'
|
|
||||||
import * as Table from '$lib/components/ui/table'
|
import * as Table from '$lib/components/ui/table'
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
import SortHeader from '$lib/components/data-table/sort-header.svelte'
|
||||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down'
|
import EmptyState from '$lib/components/EmptyState.svelte'
|
||||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down'
|
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 ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||||
|
|
||||||
@@ -21,15 +21,6 @@
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
selectedSlug?: string | null
|
selectedSlug?: string | null
|
||||||
onSelect: (slug: string) => void
|
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
|
childToParent?: Map<string, string> | null
|
||||||
} = $props()
|
} = $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 {
|
function sortValue(entity: Entity, key: SortKey): string | number {
|
||||||
if (key === 'health') return entity.health ? healthRank[entity.health] : -1
|
if (key === 'health') return entity.health ? (healthRank[entity.health] ?? -1) : -1
|
||||||
return (entity[key] ?? '').toString().toLowerCase()
|
return (entity[key as keyof Entity] ?? '').toString().toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedEntities = $derived.by(() => {
|
const sortedEntities = $derived.by(() => {
|
||||||
@@ -74,12 +70,6 @@
|
|||||||
return sorted
|
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 childrenByParent = $derived.by(() => {
|
||||||
const map = new Map<string, Entity[]>()
|
const map = new Map<string, Entity[]>()
|
||||||
if (!childToParent) return map
|
if (!childToParent) return map
|
||||||
@@ -104,27 +94,6 @@
|
|||||||
childToParent ? sortedEntities.filter((e) => !nestedSlugs.has(e.slug)) : sortedEntities
|
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 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']
|
const skeletonNameWidths = ['w-32', 'w-40', 'w-24', 'w-36', 'w-28', 'w-40', 'w-24', 'w-32']
|
||||||
</script>
|
</script>
|
||||||
@@ -160,22 +129,6 @@
|
|||||||
</Table.Root>
|
</Table.Root>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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>)}
|
{#snippet row(entity: Entity, level: number, ancestors: Set<string>)}
|
||||||
{@const ancestorsWithSelf = new Set(ancestors).add(entity.slug)}
|
{@const ancestorsWithSelf = new Set(ancestors).add(entity.slug)}
|
||||||
{@const children = (childrenByParent.get(entity.slug) ?? []).filter((c) => !ancestorsWithSelf.has(c.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><Badge variant="outline">{entity.type}</Badge></Table.Cell>
|
||||||
<Table.Cell>{entity.name}</Table.Cell>
|
<Table.Cell>{entity.name}</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{#if entity.state}
|
<StatusBadgeRenderer value={entity.state ?? ''} kind="state" />
|
||||||
<Badge variant={stateVariant(entity.state)}>{entity.state}</Badge>
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted-foreground">—</span>
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{#if entity.health}
|
<HealthDotRenderer row={entity} value={null} />
|
||||||
<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}
|
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{#if children.length > 0 && !collapsedNodes.has(entity.slug)}
|
{#if children.length > 0 && !collapsedNodes.has(entity.slug)}
|
||||||
@@ -242,22 +184,33 @@
|
|||||||
<Table.Root role={childToParent ? 'treegrid' : undefined}>
|
<Table.Root role={childToParent ? 'treegrid' : undefined}>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
{@render sortHead('slug', 'Slug')}
|
{@const ssSlug = getSortState('slug')}
|
||||||
{@render sortHead('type', 'Type')}
|
<Table.Head>
|
||||||
{@render sortHead('name', 'Name')}
|
<SortHeader label="Slug" sorted={ssSlug.sorted} direction={ssSlug.direction} onclick={() => sortBy('slug')} />
|
||||||
{@render sortHead('state', 'State')}
|
</Table.Head>
|
||||||
{@render sortHead('health', 'Health')}
|
{@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.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each topLevelEntities as entity (entity.id)}
|
{#each topLevelEntities as entity (entity.id)}
|
||||||
{@render row(entity, 1, new Set())}
|
{@render row(entity, 1, new Set())}
|
||||||
{:else}
|
{:else}
|
||||||
<Table.Row>
|
<EmptyState message="No entities in this layer match the filter." colspan={5} />
|
||||||
<Table.Cell colspan={5} class="text-center text-muted-foreground"
|
|
||||||
>No entities in this layer match the filter.</Table.Cell
|
|
||||||
>
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
{/each}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
|
|||||||
34
web/src/lib/components/FilterTabs.svelte
Normal file
34
web/src/lib/components/FilterTabs.svelte
Normal 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>
|
||||||
47
web/src/lib/components/StatusBadge.svelte
Normal file
47
web/src/lib/components/StatusBadge.svelte
Normal 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>
|
||||||
218
web/src/lib/components/data-table/DataTable.svelte
Normal file
218
web/src/lib/components/data-table/DataTable.svelte
Normal 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>
|
||||||
36
web/src/lib/components/data-table/DataTable.svelte.ts
Normal file
36
web/src/lib/components/data-table/DataTable.svelte.ts
Normal 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
|
||||||
|
}
|
||||||
9
web/src/lib/components/data-table/columns.ts
Normal file
9
web/src/lib/components/data-table/columns.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
55
web/src/lib/components/data-table/search-input.svelte
Normal file
55
web/src/lib/components/data-table/search-input.svelte
Normal 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>
|
||||||
30
web/src/lib/components/data-table/sort-header.svelte
Normal file
30
web/src/lib/components/data-table/sort-header.svelte
Normal 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>
|
||||||
33
web/src/lib/components/data-table/toolbar.svelte
Normal file
33
web/src/lib/components/data-table/toolbar.svelte
Normal 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}
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||||
import { openEntityWindow } from '$lib/stores/windows'
|
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 SearchIcon from '@lucide/svelte/icons/search'
|
||||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||||
import BotIcon from '@lucide/svelte/icons/bot'
|
import BotIcon from '@lucide/svelte/icons/bot'
|
||||||
@@ -39,23 +41,6 @@
|
|||||||
loading = false
|
loading = false
|
||||||
searched = true
|
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`
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
||||||
@@ -113,7 +98,7 @@
|
|||||||
<Card.Header>
|
<Card.Header>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Card.Title class="text-sm">{hit.title}</Card.Title>
|
<Card.Title class="text-sm">{hit.title}</Card.Title>
|
||||||
<Badge variant={typeVariant(hit.type)}>{hit.type}</Badge>
|
<StatusBadge kind="type" value={hit.type} />
|
||||||
</div>
|
</div>
|
||||||
{#if hit.snippet}
|
{#if hit.snippet}
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized below, ts_headline only ever emits <b> -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized below, ts_headline only ever emits <b> -->
|
||||||
@@ -153,7 +138,7 @@
|
|||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="text-sm font-medium">{it.title}</span>
|
<span class="text-sm font-medium">{it.title}</span>
|
||||||
<Badge variant={typeVariant(it.kind)} class="text-[10px]">{it.kind}</Badge>
|
<StatusBadge kind="type" value={it.kind} class="text-[10px]" />
|
||||||
{#if it.agent_authored}<Badge variant="outline" class="border-primary/40 text-[10px] text-primary">learned by Nomos</Badge>{/if}
|
{#if it.agent_authored}<Badge variant="outline" class="border-primary/40 text-[10px] text-primary">learned by Nomos</Badge>{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if it.tags.length}
|
{#if it.tags.length}
|
||||||
@@ -162,7 +147,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<span class="shrink-0 text-xs text-muted-foreground">{relTime(it.updated_at)}</span>
|
<span class="shrink-0 text-xs text-muted-foreground">{relativeTime(it.updated_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if !loadingRecent}
|
{#if !loadingRecent}
|
||||||
|
|||||||
@@ -10,10 +10,14 @@
|
|||||||
} from '$lib/api'
|
} from '$lib/api'
|
||||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||||
import * as Tabs from '$lib/components/ui/tabs'
|
import * as Tabs from '$lib/components/ui/tabs'
|
||||||
import * as Table from '$lib/components/ui/table'
|
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
import { Button } from '$lib/components/ui/button'
|
|
||||||
import { toast } from 'svelte-sonner'
|
import { toast } from 'svelte-sonner'
|
||||||
|
import DataTable from '$lib/components/data-table/DataTable.svelte'
|
||||||
|
import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte.ts'
|
||||||
|
import ApprovalActions from '$lib/components/data-table/renderers/ApprovalActions.svelte'
|
||||||
|
import ActivityCancel from '$lib/components/data-table/renderers/ActivityCancel.svelte'
|
||||||
|
import ActivityAction from '$lib/components/data-table/renderers/ActivityAction.svelte'
|
||||||
|
import DurationRenderer from '$lib/components/data-table/renderers/DurationRenderer.svelte'
|
||||||
|
|
||||||
let approvals = $state<Approval[]>([])
|
let approvals = $state<Approval[]>([])
|
||||||
let activity = $state<ActivityItem[]>([])
|
let activity = $state<ActivityItem[]>([])
|
||||||
@@ -30,9 +34,6 @@
|
|||||||
loadApprovals()
|
loadApprovals()
|
||||||
loadActivity()
|
loadActivity()
|
||||||
const unsubscribe = subscribeEvents()
|
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)
|
const interval = setInterval(loadActivity, 5000)
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
@@ -47,24 +48,6 @@
|
|||||||
if (ev.type.startsWith('execution.')) loadActivity()
|
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') {
|
async function decide(id: string, decision: 'approve' | 'deny') {
|
||||||
deciding = id
|
deciding = id
|
||||||
const result = await decideApproval(id, decision)
|
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 pendingApprovals = $derived(approvals.filter((a) => a.status === 'pending'))
|
||||||
const decidedApprovals = $derived(approvals.filter((a) => a.status !== 'pending'))
|
const decidedApprovals = $derived(approvals.filter((a) => a.status !== 'pending'))
|
||||||
|
|
||||||
|
const pendingColumns = $derived.by(() => [
|
||||||
|
{ key: 'subject', header: 'Subject', class: 'font-mono text-xs', width: '180px', accessor: (a: Approval) => a.subject ?? '—', truncate: true },
|
||||||
|
{ key: 'action', header: 'Action', truncate: true },
|
||||||
|
{ key: 'risk_class', header: 'Risk', render: 'status-badge', renderProps: { kind: 'risk' }, width: '120px' },
|
||||||
|
{ key: 'status', header: 'Status', render: 'status-badge', renderProps: { kind: 'execution' }, width: '100px' },
|
||||||
|
{ key: 'expires_at', header: 'Expires', render: 'date', width: '170px' },
|
||||||
|
{ key: '_actions', header: '', render: ApprovalActions,
|
||||||
|
renderProps: { deciding, onApprove: (id: string) => decide(id, 'approve'), onDeny: (id: string) => decide(id, 'deny') },
|
||||||
|
align: 'right', headerClass: 'text-right', width: '220px' },
|
||||||
|
] as DataTableColumn<Approval>[])
|
||||||
|
|
||||||
|
const decidedColumns: DataTableColumn<Approval>[] = [
|
||||||
|
{ key: 'subject', header: 'Subject', class: 'font-mono text-xs', width: '180px', accessor: (a) => a.subject ?? '—', truncate: true },
|
||||||
|
{ key: 'action', header: 'Action', truncate: true },
|
||||||
|
{ key: 'status', header: 'Status', render: 'status-badge', renderProps: { kind: 'execution' }, width: '100px' },
|
||||||
|
{ key: 'decided_at', header: 'Decided', render: 'date', width: '170px', accessor: (a) => a.decided_at ?? '—' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const activityColumns: DataTableColumn<ActivityItem>[] = [
|
||||||
|
{ key: 'target', header: 'Target', class: 'font-mono text-xs', width: '180px', accessor: (a) => a.target ?? '—', truncate: true },
|
||||||
|
{ key: '_action', header: 'Action', render: ActivityAction, truncate: true },
|
||||||
|
{ key: 'risk_class', header: 'Risk', render: 'status-badge', renderProps: { kind: 'risk' }, width: '120px' },
|
||||||
|
{ key: 'status', header: 'Status', render: 'status-badge', renderProps: { kind: 'execution' }, width: '110px' },
|
||||||
|
{ key: 'duration_ms', header: 'Duration', render: DurationRenderer, width: '90px', align: 'right' },
|
||||||
|
{ key: 'created_at', header: 'When', render: 'relative-time', width: '100px' },
|
||||||
|
{ key: '_cancel', header: '', render: ActivityCancel, renderProps: { onCancel: cancel }, align: 'right', headerClass: 'text-right', width: '100px' },
|
||||||
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
||||||
@@ -120,121 +114,18 @@
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Content value="approvals" class="flex-1 overflow-auto">
|
<Tabs.Content value="approvals" class="flex-1 overflow-auto">
|
||||||
<div class="rounded-md border">
|
<DataTable columns={pendingColumns} data={pendingApprovals} emptyMessage="No pending approvals." />
|
||||||
<Table.Root>
|
|
||||||
<Table.Header>
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Head>Subject</Table.Head>
|
|
||||||
<Table.Head>Action</Table.Head>
|
|
||||||
<Table.Head>Risk</Table.Head>
|
|
||||||
<Table.Head>Status</Table.Head>
|
|
||||||
<Table.Head>Expires</Table.Head>
|
|
||||||
<Table.Head class="text-right">Decision</Table.Head>
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#each pendingApprovals as approval (approval.id)}
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell class="font-mono text-xs">{approval.subject ?? '—'}</Table.Cell>
|
|
||||||
<Table.Cell>{approval.action}</Table.Cell>
|
|
||||||
<Table.Cell><Badge variant={riskVariant(approval.risk_class)}>{approval.risk_class}</Badge></Table.Cell>
|
|
||||||
<Table.Cell><Badge variant="outline">{approval.status}</Badge></Table.Cell>
|
|
||||||
<Table.Cell class="text-xs text-muted-foreground"
|
|
||||||
>{new Date(approval.expires_at).toLocaleString()}</Table.Cell
|
|
||||||
>
|
|
||||||
<Table.Cell class="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
disabled={deciding === approval.id}
|
|
||||||
onclick={() => decide(approval.id, 'approve')}
|
|
||||||
>
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
disabled={deciding === approval.id}
|
|
||||||
onclick={() => decide(approval.id, 'deny')}
|
|
||||||
>
|
|
||||||
Deny
|
|
||||||
</Button>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{:else}
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell colspan={6} class="text-center text-muted-foreground">No pending approvals.</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if decidedApprovals.length}
|
{#if decidedApprovals.length}
|
||||||
<p class="mt-4 text-xs text-muted-foreground">Recently decided</p>
|
<p class="mt-4 text-xs text-muted-foreground">Recently decided</p>
|
||||||
<div class="mt-1 rounded-md border">
|
<div class="mt-1">
|
||||||
<Table.Root>
|
<DataTable columns={decidedColumns} data={decidedApprovals.slice(0, 20)} />
|
||||||
<Table.Body>
|
|
||||||
{#each decidedApprovals.slice(0, 20) as approval (approval.id)}
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell class="font-mono text-xs">{approval.subject ?? '—'}</Table.Cell>
|
|
||||||
<Table.Cell>{approval.action}</Table.Cell>
|
|
||||||
<Table.Cell><Badge variant="outline">{approval.status}</Badge></Table.Cell>
|
|
||||||
<Table.Cell class="text-xs text-muted-foreground"
|
|
||||||
>{approval.decided_at ? new Date(approval.decided_at).toLocaleString() : '—'}</Table.Cell
|
|
||||||
>
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
<Tabs.Content value="executions" class="flex-1 overflow-auto">
|
<Tabs.Content value="executions" class="flex-1 overflow-auto">
|
||||||
<div class="rounded-md border">
|
<DataTable columns={activityColumns} data={activity} emptyMessage="No activity yet." />
|
||||||
<Table.Root>
|
|
||||||
<Table.Header>
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Head>Target</Table.Head>
|
|
||||||
<Table.Head>Action</Table.Head>
|
|
||||||
<Table.Head>Risk</Table.Head>
|
|
||||||
<Table.Head>Status</Table.Head>
|
|
||||||
<Table.Head>Duration</Table.Head>
|
|
||||||
<Table.Head>When</Table.Head>
|
|
||||||
<Table.Head class="text-right">Actions</Table.Head>
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#each activity as item (item.id)}
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell class="font-mono text-xs">{item.target ?? '—'}</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<div>{item.verb}</div>
|
|
||||||
{#if item.summary}
|
|
||||||
<div class="text-xs text-muted-foreground">{item.summary}</div>
|
|
||||||
{/if}
|
|
||||||
{#if item.error}
|
|
||||||
<div class="text-xs text-destructive">{item.error}</div>
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell><Badge variant={riskVariant(item.risk_class)}>{item.risk_class}</Badge></Table.Cell>
|
|
||||||
<Table.Cell><Badge variant={execStatusVariant(item.status)}>{item.status}</Badge></Table.Cell>
|
|
||||||
<Table.Cell class="text-xs text-muted-foreground">{fmtDuration(item.duration_ms)}</Table.Cell>
|
|
||||||
<Table.Cell class="text-xs text-muted-foreground">{fmtWhen(item.created_at)}</Table.Cell>
|
|
||||||
<Table.Cell class="text-right">
|
|
||||||
{#if ['pending_approval', 'approved', 'running'].includes(item.status)}
|
|
||||||
<Button size="sm" variant="outline" onclick={() => cancel(item.id)}>Cancel</Button>
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{:else}
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell colspan={7} class="text-center text-muted-foreground">No activity yet.</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
</div>
|
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Tabs.Root>
|
</Tabs.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import { sessions, loadSessions } from '$lib/stores/chat'
|
import { sessions, loadSessions } from '$lib/stores/chat'
|
||||||
import { openTaskWindow, openNewTaskWindow } from '$lib/stores/windows'
|
import { openTaskWindow, openNewTaskWindow } from '$lib/stores/windows'
|
||||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||||
import { bucket, statusStyle, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
|
import { bucket, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
|
||||||
import { relativeTime } from '$lib/utils'
|
|
||||||
import { Button } from '$lib/components/ui/button'
|
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 PlusIcon from '@lucide/svelte/icons/plus'
|
||||||
import type { Session } from '$lib/api'
|
import type { Session } from '$lib/api'
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@
|
|||||||
for (const s of $sessions) c[bucket(s)]++
|
for (const s of $sessions) c[bucket(s)]++
|
||||||
return c
|
return c
|
||||||
})
|
})
|
||||||
|
|
||||||
const visible = $derived(
|
const visible = $derived(
|
||||||
filter === 'all' ? $sessions : $sessions.filter((s) => bucket(s) === filter)
|
filter === 'all' ? $sessions : $sessions.filter((s) => bucket(s) === filter)
|
||||||
)
|
)
|
||||||
@@ -29,9 +32,6 @@
|
|||||||
loadSessions()
|
loadSessions()
|
||||||
const unsubStream = subscribeEvents()
|
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 lastSeenId = 0
|
||||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const unsub = liveEvents.subscribe((evs) => {
|
const unsub = liveEvents.subscribe((evs) => {
|
||||||
@@ -51,6 +51,19 @@
|
|||||||
unsubStream()
|
unsubStream()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const columns: DataTableColumn<Session>[] = [
|
||||||
|
{ 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.`
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative flex h-full flex-col gap-3 overflow-hidden p-4">
|
<div class="relative flex h-full flex-col gap-3 overflow-hidden p-4">
|
||||||
@@ -75,51 +88,13 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative z-10 min-h-0 flex-1 overflow-auto rounded-xl border bg-card/70 backdrop-blur">
|
<div class="relative z-10 min-h-0 flex-1 overflow-hidden rounded-xl border bg-card/70 backdrop-blur">
|
||||||
{#if visible.length === 0}
|
<DataTable
|
||||||
<div class="flex flex-col items-center gap-2 px-4 py-16 text-center">
|
{columns}
|
||||||
<p class="max-w-sm text-sm text-muted-foreground">
|
data={visible}
|
||||||
{filter === 'all'
|
{emptyMessage}
|
||||||
? 'No tasks yet. Start one and Nomos will plan it, execute it, and report the outcome.'
|
bordered={false}
|
||||||
: `No ${FILTERS.find((f) => f.id === filter)?.label.toLowerCase()} tasks.`}
|
onRowClick={openTask}
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b text-left text-xs text-muted-foreground [&>th]:sticky [&>th]:top-0 [&>th]:z-10 [&>th]:bg-card/95 [&>th]:backdrop-blur">
|
|
||||||
<th class="w-36 px-4 py-2 font-medium">Status</th>
|
|
||||||
<th class="px-4 py-2 font-medium">Task</th>
|
|
||||||
<th class="hidden px-4 py-2 font-medium md:table-cell">Summary</th>
|
|
||||||
<th class="w-28 px-4 py-2 text-right font-medium">Last active</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each visible as s (s.id)}
|
|
||||||
{@const st = statusStyle(s)}
|
|
||||||
<tr
|
|
||||||
class="cursor-pointer border-b last:border-0 transition-colors hover:bg-muted/40"
|
|
||||||
onclick={() => openTask(s)}
|
|
||||||
>
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
<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>
|
|
||||||
</td>
|
|
||||||
<td class="max-w-0 px-4 py-2.5">
|
|
||||||
<span class="line-clamp-1 font-medium">{heading(s)}</span>
|
|
||||||
</td>
|
|
||||||
<td class="hidden max-w-0 px-4 py-2.5 md:table-cell">
|
|
||||||
<span class="line-clamp-1 text-xs text-muted-foreground">{s.summary || '—'}</span>
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-4 py-2.5 text-right text-[11px] text-muted-foreground">
|
|
||||||
{relativeTime(s.last_active_at)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
import { fetchSignals, ackSignal, resolveSignal, muteSignal, type Signal } from '$lib/api'
|
import { fetchSignals, ackSignal, resolveSignal, muteSignal, type Signal } from '$lib/api'
|
||||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||||
import * as Tabs from '$lib/components/ui/tabs'
|
import * as Tabs from '$lib/components/ui/tabs'
|
||||||
import * as Table from '$lib/components/ui/table'
|
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
import { Button } from '$lib/components/ui/button'
|
|
||||||
import * as Select from '$lib/components/ui/select'
|
import * as Select from '$lib/components/ui/select'
|
||||||
import { toast } from 'svelte-sonner'
|
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<Signal[]>([])
|
let signals = $state<Signal[]>([])
|
||||||
let severityFilter = $state('all')
|
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[]) {
|
function bySeverity(list: Signal[]) {
|
||||||
return severityFilter === 'all' ? list : list.filter((s) => s.severity === severityFilter)
|
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 open = $derived(bySeverity(signals.filter((s) => ['raised', 'acknowledged', 'acting'].includes(s.state))))
|
||||||
const muted = $derived(bySeverity(signals.filter((s) => s.state === 'muted')))
|
const muted = $derived(bySeverity(signals.filter((s) => s.state === 'muted')))
|
||||||
const resolved = $derived(bySeverity(signals.filter((s) => ['resolved', 'failed'].includes(s.state))))
|
const resolved = $derived(bySeverity(signals.filter((s) => ['resolved', 'failed'].includes(s.state))))
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet signalTable(list: Signal[], showActions: boolean)}
|
function makeColumns(showActions: boolean, actingVal: string | null): DataTableColumn<Signal>[] {
|
||||||
<div class="rounded-md border">
|
const base: DataTableColumn<Signal>[] = [
|
||||||
<Table.Root>
|
{ key: 'target', header: 'Target', class: 'font-mono text-xs', width: '180px', accessor: (s) => s.target ?? '—', truncate: true },
|
||||||
<Table.Header>
|
{ key: 'kind', header: 'Kind', width: '120px' },
|
||||||
<Table.Row>
|
{ key: 'severity', header: 'Severity', render: 'status-badge', renderProps: { kind: 'severity' }, width: '100px' },
|
||||||
<Table.Head>Target</Table.Head>
|
{ key: 'state', header: 'State', render: 'status-badge', renderProps: { kind: 'state' }, width: '110px' },
|
||||||
<Table.Head>Kind</Table.Head>
|
{ key: 'occurrence_count', header: 'Occurrences', width: '100px', align: 'right' },
|
||||||
<Table.Head>Severity</Table.Head>
|
{ key: 'last_seen_at', header: 'Last seen', render: 'date', width: '170px' },
|
||||||
<Table.Head>State</Table.Head>
|
]
|
||||||
<Table.Head>Occurrences</Table.Head>
|
if (showActions) {
|
||||||
<Table.Head>Last seen</Table.Head>
|
base.push({
|
||||||
{#if showActions}
|
key: '_actions', header: '', render: SignalActions,
|
||||||
<Table.Head class="text-right">Actions</Table.Head>
|
renderProps: { acting: actingVal, onAck: ack, onMute: mute, onResolve: resolve },
|
||||||
{/if}
|
align: 'right', headerClass: 'text-right', width: '220px'
|
||||||
</Table.Row>
|
})
|
||||||
</Table.Header>
|
}
|
||||||
<Table.Body>
|
return base
|
||||||
{#each list as signal (signal.id)}
|
}
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell class="font-mono text-xs">{signal.target ?? '—'}</Table.Cell>
|
const columnsWithActions = $derived(makeColumns(true, acting))
|
||||||
<Table.Cell>{signal.kind}</Table.Cell>
|
const columnsWithoutActions = makeColumns(false, null)
|
||||||
<Table.Cell><Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge></Table.Cell>
|
</script>
|
||||||
<Table.Cell><Badge variant="outline">{signal.state}</Badge></Table.Cell>
|
|
||||||
<Table.Cell>{signal.occurrence_count}</Table.Cell>
|
|
||||||
<Table.Cell class="text-xs text-muted-foreground"
|
|
||||||
>{new Date(signal.last_seen_at).toLocaleString()}</Table.Cell
|
|
||||||
>
|
|
||||||
{#if showActions}
|
|
||||||
<Table.Cell class="flex justify-end gap-2">
|
|
||||||
{#if signal.state === 'raised'}
|
|
||||||
<Button size="sm" variant="outline" disabled={acting === signal.id} onclick={() => ack(signal.id)}
|
|
||||||
>Ack</Button
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
<Button size="sm" variant="outline" disabled={acting === signal.id} onclick={() => mute(signal.id)}
|
|
||||||
>Mute 1h</Button
|
|
||||||
>
|
|
||||||
<Button size="sm" disabled={acting === signal.id} onclick={() => resolve(signal.id)}>Resolve</Button>
|
|
||||||
</Table.Cell>
|
|
||||||
{/if}
|
|
||||||
</Table.Row>
|
|
||||||
{:else}
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell colspan={showActions ? 7 : 6} class="text-center text-muted-foreground"
|
|
||||||
>No signals.</Table.Cell
|
|
||||||
>
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -159,13 +123,13 @@
|
|||||||
<Tabs.Trigger value="resolved">Resolved</Tabs.Trigger>
|
<Tabs.Trigger value="resolved">Resolved</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
<Tabs.Content value="open" class="flex-1 overflow-auto">
|
<Tabs.Content value="open" class="flex-1 overflow-auto">
|
||||||
{@render signalTable(open, true)}
|
<DataTable columns={columnsWithActions} data={open} />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="muted" class="flex-1 overflow-auto">
|
<Tabs.Content value="muted" class="flex-1 overflow-auto">
|
||||||
{@render signalTable(muted, true)}
|
<DataTable columns={columnsWithActions} data={muted} />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="resolved" class="flex-1 overflow-auto">
|
<Tabs.Content value="resolved" class="flex-1 overflow-auto">
|
||||||
{@render signalTable(resolved, false)}
|
<DataTable columns={columnsWithoutActions} data={resolved} />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Tabs.Root>
|
</Tabs.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user