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