@naviyra/cloud-panel 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.env.example +40 -0
  2. package/README.md +145 -2
  3. package/apps/agent/package.json +13 -0
  4. package/apps/agent/src/cloudflare.mjs +733 -0
  5. package/apps/agent/src/native-worker.mjs +52 -0
  6. package/apps/agent/src/native.mjs +457 -0
  7. package/apps/agent/src/runtime.mjs +760 -0
  8. package/apps/agent/src/server.mjs +195 -0
  9. package/apps/agent/src/static-server.mjs +53 -0
  10. package/apps/panel/AGENTS.md +9 -0
  11. package/apps/panel/CLAUDE.md +1 -0
  12. package/apps/panel/app/api/[...path]/route.js +445 -0
  13. package/apps/panel/app/globals.css +2057 -0
  14. package/apps/panel/app/icon.png +0 -0
  15. package/apps/panel/app/layout.jsx +18 -0
  16. package/apps/panel/app/login/page.jsx +108 -0
  17. package/apps/panel/app/page.jsx +33 -0
  18. package/apps/panel/app/ui/cloudflare.jsx +926 -0
  19. package/apps/panel/app/ui/dashboard.jsx +1487 -0
  20. package/apps/panel/app/ui/databases.jsx +293 -0
  21. package/apps/panel/app/ui/skeleton.jsx +196 -0
  22. package/apps/panel/app/ui/theme.jsx +35 -0
  23. package/apps/panel/lib/agent.mjs +83 -0
  24. package/apps/panel/lib/auth.mjs +48 -0
  25. package/apps/panel/lib/database-runtime.mjs +243 -0
  26. package/apps/panel/lib/databases.mjs +292 -0
  27. package/apps/panel/lib/db.mjs +6 -0
  28. package/apps/panel/next.config.mjs +8 -0
  29. package/apps/panel/package.json +22 -0
  30. package/apps/panel/public/naviyra-logo.png +0 -0
  31. package/bin/naviyra.mjs +215 -0
  32. package/db/001_init.sql +28 -0
  33. package/db/002_native_runtimes.sql +3 -0
  34. package/db/003_databases.sql +8 -0
  35. package/db/004_database_engines.sql +4 -0
  36. package/deploy/naviyra-agent.service +17 -0
  37. package/docker-compose.yml +18 -0
  38. package/docs/managed-runtime.md +52 -0
  39. package/docs/native-hosts.md +48 -0
  40. package/package.json +61 -4
  41. package/packages/runtime/artifacts.json +32 -0
  42. package/packages/runtime/index.cjs +526 -0
  43. package/packages/runtime/index.mjs +10 -0
  44. package/packages/runtime/package.json +6 -0
  45. package/packages/shared/index.mjs +85 -0
  46. package/packages/shared/package.json +9 -0
  47. package/scripts/migrate.mjs +18 -0
  48. package/scripts/panel-control.mjs +195 -0
  49. package/scripts/postgres-local.mjs +97 -0
  50. package/scripts/runtime-download-manifest.mjs +30 -0
  51. package/scripts/runtime.mjs +20 -0
  52. package/scripts/seed.mjs +35 -0
  53. package/scripts/service-supervisor.mjs +164 -0
  54. package/scripts/setup-local.mjs +215 -0
  55. package/scripts/start-naviyra.mjs +303 -0
  56. package/scripts/windows-boot.mjs +259 -0
