@rebasepro/studio 0.9.1-canary.d198c11 → 0.9.1-canary.e2fc7b6
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-BCXoGREr.js +298 -0
- package/dist/LogsExplorer-BCXoGREr.js.map +1 -0
- package/dist/index.es.js +1 -1
- package/package.json +8 -8
- package/src/components/LogsExplorer/LogsExplorer.tsx +143 -45
- package/dist/LogsExplorer-Bi0bxDRx.js +0 -225
- package/dist/LogsExplorer-Bi0bxDRx.js.map +0 -1
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { 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/LogsExplorer.tsx
|
|
6
|
+
var LEVEL_COLORS = {
|
|
7
|
+
debug: "text-surface-500",
|
|
8
|
+
info: "text-blue-600 dark:text-blue-500",
|
|
9
|
+
warn: "text-amber-600 dark:text-amber-500",
|
|
10
|
+
error: "text-red-600 dark:text-red-500"
|
|
11
|
+
};
|
|
12
|
+
var SOURCE_COLORS = {
|
|
13
|
+
api: "text-sky-600 dark:text-sky-400",
|
|
14
|
+
auth: "text-purple-600 dark:text-purple-400",
|
|
15
|
+
storage: "text-green-600 dark:text-green-500",
|
|
16
|
+
realtime: "text-orange-600 dark:text-orange-400",
|
|
17
|
+
system: "text-surface-600 dark:text-surface-400"
|
|
18
|
+
};
|
|
19
|
+
var idNum = (entry) => Number(entry.id.slice(4));
|
|
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
|
+
var STICK_THRESHOLD = 40;
|
|
22
|
+
function LogsExplorer() {
|
|
23
|
+
const [logs, setLogs] = useState([]);
|
|
24
|
+
const [level, setLevel] = useState("all");
|
|
25
|
+
const [source, setSource] = useState("all");
|
|
26
|
+
const [searchInput, setSearchInput] = useState("");
|
|
27
|
+
const [search, setSearch] = useState("");
|
|
28
|
+
const [autoScroll, setAutoScroll] = useState(true);
|
|
29
|
+
const [error, setError] = useState(null);
|
|
30
|
+
const [newCount, setNewCount] = useState(0);
|
|
31
|
+
const [atBottom, setAtBottom] = useState(true);
|
|
32
|
+
const containerRef = useRef(null);
|
|
33
|
+
const stickRef = useRef(true);
|
|
34
|
+
const lastMaxIdRef = useRef(null);
|
|
35
|
+
const apiConfig = useApiConfig();
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const t = setTimeout(() => setSearch(searchInput), 300);
|
|
38
|
+
return () => clearTimeout(t);
|
|
39
|
+
}, [searchInput]);
|
|
40
|
+
const fetchLogs = useCallback(async () => {
|
|
41
|
+
if (!apiConfig?.apiUrl) {
|
|
42
|
+
setError("No API URL configured — cannot load logs.");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const params = new URLSearchParams();
|
|
47
|
+
if (level && level !== "all") params.set("level", level);
|
|
48
|
+
if (source && source !== "all") params.set("source", source);
|
|
49
|
+
if (search) params.set("search", search);
|
|
50
|
+
params.set("limit", "200");
|
|
51
|
+
const headers = {};
|
|
52
|
+
const token = apiConfig.getAuthToken ? await apiConfig.getAuthToken() : null;
|
|
53
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
54
|
+
const resp = await fetch(`${apiConfig.apiUrl}/api/logs?${params}`, { headers });
|
|
55
|
+
if (!resp.ok) {
|
|
56
|
+
setError(resp.status === 401 || resp.status === 403 ? "Not authorised to read logs — an admin role is required." : `Could not load logs (HTTP ${resp.status}).`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const entries = ((await resp.json()).entries || []).slice().reverse();
|
|
60
|
+
const prevMax = lastMaxIdRef.current;
|
|
61
|
+
if (prevMax != null && !stickRef.current) {
|
|
62
|
+
const fresh = entries.filter((e) => idNum(e) > prevMax).length;
|
|
63
|
+
if (fresh > 0) setNewCount((c) => c + fresh);
|
|
64
|
+
}
|
|
65
|
+
if (entries.length > 0) lastMaxIdRef.current = idNum(entries[entries.length - 1]);
|
|
66
|
+
setLogs((prev) => sameLogs(prev, entries) ? prev : entries);
|
|
67
|
+
setError(null);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
setError(e instanceof Error ? e.message : "Could not load logs.");
|
|
70
|
+
}
|
|
71
|
+
}, [
|
|
72
|
+
level,
|
|
73
|
+
source,
|
|
74
|
+
search,
|
|
75
|
+
apiConfig
|
|
76
|
+
]);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
lastMaxIdRef.current = null;
|
|
79
|
+
setNewCount(0);
|
|
80
|
+
}, [
|
|
81
|
+
level,
|
|
82
|
+
source,
|
|
83
|
+
search
|
|
84
|
+
]);
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
let timeoutId = null;
|
|
87
|
+
let cancelled = false;
|
|
88
|
+
fetchLogs();
|
|
89
|
+
const scheduleNext = () => {
|
|
90
|
+
if (cancelled) return;
|
|
91
|
+
timeoutId = setTimeout(async () => {
|
|
92
|
+
if (document.visibilityState === "visible") await fetchLogs();
|
|
93
|
+
scheduleNext();
|
|
94
|
+
}, 3e3);
|
|
95
|
+
};
|
|
96
|
+
scheduleNext();
|
|
97
|
+
const handleVisibility = () => {
|
|
98
|
+
if (document.visibilityState === "visible") fetchLogs();
|
|
99
|
+
};
|
|
100
|
+
document.addEventListener("visibilitychange", handleVisibility);
|
|
101
|
+
return () => {
|
|
102
|
+
cancelled = true;
|
|
103
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
104
|
+
document.removeEventListener("visibilitychange", handleVisibility);
|
|
105
|
+
};
|
|
106
|
+
}, [fetchLogs]);
|
|
107
|
+
const scrollToBottom = useCallback(() => {
|
|
108
|
+
const el = containerRef.current;
|
|
109
|
+
if (!el) return;
|
|
110
|
+
el.scrollTop = el.scrollHeight;
|
|
111
|
+
stickRef.current = true;
|
|
112
|
+
setAtBottom(true);
|
|
113
|
+
setNewCount(0);
|
|
114
|
+
}, []);
|
|
115
|
+
const handleScroll = useCallback(() => {
|
|
116
|
+
const el = containerRef.current;
|
|
117
|
+
if (!el) return;
|
|
118
|
+
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
|
|
119
|
+
stickRef.current = nearBottom;
|
|
120
|
+
setAtBottom(nearBottom);
|
|
121
|
+
if (nearBottom) setNewCount(0);
|
|
122
|
+
}, []);
|
|
123
|
+
useLayoutEffect(() => {
|
|
124
|
+
if (autoScroll && stickRef.current) {
|
|
125
|
+
const el = containerRef.current;
|
|
126
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
127
|
+
}
|
|
128
|
+
}, [logs, autoScroll]);
|
|
129
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
130
|
+
className: "flex flex-col h-[calc(100vh-64px)] w-full bg-surface-50 dark:bg-surface-800",
|
|
131
|
+
children: [
|
|
132
|
+
/* @__PURE__ */ jsxs("div", {
|
|
133
|
+
className: cls("flex gap-2 p-3 border-b items-center flex-wrap shrink-0", defaultBorderMixin),
|
|
134
|
+
children: [
|
|
135
|
+
/* @__PURE__ */ jsxs(Select, {
|
|
136
|
+
value: level,
|
|
137
|
+
onValueChange: setLevel,
|
|
138
|
+
size: "small",
|
|
139
|
+
placeholder: "All Levels",
|
|
140
|
+
children: [
|
|
141
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
142
|
+
value: "all",
|
|
143
|
+
children: "All Levels"
|
|
144
|
+
}),
|
|
145
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
146
|
+
value: "debug",
|
|
147
|
+
children: "Debug"
|
|
148
|
+
}),
|
|
149
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
150
|
+
value: "info",
|
|
151
|
+
children: "Info"
|
|
152
|
+
}),
|
|
153
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
154
|
+
value: "warn",
|
|
155
|
+
children: "Warn"
|
|
156
|
+
}),
|
|
157
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
158
|
+
value: "error",
|
|
159
|
+
children: "Error"
|
|
160
|
+
})
|
|
161
|
+
]
|
|
162
|
+
}),
|
|
163
|
+
/* @__PURE__ */ jsxs(Select, {
|
|
164
|
+
value: source,
|
|
165
|
+
onValueChange: setSource,
|
|
166
|
+
size: "small",
|
|
167
|
+
placeholder: "All Sources",
|
|
168
|
+
children: [
|
|
169
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
170
|
+
value: "all",
|
|
171
|
+
children: "All Sources"
|
|
172
|
+
}),
|
|
173
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
174
|
+
value: "api",
|
|
175
|
+
children: "API"
|
|
176
|
+
}),
|
|
177
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
178
|
+
value: "auth",
|
|
179
|
+
children: "Auth"
|
|
180
|
+
}),
|
|
181
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
182
|
+
value: "storage",
|
|
183
|
+
children: "Storage"
|
|
184
|
+
}),
|
|
185
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
186
|
+
value: "realtime",
|
|
187
|
+
children: "Realtime"
|
|
188
|
+
}),
|
|
189
|
+
/* @__PURE__ */ jsx(SelectItem, {
|
|
190
|
+
value: "system",
|
|
191
|
+
children: "System"
|
|
192
|
+
})
|
|
193
|
+
]
|
|
194
|
+
}),
|
|
195
|
+
/* @__PURE__ */ jsx(TextField, {
|
|
196
|
+
size: "small",
|
|
197
|
+
placeholder: "Search logs...",
|
|
198
|
+
value: searchInput,
|
|
199
|
+
onChange: (e) => setSearchInput(e.target.value),
|
|
200
|
+
className: "flex-1 min-w-[200px]"
|
|
201
|
+
}),
|
|
202
|
+
/* @__PURE__ */ jsxs("div", {
|
|
203
|
+
className: "flex items-center gap-1.5 cursor-pointer ml-2",
|
|
204
|
+
children: [/* @__PURE__ */ jsx(Checkbox, {
|
|
205
|
+
id: "auto-scroll",
|
|
206
|
+
checked: autoScroll,
|
|
207
|
+
onCheckedChange: (checked) => {
|
|
208
|
+
setAutoScroll(checked);
|
|
209
|
+
if (checked) scrollToBottom();
|
|
210
|
+
},
|
|
211
|
+
size: "small",
|
|
212
|
+
padding: false
|
|
213
|
+
}), /* @__PURE__ */ jsx(Label, {
|
|
214
|
+
htmlFor: "auto-scroll",
|
|
215
|
+
className: "text-xs select-none cursor-pointer text-surface-600 dark:text-surface-400",
|
|
216
|
+
children: "Auto-scroll"
|
|
217
|
+
})]
|
|
218
|
+
}),
|
|
219
|
+
/* @__PURE__ */ jsx("div", {
|
|
220
|
+
className: "ml-auto pl-4",
|
|
221
|
+
children: /* @__PURE__ */ jsxs(Typography, {
|
|
222
|
+
variant: "caption",
|
|
223
|
+
color: "secondary",
|
|
224
|
+
children: [logs.length, " entries"]
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
]
|
|
228
|
+
}),
|
|
229
|
+
error && logs.length > 0 && /* @__PURE__ */ jsx("div", {
|
|
230
|
+
className: cls("px-4 py-1.5 border-b bg-amber-50 dark:bg-amber-950/20 shrink-0", defaultBorderMixin),
|
|
231
|
+
children: /* @__PURE__ */ jsx(Typography, {
|
|
232
|
+
variant: "caption",
|
|
233
|
+
className: "text-amber-700 dark:text-amber-400",
|
|
234
|
+
children: error
|
|
235
|
+
})
|
|
236
|
+
}),
|
|
237
|
+
/* @__PURE__ */ jsxs("div", {
|
|
238
|
+
className: "relative flex-1 min-h-0",
|
|
239
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
240
|
+
ref: containerRef,
|
|
241
|
+
onScroll: handleScroll,
|
|
242
|
+
className: "h-full overflow-auto py-2",
|
|
243
|
+
children: [logs.map((log) => /* @__PURE__ */ jsxs("div", {
|
|
244
|
+
className: cls("flex gap-4 px-4 py-[6px] border-b hover:bg-surface-100 dark:hover:bg-surface-900 transition-colors", defaultBorderMixin),
|
|
245
|
+
children: [
|
|
246
|
+
/* @__PURE__ */ jsx(Typography, {
|
|
247
|
+
variant: "body2",
|
|
248
|
+
color: "secondary",
|
|
249
|
+
className: "w-[72px] shrink-0 font-mono",
|
|
250
|
+
children: new Date(log.timestamp).toLocaleTimeString()
|
|
251
|
+
}),
|
|
252
|
+
/* @__PURE__ */ jsx(Typography, {
|
|
253
|
+
variant: "body2",
|
|
254
|
+
className: cls("w-[48px] shrink-0 uppercase font-semibold font-mono", LEVEL_COLORS[log.level] || "text-surface-500"),
|
|
255
|
+
children: log.level
|
|
256
|
+
}),
|
|
257
|
+
/* @__PURE__ */ jsxs(Typography, {
|
|
258
|
+
variant: "body2",
|
|
259
|
+
className: cls("w-[80px] shrink-0 font-mono", SOURCE_COLORS[log.source] || "text-surface-500"),
|
|
260
|
+
children: [
|
|
261
|
+
"[",
|
|
262
|
+
log.source,
|
|
263
|
+
"]"
|
|
264
|
+
]
|
|
265
|
+
}),
|
|
266
|
+
/* @__PURE__ */ jsx(Typography, {
|
|
267
|
+
variant: "body2",
|
|
268
|
+
className: "flex-1 font-mono break-all whitespace-pre-wrap text-surface-900 dark:text-surface-100",
|
|
269
|
+
children: log.message
|
|
270
|
+
})
|
|
271
|
+
]
|
|
272
|
+
}, log.id)), logs.length === 0 && /* @__PURE__ */ jsx("div", {
|
|
273
|
+
className: "p-8 text-center",
|
|
274
|
+
children: /* @__PURE__ */ jsx(Typography, {
|
|
275
|
+
variant: "body2",
|
|
276
|
+
className: error ? "text-red-600 dark:text-red-500" : void 0,
|
|
277
|
+
color: error ? void 0 : "secondary",
|
|
278
|
+
children: error ?? "No log entries yet. Logs will appear here as requests come in."
|
|
279
|
+
})
|
|
280
|
+
})]
|
|
281
|
+
}), autoScroll && !atBottom && newCount > 0 && /* @__PURE__ */ jsxs("button", {
|
|
282
|
+
onClick: scrollToBottom,
|
|
283
|
+
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",
|
|
284
|
+
children: [
|
|
285
|
+
/* @__PURE__ */ jsx(ArrowDownToLineIcon, { size: 14 }),
|
|
286
|
+
newCount,
|
|
287
|
+
" new ",
|
|
288
|
+
newCount === 1 ? "entry" : "entries"
|
|
289
|
+
]
|
|
290
|
+
})]
|
|
291
|
+
})
|
|
292
|
+
]
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
//#endregion
|
|
296
|
+
export { LogsExplorer };
|
|
297
|
+
|
|
298
|
+
//# sourceMappingURL=LogsExplorer-BCXoGREr.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LogsExplorer-BCXoGREr.js","names":[],"sources":["../src/components/LogsExplorer/LogsExplorer.tsx"],"sourcesContent":["import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { ArrowDownToLineIcon, Checkbox, cls, defaultBorderMixin, Label, Select, SelectItem, TextField, Typography } from \"@rebasepro/ui\";\nimport { useApiConfig } from \"@rebasepro/app\";\n\ninterface 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\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\n// Ids are `log_<n>` with a monotonic counter, so numeric comparison tells\n// old entries from new ones across polls.\nconst idNum = (entry: LogEntry): number => Number(entry.id.slice(\"log_\".length));\n\n// The window is contiguous and ordered, so identical ends mean identical content.\nconst 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// How close to the bottom edge still counts as \"at the bottom\".\nconst STICK_THRESHOLD = 40;\n\nexport function LogsExplorer() {\n const [logs, setLogs] = useState<LogEntry[]>([]);\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 const [error, setError] = useState<string | null>(null);\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 fetch loop both read it synchronously.\n const stickRef = useRef(true);\n const lastMaxIdRef = useRef<number | null>(null);\n const apiConfig = useApiConfig();\n\n // Debounce the search box so typing doesn't refetch per keystroke.\n useEffect(() => {\n const t = setTimeout(() => setSearch(searchInput), 300);\n return () => clearTimeout(t);\n }, [searchInput]);\n\n const fetchLogs = useCallback(async () => {\n if (!apiConfig?.apiUrl) {\n setError(\"No API URL configured — cannot load logs.\");\n return;\n }\n try {\n const params = new URLSearchParams();\n if (level && level !== \"all\") params.set(\"level\", level);\n if (source && source !== \"all\") params.set(\"source\", source);\n if (search) params.set(\"search\", search);\n params.set(\"limit\", \"200\");\n\n // Logs are admin-only, so the request must carry the auth token. The\n // URL is absolute: a relative one would resolve against the frontend\n // origin, which serves index.html rather than the API.\n const headers: Record<string, string> = {};\n const token = apiConfig.getAuthToken ? await apiConfig.getAuthToken() : null;\n if (token) headers[\"Authorization\"] = `Bearer ${token}`;\n\n const resp = await fetch(`${apiConfig.apiUrl}/api/logs?${params}`, { headers });\n if (!resp.ok) {\n setError(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 return;\n }\n const data: { entries?: LogEntry[] } = await resp.json();\n // The API returns newest-first; the view tails like a terminal, so\n // flip to chronological order (newest at the bottom).\n const entries = (data.entries || []).slice().reverse();\n\n const prevMax = lastMaxIdRef.current;\n if (prevMax != null && !stickRef.current) {\n const fresh = entries.filter(e => idNum(e) > prevMax).length;\n if (fresh > 0) setNewCount(c => c + fresh);\n }\n if (entries.length > 0) lastMaxIdRef.current = idNum(entries[entries.length - 1]);\n\n // Unchanged window → keep the previous array so React leaves the\n // DOM alone (hover states and text selection survive the poll).\n setLogs(prev => sameLogs(prev, entries) ? prev : entries);\n setError(null);\n } catch (e) {\n setError(e instanceof Error ? e.message : \"Could not load logs.\");\n }\n }, [level, source, search, apiConfig]);\n\n // A filter change replaces the window wholesale — \"new entries since last\n // poll\" stops meaning anything, so the counter starts over.\n useEffect(() => {\n lastMaxIdRef.current = null;\n setNewCount(0);\n }, [level, source, search]);\n\n useEffect(() => {\n let timeoutId: ReturnType<typeof setTimeout> | null = null;\n let cancelled = false;\n\n fetchLogs();\n\n const scheduleNext = () => {\n if (cancelled) return;\n timeoutId = setTimeout(async () => {\n if (document.visibilityState === \"visible\") {\n await fetchLogs();\n }\n scheduleNext();\n }, 3000);\n };\n\n scheduleNext();\n\n const handleVisibility = () => {\n if (document.visibilityState === \"visible\") {\n fetchLogs();\n }\n };\n document.addEventListener(\"visibilitychange\", handleVisibility);\n\n return () => {\n cancelled = true;\n if (timeoutId) clearTimeout(timeoutId);\n document.removeEventListener(\"visibilitychange\", handleVisibility);\n };\n }, [fetchLogs]);\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 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 <div className=\"ml-auto pl-4\">\n <Typography variant=\"caption\" color=\"secondary\">\n {logs.length} entries\n </Typography>\n </div>\n </div>\n\n {/* A failing poll 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 {/* 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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"}
|
package/dist/index.es.js
CHANGED
|
@@ -558,7 +558,7 @@ var SchemaVisualizer = lazy(() => import("./SchemaVisualizer-D01DzU-f.js").then(
|
|
|
558
558
|
var BranchesView = lazy(() => import("./BranchesView-DH0qnU9J.js").then((m) => ({ default: m.BranchesView })));
|
|
559
559
|
var BackupsView = lazy(() => import("./BackupsView-DNS6LdVg.js").then((m) => ({ default: m.BackupsView })));
|
|
560
560
|
var ApiExplorer = lazy(() => import("./ApiExplorer-B9M9-uy_.js").then((m) => ({ default: m.ApiExplorer })));
|
|
561
|
-
var LogsExplorer = lazy(() => import("./LogsExplorer-
|
|
561
|
+
var LogsExplorer = lazy(() => import("./LogsExplorer-BCXoGREr.js").then((m) => ({ default: m.LogsExplorer })));
|
|
562
562
|
var ApiKeysView = lazy(() => import("./ApiKeysView-Dbe5cXkS.js").then((m) => ({ default: m.ApiKeysView })));
|
|
563
563
|
/**
|
|
564
564
|
* Declarative component to configure the Studio in Rebase.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/studio",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.9.1-canary.
|
|
4
|
+
"version": "0.9.1-canary.e2fc7b6",
|
|
5
5
|
"main": "./dist/index.es.js",
|
|
6
6
|
"module": "./dist/index.es.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -15,19 +15,19 @@
|
|
|
15
15
|
"pgsql-ast-parser": "12.0.2",
|
|
16
16
|
"prism-react-renderer": "^2.4.1",
|
|
17
17
|
"react-dropzone": "^15.0.0",
|
|
18
|
-
"@rebasepro/client": "0.9.1-canary.
|
|
19
|
-
"@rebasepro/
|
|
20
|
-
"@rebasepro/
|
|
21
|
-
"@rebasepro/
|
|
22
|
-
"@rebasepro/
|
|
23
|
-
"@rebasepro/utils": "0.9.1-canary.
|
|
18
|
+
"@rebasepro/client": "0.9.1-canary.e2fc7b6",
|
|
19
|
+
"@rebasepro/common": "0.9.1-canary.e2fc7b6",
|
|
20
|
+
"@rebasepro/app": "0.9.1-canary.e2fc7b6",
|
|
21
|
+
"@rebasepro/ui": "0.9.1-canary.e2fc7b6",
|
|
22
|
+
"@rebasepro/types": "0.9.1-canary.e2fc7b6",
|
|
23
|
+
"@rebasepro/utils": "0.9.1-canary.e2fc7b6"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"react": ">=19.0.0",
|
|
27
27
|
"react-dom": ">=19.0.0",
|
|
28
28
|
"react-router": "^7.0.0",
|
|
29
29
|
"react-router-dom": "^7.0.0",
|
|
30
|
-
"@rebasepro/admin": "0.9.1-canary.
|
|
30
|
+
"@rebasepro/admin": "0.9.1-canary.e2fc7b6"
|
|
31
31
|
},
|
|
32
32
|
"peerDependenciesMeta": {
|
|
33
33
|
"@rebasepro/admin": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
import {
|
|
1
|
+
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { ArrowDownToLineIcon, Checkbox, cls, defaultBorderMixin, Label, Select, SelectItem, TextField, Typography } from "@rebasepro/ui";
|
|
3
3
|
import { useApiConfig } from "@rebasepro/app";
|
|
4
4
|
|
|
5
5
|
interface LogEntry {
|
|
@@ -26,16 +26,43 @@ const SOURCE_COLORS: Record<string, string> = {
|
|
|
26
26
|
system: "text-surface-600 dark:text-surface-400"
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
// Ids are `log_<n>` with a monotonic counter, so numeric comparison tells
|
|
30
|
+
// old entries from new ones across polls.
|
|
31
|
+
const idNum = (entry: LogEntry): number => Number(entry.id.slice("log_".length));
|
|
32
|
+
|
|
33
|
+
// The window is contiguous and ordered, so identical ends mean identical content.
|
|
34
|
+
const sameLogs = (a: LogEntry[], b: LogEntry[]): boolean =>
|
|
35
|
+
a.length === b.length &&
|
|
36
|
+
a[0]?.id === b[0]?.id &&
|
|
37
|
+
a[a.length - 1]?.id === b[b.length - 1]?.id;
|
|
38
|
+
|
|
39
|
+
// How close to the bottom edge still counts as "at the bottom".
|
|
40
|
+
const STICK_THRESHOLD = 40;
|
|
41
|
+
|
|
29
42
|
export function LogsExplorer() {
|
|
30
43
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
31
44
|
const [level, setLevel] = useState<string>("all");
|
|
32
45
|
const [source, setSource] = useState<string>("all");
|
|
46
|
+
const [searchInput, setSearchInput] = useState("");
|
|
33
47
|
const [search, setSearch] = useState("");
|
|
34
48
|
const [autoScroll, setAutoScroll] = useState(true);
|
|
35
49
|
const [error, setError] = useState<string | null>(null);
|
|
50
|
+
// Entries that arrived while the user was scrolled away from the bottom.
|
|
51
|
+
const [newCount, setNewCount] = useState(0);
|
|
52
|
+
const [atBottom, setAtBottom] = useState(true);
|
|
36
53
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
54
|
+
// Whether the view is stuck to the bottom right now. A ref, not state:
|
|
55
|
+
// the scroll handler and the fetch loop both read it synchronously.
|
|
56
|
+
const stickRef = useRef(true);
|
|
57
|
+
const lastMaxIdRef = useRef<number | null>(null);
|
|
37
58
|
const apiConfig = useApiConfig();
|
|
38
59
|
|
|
60
|
+
// Debounce the search box so typing doesn't refetch per keystroke.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const t = setTimeout(() => setSearch(searchInput), 300);
|
|
63
|
+
return () => clearTimeout(t);
|
|
64
|
+
}, [searchInput]);
|
|
65
|
+
|
|
39
66
|
const fetchLogs = useCallback(async () => {
|
|
40
67
|
if (!apiConfig?.apiUrl) {
|
|
41
68
|
setError("No API URL configured — cannot load logs.");
|
|
@@ -63,13 +90,33 @@ export function LogsExplorer() {
|
|
|
63
90
|
return;
|
|
64
91
|
}
|
|
65
92
|
const data: { entries?: LogEntry[] } = await resp.json();
|
|
66
|
-
|
|
93
|
+
// The API returns newest-first; the view tails like a terminal, so
|
|
94
|
+
// flip to chronological order (newest at the bottom).
|
|
95
|
+
const entries = (data.entries || []).slice().reverse();
|
|
96
|
+
|
|
97
|
+
const prevMax = lastMaxIdRef.current;
|
|
98
|
+
if (prevMax != null && !stickRef.current) {
|
|
99
|
+
const fresh = entries.filter(e => idNum(e) > prevMax).length;
|
|
100
|
+
if (fresh > 0) setNewCount(c => c + fresh);
|
|
101
|
+
}
|
|
102
|
+
if (entries.length > 0) lastMaxIdRef.current = idNum(entries[entries.length - 1]);
|
|
103
|
+
|
|
104
|
+
// Unchanged window → keep the previous array so React leaves the
|
|
105
|
+
// DOM alone (hover states and text selection survive the poll).
|
|
106
|
+
setLogs(prev => sameLogs(prev, entries) ? prev : entries);
|
|
67
107
|
setError(null);
|
|
68
108
|
} catch (e) {
|
|
69
109
|
setError(e instanceof Error ? e.message : "Could not load logs.");
|
|
70
110
|
}
|
|
71
111
|
}, [level, source, search, apiConfig]);
|
|
72
112
|
|
|
113
|
+
// A filter change replaces the window wholesale — "new entries since last
|
|
114
|
+
// poll" stops meaning anything, so the counter starts over.
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
lastMaxIdRef.current = null;
|
|
117
|
+
setNewCount(0);
|
|
118
|
+
}, [level, source, search]);
|
|
119
|
+
|
|
73
120
|
useEffect(() => {
|
|
74
121
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
75
122
|
let cancelled = false;
|
|
@@ -102,9 +149,31 @@ export function LogsExplorer() {
|
|
|
102
149
|
};
|
|
103
150
|
}, [fetchLogs]);
|
|
104
151
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
152
|
+
const scrollToBottom = useCallback(() => {
|
|
153
|
+
const el = containerRef.current;
|
|
154
|
+
if (!el) return;
|
|
155
|
+
el.scrollTop = el.scrollHeight;
|
|
156
|
+
stickRef.current = true;
|
|
157
|
+
setAtBottom(true);
|
|
158
|
+
setNewCount(0);
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
// Stick to the bottom only while the user is already there. Scrolling up
|
|
162
|
+
// disengages; scrolling back down re-engages. Our own scrollTop writes
|
|
163
|
+
// always land at the bottom, so they can never disengage it.
|
|
164
|
+
const handleScroll = useCallback(() => {
|
|
165
|
+
const el = containerRef.current;
|
|
166
|
+
if (!el) return;
|
|
167
|
+
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
|
|
168
|
+
stickRef.current = nearBottom;
|
|
169
|
+
setAtBottom(nearBottom);
|
|
170
|
+
if (nearBottom) setNewCount(0);
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
useLayoutEffect(() => {
|
|
174
|
+
if (autoScroll && stickRef.current) {
|
|
175
|
+
const el = containerRef.current;
|
|
176
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
108
177
|
}
|
|
109
178
|
}, [logs, autoScroll]);
|
|
110
179
|
|
|
@@ -143,15 +212,18 @@ export function LogsExplorer() {
|
|
|
143
212
|
<TextField
|
|
144
213
|
size="small"
|
|
145
214
|
placeholder="Search logs..."
|
|
146
|
-
value={
|
|
147
|
-
onChange={e =>
|
|
215
|
+
value={searchInput}
|
|
216
|
+
onChange={e => setSearchInput(e.target.value)}
|
|
148
217
|
className="flex-1 min-w-[200px]"
|
|
149
218
|
/>
|
|
150
219
|
<div className="flex items-center gap-1.5 cursor-pointer ml-2">
|
|
151
220
|
<Checkbox
|
|
152
221
|
id="auto-scroll"
|
|
153
222
|
checked={autoScroll}
|
|
154
|
-
onCheckedChange={
|
|
223
|
+
onCheckedChange={(checked: boolean) => {
|
|
224
|
+
setAutoScroll(checked);
|
|
225
|
+
if (checked) scrollToBottom();
|
|
226
|
+
}}
|
|
155
227
|
size="small"
|
|
156
228
|
padding={false}
|
|
157
229
|
/>
|
|
@@ -168,44 +240,70 @@ export function LogsExplorer() {
|
|
|
168
240
|
</Typography>
|
|
169
241
|
</div>
|
|
170
242
|
</div>
|
|
171
|
-
|
|
243
|
+
|
|
244
|
+
{/* A failing poll must stay visible even while stale logs are on
|
|
245
|
+
screen — otherwise the view quietly freezes. */}
|
|
246
|
+
{error && logs.length > 0 && (
|
|
247
|
+
<div className={cls(
|
|
248
|
+
"px-4 py-1.5 border-b bg-amber-50 dark:bg-amber-950/20 shrink-0",
|
|
249
|
+
defaultBorderMixin
|
|
250
|
+
)}>
|
|
251
|
+
<Typography variant="caption" className="text-amber-700 dark:text-amber-400">
|
|
252
|
+
{error}
|
|
253
|
+
</Typography>
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
|
|
172
257
|
{/* Log entries */}
|
|
173
|
-
<div
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
{new Date(log.timestamp).toLocaleTimeString()}
|
|
187
|
-
</Typography>
|
|
188
|
-
<Typography variant="body2" className={cls("w-[48px] shrink-0 uppercase font-semibold font-mono", LEVEL_COLORS[log.level] || "text-surface-500")}>
|
|
189
|
-
{log.level}
|
|
190
|
-
</Typography>
|
|
191
|
-
<Typography variant="body2" className={cls("w-[80px] shrink-0 font-mono", SOURCE_COLORS[log.source] || "text-surface-500")}>
|
|
192
|
-
[{log.source}]
|
|
193
|
-
</Typography>
|
|
194
|
-
<Typography variant="body2" className="flex-1 font-mono break-all whitespace-pre-wrap text-surface-900 dark:text-surface-100">
|
|
195
|
-
{log.message}
|
|
196
|
-
</Typography>
|
|
197
|
-
</div>
|
|
198
|
-
))}
|
|
199
|
-
{logs.length === 0 && (
|
|
200
|
-
<div className="p-8 text-center">
|
|
201
|
-
<Typography
|
|
202
|
-
variant="body2"
|
|
203
|
-
className={error ? "text-red-600 dark:text-red-500" : undefined}
|
|
204
|
-
color={error ? undefined : "secondary"}
|
|
258
|
+
<div className="relative flex-1 min-h-0">
|
|
259
|
+
<div
|
|
260
|
+
ref={containerRef}
|
|
261
|
+
onScroll={handleScroll}
|
|
262
|
+
className="h-full overflow-auto py-2"
|
|
263
|
+
>
|
|
264
|
+
{logs.map(log => (
|
|
265
|
+
<div
|
|
266
|
+
key={log.id}
|
|
267
|
+
className={cls(
|
|
268
|
+
"flex gap-4 px-4 py-[6px] border-b hover:bg-surface-100 dark:hover:bg-surface-900 transition-colors",
|
|
269
|
+
defaultBorderMixin
|
|
270
|
+
)}
|
|
205
271
|
>
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
272
|
+
<Typography variant="body2" color="secondary" className="w-[72px] shrink-0 font-mono">
|
|
273
|
+
{new Date(log.timestamp).toLocaleTimeString()}
|
|
274
|
+
</Typography>
|
|
275
|
+
<Typography variant="body2" className={cls("w-[48px] shrink-0 uppercase font-semibold font-mono", LEVEL_COLORS[log.level] || "text-surface-500")}>
|
|
276
|
+
{log.level}
|
|
277
|
+
</Typography>
|
|
278
|
+
<Typography variant="body2" className={cls("w-[80px] shrink-0 font-mono", SOURCE_COLORS[log.source] || "text-surface-500")}>
|
|
279
|
+
[{log.source}]
|
|
280
|
+
</Typography>
|
|
281
|
+
<Typography variant="body2" className="flex-1 font-mono break-all whitespace-pre-wrap text-surface-900 dark:text-surface-100">
|
|
282
|
+
{log.message}
|
|
283
|
+
</Typography>
|
|
284
|
+
</div>
|
|
285
|
+
))}
|
|
286
|
+
{logs.length === 0 && (
|
|
287
|
+
<div className="p-8 text-center">
|
|
288
|
+
<Typography
|
|
289
|
+
variant="body2"
|
|
290
|
+
className={error ? "text-red-600 dark:text-red-500" : undefined}
|
|
291
|
+
color={error ? undefined : "secondary"}
|
|
292
|
+
>
|
|
293
|
+
{error ?? "No log entries yet. Logs will appear here as requests come in."}
|
|
294
|
+
</Typography>
|
|
295
|
+
</div>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
{autoScroll && !atBottom && newCount > 0 && (
|
|
300
|
+
<button
|
|
301
|
+
onClick={scrollToBottom}
|
|
302
|
+
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"
|
|
303
|
+
>
|
|
304
|
+
<ArrowDownToLineIcon size={14}/>
|
|
305
|
+
{newCount} new {newCount === 1 ? "entry" : "entries"}
|
|
306
|
+
</button>
|
|
209
307
|
)}
|
|
210
308
|
</div>
|
|
211
309
|
</div>
|
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
import { useApiConfig } from "@rebasepro/app";
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
-
import { Checkbox, Label, Select, SelectItem, TextField, Typography, cls, defaultBorderMixin } from "@rebasepro/ui";
|
|
4
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
-
//#region src/components/LogsExplorer/LogsExplorer.tsx
|
|
6
|
-
var LEVEL_COLORS = {
|
|
7
|
-
debug: "text-surface-500",
|
|
8
|
-
info: "text-blue-600 dark:text-blue-500",
|
|
9
|
-
warn: "text-amber-600 dark:text-amber-500",
|
|
10
|
-
error: "text-red-600 dark:text-red-500"
|
|
11
|
-
};
|
|
12
|
-
var SOURCE_COLORS = {
|
|
13
|
-
api: "text-sky-600 dark:text-sky-400",
|
|
14
|
-
auth: "text-purple-600 dark:text-purple-400",
|
|
15
|
-
storage: "text-green-600 dark:text-green-500",
|
|
16
|
-
realtime: "text-orange-600 dark:text-orange-400",
|
|
17
|
-
system: "text-surface-600 dark:text-surface-400"
|
|
18
|
-
};
|
|
19
|
-
function LogsExplorer() {
|
|
20
|
-
const [logs, setLogs] = useState([]);
|
|
21
|
-
const [level, setLevel] = useState("all");
|
|
22
|
-
const [source, setSource] = useState("all");
|
|
23
|
-
const [search, setSearch] = useState("");
|
|
24
|
-
const [autoScroll, setAutoScroll] = useState(true);
|
|
25
|
-
const [error, setError] = useState(null);
|
|
26
|
-
const containerRef = useRef(null);
|
|
27
|
-
const apiConfig = useApiConfig();
|
|
28
|
-
const fetchLogs = useCallback(async () => {
|
|
29
|
-
if (!apiConfig?.apiUrl) {
|
|
30
|
-
setError("No API URL configured — cannot load logs.");
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
try {
|
|
34
|
-
const params = new URLSearchParams();
|
|
35
|
-
if (level && level !== "all") params.set("level", level);
|
|
36
|
-
if (source && source !== "all") params.set("source", source);
|
|
37
|
-
if (search) params.set("search", search);
|
|
38
|
-
params.set("limit", "200");
|
|
39
|
-
const headers = {};
|
|
40
|
-
const token = apiConfig.getAuthToken ? await apiConfig.getAuthToken() : null;
|
|
41
|
-
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
42
|
-
const resp = await fetch(`${apiConfig.apiUrl}/api/logs?${params}`, { headers });
|
|
43
|
-
if (!resp.ok) {
|
|
44
|
-
setError(resp.status === 401 || resp.status === 403 ? "Not authorised to read logs — an admin role is required." : `Could not load logs (HTTP ${resp.status}).`);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
setLogs((await resp.json()).entries || []);
|
|
48
|
-
setError(null);
|
|
49
|
-
} catch (e) {
|
|
50
|
-
setError(e instanceof Error ? e.message : "Could not load logs.");
|
|
51
|
-
}
|
|
52
|
-
}, [
|
|
53
|
-
level,
|
|
54
|
-
source,
|
|
55
|
-
search,
|
|
56
|
-
apiConfig
|
|
57
|
-
]);
|
|
58
|
-
useEffect(() => {
|
|
59
|
-
let timeoutId = null;
|
|
60
|
-
let cancelled = false;
|
|
61
|
-
fetchLogs();
|
|
62
|
-
const scheduleNext = () => {
|
|
63
|
-
if (cancelled) return;
|
|
64
|
-
timeoutId = setTimeout(async () => {
|
|
65
|
-
if (document.visibilityState === "visible") await fetchLogs();
|
|
66
|
-
scheduleNext();
|
|
67
|
-
}, 3e3);
|
|
68
|
-
};
|
|
69
|
-
scheduleNext();
|
|
70
|
-
const handleVisibility = () => {
|
|
71
|
-
if (document.visibilityState === "visible") fetchLogs();
|
|
72
|
-
};
|
|
73
|
-
document.addEventListener("visibilitychange", handleVisibility);
|
|
74
|
-
return () => {
|
|
75
|
-
cancelled = true;
|
|
76
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
77
|
-
document.removeEventListener("visibilitychange", handleVisibility);
|
|
78
|
-
};
|
|
79
|
-
}, [fetchLogs]);
|
|
80
|
-
useEffect(() => {
|
|
81
|
-
if (autoScroll && containerRef.current) containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
|
82
|
-
}, [logs, autoScroll]);
|
|
83
|
-
return /* @__PURE__ */ jsxs("div", {
|
|
84
|
-
className: "flex flex-col h-[calc(100vh-64px)] w-full bg-surface-50 dark:bg-surface-800",
|
|
85
|
-
children: [/* @__PURE__ */ jsxs("div", {
|
|
86
|
-
className: cls("flex gap-2 p-3 border-b items-center flex-wrap shrink-0", defaultBorderMixin),
|
|
87
|
-
children: [
|
|
88
|
-
/* @__PURE__ */ jsxs(Select, {
|
|
89
|
-
value: level,
|
|
90
|
-
onValueChange: setLevel,
|
|
91
|
-
size: "small",
|
|
92
|
-
placeholder: "All Levels",
|
|
93
|
-
children: [
|
|
94
|
-
/* @__PURE__ */ jsx(SelectItem, {
|
|
95
|
-
value: "all",
|
|
96
|
-
children: "All Levels"
|
|
97
|
-
}),
|
|
98
|
-
/* @__PURE__ */ jsx(SelectItem, {
|
|
99
|
-
value: "debug",
|
|
100
|
-
children: "Debug"
|
|
101
|
-
}),
|
|
102
|
-
/* @__PURE__ */ jsx(SelectItem, {
|
|
103
|
-
value: "info",
|
|
104
|
-
children: "Info"
|
|
105
|
-
}),
|
|
106
|
-
/* @__PURE__ */ jsx(SelectItem, {
|
|
107
|
-
value: "warn",
|
|
108
|
-
children: "Warn"
|
|
109
|
-
}),
|
|
110
|
-
/* @__PURE__ */ jsx(SelectItem, {
|
|
111
|
-
value: "error",
|
|
112
|
-
children: "Error"
|
|
113
|
-
})
|
|
114
|
-
]
|
|
115
|
-
}),
|
|
116
|
-
/* @__PURE__ */ jsxs(Select, {
|
|
117
|
-
value: source,
|
|
118
|
-
onValueChange: setSource,
|
|
119
|
-
size: "small",
|
|
120
|
-
placeholder: "All Sources",
|
|
121
|
-
children: [
|
|
122
|
-
/* @__PURE__ */ jsx(SelectItem, {
|
|
123
|
-
value: "all",
|
|
124
|
-
children: "All Sources"
|
|
125
|
-
}),
|
|
126
|
-
/* @__PURE__ */ jsx(SelectItem, {
|
|
127
|
-
value: "api",
|
|
128
|
-
children: "API"
|
|
129
|
-
}),
|
|
130
|
-
/* @__PURE__ */ jsx(SelectItem, {
|
|
131
|
-
value: "auth",
|
|
132
|
-
children: "Auth"
|
|
133
|
-
}),
|
|
134
|
-
/* @__PURE__ */ jsx(SelectItem, {
|
|
135
|
-
value: "storage",
|
|
136
|
-
children: "Storage"
|
|
137
|
-
}),
|
|
138
|
-
/* @__PURE__ */ jsx(SelectItem, {
|
|
139
|
-
value: "realtime",
|
|
140
|
-
children: "Realtime"
|
|
141
|
-
}),
|
|
142
|
-
/* @__PURE__ */ jsx(SelectItem, {
|
|
143
|
-
value: "system",
|
|
144
|
-
children: "System"
|
|
145
|
-
})
|
|
146
|
-
]
|
|
147
|
-
}),
|
|
148
|
-
/* @__PURE__ */ jsx(TextField, {
|
|
149
|
-
size: "small",
|
|
150
|
-
placeholder: "Search logs...",
|
|
151
|
-
value: search,
|
|
152
|
-
onChange: (e) => setSearch(e.target.value),
|
|
153
|
-
className: "flex-1 min-w-[200px]"
|
|
154
|
-
}),
|
|
155
|
-
/* @__PURE__ */ jsxs("div", {
|
|
156
|
-
className: "flex items-center gap-1.5 cursor-pointer ml-2",
|
|
157
|
-
children: [/* @__PURE__ */ jsx(Checkbox, {
|
|
158
|
-
id: "auto-scroll",
|
|
159
|
-
checked: autoScroll,
|
|
160
|
-
onCheckedChange: setAutoScroll,
|
|
161
|
-
size: "small",
|
|
162
|
-
padding: false
|
|
163
|
-
}), /* @__PURE__ */ jsx(Label, {
|
|
164
|
-
htmlFor: "auto-scroll",
|
|
165
|
-
className: "text-xs select-none cursor-pointer text-surface-600 dark:text-surface-400",
|
|
166
|
-
children: "Auto-scroll"
|
|
167
|
-
})]
|
|
168
|
-
}),
|
|
169
|
-
/* @__PURE__ */ jsx("div", {
|
|
170
|
-
className: "ml-auto pl-4",
|
|
171
|
-
children: /* @__PURE__ */ jsxs(Typography, {
|
|
172
|
-
variant: "caption",
|
|
173
|
-
color: "secondary",
|
|
174
|
-
children: [logs.length, " entries"]
|
|
175
|
-
})
|
|
176
|
-
})
|
|
177
|
-
]
|
|
178
|
-
}), /* @__PURE__ */ jsxs("div", {
|
|
179
|
-
ref: containerRef,
|
|
180
|
-
className: "flex-1 overflow-auto py-2",
|
|
181
|
-
children: [logs.map((log) => /* @__PURE__ */ jsxs("div", {
|
|
182
|
-
className: cls("flex gap-4 px-4 py-[6px] border-b hover:bg-surface-100 dark:hover:bg-surface-900 transition-colors", defaultBorderMixin),
|
|
183
|
-
children: [
|
|
184
|
-
/* @__PURE__ */ jsx(Typography, {
|
|
185
|
-
variant: "body2",
|
|
186
|
-
color: "secondary",
|
|
187
|
-
className: "w-[72px] shrink-0 font-mono",
|
|
188
|
-
children: new Date(log.timestamp).toLocaleTimeString()
|
|
189
|
-
}),
|
|
190
|
-
/* @__PURE__ */ jsx(Typography, {
|
|
191
|
-
variant: "body2",
|
|
192
|
-
className: cls("w-[48px] shrink-0 uppercase font-semibold font-mono", LEVEL_COLORS[log.level] || "text-surface-500"),
|
|
193
|
-
children: log.level
|
|
194
|
-
}),
|
|
195
|
-
/* @__PURE__ */ jsxs(Typography, {
|
|
196
|
-
variant: "body2",
|
|
197
|
-
className: cls("w-[80px] shrink-0 font-mono", SOURCE_COLORS[log.source] || "text-surface-500"),
|
|
198
|
-
children: [
|
|
199
|
-
"[",
|
|
200
|
-
log.source,
|
|
201
|
-
"]"
|
|
202
|
-
]
|
|
203
|
-
}),
|
|
204
|
-
/* @__PURE__ */ jsx(Typography, {
|
|
205
|
-
variant: "body2",
|
|
206
|
-
className: "flex-1 font-mono break-all whitespace-pre-wrap text-surface-900 dark:text-surface-100",
|
|
207
|
-
children: log.message
|
|
208
|
-
})
|
|
209
|
-
]
|
|
210
|
-
}, log.id)), logs.length === 0 && /* @__PURE__ */ jsx("div", {
|
|
211
|
-
className: "p-8 text-center",
|
|
212
|
-
children: /* @__PURE__ */ jsx(Typography, {
|
|
213
|
-
variant: "body2",
|
|
214
|
-
className: error ? "text-red-600 dark:text-red-500" : void 0,
|
|
215
|
-
color: error ? void 0 : "secondary",
|
|
216
|
-
children: error ?? "No log entries yet. Logs will appear here as requests come in."
|
|
217
|
-
})
|
|
218
|
-
})]
|
|
219
|
-
})]
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
//#endregion
|
|
223
|
-
export { LogsExplorer };
|
|
224
|
-
|
|
225
|
-
//# sourceMappingURL=LogsExplorer-Bi0bxDRx.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"LogsExplorer-Bi0bxDRx.js","names":[],"sources":["../src/components/LogsExplorer/LogsExplorer.tsx"],"sourcesContent":["import React, { useState, useEffect, useRef, useCallback } from \"react\";\nimport { Select, SelectItem, TextField, Checkbox, Label, Typography, cls, defaultBorderMixin } from \"@rebasepro/ui\";\nimport { useApiConfig } from \"@rebasepro/app\";\n\ninterface 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\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\nexport function LogsExplorer() {\n const [logs, setLogs] = useState<LogEntry[]>([]);\n const [level, setLevel] = useState<string>(\"all\");\n const [source, setSource] = useState<string>(\"all\");\n const [search, setSearch] = useState(\"\");\n const [autoScroll, setAutoScroll] = useState(true);\n const [error, setError] = useState<string | null>(null);\n const containerRef = useRef<HTMLDivElement>(null);\n const apiConfig = useApiConfig();\n\n const fetchLogs = useCallback(async () => {\n if (!apiConfig?.apiUrl) {\n setError(\"No API URL configured — cannot load logs.\");\n return;\n }\n try {\n const params = new URLSearchParams();\n if (level && level !== \"all\") params.set(\"level\", level);\n if (source && source !== \"all\") params.set(\"source\", source);\n if (search) params.set(\"search\", search);\n params.set(\"limit\", \"200\");\n\n // Logs are admin-only, so the request must carry the auth token. The\n // URL is absolute: a relative one would resolve against the frontend\n // origin, which serves index.html rather than the API.\n const headers: Record<string, string> = {};\n const token = apiConfig.getAuthToken ? await apiConfig.getAuthToken() : null;\n if (token) headers[\"Authorization\"] = `Bearer ${token}`;\n\n const resp = await fetch(`${apiConfig.apiUrl}/api/logs?${params}`, { headers });\n if (!resp.ok) {\n setError(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 return;\n }\n const data: { entries?: LogEntry[] } = await resp.json();\n setLogs(data.entries || []);\n setError(null);\n } catch (e) {\n setError(e instanceof Error ? e.message : \"Could not load logs.\");\n }\n }, [level, source, search, apiConfig]);\n\n useEffect(() => {\n let timeoutId: ReturnType<typeof setTimeout> | null = null;\n let cancelled = false;\n\n fetchLogs();\n\n const scheduleNext = () => {\n if (cancelled) return;\n timeoutId = setTimeout(async () => {\n if (document.visibilityState === \"visible\") {\n await fetchLogs();\n }\n scheduleNext();\n }, 3000);\n };\n\n scheduleNext();\n\n const handleVisibility = () => {\n if (document.visibilityState === \"visible\") {\n fetchLogs();\n }\n };\n document.addEventListener(\"visibilitychange\", handleVisibility);\n\n return () => {\n cancelled = true;\n if (timeoutId) clearTimeout(timeoutId);\n document.removeEventListener(\"visibilitychange\", handleVisibility);\n };\n }, [fetchLogs]);\n\n useEffect(() => {\n if (autoScroll && containerRef.current) {\n containerRef.current.scrollTop = containerRef.current.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 placeholder=\"Search logs...\"\n value={search}\n onChange={e => setSearch(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={setAutoScroll}\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 <div className=\"ml-auto pl-4\">\n <Typography variant=\"caption\" color=\"secondary\">\n {logs.length} entries\n </Typography>\n </div>\n </div>\n \n {/* Log entries */}\n <div\n ref={containerRef}\n className=\"flex-1 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 </div>\n );\n}\n"],"mappings":"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"}
|