@rebasepro/studio 0.9.0 → 0.9.1-canary.0de22e0

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 (79) hide show
  1. package/README.md +3 -3
  2. package/dist/{ApiExplorer-CdIwR9Ga.js → ApiExplorer-B9M9-uy_.js} +3 -3
  3. package/dist/ApiExplorer-B9M9-uy_.js.map +1 -0
  4. package/dist/{ApiKeysView-D-_FSlNL.js → ApiKeysView-vQKqt2wl.js} +45 -3
  5. package/dist/ApiKeysView-vQKqt2wl.js.map +1 -0
  6. package/dist/{AuthSimulationSelector-iEZ-Or_1.js → AuthSimulationSelector-Bougd521.js} +2 -2
  7. package/dist/AuthSimulationSelector-Bougd521.js.map +1 -0
  8. package/dist/BackupsView-DNS6LdVg.js +227 -0
  9. package/dist/BackupsView-DNS6LdVg.js.map +1 -0
  10. package/dist/{BranchesView-Dlg78EQ8.js → BranchesView-DH0qnU9J.js} +2 -2
  11. package/dist/BranchesView-DH0qnU9J.js.map +1 -0
  12. package/dist/{CronJobsView-4gdtJvoe.js → CronJobsView-B59i3Mis.js} +2 -2
  13. package/dist/CronJobsView-B59i3Mis.js.map +1 -0
  14. package/dist/{JSEditor-Sgmw1SHC.js → JSEditor-BeHHuxhA.js} +5 -5
  15. package/dist/JSEditor-BeHHuxhA.js.map +1 -0
  16. package/dist/LogsExplorer-BCXoGREr.js +298 -0
  17. package/dist/LogsExplorer-BCXoGREr.js.map +1 -0
  18. package/dist/{MonacoEditor-COZqrIJ1.js → MonacoEditor-D0_QljcX.js} +2 -2
  19. package/dist/MonacoEditor-D0_QljcX.js.map +1 -0
  20. package/dist/{RLSEditor-DPhky8XW.js → RLSEditor-D6adwB6e.js} +34 -20
  21. package/dist/RLSEditor-D6adwB6e.js.map +1 -0
  22. package/dist/{SQLEditor-CgR2hlUI.js → SQLEditor-_dCqVAN_.js} +5 -5
  23. package/dist/SQLEditor-_dCqVAN_.js.map +1 -0
  24. package/dist/{SchemaVisualizer-WK4ZZR5X.js → SchemaVisualizer-C_ErehfH.js} +3 -3
  25. package/dist/SchemaVisualizer-C_ErehfH.js.map +1 -0
  26. package/dist/{StorageView-BMhD29YO.js → StorageView-UvyM2xdW.js} +2 -2
  27. package/dist/StorageView-UvyM2xdW.js.map +1 -0
  28. package/dist/components/AuthSimulationSelector.d.ts +1 -1
  29. package/dist/components/Backups/BackupsView.d.ts +2 -0
  30. package/dist/components/RLSEditor/PolicyEditor.d.ts +1 -1
  31. package/dist/components/SQLEditor/SQLEditor.d.ts +2 -10
  32. package/dist/components/SQLEditor/SQLEditorSidebar.d.ts +1 -1
  33. package/dist/components/SQLEditor/SchemaBrowser.d.ts +1 -1
  34. package/dist/components/SQLEditor/sql_editor_types.d.ts +16 -0
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.es.js +73 -110
  37. package/dist/index.es.js.map +1 -1
  38. package/dist/utils/sql_utils.d.ts +1 -1
  39. package/package.json +13 -12
  40. package/src/components/ApiExplorer/ApiExplorer.tsx +1 -1
  41. package/src/components/ApiExplorer/TryItPanel.tsx +1 -1
  42. package/src/components/ApiKeys/ApiKeysView.tsx +43 -3
  43. package/src/components/AuthSimulationSelector.tsx +1 -1
  44. package/src/components/Backups/BackupsView.tsx +185 -0
  45. package/src/components/Branches/BranchesView.tsx +1 -1
  46. package/src/components/CronJobs/CronJobsView.tsx +1 -1
  47. package/src/components/JSEditor/JSEditor.tsx +6 -6
  48. package/src/components/JSEditor/JSMonacoEditor.tsx +1 -1
  49. package/src/components/LogsExplorer/LogsExplorer.tsx +168 -46
  50. package/src/components/RLSEditor/PolicyEditor.tsx +6 -4
  51. package/src/components/RLSEditor/RLSEditor.tsx +50 -20
  52. package/src/components/RebaseStudio.tsx +12 -3
  53. package/src/components/SQLEditor/MonacoEditor.tsx +1 -1
  54. package/src/components/SQLEditor/SQLEditor.tsx +6 -14
  55. package/src/components/SQLEditor/SQLEditorSidebar.tsx +2 -2
  56. package/src/components/SQLEditor/SchemaBrowser.tsx +4 -4
  57. package/src/components/SQLEditor/sql_editor_types.ts +18 -0
  58. package/src/components/SchemaVisualizer/SchemaVisualizer.tsx +3 -2
  59. package/src/components/SchemaVisualizer/TableNode.tsx +1 -1
  60. package/src/components/StorageView/StorageView.tsx +1 -1
  61. package/src/components/StudioHomePage.tsx +62 -87
  62. package/src/index.ts +3 -3
  63. package/src/utils/sql_utils.test.ts +1 -1
  64. package/src/utils/sql_utils.ts +1 -1
  65. package/dist/ApiExplorer-CdIwR9Ga.js.map +0 -1
  66. package/dist/ApiKeysView-D-_FSlNL.js.map +0 -1
  67. package/dist/AuthSimulationSelector-iEZ-Or_1.js.map +0 -1
  68. package/dist/BranchesView-Dlg78EQ8.js.map +0 -1
  69. package/dist/CronJobsView-4gdtJvoe.js.map +0 -1
  70. package/dist/JSEditor-Sgmw1SHC.js.map +0 -1
  71. package/dist/LogsExplorer-J4xfsuv3.js +0 -206
  72. package/dist/LogsExplorer-J4xfsuv3.js.map +0 -1
  73. package/dist/MonacoEditor-COZqrIJ1.js.map +0 -1
  74. package/dist/RLSEditor-DPhky8XW.js.map +0 -1
  75. package/dist/SQLEditor-CgR2hlUI.js.map +0 -1
  76. package/dist/SchemaVisualizer-WK4ZZR5X.js.map +0 -1
  77. package/dist/StorageView-BMhD29YO.js.map +0 -1
  78. package/dist/index.umd.js +0 -11477
  79. package/dist/index.umd.js.map +0 -1
