@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.
@@ -5,23 +5,25 @@ import { db } from "../../../lib/db.mjs";
5
5
  import { session, signIn } from "../../../lib/auth.mjs";
6
6
  import { originAllowed } from "../../../lib/request-origin.mjs";
7
7
  import { agent, reconcile } from "../../../lib/agent.mjs";
8
- import { projectInput, idSchema, MAX_ZIP_BYTES } from "@naviyra/shared";
8
+ import { projectInput, runtimeConfig, idSchema } from "@naviyra/shared";
9
9
  import {
10
10
  listDatabases,
11
11
  createDatabase,
12
12
  deleteDatabase,
13
13
  resetDatabasePassword,
14
+ listNativeDatabases,
15
+ browseNativeDatabase,
14
16
  } from "../../../lib/databases.mjs";
15
17
  export const runtime = "nodejs";
16
18
  export const dynamic = "force-dynamic";
17
19
  const attempts = (globalThis.naviyraAttempts ||= new Map());
18
20
  const json = (data, status = 200) =>
19
21
  Response.json(data, { status, headers: { "Cache-Control": "no-store" } });
20
- async function readJson(req) {
21
- if (Number(req.headers.get("content-length")) > 8192)
22
+ async function readJson(req, limit = 8192) {
23
+ if (Number(req.headers.get("content-length")) > limit)
22
24
  throw Object.assign(Error("Request too large"), { status: 413 });
23
25
  const text = await req.text();
24
- if (text.length > 8192)
26
+ if (text.length > limit)
25
27
  throw Object.assign(Error("Request too large"), { status: 413 });
26
28
  return JSON.parse(text);
27
29
  }
@@ -91,6 +93,15 @@ async function handle(req, { params }) {
91
93
  }
92
94
  if (route === "databases" && method === "GET")
93
95
  return json(await listDatabases(user.id));
96
+ if (parts[0] === "databases" && parts[1] === "native") {
97
+ if (user.role !== "admin")
98
+ return json({ error: "Administrator access required" }, 403);
99
+ if (method !== "GET") return json({ error: "Method not allowed" }, 405);
100
+ if (parts.length === 2) return json(await listNativeDatabases());
101
+ if (parts.length === 3 || parts.length === 4)
102
+ return json(await browseNativeDatabase(parts[2], parts[3]));
103
+ return json({ error: "Not found" }, 404);
104
+ }
94
105
  if (route === "databases" && method === "POST") {
95
106
  const body = await readJson(req);
96
107
  const created = await createDatabase(
@@ -313,6 +324,82 @@ async function handle(req, { params }) {
313
324
  ])
314
325
  ).rows[0];
315
326
  if (!project) return json({ error: "Project not found" }, 404);
327
+ if (action === "files") {
328
+ const search = new URL(req.url).search || "";
329
+ if (method === "GET" && new URL(req.url).searchParams.get("download") === "1") {
330
+ const response = await agent(`/projects/${id}/files${search}`, {
331
+ raw: true,
332
+ });
333
+ return new Response(response.body, {
334
+ status: 200,
335
+ headers: {
336
+ "Content-Type": "application/octet-stream",
337
+ "Content-Disposition":
338
+ response.headers.get("content-disposition") || "attachment",
339
+ "Cache-Control": "no-store",
340
+ },
341
+ });
342
+ }
343
+ if (method === "GET")
344
+ return json(await agent(`/projects/${id}/files${search}`));
345
+ if (method === "POST") {
346
+ const length = Number(req.headers.get("content-length") || 0);
347
+ if (length > 32 * 1024 * 1024)
348
+ return json({ error: "File is larger than 32 MB" }, 413);
349
+ return json(
350
+ await agent(`/projects/${id}/files${search}`, {
351
+ method: "POST",
352
+ headers: { "Content-Type": "application/octet-stream" },
353
+ body: req.body,
354
+ duplex: "half",
355
+ signal: AbortSignal.timeout(10 * 60 * 1000),
356
+ }),
357
+ );
358
+ }
359
+ if (method === "PUT") {
360
+ const body = await readJson(req, 2 * 1024 * 1024);
361
+ return json(
362
+ await agent(`/projects/${id}/files`, {
363
+ method: "PUT",
364
+ body: JSON.stringify(body),
365
+ }),
366
+ );
367
+ }
368
+ if (method === "DELETE")
369
+ return json(
370
+ await agent(`/projects/${id}/files${search}`, { method: "DELETE" }),
371
+ );
372
+ return json({ error: "Method not allowed" }, 405);
373
+ }
374
+ if (method === "PUT" && action === "config") {
375
+ const body = await readJson(req, 32768);
376
+ const types = new Set([
377
+ "nodejs",
378
+ "python",
379
+ "php",
380
+ "go",
381
+ "static",
382
+ "custom",
383
+ ]);
384
+ const type = types.has(body.type) ? body.type : project.type;
385
+ const config = runtimeConfig.parse({
386
+ ...(project.runtime_config || {}),
387
+ ...(body.runtime_config || {}),
388
+ });
389
+ await db.query(
390
+ "UPDATE projects SET type=$2, runtime_config=$3, updated_at=now() WHERE id=$1",
391
+ [id, type, JSON.stringify(config)],
392
+ );
393
+ await agent("/projects", {
394
+ method: "POST",
395
+ body: JSON.stringify({ ...project, type, runtime_config: config }),
396
+ });
397
+ await db.query(
398
+ "INSERT INTO audit_logs(user_id,project_id,action) VALUES($1,$2,$3)",
399
+ [user.id, id, "project.config"],
400
+ );
401
+ return json({ type, runtime_config: config });
402
+ }
316
403
  if (method === "GET" && !action) {
317
404
  let current = project;
318
405
  try {
@@ -333,14 +420,6 @@ async function handle(req, { params }) {
333
420
  if (method === "PUT" && action === "source") {
334
421
  if (project.source_type !== "zip")
335
422
  return json({ error: "This project uses Git" }, 400);
336
- const chunks = [];
337
- let size = 0;
338
- for await (const chunk of req.body) {
339
- size += chunk.byteLength;
340
- if (size > MAX_ZIP_BYTES)
341
- return json({ error: "ZIP limit is 25 MB" }, 413);
342
- chunks.push(Buffer.from(chunk));
343
- }
344
423
  await agent("/projects", {
345
424
  method: "POST",
346
425
  body: JSON.stringify(project),
@@ -349,7 +428,9 @@ async function handle(req, { params }) {
349
428
  await agent(`/projects/${id}/source`, {
350
429
  method: "PUT",
351
430
  headers: { "Content-Type": "application/zip" },
352
- body: Buffer.concat(chunks),
431
+ body: req.body,
432
+ duplex: "half",
433
+ signal: AbortSignal.timeout(24 * 60 * 60 * 1000),
353
434
  }),
354
435
  );
355
436
  }
@@ -976,6 +976,84 @@ footer > span:last-child {
976
976
  color: #227757;
977
977
  border: 1px solid #d6eadc;
978
978
  }
979
+ .toast-stack {
980
+ position: fixed;
981
+ top: 22px;
982
+ right: 22px;
983
+ z-index: 70;
984
+ display: flex;
985
+ flex-direction: column;
986
+ gap: 10px;
987
+ width: min(380px, calc(100vw - 32px));
988
+ pointer-events: none;
989
+ }
990
+ .toast {
991
+ pointer-events: auto;
992
+ border-radius: 8px;
993
+ border: 1px solid transparent;
994
+ box-shadow: 0 14px 36px #142f3c24;
995
+ overflow: hidden;
996
+ }
997
+ .toast.success {
998
+ background: #eaf7ef;
999
+ color: #1d6b4a;
1000
+ border-color: #d6eadc;
1001
+ }
1002
+ .toast.progress {
1003
+ background: #fff6e8;
1004
+ color: #8a5a12;
1005
+ border-color: #f0e0c2;
1006
+ }
1007
+ .toast.failed {
1008
+ background: #fff0ed;
1009
+ color: #b85243;
1010
+ border-color: #f3d9d4;
1011
+ }
1012
+ .toast-row {
1013
+ display: flex;
1014
+ align-items: flex-start;
1015
+ justify-content: space-between;
1016
+ gap: 12px;
1017
+ padding: 14px 14px 12px;
1018
+ }
1019
+ .toast-row p {
1020
+ margin: 0;
1021
+ font-size: 13px;
1022
+ line-height: 1.5;
1023
+ }
1024
+ .toast-row button {
1025
+ background: transparent;
1026
+ color: inherit;
1027
+ padding: 0;
1028
+ }
1029
+ .toast-track {
1030
+ height: 5px;
1031
+ background: #00000014;
1032
+ }
1033
+ .toast.success .toast-progress {
1034
+ background: #1d6b4a;
1035
+ }
1036
+ .toast.progress .toast-progress {
1037
+ background: #c4842a;
1038
+ }
1039
+ .toast.failed .toast-progress {
1040
+ background: #b85243;
1041
+ }
1042
+ .toast-progress {
1043
+ display: block;
1044
+ height: 100%;
1045
+ width: 100%;
1046
+ transform-origin: left center;
1047
+ animation: toast-countdown 10s linear forwards;
1048
+ }
1049
+ @keyframes toast-countdown {
1050
+ from {
1051
+ transform: scaleX(1);
1052
+ }
1053
+ to {
1054
+ transform: scaleX(0);
1055
+ }
1056
+ }
979
1057
  .dismissible {
980
1058
  display: flex;
981
1059
  justify-content: space-between;
@@ -1011,6 +1089,7 @@ footer > span:last-child {
1011
1089
  }
1012
1090
  .tabs {
1013
1091
  display: flex;
1092
+ flex-wrap: wrap;
1014
1093
  gap: 25px;
1015
1094
  border-bottom: 1px solid #dce4e7;
1016
1095
  margin-bottom: 22px;
@@ -1033,7 +1112,7 @@ footer > span:last-child {
1033
1112
  }
1034
1113
  dl {
1035
1114
  display: grid;
1036
- grid-template-columns: 120px minmax(0, 1fr);
1115
+ grid-template-columns: 140px minmax(0, 1fr);
1037
1116
  gap: 19px 15px;
1038
1117
  padding: 24px;
1039
1118
  margin: 0;
@@ -1280,6 +1359,35 @@ label small {
1280
1359
  margin-top: 24px;
1281
1360
  padding-top: 20px;
1282
1361
  }
1362
+ .alert-backdrop {
1363
+ z-index: 80;
1364
+ }
1365
+ .alert-modal {
1366
+ width: 420px;
1367
+ }
1368
+ .alert-modal h2 {
1369
+ margin-bottom: 8px;
1370
+ }
1371
+ .alert-modal > p {
1372
+ color: var(--muted);
1373
+ line-height: 1.6;
1374
+ margin-bottom: 0;
1375
+ }
1376
+ .alert-confirm {
1377
+ color: white;
1378
+ background: #b65c58;
1379
+ border: 1px solid #b65c58;
1380
+ border-radius: 6px;
1381
+ padding: 11px 16px;
1382
+ min-height: 38px;
1383
+ font-size: 11px;
1384
+ font-weight: 550;
1385
+ }
1386
+ .alert-confirm:focus-visible,
1387
+ .alert-modal .secondary:focus-visible {
1388
+ outline: 2px solid var(--green);
1389
+ outline-offset: 2px;
1390
+ }
1283
1391
  .login {
1284
1392
  min-height: 100vh;
1285
1393
  display: grid;
@@ -1709,9 +1817,36 @@ html[data-theme="dark"] .danger-text {
1709
1817
  .database-screen {
1710
1818
  margin-bottom: 10px;
1711
1819
  }
1820
+ .native-browser {
1821
+ padding-bottom: 8px;
1822
+ }
1823
+ .native-browser .section-heading {
1824
+ padding: 18px 23px 0;
1825
+ }
1826
+ .db-pills {
1827
+ display: flex;
1828
+ flex-wrap: wrap;
1829
+ gap: 8px;
1830
+ padding: 16px 23px;
1831
+ }
1832
+ .db-pills button {
1833
+ border: 1px solid #dde4e7;
1834
+ border-radius: 6px;
1835
+ background: transparent;
1836
+ color: #3e5360;
1837
+ font-size: 12px;
1838
+ padding: 7px 10px;
1839
+ }
1840
+ .db-pills button.active {
1841
+ border-color: var(--green);
1842
+ color: var(--green);
1843
+ }
1844
+ .browser-note {
1845
+ padding: 0 23px 16px;
1846
+ }
1712
1847
  .database-create {
1713
1848
  display: grid;
1714
- grid-template-columns: minmax(240px, 340px) minmax(220px, 1fr) auto;
1849
+ grid-template-columns: minmax(220px, 1fr) auto;
1715
1850
  align-items: end;
1716
1851
  gap: 12px 16px;
1717
1852
  padding: 18px 23px 20px;
@@ -1755,6 +1890,10 @@ html[data-theme="dark"] .danger-text {
1755
1890
  height: 40px;
1756
1891
  min-height: 40px;
1757
1892
  }
1893
+ html[data-theme="dark"] .db-pills button {
1894
+ border-color: #31464c;
1895
+ color: #d5e4ea;
1896
+ }
1758
1897
  html[data-theme="dark"] .database-create select {
1759
1898
  background-color: var(--surface);
1760
1899
  color: var(--ink);
@@ -2063,11 +2202,37 @@ html[data-theme="dark"] .badge.queued {
2063
2202
  background: #3a321c;
2064
2203
  color: #efd392;
2065
2204
  }
2066
- html[data-theme="dark"] .notice {
2205
+ html[data-theme="dark"] .notice,
2206
+ html[data-theme="dark"] .toast.success {
2067
2207
  background: #17352c;
2068
2208
  color: #b7efe4;
2069
2209
  border-color: #245246;
2070
2210
  }
2211
+ html[data-theme="dark"] .toast {
2212
+ box-shadow: 0 14px 36px #00000066;
2213
+ }
2214
+ html[data-theme="dark"] .toast.progress {
2215
+ background: #3a321c;
2216
+ color: #efd392;
2217
+ border-color: #5a4a2e;
2218
+ }
2219
+ html[data-theme="dark"] .toast.failed {
2220
+ background: #3a2422;
2221
+ color: #f0b2a8;
2222
+ border-color: #5a332e;
2223
+ }
2224
+ html[data-theme="dark"] .toast-track {
2225
+ background: #ffffff18;
2226
+ }
2227
+ html[data-theme="dark"] .toast.success .toast-progress {
2228
+ background: #8ee0d0;
2229
+ }
2230
+ html[data-theme="dark"] .toast.progress .toast-progress {
2231
+ background: #efd392;
2232
+ }
2233
+ html[data-theme="dark"] .toast.failed .toast-progress {
2234
+ background: #f0b2a8;
2235
+ }
2071
2236
  html[data-theme="dark"] .quick-start {
2072
2237
  background: linear-gradient(110deg, #17342e, #172126);
2073
2238
  }
@@ -2102,3 +2267,97 @@ html[data-theme="dark"] .stat,
2102
2267
  html[data-theme="dark"] .panel {
2103
2268
  border-color: var(--line);
2104
2269
  }
2270
+ .file-crumbs,
2271
+ .file-actions,
2272
+ .env-row,
2273
+ .app-fields {
2274
+ display: flex;
2275
+ flex-wrap: wrap;
2276
+ gap: 8px;
2277
+ align-items: center;
2278
+ margin: 12px 0;
2279
+ }
2280
+ .file-crumbs button,
2281
+ .file-row > button,
2282
+ .file-row-actions button,
2283
+ .file-row-actions a {
2284
+ background: transparent;
2285
+ color: var(--ink);
2286
+ padding: 0;
2287
+ }
2288
+ .file-crumbs button + button::before {
2289
+ content: "/";
2290
+ margin-right: 8px;
2291
+ color: var(--muted);
2292
+ }
2293
+ .file-list {
2294
+ display: grid;
2295
+ gap: 6px;
2296
+ }
2297
+ .file-row {
2298
+ display: flex;
2299
+ justify-content: space-between;
2300
+ align-items: center;
2301
+ gap: 12px;
2302
+ padding: 8px 10px;
2303
+ border: 1px solid var(--line);
2304
+ border-radius: 8px;
2305
+ background: var(--surface);
2306
+ }
2307
+ .file-row > button {
2308
+ display: flex;
2309
+ justify-content: space-between;
2310
+ flex: 1;
2311
+ text-align: left;
2312
+ gap: 12px;
2313
+ }
2314
+ .file-row small {
2315
+ color: var(--muted);
2316
+ }
2317
+ .file-row-actions {
2318
+ display: flex;
2319
+ gap: 10px;
2320
+ align-items: center;
2321
+ }
2322
+ .file-upload input {
2323
+ display: none;
2324
+ }
2325
+ .file-editor {
2326
+ width: 100%;
2327
+ min-height: 280px;
2328
+ margin: 8px 0 12px;
2329
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
2330
+ font-size: 13px;
2331
+ }
2332
+ .app-fields {
2333
+ align-items: end;
2334
+ }
2335
+ .app-fields label {
2336
+ display: grid;
2337
+ gap: 6px;
2338
+ min-width: 180px;
2339
+ font-size: 13px;
2340
+ }
2341
+ .env-row input {
2342
+ min-width: 140px;
2343
+ }
2344
+ .connection-box,
2345
+ .db-mini {
2346
+ margin: 14px 0;
2347
+ }
2348
+ .connection-box code {
2349
+ display: block;
2350
+ margin: 8px 0;
2351
+ overflow-wrap: anywhere;
2352
+ }
2353
+ .db-mini {
2354
+ list-style: none;
2355
+ padding: 0;
2356
+ }
2357
+ .db-mini li {
2358
+ display: flex;
2359
+ justify-content: space-between;
2360
+ gap: 12px;
2361
+ padding: 8px 0;
2362
+ border-bottom: 1px solid var(--line);
2363
+ }
@@ -1,5 +1,6 @@
1
1
  import "./globals.css";
2
2
  import { ThemeSync } from "./ui/theme";
3
+ import { ToastProvider } from "./ui/toast";
3
4
  export const metadata = {
4
5
  title: "Naviyra · Cloudflare hosting",
5
6
  description: "Your applications. Your infrastructure. One panel.",
@@ -11,7 +12,7 @@ export default function Layout({ children }) {
11
12
  <body>
12
13
  <script dangerouslySetInnerHTML={{ __html: themeScript }} />
13
14
  <ThemeSync />
14
- {children}
15
+ <ToastProvider>{children}</ToastProvider>
15
16
  </body>
16
17
  </html>
17
18
  );
@@ -2,15 +2,15 @@
2
2
  import { useEffect, useState } from "react";
3
3
  import { ArrowRight, ShieldCheck, Cloud, Terminal } from "lucide-react";
4
4
  import { ThemeToggle } from "../ui/theme";
5
+ import { useToast } from "../ui/toast";
5
6
  export default function Login() {
6
- const [error, setError] = useState("");
7
+ const toast = useToast();
7
8
  const [busy, setBusy] = useState(false);
8
9
  const [ready, setReady] = useState(false);
9
10
  useEffect(() => setReady(true), []);
10
11
  async function submit(event) {
11
12
  event.preventDefault();
12
13
  setBusy(true);
13
- setError("");
14
14
  const values = Object.fromEntries(new FormData(event.currentTarget));
15
15
  try {
16
16
  const response = await fetch("/api/auth/login", {
@@ -22,7 +22,7 @@ export default function Login() {
22
22
  if (!response.ok) throw Error(body.error);
23
23
  window.location.href = "/";
24
24
  } catch (error) {
25
- setError(error.message);
25
+ toast.failed(error.message);
26
26
  setBusy(false);
27
27
  }
28
28
  }
@@ -86,11 +86,6 @@ export default function Login() {
86
86
  required
87
87
  />
88
88
  </label>
89
- {error && (
90
- <div role="alert" className="error">
91
- {error}
92
- </div>
93
- )}
94
89
  <button className="primary" disabled={busy || !ready}>
95
90
  {busy ? "Signing in…" : "Sign in"}
96
91
  <ArrowRight size={17} />
@@ -1,6 +1,7 @@
1
1
  import { redirect } from "next/navigation";
2
2
  import { session } from "../lib/auth.mjs";
3
3
  import Dashboard from "./ui/dashboard";
4
+ import { ConfirmProvider } from "./ui/confirm";
4
5
  export const dynamic = "force-dynamic";
5
6
  const views = new Set([
6
7
  "projects",
@@ -10,7 +11,14 @@ const views = new Set([
10
11
  "dns",
11
12
  "email",
12
13
  ]);
13
- const tabs = new Set(["overview", "logs", "deployments"]);
14
+ const tabs = new Set([
15
+ "overview",
16
+ "files",
17
+ "application",
18
+ "database",
19
+ "logs",
20
+ "deployments",
21
+ ]);
14
22
  const projectId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
23
  function one(value) {
16
24
  return (Array.isArray(value) ? value[0] : value) || "";
@@ -23,11 +31,13 @@ export default async function Page({ searchParams }) {
23
31
  const project = projectId.test(one(params?.project)) ? one(params.project) : null;
24
32
  const tab = tabs.has(one(params?.tab)) ? one(params.tab) : "overview";
25
33
  return (
26
- <Dashboard
27
- user={user}
28
- initialView={view}
29
- initialProject={project}
30
- initialTab={tab}
31
- />
34
+ <ConfirmProvider>
35
+ <Dashboard
36
+ user={user}
37
+ initialView={view}
38
+ initialProject={project}
39
+ initialTab={tab}
40
+ />
41
+ </ConfirmProvider>
32
42
  );
33
43
  }