@rebasepro/studio 0.9.0 → 0.9.1-canary.0fce67c
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/README.md +3 -3
- package/dist/{ApiExplorer-CdIwR9Ga.js → ApiExplorer-B9M9-uy_.js} +3 -3
- package/dist/ApiExplorer-B9M9-uy_.js.map +1 -0
- package/dist/{ApiKeysView-D-_FSlNL.js → ApiKeysView-vQKqt2wl.js} +45 -3
- package/dist/ApiKeysView-vQKqt2wl.js.map +1 -0
- package/dist/{AuthSimulationSelector-iEZ-Or_1.js → AuthSimulationSelector-Bougd521.js} +2 -2
- package/dist/AuthSimulationSelector-Bougd521.js.map +1 -0
- package/dist/BackupsView-DNS6LdVg.js +227 -0
- package/dist/BackupsView-DNS6LdVg.js.map +1 -0
- package/dist/{BranchesView-Dlg78EQ8.js → BranchesView-DH0qnU9J.js} +2 -2
- package/dist/BranchesView-DH0qnU9J.js.map +1 -0
- package/dist/{CronJobsView-4gdtJvoe.js → CronJobsView-B59i3Mis.js} +2 -2
- package/dist/CronJobsView-B59i3Mis.js.map +1 -0
- package/dist/{JSEditor-Sgmw1SHC.js → JSEditor-BeHHuxhA.js} +5 -5
- package/dist/JSEditor-BeHHuxhA.js.map +1 -0
- package/dist/LogsExplorer-BCXoGREr.js +298 -0
- package/dist/LogsExplorer-BCXoGREr.js.map +1 -0
- package/dist/{MonacoEditor-COZqrIJ1.js → MonacoEditor-D0_QljcX.js} +2 -2
- package/dist/MonacoEditor-D0_QljcX.js.map +1 -0
- package/dist/{RLSEditor-DPhky8XW.js → RLSEditor-D6adwB6e.js} +34 -20
- package/dist/RLSEditor-D6adwB6e.js.map +1 -0
- package/dist/{SQLEditor-CgR2hlUI.js → SQLEditor-_dCqVAN_.js} +5 -5
- package/dist/SQLEditor-_dCqVAN_.js.map +1 -0
- package/dist/{SchemaVisualizer-WK4ZZR5X.js → SchemaVisualizer-C_ErehfH.js} +3 -3
- package/dist/SchemaVisualizer-C_ErehfH.js.map +1 -0
- package/dist/{StorageView-BMhD29YO.js → StorageView-UvyM2xdW.js} +2 -2
- package/dist/StorageView-UvyM2xdW.js.map +1 -0
- package/dist/components/AuthSimulationSelector.d.ts +1 -1
- package/dist/components/Backups/BackupsView.d.ts +2 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.es.js +73 -110
- package/dist/index.es.js.map +1 -1
- package/package.json +11 -12
- package/src/components/ApiExplorer/ApiExplorer.tsx +1 -1
- package/src/components/ApiExplorer/TryItPanel.tsx +1 -1
- package/src/components/ApiKeys/ApiKeysView.tsx +43 -3
- package/src/components/AuthSimulationSelector.tsx +1 -1
- package/src/components/Backups/BackupsView.tsx +185 -0
- package/src/components/Branches/BranchesView.tsx +1 -1
- package/src/components/CronJobs/CronJobsView.tsx +1 -1
- package/src/components/JSEditor/JSEditor.tsx +6 -6
- package/src/components/JSEditor/JSMonacoEditor.tsx +1 -1
- package/src/components/LogsExplorer/LogsExplorer.tsx +168 -46
- package/src/components/RLSEditor/PolicyEditor.tsx +5 -3
- package/src/components/RLSEditor/RLSEditor.tsx +50 -20
- package/src/components/RebaseStudio.tsx +12 -3
- package/src/components/SQLEditor/MonacoEditor.tsx +1 -1
- package/src/components/SQLEditor/SQLEditor.tsx +3 -3
- package/src/components/SQLEditor/SQLEditorSidebar.tsx +1 -1
- package/src/components/SQLEditor/SchemaBrowser.tsx +3 -3
- package/src/components/SchemaVisualizer/SchemaVisualizer.tsx +3 -2
- package/src/components/SchemaVisualizer/TableNode.tsx +1 -1
- package/src/components/StorageView/StorageView.tsx +1 -1
- package/src/components/StudioHomePage.tsx +62 -87
- package/src/index.ts +3 -3
- package/dist/ApiExplorer-CdIwR9Ga.js.map +0 -1
- package/dist/ApiKeysView-D-_FSlNL.js.map +0 -1
- package/dist/AuthSimulationSelector-iEZ-Or_1.js.map +0 -1
- package/dist/BranchesView-Dlg78EQ8.js.map +0 -1
- package/dist/CronJobsView-4gdtJvoe.js.map +0 -1
- package/dist/JSEditor-Sgmw1SHC.js.map +0 -1
- package/dist/LogsExplorer-J4xfsuv3.js +0 -206
- package/dist/LogsExplorer-J4xfsuv3.js.map +0 -1
- package/dist/MonacoEditor-COZqrIJ1.js.map +0 -1
- package/dist/RLSEditor-DPhky8XW.js.map +0 -1
- package/dist/SQLEditor-CgR2hlUI.js.map +0 -1
- package/dist/SchemaVisualizer-WK4ZZR5X.js.map +0 -1
- package/dist/StorageView-BMhD29YO.js.map +0 -1
- package/dist/index.umd.js +0 -11477
- 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/
|
|
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/
|
|
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/
|
|
39
|
-
import { useRebaseContext, useRebaseClient, useSnackbarController, useApiConfig, useTranslation, useModeController, ErrorView, SelectableUser, IconForView } from "@rebasepro/
|
|
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/
|
|
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="
|
|
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
|
-
{/*
|
|
1012
|
+
{/* Save snippet dialog */}
|
|
1013
1013
|
<Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
|
|
1014
|
-
<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/
|
|
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, {
|
|
2
|
-
import {
|
|
1
|
+
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { ArrowDownToLineIcon, Checkbox, cls, defaultBorderMixin, Label, Select, SelectItem, TextField, Typography } from "@rebasepro/ui";
|
|
3
|
+
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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={
|
|
127
|
-
onChange={e =>
|
|
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={
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
<
|
|
166
|
-
|
|
167
|
-
|
|
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,7 +19,7 @@ import {
|
|
|
19
19
|
TextField,
|
|
20
20
|
Typography
|
|
21
21
|
} from "@rebasepro/ui";
|
|
22
|
-
import { useTranslation } from "@rebasepro/
|
|
22
|
+
import { useTranslation } from "@rebasepro/app";
|
|
23
23
|
import { MonacoEditor } from "../SQLEditor/MonacoEditor";
|
|
24
24
|
import { PostgresPolicy } from "./RLSEditor";
|
|
25
25
|
|
|
@@ -187,7 +187,9 @@ export const PolicyEditor = ({
|
|
|
187
187
|
|
|
188
188
|
return (
|
|
189
189
|
<>
|
|
190
|
-
|
|
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
|
-
</
|
|
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">
|