@rebasepro/studio 0.17.3 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +0 -1
- package/README.md +12 -4
- package/dist/{ApiKeysView-DiCurTEU.js → ApiKeysView-EIvegrUk.js} +54 -39
- package/dist/ApiKeysView-EIvegrUk.js.map +1 -0
- package/dist/{BackupsView-DNS6LdVg.js → BackupsView-DVkKzaPR.js} +29 -31
- package/dist/BackupsView-DVkKzaPR.js.map +1 -0
- package/dist/{BranchesView-CM3B-ER3.js → BranchesView-Df8hQ8wE.js} +85 -24
- package/dist/BranchesView-Df8hQ8wE.js.map +1 -0
- package/dist/{CronJobsView-BHtJZeJZ.js → CronJobsView-CLOGjb-L.js} +32 -27
- package/dist/CronJobsView-CLOGjb-L.js.map +1 -0
- package/dist/{RLSEditor-CBX4uD66.js → RLSEditor-Sa1D34ny.js} +724 -615
- package/dist/RLSEditor-Sa1D34ny.js.map +1 -0
- package/dist/{SQLEditor-ByecwX8B.js → SQLEditor-BfNA8xM3.js} +9 -4
- package/dist/{SQLEditor-ByecwX8B.js.map → SQLEditor-BfNA8xM3.js.map} +1 -1
- package/dist/{SchemaVisualizer-D6tMZgeC.js → SchemaVisualizer-DTDMYL6o.js} +84 -110
- package/dist/SchemaVisualizer-DTDMYL6o.js.map +1 -0
- package/dist/{StorageView-BGDeC5GI.js → StorageView-Ed9Q_VlB.js} +5 -32
- package/dist/StorageView-Ed9Q_VlB.js.map +1 -0
- package/dist/components/RLSEditor/saveRules.d.ts +38 -0
- package/dist/components/RebaseStudio.d.ts +1 -1
- package/dist/components/SchemaVisualizer/schema-visualizer.utils.d.ts +5 -2
- package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +0 -4
- package/dist/components/load-failure-view.d.ts +17 -0
- package/dist/components/load-failure.d.ts +47 -0
- package/dist/index.es.js +56 -36
- package/dist/index.es.js.map +1 -1
- package/dist/load-failure-C6OMJSGC.js +31 -0
- package/dist/load-failure-C6OMJSGC.js.map +1 -0
- package/dist/load-failure-view-CBTB_h-h.js +48 -0
- package/dist/load-failure-view-CBTB_h-h.js.map +1 -0
- package/package.json +36 -21
- package/dist/ApiKeysView-DiCurTEU.js.map +0 -1
- package/dist/BackupsView-DNS6LdVg.js.map +0 -1
- package/dist/BranchesView-CM3B-ER3.js.map +0 -1
- package/dist/CronJobsView-BHtJZeJZ.js.map +0 -1
- package/dist/RLSEditor-CBX4uD66.js.map +0 -1
- package/dist/SchemaVisualizer-D6tMZgeC.js.map +0 -1
- package/dist/StorageView-BGDeC5GI.js.map +0 -1
- package/dist/components/StorageView/storage-failure.d.ts +0 -34
- package/src/components/ApiExplorer/ApiExplorer.tsx +0 -299
- package/src/components/ApiExplorer/EndpointDetail.tsx +0 -279
- package/src/components/ApiExplorer/TryItPanel.tsx +0 -499
- package/src/components/ApiExplorer/parseSpec.ts +0 -104
- package/src/components/ApiExplorer/types.ts +0 -84
- package/src/components/ApiKeys/ApiKeysView.tsx +0 -479
- package/src/components/ApiKeys/CreateApiKeyDialog.tsx +0 -711
- package/src/components/ApiKeys/permissions.ts +0 -145
- package/src/components/AuthSimulationSelector.tsx +0 -72
- package/src/components/Backups/BackupsView.tsx +0 -185
- package/src/components/Branches/BranchesView.tsx +0 -386
- package/src/components/CronJobs/CronJobsView.tsx +0 -413
- package/src/components/JSEditor/JSEditor.tsx +0 -1100
- package/src/components/JSEditor/JSEditorSidebar.tsx +0 -350
- package/src/components/JSEditor/JSMonacoEditor.tsx +0 -410
- package/src/components/LogsExplorer/LogsExplorer.tsx +0 -273
- package/src/components/LogsExplorer/useLogTail.ts +0 -356
- package/src/components/RLSEditor/PolicyEditor.tsx +0 -399
- package/src/components/RLSEditor/RLSEditor.tsx +0 -1128
- package/src/components/RLSEditor/index.ts +0 -1
- package/src/components/RLSEditor/policy-presets.ts +0 -176
- package/src/components/RLSEditor/saveSecurityRules.ts +0 -48
- package/src/components/RebaseStudio.tsx +0 -174
- package/src/components/SQLEditor/ExplainVisualizer.tsx +0 -128
- package/src/components/SQLEditor/MonacoEditor.tsx +0 -203
- package/src/components/SQLEditor/SQLEditor.tsx +0 -1518
- package/src/components/SQLEditor/SQLEditorSidebar.tsx +0 -173
- package/src/components/SQLEditor/SchemaBrowser.tsx +0 -168
- package/src/components/SQLEditor/sql_editor_types.ts +0 -18
- package/src/components/SchemaVisualizer/RelationEdge.tsx +0 -102
- package/src/components/SchemaVisualizer/SchemaVisualizer.tsx +0 -710
- package/src/components/SchemaVisualizer/TableNode.tsx +0 -257
- package/src/components/SchemaVisualizer/schema-visualizer.utils.ts +0 -140
- package/src/components/SchemaVisualizer/useLiveRls.ts +0 -86
- package/src/components/SchemaVisualizer/useSchemaGraph.ts +0 -426
- package/src/components/StorageView/StorageView.tsx +0 -1588
- package/src/components/StorageView/storage-failure.ts +0 -63
- package/src/components/StudioHomePage.tsx +0 -430
- package/src/index.ts +0 -31
- package/src/utils/parseSpec.test.ts +0 -309
- package/src/utils/sql_utils.test.ts +0 -265
- package/src/utils/sql_utils.ts +0 -291
|
@@ -1,356 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
import { useApiBase, useApiConfig } from "@rebasepro/app";
|
|
3
|
-
|
|
4
|
-
export interface LogEntry {
|
|
5
|
-
id: string;
|
|
6
|
-
timestamp: string;
|
|
7
|
-
level: "debug" | "info" | "warn" | "error";
|
|
8
|
-
source: "api" | "auth" | "storage" | "realtime" | "system";
|
|
9
|
-
message: string;
|
|
10
|
-
metadata?: Record<string, unknown>;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface LogTailFilters {
|
|
14
|
-
/** `"all"` and `undefined` both mean unfiltered. */
|
|
15
|
-
level?: string;
|
|
16
|
-
source?: string;
|
|
17
|
-
search?: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* How the view is currently being fed.
|
|
22
|
-
*
|
|
23
|
-
* Surfaced rather than kept private: "connecting" and "polling" look identical
|
|
24
|
-
* from the outside until something is wrong, and the first question about a log
|
|
25
|
-
* view that seems stuck is whether it is actually live.
|
|
26
|
-
*/
|
|
27
|
-
export type LogTransport = "connecting" | "live" | "polling";
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Entries kept in the browser.
|
|
31
|
-
*
|
|
32
|
-
* The server window is 200; a tail that never forgets would grow without bound
|
|
33
|
-
* on a busy backend, since nothing else here ever drops an entry.
|
|
34
|
-
*/
|
|
35
|
-
const MAX_ENTRIES = 1000;
|
|
36
|
-
|
|
37
|
-
/** Fallback cadence, used only against a server with no stream route. */
|
|
38
|
-
const POLL_MS = 3000;
|
|
39
|
-
|
|
40
|
-
const RETRY_BASE_MS = 1000;
|
|
41
|
-
const RETRY_MAX_MS = 15_000;
|
|
42
|
-
|
|
43
|
-
/** Ids are `log_<n>` off a monotonic counter, so numeric order is push order. */
|
|
44
|
-
export const idNum = (entry: LogEntry): number => Number(entry.id.slice("log_".length));
|
|
45
|
-
|
|
46
|
-
/** The window is contiguous and ordered, so identical ends mean identical content. */
|
|
47
|
-
export const sameLogs = (a: LogEntry[], b: LogEntry[]): boolean =>
|
|
48
|
-
a.length === b.length &&
|
|
49
|
-
a[0]?.id === b[0]?.id &&
|
|
50
|
-
a[a.length - 1]?.id === b[b.length - 1]?.id;
|
|
51
|
-
|
|
52
|
-
/** One identity for "nothing", so clearing an already-empty view is not a change. */
|
|
53
|
-
const EMPTY: LogEntry[] = [];
|
|
54
|
-
|
|
55
|
-
const capped = (entries: LogEntry[]): LogEntry[] =>
|
|
56
|
-
entries.length > MAX_ENTRIES ? entries.slice(entries.length - MAX_ENTRIES) : entries;
|
|
57
|
-
|
|
58
|
-
export interface SSEFrame {
|
|
59
|
-
event: string;
|
|
60
|
-
data: string;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Split an SSE byte stream into frames.
|
|
65
|
-
*
|
|
66
|
-
* `EventSource` would do this, and cannot be used: logs are admin-only, and
|
|
67
|
-
* `EventSource` has no way to send an `Authorization` header. So the stream is
|
|
68
|
-
* read off `fetch` and framed here.
|
|
69
|
-
*
|
|
70
|
-
* Exported for its own test — the parser is the part with edge cases (a frame
|
|
71
|
-
* split across two chunks, multi-line data, comment keepalives).
|
|
72
|
-
*/
|
|
73
|
-
export async function* readSSEFrames(
|
|
74
|
-
body: ReadableStream<Uint8Array>
|
|
75
|
-
): AsyncGenerator<SSEFrame> {
|
|
76
|
-
const reader = body.getReader();
|
|
77
|
-
const decoder = new TextDecoder();
|
|
78
|
-
let buffer = "";
|
|
79
|
-
|
|
80
|
-
while (true) {
|
|
81
|
-
const { done, value } = await reader.read();
|
|
82
|
-
if (done) break;
|
|
83
|
-
// `stream: true` keeps a multi-byte character split across two chunks
|
|
84
|
-
// from decoding as two replacement characters.
|
|
85
|
-
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
|
|
86
|
-
|
|
87
|
-
let boundary = buffer.indexOf("\n\n");
|
|
88
|
-
while (boundary !== -1) {
|
|
89
|
-
const raw = buffer.slice(0, boundary);
|
|
90
|
-
buffer = buffer.slice(boundary + 2);
|
|
91
|
-
const frame = parseFrame(raw);
|
|
92
|
-
if (frame) yield frame;
|
|
93
|
-
boundary = buffer.indexOf("\n\n");
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function parseFrame(raw: string): SSEFrame | null {
|
|
99
|
-
let event = "message";
|
|
100
|
-
const data: string[] = [];
|
|
101
|
-
for (const line of raw.split("\n")) {
|
|
102
|
-
// A line starting with ":" is a comment. The server sends them as
|
|
103
|
-
// keepalives, and they carry nothing.
|
|
104
|
-
if (!line || line.startsWith(":")) continue;
|
|
105
|
-
const colon = line.indexOf(":");
|
|
106
|
-
const field = colon === -1 ? line : line.slice(0, colon);
|
|
107
|
-
const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
108
|
-
if (field === "event") event = value;
|
|
109
|
-
else if (field === "data") data.push(value);
|
|
110
|
-
}
|
|
111
|
-
if (data.length === 0) return null;
|
|
112
|
-
return { event,
|
|
113
|
-
data: data.join("\n") };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const buildParams = (filters: LogTailFilters, limit: number): string => {
|
|
117
|
-
const params = new URLSearchParams();
|
|
118
|
-
if (filters.level && filters.level !== "all") params.set("level", filters.level);
|
|
119
|
-
if (filters.source && filters.source !== "all") params.set("source", filters.source);
|
|
120
|
-
if (filters.search) params.set("search", filters.search);
|
|
121
|
-
params.set("limit", String(limit));
|
|
122
|
-
return params.toString();
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Why the log is not being served, in the server's words where it has any.
|
|
127
|
-
*
|
|
128
|
-
* The admin gate answers 501 with a message naming the setting to change (a
|
|
129
|
-
* backend with no `jwtSecret` and no auth adapter cannot tell an admin from the
|
|
130
|
-
* internet, so it refuses rather than opens). Rendering "HTTP 501" over the top
|
|
131
|
-
* of that would throw away the only sentence that says what to do.
|
|
132
|
-
*/
|
|
133
|
-
async function failureMessage(resp: { status: number; json?: () => Promise<unknown> }): Promise<string> {
|
|
134
|
-
try {
|
|
135
|
-
const body = await resp.json?.() as { error?: { message?: string } } | undefined;
|
|
136
|
-
const message = body?.error?.message;
|
|
137
|
-
if (typeof message === "string" && message) return message;
|
|
138
|
-
} catch {
|
|
139
|
-
/* not every failure has a JSON body */
|
|
140
|
-
}
|
|
141
|
-
return resp.status === 401 || resp.status === 403
|
|
142
|
-
? "Not authorised to read logs — an admin role is required."
|
|
143
|
-
: `Could not load logs (HTTP ${resp.status}).`;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export interface LogTail {
|
|
147
|
-
logs: LogEntry[];
|
|
148
|
-
error: string | null;
|
|
149
|
-
transport: LogTransport;
|
|
150
|
-
/**
|
|
151
|
-
* Entries the server had to discard because this client was not draining the
|
|
152
|
-
* stream fast enough, since the connection opened.
|
|
153
|
-
*
|
|
154
|
-
* Surfaced rather than swallowed. The server bounds what it will hold per
|
|
155
|
-
* connection, so under a heavy enough burst there is a hole in the tail — and
|
|
156
|
-
* a tail with a hole in it that says so beats one that silently lies.
|
|
157
|
-
*/
|
|
158
|
-
dropped: number;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Tail the server's log buffer.
|
|
163
|
-
*
|
|
164
|
-
* Server-sent events, with the 3s poll kept only as a fallback: the studio and
|
|
165
|
-
* the server are versioned and deployed separately, so a frontend that knows
|
|
166
|
-
* about `/logs/stream` will meet servers that do not. A 404 there is not an
|
|
167
|
-
* error, it is an older backend, and it degrades to what that backend supports
|
|
168
|
-
* instead of showing an empty view.
|
|
169
|
-
*
|
|
170
|
-
* The stream sends its own backlog as a `snapshot` frame before any `append`, so
|
|
171
|
-
* a reconnect restores the window without a second request and without a gap
|
|
172
|
-
* where entries logged mid-handshake would belong to neither call.
|
|
173
|
-
*/
|
|
174
|
-
export function useLogTail(filters: LogTailFilters, limit = 200): LogTail {
|
|
175
|
-
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
176
|
-
const [error, setError] = useState<string | null>(null);
|
|
177
|
-
const [transport, setTransport] = useState<LogTransport>("connecting");
|
|
178
|
-
const [dropped, setDropped] = useState(0);
|
|
179
|
-
const apiConfig = useApiConfig();
|
|
180
|
-
const apiBase = useApiBase();
|
|
181
|
-
|
|
182
|
-
// Held in a ref, and deliberately not a dependency of the connection effect.
|
|
183
|
-
// `apiConfig` is an object: a provider that forgets to memoize it hands us a
|
|
184
|
-
// new identity per render, and as a dependency that is a reconnect per
|
|
185
|
-
// render — a tail that hammers the server and never stays up. `apiBase` is a
|
|
186
|
-
// string and compares by value, so it can be a dependency and covers the one
|
|
187
|
-
// thing that actually has to reopen the connection.
|
|
188
|
-
const getAuthToken = apiConfig?.getAuthToken;
|
|
189
|
-
const getAuthTokenRef = useRef(getAuthToken);
|
|
190
|
-
useEffect(() => {
|
|
191
|
-
getAuthTokenRef.current = getAuthToken;
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
const { level, source, search } = filters;
|
|
195
|
-
|
|
196
|
-
// A hidden tab is not watching. The connection is dropped rather than left
|
|
197
|
-
// to accumulate, and the reconnect's snapshot rebuilds the window — so this
|
|
198
|
-
// costs nothing but the entries nobody saw.
|
|
199
|
-
const [visible, setVisible] = useState(
|
|
200
|
-
typeof document === "undefined" || document.visibilityState === "visible"
|
|
201
|
-
);
|
|
202
|
-
useEffect(() => {
|
|
203
|
-
const onChange = () => setVisible(document.visibilityState === "visible");
|
|
204
|
-
document.addEventListener("visibilitychange", onChange);
|
|
205
|
-
return () => document.removeEventListener("visibilitychange", onChange);
|
|
206
|
-
}, []);
|
|
207
|
-
|
|
208
|
-
// A filter change replaces the window wholesale. Clearing here rather than in
|
|
209
|
-
// the connection effect means a reconnect (or a tab coming back) keeps what
|
|
210
|
-
// is on screen until the new snapshot lands.
|
|
211
|
-
//
|
|
212
|
-
// Joined on NUL, written as the escape: the search box takes free text, so any
|
|
213
|
-
// printable separator is a character a user can type, and two filters that
|
|
214
|
-
// differ only either side of it would collide into one key and skip the clear.
|
|
215
|
-
// As a raw byte it would make the whole file binary to grep, which reads as an
|
|
216
|
-
// empty search result rather than as an error.
|
|
217
|
-
const filterKey = `${level}\u0000${source}\u0000${search}`;
|
|
218
|
-
// `EMPTY` rather than a fresh `[]` so the run on mount, when there is nothing
|
|
219
|
-
// to clear, is not a state change and does not cost a render.
|
|
220
|
-
useEffect(() => {
|
|
221
|
-
setLogs(EMPTY);
|
|
222
|
-
}, [filterKey]);
|
|
223
|
-
|
|
224
|
-
useEffect(() => {
|
|
225
|
-
if (!apiBase) {
|
|
226
|
-
setError("No API URL configured — cannot load logs.");
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
if (!visible) return;
|
|
230
|
-
|
|
231
|
-
let cancelled = false;
|
|
232
|
-
let controller: AbortController | null = null;
|
|
233
|
-
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
234
|
-
let attempt = 0;
|
|
235
|
-
|
|
236
|
-
const query = buildParams({ level,
|
|
237
|
-
source,
|
|
238
|
-
search }, limit);
|
|
239
|
-
|
|
240
|
-
const authHeaders = async (): Promise<Record<string, string>> => {
|
|
241
|
-
const headers: Record<string, string> = {};
|
|
242
|
-
const token = await getAuthTokenRef.current?.() ?? null;
|
|
243
|
-
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
244
|
-
return headers;
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
const retryLater = () => {
|
|
248
|
-
if (cancelled) return;
|
|
249
|
-
const delay = Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_MS);
|
|
250
|
-
attempt++;
|
|
251
|
-
timer = setTimeout(connect, delay);
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
/** The fallback. Only ever reached against a server with no stream route. */
|
|
255
|
-
const poll = async () => {
|
|
256
|
-
if (cancelled) return;
|
|
257
|
-
try {
|
|
258
|
-
const resp = await fetch(`${apiBase}/logs?${query}`, { headers: await authHeaders() });
|
|
259
|
-
if (cancelled) return;
|
|
260
|
-
if (!resp.ok) {
|
|
261
|
-
setError(await failureMessage(resp));
|
|
262
|
-
} else {
|
|
263
|
-
const data: { entries?: LogEntry[] } = await resp.json();
|
|
264
|
-
// The query returns newest-first; the view tails like a
|
|
265
|
-
// terminal, so flip to chronological order.
|
|
266
|
-
const entries = (data.entries || []).slice().reverse();
|
|
267
|
-
setLogs(prev => sameLogs(prev, entries) ? prev : entries);
|
|
268
|
-
setError(null);
|
|
269
|
-
}
|
|
270
|
-
} catch (e) {
|
|
271
|
-
if (!cancelled) setError(e instanceof Error ? e.message : "Could not load logs.");
|
|
272
|
-
}
|
|
273
|
-
if (!cancelled) timer = setTimeout(poll, POLL_MS);
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
const startPolling = () => {
|
|
277
|
-
if (cancelled) return;
|
|
278
|
-
setTransport("polling");
|
|
279
|
-
poll();
|
|
280
|
-
};
|
|
281
|
-
|
|
282
|
-
const connect = async () => {
|
|
283
|
-
if (cancelled) return;
|
|
284
|
-
controller = new AbortController();
|
|
285
|
-
try {
|
|
286
|
-
const resp = await fetch(`${apiBase}/logs/stream?${query}`, {
|
|
287
|
-
headers: { ...await authHeaders(),
|
|
288
|
-
Accept: "text/event-stream" },
|
|
289
|
-
signal: controller.signal
|
|
290
|
-
});
|
|
291
|
-
if (cancelled) return;
|
|
292
|
-
|
|
293
|
-
// 404 is an older server, not a failure — it has the query route
|
|
294
|
-
// but not the stream. Same for a body-less response, which is a
|
|
295
|
-
// client with no streaming support at all.
|
|
296
|
-
if (resp.status === 404 || !resp.body) {
|
|
297
|
-
startPolling();
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
if (!resp.ok) {
|
|
301
|
-
setError(await failureMessage(resp));
|
|
302
|
-
retryLater();
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
attempt = 0;
|
|
307
|
-
setError(null);
|
|
308
|
-
setTransport("live");
|
|
309
|
-
// Per-connection: the server's counter resets with the socket, so
|
|
310
|
-
// a stale total would outlive the gap it described.
|
|
311
|
-
setDropped(0);
|
|
312
|
-
|
|
313
|
-
for await (const frame of readSSEFrames(resp.body)) {
|
|
314
|
-
if (cancelled) return;
|
|
315
|
-
if (frame.event === "snapshot") {
|
|
316
|
-
const { entries } = JSON.parse(frame.data) as { entries: LogEntry[] };
|
|
317
|
-
setLogs(prev => sameLogs(prev, entries) ? prev : capped(entries));
|
|
318
|
-
} else if (frame.event === "append") {
|
|
319
|
-
const parsed = JSON.parse(frame.data) as { entries: LogEntry[]; dropped?: number };
|
|
320
|
-
if (parsed.entries.length > 0) {
|
|
321
|
-
setLogs(prev => capped([...prev, ...parsed.entries]));
|
|
322
|
-
}
|
|
323
|
-
if (parsed.dropped) setDropped(n => n + parsed.dropped!);
|
|
324
|
-
} else if (frame.event === "error") {
|
|
325
|
-
setError(frame.data);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// The stream ended on its own — a restarted or redeployed
|
|
330
|
-
// server. Reconnecting is the whole point of the backoff.
|
|
331
|
-
if (!cancelled) {
|
|
332
|
-
setTransport("connecting");
|
|
333
|
-
retryLater();
|
|
334
|
-
}
|
|
335
|
-
} catch (e) {
|
|
336
|
-
if (cancelled || (e instanceof Error && e.name === "AbortError")) return;
|
|
337
|
-
setTransport("connecting");
|
|
338
|
-
setError(e instanceof Error ? e.message : "Could not load logs.");
|
|
339
|
-
retryLater();
|
|
340
|
-
}
|
|
341
|
-
};
|
|
342
|
-
|
|
343
|
-
connect();
|
|
344
|
-
|
|
345
|
-
return () => {
|
|
346
|
-
cancelled = true;
|
|
347
|
-
if (timer) clearTimeout(timer);
|
|
348
|
-
controller?.abort();
|
|
349
|
-
};
|
|
350
|
-
}, [level, source, search, limit, apiBase, visible]);
|
|
351
|
-
|
|
352
|
-
return { logs,
|
|
353
|
-
error,
|
|
354
|
-
transport,
|
|
355
|
-
dropped };
|
|
356
|
-
}
|