@datum-cloud/datum-ui 2.5.0 → 2.7.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 (39) hide show
  1. package/dist/{calendar-date-picker-Cl4lIDl5.mjs → calendar-date-picker-bpTUcGu_.mjs} +1 -1
  2. package/dist/components/features/logs/components/columns.d.ts +15 -0
  3. package/dist/components/features/logs/components/root.d.ts +1 -1
  4. package/dist/components/features/logs/components/status-badge.d.ts +5 -0
  5. package/dist/components/features/logs/components/timeline.d.ts +3 -0
  6. package/dist/components/features/logs/fixtures/index.d.ts +2 -0
  7. package/dist/components/features/logs/index.d.ts +3 -2
  8. package/dist/components/features/logs/logs.d.ts +2 -0
  9. package/dist/components/features/logs/types.d.ts +51 -3
  10. package/dist/components/features/logs/utils/detail-width.d.ts +3 -0
  11. package/dist/components/features/logs/utils/facets.d.ts +7 -0
  12. package/dist/components/features/logs/utils/format-timestamp.d.ts +4 -0
  13. package/dist/components/features/logs/utils/histogram.d.ts +16 -0
  14. package/dist/components/features/logs/utils/host.d.ts +7 -0
  15. package/dist/components/features/logs/utils/index.d.ts +8 -4
  16. package/dist/components/features/logs/utils/parse-log-line.d.ts +17 -3
  17. package/dist/components/features/logs/utils/time-range.d.ts +19 -0
  18. package/dist/components/features/logs/utils/user-agent.d.ts +25 -0
  19. package/dist/data-table/index.mjs +1 -1
  20. package/dist/{data-table-C37OD6_4.mjs → data-table-BaWOHdZX.mjs} +1 -1
  21. package/dist/date-picker/index.mjs +3 -3
  22. package/dist/date-time/index.mjs +1 -1
  23. package/dist/{date-time-BmYjT5vd.mjs → date-time-lZdefM4H.mjs} +1 -1
  24. package/dist/date-time-picker/index.mjs +1 -1
  25. package/dist/{date-time-picker-DlaSizpS.mjs → date-time-picker-Bd7qSZVr.mjs} +1 -1
  26. package/dist/{date-time-range-picker-CQQLACgN.mjs → date-time-range-picker-DlFTh3DF.mjs} +12 -3
  27. package/dist/form/index.mjs +1 -1
  28. package/dist/{form-DDtBJ3Dj.mjs → form-BKxvdIKB.mjs} +3 -3
  29. package/dist/grouped-table/index.mjs +1 -1
  30. package/dist/{grouped-table-DImzzehJ.mjs → grouped-table-DWtEx18V.mjs} +1 -1
  31. package/dist/index.mjs +9 -9
  32. package/dist/logs/fixtures/index.mjs +770 -3
  33. package/dist/logs/index.mjs +1321 -252
  34. package/dist/picker/index.mjs +2 -2
  35. package/dist/{picker-Bbjl9FOq.mjs → picker-DbKukBkk.mjs} +1 -1
  36. package/dist/time-picker/index.mjs +1 -1
  37. package/dist/{time-picker-CoUqZCb7.mjs → time-picker-rVTkmbIG.mjs} +1 -1
  38. package/dist/{to-api-format-BzKfX7yo.mjs → to-api-format-Bm1VcKkS.mjs} +1 -1
  39. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { t as cn } from "../utils-Bu32wN-x.mjs";
2
2
  import { t as Badge } from "../badge-BTNW_FEU.mjs";
3
3
  import { t as Button } from "../button-DoMqfQLu.mjs";
4
+ import { t as Icon } from "../icon-wrapper-DKfJlJd0.mjs";
4
5
  import { t as Checkbox } from "../checkbox-C6P12fio.mjs";
5
6
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../collapsible/index.mjs";
6
7
  import { t as Input } from "../input-j4tbmYYz.mjs";
@@ -8,14 +9,314 @@ import { t as Sheet } from "../sheet-DHn6C_rs.mjs";
8
9
  import { t as useBreakpoint } from "../use-breakpoint-CwiUJSmk.mjs";
9
10
  import { Skeleton } from "../skeleton/index.mjs";
10
11
  import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "../table/index.mjs";
11
- import { t as DateTimeRangePicker } from "../date-time-range-picker-CQQLACgN.mjs";
12
- import { t as DateTime } from "../date-time-BmYjT5vd.mjs";
12
+ import { t as DateTimeRangePicker } from "../date-time-range-picker-DlFTh3DF.mjs";
13
+ import { c as getBrowserTimezone, t as DateTime, u as getTimezoneAbbreviation } from "../date-time-lZdefM4H.mjs";
13
14
  import { t as useCopyToClipboard } from "../use-copy-to-clipboard-DZDq_85T.mjs";