@@ -0,0 +1,1487 @@
1
+ "use client";
2
+ import { useCallback, useEffect, useState } from "react";
3
+ import { runtimeLabels } from "@naviyra/shared";
4
+ import { CloudflareConnect, DnsScreen, EmailScreen } from "./cloudflare";
5
+ import { DatabaseScreen } from "./databases";
6
+ import { ThemeToggle } from "./theme";
7
+ import {
8
+ PageSkeleton,
9
+ ProjectSkeleton,
10
+ SkeletonRows,
11
+ SkeletonStats,
12
+ SkeletonTable,
13
+ } from "./skeleton";
14
+ import {
15
+ Activity,
16
+ ArrowLeft,
17
+ ArrowRight,
18
+ ArrowUpRight,
19
+ Box,
20
+ Check,
21
+ ChevronRight,
22
+ Cloud,
23
+ Code2,
24
+ ExternalLink,
25
+ Folder,
26
+ GitBranch,
27
+ Globe,
28
+ HardDrive,
29
+ Layers,
30
+ LayoutDashboard,
31
+ LogOut,
32
+ Database,
33
+ Mail,
34
+ Menu,
35
+ Plus,
36
+ RefreshCw,
37
+ Rocket,
38
+ Search,
39
+ Server,
40
+ ShieldCheck,
41
+ Terminal,
42
+ Trash2,
43
+ Upload,
44
+ X,
45
+ } from "lucide-react";
46
+
47
+ async function api(url, options = {}) {
48
+ const response = await fetch(`/api/${url}`, {
49
+ ...options,
50
+ headers: { "Content-Type": "application/json", ...options.headers },
51
+ });
52
+ const body = await response.json();
53
+ if (response.status === 401) {
54
+ window.location.href = "/login";
55
+ throw Error("Session expired");
56
+ }
57
+ if (!response.ok) throw Error(body.error || "Request failed");
58
+ return body;
59
+ }
60
+ function Badge({ status }) {
61
+ return (
62
+ <span className={`badge ${status}`}>
63
+ <i />
64
+ {status === "created" ? "Not deployed" : status}
65
+ </span>
66
+ );
67
+ }
68
+ const bytes = (value) => `${((value || 0) / 1024 / 1024 / 1024).toFixed(1)} GB`;
69
+ const ago = (value) => {
70
+ const seconds = Math.max(0, (Date.now() - new Date(value)) / 1000);
71
+ return seconds < 60
72
+ ? "Just now"
73
+ : seconds < 3600
74
+ ? `${Math.floor(seconds / 60)}m ago`
75
+ : seconds < 86400
76
+ ? `${Math.floor(seconds / 3600)}h ago`
77
+ : new Date(value).toLocaleDateString();
78
+ };
79
+ export default function Dashboard({
80
+ user,
81
+ initialView = "projects",
82
+ initialProject = null,
83
+ initialTab = "overview",
84
+ }) {
85
+ const [projects, setProjects] = useState([]),
86
+ [system, setSystem] = useState(null),
87
+ [loading, setLoading] = useState(true),
88
+ [error, setError] = useState(""),
89
+ [notice, setNotice] = useState("");
90
+ const [cf, setCf] = useState(null);
91
+ const [view, setView] = useState(initialView),
92
+ [search, setSearch] = useState(""),
93
+ [filter, setFilter] = useState("all"),
94
+ [creating, setCreating] = useState(false),
95
+ [selected, setSelected] = useState(initialProject);
96
+ const [detail, setDetail] = useState(null),
97
+ [logs, setLogs] = useState(""),
98
+ [metrics, setMetrics] = useState(null),
99
+ [busy, setBusy] = useState(false),
100
+ [tab, setTab] = useState(initialTab);
101
+ const [menuOpen, setMenuOpen] = useState(false);
102
+ const load = useCallback(async () => {
103
+ try {
104
+ const list = await api("projects");
105
+ setProjects(list);
106
+ setError("");
107
+ } catch (e) {
108
+ setError(e.message);
109
+ } finally {
110
+ setLoading(false);
111
+ }
112
+ try {
113
+ setSystem(await api("system"));
114
+ } catch (e) {
115
+ setSystem({ available: false, error: e.message });
116
+ }
117
+ }, []);
118
+ useEffect(() => {
119
+ load();
120
+ const timer = setInterval(load, 5000);
121
+ return () => clearInterval(timer);
122
+ }, [load]);
123
+ useEffect(() => {
124
+ const params = new URLSearchParams();
125
+ if (view !== "projects") params.set("view", view);
126
+ if (selected) params.set("project", selected);
127
+ if (selected && tab !== "overview") params.set("tab", tab);
128
+ const query = params.toString();
129
+ const next = query ? `/?${query}` : "/";
130
+ if (window.location.pathname + window.location.search !== next)
131
+ window.history.replaceState(null, "", next);
132
+ }, [view, selected, tab]);
133
+ useEffect(() => {
134
+ if (!menuOpen) return undefined;
135
+ const onKey = (event) => {
136
+ if (event.key === "Escape") setMenuOpen(false);
137
+ };
138
+ document.addEventListener("keydown", onKey);
139
+ return () => document.removeEventListener("keydown", onKey);
140
+ }, [menuOpen]);
141
+ const openView = (item) => {
142
+ setView(item);
143
+ setSelected(null);
144
+ setMenuOpen(false);
145
+ };
146
+ useEffect(() => {
147
+ let stop = false;
148
+ api("cloudflare/status")
149
+ .then((status) => {
150
+ if (!stop) setCf(status);
151
+ })
152
+ .catch((e) => {
153
+ if (!stop && e.message !== "Session expired")
154
+ setCf({ registered: true, unavailable: true, error: e.message });
155
+ });
156
+ return () => {
157
+ stop = true;
158
+ };
159
+ }, []);
160
+ const loadDetail = useCallback(async () => {
161
+ if (!selected) return;
162
+ try {
163
+ const [p, l, m] = await Promise.all([
164
+ api(`projects/${selected}`),
165
+ api(`projects/${selected}/logs`).catch((e) => ({ logs: e.message })),
166
+ api(`projects/${selected}/metrics`).catch(() => null),
167
+ ]);
168
+ setDetail(p);
169
+ setProjects((current) =>
170
+ current.map((project) =>
171
+ project.id === p.id ? { ...project, ...p } : project,
172
+ ),
173
+ );
174
+ setLogs(l.logs);
175
+ setMetrics(m);
176
+ } catch (e) {
177
+ setError(e.message);
178
+ }
179
+ }, [selected]);
180
+ useEffect(() => {
181
+ setDetail(null);
182
+ loadDetail();
183
+ const timer = setInterval(loadDetail, 3000);
184
+ return () => clearInterval(timer);
185
+ }, [loadDetail]);
186
+ const running = projects.filter((p) => p.status === "running").length,
187
+ failed = projects.filter((p) => p.status === "failed").length;
188
+ const visible = projects.filter(
189
+ (p) =>
190
+ (filter === "all" || p.status === filter) &&
191
+ (p.name + " " + p.subdomain).includes(search.toLowerCase()),
192
+ );
193
+ const open = (id) => {
194
+ setSelected(id);
195
+ setTab("overview");
196
+ setError("");
197
+ };
198
+ async function action(kind) {
199
+ if (
200
+ kind === "delete" &&
201
+ !window.confirm(
202
+ `Delete ${detail.name}? Its source, services, tunnel and DNS route will be removed.`,
203
+ )
204
+ )
205
+ return;
206
+ setBusy(true);
207
+ setError("");
208
+ try {
209
+ await api(`projects/${selected}${kind === "delete" ? "" : `/${kind}`}`, {
210
+ method: kind === "delete" ? "DELETE" : "POST",
211
+ });
212
+ if (kind === "delete") {
213
+ setSelected(null);
214
+ setNotice("Project deleted.");
215
+ } else
216
+ setNotice(
217
+ kind === "deploy"
218
+ ? "Deployment queued. Follow its progress below."
219
+ : "Services restarted.",
220
+ );
221
+ await load();
222
+ if (kind !== "delete") await loadDetail();
223
+ } catch (e) {
224
+ setError(e.message);
225
+ } finally {
226
+ setBusy(false);
227
+ }
228
+ }
229
+ return (
230
+ <div className="app-shell">
231
+ <aside className="sidebar">
232
+ <a href="/" className="brand">
233
+ <img src="/naviyra-logo.png" alt="Naviyra" className="brand-logo" />
234
+ </a>
235
+ <div className="nav-label">WORKSPACE</div>
236
+ <nav>
237
+ <button
238
+ className={view === "projects" ? "nav-item active" : "nav-item"}
239
+ onClick={() => {
240
+ setView("projects");
241
+ setSelected(null);
242
+ }}
243
+ >
244
+ <LayoutDashboard size={18} />
245
+ Projects<span>{projects.length}</span>
246
+ </button>
247
+ <button
248
+ className={
249
+ view === "infrastructure" ? "nav-item active" : "nav-item"
250
+ }
251
+ onClick={() => {
252
+ setView("infrastructure");
253
+ setSelected(null);
254
+ }}
255
+ >
256
+ <Server size={18} />
257
+ Infrastructure
258
+ </button>
259
+ <button
260
+ className={view === "activity" ? "nav-item active" : "nav-item"}
261
+ onClick={() => {
262
+ setView("activity");
263
+ setSelected(null);
264
+ }}
265
+ >
266
+ <Activity size={18} />
267
+ Activity
268
+ </button>
269
+ <button
270
+ className={view === "databases" ? "nav-item active" : "nav-item"}
271
+ onClick={() => {
272
+ setView("databases");
273
+ setSelected(null);
274
+ }}
275
+ >
276
+ <Database size={18} />
277
+ Databases
278
+ </button>
279
+ {cf?.registered && (
280
+ <>
281
+ <div className="nav-label">CLOUDFLARE</div>
282
+ <button
283
+ className={view === "dns" ? "nav-item active" : "nav-item"}
284
+ onClick={() => {
285
+ setView("dns");
286
+ setSelected(null);
287
+ }}
288
+ >
289
+ <Globe size={18} />
290
+ DNS
291
+ </button>
292
+ <button
293
+ className={view === "email" ? "nav-item active" : "nav-item"}
294
+ onClick={() => {
295
+ setView("email");
296
+ setSelected(null);
297
+ }}
298
+ >
299
+ <Mail size={18} />
300
+ Email routing
301
+ </button>
302
+ </>
303
+ )}
304
+ </nav>
305
+ <div className="sidebar-bottom">
306
+ <button
307
+ className="profile"
308
+ onClick={async () => {
309
+ await api("auth/logout", { method: "POST" });
310
+ window.location.href = "/login";
311
+ }}
312
+ title="Sign out"
313
+ >
314
+ <span className="avatar">{user.email[0].toUpperCase()}</span>
315
+ <span>
316
+ {user.email.split("@")[0]}
317
+ <small>
318
+ {user.role === "admin" ? "Administrator" : "Viewer"}
319
+ </small>
320
+ </span>
321
+ <LogOut size={16} />
322
+ </button>
323
+ </div>
324
+ </aside>
325
+ <div className="main-shell">
326
+ <header className="topbar">
327
+ <div>
328
+ <button
329
+ className="menu-button"
330
+ aria-label="Open menu"
331
+ aria-expanded={menuOpen}
332
+ onClick={() => setMenuOpen(true)}
333
+ >
334
+ <Menu size={18} />
335
+ </button>
336
+ Workspace
337
+ <ChevronRight size={14} />
338
+ <strong>
339
+ {selected
340
+ ? "Project details"
341
+ : {
342
+ projects: "Projects",
343
+ infrastructure: "Infrastructure",
344
+ activity: "Activity",
345
+ databases: "Databases",
346
+ dns: "DNS",
347
+ email: "Email routing",
348
+ }[view]}
349
+ </strong>
350
+ </div>
351
+ <div className="topbar-right">
352
+ <ThemeToggle />
353
+ <span
354
+ className={`connection ${system?.available === false ? "offline" : ""}`}
355
+ >
356
+ <i />
357
+ {system?.available === false
358
+ ? "Agent offline"
359
+ : system
360
+ ? "Agent connected"
361
+ : "Connecting…"}
362
+ </span>
363
+ <span className="avatar small">{user.email[0].toUpperCase()}</span>
364
+ </div>
365
+ </header>
366
+ {menuOpen && (
367
+ <div className="drawer-root">
368
+ <button
369
+ className="drawer-backdrop"
370
+ aria-label="Close menu"
371
+ onClick={() => setMenuOpen(false)}
372
+ />
373
+ <aside className="drawer" role="dialog" aria-modal="true" aria-label="Menu">
374
+ <div className="drawer-head">
375
+ <img src="/naviyra-logo.png" alt="Naviyra" className="brand-logo" />
376
+ <button aria-label="Close menu" onClick={() => setMenuOpen(false)}>
377
+ <X size={18} />
378
+ </button>
379
+ </div>
380
+ <nav>
381
+ {[
382
+ ["projects", "Projects", LayoutDashboard],
383
+ ["infrastructure", "Infrastructure", Server],
384
+ ["activity", "Activity", Activity],
385
+ ["databases", "Databases", Database],
386
+ ...(cf?.registered
387
+ ? [
388
+ ["dns", "DNS", Globe],
389
+ ["email", "Email routing", Mail],
390
+ ]
391
+ : []),
392
+ ].map(([item, label, Icon]) => (
393
+ <button
394
+ key={item}
395
+ className={view === item ? "nav-item active" : "nav-item"}
396
+ onClick={() => openView(item)}
397
+ >
398
+ <Icon size={18} />
399
+ {label}
400
+ </button>
401
+ ))}
402
+ </nav>
403
+ <button
404
+ className="profile"
405
+ onClick={async () => {
406
+ await api("auth/logout", { method: "POST" });
407
+ window.location.href = "/login";
408
+ }}
409
+ >
410
+ <span className="avatar">{user.email[0].toUpperCase()}</span>
411
+ <span>
412
+ {user.email.split("@")[0]}
413
+ <small>{user.role === "admin" ? "Administrator" : "Viewer"}</small>
414
+ </span>
415
+ <LogOut size={16} />
416
+ </button>
417
+ </aside>
418
+ </div>
419
+ )}
420
+ <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
+ {cf == null ? (
441
+ <PageSkeleton view={selected ? "project" : view} />
442
+ ) : !cf.registered && system?.mode !== "mock" ? (
443
+ <CloudflareConnect
444
+ initial={cf}
445
+ canWrite={user.role === "admin"}
446
+ onReady={(status) => {
447
+ setCf(status);
448
+ setView("projects");
449
+ setSelected(null);
450
+ setNotice("Cloudflare account connected.");
451
+ }}
452
+ />
453
+ ) : selected ? (
454
+ <>
455
+ {!detail ? (
456
+ <ProjectSkeleton />
457
+ ) : (
458
+ <>
459
+ <button className="back" onClick={() => setSelected(null)}>
460
+ <ArrowLeft size={15} />
461
+ All projects
462
+ </button>
463
+ <div className="page-heading">
464
+ <div>
465
+ <div className="title-row">
466
+ <h1>{detail.name}</h1>
467
+ <Badge status={detail.status} />
468
+ </div>
469
+ <p>
470
+ <a
471
+ href={`https://${detail.subdomain}`}
472
+ target="_blank"
473
+ rel="noreferrer"
474
+ >
475
+ {detail.subdomain}
476
+ <ExternalLink size={13} />
477
+ </a>
478
+ <span className="separator">/</span>
479
+ {runtimeLabels[detail.type] || "Node.js"} application
480
+ </p>
481
+ </div>
482
+ <div className="actions">
483
+ <button
484
+ className="secondary"
485
+ onClick={() => action("restart")}
486
+ disabled={
487
+ busy ||
488
+ detail.status !== "running" ||
489
+ user.role !== "admin"
490
+ }
491
+ >
492
+ <RefreshCw size={15} />
493
+ Restart
494
+ </button>
495
+ <button
496
+ className="primary"
497
+ onClick={() => action("deploy")}
498
+ disabled={
499
+ busy ||
500
+ ["queued", "deploying"].includes(detail.status) ||
501
+ user.role !== "admin"
502
+ }
503
+ >
504
+ <Rocket size={16} />
505
+ {detail.status === "created"
506
+ ? "Deploy project"
507
+ : "Redeploy"}
508
+ </button>
509
+ </div>
510
+ </div>
511
+ {(detail.error || detail.agent_error) && (
512
+ <div className="error">
513
+ {detail.error || detail.agent_error}
514
+ </div>
515
+ )}
516
+ <div className="stats detail-stats">
517
+ <Stat
518
+ label="Application"
519
+ value={metrics?.active ? "Online" : "Offline"}
520
+ icon={Box}
521
+ note={`Port ${detail.port || "not allocated"}`}
522
+ />
523
+ <Stat
524
+ label="Cloudflare tunnel"
525
+ value={metrics?.tunnel_active ? "Active" : "Inactive"}
526
+ icon={Cloud}
527
+ note={
528
+ system?.mode === "mock"
529
+ ? "Simulated connection"
530
+ : "Service status"
531
+ }
532
+ />
533
+ <Stat
534
+ label="Memory usage"
535
+ value={
536
+ metrics?.memory_bytes != null
537
+ ? `${Math.round(metrics.memory_bytes / 1048576)} MB`
538
+ : "—"
539
+ }
540
+ icon={HardDrive}
541
+ note={
542
+ metrics?.metrics_available === false
543
+ ? "Unavailable in native mode"
544
+ : "Application service"
545
+ }
546
+ />
547
+ <Stat
548
+ label="CPU time"
549
+ value={
550
+ metrics?.cpu_nanoseconds != null
551
+ ? `${(metrics.cpu_nanoseconds / 1e9).toFixed(1)}s`
552
+ : "—"
553
+ }
554
+ icon={Activity}
555
+ note={
556
+ metrics?.metrics_available === false
557
+ ? "Unavailable in native mode"
558
+ : "Cumulative service CPU"
559
+ }
560
+ />
561
+ </div>
562
+ <div className="tabs">
563
+ {["overview", "logs", "deployments"].map((t) => (
564
+ <button
565
+ key={t}
566
+ className={tab === t ? "active" : ""}
567
+ onClick={() => setTab(t)}
568
+ >
569
+ {t[0].toUpperCase() + t.slice(1)}
570
+ </button>
571
+ ))}
572
+ </div>
573
+ {tab === "overview" && (
574
+ <div className="detail-grid">
575
+ <section className="panel">
576
+ <div className="section-heading">
577
+ <h2>Project configuration</h2>
578
+ <Code2 size={17} />
579
+ </div>
580
+ <dl>
581
+ <dt>Runtime</dt>
582
+ <dd>{runtimeLabels[detail.type] || "Node.js"}</dd>
583
+ {metrics?.local_url && (
584
+ <>
585
+ <dt>Local address</dt>
586
+ <dd>
587
+ <a
588
+ href={metrics.local_url}
589
+ target="_blank"
590
+ rel="noreferrer"
591
+ >
592
+ {metrics.local_url}
593
+ </a>
594
+ <small> (on the agent host)</small>
595
+ </dd>
596
+ </>
597
+ )}
598
+ <dt>Source</dt>
599
+ <dd>
600
+ {detail.source_type === "git"
601
+ ? detail.git_url
602
+ : "ZIP archive"}
603
+ </dd>
604
+ <dt>Hostname</dt>
605
+ <dd>{detail.subdomain}</dd>
606
+ <dt>Allocated port</dt>
607
+ <dd>
608
+ {detail.port || "Assigned on first deployment"}
609
+ </dd>
610
+ <dt>Tunnel ID</dt>
611
+ <dd className="mono">
612
+ {detail.tunnel?.tunnel_id ||
613
+ "Created on first deployment"}
614
+ </dd>
615
+ <dt>Created</dt>
616
+ <dd>
617
+ {new Date(detail.created_at).toLocaleString()}
618
+ </dd>
619
+ </dl>
620
+ {detail.source_type === "zip" && (
621
+ <label className="upload replacement">
622
+ <Upload size={20} />
623
+ <span>
624
+ Upload source archive
625
+ <small>
626
+ Source files at the ZIP root · max 25 MB
627
+ </small>
628
+ </span>
629
+ <input
630
+ aria-label="Upload source archive"
631
+ type="file"
632
+ accept=".zip"
633
+ disabled={
634
+ busy ||
635
+ ["queued", "deploying"].includes(
636
+ detail.status,
637
+ ) ||
638
+ user.role !== "admin"
639
+ }
640
+ onChange={async (e) => {
641
+ const file = e.target.files[0];
642
+ if (!file) return;
643
+ setBusy(true);
644
+ try {
645
+ await api(`projects/${selected}/source`, {
646
+ method: "PUT",
647
+ headers: {
648
+ "Content-Type": "application/zip",
649
+ },
650
+ body: file,
651
+ });
652
+ setNotice(
653
+ "Source uploaded. Deploy when ready.",
654
+ );
655
+ await loadDetail();
656
+ } catch (e) {
657
+ setError(e.message);
658
+ } finally {
659
+ setBusy(false);
660
+ }
661
+ }}
662
+ />
663
+ </label>
664
+ )}
665
+ </section>
666
+ <section className="panel">
667
+ <div className="section-heading">
668
+ <h2>Deployment output</h2>
669
+ <Terminal size={17} />
670
+ </div>
671
+ <pre className="logs compact">
672
+ {logs ||
673
+ "No deployment output yet. Deploy your project to get started."}
674
+ </pre>
675
+ <button
676
+ className="text-button"
677
+ onClick={() => setTab("logs")}
678
+ >
679
+ View full logs
680
+ <ArrowRight size={14} />
681
+ </button>
682
+ </section>
683
+ </div>
684
+ )}
685
+ {tab === "logs" && (
686
+ <section className="panel">
687
+ <div className="section-heading">
688
+ <h2>Live service logs</h2>
689
+ <span className="muted">Refreshes every 3 seconds</span>
690
+ </div>
691
+ <pre className="logs">{logs || "No logs yet."}</pre>
692
+ </section>
693
+ )}
694
+ {tab === "deployments" && (
695
+ <section className="panel">
696
+ <div className="section-heading">
697
+ <h2>Deployment history</h2>
698
+ <span className="muted">Latest 20 deployments</span>
699
+ </div>
700
+ {detail.deployments.length ? (
701
+ detail.deployments.map((d) => (
702
+ <div className="deployment" key={d.id}>
703
+ <Rocket size={18} />
704
+ <div>
705
+ <strong className="mono">
706
+ {d.id.slice(0, 8)}
707
+ </strong>
708
+ <small>
709
+ {d.error ||
710
+ `${d.source_type.toUpperCase()} · ${new Date(d.created_at).toLocaleString()}`}
711
+ </small>
712
+ </div>
713
+ <Badge status={d.status} />
714
+ </div>
715
+ ))
716
+ ) : (
717
+ <div className="empty compact-empty">
718
+ No deployments yet.
719
+ </div>
720
+ )}
721
+ </section>
722
+ )}
723
+ <div className="danger-zone">
724
+ <div>
725
+ <strong>Delete project</strong>
726
+ <p>
727
+ Remove this project, its services, tunnel and DNS route.
728
+ </p>
729
+ </div>
730
+ <button
731
+ className="danger"
732
+ disabled={
733
+ busy ||
734
+ ["queued", "deploying"].includes(detail.status) ||
735
+ user.role !== "admin"
736
+ }
737
+ onClick={() => action("delete")}
738
+ >
739
+ <Trash2 size={15} />
740
+ Delete project
741
+ </button>
742
+ </div>
743
+ </>
744
+ )}
745
+ </>
746
+ ) : view === "databases" ? (
747
+ <DatabaseScreen canWrite={user.role === "admin"} />
748
+ ) : view === "dns" ? (
749
+ <DnsScreen canWrite={user.role === "admin"} />
750
+ ) : view === "email" ? (
751
+ <EmailScreen canWrite={user.role === "admin"} />
752
+ ) : view === "projects" ? (
753
+ <>
754
+ <div className="page-heading">
755
+ <h1>
756
+ Projects<span className="title-dot">.</span>
757
+ </h1>
758
+ <button
759
+ className="primary"
760
+ disabled={user.role !== "admin"}
761
+ onClick={() => setCreating(true)}
762
+ >
763
+ <Plus size={17} />
764
+ New project
765
+ </button>
766
+ </div>
767
+ <div className="stats">
768
+ <Stat
769
+ label="Total projects"
770
+ value={projects.length.toString().padStart(2, "0")}
771
+ icon={Layers}
772
+ note="Across your workspace"
773
+ />
774
+ <Stat
775
+ label="Running applications"
776
+ value={running.toString().padStart(2, "0")}
777
+ icon={Activity}
778
+ note={
779
+ running
780
+ ? "Services are running"
781
+ : "Ready for your first deploy"
782
+ }
783
+ accent
784
+ />
785
+ <Stat
786
+ label="Active tunnels"
787
+ value={running.toString().padStart(2, "0")}
788
+ icon={Cloud}
789
+ note={
790
+ system?.mode === "mock"
791
+ ? "Simulated in local mode"
792
+ : "Last reported deployment state"
793
+ }
794
+ />
795
+ <Stat
796
+ label="Needs attention"
797
+ value={failed.toString().padStart(2, "0")}
798
+ icon={ShieldCheck}
799
+ note={
800
+ failed ? "Review deployment logs" : "No failed deployments"
801
+ }
802
+ warning={failed > 0}
803
+ />
804
+ </div>
805
+ <section className="panel projects-panel">
806
+ <div className="section-heading">
807
+ <div className="title-row">
808
+ <h2>Your projects</h2>
809
+ <span className="count">{projects.length}</span>
810
+ </div>
811
+ <button
812
+ className="icon-button"
813
+ title="Refresh projects"
814
+ onClick={load}
815
+ >
816
+ <RefreshCw size={16} />
817
+ </button>
818
+ </div>
819
+ <div className="toolbar">
820
+ <div className="search">
821
+ <Search size={16} />
822
+ <input
823
+ aria-label="Search projects"
824
+ placeholder="Search by name or domain…"
825
+ value={search}
826
+ onChange={(e) => setSearch(e.target.value)}
827
+ />
828
+ </div>
829
+ <select
830
+ aria-label="Filter by status"
831
+ value={filter}
832
+ onChange={(e) => setFilter(e.target.value)}
833
+ >
834
+ <option value="all">All statuses</option>
835
+ <option value="running">Running</option>
836
+ <option value="deploying">Deploying</option>
837
+ <option value="queued">Queued</option>
838
+ <option value="created">Not deployed</option>
839
+ <option value="failed">Failed</option>
840
+ </select>
841
+ <span className="toolbar-count">
842
+ {visible.length} project{visible.length !== 1 ? "s" : ""}
843
+ </span>
844
+ </div>
845
+ {loading && !projects.length ? (
846
+ <SkeletonTable
847
+ label="Loading projects"
848
+ columns={["Project", "Status", "Domain", "Port", "Updated", ""]}
849
+ />
850
+ ) : visible.length ? (
851
+ <div className="table-wrap">
852
+ <table>
853
+ <thead>
854
+ <tr>
855
+ <th>PROJECT</th>
856
+ <th>STATUS</th>
857
+ <th>DOMAIN</th>
858
+ <th>PORT</th>
859
+ <th>UPDATED</th>
860
+ <th />
861
+ </tr>
862
+ </thead>
863
+ <tbody>
864
+ {visible.map((p) => (
865
+ <tr key={p.id} onClick={() => open(p.id)}>
866
+ <td>
867
+ <div className="project-name">
868
+ <span className="project-icon">
869
+ <Code2 size={19} />
870
+ </span>
871
+ <div>
872
+ <button
873
+ className="name-button"
874
+ onClick={(e) => {
875
+ e.stopPropagation();
876
+ open(p.id);
877
+ }}
878
+ >
879
+ {p.name}
880
+ </button>
881
+ <small>
882
+ <GitBranch size={11} />
883
+ {p.source_type === "git"
884
+ ? "Git repository"
885
+ : "ZIP archive"}
886
+ <span>·</span>
887
+ {runtimeLabels[p.type] || "Node.js"}
888
+ </small>
889
+ </div>
890
+ </div>
891
+ </td>
892
+ <td>
893
+ <Badge status={p.status} />
894
+ {p.agent_error && (
895
+ <small className="unreachable">
896
+ Agent unreachable
897
+ </small>
898
+ )}
899
+ </td>
900
+ <td>
901
+ <a
902
+ className="domain"
903
+ href={`https://${p.subdomain}`}
904
+ target="_blank"
905
+ rel="noreferrer"
906
+ onClick={(e) => e.stopPropagation()}
907
+ >
908
+ {p.subdomain}
909
+ <ArrowUpRight size={13} />
910
+ </a>
911
+ </td>
912
+ <td className="mono muted">{p.port || "—"}</td>
913
+ <td className="muted">{ago(p.updated_at)}</td>
914
+ <td>
915
+ <ChevronRight size={16} className="muted" />
916
+ </td>
917
+ </tr>
918
+ ))}
919
+ </tbody>
920
+ </table>
921
+ </div>
922
+ ) : (
923
+ <div className="empty">
924
+ <div className="empty-icon">
925
+ <Folder size={30} />
926
+ </div>
927
+ <h3>
928
+ {projects.length
929
+ ? "No matching projects"
930
+ : "Your next idea starts here"}
931
+ </h3>
932
+ <p>
933
+ {projects.length
934
+ ? "Try another search or status filter."
935
+ : "Connect a repository or upload your source to deploy your first application."}
936
+ </p>
937
+ {!projects.length && (
938
+ <button
939
+ className="primary"
940
+ disabled={user.role !== "admin"}
941
+ onClick={() => setCreating(true)}
942
+ >
943
+ <Plus size={16} />
944
+ Create your first project
945
+ </button>
946
+ )}
947
+ </div>
948
+ )}
949
+ <div className="table-footer">
950
+ <span>
951
+ <ShieldCheck size={13} /> Applications are published through
952
+ Cloudflare Tunnel
953
+ </span>
954
+ <span>Multiple native runtimes</span>
955
+ </div>
956
+ </section>
957
+ <div className="bottom-grid">
958
+ <section className="panel server-card">
959
+ <div className="section-heading">
960
+ <h2>
961
+ <Server size={17} />
962
+ Host overview
963
+ </h2>
964
+ <span className="tiny-label">PRIMARY AGENT</span>
965
+ </div>
966
+ <div className="host-details">
967
+ <div>
968
+ <span className="server-avatar">
969
+ <Server size={24} />
970
+ </span>
971
+ <div>
972
+ <strong>
973
+ {system?.hostname || "Waiting for agent"}
974
+ </strong>
975
+ <small>
976
+ {system?.mode === "mock"
977
+ ? "Local development host"
978
+ : "Deployment host"}
979
+ </small>
980
+ </div>
981
+ </div>
982
+ <Badge
983
+ status={
984
+ system?.available === false
985
+ ? "failed"
986
+ : system
987
+ ? "running"
988
+ : "queued"
989
+ }
990
+ />
991
+ </div>
992
+ <div className="memory-line">
993
+ <span>Host memory</span>
994
+ <strong>
995
+ {system?.memory_total
996
+ ? `${bytes(system.memory_used)} / ${bytes(system.memory_total)}`
997
+ : "Unavailable"}
998
+ </strong>
999
+ </div>
1000
+ <div className="progress">
1001
+ <i
1002
+ style={{
1003
+ width: system?.memory_total
1004
+ ? `${(100 * system.memory_used) / system.memory_total}%`
1005
+ : "0%",
1006
+ }}
1007
+ />
1008
+ </div>
1009
+ <button
1010
+ className="text-button"
1011
+ onClick={() => setView("infrastructure")}
1012
+ >
1013
+ View infrastructure
1014
+ <ArrowRight size={14} />
1015
+ </button>
1016
+ </section>
1017
+ <section className="quick-start">
1018
+ <div className="quick-art">
1019
+ <Cloud size={44} strokeWidth={1.3} />
1020
+ <span>
1021
+ <Check size={14} />
1022
+ </span>
1023
+ </div>
1024
+ <div>
1025
+ <span className="eyebrow">FROM CODE TO CLOUD</span>
1026
+ <h2>Less setup. More shipping.</h2>
1027
+ <p>
1028
+ One project. One port. One secure tunnel.
1029
+ <br />
1030
+ Your deployment workflow, taken care of.
1031
+ </p>
1032
+ <button
1033
+ className="text-button"
1034
+ onClick={() => setCreating(true)}
1035
+ disabled={user.role !== "admin"}
1036
+ >
1037
+ Deploy an application
1038
+ <ArrowRight size={14} />
1039
+ </button>
1040
+ </div>
1041
+ </section>
1042
+ </div>
1043
+ </>
1044
+ ) : view === "infrastructure" ? (
1045
+ <>
1046
+ <div className="page-heading">
1047
+ <h1>
1048
+ Infrastructure<span className="title-dot">.</span>
1049
+ </h1>
1050
+ </div>
1051
+ {!system ? (
1052
+ <>
1053
+ <SkeletonStats label="Loading infrastructure" />
1054
+ <section className="panel">
1055
+ <SkeletonRows rows={4} label="Loading infrastructure" />
1056
+ </section>
1057
+ </>
1058
+ ) : (
1059
+ <>
1060
+ <div className="stats">
1061
+ <Stat
1062
+ label="Hostname"
1063
+ value={system?.hostname || "Offline"}
1064
+ icon={Server}
1065
+ note="Primary agent"
1066
+ />
1067
+ <Stat
1068
+ label="Host memory"
1069
+ value={bytes(system?.memory_used)}
1070
+ icon={HardDrive}
1071
+ note={`Of ${bytes(system?.memory_total)} total`}
1072
+ />
1073
+ <Stat
1074
+ label="Load average"
1075
+ value={system?.load?.[0]?.toFixed(2) || "0.00"}
1076
+ icon={Activity}
1077
+ note="1 minute · unavailable on Windows"
1078
+ />
1079
+ <Stat
1080
+ label="Host uptime"
1081
+ value={system ? `${Math.floor(system.uptime / 3600)}h` : "—"}
1082
+ icon={RefreshCw}
1083
+ note="Since last host boot"
1084
+ />
1085
+ </div>
1086
+ <section className="panel">
1087
+ <div className="section-heading">
1088
+ <h2>Agent connection</h2>
1089
+ <Cloud size={18} />
1090
+ </div>
1091
+ <dl>
1092
+ <dt>Mode</dt>
1093
+ <dd>{system?.mode || "Unavailable"}</dd>
1094
+ <dt>Connection</dt>
1095
+ <dd>
1096
+ {system?.available === false
1097
+ ? system.error
1098
+ : "Authenticated server-to-server connection"}
1099
+ </dd>
1100
+ <dt>Agent projects</dt>
1101
+ <dd>{system?.projects ?? "—"}</dd>
1102
+ <dt>Public access</dt>
1103
+ <dd>Cloudflare Tunnel · HTTPS</dd>
1104
+ </dl>
1105
+ </section>
1106
+ </>
1107
+ )}
1108
+ </>
1109
+ ) : (
1110
+ <>
1111
+ <div className="page-heading">
1112
+ <h1>
1113
+ Project activity<span className="title-dot">.</span>
1114
+ </h1>
1115
+ </div>
1116
+ <section className="panel">
1117
+ {loading && !projects.length ? (
1118
+ <SkeletonRows label="Loading activity" />
1119
+ ) : projects.length ? (
1120
+ projects.map((p) => (
1121
+ <button
1122
+ className="activity-row"
1123
+ key={p.id}
1124
+ onClick={() => open(p.id)}
1125
+ >
1126
+ <span className="project-icon">
1127
+ <Rocket size={18} />
1128
+ </span>
1129
+ <div>
1130
+ <strong>{p.name}</strong>
1131
+ <small>{p.subdomain}</small>
1132
+ </div>
1133
+ <Badge status={p.status} />
1134
+ <span className="muted">{ago(p.created_at)}</span>
1135
+ <ChevronRight size={16} />
1136
+ </button>
1137
+ ))
1138
+ ) : (
1139
+ <div className="empty">
1140
+ <Activity size={30} />
1141
+ <h3>A fresh workspace</h3>
1142
+ <p>Your project activity will appear here.</p>
1143
+ </div>
1144
+ )}
1145
+ </section>
1146
+ </>
1147
+ )}
1148
+ <footer>
1149
+ <span>
1150
+ Naviyra Panel <span className="footer-dot">·</span> Built for your
1151
+ infrastructure
1152
+ </span>
1153
+ <span>
1154
+ <span className="green-dot" />
1155
+ Cloudflare-powered hosting
1156
+ </span>
1157
+ </footer>
1158
+ </main>
1159
+ </div>
1160
+ {creating && (
1161
+ <CreateProject
1162
+ onClose={() => setCreating(false)}
1163
+ onCreated={async (id) => {
1164
+ setCreating(false);
1165
+ await load();
1166
+ open(id);
1167
+ }}
1168
+ />
1169
+ )}
1170
+ </div>
1171
+ );
1172
+ }
1173
+ function Stat({ label, value, icon: Icon, note, accent, warning }) {
1174
+ return (
1175
+ <section
1176
+ className={`stat ${accent ? "accent" : ""} ${warning ? "warning" : ""}`}
1177
+ >
1178
+ <div>
1179
+ <span>{label}</span>
1180
+ <Icon size={17} />
1181
+ </div>
1182
+ <strong>{value}</strong>
1183
+ <small>
1184
+ {accent && <i className="green-dot" />}
1185
+ {note}
1186
+ </small>
1187
+ </section>
1188
+ );
1189
+ }
1190
+ function CreateProject({ onClose, onCreated }) {
1191
+ const [runtimeType, setRuntimeType] = useState("nodejs");
1192
+ const [source, setSource] = useState("git"),
1193
+ [error, setError] = useState(""),
1194
+ [busy, setBusy] = useState(false),
1195
+ [file, setFile] = useState(null),
1196
+ [createdId, setCreatedId] = useState(null);
1197
+ useEffect(() => {
1198
+ const previous = document.activeElement;
1199
+ document.getElementById("project-name")?.focus();
1200
+ const listener = (e) => {
1201
+ if (e.key === "Escape" && !busy) onClose();
1202
+ if (e.key === "Tab") {
1203
+ const items = [
1204
+ ...document.querySelectorAll(
1205
+ ".modal button:not(:disabled), .modal input:not(:disabled), .modal select:not(:disabled)",
1206
+ ),
1207
+ ];
1208
+ const first = items[0],
1209
+ last = items.at(-1);
1210
+ if (e.shiftKey && document.activeElement === first) {
1211
+ e.preventDefault();
1212
+ last?.focus();
1213
+ } else if (!e.shiftKey && document.activeElement === last) {
1214
+ e.preventDefault();
1215
+ first?.focus();
1216
+ }
1217
+ }
1218
+ };
1219
+ document.addEventListener("keydown", listener);
1220
+ return () => {
1221
+ document.removeEventListener("keydown", listener);
1222
+ previous?.focus();
1223
+ };
1224
+ }, [busy, onClose]);
1225
+ async function submit(e) {
1226
+ e.preventDefault();
1227
+ setError("");
1228
+ setBusy(true);
1229
+ try {
1230
+ 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");
1233
+ let id = createdId;
1234
+ if (!id) {
1235
+ const project = await api("projects", {
1236
+ method: "POST",
1237
+ body: JSON.stringify({
1238
+ ...values,
1239
+ source_type: source,
1240
+ type: runtimeType,
1241
+ runtime_config: {
1242
+ python_server: values.python_server || "asgi",
1243
+ entrypoint: values.entrypoint || "app:app",
1244
+ public_dir: values.public_dir || ".",
1245
+ executable: values.executable || "",
1246
+ args: values.custom_args ? JSON.parse(values.custom_args) : [],
1247
+ },
1248
+ }),
1249
+ });
1250
+ id = project.id;
1251
+ setCreatedId(id);
1252
+ }
1253
+ if (source === "zip")
1254
+ await api(`projects/${id}/source`, {
1255
+ method: "PUT",
1256
+ headers: { "Content-Type": "application/zip" },
1257
+ body: file,
1258
+ });
1259
+ await onCreated(id);
1260
+ } catch (e) {
1261
+ setError(e.message);
1262
+ } finally {
1263
+ setBusy(false);
1264
+ }
1265
+ }
1266
+ return (
1267
+ <div
1268
+ className="modal-backdrop"
1269
+ onClick={(e) => {
1270
+ if (e.target === e.currentTarget && !busy) onClose();
1271
+ }}
1272
+ >
1273
+ <section
1274
+ className="modal"
1275
+ role="dialog"
1276
+ aria-modal="true"
1277
+ aria-labelledby="create-title"
1278
+ >
1279
+ <div className="modal-heading">
1280
+ <span className="project-icon">
1281
+ <Box size={22} />
1282
+ </span>
1283
+ <button
1284
+ className="icon-button"
1285
+ aria-label="Close dialog"
1286
+ disabled={busy}
1287
+ onClick={onClose}
1288
+ >
1289
+ <X size={20} />
1290
+ </button>
1291
+ </div>
1292
+ <h2 id="create-title">A new home for your code.</h2>
1293
+ <p className="muted">Add your application. Deploy when you’re ready.</p>
1294
+ <form onSubmit={submit}>
1295
+ <fieldset disabled={busy || !!createdId}>
1296
+ <label>
1297
+ Project name
1298
+ <input
1299
+ id="project-name"
1300
+ name="name"
1301
+ placeholder="my-awesome-app"
1302
+ pattern="[a-z][a-z0-9-]+"
1303
+ minLength={2}
1304
+ maxLength={48}
1305
+ required
1306
+ />
1307
+ <small>Lowercase letters, numbers and hyphens.</small>
1308
+ </label>
1309
+ <label>
1310
+ Public hostname
1311
+ <div className="input-icon">
1312
+ <Globe size={16} />
1313
+ <input
1314
+ name="subdomain"
1315
+ placeholder="app.example.com"
1316
+ required
1317
+ />
1318
+ </div>
1319
+ <small>Use a domain in your Cloudflare account.</small>
1320
+ </label>
1321
+ <label>
1322
+ Application runtime
1323
+ <select
1324
+ className="runtime-select"
1325
+ name="type"
1326
+ aria-label="Application runtime"
1327
+ value={runtimeType}
1328
+ onChange={(e) => setRuntimeType(e.target.value)}
1329
+ >
1330
+ {Object.entries(runtimeLabels).map(([value, label]) => (
1331
+ <option key={value} value={value}>
1332
+ {label}
1333
+ </option>
1334
+ ))}
1335
+ </select>
1336
+ <small>
1337
+ Native deployment on Windows and Linux. Install the selected
1338
+ runtime on your host.
1339
+ </small>
1340
+ </label>
1341
+ {runtimeType === "python" && (
1342
+ <>
1343
+ <label>
1344
+ Python server
1345
+ <select className="runtime-select" name="python_server">
1346
+ <option value="asgi">
1347
+ ASGI · FastAPI / Starlette (Uvicorn)
1348
+ </option>
1349
+ <option value="wsgi">
1350
+ WSGI · Flask / Django (Waitress)
1351
+ </option>
1352
+ </select>
1353
+ </label>
1354
+ <label>
1355
+ Application entry point
1356
+ <input name="entrypoint" defaultValue="app:app" required />
1357
+ <small>
1358
+ Module:object, e.g. app:app or mysite.wsgi:application
1359
+ </small>
1360
+ </label>
1361
+ </>
1362
+ )}
1363
+ {["php", "static"].includes(runtimeType) && (
1364
+ <label>
1365
+ Public directory
1366
+ <input name="public_dir" defaultValue="." required />
1367
+ <small>
1368
+ {runtimeType === "php"
1369
+ ? "Use public for frameworks. Host requires PHP-CGI and Caddy."
1370
+ : "Use dist for a built frontend, or . for plain HTML. A package.json build script runs automatically."}
1371
+ </small>
1372
+ </label>
1373
+ )}
1374
+ {runtimeType === "go" && (
1375
+ <p className="muted">
1376
+ The agent runs go build in the source root. Your server must
1377
+ read PORT and bind to 127.0.0.1.
1378
+ </p>
1379
+ )}
1380
+ {runtimeType === "custom" && (
1381
+ <>
1382
+ <label>
1383
+ Executable alias
1384
+ <input name="executable" placeholder="dotnet" required />
1385
+ <small>
1386
+ Must be registered by the host owner in NATIVE_EXECUTABLES.
1387
+ </small>
1388
+ </label>
1389
+ <label>
1390
+ Arguments (JSON array)
1391
+ <input
1392
+ name="custom_args"
1393
+ defaultValue={
1394
+ '["MyApp.dll", "--urls", "http://127.0.0.1:{PORT}"]'
1395
+ }
1396
+ required
1397
+ />
1398
+ <small>
1399
+ Use prebuilt apps. No shell syntax; each array item is one
1400
+ argument.
1401
+ </small>
1402
+ </label>
1403
+ </>
1404
+ )}
1405
+ <label>Source</label>
1406
+ <div className="source-options">
1407
+ <button
1408
+ type="button"
1409
+ className={source === "git" ? "selected" : ""}
1410
+ onClick={() => setSource("git")}
1411
+ >
1412
+ <GitBranch size={17} />
1413
+ Git repository
1414
+ </button>
1415
+ <button
1416
+ type="button"
1417
+ className={source === "zip" ? "selected" : ""}
1418
+ onClick={() => setSource("zip")}
1419
+ >
1420
+ <Upload size={17} />
1421
+ ZIP upload
1422
+ </button>
1423
+ </div>
1424
+ {source === "git" && (
1425
+ <label>
1426
+ Repository URL
1427
+ <input
1428
+ name="git_url"
1429
+ type="url"
1430
+ placeholder="https://github.com/you/your-app"
1431
+ required
1432
+ />
1433
+ <small>Public GitHub, GitLab, or Bitbucket repositories.</small>
1434
+ </label>
1435
+ )}
1436
+ </fieldset>
1437
+ {source === "zip" && (
1438
+ <label className="upload">
1439
+ <Upload size={24} />
1440
+ <strong>{file ? file.name : "Choose a source archive"}</strong>
1441
+ <small>ZIP · Up to 25 MB · source files at the root</small>
1442
+ <input
1443
+ aria-label="Choose ZIP archive"
1444
+ type="file"
1445
+ accept=".zip"
1446
+ onChange={(e) => setFile(e.target.files[0])}
1447
+ disabled={busy}
1448
+ />
1449
+ </label>
1450
+ )}
1451
+ <div className="info">
1452
+ <ShieldCheck size={17} />
1453
+ <p>
1454
+ A port and Cloudflare tunnel will be assigned automatically on
1455
+ your first deployment.
1456
+ </p>
1457
+ </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
+ <div className="modal-actions">
1466
+ <button
1467
+ type="button"
1468
+ className="secondary"
1469
+ disabled={busy}
1470
+ onClick={onClose}
1471
+ >
1472
+ Cancel
1473
+ </button>
1474
+ <button className="primary" disabled={busy}>
1475
+ {busy
1476
+ ? "Creating project…"
1477
+ : createdId
1478
+ ? "Retry upload"
1479
+ : "Create project"}
1480
+ <ArrowRight size={16} />
1481
+ </button>
1482
+ </div>
1483
+ </form>
1484
+ </section>
1485
+ </div>
1486
+ );
1487
+ }