@@ -0,0 +1,185 @@
1
+ import React, { useState, useEffect, useRef, useCallback } from "react";
2
+ import {
3
+ Alert,
4
+ Button,
5
+ Chip,
6
+ CircularProgress,
7
+ cls,
8
+ DatabaseIcon,
9
+ defaultBorderMixin,
10
+ DownloadIcon,
11
+ iconSize,
12
+ IconButton,
13
+ RefreshCwIcon,
14
+ Typography
15
+ } from "@rebasepro/ui";
16
+ import { useRebaseClient, useSnackbarController } from "@rebasepro/app";
17
+ import type { BackupInfo, RebaseClient } from "@rebasepro/types";
18
+
19
+ function formatSize(bytes: number | undefined): string {
20
+ if (bytes === undefined || bytes === null) return "—";
21
+ if (bytes < 1024) return `${bytes} B`;
22
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
23
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
24
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
25
+ }
26
+
27
+ function formatDate(iso: string | undefined): string {
28
+ if (!iso) return "—";
29
+ const d = new Date(iso);
30
+ if (isNaN(d.getTime())) return "—";
31
+ return d.toLocaleString();
32
+ }
33
+
34
+ export function BackupsView() {
35
+ const client = useRebaseClient<RebaseClient>();
36
+ const snackbar = useSnackbarController();
37
+
38
+ const [backups, setBackups] = useState<BackupInfo[]>([]);
39
+ const [destinationKind, setDestinationKind] = useState<string>("local");
40
+ const [configured, setConfigured] = useState(true);
41
+ const [loading, setLoading] = useState(true);
42
+ const [downloading, setDownloading] = useState<string | null>(null);
43
+
44
+ const clientRef = useRef(client);
45
+ clientRef.current = client;
46
+ const snackbarRef = useRef(snackbar);
47
+ snackbarRef.current = snackbar;
48
+
49
+ const load = useCallback(async () => {
50
+ const c = clientRef.current;
51
+ if (!c?.backups) {
52
+ setLoading(false);
53
+ setConfigured(false);
54
+ return;
55
+ }
56
+ try {
57
+ const res = await c.backups.list();
58
+ setBackups(res.backups);
59
+ setDestinationKind(res.destinationKind);
60
+ setConfigured(res.configured);
61
+ } catch (e: unknown) {
62
+ snackbarRef.current.open({
63
+ type: "error",
64
+ message: e instanceof Error ? e.message : String(e)
65
+ });
66
+ } finally {
67
+ setLoading(false);
68
+ }
69
+ }, []);
70
+
71
+ useEffect(() => {
72
+ load();
73
+ }, [load]);
74
+
75
+ const handleDownload = async (backup: BackupInfo) => {
76
+ const c = clientRef.current;
77
+ if (!c?.backups) return;
78
+ setDownloading(backup.key);
79
+ try {
80
+ const blob = await c.backups.download(backup.key);
81
+ const url = URL.createObjectURL(blob);
82
+ const a = document.createElement("a");
83
+ a.href = url;
84
+ a.download = backup.name;
85
+ document.body.appendChild(a);
86
+ a.click();
87
+ a.remove();
88
+ URL.revokeObjectURL(url);
89
+ } catch (e: unknown) {
90
+ snackbarRef.current.open({
91
+ type: "error",
92
+ message: e instanceof Error ? e.message : String(e)
93
+ });
94
+ } finally {
95
+ setDownloading(null);
96
+ }
97
+ };
98
+
99
+ // Backups API not available on this client (e.g. non-Postgres backend).
100
+ if (!client?.backups) {
101
+ return (
102
+ <div className="flex flex-col items-center justify-center h-full gap-4 text-center p-8">
103
+ <DatabaseIcon size={iconSize.large} className="text-surface-300 dark:text-surface-600"/>
104
+ <Typography variant="h6" color="secondary">Backups Not Available</Typography>
105
+ <Typography variant="body2" color="disabled" className="max-w-md">
106
+ The backups API is not exposed by this backend.
107
+ </Typography>
108
+ </div>
109
+ );
110
+ }
111
+
112
+ if (loading) {
113
+ return (
114
+ <div className="flex items-center justify-center h-full">
115
+ <CircularProgress/>
116
+ </div>
117
+ );
118
+ }
119
+
120
+ return (
121
+ <div className="flex flex-col h-full w-full overflow-hidden bg-white dark:bg-surface-950">
122
+ {/* Header */}
123
+ <div className={cls("flex items-center justify-between px-5 py-2.5 border-b bg-surface-50 dark:bg-surface-900 min-h-[48px]", defaultBorderMixin)}>
124
+ <div className="flex items-center gap-2">
125
+ <DatabaseIcon size={iconSize.small} className="text-primary"/>
126
+ <Typography variant="subtitle2" className="font-semibold">Backups</Typography>
127
+ <Chip size="smallest" className="bg-surface-200 dark:bg-surface-700 text-surface-600 dark:text-surface-300">{backups.length}</Chip>
128
+ <Chip size="smallest" className="bg-surface-100 dark:bg-surface-800 text-surface-500 dark:text-surface-400 uppercase font-mono text-[10px]">{destinationKind}</Chip>
129
+ </div>
130
+ <IconButton size="small" onClick={load} title="Refresh">
131
+ <RefreshCwIcon size={iconSize.smallest}/>
132
+ </IconButton>
133
+ </div>
134
+
135
+ <div className="flex-1 overflow-y-auto p-5">
136
+ {!configured ? (
137
+ <Alert color="info">
138
+ <Typography variant="body2" className="text-[13px]">
139
+ <strong>Backups are not configured.</strong> Set
140
+ <code className="mx-1 px-1.5 py-0.5 rounded bg-surface-100 dark:bg-surface-950 font-mono text-[12px]">BACKUP_DESTINATION</code>
141
+ (a local path or an <code className="font-mono text-[12px]">s3://</code> / <code className="font-mono text-[12px]">gs://</code> URL) and create backups with
142
+ <code className="mx-1 px-1.5 py-0.5 rounded bg-surface-100 dark:bg-surface-950 font-mono text-[12px]">rebase db backup</code>
143
+ or a scheduled cron. See docs/backups.md.
144
+ </Typography>
145
+ </Alert>
146
+ ) : backups.length === 0 ? (
147
+ <div className="flex flex-col items-center justify-center h-full gap-3 text-center">
148
+ <DatabaseIcon size={iconSize.large} className="text-surface-200 dark:text-surface-700"/>
149
+ <Typography variant="body2" color="disabled">
150
+ No backups found yet. Run <code className="font-mono text-[12px]">rebase db backup</code> or wait for the next scheduled run.
151
+ </Typography>
152
+ </div>
153
+ ) : (
154
+ <div className="space-y-2 max-w-3xl">
155
+ {backups.map(backup => (
156
+ <div
157
+ key={backup.key}
158
+ className={cls("flex items-center gap-3 px-4 py-3 rounded-lg border bg-white dark:bg-surface-900", defaultBorderMixin)}
159
+ >
160
+ <DatabaseIcon size={iconSize.small} className="text-surface-400 shrink-0"/>
161
+ <div className="flex-1 min-w-0">
162
+ <Typography variant="body2" className="truncate font-medium font-mono text-[12px]">{backup.name}</Typography>
163
+ <Typography variant="caption" color="secondary" className="text-[11px]">
164
+ {formatDate(backup.createdAt)} · {formatSize(backup.sizeBytes)}
165
+ </Typography>
166
+ </div>
167
+ <Button
168
+ size="small"
169
+ variant="outlined"
170
+ onClick={() => handleDownload(backup)}
171
+ disabled={downloading === backup.key}
172
+ startIcon={downloading === backup.key
173
+ ? <CircularProgress size="smallest"/>
174
+ : <DownloadIcon size={iconSize.smallest}/>}
175
+ >
176
+ {downloading === backup.key ? "Downloading…" : "Download"}
177
+ </Button>
178
+ </div>
179
+ ))}
180
+ </div>
181
+ )}
182
+ </div>
183
+ </div>
184
+ );
185
+ }
@@ -24,7 +24,7 @@ import {
24
24
  Trash2Icon,
25
25
  Typography
26
26
  } from "@rebasepro/ui";
27
- import { useRebaseContext, useSnackbarController, ConfirmationDialog } from "@rebasepro/core";
27
+ import { useRebaseContext, useSnackbarController, ConfirmationDialog } from "@rebasepro/app";
28
28
  import { isBranchAdmin } from "@rebasepro/types";
29
29
  import type { BranchInfo } from "@rebasepro/types";
30
30
 
@@ -19,7 +19,7 @@ import {
19
19
  RefreshCwIcon,
20
20
  Typography
21
21
  } from "@rebasepro/ui";
22
- import { useRebaseClient, useSnackbarController } from "@rebasepro/core";
22
+ import { useRebaseClient, useSnackbarController } from "@rebasepro/app";
23
23
  import type { CronJobStatus, CronJobLogEntry } from "@rebasepro/types";
24
24
  import type { RebaseClient } from "@rebasepro/types";
25
25
 
@@ -35,8 +35,8 @@ import {
35
35
  VirtualTableColumn,
36
36
  XIcon
37
37
  } from "@rebasepro/ui";
38
- import { useStudioUrlController, useStudioCollectionRegistry, useStudioSidePanelController } from "@rebasepro/core";
39
- import { useRebaseContext, useRebaseClient, useSnackbarController, useApiConfig, useTranslation, useModeController, ErrorView, SelectableUser, IconForView } from "@rebasepro/core";
38
+ import { useStudioUrlController, useStudioCollectionRegistry, useStudioSidePanelController } from "@rebasepro/app";
39
+ import { useRebaseContext, useRebaseClient, useSnackbarController, useApiConfig, useTranslation, useModeController, ErrorView, SelectableUser, IconForView } from "@rebasepro/app";
40
40
  import { CollectionConfig } from "@rebasepro/types";
41
41
  import { createRebaseClient } from "@rebasepro/client";
42
42
  import { JSMonacoEditor } from "./JSMonacoEditor";
@@ -65,7 +65,7 @@ interface EditorTab {
65
65
  code: string;
66
66
  }
67
67
 
68
- // SelectedUser is now SelectableUser from @rebasepro/core
68
+ // SelectedUser is now SelectableUser from @rebasepro/app
69
69
 
70
70
  // ─── Constants ───────────────────────────────────────────────────────
71
71
 
@@ -670,7 +670,7 @@ message: t("studio_sql_markdown_copy_failed") });
670
670
  </div>