14
- import { ChevronDown, ChevronUp, Copy, Download, RefreshCw, Search, SlidersHorizontal, X } from "lucide-react";
15
- import { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from "react";
15
+ import { Bot, ChevronDown, ChevronUp, Clock, Copy, Download, HelpCircle, Inbox, Monitor, RefreshCw, Search, SlidersHorizontal, Smartphone, Tablet, TriangleAlert, X } from "lucide-react";
16
+ import { createContext, memo, use, useCallback, useEffect, useMemo, useRef, useState } from "react";
16
17
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
17
18
  import { AnimatePresence, motion, useReducedMotion } from "motion/react";
18
- import { format } from "date-fns";
19
+ import { format, isSameDay } from "date-fns";
20
+ //#region src/components/features/logs/utils/format-timestamp.ts
21
+ function formatLogTimestampParts(date) {
22
+ return {
23
+ date: format(date, "MMM dd").toUpperCase(),
24
+ time: format(date, "HH:mm:ss.SS")
25
+ };
26
+ }
27
+ function formatLogTimestamp(date) {
28
+ const { date: day, time } = formatLogTimestampParts(date);
29
+ return `${day} ${time}`;
30
+ }
31
+ //#endregion
32
+ //#region src/components/features/logs/utils/host.ts
33
+ /**
34
+ * Host shown for an HTTP access log. An explicit `host` label (set by the
35
+ * host app) wins; otherwise prefer the name the client asked for
36
+ * (`requested_server_name`, `x_forwarded_host`) over the upstream
37
+ * `authority`, then fall back to `resource_name`.
38
+ */
39
+ function logRequestHost(labels) {
40
+ return labels.host?.trim() || labels.requested_server_name?.trim() || labels.x_forwarded_host?.trim() || labels.authority?.trim() || labels.resource_name?.trim() || void 0;
41
+ }
42
+ //#endregion
43
+ //#region src/components/features/logs/utils/severity.ts
44
+ const SEVERITY_BADGE = {
45
+ ERROR: "danger",
46
+ FATAL: "danger",
47
+ CRITICAL: "danger",
48
+ WARN: "warning",
49
+ WARNING: "warning",
50
+ INFO: "info",
51
+ DEBUG: "muted",
52
+ TRACE: "muted"
53
+ };
54
+ function severityBadgeType(severity) {
55
+ if (!severity) return "muted";
56
+ return SEVERITY_BADGE[severity.toUpperCase()] ?? "muted";
57
+ }
58
+ function httpStatusBadgeType(status) {
59
+ if (status >= 500) return "danger";
60
+ if (status >= 400) return "warning";
61
+ if (status >= 300) return "info";
62
+ if (status >= 200) return "success";
63
+ return "muted";
64
+ }
65
+ function httpStatusTextClass(status) {
66
+ switch (httpStatusBadgeType(status)) {
67
+ case "danger": return "text-[var(--color-badge-danger)]";
68
+ case "warning": return "text-[var(--color-badge-warning)]";
69
+ case "info": return "text-[var(--color-badge-info)]";
70
+ case "success": return "text-[var(--color-badge-success)]";
71
+ default: return "text-muted-foreground";
72
+ }
73
+ }
74
+ //#endregion
75
+ //#region src/components/features/logs/components/status-badge.tsx
76
+ function LogsSeverityBadge({ severity }) {
77
+ const unlabeled = !severity;
78
+ return /* @__PURE__ */ jsx(Badge, {
79
+ type: severityBadgeType(severity),
80
+ theme: unlabeled ? "solid" : "light",
81
+ className: cn("h-4 px-1.5 py-0 font-mono text-[10px] leading-4 font-medium", unlabeled && "border-border bg-muted text-foreground"),
82
+ children: severity ?? "LOG"
83
+ });
84
+ }
85
+ /** Compact HTTP status chip, used inline next to the detail title. */
86
+ function LogsHttpStatusChip({ status, className }) {
87
+ return /* @__PURE__ */ jsx(Badge, {
88
+ type: httpStatusBadgeType(status),
89
+ theme: "light",
90
+ "data-slot": "logs-http-status",
91
+ className: cn("h-5 shrink-0 px-1.5 py-0 font-mono text-[11px] leading-5 font-medium tabular-nums", className),
92
+ children: status
93
+ });
94
+ }
95
+ function LogsStatusBadge({ parsed }) {
96
+ if (parsed.kind !== "http") return /* @__PURE__ */ jsx("span", {
97
+ className: "text-muted-foreground",
98
+ children: "—"
99
+ });
100
+ return /* @__PURE__ */ jsxs("span", {
101
+ className: "inline-flex h-4 items-center gap-1.5 font-mono text-[11px] leading-4 font-medium",
102
+ children: [/* @__PURE__ */ jsx("span", {
103
+ className: cn("tabular-nums", httpStatusTextClass(parsed.status)),
104
+ children: parsed.status
105
+ }), /* @__PURE__ */ jsx("span", {
106
+ className: "text-muted-foreground",
107
+ children: parsed.method
108
+ })]
109
+ });
110
+ }
111
+ //#endregion
112
+ //#region src/components/features/logs/components/columns.tsx
113
+ const DEFAULT_FIXED_WIDTH = 160;
114
+ /** `MMM dd HH:mm:ss.SS` — e.g. `SEP 10 12:03:37.79`. */
115
+ const TIME_CHS = 18;
116
+ const METHOD_CHS = [
117
+ 3,
118
+ 4,
119
+ 3,
120
+ 4
121
+ ];
122
+ const HOST_CHS = [
123
+ 16,
124
+ 22,
125
+ 14,
126
+ 20,
127
+ 18,
128
+ 24
129
+ ];
130
+ const PATH_CHS = [
131
+ 12,
132
+ 28,
133
+ 20,
134
+ 40,
135
+ 16,
136
+ 34
137
+ ];
138
+ function pick(values, row) {
139
+ return values[row % values.length];
140
+ }
141
+ function lineSkeleton(widthCh, className) {
142
+ return /* @__PURE__ */ jsx(Skeleton, {
143
+ "data-slot": "logs-skeleton",
144
+ className: cn("inline-block h-4 max-w-full align-middle rounded-sm font-mono text-xs leading-4", className),
145
+ style: { width: `${widthCh}ch` }
146
+ });
147
+ }
148
+ const BUILTIN_COLUMNS = {
149
+ time: {
150
+ id: "time",
151
+ header: "Time",
152
+ size: "hug",
153
+ cell: ({ entry }) => {
154
+ const { date, time } = formatLogTimestampParts(entry.timestamp);
155
+ return /* @__PURE__ */ jsxs("time", {
156
+ dateTime: entry.timestamp.toISOString(),
157
+ className: "font-mono text-xs whitespace-nowrap",
158
+ children: [
159
+ /* @__PURE__ */ jsx("span", {
160
+ className: "text-muted-foreground",
161
+ children: date
162
+ }),
163
+ " ",
164
+ /* @__PURE__ */ jsx("span", {
165
+ className: "text-foreground",
166
+ children: time
167
+ })
168
+ ]
169
+ });
170
+ },
171
+ skeleton: () => lineSkeleton(TIME_CHS)
172
+ },
173
+ severity: {
174
+ id: "severity",
175
+ header: "Severity",
176
+ size: "hug",
177
+ cell: ({ entry }) => /* @__PURE__ */ jsx(LogsSeverityBadge, { severity: entry.labels.severity }),
178
+ skeleton: () => lineSkeleton(5, "h-4 rounded-md")
179
+ },
180
+ status: {
181
+ id: "status",
182
+ header: "Status",
183
+ size: "hug",
184
+ cell: ({ parsed }) => /* @__PURE__ */ jsx(LogsStatusBadge, { parsed }),
185
+ skeleton: ({ row }) => /* @__PURE__ */ jsxs("span", {
186
+ className: "inline-flex h-4 items-center gap-1.5 font-mono text-[11px] leading-4",
187
+ "data-slot": "logs-skeleton-status",
188
+ children: [lineSkeleton(3, "h-[11px]"), lineSkeleton(pick(METHOD_CHS, row), "h-[11px]")]
189
+ })
190
+ },
191
+ service: {
192
+ id: "service",
193
+ header: "Service",
194
+ size: "fixed",
195
+ width: DEFAULT_FIXED_WIDTH,
196
+ className: "text-muted-foreground truncate font-mono text-xs",
197
+ cell: ({ entry }) => entry.labels.service_name ?? "—",
198
+ skeleton: ({ row }) => lineSkeleton(pick(HOST_CHS, row))
199
+ },
200
+ resource: {
201
+ id: "resource",
202
+ header: "Resource",
203
+ size: "fixed",
204
+ width: DEFAULT_FIXED_WIDTH,
205
+ className: "text-muted-foreground truncate font-mono text-xs",
206
+ cell: ({ entry }) => {
207
+ const value = entry.labels.resource_name;
208
+ return value ? /* @__PURE__ */ jsx("span", {
209
+ title: value,
210
+ children: value
211
+ }) : "—";
212
+ },
213
+ skeleton: ({ row }) => lineSkeleton(pick(HOST_CHS, row))
214
+ },
215
+ host: {
216
+ id: "host",
217
+ header: "Host",
218
+ size: "fixed",
219
+ width: DEFAULT_FIXED_WIDTH,
220
+ className: "text-muted-foreground truncate font-mono text-xs",
221
+ cell: ({ entry }) => {
222
+ const value = logRequestHost(entry.labels);
223
+ return value ? /* @__PURE__ */ jsx("span", {
224
+ title: value,
225
+ children: value
226
+ }) : "—";
227
+ },
228
+ skeleton: ({ row }) => lineSkeleton(pick(HOST_CHS, row))
229
+ },
230
+ path: {
231
+ id: "path",
232
+ header: "Path",
233
+ size: "fill",
234
+ className: "truncate font-mono text-xs",
235
+ cell: ({ path }) => /* @__PURE__ */ jsx("span", {
236
+ className: path ? void 0 : "text-muted-foreground",
237
+ title: path ?? void 0,
238
+ children: path ?? "—"
239
+ }),
240
+ skeleton: ({ row }) => lineSkeleton(pick(PATH_CHS, row))
241
+ },
242
+ message: {
243
+ id: "message",
244
+ header: "Message",
245
+ size: "fill",
246
+ className: "truncate font-mono text-xs",
247
+ cell: ({ message }) => message,
248
+ skeleton: ({ row }) => lineSkeleton(pick(PATH_CHS, row))
249
+ }
250
+ };
251
+ const warnedColumnIds = /* @__PURE__ */ new Set();
252
+ /**
253
+ * Expand column specs into renderable columns. Unknown built-in ids are
254
+ * skipped (with a one-time dev warning) so a typo or a stale URL-driven
255
+ * column list degrades to a narrower table instead of a crash.
256
+ */
257
+ function resolveLogColumns(specs) {
258
+ const columns = [];
259
+ for (const spec of specs) {
260
+ if (typeof spec !== "string") {
261
+ columns.push({
262
+ size: "fixed",
263
+ width: DEFAULT_FIXED_WIDTH,
264
+ ...spec
265
+ });
266
+ continue;
267
+ }
268
+ const builtin = Object.hasOwn(BUILTIN_COLUMNS, spec) ? BUILTIN_COLUMNS[spec] : void 0;
269
+ if (builtin) {
270
+ columns.push(builtin);
271
+ continue;
272
+ }
273
+ if (process.env.NODE_ENV !== "production" && !warnedColumnIds.has(spec)) {
274
+ warnedColumnIds.add(spec);
275
+ console.warn(`[datum-ui/logs] Unknown column id "${spec}" was skipped. Built-in ids: ${Object.keys(BUILTIN_COLUMNS).join(", ")}.`);
276
+ }
277
+ }
278
+ return columns;
279
+ }
280
+ function columnSize(column) {
281
+ return column.size ?? "fixed";
282
+ }
283
+ function columnWidth(column) {
284
+ return column.width ?? DEFAULT_FIXED_WIDTH;
285
+ }
286
+ function columnHeadClass(column) {
287
+ switch (columnSize(column)) {
288
+ case "hug": return "w-px whitespace-nowrap";
289
+ case "fixed": return "whitespace-nowrap";
290
+ case "fill": return "min-w-0";
291
+ }
292
+ }
293
+ function columnCellClass(column) {
294
+ switch (columnSize(column)) {
295
+ case "hug": return "w-px whitespace-nowrap";
296
+ case "fixed": return "truncate";
297
+ case "fill": return "min-w-0 truncate";
298
+ }
299
+ }
300
+ function columnWidthStyle(column) {
301
+ if (columnSize(column) !== "fixed") return void 0;
302
+ const width = columnWidth(column);
303
+ return {
304
+ width,
305
+ maxWidth: width
306
+ };
307
+ }
308
+ function columnSkeleton(column, row) {
309
+ if (column.skeleton) return column.skeleton({ row });
310
+ switch (columnSize(column)) {
311
+ case "hug": return lineSkeleton(column.id === "time" ? TIME_CHS : 8);
312
+ case "fixed": return lineSkeleton(pick(HOST_CHS, row));
313
+ case "fill": return lineSkeleton(pick(PATH_CHS, row));
314
+ }
315
+ }
316
+ function columnHeader(column) {
317
+ return column.header;
318
+ }
319
+ //#endregion
19
320
  //#region src/components/features/logs/hooks/use-logs.ts
20
321
  const LogsContext = createContext(null);
21
322
  function useLogs() {
@@ -23,24 +324,39 @@ function useLogs() {
23
324
  if (!context) throw new Error("Logs components must be used within <Logs.Root>");
24
325
  return context;
25
326
  }
327
+ function clampLogsDetailWidth(width, maxWidth) {
328
+ return Math.min(Math.max(Math.round(width), 400), maxWidth > 400 ? maxWidth : 400);
329
+ }
330
+ function logsDetailMaxWidth(node) {
331
+ const parentWidth = node?.offsetParent instanceof HTMLElement ? node.offsetParent.clientWidth : 0;
332
+ if (parentWidth > 400) return parentWidth;
333
+ return typeof window === "undefined" ? 400 : window.innerWidth;
334
+ }
26
335
  //#endregion
27
336
  //#region src/components/features/logs/utils/parse-log-line.ts
28
337
  const ACCESS_LOG_RE = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\S+)\s+(\d{3})\s+(\d+)ms\b/i;
29
338
  const PATH_TOKEN_RE = /(?:^|\s)(\/\S+)/;
30
- function parseLogLine(line) {
31
- const match = ACCESS_LOG_RE.exec(line);
32
- if (!match) return {
339
+ /**
340
+ * Parse an access-log line, falling back to Envoy/OTEL stream labels when the
341
+ * body is empty (queryapi stores method/path/status as attributes).
342
+ */
343
+ function parseLogLine(line, labels = {}) {
344
+ return parseAccessLogText(line) ?? parseAccessLogFields(labels) ?? {
33
345
  kind: "text",
34
346
  line
35
347
  };
348
+ }
349
+ function formatHttpLogLine(parsed) {
350
+ return `${parsed.method} ${parsed.path} ${parsed.status} ${parsed.durationMs}ms`;
351
+ }
352
+ function parseAccessLogText(line) {
353
+ const match = ACCESS_LOG_RE.exec(line);
354
+ if (!match) return null;
36
355
  const method = match[1];
37
356
  const path = match[2];
38
357
  const status = match[3];
39
358
  const duration = match[4];
40
- if (!method || !path || !status || !duration) return {
41
- kind: "text",
42
- line
43
- };
359
+ if (!method || !path || !status || !duration) return null;
44
360
  return {
45
361
  kind: "http",
46
362
  method: method.toUpperCase(),
@@ -49,8 +365,47 @@ function parseLogLine(line) {
49
365
  durationMs: Number(duration)
50
366
  };
51
367
  }
52
- function logLineDisplay(line) {
53
- const parsed = parseLogLine(line);
368
+ function parseAccessLogFields(fields) {
369
+ const method = fields.method?.trim();
370
+ const path = fields.path?.trim();
371
+ const status = fields.response_code?.trim() || fields.status?.trim();
372
+ if (!method || !path || !status || !/^\d{3}$/.test(status)) return null;
373
+ return {
374
+ kind: "http",
375
+ method: method.toUpperCase(),
376
+ path,
377
+ status: Number(status),
378
+ durationMs: parseDurationMs(fields.duration)
379
+ };
380
+ }
381
+ /** Envoy `%DURATION%` is milliseconds with no unit. */
382
+ function parseDurationMs(raw) {
383
+ if (!raw) return 0;
384
+ const n = Number.parseFloat(raw.replace(/ms$/i, "").trim());
385
+ return Number.isFinite(n) ? Math.max(0, Math.round(n)) : 0;
386
+ }
387
+ /** Separate `/a/b?x=1&y=2` into its pathname and decoded search params. */
388
+ function splitPathQuery(path) {
389
+ const hashIndex = path.indexOf("#");
390
+ const withoutHash = hashIndex === -1 ? path : path.slice(0, hashIndex);
391
+ const queryIndex = withoutHash.indexOf("?");
392
+ if (queryIndex === -1) return {
393
+ pathname: withoutHash,
394
+ search: "",
395
+ params: []
396
+ };
397
+ const pathname = withoutHash.slice(0, queryIndex);
398
+ const search = withoutHash.slice(queryIndex + 1);
399
+ const params = [];
400
+ for (const [key, value] of new URLSearchParams(search)) if (key) params.push([key, value]);
401
+ return {
402
+ pathname,
403
+ search,
404
+ params
405
+ };
406
+ }
407
+ function logLineDisplay(line, labels = {}) {
408
+ const parsed = parseLogLine(line, labels);
54
409
  if (parsed.kind === "http") {
55
410
  const match = ACCESS_LOG_RE.exec(line);
56
411
  const rest = match ? line.slice(match[0].length).trim() : "";
@@ -60,7 +415,7 @@ function logLineDisplay(line) {
60
415
  message: extraMessage(rest)
61
416
  };
62
417
  }
63
- const path = PATH_TOKEN_RE.exec(line)?.[1] ?? null;
418
+ const path = labels.path?.trim() || PATH_TOKEN_RE.exec(line)?.[1] || null;
64
419
  if (!path) return {
65
420
  parsed,
66
421
  path: null,
@@ -69,7 +424,7 @@ function logLineDisplay(line) {
69
424
  return {
70
425
  parsed,
71
426
  path,
72
- message: extraMessage(stripOnce(line, path))
427
+ message: extraMessage(line ? stripOnce(line, path) : "")
73
428
  };
74
429
  }
75
430
  /** Drop leftover that is only fields already shown (method, status, path, duration, label=value). */
@@ -83,65 +438,238 @@ function stripOnce(line, token) {
83
438
  return `${line.slice(0, index)}${line.slice(index + token.length)}`.replace(/\s+/g, " ").trim();
84
439
  }
85
440
  //#endregion
86
- //#region src/components/features/logs/utils/severity.ts
87
- const SEVERITY_BADGE = {
88
- ERROR: "danger",
89
- FATAL: "danger",
90
- CRITICAL: "danger",
91
- WARN: "warning",
92
- WARNING: "warning",
93
- INFO: "info",
94
- DEBUG: "muted",
95
- TRACE: "muted"
96
- };
97
- function severityBadgeType(severity) {
98
- if (!severity) return "muted";
99
- return SEVERITY_BADGE[severity.toUpperCase()] ?? "muted";
441
+ //#region src/components/features/logs/utils/user-agent.ts
442
+ /** Label keys, in order, that may carry the client's User-Agent header. */
443
+ const USER_AGENT_LABEL_KEYS = [
444
+ "user_agent",
445
+ "http_user_agent",
446
+ "useragent",
447
+ "ua",
448
+ "http.user_agent"
449
+ ];
450
+ /** The User-Agent header from an entry's labels, if any. */
451
+ function logUserAgent(labels) {
452
+ for (const key of USER_AGENT_LABEL_KEYS) {
453
+ const value = labels[key]?.trim();
454
+ if (value) return value;
455
+ }
100
456
  }
101
- function httpStatusBadgeType(status) {
102
- if (status >= 500) return "danger";
103
- if (status >= 400) return "warning";
104
- if (status >= 300) return "info";
105
- if (status >= 200) return "success";
106
- return "muted";
457
+ const BOT_RE = /bot|crawl|spider|slurp|facebookexternalhit|preview|monitor|uptime|pingdom|kube-probe|prometheus|blackbox|healthcheck|health-check|datadog|newrelic|statuscake/i;
458
+ /** Order matters: forks embed the names of what they fork. */
459
+ const CLIENT_RULES = [
460
+ {
461
+ name: "curl",
462
+ re: /\bcurl\/(\d+(?:\.\d+)*)/i,
463
+ tool: true
464
+ },
465
+ {
466
+ name: "Wget",
467
+ re: /\bWget\/(\d+(?:\.\d+)*)/i,
468
+ tool: true
469
+ },
470
+ {
471
+ name: "Python Requests",
472
+ re: /\bpython-requests\/(\d+(?:\.\d+)*)/i,
473
+ tool: true
474
+ },
475
+ {
476
+ name: "Python urllib",
477
+ re: /\bPython-urllib\/(\d+(?:\.\d+)*)/i,
478
+ tool: true
479
+ },
480
+ {
481
+ name: "Go HTTP client",
482
+ re: /\bGo-http-client\/(\d+(?:\.\d+)*)/i,
483
+ tool: true
484
+ },
485
+ {
486
+ name: "okhttp",
487
+ re: /\bokhttp\/(\d+(?:\.\d+)*)/i,
488
+ tool: true
489
+ },
490
+ {
491
+ name: "axios",
492
+ re: /\baxios\/(\d+(?:\.\d+)*)/i,
493
+ tool: true
494
+ },
495
+ {
496
+ name: "node-fetch",
497
+ re: /\bnode-fetch\/(\d+(?:\.\d+)*)/i,
498
+ tool: true
499
+ },
500
+ {
501
+ name: "Node.js",
502
+ re: /\bnode\/v?(\d+(?:\.\d+)*)/i,
503
+ tool: true
504
+ },
505
+ {
506
+ name: "Postman",
507
+ re: /\bPostmanRuntime\/(\d+(?:\.\d+)*)/i,
508
+ tool: true
509
+ },
510
+ {
511
+ name: "Insomnia",
512
+ re: /\binsomnia\/(\d+(?:\.\d+)*)/i,
513
+ tool: true
514
+ },
515
+ {
516
+ name: "Java",
517
+ re: /\bJava\/(\d+(?:\.\d+)*)/i,
518
+ tool: true
519
+ },
520
+ {
521
+ name: "Edge",
522
+ re: /\bEdgA?\/(\d+(?:\.\d+)*)/
523
+ },
524
+ {
525
+ name: "Edge",
526
+ re: /\bEdgiOS\/(\d+(?:\.\d+)*)/
527
+ },
528
+ {
529
+ name: "Opera",
530
+ re: /\bOPR\/(\d+(?:\.\d+)*)/
531
+ },
532
+ {
533
+ name: "Opera",
534
+ re: /\bOpera\/(\d+(?:\.\d+)*)/
535
+ },
536
+ {
537
+ name: "Samsung Internet",
538
+ re: /\bSamsungBrowser\/(\d+(?:\.\d+)*)/
539
+ },
540
+ {
541
+ name: "Brave",
542
+ re: /\bBrave\/(\d+(?:\.\d+)*)/
543
+ },
544
+ {
545
+ name: "Vivaldi",
546
+ re: /\bVivaldi\/(\d+(?:\.\d+)*)/
547
+ },
548
+ {
549
+ name: "Firefox",
550
+ re: /\b(?:Firefox|FxiOS)\/(\d+(?:\.\d+)*)/
551
+ },
552
+ {
553
+ name: "Chrome",
554
+ re: /\b(?:Chrome|CriOS|HeadlessChrome)\/(\d+(?:\.\d+)*)/
555
+ }
556
+ ];
557
+ /** Safari carries its version under `Version/`; WebKit views on iOS have neither. */
558
+ function detectSafari(ua) {
559
+ if (!/\bSafari\//.test(ua) && !/\bAppleWebKit\//.test(ua)) return void 0;
560
+ const version = /\bVersion\/(\d+(?:\.\d+)*)/.exec(ua);
561
+ if (version && /\bSafari\//.test(ua)) return { version: majorVersion(version[1]) };
562
+ if (/\b(?:iPhone|iPad|iPod)\b/.test(ua)) return {};
107
563
  }
108
- function httpStatusTextClass(status) {
109
- switch (httpStatusBadgeType(status)) {
110
- case "danger": return "text-[var(--color-badge-danger)]";
111
- case "warning": return "text-[var(--color-badge-warning)]";
112
- case "info": return "text-[var(--color-badge-info)]";
113
- case "success": return "text-[var(--color-badge-success)]";
114
- default: return "text-muted-foreground";
564
+ function majorVersion(version) {
565
+ if (!version) return void 0;
566
+ return version.split(".")[0];
567
+ }
568
+ function detectOs(ua) {
569
+ let match;
570
+ if (/\b(?:iPhone|iPad|iPod)\b/i.test(ua)) {
571
+ match = /\bOS (\d+)[._](\d+)(?:[._](\d+))?/.exec(ua);
572
+ if (!match) return { os: "iOS" };
573
+ const patch = match[3] && match[3] !== "0" ? `.${match[3]}` : "";
574
+ return {
575
+ os: "iOS",
576
+ osVersion: `${match[1]}.${match[2]}${patch}`
577
+ };
115
578
  }
579
+ match = /\bAndroid[ /](\d+(?:\.\d+)?)/i.exec(ua);
580
+ if (match) return {
581
+ os: "Android",
582
+ osVersion: match[1]
583
+ };
584
+ if (/\bAndroid\b/i.test(ua)) return { os: "Android" };
585
+ if (/\bWindows Phone\b/i.test(ua)) return { os: "Windows Phone" };
586
+ if (/\bWindows\b/i.test(ua)) return { os: "Windows" };
587
+ if (/\bCrOS\b/.test(ua)) return { os: "ChromeOS" };
588
+ if (/\bMac OS X\b|\bMacintosh\b/i.test(ua)) return { os: "macOS" };
589
+ if (/\bLinux\b|\bX11\b/i.test(ua)) return { os: "Linux" };
590
+ if (/\bFreeBSD\b/i.test(ua)) return { os: "FreeBSD" };
591
+ return {};
116
592
  }
117
- //#endregion
118
- //#region src/components/features/logs/components/status-badge.tsx
119
- function LogsSeverityBadge({ severity }) {
120
- return /* @__PURE__ */ jsx(Badge, {
121
- type: severityBadgeType(severity),
122
- theme: "light",
123
- className: "h-4 px-1.5 py-0 font-mono text-[10px] leading-4 font-medium",
124
- children: severity ?? "LOG"
125
- });
593
+ function detectDevice(ua, os, bot, tool) {
594
+ if (bot || tool) return "bot";
595
+ if (/\biPad\b|\bTablet\b|\bKindle\b|\bSilk\b/i.test(ua)) return "tablet";
596
+ if (os === "Android" && !/\bMobile\b/i.test(ua)) return "tablet";
597
+ if (/\bMobile\b|\biPhone\b|\biPod\b|\bWindows Phone\b/i.test(ua)) return "mobile";
598
+ if (os === "iOS") return "mobile";
599
+ if (os === "Windows" || os === "macOS" || os === "Linux" || os === "ChromeOS" || os === "FreeBSD") return "desktop";
600
+ return "unknown";
126
601
  }
127
- function LogsStatusBadge({ parsed }) {
128
- if (parsed.kind !== "http") return /* @__PURE__ */ jsx("span", {
129
- className: "text-muted-foreground",
130
- children: "—"
131
- });
132
- return /* @__PURE__ */ jsxs("span", {
133
- className: "inline-grid grid-cols-[7ch_auto] gap-x-2 font-mono text-[11px] font-medium",
134
- children: [/* @__PURE__ */ jsx("span", {
135
- className: "text-muted-foreground",
136
- children: parsed.method
137
- }), /* @__PURE__ */ jsx("span", {
138
- className: cn("tabular-nums", httpStatusTextClass(parsed.status)),
139
- children: parsed.status
140
- })]
141
- });
602
+ function botClient(ua) {
603
+ const match = /([\w.-]*(?:bot|crawl|spider|probe|monitor|uptime|check)[\w.-]*)(?:\/(\d+(?:\.\d+)*))?/i.exec(ua);
604
+ if (!match?.[1]) return void 0;
605
+ return {
606
+ name: match[1],
607
+ version: majorVersion(match[2])
608
+ };
609
+ }
610
+ /**
611
+ * Parse a User-Agent header into browser, OS and device type. Deliberately
612
+ * small: it recognises the mainstream browsers, mobile platforms, common
613
+ * command-line / SDK clients, and anything that calls itself a bot. Returns
614
+ * `null` for an empty string; unrecognised strings still return `summary`
615
+ * as the raw value with `device: 'unknown'`.
616
+ */
617
+ function parseUserAgent(raw) {
618
+ const ua = raw.trim();
619
+ if (!ua) return null;
620
+ let browser;
621
+ let browserVersion;
622
+ let tool = false;
623
+ for (const rule of CLIENT_RULES) {
624
+ const match = rule.re.exec(ua);
625
+ if (match) {
626
+ browser = rule.name;
627
+ browserVersion = majorVersion(match[1]);
628
+ tool = Boolean(rule.tool);
629
+ break;
630
+ }
631
+ }
632
+ if (!browser) {
633
+ const safari = detectSafari(ua);
634
+ if (safari) {
635
+ browser = "Safari";
636
+ browserVersion = safari.version;
637
+ }
638
+ }
639
+ const { os, osVersion } = tool ? {} : detectOs(ua);
640
+ const bot = !tool && BOT_RE.test(ua);
641
+ const device = detectDevice(ua, os, bot, tool);
642
+ if (bot && !browser) {
643
+ const named = botClient(ua);
644
+ browser = named?.name;
645
+ browserVersion = named?.version;
646
+ }
647
+ const client = browser ? browserVersion ? `${browser} ${browserVersion}` : browser : void 0;
648
+ const platform = os ? osVersion ? `${os} ${osVersion}` : os : void 0;
649
+ let summary;
650
+ if (client && platform) summary = `${client} on ${platform}`;
651
+ else if (client) summary = client;
652
+ else if (platform) summary = platform;
653
+ else summary = ua;
654
+ return {
655
+ browser,
656
+ browserVersion,
657
+ os,
658
+ osVersion,
659
+ device,
660
+ summary,
661
+ raw: ua
662
+ };
142
663
  }
143
664
  //#endregion
144
665
  //#region src/components/features/logs/components/detail.tsx
666
+ const DEVICE_ICONS = {
667
+ desktop: Monitor,
668
+ mobile: Smartphone,
669
+ tablet: Tablet,
670
+ bot: Bot,
671
+ unknown: HelpCircle
672
+ };
145
673
  function MetaCell({ label, children, className }) {
146
674
  return /* @__PURE__ */ jsxs("div", {
147
675
  className: cn("bg-background min-w-0 px-3 py-2.5", className),
@@ -154,77 +682,111 @@ function MetaCell({ label, children, className }) {
154
682
  })]
155
683
  });
156
684
  }
157
- function DetailBody({ showClose = true }) {
685
+ /** Stable React keys for search params, which may repeat `key=value` verbatim. */
686
+ function keyedParams(params) {
687
+ const seen = /* @__PURE__ */ new Map();
688
+ return params.map(([key, value]) => {
689
+ const pair = `${key}=${value}`;
690
+ const count = seen.get(pair) ?? 0;
691
+ seen.set(pair, count + 1);
692
+ return {
693
+ id: count === 0 ? pair : `${pair}#${count}`,
694
+ key,
695
+ value
696
+ };
697
+ });
698
+ }
699
+ const DetailBody = memo(({ showClose = true }) => {
158
700
  const { selectedEntry, selectPrevious, selectNext, setSelectedId } = useLogs();
159
701
  const [, copy] = useCopyToClipboard();
160
702
  if (!selectedEntry) return null;
161
- const parsed = parseLogLine(selectedEntry.line);
162
- const title = parsed.kind === "http" ? `${parsed.method} ${parsed.path}` : selectedEntry.labels.service_name ?? "Log";
163
- const service = selectedEntry.labels.service_name;
703
+ const parsed = parseLogLine(selectedEntry.line, selectedEntry.labels);
704
+ const displayLine = parsed.kind === "http" && !selectedEntry.line.trim() ? formatHttpLogLine(parsed) : selectedEntry.line;
705
+ const request = splitPathQuery(parsed.kind === "http" ? parsed.path : "");
706
+ const title = parsed.kind === "http" ? `${parsed.method} ${request.pathname}` : selectedEntry.labels.service_name ?? "Log";
707
+ const service = selectedEntry.labels.service_name ?? logRequestHost(selectedEntry.labels);
708
+ const userAgent = logUserAgent(selectedEntry.labels);
709
+ const client = userAgent ? parseUserAgent(userAgent) : null;
164
710
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("header", {
165
- className: "flex items-start gap-2 border-b px-4 py-3",
711
+ className: "border-b px-4 py-3",
166
712
  children: [/* @__PURE__ */ jsxs("div", {
167
- className: "min-w-0 flex-1",
713
+ className: "flex items-center gap-2",
168
714
  children: [
169
- /* @__PURE__ */ jsx("div", {
170
- className: "mb-1.5",
171
- children: /* @__PURE__ */ jsx(LogsSeverityBadge, { severity: selectedEntry.labels.severity })
172
- }),
173
715
  /* @__PURE__ */ jsx("h3", {
174
- className: "truncate font-mono text-sm font-medium",
716
+ className: "min-w-0 flex-1 truncate font-mono text-sm font-medium",
717
+ title,
175
718
  children: title
176
719
  }),
720
+ parsed.kind === "http" ? /* @__PURE__ */ jsx(LogsHttpStatusChip, { status: parsed.status }) : /* @__PURE__ */ jsx(LogsSeverityBadge, { severity: selectedEntry.labels.severity }),
177
721
  /* @__PURE__ */ jsxs("div", {
178
- className: "text-muted-foreground mt-1 flex min-w-0 items-center gap-1.5 text-xs",
179
- children: [service && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
180
- className: "truncate font-mono",
181
- children: service
182
- }), /* @__PURE__ */ jsx("span", {
183
- "aria-hidden": "true",
184
- children: "·"
185
- })] }), /* @__PURE__ */ jsx(DateTime, {
186
- date: selectedEntry.timestamp,
187
- timestamp: selectedEntry.timestampNs,
188
- variant: "relative",
189
- tooltip: "detailed",
190
- className: "text-muted-foreground text-xs"
191
- })]
722
+ className: "-my-1 flex shrink-0 items-center gap-1",
723
+ children: [
724
+ /* @__PURE__ */ jsx(Button, {
725
+ type: "secondary",
726
+ theme: "borderless",
727
+ size: "icon",
728
+ "aria-label": "Previous log",
729
+ onClick: selectPrevious,
730
+ children: /* @__PURE__ */ jsx(Icon, { icon: ChevronUp })
731
+ }),
732
+ /* @__PURE__ */ jsx(Button, {
733
+ type: "secondary",
734
+ theme: "borderless",
735
+ size: "icon",
736
+ "aria-label": "Next log",
737
+ onClick: selectNext,
738
+ children: /* @__PURE__ */ jsx(Icon, { icon: ChevronDown })
739
+ }),
740
+ /* @__PURE__ */ jsx(Button, {
741
+ type: "secondary",
742
+ theme: "borderless",
743
+ size: "icon",
744
+ "aria-label": "Copy log line",
745
+ onClick: () => copy(displayLine, { withToast: true }),
746
+ children: /* @__PURE__ */ jsx(Icon, { icon: Copy })
747
+ }),
748
+ showClose && /* @__PURE__ */ jsx(Button, {
749
+ type: "secondary",
750
+ theme: "borderless",
751
+ size: "icon",
752
+ "aria-label": "Close details",
753
+ onClick: () => setSelectedId(null),
754
+ children: /* @__PURE__ */ jsx(Icon, { icon: X })
755
+ })
756
+ ]
192
757
  })
193
758
  ]
194
759
  }), /* @__PURE__ */ jsxs("div", {
195
- className: "flex shrink-0 items-center gap-1",
760
+ className: "text-muted-foreground mt-1 flex min-w-0 flex-nowrap items-center gap-1.5 text-xs",
196
761
  children: [
197
- /* @__PURE__ */ jsx(Button, {
198
- type: "secondary",
199
- theme: "borderless",
200
- size: "icon",
201
- "aria-label": "Previous log",
202
- onClick: selectPrevious,
203
- children: /* @__PURE__ */ jsx(ChevronUp, { className: "size-4" })
204
- }),
205
- /* @__PURE__ */ jsx(Button, {
206
- type: "secondary",
207
- theme: "borderless",
208
- size: "icon",
209
- "aria-label": "Next log",
210
- onClick: selectNext,
211
- children: /* @__PURE__ */ jsx(ChevronDown, { className: "size-4" })
212
- }),
213
- /* @__PURE__ */ jsx(Button, {
214
- type: "secondary",
215
- theme: "borderless",
216
- size: "icon",
217
- "aria-label": "Copy log line",
218
- onClick: () => copy(selectedEntry.line, { withToast: true }),
219
- children: /* @__PURE__ */ jsx(Copy, { className: "size-4" })
220
- }),
221
- showClose && /* @__PURE__ */ jsx(Button, {
222
- type: "secondary",
223
- theme: "borderless",
224
- size: "icon",
225
- "aria-label": "Close details",
226
- onClick: () => setSelectedId(null),
227
- children: /* @__PURE__ */ jsx(X, { className: "size-4" })
762
+ parsed.kind === "http" && selectedEntry.labels.severity && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(LogsSeverityBadge, { severity: selectedEntry.labels.severity }), /* @__PURE__ */ jsx("span", {
763
+ className: "shrink-0",
764
+ "aria-hidden": "true",
765
+ children: "·"
766
+ })] }),
767
+ service && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
768
+ className: "min-w-0 truncate font-mono",
769
+ children: service
770
+ }), /* @__PURE__ */ jsx("span", {
771
+ className: "shrink-0",
772
+ "aria-hidden": "true",
773
+ children: "·"
774
+ })] }),
775
+ /* @__PURE__ */ jsx(DateTime, {
776
+ date: selectedEntry.timestamp,
777
+ timestamp: selectedEntry.timestampNs,
778
+ variant: "absolute",
779
+ tooltip: "detailed",
780
+ children: /* @__PURE__ */ jsxs("time", {
781
+ dateTime: selectedEntry.timestamp.toISOString(),
782
+ "data-slot": "logs-detail-time",
783
+ className: "shrink-0 cursor-default font-mono whitespace-nowrap tabular-nums",
784
+ children: [
785
+ formatLogTimestamp(selectedEntry.timestamp),
786
+ " ",
787
+ getTimezoneAbbreviation(selectedEntry.timestamp, getBrowserTimezone())
788
+ ]
789
+ })
228
790
  })
229
791
  ]
230
792
  })]
@@ -236,7 +798,7 @@ function DetailBody({ showClose = true }) {
236
798
  children: "Message"
237
799
  }), /* @__PURE__ */ jsx("pre", {
238
800
  className: "bg-muted text-foreground overflow-x-auto rounded-md border p-3 font-mono text-xs leading-5 whitespace-pre-wrap break-all",
239
- children: selectedEntry.line
801
+ children: displayLine
240
802
  })] }),
241
803
  parsed.kind === "http" && /* @__PURE__ */ jsxs("section", { children: [/* @__PURE__ */ jsx("h4", {
242
804
  className: "text-muted-foreground mb-2 text-xs font-medium",
@@ -262,7 +824,89 @@ function DetailBody({ showClose = true }) {
262
824
  /* @__PURE__ */ jsx(MetaCell, {
263
825
  label: "Path",
264
826
  className: "col-span-3",
265
- children: parsed.path
827
+ children: request.pathname
828
+ }),
829
+ client && /* @__PURE__ */ jsxs("div", {
830
+ className: "bg-background col-span-3 min-w-0 px-3 py-2.5",
831
+ "data-slot": "logs-client",
832
+ children: [
833
+ /* @__PURE__ */ jsxs("div", {
834
+ className: "mb-1 flex items-center justify-between",
835
+ children: [/* @__PURE__ */ jsx("span", {
836
+ className: "text-muted-foreground text-[11px] font-medium",
837
+ children: "Client"
838
+ }), /* @__PURE__ */ jsx(Button, {
839
+ type: "secondary",
840
+ theme: "borderless",
841
+ size: "icon",
842
+ className: "-my-1 size-6",
843
+ "aria-label": "Copy user agent",
844
+ onClick: () => copy(client.raw, { withToast: true }),
845
+ children: /* @__PURE__ */ jsx(Icon, {
846
+ icon: Copy,
847
+ size: 14
848
+ })
849
+ })]
850
+ }),
851
+ /* @__PURE__ */ jsxs("div", {
852
+ className: "flex items-center gap-2 text-xs",
853
+ children: [
854
+ /* @__PURE__ */ jsx(Icon, {
855
+ icon: DEVICE_ICONS[client.device],
856
+ className: "text-muted-foreground shrink-0"
857
+ }),
858
+ /* @__PURE__ */ jsx("span", {
859
+ className: "min-w-0 truncate font-medium",
860
+ "data-slot": "logs-client-summary",
861
+ children: client.summary
862
+ }),
863
+ /* @__PURE__ */ jsx("span", {
864
+ className: "text-muted-foreground shrink-0 text-[11px] capitalize",
865
+ children: client.device === "unknown" ? "" : client.device
866
+ })
867
+ ]
868
+ }),
869
+ client.summary !== client.raw && /* @__PURE__ */ jsx("p", {
870
+ className: "text-muted-foreground mt-1 font-mono text-[11px] leading-4 break-all",
871
+ title: client.raw,
872
+ children: client.raw
873
+ })
874
+ ]
875
+ }),
876
+ request.params.length > 0 && /* @__PURE__ */ jsxs("div", {
877
+ className: "bg-background col-span-3 min-w-0 px-3 py-2.5",
878
+ "data-slot": "logs-search-params",
879
+ children: [/* @__PURE__ */ jsxs("div", {
880
+ className: "mb-1.5 flex items-center justify-between",
881
+ children: [/* @__PURE__ */ jsx("span", {
882
+ className: "text-muted-foreground text-[11px] font-medium",
883
+ children: "Search params"
884
+ }), /* @__PURE__ */ jsx(Button, {
885
+ type: "secondary",
886
+ theme: "borderless",
887
+ size: "icon",
888
+ className: "-my-1 size-6",
889
+ "aria-label": "Copy search params",
890
+ onClick: () => copy(`?${request.search}`, { withToast: true }),
891
+ children: /* @__PURE__ */ jsx(Icon, {
892
+ icon: Copy,
893
+ size: 14
894
+ })
895
+ })]
896
+ }), /* @__PURE__ */ jsx("dl", {
897
+ className: "flex flex-wrap gap-1.5",
898
+ children: keyedParams(request.params).map(({ id, key, value }) => /* @__PURE__ */ jsxs("div", {
899
+ className: "bg-muted/60 inline-flex max-w-full items-stretch overflow-hidden rounded-md border font-mono text-xs",
900
+ children: [/* @__PURE__ */ jsx("dt", {
901
+ className: "text-muted-foreground border-r px-2 py-1",
902
+ children: key
903
+ }), /* @__PURE__ */ jsx("dd", {
904
+ className: "min-w-0 truncate px-2 py-1",
905
+ title: value,
906
+ children: value || "—"
907
+ })]
908
+ }, id))
909
+ })]
266
910
  })
267
911
  ]
268
912
  })] }),
@@ -284,6 +928,59 @@ function DetailBody({ showClose = true }) {
284
928
  })] })
