datool 0.0.1

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 (45) hide show
  1. package/README.md +218 -0
  2. package/client-dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  3. package/client-dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  4. package/client-dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  5. package/client-dist/assets/index-BeRNeRUq.css +1 -0
  6. package/client-dist/assets/index-uoZ4c_I8.js +164 -0
  7. package/client-dist/index.html +13 -0
  8. package/index.html +12 -0
  9. package/package.json +55 -0
  10. package/src/client/App.tsx +885 -0
  11. package/src/client/components/connection-status.tsx +43 -0
  12. package/src/client/components/data-table-cell.tsx +235 -0
  13. package/src/client/components/data-table-col-icon.tsx +73 -0
  14. package/src/client/components/data-table-header-col.tsx +225 -0
  15. package/src/client/components/data-table-search-input.tsx +729 -0
  16. package/src/client/components/data-table.tsx +2014 -0
  17. package/src/client/components/stream-controls.tsx +157 -0
  18. package/src/client/components/theme-provider.tsx +230 -0
  19. package/src/client/components/ui/button.tsx +68 -0
  20. package/src/client/components/ui/combobox.tsx +308 -0
  21. package/src/client/components/ui/context-menu.tsx +261 -0
  22. package/src/client/components/ui/dropdown-menu.tsx +267 -0
  23. package/src/client/components/ui/input-group.tsx +153 -0
  24. package/src/client/components/ui/input.tsx +19 -0
  25. package/src/client/components/ui/textarea.tsx +18 -0
  26. package/src/client/components/viewer-settings.tsx +185 -0
  27. package/src/client/index.css +192 -0
  28. package/src/client/lib/data-table-search.ts +750 -0
  29. package/src/client/lib/datool-icons.ts +37 -0
  30. package/src/client/lib/datool-url-state.ts +159 -0
  31. package/src/client/lib/filterable-table.ts +146 -0
  32. package/src/client/lib/table-search-persistence.ts +94 -0
  33. package/src/client/lib/utils.ts +6 -0
  34. package/src/client/main.tsx +14 -0
  35. package/src/index.ts +19 -0
  36. package/src/node/cli.ts +54 -0
  37. package/src/node/config.ts +231 -0
  38. package/src/node/lines.ts +82 -0
  39. package/src/node/runtime.ts +102 -0
  40. package/src/node/server.ts +403 -0
  41. package/src/node/sources/command.ts +82 -0
  42. package/src/node/sources/file.ts +116 -0
  43. package/src/node/sources/ssh.ts +59 -0
  44. package/src/shared/columns.ts +41 -0
  45. package/src/shared/types.ts +188 -0