671
671
  </div>
672
672
  <div className="flex shrink-0 items-center justify-end gap-1.5">
673
- <Tooltip title="SaveIcon as snippet">
673
+ <Tooltip title="Save as snippet">
674
674
  <IconButton
675
675
  size="small"
676
676
  onClick={() => {
@@ -1009,9 +1009,9 @@ id: String(ra.entityId) })}
1009
1009
  />
1010
1010
  </div>
1011
1011
 
1012
- {/* SaveIcon snippet dialog */}
1012
+ {/* Save snippet dialog */}
1013
1013
  <Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
1014
- <DialogTitle>SaveIcon Snippet</DialogTitle>
1014
+ <DialogTitle>Save Snippet</DialogTitle>
1015
1015
  <DialogContent>
1016
1016
  <TextField
1017
1017
  label="Snippet name"
@@ -3,7 +3,7 @@ import React, { useRef, useEffect } from "react";
3
3
  import Editor, { Monaco, OnMount } from "@monaco-editor/react";
4
4
  import type { editor } from "monaco-editor";
5
5
  import { cls, defaultBorderMixin, FileIcon } from "@rebasepro/ui";
6
- import { useModeController } from "@rebasepro/core";
6
+ import { useModeController } from "@rebasepro/app";
7
7
 
8
8
  /**
9
9
  * Shape of `monaco.languages.typescript` at runtime.
@@ -1,5 +1,6 @@
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
+ import { useApiConfig } from "@rebasepro/app";
3
4
 
4
5
  interface LogEntry {
5
6
  id: string;
@@ -25,14 +26,48 @@ const SOURCE_COLORS: Record<string, string> = {
25
26
  system: "text-surface-600 dark:text-surface-400"
26
27
  };
27
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
+
28
42
  export function LogsExplorer() {
29
43
  const [logs, setLogs] = useState<LogEntry[]>([]);
30
44
  const [level, setLevel] = useState<string>("all");
31
45
  const [source, setSource] = useState<string>("all");
46
+ const [searchInput, setSearchInput] = useState("");
32
47
  const [search, setSearch] = useState("");
33
48
  const [autoScroll, setAutoScroll] = useState(true);
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);
34
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);
58
+ const apiConfig = useApiConfig();
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
+
35
66
  const fetchLogs = useCallback(async () => {
67
+ if (!apiConfig?.apiUrl) {
68
+ setError("No API URL configured — cannot load logs.");
69
+ return;
70
+ }
36
71
  try {
37
72
  const params = new URLSearchParams();
38
73
  if (level && level !== "all") params.set("level", level);
@@ -40,14 +75,46 @@ export function LogsExplorer() {
40
75
  if (search) params.set("search", search);
41
76
  params.set("limit", "200");
42
77
 
43
- const resp = await fetch(`/api/logs?${params}`);
44
- if (resp.ok) {
45
- const data: { entries?: LogEntry[] } = await resp.json();
46
- setLogs(data.entries || []);
78
+ // Logs are admin-only, so the request must carry the auth token. The
79
+ // URL is absolute: a relative one would resolve against the frontend
80
+ // origin, which serves index.html rather than the API.
81
+ const headers: Record<string, string> = {};
82
+ const token = apiConfig.getAuthToken ? await apiConfig.getAuthToken() : null;
83
+ if (token) headers["Authorization"] = `Bearer ${token}`;
84
+
85
+ const resp = await fetch(`${apiConfig.apiUrl}/api/logs?${params}`, { headers });
86
+ if (!resp.ok) {
87
+ setError(resp.status === 401 || resp.status === 403
88
+ ? "Not authorised to read logs — an admin role is required."
89
+ : `Could not load logs (HTTP ${resp.status}).`);
90
+ return;
91
+ }
92
+ const data: { entries?: LogEntry[] } = await resp.json();
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);
47
101
  }
48
- } catch {
49
- /* ignore poll failures */
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);
107
+ setError(null);
108
+ } catch (e) {
109
+ setError(e instanceof Error ? e.message : "Could not load logs.");
50
110
  }
