@datum-cloud/datum-ui 2.5.0 → 2.6.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.
- package/dist/{calendar-date-picker-Cl4lIDl5.mjs → calendar-date-picker-bpTUcGu_.mjs} +1 -1
- package/dist/components/features/logs/components/columns.d.ts +15 -0
- package/dist/components/features/logs/components/root.d.ts +1 -1
- package/dist/components/features/logs/components/status-badge.d.ts +5 -0
- package/dist/components/features/logs/components/timeline.d.ts +3 -0
- package/dist/components/features/logs/fixtures/index.d.ts +2 -0
- package/dist/components/features/logs/index.d.ts +3 -2
- package/dist/components/features/logs/logs.d.ts +2 -0
- package/dist/components/features/logs/types.d.ts +51 -3
- package/dist/components/features/logs/utils/detail-width.d.ts +3 -0
- package/dist/components/features/logs/utils/facets.d.ts +7 -0
- package/dist/components/features/logs/utils/format-timestamp.d.ts +4 -0
- package/dist/components/features/logs/utils/histogram.d.ts +16 -0
- package/dist/components/features/logs/utils/host.d.ts +7 -0
- package/dist/components/features/logs/utils/index.d.ts +6 -4
- package/dist/components/features/logs/utils/parse-log-line.d.ts +17 -3
- package/dist/components/features/logs/utils/time-range.d.ts +19 -0
- package/dist/data-table/index.mjs +1 -1
- package/dist/{data-table-C37OD6_4.mjs → data-table-BaWOHdZX.mjs} +1 -1
- package/dist/date-picker/index.mjs +3 -3
- package/dist/date-time/index.mjs +1 -1
- package/dist/{date-time-BmYjT5vd.mjs → date-time-lZdefM4H.mjs} +1 -1
- package/dist/date-time-picker/index.mjs +1 -1
- package/dist/{date-time-picker-DlaSizpS.mjs → date-time-picker-Bd7qSZVr.mjs} +1 -1
- package/dist/{date-time-range-picker-CQQLACgN.mjs → date-time-range-picker-DlFTh3DF.mjs} +12 -3
- package/dist/form/index.mjs +1 -1
- package/dist/{form-DDtBJ3Dj.mjs → form-BKxvdIKB.mjs} +3 -3
- package/dist/grouped-table/index.mjs +1 -1
- package/dist/{grouped-table-DImzzehJ.mjs → grouped-table-DWtEx18V.mjs} +1 -1
- package/dist/index.mjs +9 -9
- package/dist/logs/fixtures/index.mjs +770 -3
- package/dist/logs/index.mjs +1042 -247
- package/dist/picker/index.mjs +2 -2
- package/dist/{picker-Bbjl9FOq.mjs → picker-DbKukBkk.mjs} +1 -1
- package/dist/time-picker/index.mjs +1 -1
- package/dist/{time-picker-CoUqZCb7.mjs → time-picker-rVTkmbIG.mjs} +1 -1
- package/dist/{to-api-format-BzKfX7yo.mjs → to-api-format-Bm1VcKkS.mjs} +1 -1
- package/package.json +1 -1
package/dist/logs/index.mjs
CHANGED
|
@@ -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,79 +9,35 @@ 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-
|
|
12
|
-
import { t as DateTime } from "../date-time-
|
|
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 { ChevronDown, ChevronUp, Clock, Copy, Download, Inbox, RefreshCw, Search, SlidersHorizontal, 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
|
-
//#region src/components/features/logs/
|
|
20
|
-
|
|
21
|
-
function useLogs() {
|
|
22
|
-
const context = use(LogsContext);
|
|
23
|
-
if (!context) throw new Error("Logs components must be used within <Logs.Root>");
|
|
24
|
-
return context;
|
|
25
|
-
}
|
|
26
|
-
//#endregion
|
|
27
|
-
//#region src/components/features/logs/utils/parse-log-line.ts
|
|
28
|
-
const ACCESS_LOG_RE = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\S+)\s+(\d{3})\s+(\d+)ms\b/i;
|
|
29
|
-
const PATH_TOKEN_RE = /(?:^|\s)(\/\S+)/;
|
|
30
|
-
function parseLogLine(line) {
|
|
31
|
-
const match = ACCESS_LOG_RE.exec(line);
|
|
32
|
-
if (!match) return {
|
|
33
|
-
kind: "text",
|
|
34
|
-
line
|
|
35
|
-
};
|
|
36
|
-
const method = match[1];
|
|
37
|
-
const path = match[2];
|
|
38
|
-
const status = match[3];
|
|
39
|
-
const duration = match[4];
|
|
40
|
-
if (!method || !path || !status || !duration) return {
|
|
41
|
-
kind: "text",
|
|
42
|
-
line
|
|
43
|
-
};
|
|
44
|
-
return {
|
|
45
|
-
kind: "http",
|
|
46
|
-
method: method.toUpperCase(),
|
|
47
|
-
path,
|
|
48
|
-
status: Number(status),
|
|
49
|
-
durationMs: Number(duration)
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
function logLineDisplay(line) {
|
|
53
|
-
const parsed = parseLogLine(line);
|
|
54
|
-
if (parsed.kind === "http") {
|
|
55
|
-
const match = ACCESS_LOG_RE.exec(line);
|
|
56
|
-
const rest = match ? line.slice(match[0].length).trim() : "";
|
|
57
|
-
return {
|
|
58
|
-
parsed,
|
|
59
|
-
path: parsed.path,
|
|
60
|
-
message: extraMessage(rest)
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
const path = PATH_TOKEN_RE.exec(line)?.[1] ?? null;
|
|
64
|
-
if (!path) return {
|
|
65
|
-
parsed,
|
|
66
|
-
path: null,
|
|
67
|
-
message: line
|
|
68
|
-
};
|
|
19
|
+
import { format, isSameDay } from "date-fns";
|
|
20
|
+
//#region src/components/features/logs/utils/format-timestamp.ts
|
|
21
|
+
function formatLogTimestampParts(date) {
|
|
69
22
|
return {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
message: extraMessage(stripOnce(line, path))
|
|
23
|
+
date: format(date, "MMM dd").toUpperCase(),
|
|
24
|
+
time: format(date, "HH:mm:ss.SS")
|
|
73
25
|
};
|
|
74
26
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return rest.split(/\s+/).filter(Boolean).some((token) => !/^[A-Z_]\w*=\S+$/i.test(token)) ? rest : "";
|
|
27
|
+
function formatLogTimestamp(date) {
|
|
28
|
+
const { date: day, time } = formatLogTimestampParts(date);
|
|
29
|
+
return `${day} ${time}`;
|
|
79
30
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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;
|
|
84
41
|
}
|
|
85
42
|
//#endregion
|
|
86
43
|
//#region src/components/features/logs/utils/severity.ts
|
|
@@ -117,30 +74,370 @@ function httpStatusTextClass(status) {
|
|
|
117
74
|
//#endregion
|
|
118
75
|
//#region src/components/features/logs/components/status-badge.tsx
|
|
119
76
|
function LogsSeverityBadge({ severity }) {
|
|
77
|
+
const unlabeled = !severity;
|
|
120
78
|
return /* @__PURE__ */ jsx(Badge, {
|
|
121
79
|
type: severityBadgeType(severity),
|
|
122
|
-
theme: "light",
|
|
123
|
-
className: "h-4 px-1.5 py-0 font-mono text-[10px] leading-4 font-medium",
|
|
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"),
|
|
124
82
|
children: severity ?? "LOG"
|
|
125
83
|
});
|
|
126
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
|
+
}
|
|
127
95
|
function LogsStatusBadge({ parsed }) {
|
|
128
96
|
if (parsed.kind !== "http") return /* @__PURE__ */ jsx("span", {
|
|
129
97
|
className: "text-muted-foreground",
|
|
130
98
|
children: "—"
|
|
131
99
|
});
|
|
132
100
|
return /* @__PURE__ */ jsxs("span", {
|
|
133
|
-
className: "inline-
|
|
101
|
+
className: "inline-flex h-4 items-center gap-1.5 font-mono text-[11px] leading-4 font-medium",
|
|
134
102
|
children: [/* @__PURE__ */ jsx("span", {
|
|
135
|
-
className: "text-muted-foreground",
|
|
136
|
-
children: parsed.method
|
|
137
|
-
}), /* @__PURE__ */ jsx("span", {
|
|
138
103
|
className: cn("tabular-nums", httpStatusTextClass(parsed.status)),
|
|
139
104
|
children: parsed.status
|
|
105
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
106
|
+
className: "text-muted-foreground",
|
|
107
|
+
children: parsed.method
|
|
140
108
|
})]
|
|
141
109
|
});
|
|
142
110
|
}
|
|
143
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
|
|
320
|
+
//#region src/components/features/logs/hooks/use-logs.ts
|
|
321
|
+
const LogsContext = createContext(null);
|
|
322
|
+
function useLogs() {
|
|
323
|
+
const context = use(LogsContext);
|
|
324
|
+
if (!context) throw new Error("Logs components must be used within <Logs.Root>");
|
|
325
|
+
return context;
|
|
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
|
+
}
|
|
335
|
+
//#endregion
|
|
336
|
+
//#region src/components/features/logs/utils/parse-log-line.ts
|
|
337
|
+
const ACCESS_LOG_RE = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\S+)\s+(\d{3})\s+(\d+)ms\b/i;
|
|
338
|
+
const PATH_TOKEN_RE = /(?:^|\s)(\/\S+)/;
|
|
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) ?? {
|
|
345
|
+
kind: "text",
|
|
346
|
+
line
|
|
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;
|
|
355
|
+
const method = match[1];
|
|
356
|
+
const path = match[2];
|
|
357
|
+
const status = match[3];
|
|
358
|
+
const duration = match[4];
|
|
359
|
+
if (!method || !path || !status || !duration) return null;
|
|
360
|
+
return {
|
|
361
|
+
kind: "http",
|
|
362
|
+
method: method.toUpperCase(),
|
|
363
|
+
path,
|
|
364
|
+
status: Number(status),
|
|
365
|
+
durationMs: Number(duration)
|
|
366
|
+
};
|
|
367
|
+
}
|
|
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);
|
|
409
|
+
if (parsed.kind === "http") {
|
|
410
|
+
const match = ACCESS_LOG_RE.exec(line);
|
|
411
|
+
const rest = match ? line.slice(match[0].length).trim() : "";
|
|
412
|
+
return {
|
|
413
|
+
parsed,
|
|
414
|
+
path: parsed.path,
|
|
415
|
+
message: extraMessage(rest)
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
const path = labels.path?.trim() || PATH_TOKEN_RE.exec(line)?.[1] || null;
|
|
419
|
+
if (!path) return {
|
|
420
|
+
parsed,
|
|
421
|
+
path: null,
|
|
422
|
+
message: line
|
|
423
|
+
};
|
|
424
|
+
return {
|
|
425
|
+
parsed,
|
|
426
|
+
path,
|
|
427
|
+
message: extraMessage(line ? stripOnce(line, path) : "")
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
/** Drop leftover that is only fields already shown (method, status, path, duration, label=value). */
|
|
431
|
+
function extraMessage(rest) {
|
|
432
|
+
if (!rest) return "";
|
|
433
|
+
return rest.split(/\s+/).filter(Boolean).some((token) => !/^[A-Z_]\w*=\S+$/i.test(token)) ? rest : "";
|
|
434
|
+
}
|
|
435
|
+
function stripOnce(line, token) {
|
|
436
|
+
const index = line.indexOf(token);
|
|
437
|
+
if (index === -1) return line;
|
|
438
|
+
return `${line.slice(0, index)}${line.slice(index + token.length)}`.replace(/\s+/g, " ").trim();
|
|
439
|
+
}
|
|
440
|
+
//#endregion
|
|
144
441
|
//#region src/components/features/logs/components/detail.tsx
|
|
145
442
|
function MetaCell({ label, children, className }) {
|
|
146
443
|
return /* @__PURE__ */ jsxs("div", {
|
|
@@ -154,77 +451,109 @@ function MetaCell({ label, children, className }) {
|
|
|
154
451
|
})]
|
|
155
452
|
});
|
|
156
453
|
}
|
|
157
|
-
|
|
454
|
+
/** Stable React keys for search params, which may repeat `key=value` verbatim. */
|
|
455
|
+
function keyedParams(params) {
|
|
456
|
+
const seen = /* @__PURE__ */ new Map();
|
|
457
|
+
return params.map(([key, value]) => {
|
|
458
|
+
const pair = `${key}=${value}`;
|
|
459
|
+
const count = seen.get(pair) ?? 0;
|
|
460
|
+
seen.set(pair, count + 1);
|
|
461
|
+
return {
|
|
462
|
+
id: count === 0 ? pair : `${pair}#${count}`,
|
|
463
|
+
key,
|
|
464
|
+
value
|
|
465
|
+
};
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
const DetailBody = memo(({ showClose = true }) => {
|
|
158
469
|
const { selectedEntry, selectPrevious, selectNext, setSelectedId } = useLogs();
|
|
159
470
|
const [, copy] = useCopyToClipboard();
|
|
160
471
|
if (!selectedEntry) return null;
|
|
161
|
-
const parsed = parseLogLine(selectedEntry.line);
|
|
162
|
-
const
|
|
163
|
-
const
|
|
472
|
+
const parsed = parseLogLine(selectedEntry.line, selectedEntry.labels);
|
|
473
|
+
const displayLine = parsed.kind === "http" && !selectedEntry.line.trim() ? formatHttpLogLine(parsed) : selectedEntry.line;
|
|
474
|
+
const request = splitPathQuery(parsed.kind === "http" ? parsed.path : "");
|
|
475
|
+
const title = parsed.kind === "http" ? `${parsed.method} ${request.pathname}` : selectedEntry.labels.service_name ?? "Log";
|
|
476
|
+
const service = selectedEntry.labels.service_name ?? logRequestHost(selectedEntry.labels);
|
|
164
477
|
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("header", {
|
|
165
|
-
className: "
|
|
478
|
+
className: "border-b px-4 py-3",
|
|
166
479
|
children: [/* @__PURE__ */ jsxs("div", {
|
|
167
|
-
className: "
|
|
480
|
+
className: "flex items-center gap-2",
|
|
168
481
|
children: [
|
|
169
|
-
/* @__PURE__ */ jsx("div", {
|
|
170
|
-
className: "mb-1.5",
|
|
171
|
-
children: /* @__PURE__ */ jsx(LogsSeverityBadge, { severity: selectedEntry.labels.severity })
|
|
172
|
-
}),
|
|
173
482
|
/* @__PURE__ */ jsx("h3", {
|
|
174
|
-
className: "truncate font-mono text-sm font-medium",
|
|
483
|
+
className: "min-w-0 flex-1 truncate font-mono text-sm font-medium",
|
|
484
|
+
title,
|
|
175
485
|
children: title
|
|
176
486
|
}),
|
|
487
|
+
parsed.kind === "http" ? /* @__PURE__ */ jsx(LogsHttpStatusChip, { status: parsed.status }) : /* @__PURE__ */ jsx(LogsSeverityBadge, { severity: selectedEntry.labels.severity }),
|
|
177
488
|
/* @__PURE__ */ jsxs("div", {
|
|
178
|
-
className: "
|
|
179
|
-
children: [
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
489
|
+
className: "-my-1 flex shrink-0 items-center gap-1",
|
|
490
|
+
children: [
|
|
491
|
+
/* @__PURE__ */ jsx(Button, {
|
|
492
|
+
type: "secondary",
|
|
493
|
+
theme: "borderless",
|
|
494
|
+
size: "icon",
|
|
495
|
+
"aria-label": "Previous log",
|
|
496
|
+
onClick: selectPrevious,
|
|
497
|
+
children: /* @__PURE__ */ jsx(Icon, { icon: ChevronUp })
|
|
498
|
+
}),
|
|
499
|
+
/* @__PURE__ */ jsx(Button, {
|
|
500
|
+
type: "secondary",
|
|
501
|
+
theme: "borderless",
|
|
502
|
+
size: "icon",
|
|
503
|
+
"aria-label": "Next log",
|
|
504
|
+
onClick: selectNext,
|
|
505
|
+
children: /* @__PURE__ */ jsx(Icon, { icon: ChevronDown })
|
|
506
|
+
}),
|
|
507
|
+
/* @__PURE__ */ jsx(Button, {
|
|
508
|
+
type: "secondary",
|
|
509
|
+
theme: "borderless",
|
|
510
|
+
size: "icon",
|
|
511
|
+
"aria-label": "Copy log line",
|
|
512
|
+
onClick: () => copy(displayLine, { withToast: true }),
|
|
513
|
+
children: /* @__PURE__ */ jsx(Icon, { icon: Copy })
|
|
514
|
+
}),
|
|
515
|
+
showClose && /* @__PURE__ */ jsx(Button, {
|
|
516
|
+
type: "secondary",
|
|
517
|
+
theme: "borderless",
|
|
518
|
+
size: "icon",
|
|
519
|
+
"aria-label": "Close details",
|
|
520
|
+
onClick: () => setSelectedId(null),
|
|
521
|
+
children: /* @__PURE__ */ jsx(Icon, { icon: X })
|
|
522
|
+
})
|
|
523
|
+
]
|
|
192
524
|
})
|
|
193
525
|
]
|
|
194
526
|
}), /* @__PURE__ */ jsxs("div", {
|
|
195
|
-
className: "flex
|
|
527
|
+
className: "text-muted-foreground mt-1 flex min-w-0 flex-nowrap items-center gap-1.5 text-xs",
|
|
196
528
|
children: [
|
|
197
|
-
/* @__PURE__ */ jsx(
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
/* @__PURE__ */ jsx(
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
"aria-label": "Close details",
|
|
226
|
-
onClick: () => setSelectedId(null),
|
|
227
|
-
children: /* @__PURE__ */ jsx(X, { className: "size-4" })
|
|
529
|
+
parsed.kind === "http" && selectedEntry.labels.severity && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(LogsSeverityBadge, { severity: selectedEntry.labels.severity }), /* @__PURE__ */ jsx("span", {
|
|
530
|
+
className: "shrink-0",
|
|
531
|
+
"aria-hidden": "true",
|
|
532
|
+
children: "·"
|
|
533
|
+
})] }),
|
|
534
|
+
service && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
|
|
535
|
+
className: "min-w-0 truncate font-mono",
|
|
536
|
+
children: service
|
|
537
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
538
|
+
className: "shrink-0",
|
|
539
|
+
"aria-hidden": "true",
|
|
540
|
+
children: "·"
|
|
541
|
+
})] }),
|
|
542
|
+
/* @__PURE__ */ jsx(DateTime, {
|
|
543
|
+
date: selectedEntry.timestamp,
|
|
544
|
+
timestamp: selectedEntry.timestampNs,
|
|
545
|
+
variant: "absolute",
|
|
546
|
+
tooltip: "detailed",
|
|
547
|
+
children: /* @__PURE__ */ jsxs("time", {
|
|
548
|
+
dateTime: selectedEntry.timestamp.toISOString(),
|
|
549
|
+
"data-slot": "logs-detail-time",
|
|
550
|
+
className: "shrink-0 cursor-default font-mono whitespace-nowrap tabular-nums",
|
|
551
|
+
children: [
|
|
552
|
+
formatLogTimestamp(selectedEntry.timestamp),
|
|
553
|
+
" ",
|
|
554
|
+
getTimezoneAbbreviation(selectedEntry.timestamp, getBrowserTimezone())
|
|
555
|
+
]
|
|
556
|
+
})
|
|
228
557
|
})
|
|
229
558
|
]
|
|
230
559
|
})]
|
|
@@ -236,7 +565,7 @@ function DetailBody({ showClose = true }) {
|
|
|
236
565
|
children: "Message"
|
|
237
566
|
}), /* @__PURE__ */ jsx("pre", {
|
|
238
567
|
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:
|
|
568
|
+
children: displayLine
|
|
240
569
|
})] }),
|
|
241
570
|
parsed.kind === "http" && /* @__PURE__ */ jsxs("section", { children: [/* @__PURE__ */ jsx("h4", {
|
|
242
571
|
className: "text-muted-foreground mb-2 text-xs font-medium",
|
|
@@ -262,7 +591,42 @@ function DetailBody({ showClose = true }) {
|
|
|
262
591
|
/* @__PURE__ */ jsx(MetaCell, {
|
|
263
592
|
label: "Path",
|
|
264
593
|
className: "col-span-3",
|
|
265
|
-
children:
|
|
594
|
+
children: request.pathname
|
|
595
|
+
}),
|
|
596
|
+
request.params.length > 0 && /* @__PURE__ */ jsxs("div", {
|
|
597
|
+
className: "bg-background col-span-3 min-w-0 px-3 py-2.5",
|
|
598
|
+
"data-slot": "logs-search-params",
|
|
599
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
600
|
+
className: "mb-1.5 flex items-center justify-between",
|
|
601
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
602
|
+
className: "text-muted-foreground text-[11px] font-medium",
|
|
603
|
+
children: "Search params"
|
|
604
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
605
|
+
type: "secondary",
|
|
606
|
+
theme: "borderless",
|
|
607
|
+
size: "icon",
|
|
608
|
+
className: "-my-1 size-6",
|
|
609
|
+
"aria-label": "Copy search params",
|
|
610
|
+
onClick: () => copy(`?${request.search}`, { withToast: true }),
|
|
611
|
+
children: /* @__PURE__ */ jsx(Icon, {
|
|
612
|
+
icon: Copy,
|
|
613
|
+
size: 14
|
|
614
|
+
})
|
|
615
|
+
})]
|
|
616
|
+
}), /* @__PURE__ */ jsx("dl", {
|
|
617
|
+
className: "flex flex-wrap gap-1.5",
|
|
618
|
+
children: keyedParams(request.params).map(({ id, key, value }) => /* @__PURE__ */ jsxs("div", {
|
|
619
|
+
className: "bg-muted/60 inline-flex max-w-full items-stretch overflow-hidden rounded-md border font-mono text-xs",
|
|
620
|
+
children: [/* @__PURE__ */ jsx("dt", {
|
|
621
|
+
className: "text-muted-foreground border-r px-2 py-1",
|
|
622
|
+
children: key
|
|
623
|
+
}), /* @__PURE__ */ jsx("dd", {
|
|
624
|
+
className: "min-w-0 truncate px-2 py-1",
|
|
625
|
+
title: value,
|
|
626
|
+
children: value || "—"
|
|
627
|
+
})]
|
|
628
|
+
}, id))
|
|
629
|
+
})]
|
|
266
630
|
})
|
|
267
631
|
]
|
|
268
632
|
})] }),
|
|
@@ -284,6 +648,59 @@ function DetailBody({ showClose = true }) {
|
|
|
284
648
|
})] })
|
|
285
649
|
]
|
|
286
650
|
})] });
|
|
651
|
+
});
|
|
652
|
+
function LogsDetailResizeHandle({ width, onWidthChange, panelRef }) {
|
|
653
|
+
const dragRef = useRef(null);
|
|
654
|
+
const [maxWidth, setMaxWidth] = useState(() => logsDetailMaxWidth(null));
|
|
655
|
+
const onPointerDown = useCallback((event) => {
|
|
656
|
+
if (event.button !== 0) return;
|
|
657
|
+
event.preventDefault();
|
|
658
|
+
const max = logsDetailMaxWidth(panelRef.current);
|
|
659
|
+
setMaxWidth(max);
|
|
660
|
+
dragRef.current = {
|
|
661
|
+
startX: event.clientX,
|
|
662
|
+
startWidth: width,
|
|
663
|
+
maxWidth: max
|
|
664
|
+
};
|
|
665
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
666
|
+
}, [panelRef, width]);
|
|
667
|
+
const onPointerMove = useCallback((event) => {
|
|
668
|
+
const drag = dragRef.current;
|
|
669
|
+
if (!drag) return;
|
|
670
|
+
onWidthChange(clampLogsDetailWidth(drag.startWidth + (drag.startX - event.clientX), drag.maxWidth));
|
|
671
|
+
}, [onWidthChange]);
|
|
672
|
+
const onPointerUp = useCallback((event) => {
|
|
673
|
+
dragRef.current = null;
|
|
674
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
|
675
|
+
}, []);
|
|
676
|
+
return /* @__PURE__ */ jsx("div", {
|
|
677
|
+
role: "separator",
|
|
678
|
+
"aria-orientation": "vertical",
|
|
679
|
+
"aria-label": "Resize details",
|
|
680
|
+
"aria-valuemin": 400,
|
|
681
|
+
"aria-valuemax": maxWidth,
|
|
682
|
+
"aria-valuenow": width,
|
|
683
|
+
"data-slot": "logs-detail-resize",
|
|
684
|
+
tabIndex: 0,
|
|
685
|
+
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"),
|
|
686
|
+
onPointerDown,
|
|
687
|
+
onPointerMove,
|
|
688
|
+
onPointerUp,
|
|
689
|
+
onPointerCancel: onPointerUp,
|
|
690
|
+
onKeyDown: (event) => {
|
|
691
|
+
const step = event.shiftKey ? 40 : 16;
|
|
692
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
693
|
+
event.preventDefault();
|
|
694
|
+
const max = logsDetailMaxWidth(panelRef.current);
|
|
695
|
+
setMaxWidth(max);
|
|
696
|
+
onWidthChange(clampLogsDetailWidth(width + (event.key === "ArrowLeft" ? step : -step), max));
|
|
697
|
+
}
|
|
698
|
+
if (event.key === "Home") {
|
|
699
|
+
event.preventDefault();
|
|
700
|
+
onWidthChange(400);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
});
|
|
287
704
|
}
|
|
288
705
|
function LogsDetail({ className }) {
|
|
289
706
|
const { selectedEntry, setSelectedId } = useLogs();
|
|
@@ -291,6 +708,8 @@ function LogsDetail({ className }) {
|
|
|
291
708
|
const reduceMotion = useReducedMotion();
|
|
292
709
|
const isDesktop = breakpoint === "desktop";
|
|
293
710
|
const open = selectedEntry !== null;
|
|
711
|
+
const panelRef = useRef(null);
|
|
712
|
+
const [width, setWidth] = useState(400);
|
|
294
713
|
if (!isDesktop) return /* @__PURE__ */ jsx(Sheet, {
|
|
295
714
|
open,
|
|
296
715
|
onOpenChange: (next) => {
|
|
@@ -317,23 +736,101 @@ function LogsDetail({ className }) {
|
|
|
317
736
|
]
|
|
318
737
|
})
|
|
319
738
|
});
|
|
320
|
-
return /* @__PURE__ */ jsx(AnimatePresence, { children: open && /* @__PURE__ */
|
|
739
|
+
return /* @__PURE__ */ jsx(AnimatePresence, { children: open && /* @__PURE__ */ jsxs(motion.aside, {
|
|
740
|
+
ref: panelRef,
|
|
321
741
|
"data-slot": "logs-detail",
|
|
322
|
-
initial: reduceMotion ? false : {
|
|
323
|
-
animate: {
|
|
324
|
-
exit: {
|
|
742
|
+
initial: reduceMotion ? false : { x: "100%" },
|
|
743
|
+
animate: { x: 0 },
|
|
744
|
+
exit: { x: "100%" },
|
|
325
745
|
transition: reduceMotion ? { duration: 0 } : {
|
|
326
746
|
duration: .2,
|
|
327
747
|
ease: "easeOut"
|
|
328
748
|
},
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
749
|
+
style: { width },
|
|
750
|
+
className: cn("bg-background absolute inset-y-0 right-0 z-20 flex h-full flex-col overflow-hidden border-l shadow-lg", className),
|
|
751
|
+
children: [/* @__PURE__ */ jsx(LogsDetailResizeHandle, {
|
|
752
|
+
width,
|
|
753
|
+
onWidthChange: setWidth,
|
|
754
|
+
panelRef
|
|
755
|
+
}), /* @__PURE__ */ jsx(DetailBody, {})]
|
|
334
756
|
}) });
|
|
335
757
|
}
|
|
336
758
|
//#endregion
|
|
759
|
+
//#region src/components/features/logs/utils/time-range.ts
|
|
760
|
+
const MINUTE_MS$1 = 6e4;
|
|
761
|
+
const HOUR_MS = 60 * MINUTE_MS$1;
|
|
762
|
+
const DAY_MS$1 = 24 * HOUR_MS;
|
|
763
|
+
function relativePreset(key, label, durationMs, shortcut) {
|
|
764
|
+
return {
|
|
765
|
+
key,
|
|
766
|
+
label,
|
|
767
|
+
shortcut,
|
|
768
|
+
getRange: () => {
|
|
769
|
+
const now = /* @__PURE__ */ new Date();
|
|
770
|
+
return {
|
|
771
|
+
from: new Date(now.getTime() - durationMs),
|
|
772
|
+
to: now
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Relative "Last N" windows offered by `Logs.Filters`. Keys are persisted on
|
|
779
|
+
* `LogTimeRange.preset` so hosts can slide the window forward with
|
|
780
|
+
* `resolveLogTimeRange` on refresh or while live tailing.
|
|
781
|
+
*
|
|
782
|
+
* Shortcuts follow the `picker` datetime presets (`1` / `2` / `H` / `W`) that
|
|
783
|
+
* the logs picker is built on. Keys that mean something else in the pickers
|
|
784
|
+
* (`3` is "Last 30 minutes" in `time-range-picker`, `D` is "Today") are not
|
|
785
|
+
* reused, so muscle memory carries over between the two.
|
|
786
|
+
*/
|
|
787
|
+
const LOG_TIME_PRESETS = [
|
|
788
|
+
relativePreset("last-15m", "Last 15 minutes", 15 * MINUTE_MS$1, "1"),
|
|
789
|
+
relativePreset("last-30m", "Last 30 minutes", 30 * MINUTE_MS$1, "2"),
|
|
790
|
+
relativePreset("last-1h", "Last hour", HOUR_MS, "H"),
|
|
791
|
+
relativePreset("last-3h", "Last 3 hours", 3 * HOUR_MS),
|
|
792
|
+
relativePreset("last-6h", "Last 6 hours", 6 * HOUR_MS, "6"),
|
|
793
|
+
relativePreset("last-12h", "Last 12 hours", 12 * HOUR_MS),
|
|
794
|
+
relativePreset("last-24h", "Last 24 hours", DAY_MS$1),
|
|
795
|
+
relativePreset("last-7d", "Last 7 days", 7 * DAY_MS$1, "W")
|
|
796
|
+
];
|
|
797
|
+
function lastThirtyMinutes(now = /* @__PURE__ */ new Date()) {
|
|
798
|
+
return {
|
|
799
|
+
from: (/* @__PURE__ */ new Date(now.getTime() - 30 * MINUTE_MS$1)).toISOString(),
|
|
800
|
+
to: now.toISOString(),
|
|
801
|
+
preset: "last-30m"
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Re-anchor a preset range to `now`. Absolute ranges (no `preset`, or an
|
|
806
|
+
* unknown key) are returned unchanged.
|
|
807
|
+
*/
|
|
808
|
+
function resolveLogTimeRange(range, now = /* @__PURE__ */ new Date()) {
|
|
809
|
+
if (!range.preset) return range;
|
|
810
|
+
const preset = LOG_TIME_PRESETS.find((item) => item.key === range.preset);
|
|
811
|
+
if (!preset) return range;
|
|
812
|
+
const { from, to } = preset.getRange("UTC");
|
|
813
|
+
const shift = now.getTime() - to.getTime();
|
|
814
|
+
return {
|
|
815
|
+
from: new Date(from.getTime() + shift).toISOString(),
|
|
816
|
+
to: now.toISOString(),
|
|
817
|
+
preset: range.preset
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
/** Human label for the time-range trigger: preset name, or a compact absolute range. */
|
|
821
|
+
function logTimeRangeLabel(range) {
|
|
822
|
+
const preset = range.preset ? LOG_TIME_PRESETS.find((item) => item.key === range.preset) : void 0;
|
|
823
|
+
if (preset) return preset.label;
|
|
824
|
+
const from = new Date(range.from);
|
|
825
|
+
const to = new Date(range.to);
|
|
826
|
+
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return "Select range";
|
|
827
|
+
if (isSameDay(from, to)) return `${format(from, "MMM d, HH:mm")} – ${format(to, "HH:mm")}`;
|
|
828
|
+
return `${format(from, "MMM d, HH:mm")} – ${format(to, "MMM d, HH:mm")}`;
|
|
829
|
+
}
|
|
830
|
+
function filtersAreActive(filters) {
|
|
831
|
+
return Object.values(filters).some((values) => values.length > 0);
|
|
832
|
+
}
|
|
833
|
+
//#endregion
|
|
337
834
|
//#region src/components/features/logs/components/filters.tsx
|
|
338
835
|
function LogsFilterGroup({ facet, defaultOpen }) {
|
|
339
836
|
const { filters, toggleFilterValue } = useLogs();
|
|
@@ -343,7 +840,10 @@ function LogsFilterGroup({ facet, defaultOpen }) {
|
|
|
343
840
|
className: "border-b border-border",
|
|
344
841
|
children: [/* @__PURE__ */ jsxs(CollapsibleTrigger, {
|
|
345
842
|
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(
|
|
843
|
+
children: [facet.label, /* @__PURE__ */ jsx(Icon, {
|
|
844
|
+
icon: ChevronDown,
|
|
845
|
+
className: "text-muted-foreground transition-transform [[data-state=open]_&]:rotate-180"
|
|
846
|
+
})]
|
|
347
847
|
}), /* @__PURE__ */ jsx(CollapsibleContent, { children: /* @__PURE__ */ jsx("ul", {
|
|
348
848
|
className: "flex flex-col gap-1 px-3 pb-3",
|
|
349
849
|
children: facet.options.map((option) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("label", {
|
|
@@ -367,6 +867,9 @@ function LogsFilterGroup({ facet, defaultOpen }) {
|
|
|
367
867
|
}) })]
|
|
368
868
|
});
|
|
369
869
|
}
|
|
870
|
+
function isLogTimeRange(value) {
|
|
871
|
+
return typeof value === "object" && value !== null && typeof value.from === "string" && typeof value.to === "string";
|
|
872
|
+
}
|
|
370
873
|
function LogsTimeRangeFilter({ className }) {
|
|
371
874
|
const { timeRange, setTimeRange } = useLogs();
|
|
372
875
|
return /* @__PURE__ */ jsxs("div", {
|
|
@@ -377,23 +880,83 @@ function LogsTimeRangeFilter({ className }) {
|
|
|
377
880
|
}), /* @__PURE__ */ jsx(DateTimeRangePicker, {
|
|
378
881
|
value: timeRange,
|
|
379
882
|
onChange: (next) => {
|
|
380
|
-
if (next)
|
|
883
|
+
if (!next) return;
|
|
884
|
+
const preset = next.preset;
|
|
885
|
+
setTimeRange(preset ? {
|
|
886
|
+
from: next.from,
|
|
887
|
+
to: next.to,
|
|
888
|
+
preset
|
|
889
|
+
} : {
|
|
381
890
|
from: next.from,
|
|
382
891
|
to: next.to
|
|
383
892
|
});
|
|
384
893
|
},
|
|
894
|
+
presets: LOG_TIME_PRESETS,
|
|
895
|
+
triggerLabel: (value) => isLogTimeRange(value) ? logTimeRangeLabel(value) : "Select range",
|
|
896
|
+
icon: /* @__PURE__ */ jsx(Icon, {
|
|
897
|
+
icon: Clock,
|
|
898
|
+
className: "text-muted-foreground shrink-0"
|
|
899
|
+
}),
|
|
385
900
|
placeholder: "Select range",
|
|
901
|
+
clearable: false,
|
|
386
902
|
disableFuture: true,
|
|
387
903
|
hideTimezone: true,
|
|
388
904
|
numberOfMonths: 1,
|
|
389
905
|
className: "w-full",
|
|
390
906
|
triggerClassName: "w-full justify-start text-xs",
|
|
907
|
+
popoverClassName: "w-auto",
|
|
391
908
|
sheetTitle: "Select time range"
|
|
392
909
|
})]
|
|
393
910
|
});
|
|
394
911
|
}
|
|
912
|
+
const FILTER_LABEL_CHS = [
|
|
913
|
+
6,
|
|
914
|
+
11,
|
|
915
|
+
4
|
|
916
|
+
];
|
|
917
|
+
const FILTER_VALUE_CHS = [
|
|
918
|
+
3,
|
|
919
|
+
8,
|
|
920
|
+
14,
|
|
921
|
+
6,
|
|
922
|
+
20,
|
|
923
|
+
11,
|
|
924
|
+
4,
|
|
925
|
+
16
|
|
926
|
+
];
|
|
927
|
+
function LogsFiltersSkeleton() {
|
|
928
|
+
return /* @__PURE__ */ jsx("div", {
|
|
929
|
+
"data-slot": "logs-filters-skeleton",
|
|
930
|
+
className: "flex flex-col",
|
|
931
|
+
"aria-hidden": true,
|
|
932
|
+
children: Array.from({ length: 3 }, (_, group) => /* @__PURE__ */ jsxs("div", {
|
|
933
|
+
className: "border-b border-border",
|
|
934
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
935
|
+
className: "flex w-full items-center justify-between px-3 py-2",
|
|
936
|
+
children: [/* @__PURE__ */ jsx(Skeleton, {
|
|
937
|
+
className: "h-[21px] rounded-sm font-medium",
|
|
938
|
+
style: { width: `${FILTER_LABEL_CHS[group] ?? 8}ch` }
|
|
939
|
+
}), /* @__PURE__ */ jsx(Skeleton, { className: "size-4 shrink-0 rounded-sm" })]
|
|
940
|
+
}), /* @__PURE__ */ jsx("ul", {
|
|
941
|
+
className: "flex flex-col gap-1 px-3 pb-3",
|
|
942
|
+
children: Array.from({ length: 4 }, (_, row) => /* @__PURE__ */ jsxs("li", {
|
|
943
|
+
className: "flex items-center gap-2 px-1 py-1",
|
|
944
|
+
children: [
|
|
945
|
+
/* @__PURE__ */ jsx(Skeleton, { className: "size-4 shrink-0 rounded-[4px]" }),
|
|
946
|
+
/* @__PURE__ */ jsx(Skeleton, {
|
|
947
|
+
className: "h-4 max-w-full rounded-sm font-mono text-xs leading-4",
|
|
948
|
+
style: { width: `${FILTER_VALUE_CHS[(group * 4 + row) % FILTER_VALUE_CHS.length]}ch` }
|
|
949
|
+
}),
|
|
950
|
+
/* @__PURE__ */ jsx(Skeleton, { className: "ml-auto h-4 w-[2ch] rounded-sm" })
|
|
951
|
+
]
|
|
952
|
+
}, row))
|
|
953
|
+
})]
|
|
954
|
+
}, group))
|
|
955
|
+
});
|
|
956
|
+
}
|
|
395
957
|
function LogsFilters({ className }) {
|
|
396
|
-
const { facets, hasActiveFilters, resetFilters } = useLogs();
|
|
958
|
+
const { facets, hasActiveFilters, resetFilters, isLoading } = useLogs();
|
|
959
|
+
const showFacetSkeleton = isLoading && facets.length === 0;
|
|
397
960
|
return /* @__PURE__ */ jsxs("aside", {
|
|
398
961
|
"data-slot": "logs-filters",
|
|
399
962
|
className: cn("bg-background flex h-full min-h-0 w-64 shrink-0 flex-col border-r", className),
|
|
@@ -411,7 +974,7 @@ function LogsFilters({ className }) {
|
|
|
411
974
|
})]
|
|
412
975
|
}), /* @__PURE__ */ jsxs("div", {
|
|
413
976
|
className: "min-h-0 flex-1 overflow-y-auto",
|
|
414
|
-
children: [/* @__PURE__ */ jsx(LogsTimeRangeFilter, {}), facets.map((facet, index) => /* @__PURE__ */ jsx(LogsFilterGroup, {
|
|
977
|
+
children: [/* @__PURE__ */ jsx(LogsTimeRangeFilter, {}), showFacetSkeleton ? /* @__PURE__ */ jsx(LogsFiltersSkeleton, {}) : facets.map((facet, index) => /* @__PURE__ */ jsx(LogsFilterGroup, {
|
|
415
978
|
facet,
|
|
416
979
|
defaultOpen: index === 0
|
|
417
980
|
}, facet.name))]
|
|
@@ -419,61 +982,78 @@ function LogsFilters({ className }) {
|
|
|
419
982
|
});
|
|
420
983
|
}
|
|
421
984
|
//#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
985
|
//#region src/components/features/logs/components/table.tsx
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
case "message": return /* @__PURE__ */ jsx(TableCell, {
|
|
469
|
-
className: "truncate font-mono text-xs",
|
|
470
|
-
children: message
|
|
471
|
-
});
|
|
472
|
-
}
|
|
986
|
+
const SKELETON_ROWS = 12;
|
|
987
|
+
/** Centred message row shared by the empty and error states. */
|
|
988
|
+
function LogsTableState({ icon, title, description, action, colSpan, slot, role, tone = "muted" }) {
|
|
989
|
+
return /* @__PURE__ */ jsx(TableRow, {
|
|
990
|
+
className: "hover:bg-transparent",
|
|
991
|
+
children: /* @__PURE__ */ jsx(TableCell, {
|
|
992
|
+
colSpan,
|
|
993
|
+
className: "pt-20 pb-12 text-center",
|
|
994
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
995
|
+
role,
|
|
996
|
+
"data-slot": slot,
|
|
997
|
+
className: "mx-auto flex max-w-md flex-col items-center gap-2 px-4",
|
|
998
|
+
children: [
|
|
999
|
+
/* @__PURE__ */ jsx("span", {
|
|
1000
|
+
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"),
|
|
1001
|
+
children: icon
|
|
1002
|
+
}),
|
|
1003
|
+
/* @__PURE__ */ jsx("p", {
|
|
1004
|
+
className: "text-foreground text-sm font-medium",
|
|
1005
|
+
children: title
|
|
1006
|
+
}),
|
|
1007
|
+
description && /* @__PURE__ */ jsx("p", {
|
|
1008
|
+
className: "text-muted-foreground text-xs",
|
|
1009
|
+
children: description
|
|
1010
|
+
}),
|
|
1011
|
+
action && /* @__PURE__ */ jsx("div", {
|
|
1012
|
+
className: "mt-1.5",
|
|
1013
|
+
children: action
|
|
1014
|
+
})
|
|
1015
|
+
]
|
|
1016
|
+
})
|
|
1017
|
+
})
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
function LogCell({ column, children }) {
|
|
1021
|
+
return /* @__PURE__ */ jsx(TableCell, {
|
|
1022
|
+
className: cn(columnCellClass(column), column.className),
|
|
1023
|
+
style: columnWidthStyle(column),
|
|
1024
|
+
children
|
|
1025
|
+
});
|
|
473
1026
|
}
|
|
474
1027
|
function LogsTable({ className }) {
|
|
475
|
-
const { entries, columns, selectedId, setSelectedId, isLoading, error, selectPrevious, selectNext } = useLogs();
|
|
1028
|
+
const { entries, columns, selectedId, setSelectedId, isLoading, error, selectPrevious, selectNext, onRefresh, hasActiveFilters, resetFilters, search, setSearch } = useLogs();
|
|
476
1029
|
const scrollerRef = useRef(null);
|
|
1030
|
+
const rows = useMemo(() => entries.map((entry) => {
|
|
1031
|
+
const { parsed, path, message } = logLineDisplay(entry.line, entry.labels);
|
|
1032
|
+
return {
|
|
1033
|
+
entry,
|
|
1034
|
+
ctx: {
|
|
1035
|
+
entry,
|
|
1036
|
+
parsed,
|
|
1037
|
+
path,
|
|
1038
|
+
message
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
}), [entries]);
|
|
1042
|
+
const hasRows = entries.length > 0;
|
|
1043
|
+
const showError = Boolean(error) && !hasRows;
|
|
1044
|
+
const showErrorBanner = Boolean(error) && hasRows;
|
|
1045
|
+
const showSkeleton = isLoading && !hasRows && !error;
|
|
1046
|
+
const showEmpty = !isLoading && !hasRows && !error;
|
|
1047
|
+
const retry = onRefresh && /* @__PURE__ */ jsxs(Button, {
|
|
1048
|
+
type: "secondary",
|
|
1049
|
+
theme: "outline",
|
|
1050
|
+
size: "small",
|
|
1051
|
+
onClick: onRefresh,
|
|
1052
|
+
children: [/* @__PURE__ */ jsx(Icon, {
|
|
1053
|
+
icon: RefreshCw,
|
|
1054
|
+
size: 14
|
|
1055
|
+
}), "Retry"]
|
|
1056
|
+
});
|
|
477
1057
|
function onKeyDown(event) {
|
|
478
1058
|
if (event.key === "ArrowDown" || event.key === "j") {
|
|
479
1059
|
event.preventDefault();
|
|
@@ -492,52 +1072,233 @@ function LogsTable({ className }) {
|
|
|
492
1072
|
tabIndex: 0,
|
|
493
1073
|
"aria-busy": isLoading || void 0,
|
|
494
1074
|
onKeyDown,
|
|
495
|
-
children: [
|
|
1075
|
+
children: [showErrorBanner && /* @__PURE__ */ jsxs("div", {
|
|
496
1076
|
role: "alert",
|
|
497
|
-
|
|
498
|
-
|
|
1077
|
+
"data-slot": "logs-error-banner",
|
|
1078
|
+
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",
|
|
1079
|
+
children: [
|
|
1080
|
+
/* @__PURE__ */ jsx(Icon, {
|
|
1081
|
+
icon: TriangleAlert,
|
|
1082
|
+
size: 14,
|
|
1083
|
+
className: "text-destructive shrink-0"
|
|
1084
|
+
}),
|
|
1085
|
+
/* @__PURE__ */ jsxs("span", {
|
|
1086
|
+
className: "min-w-0 truncate",
|
|
1087
|
+
children: [
|
|
1088
|
+
/* @__PURE__ */ jsx("span", {
|
|
1089
|
+
className: "font-medium",
|
|
1090
|
+
children: "Refresh failed."
|
|
1091
|
+
}),
|
|
1092
|
+
" ",
|
|
1093
|
+
/* @__PURE__ */ jsx("span", {
|
|
1094
|
+
className: "text-muted-foreground",
|
|
1095
|
+
children: "Showing the last successful result."
|
|
1096
|
+
}),
|
|
1097
|
+
" ",
|
|
1098
|
+
/* @__PURE__ */ jsx("code", {
|
|
1099
|
+
className: "font-mono",
|
|
1100
|
+
children: error
|
|
1101
|
+
})
|
|
1102
|
+
]
|
|
1103
|
+
}),
|
|
1104
|
+
/* @__PURE__ */ jsx("span", {
|
|
1105
|
+
className: "ml-auto shrink-0",
|
|
1106
|
+
children: retry
|
|
1107
|
+
})
|
|
1108
|
+
]
|
|
499
1109
|
}), /* @__PURE__ */ jsxs("table", {
|
|
500
|
-
className: "w-full caption-bottom table-
|
|
1110
|
+
className: "w-full caption-bottom table-auto text-sm [&_th]:px-3 [&_th]:py-2 [&_td]:px-3 [&_td]:py-1.5",
|
|
501
1111
|
children: [/* @__PURE__ */ jsx(TableHeader, {
|
|
502
1112
|
className: "bg-background sticky top-0 z-10",
|
|
503
1113
|
children: /* @__PURE__ */ jsx(TableRow, { children: columns.map((column) => /* @__PURE__ */ jsx(TableHead, {
|
|
504
|
-
className:
|
|
505
|
-
|
|
506
|
-
|
|
1114
|
+
className: cn(columnHeadClass(column), column.headerClassName),
|
|
1115
|
+
style: columnWidthStyle(column),
|
|
1116
|
+
children: columnHeader(column)
|
|
1117
|
+
}, column.id)) })
|
|
507
1118
|
}), /* @__PURE__ */ jsxs(TableBody, { children: [
|
|
508
|
-
|
|
1119
|
+
showSkeleton && Array.from({ length: SKELETON_ROWS }, (_, index) => /* @__PURE__ */ jsx(TableRow, {
|
|
1120
|
+
"data-slot": "logs-skeleton-row",
|
|
1121
|
+
children: columns.map((column) => /* @__PURE__ */ jsx(LogCell, {
|
|
1122
|
+
column,
|
|
1123
|
+
children: columnSkeleton(column, index)
|
|
1124
|
+
}, column.id))
|
|
1125
|
+
}, `skeleton-${index}`)),
|
|
1126
|
+
showError && /* @__PURE__ */ jsx(LogsTableState, {
|
|
1127
|
+
role: "alert",
|
|
1128
|
+
slot: "logs-error",
|
|
1129
|
+
tone: "destructive",
|
|
509
1130
|
colSpan: columns.length,
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
1131
|
+
icon: /* @__PURE__ */ jsx(Icon, { icon: TriangleAlert }),
|
|
1132
|
+
title: "Couldn't load logs",
|
|
1133
|
+
description: /* @__PURE__ */ jsx("code", {
|
|
1134
|
+
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",
|
|
1135
|
+
children: error
|
|
1136
|
+
}),
|
|
1137
|
+
action: retry
|
|
1138
|
+
}),
|
|
1139
|
+
showEmpty && /* @__PURE__ */ jsx(LogsTableState, {
|
|
1140
|
+
slot: "logs-empty",
|
|
513
1141
|
colSpan: columns.length,
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
1142
|
+
icon: /* @__PURE__ */ jsx(Icon, { icon: Inbox }),
|
|
1143
|
+
title: "No logs in this time range",
|
|
1144
|
+
description: hasActiveFilters || search ? "Try widening the range or clearing the filters." : "Try widening the range or check back in a moment.",
|
|
1145
|
+
action: (hasActiveFilters || search) && /* @__PURE__ */ jsx(Button, {
|
|
1146
|
+
type: "secondary",
|
|
1147
|
+
theme: "outline",
|
|
1148
|
+
size: "small",
|
|
1149
|
+
onClick: () => {
|
|
1150
|
+
resetFilters();
|
|
1151
|
+
setSearch("");
|
|
1152
|
+
},
|
|
1153
|
+
children: "Clear filters"
|
|
1154
|
+
})
|
|
1155
|
+
}),
|
|
1156
|
+
rows.map(({ entry, ctx }) => {
|
|
519
1157
|
const selected = entry.id === selectedId;
|
|
520
1158
|
return /* @__PURE__ */ jsx(TableRow, {
|
|
521
1159
|
"data-state": selected ? "selected" : void 0,
|
|
522
1160
|
"aria-selected": selected,
|
|
523
|
-
className: cn("cursor-pointer hover:bg-foreground/
|
|
1161
|
+
className: cn("cursor-pointer hover:bg-foreground/5", "data-[state=selected]:bg-foreground/10", "data-[state=selected]:hover:bg-foreground/10"),
|
|
524
1162
|
onClick: () => {
|
|
525
1163
|
setSelectedId(selected ? null : entry.id);
|
|
526
1164
|
scrollerRef.current?.focus();
|
|
527
1165
|
},
|
|
528
1166
|
children: columns.map((column) => /* @__PURE__ */ jsx(LogCell, {
|
|
529
1167
|
column,
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
path,
|
|
533
|
-
message
|
|
534
|
-
}, column))
|
|
1168
|
+
children: column.cell(ctx)
|
|
1169
|
+
}, column.id))
|
|
535
1170
|
}, entry.id);
|
|
536
1171
|
})
|
|
537
1172
|
] })]
|
|
538
1173
|
})]
|
|
539
1174
|
});
|
|
540
1175
|
}
|
|
1176
|
+
const MINUTE_MS = 6e4;
|
|
1177
|
+
const DAY_MS = 24 * (60 * MINUTE_MS);
|
|
1178
|
+
/**
|
|
1179
|
+
* Bucket `entries` into `bucketCount` equal-width bars across `range`.
|
|
1180
|
+
* Entries outside the range are ignored. Returns `[]` when the range is
|
|
1181
|
+
* invalid or empty so the timeline can fall back to an axis-only state.
|
|
1182
|
+
*/
|
|
1183
|
+
function histogramFromEntries(entries, range, bucketCount = 60) {
|
|
1184
|
+
const from = new Date(range.from).getTime();
|
|
1185
|
+
const to = new Date(range.to).getTime();
|
|
1186
|
+
if (Number.isNaN(from) || Number.isNaN(to) || to <= from || bucketCount < 1) return [];
|
|
1187
|
+
const width = (to - from) / bucketCount;
|
|
1188
|
+
const counts = Array.from({ length: bucketCount }).fill(0);
|
|
1189
|
+
for (const entry of entries) {
|
|
1190
|
+
const at = entry.timestamp.getTime();
|
|
1191
|
+
if (at < from || at > to) continue;
|
|
1192
|
+
const index = Math.min(Math.floor((at - from) / width), bucketCount - 1);
|
|
1193
|
+
counts[index] += 1;
|
|
1194
|
+
}
|
|
1195
|
+
return counts.map((count, index) => ({
|
|
1196
|
+
start: new Date(from + index * width).toISOString(),
|
|
1197
|
+
end: new Date(index === bucketCount - 1 ? to : from + (index + 1) * width).toISOString(),
|
|
1198
|
+
count
|
|
1199
|
+
}));
|
|
1200
|
+
}
|
|
1201
|
+
/** Index of the bucket containing `date`, or `-1`. */
|
|
1202
|
+
function histogramBucketIndex(histogram, date) {
|
|
1203
|
+
const at = date.getTime();
|
|
1204
|
+
const last = histogram.length - 1;
|
|
1205
|
+
return histogram.findIndex((bucket, index) => {
|
|
1206
|
+
const start = new Date(bucket.start).getTime();
|
|
1207
|
+
const end = new Date(bucket.end).getTime();
|
|
1208
|
+
return at >= start && (at < end || index === last && at === end);
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
/** Total span covered by a histogram, in ms. `0` when empty. */
|
|
1212
|
+
function histogramSpanMs(histogram) {
|
|
1213
|
+
const first = histogram[0];
|
|
1214
|
+
const last = histogram[histogram.length - 1];
|
|
1215
|
+
if (!first || !last) return 0;
|
|
1216
|
+
return new Date(last.end).getTime() - new Date(first.start).getTime();
|
|
1217
|
+
}
|
|
1218
|
+
/** Axis tick label whose precision follows the span being displayed. */
|
|
1219
|
+
function formatTimelineTick(date, spanMs) {
|
|
1220
|
+
if (spanMs <= 2 * MINUTE_MS) return format(date, "HH:mm:ss");
|
|
1221
|
+
if (spanMs < DAY_MS) return format(date, "HH:mm");
|
|
1222
|
+
return format(date, "MMM d HH:mm");
|
|
1223
|
+
}
|
|
1224
|
+
/** Tooltip text for a bucket: count plus its window. */
|
|
1225
|
+
function formatBucketSummary(bucket, spanMs) {
|
|
1226
|
+
const start = new Date(bucket.start);
|
|
1227
|
+
const end = new Date(bucket.end);
|
|
1228
|
+
const tickSpan = end.getTime() - start.getTime() < MINUTE_MS ? MINUTE_MS : spanMs;
|
|
1229
|
+
const noun = bucket.count === 1 ? "log" : "logs";
|
|
1230
|
+
return `${bucket.count} ${noun} · ${formatTimelineTick(start, tickSpan)} – ${formatTimelineTick(end, tickSpan)}`;
|
|
1231
|
+
}
|
|
1232
|
+
//#endregion
|
|
1233
|
+
//#region src/components/features/logs/components/timeline.tsx
|
|
1234
|
+
const SKELETON_HEIGHTS = [
|
|
1235
|
+
18,
|
|
1236
|
+
34,
|
|
1237
|
+
22,
|
|
1238
|
+
48,
|
|
1239
|
+
30,
|
|
1240
|
+
60,
|
|
1241
|
+
26,
|
|
1242
|
+
40,
|
|
1243
|
+
20,
|
|
1244
|
+
52,
|
|
1245
|
+
36,
|
|
1246
|
+
28
|
|
1247
|
+
];
|
|
1248
|
+
function TimelineSkeleton() {
|
|
1249
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1250
|
+
"data-slot": "logs-timeline-skeleton",
|
|
1251
|
+
className: "flex h-10 items-end gap-px",
|
|
1252
|
+
"aria-hidden": true,
|
|
1253
|
+
children: Array.from({ length: 60 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, {
|
|
1254
|
+
className: "min-w-0 flex-1 rounded-[1px]",
|
|
1255
|
+
style: { height: `${SKELETON_HEIGHTS[index % SKELETON_HEIGHTS.length]}%` }
|
|
1256
|
+
}, index))
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
function LogsTimeline({ className }) {
|
|
1260
|
+
const { histogram, isLoading, selectedEntry, timeRange } = useLogs();
|
|
1261
|
+
const spanMs = histogramSpanMs(histogram);
|
|
1262
|
+
const max = useMemo(() => histogram.reduce((peak, bucket) => Math.max(peak, bucket.count), 0), [histogram]);
|
|
1263
|
+
const total = useMemo(() => histogram.reduce((sum, bucket) => sum + bucket.count, 0), [histogram]);
|
|
1264
|
+
const selectedIndex = selectedEntry ? histogramBucketIndex(histogram, selectedEntry.timestamp) : -1;
|
|
1265
|
+
const titles = useMemo(() => histogram.map((bucket) => formatBucketSummary(bucket, spanMs)), [histogram, spanMs]);
|
|
1266
|
+
const from = histogram[0] ? new Date(histogram[0].start) : new Date(timeRange.from);
|
|
1267
|
+
const to = histogram.length > 0 ? new Date(histogram[histogram.length - 1].end) : new Date(timeRange.to);
|
|
1268
|
+
const axisSpan = spanMs > 0 ? spanMs : to.getTime() - from.getTime();
|
|
1269
|
+
const mid = /* @__PURE__ */ new Date((from.getTime() + to.getTime()) / 2);
|
|
1270
|
+
const showSkeleton = isLoading && histogram.length === 0;
|
|
1271
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1272
|
+
"data-slot": "logs-timeline",
|
|
1273
|
+
className: cn("border-b px-3 pt-2 pb-1", className),
|
|
1274
|
+
children: [showSkeleton ? /* @__PURE__ */ jsx(TimelineSkeleton, {}) : /* @__PURE__ */ jsx("div", {
|
|
1275
|
+
role: "img",
|
|
1276
|
+
"aria-label": `${total} logs between ${formatTimelineTick(from, axisSpan)} and ${formatTimelineTick(to, axisSpan)}`,
|
|
1277
|
+
className: "flex h-10 items-end gap-px",
|
|
1278
|
+
children: histogram.map((bucket, index) => {
|
|
1279
|
+
const height = max > 0 ? bucket.count / max * 100 : 0;
|
|
1280
|
+
const selected = index === selectedIndex;
|
|
1281
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1282
|
+
title: titles[index],
|
|
1283
|
+
"data-slot": "logs-timeline-bucket",
|
|
1284
|
+
"data-selected": selected || void 0,
|
|
1285
|
+
className: "relative flex h-full min-w-0 flex-1 items-end",
|
|
1286
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
1287
|
+
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"),
|
|
1288
|
+
style: { height: bucket.count > 0 ? `max(${height}%, 2px)` : "1px" }
|
|
1289
|
+
})
|
|
1290
|
+
}, bucket.start);
|
|
1291
|
+
})
|
|
1292
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
1293
|
+
className: "text-muted-foreground mt-1 flex justify-between font-mono text-[10px] tabular-nums",
|
|
1294
|
+
children: [
|
|
1295
|
+
/* @__PURE__ */ jsx("span", { children: formatTimelineTick(from, axisSpan) }),
|
|
1296
|
+
/* @__PURE__ */ jsx("span", { children: formatTimelineTick(mid, axisSpan) }),
|
|
1297
|
+
/* @__PURE__ */ jsx("span", { children: formatTimelineTick(to, axisSpan) })
|
|
1298
|
+
]
|
|
1299
|
+
})]
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
541
1302
|
//#endregion
|
|
542
1303
|
//#region src/components/features/logs/components/toolbar.tsx
|
|
543
1304
|
function LogsSearch({ placeholder = "Search logs...", className }) {
|
|
@@ -563,7 +1324,10 @@ function LogsSearch({ placeholder = "Search logs...", className }) {
|
|
|
563
1324
|
}, [value, setSearch]);
|
|
564
1325
|
return /* @__PURE__ */ jsxs("div", {
|
|
565
1326
|
className: cn("relative min-w-0 flex-1", className),
|
|
566
|
-
children: [/* @__PURE__ */ jsx(
|
|
1327
|
+
children: [/* @__PURE__ */ jsx(Icon, {
|
|
1328
|
+
icon: Search,
|
|
1329
|
+
className: "text-muted-foreground pointer-events-none absolute top-1/2 left-3 -translate-y-1/2"
|
|
1330
|
+
}), /* @__PURE__ */ jsx(Input, {
|
|
567
1331
|
value,
|
|
568
1332
|
onChange: (event) => setValue(event.target.value),
|
|
569
1333
|
placeholder,
|
|
@@ -601,7 +1365,7 @@ function LogsToolbar({ children, className }) {
|
|
|
601
1365
|
size: "icon",
|
|
602
1366
|
"aria-label": "Refresh logs",
|
|
603
1367
|
onClick: onRefresh,
|
|
604
|
-
children: /* @__PURE__ */ jsx(
|
|
1368
|
+
children: /* @__PURE__ */ jsx(Icon, { icon: RefreshCw })
|
|
605
1369
|
}),
|
|
606
1370
|
onExport && /* @__PURE__ */ jsx(Button, {
|
|
607
1371
|
type: "secondary",
|
|
@@ -609,7 +1373,7 @@ function LogsToolbar({ children, className }) {
|
|
|
609
1373
|
size: "icon",
|
|
610
1374
|
"aria-label": "Export logs",
|
|
611
1375
|
onClick: () => onExport(entries),
|
|
612
|
-
children: /* @__PURE__ */ jsx(
|
|
1376
|
+
children: /* @__PURE__ */ jsx(Icon, { icon: Download })
|
|
613
1377
|
})
|
|
614
1378
|
]
|
|
615
1379
|
});
|
|
@@ -644,16 +1408,20 @@ function LogsExplorer({ className }) {
|
|
|
644
1408
|
}),
|
|
645
1409
|
/* @__PURE__ */ jsxs("div", {
|
|
646
1410
|
className: "flex min-w-0 flex-1 flex-col",
|
|
647
|
-
children: [
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
1411
|
+
children: [
|
|
1412
|
+
/* @__PURE__ */ jsx(LogsToolbar, { children: !isDesktop && /* @__PURE__ */ jsxs(Button, {
|
|
1413
|
+
type: "secondary",
|
|
1414
|
+
theme: "outline",
|
|
1415
|
+
size: "small",
|
|
1416
|
+
onClick: () => setFiltersOpen(true),
|
|
1417
|
+
children: [/* @__PURE__ */ jsx(Icon, { icon: SlidersHorizontal }), "Filters"]
|
|
1418
|
+
}) }),
|
|
1419
|
+
/* @__PURE__ */ jsx(LogsTimeline, {}),
|
|
1420
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1421
|
+
className: "relative flex min-h-0 min-w-0 flex-1",
|
|
1422
|
+
children: [/* @__PURE__ */ jsx(LogsTable, {}), isDesktop && /* @__PURE__ */ jsx(LogsDetail, {})]
|
|
1423
|
+
})
|
|
1424
|
+
]
|
|
657
1425
|
}),
|
|
658
1426
|
!isDesktop && /* @__PURE__ */ jsx(LogsDetail, {})
|
|
659
1427
|
]
|
|
@@ -687,7 +1455,8 @@ const CANONICAL_FACET_NAMES = [
|
|
|
687
1455
|
const FACET_LABELS = {
|
|
688
1456
|
severity: "Severity",
|
|
689
1457
|
service_name: "Service",
|
|
690
|
-
resource_name: "Resource"
|
|
1458
|
+
resource_name: "Resource",
|
|
1459
|
+
host: "Host"
|
|
691
1460
|
};
|
|
692
1461
|
const SEVERITY_ORDER = [
|
|
693
1462
|
"ERROR",
|
|
@@ -702,7 +1471,10 @@ const DEFAULT_LOGQL_MATCHER = "{service_name=~\".+\"}";
|
|
|
702
1471
|
//#endregion
|
|
703
1472
|
//#region src/components/features/logs/utils/facets.ts
|
|
704
1473
|
function facetLabel(name) {
|
|
705
|
-
|
|
1474
|
+
const mapped = FACET_LABELS[name];
|
|
1475
|
+
if (mapped) return mapped;
|
|
1476
|
+
const raw = name.replaceAll("_", " ");
|
|
1477
|
+
return raw ? raw.charAt(0).toUpperCase() + raw.slice(1) : raw;
|
|
706
1478
|
}
|
|
707
1479
|
function compareFacetValues(name, a, b) {
|
|
708
1480
|
if (name === "severity") {
|
|
@@ -754,25 +1526,38 @@ function filterEntries(entries, filters, search = "") {
|
|
|
754
1526
|
const label = entry.labels[name];
|
|
755
1527
|
if (!label || !values.includes(label)) return false;
|
|
756
1528
|
}
|
|
757
|
-
if (query && !entry
|
|
1529
|
+
if (query && !entryMatchesSearch(entry, query)) return false;
|
|
758
1530
|
return true;
|
|
759
1531
|
});
|
|
760
1532
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
};
|
|
1533
|
+
/**
|
|
1534
|
+
* Text a search term is matched against: the line, plus the fields the table
|
|
1535
|
+
* actually shows for a label-only access log (method, path, status, host).
|
|
1536
|
+
* Other labels (request ids, user agents, pod names) are deliberately not
|
|
1537
|
+
* searched so a hit is always visible on screen.
|
|
1538
|
+
*/
|
|
1539
|
+
function searchableLogText(entry) {
|
|
1540
|
+
const { parsed, path } = logLineDisplay(entry.line, entry.labels);
|
|
1541
|
+
const parts = [entry.line, path ?? ""];
|
|
1542
|
+
if (parsed.kind === "http") parts.push(formatHttpLogLine(parsed));
|
|
1543
|
+
const host = logRequestHost(entry.labels);
|
|
1544
|
+
if (host) parts.push(host);
|
|
1545
|
+
return parts.join("\n").toLowerCase();
|
|
769
1546
|
}
|
|
770
|
-
function
|
|
771
|
-
return
|
|
1547
|
+
function entryMatchesSearch(entry, query) {
|
|
1548
|
+
return searchableLogText(entry).includes(query);
|
|
772
1549
|
}
|
|
773
1550
|
//#endregion
|
|
774
1551
|
//#region src/components/features/logs/components/root.tsx
|
|
775
|
-
|
|
1552
|
+
/** Return the previous array while its items are shallow-equal to the new one. */
|
|
1553
|
+
function useShallowArray(next) {
|
|
1554
|
+
const ref = useRef(next);
|
|
1555
|
+
const prev = ref.current;
|
|
1556
|
+
const same = prev === next || prev.length === next.length && prev.every((item, index) => item === next[index]);
|
|
1557
|
+
if (!same) ref.current = next;
|
|
1558
|
+
return same ? prev : next;
|
|
1559
|
+
}
|
|
1560
|
+
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
1561
|
const [currentTimeRange, setTimeRange] = useControllableState({
|
|
777
1562
|
value: timeRange,
|
|
778
1563
|
defaultValue: defaultTimeRange ?? lastThirtyMinutes(),
|
|
@@ -799,6 +1584,13 @@ function LogsRoot({ entries, facets, timeRange, defaultTimeRange, filters, defau
|
|
|
799
1584
|
onChange: onSelectedIdChange
|
|
800
1585
|
});
|
|
801
1586
|
const resolvedFacets = useMemo(() => facets ?? facetsFromEntries(entries), [facets, entries]);
|
|
1587
|
+
const resolvedHistogram = useMemo(() => histogram ?? histogramFromEntries(entries, currentTimeRange), [
|
|
1588
|
+
histogram,
|
|
1589
|
+
entries,
|
|
1590
|
+
currentTimeRange
|
|
1591
|
+
]);
|
|
1592
|
+
const stableColumns = useShallowArray(columns);
|
|
1593
|
+
const resolvedColumns = useMemo(() => resolveLogColumns(stableColumns), [stableColumns]);
|
|
802
1594
|
const selectedIndex = useMemo(() => entries.findIndex((entry) => entry.id === currentSelectedId), [entries, currentSelectedId]);
|
|
803
1595
|
const selectedEntry = selectedIndex >= 0 ? entries[selectedIndex] ?? null : null;
|
|
804
1596
|
const toggleFilterValue = useCallback((name, value) => {
|
|
@@ -833,6 +1625,7 @@ function LogsRoot({ entries, facets, timeRange, defaultTimeRange, filters, defau
|
|
|
833
1625
|
const value = useMemo(() => ({
|
|
834
1626
|
entries,
|
|
835
1627
|
facets: resolvedFacets,
|
|
1628
|
+
histogram: resolvedHistogram,
|
|
836
1629
|
timeRange: currentTimeRange,
|
|
837
1630
|
setTimeRange,
|
|
838
1631
|
filters: currentFilters,
|
|
@@ -851,13 +1644,14 @@ function LogsRoot({ entries, facets, timeRange, defaultTimeRange, filters, defau
|
|
|
851
1644
|
selectNext,
|
|
852
1645
|
isLoading,
|
|
853
1646
|
error,
|
|
854
|
-
columns,
|
|
1647
|
+
columns: resolvedColumns,
|
|
855
1648
|
hasActiveFilters: filtersAreActive(currentFilters),
|
|
856
1649
|
onRefresh,
|
|
857
1650
|
onExport
|
|
858
1651
|
}), [
|
|
859
1652
|
entries,
|
|
860
1653
|
resolvedFacets,
|
|
1654
|
+
resolvedHistogram,
|
|
861
1655
|
currentTimeRange,
|
|
862
1656
|
setTimeRange,
|
|
863
1657
|
currentFilters,
|
|
@@ -876,7 +1670,7 @@ function LogsRoot({ entries, facets, timeRange, defaultTimeRange, filters, defau
|
|
|
876
1670
|
selectNext,
|
|
877
1671
|
isLoading,
|
|
878
1672
|
error,
|
|
879
|
-
|
|
1673
|
+
resolvedColumns,
|
|
880
1674
|
onRefresh,
|
|
881
1675
|
onExport
|
|
882
1676
|
]);
|
|
@@ -900,6 +1694,7 @@ const Logs = {
|
|
|
900
1694
|
Toolbar: LogsToolbar,
|
|
901
1695
|
Search: LogsSearch,
|
|
902
1696
|
LiveToggle: LogsLiveToggle,
|
|
1697
|
+
Timeline: LogsTimeline,
|
|
903
1698
|
Table: LogsTable,
|
|
904
1699
|
Detail: LogsDetail
|
|
905
1700
|
};
|
|
@@ -977,4 +1772,4 @@ function flattenLokiStreams(response) {
|
|
|
977
1772
|
return entries;
|
|
978
1773
|
}
|
|
979
1774
|
//#endregion
|
|
980
|
-
export { Logs, buildLogQL, facetsFromEntries, filterEntries, flattenLokiStreams, httpStatusBadgeType, httpStatusTextClass, lastThirtyMinutes, logLineDisplay, nsToDate, parseLogLine, severityBadgeType, useLogs };
|
|
1775
|
+
export { LOG_TIME_PRESETS, Logs, buildLogQL, facetsFromEntries, filterEntries, flattenLokiStreams, formatHttpLogLine, histogramFromEntries, httpStatusBadgeType, httpStatusTextClass, lastThirtyMinutes, logLineDisplay, logRequestHost, logTimeRangeLabel, nsToDate, parseLogLine, resolveLogColumns, resolveLogTimeRange, searchableLogText, severityBadgeType, splitPathQuery, useLogs };
|