@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.
@@ -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-Bi0bxDRx.js").then((m) => ({ default: m.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.d198c11",
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.d198c11",
19
- "@rebasepro/app": "0.9.1-canary.d198c11",
20
- "@rebasepro/common": "0.9.1-canary.d198c11",
21
- "@rebasepro/types": "0.9.1-canary.d198c11",
22
- "@rebasepro/ui": "0.9.1-canary.d198c11",
23
- "@rebasepro/utils": "0.9.1-canary.d198c11"
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.d198c11"
30
+ "@rebasepro/admin": "0.9.1-canary.e2fc7b6"
31
31
  },
32
32
  "peerDependenciesMeta": {
33
33
  "@rebasepro/admin": {
@@ -1,5 +1,5 @@
1
- import React, { useState, useEffect, useRef, useCallback } from "react";
2
- import { Select, SelectItem, TextField, Checkbox, Label, Typography, cls, defaultBorderMixin } from "@rebasepro/ui";
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
- setLogs(data.entries || []);
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
- useEffect(() => {
106
- if (autoScroll && containerRef.current) {
107
- containerRef.current.scrollTop = containerRef.current.scrollHeight;
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={search}
147
- onChange={e => setSearch(e.target.value)}
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={setAutoScroll}
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
- ref={containerRef}
175
- className="flex-1 overflow-auto py-2"
176
- >
177
- {logs.map(log => (
178
- <div
179
- key={log.id}
180
- className={cls(
181
- "flex gap-4 px-4 py-[6px] border-b hover:bg-surface-100 dark:hover:bg-surface-900 transition-colors",
182
- defaultBorderMixin
183
- )}
184
- >
185
- <Typography variant="body2" color="secondary" className="w-[72px] shrink-0 font-mono">
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
- {error ?? "No log entries yet. Logs will appear here as requests come in."}
207
- </Typography>
208
- </div>
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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"}