111
+ }, [level, source, search, apiConfig]);
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);
51
118
  }, [level, source, search]);
52
119
 
53
120
  useEffect(() => {
@@ -82,9 +149,31 @@ export function LogsExplorer() {
82
149
  };
83
150
  }, [fetchLogs]);
84
151
 
85
- useEffect(() => {
86
- if (autoScroll && containerRef.current) {
87
- 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;
88
177
  }
89
178
  }, [logs, autoScroll]);
90
179
 
@@ -123,15 +212,18 @@ export function LogsExplorer() {
123
212
  <TextField
124
213
  size="small"
125
214
  placeholder="Search logs..."
126
- value={search}
127
- onChange={e => setSearch(e.target.value)}
215
+ value={searchInput}
216
+ onChange={e => setSearchInput(e.target.value)}
128
217
  className="flex-1 min-w-[200px]"
129
218
  />
130
219
  <div className="flex items-center gap-1.5 cursor-pointer ml-2">
131
220
  <Checkbox
132
221
  id="auto-scroll"
133
222
  checked={autoScroll}
134
- onCheckedChange={setAutoScroll}
223
+ onCheckedChange={(checked: boolean) => {
224
+ setAutoScroll(checked);
225
+ if (checked) scrollToBottom();
226
+ }}
135
227
  size="small"
136
228
  padding={false}
137
229
  />
@@ -148,40 +240,70 @@ export function LogsExplorer() {
148
240
  </Typography>
149
241
  </div>
150
242
  </div>
151
-
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
+
152
257
  {/* Log entries */}
153
- <div
154
- ref={containerRef}
155
- className="flex-1 overflow-auto py-2"
156
- >
157
- {logs.map(log => (
158
- <div
159
- key={log.id}
160
- className={cls(
161
- "flex gap-4 px-4 py-[6px] border-b hover:bg-surface-100 dark:hover:bg-surface-900 transition-colors",
162
- defaultBorderMixin
163
- )}
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
+ )}
271
+ >
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"
164
303
  >
165
- <Typography variant="body2" color="secondary" className="w-[72px] shrink-0 font-mono">
166
- {new Date(log.timestamp).toLocaleTimeString()}
167
- </Typography>
168
- <Typography variant="body2" className={cls("w-[48px] shrink-0 uppercase font-semibold font-mono", LEVEL_COLORS[log.level] || "text-surface-500")}>
169
- {log.level}
170
- </Typography>
171
- <Typography variant="body2" className={cls("w-[80px] shrink-0 font-mono", SOURCE_COLORS[log.source] || "text-surface-500")}>
172
- [{log.source}]
173
- </Typography>
174
- <Typography variant="body2" className="flex-1 font-mono break-all whitespace-pre-wrap text-surface-900 dark:text-surface-100">
175
- {log.message}
176
- </Typography>
177
- </div>
178
- ))}
179
- {logs.length === 0 && (
180
- <div className="p-8 text-center">
181
- <Typography variant="body2" color="secondary">
182
- No log entries yet. Logs will appear here as requests come in.
183
- </Typography>
184
- </div>
304
+ <ArrowDownToLineIcon size={14}/>
305
+ {newCount} new {newCount === 1 ? "entry" : "entries"}
306
+ </button>
185
307
  )}