@@ -0,0 +1,885 @@
1
+ import * as React from "react"
2
+ import type { VisibilityState } from "@tanstack/react-table"
3
+ import { Copy, Filter } from "lucide-react"
4
+
5
+ import {
6
+ DataTable,
7
+ DataTableProvider,
8
+ type DataTableColumnConfig,
9
+ type DataTableRowAction,
10
+ useDataTableContext,
11
+ } from "@/components/data-table"
12
+ import {
13
+ DataTableColIcon,
14
+ type DataTableColumnKind,
15
+ } from "@/components/data-table-col-icon"
16
+ import {
17
+ getValueAtPath,
18
+ isNestedAccessorKey,
19
+ resolveDatoolColumnId,
20
+ } from "../shared/columns"
21
+ import type {
22
+ DatoolActionRequest,
23
+ DatoolActionRowChange,
24
+ DatoolActionResponse,
25
+ DatoolClientConfig,
26
+ DatoolClientStream,
27
+ DatoolColumn,
28
+ DatoolRowEvent,
29
+ DatoolSseErrorEvent,
30
+ } from "../shared/types"
31
+ import {
32
+ DataTableSearchInput,
33
+ type DataTableSearchInputHandle,
34
+ } from "@/components/data-table-search-input"
35
+ import { StreamControls } from "@/components/stream-controls"
36
+ import { ViewerSettings } from "@/components/viewer-settings"
37
+ import {
38
+ quoteSearchTokenValue,
39
+ splitSearchQuery,
40
+ } from "@/lib/data-table-search"
41
+ import {
42
+ readDatoolColumnVisibility,
43
+ readDatoolSearch,
44
+ readSelectedStreamId,
45
+ writeDatoolUrlState,
46
+ } from "@/lib/datool-url-state"
47
+ import { LOG_VIEWER_ICONS } from "@/lib/datool-icons"
48
+
49
+ type ViewerRow = Record<string, unknown> & {
50
+ __datoolRowId: string
51
+ }
52
+
53
+ type ViewerExportColumn = {
54
+ accessorKey: string
55
+ id: string
56
+ kind?: DataTableColumnKind
57
+ label: string
58
+ }
59
+
60
+ function toActionRows(rows: ViewerRow[]): Record<string, unknown>[] {
61
+ return rows.map(({ __datoolRowId: _datoolRowId, ...row }) => row)
62
+ }
63
+
64
+ function applyActionRowChanges(
65
+ currentRows: ViewerRow[],
66
+ targetRowIds: string[],
67
+ rowChanges: Array<DatoolActionRowChange<Record<string, unknown>>> | undefined
68
+ ) {
69
+ if (!rowChanges || rowChanges.length === 0) {
70
+ return currentRows
71
+ }
72
+
73
+ const rowChangeById = new Map(
74
+ targetRowIds.map((rowId, index) => [rowId, rowChanges[index] ?? true])
75
+ )
76
+
77
+ return currentRows.flatMap((row) => {
78
+ const change = rowChangeById.get(row.__datoolRowId)
79
+
80
+ if (change === undefined || change === true) {
81
+ return [row]
82
+ }
83
+
84
+ if (change === false || change === null) {
85
+ return []
86
+ }
87
+
88
+ return [
89
+ {
90
+ ...change,
91
+ __datoolRowId: row.__datoolRowId,
92
+ },
93
+ ]
94
+ })
95
+ }
96
+
97
+ function getInitialStreamId() {
98
+ return readSelectedStreamId()
99
+ }
100
+
101
+ function formatColumnLabel(key: string) {
102
+ return key
103
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
104
+ .replace(/[_-]+/g, " ")
105
+ .replace(/\s+/g, " ")
106
+ .trim()
107
+ .replace(/\b\w/g, (match) => match.toUpperCase())
108
+ }
109
+
110
+ function getTableId(streamId: string | null) {
111
+ return streamId ? `datool-${streamId}` : "datool"
112
+ }
113
+
114
+ function stringifyExportValue(value: unknown, kind?: DataTableColumnKind) {
115
+ if (value === null || value === undefined || value === "") {
116
+ return ""
117
+ }
118
+
119
+ if (kind === "date") {
120
+ const date =
121
+ value instanceof Date ? value : new Date(value as string | number)
122
+
123
+ if (!Number.isNaN(date.getTime())) {
124
+ return date.toISOString()
125
+ }
126
+ }
127
+
128
+ if (typeof value === "object") {
129
+ return JSON.stringify(value)
130
+ }
131
+
132
+ return String(value)
133
+ }
134
+
135
+ function escapeCsvValue(value: string) {
136
+ if (!/[",\n]/.test(value)) {
137
+ return value
138
+ }
139
+
140
+ return `"${value.replaceAll('"', '""')}"`
141
+ }
142
+
143
+ function escapeMarkdownValue(value: string) {
144
+ return value.replace(/\|/g, "\\|").replace(/\r\n?/g, "\n").replace(/\n/g, "<br />")
145
+ }
146
+
147
+ function sanitizeFilePart(value: string) {
148
+ return (
149
+ value
150
+ .trim()
151
+ .toLowerCase()
152
+ .replace(/[^a-z0-9]+/g, "-")
153
+ .replace(/^-+|-+$/g, "") || "log-view"
154
+ )
155
+ }
156
+
157
+ function downloadTextFile(
158
+ content: string,
159
+ fileName: string,
160
+ contentType: string
161
+ ) {
162
+ const blob = new Blob([content], {
163
+ type: `${contentType};charset=utf-8`,
164
+ })
165
+ const objectUrl = window.URL.createObjectURL(blob)
166
+ const anchor = document.createElement("a")
167
+
168
+ anchor.href = objectUrl
169
+ anchor.download = fileName
170
+ document.body.appendChild(anchor)
171
+ anchor.click()
172
+ anchor.remove()
173
+
174
+ window.setTimeout(() => {
175
+ window.URL.revokeObjectURL(objectUrl)
176
+ }, 0)
177
+ }
178
+
179
+ function buildCsvContent(rows: ViewerRow[], columns: ViewerExportColumn[]) {
180
+ const headerRow = columns.map((column) => escapeCsvValue(column.label)).join(",")
181
+ const dataRows = rows.map((row) =>
182
+ columns
183
+ .map((column) =>
184
+ escapeCsvValue(
185
+ stringifyExportValue(getValueAtPath(row, column.accessorKey), column.kind)
186
+ )
187
+ )
188
+ .join(",")
189
+ )
190
+
191
+ return [headerRow, ...dataRows].join("\n")
192
+ }
193
+
194
+ function buildMarkdownContent(rows: ViewerRow[], columns: ViewerExportColumn[]) {
195
+ const headerRow = `| ${columns
196
+ .map((column) => escapeMarkdownValue(column.label))
197
+ .join(" | ")} |`
198
+ const dividerRow = `| ${columns.map(() => "---").join(" | ")} |`
199
+ const dataRows = rows.map((row) =>
200
+ `| ${columns
201
+ .map((column) =>
202
+ escapeMarkdownValue(
203
+ stringifyExportValue(getValueAtPath(row, column.accessorKey), column.kind)
204
+ )
205
+ )
206
+ .join(" | ")} |`
207
+ )
208
+
209
+ return [headerRow, dividerRow, ...dataRows].join("\n")
210
+ }
211
+
212
+ function buildTableColumns(
213
+ columns: DatoolColumn[]
214
+ ): DataTableColumnConfig<ViewerRow>[] {
215
+ return columns.map((column, index) => {
216
+ const nestedAccessor = isNestedAccessorKey(column.accessorKey)
217
+
218
+ return {
219
+ accessorFn: nestedAccessor
220
+ ? (row) => getValueAtPath(row, column.accessorKey)
221
+ : undefined,
222
+ accessorKey: nestedAccessor
223
+ ? undefined
224
+ : (column.accessorKey as Extract<keyof ViewerRow, string>),
225
+ align: column.align,
226
+ header: column.header,
227
+ id: resolveDatoolColumnId(column, index),
228
+ kind: column.kind,
229
+ maxWidth: column.maxWidth,
230
+ minWidth: column.minWidth,
231
+ truncate: column.truncate,
232
+ width: column.width,
233
+ } satisfies DataTableColumnConfig<ViewerRow>
234
+ })
235
+ }
236
+
237
+ function parseRowEvent(event: MessageEvent<string>) {
238
+ return JSON.parse(event.data) as DatoolRowEvent
239
+ }
240
+
241
+ function parseErrorEvent(event: MessageEvent<string>) {
242
+ return JSON.parse(event.data) as DatoolSseErrorEvent
243
+ }
244
+
245
+ function stringifyRowActionValue(value: unknown) {
246
+ if (value === null || value === undefined) {
247
+ return ""
248
+ }
249
+
250
+ if (value instanceof Date) {
251
+ return value.toISOString()
252
+ }
253
+
254
+ if (typeof value === "object") {
255
+ return JSON.stringify(value)
256
+ }
257
+
258
+ return String(value)
259
+ }
260
+
261
+ function escapeMarkdownCell(value: string) {
262
+ return value
263
+ .replace(/\|/g, "\\|")
264
+ .replace(/\r\n?/g, "\n")
265
+ .replace(/\n/g, "<br />")
266
+ }
267
+
268
+ function getColumnValue(
269
+ row: ViewerRow,
270
+ column: DataTableColumnConfig<ViewerRow>
271
+ ): unknown {
272
+ if (column.accessorFn) {
273
+ return column.accessorFn(row)
274
+ }
275
+
276
+ if (column.accessorKey) {
277
+ return row[column.accessorKey]
278
+ }
279
+
280
+ return undefined
281
+ }
282
+
283
+ function getColumnLabel(
284
+ column: DataTableColumnConfig<ViewerRow>,
285
+ index: number
286
+ ): string {
287
+ return column.header ?? column.id ?? column.accessorKey ?? `Column ${index + 1}`
288
+ }
289
+
290
+ function toMarkdownTable(
291
+ tableRows: ViewerRow[],
292
+ tableColumns: DataTableColumnConfig<ViewerRow>[]
293
+ ) {
294
+ const headers = tableColumns.map((column, index) =>
295
+ escapeMarkdownCell(getColumnLabel(column, index))
296
+ )
297
+ const lines = [
298
+ `| ${headers.join(" | ")} |`,
299
+ `| ${headers.map(() => "---").join(" | ")} |`,
300
+ ...tableRows.map((row) => {
301
+ const values = tableColumns.map((column) =>
302
+ escapeMarkdownCell(stringifyRowActionValue(getColumnValue(row, column)))
303
+ )
304
+
305
+ return `| ${values.join(" | ")} |`
306
+ }),
307
+ ]
308
+
309
+ return lines.join("\n")
310
+ }
311
+
312
+ function buildFieldFilterToken(fieldId: string, value: unknown) {
313
+ const serializedValue = stringifyRowActionValue(value)
314
+
315
+ if (!serializedValue.trim()) {
316
+ return null
317
+ }
318
+
319
+ return `${fieldId}:${quoteSearchTokenValue(serializedValue)}`
320
+ }
321
+
322
+ function replaceFieldFilter(query: string, fieldId: string, nextToken: string) {
323
+ const escapedFieldId = fieldId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
324
+ const nextTokens = splitSearchQuery(query).filter(
325
+ (token) => !token.match(new RegExp(`^${escapedFieldId}(:|>|<)`))
326
+ )
327
+
328
+ nextTokens.push(nextToken)
329
+
330
+ return nextTokens.join(" ")
331
+ }
332
+
333
+ function DatoolTable({
334
+ activeStream,
335
+ columns,
336
+ config,
337
+ errorMessage,
338
+ isConnected,
339
+ isConnecting,
340
+ isLoadingConfig,
341
+ rows,
342
+ settingsColumns,
343
+ selectedStreamId,
344
+ setColumnVisibility,
345
+ searchInputRef,
346
+ handleExport,
347
+ setRows,
348
+ setSelectedStreamId,
349
+ setShouldConnect,
350
+ }: {
351
+ activeStream: DatoolClientStream | null
352
+ columns: DataTableColumnConfig<ViewerRow>[]
353
+ config: DatoolClientConfig | null
354
+ errorMessage: string | null
355
+ isConnected: boolean
356
+ isConnecting: boolean
357
+ isLoadingConfig: boolean
358
+ rows: ViewerRow[]
359
+ settingsColumns: Array<{
360
+ id: string
361
+ kind?: DataTableColumnKind
362
+ label: string
363
+ visible: boolean
364
+ }>
365
+ selectedStreamId: string | null
366
+ setColumnVisibility: React.Dispatch<React.SetStateAction<VisibilityState>>
367
+ searchInputRef: React.RefObject<DataTableSearchInputHandle | null>
368
+ handleExport: (format: "csv" | "md") => void
369
+ setRows: React.Dispatch<React.SetStateAction<ViewerRow[]>>
370
+ setSelectedStreamId: React.Dispatch<React.SetStateAction<string | null>>
371
+ setShouldConnect: React.Dispatch<React.SetStateAction<boolean>>
372
+ }) {
373
+ const { search, setSearch } = useDataTableContext<ViewerRow>()
374
+ const rowActions = React.useMemo<DataTableRowAction<ViewerRow>[]>(
375
+ () => {
376
+ const configActions =
377
+ activeStream?.actions.map(
378
+ (action): DataTableRowAction<ViewerRow> => ({
379
+ button: action.button,
380
+ icon: action.icon ? LOG_VIEWER_ICONS[action.icon] : undefined,
381
+ id: `config-${action.id}`,
382
+ label: action.label,
383
+ onSelect: async ({ actionRowIds, actionRows }) => {
384
+ if (!activeStream) {
385
+ return
386
+ }
387
+
388
+ const url = new URL(
389
+ `/api/streams/${encodeURIComponent(activeStream.id)}/actions/${encodeURIComponent(action.id)}`,
390
+ window.location.origin
391
+ )
392
+ const currentParams = new URL(window.location.href).searchParams
393
+
394
+ for (const [key, value] of currentParams.entries()) {
395
+ url.searchParams.set(key, value)
396
+ }
397
+
398
+ const requestBody: DatoolActionRequest = {
399
+ rows: toActionRows(actionRows),
400
+ }
401
+ const response = await fetch(url, {
402
+ body: JSON.stringify(requestBody),
403
+ headers: {
404
+ "Content-Type": "application/json",
405
+ },
406
+ method: "POST",
407
+ })
408
+ const payload = (await response
409
+ .json()
410
+ .catch(() => null)) as
411
+ | (DatoolActionResponse & {
412
+ error?: string
413
+ })
414
+ | null
415
+
416
+ if (!response.ok) {
417
+ throw new Error(
418
+ payload?.error ?? `Failed to run action "${action.label}".`
419
+ )
420
+ }
421
+
422
+ if (
423
+ payload?.rowChanges !== undefined &&
424
+ !Array.isArray(payload.rowChanges)
425
+ ) {
426
+ throw new Error(
427
+ `Action "${action.label}" returned an invalid response.`
428
+ )
429
+ }
430
+
431
+ setRows((currentRows) =>
432
+ applyActionRowChanges(
433
+ currentRows,
434
+ actionRowIds,
435
+ payload?.rowChanges
436
+ )
437
+ )
438
+ },
439
+ scope: "selection",
440
+ })
441
+ ) ?? []
442
+
443
+ return [
444
+ ...configActions,
445
+ {
446
+ icon: Copy,
447
+ id: "copy-markdown",
448
+ label: ({ actionRows }) =>
449
+ actionRows.length > 1
450
+ ? `Copy ${actionRows.length} rows as Markdown`
451
+ : "Copy row as Markdown",
452
+ onSelect: async ({ actionRows }) => {
453
+ await navigator.clipboard.writeText(
454
+ toMarkdownTable(actionRows, columns)
455
+ )
456
+ },
457
+ scope: "selection",
458
+ },
459
+ {
460
+ icon: Filter,
461
+ id: "filter-matching",
462
+ items: ({ anchorRow }) =>
463
+ columns.map((column, index) => {
464
+ const value = getColumnValue(anchorRow, column)
465
+ const token = buildFieldFilterToken(
466
+ column.id ?? `column-${index}`,
467
+ value
468
+ )
469
+
470
+ return {
471
+ disabled: token === null,
472
+ icon: (props) => (
473
+ <DataTableColIcon kind={column.kind ?? "text"} {...props} />
474
+ ),
475
+ id: `filter-${column.id ?? index}`,
476
+ label: `${getColumnLabel(column, index)}: ${
477
+ stringifyRowActionValue(value) || "(empty)"
478
+ }`,
479
+ onSelect: () => {
480
+ if (!token) {
481
+ return
482
+ }
483
+
484
+ setSearch(
485
+ replaceFieldFilter(
486
+ search,
487
+ column.id ?? `column-${index}`,
488
+ token
489
+ )
490
+ )
491
+ },
492
+ scope: "row",
493
+ } satisfies DataTableRowAction<ViewerRow>
494
+ }),
495
+ label: "Filter matching",
496
+ scope: "row",
497
+ },
498
+ ]
499
+ },
500
+ [activeStream, columns, search, setSearch]
501
+ )
502
+
503
+ return (
504
+ <main className="flex h-svh min-h-0 w-full min-w-0 flex-col gap-3 bg-background px-0 pt-3 pb-0">
505
+ <header className="flex w-full flex-wrap items-start gap-3 px-4">
506
+ <DataTableSearchInput inputRef={searchInputRef} />
507
+ <div className="flex min-w-20 items-start gap-3">
508
+ <StreamControls
509
+ streams={config?.streams ?? []}
510
+ selectedStreamId={selectedStreamId}
511
+ isConnected={isConnected}
512
+ isConnecting={isConnecting}
513
+ isDisabled={isLoadingConfig || !config}
514
+ canClear={rows.length > 0}
515
+ onSelectStream={setSelectedStreamId}
516
+ onPlay={() => setShouldConnect(true)}
517
+ onPause={() => setShouldConnect(false)}
518
+ onClear={() => setRows([])}
519
+ />
520
+ <ViewerSettings
521
+ columns={settingsColumns}
522
+ isDisabled={isLoadingConfig || !activeStream}
523
+ onExportCsv={() => handleExport("csv")}
524
+ onExportMarkdown={() => handleExport("md")}
525
+ onToggleColumn={(columnId, visible) =>
526
+ setColumnVisibility((current) => ({
527
+ ...current,
528
+ [columnId]: visible,
529
+ }))
530
+ }
531
+ />
532
+ </div>
533
+ </header>
534
+
535
+ {errorMessage ? (
536
+ <div className="px-4 text-sm text-destructive">{errorMessage}</div>
537
+ ) : null}
538
+
539
+ <div className="min-h-0 flex-1">
540
+ {activeStream ? (
541
+ <DataTable rowActions={rowActions} />
542
+ ) : (
543
+ <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
544
+ No stream selected.
545
+ </div>
546
+ )}
547
+ </div>
548
+ </main>
549
+ )
550
+ }
551
+
552
+ export default function App() {
553
+ const [initialUrlState] = React.useState(() => {
554
+ const streamId = getInitialStreamId()
555
+ const tableId = getTableId(streamId)
556
+
557
+ return {
558
+ search: readDatoolSearch(tableId),
559
+ streamId,
560
+ tableId,
561
+ }
562
+ })
563
+ const [config, setConfig] = React.useState<DatoolClientConfig | null>(null)
564
+ const [rows, setRows] = React.useState<ViewerRow[]>([])
565
+ const [selectedStreamId, setSelectedStreamId] = React.useState<string | null>(
566
+ () => initialUrlState.streamId
567
+ )
568
+ const [shouldConnect, setShouldConnect] = React.useState(() =>
569
+ Boolean(initialUrlState.streamId)
570
+ )
571
+ const [isLoadingConfig, setIsLoadingConfig] = React.useState(true)
572
+ const [isConnected, setIsConnected] = React.useState(false)
573
+ const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
574
+ const [search, setSearch] = React.useState(() => initialUrlState.search)
575
+ const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(
576
+ {}
577
+ )
578
+ const eventSourceRef = React.useRef<EventSource | null>(null)
579
+ const hasInitializedStreamRef = React.useRef(false)
580
+ const [hydratedTableId, setHydratedTableId] = React.useState<string | null>(
581
+ null
582
+ )
583
+
584
+ React.useEffect(() => {
585
+ let cancelled = false
586
+
587
+ void fetch("/api/config")
588
+ .then(async (response) => {
589
+ if (!response.ok) {
590
+ throw new Error("Failed to load log viewer config.")
591
+ }
592
+
593
+ return (await response.json()) as DatoolClientConfig
594
+ })
595
+ .then((nextConfig) => {
596
+ if (cancelled) {
597
+ return
598
+ }
599
+
600
+ setConfig(nextConfig)
601
+ setSelectedStreamId((currentValue) => {
602
+ if (
603
+ currentValue &&
604
+ nextConfig.streams.some((stream) => stream.id === currentValue)
605
+ ) {
606
+ return currentValue
607
+ }
608
+
609
+ return nextConfig.streams[0]?.id ?? null
610
+ })
611
+ })
612
+ .catch((error) => {
613
+ if (!cancelled) {
614
+ setErrorMessage(
615
+ error instanceof Error ? error.message : String(error)
616
+ )
617
+ }
618
+ })
619
+ .finally(() => {
620
+ if (!cancelled) {
621
+ setIsLoadingConfig(false)
622
+ }
623
+ })
624
+
625
+ return () => {
626
+ cancelled = true
627
+ }
628
+ }, [])
629
+
630
+ const activeStream = React.useMemo<DatoolClientStream | null>(() => {
631
+ if (!config || !selectedStreamId) {
632
+ return null
633
+ }
634
+
635
+ return (
636
+ config.streams.find((stream) => stream.id === selectedStreamId) ?? null
637
+ )
638
+ }, [config, selectedStreamId])
639
+
640
+ const columns = React.useMemo(
641
+ () => (activeStream ? buildTableColumns(activeStream.columns) : []),
642
+ [activeStream]
643
+ )
644
+ const tableId = React.useMemo(
645
+ () => getTableId(selectedStreamId),
646
+ [selectedStreamId]
647
+ )
648
+ const exportColumns = React.useMemo(
649
+ () =>
650
+ activeStream?.columns.map((column, index) => ({
651
+ accessorKey: column.accessorKey,
652
+ id: resolveDatoolColumnId(column, index),
653
+ kind: column.kind,
654
+ label: column.header ?? formatColumnLabel(column.accessorKey),
655
+ })) ?? [],
656
+ [activeStream]
657
+ )
658
+ const settingsColumns = React.useMemo(
659
+ () =>
660
+ exportColumns.map((column) => ({
661
+ id: column.id,
662
+ kind: column.kind,
663
+ label: column.label,
664
+ visible: columnVisibility[column.id] !== false,
665
+ })),
666
+ [columnVisibility, exportColumns]
667
+ )
668
+ const visibleExportColumns = React.useMemo(
669
+ () =>
670
+ exportColumns.filter((column) => columnVisibility[column.id] !== false),
671
+ [columnVisibility, exportColumns]
672
+ )
673
+ const isConnecting = Boolean(selectedStreamId) && shouldConnect && !isConnected
674
+ const columnIds = React.useMemo(
675
+ () => exportColumns.map((column) => column.id),
676
+ [exportColumns]
677
+ )
678
+
679
+ React.useEffect(() => {
680
+ if (hasInitializedStreamRef.current) {
681
+ setRows([])
682
+ setErrorMessage(null)
683
+ }
684
+
685
+ hasInitializedStreamRef.current = true
686
+ }, [selectedStreamId])
687
+
688
+ React.useEffect(() => {
689
+ if (!activeStream) {
690
+ setHydratedTableId(null)
691
+ return
692
+ }
693
+
694
+ setSearch(readDatoolSearch(tableId))
695
+ setColumnVisibility(readDatoolColumnVisibility(tableId, columnIds))
696
+ setHydratedTableId(tableId)
697
+ }, [activeStream, columnIds, tableId])
698
+
699
+ React.useEffect(() => {
700
+ if (!activeStream || hydratedTableId !== tableId) {
701
+ return
702
+ }
703
+
704
+ const timeoutId = window.setTimeout(() => {
705
+ writeDatoolUrlState({
706
+ columnIds,
707
+ columnVisibility,
708
+ search,
709
+ selectedStreamId,
710
+ tableId,
711
+ })
712
+ }, 300)
713
+
714
+ return () => window.clearTimeout(timeoutId)
715
+ }, [
716
+ activeStream,
717
+ columnIds,
718
+ columnVisibility,
719
+ hydratedTableId,
720
+ search,
721
+ selectedStreamId,
722
+ tableId,
723
+ ])
724
+
725
+ React.useEffect(() => {
726
+ const handlePopState = () => {
727
+ const nextStreamId = readSelectedStreamId()
728
+ const nextTableId = getTableId(nextStreamId)
729
+
730
+ setSelectedStreamId((currentValue) => {
731
+ if (currentValue === nextStreamId) {
732
+ setSearch(readDatoolSearch(nextTableId))
733
+ setColumnVisibility(
734
+ readDatoolColumnVisibility(nextTableId, columnIds)
735
+ )
736
+ setHydratedTableId(nextTableId)
737
+ }
738
+
739
+ return nextStreamId
740
+ })
741
+ }
742
+
743
+ window.addEventListener("popstate", handlePopState)
744
+
745
+ return () => window.removeEventListener("popstate", handlePopState)
746
+ }, [columnIds])
747
+
748
+ React.useEffect(() => {
749
+ if (!selectedStreamId || !shouldConnect) {
750
+ eventSourceRef.current?.close()
751
+ eventSourceRef.current = null
752
+ setIsConnected(false)
753
+ return
754
+ }
755
+
756
+ setErrorMessage(null)
757
+ setRows([])
758
+
759
+ const url = new URL(
760
+ `/api/streams/${encodeURIComponent(selectedStreamId)}/events`,
761
+ window.location.origin
762
+ )
763
+ const currentParams = new URL(window.location.href).searchParams
764
+
765
+ for (const [key, value] of currentParams.entries()) {
766
+ url.searchParams.set(key, value)
767
+ }
768
+
769
+ const eventSource = new EventSource(url)
770
+
771
+ eventSourceRef.current = eventSource
772
+
773
+ const handleRow = (event: MessageEvent<string>) => {
774
+ const payload = parseRowEvent(event)
775
+
776
+ setRows((currentRows) => [
777
+ ...currentRows,
778
+ {
779
+ ...payload.row,
780
+ __datoolRowId: payload.id,
781
+ },
782
+ ])
783
+ }
784
+
785
+ const handleRuntimeError = (event: MessageEvent<string>) => {
786
+ const payload = parseErrorEvent(event)
787
+
788
+ setErrorMessage(payload.message)
789
+ }
790
+
791
+ eventSource.onopen = () => {
792
+ setIsConnected(true)
793
+ }
794
+
795
+ eventSource.onerror = () => {
796
+ setIsConnected(false)
797
+ }
798
+
799
+ eventSource.addEventListener("row", handleRow as EventListener)
800
+ eventSource.addEventListener("error", handleRuntimeError as EventListener)
801
+
802
+ return () => {
803
+ eventSource.removeEventListener("row", handleRow as EventListener)
804
+ eventSource.removeEventListener(
805
+ "error",
806
+ handleRuntimeError as EventListener
807
+ )
808
+ eventSource.close()
809
+ setIsConnected(false)
810
+ }
811
+ }, [selectedStreamId, shouldConnect])
812
+
813
+ const searchInputRef = React.useRef<DataTableSearchInputHandle>(null)
814
+ React.useEffect(() => {
815
+ const handleKeyDown = (event: KeyboardEvent) => {
816
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") {
817
+ event.preventDefault()
818
+ searchInputRef.current?.focus()
819
+ searchInputRef.current?.selectAll()
820
+ }
821
+ }
822
+
823
+ window.addEventListener("keydown", handleKeyDown)
824
+
825
+ return () => window.removeEventListener("keydown", handleKeyDown)
826
+ }, [])
827
+
828
+ const handleExport = React.useCallback(
829
+ (format: "csv" | "md") => {
830
+ if (!activeStream || visibleExportColumns.length === 0) {
831
+ return
832
+ }
833
+
834
+ const timeStamp = new Date().toISOString().replaceAll(":", "-")
835
+ const fileBaseName = `${sanitizeFilePart(activeStream.label)}-${timeStamp}`
836
+ const content =
837
+ format === "csv"
838
+ ? buildCsvContent(rows, visibleExportColumns)
839
+ : buildMarkdownContent(rows, visibleExportColumns)
840
+
841
+ downloadTextFile(
842
+ content,
843
+ `${fileBaseName}.${format}`,
844
+ format === "csv" ? "text/csv" : "text/markdown"
845
+ )
846
+ },
847
+ [activeStream, rows, visibleExportColumns]
848
+ )
849
+
850
+ return (
851
+ <DataTableProvider
852
+ autoScrollToBottom
853
+ columnVisibility={columnVisibility}
854
+ columns={columns}
855
+ data={rows}
856
+ getRowId={(row) => row.__datoolRowId}
857
+ height="100%"
858
+ id={tableId}
859
+ onColumnVisibilityChange={setColumnVisibility}
860
+ onSearchChange={setSearch}
861
+ search={search}
862
+ rowHeight={20}
863
+ statePersistence="none"
864
+ >
865
+ <DatoolTable
866
+ activeStream={activeStream}
867
+ columns={columns}
868
+ config={config}
869
+ errorMessage={errorMessage}
870
+ handleExport={handleExport}
871
+ isConnected={isConnected}
872
+ isConnecting={isConnecting}
873
+ isLoadingConfig={isLoadingConfig}
874
+ rows={rows}
875
+ settingsColumns={settingsColumns}
876
+ setColumnVisibility={setColumnVisibility}
877
+ searchInputRef={searchInputRef}
878
+ selectedStreamId={selectedStreamId}
879
+ setRows={setRows}
880
+ setSelectedStreamId={setSelectedStreamId}
881
+ setShouldConnect={setShouldConnect}
882
+ />
883
+ </DataTableProvider>
884
+ )
885
+ }