@rebasepro/studio 0.17.3 → 0.18.1

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.
Files changed (81) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +12 -4
  3. package/dist/{ApiKeysView-DiCurTEU.js → ApiKeysView-EIvegrUk.js} +54 -39
  4. package/dist/ApiKeysView-EIvegrUk.js.map +1 -0
  5. package/dist/{BackupsView-DNS6LdVg.js → BackupsView-DVkKzaPR.js} +29 -31
  6. package/dist/BackupsView-DVkKzaPR.js.map +1 -0
  7. package/dist/{BranchesView-CM3B-ER3.js → BranchesView-Df8hQ8wE.js} +85 -24
  8. package/dist/BranchesView-Df8hQ8wE.js.map +1 -0
  9. package/dist/{CronJobsView-BHtJZeJZ.js → CronJobsView-CLOGjb-L.js} +32 -27
  10. package/dist/CronJobsView-CLOGjb-L.js.map +1 -0
  11. package/dist/{RLSEditor-CBX4uD66.js → RLSEditor-Sa1D34ny.js} +724 -615
  12. package/dist/RLSEditor-Sa1D34ny.js.map +1 -0
  13. package/dist/{SQLEditor-ByecwX8B.js → SQLEditor-BfNA8xM3.js} +9 -4
  14. package/dist/{SQLEditor-ByecwX8B.js.map → SQLEditor-BfNA8xM3.js.map} +1 -1
  15. package/dist/{SchemaVisualizer-D6tMZgeC.js → SchemaVisualizer-DTDMYL6o.js} +84 -110
  16. package/dist/SchemaVisualizer-DTDMYL6o.js.map +1 -0
  17. package/dist/{StorageView-BGDeC5GI.js → StorageView-Ed9Q_VlB.js} +5 -32
  18. package/dist/StorageView-Ed9Q_VlB.js.map +1 -0
  19. package/dist/components/RLSEditor/saveRules.d.ts +38 -0
  20. package/dist/components/RebaseStudio.d.ts +1 -1
  21. package/dist/components/SchemaVisualizer/schema-visualizer.utils.d.ts +5 -2
  22. package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +0 -4
  23. package/dist/components/load-failure-view.d.ts +17 -0
  24. package/dist/components/load-failure.d.ts +47 -0
  25. package/dist/index.es.js +56 -36
  26. package/dist/index.es.js.map +1 -1
  27. package/dist/load-failure-C6OMJSGC.js +31 -0
  28. package/dist/load-failure-C6OMJSGC.js.map +1 -0
  29. package/dist/load-failure-view-CBTB_h-h.js +48 -0
  30. package/dist/load-failure-view-CBTB_h-h.js.map +1 -0
  31. package/package.json +36 -21
  32. package/dist/ApiKeysView-DiCurTEU.js.map +0 -1
  33. package/dist/BackupsView-DNS6LdVg.js.map +0 -1
  34. package/dist/BranchesView-CM3B-ER3.js.map +0 -1
  35. package/dist/CronJobsView-BHtJZeJZ.js.map +0 -1
  36. package/dist/RLSEditor-CBX4uD66.js.map +0 -1
  37. package/dist/SchemaVisualizer-D6tMZgeC.js.map +0 -1
  38. package/dist/StorageView-BGDeC5GI.js.map +0 -1
  39. package/dist/components/StorageView/storage-failure.d.ts +0 -34
  40. package/src/components/ApiExplorer/ApiExplorer.tsx +0 -299
  41. package/src/components/ApiExplorer/EndpointDetail.tsx +0 -279
  42. package/src/components/ApiExplorer/TryItPanel.tsx +0 -499
  43. package/src/components/ApiExplorer/parseSpec.ts +0 -104
  44. package/src/components/ApiExplorer/types.ts +0 -84
  45. package/src/components/ApiKeys/ApiKeysView.tsx +0 -479
  46. package/src/components/ApiKeys/CreateApiKeyDialog.tsx +0 -711
  47. package/src/components/ApiKeys/permissions.ts +0 -145
  48. package/src/components/AuthSimulationSelector.tsx +0 -72
  49. package/src/components/Backups/BackupsView.tsx +0 -185
  50. package/src/components/Branches/BranchesView.tsx +0 -386
  51. package/src/components/CronJobs/CronJobsView.tsx +0 -413
  52. package/src/components/JSEditor/JSEditor.tsx +0 -1100
  53. package/src/components/JSEditor/JSEditorSidebar.tsx +0 -350
  54. package/src/components/JSEditor/JSMonacoEditor.tsx +0 -410
  55. package/src/components/LogsExplorer/LogsExplorer.tsx +0 -273
  56. package/src/components/LogsExplorer/useLogTail.ts +0 -356
  57. package/src/components/RLSEditor/PolicyEditor.tsx +0 -399
  58. package/src/components/RLSEditor/RLSEditor.tsx +0 -1128
  59. package/src/components/RLSEditor/index.ts +0 -1
  60. package/src/components/RLSEditor/policy-presets.ts +0 -176
  61. package/src/components/RLSEditor/saveSecurityRules.ts +0 -48
  62. package/src/components/RebaseStudio.tsx +0 -174
  63. package/src/components/SQLEditor/ExplainVisualizer.tsx +0 -128
  64. package/src/components/SQLEditor/MonacoEditor.tsx +0 -203
  65. package/src/components/SQLEditor/SQLEditor.tsx +0 -1518
  66. package/src/components/SQLEditor/SQLEditorSidebar.tsx +0 -173
  67. package/src/components/SQLEditor/SchemaBrowser.tsx +0 -168
  68. package/src/components/SQLEditor/sql_editor_types.ts +0 -18
  69. package/src/components/SchemaVisualizer/RelationEdge.tsx +0 -102
  70. package/src/components/SchemaVisualizer/SchemaVisualizer.tsx +0 -710
  71. package/src/components/SchemaVisualizer/TableNode.tsx +0 -257
  72. package/src/components/SchemaVisualizer/schema-visualizer.utils.ts +0 -140
  73. package/src/components/SchemaVisualizer/useLiveRls.ts +0 -86
  74. package/src/components/SchemaVisualizer/useSchemaGraph.ts +0 -426
  75. package/src/components/StorageView/StorageView.tsx +0 -1588
  76. package/src/components/StorageView/storage-failure.ts +0 -63
  77. package/src/components/StudioHomePage.tsx +0 -430
  78. package/src/index.ts +0 -31
  79. package/src/utils/parseSpec.test.ts +0 -309
  80. package/src/utils/sql_utils.test.ts +0 -265
  81. package/src/utils/sql_utils.ts +0 -291
