@reopt-ai/opt-ui 1.11.0 → 1.12.0

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 (73) hide show
  1. package/COMPONENT_CATALOG.md +394 -201
  2. package/README.md +43 -5
  3. package/dist/core/index.cjs +2 -2
  4. package/dist/core/index.d.cts +2 -2
  5. package/dist/core/index.d.ts +2 -2
  6. package/dist/core/index.js +2 -2
  7. package/dist/docs/02-components/01-core.md +150 -84
  8. package/dist/docs/02-components/02-visuals.md +1 -1
  9. package/dist/docs/02-components/03-shells.md +258 -117
  10. package/dist/docs/02-components/04-surfaces.md +1 -1
  11. package/dist/docs/02-components/index.md +5 -5
  12. package/dist/docs/03-recipes/02-dashboards.md +126 -10
  13. package/dist/{field-sidebar-DAuN4jPV.cjs → field-sidebar-BYgEmdy0.cjs} +1539 -1536
  14. package/dist/{field-sidebar-BXl5RJ3q.js → field-sidebar-BuO37l4c.js} +1540 -1539
  15. package/dist/{field-sidebar-B5SQC35J.d.cts → field-sidebar-CrYlRZew.d.ts} +164 -154
  16. package/dist/{field-sidebar-CBeWE8xX.d.ts → field-sidebar-uXmoyvd7.d.cts} +164 -154
  17. package/dist/id-registry.cjs +78 -6
  18. package/dist/id-registry.d.cts +1 -2
  19. package/dist/id-registry.d.ts +1 -2
  20. package/dist/id-registry.js +78 -8
  21. package/dist/id-registry.json +168 -19
  22. package/dist/index.cjs +1127 -224
  23. package/dist/index.d.cts +567 -58
  24. package/dist/index.d.ts +567 -58
  25. package/dist/index.js +1118 -227
  26. package/dist/{key-pad-menu-BxTy_u0Q.d.cts → key-pad-menu-CwcY_Vps.d.cts} +2 -3
  27. package/dist/{key-pad-menu-wXkWn1I1.js → key-pad-menu-Di2aRGbM.js} +4 -6
  28. package/dist/{key-pad-menu-Cg6rZBie.cjs → key-pad-menu-SItoHPLu.cjs} +1 -1
  29. package/dist/{key-pad-menu-u5GTJl9B.d.ts → key-pad-menu-WBJShCJn.d.ts} +3 -4
  30. package/dist/meta.cjs +678 -19
  31. package/dist/meta.d.cts +12 -36
  32. package/dist/meta.d.ts +12 -36
  33. package/dist/meta.js +678 -21
  34. package/dist/query.d.cts +1 -2
  35. package/dist/query.d.ts +1 -2
  36. package/dist/query.js +0 -2
  37. package/dist/shells/index.cjs +1 -1
  38. package/dist/shells/index.d.cts +1 -1
  39. package/dist/shells/index.d.ts +1 -1
  40. package/dist/shells/index.js +1 -1
  41. package/dist/{text-truncate-DW78lg4S.d.cts → text-truncate-CQyfUZvq.d.cts} +31 -3
  42. package/dist/{text-truncate-DW78lg4S.d.ts → text-truncate-CQyfUZvq.d.ts} +31 -3
  43. package/dist/{text-truncate-C17HHCR-.js → text-truncate-DSuZECy0.js} +43 -10
  44. package/dist/{text-truncate-BZdL1k5Z.cjs → text-truncate-I1XLWmRa.cjs} +48 -7
  45. package/dist/theme/server.d.cts +1 -2
  46. package/dist/theme/server.d.ts +1 -2
  47. package/dist/theme/server.js +0 -2
  48. package/dist/visuals/index.js +0 -1
  49. package/package.json +4 -3
  50. package/dist/field-sidebar-B5SQC35J.d.cts.map +0 -1
  51. package/dist/field-sidebar-BXl5RJ3q.js.map +0 -1
  52. package/dist/field-sidebar-CBeWE8xX.d.ts.map +0 -1
  53. package/dist/id-registry.d.cts.map +0 -1
  54. package/dist/id-registry.d.ts.map +0 -1
  55. package/dist/id-registry.js.map +0 -1
  56. package/dist/index.d.cts.map +0 -1
  57. package/dist/index.d.ts.map +0 -1
  58. package/dist/index.js.map +0 -1
  59. package/dist/key-pad-menu-BxTy_u0Q.d.cts.map +0 -1
  60. package/dist/key-pad-menu-u5GTJl9B.d.ts.map +0 -1
  61. package/dist/key-pad-menu-wXkWn1I1.js.map +0 -1
  62. package/dist/meta.d.cts.map +0 -1
  63. package/dist/meta.d.ts.map +0 -1
  64. package/dist/meta.js.map +0 -1
  65. package/dist/query.d.cts.map +0 -1
  66. package/dist/query.d.ts.map +0 -1
  67. package/dist/query.js.map +0 -1
  68. package/dist/text-truncate-C17HHCR-.js.map +0 -1
  69. package/dist/text-truncate-DW78lg4S.d.cts.map +0 -1
  70. package/dist/text-truncate-DW78lg4S.d.ts.map +0 -1
  71. package/dist/theme/server.d.cts.map +0 -1
  72. package/dist/theme/server.d.ts.map +0 -1
  73. package/dist/theme/server.js.map +0 -1
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { $ as PopoverContent$1, $n as OPT_TEXT_PRIMARY, $t as CompositeRow, A as FormRadioGroup$1, An as OPT_BADGE_YELLOW, At as DialogDescription$1, B as FormSwitch$1, Bn as OPT_FOCUS_VISIBLE, Bt as SelectPopover$1, Cn as OPT_ACTIVE_ITEM, Dn as OPT_BADGE_GREEN, Dt as ComboboxRoot$1, En as OPT_BADGE_BLUE, Et as ComboboxPopover$1, F as FormSelectItem, Ft as DialogHeading$1, G as useFormStore, Gn as OPT_SURFACE, Gt as TabPanel$1, H as FormTextarea, Hn as OPT_INPUT, Ht as SelectTrigger$1, I as FormSelectPopover, In as OPT_DOT_GREEN, It as DialogPanel$1, J as TagInput, Jn as OPT_TABLE_ROW_ACTIVE, Jt as DisclosureRoot$1, Kn as OPT_SURFACE_POPOVER, Kt as TabsRoot$1, Ln as OPT_DOT_RED, Lt as DialogRoot$1, Mn as OPT_DISCLOSURE_CONTENT, N as FormRoot$1, Nn as OPT_DISCLOSURE_TRIGGER, On as OPT_BADGE_NEUTRAL, P as FormSelect, Pn as OPT_DIVIDER, Qn as OPT_TEXT_LABEL, Qt as CompositeProvider, Rn as OPT_DOT_YELLOW, Rt as SelectItem$1, T as FormLabel$1, Tn as OPT_ANIMATE_ENTER_EXIT, Tt as ComboboxItem$1, Ut as Tab$1, Vt as SelectRoot$1, Wn as OPT_ITEM_BASE, Wt as TabList$1, Xn as OPT_TD_MUTED, Xt as Composite, Yn as OPT_TD, Yt as DisclosureTrigger$1, Zt as CompositeItem, _t as MenuSeparator$1, an as Alert, ar as Kbd, at as ToolbarContainer$1, bt as MenubarRoot$1, cn as EmptyState, ct as Tooltip$1, dt as MenuButtonArrow$1, er as OPT_TEXT_SECONDARY, et as PopoverRoot$1, ft as MenuItem$1, gt as MenuRoot$1, ht as MenuPopover$1, i as FieldToken, ir as _objectWithoutProperties, it as ToolbarButton$1, jn as OPT_BORDER, k as FormRadio$1, kn as OPT_BADGE_RED, l as OPT_UI_DEFAULT_LOCALE, ln as Button, lt as TooltipAnchor$1, m as FormCheckbox$1, mn as Skeleton, mt as MenuItemRadio$1, n as Highlight, nr as OPT_TH, o as Pagination, on as Spinner, or as cn, ot as ToolbarRoot$1, p as formatOptTime, pn as StatCard, pt as MenuItemCheckbox$1, qt as DisclosureContent$1, rn as Input, rr as _objectSpread2, s as Checkbox, sn as Badge, st as ToolbarSeparator$1, t as TextTruncate, tn as Meter$1, tr as OPT_TEXT_TERTIARY, tt as PopoverTrigger$1, ut as TooltipProvider$1, vt as MenuTrigger$1, w as FormInput$1, wt as ComboboxInput$1, xt as ComboboxEmpty, y as FormError$1, yt as MenubarContainer$1, z as FormSubmit$1, zn as OPT_FOCUS, zt as SelectLabel$1 } from "./text-truncate-C17HHCR-.js";
2
+ import { $n as OPT_TEXT_LABEL, $t as CompositeProvider, A as FormRadio$1, An as OPT_BADGE_RED, B as FormSubmit$1, Bn as OPT_FOCUS, Bt as SelectLabel$1, Dn as OPT_BADGE_BLUE, Dt as ComboboxPopover$1, E as FormLabel$1, En as OPT_ANIMATE_ENTER_EXIT, Et as ComboboxItem$1, F as FormSelect, Fn as OPT_DIVIDER, Gn as OPT_ITEM_BASE, Gt as TabList$1, Ht as SelectRoot$1, I as FormSelectItem, It as DialogHeading$1, Jt as DisclosureContent$1, K as useFormStore, Kn as OPT_SURFACE, Kt as TabPanel$1, L as FormSelectPopover, Ln as OPT_DOT_GREEN, Lt as DialogPanel$1, Mn as OPT_BORDER, Nn as OPT_DISCLOSURE_CONTENT, On as OPT_BADGE_GREEN, Ot as ComboboxRoot$1, P as FormRoot$1, Pn as OPT_DISCLOSURE_TRIGGER, Qt as CompositeItem, Rn as OPT_DOT_RED, Rt as DialogRoot$1, St as ComboboxEmpty, T as FormInput$1, Tt as ComboboxInput$1, U as FormTextarea, Un as OPT_INPUT, Ut as SelectTrigger$1, V as FormSwitch$1, Vn as OPT_FOCUS_VISIBLE, Vt as SelectPopover$1, Wt as Tab$1, Xn as OPT_TD, Xt as DisclosureTrigger$1, Y as TagInput, Yn as OPT_TABLE_ROW_ACTIVE, Yt as DisclosureRoot$1, Zn as OPT_TD_MUTED, Zt as Composite, _t as MenuRoot$1, ar as _objectWithoutProperties, at as ToolbarButton$1, b as FormError$1, bt as MenubarContainer$1, c as Checkbox, cn as Badge, ct as ToolbarSeparator$1, dt as TooltipProvider$1, en as CompositeRow, er as OPT_TEXT_PRIMARY, et as PopoverContent$1, ft as MenuButtonArrow$1, gt as MenuPopover$1, h as FormCheckbox$1, hn as Skeleton, ht as MenuItemRadio$1, i as FieldToken, in as Input, ir as _objectSpread2, j as FormRadioGroup$1, jn as OPT_BADGE_YELLOW, jt as DialogDescription$1, kn as OPT_BADGE_NEUTRAL, ln as EmptyState, lt as Tooltip$1, m as formatOptTime, mn as StatCard, mt as MenuItemCheckbox$1, n as Highlight, nn as Meter$1, nr as OPT_TEXT_TERTIARY, nt as PopoverTrigger$1, on as Alert, or as Kbd, ot as ToolbarContainer$1, pt as MenuItem$1, qn as OPT_SURFACE_POPOVER, qt as TabsRoot$1, rr as OPT_TH, s as Pagination, sn as Spinner, sr as cn, st as ToolbarRoot$1, t as TextTruncate, tr as OPT_TEXT_SECONDARY, tt as PopoverRoot$1, u as OPT_UI_DEFAULT_LOCALE, un as Button, ut as TooltipAnchor$1, vt as MenuSeparator$1, wn as OPT_ACTIVE_ITEM, xt as MenubarRoot$1, yt as MenuTrigger$1, zn as OPT_DOT_YELLOW, zt as SelectItem$1 } from "./text-truncate-DSuZECy0.js";
3
3
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
4
  import * as React from "react";
5
5
  import { Fragment as Fragment$1, createContext, forwardRef, memo, useCallback, useContext, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
@@ -1474,7 +1474,7 @@ function DataTableProgressBar({ value, barColor, label }) {
1474
1474
  });
1475
1475
  }
1476
1476
  /** Renders the data table component. */
