selftune 0.2.2 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +11 -0
  2. package/apps/local-dashboard/dist/assets/index-C75H1Q3n.css +1 -0
  3. package/apps/local-dashboard/dist/assets/index-axE4kz3Q.js +15 -0
  4. package/apps/local-dashboard/dist/assets/vendor-ui-r2k_Ku_V.js +346 -0
  5. package/apps/local-dashboard/dist/index.html +3 -3
  6. package/cli/selftune/analytics.ts +354 -0
  7. package/cli/selftune/badge/badge.ts +2 -2
  8. package/cli/selftune/dashboard-server.ts +3 -3
  9. package/cli/selftune/evolution/evolve-body.ts +1 -1
  10. package/cli/selftune/evolution/evolve.ts +1 -1
  11. package/cli/selftune/index.ts +15 -1
  12. package/cli/selftune/init.ts +5 -1
  13. package/cli/selftune/observability.ts +63 -2
  14. package/cli/selftune/orchestrate.ts +1 -1
  15. package/cli/selftune/quickstart.ts +1 -1
  16. package/cli/selftune/status.ts +2 -2
  17. package/cli/selftune/types.ts +1 -0
  18. package/cli/selftune/utils/llm-call.ts +2 -1
  19. package/package.json +6 -4
  20. package/packages/ui/README.md +113 -0
  21. package/packages/ui/index.ts +10 -0
  22. package/packages/ui/package.json +62 -0
  23. package/packages/ui/src/components/ActivityTimeline.tsx +171 -0
  24. package/packages/ui/src/components/EvidenceViewer.tsx +718 -0
  25. package/packages/ui/src/components/EvolutionTimeline.tsx +252 -0
  26. package/packages/ui/src/components/InfoTip.tsx +19 -0
  27. package/packages/ui/src/components/OrchestrateRunsPanel.tsx +164 -0
  28. package/packages/ui/src/components/index.ts +7 -0
  29. package/packages/ui/src/components/section-cards.tsx +155 -0
  30. package/packages/ui/src/components/skill-health-grid.tsx +686 -0
  31. package/packages/ui/src/lib/constants.tsx +43 -0
  32. package/packages/ui/src/lib/format.ts +37 -0
  33. package/packages/ui/src/lib/index.ts +3 -0
  34. package/packages/ui/src/lib/utils.ts +6 -0
  35. package/packages/ui/src/primitives/badge.tsx +52 -0
  36. package/packages/ui/src/primitives/button.tsx +58 -0
  37. package/packages/ui/src/primitives/card.tsx +103 -0
  38. package/packages/ui/src/primitives/checkbox.tsx +27 -0
  39. package/packages/ui/src/primitives/collapsible.tsx +7 -0
  40. package/packages/ui/src/primitives/dropdown-menu.tsx +266 -0
  41. package/packages/ui/src/primitives/index.ts +55 -0
  42. package/packages/ui/src/primitives/label.tsx +20 -0
  43. package/packages/ui/src/primitives/select.tsx +197 -0
  44. package/packages/ui/src/primitives/table.tsx +114 -0
  45. package/packages/ui/src/primitives/tabs.tsx +82 -0
  46. package/packages/ui/src/primitives/tooltip.tsx +64 -0
  47. package/packages/ui/src/types.ts +87 -0
  48. package/packages/ui/tsconfig.json +17 -0
  49. package/skill/SKILL.md +3 -0
  50. package/skill/Workflows/Telemetry.md +59 -0
  51. package/apps/local-dashboard/dist/assets/index-C4EOTFZ2.js +0 -15
  52. package/apps/local-dashboard/dist/assets/index-bl-Webyd.css +0 -1
  53. package/apps/local-dashboard/dist/assets/vendor-ui-D7_zX_qy.js +0 -346