@@ -1,410 +0,0 @@
1
-
2
- import React, { useRef, useEffect } from "react";
3
- import Editor, { Monaco, OnMount } from "@monaco-editor/react";
4
- import type { editor } from "monaco-editor";
5
- import { cls, defaultBorderMixin, FileIcon } from "@rebasepro/ui";
6
- import { useModeController } from "@rebasepro/app";
7
- import { ALL_WHERE_FILTER_OPS } from "@rebasepro/types";
8
-
9
- /**
10
- * Shape of `monaco.languages.typescript` at runtime.
11
- * The bundled type stubs mark this API as deprecated, but it is fully functional.
12
- */
13
- interface MonacoTypeScriptApi {
14
- typescriptDefaults: {
15
- setCompilerOptions(options: Record<string, unknown>): void;
16
- setDiagnosticsOptions(options: Record<string, unknown>): void;
17
- addExtraLib(content: string, filePath: string): void;
18
- };
19
- ScriptTarget: Record<string, number>;
20
- ModuleKind: Record<string, number>;
21
- }
22
-
23
- /**
24
- * Ambient type definitions for the Rebase client SDK injected into Monaco.
25
- *
26
- * Hand-mirrored from `@rebasepro/client`, so it drifts: this block spent long
27
- * enough declaring the ten Firestore-era filter operators and a
28
- * `where?: Record<string, string>` that the editor was autocompleting a query
29
- * shape the server rejects. The operator union is interpolated from
30
- * {@link ALL_WHERE_FILTER_OPS} for that reason — it is the one part that cannot
31
- * fall behind. The rest still has to be kept in step by hand.
32
- */
33
- const REBASE_CLIENT_TYPES = `
34
- // ─── Rebase Client SDK Type Definitions ─────────────────────────────
35
-
36
- /** A flat database row with the id merged at the top level. */
37
-
38
- interface FindParams {
39
- limit?: number;
40
- offset?: number;
41
- page?: number;
42
- /** \`{ status: ["==", "active"] }\` — a tuple per field, or an array of tuples. */
43
- where?: Record<string, [WhereFilterOp, any] | [WhereFilterOp, any][]>;
44
- logical?: LogicalCondition;
45
- /** \`["created_at", "desc"]\` */
46
- orderBy?: [string, "asc" | "desc"];
47
- include?: string[];
48
- searchString?: string;
49
- }
50
-
51
- interface FindResult<M extends Record<string, any> = any> {
52
- data: M[];
53
- meta: {
54
- total: number;
55
- limit: number;
56
- offset: number;
57
- hasMore: boolean;
58
- };
59
- }
60
-
61
- type WhereFilterOp = ${ALL_WHERE_FILTER_OPS.map(op => `"${op}"`).join(" | ")};
62
-
63
- interface FilterCondition {
64
- column: string;
65
- operator: WhereFilterOp;
66
- value: any;
67
- }
68
-
69
- interface LogicalCondition {
70
- type: "and" | "or";
71
- conditions: (FilterCondition | LogicalCondition)[];
72
- }
73
-
74
- interface QueryBuilder<M extends Record<string, any> = any> {
75
- where(column: keyof M & string, operator: WhereFilterOp, value: any): QueryBuilder<M>;
76
- orderBy(column: keyof M & string, direction?: "asc" | "desc"): QueryBuilder<M>;
77
- limit(count: number): QueryBuilder<M>;
78
- offset(count: number): QueryBuilder<M>;
79
- search(searchString: string): QueryBuilder<M>;
80
- find(): Promise<FindResult<M>>;
81
- count(): Promise<number>;
82
- }
83
-
84
- interface CollectionClient<M extends Record<string, any> = any> {
85
- find(params?: FindParams): Promise<FindResult<M>>;
86
- findById(id: string | number): Promise<M | undefined>;
87
- create(data: Partial<M>, id?: string | number): Promise<M>;
88
- update(id: string | number, data: Partial<M>): Promise<M>;
89
- delete(id: string | number): Promise<void>;
90
- where(column: keyof M & string, operator: WhereFilterOp, value: any): QueryBuilder<M>;
91
- orderBy(column: keyof M & string, direction?: "asc" | "desc"): QueryBuilder<M>;
92
- limit(count: number): QueryBuilder<M>;
93
- offset(count: number): QueryBuilder<M>;
94
- search(searchString: string): QueryBuilder<M>;
95
- listen?(params: FindParams | undefined, onUpdate: (response: FindResult<M>) => void, onError?: (error: Error) => void): () => void;
96
- listenById?(id: string | number, onUpdate: (row: M | undefined) => void, onError?: (error: Error) => void): () => void;
97
- count?(params?: FindParams): Promise<number>;
98
- }
99
-
100
- interface User {
101
- uid: string;
102
- email: string | null;
103
- displayName: string | null;
104
- photoURL: string | null;
105
- providerId: string;
106
- isAnonymous: boolean;
107
- emailVerified?: boolean;
108
- roles?: string[];
109
- metadata?: Record<string, unknown>;
110
- }
111
-
112
- interface RebaseSession {
113
- accessToken: string;
114
- refreshToken: string;
115
- expiresAt: number;
116
- user: User;
117
- }
118
-
119
- type AuthChangeEvent = 'SIGNED_IN' | 'SIGNED_OUT' | 'TOKEN_REFRESHED' | 'USER_UPDATED';
120
-
121
- interface RebaseAuth {
122
- signInWithEmail(email: string, password: string): Promise<{ user: User; accessToken: string; refreshToken: string }>;
123
- signUp(email: string, password: string, displayName?: string): Promise<{ user: User; accessToken: string; refreshToken: string }>;
124
- signInWithGoogle(idToken: string): Promise<{ user: User; accessToken: string; refreshToken: string }>;
125
- signOut(): Promise<void>;
126
- refreshSession(): Promise<RebaseSession>;
127
- getUser(): Promise<User>;
128
- updateUser(updates: { displayName?: string; photoURL?: string }): Promise<User>;
129
- resetPasswordForEmail(email: string): Promise<{ success: boolean; message: string }>;
130
- resetPassword(token: string, password: string): Promise<{ success: boolean; message: string }>;
131
- changePassword(oldPassword: string, newPassword: string): Promise<{ success: boolean; message: string }>;
132
- sendVerificationEmail(): Promise<{ success: boolean; message: string }>;
133
- verifyEmail(token: string): Promise<{ success: boolean; message: string }>;
134
- getSessions(): Promise<RebaseSession[]>;
135
- revokeSession(sessionId: string): Promise<{ success: boolean }>;
136
- revokeAllSessions(): Promise<{ success: boolean }>;
137
- getSession(): RebaseSession | null;
138
- onAuthStateChange(callback: (event: AuthChangeEvent, session: RebaseSession | null) => void): () => void;
139
- }
140
-
141
- interface AdminUser {
142
- uid: string;
143
- email: string;
144
- displayName: string | null;
145
- photoURL: string | null;
146
- provider: string;
147
- roles: string[];
148
- createdAt: string;
149
- updatedAt: string;
150
- }
151
-
152
- interface RebaseCMS {
153
- listUsers(): Promise<{ users: AdminUser[] }>;
154
- getUser(userId: string): Promise<{ user: AdminUser }>;
155
- createUser(data: { email: string; displayName?: string; password?: string; roles?: string[] }): Promise<{ user: AdminUser }>;
156
- updateUser(userId: string, data: { email?: string; displayName?: string; password?: string; roles?: string[] }): Promise<{ user: AdminUser }>;
157
- deleteUser(userId: string): Promise<{ success: boolean }>;
158
- bootstrap(): Promise<{ success: boolean; message: string; user: { uid: string; roles: string[] } }>;
159
- }
160
-
161
- interface UploadFileProps {
162
- file: FileIcon;
163
- fileName?: string;
164
- path?: string;
165
- metadata?: Record<string, unknown>;
166
- bucket?: string;
167
- }
168
-
169
- interface UploadFileResult {
170
- path: string;
171
- bucket?: string;
172
- downloadUrl?: string;
173
- }
174
-
175
- interface DownloadConfig {
176
- url: string | null;
177
- fileNotFound?: boolean;
178
- metadata?: Record<string, unknown>;
179
- }
180
-
181
- interface StorageSource {
182
- putObject(props: UploadFileProps): Promise<UploadFileResult>;
183
- getSignedUrl(pathOrUrl: string, bucket?: string): Promise<DownloadConfig>;
184
- getObject(path: string, bucket?: string): Promise<FileIcon | null>;
185
- deleteObject(path: string, bucket?: string): Promise<void>;
186
- listObjects(path: string, options?: { bucket?: string; maxResults?: number; pageToken?: string }): Promise<unknown>;
187
- }
188
-
189
- type RebaseData = {
190
- /** Look up a collection by slug. */
191
- collection(slug: string): CollectionClient;
192
- } & {
193
- /** Dynamic collection access — e.g. client.data.authors */
194
- [collectionSlug: string]: CollectionClient;
195
- };
196
-
197
- /**
198
- * The Rebase client instance. Use \`client.data\`, \`client.auth\`, \`client.admin\`,
199
- * \`client.storage\`, and \`client.call()\` to interact with your Rebase backend.
200
- *
201
- * @example
202
- * // Query a collection
203
- * const result = await client.data.products.find({ limit: 10 });
204
- *
205
- * // Create a record
206
- * await client.data.products.create({ name: "Camera", price: 299 });
207
- *
208
- * // Fluent query
209
- * const expensive = await client.data.products.where("price", ">", 100).orderBy("price", "desc").limit(5).find();
210
- *
211
- * // Auth
212
- * const session = client.auth.getSession();
213
- *
214
- * // Admin
215
- * const { users } = await client.admin.listUsers();
216
- *
217
- * // Custom endpoint
218
- * const result = await client.call("/my-endpoint", { myData: 123 });
219
- */
220
- interface RebaseClient {
221
- /** Data access — dynamic collection accessors */
222
- data: RebaseData;
223
- /** Authentication methods */
224
- auth: RebaseAuth;
225
- /** User/role admin methods */
226
- admin: RebaseCMS;
227
- /** Storage operations */
228
- storage?: StorageSource;
229
- /** Call a custom server-side endpoint */
230
- call<T = unknown>(endpoint: string, payload?: unknown): Promise<T>;
231
- /** Direct collection access (shorthand) */
232
- [collectionSlug: string]: unknown;
233
- }
234
-
235
- /** The pre-configured client instance. Already authenticated with the current user session. */
236
- declare const client: RebaseClient;
237
-
238
- /** Execution context with user and collection information. */
239
- interface JSEditorContext {
240
- /** The user the script is running as. */
241
- user: {
242
- uid: string;
243
- displayName: string | null;
244
- email: string | null;
245
- roles?: string[];
246
- } | null;
247
- /** Registered collections with their property names. */
248
- collections: Array<{
249
- slug: string;
250
- name: string;
251
- properties: string[];
252
- }>;
253
- }
254
-
255
- /** Execution context — contains info about the selected user and registered collections. */
256
- declare const context: JSEditorContext;
257
- `;
258
-
259
- export type CollectionInfo = {
260
- slug: string;
261
- name: string;
262
- properties: string[];
263
- };
264
-
265
- export type JSMonacoEditorProps = {
266
- value: string;
267
- onChange: (value: string | undefined) => void;
268
- onRun?: (selectedText?: string) => void;
269
- className?: string;
270
- readOnly?: boolean;
271
- autoFocus?: boolean;
272
- /** Collection slugs for basic autocomplete */
273
- collectionSlugs?: string[];
274
- /** Full collection info with property names for richer type generation */
275
- collections?: CollectionInfo[];
276
- };
277
-
278
- export const JSMonacoEditor = ({
279
- value,
280
- onChange,
281
- onRun,
282
- className,
283
- readOnly = false,
284
- autoFocus = true,
285
- collectionSlugs = [],
286
- collections = []
287
- }: JSMonacoEditorProps) => {
288
- const { mode } = useModeController();
289
- const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
290
- const monacoRef = useRef<Monaco | null>(null);
291
- const onRunRef = useRef(onRun);
292
- onRunRef.current = onRun;
293
- const typesRegisteredRef = useRef(false);
294
-
295
- const handleEditorOnMount: OnMount = (editorInstance, monaco) => {
296
- editorRef.current = editorInstance;
297
- monacoRef.current = monaco;
298
-
299
- // Register Cmd/Ctrl+Enter to run
300
- editorInstance.addAction({
301
- id: "run-script",
302
- label: "Run Script",
303
- keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter],
304
- contextMenuGroupId: "operation",
305
- contextMenuOrder: 0,
306
- run: () => {
307
- if (onRunRef.current) {
308
- const selection = editorInstance.getSelection();
309
- let selectedText: string | undefined;
310
- if (selection && !selection.isEmpty()) {
311
- selectedText = editorInstance.getModel()?.getValueInRange(selection)?.trim();
312
- }
313
- onRunRef.current(selectedText || undefined);
314
- }
315
- }
316
- });
317
-
318
- // Configure TypeScript/JavaScript defaults for richer completion
319
- if (!typesRegisteredRef.current) {
320
- typesRegisteredRef.current = true;
321
-
322
- // Configure TS compiler for modern JS with top-level await
323
- // Note: cast through `any` because the bundled type stubs mark
324
- // `monaco.languages.typescript` as deprecated while the runtime
325
- // API is fully functional.
326
- const ts = (monaco.languages as unknown as { typescript: MonacoTypeScriptApi }).typescript;
327
- ts.typescriptDefaults.setCompilerOptions({
328
- target: ts.ScriptTarget.ESNext,
329
- module: ts.ModuleKind.ESNext,
330
- allowJs: true,
331
- checkJs: false,
332
- strict: false,
333
- noEmit: true,
334
- allowNonTsExtensions: true
335
- });
336
-
337
- // Suppress diagnostics that don't apply to our script context.
338
- // Scripts are executed inside `new AsyncFunction(...)` at runtime,
339
- // so top-level `await` (1375) and `return` (1108) are valid.
340
- ts.typescriptDefaults.setDiagnosticsOptions({
341
- diagnosticCodesToIgnore: [1375, 1108]
342
- });
343
-
344
- // Inject the Rebase Client SDK type definitions
345
- ts.typescriptDefaults.addExtraLib(
346
- REBASE_CLIENT_TYPES,
347
- "ts:rebase-client.d.ts"
348
- );
349
-
350
- // Generate collection-specific types from registered collections
351
- if (collections.length > 0) {
352
- const lines: string[] = [];
353
- for (const col of collections) {
354
- // Generate a typed interface for each collection
355
- const ifaceName = col.slug.replace(/[^a-zA-Z0-9_]/g, "_");
356
- const propsType = col.properties.length > 0
357
- ? `{ ${col.properties.map(p => `${p}: any`).join("; ")} }`
358
- : "Record<string, any>";
359
- lines.push(`/** Collection: ${col.name} (${col.slug}) */`);
360
- lines.push(`interface ${ifaceName}_Row ${propsType}`);
361
- lines.push(`declare namespace client.data { const ${col.slug}: CollectionClient<${ifaceName}_Row>; }`);
362
- }
363
- ts.typescriptDefaults.addExtraLib(
364
- lines.join("\n"),
365
- "ts:rebase-collections.d.ts"
366
- );
367
- } else if (collectionSlugs.length > 0) {
368
- // Fallback: just slug names without property info
369
- const collectionHints = collectionSlugs.map(slug =>
370
- `/** Collection: ${slug} */\ndeclare namespace client.data { const ${slug}: CollectionClient; }`
371
- ).join("\n");
372
- ts.typescriptDefaults.addExtraLib(
373
- collectionHints,
374
- "ts:rebase-collections.d.ts"
375
- );
376
- }
377
- }
378
-
379
- if (autoFocus) {
380
- editorInstance.focus();
381
- }
382
- };
383
-
384
- return (
385
- <div className={cls("relative w-full h-full overflow-hidden", className)}>
386
- <Editor
387
- height="100%"
388
- defaultLanguage="typescript"
389
- path="rebase-script.ts"
390
- value={value}
391
- onChange={onChange}
392
- onMount={handleEditorOnMount}
393
- theme={mode === "dark" ? "vs-dark" : "vs"}
394
- options={{
395
- minimap: { enabled: false },
396
- fontSize: 14,
397
- lineNumbers: "on",
398
- scrollBeyondLastLine: false,
399
- automaticLayout: true,
400
- readOnly,
401
- tabSize: 2,
402
- wordWrap: "on",
403
- suggestOnTriggerCharacters: true,
404
- quickSuggestions: true,
405
- parameterHints: { enabled: true }
406
- }}
407
- />
408
- </div>
409
- );
410
- };
@@ -1,273 +0,0 @@
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
- import { idNum, useLogTail } from "./useLogTail";
4
- import type { LogTransport } from "./useLogTail";
5
-
6
- const LEVEL_COLORS: Record<string, string> = {
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
-
13
- const SOURCE_COLORS: Record<string, string> = {
14
- api: "text-sky-600 dark:text-sky-400",
15
- auth: "text-purple-600 dark:text-purple-400",
16
- storage: "text-green-600 dark:text-green-500",
17
- realtime: "text-orange-600 dark:text-orange-400",
18
- system: "text-surface-600 dark:text-surface-400"
19
- };
20
-
21
- const TRANSPORT_LABELS: Record<LogTransport, string> = {
22
- connecting: "Connecting",
23
- live: "Live",
24
- polling: "Polling"
25
- };
26
-
27
- const TRANSPORT_DOTS: Record<LogTransport, string> = {
28
- connecting: "bg-amber-500",
29
- live: "bg-green-500",
30
- polling: "bg-surface-400"
31
- };
32
-
33
- const TRANSPORT_TITLES: Record<LogTransport, string> = {
34
- connecting: "Reconnecting to the log stream.",
35
- live: "Streaming — entries appear as the server writes them.",
36
- polling: "This server has no log stream; falling back to a refresh every 3 seconds."
37
- };
38
-
39
- // How close to the bottom edge still counts as "at the bottom".
40
- const STICK_THRESHOLD = 40;
41
-
42
- export function LogsExplorer() {
43
- const [level, setLevel] = useState<string>("all");
44
- const [source, setSource] = useState<string>("all");
45
- const [searchInput, setSearchInput] = useState("");
46
- const [search, setSearch] = useState("");
47
- const [autoScroll, setAutoScroll] = useState(true);
48
- // Entries that arrived while the user was scrolled away from the bottom.
49
- const [newCount, setNewCount] = useState(0);
50
- const [atBottom, setAtBottom] = useState(true);
51
- const containerRef = useRef<HTMLDivElement>(null);
52
- // Whether the view is stuck to the bottom right now. A ref, not state:
53
- // the scroll handler and the entry-counting effect both read it synchronously.
54
- const stickRef = useRef(true);
55
- const lastMaxIdRef = useRef<number | null>(null);
56
-
57
- // Debounce the search box so typing doesn't reopen the stream per keystroke.
58
- useEffect(() => {
59
- const t = setTimeout(() => setSearch(searchInput), 300);
60
- return () => clearTimeout(t);
61
- }, [searchInput]);
62
-
63
- const { logs, error, transport, dropped } = useLogTail({ level,
64
- source,
65
- search });
66
-
67
- // A filter change replaces the window wholesale — "new since you looked
68
- // away" stops meaning anything, so the counter starts over.
69
- useEffect(() => {
70
- setNewCount(0);
71
- }, [level, source, search]);
72
-
73
- // Count what arrived while the user was reading further up. Driven off the
74
- // rendered window rather than off the transport, so it means the same thing
75
- // whether the entries were streamed or polled.
76
- useEffect(() => {
77
- const max = logs.length > 0 ? idNum(logs[logs.length - 1]) : null;
78
- const prevMax = lastMaxIdRef.current;
79
- if (prevMax != null && !stickRef.current) {
80
- const fresh = logs.filter(e => idNum(e) > prevMax).length;
81
- if (fresh > 0) setNewCount(c => c + fresh);
82
- }
83
- lastMaxIdRef.current = max;
84
- }, [logs]);
85
-
86
- const scrollToBottom = useCallback(() => {
87
- const el = containerRef.current;
88
- if (!el) return;
89
- el.scrollTop = el.scrollHeight;
90
- stickRef.current = true;
91
- setAtBottom(true);
92
- setNewCount(0);
93
- }, []);
94
-
95
- // Stick to the bottom only while the user is already there. Scrolling up
96
- // disengages; scrolling back down re-engages. Our own scrollTop writes
97
- // always land at the bottom, so they can never disengage it.
98
- const handleScroll = useCallback(() => {
99
- const el = containerRef.current;
100
- if (!el) return;
101
- const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
102
- stickRef.current = nearBottom;
103
- setAtBottom(nearBottom);
104
- if (nearBottom) setNewCount(0);
105
- }, []);
106
-
107
- useLayoutEffect(() => {
108
- if (autoScroll && stickRef.current) {
109
- const el = containerRef.current;
110
- if (el) el.scrollTop = el.scrollHeight;
111
- }
112
- }, [logs, autoScroll]);
113
-
114
- return (
115
- <div className="flex flex-col h-[calc(100vh-64px)] w-full bg-surface-50 dark:bg-surface-800">
116
- {/* Toolbar */}
117
- <div className={cls(
118
- "flex gap-2 p-3 border-b items-center flex-wrap shrink-0",
119
- defaultBorderMixin
120
- )}>
121
- <Select
122
- value={level}
123
- onValueChange={setLevel}
124
- size="small"
125
- placeholder="All Levels"
126
- >
127
- <SelectItem value="all">All Levels</SelectItem>
128
- <SelectItem value="debug">Debug</SelectItem>
129
- <SelectItem value="info">Info</SelectItem>
130
- <SelectItem value="warn">Warn</SelectItem>
131
- <SelectItem value="error">Error</SelectItem>
132
- </Select>
133
- <Select
134
- value={source}
135
- onValueChange={setSource}
136
- size="small"
137
- placeholder="All Sources"
138
- >
139
- <SelectItem value="all">All Sources</SelectItem>
140
- <SelectItem value="api">API</SelectItem>
141
- <SelectItem value="auth">Auth</SelectItem>
142
- <SelectItem value="storage">Storage</SelectItem>
143
- <SelectItem value="realtime">Realtime</SelectItem>
144
- <SelectItem value="system">System</SelectItem>
145
- </Select>
146
- <TextField
147
- size="small"
148
- aria-label="Search logs"
149
- placeholder="Search logs..."
150
- value={searchInput}
151
- onChange={e => setSearchInput(e.target.value)}
152
- className="flex-1 min-w-[200px]"
153
- />
154
- <div className="flex items-center gap-1.5 cursor-pointer ml-2">
155
- <Checkbox
156
- id="auto-scroll"
157
- checked={autoScroll}
158
- onCheckedChange={(checked: boolean) => {
159
- setAutoScroll(checked);
160
- if (checked) scrollToBottom();
161
- }}
162
- size="small"
163
- padding={false}
164
- />
165
- <Label
166
- htmlFor="auto-scroll"
167
- className="text-xs select-none cursor-pointer text-surface-600 dark:text-surface-400"
168
- >
169
- Auto-scroll
170
- </Label>
171
- </div>
172
- {/* Whether the tail is actually attached. A log view with
173
- nothing in it is ambiguous — quiet server, or a stream that
174
- dropped — and this is the difference. */}
175
- <div className="ml-auto pl-4 flex items-center gap-3">
176
- <div className="flex items-center gap-1.5" title={TRANSPORT_TITLES[transport]}>
177
- <span
178
- aria-hidden
179
- className={cls("w-1.5 h-1.5 rounded-full shrink-0", TRANSPORT_DOTS[transport])}
180
- />
181
- <Typography variant="caption" color="secondary">
182
- {TRANSPORT_LABELS[transport]}
183
- </Typography>
184
- </div>
185
- <Typography variant="caption" color="secondary">
186
- {logs.length} entries
187
- </Typography>
188
- </div>
189
- </div>
190
-
191
- {/* A broken tail must stay visible even while stale logs are on
192
- screen — otherwise the view quietly freezes. */}
193
- {error && logs.length > 0 && (
194
- <div className={cls(
195
- "px-4 py-1.5 border-b bg-amber-50 dark:bg-amber-950/20 shrink-0",
196
- defaultBorderMixin
197
- )}>
198
- <Typography variant="caption" className="text-amber-700 dark:text-amber-400">
199
- {error}
200
- </Typography>
201
- </div>
202
- )}
203
-
204
- {/* The server bounds what it holds per connection, so a burst big
205
- enough leaves a hole in what is on screen. Say so: a tail that
206
- silently skips entries is worse than one that admits it. */}
207
- {dropped > 0 && (
208
- <div className={cls(
209
- "px-4 py-1.5 border-b bg-amber-50 dark:bg-amber-950/20 shrink-0",
210
- defaultBorderMixin
211
- )}>
212
- <Typography variant="caption" className="text-amber-700 dark:text-amber-400">
213
- {dropped} {dropped === 1 ? "entry was" : "entries were"} dropped — the log is
214
- arriving faster than this view can read it. Narrow the filter to keep up.
215
- </Typography>
216
- </div>
217
- )}
218
-
219
- {/* Log entries */}
220
- <div className="relative flex-1 min-h-0">
221
- <div
222
- ref={containerRef}
223
- onScroll={handleScroll}
224
- className="h-full overflow-auto py-2"
225
- >
226
- {logs.map(log => (
227
- <div
228
- key={log.id}
229
- className={cls(
230
- "flex gap-4 px-4 py-[6px] border-b hover:bg-surface-100 dark:hover:bg-surface-900 transition-colors",
231
- defaultBorderMixin
232
- )}
233
- >
234
- <Typography variant="body2" color="secondary" className="w-[72px] shrink-0 font-mono">
235
- {new Date(log.timestamp).toLocaleTimeString()}
236
- </Typography>
237
- <Typography variant="body2" className={cls("w-[48px] shrink-0 uppercase font-semibold font-mono", LEVEL_COLORS[log.level] || "text-surface-500")}>
238
- {log.level}
239
- </Typography>
240
- <Typography variant="body2" className={cls("w-[80px] shrink-0 font-mono", SOURCE_COLORS[log.source] || "text-surface-500")}>
241
- [{log.source}]
242
- </Typography>
243
- <Typography variant="body2" className="flex-1 font-mono break-all whitespace-pre-wrap text-surface-900 dark:text-surface-100">
244
- {log.message}
245
- </Typography>
246
- </div>
247
- ))}
248
- {logs.length === 0 && (
249
- <div className="p-8 text-center">
250
- <Typography
251
- variant="body2"
252
- className={error ? "text-red-600 dark:text-red-500" : undefined}
253
- color={error ? undefined : "secondary"}
254
- >
255
- {error ?? "No log entries yet. Logs will appear here as requests come in."}
256
- </Typography>
257
- </div>
258
- )}
259
- </div>
260
-
261
- {autoScroll && !atBottom && newCount > 0 && (
262
- <button
263
- onClick={scrollToBottom}
264
- 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"
265
- >
266
- <ArrowDownToLineIcon size={14}/>
267
- {newCount} new {newCount === 1 ? "entry" : "entries"}
268
- </button>
269
- )}
270
- </div>
271
- </div>
272
- );
273
- }