285
929
  ]
286
930
  })] });
931
+ });
932
+ function LogsDetailResizeHandle({ width, onWidthChange, panelRef }) {
933
+ const dragRef = useRef(null);
934
+ const [maxWidth, setMaxWidth] = useState(() => logsDetailMaxWidth(null));
935
+ const onPointerDown = useCallback((event) => {
936
+ if (event.button !== 0) return;
937
+ event.preventDefault();
938
+ const max = logsDetailMaxWidth(panelRef.current);
939
+ setMaxWidth(max);
940
+ dragRef.current = {
941
+ startX: event.clientX,
942
+ startWidth: width,
943
+ maxWidth: max
944
+ };
945
+ event.currentTarget.setPointerCapture(event.pointerId);
946
+ }, [panelRef, width]);
947
+ const onPointerMove = useCallback((event) => {
948
+ const drag = dragRef.current;
949
+ if (!drag) return;
950
+ onWidthChange(clampLogsDetailWidth(drag.startWidth + (drag.startX - event.clientX), drag.maxWidth));
951
+ }, [onWidthChange]);
952
+ const onPointerUp = useCallback((event) => {
953
+ dragRef.current = null;
954
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
955
+ }, []);
956
+ return /* @__PURE__ */ jsx("div", {
957
+ role: "separator",
958
+ "aria-orientation": "vertical",
959
+ "aria-label": "Resize details",
960
+ "aria-valuemin": 400,
961
+ "aria-valuemax": maxWidth,
962
+ "aria-valuenow": width,
963
+ "data-slot": "logs-detail-resize",
964
+ tabIndex: 0,
965
+ className: cn("absolute inset-y-0 left-0 z-10 w-3 -translate-x-1/2 cursor-ew-resize", "after:bg-transparent hover:after:bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 after:-translate-x-1/2"),
966
+ onPointerDown,
967
+ onPointerMove,
968
+ onPointerUp,
969
+ onPointerCancel: onPointerUp,
970
+ onKeyDown: (event) => {
971
+ const step = event.shiftKey ? 40 : 16;
972
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
973
+ event.preventDefault();
974
+ const max = logsDetailMaxWidth(panelRef.current);
975
+ setMaxWidth(max);
976
+ onWidthChange(clampLogsDetailWidth(width + (event.key === "ArrowLeft" ? step : -step), max));
977
+ }
978
+ if (event.key === "Home") {
979
+ event.preventDefault();
980
+ onWidthChange(400);
981
+ }
982
+ }
983
+ });
287
984
  }
