@naviyra/cloud-panel 0.0.3-beta2 → 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.
@@ -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
- setError("");
112
+ listError.current = "";
107
113
  } catch (e) {
108
- setError(e.message);
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
- setError(e.message);
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
- !window.confirm(
202
- `Delete ${detail.name}? Its source, services, tunnel and DNS route will be removed.`,
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
- setNotice("Project deleted.");
215
- } else
216
- setNotice(
217
- kind === "deploy"
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
- setError(e.message);
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
- setNotice("Cloudflare account connected.");
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
- {["overview", "logs", "deployments"].map((t) => (
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={t}
566
- className={tab === t ? "active" : ""}
567
- onClick={() => setTab(t)}
565
+ key={id}
566
+ className={tab === id ? "active" : ""}
567
+ onClick={() => setTab(id)}
568
568
  >
569
- {t[0].toUpperCase() + t.slice(1)}
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 · max 25 MB
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
- setNotice(
658
+ toast.success(
653
659
  "Source uploaded. Deploy when ready.",
654
660
  );
655
661
  await loadDetail();
656
662
  } catch (e) {
657
- setError(e.message);
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" && !busy) onClose();
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
- }, [busy, onClose]);
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" && (!file || file.size > 25 * 1024 * 1024))
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
- setError(e.message);
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
- Native deployment on Windows and Linux. Install the selected
1338
- runtime on your host.
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 · Up to 25 MB · source files at the root</small>
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
- setError(e.message);
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
- }, [load]);
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
- setError(e.message);
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
- !window.confirm(
66
- `Replace the password for ${item.database}? The current password will stop working.`,
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
- setError(e.message);
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
- !window.confirm(
83
- `Delete database ${item.database}? Applications using it will lose this data.`,
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
- setError(e.message);
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
- setError("Could not copy to the clipboard.");
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
- Choose a database for applications on this server. Missing engines
116
- download automatically.
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 engine
202
+ Database name
158
203
  <small>
159
- Naviyra installs and manages the runtime automatically.
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