datool 0.0.6 → 0.0.7

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-CU3ksvkv.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-Rn9jSaAz.css">
7
+ <script type="module" crossorigin src="/assets/index-C6-ONVzY.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BJjc4NKE.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.6",
3
+ "version": "0.0.7",
4
4
  "type": "module",
5
5
  "description": "Local-only config-driven log viewer with SSE streaming and a generic table UI.",
6
6
  "bin": {
@@ -8,7 +8,6 @@ import {
8
8
  getSortedRowModel,
9
9
  useReactTable,
10
10
  type AggregationFnOption,
11
- type Column,
12
11
  type ColumnDef,
13
12
  type ColumnFiltersState,
14
13
  type ExpandedState,
@@ -604,14 +603,6 @@ function formatDuration(durationMs: number) {
604
603
  return parts.join(" ")
605
604
  }
606
605
 
607
- function getColumnHeaderLabel<TData extends DataTableRow>(
608
- column: Column<TData, unknown>
609
- ) {
610
- return typeof column.columnDef.header === "string"
611
- ? column.columnDef.header
612
- : formatHeaderLabel(column.id)
613
- }
614
-
615
606
  function resolveGroupedPadding(
616
607
  padding: React.CSSProperties["paddingLeft"],
617
608
  depth: number
@@ -1294,21 +1285,6 @@ function RowActionButtonGroup<TData extends DataTableRow>({
1294
1285
  )
1295
1286
  }
1296
1287
 
1297
- function GroupSummaryBadge({
1298
- label,
1299
- value,
1300
- }: {
1301
- label: string
1302
- value: string
1303
- }) {
1304
- return (
1305
- <span className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/80 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
1306
- <span className="text-foreground">{value}</span>
1307
- <span>{label}</span>
1308
- </span>
1309
- )
1310
- }
1311
-
1312
1288
  function DataTableView<TData extends DataTableRow>({
1313
1289
  autoScrollToBottom = false,
1314
1290
  autoScrollToBottomThreshold = 96,
@@ -1359,6 +1335,9 @@ function DataTableView<TData extends DataTableRow>({
1359
1335
  const [grouping, setGrouping] = React.useState<GroupingState>(
1360
1336
  () => persistedState.grouping ?? []
1361
1337
  )
1338
+ const [expanded, setExpanded] = React.useState<ExpandedState>(() =>
1339
+ (controlledGrouping ?? persistedState.grouping ?? []).length > 0 ? true : {}
1340
+ )
1362
1341
  const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
1363
1342
  const [rowActionStatuses, setRowActionStatuses] = React.useState<
1364
1343
  Record<string, RowActionStatus>
@@ -1386,6 +1365,10 @@ function DataTableView<TData extends DataTableRow>({
1386
1365
  const resolvedColumnVisibility =
1387
1366
  controlledColumnVisibility ?? columnVisibility
1388
1367
  const resolvedGrouping = controlledGrouping ?? grouping
1368
+ const groupingKey = React.useMemo(
1369
+ () => resolvedGrouping.join("\u001f"),
1370
+ [resolvedGrouping]
1371
+ )
1389
1372
  const hasSelectionActions = rowActions
1390
1373
  ? hasSelectionScopedAction(rowActions)
1391
1374
  : false
@@ -1526,12 +1509,9 @@ function DataTableView<TData extends DataTableRow>({
1526
1509
  },
1527
1510
  [isGroupingControlled, onGroupingChange, resolvedGrouping]
1528
1511
  )
1529
- const expandedState = React.useMemo<ExpandedState>(
1530
- () => (resolvedGrouping.length > 0 ? true : {}),
1531
- [resolvedGrouping.length]
1532
- )
1533
1512
 
1534
1513
  const table = useReactTable({
1514
+ autoResetExpanded: false,
1535
1515
  columnResizeMode: "onChange",
1536
1516
  columns: tableColumns,
1537
1517
  data,
@@ -1550,6 +1530,7 @@ function DataTableView<TData extends DataTableRow>({
1550
1530
  onColumnFiltersChange: handleColumnFiltersChange,
1551
1531
  onColumnSizingChange: handleColumnSizingChange,
1552
1532
  onColumnVisibilityChange: handleColumnVisibilityChange,
1533
+ onExpandedChange: setExpanded,
1553
1534
  onGroupingChange: handleGroupingChange,
1554
1535
  onRowSelectionChange: setRowSelection,
1555
1536
  onSortingChange: setSorting,
@@ -1557,7 +1538,7 @@ function DataTableView<TData extends DataTableRow>({
1557
1538
  columnFilters: resolvedColumnFilters,
1558
1539
  columnSizing,
1559
1540
  columnVisibility: resolvedColumnVisibility,
1560
- expanded: expandedState,
1541
+ expanded,
1561
1542
  globalFilter: deferredSearch.trim(),
1562
1543
  grouping: resolvedGrouping,
1563
1544
  rowSelection,
@@ -1618,6 +1599,12 @@ function DataTableView<TData extends DataTableRow>({
1618
1599
  if (!isGroupingControlled) {
1619
1600
  setGrouping(nextState.grouping ?? [])
1620
1601
  }
1602
+ setExpanded(
1603
+ (isGroupingControlled ? controlledGrouping : nextState.grouping ?? [])
1604
+ .length > 0
1605
+ ? true
1606
+ : {}
1607
+ )
1621
1608
  setSearchDraft(nextState.globalFilter ?? "")
1622
1609
  setRowSelection({})
1623
1610
  dragSelectionRef.current = null
@@ -1630,6 +1617,7 @@ function DataTableView<TData extends DataTableRow>({
1630
1617
  rowActionStatusTimersRef.current = {}
1631
1618
  setRowActionStatuses({})
1632
1619
  }, [
1620
+ controlledGrouping,
1633
1621
  id,
1634
1622
  isColumnFiltersControlled,
1635
1623
  isColumnVisibilityControlled,
@@ -1687,6 +1675,12 @@ function DataTableView<TData extends DataTableRow>({
1687
1675
  if (!isGroupingControlled) {
1688
1676
  setGrouping(nextState.grouping ?? [])
1689
1677
  }
1678
+ setExpanded(
1679
+ (isGroupingControlled ? controlledGrouping : nextState.grouping ?? [])
1680
+ .length > 0
1681
+ ? true
1682
+ : {}
1683
+ )
1690
1684
  setSearchDraft(nextState.globalFilter ?? "")
1691
1685
  setRowSelection({})
1692
1686
  dragSelectionRef.current = null
@@ -1698,7 +1692,13 @@ function DataTableView<TData extends DataTableRow>({
1698
1692
  window.addEventListener("popstate", syncFromUrl)
1699
1693
 
1700
1694
  return () => window.removeEventListener("popstate", syncFromUrl)
1701
- }, [id, isColumnVisibilityControlled, isGroupingControlled, statePersistence])
1695
+ }, [
1696
+ controlledGrouping,
1697
+ id,
1698
+ isColumnVisibilityControlled,
1699
+ isGroupingControlled,
1700
+ statePersistence,
1701
+ ])
1702
1702
 
1703
1703
  React.useEffect(() => {
1704
1704
  if (isGlobalFilterControlled || isColumnFiltersControlled) {
@@ -1729,6 +1729,10 @@ function DataTableView<TData extends DataTableRow>({
1729
1729
  statePersistence,
1730
1730
  ])
1731
1731
 
1732
+ React.useEffect(() => {
1733
+ setExpanded(resolvedGrouping.length > 0 ? true : {})
1734
+ }, [groupingKey, resolvedGrouping.length])
1735
+
1732
1736
  React.useEffect(() => {
1733
1737
  const container = containerRef.current
1734
1738
 
@@ -2200,34 +2204,13 @@ function DataTableView<TData extends DataTableRow>({
2200
2204
  enumOptions: groupingMeta?.enumOptions,
2201
2205
  })
2202
2206
  : null
2203
- const groupSummaries = isGroupRow
2204
- ? groupVisibleCells.flatMap((cell) => {
2205
- const meta = (cell.column.columnDef.meta ??
2206
- {}) as DataTableColumnMeta
2207
- const value = cell.getValue()
2208
- const label = getColumnHeaderLabel(cell.column)
2209
-
2210
- if (meta.kind === "date" && isDateRangeAggregate(value)) {
2211
- return [
2212
- {
2213
- label: `${label} span`,
2214
- value: formatDuration(value.durationMs),
2215
- },
2216
- ]
2217
- }
2218
-
2219
- if (meta.kind === "number" && typeof value === "number") {
2220
- return [
2221
- {
2222
- label: `${label} sum`,
2223
- value: formatSummaryNumber(value),
2224
- },
2225
- ]
2226
- }
2227
-
2228
- return []
2229
- })
2230
- : []
2207
+ const hasVisibleGroupedCell = groupVisibleCells.some((cell) =>
2208
+ cell.getIsGrouped()
2209
+ )
2210
+ const primaryGroupCellId = groupVisibleCells.find(
2211
+ (cell) =>
2212
+ cell.column.id !== "__select" && cell.column.id !== "__actions"
2213
+ )?.id
2231
2214
  const rowContent = (
2232
2215
  <tr
2233
2216
  aria-selected={isSelected}
@@ -2274,65 +2257,95 @@ function DataTableView<TData extends DataTableRow>({
2274
2257
  }}
2275
2258
  >
2276
2259
  {isGroupRow ? (
2277
- <td
2278
- className="flex shrink-0 items-center border-y border-border/70 px-2 py-2 align-middle text-xs text-muted-foreground"
2279
- style={{
2280
- background:
2281
- "var(--color-table-gap, color-mix(in oklab, var(--color-muted) 84%, transparent))",
2282
- paddingLeft: resolveGroupedPadding(
2283
- edgeHorizontalPadding,
2284
- row.depth
2285
- ),
2286
- paddingRight: edgeHorizontalPadding,
2287
- width: table.getTotalSize(),
2288
- }}
2289
- >
2290
- <div className="flex w-full min-w-0 flex-wrap items-center gap-2">
2291
- {row.getCanExpand() ? (
2292
- <button
2293
- aria-label={
2294
- row.getIsExpanded()
2295
- ? "Collapse group"
2296
- : "Expand group"
2297
- }
2298
- className="inline-flex size-6 items-center justify-center rounded-md border border-border/70 bg-background/80 text-foreground transition-colors hover:bg-background"
2299
- onClick={() => row.toggleExpanded()}
2300
- type="button"
2301
- >
2302
- {row.getIsExpanded() ? (
2303
- <ChevronDown className="size-3.5" />
2304
- ) : (
2305
- <ChevronRight className="size-3.5" />
2306
- )}
2307
- </button>
2308
- ) : null}
2309
- {groupingColumn ? (
2310
- <span className="inline-flex items-center gap-2 text-xs">
2311
- <span className="font-medium text-foreground">
2312
- {getColumnHeaderLabel(groupingColumn)}
2313
- </span>
2314
- <span className="min-w-0 truncate text-foreground">
2315
- {groupingValue}
2316
- </span>
2317
- </span>
2318
- ) : (
2319
- <span className="font-medium text-foreground">
2320
- Group
2321
- </span>
2322
- )}
2323
- <GroupSummaryBadge
2324
- label="rows"
2325
- value={formatSummaryNumber(row.getLeafRows().length)}
2326
- />
2327
- {groupSummaries.map((summary) => (
2328
- <GroupSummaryBadge
2329
- key={`${row.id}-${summary.label}`}
2330
- label={summary.label}
2331
- value={summary.value}
2332
- />
2333
- ))}
2334
- </div>
2335
- </td>
2260
+ groupVisibleCells.map((cell, index, visibleCells) => {
2261
+ const meta = (cell.column.columnDef.meta ??
2262
+ {}) as DataTableColumnMeta
2263
+ const isActionsCell = cell.column.id === "__actions"
2264
+ const isSelectionCell = meta.kind === "selection"
2265
+ const shouldRenderGroupLabel =
2266
+ cell.getIsGrouped() ||
2267
+ (!hasVisibleGroupedCell && cell.id === primaryGroupCellId)
2268
+ const value = cell.getValue()
2269
+ let content: React.ReactNode = null
2270
+
2271
+ if (!isActionsCell && !isSelectionCell) {
2272
+ if (shouldRenderGroupLabel) {
2273
+ content = (
2274
+ <div className="flex min-w-0 items-center gap-0.5">
2275
+ {row.getCanExpand() ? (
2276
+ <button
2277
+ aria-label={
2278
+ row.getIsExpanded()
2279
+ ? "Collapse group"
2280
+ : "Expand group"
2281
+ }
2282
+ className="-ml-4"
2283
+ onClick={() => row.toggleExpanded()}
2284
+ type="button"
2285
+ >
2286
+ {row.getIsExpanded() ? (
2287
+ <ChevronDown className="size-3.5" />
2288
+ ) : (
2289
+ <ChevronRight className="size-3.5" />
2290
+ )}
2291
+ </button>
2292
+ ) : null}
2293
+ <div className="min-w-0 truncate font-medium text-foreground">
2294
+ {groupingColumn ? groupingValue : "Group"}
2295
+ </div>
2296
+ </div>
2297
+ )
2298
+ } else if (meta.kind === "date" && isDateRangeAggregate(value)) {
2299
+ content = (
2300
+ <div className="min-w-0 truncate">
2301
+ <span className="font-medium text-foreground">
2302
+ {formatDuration(value.durationMs)}
2303
+ </span>
2304
+ <span className="ml-2 text-[11px] text-muted-foreground">
2305
+ span
2306
+ </span>
2307
+ </div>
2308
+ )
2309
+ } else if (meta.kind === "number" && typeof value === "number") {
2310
+ content = (
2311
+ <div className="min-w-0 truncate font-medium text-foreground">
2312
+ {formatSummaryNumber(value)}
2313
+ </div>
2314
+ )
2315
+ }
2316
+ }
2317
+
2318
+ return (
2319
+ <td
2320
+ className={cn(
2321
+ "flex shrink-0 items-center border-y border-border/70 px-2 py-2 align-middle text-xs text-muted-foreground",
2322
+ meta.align === "center" && "justify-center text-center",
2323
+ meta.align === "right" && "justify-end text-right",
2324
+ meta.sticky === "left" &&
2325
+ "sticky left-0 z-10 border-r border-r-border"
2326
+ )}
2327
+ key={cell.id}
2328
+ style={{
2329
+ background:
2330
+ "var(--color-table-gap, color-mix(in oklab, var(--color-muted) 84%, transparent))",
2331
+ paddingLeft:
2332
+ index === 0
2333
+ ? resolveGroupedPadding(
2334
+ edgeHorizontalPadding,
2335
+ row.depth
2336
+ )
2337
+ : undefined,
2338
+ paddingRight:
2339
+ index === visibleCells.length - 1
2340
+ ? edgeHorizontalPadding
2341
+ : undefined,
2342
+ width: cell.column.getSize(),
2343
+ }}
2344
+ >
2345
+ <div className="w-full min-w-0">{content}</div>
2346
+ </td>
2347
+ )
2348
+ })
2336
2349
  ) : (
2337
2350
  row.getVisibleCells().map((cell, index, visibleCells) => {
2338
2351
  const meta = (cell.column.columnDef.meta ??
@@ -36,6 +36,8 @@ const SSE_HEADERS = {
36
36
  "Content-Type": "text/event-stream; charset=utf-8",
37
37
  } as const
38
38
 
39
+ const MAX_PORT_ATTEMPTS = 10
40
+
39
41
  function packageRootFromImportMeta() {
40
42
  return path.resolve(import.meta.dir, "..", "..")
41
43
  }
@@ -85,6 +87,14 @@ function encodeSseEvent(event: string, data: unknown) {
85
87
  return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
86
88
  }
87
89
 
90
+ function isAddressInUseError(error: unknown) {
91
+ return (
92
+ error instanceof Error &&
93
+ "code" in error &&
94
+ error.code === "EADDRINUSE"
95
+ )
96
+ }
97
+
88
98
  function isRecord(value: unknown): value is Record<string, unknown> {
89
99
  return Boolean(value) && typeof value === "object"
90
100
  }
@@ -334,78 +344,108 @@ export async function startDatoolServer(
334
344
  cwd,
335
345
  })
336
346
  const host = options.host ?? config.server?.host ?? "127.0.0.1"
337
- const port = options.port ?? config.server?.port ?? 3210
347
+ const requestedPort = options.port ?? config.server?.port ?? 3210
338
348
  const indexHtml = await loadClientIndexHtml(packageRoot)
339
- const server = Bun.serve({
340
- async fetch(request) {
341
- const url = new URL(request.url)
349
+ const fetch = async (request: Request) => {
350
+ const url = new URL(request.url)
342
351
 
343
- if (url.pathname === "/api/config") {
344
- return jsonResponse(toClientConfig(config))
345
- }
352
+ if (url.pathname === "/api/config") {
353
+ return jsonResponse(toClientConfig(config))
354
+ }
346
355
 
347
- if (
348
- url.pathname.startsWith("/api/streams/") &&
349
- url.pathname.endsWith("/events")
350
- ) {
351
- const streamId = decodeURIComponent(
352
- url.pathname
353
- .slice("/api/streams/".length, -"/events".length)
354
- .replace(/\/+$/, "")
355
- )
356
- const query = new URLSearchParams(url.searchParams)
356
+ if (
357
+ url.pathname.startsWith("/api/streams/") &&
358
+ url.pathname.endsWith("/events")
359
+ ) {
360
+ const streamId = decodeURIComponent(
361
+ url.pathname
362
+ .slice("/api/streams/".length, -"/events".length)
363
+ .replace(/\/+$/, "")
364
+ )
365
+ const query = new URLSearchParams(url.searchParams)
366
+
367
+ query.delete("stream")
357
368
 
358
- query.delete("stream")
369
+ return createSseResponse(config, streamId, query, request.signal)
370
+ }
371
+
372
+ if (
373
+ request.method === "POST" &&
374
+ url.pathname.startsWith("/api/streams/") &&
375
+ url.pathname.includes("/actions/")
376
+ ) {
377
+ const pathMatch = url.pathname.match(
378
+ /^\/api\/streams\/(.+?)\/actions\/(.+?)\/?$/
379
+ )
359
380
 
360
- return createSseResponse(config, streamId, query, request.signal)
381
+ if (!pathMatch) {
382
+ return new Response("Not found", {
383
+ status: 404,
384
+ })
361
385
  }
362
386
 
363
- if (
364
- request.method === "POST" &&
365
- url.pathname.startsWith("/api/streams/") &&
366
- url.pathname.includes("/actions/")
367
- ) {
368
- const pathMatch = url.pathname.match(
369
- /^\/api\/streams\/(.+?)\/actions\/(.+?)\/?$/
370
- )
387
+ const streamId = decodeURIComponent(pathMatch[1] ?? "")
388
+ const actionId = decodeURIComponent(pathMatch[2] ?? "")
389
+ const query = new URLSearchParams(url.searchParams)
371
390
 
372
- if (!pathMatch) {
373
- return new Response("Not found", {
374
- status: 404,
375
- })
376
- }
391
+ query.delete("stream")
392
+
393
+ return createActionResponse(config, streamId, actionId, query, request)
394
+ }
395
+
396
+ if (url.pathname === "/" || url.pathname === "/index.html") {
397
+ return new Response(indexHtml, {
398
+ headers: {
399
+ "Content-Type": "text/html; charset=utf-8",
400
+ },
401
+ })
402
+ }
377
403
 
378
- const streamId = decodeURIComponent(pathMatch[1] ?? "")
379
- const actionId = decodeURIComponent(pathMatch[2] ?? "")
380
- const query = new URLSearchParams(url.searchParams)
404
+ const assetPath = getClientAssetPath(packageRoot, url.pathname)
381
405
 
382
- query.delete("stream")
406
+ if (assetPath && fs.existsSync(assetPath)) {
407
+ return new Response(Bun.file(assetPath))
408
+ }
383
409
 
384
- return createActionResponse(config, streamId, actionId, query, request)
410
+ return new Response("Not found", {
411
+ status: 404,
412
+ })
413
+ }
414
+ const maxPort = requestedPort === 0
415
+ ? 0
416
+ : Math.min(requestedPort + MAX_PORT_ATTEMPTS - 1, 65_535)
417
+ let server: ReturnType<typeof Bun.serve> | null = null
418
+
419
+ for (
420
+ let port = requestedPort;
421
+ port <= maxPort && server === null;
422
+ port += 1
423
+ ) {
424
+ try {
425
+ server = Bun.serve({
426
+ fetch,
427
+ hostname: host,
428
+ port,
429
+ })
430
+ } catch (error) {
431
+ if (isAddressInUseError(error) && port < maxPort) {
432
+ continue
385
433
  }
386
434
 
387
- if (url.pathname === "/" || url.pathname === "/index.html") {
388
- return new Response(indexHtml, {
389
- headers: {
390
- "Content-Type": "text/html; charset=utf-8",
391
- },
392
- })
435
+ if (isAddressInUseError(error)) {
436
+ throw new Error(
437
+ `No available ports from ${requestedPort} to ${maxPort}.`
438
+ )
393
439
  }
394
440
 
395
- const assetPath = getClientAssetPath(packageRoot, url.pathname)
396
-
397
- if (assetPath && fs.existsSync(assetPath)) {
398
- return new Response(Bun.file(assetPath))
399
- }
441
+ throw error
442
+ }
443
+ }
400
444
 
401
- return new Response("Not found", {
402
- status: 404,
403
- })
404
- },
405
- hostname: host,
406
- port,
407
- })
408
- const resolvedPort = server.port ?? port
445
+ if (!server) {
446
+ throw new Error(`No available ports from ${requestedPort} to ${maxPort}.`)
447
+ }
448
+ const resolvedPort = server.port ?? requestedPort
409
449
 
410
450
  return {
411
451
  config,