@naviyra/cloud-panel 0.0.3-beta → 0.0.3-beta3
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/apps/agent/src/cloudflare.mjs +858 -733
- package/apps/agent/src/files.mjs +162 -0
- package/apps/agent/src/host-tools.mjs +518 -0
- package/apps/agent/src/native.mjs +157 -32
- package/apps/agent/src/runtime.mjs +70 -37
- package/apps/agent/src/server.mjs +61 -6
- package/apps/panel/app/api/[...path]/route.js +96 -14
- package/apps/panel/app/globals.css +313 -7
- package/apps/panel/app/layout.jsx +2 -1
- package/apps/panel/app/login/page.jsx +3 -8
- package/apps/panel/app/page.jsx +17 -7
- package/apps/panel/app/ui/cloudflare.jsx +72 -47
- package/apps/panel/app/ui/confirm.jsx +109 -0
- package/apps/panel/app/ui/dashboard.jsx +87 -72
- package/apps/panel/app/ui/databases.jsx +168 -59
- package/apps/panel/app/ui/project-tools.jsx +604 -0
- package/apps/panel/app/ui/toast.jsx +90 -0
- package/apps/panel/lib/agent.mjs +13 -2
- package/apps/panel/lib/databases.mjs +120 -0
- package/apps/panel/lib/request-origin.mjs +29 -0
- package/package.json +1 -1
- package/packages/shared/index.mjs +49 -0
- package/scripts/panel-control.mjs +214 -214
- package/scripts/service-supervisor.mjs +164 -164
- package/scripts/windows-boot.mjs +259 -259
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { runtimeLabels } from "@naviyra/shared";
|
|
4
4
|
import { CloudflareConnect, DnsScreen, EmailScreen } from "./cloudflare";
|
|
5
|
+
import { useConfirm } from "./confirm";
|
|
6
|
+
import { useToast } from "./toast";
|
|
5
7
|
import { DatabaseScreen } from "./databases";
|
|
8
|
+
import { ProjectWorkspace } from "./project-tools";
|
|
6
9
|
import { ThemeToggle } from "./theme";
|
|
7
10
|
import {
|
|
8
11
|
PageSkeleton,
|
|
@@ -82,11 +85,14 @@ export default function Dashboard({
|
|
|
82
85
|
initialProject = null,
|
|
83
86
|
initialTab = "overview",
|
|
84
87
|
}) {
|
|
88
|
+
const confirm = useConfirm();
|
|
89
|
+
const toast = useToast();
|
|
90
|
+
const listError = useRef("");
|
|
91
|
+
const detailLoadError = useRef("");
|
|
92
|
+
const projectError = useRef("");
|
|
85
93
|
const [projects, setProjects] = useState([]),
|
|
86
94
|
[system, setSystem] = useState(null),
|
|
87
|
-
[loading, setLoading] = useState(true)
|
|
88
|
-
[error, setError] = useState(""),
|
|
89
|
-
[notice, setNotice] = useState("");
|
|
95
|
+
[loading, setLoading] = useState(true);
|
|
90
96
|
const [cf, setCf] = useState(null);
|
|
91
97
|
const [view, setView] = useState(initialView),
|
|
92
98
|
[search, setSearch] = useState(""),
|
|
@@ -103,9 +109,12 @@ export default function Dashboard({
|
|
|
103
109
|
try {
|
|
104
110
|
const list = await api("projects");
|
|
105
111
|
setProjects(list);
|
|
106
|
-
|
|
112
|
+
listError.current = "";
|
|
107
113
|
} catch (e) {
|
|
108
|
-
|
|
114
|
+
if (listError.current !== e.message) {
|
|
115
|
+
listError.current = e.message;
|
|
116
|
+
toast.failed(e.message);
|
|
117
|
+
}
|
|
109
118
|
} finally {
|
|
110
119
|
setLoading(false);
|
|
111
120
|
}
|
|
@@ -114,7 +123,7 @@ export default function Dashboard({
|
|
|
114
123
|
} catch (e) {
|
|
115
124
|
setSystem({ available: false, error: e.message });
|
|
116
125
|
}
|
|
117
|
-
}, []);
|
|
126
|
+
}, [toast]);
|
|
118
127
|
useEffect(() => {
|
|
119
128
|
load();
|
|
120
129
|
const timer = setInterval(load, 5000);
|
|
@@ -173,16 +182,26 @@ export default function Dashboard({
|
|
|
173
182
|
);
|
|
174
183
|
setLogs(l.logs);
|
|
175
184
|
setMetrics(m);
|
|
185
|
+
detailLoadError.current = "";
|
|
176
186
|
} catch (e) {
|
|
177
|
-
|
|
187
|
+
if (detailLoadError.current !== e.message) {
|
|
188
|
+
detailLoadError.current = e.message;
|
|
189
|
+
toast.failed(e.message);
|
|
190
|
+
}
|
|
178
191
|
}
|
|
179
|
-
}, [selected]);
|
|
192
|
+
}, [selected, toast]);
|
|
180
193
|
useEffect(() => {
|
|
181
194
|
setDetail(null);
|
|
182
195
|
loadDetail();
|
|
183
196
|
const timer = setInterval(loadDetail, 3000);
|
|
184
197
|
return () => clearInterval(timer);
|
|
185
198
|
}, [loadDetail]);
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
const message = detail?.error || detail?.agent_error || "";
|
|
201
|
+
if (message === projectError.current) return;
|
|
202
|
+
projectError.current = message;
|
|
203
|
+
if (message) toast.failed(message);
|
|
204
|
+
}, [detail, toast]);
|
|
186
205
|
const running = projects.filter((p) => p.status === "running").length,
|
|
187
206
|
failed = projects.filter((p) => p.status === "failed").length;
|
|
188
207
|
const visible = projects.filter(
|
|
@@ -193,35 +212,33 @@ export default function Dashboard({
|
|
|
193
212
|
const open = (id) => {
|
|
194
213
|
setSelected(id);
|
|
195
214
|
setTab("overview");
|
|
196
|
-
setError("");
|
|
197
215
|
};
|
|
198
216
|
async function action(kind) {
|
|
199
217
|
if (
|
|
200
218
|
kind === "delete" &&
|
|
201
|
-
!
|
|
202
|
-
`Delete ${detail.name}
|
|
203
|
-
|
|
219
|
+
!(await confirm({
|
|
220
|
+
title: `Delete ${detail.name}?`,
|
|
221
|
+
message:
|
|
222
|
+
"Its source, services, tunnel and DNS route will be removed.",
|
|
223
|
+
confirmLabel: "Delete",
|
|
224
|
+
}))
|
|
204
225
|
)
|
|
205
226
|
return;
|
|
206
227
|
setBusy(true);
|
|
207
|
-
setError("");
|
|
208
228
|
try {
|
|
209
229
|
await api(`projects/${selected}${kind === "delete" ? "" : `/${kind}`}`, {
|
|
210
230
|
method: kind === "delete" ? "DELETE" : "POST",
|
|
211
231
|
});
|
|
212
232
|
if (kind === "delete") {
|
|
213
233
|
setSelected(null);
|
|
214
|
-
|
|
215
|
-
} else
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
? "Deployment queued. Follow its progress below."
|
|
219
|
-
: "Services restarted.",
|
|
220
|
-
);
|
|
234
|
+
toast.success("Project deleted.");
|
|
235
|
+
} else if (kind === "deploy")
|
|
236
|
+
toast.progress("Deployment queued. Follow its progress below.");
|
|
237
|
+
else toast.success("Services restarted.");
|
|
221
238
|
await load();
|
|
222
239
|
if (kind !== "delete") await loadDetail();
|
|
223
240
|
} catch (e) {
|
|
224
|
-
|
|
241
|
+
toast.failed(e.message);
|
|
225
242
|
} finally {
|
|
226
243
|
setBusy(false);
|
|
227
244
|
}
|
|
@@ -418,25 +435,6 @@ export default function Dashboard({
|
|
|
418
435
|
</div>
|
|
419
436
|
)}
|
|
420
437
|
<main className="content">
|
|
421
|
-
{error && (
|
|
422
|
-
<div role="alert" className="error dismissible">
|
|
423
|
-
{error}
|
|
424
|
-
<button aria-label="Dismiss error" onClick={() => setError("")}>
|
|
425
|
-
<X size={16} />
|
|
426
|
-
</button>
|
|
427
|
-
</div>
|
|
428
|
-
)}
|
|
429
|
-
{notice && (
|
|
430
|
-
<div role="status" className="notice dismissible">
|
|
431
|
-
{notice}
|
|
432
|
-
<button
|
|
433
|
-
aria-label="Dismiss notification"
|
|
434
|
-
onClick={() => setNotice("")}
|
|
435
|
-
>
|
|
436
|
-
<X size={16} />
|
|
437
|
-
</button>
|
|
438
|
-
</div>
|
|
439
|
-
)}
|
|
440
438
|
{cf == null ? (
|
|
441
439
|
<PageSkeleton view={selected ? "project" : view} />
|
|
442
440
|
) : !cf.registered && system?.mode !== "mock" ? (
|
|
@@ -447,7 +445,7 @@ export default function Dashboard({
|
|
|
447
445
|
setCf(status);
|
|
448
446
|
setView("projects");
|
|
449
447
|
setSelected(null);
|
|
450
|
-
|
|
448
|
+
toast.success("Cloudflare account connected.");
|
|
451
449
|
}}
|
|
452
450
|
/>
|
|
453
451
|
) : selected ? (
|
|
@@ -508,11 +506,6 @@ export default function Dashboard({
|
|
|
508
506
|
</button>
|
|
509
507
|
</div>
|
|
510
508
|
</div>
|
|
511
|
-
{(detail.error || detail.agent_error) && (
|
|
512
|
-
<div className="error">
|
|
513
|
-
{detail.error || detail.agent_error}
|
|
514
|
-
</div>
|
|
515
|
-
)}
|
|
516
509
|
<div className="stats detail-stats">
|
|
517
510
|
<Stat
|
|
518
511
|
label="Application"
|
|
@@ -560,13 +553,20 @@ export default function Dashboard({
|
|
|
560
553
|
/>
|
|
561
554
|
</div>
|
|
562
555
|
<div className="tabs">
|
|
563
|
-
{[
|
|
556
|
+
{[
|
|
557
|
+
["overview", "Overview"],
|
|
558
|
+
["files", "Files"],
|
|
559
|
+
["application", "Application"],
|
|
560
|
+
["database", "Database"],
|
|
561
|
+
["logs", "Logs"],
|
|
562
|
+
["deployments", "Deployments"],
|
|
563
|
+
].map(([id, label]) => (
|
|
564
564
|
<button
|
|
565
|
-
key={
|
|
566
|
-
className={tab ===
|
|
567
|
-
onClick={() => setTab(
|
|
565
|
+
key={id}
|
|
566
|
+
className={tab === id ? "active" : ""}
|
|
567
|
+
onClick={() => setTab(id)}
|
|
568
568
|
>
|
|
569
|
-
{
|
|
569
|
+
{label}
|
|
570
570
|
</button>
|
|
571
571
|
))}
|
|
572
572
|
</div>
|
|
@@ -601,6 +601,12 @@ export default function Dashboard({
|
|
|
601
601
|
? detail.git_url
|
|
602
602
|
: "ZIP archive"}
|
|
603
603
|
</dd>
|
|
604
|
+
<dt>Deployed directory</dt>
|
|
605
|
+
<dd className="mono">
|
|
606
|
+
{detail.folder_path
|
|
607
|
+
? `${detail.folder_path.replace(/[\\/]$/, "")}${detail.folder_path.includes("\\") ? "\\" : "/"}app`
|
|
608
|
+
: "Available after the first upload"}
|
|
609
|
+
</dd>
|
|
604
610
|
<dt>Hostname</dt>
|
|
605
611
|
<dd>{detail.subdomain}</dd>
|
|
606
612
|
<dt>Allocated port</dt>
|
|
@@ -623,7 +629,7 @@ export default function Dashboard({
|
|
|
623
629
|
<span>
|
|
624
630
|
Upload source archive
|
|
625
631
|
<small>
|
|
626
|
-
Source files at the ZIP root
|
|
632
|
+
Source files at the ZIP root
|
|
627
633
|
</small>
|
|
628
634
|
</span>
|
|
629
635
|
<input
|
|
@@ -649,12 +655,12 @@ export default function Dashboard({
|
|
|
649
655
|
},
|
|
650
656
|
body: file,
|
|
651
657
|
});
|
|
652
|
-
|
|
658
|
+
toast.success(
|
|
653
659
|
"Source uploaded. Deploy when ready.",
|
|
654
660
|
);
|
|
655
661
|
await loadDetail();
|
|
656
662
|
} catch (e) {
|
|
657
|
-
|
|
663
|
+
toast.failed(e.message);
|
|
658
664
|
} finally {
|
|
659
665
|
setBusy(false);
|
|
660
666
|
}
|
|
@@ -720,6 +726,16 @@ export default function Dashboard({
|
|
|
720
726
|
)}
|
|
721
727
|
</section>
|
|
722
728
|
)}
|
|
729
|
+
{(tab === "files" ||
|
|
730
|
+
tab === "application" ||
|
|
731
|
+
tab === "database") && (
|
|
732
|
+
<ProjectWorkspace
|
|
733
|
+
tab={tab}
|
|
734
|
+
project={detail}
|
|
735
|
+
canWrite={user.role === "admin"}
|
|
736
|
+
onOpenDatabases={() => setView("databases")}
|
|
737
|
+
/>
|
|
738
|
+
)}
|
|
723
739
|
<div className="danger-zone">
|
|
724
740
|
<div>
|
|
725
741
|
<strong>Delete project</strong>
|
|
@@ -1188,17 +1204,21 @@ function Stat({ label, value, icon: Icon, note, accent, warning }) {
|
|
|
1188
1204
|
);
|
|
1189
1205
|
}
|
|
1190
1206
|
function CreateProject({ onClose, onCreated }) {
|
|
1207
|
+
const toast = useToast();
|
|
1191
1208
|
const [runtimeType, setRuntimeType] = useState("nodejs");
|
|
1192
1209
|
const [source, setSource] = useState("git"),
|
|
1193
|
-
[error, setError] = useState(""),
|
|
1194
1210
|
[busy, setBusy] = useState(false),
|
|
1195
1211
|
[file, setFile] = useState(null),
|
|
1196
1212
|
[createdId, setCreatedId] = useState(null);
|
|
1213
|
+
const busyRef = useRef(busy);
|
|
1214
|
+
const onCloseRef = useRef(onClose);
|
|
1215
|
+
busyRef.current = busy;
|
|
1216
|
+
onCloseRef.current = onClose;
|
|
1197
1217
|
useEffect(() => {
|
|
1198
1218
|
const previous = document.activeElement;
|
|
1199
1219
|
document.getElementById("project-name")?.focus();
|
|
1200
1220
|
const listener = (e) => {
|
|
1201
|
-
if (e.key === "Escape" && !
|
|
1221
|
+
if (e.key === "Escape" && !busyRef.current) onCloseRef.current();
|
|
1202
1222
|
if (e.key === "Tab") {
|
|
1203
1223
|
const items = [
|
|
1204
1224
|
...document.querySelectorAll(
|
|
@@ -1221,15 +1241,13 @@ function CreateProject({ onClose, onCreated }) {
|
|
|
1221
1241
|
document.removeEventListener("keydown", listener);
|
|
1222
1242
|
previous?.focus();
|
|
1223
1243
|
};
|
|
1224
|
-
}, [
|
|
1244
|
+
}, []);
|
|
1225
1245
|
async function submit(e) {
|
|
1226
1246
|
e.preventDefault();
|
|
1227
|
-
setError("");
|
|
1228
1247
|
setBusy(true);
|
|
1229
1248
|
try {
|
|
1230
1249
|
const values = Object.fromEntries(new FormData(e.currentTarget));
|
|
1231
|
-
if (source === "zip" &&
|
|
1232
|
-
throw Error("Choose a ZIP archive smaller than 25 MB");
|
|
1250
|
+
if (source === "zip" && !file) throw Error("Choose a ZIP archive");
|
|
1233
1251
|
let id = createdId;
|
|
1234
1252
|
if (!id) {
|
|
1235
1253
|
const project = await api("projects", {
|
|
@@ -1258,7 +1276,11 @@ function CreateProject({ onClose, onCreated }) {
|
|
|
1258
1276
|
});
|
|
1259
1277
|
await onCreated(id);
|
|
1260
1278
|
} catch (e) {
|
|
1261
|
-
|
|
1279
|
+
toast.failed(
|
|
1280
|
+
createdId
|
|
1281
|
+
? `${e.message} Project created; retry the upload or open it from your project list.`
|
|
1282
|
+
: e.message,
|
|
1283
|
+
);
|
|
1262
1284
|
} finally {
|
|
1263
1285
|
setBusy(false);
|
|
1264
1286
|
}
|
|
@@ -1334,8 +1356,8 @@ function CreateProject({ onClose, onCreated }) {
|
|
|
1334
1356
|
))}
|
|
1335
1357
|
</select>
|
|
1336
1358
|
<small>
|
|
1337
|
-
|
|
1338
|
-
|
|
1359
|
+
PHP is downloaded by the panel on Windows and Linux. Node.js,
|
|
1360
|
+
Python, and Go must already be installed on the host.
|
|
1339
1361
|
</small>
|
|
1340
1362
|
</label>
|
|
1341
1363
|
{runtimeType === "python" && (
|
|
@@ -1438,7 +1460,7 @@ function CreateProject({ onClose, onCreated }) {
|
|
|
1438
1460
|
<label className="upload">
|
|
1439
1461
|
<Upload size={24} />
|
|
1440
1462
|
<strong>{file ? file.name : "Choose a source archive"}</strong>
|
|
1441
|
-
<small>ZIP ·
|
|
1463
|
+
<small>ZIP · source files at the root</small>
|
|
1442
1464
|
<input
|
|
1443
1465
|
aria-label="Choose ZIP archive"
|
|
1444
1466
|
type="file"
|
|
@@ -1455,13 +1477,6 @@ function CreateProject({ onClose, onCreated }) {
|
|
|
1455
1477
|
your first deployment.
|
|
1456
1478
|
</p>
|
|
1457
1479
|
</div>
|
|
1458
|
-
{error && (
|
|
1459
|
-
<div role="alert" className="error">
|
|
1460
|
-
{error}
|
|
1461
|
-
{createdId &&
|
|
1462
|
-
" Project created; retry the upload or open it from your project list."}
|
|
1463
|
-
</div>
|
|
1464
|
-
)}
|
|
1465
1480
|
<div className="modal-actions">
|
|
1466
1481
|
<button
|
|
1467
1482
|
type="button"
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { Copy, Database, Plus, Trash2 } from "lucide-react";
|
|
4
4
|
import { SkeletonTable } from "./skeleton";
|
|
5
|
+
import { useConfirm } from "./confirm";
|
|
6
|
+
import { useToast } from "./toast";
|
|
5
7
|
|
|
6
8
|
async function api(url, options = {}) {
|
|
7
9
|
const response = await fetch(`/api/${url}`, {
|
|
@@ -18,80 +20,124 @@ async function api(url, options = {}) {
|
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export function DatabaseScreen({ canWrite }) {
|
|
23
|
+
const confirm = useConfirm();
|
|
24
|
+
const toast = useToast();
|
|
25
|
+
const nativeError = useRef("");
|
|
21
26
|
const [data, setData] = useState(null);
|
|
22
27
|
const [loading, setLoading] = useState(true);
|
|
23
28
|
const [busy, setBusy] = useState(false);
|
|
24
|
-
const [error, setError] = useState("");
|
|
25
29
|
const [secret, setSecret] = useState(null);
|
|
30
|
+
const [native, setNative] = useState(null);
|
|
31
|
+
const [browse, setBrowse] = useState(null);
|
|
26
32
|
const load = useCallback(async () => {
|
|
27
33
|
try {
|
|
28
34
|
setData(await api("databases"));
|
|
29
|
-
setError("");
|
|
30
35
|
} catch (e) {
|
|
31
|
-
|
|
36
|
+
toast.failed(e.message);
|
|
32
37
|
} finally {
|
|
33
38
|
setLoading(false);
|
|
34
39
|
}
|
|
35
|
-
}, []);
|
|
40
|
+
}, [toast]);
|
|
41
|
+
const loadNative = useCallback(async () => {
|
|
42
|
+
if (!canWrite) return;
|
|
43
|
+
try {
|
|
44
|
+
setNative(await api("databases/native"));
|
|
45
|
+
} catch (e) {
|
|
46
|
+
setNative({ error: e.message });
|
|
47
|
+
}
|
|
48
|
+
}, [canWrite]);
|
|
36
49
|
useEffect(() => {
|
|
37
50
|
load();
|
|
38
|
-
|
|
51
|
+
loadNative();
|
|
52
|
+
}, [load, loadNative]);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const message = native?.error || "";
|
|
55
|
+
if (message === nativeError.current) return;
|
|
56
|
+
nativeError.current = message;
|
|
57
|
+
if (message) toast.failed(message);
|
|
58
|
+
}, [native, toast]);
|
|
59
|
+
const openDatabase = async (name) => {
|
|
60
|
+
setBusy(true);
|
|
61
|
+
try {
|
|
62
|
+
setBrowse(await api(`databases/native/${encodeURIComponent(name)}`));
|
|
63
|
+
} catch (e) {
|
|
64
|
+
toast.failed(e.message);
|
|
65
|
+
} finally {
|
|
66
|
+
setBusy(false);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const openTable = async (name, table) => {
|
|
70
|
+
setBusy(true);
|
|
71
|
+
try {
|
|
72
|
+
setBrowse(
|
|
73
|
+
await api(
|
|
74
|
+
`databases/native/${encodeURIComponent(name)}/${encodeURIComponent(table)}`,
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
toast.failed(e.message);
|
|
79
|
+
} finally {
|
|
80
|
+
setBusy(false);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
39
83
|
const create = async (event) => {
|
|
40
84
|
event.preventDefault();
|
|
41
85
|
const form = event.currentTarget;
|
|
42
86
|
const name = new FormData(form).get("name");
|
|
43
|
-
const engine = new FormData(form).get("engine");
|
|
44
87
|
setBusy(true);
|
|
45
|
-
setError("");
|
|
46
88
|
try {
|
|
47
89
|
const created = await api("databases", {
|
|
48
90
|
method: "POST",
|
|
49
|
-
body: JSON.stringify({
|
|
50
|
-
name,
|
|
51
|
-
...(engine === "native" ? {} : { engine }),
|
|
52
|
-
}),
|
|
91
|
+
body: JSON.stringify({ name }),
|
|
53
92
|
});
|
|
54
93
|
form.reset();
|
|
55
94
|
setSecret(created);
|
|
95
|
+
toast.success("Database created.");
|
|
56
96
|
await load();
|
|
97
|
+
await loadNative();
|
|
57
98
|
} catch (e) {
|
|
58
|
-
|
|
99
|
+
toast.failed(e.message);
|
|
59
100
|
} finally {
|
|
60
101
|
setBusy(false);
|
|
61
102
|
}
|
|
62
103
|
};
|
|
63
104
|
const resetPassword = async (item) => {
|
|
64
105
|
if (
|
|
65
|
-
!
|
|
66
|
-
`Replace the password for ${item.database}
|
|
67
|
-
|
|
106
|
+
!(await confirm({
|
|
107
|
+
title: `Replace the password for ${item.database}?`,
|
|
108
|
+
message: "The current password will stop working.",
|
|
109
|
+
confirmLabel: "Replace password",
|
|
110
|
+
}))
|
|
68
111
|
)
|
|
69
112
|
return;
|
|
70
113
|
setBusy(true);
|
|
71
|
-
setError("");
|
|
72
114
|
try {
|
|
73
115
|
setSecret(await api(`databases/${item.id}/password`, { method: "POST" }));
|
|
116
|
+
toast.success("Password replaced.");
|
|
74
117
|
} catch (e) {
|
|
75
|
-
|
|
118
|
+
toast.failed(e.message);
|
|
76
119
|
} finally {
|
|
77
120
|
setBusy(false);
|
|
78
121
|
}
|
|
79
122
|
};
|
|
80
123
|
const remove = async (item) => {
|
|
81
124
|
if (
|
|
82
|
-
!
|
|
83
|
-
`Delete
|
|
84
|
-
|
|
125
|
+
!(await confirm({
|
|
126
|
+
title: `Delete ${item.database}?`,
|
|
127
|
+
message: "Applications using it will lose this data.",
|
|
128
|
+
confirmLabel: "Delete",
|
|
129
|
+
}))
|
|
85
130
|
)
|
|
86
131
|
return;
|
|
87
132
|
setBusy(true);
|
|
88
|
-
setError("");
|
|
89
133
|
try {
|
|
90
134
|
await api(`databases/${item.id}`, { method: "DELETE" });
|
|
91
135
|
if (secret?.id === item.id) setSecret(null);
|
|
136
|
+
toast.success("Database deleted.");
|
|
92
137
|
await load();
|
|
138
|
+
await loadNative();
|
|
93
139
|
} catch (e) {
|
|
94
|
-
|
|
140
|
+
toast.failed(e.message);
|
|
95
141
|
} finally {
|
|
96
142
|
setBusy(false);
|
|
97
143
|
}
|
|
@@ -100,7 +146,7 @@ export function DatabaseScreen({ canWrite }) {
|
|
|
100
146
|
try {
|
|
101
147
|
await navigator.clipboard.writeText(value);
|
|
102
148
|
} catch {
|
|
103
|
-
|
|
149
|
+
toast.failed("Could not copy to the clipboard.");
|
|
104
150
|
}
|
|
105
151
|
};
|
|
106
152
|
const rows = data?.databases || [];
|
|
@@ -112,13 +158,12 @@ export function DatabaseScreen({ canWrite }) {
|
|
|
112
158
|
Databases<span className="title-dot">.</span>
|
|
113
159
|
</h1>
|
|
114
160
|
<p>
|
|
115
|
-
|
|
116
|
-
|
|
161
|
+
Databases are created on the PostgreSQL server already running on
|
|
162
|
+
this machine.
|
|
117
163
|
</p>
|
|
118
164
|
</div>
|
|
119
165
|
</div>
|
|
120
166
|
<div className="stack database-screen">
|
|
121
|
-
{error && <div className="error">{error}</div>}
|
|
122
167
|
{secret && (
|
|
123
168
|
<section className="credential">
|
|
124
169
|
<strong>Password for {secret.database}</strong>
|
|
@@ -154,34 +199,11 @@ export function DatabaseScreen({ canWrite }) {
|
|
|
154
199
|
<section className="panel">
|
|
155
200
|
<form className="database-create" onSubmit={create}>
|
|
156
201
|
<label>
|
|
157
|
-
Database
|
|
202
|
+
Database name
|
|
158
203
|
<small>
|
|
159
|
-
|
|
204
|
+
Created on the native PostgreSQL server. Lowercase letters,
|
|
205
|
+
numbers, and underscores.
|
|
160
206
|
</small>
|
|
161
|
-
<select
|
|
162
|
-
name="engine"
|
|
163
|
-
aria-label="Database engine"
|
|
164
|
-
disabled={!canWrite || busy}
|
|
165
|
-
>
|
|
166
|
-
{(
|
|
167
|
-
data?.engines || [
|
|
168
|
-
{ value: "postgresql", label: "PostgreSQL" },
|
|
169
|
-
{ value: "mysql", label: "MySQL" },
|
|
170
|
-
{ value: "mariadb", label: "MariaDB" },
|
|
171
|
-
]
|
|
172
|
-
).map((engine) => (
|
|
173
|
-
<option key={engine.value} value={engine.value}>
|
|
174
|
-
{engine.label}
|
|
175
|
-
</option>
|
|
176
|
-
))}
|
|
177
|
-
<option value="native">
|
|
178
|
-
PostgreSQL (existing native server)
|
|
179
|
-
</option>
|
|
180
|
-
</select>
|
|
181
|
-
</label>
|
|
182
|
-
<label>
|
|
183
|
-
Database name
|
|
184
|
-
<small>Lowercase letters, numbers, and underscores.</small>
|
|
185
207
|
<input
|
|
186
208
|
name="name"
|
|
187
209
|
placeholder="crm"
|
|
@@ -196,13 +218,100 @@ export function DatabaseScreen({ canWrite }) {
|
|
|
196
218
|
{busy ? "Working…" : "Create database"}
|
|
197
219
|
</button>
|
|
198
220
|
</form>
|
|
199
|
-
{busy && (
|
|
200
|
-
<p role="status">
|
|
201
|
-
Preparing the runtime and database. First setup can take several
|
|
202
|
-
minutes. Keep this page open.
|
|
203
|
-
</p>
|
|
204
|
-
)}
|
|
205
221
|
</section>
|
|
222
|
+
{canWrite && (
|
|
223
|
+
<section className="panel native-browser">
|
|
224
|
+
<div className="section-heading">
|
|
225
|
+
<h2>Native PostgreSQL</h2>
|
|
226
|
+
<span className="muted">
|
|
227
|
+
{native?.host
|
|
228
|
+
? `${native.host}:${native.port}`
|
|
229
|
+
: native?.error || "Checking the local server"}
|
|
230
|
+
</span>
|
|
231
|
+
</div>
|
|
232
|
+
{native?.databases?.length ? (
|
|
233
|
+
<div className="db-pills" role="list">
|
|
234
|
+
{native.databases.map((item) => (
|
|
235
|
+
<button
|
|
236
|
+
key={item.name}
|
|
237
|
+
type="button"
|
|
238
|
+
className={
|
|
239
|
+
browse?.database === item.name ? "active" : ""
|
|
240
|
+
}
|
|
241
|
+
disabled={busy}
|
|
242
|
+
onClick={() => openDatabase(item.name)}
|
|
243
|
+
>
|
|
244
|
+
{item.name}
|
|
245
|
+
</button>
|
|
246
|
+
))}
|
|
247
|
+
</div>
|
|
248
|
+
) : (
|
|
249
|
+
native &&
|
|
250
|
+
!native.error && (
|
|
251
|
+
<p className="muted browser-note">
|
|
252
|
+
No application databases yet.
|
|
253
|
+
</p>
|
|
254
|
+
)
|
|
255
|
+
)}
|
|
256
|
+
{browse && (
|
|
257
|
+
<>
|
|
258
|
+
<div className="db-pills" role="list">
|
|
259
|
+
{browse.tables.length ? (
|
|
260
|
+
browse.tables.map((table) => (
|
|
261
|
+
<button
|
|
262
|
+
key={table}
|
|
263
|
+
type="button"
|
|
264
|
+
className={browse.table === table ? "active" : ""}
|
|
265
|
+
disabled={busy}
|
|
266
|
+
onClick={() => openTable(browse.database, table)}
|
|
267
|
+
>
|
|
268
|
+
{table}
|
|
269
|
+
</button>
|
|
270
|
+
))
|
|
271
|
+
) : (
|
|
272
|
+
<p className="muted browser-note">
|
|
273
|
+
No tables in the public schema.
|
|
274
|
+
</p>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
{browse.table &&
|
|
278
|
+
(browse.rows.length ? (
|
|
279
|
+
<div className="table-wrap">
|
|
280
|
+
<table>
|
|
281
|
+
<thead>
|
|
282
|
+
<tr>
|
|
283
|
+
{browse.columns.map((column) => (
|
|
284
|
+
<th key={column.name}>{column.name}</th>
|
|
285
|
+
))}
|
|
286
|
+
</tr>
|
|
287
|
+
</thead>
|
|
288
|
+
<tbody>
|
|
289
|
+
{browse.rows.map((row, index) => (
|
|
290
|
+
<tr key={index}>
|
|
291
|
+
{browse.columns.map((column) => (
|
|
292
|
+
<td key={column.name} className="mono">
|
|
293
|
+
{row[column.name] == null
|
|
294
|
+
? "—"
|
|
295
|
+
: String(row[column.name])}
|
|
296
|
+
</td>
|
|
297
|
+
))}
|
|
298
|
+
</tr>
|
|
299
|
+
))}
|
|
300
|
+
</tbody>
|
|
301
|
+
</table>
|
|
302
|
+
</div>
|
|
303
|
+
) : (
|
|
304
|
+
<p className="muted browser-note">This table is empty.</p>
|
|
305
|
+
))}
|
|
306
|
+
{browse.table && (
|
|
307
|
+
<p className="muted browser-note">
|
|
308
|
+
Showing up to 50 rows. Passwords and tokens stay hidden.
|
|
309
|
+
</p>
|
|
310
|
+
)}
|
|
311
|
+
</>
|
|
312
|
+
)}
|
|
313
|
+
</section>
|
|
314
|
+
)}
|
|
206
315
|
<section className="panel">
|
|
207
316
|
{!data && loading ? (
|
|
208
317
|
<SkeletonTable
|