@@ -0,0 +1,686 @@
1
+ import * as React from "react"
2
+ import {
3
+ closestCenter,
4
+ DndContext,
5
+ KeyboardSensor,
6
+ MouseSensor,
7
+ TouchSensor,
8
+ useSensor,
9
+ useSensors,
10
+ type DragEndEvent,
11
+ type UniqueIdentifier,
12
+ } from "@dnd-kit/core"
13
+ import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
14
+ import {
15
+ arrayMove,
16
+ SortableContext,
17
+ sortableKeyboardCoordinates,
18
+ useSortable,
19
+ verticalListSortingStrategy,
20
+ } from "@dnd-kit/sortable"
21
+ import { CSS } from "@dnd-kit/utilities"
22
+ import {
23
+ flexRender,
24
+ getCoreRowModel,
25
+ getFacetedRowModel,
26
+ getFacetedUniqueValues,
27
+ getFilteredRowModel,
28
+ getPaginationRowModel,
29
+ getSortedRowModel,
30
+ useReactTable,
31
+ type ColumnDef,
32
+ type ColumnFiltersState,
33
+ type Row,
34
+ type SortingState,
35
+ type VisibilityState,
36
+ } from "@tanstack/react-table"
37
+
38
+ import { Badge } from "../primitives/badge"
39
+ import { Button } from "../primitives/button"
40
+ import { Checkbox } from "../primitives/checkbox"
41
+ import {
42
+ DropdownMenu,
43
+ DropdownMenuCheckboxItem,
44
+ DropdownMenuContent,
45
+ DropdownMenuRadioGroup,
46
+ DropdownMenuRadioItem,
47
+ DropdownMenuTrigger,
48
+ } from "../primitives/dropdown-menu"
49
+ import { Label } from "../primitives/label"
50
+ import {
51
+ Select,
52
+ SelectContent,
53
+ SelectGroup,
54
+ SelectItem,
55
+ SelectTrigger,
56
+ SelectValue,
57
+ } from "../primitives/select"
58
+ import {
59
+ Table,
60
+ TableBody,
61
+ TableCell,
62
+ TableHead,
63
+ TableHeader,
64
+ TableRow,
65
+ } from "../primitives/table"
66
+ import {
67
+ Tabs,
68
+ TabsContent,
69
+ TabsList,
70
+ TabsTrigger,
71
+ } from "../primitives/tabs"
72
+ import { STATUS_CONFIG } from "../lib/constants"
73
+ import type { SkillCard, SkillHealthStatus } from "../types"
74
+ import { formatRate, timeAgo } from "../lib/format"
75
+ import {
76
+ GripVerticalIcon,
77
+ Columns3Icon,
78
+ ChevronDownIcon,
79
+ ChevronsLeftIcon,
80
+ ChevronLeftIcon,
81
+ ChevronRightIcon,
82
+ ChevronsRightIcon,
83
+ ClockIcon,
84
+ LayersIcon,
85
+ FilterIcon,
86
+ CheckCircleIcon,
87
+ AlertTriangleIcon,
88
+ XCircleIcon,
89
+ CircleDotIcon,
90
+ HelpCircleIcon,
91
+ } from "lucide-react"
92
+
93
+ // ---------- Drag handle ----------
94
+
95
+ type SortableContextValue = Pick<ReturnType<typeof useSortable>, "attributes" | "listeners" | "setActivatorNodeRef">
96
+
97
+ const SortableRowContext = React.createContext<SortableContextValue | null>(null)
98
+
99
+ function DragHandle() {
100
+ const ctx = React.useContext(SortableRowContext)
101
+ if (!ctx) return null
102
+ return (
103
+ <Button
104
+ ref={ctx.setActivatorNodeRef}
105
+ {...ctx.attributes}
106
+ {...ctx.listeners}
107
+ variant="ghost"
108
+ size="icon"
109
+ className="size-7 text-muted-foreground hover:bg-transparent"
110
+ >
111
+ <GripVerticalIcon className="size-3 text-muted-foreground" />
112
+ <span className="sr-only">Drag to reorder</span>
113
+ </Button>
114
+ )
115
+ }
116
+
117
+ // ---------- Column definitions ----------
118
+
119
+ function createColumns(renderSkillName?: (skill: SkillCard) => React.ReactNode): ColumnDef<SkillCard>[] {
120
+ return [
121
+ {
122
+ id: "drag",
123
+ header: () => null,
124
+ cell: () => <DragHandle />,
125
+ },
126
+ {
127
+ id: "select",
128
+ header: ({ table }) => (
129
+ <div className="flex items-center justify-center">
130
+ <Checkbox
131
+ checked={table.getIsAllPageRowsSelected()}
132
+ indeterminate={
133
+ table.getIsSomePageRowsSelected() &&
134
+ !table.getIsAllPageRowsSelected()
135
+ }
136
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
137
+ aria-label="Select all"
138
+ />
139
+ </div>
140
+ ),
141
+ cell: ({ row }) => (
142
+ <div className="flex items-center justify-center">
143
+ <Checkbox
144
+ checked={row.getIsSelected()}
145
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
146
+ aria-label="Select row"
147
+ />
148
+ </div>
149
+ ),
150
+ enableSorting: false,
151
+ enableHiding: false,
152
+ },
153
+ {
154
+ accessorKey: "name",
155
+ header: "Skill",
156
+ cell: ({ row }) => renderSkillName
157
+ ? renderSkillName(row.original)
158
+ : <span className="text-sm font-medium">{row.original.name}</span>,
159
+ enableHiding: false,
160
+ },
161
+ {
162
+ accessorKey: "scope",
163
+ header: "Scope",
164
+ cell: ({ row }) => {
165
+ const scope = row.original.scope
166
+ if (!scope) return <span className="text-xs text-muted-foreground">--</span>
167
+ return (
168
+ <Badge variant="secondary" className="text-[10px]">
169
+ {scope}
170
+ </Badge>
171
+ )
172
+ },
173
+ },
174
+ {
175
+ accessorKey: "status",
176
+ header: "Status",
177
+ cell: ({ row }) => {
178
+ const config = STATUS_CONFIG[row.original.status]
179
+ return (
180
+ <Badge variant={config.variant} className="gap-1 px-1.5 text-muted-foreground">
181
+ {config.icon}
182
+ {config.label}
183
+ </Badge>
184
+ )
185
+ },
186
+ sortingFn: (rowA, rowB) => {
187
+ const order: Record<SkillHealthStatus, number> = {
188
+ CRITICAL: 0, WARNING: 1, UNGRADED: 2, UNKNOWN: 3, HEALTHY: 4,
189
+ }
190
+ return order[rowA.original.status] - order[rowB.original.status]
191
+ },
192
+ },
193
+ {
194
+ accessorKey: "passRate",
195
+ header: () => <div className="w-full text-right">Pass Rate</div>,
196
+ cell: ({ row }) => {
197
+ const rate = row.original.passRate
198
+ const isLow = rate !== null && rate < 0.5
199
+ return (
200
+ <div className={`text-right font-mono tabular-nums ${isLow ? "text-red-600 font-semibold" : ""}`}>
201
+ {formatRate(rate)}
202
+ </div>
203
+ )
204
+ },
205
+ sortingFn: (rowA, rowB) => {
206
+ const a = rowA.original.passRate ?? -1
207
+ const b = rowB.original.passRate ?? -1
208
+ return a - b
209
+ },
210
+ },
211
+ {
212
+ accessorKey: "checks",
213
+ header: () => <div className="w-full text-right">Checks</div>,
214
+ cell: ({ row }) => (
215
+ <div className="text-right font-mono tabular-nums">
216
+ {row.original.checks}
217
+ </div>
218
+ ),
219
+ },
220
+ {
221
+ accessorKey: "uniqueSessions",
222
+ header: () => <div className="w-full text-right">Sessions</div>,
223
+ cell: ({ row }) => (
224
+ <div className="text-right font-mono tabular-nums">
225
+ {row.original.uniqueSessions}
226
+ </div>
227
+ ),
228
+ },
229
+ {
230
+ accessorKey: "lastSeen",
231
+ header: "Last Seen",
232
+ cell: ({ row }) => (
233
+ <div className="flex items-center gap-1 text-muted-foreground">
234
+ {row.original.lastSeen ? (
235
+ <>
236
+ <ClockIcon className="size-3" />
237
+ <span className="font-mono text-xs">{timeAgo(row.original.lastSeen)}</span>
238
+ </>
239
+ ) : (
240
+ <span className="text-xs">--</span>
241
+ )}
242
+ </div>
243
+ ),
244
+ sortingFn: (rowA, rowB) => {
245
+ const toEpoch = (v: string | null) => {
246
+ if (!v) return 0
247
+ const t = new Date(v).getTime()
248
+ return Number.isNaN(t) ? 0 : t
249
+ }
250
+ const a = toEpoch(rowA.original.lastSeen)
251
+ const b = toEpoch(rowB.original.lastSeen)
252
+ return a - b
253
+ },
254
+ },
255
+ {
256
+ accessorKey: "hasEvidence",
257
+ header: "Evidence",
258
+ cell: ({ row }) => (
259
+ <Badge
260
+ variant={row.original.hasEvidence ? "outline" : "secondary"}
261
+ className="px-1.5 text-[10px] text-muted-foreground"
262
+ >
263
+ {row.original.hasEvidence ? "Yes" : "No"}
264
+ </Badge>
265
+ ),
266
+ },
267
+ ]
268
+ }
269
+
270
+ // ---------- Draggable row ----------
271
+
272
+ function DraggableRow({ row }: { row: Row<SkillCard> }) {
273
+ const { transform, transition, setNodeRef, setActivatorNodeRef, isDragging, attributes, listeners } = useSortable({
274
+ id: row.original.name,
275
+ })
276
+ const sortableCtx = React.useMemo(
277
+ () => ({ attributes, listeners, setActivatorNodeRef }),
278
+ [attributes, listeners, setActivatorNodeRef],
279
+ )
280
+ return (
281
+ <SortableRowContext.Provider value={sortableCtx}>
282
+ <TableRow
283
+ data-state={row.getIsSelected() && "selected"}
284
+ data-dragging={isDragging}
285
+ ref={setNodeRef}
286
+ className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
287
+ style={{
288
+ transform: CSS.Transform.toString(transform),
289
+ transition: transition,
290
+ }}
291
+ >
292
+ {row.getVisibleCells().map((cell) => (
293
+ <TableCell key={cell.id}>
294
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
295
+ </TableCell>
296
+ ))}
297
+ </TableRow>
298
+ </SortableRowContext.Provider>
299
+ )
300
+ }
301
+
302
+ // ---------- Main component ----------
303
+
304
+ export function SkillHealthGrid({
305
+ cards,
306
+ totalCount,
307
+ statusFilter,
308
+ onStatusFilterChange,
309
+ renderSkillName,
310
+ }: {
311
+ cards: SkillCard[]
312
+ totalCount: number
313
+ statusFilter?: SkillHealthStatus | "ALL"
314
+ onStatusFilterChange?: (v: SkillHealthStatus | "ALL") => void
315
+ renderSkillName?: (skill: SkillCard) => React.ReactNode
316
+ }) {
317
+ const [activeView, setActiveView] = React.useState("all")
318
+ const [data, setData] = React.useState<SkillCard[]>([])
319
+ const [rowSelection, setRowSelection] = React.useState({})
320
+ const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
321
+ const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
322
+ const [sorting, setSorting] = React.useState<SortingState>([])
323
+ const [pagination, setPagination] = React.useState({
324
+ pageIndex: 0,
325
+ pageSize: 20,
326
+ })
327
+
328
+ const columns = React.useMemo(() => createColumns(renderSkillName), [renderSkillName])
329
+
330
+ // View counts for tab badges
331
+ const viewCounts = React.useMemo(() => ({
332
+ all: cards.length,
333
+ attention: cards.filter((c) => c.status === "CRITICAL" || c.status === "WARNING").length,
334
+ recent: cards.filter((c) => c.lastSeen !== null).length,
335
+ ungraded: cards.filter((c) => c.status === "UNGRADED" || c.status === "UNKNOWN").length,
336
+ }), [cards])
337
+
338
+ // Filter cards based on active view tab, then sync into local state for DnD
339
+ React.useEffect(() => {
340
+ let filtered = cards
341
+ if (activeView === "attention") {
342
+ filtered = cards.filter((c) => c.status === "CRITICAL" || c.status === "WARNING")
343
+ } else if (activeView === "recent") {
344
+ filtered = [...cards.filter((c) => c.lastSeen !== null)].sort((a, b) => {
345
+ const aTime = a.lastSeen ? new Date(a.lastSeen).getTime() : 0
346
+ const bTime = b.lastSeen ? new Date(b.lastSeen).getTime() : 0
347
+ return bTime - aTime
348
+ })
349
+ } else if (activeView === "ungraded") {
350
+ filtered = cards.filter((c) => c.status === "UNGRADED" || c.status === "UNKNOWN")
351
+ }
352
+ setData(filtered)
353
+ setPagination((prev) => ({ ...prev, pageIndex: 0 }))
354
+ }, [cards, activeView])
355
+
356
+ const sortableId = React.useId()
357
+ const sensors = useSensors(
358
+ useSensor(MouseSensor, {}),
359
+ useSensor(TouchSensor, {}),
360
+ useSensor(KeyboardSensor, {
361
+ coordinateGetter: sortableKeyboardCoordinates,
362
+ })
363
+ )
364
+
365
+ const table = useReactTable({
366
+ data,
367
+ columns,
368
+ state: {
369
+ sorting,
370
+ columnVisibility,
371
+ rowSelection,
372
+ columnFilters,
373
+ pagination,
374
+ },
375
+ getRowId: (row) => row.name,
376
+ enableRowSelection: true,
377
+ onRowSelectionChange: setRowSelection,
378
+ onSortingChange: setSorting,
379
+ onColumnFiltersChange: setColumnFilters,
380
+ onColumnVisibilityChange: setColumnVisibility,
381
+ onPaginationChange: setPagination,
382
+ getCoreRowModel: getCoreRowModel(),
383
+ getFilteredRowModel: getFilteredRowModel(),
384
+ getPaginationRowModel: getPaginationRowModel(),
385
+ getSortedRowModel: getSortedRowModel(),
386
+ getFacetedRowModel: getFacetedRowModel(),
387
+ getFacetedUniqueValues: getFacetedUniqueValues(),
388
+ })
389
+
390
+ const dataIds = React.useMemo<UniqueIdentifier[]>(
391
+ () => table.getRowModel().rows.map((r) => r.id),
392
+ [table.getRowModel().rows]
393
+ )
394
+
395
+ const isSorted = sorting.length > 0
396
+
397
+ function handleDragEnd(event: DragEndEvent) {
398
+ if (isSorted) return
399
+ const { active, over } = event
400
+ if (active && over && active.id !== over.id) {
401
+ setData((prev) => {
402
+ const ids = prev.map((d) => d.name)
403
+ const oldIndex = ids.indexOf(active.id as string)
404
+ const newIndex = ids.indexOf(over.id as string)
405
+ if (oldIndex === -1 || newIndex === -1) return prev
406
+ return arrayMove(prev, oldIndex, newIndex)
407
+ })
408
+ }
409
+ }
410
+
411
+ return (
412
+ <Tabs
413
+ value={activeView}
414
+ onValueChange={setActiveView}
415
+ className="flex w-full flex-col justify-start gap-6"
416
+ >
417
+ <div className="flex items-center justify-between px-4 lg:px-6">
418
+ {/* Mobile: Select dropdown */}
419
+ <Label htmlFor="view-selector" className="sr-only">
420
+ View
421
+ </Label>
422
+ <Select
423
+ value={activeView}
424
+ onValueChange={(v) => v && setActiveView(v)}
425
+ >
426
+ <SelectTrigger
427
+ className="flex w-fit @4xl/main:hidden"
428
+ size="sm"
429
+ id="view-selector"
430
+ >
431
+ <SelectValue placeholder="Select a view" />
432
+ </SelectTrigger>
433
+ <SelectContent>
434
+ <SelectGroup>
435
+ <SelectItem value="all">All Skills</SelectItem>
436
+ <SelectItem value="attention">Needs Attention</SelectItem>
437
+ <SelectItem value="recent">Recently Active</SelectItem>
438
+ <SelectItem value="ungraded">Ungraded</SelectItem>
439
+ </SelectGroup>
440
+ </SelectContent>
441
+ </Select>
442
+
443
+ {/* Desktop: Tab bar */}
444
+ <TabsList className="hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:bg-muted-foreground/30 **:data-[slot=badge]:px-1 @4xl/main:flex">
445
+ <TabsTrigger value="all">
446
+ All Skills <Badge variant="secondary">{viewCounts.all}</Badge>
447
+ </TabsTrigger>
448
+ <TabsTrigger value="attention">
449
+ Needs Attention{" "}
450
+ {viewCounts.attention > 0 && (
451
+ <Badge variant="secondary">{viewCounts.attention}</Badge>
452
+ )}
453
+ </TabsTrigger>
454
+ <TabsTrigger value="recent">
455
+ Recently Active{" "}
456
+ {viewCounts.recent > 0 && (
457
+ <Badge variant="secondary">{viewCounts.recent}</Badge>
458
+ )}
459
+ </TabsTrigger>
460
+ <TabsTrigger value="ungraded">
461
+ Ungraded{" "}
462
+ {viewCounts.ungraded > 0 && (
463
+ <Badge variant="secondary">{viewCounts.ungraded}</Badge>
464
+ )}
465
+ </TabsTrigger>
466
+ </TabsList>
467
+
468
+ <div className="flex items-center gap-2">
469
+ {onStatusFilterChange && (
470
+ <DropdownMenu>
471
+ <DropdownMenuTrigger render={<Button variant="outline" size="sm" />}>
472
+ <FilterIcon data-icon="inline-start" className="size-3.5" />
473
+ {statusFilter && statusFilter !== "ALL" ? statusFilter.charAt(0) + statusFilter.slice(1).toLowerCase() : "Status"}
474
+ <ChevronDownIcon data-icon="inline-end" />
475
+ </DropdownMenuTrigger>
476
+ <DropdownMenuContent align="end" className="w-40">
477
+ <DropdownMenuRadioGroup
478
+ value={statusFilter ?? "ALL"}
479
+ onValueChange={(v) => onStatusFilterChange(v as SkillHealthStatus | "ALL")}
480
+ >
481
+ {([
482
+ { label: "All", value: "ALL" as const, icon: <LayersIcon className="size-3.5" /> },
483
+ { label: "Healthy", value: "HEALTHY" as const, icon: <CheckCircleIcon className="size-3.5 text-emerald-600" /> },
484
+ { label: "Warning", value: "WARNING" as const, icon: <AlertTriangleIcon className="size-3.5 text-amber-500" /> },
485
+ { label: "Critical", value: "CRITICAL" as const, icon: <XCircleIcon className="size-3.5 text-red-500" /> },
486
+ { label: "Ungraded", value: "UNGRADED" as const, icon: <CircleDotIcon className="size-3.5 text-muted-foreground" /> },
487
+ { label: "Unknown", value: "UNKNOWN" as const, icon: <HelpCircleIcon className="size-3.5 text-muted-foreground/60" /> },
488
+ ] as const).map((f) => (
489
+ <DropdownMenuRadioItem key={f.value} value={f.value}>
490
+ <span className="flex items-center gap-2">
491
+ {f.icon}
492
+ {f.label}
493
+ </span>
494
+ </DropdownMenuRadioItem>
495
+ ))}
496
+ </DropdownMenuRadioGroup>
497
+ </DropdownMenuContent>
498
+ </DropdownMenu>
499
+ )}
500
+ <DropdownMenu>
501
+ <DropdownMenuTrigger render={<Button variant="outline" size="sm" />}>
502
+ <Columns3Icon data-icon="inline-start" />
503
+ Columns
504
+ <ChevronDownIcon data-icon="inline-end" />
505
+ </DropdownMenuTrigger>
506
+ <DropdownMenuContent align="end" className="w-40">
507
+ {table
508
+ .getAllColumns()
509
+ .filter(
510
+ (column) =>
511
+ typeof column.accessorFn !== "undefined" &&
512
+ column.getCanHide()
513
+ )
514
+ .map((column) => (
515
+ <DropdownMenuCheckboxItem
516
+ key={column.id}
517
+ className="capitalize"
518
+ checked={column.getIsVisible()}
519
+ onCheckedChange={(value) =>
520
+ column.toggleVisibility(!!value)
521
+ }
522
+ >
523
+ {column.id === "scope" ? "Scope"
524
+ : column.id === "passRate" ? "Pass Rate"
525
+ : column.id === "uniqueSessions" ? "Sessions"
526
+ : column.id === "lastSeen" ? "Last Seen"
527
+ : column.id === "hasEvidence" ? "Evidence"
528
+ : column.id}
529
+ </DropdownMenuCheckboxItem>
530
+ ))}
531
+ </DropdownMenuContent>
532
+ </DropdownMenu>
533
+ </div>
534
+ </div>
535
+
536
+ <TabsContent value={activeView} className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
537
+ <div className="overflow-hidden rounded-lg border">
538
+ <DndContext
539
+ collisionDetection={closestCenter}
540
+ modifiers={[restrictToVerticalAxis]}
541
+ onDragEnd={handleDragEnd}
542
+ sensors={sensors}
543
+ id={sortableId}
544
+ >
545
+ <Table>
546
+ <TableHeader className="sticky top-0 z-10 bg-muted">
547
+ {table.getHeaderGroups().map((headerGroup) => (
548
+ <TableRow key={headerGroup.id}>
549
+ {headerGroup.headers.map((header) => (
550
+ <TableHead
551
+ key={header.id}
552
+ colSpan={header.colSpan}
553
+ className={header.column.getCanSort() ? "cursor-pointer select-none" : ""}
554
+ onClick={header.column.getToggleSortingHandler()}
555
+ >
556
+ <div className="flex items-center gap-1">
557
+ {header.isPlaceholder
558
+ ? null
559
+ : flexRender(
560
+ header.column.columnDef.header,
561
+ header.getContext()
562
+ )}
563
+ {header.column.getIsSorted() === "asc" ? " ↑"
564
+ : header.column.getIsSorted() === "desc" ? " ↓"
565
+ : null}
566
+ </div>
567
+ </TableHead>
568
+ ))}
569
+ </TableRow>
570
+ ))}
571
+ </TableHeader>
572
+ <TableBody className="**:data-[slot=table-cell]:first:w-8">
573
+ {table.getRowModel().rows?.length ? (
574
+ <SortableContext
575
+ items={dataIds}
576
+ strategy={verticalListSortingStrategy}
577
+ >
578
+ {table.getRowModel().rows.map((row) => (
579
+ <DraggableRow key={row.id} row={row} />
580
+ ))}
581
+ </SortableContext>
582
+ ) : (
583
+ <TableRow>
584
+ <TableCell
585
+ colSpan={columns.length}
586
+ className="h-24 text-center text-muted-foreground"
587
+ >
588
+ {totalCount === 0
589
+ ? "No skills detected yet. Trigger some skills to see data."
590
+ : "No skills match your filters."}
591
+ </TableCell>
592
+ </TableRow>
593
+ )}
594
+ </TableBody>
595
+ </Table>
596
+ </DndContext>
597
+ </div>
598
+
599
+ {/* Pagination */}
600
+ <div className="flex items-center justify-between px-4">
601
+ <div className="hidden flex-1 text-sm text-muted-foreground lg:flex">
602
+ {table.getFilteredSelectedRowModel().rows.length} of{" "}
603
+ {table.getFilteredRowModel().rows.length} skill(s) selected.
604
+ </div>
605
+ <div className="flex w-full items-center gap-8 lg:w-fit">
606
+ <div className="hidden items-center gap-2 lg:flex">
607
+ <Label htmlFor="rows-per-page" className="text-sm font-medium">
608
+ Rows per page
609
+ </Label>
610
+ <Select
611
+ value={`${table.getState().pagination.pageSize}`}
612
+ onValueChange={(value) => {
613
+ table.setPageSize(Number(value))
614
+ }}
615
+ items={[10, 20, 50, 100].map((s) => ({
616
+ label: `${s}`,
617
+ value: `${s}`,
618
+ }))}
619
+ >
620
+ <SelectTrigger size="sm" className="w-20" id="rows-per-page">
621
+ <SelectValue placeholder={table.getState().pagination.pageSize} />
622
+ </SelectTrigger>
623
+ <SelectContent side="top">
624
+ <SelectGroup>
625
+ {[10, 20, 50, 100].map((pageSize) => (
626
+ <SelectItem key={pageSize} value={`${pageSize}`}>
627
+ {pageSize}
628
+ </SelectItem>
629
+ ))}
630
+ </SelectGroup>
631
+ </SelectContent>
632
+ </Select>
633
+ </div>
634
+ {table.getRowModel().rows.length > 0 && (
635
+ <div className="flex w-fit items-center justify-center text-sm font-medium">
636
+ Page {table.getState().pagination.pageIndex + 1} of{" "}
637
+ {table.getPageCount()}
638
+ </div>
639
+ )}
640
+ <div className="ml-auto flex items-center gap-2 lg:ml-0">
641
+ <Button
642
+ variant="outline"
643
+ className="hidden h-8 w-8 p-0 lg:flex"
644
+ onClick={() => table.setPageIndex(0)}
645
+ disabled={!table.getCanPreviousPage()}
646
+ >
647
+ <span className="sr-only">Go to first page</span>
648
+ <ChevronsLeftIcon />
649
+ </Button>
650
+ <Button
651
+ variant="outline"
652
+ className="size-8"
653
+ size="icon"
654
+ onClick={() => table.previousPage()}
655
+ disabled={!table.getCanPreviousPage()}
656
+ >
657
+ <span className="sr-only">Go to previous page</span>
658
+ <ChevronLeftIcon />
659
+ </Button>
660
+ <Button
661
+ variant="outline"
662
+ className="size-8"
663
+ size="icon"
664
+ onClick={() => table.nextPage()}
665
+ disabled={!table.getCanNextPage()}
666
+ >
667
+ <span className="sr-only">Go to next page</span>
668
+ <ChevronRightIcon />
669
+ </Button>
670
+ <Button
671
+ variant="outline"
672
+ className="hidden size-8 lg:flex"
673
+ size="icon"
674
+ onClick={() => table.setPageIndex(table.getPageCount() - 1)}
675
+ disabled={!table.getCanNextPage()}
676
+ >
677
+ <span className="sr-only">Go to last page</span>
678
+ <ChevronsRightIcon />
679
+ </Button>
680
+ </div>
681
+ </div>
682
+ </div>
683
+ </TabsContent>
684
+ </Tabs>
685
+ )
686
+ }