1477
- function DataTable({ data, columns, keyExtractor, title = "Data Table", onRowClick, emptyMessage = "No data available", activeRowId, sortable = false, selectable = false, selectedKeys, onSelectionChange, expandable = false, renderExpanded, pageSize, stickyHeader = false, resizableColumns = false, onSort: onSortExternal, className }) {
1477
+ function DataTable({ data, columns, keyExtractor, title, ariaLabel, onRowClick, emptyMessage = "No data available", activeRowId, sortable = false, selectable = false, selectedKeys, onSelectionChange, expandable = false, renderExpanded, pageSize, stickyHeader = false, resizableColumns = false, onSort: onSortExternal, className }) {
1478
1478
  const [sortCol, setSortCol] = useState(null);
1479
1479
  const [sortDir, setSortDir] = useState(null);
1480
1480
  const [expandedKeys, setExpandedKeys] = useState(/* @__PURE__ */ new Set());
@@ -1568,8 +1568,8 @@ function DataTable({ data, columns, keyExtractor, title = "Data Table", onRowCli
1568
1568
  focusWrap: true,
1569
1569
  children: /* @__PURE__ */ jsxs(Composite, {
1570
1570
  role: "grid",
1571
- "aria-label": title,
1572
- className: "w-full",
1571
+ "aria-label": title !== null && title !== void 0 ? title : ariaLabel,
1572
+ className: "w-full min-w-max",
1573
1573
  render: /* @__PURE__ */ jsx("table", {}),
1574
1574
  children: [/* @__PURE__ */ jsx("thead", {
1575
1575
  className: cn("border-border-subtle bg-surface-inset border-b", stickyHeader && "sticky top-0 z-10"),
@@ -1600,7 +1600,7 @@ function DataTable({ data, columns, keyExtractor, title = "Data Table", onRowCli
1600
1600
  tabIndex: isSortable ? 0 : void 0,
1601
1601
  "aria-sort": sortCol === col.id && sortDir ? sortDir === "asc" ? "ascending" : "descending" : void 0,
1602
1602
  children: [/* @__PURE__ */ jsxs("span", {
1603
- className: "flex items-center",
1603
+ className: "flex items-center whitespace-nowrap",
1604
1604
  children: [col.header, sortIndicator(col.id)]
1605
1605
  }), resizableColumns && /* @__PURE__ */ jsx("span", {
1606
1606
  role: "separator",
@@ -1664,7 +1664,7 @@ function DataTable({ data, columns, keyExtractor, title = "Data Table", onRowCli
1664
1664
  className: cn("px-3 py-2.5 text-sm text-text-primary whitespace-nowrap", "w-10"),
1665
1665
  role: "gridcell"
1666
1666
  }),
1667
- className: "outline-none data-[active-item]:bg-accent-subtle data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:-outline-offset-1",
1667
+ className: "outline-none data-[focus-visible]:bg-accent-subtle data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:-outline-offset-1",
1668
1668
  children: /* @__PURE__ */ jsx(Checkbox, {
1669
1669
  checked: isSelected,
1670
1670
  onChange: () => toggleSelectRow(key),
@@ -1676,7 +1676,7 @@ function DataTable({ data, columns, keyExtractor, title = "Data Table", onRowCli
1676
1676
  className: cn("px-3 py-2.5 text-sm text-text-primary whitespace-nowrap", "w-10"),
1677
1677
  role: "gridcell"
1678
1678
  }),
1679
- className: "outline-none data-[active-item]:bg-accent-subtle data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:-outline-offset-1",
1679
+ className: "outline-none data-[focus-visible]:bg-accent-subtle data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:-outline-offset-1",
1680
1680
  children: /* @__PURE__ */ jsx("button", {
1681
1681
  type: "button",
1682
1682
  onClick: (e) => {
@@ -2949,1651 +2949,1654 @@ function Flyout({ open, onClose, title, description, type = "overlay", side = "r
2949
2949
  }
2950
2950
  Flyout.displayName = "Flyout";
2951
2951
  //#endregion
2952
- //#region src/lib/use-auto-refresh.ts
2952
+ //#region src/lib/query/query-ast.ts
2953
+ const EMPTY_QUERY = { clauses: [] };
2954
+ /** Whether a query constrains anything at all. */
2955
+ function isEmptyQuery(query) {
2956
+ return !query || query.clauses.length === 0;
2957
+ }
2958
+ function sameClause(a, b) {
2959
+ if (a.type !== b.type) return false;
2960
+ if (a.type === "term" && b.type === "term") return a.value === b.value && a.negated === b.negated;
2961
+ const fieldA = a;
2962
+ const fieldB = b;
2963
+ return fieldA.field === fieldB.field && fieldA.operator === fieldB.operator && fieldA.negated === fieldB.negated && fieldA.values.length === fieldB.values.length && fieldA.values.every((value, index) => value === fieldB.values[index]);
2964
+ }
2965
+ /** Add a clause unless an identical one is already there. */
2966
+ function addClause(query, clause) {
2967
+ if (query.clauses.some((existing) => sameClause(existing, clause))) return query;
2968
+ return { clauses: [...query.clauses, clause] };
2969
+ }
2953
2970
  /**
2954
- * A refresh timer that waits for its own work.
2971
+ * Constrain a field to a value — what a cell's "filter for this" action does.
2955
2972
  *
2956
- * A plain `setInterval` fires on a schedule regardless of whether the previous
2957
- * query came back, so a five-second interval over a seven-second query stacks
2958
- * requests until something gives. Chaining the next wait onto the settled
2959
- * promise keeps exactly one refresh in flight, and a rejection stops the loop
2960
- * instead of hammering an endpoint that is already failing.
2973
+ * Applying the opposite of a constraint already present replaces it rather than
2974
+ * adding it: `status:200` then "filter out 200" should read as one changed
2975
+ * mind, not as a query that can never match.
2961
2976
  */
2962
- function useAutoRefresh({ interval, paused = false, onRefresh }) {
2963
- const callbackRef = useRef(onRefresh);
2964
- callbackRef.current = onRefresh;
2965
- useEffect(() => {
2966
- if (paused || interval <= 0 || !callbackRef.current) return;
2967
- let cancelled = false;
2968
- let timer;
2969
- const schedule = () => {
2970
- timer = setTimeout(async () => {
2971
- if (cancelled) return;
2972
- try {
2973
- var _callbackRef$current;
2974
- await ((_callbackRef$current = callbackRef.current) === null || _callbackRef$current === void 0 ? void 0 : _callbackRef$current.call(callbackRef));
2975
- } catch (_unused) {
2976
- return;
2977
- }
2978
- if (!cancelled) schedule();
2979
- }, interval);
2980
- };
2981
- schedule();
2982
- return () => {
2983
- cancelled = true;
2984
- if (timer) clearTimeout(timer);
2985
- };
2986
- }, [interval, paused]);
2977
+ function withFieldValue(query, field, value, options = {}) {
2978
+ var _options$negated, _options$operator;
2979
+ const negated = (_options$negated = options.negated) !== null && _options$negated !== void 0 ? _options$negated : false;
2980
+ const operator = (_options$operator = options.operator) !== null && _options$operator !== void 0 ? _options$operator : "eq";
2981
+ return { clauses: [...query.clauses.filter((clause) => !(clause.type === "field" && clause.field === field && clause.operator === operator && clause.values.length === 1 && clause.values[0] === value)), {
2982
+ type: "field",
2983
+ field,
2984
+ operator,
2985
+ values: [value],
2986
+ negated
2987
+ }] };
2987
2988
  }
2988
- //#endregion
2989
- //#region src/shells/time-range-control.tsx
2990
- const defaultLabels$4 = {
2991
- trigger: "Select time range",
2992
- invalid: "Invalid time range",
2993
- last: (duration) => `Last ${duration}`,
2994
- next: (duration) => `Next ${duration}`,
2995
- quickSelect: "Quick select",
2996
- apply: "Apply",
2997
- update: "Update",
2998
- refresh: "Refresh",
2999
- commonlyUsed: "Commonly used",
3000
- recentlyUsed: "Recently used",
3001
- start: "Start",
3002
- end: "End",
3003
- startHint: "Absolute time or date math, e.g. now-7d",
3004
- autoRefresh: "Auto refresh",
3005
- autoRefreshOff: "Off",
3006
- every: (duration) => `Every ${duration}`,
3007
- previous: "Previous window",
3008
- nextWindow: "Next window",
3009
- zoomIn: "Zoom in",
3010
- zoomOut: "Zoom out",
3011
- thisUnit: (unit) => `This ${unit}`,
3012
- units: {
3013
- s: "seconds",
3014
- m: "minutes",
3015
- h: "hours",
3016
- d: "days",
3017
- w: "weeks",
3018
- M: "months",
3019
- y: "years"
3020
- }
3021
- };
3022
- const QUICK_UNITS = [
3023
- "m",
3024
- "h",
3025
- "d",
3026
- "w",
3027
- "M",
3028
- "y"
3029
- ];
3030
- const REFRESH_INTERVALS = [
3031
- 0,
3032
- 5e3,
3033
- 1e4,
3034
- 3e4,
3035
- 6e4,
3036
- 3e5
3037
- ];
3038
- function selectValue(value) {
3039
- var _value$;
3040
- return Array.isArray(value) ? (_value$ = value[0]) !== null && _value$ !== void 0 ? _value$ : "" : value;
2989
+ /** Drop every clause naming `field`. */
2990
+ function withoutField(query, field) {
2991
+ return { clauses: query.clauses.filter((clause) => clause.type !== "field" || clause.field !== field) };
2992
+ }
2993
+ /** Drop the clause at `index`, for a removable chip. */
2994
+ function removeClause(query, index) {
2995
+ return { clauses: query.clauses.filter((_, at) => at !== index) };
2996
+ }
2997
+ /** Every field the query names, in the order they first appear. */
2998
+ function queryFields(query) {
2999
+ const seen = [];
3000
+ for (const clause of query.clauses) if (clause.type === "field" && !seen.includes(clause.field)) seen.push(clause.field);
3001
+ return seen;
3002
+ }
3003
+ /** The values a field is constrained to, if any — for showing a filter as active. */
3004
+ function fieldValues(query, field) {
3005
+ return query.clauses.flatMap((clause) => clause.type === "field" && clause.field === field && !clause.negated ? clause.values : []);
3041
3006
  }
3042
3007
  /**
3043
- * What the trigger says.
3008
+ * Fold a cell action into the query.
3044
3009
  *
3045
- * A window anchored to `now` deserves its intent ("Last 7 days"), not two
3046
- * timestamps the reader has to subtract. Everything else gets both ends
3047
- * spelled out. A duration that does not divide into whole minutes is prefixed
3048
- * with `~` rather than rounded silently.
3010
+ * The pair of buttons on a log cell is the fastest path from "I noticed this
3011
+ * value" to a filtered view, and the reason it works is that the result is a
3012
+ * query the reader can then see, edit and share not a hidden filter stack
3013
+ * with its own rules.
3049
3014
  */
3050
- function describeRange(range, labels, options) {
3051
- const window = measureTimeWindow(range, {
3052
- timeZone: options.timeZone,
3053
- now: options.now
3054
- });
3055
- if (window.isInvalid) return {
3056
- text: labels.invalid,
3057
- isInvalid: true
3058
- };
3059
- const fromParts = parseRelativeParts(range.from);
3060
- const toParts = parseRelativeParts(range.to);
3061
- if ((fromParts === null || fromParts === void 0 ? void 0 : fromParts.roundUnit) && fromParts.count === 0 && (toParts === null || toParts === void 0 ? void 0 : toParts.roundUnit) === fromParts.roundUnit && toParts.count === 0) return {
3062
- text: labels.thisUnit(formatCountUnit(1, fromParts.roundUnit, options.locale).replace(/^1\s*/, "")),
3063
- isInvalid: false
3064
- };
3065
- if (isRelativeToNow(range)) {
3066
- var _toParts$count;
3067
- const anchoredAtEnd = ((_toParts$count = toParts === null || toParts === void 0 ? void 0 : toParts.count) !== null && _toParts$count !== void 0 ? _toParts$count : 0) === 0;
3068
- const parts = anchoredAtEnd ? fromParts : toParts;
3069
- const bothSnapped = (parts === null || parts === void 0 ? void 0 : parts.roundUnit) === (parts === null || parts === void 0 ? void 0 : parts.unit) && (toParts === null || toParts === void 0 ? void 0 : toParts.roundUnit) === (parts === null || parts === void 0 ? void 0 : parts.unit);
3070
- const counted = parts && parts.count > 0 ? formatCountUnit(parts.roundUnit && bothSnapped ? parts.count + 1 : parts.count, parts.unit, options.locale) : null;
3071
- const exact = counted !== null && (!(parts === null || parts === void 0 ? void 0 : parts.roundUnit) || bothSnapped);
3072
- const duration = exact ? counted : formatDuration(window.duration, options.locale);
3073
- const prefix = exact || isExactMinutes(window.duration) ? "" : "~";
3074
- return {
3075
- text: anchoredAtEnd ? labels.last(`${prefix}${duration}`) : labels.next(`${prefix}${duration}`),
3076
- isInvalid: false
3077
- };
3078
- }
3079
- const dateOnly = isDateOnly(range.from) && isDateOnly(range.to);
3080
- const from = formatInstant(window.from, _objectSpread2(_objectSpread2({}, options), {}, { dateOnly }));
3081
- const to = formatInstant(window.to, _objectSpread2(_objectSpread2({}, options), {}, { dateOnly }));
3082
- return {
3083
- text: from === to ? from : `${from} – ${to}`,
3084
- isInvalid: false
3085
- };
3015
+ function applyCellAction(query, action) {
3016
+ return withFieldValue(query, action.field, action.value, { negated: action.intent === "exclude" });
3086
3017
  }
3018
+ //#endregion
3019
+ //#region src/lib/query/query-parse.ts
3020
+ const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
3087
3021
  /**
3088
- * TimeRangeControl the window as an expression, not two frozen instants.
3089
- *
3090
- * Holding `{ from: "now-7d", to: "now" }` instead of a resolved pair is what
3091
- * makes a saved board, a shared link and an auto-refresh timer all mean the
3092
- * same thing tomorrow that they mean today. Around that sit the moves people
3093
- * actually make on a chart: jump to a named window, step the same width back
3094
- * and forth to compare, widen when the interesting thing is just off screen,
3095
- * and let it re-query on a timer while they watch.
3096
- *
3097
- * @example
3098
- * ```tsx
3099
- * const [range, setRange] = useState<TimeRange>({ from: "now-7d", to: "now" });
3022
+ * Split on whitespace, but keep quoted runs and parenthesised groups whole.
3100
3023
  *
3101
- * <TimeRangeControl
3102
- * value={range}
3103
- * onChange={setRange}
3104
- * timeZone="Asia/Seoul"
3105
- * showWindowButtons
3106
- * onRefresh={() => refetch()}
3107
- * />
3108
- * ```
3024
+ * An unterminated quote or group swallows the rest of the line rather than
3025
+ * erroring: the reader is mid-word, and the alternative is the query flickering
3026
+ * between two different meanings as they type the closing character.
3109
3027
  */
3110
- function TimeRangeControl({ value, onChange, presets, recentRanges, timeZone, locale, showUpdateButton = true, showWindowButtons = true, zoomFactor = .5, refresh, onRefreshChange, onRefresh, isLoading = false, disabled = false, className, labels: customLabels, "aria-label": ariaLabel }) {
3111
- var _refresh$interval, _refresh$paused, _refresh$interval2;
3112
- const labels = _objectSpread2(_objectSpread2(_objectSpread2({}, defaultLabels$4), customLabels), {}, { units: _objectSpread2(_objectSpread2({}, defaultLabels$4.units), customLabels === null || customLabels === void 0 ? void 0 : customLabels.units) });
3113
- const [open, setOpen] = useState(false);
3114
- const [draft, setDraft] = useState(null);
3115
- const [quickCount, setQuickCount] = useState("15");
3116
- const [quickUnit, setQuickUnit] = useState("m");
3117
- const editing = draft !== null && draft !== void 0 ? draft : value;
3118
- const hasChanges = draft !== null && (draft.from !== value.from || draft.to !== value.to);
3119
- const description = useMemo(() => describeRange(value, labels, {
3120
- locale,
3121
- timeZone
3122
- }), [
3123
- value,
3124
- locale,
3125
- timeZone,
3126
- customLabels
3127
- ]);
3128
- const boundState = useCallback((expression) => parseTimeExpression(expression, { timeZone }) === null ? labels.invalid : void 0, [timeZone, labels]);
3129
- const commit = useCallback((range) => {
3130
- setDraft(null);
3131
- onChange(range);
3132
- }, [onChange]);
3133
- const apply = useCallback(() => {
3134
- if (draft) commit(draft);
3135
- else onRefresh === null || onRefresh === void 0 || onRefresh();
3136
- }, [
3137
- draft,
3138
- commit,
3139
- onRefresh
3140
- ]);
3141
- const commitOnEnter = useCallback((event) => {
3142
- if (event.key !== "Enter") return;
3143
- apply();
3144
- setOpen(false);
3145
- }, [apply]);
3146
- const applyQuick = useCallback(() => {
3147
- const count = Number(quickCount);
3148
- if (!Number.isFinite(count) || count <= 0) return;
3149
- commit({
3150
- from: `now-${Math.round(count)}${quickUnit}`,
3151
- to: "now"
3152
- });
3153
- setOpen(false);
3154
- }, [
3155
- quickCount,
3156
- quickUnit,
3157
- commit
3158
- ]);
3159
- const step = useCallback((direction) => {
3160
- const next = shiftTimeRange(value, direction, { timeZone });
3161
- if (next) commit(next);
3162
- }, [
3163
- value,
3164
- timeZone,
3165
- commit
3166
- ]);
3167
- const zoom = useCallback((factor) => {
3168
- const next = zoomTimeRange(value, factor, { timeZone });
3169
- if (next) commit(next);
3170
- }, [
3171
- value,
3172
- timeZone,
3173
- commit
3174
- ]);
3175
- useAutoRefresh({
3176
- interval: (_refresh$interval = refresh === null || refresh === void 0 ? void 0 : refresh.interval) !== null && _refresh$interval !== void 0 ? _refresh$interval : 0,
3177
- paused: (_refresh$paused = refresh === null || refresh === void 0 ? void 0 : refresh.paused) !== null && _refresh$paused !== void 0 ? _refresh$paused : true,
3178
- onRefresh
3179
- });
3180
- const refreshLabel = !refresh || refresh.paused || refresh.interval === 0 ? labels.autoRefreshOff : labels.every(formatDuration(refresh.interval, locale));
3181
- return /* @__PURE__ */ jsxs("div", {
3182
- className: cn("bg-surface inline-flex items-center rounded-lg border", OPT_BORDER, className),
3183
- role: "group",
3184
- "aria-label": ariaLabel !== null && ariaLabel !== void 0 ? ariaLabel : labels.trigger,
3185
- "data-opt-id": "3WJ8H",
3186
- "data-opt-slug": "time-range-control.TimeRangeControl",
3187
- children: [
3188
- /* @__PURE__ */ jsxs(PopoverRoot$1, {
3189
- open,
3190
- onOpenChange: setOpen,
3191
- children: [/* @__PURE__ */ jsxs(PopoverTrigger$1, {
3192
- className: cn("text-text-primary h-10 gap-2", description.isInvalid && "text-danger-fg"),
3193
- disabled,
3194
- children: [/* @__PURE__ */ jsx(Clock, {
3195
- className: "text-text-tertiary h-4 w-4",
3196
- "aria-hidden": "true"
3197
- }), /* @__PURE__ */ jsx("span", {
3198
- className: "truncate text-sm",
3199
- children: description.text
3200
- })]
3201
- }), /* @__PURE__ */ jsxs(PopoverContent$1, {
3202
- className: "gap-group flex w-80 flex-col",
3203
- children: [
3204
- /* @__PURE__ */ jsxs("div", {
3205
- className: "gap-element flex flex-col",
3206
- children: [/* @__PURE__ */ jsx("p", {
3207
- className: "text-text-primary text-xs font-medium",
3208
- children: labels.quickSelect
3209
- }), /* @__PURE__ */ jsxs("div", {
3210
- className: "flex items-end gap-2",
3211
- children: [
3212
- /* @__PURE__ */ jsx(Input, {
3213
- "aria-label": labels.quickSelect,
3214
- type: "number",
3215
- min: 1,
3216
- size: "sm",
3217
- className: "w-20",
3218
- value: quickCount,
3219
- onChange: (event) => setQuickCount(event.target.value),
3220
- onKeyDown: (event) => {
3221
- if (event.key === "Enter") applyQuick();
3222
- }
3223
- }),
3224
- /* @__PURE__ */ jsxs(SelectRoot$1, {
3225
- value: quickUnit,
3226
- setValue: (next) => setQuickUnit(selectValue(next)),
3227
- children: [/* @__PURE__ */ jsx(SelectTrigger$1, {
3228
- minWidth: 110,
3229
- "aria-label": labels.quickSelect
3230
- }), /* @__PURE__ */ jsx(SelectPopover$1, { children: QUICK_UNITS.map((unit) => /* @__PURE__ */ jsx(SelectItem$1, {
3231
- value: unit,
3232
- children: labels.units[unit]
3233
- }, unit)) })]
3234
- }),
3235
- /* @__PURE__ */ jsx(Button, {
3236
- variant: "secondary",
3237
- size: "sm",
3238
- onClick: applyQuick,
3239
- children: labels.apply
3240
- })
3241
- ]
3242
- })]
3243
- }),
3244
- /* @__PURE__ */ jsxs("div", {
3245
- className: "gap-element flex flex-col",
3246
- children: [/* @__PURE__ */ jsx(Input, {
3247
- label: labels.start,
3248
- size: "sm",
3249
- value: editing.from,
3250
- error: boundState(editing.from),
3251
- hint: labels.startHint,
3252
- onChange: (event) => setDraft(_objectSpread2(_objectSpread2({}, editing), {}, { from: event.target.value })),
3253
- onKeyDown: commitOnEnter
3254
- }), /* @__PURE__ */ jsx(Input, {
3255
- label: labels.end,
3256
- size: "sm",
3257
- value: editing.to,
3258
- error: boundState(editing.to),
3259
- onChange: (event) => setDraft(_objectSpread2(_objectSpread2({}, editing), {}, { to: event.target.value })),
3260
- onKeyDown: commitOnEnter
3261
- })]
3262
- }),
3263
- presets && presets.length > 0 && /* @__PURE__ */ jsx(RangeList, {
3264
- title: labels.commonlyUsed,
3265
- items: presets,
3266
- onPick: (range) => {
3267
- commit(range);
3268
- setOpen(false);
3269
- }
3270
- }),
3271
- recentRanges && recentRanges.length > 0 && /* @__PURE__ */ jsx(RangeList, {
3272
- title: labels.recentlyUsed,
3273
- items: recentRanges.slice(0, 5),
3274
- onPick: (range) => {
3275
- commit(range);
3276
- setOpen(false);
3277
- }
3278
- }),
3279
- onRefreshChange && /* @__PURE__ */ jsxs("div", {
3280
- className: "gap-element flex flex-col",
3281
- children: [/* @__PURE__ */ jsx("p", {
3282
- className: "text-text-primary text-xs font-medium",
3283
- children: labels.autoRefresh
3284
- }), /* @__PURE__ */ jsxs(SelectRoot$1, {
3285
- value: String((refresh === null || refresh === void 0 ? void 0 : refresh.paused) ? 0 : (_refresh$interval2 = refresh === null || refresh === void 0 ? void 0 : refresh.interval) !== null && _refresh$interval2 !== void 0 ? _refresh$interval2 : 0),
3286
- setValue: (next) => {
3287
- const interval = Number(selectValue(next));
3288
- onRefreshChange({
3289
- interval,
3290
- paused: interval === 0
3291
- });
3292
- },
3293
- children: [/* @__PURE__ */ jsx(SelectTrigger$1, {
3294
- minWidth: 140,
3295
- "aria-label": labels.autoRefresh
3296
- }), /* @__PURE__ */ jsx(SelectPopover$1, { children: REFRESH_INTERVALS.map((interval) => /* @__PURE__ */ jsx(SelectItem$1, {
3297
- value: String(interval),
3298
- children: interval === 0 ? labels.autoRefreshOff : labels.every(formatDuration(interval, locale))
3299
- }, interval)) })]
3300
- })]
3301
- })
3302
- ]
3303
- })]
3304
- }),
3305
- showWindowButtons && /* @__PURE__ */ jsxs("div", {
3306
- className: cn("flex items-center border-l", "border border-border"),
3307
- role: "group",
3308
- "aria-label": labels.previous,
3309
- children: [
3310
- /* @__PURE__ */ jsx(IconButton, {
3311
- label: labels.previous,
3312
- disabled: disabled || description.isInvalid,
3313
- onClick: () => step(-1),
3314
- children: /* @__PURE__ */ jsx(ChevronLeft, {
3315
- className: "h-4 w-4",
3316
- "aria-hidden": "true"
3317
- })
3318
- }),
3319
- /* @__PURE__ */ jsx(IconButton, {
3320
- label: labels.zoomIn,
3321
- disabled: disabled || description.isInvalid,
3322
- onClick: () => zoom(-zoomFactor),
3323
- children: /* @__PURE__ */ jsx(ZoomIn, {
3324
- className: "h-4 w-4",
3325
- "aria-hidden": "true"
3326
- })
3327
- }),
3328
- /* @__PURE__ */ jsx(IconButton, {
3329
- label: labels.zoomOut,
3330
- disabled: disabled || description.isInvalid,
3331
- onClick: () => zoom(zoomFactor),
3332
- children: /* @__PURE__ */ jsx(ZoomOut, {
3333
- className: "h-4 w-4",
3334
- "aria-hidden": "true"
3335
- })
3336
- }),
3337
- /* @__PURE__ */ jsx(IconButton, {
3338
- label: labels.nextWindow,
3339
- disabled: disabled || description.isInvalid,
3340
- onClick: () => step(1),
3341
- children: /* @__PURE__ */ jsx(ChevronRight, {
3342
- className: "h-4 w-4",
3343
- "aria-hidden": "true"
3344
- })
3345
- })
3346
- ]
3347
- }),
3348
- (onRefresh || showUpdateButton) && /* @__PURE__ */ jsx("div", {
3349
- className: cn("border-l pl-2 pr-2", "border border-border"),
3350
- children: /* @__PURE__ */ jsx(Button, {
3351
- variant: hasChanges ? "primary" : "secondary",
3352
- size: "sm",
3353
- loading: isLoading,
3354
- disabled: disabled || description.isInvalid,
3355
- onClick: apply,
3356
- leftIcon: hasChanges ? void 0 : /* @__PURE__ */ jsx(RefreshCw, {
3357
- className: "h-3.5 w-3.5",
3358
- "aria-hidden": "true"
3359
- }),
3360
- title: refreshLabel,
3361
- children: hasChanges ? labels.update : labels.refresh
3362
- })
3363
- })
3364
- ]
3365
- });
3028
+ function tokenize(input) {
3029
+ const tokens = [];
3030
+ let index = 0;
3031
+ while (index < input.length) {
3032
+ while (index < input.length && /\s/.test(input[index])) index += 1;
3033
+ if (index >= input.length) break;
3034
+ const start = index;
3035
+ let text = "";
3036
+ let quoted = false;
3037
+ let atTokenStart = true;
3038
+ let depth = 0;
3039
+ while (index < input.length) {
3040
+ const char = input[index];
3041
+ if (char === "\"") {
3042
+ if (atTokenStart) quoted = true;
3043
+ atTokenStart = false;
3044
+ index += 1;
3045
+ while (index < input.length && input[index] !== "\"") {
3046
+ text += input[index];
3047
+ index += 1;
3048
+ }
3049
+ index += 1;
3050
+ continue;
3051
+ }
3052
+ if (char === "(") depth += 1;
3053
+ if (char === ")") depth = Math.max(0, depth - 1);
3054
+ if (/\s/.test(char) && depth === 0) break;
3055
+ atTokenStart = false;
3056
+ text += char;
3057
+ index += 1;
3058
+ }
3059
+ if (text.length > 0 || quoted) tokens.push({
3060
+ text,
3061
+ at: start,
3062
+ quoted
3063
+ });
3064
+ }
3065
+ return tokens;
3366
3066
  }
3367
- TimeRangeControl.displayName = "TimeRangeControl";
3368
- function IconButton({ label, disabled, onClick, children }) {
3369
- return /* @__PURE__ */ jsx(Button, {
3370
- variant: "ghost",
3371
- size: "sm",
3372
- "aria-label": label,
3373
- title: label,
3374
- disabled,
3375
- onClick,
3376
- children
3377
- });
3067
+ /** `(a or b)` → `["a", "b"]`; anything else is a single value. */
3068
+ function readValues(raw) {
3069
+ const trimmed = raw.trim();
3070
+ if (!trimmed.startsWith("(")) return [stripQuotes(trimmed)];
3071
+ return trimmed.replace(/^\(/, "").replace(/\)$/, "").split(/\s+or\s+/i).map((part) => stripQuotes(part.trim())).filter((part) => part.length > 0);
3378
3072
  }
3379
- function RangeList({ title, items, onPick }) {
3380
- return /* @__PURE__ */ jsxs("div", {
3381
- className: "gap-element flex flex-col",
3382
- children: [/* @__PURE__ */ jsx("p", {
3383
- className: "text-text-primary text-xs font-medium",
3384
- children: title
3385
- }), /* @__PURE__ */ jsx("ul", {
3386
- className: "grid grid-cols-2 gap-1",
3387
- children: items.map((item, index) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("button", {
3388
- type: "button",
3389
- className: cn("hover:bg-bg-subtle w-full cursor-pointer rounded px-2 py-1 text-left text-xs", OPT_TEXT_SECONDARY),
3390
- onClick: () => onPick(item.range),
3391
- children: item.label
3392
- }) }, `${item.label}-${index}`))
3393
- })]
3394
- });
3073
+ function stripQuotes(value) {
3074
+ if (value.length >= 2 && value.startsWith("\"") && value.endsWith("\"")) return value.slice(1, -1);
3075
+ return value.replace(/^"/, "");
3395
3076
  }
3396
- /**
3397
- * The windows most analytics tools open with. Exported so a caller gets a
3398
- * sensible list without inventing date math of their own.
3399
- */
3400
- const DEFAULT_TIME_PRESETS = [
3077
+ const OPERATORS = [
3401
3078
  {
3402
- label: "Last 15 minutes",
3403
- range: {
3404
- from: "now-15m",
3405
- to: "now"
3406
- }
3079
+ symbol: ">=",
3080
+ operator: "gte"
3407
3081
  },
3408
3082
  {
3409
- label: "Last hour",
3410
- range: {
3411
- from: "now-1h",
3412
- to: "now"
3413
- }
3083
+ symbol: "<=",
3084
+ operator: "lte"
3414
3085
  },
3415
3086
  {
3416
- label: "Last 24 hours",
3417
- range: {
3418
- from: "now-24h",
3419
- to: "now"
3420
- }
3087
+ symbol: ":",
3088
+ operator: "eq"
3421
3089
  },
3422
3090
  {
3423
- label: "Last 7 days",
3424
- range: {
3425
- from: "now-7d",
3426
- to: "now"
3427
- }
3091
+ symbol: ">",
3092
+ operator: "gt"
3428
3093
  },
3429
3094
  {
3430
- label: "Last 30 days",
3431
- range: {
3432
- from: "now-30d",
3433
- to: "now"
3095
+ symbol: "<",
3096
+ operator: "lt"
3097
+ }
3098
+ ];
3099
+ /** Where a field operator starts in the token, or `-1`. */
3100
+ function findOperator(text) {
3101
+ let best = null;
3102
+ for (const { symbol, operator } of OPERATORS) {
3103
+ const index = text.indexOf(symbol);
3104
+ if (index <= 0) continue;
3105
+ if (!best || index < best.index || index === best.index && symbol.length > best.symbol.length) best = {
3106
+ index,
3107
+ symbol,
3108
+ operator
3109
+ };
3110
+ }
3111
+ return best;
3112
+ }
3113
+ /** Parse, keeping whatever could not be understood as a plain term. */
3114
+ function parseQueryWithDiagnostics(input) {
3115
+ const diagnostics = [];
3116
+ const clauses = [];
3117
+ let trailing = null;
3118
+ for (const token of tokenize(input)) {
3119
+ let text = token.text;
3120
+ let negated = false;
3121
+ if (!token.quoted && text.startsWith("-") && text.length > 1) {
3122
+ negated = true;
3123
+ text = text.slice(1);
3434
3124
  }
3435
- },
3436
- {
3437
- label: "Last 90 days",
3438
- range: {
3439
- from: "now-90d",
3440
- to: "now"
3125
+ const operator = token.quoted ? null : findOperator(text);
3126
+ if (!operator) {
3127
+ if (text.length > 0) clauses.push({
3128
+ type: "term",
3129
+ value: text,
3130
+ negated
3131
+ });
3132
+ continue;
3441
3133
  }
3442
- },
3443
- {
3444
- label: "Today",
3445
- range: {
3446
- from: "now/d",
3447
- to: "now/d"
3134
+ const field = text.slice(0, operator.index);
3135
+ const rawValue = text.slice(operator.index + operator.symbol.length);
3136
+ if (!FIELD_NAME.test(field)) {
3137
+ clauses.push({
3138
+ type: "term",
3139
+ value: text,
3140
+ negated
3141
+ });
3142
+ continue;
3448
3143
  }
3449
- },
3450
- {
3451
- label: "This week",
3452
- range: {
3453
- from: "now/w",
3454
- to: "now/w"
3144
+ if (rawValue === "") {
3145
+ trailing = {
3146
+ field,
3147
+ operator: operator.operator,
3148
+ value: ""
3149
+ };
3150
+ continue;
3151
+ }
3152
+ if (rawValue === "*" && operator.operator === "eq") {
3153
+ clauses.push({
3154
+ type: "field",
3155
+ field,
3156
+ operator: "exists",
3157
+ values: [],
3158
+ negated
3159
+ });
3160
+ continue;
3161
+ }
3162
+ if (operator.operator === "eq" && rawValue.length > 2 && rawValue.startsWith("*") && rawValue.endsWith("*")) {
3163
+ clauses.push({
3164
+ type: "field",
3165
+ field,
3166
+ operator: "contains",
3167
+ values: [stripQuotes(rawValue.slice(1, -1))],
3168
+ negated
3169
+ });
3170
+ continue;
3455
3171
  }
3172
+ const values = readValues(rawValue);
3173
+ if (values.length === 0) {
3174
+ diagnostics.push({
3175
+ at: token.at,
3176
+ text: token.text,
3177
+ message: "Empty value group"
3178
+ });
3179
+ continue;
3180
+ }
3181
+ if (operator.operator !== "eq" && values.length > 1) diagnostics.push({
3182
+ at: token.at,
3183
+ text: token.text,
3184
+ message: "A comparison takes a single value"
3185
+ });
3186
+ const clause = {
3187
+ type: "field",
3188
+ field,
3189
+ operator: operator.operator,
3190
+ values: operator.operator === "eq" ? values : values.slice(0, 1),
3191
+ negated
3192
+ };
3193
+ clauses.push(clause);
3194
+ if (!rawValue.startsWith("(")) {
3195
+ var _values$;
3196
+ trailing = {
3197
+ field,
3198
+ operator: operator.operator,
3199
+ value: (_values$ = values[0]) !== null && _values$ !== void 0 ? _values$ : ""
3200
+ };
3201
+ }
3202
+ }
3203
+ return {
3204
+ query: { clauses },
3205
+ diagnostics,
3206
+ trailing
3207
+ };
3208
+ }
3209
+ /** Parse, discarding diagnostics. */
3210
+ function parseQuery(input) {
3211
+ return parseQueryWithDiagnostics(input).query;
3212
+ }
3213
+ //#endregion
3214
+ //#region src/lib/query/query-format.ts
3215
+ const OPERATOR_SYMBOL = {
3216
+ eq: ":",
3217
+ contains: ":",
3218
+ gt: ">",
3219
+ gte: ">=",
3220
+ lt: "<",
3221
+ lte: "<=",
3222
+ exists: ":"
3223
+ };
3224
+ /** Quote a value that would otherwise re-parse as something else. */
3225
+ function quoteValue(value) {
3226
+ return /[\s"()]/.test(value) || value === "" ? `"${value.replace(/"/g, "")}"` : value;
3227
+ }
3228
+ /** One clause as text. */
3229
+ function formatClause(clause) {
3230
+ var _clause$values$2;
3231
+ const prefix = clause.negated ? "-" : "";
3232
+ if (clause.type === "term") return `${prefix}${quoteValue(clause.value)}`;
3233
+ if (clause.operator === "exists") return `${prefix}${clause.field}:*`;
3234
+ if (clause.operator === "contains") {
3235
+ var _clause$values$;
3236
+ return `${prefix}${clause.field}:*${(_clause$values$ = clause.values[0]) !== null && _clause$values$ !== void 0 ? _clause$values$ : ""}*`;
3237
+ }
3238
+ const symbol = OPERATOR_SYMBOL[clause.operator];
3239
+ if (clause.values.length > 1) return `${prefix}${clause.field}${symbol}(${clause.values.map(quoteValue).join(" or ")})`;
3240
+ return `${prefix}${clause.field}${symbol}${quoteValue((_clause$values$2 = clause.values[0]) !== null && _clause$values$2 !== void 0 ? _clause$values$2 : "")}`;
3241
+ }
3242
+ /** The whole query as text. */
3243
+ function formatQuery(query) {
3244
+ return query.clauses.map(formatClause).join(" ");
3245
+ }
3246
+ /** A short human label for a clause chip. */
3247
+ function describeClause(clause) {
3248
+ if (clause.type === "term") return clause.negated ? `not "${clause.value}"` : `"${clause.value}"`;
3249
+ if (clause.operator === "exists") return clause.negated ? `${clause.field} missing` : `${clause.field} present`;
3250
+ if (clause.operator === "contains") {
3251
+ var _clause$values$3;
3252
+ return `${clause.negated ? "not " : ""}${clause.field} ⊃ ${(_clause$values$3 = clause.values[0]) !== null && _clause$values$3 !== void 0 ? _clause$values$3 : ""}`;
3456
3253
  }
3457
- ];
3254
+ const values = clause.values.join(", ");
3255
+ const symbol = OPERATOR_SYMBOL[clause.operator];
3256
+ return `${clause.negated ? "not " : ""}${clause.field} ${symbol === ":" ? "=" : symbol} ${values}`;
3257
+ }
3458
3258
  //#endregion
3459
- //#region src/shells/time-series-panel.tsx
3460
- const defaultLabels$3 = {
3461
- back: "Back to previous range",
3462
- legend: "Series"
3259
+ //#region src/shells/query-bar.tsx
3260
+ const defaultLabels$4 = {
3261
+ placeholder: "Search, or filter with field:value",
3262
+ ariaLabel: "Search query",
3263
+ clear: "Clear query",
3264
+ removeClause: (clause) => `Remove ${clause}`,
3265
+ fieldsHeading: "Fields",
3266
+ valuesHeading: "Values",
3267
+ syntaxHint: null
3463
3268
  };
3269
+ const MAX_SUGGESTIONS = 8;
3464
3270
  /**
3465
- * TimeSeriesPanela chart you can steer.
3271
+ * QueryBarone control for "find this" and "only these".
3466
3272
  *
3467
- * The point of putting the control and the chart in one component is the
3468
- * loop between them: drag across the plot and the window narrows to what you
3469
- * dragged, which re-queries, which redraws the axis you just picked. That
3470
- * only works if someone owns the step back out again so zoom keeps a
3471
- * history, and the header offers the way back rather than leaving the reader
3472
- * to retype the range they came from.
3273
+ * A search box and a row of filter pickers answer halves of the same question
3274
+ * and neither can express the other, so people end up using both and holding
3275
+ * the combination in their head. Typing the filter is what closes that: the
3276
+ * whole question is one string, which means it can be read back, corrected in
3277
+ * place, pasted to a colleague, and stored in a URL.
3278
+ *
3279
+ * The text is the source of truth while the reader is typing; the parsed query
3280
+ * is committed on Enter or blur. Anything half-written parses to a plain term
3281
+ * rather than an error, so the bar never argues with someone mid-word.
3473
3282
  *
3474
3283
  * @example
3475
3284
  * ```tsx
3476
- * const [range, setRange] = useState<TimeRange>({ from: "now-7d", to: "now" });
3477
- *
3478
- * <TimeSeriesPanel
3479
- * title="Sessions"
3480
- * data={points}
3481
- * series={[{ dataKey: "sessions", name: "Sessions" }]}
3482
- * range={range}
3483
- * onRangeChange={setRange}
3484
- * onRefresh={() => refetch()}
3485
- * toggleableLegend
3285
+ * <QueryBar
3286
+ * value={query}
3287
+ * onChange={setQuery}
3288
+ * fields={[{ name: "path", values: ["/checkout", "/cart"] }]}
3289
+ * showClauses
3486
3290
  * />
3487
3291
  * ```
3488
3292
  */
3489
- function TimeSeriesPanel({ title, description, actions, data, series, range, onRangeChange, granularity = "auto", timeZone, locale, showRangeControl = true, presets, recentRanges, refresh, onRefreshChange, onRefresh, toggleableLegend = false, annotations, isLoading = false, height = 280, className, chartProps, labels: customLabels, "aria-label": ariaLabel }) {
3490
- const labels = _objectSpread2(_objectSpread2({}, defaultLabels$3), customLabels);
3491
- const [history, setHistory] = useState([]);
3492
- const [hidden, setHidden] = useState([]);
3493
- const resolved = useMemo(() => resolveTimeRange(range, { timeZone }), [range, timeZone]);
3494
- const visibleSeries = useMemo(() => series.filter((def) => !hidden.includes(def.dataKey)), [series, hidden]);
3495
- const handleRangeSelect = useCallback((selection) => {
3496
- setHistory((current) => [...current, range].slice(-10));
3497
- onRangeChange(toAbsoluteRange(selection));
3498
- }, [range, onRangeChange]);
3499
- const goBack = useCallback(() => {
3500
- setHistory((current) => {
3501
- const previous = current[current.length - 1];
3502
- if (previous) onRangeChange(previous);
3503
- return current.slice(0, -1);
3504
- });
3505
- }, [onRangeChange]);
3506
- const setRange = useCallback((next) => {
3507
- setHistory([]);
3508
- onRangeChange(next);
3509
- }, [onRangeChange]);
3510
- const toggleSeries = useCallback((dataKey) => {
3511
- setHidden((current) => current.includes(dataKey) ? current.filter((key) => key !== dataKey) : [...current, dataKey]);
3512
- }, []);
3513
- return /* @__PURE__ */ jsxs("section", {
3514
- className: cn(OPT_SURFACE, OPT_BORDER, "flex flex-col", className),
3515
- "aria-label": ariaLabel,
3516
- "data-opt-id": "Y0XCY",
3517
- "data-opt-slug": "time-series-panel.TimeSeriesPanel",
3518
- children: [/* @__PURE__ */ jsxs("header", {
3519
- className: "gap-group flex flex-wrap items-start justify-between px-5 pt-5 pb-3",
3520
- children: [/* @__PURE__ */ jsxs("div", {
3521
- className: "gap-element flex min-w-0 flex-col",
3522
- children: [title && /* @__PURE__ */ jsx("h3", {
3523
- className: "text-text-primary truncate text-base font-semibold",
3524
- children: title
3525
- }), description && /* @__PURE__ */ jsx("p", {
3526
- className: cn("text-text-secondary", "truncate text-sm"),
3527
- children: description
3528
- })]
3529
- }), /* @__PURE__ */ jsxs("div", {
3530
- className: "gap-element flex flex-wrap items-center",
3293
+ function QueryBar({ value, onChange, fields = [], showClauses = false, live = false, disabled = false, className, labels: customLabels }) {
3294
+ const labels = _objectSpread2(_objectSpread2({}, defaultLabels$4), customLabels);
3295
+ const inputRef = useRef(null);
3296
+ const [text, setText] = useState(() => formatQuery(value));
3297
+ const [focused, setFocused] = useState(false);
3298
+ const [highlighted, setHighlighted] = useState(0);
3299
+ const committedRef = useRef(formatQuery(value));
3300
+ useEffect(() => {
3301
+ const next = formatQuery(value);
3302
+ if (next === committedRef.current) return;
3303
+ committedRef.current = next;
3304
+ if (!focused) setText(next);
3305
+ }, [value, focused]);
3306
+ const parsed = useMemo(() => parseQueryWithDiagnostics(text), [text]);
3307
+ const commit = useCallback((nextText) => {
3308
+ const nextQuery = parseQueryWithDiagnostics(nextText).query;
3309
+ committedRef.current = formatQuery(nextQuery);
3310
+ onChange(nextQuery);
3311
+ }, [onChange]);
3312
+ const suggestions = useSuggestions(text, parsed.trailing, fields);
3313
+ const applySuggestion = useCallback((suggestion) => {
3314
+ var _inputRef$current;
3315
+ const nextText = suggestion.replace(text);
3316
+ setText(nextText);
3317
+ if (suggestion.kind === "value") commit(nextText);
3318
+ (_inputRef$current = inputRef.current) === null || _inputRef$current === void 0 || _inputRef$current.focus();
3319
+ setHighlighted(0);
3320
+ }, [text, commit]);
3321
+ const handleKeyDown = (event) => {
3322
+ if (suggestions.length > 0 && focused) {
3323
+ if (event.key === "ArrowDown") {
3324
+ event.preventDefault();
3325
+ setHighlighted((current) => (current + 1) % suggestions.length);
3326
+ return;
3327
+ }
3328
+ if (event.key === "ArrowUp") {
3329
+ event.preventDefault();
3330
+ setHighlighted((current) => (current - 1 + suggestions.length) % suggestions.length);
3331
+ return;
3332
+ }
3333
+ if (event.key === "Tab" || event.key === "Enter" && highlighted > 0) {
3334
+ event.preventDefault();
3335
+ applySuggestion(suggestions[highlighted]);
3336
+ return;
3337
+ }
3338
+ }
3339
+ if (event.key === "Enter") {
3340
+ event.preventDefault();
3341
+ commit(text);
3342
+ return;
3343
+ }
3344
+ if (event.key === "Escape") {
3345
+ var _inputRef$current2;
3346
+ setText(committedRef.current);
3347
+ (_inputRef$current2 = inputRef.current) === null || _inputRef$current2 === void 0 || _inputRef$current2.blur();
3348
+ }
3349
+ };
3350
+ const showSuggestions = focused && suggestions.length > 0;
3351
+ return /* @__PURE__ */ jsxs("div", {
3352
+ className: cn("relative flex min-w-0 flex-col gap-1.5", className),
3353
+ "data-opt-id": "JSVZ1",
3354
+ "data-opt-slug": "query-bar.QueryBar",
3355
+ children: [
3356
+ /* @__PURE__ */ jsxs("div", {
3357
+ className: cn("bg-surface flex h-9 items-center gap-2 rounded-md border px-2.5", OPT_BORDER, "focus-within:border-accent transition-colors", disabled && "opacity-60"),
3531
3358
  children: [
3532
- history.length > 0 && /* @__PURE__ */ jsx(Button, {
3533
- variant: "ghost",
3534
- size: "sm",
3535
- onClick: goBack,
3536
- leftIcon: /* @__PURE__ */ jsx(Undo2, {
3537
- className: "h-3.5 w-3.5",
3538
- "aria-hidden": "true"
3539
- }),
3540
- children: labels.back
3359
+ /* @__PURE__ */ jsx(Search, {
3360
+ className: "text-text-tertiary size-4 shrink-0",
3361
+ "aria-hidden": true
3541
3362
  }),
3542
- actions,
3543
- showRangeControl && /* @__PURE__ */ jsx(TimeRangeControl, {
3544
- value: range,
3545
- onChange: setRange,
3546
- presets,
3547
- recentRanges,
3548
- timeZone,
3549
- locale,
3550
- refresh,
3551
- onRefreshChange,
3552
- onRefresh,
3553
- isLoading,
3554
- labels: customLabels === null || customLabels === void 0 ? void 0 : customLabels.control
3363
+ /* @__PURE__ */ jsx("input", {
3364
+ ref: inputRef,
3365
+ type: "text",
3366
+ role: "combobox",
3367
+ "aria-expanded": showSuggestions,
3368
+ "aria-controls": "query-bar-suggestions",
3369
+ "aria-autocomplete": "list",
3370
+ "aria-label": labels.ariaLabel,
3371
+ placeholder: labels.placeholder,
3372
+ disabled,
3373
+ className: cn("text-text-primary placeholder:text-text-tertiary min-w-0 flex-1", "bg-transparent font-mono text-sm outline-none focus-visible:outline-none"),
3374
+ value: text,
3375
+ onChange: (event) => {
3376
+ setText(event.target.value);
3377
+ setHighlighted(0);
3378
+ if (live) commit(event.target.value);
3379
+ },
3380
+ onFocus: () => setFocused(true),
3381
+ onBlur: () => {
3382
+ window.setTimeout(() => setFocused(false), 120);
3383
+ if (!live) commit(text);
3384
+ },
3385
+ onKeyDown: handleKeyDown
3386
+ }),
3387
+ text.length > 0 && /* @__PURE__ */ jsx("button", {
3388
+ type: "button",
3389
+ "aria-label": labels.clear,
3390
+ title: labels.clear,
3391
+ className: cn("text-text-tertiary hover:text-text-primary cursor-pointer rounded-sm p-0.5", "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-ring-offset"),
3392
+ onClick: () => {
3393
+ setText("");
3394
+ commit("");
3395
+ },
3396
+ children: /* @__PURE__ */ jsx(X, {
3397
+ className: "size-3.5",
3398
+ "aria-hidden": true
3399
+ })
3555
3400
  })
3556
3401
  ]
3557
- })]
3558
- }), /* @__PURE__ */ jsx("div", {
3559
- className: "px-2 pb-4",
3560
- children: /* @__PURE__ */ jsx(TimeSeriesChart, _objectSpread2({
3561
- data,
3562
- series: visibleSeries,
3563
- from: resolved === null || resolved === void 0 ? void 0 : resolved.from,
3564
- to: resolved === null || resolved === void 0 ? void 0 : resolved.to,
3565
- granularity,
3566
- timeZone,
3567
- locale,
3568
- height,
3569
- selectable: true,
3570
- onRangeSelect: handleRangeSelect,
3571
- annotations,
3572
- showLegend: series.length > 1,
3573
- legendOptions: toggleableLegend ? {
3574
- onClick: toggleSeries,
3575
- inactiveKeys: hidden
3576
- } : void 0,
3577
- "aria-label": typeof title === "string" ? `${title} time series` : ariaLabel
3578
- }, chartProps))
3579
- })]
3402
+ }),
3403
+ showSuggestions && /* @__PURE__ */ jsx("ul", {
3404
+ id: "query-bar-suggestions",
3405
+ role: "listbox",
3406
+ className: cn("bg-surface-overlay absolute top-10 z-40 max-h-64 w-full overflow-y-auto rounded-md border p-1 shadow-lg", "border border-border"),
3407
+ children: suggestions.map((suggestion, index) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("button", {
3408
+ type: "button",
3409
+ role: "option",
3410
+ "aria-selected": index === highlighted,
3411
+ className: cn("flex w-full cursor-pointer items-center justify-between gap-3 rounded px-2 py-1 text-left text-sm", index === highlighted ? "bg-bg-subtle" : "hover:bg-bg-subtle"),
3412
+ onMouseEnter: () => setHighlighted(index),
3413
+ onMouseDown: (event) => event.preventDefault(),
3414
+ onClick: () => applySuggestion(suggestion),
3415
+ children: [/* @__PURE__ */ jsx("span", {
3416
+ className: "text-text-primary truncate font-mono",
3417
+ children: suggestion.label
3418
+ }), suggestion.hint && /* @__PURE__ */ jsx("span", {
3419
+ className: "text-text-tertiary shrink-0 text-xs",
3420
+ children: suggestion.hint
3421
+ })]
3422
+ }) }, `${suggestion.kind}-${suggestion.label}`))
3423
+ }),
3424
+ showClauses && value.clauses.length > 0 && /* @__PURE__ */ jsx("ul", {
3425
+ className: "flex flex-wrap items-center gap-1.5",
3426
+ children: value.clauses.map((clause, index) => {
3427
+ const label = describeClause(clause);
3428
+ return /* @__PURE__ */ jsxs("li", {
3429
+ className: cn("bg-bg-subtle text-text-secondary flex items-center gap-1 rounded px-1.5 py-0.5 text-xs", clause.negated && "text-danger-fg"),
3430
+ children: [/* @__PURE__ */ jsx("span", {
3431
+ className: "truncate",
3432
+ children: label
3433
+ }), /* @__PURE__ */ jsx("button", {
3434
+ type: "button",
3435
+ "aria-label": labels.removeClause(label),
3436
+ className: "hover:text-text-primary cursor-pointer",
3437
+ onClick: () => {
3438
+ const next = removeClause(value, index);
3439
+ setText(formatQuery(next));
3440
+ committedRef.current = formatQuery(next);
3441
+ onChange(next);
3442
+ },
3443
+ children: /* @__PURE__ */ jsx(X, {
3444
+ className: "size-3",
3445
+ "aria-hidden": true
3446
+ })
3447
+ })]
3448
+ }, `${label}-${index}`);
3449
+ })
3450
+ }),
3451
+ parsed.diagnostics.length > 0 && /* @__PURE__ */ jsx(QueryDiagnostics, { diagnostics: parsed.diagnostics }),
3452
+ labels.syntaxHint && !showSuggestions && /* @__PURE__ */ jsx("p", {
3453
+ className: "text-text-tertiary text-xs",
3454
+ children: labels.syntaxHint
3455
+ })
3456
+ ]
3580
3457
  });
3581
3458
  }
3582
- TimeSeriesPanel.displayName = "TimeSeriesPanel";
3459
+ QueryBar.displayName = "QueryBar";
3460
+ function QueryDiagnostics({ diagnostics }) {
3461
+ return /* @__PURE__ */ jsx("ul", {
3462
+ className: "text-warning-fg flex flex-col gap-0.5 text-xs",
3463
+ children: diagnostics.map((diagnostic, index) => /* @__PURE__ */ jsxs("li", { children: [
3464
+ /* @__PURE__ */ jsx("code", {
3465
+ className: "font-mono",
3466
+ children: diagnostic.text
3467
+ }),
3468
+ " —",
3469
+ " ",
3470
+ diagnostic.message
3471
+ ] }, `${diagnostic.at}-${index}`))
3472
+ });
3473
+ }
3474
+ /**
3475
+ * What to offer, based on the token under the cursor.
3476
+ *
3477
+ * Completing fields is the part that makes the syntax discoverable at all —
3478
+ * nobody guesses that `utm_source` is a field they can filter on. Once the
3479
+ * field is chosen, its known values are the second half of the same guess.
3480
+ */
3481
+ function useSuggestions(text, trailing, fields) {
3482
+ return useMemo(() => {
3483
+ const lastSpace = text.lastIndexOf(" ");
3484
+ const head = lastSpace === -1 ? "" : text.slice(0, lastSpace + 1);
3485
+ const token = text.slice(lastSpace + 1);
3486
+ if (trailing) {
3487
+ var _field$values;
3488
+ const field = fields.find((candidate) => candidate.name === trailing.field);
3489
+ const values = (_field$values = field === null || field === void 0 ? void 0 : field.values) !== null && _field$values !== void 0 ? _field$values : [];
3490
+ const typed = trailing.value.toLowerCase();
3491
+ return values.filter((value) => value.toLowerCase().includes(typed)).slice(0, MAX_SUGGESTIONS).map((value) => ({
3492
+ kind: "value",
3493
+ label: `${trailing.field}:${value}`,
3494
+ replace: () => `${head}${trailing.field}:${/[\s"()]/.test(value) ? `"${value}"` : value} `
3495
+ }));
3496
+ }
3497
+ const bare = token.replace(/^-/, "").toLowerCase();
3498
+ if (bare.length === 0) return [];
3499
+ return fields.filter((field) => field.name.toLowerCase().includes(bare)).slice(0, MAX_SUGGESTIONS).map((field) => ({
3500
+ kind: "field",
3501
+ label: `${field.name}:`,
3502
+ hint: field.hint,
3503
+ replace: () => `${head}${token.startsWith("-") ? "-" : ""}${field.name}:`
3504
+ }));
3505
+ }, [
3506
+ text,
3507
+ trailing,
3508
+ fields
3509
+ ]);
3510
+ }
3583
3511
  //#endregion
3584
- //#region src/shells/log-table.tsx
3585
- const HIDE_BELOW = {
3586
- always: "",
3587
- sm: "hidden @[40rem]/log:flex",
3588
- md: "hidden @[46rem]/log:flex",
3589
- lg: "hidden @[58rem]/log:flex",
3590
- xl: "hidden @[70rem]/log:flex"
3591
- };
3592
- const ROW_HEIGHT = {
3593
- compact: 26,
3594
- normal: 32,
3595
- comfortable: 40
3596
- };
3597
- const DENSITY_TEXT = {
3598
- compact: "text-[11px]",
3599
- normal: "text-xs",
3600
- comfortable: "text-sm"
3601
- };
3602
- const defaultLabels$2 = {
3603
- listLabel: "Log",
3604
- loadingMore: "Loading earlier entries…",
3605
- endOfResults: "That is every entry in this range.",
3606
- filterFor: (value) => `Filter for ${value}`,
3607
- filterOut: (value) => `Filter out ${value}`,
3608
- sortAscending: "Sort ascending",
3609
- sortDescending: "Sort descending",
3610
- moveLeft: (column) => `Move ${column} left`,
3611
- moveRight: (column) => `Move ${column} right`
3612
- };
3613
- /** Rows rendered beyond the viewport on each side, so a fast scroll stays filled. */
3614
- const OVERSCAN = 10;
3512
+ //#region src/lib/use-auto-refresh.ts
3615
3513
  /**
3616
- * A stable hue per value. Colour does real work in a log — the eye groups by it
3617
- * long before it reads the text — so the same event name has to land on the
3618
- * same hue on every render and in every session, which rules out an index into
3619
- * a palette.
3514
+ * A refresh timer that waits for its own work.
3515
+ *
3516
+ * A plain `setInterval` fires on a schedule regardless of whether the previous
3517
+ * query came back, so a five-second interval over a seven-second query stacks
3518
+ * requests until something gives. Chaining the next wait onto the settled
3519
+ * promise keeps exactly one refresh in flight, and a rejection stops the loop
3520
+ * instead of hammering an endpoint that is already failing.
3620
3521
  */
3621
- function hueOf(value) {
3622
- let hash = 0;
3623
- for (let index = 0; index < value.length; index += 1) hash = hash * 31 + value.charCodeAt(index) | 0;
3624
- return Math.abs(hash) % 360;
3522
+ function useAutoRefresh({ interval, paused = false, onRefresh }) {
3523
+ const callbackRef = useRef(onRefresh);
3524
+ callbackRef.current = onRefresh;
3525
+ useEffect(() => {
3526
+ if (paused || interval <= 0 || !callbackRef.current) return;
3527
+ let cancelled = false;
3528
+ let timer;
3529
+ const schedule = () => {
3530
+ timer = setTimeout(async () => {
3531
+ if (cancelled) return;
3532
+ try {
3533
+ var _callbackRef$current;
3534
+ await ((_callbackRef$current = callbackRef.current) === null || _callbackRef$current === void 0 ? void 0 : _callbackRef$current.call(callbackRef));
3535
+ } catch (_unused) {
3536
+ return;
3537
+ }
3538
+ if (!cancelled) schedule();
3539
+ }, interval);
3540
+ };
3541
+ schedule();
3542
+ return () => {
3543
+ cancelled = true;
3544
+ if (timer) clearTimeout(timer);
3545
+ };
3546
+ }, [interval, paused]);
3547
+ }
3548
+ //#endregion
3549
+ //#region src/shells/time-range-control.tsx
3550
+ const defaultLabels$3 = {
3551
+ trigger: "Select time range",
3552
+ invalid: "Invalid time range",
3553
+ last: (duration) => `Last ${duration}`,
3554
+ next: (duration) => `Next ${duration}`,
3555
+ quickSelect: "Quick select",
3556
+ apply: "Apply",
3557
+ update: "Update",
3558
+ refresh: "Refresh",
3559
+ commonlyUsed: "Commonly used",
3560
+ recentlyUsed: "Recently used",
3561
+ start: "Start",
3562
+ end: "End",
3563
+ startHint: "Absolute time or date math, e.g. now-7d",
3564
+ autoRefresh: "Auto refresh",
3565
+ autoRefreshOff: "Off",
3566
+ every: (duration) => `Every ${duration}`,
3567
+ previous: "Previous window",
3568
+ nextWindow: "Next window",
3569
+ zoomIn: "Zoom in",
3570
+ zoomOut: "Zoom out",
3571
+ thisUnit: (unit) => `This ${unit}`,
3572
+ units: {
3573
+ s: "seconds",
3574
+ m: "minutes",
3575
+ h: "hours",
3576
+ d: "days",
3577
+ w: "weeks",
3578
+ M: "months",
3579
+ y: "years"
3580
+ }
3581
+ };
3582
+ const QUICK_UNITS = [
3583
+ "m",
3584
+ "h",
3585
+ "d",
3586
+ "w",
3587
+ "M",
3588
+ "y"
3589
+ ];
3590
+ const REFRESH_INTERVALS = [
3591
+ 0,
3592
+ 5e3,
3593
+ 1e4,
3594
+ 3e4,
3595
+ 6e4,
3596
+ 3e5
3597
+ ];
3598
+ function selectValue(value) {
3599
+ var _value$;
3600
+ return Array.isArray(value) ? (_value$ = value[0]) !== null && _value$ !== void 0 ? _value$ : "" : value;
3625
3601
  }
3626
- const EM_DASH = "—";
3627
3602
  /**
3628
- * A column with nothing in it is a fact about the entry, not a value. Dimming
3629
- * every absence the same way lets the eye skip the gaps while scanning, instead
3630
- * of reading a dash as though it were data.
3603
+ * What the trigger says.
3604
+ *
3605
+ * A window anchored to `now` deserves its intent ("Last 7 days"), not two
3606
+ * timestamps the reader has to subtract. Everything else gets both ends
3607
+ * spelled out. A duration that does not divide into whole minutes is prefixed
3608
+ * with `~` rather than rounded silently.
3631
3609
  */
3632
- const ABSENT = "text-text-tertiary/50";
3633
- function columnStyle(column) {
3634
- return column.width ? {
3635
- width: column.width,
3636
- flex: "0 0 auto"
3637
- } : {
3638
- flex: "1 1 0%",
3639
- minWidth: 0
3610
+ function describeRange(range, labels, options) {
3611
+ const window = measureTimeWindow(range, {
3612
+ timeZone: options.timeZone,
3613
+ now: options.now
3614
+ });
3615
+ if (window.isInvalid) return {
3616
+ text: labels.invalid,
3617
+ isInvalid: true
3618
+ };
3619
+ const fromParts = parseRelativeParts(range.from);
3620
+ const toParts = parseRelativeParts(range.to);
3621
+ if ((fromParts === null || fromParts === void 0 ? void 0 : fromParts.roundUnit) && fromParts.count === 0 && (toParts === null || toParts === void 0 ? void 0 : toParts.roundUnit) === fromParts.roundUnit && toParts.count === 0) return {
3622
+ text: labels.thisUnit(formatCountUnit(1, fromParts.roundUnit, options.locale).replace(/^1\s*/, "")),
3623
+ isInvalid: false
3624
+ };
3625
+ if (isRelativeToNow(range)) {
3626
+ var _toParts$count;
3627
+ const anchoredAtEnd = ((_toParts$count = toParts === null || toParts === void 0 ? void 0 : toParts.count) !== null && _toParts$count !== void 0 ? _toParts$count : 0) === 0;
3628
+ const parts = anchoredAtEnd ? fromParts : toParts;
3629
+ const bothSnapped = (parts === null || parts === void 0 ? void 0 : parts.roundUnit) === (parts === null || parts === void 0 ? void 0 : parts.unit) && (toParts === null || toParts === void 0 ? void 0 : toParts.roundUnit) === (parts === null || parts === void 0 ? void 0 : parts.unit);
3630
+ const counted = parts && parts.count > 0 ? formatCountUnit(parts.roundUnit && bothSnapped ? parts.count + 1 : parts.count, parts.unit, options.locale) : null;
3631
+ const exact = counted !== null && (!(parts === null || parts === void 0 ? void 0 : parts.roundUnit) || bothSnapped);
3632
+ const duration = exact ? counted : formatDuration(window.duration, options.locale);
3633
+ const prefix = exact || isExactMinutes(window.duration) ? "" : "~";
3634
+ return {
3635
+ text: anchoredAtEnd ? labels.last(`${prefix}${duration}`) : labels.next(`${prefix}${duration}`),
3636
+ isInvalid: false
3637
+ };
3638
+ }
3639
+ const dateOnly = isDateOnly(range.from) && isDateOnly(range.to);
3640
+ const from = formatInstant(window.from, _objectSpread2(_objectSpread2({}, options), {}, { dateOnly }));
3641
+ const to = formatInstant(window.to, _objectSpread2(_objectSpread2({}, options), {}, { dateOnly }));
3642
+ return {
3643
+ text: from === to ? from : `${from} – ${to}`,
3644
+ isInvalid: false
3640
3645
  };
3641
3646
  }
3642
3647
  /**
3643
- * LogTablea windowed table for entries that keep arriving.
3644
- *
3645
- * Written by hand rather than delegated to a data grid for the two things a log
3646
- * needs that a grid does not: rows are inserted at the *top* while the reader
3647
- * is partway down, which needs a scroll correction applied before paint, and
3648
- * "am I at the newest row?" has to be answerable from the scroll container to
3649
- * decide whether to follow the tail or hold position.
3648
+ * TimeRangeControlthe window as an expression, not two frozen instants.
3650
3649
  *
3651
- * What it takes from a data grid is the part readers actually reach for:
3652
- * columns are data, so a caller can let people add, drop and reorder them; a
3653
- * cell offers to filter for or against its own value; and density is a setting
3654
- * rather than a constant.
3650
+ * Holding `{ from: "now-7d", to: "now" }` instead of a resolved pair is what
3651
+ * makes a saved board, a shared link and an auto-refresh timer all mean the
3652
+ * same thing tomorrow that they mean today. Around that sit the moves people
3653
+ * actually make on a chart: jump to a named window, step the same width back
3654
+ * and forth to compare, widen when the interesting thing is just off screen,
3655
+ * and let it re-query on a timer while they watch.
3655
3656
  *
3656
3657
  * @example
3657
3658
  * ```tsx
3658
- * <LogTable
3659
- * rows={rows}
3660
- * getRowId={(row) => row.id}
3661
- * columns={[
3662
- * { id: "time", header: "Time", width: "9rem", mono: true, value: (r) => r.time },
3663
- * { id: "name", header: "Event", width: "14rem", field: "name", marker: true, value: (r) => r.name },
3664
- * { id: "path", header: "Path", field: "path", value: (r) => r.path },
3665
- * ]}
3666
- * onCellAction={(action) => setQuery(applyCellAction(query, action))}
3659
+ * const [range, setRange] = useState<TimeRange>({ from: "now-7d", to: "now" });
3660
+ *
3661
+ * <TimeRangeControl
3662
+ * value={range}
3663
+ * onChange={setRange}
3664
+ * timeZone="Asia/Seoul"
3665
+ * showWindowButtons
3666
+ * onRefresh={() => refetch()}
3667
3667
  * />
3668
3668
  * ```
3669
3669
  */
3670
- function LogTableInner({ rows, columns, getRowId, selectedId = null, onSelect, onLoadMore, hasMore = false, loadingMore = false, prepending = false, onFollowChange, isNew, sort = null, onSortChange, onCellAction, highlightTerms, onColumnMove, density = "normal", showHeader = true, emptyState, labels: customLabels, className }, ref) {
3671
- const labels = _objectSpread2(_objectSpread2({}, defaultLabels$2), customLabels);
3672
- const rowHeight = ROW_HEIGHT[density];
3673
- const scrollRef = useRef(null);
3674
- const [scrollTop, setScrollTop] = useState(0);
3675
- const [viewportHeight, setViewportHeight] = useState(0);
3676
- const followingRef = useRef(true);
3677
- const previousTopIdRef = useRef(null);
3678
- const previousLengthRef = useRef(0);
3679
- useImperativeHandle(ref, () => ({
3680
- scrollToTop: () => {
3681
- const container = scrollRef.current;
3682
- if (!container) return;
3683
- container.scrollTop = 0;
3684
- setScrollTop(0);
3685
- if (!followingRef.current) {
3686
- followingRef.current = true;
3687
- onFollowChange === null || onFollowChange === void 0 || onFollowChange(true);
3688
- }
3689
- },
3690
- revealRow: (id) => {
3691
- const index = rows.findIndex((row) => getRowId(row) === id);
3692
- const container = scrollRef.current;
3693
- if (index === -1 || !container) return;
3694
- const top = index * rowHeight;
3695
- const bottom = top + rowHeight;
3696
- if (top < container.scrollTop) container.scrollTop = top;
3697
- else if (bottom > container.scrollTop + container.clientHeight) container.scrollTop = bottom - container.clientHeight;
3698
- }
3670
+ function TimeRangeControl({ value, onChange, presets, recentRanges, timeZone, locale, showUpdateButton = true, showWindowButtons = true, zoomFactor = .5, refresh, onRefreshChange, onRefresh, isLoading = false, disabled = false, className, labels: customLabels, "aria-label": ariaLabel }) {
3671
+ var _refresh$interval, _refresh$paused, _refresh$interval2;
3672
+ const labels = _objectSpread2(_objectSpread2(_objectSpread2({}, defaultLabels$3), customLabels), {}, { units: _objectSpread2(_objectSpread2({}, defaultLabels$3.units), customLabels === null || customLabels === void 0 ? void 0 : customLabels.units) });
3673
+ const [open, setOpen] = useState(false);
3674
+ const [draft, setDraft] = useState(null);
3675
+ const [quickCount, setQuickCount] = useState("15");
3676
+ const [quickUnit, setQuickUnit] = useState("m");
3677
+ const editing = draft !== null && draft !== void 0 ? draft : value;
3678
+ const hasChanges = draft !== null && (draft.from !== value.from || draft.to !== value.to);
3679
+ const description = useMemo(() => describeRange(value, labels, {
3680
+ locale,
3681
+ timeZone
3699
3682
  }), [
3700
- onFollowChange,
3701
- rows,
3702
- getRowId,
3703
- rowHeight
3704
- ]);
3705
- useEffect(() => {
3706
- const container = scrollRef.current;
3707
- if (!container) return;
3708
- const observer = new ResizeObserver(() => setViewportHeight(container.clientHeight));
3709
- observer.observe(container);
3710
- setViewportHeight(container.clientHeight);
3711
- return () => observer.disconnect();
3712
- }, []);
3713
- /**
3714
- * Report whether the list is pinned to the newest row.
3715
- *
3716
- * Called from more than the scroll handler on purpose: the list can arrive at
3717
- * the top without a scroll event — a reset, a jump to newest, a shorter
3718
- * result set — and a stale "not following" would silently divert every live
3719
- * row into a held-back pile.
3720
- */
3721
- const syncFollowing = useCallback((container) => {
3722
- const following = container.scrollTop <= rowHeight;
3723
- if (following === followingRef.current) return;
3724
- followingRef.current = following;
3725
- onFollowChange === null || onFollowChange === void 0 || onFollowChange(following);
3726
- }, [onFollowChange, rowHeight]);
3727
- /**
3728
- * Hold the reader's place when rows arrive above them.
3729
- *
3730
- * Runs before paint: by the time the browser draws, the scroll offset has
3731
- * already absorbed the inserted height, so the content under the cursor never
3732
- * moves. Skipped while following, where the whole point is to stay at the top.
3733
- */
3734
- useLayoutEffect(() => {
3735
- const container = scrollRef.current;
3736
- const previousTopId = previousTopIdRef.current;
3737
- previousTopIdRef.current = rows.length ? getRowId(rows[0]) : null;
3738
- const previousLength = previousLengthRef.current;
3739
- previousLengthRef.current = rows.length;
3740
- if (!container) return;
3741
- syncFollowing(container);
3742
- if (!prepending || followingRef.current || !previousTopId) return;
3743
- if (rows.length <= previousLength) return;
3744
- const prepended = rows.findIndex((row) => getRowId(row) === previousTopId);
3745
- if (prepended <= 0) return;
3746
- container.scrollTop += prepended * rowHeight;
3747
- setScrollTop(container.scrollTop);
3748
- }, [
3749
- rows,
3750
- getRowId,
3751
- prepending,
3752
- rowHeight,
3753
- syncFollowing
3754
- ]);
3755
- /**
3756
- * Page forward when the rows do not reach the bottom of the viewport.
3757
- *
3758
- * Without this the list only advances on a scroll event, so a tall screen —
3759
- * or a filter matching few rows per page — stops at the first page with no
3760
- * way to ask for more.
3761
- */
3762
- useEffect(() => {
3763
- const container = scrollRef.current;
3764
- if (!container || !hasMore || loadingMore || !onLoadMore) return;
3765
- if (container.scrollHeight <= container.clientHeight) onLoadMore();
3766
- }, [
3767
- hasMore,
3768
- loadingMore,
3769
- onLoadMore,
3770
- rows.length,
3771
- viewportHeight
3683
+ value,
3684
+ locale,
3685
+ timeZone,
3686
+ customLabels
3772
3687
  ]);
3773
- const handleScroll = useCallback((event) => {
3774
- const container = event.currentTarget;
3775
- setScrollTop(container.scrollTop);
3776
- syncFollowing(container);
3777
- const remaining = container.scrollHeight - container.scrollTop - container.clientHeight;
3778
- if (hasMore && !loadingMore && remaining < container.clientHeight) onLoadMore === null || onLoadMore === void 0 || onLoadMore();
3688
+ const boundState = useCallback((expression) => parseTimeExpression(expression, { timeZone }) === null ? labels.invalid : void 0, [timeZone, labels]);
3689
+ const commit = useCallback((range) => {
3690
+ setDraft(null);
3691
+ onChange(range);
3692
+ }, [onChange]);
3693
+ const apply = useCallback(() => {
3694
+ if (draft) commit(draft);
3695
+ else onRefresh === null || onRefresh === void 0 || onRefresh();
3779
3696
  }, [
3780
- hasMore,
3781
- loadingMore,
3782
- onLoadMore,
3783
- syncFollowing
3784
- ]);
3785
- const totalHeight = rows.length * rowHeight;
3786
- const firstVisible = Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN);
3787
- const visibleCount = Math.ceil((viewportHeight || 480) / rowHeight) + 20;
3788
- const lastVisible = Math.min(rows.length, firstVisible + visibleCount);
3789
- const visibleRows = useMemo(() => rows.slice(firstVisible, lastVisible), [
3790
- rows,
3791
- firstVisible,
3792
- lastVisible
3697
+ draft,
3698
+ commit,
3699
+ onRefresh
3793
3700
  ]);
3794
- return /* @__PURE__ */ jsxs("div", {
3795
- className: cn("@container/log flex min-h-0 flex-col", className),
3796
- "data-opt-id": "3ZR82",
3797
- "data-opt-slug": "log-table.LogTable",
3798
- children: [showHeader && /* @__PURE__ */ jsx(LogTableHeader, {
3799
- columns,
3800
- sort,
3801
- onSortChange,
3802
- onColumnMove,
3803
- density,
3804
- labels
3805
- }), /* @__PURE__ */ jsxs("div", {
3806
- ref: scrollRef,
3807
- onScroll: handleScroll,
3808
- className: "relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto",
3809
- role: "listbox",
3810
- "aria-label": labels.listLabel,
3811
- "aria-activedescendant": selectedId ? `log-row-${selectedId}` : void 0,
3812
- tabIndex: 0,
3813
- children: [
3814
- rows.length === 0 ? emptyState : /* @__PURE__ */ jsx("div", {
3815
- style: { height: totalHeight },
3816
- className: "relative",
3817
- children: visibleRows.map((row, index) => {
3818
- var _isNew;
3819
- const id = getRowId(row);
3820
- return /* @__PURE__ */ jsx(LogTableRow, {
3821
- id,
3822
- row,
3823
- columns,
3824
- top: (firstVisible + index) * rowHeight,
3825
- height: rowHeight,
3826
- density,
3827
- selected: id === selectedId,
3828
- isNew: (_isNew = isNew === null || isNew === void 0 ? void 0 : isNew(row)) !== null && _isNew !== void 0 ? _isNew : false,
3829
- onSelect,
3830
- onCellAction,
3831
- highlightTerms,
3832
- labels
3833
- }, id);
3834
- })
3835
- }),
3836
- loadingMore && /* @__PURE__ */ jsxs("div", {
3837
- className: "gap-element text-text-tertiary flex items-center justify-center py-3 text-xs",
3838
- children: [/* @__PURE__ */ jsx(Spinner, {
3839
- size: "sm",
3840
- "aria-hidden": true
3841
- }), labels.loadingMore]
3842
- }),
3843
- !hasMore && rows.length > 0 && /* @__PURE__ */ jsx("p", {
3844
- className: cn("text-text-tertiary", "py-3 text-center text-xs"),
3845
- children: labels.endOfResults
3846
- })
3847
- ]
3848
- })]
3701
+ const commitOnEnter = useCallback((event) => {
3702
+ if (event.key !== "Enter") return;
3703
+ apply();
3704
+ setOpen(false);
3705
+ }, [apply]);
3706
+ const applyQuick = useCallback(() => {
3707
+ const count = Number(quickCount);
3708
+ if (!Number.isFinite(count) || count <= 0) return;
3709
+ commit({
3710
+ from: `now-${Math.round(count)}${quickUnit}`,
3711
+ to: "now"
3712
+ });
3713
+ setOpen(false);
3714
+ }, [
3715
+ quickCount,
3716
+ quickUnit,
3717
+ commit
3718
+ ]);
3719
+ const step = useCallback((direction) => {
3720
+ const next = shiftTimeRange(value, direction, { timeZone });
3721
+ if (next) commit(next);
3722
+ }, [
3723
+ value,
3724
+ timeZone,
3725
+ commit
3726
+ ]);
3727
+ const zoom = useCallback((factor) => {
3728
+ const next = zoomTimeRange(value, factor, { timeZone });
3729
+ if (next) commit(next);
3730
+ }, [
3731
+ value,
3732
+ timeZone,
3733
+ commit
3734
+ ]);
3735
+ useAutoRefresh({
3736
+ interval: (_refresh$interval = refresh === null || refresh === void 0 ? void 0 : refresh.interval) !== null && _refresh$interval !== void 0 ? _refresh$interval : 0,
3737
+ paused: (_refresh$paused = refresh === null || refresh === void 0 ? void 0 : refresh.paused) !== null && _refresh$paused !== void 0 ? _refresh$paused : true,
3738
+ onRefresh
3849
3739
  });
3850
- }
3851
- /**
3852
- * LogTable see {@link LogTableInner}.
3853
- *
3854
- * The cast preserves the row generic through `forwardRef`, which otherwise
3855
- * widens it to its constraint and takes the column callbacks' types with it.
3856
- */
3857
- const LogTable = forwardRef(LogTableInner);
3858
- function LogTableHeader({ columns, sort, onSortChange, onColumnMove, density, labels }) {
3859
- return /* @__PURE__ */ jsx("div", {
3860
- role: "row",
3861
- className: cn("border-border-subtle text-text-tertiary flex shrink-0 items-center gap-3 border-b px-3", DENSITY_TEXT[density]),
3862
- style: { height: ROW_HEIGHT[density] },
3863
- children: columns.map((column, index) => {
3864
- var _column$priority;
3865
- const active = (sort === null || sort === void 0 ? void 0 : sort.columnId) === column.id;
3866
- const sortable = column.sortable && onSortChange;
3867
- const columnLabel = typeof column.header === "string" ? column.header : column.id;
3868
- const content = /* @__PURE__ */ jsxs(Fragment, { children: [
3869
- column.marker && /* @__PURE__ */ jsx("span", {
3870
- "aria-hidden": true,
3871
- className: "size-1.5 shrink-0"
3872
- }),
3873
- /* @__PURE__ */ jsx("span", {
3874
- className: "truncate",
3875
- children: column.header
3876
- }),
3877
- active && (sort.direction === "asc" ? /* @__PURE__ */ jsx(ChevronUp, {
3878
- className: "size-3 shrink-0",
3879
- "aria-hidden": true
3880
- }) : /* @__PURE__ */ jsx(ChevronDown, {
3881
- className: "size-3 shrink-0",
3882
- "aria-hidden": true
3883
- }))
3884
- ] });
3885
- return /* @__PURE__ */ jsxs("div", {
3886
- role: "columnheader",
3887
- "aria-sort": active ? sort.direction === "asc" ? "ascending" : "descending" : void 0,
3888
- className: cn("group/header flex items-center gap-1.5", column.align === "right" && "justify-end", HIDE_BELOW[(_column$priority = column.priority) !== null && _column$priority !== void 0 ? _column$priority : "always"]),
3889
- style: columnStyle(column),
3890
- children: [sortable ? /* @__PURE__ */ jsx("button", {
3891
- type: "button",
3892
- className: "hover:text-text-primary flex cursor-pointer items-center gap-1.5 truncate",
3893
- title: active && sort.direction === "desc" ? labels.sortAscending : labels.sortDescending,
3894
- onClick: () => onSortChange({
3895
- columnId: column.id,
3896
- direction: active && sort.direction === "desc" ? "asc" : "desc"
3897
- }),
3898
- children: content
3899
- }) : content, onColumnMove && /* @__PURE__ */ jsxs("span", {
3900
- className: cn("ml-auto flex shrink-0 items-center gap-0.5", "opacity-0 transition-opacity", "group-hover/header:opacity-100 focus-within:opacity-100"),
3901
- children: [index > 0 && /* @__PURE__ */ jsx("button", {
3902
- type: "button",
3903
- className: "hover:text-text-primary cursor-pointer",
3904
- "aria-label": labels.moveLeft(columnLabel),
3905
- title: labels.moveLeft(columnLabel),
3906
- onClick: () => onColumnMove(column.id, "left"),
3907
- children: /* @__PURE__ */ jsx(ChevronLeft, {
3908
- className: "size-3",
3909
- "aria-hidden": true
3910
- })
3911
- }), index < columns.length - 1 && /* @__PURE__ */ jsx("button", {
3912
- type: "button",
3913
- className: "hover:text-text-primary cursor-pointer",
3914
- "aria-label": labels.moveRight(columnLabel),
3915
- title: labels.moveRight(columnLabel),
3916
- onClick: () => onColumnMove(column.id, "right"),
3917
- children: /* @__PURE__ */ jsx(ChevronRight, {
3918
- className: "size-3",
3919
- "aria-hidden": true
3920
- })
3740
+ const refreshLabel = !refresh || refresh.paused || refresh.interval === 0 ? labels.autoRefreshOff : labels.every(formatDuration(refresh.interval, locale));
3741
+ return /* @__PURE__ */ jsxs("div", {
3742
+ className: cn("bg-surface inline-flex items-center rounded-lg border", OPT_BORDER, className),
3743
+ role: "group",
3744
+ "aria-label": ariaLabel !== null && ariaLabel !== void 0 ? ariaLabel : labels.trigger,
3745
+ "data-opt-id": "3WJ8H",
3746
+ "data-opt-slug": "time-range-control.TimeRangeControl",
3747
+ children: [
3748
+ /* @__PURE__ */ jsxs(PopoverRoot$1, {
3749
+ open,
3750
+ onOpenChange: setOpen,
3751
+ children: [/* @__PURE__ */ jsxs(PopoverTrigger$1, {
3752
+ className: cn("text-text-primary h-10 gap-2", description.isInvalid && "text-danger-fg"),
3753
+ disabled,
3754
+ children: [/* @__PURE__ */ jsx(Clock, {
3755
+ className: "text-text-tertiary h-4 w-4",
3756
+ "aria-hidden": "true"
3757
+ }), /* @__PURE__ */ jsx("span", {
3758
+ className: "truncate text-sm",
3759
+ children: description.text
3921
3760
  })]
3922
- })]
3923
- }, column.id);
3924
- })
3925
- });
3926
- }
3927
- function LogTableRowImpl({ id, row, columns, top, height, density, selected, isNew, onSelect, onCellAction, highlightTerms, labels }) {
3928
- return /* @__PURE__ */ jsx("div", {
3929
- role: "option",
3930
- "aria-selected": selected,
3931
- tabIndex: -1,
3932
- id: `log-row-${id}`,
3933
- "data-log-row-id": id,
3934
- onClick: () => onSelect === null || onSelect === void 0 ? void 0 : onSelect(id),
3935
- onKeyDown: (event) => {
3936
- if (event.key !== "Enter" && event.key !== " ") return;
3937
- event.preventDefault();
3938
- onSelect === null || onSelect === void 0 || onSelect(id);
3939
- },
3940
- style: {
3941
- top,
3942
- height
3943
- },
3944
- className: cn("border-border-subtle/60 absolute inset-x-0 flex items-center gap-3 border-b px-3", DENSITY_TEXT[density], onSelect && "cursor-pointer", "transition-colors duration-100", "hover:bg-surface-hover", selected && "bg-accent-subtle hover:bg-accent-subtle", isNew && !selected && "opt-log-row-enter"),
3945
- children: columns.map((column) => {
3946
- var _column$priority2;
3947
- const raw = column.value(row);
3948
- const text = raw === null || raw === void 0 || raw === "" ? "" : String(raw);
3949
- return /* @__PURE__ */ jsxs("div", {
3950
- className: cn("group/cell relative flex min-w-0 items-center gap-1.5", column.align === "right" && "justify-end", HIDE_BELOW[(_column$priority2 = column.priority) !== null && _column$priority2 !== void 0 ? _column$priority2 : "always"]),
3951
- style: columnStyle(column),
3761
+ }), /* @__PURE__ */ jsxs(PopoverContent$1, {
3762
+ className: "gap-group flex w-80 flex-col",
3763
+ children: [
3764
+ /* @__PURE__ */ jsxs("div", {
3765
+ className: "gap-element flex flex-col",
3766
+ children: [/* @__PURE__ */ jsx("p", {
3767
+ className: "text-text-primary text-xs font-medium",
3768
+ children: labels.quickSelect
3769
+ }), /* @__PURE__ */ jsxs("div", {
3770
+ className: "flex items-end gap-2",
3771
+ children: [
3772
+ /* @__PURE__ */ jsx(Input, {
3773
+ "aria-label": labels.quickSelect,
3774
+ type: "number",
3775
+ min: 1,
3776
+ size: "sm",
3777
+ className: "w-20",
3778
+ value: quickCount,
3779
+ onChange: (event) => setQuickCount(event.target.value),
3780
+ onKeyDown: (event) => {
3781
+ if (event.key === "Enter") applyQuick();
3782
+ }
3783
+ }),
3784
+ /* @__PURE__ */ jsxs(SelectRoot$1, {
3785
+ value: quickUnit,
3786
+ setValue: (next) => setQuickUnit(selectValue(next)),
3787
+ children: [/* @__PURE__ */ jsx(SelectTrigger$1, {
3788
+ minWidth: 110,
3789
+ "aria-label": labels.quickSelect
3790
+ }), /* @__PURE__ */ jsx(SelectPopover$1, { children: QUICK_UNITS.map((unit) => /* @__PURE__ */ jsx(SelectItem$1, {
3791
+ value: unit,
3792
+ children: labels.units[unit]
3793
+ }, unit)) })]
3794
+ }),
3795
+ /* @__PURE__ */ jsx(Button, {
3796
+ variant: "secondary",
3797
+ size: "sm",
3798
+ onClick: applyQuick,
3799
+ children: labels.apply
3800
+ })
3801
+ ]
3802
+ })]
3803
+ }),
3804
+ /* @__PURE__ */ jsxs("div", {
3805
+ className: "gap-element flex flex-col",
3806
+ children: [/* @__PURE__ */ jsx(Input, {
3807
+ label: labels.start,
3808
+ size: "sm",
3809
+ value: editing.from,
3810
+ error: boundState(editing.from),
3811
+ hint: labels.startHint,
3812
+ onChange: (event) => setDraft(_objectSpread2(_objectSpread2({}, editing), {}, { from: event.target.value })),
3813
+ onKeyDown: commitOnEnter
3814
+ }), /* @__PURE__ */ jsx(Input, {
3815
+ label: labels.end,
3816
+ size: "sm",
3817
+ value: editing.to,
3818
+ error: boundState(editing.to),
3819
+ onChange: (event) => setDraft(_objectSpread2(_objectSpread2({}, editing), {}, { to: event.target.value })),
3820
+ onKeyDown: commitOnEnter
3821
+ })]
3822
+ }),
3823
+ presets && presets.length > 0 && /* @__PURE__ */ jsx(RangeList, {
3824
+ title: labels.commonlyUsed,
3825
+ items: presets,
3826
+ onPick: (range) => {
3827
+ commit(range);
3828
+ setOpen(false);
3829
+ }
3830
+ }),
3831
+ recentRanges && recentRanges.length > 0 && /* @__PURE__ */ jsx(RangeList, {
3832
+ title: labels.recentlyUsed,
3833
+ items: recentRanges.slice(0, 5),
3834
+ onPick: (range) => {
3835
+ commit(range);
3836
+ setOpen(false);
3837
+ }
3838
+ }),
3839
+ onRefreshChange && /* @__PURE__ */ jsxs("div", {
3840
+ className: "gap-element flex flex-col",
3841
+ children: [/* @__PURE__ */ jsx("p", {
3842
+ className: "text-text-primary text-xs font-medium",
3843
+ children: labels.autoRefresh
3844
+ }), /* @__PURE__ */ jsxs(SelectRoot$1, {
3845
+ value: String((refresh === null || refresh === void 0 ? void 0 : refresh.paused) ? 0 : (_refresh$interval2 = refresh === null || refresh === void 0 ? void 0 : refresh.interval) !== null && _refresh$interval2 !== void 0 ? _refresh$interval2 : 0),
3846
+ setValue: (next) => {
3847
+ const interval = Number(selectValue(next));
3848
+ onRefreshChange({
3849
+ interval,
3850
+ paused: interval === 0
3851
+ });
3852
+ },
3853
+ children: [/* @__PURE__ */ jsx(SelectTrigger$1, {
3854
+ minWidth: 140,
3855
+ "aria-label": labels.autoRefresh
3856
+ }), /* @__PURE__ */ jsx(SelectPopover$1, { children: REFRESH_INTERVALS.map((interval) => /* @__PURE__ */ jsx(SelectItem$1, {
3857
+ value: String(interval),
3858
+ children: interval === 0 ? labels.autoRefreshOff : labels.every(formatDuration(interval, locale))
3859
+ }, interval)) })]
3860
+ })]
3861
+ })
3862
+ ]
3863
+ })]
3864
+ }),
3865
+ showWindowButtons && /* @__PURE__ */ jsxs("div", {
3866
+ className: cn("flex items-center border-l", "border border-border"),
3867
+ role: "group",
3868
+ "aria-label": labels.previous,
3952
3869
  children: [
3953
- column.marker && text && /* @__PURE__ */ jsx("span", {
3954
- "aria-hidden": true,
3955
- className: "size-1.5 shrink-0 rounded-full",
3956
- style: { backgroundColor: `hsl(${hueOf(text)} 65% 55%)` }
3870
+ /* @__PURE__ */ jsx(IconButton, {
3871
+ label: labels.previous,
3872
+ disabled: disabled || description.isInvalid,
3873
+ onClick: () => step(-1),
3874
+ children: /* @__PURE__ */ jsx(ChevronLeft, {
3875
+ className: "h-4 w-4",
3876
+ "aria-hidden": "true"
3877
+ })
3957
3878
  }),
3958
- /* @__PURE__ */ jsx("span", {
3959
- className: cn("min-w-0 flex-1", !column.truncate && "truncate", column.mono && "font-mono", text ? "text-text-secondary" : ABSENT),
3960
- title: column.truncate ? void 0 : text || void 0,
3961
- children: column.render ? column.render(row) : text ? column.truncate ? /* @__PURE__ */ jsx(TextTruncate, {
3962
- text,
3963
- truncation: column.truncate,
3964
- children: (shown) => highlightTerms && highlightTerms.length > 0 ? /* @__PURE__ */ jsx(Highlight, {
3965
- text: shown,
3966
- search: highlightTerms
3967
- }) : shown
3968
- }) : highlightTerms && highlightTerms.length > 0 ? /* @__PURE__ */ jsx(Highlight, {
3969
- text,
3970
- search: highlightTerms
3971
- }) : text : EM_DASH
3879
+ /* @__PURE__ */ jsx(IconButton, {
3880
+ label: labels.zoomIn,
3881
+ disabled: disabled || description.isInvalid,
3882
+ onClick: () => zoom(-zoomFactor),
3883
+ children: /* @__PURE__ */ jsx(ZoomIn, {
3884
+ className: "h-4 w-4",
3885
+ "aria-hidden": "true"
3886
+ })
3972
3887
  }),
3973
- onCellAction && column.field && text && /* @__PURE__ */ jsx(CellActions, {
3974
- field: column.field,
3975
- value: text,
3976
- onAction: onCellAction,
3977
- labels
3888
+ /* @__PURE__ */ jsx(IconButton, {
3889
+ label: labels.zoomOut,
3890
+ disabled: disabled || description.isInvalid,
3891
+ onClick: () => zoom(zoomFactor),
3892
+ children: /* @__PURE__ */ jsx(ZoomOut, {
3893
+ className: "h-4 w-4",
3894
+ "aria-hidden": "true"
3895
+ })
3896
+ }),
3897
+ /* @__PURE__ */ jsx(IconButton, {
3898
+ label: labels.nextWindow,
3899
+ disabled: disabled || description.isInvalid,
3900
+ onClick: () => step(1),
3901
+ children: /* @__PURE__ */ jsx(ChevronRight, {
3902
+ className: "h-4 w-4",
3903
+ "aria-hidden": "true"
3904
+ })
3978
3905
  })
3979
3906
  ]
3980
- }, column.id);
3981
- })
3982
- });
3983
- }
3984
- /**
3985
- * Memoized because a live log re-renders several times a second while almost
3986
- * every visible row is unchanged.
3987
- */
3988
- const LogTableRow = memo(LogTableRowImpl);
3989
- /**
3990
- * The two questions a reader has about a value they just spotted: show me only
3991
- * these, and get these out of my way.
3992
- *
3993
- * Revealed on hover rather than always drawn — a log is scanned, and a pair of
3994
- * buttons on every cell of every row is a wall of chrome. They stay in the tab
3995
- * order regardless, since hover is not an interaction everyone has.
3996
- */
3997
- function CellActions({ field, value, onAction, labels }) {
3998
- const button = "cursor-pointer rounded-sm p-0.5 text-text-tertiary hover:bg-bg-muted hover:text-text-primary";
3999
- return /* @__PURE__ */ jsxs("span", {
4000
- className: cn("bg-surface/90 ml-auto flex shrink-0 items-center gap-0.5 rounded pl-1", "opacity-0 transition-opacity", "group-hover/cell:opacity-100 focus-within:opacity-100"),
4001
- children: [/* @__PURE__ */ jsx("button", {
4002
- type: "button",
4003
- className: button,
4004
- title: labels.filterFor(value),
4005
- "aria-label": labels.filterFor(value),
4006
- onClick: (event) => {
4007
- event.stopPropagation();
4008
- onAction({
4009
- field,
4010
- value,
4011
- intent: "include"
4012
- });
4013
- },
4014
- children: /* @__PURE__ */ jsx(Plus, {
4015
- className: "size-3",
4016
- "aria-hidden": true
4017
- })
4018
- }), /* @__PURE__ */ jsx("button", {
4019
- type: "button",
4020
- className: button,
4021
- title: labels.filterOut(value),
4022
- "aria-label": labels.filterOut(value),
4023
- onClick: (event) => {
4024
- event.stopPropagation();
4025
- onAction({
4026
- field,
4027
- value,
4028
- intent: "exclude"
4029
- });
4030
- },
4031
- children: /* @__PURE__ */ jsx(Minus, {
4032
- className: "size-3",
4033
- "aria-hidden": true
3907
+ }),
3908
+ (onRefresh || showUpdateButton) && /* @__PURE__ */ jsx("div", {
3909
+ className: cn("border-l pl-2 pr-2", "border border-border"),
3910
+ children: /* @__PURE__ */ jsx(Button, {
3911
+ variant: hasChanges ? "primary" : "secondary",
3912
+ size: "sm",
3913
+ loading: isLoading,
3914
+ disabled: disabled || description.isInvalid,
3915
+ onClick: apply,
3916
+ leftIcon: hasChanges ? void 0 : /* @__PURE__ */ jsx(RefreshCw, {
3917
+ className: "h-3.5 w-3.5",
3918
+ "aria-hidden": "true"
3919
+ }),
3920
+ title: refreshLabel,
3921
+ children: hasChanges ? labels.update : labels.refresh
3922
+ })
4034
3923
  })
4035
- })]
4036
- });
4037
- }
4038
- //#endregion
4039
- //#region src/lib/query/query-ast.ts
4040
- const EMPTY_QUERY = { clauses: [] };
4041
- /** Whether a query constrains anything at all. */
4042
- function isEmptyQuery(query) {
4043
- return !query || query.clauses.length === 0;
4044
- }
4045
- function sameClause(a, b) {
4046
- if (a.type !== b.type) return false;
4047
- if (a.type === "term" && b.type === "term") return a.value === b.value && a.negated === b.negated;
4048
- const fieldA = a;
4049
- const fieldB = b;
4050
- return fieldA.field === fieldB.field && fieldA.operator === fieldB.operator && fieldA.negated === fieldB.negated && fieldA.values.length === fieldB.values.length && fieldA.values.every((value, index) => value === fieldB.values[index]);
4051
- }
4052
- /** Add a clause unless an identical one is already there. */
4053
- function addClause(query, clause) {
4054
- if (query.clauses.some((existing) => sameClause(existing, clause))) return query;
4055
- return { clauses: [...query.clauses, clause] };
4056
- }
4057
- /**
4058
- * Constrain a field to a value — what a cell's "filter for this" action does.
4059
- *
4060
- * Applying the opposite of a constraint already present replaces it rather than
4061
- * adding it: `status:200` then "filter out 200" should read as one changed
4062
- * mind, not as a query that can never match.
4063
- */
4064
- function withFieldValue(query, field, value, options = {}) {
4065
- var _options$negated, _options$operator;
4066
- const negated = (_options$negated = options.negated) !== null && _options$negated !== void 0 ? _options$negated : false;
4067
- const operator = (_options$operator = options.operator) !== null && _options$operator !== void 0 ? _options$operator : "eq";
4068
- return { clauses: [...query.clauses.filter((clause) => !(clause.type === "field" && clause.field === field && clause.operator === operator && clause.values.length === 1 && clause.values[0] === value)), {
4069
- type: "field",
4070
- field,
4071
- operator,
4072
- values: [value],
4073
- negated
4074
- }] };
4075
- }
4076
- /** Drop every clause naming `field`. */
4077
- function withoutField(query, field) {
4078
- return { clauses: query.clauses.filter((clause) => clause.type !== "field" || clause.field !== field) };
4079
- }
4080
- /** Drop the clause at `index`, for a removable chip. */
4081
- function removeClause(query, index) {
4082
- return { clauses: query.clauses.filter((_, at) => at !== index) };
4083
- }
4084
- /** Every field the query names, in the order they first appear. */
4085
- function queryFields(query) {
4086
- const seen = [];
4087
- for (const clause of query.clauses) if (clause.type === "field" && !seen.includes(clause.field)) seen.push(clause.field);
4088
- return seen;
4089
- }
4090
- /** The values a field is constrained to, if any — for showing a filter as active. */
4091
- function fieldValues(query, field) {
4092
- return query.clauses.flatMap((clause) => clause.type === "field" && clause.field === field && !clause.negated ? clause.values : []);
4093
- }
4094
- /**
4095
- * Fold a cell action into the query.
4096
- *
4097
- * The pair of buttons on a log cell is the fastest path from "I noticed this
4098
- * value" to a filtered view, and the reason it works is that the result is a
4099
- * query the reader can then see, edit and share — not a hidden filter stack
4100
- * with its own rules.
4101
- */
4102
- function applyCellAction(query, action) {
4103
- return withFieldValue(query, action.field, action.value, { negated: action.intent === "exclude" });
4104
- }
4105
- //#endregion
4106
- //#region src/lib/query/query-parse.ts
4107
- const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
4108
- /**
4109
- * Split on whitespace, but keep quoted runs and parenthesised groups whole.
4110
- *
4111
- * An unterminated quote or group swallows the rest of the line rather than
4112
- * erroring: the reader is mid-word, and the alternative is the query flickering
4113
- * between two different meanings as they type the closing character.
4114
- */
4115
- function tokenize(input) {
4116
- const tokens = [];
4117
- let index = 0;
4118
- while (index < input.length) {
4119
- while (index < input.length && /\s/.test(input[index])) index += 1;
4120
- if (index >= input.length) break;
4121
- const start = index;
4122
- let text = "";
4123
- let quoted = false;
4124
- let atTokenStart = true;
4125
- let depth = 0;
4126
- while (index < input.length) {
4127
- const char = input[index];
4128
- if (char === "\"") {
4129
- if (atTokenStart) quoted = true;
4130
- atTokenStart = false;
4131
- index += 1;
4132
- while (index < input.length && input[index] !== "\"") {
4133
- text += input[index];
4134
- index += 1;
4135
- }
4136
- index += 1;
4137
- continue;
4138
- }
4139
- if (char === "(") depth += 1;
4140
- if (char === ")") depth = Math.max(0, depth - 1);
4141
- if (/\s/.test(char) && depth === 0) break;
4142
- atTokenStart = false;
4143
- text += char;
4144
- index += 1;
4145
- }
4146
- if (text.length > 0 || quoted) tokens.push({
4147
- text,
4148
- at: start,
4149
- quoted
4150
- });
4151
- }
4152
- return tokens;
4153
- }
4154
- /** `(a or b)` → `["a", "b"]`; anything else is a single value. */
4155
- function readValues(raw) {
4156
- const trimmed = raw.trim();
4157
- if (!trimmed.startsWith("(")) return [stripQuotes(trimmed)];
4158
- return trimmed.replace(/^\(/, "").replace(/\)$/, "").split(/\s+or\s+/i).map((part) => stripQuotes(part.trim())).filter((part) => part.length > 0);
3924
+ ]
3925
+ });
4159
3926
  }
4160
- function stripQuotes(value) {
4161
- if (value.length >= 2 && value.startsWith("\"") && value.endsWith("\"")) return value.slice(1, -1);
4162
- return value.replace(/^"/, "");
3927
+ TimeRangeControl.displayName = "TimeRangeControl";
3928
+ function IconButton({ label, disabled, onClick, children }) {
3929
+ return /* @__PURE__ */ jsx(Button, {
3930
+ variant: "ghost",
3931
+ size: "sm",
3932
+ "aria-label": label,
3933
+ title: label,
3934
+ disabled,
3935
+ onClick,
3936
+ children
3937
+ });
4163
3938
  }
4164
- const OPERATORS = [
3939
+ function RangeList({ title, items, onPick }) {
3940
+ return /* @__PURE__ */ jsxs("div", {
3941
+ className: "gap-element flex flex-col",
3942
+ children: [/* @__PURE__ */ jsx("p", {
3943
+ className: "text-text-primary text-xs font-medium",
3944
+ children: title
3945
+ }), /* @__PURE__ */ jsx("ul", {
3946
+ className: "grid grid-cols-2 gap-1",
3947
+ children: items.map((item, index) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("button", {
3948
+ type: "button",
3949
+ className: cn("hover:bg-bg-subtle w-full cursor-pointer rounded px-2 py-1 text-left text-xs", OPT_TEXT_SECONDARY),
3950
+ onClick: () => onPick(item.range),
3951
+ children: item.label
3952
+ }) }, `${item.label}-${index}`))
3953
+ })]
3954
+ });
3955
+ }
3956
+ /**
3957
+ * The windows most analytics tools open with. Exported so a caller gets a
3958
+ * sensible list without inventing date math of their own.
3959
+ */
3960
+ const DEFAULT_TIME_PRESETS = [
4165
3961
  {
4166
- symbol: ">=",
4167
- operator: "gte"
3962
+ label: "Last 15 minutes",
3963
+ range: {
3964
+ from: "now-15m",
3965
+ to: "now"
3966
+ }
4168
3967
  },
4169
3968
  {
4170
- symbol: "<=",
4171
- operator: "lte"
3969
+ label: "Last hour",
3970
+ range: {
3971
+ from: "now-1h",
3972
+ to: "now"
3973
+ }
4172
3974
  },
4173
3975
  {
4174
- symbol: ":",
4175
- operator: "eq"
3976
+ label: "Last 24 hours",
3977
+ range: {
3978
+ from: "now-24h",
3979
+ to: "now"
3980
+ }
4176
3981
  },
4177
3982
  {
4178
- symbol: ">",
4179
- operator: "gt"
3983
+ label: "Last 7 days",
3984
+ range: {
3985
+ from: "now-7d",
3986
+ to: "now"
3987
+ }
4180
3988
  },
4181
3989
  {
4182
- symbol: "<",
4183
- operator: "lt"
4184
- }
4185
- ];
4186
- /** Where a field operator starts in the token, or `-1`. */
4187
- function findOperator(text) {
4188
- let best = null;
4189
- for (const { symbol, operator } of OPERATORS) {
4190
- const index = text.indexOf(symbol);
4191
- if (index <= 0) continue;
4192
- if (!best || index < best.index || index === best.index && symbol.length > best.symbol.length) best = {
4193
- index,
4194
- symbol,
4195
- operator
4196
- };
4197
- }
4198
- return best;
4199
- }
4200
- /** Parse, keeping whatever could not be understood as a plain term. */
4201
- function parseQueryWithDiagnostics(input) {
4202
- const diagnostics = [];
4203
- const clauses = [];
4204
- let trailing = null;
4205
- for (const token of tokenize(input)) {
4206
- let text = token.text;
4207
- let negated = false;
4208
- if (!token.quoted && text.startsWith("-") && text.length > 1) {
4209
- negated = true;
4210
- text = text.slice(1);
4211
- }
4212
- const operator = token.quoted ? null : findOperator(text);
4213
- if (!operator) {
4214
- if (text.length > 0) clauses.push({
4215
- type: "term",
4216
- value: text,
4217
- negated
4218
- });
4219
- continue;
4220
- }
4221
- const field = text.slice(0, operator.index);
4222
- const rawValue = text.slice(operator.index + operator.symbol.length);
4223
- if (!FIELD_NAME.test(field)) {
4224
- clauses.push({
4225
- type: "term",
4226
- value: text,
4227
- negated
4228
- });
4229
- continue;
4230
- }
4231
- if (rawValue === "") {
4232
- trailing = {
4233
- field,
4234
- operator: operator.operator,
4235
- value: ""
4236
- };
4237
- continue;
4238
- }
4239
- if (rawValue === "*" && operator.operator === "eq") {
4240
- clauses.push({
4241
- type: "field",
4242
- field,
4243
- operator: "exists",
4244
- values: [],
4245
- negated
4246
- });
4247
- continue;
3990
+ label: "Last 30 days",
3991
+ range: {
3992
+ from: "now-30d",
3993
+ to: "now"
4248
3994
  }
4249
- if (operator.operator === "eq" && rawValue.length > 2 && rawValue.startsWith("*") && rawValue.endsWith("*")) {
4250
- clauses.push({
4251
- type: "field",
4252
- field,
4253
- operator: "contains",
4254
- values: [stripQuotes(rawValue.slice(1, -1))],
4255
- negated
4256
- });
4257
- continue;
3995
+ },
3996
+ {
3997
+ label: "Last 90 days",
3998
+ range: {
3999
+ from: "now-90d",
4000
+ to: "now"
4258
4001
  }
4259
- const values = readValues(rawValue);
4260
- if (values.length === 0) {
4261
- diagnostics.push({
4262
- at: token.at,
4263
- text: token.text,
4264
- message: "Empty value group"
4265
- });
4266
- continue;
4002
+ },
4003
+ {
4004
+ label: "Today",
4005
+ range: {
4006
+ from: "now/d",
4007
+ to: "now/d"
4267
4008
  }
4268
- if (operator.operator !== "eq" && values.length > 1) diagnostics.push({
4269
- at: token.at,
4270
- text: token.text,
4271
- message: "A comparison takes a single value"
4272
- });
4273
- const clause = {
4274
- type: "field",
4275
- field,
4276
- operator: operator.operator,
4277
- values: operator.operator === "eq" ? values : values.slice(0, 1),
4278
- negated
4279
- };
4280
- clauses.push(clause);
4281
- if (!rawValue.startsWith("(")) {
4282
- var _values$;
4283
- trailing = {
4284
- field,
4285
- operator: operator.operator,
4286
- value: (_values$ = values[0]) !== null && _values$ !== void 0 ? _values$ : ""
4287
- };
4009
+ },
4010
+ {
4011
+ label: "This week",
4012
+ range: {
4013
+ from: "now/w",
4014
+ to: "now/w"
4288
4015
  }
4289
4016
  }
4290
- return {
4291
- query: { clauses },
4292
- diagnostics,
4293
- trailing
4294
- };
4295
- }
4296
- /** Parse, discarding diagnostics. */
4297
- function parseQuery(input) {
4298
- return parseQueryWithDiagnostics(input).query;
4017
+ ];
4018
+ //#endregion
4019
+ //#region src/shells/time-series-panel.tsx
4020
+ const defaultLabels$2 = {
4021
+ back: "Back to previous range",
4022
+ legend: "Series"
4023
+ };
4024
+ /**
4025
+ * TimeSeriesPanel — a chart you can steer.
4026
+ *
4027
+ * The point of putting the control and the chart in one component is the
4028
+ * loop between them: drag across the plot and the window narrows to what you
4029
+ * dragged, which re-queries, which redraws the axis you just picked. That
4030
+ * only works if someone owns the step back out again — so zoom keeps a
4031
+ * history, and the header offers the way back rather than leaving the reader
4032
+ * to retype the range they came from.
4033
+ *
4034
+ * @example
4035
+ * ```tsx
4036
+ * const [range, setRange] = useState<TimeRange>({ from: "now-7d", to: "now" });
4037
+ *
4038
+ * <TimeSeriesPanel
4039
+ * title="Sessions"
4040
+ * data={points}
4041
+ * series={[{ dataKey: "sessions", name: "Sessions" }]}
4042
+ * range={range}
4043
+ * onRangeChange={setRange}
4044
+ * onRefresh={() => refetch()}
4045
+ * toggleableLegend
4046
+ * />
4047
+ * ```
4048
+ */
4049
+ function TimeSeriesPanel({ title, description, actions, data, series, range, onRangeChange, granularity = "auto", timeZone, locale, showRangeControl = true, presets, recentRanges, refresh, onRefreshChange, onRefresh, toggleableLegend = false, annotations, isLoading = false, height = 280, className, chartProps, labels: customLabels, "aria-label": ariaLabel }) {
4050
+ const labels = _objectSpread2(_objectSpread2({}, defaultLabels$2), customLabels);
4051
+ const titleId = useId();
4052
+ const [history, setHistory] = useState([]);
4053
+ const [hidden, setHidden] = useState([]);
4054
+ const resolved = useMemo(() => resolveTimeRange(range, { timeZone }), [range, timeZone]);
4055
+ const visibleSeries = useMemo(() => series.filter((def) => !hidden.includes(def.dataKey)), [series, hidden]);
4056
+ const handleRangeSelect = useCallback((selection) => {
4057
+ setHistory((current) => [...current, range].slice(-10));
4058
+ onRangeChange(toAbsoluteRange(selection));
4059
+ }, [range, onRangeChange]);
4060
+ const goBack = useCallback(() => {
4061
+ setHistory((current) => {
4062
+ const previous = current[current.length - 1];
4063
+ if (previous) onRangeChange(previous);
4064
+ return current.slice(0, -1);
4065
+ });
4066
+ }, [onRangeChange]);
4067
+ const setRange = useCallback((next) => {
4068
+ setHistory([]);
4069
+ onRangeChange(next);
4070
+ }, [onRangeChange]);
4071
+ const toggleSeries = useCallback((dataKey) => {
4072
+ setHidden((current) => current.includes(dataKey) ? current.filter((key) => key !== dataKey) : [...current, dataKey]);
4073
+ }, []);
4074
+ return /* @__PURE__ */ jsxs("section", {
4075
+ className: cn(OPT_SURFACE, OPT_BORDER, "flex flex-col", className),
4076
+ "aria-label": ariaLabel,
4077
+ "aria-labelledby": ariaLabel == null && title != null ? titleId : void 0,
4078
+ "data-opt-id": "9EHHC",
4079
+ "data-opt-slug": "time-series-panel.TimeSeriesPanel",
4080
+ children: [/* @__PURE__ */ jsxs("header", {
4081
+ className: "gap-group flex flex-wrap items-start justify-between px-5 pt-5 pb-3",
4082
+ children: [/* @__PURE__ */ jsxs("div", {
4083
+ className: "gap-element flex min-w-0 flex-col",
4084
+ children: [title && /* @__PURE__ */ jsx("h3", {
4085
+ id: titleId,
4086
+ className: "text-text-primary truncate text-base font-semibold",
4087
+ children: title
4088
+ }), description && /* @__PURE__ */ jsx("p", {
4089
+ className: cn("text-text-secondary", "truncate text-sm"),
4090
+ children: description
4091
+ })]
4092
+ }), /* @__PURE__ */ jsxs("div", {
4093
+ className: "gap-element flex flex-wrap items-center",
4094
+ children: [
4095
+ history.length > 0 && /* @__PURE__ */ jsx(Button, {
4096
+ variant: "ghost",
4097
+ size: "sm",
4098
+ onClick: goBack,
4099
+ leftIcon: /* @__PURE__ */ jsx(Undo2, {
4100
+ className: "h-3.5 w-3.5",
4101
+ "aria-hidden": "true"
4102
+ }),
4103
+ children: labels.back
4104
+ }),
4105
+ actions,
4106
+ showRangeControl && /* @__PURE__ */ jsx(TimeRangeControl, {
4107
+ value: range,
4108
+ onChange: setRange,
4109
+ presets,
4110
+ recentRanges,
4111
+ timeZone,
4112
+ locale,
4113
+ refresh,
4114
+ onRefreshChange,
4115
+ onRefresh,
4116
+ isLoading,
4117
+ labels: customLabels === null || customLabels === void 0 ? void 0 : customLabels.control
4118
+ })
4119
+ ]
4120
+ })]
4121
+ }), /* @__PURE__ */ jsx("div", {
4122
+ className: "px-2 pb-4",
4123
+ children: /* @__PURE__ */ jsx(TimeSeriesChart, _objectSpread2({
4124
+ data,
4125
+ series: visibleSeries,
4126
+ from: resolved === null || resolved === void 0 ? void 0 : resolved.from,
4127
+ to: resolved === null || resolved === void 0 ? void 0 : resolved.to,
4128
+ granularity,
4129
+ timeZone,
4130
+ locale,
4131
+ height,
4132
+ selectable: true,
4133
+ onRangeSelect: handleRangeSelect,
4134
+ annotations,
4135
+ showLegend: series.length > 1,
4136
+ legendOptions: toggleableLegend ? {
4137
+ onClick: toggleSeries,
4138
+ inactiveKeys: hidden
4139
+ } : void 0,
4140
+ "aria-label": typeof title === "string" ? `${title} time series` : ariaLabel
4141
+ }, chartProps))
4142
+ })]
4143
+ });
4299
4144
  }
4145
+ TimeSeriesPanel.displayName = "TimeSeriesPanel";
4300
4146
  //#endregion
4301
- //#region src/lib/query/query-format.ts
4302
- const OPERATOR_SYMBOL = {
4303
- eq: ":",
4304
- contains: ":",
4305
- gt: ">",
4306
- gte: ">=",
4307
- lt: "<",
4308
- lte: "<=",
4309
- exists: ":"
4147
+ //#region src/shells/log-table.tsx
4148
+ const HIDE_BELOW = {
4149
+ always: "",
4150
+ sm: "hidden @[40rem]/log:flex",
4151
+ md: "hidden @[46rem]/log:flex",
4152
+ lg: "hidden @[58rem]/log:flex",
4153
+ xl: "hidden @[70rem]/log:flex"
4154
+ };
4155
+ const ROW_HEIGHT = {
4156
+ compact: 26,
4157
+ normal: 32,
4158
+ comfortable: 40
4159
+ };
4160
+ const DENSITY_TEXT = {
4161
+ compact: "text-[11px]",
4162
+ normal: "text-xs",
4163
+ comfortable: "text-sm"
4310
4164
  };
4311
- /** Quote a value that would otherwise re-parse as something else. */
4312
- function quoteValue(value) {
4313
- return /[\s"()]/.test(value) || value === "" ? `"${value.replace(/"/g, "")}"` : value;
4314
- }
4315
- /** One clause as text. */
4316
- function formatClause(clause) {
4317
- var _clause$values$2;
4318
- const prefix = clause.negated ? "-" : "";
4319
- if (clause.type === "term") return `${prefix}${quoteValue(clause.value)}`;
4320
- if (clause.operator === "exists") return `${prefix}${clause.field}:*`;
4321
- if (clause.operator === "contains") {
4322
- var _clause$values$;
4323
- return `${prefix}${clause.field}:*${(_clause$values$ = clause.values[0]) !== null && _clause$values$ !== void 0 ? _clause$values$ : ""}*`;
4324
- }
4325
- const symbol = OPERATOR_SYMBOL[clause.operator];
4326
- if (clause.values.length > 1) return `${prefix}${clause.field}${symbol}(${clause.values.map(quoteValue).join(" or ")})`;
4327
- return `${prefix}${clause.field}${symbol}${quoteValue((_clause$values$2 = clause.values[0]) !== null && _clause$values$2 !== void 0 ? _clause$values$2 : "")}`;
4328
- }
4329
- /** The whole query as text. */
4330
- function formatQuery(query) {
4331
- return query.clauses.map(formatClause).join(" ");
4332
- }
4333
- /** A short human label for a clause chip. */
4334
- function describeClause(clause) {
4335
- if (clause.type === "term") return clause.negated ? `not "${clause.value}"` : `"${clause.value}"`;
4336
- if (clause.operator === "exists") return clause.negated ? `${clause.field} missing` : `${clause.field} present`;
4337
- if (clause.operator === "contains") {
4338
- var _clause$values$3;
4339
- return `${clause.negated ? "not " : ""}${clause.field} ⊃ ${(_clause$values$3 = clause.values[0]) !== null && _clause$values$3 !== void 0 ? _clause$values$3 : ""}`;
4340
- }
4341
- const values = clause.values.join(", ");
4342
- const symbol = OPERATOR_SYMBOL[clause.operator];
4343
- return `${clause.negated ? "not " : ""}${clause.field} ${symbol === ":" ? "=" : symbol} ${values}`;
4344
- }
4345
- //#endregion
4346
- //#region src/shells/query-bar.tsx
4347
4165
  const defaultLabels$1 = {
4348
- placeholder: "Search, or filter with field:value",
4349
- ariaLabel: "Search query",
4350
- clear: "Clear query",
4351
- removeClause: (clause) => `Remove ${clause}`,
4352
- fieldsHeading: "Fields",
4353
- valuesHeading: "Values",
4354
- syntaxHint: null
4166
+ listLabel: "Log",
4167
+ loadingMore: "Loading earlier entries…",
4168
+ endOfResults: "That is every entry in this range.",
4169
+ filterFor: (value) => `Filter for ${value}`,
4170
+ filterOut: (value) => `Filter out ${value}`,
4171
+ sortAscending: "Sort ascending",
4172
+ sortDescending: "Sort descending",
4173
+ moveLeft: (column) => `Move ${column} left`,
4174
+ moveRight: (column) => `Move ${column} right`
4355
4175
  };
4356
- const MAX_SUGGESTIONS = 8;
4176
+ /** Rows rendered beyond the viewport on each side, so a fast scroll stays filled. */
4177
+ const OVERSCAN = 10;
4357
4178
  /**
4358
- * QueryBar one control for "find this" and "only these".
4179
+ * A stable hue per value. Colour does real work in a log — the eye groups by it
4180
+ * long before it reads the text — so the same event name has to land on the
4181
+ * same hue on every render and in every session, which rules out an index into
4182
+ * a palette.
4183
+ */
4184
+ function hueOf(value) {
4185
+ let hash = 0;
4186
+ for (let index = 0; index < value.length; index += 1) hash = hash * 31 + value.charCodeAt(index) | 0;
4187
+ return Math.abs(hash) % 360;
4188
+ }
4189
+ const EM_DASH = "—";
4190
+ /**
4191
+ * A column with nothing in it is a fact about the entry, not a value. Dimming
4192
+ * every absence the same way lets the eye skip the gaps while scanning, instead
4193
+ * of reading a dash as though it were data.
4194
+ */
4195
+ const ABSENT = "text-text-tertiary/50";
4196
+ function columnStyle(column) {
4197
+ return column.width ? {
4198
+ width: column.width,
4199
+ flex: "0 0 auto"
4200
+ } : {
4201
+ flex: "1 1 0%",
4202
+ minWidth: 0
4203
+ };
4204
+ }
4205
+ /**
4206
+ * LogTable — a windowed table for entries that keep arriving.
4359
4207
  *
4360
- * A search box and a row of filter pickers answer halves of the same question
4361
- * and neither can express the other, so people end up using both and holding
4362
- * the combination in their head. Typing the filter is what closes that: the
4363
- * whole question is one string, which means it can be read back, corrected in
4364
- * place, pasted to a colleague, and stored in a URL.
4208
+ * Written by hand rather than delegated to a data grid for the two things a log
4209
+ * needs that a grid does not: rows are inserted at the *top* while the reader
4210
+ * is partway down, which needs a scroll correction applied before paint, and
4211
+ * "am I at the newest row?" has to be answerable from the scroll container to
4212
+ * decide whether to follow the tail or hold position.
4365
4213
  *
4366
- * The text is the source of truth while the reader is typing; the parsed query
4367
- * is committed on Enter or blur. Anything half-written parses to a plain term
4368
- * rather than an error, so the bar never argues with someone mid-word.
4214
+ * What it takes from a data grid is the part readers actually reach for:
4215
+ * columns are data, so a caller can let people add, drop and reorder them; a
4216
+ * cell offers to filter for or against its own value; and density is a setting
4217
+ * rather than a constant.
4369
4218
  *
4370
4219
  * @example
4371
4220
  * ```tsx
4372
- * <QueryBar
4373
- * value={query}
4374
- * onChange={setQuery}
4375
- * fields={[{ name: "path", values: ["/checkout", "/cart"] }]}
4376
- * showClauses
4377
- * />
4378
- * ```
4379
- */
4380
- function QueryBar({ value, onChange, fields = [], showClauses = false, live = false, disabled = false, className, labels: customLabels }) {
4381
- const labels = _objectSpread2(_objectSpread2({}, defaultLabels$1), customLabels);
4382
- const inputRef = useRef(null);
4383
- const [text, setText] = useState(() => formatQuery(value));
4384
- const [focused, setFocused] = useState(false);
4385
- const [highlighted, setHighlighted] = useState(0);
4386
- const committedRef = useRef(formatQuery(value));
4387
- useEffect(() => {
4388
- const next = formatQuery(value);
4389
- if (next === committedRef.current) return;
4390
- committedRef.current = next;
4391
- if (!focused) setText(next);
4392
- }, [value, focused]);
4393
- const parsed = useMemo(() => parseQueryWithDiagnostics(text), [text]);
4394
- const commit = useCallback((nextText) => {
4395
- const nextQuery = parseQueryWithDiagnostics(nextText).query;
4396
- committedRef.current = formatQuery(nextQuery);
4397
- onChange(nextQuery);
4398
- }, [onChange]);
4399
- const suggestions = useSuggestions(text, parsed.trailing, fields);
4400
- const applySuggestion = useCallback((suggestion) => {
4401
- var _inputRef$current;
4402
- const nextText = suggestion.replace(text);
4403
- setText(nextText);
4404
- if (suggestion.kind === "value") commit(nextText);
4405
- (_inputRef$current = inputRef.current) === null || _inputRef$current === void 0 || _inputRef$current.focus();
4406
- setHighlighted(0);
4407
- }, [text, commit]);
4408
- const handleKeyDown = (event) => {
4409
- if (suggestions.length > 0 && focused) {
4410
- if (event.key === "ArrowDown") {
4411
- event.preventDefault();
4412
- setHighlighted((current) => (current + 1) % suggestions.length);
4413
- return;
4414
- }
4415
- if (event.key === "ArrowUp") {
4416
- event.preventDefault();
4417
- setHighlighted((current) => (current - 1 + suggestions.length) % suggestions.length);
4418
- return;
4419
- }
4420
- if (event.key === "Tab" || event.key === "Enter" && highlighted > 0) {
4421
- event.preventDefault();
4422
- applySuggestion(suggestions[highlighted]);
4423
- return;
4221
+ * <LogTable
4222
+ * rows={rows}
4223
+ * getRowId={(row) => row.id}
4224
+ * columns={[
4225
+ * { id: "time", header: "Time", width: "9rem", mono: true, value: (r) => r.time },
4226
+ * { id: "name", header: "Event", width: "14rem", field: "name", marker: true, value: (r) => r.name },
4227
+ * { id: "path", header: "Path", field: "path", value: (r) => r.path },
4228
+ * ]}
4229
+ * onCellAction={(action) => setQuery(applyCellAction(query, action))}
4230
+ * />
4231
+ * ```
4232
+ */
4233
+ function LogTableInner({ rows, columns, getRowId, selectedId = null, onSelect, onLoadMore, hasMore = false, loadingMore = false, prepending = false, onFollowChange, isNew, sort = null, onSortChange, onCellAction, highlightTerms, onColumnMove, density = "normal", showHeader = true, emptyState, labels: customLabels, className }, ref) {
4234
+ const labels = _objectSpread2(_objectSpread2({}, defaultLabels$1), customLabels);
4235
+ const rowHeight = ROW_HEIGHT[density];
4236
+ const scrollRef = useRef(null);
4237
+ const [scrollTop, setScrollTop] = useState(0);
4238
+ const [viewportHeight, setViewportHeight] = useState(0);
4239
+ const followingRef = useRef(true);
4240
+ const previousTopIdRef = useRef(null);
4241
+ const previousLengthRef = useRef(0);
4242
+ useImperativeHandle(ref, () => ({
4243
+ scrollToTop: () => {
4244
+ const container = scrollRef.current;
4245
+ if (!container) return;
4246
+ container.scrollTop = 0;
4247
+ setScrollTop(0);
4248
+ if (!followingRef.current) {
4249
+ followingRef.current = true;
4250
+ onFollowChange === null || onFollowChange === void 0 || onFollowChange(true);
4424
4251
  }
4252
+ },
4253
+ revealRow: (id) => {
4254
+ const index = rows.findIndex((row) => getRowId(row) === id);
4255
+ const container = scrollRef.current;
4256
+ if (index === -1 || !container) return;
4257
+ const top = index * rowHeight;
4258
+ const bottom = top + rowHeight;
4259
+ if (top < container.scrollTop) container.scrollTop = top;
4260
+ else if (bottom > container.scrollTop + container.clientHeight) container.scrollTop = bottom - container.clientHeight;
4425
4261
  }
4426
- if (event.key === "Enter") {
4427
- event.preventDefault();
4428
- commit(text);
4429
- return;
4430
- }
4431
- if (event.key === "Escape") {
4432
- var _inputRef$current2;
4433
- setText(committedRef.current);
4434
- (_inputRef$current2 = inputRef.current) === null || _inputRef$current2 === void 0 || _inputRef$current2.blur();
4435
- }
4436
- };
4437
- const showSuggestions = focused && suggestions.length > 0;
4262
+ }), [
4263
+ onFollowChange,
4264
+ rows,
4265
+ getRowId,
4266
+ rowHeight
4267
+ ]);
4268
+ useEffect(() => {
4269
+ const container = scrollRef.current;
4270
+ if (!container) return;
4271
+ const observer = new ResizeObserver(() => setViewportHeight(container.clientHeight));
4272
+ observer.observe(container);
4273
+ setViewportHeight(container.clientHeight);
4274
+ return () => observer.disconnect();
4275
+ }, []);
4276
+ /**
4277
+ * Report whether the list is pinned to the newest row.
4278
+ *
4279
+ * Called from more than the scroll handler on purpose: the list can arrive at
4280
+ * the top without a scroll event — a reset, a jump to newest, a shorter
4281
+ * result set — and a stale "not following" would silently divert every live
4282
+ * row into a held-back pile.
4283
+ */
4284
+ const syncFollowing = useCallback((container) => {
4285
+ const following = container.scrollTop <= rowHeight;
4286
+ if (following === followingRef.current) return;
4287
+ followingRef.current = following;
4288
+ onFollowChange === null || onFollowChange === void 0 || onFollowChange(following);
4289
+ }, [onFollowChange, rowHeight]);
4290
+ /**
4291
+ * Hold the reader's place when rows arrive above them.
4292
+ *
4293
+ * Runs before paint: by the time the browser draws, the scroll offset has
4294
+ * already absorbed the inserted height, so the content under the cursor never
4295
+ * moves. Skipped while following, where the whole point is to stay at the top.
4296
+ */
4297
+ useLayoutEffect(() => {
4298
+ const container = scrollRef.current;
4299
+ const previousTopId = previousTopIdRef.current;
4300
+ previousTopIdRef.current = rows.length ? getRowId(rows[0]) : null;
4301
+ const previousLength = previousLengthRef.current;
4302
+ previousLengthRef.current = rows.length;
4303
+ if (!container) return;
4304
+ syncFollowing(container);
4305
+ if (!prepending || followingRef.current || !previousTopId) return;
4306
+ if (rows.length <= previousLength) return;
4307
+ const prepended = rows.findIndex((row) => getRowId(row) === previousTopId);
4308
+ if (prepended <= 0) return;
4309
+ container.scrollTop += prepended * rowHeight;
4310
+ setScrollTop(container.scrollTop);
4311
+ }, [
4312
+ rows,
4313
+ getRowId,
4314
+ prepending,
4315
+ rowHeight,
4316
+ syncFollowing
4317
+ ]);
4318
+ /**
4319
+ * Page forward when the rows do not reach the bottom of the viewport.
4320
+ *
4321
+ * Without this the list only advances on a scroll event, so a tall screen —
4322
+ * or a filter matching few rows per page — stops at the first page with no
4323
+ * way to ask for more.
4324
+ */
4325
+ useEffect(() => {
4326
+ const container = scrollRef.current;
4327
+ if (!container || !hasMore || loadingMore || !onLoadMore) return;
4328
+ if (container.scrollHeight <= container.clientHeight) onLoadMore();
4329
+ }, [
4330
+ hasMore,
4331
+ loadingMore,
4332
+ onLoadMore,
4333
+ rows.length,
4334
+ viewportHeight
4335
+ ]);
4336
+ const handleScroll = useCallback((event) => {
4337
+ const container = event.currentTarget;
4338
+ setScrollTop(container.scrollTop);
4339
+ syncFollowing(container);
4340
+ const remaining = container.scrollHeight - container.scrollTop - container.clientHeight;
4341
+ if (hasMore && !loadingMore && remaining < container.clientHeight) onLoadMore === null || onLoadMore === void 0 || onLoadMore();
4342
+ }, [
4343
+ hasMore,
4344
+ loadingMore,
4345
+ onLoadMore,
4346
+ syncFollowing
4347
+ ]);
4348
+ const totalHeight = rows.length * rowHeight;
4349
+ const firstVisible = Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN);
4350
+ const visibleCount = Math.ceil((viewportHeight || 480) / rowHeight) + 20;
4351
+ const lastVisible = Math.min(rows.length, firstVisible + visibleCount);
4352
+ const visibleRows = useMemo(() => rows.slice(firstVisible, lastVisible), [
4353
+ rows,
4354
+ firstVisible,
4355
+ lastVisible
4356
+ ]);
4438
4357
  return /* @__PURE__ */ jsxs("div", {
4439
- className: cn("relative flex min-w-0 flex-col gap-1.5", className),
4440
- "data-opt-id": "JSVZ1",
4441
- "data-opt-slug": "query-bar.QueryBar",
4442
- children: [
4443
- /* @__PURE__ */ jsxs("div", {
4444
- className: cn("bg-surface flex h-9 items-center gap-2 rounded-md border px-2.5", OPT_BORDER, "focus-within:border-accent transition-colors", disabled && "opacity-60"),
4445
- children: [
4446
- /* @__PURE__ */ jsx(Search, {
4447
- className: "text-text-tertiary size-4 shrink-0",
4358
+ className: cn("@container/log flex min-h-0 flex-col", className),
4359
+ "data-opt-id": "3ZR82",
4360
+ "data-opt-slug": "log-table.LogTable",
4361
+ children: [showHeader && /* @__PURE__ */ jsx(LogTableHeader, {
4362
+ columns,
4363
+ sort,
4364
+ onSortChange,
4365
+ onColumnMove,
4366
+ density,
4367
+ labels
4368
+ }), /* @__PURE__ */ jsxs("div", {
4369
+ ref: scrollRef,
4370
+ onScroll: handleScroll,
4371
+ className: "relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto",
4372
+ role: "listbox",
4373
+ "aria-label": labels.listLabel,
4374
+ "aria-activedescendant": selectedId ? `log-row-${selectedId}` : void 0,
4375
+ tabIndex: 0,
4376
+ children: [
4377
+ rows.length === 0 ? emptyState : /* @__PURE__ */ jsx("div", {
4378
+ style: { height: totalHeight },
4379
+ className: "relative",
4380
+ children: visibleRows.map((row, index) => {
4381
+ var _isNew;
4382
+ const id = getRowId(row);
4383
+ return /* @__PURE__ */ jsx(LogTableRow, {
4384
+ id,
4385
+ row,
4386
+ columns,
4387
+ top: (firstVisible + index) * rowHeight,
4388
+ height: rowHeight,
4389
+ density,
4390
+ selected: id === selectedId,
4391
+ isNew: (_isNew = isNew === null || isNew === void 0 ? void 0 : isNew(row)) !== null && _isNew !== void 0 ? _isNew : false,
4392
+ onSelect,
4393
+ onCellAction,
4394
+ highlightTerms,
4395
+ labels
4396
+ }, id);
4397
+ })
4398
+ }),
4399
+ loadingMore && /* @__PURE__ */ jsxs("div", {
4400
+ className: "gap-element text-text-tertiary flex items-center justify-center py-3 text-xs",
4401
+ children: [/* @__PURE__ */ jsx(Spinner, {
4402
+ size: "sm",
4448
4403
  "aria-hidden": true
4404
+ }), labels.loadingMore]
4405
+ }),
4406
+ !hasMore && rows.length > 0 && /* @__PURE__ */ jsx("p", {
4407
+ className: cn("text-text-tertiary", "py-3 text-center text-xs"),
4408
+ children: labels.endOfResults
4409
+ })
4410
+ ]
4411
+ })]
4412
+ });
4413
+ }
4414
+ /**
4415
+ * LogTable — see {@link LogTableInner}.
4416
+ *
4417
+ * The cast preserves the row generic through `forwardRef`, which otherwise
4418
+ * widens it to its constraint and takes the column callbacks' types with it.
4419
+ */
4420
+ const LogTable = forwardRef(LogTableInner);
4421
+ function LogTableHeader({ columns, sort, onSortChange, onColumnMove, density, labels }) {
4422
+ return /* @__PURE__ */ jsx("div", {
4423
+ role: "row",
4424
+ className: cn("border-border-subtle text-text-tertiary flex shrink-0 items-center gap-3 border-b px-3", DENSITY_TEXT[density]),
4425
+ style: { height: ROW_HEIGHT[density] },
4426
+ children: columns.map((column, index) => {
4427
+ var _column$priority;
4428
+ const active = (sort === null || sort === void 0 ? void 0 : sort.columnId) === column.id;
4429
+ const sortable = column.sortable && onSortChange;
4430
+ const columnLabel = typeof column.header === "string" ? column.header : column.id;
4431
+ const content = /* @__PURE__ */ jsxs(Fragment, { children: [
4432
+ column.marker && /* @__PURE__ */ jsx("span", {
4433
+ "aria-hidden": true,
4434
+ className: "size-1.5 shrink-0"
4435
+ }),
4436
+ /* @__PURE__ */ jsx("span", {
4437
+ className: "truncate",
4438
+ children: column.header
4439
+ }),
4440
+ active && (sort.direction === "asc" ? /* @__PURE__ */ jsx(ChevronUp, {
4441
+ className: "size-3 shrink-0",
4442
+ "aria-hidden": true
4443
+ }) : /* @__PURE__ */ jsx(ChevronDown, {
4444
+ className: "size-3 shrink-0",
4445
+ "aria-hidden": true
4446
+ }))
4447
+ ] });
4448
+ return /* @__PURE__ */ jsxs("div", {
4449
+ role: "columnheader",
4450
+ "aria-sort": active ? sort.direction === "asc" ? "ascending" : "descending" : void 0,
4451
+ className: cn("group/header flex items-center gap-1.5", column.align === "right" && "justify-end", HIDE_BELOW[(_column$priority = column.priority) !== null && _column$priority !== void 0 ? _column$priority : "always"]),
4452
+ style: columnStyle(column),
4453
+ children: [sortable ? /* @__PURE__ */ jsx("button", {
4454
+ type: "button",
4455
+ className: "hover:text-text-primary flex cursor-pointer items-center gap-1.5 truncate",
4456
+ title: active && sort.direction === "desc" ? labels.sortAscending : labels.sortDescending,
4457
+ onClick: () => onSortChange({
4458
+ columnId: column.id,
4459
+ direction: active && sort.direction === "desc" ? "asc" : "desc"
4449
4460
  }),
4450
- /* @__PURE__ */ jsx("input", {
4451
- ref: inputRef,
4452
- type: "text",
4453
- role: "combobox",
4454
- "aria-expanded": showSuggestions,
4455
- "aria-controls": "query-bar-suggestions",
4456
- "aria-autocomplete": "list",
4457
- "aria-label": labels.ariaLabel,
4458
- placeholder: labels.placeholder,
4459
- disabled,
4460
- className: cn("text-text-primary placeholder:text-text-tertiary min-w-0 flex-1", "bg-transparent font-mono text-sm outline-none focus-visible:outline-none"),
4461
- value: text,
4462
- onChange: (event) => {
4463
- setText(event.target.value);
4464
- setHighlighted(0);
4465
- if (live) commit(event.target.value);
4466
- },
4467
- onFocus: () => setFocused(true),
4468
- onBlur: () => {
4469
- window.setTimeout(() => setFocused(false), 120);
4470
- if (!live) commit(text);
4471
- },
4472
- onKeyDown: handleKeyDown
4473
- }),
4474
- text.length > 0 && /* @__PURE__ */ jsx("button", {
4461
+ children: content
4462
+ }) : content, onColumnMove && /* @__PURE__ */ jsxs("span", {
4463
+ className: cn("ml-auto flex shrink-0 items-center gap-0.5", "opacity-0 transition-opacity", "group-hover/header:opacity-100 focus-within:opacity-100"),
4464
+ children: [index > 0 && /* @__PURE__ */ jsx("button", {
4475
4465
  type: "button",
4476
- "aria-label": labels.clear,
4477
- title: labels.clear,
4478
- className: cn("text-text-tertiary hover:text-text-primary cursor-pointer rounded-sm p-0.5", "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-ring-offset"),
4479
- onClick: () => {
4480
- setText("");
4481
- commit("");
4482
- },
4483
- children: /* @__PURE__ */ jsx(X, {
4484
- className: "size-3.5",
4466
+ className: "hover:text-text-primary cursor-pointer",
4467
+ "aria-label": labels.moveLeft(columnLabel),
4468
+ title: labels.moveLeft(columnLabel),
4469
+ onClick: () => onColumnMove(column.id, "left"),
4470
+ children: /* @__PURE__ */ jsx(ChevronLeft, {
4471
+ className: "size-3",
4472
+ "aria-hidden": true
4473
+ })
4474
+ }), index < columns.length - 1 && /* @__PURE__ */ jsx("button", {
4475
+ type: "button",
4476
+ className: "hover:text-text-primary cursor-pointer",
4477
+ "aria-label": labels.moveRight(columnLabel),
4478
+ title: labels.moveRight(columnLabel),
4479
+ onClick: () => onColumnMove(column.id, "right"),
4480
+ children: /* @__PURE__ */ jsx(ChevronRight, {
4481
+ className: "size-3",
4485
4482
  "aria-hidden": true
4486
4483
  })
4487
- })
4488
- ]
4489
- }),
4490
- showSuggestions && /* @__PURE__ */ jsx("ul", {
4491
- id: "query-bar-suggestions",
4492
- role: "listbox",
4493
- className: cn("bg-surface-overlay absolute top-10 z-40 max-h-64 w-full overflow-y-auto rounded-md border p-1 shadow-lg", "border border-border"),
4494
- children: suggestions.map((suggestion, index) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("button", {
4495
- type: "button",
4496
- role: "option",
4497
- "aria-selected": index === highlighted,
4498
- className: cn("flex w-full cursor-pointer items-center justify-between gap-3 rounded px-2 py-1 text-left text-sm", index === highlighted ? "bg-bg-subtle" : "hover:bg-bg-subtle"),
4499
- onMouseEnter: () => setHighlighted(index),
4500
- onMouseDown: (event) => event.preventDefault(),
4501
- onClick: () => applySuggestion(suggestion),
4502
- children: [/* @__PURE__ */ jsx("span", {
4503
- className: "text-text-primary truncate font-mono",
4504
- children: suggestion.label
4505
- }), suggestion.hint && /* @__PURE__ */ jsx("span", {
4506
- className: "text-text-tertiary shrink-0 text-xs",
4507
- children: suggestion.hint
4508
4484
  })]
4509
- }) }, `${suggestion.kind}-${suggestion.label}`))
4510
- }),
4511
- showClauses && value.clauses.length > 0 && /* @__PURE__ */ jsx("ul", {
4512
- className: "flex flex-wrap items-center gap-1.5",
4513
- children: value.clauses.map((clause, index) => {
4514
- const label = describeClause(clause);
4515
- return /* @__PURE__ */ jsxs("li", {
4516
- className: cn("bg-bg-subtle text-text-secondary flex items-center gap-1 rounded px-1.5 py-0.5 text-xs", clause.negated && "text-danger-fg"),
4517
- children: [/* @__PURE__ */ jsx("span", {
4518
- className: "truncate",
4519
- children: label
4520
- }), /* @__PURE__ */ jsx("button", {
4521
- type: "button",
4522
- "aria-label": labels.removeClause(label),
4523
- className: "hover:text-text-primary cursor-pointer",
4524
- onClick: () => {
4525
- const next = removeClause(value, index);
4526
- setText(formatQuery(next));
4527
- committedRef.current = formatQuery(next);
4528
- onChange(next);
4529
- },
4530
- children: /* @__PURE__ */ jsx(X, {
4531
- className: "size-3",
4532
- "aria-hidden": true
4533
- })
4534
- })]
4535
- }, `${label}-${index}`);
4536
- })
4537
- }),
4538
- parsed.diagnostics.length > 0 && /* @__PURE__ */ jsx(QueryDiagnostics, { diagnostics: parsed.diagnostics }),
4539
- labels.syntaxHint && !showSuggestions && /* @__PURE__ */ jsx("p", {
4540
- className: "text-text-tertiary text-xs",
4541
- children: labels.syntaxHint
4542
- })
4543
- ]
4485
+ })]
4486
+ }, column.id);
4487
+ })
4544
4488
  });
4545
4489
  }
4546
- QueryBar.displayName = "QueryBar";
4547
- function QueryDiagnostics({ diagnostics }) {
4548
- return /* @__PURE__ */ jsx("ul", {
4549
- className: "text-warning-fg flex flex-col gap-0.5 text-xs",
4550
- children: diagnostics.map((diagnostic, index) => /* @__PURE__ */ jsxs("li", { children: [
4551
- /* @__PURE__ */ jsx("code", {
4552
- className: "font-mono",
4553
- children: diagnostic.text
4554
- }),
4555
- " ",
4556
- " ",
4557
- diagnostic.message
4558
- ] }, `${diagnostic.at}-${index}`))
4490
+ function LogTableRowImpl({ id, row, columns, top, height, density, selected, isNew, onSelect, onCellAction, highlightTerms, labels }) {
4491
+ return /* @__PURE__ */ jsx("div", {
4492
+ role: "option",
4493
+ "aria-selected": selected,
4494
+ tabIndex: -1,
4495
+ id: `log-row-${id}`,
4496
+ "data-log-row-id": id,
4497
+ onClick: () => onSelect === null || onSelect === void 0 ? void 0 : onSelect(id),
4498
+ onKeyDown: (event) => {
4499
+ if (event.key !== "Enter" && event.key !== " ") return;
4500
+ event.preventDefault();
4501
+ onSelect === null || onSelect === void 0 || onSelect(id);
4502
+ },
4503
+ style: {
4504
+ top,
4505
+ height
4506
+ },
4507
+ className: cn("border-border-subtle/60 absolute inset-x-0 flex items-center gap-3 border-b px-3", DENSITY_TEXT[density], onSelect && "cursor-pointer", "transition-colors duration-100", "hover:bg-surface-hover", selected && "bg-accent-subtle hover:bg-accent-subtle", isNew && !selected && "opt-log-row-enter"),
4508
+ children: columns.map((column) => {
4509
+ var _column$priority2;
4510
+ const raw = column.value(row);
4511
+ const text = raw === null || raw === void 0 || raw === "" ? "" : String(raw);
4512
+ return /* @__PURE__ */ jsxs("div", {
4513
+ className: cn("group/cell relative flex min-w-0 items-center gap-1.5", column.align === "right" && "justify-end", HIDE_BELOW[(_column$priority2 = column.priority) !== null && _column$priority2 !== void 0 ? _column$priority2 : "always"]),
4514
+ style: columnStyle(column),
4515
+ children: [
4516
+ column.marker && text && /* @__PURE__ */ jsx("span", {
4517
+ "aria-hidden": true,
4518
+ className: "size-1.5 shrink-0 rounded-full",
4519
+ style: { backgroundColor: `hsl(${hueOf(text)} 65% 55%)` }
4520
+ }),
4521
+ /* @__PURE__ */ jsx("span", {
4522
+ className: cn("min-w-0 flex-1", !column.truncate && "truncate", column.mono && "font-mono", text ? "text-text-secondary" : ABSENT),
4523
+ title: column.truncate ? void 0 : text || void 0,
4524
+ children: column.render ? column.render(row) : text ? column.truncate ? /* @__PURE__ */ jsx(TextTruncate, {
4525
+ text,
4526
+ truncation: column.truncate,
4527
+ children: (shown) => highlightTerms && highlightTerms.length > 0 ? /* @__PURE__ */ jsx(Highlight, {
4528
+ text: shown,
4529
+ search: highlightTerms
4530
+ }) : shown
4531
+ }) : highlightTerms && highlightTerms.length > 0 ? /* @__PURE__ */ jsx(Highlight, {
4532
+ text,
4533
+ search: highlightTerms
4534
+ }) : text : EM_DASH
4535
+ }),
4536
+ onCellAction && column.field && text && /* @__PURE__ */ jsx(CellActions, {
4537
+ field: column.field,
4538
+ value: text,
4539
+ onAction: onCellAction,
4540
+ labels
4541
+ })
4542
+ ]
4543
+ }, column.id);
4544
+ })
4559
4545
  });
4560
4546
  }
4561
4547
  /**
4562
- * What to offer, based on the token under the cursor.
4548
+ * Memoized because a live log re-renders several times a second while almost
4549
+ * every visible row is unchanged.
4550
+ */
4551
+ const LogTableRow = memo(LogTableRowImpl);
4552
+ /**
4553
+ * The two questions a reader has about a value they just spotted: show me only
4554
+ * these, and get these out of my way.
4563
4555
  *
4564
- * Completing fields is the part that makes the syntax discoverable at all
4565
- * nobody guesses that `utm_source` is a field they can filter on. Once the
4566
- * field is chosen, its known values are the second half of the same guess.
4556
+ * Revealed on hover rather than always drawn a log is scanned, and a pair of
4557
+ * buttons on every cell of every row is a wall of chrome. They stay in the tab
4558
+ * order regardless, since hover is not an interaction everyone has.
4567
4559
  */
4568
- function useSuggestions(text, trailing, fields) {
4569
- return useMemo(() => {
4570
- const lastSpace = text.lastIndexOf(" ");
4571
- const head = lastSpace === -1 ? "" : text.slice(0, lastSpace + 1);
4572
- const token = text.slice(lastSpace + 1);
4573
- if (trailing) {
4574
- var _field$values;
4575
- const field = fields.find((candidate) => candidate.name === trailing.field);
4576
- const values = (_field$values = field === null || field === void 0 ? void 0 : field.values) !== null && _field$values !== void 0 ? _field$values : [];
4577
- const typed = trailing.value.toLowerCase();
4578
- return values.filter((value) => value.toLowerCase().includes(typed)).slice(0, MAX_SUGGESTIONS).map((value) => ({
4579
- kind: "value",
4580
- label: `${trailing.field}:${value}`,
4581
- replace: () => `${head}${trailing.field}:${/[\s"()]/.test(value) ? `"${value}"` : value} `
4582
- }));
4583
- }
4584
- const bare = token.replace(/^-/, "").toLowerCase();
4585
- if (bare.length === 0) return [];
4586
- return fields.filter((field) => field.name.toLowerCase().includes(bare)).slice(0, MAX_SUGGESTIONS).map((field) => ({
4587
- kind: "field",
4588
- label: `${field.name}:`,
4589
- hint: field.hint,
4590
- replace: () => `${head}${token.startsWith("-") ? "-" : ""}${field.name}:`
4591
- }));
4592
- }, [
4593
- text,
4594
- trailing,
4595
- fields
4596
- ]);
4560
+ function CellActions({ field, value, onAction, labels }) {
4561
+ const button = "cursor-pointer rounded-sm p-0.5 text-text-tertiary hover:bg-bg-muted hover:text-text-primary";
4562
+ return /* @__PURE__ */ jsxs("span", {
4563
+ className: cn("bg-surface/90 ml-auto flex shrink-0 items-center gap-0.5 rounded pl-1", "opacity-0 transition-opacity", "group-hover/cell:opacity-100 focus-within:opacity-100"),
4564
+ children: [/* @__PURE__ */ jsx("button", {
4565
+ type: "button",
4566
+ className: button,
4567
+ title: labels.filterFor(value),
4568
+ "aria-label": labels.filterFor(value),
4569
+ onClick: (event) => {
4570
+ event.stopPropagation();
4571
+ onAction({
4572
+ field,
4573
+ value,
4574
+ intent: "include"
4575
+ });
4576
+ },
4577
+ children: /* @__PURE__ */ jsx(Plus, {
4578
+ className: "size-3",
4579
+ "aria-hidden": true
4580
+ })
4581
+ }), /* @__PURE__ */ jsx("button", {
4582
+ type: "button",
4583
+ className: button,
4584
+ title: labels.filterOut(value),
4585
+ "aria-label": labels.filterOut(value),
4586
+ onClick: (event) => {
4587
+ event.stopPropagation();
4588
+ onAction({
4589
+ field,
4590
+ value,
4591
+ intent: "exclude"
4592
+ });
4593
+ },
4594
+ children: /* @__PURE__ */ jsx(Minus, {
4595
+ className: "size-3",
4596
+ "aria-hidden": true
4597
+ })
4598
+ })]
4599
+ });
4597
4600
  }
4598
4601
  //#endregion
4599
4602
  //#region src/shells/field-sidebar.tsx
@@ -4819,6 +4822,4 @@ function FieldValues({ values, loading, total, sampledRows, onFilter, labels })
4819
4822
  });
4820
4823
  }
4821
4824
  //#endregion
4822
- export { CommandGroup$1 as $, ToastProvider as A, FaqAccordion as B, Flyout as C, KeyValueEditor as D, WorkflowCanvas as E, BranchSelect as F, StatusSelect as G, EditorToolbar as H, EnvPanel as I, CommandPaletteTrigger as J, SearchCombobox as K, ProjectSwitcher as L, useToast as M, DataTable as N, FailureList as O, DomainTable as P, CommandEmpty$1 as Q, DeploymentTimeline as R, useAutoRefresh as S, EventTimeline as T, SidebarNav as U, ContentTabs as V, AppMenubar as W, Command as X, CommandPalette as Y, CommandDialog as Z, withoutField as _, formatQuery as a, DEFAULT_TIME_PRESETS as b, parseQueryWithDiagnostics as c, applyCellAction as d, CommandInput$1 as et, fieldValues as f, withFieldValue as g, removeClause as h, formatClause as i, CommandShortcut as it, toast as j, NotificationToast as k, EMPTY_QUERY as l, queryFields as m, QueryBar as n, CommandList$1 as nt, quoteValue as o, isEmptyQuery as p, DashboardGrid as q, describeClause as r, CommandSeparator$1 as rt, parseQuery as s, FieldSidebar as t, CommandItem$1 as tt, addClause as u, LogTable as v, ScoreBreakdown as w, TimeRangeControl as x, TimeSeriesPanel as y, SettingsForm as z };
4823
-
4824
- //# sourceMappingURL=field-sidebar-BXl5RJ3q.js.map
4825
+ export { CommandGroup$1 as $, ToastProvider as A, FaqAccordion as B, Flyout as C, KeyValueEditor as D, WorkflowCanvas as E, BranchSelect as F, StatusSelect as G, EditorToolbar as H, EnvPanel as I, CommandPaletteTrigger as J, SearchCombobox as K, ProjectSwitcher as L, useToast as M, DataTable as N, FailureList as O, DomainTable as P, CommandEmpty$1 as Q, DeploymentTimeline as R, withoutField as S, EventTimeline as T, SidebarNav as U, ContentTabs as V, AppMenubar as W, Command as X, CommandPalette as Y, CommandDialog as Z, fieldValues as _, TimeRangeControl as a, removeClause as b, describeClause as c, quoteValue as d, CommandInput$1 as et, parseQuery as f, applyCellAction as g, addClause as h, DEFAULT_TIME_PRESETS as i, CommandShortcut as it, toast as j, NotificationToast as k, formatClause as l, EMPTY_QUERY as m, LogTable as n, CommandList$1 as nt, useAutoRefresh as o, parseQueryWithDiagnostics as p, DashboardGrid as q, TimeSeriesPanel as r, CommandSeparator$1 as rt, QueryBar as s, FieldSidebar as t, CommandItem$1 as tt, formatQuery as u, isEmptyQuery as v, ScoreBreakdown as w, withFieldValue as x, queryFields as y, SettingsForm as z };