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.
- package/README.md +11 -0
- package/apps/local-dashboard/dist/assets/index-C75H1Q3n.css +1 -0
- package/apps/local-dashboard/dist/assets/index-axE4kz3Q.js +15 -0
- package/apps/local-dashboard/dist/assets/vendor-ui-r2k_Ku_V.js +346 -0
- package/apps/local-dashboard/dist/index.html +3 -3
- package/cli/selftune/analytics.ts +354 -0
- package/cli/selftune/badge/badge.ts +2 -2
- package/cli/selftune/dashboard-server.ts +3 -3
- package/cli/selftune/evolution/evolve-body.ts +1 -1
- package/cli/selftune/evolution/evolve.ts +1 -1
- package/cli/selftune/index.ts +15 -1
- package/cli/selftune/init.ts +5 -1
- package/cli/selftune/observability.ts +63 -2
- package/cli/selftune/orchestrate.ts +1 -1
- package/cli/selftune/quickstart.ts +1 -1
- package/cli/selftune/status.ts +2 -2
- package/cli/selftune/types.ts +1 -0
- package/cli/selftune/utils/llm-call.ts +2 -1
- package/package.json +6 -4
- package/packages/ui/README.md +113 -0
- package/packages/ui/index.ts +10 -0
- package/packages/ui/package.json +62 -0
- package/packages/ui/src/components/ActivityTimeline.tsx +171 -0
- package/packages/ui/src/components/EvidenceViewer.tsx +718 -0
- package/packages/ui/src/components/EvolutionTimeline.tsx +252 -0
- package/packages/ui/src/components/InfoTip.tsx +19 -0
- package/packages/ui/src/components/OrchestrateRunsPanel.tsx +164 -0
- package/packages/ui/src/components/index.ts +7 -0
- package/packages/ui/src/components/section-cards.tsx +155 -0
- package/packages/ui/src/components/skill-health-grid.tsx +686 -0
- package/packages/ui/src/lib/constants.tsx +43 -0
- package/packages/ui/src/lib/format.ts +37 -0
- package/packages/ui/src/lib/index.ts +3 -0
- package/packages/ui/src/lib/utils.ts +6 -0
- package/packages/ui/src/primitives/badge.tsx +52 -0
- package/packages/ui/src/primitives/button.tsx +58 -0
- package/packages/ui/src/primitives/card.tsx +103 -0
- package/packages/ui/src/primitives/checkbox.tsx +27 -0
- package/packages/ui/src/primitives/collapsible.tsx +7 -0
- package/packages/ui/src/primitives/dropdown-menu.tsx +266 -0
- package/packages/ui/src/primitives/index.ts +55 -0
- package/packages/ui/src/primitives/label.tsx +20 -0
- package/packages/ui/src/primitives/select.tsx +197 -0
- package/packages/ui/src/primitives/table.tsx +114 -0
- package/packages/ui/src/primitives/tabs.tsx +82 -0
- package/packages/ui/src/primitives/tooltip.tsx +64 -0
- package/packages/ui/src/types.ts +87 -0
- package/packages/ui/tsconfig.json +17 -0
- package/skill/SKILL.md +3 -0
- package/skill/Workflows/Telemetry.md +59 -0
- package/apps/local-dashboard/dist/assets/index-C4EOTFZ2.js +0 -15
- package/apps/local-dashboard/dist/assets/index-bl-Webyd.css +0 -1
- 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
|
+
}
|