288
985
  function LogsDetail({ className }) {
289
986
  const { selectedEntry, setSelectedId } = useLogs();
@@ -291,6 +988,8 @@ function LogsDetail({ className }) {
291
988
  const reduceMotion = useReducedMotion();
292
989
  const isDesktop = breakpoint === "desktop";
293
990
  const open = selectedEntry !== null;
991
+ const panelRef = useRef(null);
992
+ const [width, setWidth] = useState(400);
294
993
  if (!isDesktop) return /* @__PURE__ */ jsx(Sheet, {
295
994
  open,
296
995
  onOpenChange: (next) => {
@@ -317,23 +1016,101 @@ function LogsDetail({ className }) {
317
1016
  ]
318
1017
  })
319
1018
  });
320
- return /* @__PURE__ */ jsx(AnimatePresence, { children: open && /* @__PURE__ */ jsx(motion.aside, {
1019
+ return /* @__PURE__ */ jsx(AnimatePresence, { children: open && /* @__PURE__ */ jsxs(motion.aside, {
1020
+ ref: panelRef,
321
1021
  "data-slot": "logs-detail",
322
- initial: reduceMotion ? false : { width: 0 },
323
- animate: { width: 400 },
324
- exit: { width: 0 },
1022
+ initial: reduceMotion ? false : { x: "100%" },
1023
+ animate: { x: 0 },
1024
+ exit: { x: "100%" },
325
1025
  transition: reduceMotion ? { duration: 0 } : {
326
1026
  duration: .2,
327
1027
  ease: "easeOut"
328
1028
  },
329
- className: cn("bg-background flex h-full shrink-0 flex-col overflow-hidden border-l", className),
330
- children: /* @__PURE__ */ jsx("div", {
331
- className: "flex h-full w-[400px] flex-col",
332
- children: /* @__PURE__ */ jsx(DetailBody, {})
333
- })
1029
+ style: { width },
1030
+ className: cn("bg-background absolute inset-y-0 right-0 z-20 flex h-full flex-col overflow-hidden border-l shadow-lg", className),
1031
+ children: [/* @__PURE__ */ jsx(LogsDetailResizeHandle, {
1032
+ width,
1033
+ onWidthChange: setWidth,
1034
+ panelRef
1035
+ }), /* @__PURE__ */ jsx(DetailBody, {})]
334
1036
  }) });
335
1037
  }
336
1038
  //#endregion
1039
+ //#region src/components/features/logs/utils/time-range.ts
1040
+ const MINUTE_MS$1 = 6e4;
1041
+ const HOUR_MS = 60 * MINUTE_MS$1;
1042
+ const DAY_MS$1 = 24 * HOUR_MS;
1043
+ function relativePreset(key, label, durationMs, shortcut) {
1044
+ return {
1045
+ key,
1046
+ label,
1047
+ shortcut,
1048
+ getRange: () => {
1049
+ const now = /* @__PURE__ */ new Date();
1050
+ return {
1051
+ from: new Date(now.getTime() - durationMs),
1052
+ to: now
1053
+ };
1054
+ }
1055
+ };
1056
+ }
1057
+ /**
1058
+ * Relative "Last N" windows offered by `Logs.Filters`. Keys are persisted on
1059
+ * `LogTimeRange.preset` so hosts can slide the window forward with
1060
+ * `resolveLogTimeRange` on refresh or while live tailing.
1061
+ *
1062
+ * Shortcuts follow the `picker` datetime presets (`1` / `2` / `H` / `W`) that
1063
+ * the logs picker is built on. Keys that mean something else in the pickers
1064
+ * (`3` is "Last 30 minutes" in `time-range-picker`, `D` is "Today") are not
1065
+ * reused, so muscle memory carries over between the two.
1066
+ */
1067
+ const LOG_TIME_PRESETS = [
1068
+ relativePreset("last-15m", "Last 15 minutes", 15 * MINUTE_MS$1, "1"),
1069
+ relativePreset("last-30m", "Last 30 minutes", 30 * MINUTE_MS$1, "2"),
1070
+ relativePreset("last-1h", "Last hour", HOUR_MS, "H"),
1071
+ relativePreset("last-3h", "Last 3 hours", 3 * HOUR_MS),
1072
+ relativePreset("last-6h", "Last 6 hours", 6 * HOUR_MS, "6"),
1073
+ relativePreset("last-12h", "Last 12 hours", 12 * HOUR_MS),
1074
+ relativePreset("last-24h", "Last 24 hours", DAY_MS$1),
1075
+ relativePreset("last-7d", "Last 7 days", 7 * DAY_MS$1, "W")
1076
+ ];
1077
+ function lastThirtyMinutes(now = /* @__PURE__ */ new Date()) {
1078
+ return {
1079
+ from: (/* @__PURE__ */ new Date(now.getTime() - 30 * MINUTE_MS$1)).toISOString(),
1080
+ to: now.toISOString(),
1081
+ preset: "last-30m"
1082
+ };
1083
+ }
1084
+ /**
1085
+ * Re-anchor a preset range to `now`. Absolute ranges (no `preset`, or an
1086
+ * unknown key) are returned unchanged.
1087
+ */
1088
+ function resolveLogTimeRange(range, now = /* @__PURE__ */ new Date()) {
1089
+ if (!range.preset) return range;
1090
+ const preset = LOG_TIME_PRESETS.find((item) => item.key === range.preset);
1091
+ if (!preset) return range;
1092
+ const { from, to } = preset.getRange("UTC");
1093
+ const shift = now.getTime() - to.getTime();
1094
+ return {
1095
+ from: new Date(from.getTime() + shift).toISOString(),
1096
+ to: now.toISOString(),
1097
+ preset: range.preset
1098
+ };
1099
+ }
1100
+ /** Human label for the time-range trigger: preset name, or a compact absolute range. */
1101
+ function logTimeRangeLabel(range) {
1102
+ const preset = range.preset ? LOG_TIME_PRESETS.find((item) => item.key === range.preset) : void 0;
1103
+ if (preset) return preset.label;
1104
+ const from = new Date(range.from);
1105
+ const to = new Date(range.to);
1106
+ if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return "Select range";
1107
+ if (isSameDay(from, to)) return `${format(from, "MMM d, HH:mm")} – ${format(to, "HH:mm")}`;
1108
+ return `${format(from, "MMM d, HH:mm")} – ${format(to, "MMM d, HH:mm")}`;
1109
+ }
1110
+ function filtersAreActive(filters) {
1111
+ return Object.values(filters).some((values) => values.length > 0);
1112
+ }
1113
+ //#endregion
337
1114
  //#region src/components/features/logs/components/filters.tsx
338
1115
  function LogsFilterGroup({ facet, defaultOpen }) {
339
1116
  const { filters, toggleFilterValue } = useLogs();
@@ -343,7 +1120,10 @@ function LogsFilterGroup({ facet, defaultOpen }) {
343
1120
  className: "border-b border-border",
344
1121
  children: [/* @__PURE__ */ jsxs(CollapsibleTrigger, {
345
1122
  className: "hover:bg-muted/40 flex w-full items-center justify-between px-3 py-2 text-left text-sm font-medium",
346
- children: [facet.label, /* @__PURE__ */ jsx(ChevronDown, { className: "text-muted-foreground size-4 transition-transform [[data-state=open]_&]:rotate-180" })]
1123
+ children: [facet.label, /* @__PURE__ */ jsx(Icon, {
1124
+ icon: ChevronDown,
1125
+ className: "text-muted-foreground transition-transform [[data-state=open]_&]:rotate-180"
1126
+ })]
347
1127
  }), /* @__PURE__ */ jsx(CollapsibleContent, { children: /* @__PURE__ */ jsx("ul", {
348
1128
  className: "flex flex-col gap-1 px-3 pb-3",
349
1129
  children: facet.options.map((option) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("label", {
@@ -367,6 +1147,9 @@ function LogsFilterGroup({ facet, defaultOpen }) {
367
1147
  }) })]
368
1148
  });
369
1149
  }
1150
+ function isLogTimeRange(value) {
1151
+ return typeof value === "object" && value !== null && typeof value.from === "string" && typeof value.to === "string";
1152
+ }
370
1153
  function LogsTimeRangeFilter({ className }) {
371
1154
  const { timeRange, setTimeRange } = useLogs();
372
1155
  return /* @__PURE__ */ jsxs("div", {
@@ -377,23 +1160,83 @@ function LogsTimeRangeFilter({ className }) {
377
1160
  }), /* @__PURE__ */ jsx(DateTimeRangePicker, {
378
1161
  value: timeRange,
379
1162
  onChange: (next) => {
380
- if (next) setTimeRange({
1163
+ if (!next) return;
1164
+ const preset = next.preset;
1165
+ setTimeRange(preset ? {
1166
+ from: next.from,
1167
+ to: next.to,
1168
+ preset
1169
+ } : {
381
1170
  from: next.from,
382
1171
  to: next.to
383
1172
  });
384
1173
  },
1174
+ presets: LOG_TIME_PRESETS,
1175
+ triggerLabel: (value) => isLogTimeRange(value) ? logTimeRangeLabel(value) : "Select range",
1176
+ icon: /* @__PURE__ */ jsx(Icon, {
1177
+ icon: Clock,
1178
+ className: "text-muted-foreground shrink-0"
1179
+ }),
385
1180
  placeholder: "Select range",
1181
+ clearable: false,
386
1182
  disableFuture: true,
387
1183
  hideTimezone: true,
388
1184
  numberOfMonths: 1,
389
1185
  className: "w-full",
390
1186
  triggerClassName: "w-full justify-start text-xs",
1187
+ popoverClassName: "w-auto",
391
1188
  sheetTitle: "Select time range"
392
1189
  })]
393
1190
  });
394
1191
  }
1192
+ const FILTER_LABEL_CHS = [
1193
+ 6,
1194
+ 11,
1195
+ 4
1196
+ ];
1197
+ const FILTER_VALUE_CHS = [
1198
+ 3,
1199
+ 8,
1200
+ 14,
1201
+ 6,
1202
+ 20,
1203
+ 11,
1204
+ 4,
1205
+ 16
1206
+ ];
1207
+ function LogsFiltersSkeleton() {
1208
+ return /* @__PURE__ */ jsx("div", {
1209
+ "data-slot": "logs-filters-skeleton",
1210
+ className: "flex flex-col",
1211
+ "aria-hidden": true,
1212
+ children: Array.from({ length: 3 }, (_, group) => /* @__PURE__ */ jsxs("div", {
1213
+ className: "border-b border-border",
1214
+ children: [/* @__PURE__ */ jsxs("div", {
1215
+ className: "flex w-full items-center justify-between px-3 py-2",
1216
+ children: [/* @__PURE__ */ jsx(Skeleton, {
1217
+ className: "h-[21px] rounded-sm font-medium",
1218
+ style: { width: `${FILTER_LABEL_CHS[group] ?? 8}ch` }
1219
+ }), /* @__PURE__ */ jsx(Skeleton, { className: "size-4 shrink-0 rounded-sm" })]
1220
+ }), /* @__PURE__ */ jsx("ul", {
1221
+ className: "flex flex-col gap-1 px-3 pb-3",
1222
+ children: Array.from({ length: 4 }, (_, row) => /* @__PURE__ */ jsxs("li", {
1223
+ className: "flex items-center gap-2 px-1 py-1",
1224
+ children: [
1225
+ /* @__PURE__ */ jsx(Skeleton, { className: "size-4 shrink-0 rounded-[4px]" }),
1226
+ /* @__PURE__ */ jsx(Skeleton, {
1227
+ className: "h-4 max-w-full rounded-sm font-mono text-xs leading-4",
1228
+ style: { width: `${FILTER_VALUE_CHS[(group * 4 + row) % FILTER_VALUE_CHS.length]}ch` }
1229
+ }),
1230
+ /* @__PURE__ */ jsx(Skeleton, { className: "ml-auto h-4 w-[2ch] rounded-sm" })
1231
+ ]
1232
+ }, row))
1233
+ })]
1234
+ }, group))
1235
+ });
1236
+ }
395
1237
  function LogsFilters({ className }) {
396
- const { facets, hasActiveFilters, resetFilters } = useLogs();
1238
+ const { facets, hasActiveFilters, resetFilters, isLoading } = useLogs();
1239
+ const showFacetSkeleton = isLoading && facets.length === 0;
397
1240
  return /* @__PURE__ */ jsxs("aside", {
398
1241
  "data-slot": "logs-filters",
399
1242
  className: cn("bg-background flex h-full min-h-0 w-64 shrink-0 flex-col border-r", className),
@@ -411,7 +1254,7 @@ function LogsFilters({ className }) {
411
1254
  })]
412
1255
  }), /* @__PURE__ */ jsxs("div", {
413
1256
  className: "min-h-0 flex-1 overflow-y-auto",
414
- children: [/* @__PURE__ */ jsx(LogsTimeRangeFilter, {}), facets.map((facet, index) => /* @__PURE__ */ jsx(LogsFilterGroup, {
1257
+ children: [/* @__PURE__ */ jsx(LogsTimeRangeFilter, {}), showFacetSkeleton ? /* @__PURE__ */ jsx(LogsFiltersSkeleton, {}) : facets.map((facet, index) => /* @__PURE__ */ jsx(LogsFilterGroup, {
415
1258
  facet,
416
1259
  defaultOpen: index === 0
417
1260
  }, facet.name))]
@@ -419,61 +1262,74 @@ function LogsFilters({ className }) {
419
1262
  });
420
1263
  }
421
1264
  //#endregion
422
- //#region src/components/features/logs/utils/format-timestamp.ts
423
- function formatLogTimestamp(date) {
424
- return format(date, "MMM dd HH:mm:ss.SS").toUpperCase();
425
- }
426
- //#endregion
427
1265
  //#region src/components/features/logs/components/table.tsx
428
- const COLUMN_LABEL = {
429
- time: "Time",
430
- severity: "Severity",
431
- status: "Status",
432
- service: "Service",
433
- resource: "Resource",
434
- path: "Path",
435
- message: "Message"
436
- };
437
- const COLUMN_HEAD_CLASS = {
438
- time: "w-[168px]",
439
- severity: "w-[88px]",
440
- status: "w-[110px]",
441
- service: "w-[160px]",
442
- resource: "w-[160px]",
443
- path: "w-[200px]",
444
- message: ""
445
- };
446
- function LogCell({ column, entry, parsed, path, message }) {
447
- switch (column) {
448
- case "time": return /* @__PURE__ */ jsx(TableCell, { children: /* @__PURE__ */ jsx("time", {
449
- dateTime: entry.timestamp.toISOString(),
450
- className: "text-muted-foreground font-mono text-xs whitespace-nowrap",
451
- children: formatLogTimestamp(entry.timestamp)
452
- }) });
453
- case "severity": return /* @__PURE__ */ jsx(TableCell, { children: /* @__PURE__ */ jsx(LogsSeverityBadge, { severity: entry.labels.severity }) });
454
- case "status": return /* @__PURE__ */ jsx(TableCell, { children: /* @__PURE__ */ jsx(LogsStatusBadge, { parsed }) });
455
- case "service": return /* @__PURE__ */ jsx(TableCell, {
456
- className: "text-muted-foreground truncate font-mono text-xs",
457
- children: entry.labels.service_name ?? "—"
458
- });
459
- case "resource": return /* @__PURE__ */ jsx(TableCell, {
460
- className: "text-muted-foreground truncate font-mono text-xs",
461
- children: entry.labels.resource_name ?? "—"
462
- });
463
- case "path": return /* @__PURE__ */ jsx(TableCell, {
464
- className: cn("truncate font-mono text-xs", !path && "text-muted-foreground"),
465
- title: path ?? void 0,
466
- children: path ?? "—"
467
- });
468
- case "message": return /* @__PURE__ */ jsx(TableCell, {
469
- className: "truncate font-mono text-xs",
470
- children: message
471
- });
472
- }
1266
+ const SKELETON_ROWS = 12;
1267
+ /** Centred message shared by the empty and error states. */
1268
+ function LogsTableState({ icon, title, description, action, slot, role, tone = "muted" }) {
1269
+ return /* @__PURE__ */ jsx("div", {
1270
+ className: "flex min-h-48 flex-1 items-center justify-center px-4 py-8 text-center",
1271
+ children: /* @__PURE__ */ jsxs("div", {
1272
+ role,
1273
+ "data-slot": slot,
1274
+ className: "flex max-w-md flex-col items-center gap-2",
1275
+ children: [
1276
+ /* @__PURE__ */ jsx("span", {
1277
+ className: cn("flex size-9 items-center justify-center rounded-full border", tone === "destructive" ? "border-destructive/20 bg-destructive/10 text-destructive" : "border-border bg-muted text-muted-foreground"),
1278
+ children: icon
1279
+ }),
1280
+ /* @__PURE__ */ jsx("p", {
1281
+ className: "text-foreground text-sm font-medium",
1282
+ children: title
1283
+ }),
1284
+ description && /* @__PURE__ */ jsx("p", {
1285
+ className: "text-muted-foreground text-xs",
1286
+ children: description
1287
+ }),
1288
+ action && /* @__PURE__ */ jsx("div", {
1289
+ className: "mt-1.5",
1290
+ children: action
1291
+ })
1292
+ ]
1293
+ })
1294
+ });
1295
+ }
1296
+ function LogCell({ column, children }) {
1297
+ return /* @__PURE__ */ jsx(TableCell, {
1298
+ className: cn(columnCellClass(column), column.className),
1299
+ style: columnWidthStyle(column),
1300
+ children
1301
+ });
473
1302
  }
474
1303
  function LogsTable({ className }) {
475
- const { entries, columns, selectedId, setSelectedId, isLoading, error, selectPrevious, selectNext } = useLogs();
1304
+ const { entries, columns, selectedId, setSelectedId, isLoading, error, selectPrevious, selectNext, onRefresh, hasActiveFilters, resetFilters, search, setSearch } = useLogs();
476
1305
  const scrollerRef = useRef(null);
1306
+ const rows = useMemo(() => entries.map((entry) => {
1307
+ const { parsed, path, message } = logLineDisplay(entry.line, entry.labels);
1308
+ return {
1309
+ entry,
1310
+ ctx: {
1311
+ entry,
1312
+ parsed,
1313
+ path,
1314
+ message
1315
+ }
1316
+ };
1317
+ }), [entries]);
1318
+ const hasRows = entries.length > 0;
1319
+ const showError = Boolean(error) && !hasRows;
1320
+ const showErrorBanner = Boolean(error) && hasRows;
1321
+ const showSkeleton = isLoading && !hasRows && !error;
1322
+ const showEmpty = !isLoading && !hasRows && !error;
1323
+ const retry = onRefresh && /* @__PURE__ */ jsxs(Button, {
1324
+ type: "secondary",
1325
+ theme: "outline",
1326
+ size: "small",
1327
+ onClick: onRefresh,
1328
+ children: [/* @__PURE__ */ jsx(Icon, {
1329
+ icon: RefreshCw,
1330
+ size: 14
1331
+ }), "Retry"]
1332
+ });
477
1333
  function onKeyDown(event) {
478
1334
  if (event.key === "ArrowDown" || event.key === "j") {
479
1335
  event.preventDefault();
@@ -488,53 +1344,232 @@ function LogsTable({ className }) {
488
1344
  return /* @__PURE__ */ jsxs("div", {
489
1345
  ref: scrollerRef,
490
1346
  "data-slot": "logs-table",
491
- className: cn("min-h-0 flex-1 overflow-auto outline-none", className),
1347
+ className: cn("flex min-h-0 flex-1 flex-col overflow-auto outline-none", className),
492
1348
  tabIndex: 0,
493
1349
  "aria-busy": isLoading || void 0,
494
1350
  onKeyDown,
495
- children: [error && /* @__PURE__ */ jsx("div", {
496
- role: "alert",
497
- className: "text-destructive px-4 py-3 text-sm",
498
- children: error
499
- }), /* @__PURE__ */ jsxs("table", {
500
- className: "w-full caption-bottom table-fixed text-sm",
501
- children: [/* @__PURE__ */ jsx(TableHeader, {
502
- className: "bg-background sticky top-0 z-10",
503
- children: /* @__PURE__ */ jsx(TableRow, { children: columns.map((column) => /* @__PURE__ */ jsx(TableHead, {
504
- className: COLUMN_HEAD_CLASS[column],
505
- children: COLUMN_LABEL[column]
506
- }, column)) })
507
- }), /* @__PURE__ */ jsxs(TableBody, { children: [
508
- isLoading && entries.length === 0 && Array.from({ length: 8 }, (_, index) => /* @__PURE__ */ jsx(TableRow, { children: /* @__PURE__ */ jsx(TableCell, {
509
- colSpan: columns.length,
510
- children: /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" })
511
- }) }, `skeleton-${index}`)),
512
- !isLoading && entries.length === 0 && !error && /* @__PURE__ */ jsx(TableRow, { children: /* @__PURE__ */ jsx(TableCell, {
513
- colSpan: columns.length,
514
- className: "text-muted-foreground py-10 text-center text-sm",
515
- children: "No logs in this time range"
516
- }) }),
517
- entries.map((entry) => {
518
- const { parsed, path, message } = logLineDisplay(entry.line);
1351
+ children: [
1352
+ showErrorBanner && /* @__PURE__ */ jsxs("div", {
1353
+ role: "alert",
1354
+ "data-slot": "logs-error-banner",
1355
+ className: "border-destructive/30 bg-destructive/5 text-foreground sticky top-0 z-20 flex items-center gap-2 border-b px-3 py-1.5 text-xs",
1356
+ children: [
1357
+ /* @__PURE__ */ jsx(Icon, {
1358
+ icon: TriangleAlert,
1359
+ size: 14,
1360
+ className: "text-destructive shrink-0"
1361
+ }),
1362
+ /* @__PURE__ */ jsxs("span", {
1363
+ className: "min-w-0 truncate",
1364
+ children: [
1365
+ /* @__PURE__ */ jsx("span", {
1366
+ className: "font-medium",
1367
+ children: "Refresh failed."
1368
+ }),
1369
+ " ",
1370
+ /* @__PURE__ */ jsx("span", {
1371
+ className: "text-muted-foreground",
1372
+ children: "Showing the last successful result."
1373
+ }),
1374
+ " ",
1375
+ /* @__PURE__ */ jsx("code", {
1376
+ className: "font-mono",
1377
+ children: error
1378
+ })
1379
+ ]
1380
+ }),
1381
+ /* @__PURE__ */ jsx("span", {
1382
+ className: "ml-auto shrink-0",
1383
+ children: retry
1384
+ })
1385
+ ]
1386
+ }),
1387
+ /* @__PURE__ */ jsxs("table", {
1388
+ className: "w-full shrink-0 caption-bottom table-auto text-sm [&_th]:px-3 [&_th]:py-2 [&_td]:px-3 [&_td]:py-1.5",
1389
+ children: [/* @__PURE__ */ jsx(TableHeader, {
1390
+ className: "bg-background sticky top-0 z-10",
1391
+ children: /* @__PURE__ */ jsx(TableRow, { children: columns.map((column) => /* @__PURE__ */ jsx(TableHead, {
1392
+ className: cn(columnHeadClass(column), column.headerClassName),
1393
+ style: columnWidthStyle(column),
1394
+ children: columnHeader(column)
1395
+ }, column.id)) })
1396
+ }), /* @__PURE__ */ jsxs(TableBody, { children: [showSkeleton && Array.from({ length: SKELETON_ROWS }, (_, index) => /* @__PURE__ */ jsx(TableRow, {
1397
+ "data-slot": "logs-skeleton-row",
1398
+ children: columns.map((column) => /* @__PURE__ */ jsx(LogCell, {
1399
+ column,
1400
+ children: columnSkeleton(column, index)
1401
+ }, column.id))
1402
+ }, `skeleton-${index}`)), rows.map(({ entry, ctx }) => {
519
1403
  const selected = entry.id === selectedId;
520
1404
  return /* @__PURE__ */ jsx(TableRow, {
521
1405
  "data-state": selected ? "selected" : void 0,
522
1406
  "aria-selected": selected,
523
- className: cn("cursor-pointer hover:bg-foreground/[0.08]", "data-[state=selected]:bg-foreground/[0.14]", "data-[state=selected]:hover:bg-foreground/[0.14]"),
1407
+ className: cn("cursor-pointer hover:bg-foreground/5", "data-[state=selected]:bg-foreground/10", "data-[state=selected]:hover:bg-foreground/10"),
524
1408
  onClick: () => {
525
1409
  setSelectedId(selected ? null : entry.id);
526
1410
  scrollerRef.current?.focus();
527
1411
  },
528
1412
  children: columns.map((column) => /* @__PURE__ */ jsx(LogCell, {
529
1413
  column,
530
- entry,
531
- parsed,
532
- path,
533
- message
534
- }, column))
1414
+ children: column.cell(ctx)
1415
+ }, column.id))
535
1416
  }, entry.id);
1417
+ })] })]
1418
+ }),
1419
+ showError && /* @__PURE__ */ jsx(LogsTableState, {
1420
+ role: "alert",
1421
+ slot: "logs-error",
1422
+ tone: "destructive",
1423
+ icon: /* @__PURE__ */ jsx(Icon, { icon: TriangleAlert }),
1424
+ title: "Couldn't load logs",
1425
+ description: /* @__PURE__ */ jsx("code", {
1426
+ className: "bg-muted text-foreground inline-block max-w-full rounded-md border px-2 py-1 text-left font-mono text-[11px] leading-4 break-all",
1427
+ children: error
1428
+ }),
1429
+ action: retry
1430
+ }),
1431
+ showEmpty && /* @__PURE__ */ jsx(LogsTableState, {
1432
+ slot: "logs-empty",
1433
+ icon: /* @__PURE__ */ jsx(Icon, { icon: Inbox }),
1434
+ title: "No logs in this time range",
1435
+ description: hasActiveFilters || search ? "Try widening the range or clearing the filters." : "Try widening the range or check back in a moment.",
1436
+ action: (hasActiveFilters || search) && /* @__PURE__ */ jsx(Button, {
1437
+ type: "secondary",
1438
+ theme: "outline",
1439
+ size: "small",
1440
+ onClick: () => {
1441
+ resetFilters();
1442
+ setSearch("");
1443
+ },
1444
+ children: "Clear filters"
536
1445
  })
537
- ] })]
1446
+ })
1447
+ ]
1448
+ });
1449
+ }
1450
+ const MINUTE_MS = 6e4;
1451
+ const DAY_MS = 24 * (60 * MINUTE_MS);
1452
+ /**
1453
+ * Bucket `entries` into `bucketCount` equal-width bars across `range`.
1454
+ * Entries outside the range are ignored. Returns `[]` when the range is
1455
+ * invalid or empty so the timeline can fall back to an axis-only state.
1456
+ */
1457
+ function histogramFromEntries(entries, range, bucketCount = 60) {
1458
+ const from = new Date(range.from).getTime();
1459
+ const to = new Date(range.to).getTime();
1460
+ if (Number.isNaN(from) || Number.isNaN(to) || to <= from || bucketCount < 1) return [];
1461
+ const width = (to - from) / bucketCount;
1462
+ const counts = Array.from({ length: bucketCount }).fill(0);
1463
+ for (const entry of entries) {
1464
+ const at = entry.timestamp.getTime();
1465
+ if (at < from || at > to) continue;
1466
+ const index = Math.min(Math.floor((at - from) / width), bucketCount - 1);
1467
+ counts[index] += 1;
1468
+ }
1469
+ return counts.map((count, index) => ({
1470
+ start: new Date(from + index * width).toISOString(),
1471
+ end: new Date(index === bucketCount - 1 ? to : from + (index + 1) * width).toISOString(),
1472
+ count
1473
+ }));
1474
+ }
1475
+ /** Index of the bucket containing `date`, or `-1`. */
1476
+ function histogramBucketIndex(histogram, date) {
1477
+ const at = date.getTime();
1478
+ const last = histogram.length - 1;
1479
+ return histogram.findIndex((bucket, index) => {
1480
+ const start = new Date(bucket.start).getTime();
1481
+ const end = new Date(bucket.end).getTime();
1482
+ return at >= start && (at < end || index === last && at === end);
1483
+ });
1484
+ }
1485
+ /** Total span covered by a histogram, in ms. `0` when empty. */
1486
+ function histogramSpanMs(histogram) {
1487
+ const first = histogram[0];
1488
+ const last = histogram[histogram.length - 1];
1489
+ if (!first || !last) return 0;
1490
+ return new Date(last.end).getTime() - new Date(first.start).getTime();
1491
+ }
1492
+ /** Axis tick label whose precision follows the span being displayed. */
1493
+ function formatTimelineTick(date, spanMs) {
1494
+ if (spanMs <= 2 * MINUTE_MS) return format(date, "HH:mm:ss");
1495
+ if (spanMs < DAY_MS) return format(date, "HH:mm");
1496
+ return format(date, "MMM d HH:mm");
1497
+ }
1498
+ /** Tooltip text for a bucket: count plus its window. */
1499
+ function formatBucketSummary(bucket, spanMs) {
1500
+ const start = new Date(bucket.start);
1501
+ const end = new Date(bucket.end);
1502
+ const tickSpan = end.getTime() - start.getTime() < MINUTE_MS ? MINUTE_MS : spanMs;
1503
+ const noun = bucket.count === 1 ? "log" : "logs";
1504
+ return `${bucket.count} ${noun} · ${formatTimelineTick(start, tickSpan)} – ${formatTimelineTick(end, tickSpan)}`;
1505
+ }
1506
+ //#endregion
1507
+ //#region src/components/features/logs/components/timeline.tsx
1508
+ const SKELETON_HEIGHTS = [
1509
+ 18,
1510
+ 34,
1511
+ 22,
1512
+ 48,
1513
+ 30,
1514
+ 60,
1515
+ 26,
1516
+ 40,
1517
+ 20,
1518
+ 52,
1519
+ 36,
1520
+ 28
1521
+ ];
1522
+ function TimelineSkeleton() {
1523
+ return /* @__PURE__ */ jsx("div", {
1524
+ "data-slot": "logs-timeline-skeleton",
1525
+ className: "flex h-10 items-end gap-px",
1526
+ "aria-hidden": true,
1527
+ children: Array.from({ length: 60 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, {
1528
+ className: "min-w-0 flex-1 rounded-[1px]",
1529
+ style: { height: `${SKELETON_HEIGHTS[index % SKELETON_HEIGHTS.length]}%` }
1530
+ }, index))
1531
+ });
1532
+ }
1533
+ function LogsTimeline({ className }) {
1534
+ const { histogram, isLoading, selectedEntry, timeRange } = useLogs();
1535
+ const spanMs = histogramSpanMs(histogram);
1536
+ const max = useMemo(() => histogram.reduce((peak, bucket) => Math.max(peak, bucket.count), 0), [histogram]);
1537
+ const total = useMemo(() => histogram.reduce((sum, bucket) => sum + bucket.count, 0), [histogram]);
1538
+ const selectedIndex = selectedEntry ? histogramBucketIndex(histogram, selectedEntry.timestamp) : -1;
1539
+ const titles = useMemo(() => histogram.map((bucket) => formatBucketSummary(bucket, spanMs)), [histogram, spanMs]);
1540
+ const from = histogram[0] ? new Date(histogram[0].start) : new Date(timeRange.from);
1541
+ const to = histogram.length > 0 ? new Date(histogram[histogram.length - 1].end) : new Date(timeRange.to);
1542
+ const axisSpan = spanMs > 0 ? spanMs : to.getTime() - from.getTime();
1543
+ const mid = /* @__PURE__ */ new Date((from.getTime() + to.getTime()) / 2);
1544
+ const showSkeleton = isLoading && histogram.length === 0;
1545
+ return /* @__PURE__ */ jsxs("div", {
1546
+ "data-slot": "logs-timeline",
1547
+ className: cn("border-b px-3 pt-2 pb-1", className),
1548
+ children: [showSkeleton ? /* @__PURE__ */ jsx(TimelineSkeleton, {}) : /* @__PURE__ */ jsx("div", {
1549
+ role: "img",
1550
+ "aria-label": `${total} logs between ${formatTimelineTick(from, axisSpan)} and ${formatTimelineTick(to, axisSpan)}`,
1551
+ className: "flex h-10 items-end gap-px",
1552
+ children: histogram.map((bucket, index) => {
1553
+ const height = max > 0 ? bucket.count / max * 100 : 0;
1554
+ const selected = index === selectedIndex;
1555
+ return /* @__PURE__ */ jsx("div", {
1556
+ title: titles[index],
1557
+ "data-slot": "logs-timeline-bucket",
1558
+ "data-selected": selected || void 0,
1559
+ className: "relative flex h-full min-w-0 flex-1 items-end",
1560
+ children: /* @__PURE__ */ jsx("div", {
1561
+ className: cn("w-full rounded-[1px] transition-colors", bucket.count > 0 ? "bg-foreground/30 hover:bg-foreground/50" : "bg-foreground/[0.06]", selected && "bg-primary hover:bg-primary"),
1562
+ style: { height: bucket.count > 0 ? `max(${height}%, 2px)` : "1px" }
1563
+ })
1564
+ }, bucket.start);
1565
+ })
1566
+ }), /* @__PURE__ */ jsxs("div", {
1567
+ className: "text-muted-foreground mt-1 flex justify-between font-mono text-[10px] tabular-nums",
1568
+ children: [
1569
+ /* @__PURE__ */ jsx("span", { children: formatTimelineTick(from, axisSpan) }),
1570
+ /* @__PURE__ */ jsx("span", { children: formatTimelineTick(mid, axisSpan) }),
1571
+ /* @__PURE__ */ jsx("span", { children: formatTimelineTick(to, axisSpan) })
1572
+ ]
538
1573
  })]
539
1574
  });
540
1575
  }
@@ -563,7 +1598,10 @@ function LogsSearch({ placeholder = "Search logs...", className }) {
563
1598
  }, [value, setSearch]);
564
1599
  return /* @__PURE__ */ jsxs("div", {
565
1600
  className: cn("relative min-w-0 flex-1", className),
566
- children: [/* @__PURE__ */ jsx(Search, { className: "text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2" }), /* @__PURE__ */ jsx(Input, {
1601
+ children: [/* @__PURE__ */ jsx(Icon, {
1602
+ icon: Search,
1603
+ className: "text-muted-foreground pointer-events-none absolute top-1/2 left-3 -translate-y-1/2"
1604
+ }), /* @__PURE__ */ jsx(Input, {
567
1605
  value,
568
1606
  onChange: (event) => setValue(event.target.value),
569
1607
  placeholder,
@@ -601,7 +1639,7 @@ function LogsToolbar({ children, className }) {
601
1639
  size: "icon",
602
1640
  "aria-label": "Refresh logs",
603
1641
  onClick: onRefresh,
604
- children: /* @__PURE__ */ jsx(RefreshCw, { className: "size-4" })
1642
+ children: /* @__PURE__ */ jsx(Icon, { icon: RefreshCw })
605
1643
  }),
606
1644
  onExport && /* @__PURE__ */ jsx(Button, {
607
1645
  type: "secondary",
@@ -609,7 +1647,7 @@ function LogsToolbar({ children, className }) {
609
1647
  size: "icon",
610
1648
  "aria-label": "Export logs",
611
1649
  onClick: () => onExport(entries),
612
- children: /* @__PURE__ */ jsx(Download, { className: "size-4" })
1650
+ children: /* @__PURE__ */ jsx(Icon, { icon: Download })
613
1651
  })
614
1652
  ]
615
1653
  });
@@ -644,16 +1682,20 @@ function LogsExplorer({ className }) {
644
1682
  }),
645
1683
  /* @__PURE__ */ jsxs("div", {
646
1684
  className: "flex min-w-0 flex-1 flex-col",
647
- children: [/* @__PURE__ */ jsx(LogsToolbar, { children: !isDesktop && /* @__PURE__ */ jsxs(Button, {
648
- type: "secondary",
649
- theme: "outline",
650
- size: "small",
651
- onClick: () => setFiltersOpen(true),
652
- children: [/* @__PURE__ */ jsx(SlidersHorizontal, { className: "size-4" }), "Filters"]
653
- }) }), /* @__PURE__ */ jsxs("div", {
654
- className: "flex min-h-0 flex-1",
655
- children: [/* @__PURE__ */ jsx(LogsTable, {}), isDesktop && /* @__PURE__ */ jsx(LogsDetail, {})]
656
- })]
1685
+ children: [
1686
+ /* @__PURE__ */ jsx(LogsToolbar, { children: !isDesktop && /* @__PURE__ */ jsxs(Button, {
1687
+ type: "secondary",
1688
+ theme: "outline",
1689
+ size: "small",
1690
+ onClick: () => setFiltersOpen(true),
1691
+ children: [/* @__PURE__ */ jsx(Icon, { icon: SlidersHorizontal }), "Filters"]
1692
+ }) }),
1693
+ /* @__PURE__ */ jsx(LogsTimeline, {}),
1694
+ /* @__PURE__ */ jsxs("div", {
1695
+ className: "relative flex min-h-0 min-w-0 flex-1",
1696
+ children: [/* @__PURE__ */ jsx(LogsTable, {}), isDesktop && /* @__PURE__ */ jsx(LogsDetail, {})]
1697
+ })
1698
+ ]
657
1699
  }),
658
1700
  !isDesktop && /* @__PURE__ */ jsx(LogsDetail, {})
659
1701
  ]
@@ -687,7 +1729,8 @@ const CANONICAL_FACET_NAMES = [
687
1729
  const FACET_LABELS = {
688
1730
  severity: "Severity",
689
1731
  service_name: "Service",
690
- resource_name: "Resource"
1732
+ resource_name: "Resource",
1733
+ host: "Host"
691
1734
  };
692
1735
  const SEVERITY_ORDER = [
693
1736
  "ERROR",
@@ -702,7 +1745,10 @@ const DEFAULT_LOGQL_MATCHER = "{service_name=~\".+\"}";
702
1745
  //#endregion
703
1746
  //#region src/components/features/logs/utils/facets.ts
704
1747
  function facetLabel(name) {
705
- return FACET_LABELS[name] ?? name.replaceAll("_", " ");
1748
+ const mapped = FACET_LABELS[name];
1749
+ if (mapped) return mapped;
1750
+ const raw = name.replaceAll("_", " ");
1751
+ return raw ? raw.charAt(0).toUpperCase() + raw.slice(1) : raw;
706
1752
  }
707
1753
  function compareFacetValues(name, a, b) {
708
1754
  if (name === "severity") {
@@ -754,25 +1800,38 @@ function filterEntries(entries, filters, search = "") {
754
1800
  const label = entry.labels[name];
755
1801
  if (!label || !values.includes(label)) return false;
756
1802
  }
757
- if (query && !entry.line.toLowerCase().includes(query)) return false;
1803
+ if (query && !entryMatchesSearch(entry, query)) return false;
758
1804
  return true;
759
1805
  });
760
1806
  }
761
- //#endregion
762
- //#region src/components/features/logs/utils/time-range.ts
763
- const THIRTY_MINUTES_MS = 18e5;
764
- function lastThirtyMinutes(now = /* @__PURE__ */ new Date()) {
765
- return {
766
- from: (/* @__PURE__ */ new Date(now.getTime() - THIRTY_MINUTES_MS)).toISOString(),
767
- to: now.toISOString()
768
- };
1807
+ /**
1808
+ * Text a search term is matched against: the line, plus the fields the table
1809
+ * actually shows for a label-only access log (method, path, status, host).
1810
+ * Other labels (request ids, user agents, pod names) are deliberately not
1811
+ * searched so a hit is always visible on screen.
1812
+ */
1813
+ function searchableLogText(entry) {
1814
+ const { parsed, path } = logLineDisplay(entry.line, entry.labels);
1815
+ const parts = [entry.line, path ?? ""];
1816
+ if (parsed.kind === "http") parts.push(formatHttpLogLine(parsed));
1817
+ const host = logRequestHost(entry.labels);
1818
+ if (host) parts.push(host);
1819
+ return parts.join("\n").toLowerCase();
769
1820
  }
770
- function filtersAreActive(filters) {
771
- return Object.values(filters).some((values) => values.length > 0);
1821
+ function entryMatchesSearch(entry, query) {
1822
+ return searchableLogText(entry).includes(query);
772
1823
  }
773
1824
  //#endregion
774
1825
  //#region src/components/features/logs/components/root.tsx
775
- function LogsRoot({ entries, facets, timeRange, defaultTimeRange, filters, defaultFilters, search, defaultSearch = "", live, defaultLive = false, selectedId, defaultSelectedId = null, isLoading = false, error, columns = DEFAULT_LOG_COLUMNS, onTimeRangeChange, onFiltersChange, onSearchChange, onLiveChange, onSelectedIdChange, onRefresh, onExport, className, children }) {
1826
+ /** Return the previous array while its items are shallow-equal to the new one. */
1827
+ function useShallowArray(next) {
1828
+ const ref = useRef(next);
1829
+ const prev = ref.current;
1830
+ const same = prev === next || prev.length === next.length && prev.every((item, index) => item === next[index]);
1831
+ if (!same) ref.current = next;
1832
+ return same ? prev : next;
1833
+ }
1834
+ function LogsRoot({ entries, facets, histogram, timeRange, defaultTimeRange, filters, defaultFilters, search, defaultSearch = "", live, defaultLive = false, selectedId, defaultSelectedId = null, isLoading = false, error, columns = DEFAULT_LOG_COLUMNS, onTimeRangeChange, onFiltersChange, onSearchChange, onLiveChange, onSelectedIdChange, onRefresh, onExport, className, children }) {
776
1835
  const [currentTimeRange, setTimeRange] = useControllableState({
777
1836
  value: timeRange,
778
1837
  defaultValue: defaultTimeRange ?? lastThirtyMinutes(),
@@ -799,6 +1858,13 @@ function LogsRoot({ entries, facets, timeRange, defaultTimeRange, filters, defau
799
1858
  onChange: onSelectedIdChange
800
1859
  });
801
1860
  const resolvedFacets = useMemo(() => facets ?? facetsFromEntries(entries), [facets, entries]);
1861
+ const resolvedHistogram = useMemo(() => histogram ?? histogramFromEntries(entries, currentTimeRange), [
1862
+ histogram,
1863
+ entries,
1864
+ currentTimeRange
1865
+ ]);
1866
+ const stableColumns = useShallowArray(columns);
1867
+ const resolvedColumns = useMemo(() => resolveLogColumns(stableColumns), [stableColumns]);
802
1868
  const selectedIndex = useMemo(() => entries.findIndex((entry) => entry.id === currentSelectedId), [entries, currentSelectedId]);
803
1869
  const selectedEntry = selectedIndex >= 0 ? entries[selectedIndex] ?? null : null;
804
1870
  const toggleFilterValue = useCallback((name, value) => {
@@ -833,6 +1899,7 @@ function LogsRoot({ entries, facets, timeRange, defaultTimeRange, filters, defau
833
1899
  const value = useMemo(() => ({
834
1900
  entries,
835
1901
  facets: resolvedFacets,
1902
+ histogram: resolvedHistogram,
836
1903
  timeRange: currentTimeRange,
837
1904
  setTimeRange,
838
1905
  filters: currentFilters,
@@ -851,13 +1918,14 @@ function LogsRoot({ entries, facets, timeRange, defaultTimeRange, filters, defau
851
1918
  selectNext,
852
1919
  isLoading,
853
1920
  error,
854
- columns,
1921
+ columns: resolvedColumns,
855
1922
  hasActiveFilters: filtersAreActive(currentFilters),
856
1923
  onRefresh,
857
1924
  onExport
858
1925
  }), [
859
1926
  entries,
860
1927
  resolvedFacets,
1928
+ resolvedHistogram,
861
1929
  currentTimeRange,
862
1930
  setTimeRange,
863
1931
  currentFilters,
@@ -876,7 +1944,7 @@ function LogsRoot({ entries, facets, timeRange, defaultTimeRange, filters, defau
876
1944
  selectNext,
877
1945
  isLoading,
878
1946
  error,
879
- columns,
1947
+ resolvedColumns,
880
1948
  onRefresh,
881
1949
  onExport
882
1950
  ]);
@@ -900,6 +1968,7 @@ const Logs = {
900
1968
  Toolbar: LogsToolbar,
901
1969
  Search: LogsSearch,
902
1970
  LiveToggle: LogsLiveToggle,
1971
+ Timeline: LogsTimeline,
903
1972
  Table: LogsTable,
904
1973
  Detail: LogsDetail
905
1974
  };
@@ -977,4 +2046,4 @@ function flattenLokiStreams(response) {
977
2046
  return entries;
978
2047
  }
979
2048
  //#endregion
980
- export { Logs, buildLogQL, facetsFromEntries, filterEntries, flattenLokiStreams, httpStatusBadgeType, httpStatusTextClass, lastThirtyMinutes, logLineDisplay, nsToDate, parseLogLine, severityBadgeType, useLogs };
2049
+ export { LOG_TIME_PRESETS, Logs, buildLogQL, facetsFromEntries, filterEntries, flattenLokiStreams, formatHttpLogLine, histogramFromEntries, httpStatusBadgeType, httpStatusTextClass, lastThirtyMinutes, logLineDisplay, logRequestHost, logTimeRangeLabel, logUserAgent, nsToDate, parseLogLine, parseUserAgent, resolveLogColumns, resolveLogTimeRange, searchableLogText, severityBadgeType, splitPathQuery, useLogs };