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.
- package/README.md +218 -0
- package/client-dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- package/client-dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- package/client-dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- package/client-dist/assets/index-BeRNeRUq.css +1 -0
- package/client-dist/assets/index-uoZ4c_I8.js +164 -0
- package/client-dist/index.html +13 -0
- package/index.html +12 -0
- package/package.json +55 -0
- package/src/client/App.tsx +885 -0
- package/src/client/components/connection-status.tsx +43 -0
- package/src/client/components/data-table-cell.tsx +235 -0
- package/src/client/components/data-table-col-icon.tsx +73 -0
- package/src/client/components/data-table-header-col.tsx +225 -0
- package/src/client/components/data-table-search-input.tsx +729 -0
- package/src/client/components/data-table.tsx +2014 -0
- package/src/client/components/stream-controls.tsx +157 -0
- package/src/client/components/theme-provider.tsx +230 -0
- package/src/client/components/ui/button.tsx +68 -0
- package/src/client/components/ui/combobox.tsx +308 -0
- package/src/client/components/ui/context-menu.tsx +261 -0
- package/src/client/components/ui/dropdown-menu.tsx +267 -0
- package/src/client/components/ui/input-group.tsx +153 -0
- package/src/client/components/ui/input.tsx +19 -0
- package/src/client/components/ui/textarea.tsx +18 -0
- package/src/client/components/viewer-settings.tsx +185 -0
- package/src/client/index.css +192 -0
- package/src/client/lib/data-table-search.ts +750 -0
- package/src/client/lib/datool-icons.ts +37 -0
- package/src/client/lib/datool-url-state.ts +159 -0
- package/src/client/lib/filterable-table.ts +146 -0
- package/src/client/lib/table-search-persistence.ts +94 -0
- package/src/client/lib/utils.ts +6 -0
- package/src/client/main.tsx +14 -0
- package/src/index.ts +19 -0
- package/src/node/cli.ts +54 -0
- package/src/node/config.ts +231 -0
- package/src/node/lines.ts +82 -0
- package/src/node/runtime.ts +102 -0
- package/src/node/server.ts +403 -0
- package/src/node/sources/command.ts +82 -0
- package/src/node/sources/file.ts +116 -0
- package/src/node/sources/ssh.ts +59 -0
- package/src/shared/columns.ts +41 -0
- 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
|
+
}
|