datool 0.0.4 → 0.0.5

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.
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>datool</title>
7
- <script type="module" crossorigin src="/assets/index-B5MN-j1l.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-BkIiz0aS.css">
7
+ <script type="module" crossorigin src="/assets/index-OdNyDkx7.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-DUkIilaZ.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "datool",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "type": "module",
5
5
  "description": "Local-only config-driven log viewer with SSE streaming and a generic table UI.",
6
6
  "bin": {
@@ -41,6 +41,7 @@ import {
41
41
  } from "@/lib/data-table-search"
42
42
  import {
43
43
  readDatoolColumnVisibility,
44
+ readDatoolGrouping,
44
45
  readDatoolSearch,
45
46
  readSelectedStreamId,
46
47
  writeDatoolUrlState,
@@ -59,10 +60,89 @@ type ViewerExportColumn = {
59
60
  label: string
60
61
  }
61
62
 
63
+ const GROUPED_ROW_GAP = 32
64
+
62
65
  function toActionRows(rows: ViewerRow[]): Record<string, unknown>[] {
63
66
  return rows.map(({ __datoolRowId: _datoolRowId, ...row }) => row)
64
67
  }
65
68
 
69
+ function stringifyGroupingValue(value: unknown) {
70
+ if (value === undefined) {
71
+ return "undefined:"
72
+ }
73
+
74
+ if (value === null) {
75
+ return "null:"
76
+ }
77
+
78
+ if (value instanceof Date) {
79
+ return `date:${value.toISOString()}`
80
+ }
81
+
82
+ if (typeof value === "object") {
83
+ try {
84
+ return `object:${JSON.stringify(value)}`
85
+ } catch {
86
+ return `object:${String(value)}`
87
+ }
88
+ }
89
+
90
+ return `${typeof value}:${String(value)}`
91
+ }
92
+
93
+ function groupViewerRows(
94
+ rows: ViewerRow[],
95
+ columns: ViewerExportColumn[]
96
+ ): {
97
+ groupStartRowIds: Set<string>
98
+ rows: ViewerRow[]
99
+ } {
100
+ if (columns.length === 0 || rows.length === 0) {
101
+ return {
102
+ groupStartRowIds: new Set<string>(),
103
+ rows,
104
+ }
105
+ }
106
+
107
+ const groupOrder: string[] = []
108
+ const rowsByGroup = new Map<string, ViewerRow[]>()
109
+
110
+ for (const row of rows) {
111
+ const groupKey = columns
112
+ .map((column) =>
113
+ stringifyGroupingValue(getValueAtPath(row, column.accessorKey))
114
+ )
115
+ .join("\u001f")
116
+ const existingRows = rowsByGroup.get(groupKey)
117
+
118
+ if (existingRows) {
119
+ existingRows.push(row)
120
+ continue
121
+ }
122
+
123
+ groupOrder.push(groupKey)
124
+ rowsByGroup.set(groupKey, [row])
125
+ }
126
+
127
+ const groupedRows: ViewerRow[] = []
128
+ const groupStartRowIds = new Set<string>()
129
+
130
+ groupOrder.forEach((groupKey, index) => {
131
+ const groupRows = rowsByGroup.get(groupKey) ?? []
132
+
133
+ if (index > 0 && groupRows[0]) {
134
+ groupStartRowIds.add(groupRows[0].__datoolRowId)
135
+ }
136
+
137
+ groupedRows.push(...groupRows)
138
+ })
139
+
140
+ return {
141
+ groupStartRowIds,
142
+ rows: groupedRows,
143
+ }
144
+ }
145
+
66
146
  function applyActionRowChanges(
67
147
  currentRows: ViewerRow[],
68
148
  targetRowIds: string[],
@@ -347,7 +427,10 @@ function DatoolTable({
347
427
  isLoadingConfig,
348
428
  rows,
349
429
  settingsColumns,
430
+ groupedColumnIds,
431
+ groupedRowStartIds,
350
432
  selectedStreamId,
433
+ setGroupedColumnIds,
351
434
  setColumnVisibility,
352
435
  searchInputRef,
353
436
  handleExport,
@@ -369,7 +452,10 @@ function DatoolTable({
369
452
  label: string
370
453
  visible: boolean
371
454
  }>
455
+ groupedColumnIds: string[]
456
+ groupedRowStartIds: Set<string>
372
457
  selectedStreamId: string | null
458
+ setGroupedColumnIds: React.Dispatch<React.SetStateAction<string[]>>
373
459
  setColumnVisibility: React.Dispatch<React.SetStateAction<VisibilityState>>
374
460
  searchInputRef: React.RefObject<DataTableSearchInputHandle | null>
375
461
  handleExport: (format: "csv" | "md") => void
@@ -378,6 +464,19 @@ function DatoolTable({
378
464
  setShouldConnect: React.Dispatch<React.SetStateAction<boolean>>
379
465
  }) {
380
466
  const { search, setSearch } = useDataTableContext<ViewerRow>()
467
+ const resolveRowStyle = React.useCallback(
468
+ (row: ViewerRow) => {
469
+ if (!groupedRowStartIds.has(row.__datoolRowId)) {
470
+ return undefined
471
+ }
472
+
473
+ return {
474
+ borderTop: `${GROUPED_ROW_GAP}px solid var(--color-table-gap)`,
475
+ boxShadow: `inset 0 1px 0 0 var(--color-border)`,
476
+ } satisfies React.CSSProperties
477
+ },
478
+ [groupedRowStartIds]
479
+ )
381
480
  const rowActions = React.useMemo<DataTableRowAction<ViewerRow>[]>(
382
481
  () => {
383
482
  const configActions =
@@ -526,9 +625,24 @@ function DatoolTable({
526
625
  />
527
626
  <ViewerSettings
528
627
  columns={settingsColumns}
628
+ groupedColumnIds={groupedColumnIds}
529
629
  isDisabled={isLoadingConfig || !activeStream}
530
630
  onExportCsv={() => handleExport("csv")}
531
631
  onExportMarkdown={() => handleExport("md")}
632
+ onClearGrouping={() => setGroupedColumnIds([])}
633
+ onToggleGrouping={(columnId, grouped) =>
634
+ setGroupedColumnIds((current) => {
635
+ if (grouped) {
636
+ return current.includes(columnId)
637
+ ? current
638
+ : [...current, columnId]
639
+ }
640
+
641
+ return current.filter(
642
+ (currentColumnId) => currentColumnId !== columnId
643
+ )
644
+ })
645
+ }
532
646
  onToggleColumn={(columnId, visible) =>
533
647
  setColumnVisibility((current) => ({
534
648
  ...current,
@@ -545,7 +659,7 @@ function DatoolTable({
545
659
 
546
660
  <div className="min-h-0 flex-1">
547
661
  {activeStream ? (
548
- <DataTable rowActions={rowActions} />
662
+ <DataTable rowActions={rowActions} rowStyle={resolveRowStyle} />
549
663
  ) : (
550
664
  <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
551
665
  No stream selected.
@@ -582,6 +696,7 @@ export default function App() {
582
696
  const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(
583
697
  {}
584
698
  )
699
+ const [groupedColumnIds, setGroupedColumnIds] = React.useState<string[]>([])
585
700
  const eventSourceRef = React.useRef<EventSource | null>(null)
586
701
  const hasInitializedStreamRef = React.useRef(false)
587
702
  const [hydratedTableId, setHydratedTableId] = React.useState<string | null>(
@@ -677,6 +792,23 @@ export default function App() {
677
792
  exportColumns.filter((column) => columnVisibility[column.id] !== false),
678
793
  [columnVisibility, exportColumns]
679
794
  )
795
+ const exportColumnsById = React.useMemo(
796
+ () => new Map(exportColumns.map((column) => [column.id, column])),
797
+ [exportColumns]
798
+ )
799
+ const groupedExportColumns = React.useMemo(
800
+ () =>
801
+ groupedColumnIds.flatMap((columnId) => {
802
+ const column = exportColumnsById.get(columnId)
803
+
804
+ return column ? [column] : []
805
+ }),
806
+ [exportColumnsById, groupedColumnIds]
807
+ )
808
+ const groupedRowsState = React.useMemo(
809
+ () => groupViewerRows(rows, groupedExportColumns),
810
+ [groupedExportColumns, rows]
811
+ )
680
812
  const isConnecting = Boolean(selectedStreamId) && shouldConnect && !isConnected
681
813
  const columnIds = React.useMemo(
682
814
  () => exportColumns.map((column) => column.id),
@@ -694,12 +826,14 @@ export default function App() {
694
826
 
695
827
  React.useEffect(() => {
696
828
  if (!activeStream) {
829
+ setGroupedColumnIds([])
697
830
  setHydratedTableId(null)
698
831
  return
699
832
  }
700
833
 
701
834
  setSearch(readDatoolSearch(tableId))
702
835
  setColumnVisibility(readDatoolColumnVisibility(tableId, columnIds))
836
+ setGroupedColumnIds(readDatoolGrouping(tableId, columnIds))
703
837
  setHydratedTableId(tableId)
704
838
  }, [activeStream, columnIds, tableId])
705
839
 
@@ -712,6 +846,7 @@ export default function App() {
712
846
  writeDatoolUrlState({
713
847
  columnIds,
714
848
  columnVisibility,
849
+ groupBy: groupedColumnIds,
715
850
  search,
716
851
  selectedStreamId,
717
852
  tableId,
@@ -723,6 +858,7 @@ export default function App() {
723
858
  activeStream,
724
859
  columnIds,
725
860
  columnVisibility,
861
+ groupedColumnIds,
726
862
  hydratedTableId,
727
863
  search,
728
864
  selectedStreamId,
@@ -740,6 +876,7 @@ export default function App() {
740
876
  setColumnVisibility(
741
877
  readDatoolColumnVisibility(nextTableId, columnIds)
742
878
  )
879
+ setGroupedColumnIds(readDatoolGrouping(nextTableId, columnIds))
743
880
  setHydratedTableId(nextTableId)
744
881
  }
745
882
 
@@ -853,8 +990,8 @@ export default function App() {
853
990
  const fileBaseName = `${sanitizeFilePart(activeStream.label)}-${timeStamp}`
854
991
  const content =
855
992
  format === "csv"
856
- ? buildCsvContent(rows, visibleExportColumns)
857
- : buildMarkdownContent(rows, visibleExportColumns)
993
+ ? buildCsvContent(groupedRowsState.rows, visibleExportColumns)
994
+ : buildMarkdownContent(groupedRowsState.rows, visibleExportColumns)
858
995
 
859
996
  downloadTextFile(
860
997
  content,
@@ -862,15 +999,15 @@ export default function App() {
862
999
  format === "csv" ? "text/csv" : "text/markdown"
863
1000
  )
864
1001
  },
865
- [activeStream, rows, visibleExportColumns]
1002
+ [activeStream, groupedRowsState.rows, visibleExportColumns]
866
1003
  )
867
1004
 
868
1005
  return (
869
1006
  <DataTableProvider
870
- autoScrollToBottom
1007
+ autoScrollToBottom={groupedColumnIds.length === 0}
871
1008
  columnVisibility={columnVisibility}
872
1009
  columns={columns}
873
- data={rows}
1010
+ data={groupedRowsState.rows}
874
1011
  getRowId={(row) => row.__datoolRowId}
875
1012
  height="100%"
876
1013
  id={tableId}
@@ -889,8 +1026,11 @@ export default function App() {
889
1026
  isConnected={isConnected}
890
1027
  isConnecting={isConnecting}
891
1028
  isLoadingConfig={isLoadingConfig}
1029
+ groupedColumnIds={groupedColumnIds}
1030
+ groupedRowStartIds={groupedRowsState.groupStartRowIds}
892
1031
  rows={rows}
893
1032
  settingsColumns={settingsColumns}
1033
+ setGroupedColumnIds={setGroupedColumnIds}
894
1034
  setColumnVisibility={setColumnVisibility}
895
1035
  searchInputRef={searchInputRef}
896
1036
  selectedStreamId={selectedStreamId}
@@ -184,6 +184,7 @@ export type DataTableProps<TData extends DataTableRow> = {
184
184
  resolveColumnHighlightTerms?: (columnId: string, query: string) => string[]
185
185
  rowActions?: DataTableRowAction<TData>[]
186
186
  rowClassName?: (row: TData) => string | undefined
187
+ rowStyle?: (row: TData) => React.CSSProperties | undefined
187
188
  rowHeight?: number
188
189
  statePersistence?: "localStorage" | "none" | "url"
189
190
  }
@@ -1139,6 +1140,7 @@ function DataTableView<TData extends DataTableRow>({
1139
1140
  resolveColumnHighlightTerms,
1140
1141
  rowActions,
1141
1142
  rowClassName,
1143
+ rowStyle,
1142
1144
  rowHeight = 48,
1143
1145
  statePersistence = "localStorage",
1144
1146
  }: DataTableProps<TData>) {
@@ -1923,6 +1925,7 @@ function DataTableView<TData extends DataTableRow>({
1923
1925
  }
1924
1926
  }}
1925
1927
  style={{
1928
+ ...rowStyle?.(row.original),
1926
1929
  minHeight: rowHeight,
1927
1930
  transform: `translateY(${virtualRow.start}px)`,
1928
1931
  width: table.getTotalSize(),
@@ -2,6 +2,7 @@ import * as React from "react"
2
2
  import {
3
3
  DownloadIcon,
4
4
  EllipsisIcon,
5
+ LayoutGridIcon,
5
6
  MoonIcon,
6
7
  Settings2Icon,
7
8
  SunIcon,
@@ -38,9 +39,12 @@ type ViewerSettingsColumn = {
38
39
 
39
40
  type ViewerSettingsProps = {
40
41
  columns: ViewerSettingsColumn[]
42
+ groupedColumnIds: string[]
41
43
  isDisabled?: boolean
42
44
  onExportCsv: () => void
43
45
  onExportMarkdown: () => void
46
+ onClearGrouping: () => void
47
+ onToggleGrouping: (columnId: string, grouped: boolean) => void
44
48
  onToggleColumn: (columnId: string, visible: boolean) => void
45
49
  className?: string
46
50
  }
@@ -69,14 +73,26 @@ const THEME_OPTIONS: Array<{
69
73
 
70
74
  export function ViewerSettings({
71
75
  columns,
76
+ groupedColumnIds,
72
77
  isDisabled = false,
73
78
  onExportCsv,
74
79
  onExportMarkdown,
80
+ onClearGrouping,
81
+ onToggleGrouping,
75
82
  onToggleColumn,
76
83
  className,
77
84
  }: ViewerSettingsProps) {
78
85
  const { theme, setTheme } = useTheme()
79
86
  const canExport = !isDisabled && columns.length > 0
87
+ const groupedLabels = React.useMemo(
88
+ () =>
89
+ groupedColumnIds.flatMap((columnId) => {
90
+ const column = columns.find((candidate) => candidate.id === columnId)
91
+
92
+ return column ? [column.label] : []
93
+ }),
94
+ [columns, groupedColumnIds]
95
+ )
80
96
 
81
97
  return (
82
98
  <DropdownMenu>
@@ -131,6 +147,57 @@ export function ViewerSettings({
131
147
  ))}
132
148
  </DropdownMenuSubContent>
133
149
  </DropdownMenuSub>
150
+ <DropdownMenuSub>
151
+ <DropdownMenuSubTrigger
152
+ className="min-h-9 text-sm"
153
+ disabled={isDisabled || columns.length === 0}
154
+ >
155
+ <LayoutGridIcon className="size-4 text-muted-foreground" />
156
+ Group rows
157
+ </DropdownMenuSubTrigger>
158
+ <DropdownMenuSubContent className="max-h-80 w-64 overflow-y-auto">
159
+ <DropdownMenuLabel>
160
+ {groupedLabels.length > 0
161
+ ? `Grouped by ${groupedLabels.join(", ")}`
162
+ : "Group rows by field"}
163
+ </DropdownMenuLabel>
164
+ <DropdownMenuSeparator />
165
+ <DropdownMenuItem
166
+ className="min-h-9 text-sm"
167
+ disabled={groupedColumnIds.length === 0}
168
+ onSelect={(event) => {
169
+ event.preventDefault()
170
+ onClearGrouping()
171
+ }}
172
+ >
173
+ Clear grouping
174
+ </DropdownMenuItem>
175
+ <DropdownMenuSeparator />
176
+ {columns.map((column) => (
177
+ <DropdownMenuCheckboxItem
178
+ key={column.id}
179
+ checked={groupedColumnIds.includes(column.id)}
180
+ className="min-h-9 text-sm"
181
+ onSelect={(event) => {
182
+ event.preventDefault()
183
+ }}
184
+ onCheckedChange={(checked) =>
185
+ onToggleGrouping(column.id, checked === true)
186
+ }
187
+ >
188
+ <span className="flex min-w-0 items-center gap-2 pr-4">
189
+ {column.kind ? (
190
+ <DataTableColIcon
191
+ kind={column.kind}
192
+ className="size-4 shrink-0 text-muted-foreground"
193
+ />
194
+ ) : null}
195
+ <span className="truncate">{column.label}</span>
196
+ </span>
197
+ </DropdownMenuCheckboxItem>
198
+ ))}
199
+ </DropdownMenuSubContent>
200
+ </DropdownMenuSub>
134
201
  <DropdownMenuSub>
135
202
  <DropdownMenuSubTrigger className="min-h-9 text-sm">
136
203
  <SunIcon className="size-4 text-muted-foreground dark:hidden" />
@@ -17,6 +17,7 @@
17
17
  --secondary: oklch(0.967 0.001 286.375);
18
18
  --secondary-foreground: oklch(0.21 0.006 285.885);
19
19
  --muted: oklch(0.967 0.001 286.375);
20
+ --table-gap: oklch(0.967 0.001 286.375);
20
21
  --muted-foreground: oklch(0.552 0.016 285.938);
21
22
  --accent: oklch(0.967 0.001 286.375);
22
23
  --accent-foreground: oklch(0.21 0.006 285.885);
@@ -52,6 +53,7 @@
52
53
  --secondary: oklch(0.274 0.006 286.033);
53
54
  --secondary-foreground: oklch(0.985 0 0);
54
55
  --muted: oklch(0.274 0.006 286.033);
56
+ --table-gap: oklch(0.180 0.005 285.823);
55
57
  --muted-foreground: oklch(0.705 0.015 286.067);
56
58
  --accent: oklch(0.274 0.006 286.033);
57
59
  --accent-foreground: oklch(0.985 0 0);
@@ -97,6 +99,7 @@
97
99
  --color-accent: var(--accent);
98
100
  --color-muted-foreground: var(--muted-foreground);
99
101
  --color-muted: var(--muted);
102
+ --color-table-gap: var(--table-gap);
100
103
  --color-secondary-foreground: var(--secondary-foreground);
101
104
  --color-secondary: var(--secondary);
102
105
  --color-primary-foreground: var(--primary-foreground);
@@ -5,6 +5,7 @@ type PersistedTableState = {
5
5
  columnSizing?: Record<string, number>
6
6
  columnVisibility?: VisibilityState
7
7
  globalFilter?: string
8
+ groupBy?: string[]
8
9
  highlightedColumns?: Record<string, boolean>
9
10
  sorting?: unknown[]
10
11
  }
@@ -23,7 +24,9 @@ function getTableUrlParam(tableId: string) {
23
24
 
24
25
  function isDatoolUrlParam(key: string) {
25
26
  return (
26
- key.startsWith(`${DATA_TABLE_URL_PARAM_PREFIX}${LOG_VIEWER_TABLE_ID_PREFIX}`) ||
27
+ key.startsWith(
28
+ `${DATA_TABLE_URL_PARAM_PREFIX}${LOG_VIEWER_TABLE_ID_PREFIX}`
29
+ ) ||
27
30
  (key.startsWith(`${LOG_VIEWER_TABLE_ID_PREFIX}-`) && key.endsWith("-search"))
28
31
  )
29
32
  }
@@ -57,6 +60,14 @@ function sanitizeColumnVisibility(
57
60
  )
58
61
  }
59
62
 
63
+ function sanitizeGroupBy(groupBy: string[] | undefined, columnIds: string[]) {
64
+ const validIds = new Set(columnIds)
65
+
66
+ return (groupBy ?? []).filter((columnId, index, values) => {
67
+ return validIds.has(columnId) && values.indexOf(columnId) === index
68
+ })
69
+ }
70
+
60
71
  function cleanUpDatoolParams(url: URL, tableId: string) {
61
72
  const activeSearchParam = getSearchUrlParam(tableId)
62
73
  const activeTableParam = getTableUrlParam(tableId)
@@ -89,7 +100,10 @@ export function readDatoolSearch(tableId: string) {
89
100
  return ""
90
101
  }
91
102
 
92
- return new URL(window.location.href).searchParams.get(getSearchUrlParam(tableId)) ?? ""
103
+ return (
104
+ new URL(window.location.href).searchParams.get(getSearchUrlParam(tableId)) ??
105
+ ""
106
+ )
93
107
  }
94
108
 
95
109
  export function readDatoolColumnVisibility(
@@ -102,15 +116,21 @@ export function readDatoolColumnVisibility(
102
116
  )
103
117
  }
104
118
 
119
+ export function readDatoolGrouping(tableId: string, columnIds: string[]) {
120
+ return sanitizeGroupBy(readPersistedTableState(tableId)?.groupBy, columnIds)
121
+ }
122
+
105
123
  export function writeDatoolUrlState({
106
124
  columnIds,
107
125
  columnVisibility,
126
+ groupBy,
108
127
  search,
109
128
  selectedStreamId,
110
129
  tableId,
111
130
  }: {
112
131
  columnIds: string[]
113
132
  columnVisibility: VisibilityState
133
+ groupBy: string[]
114
134
  search: string
115
135
  selectedStreamId: string | null
116
136
  tableId: string
@@ -125,6 +145,7 @@ export function writeDatoolUrlState({
125
145
  columnVisibility,
126
146
  columnIds
127
147
  )
148
+ const nextGroupBy = sanitizeGroupBy(groupBy, columnIds)
128
149
  const nextTableState = {
129
150
  ...readPersistedTableState(tableId),
130
151
  } satisfies PersistedTableState
@@ -149,6 +170,12 @@ export function writeDatoolUrlState({
149
170
  delete nextTableState.columnVisibility
150
171
  }
151
172
 
173
+ if (nextGroupBy.length > 0) {
174
+ nextTableState.groupBy = nextGroupBy
175
+ } else {
176
+ delete nextTableState.groupBy
177
+ }
178
+
152
179
  if (Object.keys(nextTableState).length > 0) {
153
180
  url.searchParams.set(getTableUrlParam(tableId), JSON.stringify(nextTableState))
154
181
  } else {