186
308
  </div>
187
309
  </div>
@@ -19,9 +19,9 @@ import {
19
19
  TextField,
20
20
  Typography
21
21
  } from "@rebasepro/ui";
22
- import { useTranslation } from "@rebasepro/core";
22
+ import { useTranslation } from "@rebasepro/app";
23
23
  import { MonacoEditor } from "../SQLEditor/MonacoEditor";
24
- import { PostgresPolicy } from "./RLSEditor";
24
+ import type { PostgresPolicy } from "./RLSEditor";
25
25
 
26
26
  export interface PolicyEditorProps {
27
27
  policy?: PostgresPolicy;
@@ -187,7 +187,9 @@ export const PolicyEditor = ({
187
187
 
188
188
  return (
189
189
  <>
190
- <DialogTitle className="flex justify-between items-center w-full" variant="h6">
190
+ {/* Inline header: this editor renders as a full pane, not inside a Dialog,
191
+ so DialogTitle (Radix-bound) cannot be used here. */}
192
+ <Typography variant="h6" gutterBottom className="mt-8 mx-8 flex justify-between items-center w-full">
191
193
  <div>
192
194
  <div>{policy ? t("studio_policy_edit") : t("studio_policy_create")}</div>
193
195
  <div className="text-sm font-normal text-text-secondary dark:text-text-secondary-dark tracking-wide mt-1">
@@ -197,7 +199,7 @@ export const PolicyEditor = ({
197
199
  <IconButton size="small" onClick={() => setHelpOpen(true)}>
198
200
  <HelpCircleIcon size={iconSize.smallest}/>
199
201
  </IconButton>
200
- </DialogTitle>
202
+ </Typography>
201
203
 
202
204
  <DialogContent className="p-4 md:p-6 border-t dark:border-surface-950 bg-surface-50 dark:bg-surface-950" includeMargin={false}>
203
205
  <div className="max-w-4xl mx-auto">