@rebasepro/studio 0.14.1 → 0.14.2-canary.g27a129e
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/LogsExplorer-DI8SVpya.js +527 -0
- package/dist/LogsExplorer-DI8SVpya.js.map +1 -0
- package/dist/components/LogsExplorer/useLogTail.d.ts +69 -0
- package/dist/index.es.js +13 -13
- package/dist/index.es.js.map +1 -1
- package/package.json +9 -9
- package/src/components/LogsExplorer/LogsExplorer.tsx +65 -105
- package/src/components/LogsExplorer/useLogTail.ts +356 -0
- package/src/components/RebaseStudio.tsx +13 -13
- package/dist/LogsExplorer-Ctk2f3MM.js +0 -301
- package/dist/LogsExplorer-Ctk2f3MM.js.map +0 -1
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import { useApiBase, useApiConfig } from "@rebasepro/app";
|
|
2
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { ArrowDownToLineIcon, Checkbox, Label, Select, SelectItem, TextField, Typography, cls, defaultBorderMixin } from "@rebasepro/ui";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
//#region src/components/LogsExplorer/useLogTail.ts
|
|
6
|
+
/**
|
|
7
|
+
* Entries kept in the browser.
|
|
8
|
+
*
|
|
9
|
+
* The server window is 200; a tail that never forgets would grow without bound
|
|
10
|
+
* on a busy backend, since nothing else here ever drops an entry.
|
|
11
|
+
*/
|
|
12
|
+
var MAX_ENTRIES = 1e3;
|
|
13
|
+
/** Fallback cadence, used only against a server with no stream route. */
|
|
14
|
+
var POLL_MS = 3e3;
|
|
15
|
+
var RETRY_BASE_MS = 1e3;
|
|
16
|
+
var RETRY_MAX_MS = 15e3;
|
|
17
|
+
/** Ids are `log_<n>` off a monotonic counter, so numeric order is push order. */
|
|
18
|
+
var idNum = (entry) => Number(entry.id.slice(4));
|
|
19
|
+
/** The window is contiguous and ordered, so identical ends mean identical content. */
|
|
20
|
+
var sameLogs = (a, b) => a.length === b.length && a[0]?.id === b[0]?.id && a[a.length - 1]?.id === b[b.length - 1]?.id;
|
|
21
|
+
/** One identity for "nothing", so clearing an already-empty view is not a change. */
|
|
22
|
+
var EMPTY = [];
|
|
23
|
+
var capped = (entries) => entries.length > MAX_ENTRIES ? entries.slice(entries.length - MAX_ENTRIES) : entries;
|
|
24
|
+
/**
|
|
25
|
+
* Split an SSE byte stream into frames.
|
|
26
|
+
*
|
|
27
|
+
* `EventSource` would do this, and cannot be used: logs are admin-only, and
|
|
28
|
+
* `EventSource` has no way to send an `Authorization` header. So the stream is
|
|
29
|
+
* read off `fetch` and framed here.
|
|
30
|
+
*
|
|
31
|
+
* Exported for its own test — the parser is the part with edge cases (a frame
|
|
32
|
+
* split across two chunks, multi-line data, comment keepalives).
|
|
33
|
+
*/
|
|
34
|
+
async function* readSSEFrames(body) {
|
|
35
|
+
const reader = body.getReader();
|
|
36
|
+
const decoder = new TextDecoder();
|
|
37
|
+
let buffer = "";
|
|
38
|
+
while (true) {
|
|
39
|
+
const { done, value } = await reader.read();
|
|
40
|
+
if (done) break;
|
|
41
|
+
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
|
|
42
|
+
let boundary = buffer.indexOf("\n\n");
|
|
43
|
+
while (boundary !== -1) {
|
|
44
|
+
const raw = buffer.slice(0, boundary);
|
|
45
|
+
buffer = buffer.slice(boundary + 2);
|
|
46
|
+
const frame = parseFrame(raw);
|
|
47
|
+
if (frame) yield frame;
|
|
48
|
+
boundary = buffer.indexOf("\n\n");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function parseFrame(raw) {
|
|
53
|
+
let event = "message";
|
|
54
|
+
const data = [];
|
|
55
|
+
for (const line of raw.split("\n")) {
|
|
56
|
+
if (!line || line.startsWith(":")) continue;
|
|
57
|
+
const colon = line.indexOf(":");
|
|
58
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
59
|
+
const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
60
|
+
if (field === "event") event = value;
|
|
61
|
+
else if (field === "data") data.push(value);
|
|
62
|
+
}
|
|
63
|
+
if (data.length === 0) return null;
|
|
64
|
+
return {
|
|
65
|
+
event,
|
|
66
|
+
data: data.join("\n")
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
var buildParams = (filters, limit) => {
|
|
70
|
+
const params = new URLSearchParams();
|
|
71
|
+
if (filters.level && filters.level !== "all") params.set("level", filters.level);
|
|
72
|
+
if (filters.source && filters.source !== "all") params.set("source", filters.source);
|
|
73
|
+
if (filters.search) params.set("search", filters.search);
|
|
74
|
+
params.set("limit", String(limit));
|
|
75
|
+
return params.toString();
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Why the log is not being served, in the server's words where it has any.
|
|
79
|
+
*
|
|
80
|
+
* The admin gate answers 501 with a message naming the setting to change (a
|
|
81
|
+
* backend with no `jwtSecret` and no auth adapter cannot tell an admin from the
|
|
82
|
+
* internet, so it refuses rather than opens). Rendering "HTTP 501" over the top
|
|
83
|
+
* of that would throw away the only sentence that says what to do.
|
|
84
|
+
*/
|
|
85
|
+
async function failureMessage(resp) {
|
|
86
|
+
try {
|
|
87
|
+
const message = (await resp.json?.())?.error?.message;
|
|
88
|
+
if (typeof message === "string" && message) return message;
|
|
89
|
+
} catch {}
|
|
90
|
+
return resp.status === 401 || resp.status === 403 ? "Not authorised to read logs — an admin role is required." : `Could not load logs (HTTP ${resp.status}).`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Tail the server's log buffer.
|
|
94
|
+
*
|
|
95
|
+
* Server-sent events, with the 3s poll kept only as a fallback: the studio and
|
|
96
|
+
* the server are versioned and deployed separately, so a frontend that knows
|
|
97
|
+
* about `/logs/stream` will meet servers that do not. A 404 there is not an
|
|
98
|
+
* error, it is an older backend, and it degrades to what that backend supports
|
|
99
|
+
* instead of showing an empty view.
|
|
100
|
+
*
|
|
101
|
+
* The stream sends its own backlog as a `snapshot` frame before any `append`, so
|
|
102
|
+
* a reconnect restores the window without a second request and without a gap
|
|
103
|
+
* where entries logged mid-handshake would belong to neither call.
|
|
104
|
+
*/
|
|
105
|
+
function useLogTail(filters, limit = 200) {
|
|
106
|
+
const [logs, setLogs] = useState([]);
|
|
107
|
+
const [error, setError] = useState(null);
|
|
108
|
+
const [transport, setTransport] = useState("connecting");
|
|
109
|
+
const [dropped, setDropped] = useState(0);
|
|
110
|
+
const apiConfig = useApiConfig();
|
|
111
|
+
const apiBase = useApiBase();
|
|
112
|
+
const getAuthToken = apiConfig?.getAuthToken;
|
|
113
|
+
const getAuthTokenRef = useRef(getAuthToken);
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
getAuthTokenRef.current = getAuthToken;
|
|
116
|
+
});
|
|
117
|
+
const { level, source, search } = filters;
|
|
118
|
+
const [visible, setVisible] = useState(typeof document === "undefined" || document.visibilityState === "visible");
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const onChange = () => setVisible(document.visibilityState === "visible");
|
|
121
|
+
document.addEventListener("visibilitychange", onChange);
|
|
122
|
+
return () => document.removeEventListener("visibilitychange", onChange);
|
|
123
|
+
}, []);
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
setLogs(EMPTY);
|
|
126
|
+
}, [`${level}\u0000${source}\u0000${search}`]);
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (!apiBase) {
|
|
129
|
+
setError("No API URL configured — cannot load logs.");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!visible) return;
|
|
133
|
+
let cancelled = false;
|
|
134
|
+
let controller = null;
|
|
135
|
+
let timer = null;
|
|
136
|
+
let attempt = 0;
|
|
137
|
+
const query = buildParams({
|
|
138
|
+
level,
|
|
139
|
+
source,
|
|
140
|
+
search
|
|
141
|
+
}, limit);
|
|
142
|
+
const authHeaders = async () => {
|
|
143
|
+
const headers = {};
|
|
144
|
+
const token = await getAuthTokenRef.current?.() ?? null;
|
|
145
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
146
|
+
return headers;
|
|
147
|
+
};
|
|
148
|
+
const retryLater = () => {
|
|
149
|
+
if (cancelled) return;
|
|
150
|
+
const delay = Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_MS);
|
|
151
|
+
attempt++;
|
|
152
|
+
timer = setTimeout(connect, delay);
|
|
153
|
+
};
|
|
154
|
+
/** The fallback. Only ever reached against a server with no stream route. */
|
|
155
|
+
const poll = async () => {
|
|
156
|
+
if (cancelled) return;
|
|
157
|
+
try {
|
|
158
|
+
const resp = await fetch(`${apiBase}/logs?${query}`, { headers: await authHeaders() });
|
|
159
|
+
if (cancelled) return;
|
|
160
|
+
if (!resp.ok) setError(await failureMessage(resp));
|
|
161
|
+
else {
|
|
162
|
+
const entries = ((await resp.json()).entries || []).slice().reverse();
|
|
163
|
+
setLogs((prev) => sameLogs(prev, entries) ? prev : entries);
|
|
164
|
+
setError(null);
|
|
165
|
+
}
|
|
166
|
+
} catch (e) {
|
|
167
|
+
if (!cancelled) setError(e instanceof Error ? e.message : "Could not load logs.");
|
|
168
|
+
}
|
|
169
|
+
if (!cancelled) timer = setTimeout(poll, POLL_MS);
|
|
170
|
+
};
|
|
171
|
+
const startPolling = () => {
|
|
172
|
+
if (cancelled) return;
|
|
173
|
+
setTransport("polling");
|
|
174
|
+
poll();
|
|
175
|
+
};
|
|
176
|
+
const connect = async () => {
|
|
177
|
+
if (cancelled) return;
|
|
178
|
+
controller = new AbortController();
|
|
179
|
+
try {
|
|
180
|
+
const resp = await fetch(`${apiBase}/logs/stream?${query}`, {
|
|
181
|
+
headers: {
|
|
182
|
+
...await authHeaders(),
|
|
183
|
+
Accept: "text/event-stream"
|
|
184
|
+
},
|
|
185
|
+
signal: controller.signal
|
|
186
|
+
});
|
|
187
|
+
if (cancelled) return;
|
|
188
|
+
if (resp.status === 404 || !resp.body) {
|
|
189
|
+
startPolling();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!resp.ok) {
|
|
193
|
+
setError(await failureMessage(resp));
|
|
194
|
+
retryLater();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
attempt = 0;
|
|
198
|
+
setError(null);
|
|
199
|
+
setTransport("live");
|
|
200
|
+
setDropped(0);
|
|
201
|
+
for await (const frame of readSSEFrames(resp.body)) {
|
|
202
|
+
if (cancelled) return;
|
|
203
|
+
if (frame.event === "snapshot") {
|
|
204
|
+
const { entries } = JSON.parse(frame.data);
|
|
205
|
+
setLogs((prev) => sameLogs(prev, entries) ? prev : capped(entries));
|
|
206
|
+
} else if (frame.event === "append") {
|
|
207
|
+
const parsed = JSON.parse(frame.data);
|
|
208
|
+
if (parsed.entries.length > 0) setLogs((prev) => capped([...prev, ...parsed.entries]));
|
|
209
|
+
if (parsed.dropped) setDropped((n) => n + parsed.dropped);
|
|
210
|
+
} else if (frame.event === "error") setError(frame.data);
|
|
211
|
+
}
|
|
212
|
+
if (!cancelled) {
|
|
213
|
+
setTransport("connecting");
|
|
214
|
+
retryLater();
|
|
215
|
+
}
|
|
216
|
+
} catch (e) {
|
|
217
|
+
if (cancelled || e instanceof Error && e.name === "AbortError") return;
|
|
218
|
+
setTransport("connecting");
|
|
219
|
+
setError(e instanceof Error ? e.message : "Could not load logs.");
|
|
220
|
+
retryLater();
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
connect();
|
|
224
|
+
return () => {
|
|
225
|
+
cancelled = true;
|
|
226
|
+
if (timer) clearTimeout(timer);
|
|
227
|
+
controller?.abort();
|
|
228
|
+
};
|
|
229
|
+
}, [
|
|
230
|
+
level,
|
|
231
|
+
source,
|
|
232
|
+
search,
|
|
233
|
+
limit,
|
|
234
|
+
apiBase,
|
|
235
|
+
visible
|
|
236
|
+
]);
|
|
237
|
+
return {
|
|
238
|
+
logs,
|
|
239
|
+
error,
|
|
240
|
+
transport,
|
|
241
|
+
dropped
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/components/LogsExplorer/LogsExplorer.tsx
|
|
246
|
+
var LEVEL_COLORS = {
|
|
247
|
+
debug: "text-surface-500",
|
|
248
|
+
info: "text-blue-600 dark:text-blue-500",
|
|
249
|
+
warn: "text-amber-600 dark:text-amber-500",
|
|
250
|
+
error: "text-red-600 dark:text-red-500"
|
|
251
|
+
};
|
|
252
|
+
var SOURCE_COLORS = {
|
|
253
|
+
api: "text-sky-600 dark:text-sky-400",
|
|
254
|
+
auth: "text-purple-600 dark:text-purple-400",
|
|
255
|
+
storage: "text-green-600 dark:text-green-500",
|
|
256
|
+
realtime: "text-orange-600 dark:text-orange-400",
|
|
257
|
+
system: "text-surface-600 dark:text-surface-400"
|
|
258
|
+
};
|
|
259
|
+
var TRANSPORT_LABELS = {
|
|
260
|
+
connecting: "Connecting",
|
|
261
|
+
live: "Live",
|
|
262
|
+
polling: "Polling"
|
|
263
|
+
};
|
|
264
|
+
var TRANSPORT_DOTS = {
|
|
265
|
+
connecting: "bg-amber-500",
|
|
266
|
+
live: "bg-green-500",
|
|
267
|
+
polling: "bg-surface-400"
|
|
268
|
+
};
|
|
269
|
+
var TRANSPORT_TITLES = {
|
|
270
|
+
connecting: "Reconnecting to the log stream.",
|
|
271
|
+
live: "Streaming — entries appear as the server writes them.",
|
|
272
|
+
polling: "This server has no log stream; falling back to a refresh every 3 seconds."
|
|
273
|
+
};
|
|
274
|
+
var STICK_THRESHOLD = 40;
|
|
275
|
+
function LogsExplorer() {
|
|
276
|
+
const [level, setLevel] = useState("all");
|
|
277
|
+
const [source, setSource] = useState("all");
|
|
278
|
+
const [searchInput, setSearchInput] = useState("");
|
|
279
|
+
const [search, setSearch] = useState("");
|
|
280
|
+
const [autoScroll, setAutoScroll] = useState(true);
|
|
281
|
+
const [newCount, setNewCount] = useState(0);
|
|
282
|
+
const [atBottom, setAtBottom] = useState(true);
|
|
283
|
+
const containerRef = useRef(null);
|
|
284
|
+
const stickRef = useRef(true);
|
|
285
|
+
const lastMaxIdRef = useRef(null);
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
const t = setTimeout(() => setSearch(searchInput), 300);
|
|
288
|
+
return () => clearTimeout(t);
|
|
289
|
+
}, [searchInput]);
|
|
290
|
+
const { logs, error, transport, dropped } = useLogTail({
|
|
291
|
+
level,
|
|
292
|
+
source,
|
|
293
|
+
search
|
|
294
|
+
});
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
setNewCount(0);
|
|
297
|
+
}, [
|
|
298
|
+
level,
|
|
299
|
+
source,
|
|
300
|
+
search
|
|
301
|
+
]);
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
const max = logs.length > 0 ? idNum(logs[logs.length - 1]) : null;
|
|
304
|
+
const prevMax = lastMaxIdRef.current;
|
|
305
|
+
if (prevMax != null && !stickRef.current) {
|
|
306
|
+
const fresh = logs.filter((e) => idNum(e) > prevMax).length;
|
|
307
|
+
if (fresh > 0) setNewCount((c) => c + fresh);
|
|
308
|
+
}
|
|
309
|
+
lastMaxIdRef.current = max;
|
|
310
|
+
}, [logs]);
|
|
311
|
+
const scrollToBottom = useCallback(() => {
|
|
312
|
+
const el = containerRef.current;
|
|
313
|
+
if (!el) return;
|
|
314
|
+
el.scrollTop = el.scrollHeight;
|
|
315
|
+
stickRef.current = true;
|
|
316
|
+
setAtBottom(true);
|
|
317
|
+
setNewCount(0);
|
|
318
|
+
}, []);
|
|
319
|
+
const handleScroll = useCallback(() => {
|
|
320
|
+
const el = containerRef.current;
|
|
321
|
+
if (!el) return;
|
|
322
|
+
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
|
|
323
|
+
stickRef.current = nearBottom;
|
|
324
|
+
setAtBottom(nearBottom);
|
|
325
|
+
if (nearBottom) setNewCount(0);
|
|
326
|
+
}, []);
|
|
327
|
+
useLayoutEffect(() => {
|
|
328
|
+
if (autoScroll && stickRef.current) {
|
|
329
|
+
const el = containerRef.current;
|
|
330
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
331
|
+
}
|
|
332
|
+
}, [logs, autoScroll]);
|
|
333
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
334
|
+
className: "flex flex-col h-[calc(100vh-64px)] w-full bg-surface-50 dark:bg-surface-800",
|
|
335
|
+
children: [
|
|
336
|
+
/* @__PURE__ */ jsxs("div", {
|
|
337
|
+
className: cls("flex gap-2 p-3 border-b items-center flex-wrap shrink-0", defaultBorderMixin),
|
|
338
|
+
children: [
|
|
339
|
+
/* @__PURE__ */ jsxs(Select, {
|
|
340
|
+
value: level,
|
|
341
|
+
onValueChange: setLevel,
|
|
342
|
+
size: "small",
|
|
343
|
+
placeholder: "All Levels",
|
|
344
|
+
children: [
|
|
345
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
346
|
+
value: "all",
|
|
347
|
+
children: "All Levels"
|
|
348
|
+
}),
|
|
349
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
350
|
+
value: "debug",
|
|
351
|
+
children: "Debug"
|
|
352
|
+
}),
|
|
353
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
354
|
+
value: "info",
|
|
355
|
+
children: "Info"
|
|
356
|
+
}),
|
|
357
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
358
|
+
value: "warn",
|
|
359
|
+
children: "Warn"
|
|
360
|
+
}),
|
|
361
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
362
|
+
value: "error",
|
|
363
|
+
children: "Error"
|
|
364
|
+
})
|
|
365
|
+
]
|
|
366
|
+
}),
|
|
367
|
+
/* @__PURE__ */ jsxs(Select, {
|
|
368
|
+
value: source,
|
|
369
|
+
onValueChange: setSource,
|
|
370
|
+
size: "small",
|
|
371
|
+
placeholder: "All Sources",
|
|
372
|
+
children: [
|
|
373
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
374
|
+
value: "all",
|
|
375
|
+
children: "All Sources"
|
|
376
|
+
}),
|
|
377
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
378
|
+
value: "api",
|
|
379
|
+
children: "API"
|
|
380
|
+
}),
|
|
381
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
382
|
+
value: "auth",
|
|
383
|
+
children: "Auth"
|
|
384
|
+
}),
|
|
385
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
386
|
+
value: "storage",
|
|
387
|
+
children: "Storage"
|
|
388
|
+
}),
|
|
389
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
390
|
+
value: "realtime",
|
|
391
|
+
children: "Realtime"
|
|
392
|
+
}),
|
|
393
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
394
|
+
value: "system",
|
|
395
|
+
children: "System"
|
|
396
|
+
})
|
|
397
|
+
]
|
|
398
|
+
}),
|
|
399
|
+
/* @__PURE__ */ jsx(TextField, {
|
|
400
|
+
size: "small",
|
|
401
|
+
"aria-label": "Search logs",
|
|
402
|
+
placeholder: "Search logs...",
|
|
403
|
+
value: searchInput,
|
|
404
|
+
onChange: (e) => setSearchInput(e.target.value),
|
|
405
|
+
className: "flex-1 min-w-[200px]"
|
|
406
|
+
}),
|
|
407
|
+
/* @__PURE__ */ jsxs("div", {
|
|
408
|
+
className: "flex items-center gap-1.5 cursor-pointer ml-2",
|
|
409
|
+
children: [/* @__PURE__ */ jsx(Checkbox, {
|
|
410
|
+
id: "auto-scroll",
|
|
411
|
+
checked: autoScroll,
|
|
412
|
+
onCheckedChange: (checked) => {
|
|
413
|
+
setAutoScroll(checked);
|
|
414
|
+
if (checked) scrollToBottom();
|
|
415
|
+
},
|
|
416
|
+
size: "small",
|
|
417
|
+
padding: false
|
|
418
|
+
}), /* @__PURE__ */ jsx(Label, {
|
|
419
|
+
htmlFor: "auto-scroll",
|
|
420
|
+
className: "text-xs select-none cursor-pointer text-surface-600 dark:text-surface-400",
|
|
421
|
+
children: "Auto-scroll"
|
|
422
|
+
})]
|
|
423
|
+
}),
|
|
424
|
+
/* @__PURE__ */ jsxs("div", {
|
|
425
|
+
className: "ml-auto pl-4 flex items-center gap-3",
|
|
426
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
427
|
+
className: "flex items-center gap-1.5",
|
|
428
|
+
title: TRANSPORT_TITLES[transport],
|
|
429
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
430
|
+
"aria-hidden": true,
|
|
431
|
+
className: cls("w-1.5 h-1.5 rounded-full shrink-0", TRANSPORT_DOTS[transport])
|
|
432
|
+
}), /* @__PURE__ */ jsx(Typography, {
|
|
433
|
+
variant: "caption",
|
|
434
|
+
color: "secondary",
|
|
435
|
+
children: TRANSPORT_LABELS[transport]
|
|
436
|
+
})]
|
|
437
|
+
}), /* @__PURE__ */ jsxs(Typography, {
|
|
438
|
+
variant: "caption",
|
|
439
|
+
color: "secondary",
|
|
440
|
+
children: [logs.length, " entries"]
|
|
441
|
+
})]
|
|
442
|
+
})
|
|
443
|
+
]
|
|
444
|
+
}),
|
|
445
|
+
error && logs.length > 0 && /* @__PURE__ */ jsx("div", {
|
|
446
|
+
className: cls("px-4 py-1.5 border-b bg-amber-50 dark:bg-amber-950/20 shrink-0", defaultBorderMixin),
|
|
447
|
+
children: /* @__PURE__ */ jsx(Typography, {
|
|
448
|
+
variant: "caption",
|
|
449
|
+
className: "text-amber-700 dark:text-amber-400",
|
|
450
|
+
children: error
|
|
451
|
+
})
|
|
452
|
+
}),
|
|
453
|
+
dropped > 0 && /* @__PURE__ */ jsx("div", {
|
|
454
|
+
className: cls("px-4 py-1.5 border-b bg-amber-50 dark:bg-amber-950/20 shrink-0", defaultBorderMixin),
|
|
455
|
+
children: /* @__PURE__ */ jsxs(Typography, {
|
|
456
|
+
variant: "caption",
|
|
457
|
+
className: "text-amber-700 dark:text-amber-400",
|
|
458
|
+
children: [
|
|
459
|
+
dropped,
|
|
460
|
+
" ",
|
|
461
|
+
dropped === 1 ? "entry was" : "entries were",
|
|
462
|
+
" dropped — the log is arriving faster than this view can read it. Narrow the filter to keep up."
|
|
463
|
+
]
|
|
464
|
+
})
|
|
465
|
+
}),
|
|
466
|
+
/* @__PURE__ */ jsxs("div", {
|
|
467
|
+
className: "relative flex-1 min-h-0",
|
|
468
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
469
|
+
ref: containerRef,
|
|
470
|
+
onScroll: handleScroll,
|
|
471
|
+
className: "h-full overflow-auto py-2",
|
|
472
|
+
children: [logs.map((log) => /* @__PURE__ */ jsxs("div", {
|
|
473
|
+
className: cls("flex gap-4 px-4 py-[6px] border-b hover:bg-surface-100 dark:hover:bg-surface-900 transition-colors", defaultBorderMixin),
|
|
474
|
+
children: [
|
|
475
|
+
/* @__PURE__ */ jsx(Typography, {
|
|
476
|
+
variant: "body2",
|
|
477
|
+
color: "secondary",
|
|
478
|
+
className: "w-[72px] shrink-0 font-mono",
|
|
479
|
+
children: new Date(log.timestamp).toLocaleTimeString()
|
|
480
|
+
}),
|
|
481
|
+
/* @__PURE__ */ jsx(Typography, {
|
|
482
|
+
variant: "body2",
|
|
483
|
+
className: cls("w-[48px] shrink-0 uppercase font-semibold font-mono", LEVEL_COLORS[log.level] || "text-surface-500"),
|
|
484
|
+
children: log.level
|
|
485
|
+
}),
|
|
486
|
+
/* @__PURE__ */ jsxs(Typography, {
|
|
487
|
+
variant: "body2",
|
|
488
|
+
className: cls("w-[80px] shrink-0 font-mono", SOURCE_COLORS[log.source] || "text-surface-500"),
|
|
489
|
+
children: [
|
|
490
|
+
"[",
|
|
491
|
+
log.source,
|
|
492
|
+
"]"
|
|
493
|
+
]
|
|
494
|
+
}),
|
|
495
|
+
/* @__PURE__ */ jsx(Typography, {
|
|
496
|
+
variant: "body2",
|
|
497
|
+
className: "flex-1 font-mono break-all whitespace-pre-wrap text-surface-900 dark:text-surface-100",
|
|
498
|
+
children: log.message
|
|
499
|
+
})
|
|
500
|
+
]
|
|
501
|
+
}, log.id)), logs.length === 0 && /* @__PURE__ */ jsx("div", {
|
|
502
|
+
className: "p-8 text-center",
|
|
503
|
+
children: /* @__PURE__ */ jsx(Typography, {
|
|
504
|
+
variant: "body2",
|
|
505
|
+
className: error ? "text-red-600 dark:text-red-500" : void 0,
|
|
506
|
+
color: error ? void 0 : "secondary",
|
|
507
|
+
children: error ?? "No log entries yet. Logs will appear here as requests come in."
|
|
508
|
+
})
|
|
509
|
+
})]
|
|
510
|
+
}), autoScroll && !atBottom && newCount > 0 && /* @__PURE__ */ jsxs("button", {
|
|
511
|
+
onClick: scrollToBottom,
|
|
512
|
+
className: "absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 rounded-full bg-primary text-white text-xs font-medium pl-2.5 pr-3 py-1.5 shadow-md hover:bg-primary-dark transition-colors",
|
|
513
|
+
children: [
|
|
514
|
+
/* @__PURE__ */ jsx(ArrowDownToLineIcon, { size: 14 }),
|
|
515
|
+
newCount,
|
|
516
|
+
" new ",
|
|
517
|
+
newCount === 1 ? "entry" : "entries"
|
|
518
|
+
]
|
|
519
|
+
})]
|
|
520
|
+
})
|
|
521
|
+
]
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
//#endregion
|
|
525
|
+
export { LogsExplorer };
|
|
526
|
+
|
|
527
|
+
//# sourceMappingURL=LogsExplorer-DI8SVpya.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LogsExplorer-DI8SVpya.js","names":[],"sources":["../src/components/LogsExplorer/useLogTail.ts","../src/components/LogsExplorer/LogsExplorer.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport { useApiBase, useApiConfig } from \"@rebasepro/app\";\n\nexport interface LogEntry {\n id: string;\n timestamp: string;\n level: \"debug\" | \"info\" | \"warn\" | \"error\";\n source: \"api\" | \"auth\" | \"storage\" | \"realtime\" | \"system\";\n message: string;\n metadata?: Record<string, unknown>;\n}\n\nexport interface LogTailFilters {\n /** `\"all\"` and `undefined` both mean unfiltered. */\n level?: string;\n source?: string;\n search?: string;\n}\n\n/**\n * How the view is currently being fed.\n *\n * Surfaced rather than kept private: \"connecting\" and \"polling\" look identical\n * from the outside until something is wrong, and the first question about a log\n * view that seems stuck is whether it is actually live.\n */\nexport type LogTransport = \"connecting\" | \"live\" | \"polling\";\n\n/**\n * Entries kept in the browser.\n *\n * The server window is 200; a tail that never forgets would grow without bound\n * on a busy backend, since nothing else here ever drops an entry.\n */\nconst MAX_ENTRIES = 1000;\n\n/** Fallback cadence, used only against a server with no stream route. */\nconst POLL_MS = 3000;\n\nconst RETRY_BASE_MS = 1000;\nconst RETRY_MAX_MS = 15_000;\n\n/** Ids are `log_<n>` off a monotonic counter, so numeric order is push order. */\nexport const idNum = (entry: LogEntry): number => Number(entry.id.slice(\"log_\".length));\n\n/** The window is contiguous and ordered, so identical ends mean identical content. */\nexport const sameLogs = (a: LogEntry[], b: LogEntry[]): boolean =>\n a.length === b.length &&\n a[0]?.id === b[0]?.id &&\n a[a.length - 1]?.id === b[b.length - 1]?.id;\n\n/** One identity for \"nothing\", so clearing an already-empty view is not a change. */\nconst EMPTY: LogEntry[] = [];\n\nconst capped = (entries: LogEntry[]): LogEntry[] =>\n entries.length > MAX_ENTRIES ? entries.slice(entries.length - MAX_ENTRIES) : entries;\n\nexport interface SSEFrame {\n event: string;\n data: string;\n}\n\n/**\n * Split an SSE byte stream into frames.\n *\n * `EventSource` would do this, and cannot be used: logs are admin-only, and\n * `EventSource` has no way to send an `Authorization` header. So the stream is\n * read off `fetch` and framed here.\n *\n * Exported for its own test — the parser is the part with edge cases (a frame\n * split across two chunks, multi-line data, comment keepalives).\n */\nexport async function* readSSEFrames(\n body: ReadableStream<Uint8Array>\n): AsyncGenerator<SSEFrame> {\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n // `stream: true` keeps a multi-byte character split across two chunks\n // from decoding as two replacement characters.\n buffer += decoder.decode(value, { stream: true }).replace(/\\r\\n/g, \"\\n\");\n\n let boundary = buffer.indexOf(\"\\n\\n\");\n while (boundary !== -1) {\n const raw = buffer.slice(0, boundary);\n buffer = buffer.slice(boundary + 2);\n const frame = parseFrame(raw);\n if (frame) yield frame;\n boundary = buffer.indexOf(\"\\n\\n\");\n }\n }\n}\n\nfunction parseFrame(raw: string): SSEFrame | null {\n let event = \"message\";\n const data: string[] = [];\n for (const line of raw.split(\"\\n\")) {\n // A line starting with \":\" is a comment. The server sends them as\n // keepalives, and they carry nothing.\n if (!line || line.startsWith(\":\")) continue;\n const colon = line.indexOf(\":\");\n const field = colon === -1 ? line : line.slice(0, colon);\n const value = colon === -1 ? \"\" : line.slice(colon + 1).replace(/^ /, \"\");\n if (field === \"event\") event = value;\n else if (field === \"data\") data.push(value);\n }\n if (data.length === 0) return null;\n return { event,\n data: data.join(\"\\n\") };\n}\n\nconst buildParams = (filters: LogTailFilters, limit: number): string => {\n const params = new URLSearchParams();\n if (filters.level && filters.level !== \"all\") params.set(\"level\", filters.level);\n if (filters.source && filters.source !== \"all\") params.set(\"source\", filters.source);\n if (filters.search) params.set(\"search\", filters.search);\n params.set(\"limit\", String(limit));\n return params.toString();\n};\n\n/**\n * Why the log is not being served, in the server's words where it has any.\n *\n * The admin gate answers 501 with a message naming the setting to change (a\n * backend with no `jwtSecret` and no auth adapter cannot tell an admin from the\n * internet, so it refuses rather than opens). Rendering \"HTTP 501\" over the top\n * of that would throw away the only sentence that says what to do.\n */\nasync function failureMessage(resp: { status: number; json?: () => Promise<unknown> }): Promise<string> {\n try {\n const body = await resp.json?.() as { error?: { message?: string } } | undefined;\n const message = body?.error?.message;\n if (typeof message === \"string\" && message) return message;\n } catch {\n /* not every failure has a JSON body */\n }\n return resp.status === 401 || resp.status === 403\n ? \"Not authorised to read logs — an admin role is required.\"\n : `Could not load logs (HTTP ${resp.status}).`;\n}\n\nexport interface LogTail {\n logs: LogEntry[];\n error: string | null;\n transport: LogTransport;\n /**\n * Entries the server had to discard because this client was not draining the\n * stream fast enough, since the connection opened.\n *\n * Surfaced rather than swallowed. The server bounds what it will hold per\n * connection, so under a heavy enough burst there is a hole in the tail — and\n * a tail with a hole in it that says so beats one that silently lies.\n */\n dropped: number;\n}\n\n/**\n * Tail the server's log buffer.\n *\n * Server-sent events, with the 3s poll kept only as a fallback: the studio and\n * the server are versioned and deployed separately, so a frontend that knows\n * about `/logs/stream` will meet servers that do not. A 404 there is not an\n * error, it is an older backend, and it degrades to what that backend supports\n * instead of showing an empty view.\n *\n * The stream sends its own backlog as a `snapshot` frame before any `append`, so\n * a reconnect restores the window without a second request and without a gap\n * where entries logged mid-handshake would belong to neither call.\n */\nexport function useLogTail(filters: LogTailFilters, limit = 200): LogTail {\n const [logs, setLogs] = useState<LogEntry[]>([]);\n const [error, setError] = useState<string | null>(null);\n const [transport, setTransport] = useState<LogTransport>(\"connecting\");\n const [dropped, setDropped] = useState(0);\n const apiConfig = useApiConfig();\n const apiBase = useApiBase();\n\n // Held in a ref, and deliberately not a dependency of the connection effect.\n // `apiConfig` is an object: a provider that forgets to memoize it hands us a\n // new identity per render, and as a dependency that is a reconnect per\n // render — a tail that hammers the server and never stays up. `apiBase` is a\n // string and compares by value, so it can be a dependency and covers the one\n // thing that actually has to reopen the connection.\n const getAuthToken = apiConfig?.getAuthToken;\n const getAuthTokenRef = useRef(getAuthToken);\n useEffect(() => {\n getAuthTokenRef.current = getAuthToken;\n });\n\n const { level, source, search } = filters;\n\n // A hidden tab is not watching. The connection is dropped rather than left\n // to accumulate, and the reconnect's snapshot rebuilds the window — so this\n // costs nothing but the entries nobody saw.\n const [visible, setVisible] = useState(\n typeof document === \"undefined\" || document.visibilityState === \"visible\"\n );\n useEffect(() => {\n const onChange = () => setVisible(document.visibilityState === \"visible\");\n document.addEventListener(\"visibilitychange\", onChange);\n return () => document.removeEventListener(\"visibilitychange\", onChange);\n }, []);\n\n // A filter change replaces the window wholesale. Clearing here rather than in\n // the connection effect means a reconnect (or a tab coming back) keeps what\n // is on screen until the new snapshot lands.\n //\n // Joined on NUL, written as the escape: the search box takes free text, so any\n // printable separator is a character a user can type, and two filters that\n // differ only either side of it would collide into one key and skip the clear.\n // As a raw byte it would make the whole file binary to grep, which reads as an\n // empty search result rather than as an error.\n const filterKey = `${level}\\u0000${source}\\u0000${search}`;\n // `EMPTY` rather than a fresh `[]` so the run on mount, when there is nothing\n // to clear, is not a state change and does not cost a render.\n useEffect(() => {\n setLogs(EMPTY);\n }, [filterKey]);\n\n useEffect(() => {\n if (!apiBase) {\n setError(\"No API URL configured — cannot load logs.\");\n return;\n }\n if (!visible) return;\n\n let cancelled = false;\n let controller: AbortController | null = null;\n let timer: ReturnType<typeof setTimeout> | null = null;\n let attempt = 0;\n\n const query = buildParams({ level,\n source,\n search }, limit);\n\n const authHeaders = async (): Promise<Record<string, string>> => {\n const headers: Record<string, string> = {};\n const token = await getAuthTokenRef.current?.() ?? null;\n if (token) headers[\"Authorization\"] = `Bearer ${token}`;\n return headers;\n };\n\n const retryLater = () => {\n if (cancelled) return;\n const delay = Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_MS);\n attempt++;\n timer = setTimeout(connect, delay);\n };\n\n /** The fallback. Only ever reached against a server with no stream route. */\n const poll = async () => {\n if (cancelled) return;\n try {\n const resp = await fetch(`${apiBase}/logs?${query}`, { headers: await authHeaders() });\n if (cancelled) return;\n if (!resp.ok) {\n setError(await failureMessage(resp));\n } else {\n const data: { entries?: LogEntry[] } = await resp.json();\n // The query returns newest-first; the view tails like a\n // terminal, so flip to chronological order.\n const entries = (data.entries || []).slice().reverse();\n setLogs(prev => sameLogs(prev, entries) ? prev : entries);\n setError(null);\n }\n } catch (e) {\n if (!cancelled) setError(e instanceof Error ? e.message : \"Could not load logs.\");\n }\n if (!cancelled) timer = setTimeout(poll, POLL_MS);\n };\n\n const startPolling = () => {\n if (cancelled) return;\n setTransport(\"polling\");\n poll();\n };\n\n const connect = async () => {\n if (cancelled) return;\n controller = new AbortController();\n try {\n const resp = await fetch(`${apiBase}/logs/stream?${query}`, {\n headers: { ...await authHeaders(),\n Accept: \"text/event-stream\" },\n signal: controller.signal\n });\n if (cancelled) return;\n\n // 404 is an older server, not a failure — it has the query route\n // but not the stream. Same for a body-less response, which is a\n // client with no streaming support at all.\n if (resp.status === 404 || !resp.body) {\n startPolling();\n return;\n }\n if (!resp.ok) {\n setError(await failureMessage(resp));\n retryLater();\n return;\n }\n\n attempt = 0;\n setError(null);\n setTransport(\"live\");\n // Per-connection: the server's counter resets with the socket, so\n // a stale total would outlive the gap it described.\n setDropped(0);\n\n for await (const frame of readSSEFrames(resp.body)) {\n if (cancelled) return;\n if (frame.event === \"snapshot\") {\n const { entries } = JSON.parse(frame.data) as { entries: LogEntry[] };\n setLogs(prev => sameLogs(prev, entries) ? prev : capped(entries));\n } else if (frame.event === \"append\") {\n const parsed = JSON.parse(frame.data) as { entries: LogEntry[]; dropped?: number };\n if (parsed.entries.length > 0) {\n setLogs(prev => capped([...prev, ...parsed.entries]));\n }\n if (parsed.dropped) setDropped(n => n + parsed.dropped!);\n } else if (frame.event === \"error\") {\n setError(frame.data);\n }\n }\n\n // The stream ended on its own — a restarted or redeployed\n // server. Reconnecting is the whole point of the backoff.\n if (!cancelled) {\n setTransport(\"connecting\");\n retryLater();\n }\n } catch (e) {\n if (cancelled || (e instanceof Error && e.name === \"AbortError\")) return;\n setTransport(\"connecting\");\n setError(e instanceof Error ? e.message : \"Could not load logs.\");\n retryLater();\n }\n };\n\n connect();\n\n return () => {\n cancelled = true;\n if (timer) clearTimeout(timer);\n controller?.abort();\n };\n }, [level, source, search, limit, apiBase, visible]);\n\n return { logs,\n error,\n transport,\n dropped };\n}\n","import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { ArrowDownToLineIcon, Checkbox, cls, defaultBorderMixin, Label, Select, SelectItem, TextField, Typography } from \"@rebasepro/ui\";\nimport { idNum, useLogTail } from \"./useLogTail\";\nimport type { LogTransport } from \"./useLogTail\";\n\nconst LEVEL_COLORS: Record<string, string> = {\n debug: \"text-surface-500\",\n info: \"text-blue-600 dark:text-blue-500\",\n warn: \"text-amber-600 dark:text-amber-500\",\n error: \"text-red-600 dark:text-red-500\"\n};\n\nconst SOURCE_COLORS: Record<string, string> = {\n api: \"text-sky-600 dark:text-sky-400\",\n auth: \"text-purple-600 dark:text-purple-400\",\n storage: \"text-green-600 dark:text-green-500\",\n realtime: \"text-orange-600 dark:text-orange-400\",\n system: \"text-surface-600 dark:text-surface-400\"\n};\n\nconst TRANSPORT_LABELS: Record<LogTransport, string> = {\n connecting: \"Connecting\",\n live: \"Live\",\n polling: \"Polling\"\n};\n\nconst TRANSPORT_DOTS: Record<LogTransport, string> = {\n connecting: \"bg-amber-500\",\n live: \"bg-green-500\",\n polling: \"bg-surface-400\"\n};\n\nconst TRANSPORT_TITLES: Record<LogTransport, string> = {\n connecting: \"Reconnecting to the log stream.\",\n live: \"Streaming — entries appear as the server writes them.\",\n polling: \"This server has no log stream; falling back to a refresh every 3 seconds.\"\n};\n\n// How close to the bottom edge still counts as \"at the bottom\".\nconst STICK_THRESHOLD = 40;\n\nexport function LogsExplorer() {\n const [level, setLevel] = useState<string>(\"all\");\n const [source, setSource] = useState<string>(\"all\");\n const [searchInput, setSearchInput] = useState(\"\");\n const [search, setSearch] = useState(\"\");\n const [autoScroll, setAutoScroll] = useState(true);\n // Entries that arrived while the user was scrolled away from the bottom.\n const [newCount, setNewCount] = useState(0);\n const [atBottom, setAtBottom] = useState(true);\n const containerRef = useRef<HTMLDivElement>(null);\n // Whether the view is stuck to the bottom right now. A ref, not state:\n // the scroll handler and the entry-counting effect both read it synchronously.\n const stickRef = useRef(true);\n const lastMaxIdRef = useRef<number | null>(null);\n\n // Debounce the search box so typing doesn't reopen the stream per keystroke.\n useEffect(() => {\n const t = setTimeout(() => setSearch(searchInput), 300);\n return () => clearTimeout(t);\n }, [searchInput]);\n\n const { logs, error, transport, dropped } = useLogTail({ level,\n source,\n search });\n\n // A filter change replaces the window wholesale — \"new since you looked\n // away\" stops meaning anything, so the counter starts over.\n useEffect(() => {\n setNewCount(0);\n }, [level, source, search]);\n\n // Count what arrived while the user was reading further up. Driven off the\n // rendered window rather than off the transport, so it means the same thing\n // whether the entries were streamed or polled.\n useEffect(() => {\n const max = logs.length > 0 ? idNum(logs[logs.length - 1]) : null;\n const prevMax = lastMaxIdRef.current;\n if (prevMax != null && !stickRef.current) {\n const fresh = logs.filter(e => idNum(e) > prevMax).length;\n if (fresh > 0) setNewCount(c => c + fresh);\n }\n lastMaxIdRef.current = max;\n }, [logs]);\n\n const scrollToBottom = useCallback(() => {\n const el = containerRef.current;\n if (!el) return;\n el.scrollTop = el.scrollHeight;\n stickRef.current = true;\n setAtBottom(true);\n setNewCount(0);\n }, []);\n\n // Stick to the bottom only while the user is already there. Scrolling up\n // disengages; scrolling back down re-engages. Our own scrollTop writes\n // always land at the bottom, so they can never disengage it.\n const handleScroll = useCallback(() => {\n const el = containerRef.current;\n if (!el) return;\n const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;\n stickRef.current = nearBottom;\n setAtBottom(nearBottom);\n if (nearBottom) setNewCount(0);\n }, []);\n\n useLayoutEffect(() => {\n if (autoScroll && stickRef.current) {\n const el = containerRef.current;\n if (el) el.scrollTop = el.scrollHeight;\n }\n }, [logs, autoScroll]);\n\n return (\n <div className=\"flex flex-col h-[calc(100vh-64px)] w-full bg-surface-50 dark:bg-surface-800\">\n {/* Toolbar */}\n <div className={cls(\n \"flex gap-2 p-3 border-b items-center flex-wrap shrink-0\",\n defaultBorderMixin\n )}>\n <Select\n value={level}\n onValueChange={setLevel}\n size=\"small\"\n placeholder=\"All Levels\"\n >\n <SelectItem value=\"all\">All Levels</SelectItem>\n <SelectItem value=\"debug\">Debug</SelectItem>\n <SelectItem value=\"info\">Info</SelectItem>\n <SelectItem value=\"warn\">Warn</SelectItem>\n <SelectItem value=\"error\">Error</SelectItem>\n </Select>\n <Select\n value={source}\n onValueChange={setSource}\n size=\"small\"\n placeholder=\"All Sources\"\n >\n <SelectItem value=\"all\">All Sources</SelectItem>\n <SelectItem value=\"api\">API</SelectItem>\n <SelectItem value=\"auth\">Auth</SelectItem>\n <SelectItem value=\"storage\">Storage</SelectItem>\n <SelectItem value=\"realtime\">Realtime</SelectItem>\n <SelectItem value=\"system\">System</SelectItem>\n </Select>\n <TextField\n size=\"small\"\n aria-label=\"Search logs\"\n placeholder=\"Search logs...\"\n value={searchInput}\n onChange={e => setSearchInput(e.target.value)}\n className=\"flex-1 min-w-[200px]\"\n />\n <div className=\"flex items-center gap-1.5 cursor-pointer ml-2\">\n <Checkbox\n id=\"auto-scroll\"\n checked={autoScroll}\n onCheckedChange={(checked: boolean) => {\n setAutoScroll(checked);\n if (checked) scrollToBottom();\n }}\n size=\"small\"\n padding={false}\n />\n <Label\n htmlFor=\"auto-scroll\"\n className=\"text-xs select-none cursor-pointer text-surface-600 dark:text-surface-400\"\n >\n Auto-scroll\n </Label>\n </div>\n {/* Whether the tail is actually attached. A log view with\n nothing in it is ambiguous — quiet server, or a stream that\n dropped — and this is the difference. */}\n <div className=\"ml-auto pl-4 flex items-center gap-3\">\n <div className=\"flex items-center gap-1.5\" title={TRANSPORT_TITLES[transport]}>\n <span\n aria-hidden\n className={cls(\"w-1.5 h-1.5 rounded-full shrink-0\", TRANSPORT_DOTS[transport])}\n />\n <Typography variant=\"caption\" color=\"secondary\">\n {TRANSPORT_LABELS[transport]}\n </Typography>\n </div>\n <Typography variant=\"caption\" color=\"secondary\">\n {logs.length} entries\n </Typography>\n </div>\n </div>\n\n {/* A broken tail must stay visible even while stale logs are on\n screen — otherwise the view quietly freezes. */}\n {error && logs.length > 0 && (\n <div className={cls(\n \"px-4 py-1.5 border-b bg-amber-50 dark:bg-amber-950/20 shrink-0\",\n defaultBorderMixin\n )}>\n <Typography variant=\"caption\" className=\"text-amber-700 dark:text-amber-400\">\n {error}\n </Typography>\n </div>\n )}\n\n {/* The server bounds what it holds per connection, so a burst big\n enough leaves a hole in what is on screen. Say so: a tail that\n silently skips entries is worse than one that admits it. */}\n {dropped > 0 && (\n <div className={cls(\n \"px-4 py-1.5 border-b bg-amber-50 dark:bg-amber-950/20 shrink-0\",\n defaultBorderMixin\n )}>\n <Typography variant=\"caption\" className=\"text-amber-700 dark:text-amber-400\">\n {dropped} {dropped === 1 ? \"entry was\" : \"entries were\"} dropped — the log is\n arriving faster than this view can read it. Narrow the filter to keep up.\n </Typography>\n </div>\n )}\n\n {/* Log entries */}\n <div className=\"relative flex-1 min-h-0\">\n <div\n ref={containerRef}\n onScroll={handleScroll}\n className=\"h-full overflow-auto py-2\"\n >\n {logs.map(log => (\n <div\n key={log.id}\n className={cls(\n \"flex gap-4 px-4 py-[6px] border-b hover:bg-surface-100 dark:hover:bg-surface-900 transition-colors\",\n defaultBorderMixin\n )}\n >\n <Typography variant=\"body2\" color=\"secondary\" className=\"w-[72px] shrink-0 font-mono\">\n {new Date(log.timestamp).toLocaleTimeString()}\n </Typography>\n <Typography variant=\"body2\" className={cls(\"w-[48px] shrink-0 uppercase font-semibold font-mono\", LEVEL_COLORS[log.level] || \"text-surface-500\")}>\n {log.level}\n </Typography>\n <Typography variant=\"body2\" className={cls(\"w-[80px] shrink-0 font-mono\", SOURCE_COLORS[log.source] || \"text-surface-500\")}>\n [{log.source}]\n </Typography>\n <Typography variant=\"body2\" className=\"flex-1 font-mono break-all whitespace-pre-wrap text-surface-900 dark:text-surface-100\">\n {log.message}\n </Typography>\n </div>\n ))}\n {logs.length === 0 && (\n <div className=\"p-8 text-center\">\n <Typography\n variant=\"body2\"\n className={error ? \"text-red-600 dark:text-red-500\" : undefined}\n color={error ? undefined : \"secondary\"}\n >\n {error ?? \"No log entries yet. Logs will appear here as requests come in.\"}\n </Typography>\n </div>\n )}\n </div>\n\n {autoScroll && !atBottom && newCount > 0 && (\n <button\n onClick={scrollToBottom}\n className=\"absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 rounded-full bg-primary text-white text-xs font-medium pl-2.5 pr-3 py-1.5 shadow-md hover:bg-primary-dark transition-colors\"\n >\n <ArrowDownToLineIcon size={14}/>\n {newCount} new {newCount === 1 ? \"entry\" : \"entries\"}\n </button>\n )}\n </div>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export interface LogEntry {
|
|
2
|
+
id: string;
|
|
3
|
+
timestamp: string;
|
|
4
|
+
level: "debug" | "info" | "warn" | "error";
|
|
5
|
+
source: "api" | "auth" | "storage" | "realtime" | "system";
|
|
6
|
+
message: string;
|
|
7
|
+
metadata?: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
export interface LogTailFilters {
|
|
10
|
+
/** `"all"` and `undefined` both mean unfiltered. */
|
|
11
|
+
level?: string;
|
|
12
|
+
source?: string;
|
|
13
|
+
search?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* How the view is currently being fed.
|
|
17
|
+
*
|
|
18
|
+
* Surfaced rather than kept private: "connecting" and "polling" look identical
|
|
19
|
+
* from the outside until something is wrong, and the first question about a log
|
|
20
|
+
* view that seems stuck is whether it is actually live.
|
|
21
|
+
*/
|
|
22
|
+
export type LogTransport = "connecting" | "live" | "polling";
|
|
23
|
+
/** Ids are `log_<n>` off a monotonic counter, so numeric order is push order. */
|
|
24
|
+
export declare const idNum: (entry: LogEntry) => number;
|
|
25
|
+
/** The window is contiguous and ordered, so identical ends mean identical content. */
|
|
26
|
+
export declare const sameLogs: (a: LogEntry[], b: LogEntry[]) => boolean;
|
|
27
|
+
export interface SSEFrame {
|
|
28
|
+
event: string;
|
|
29
|
+
data: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Split an SSE byte stream into frames.
|
|
33
|
+
*
|
|
34
|
+
* `EventSource` would do this, and cannot be used: logs are admin-only, and
|
|
35
|
+
* `EventSource` has no way to send an `Authorization` header. So the stream is
|
|
36
|
+
* read off `fetch` and framed here.
|
|
37
|
+
*
|
|
38
|
+
* Exported for its own test — the parser is the part with edge cases (a frame
|
|
39
|
+
* split across two chunks, multi-line data, comment keepalives).
|
|
40
|
+
*/
|
|
41
|
+
export declare function readSSEFrames(body: ReadableStream<Uint8Array>): AsyncGenerator<SSEFrame>;
|
|
42
|
+
export interface LogTail {
|
|
43
|
+
logs: LogEntry[];
|
|
44
|
+
error: string | null;
|
|
45
|
+
transport: LogTransport;
|
|
46
|
+
/**
|
|
47
|
+
* Entries the server had to discard because this client was not draining the
|
|
48
|
+
* stream fast enough, since the connection opened.
|
|
49
|
+
*
|
|
50
|
+
* Surfaced rather than swallowed. The server bounds what it will hold per
|
|
51
|
+
* connection, so under a heavy enough burst there is a hole in the tail — and
|
|
52
|
+
* a tail with a hole in it that says so beats one that silently lies.
|
|
53
|
+
*/
|
|
54
|
+
dropped: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Tail the server's log buffer.
|
|
58
|
+
*
|
|
59
|
+
* Server-sent events, with the 3s poll kept only as a fallback: the studio and
|
|
60
|
+
* the server are versioned and deployed separately, so a frontend that knows
|
|
61
|
+
* about `/logs/stream` will meet servers that do not. A 404 there is not an
|
|
62
|
+
* error, it is an older backend, and it degrades to what that backend supports
|
|
63
|
+
* instead of showing an empty view.
|
|
64
|
+
*
|
|
65
|
+
* The stream sends its own backlog as a `snapshot` frame before any `append`, so
|
|
66
|
+
* a reconnect restores the window without a second request and without a gap
|
|
67
|
+
* where entries logged mid-handshake would belong to neither call.
|
|
68
|
+
*/
|
|
69
|
+
export declare function useLogTail(filters: LogTailFilters, limit?: number): LogTail;
|