@naviyra/cloud-panel 0.0.0-stage → 0.0.1
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/.env.example +40 -0
- package/README.md +134 -2
- package/apps/agent/package.json +13 -0
- package/apps/agent/src/cloudflare.mjs +733 -0
- package/apps/agent/src/native-worker.mjs +52 -0
- package/apps/agent/src/native.mjs +457 -0
- package/apps/agent/src/runtime.mjs +760 -0
- package/apps/agent/src/server.mjs +195 -0
- package/apps/agent/src/static-server.mjs +53 -0
- package/apps/panel/AGENTS.md +9 -0
- package/apps/panel/CLAUDE.md +1 -0
- package/apps/panel/app/api/[...path]/route.js +445 -0
- package/apps/panel/app/globals.css +2057 -0
- package/apps/panel/app/icon.png +0 -0
- package/apps/panel/app/layout.jsx +18 -0
- package/apps/panel/app/login/page.jsx +108 -0
- package/apps/panel/app/page.jsx +33 -0
- package/apps/panel/app/ui/cloudflare.jsx +926 -0
- package/apps/panel/app/ui/dashboard.jsx +1487 -0
- package/apps/panel/app/ui/databases.jsx +293 -0
- package/apps/panel/app/ui/skeleton.jsx +196 -0
- package/apps/panel/app/ui/theme.jsx +35 -0
- package/apps/panel/lib/agent.mjs +83 -0
- package/apps/panel/lib/auth.mjs +48 -0
- package/apps/panel/lib/database-runtime.mjs +243 -0
- package/apps/panel/lib/databases.mjs +292 -0
- package/apps/panel/lib/db.mjs +6 -0
- package/apps/panel/next.config.mjs +8 -0
- package/apps/panel/package.json +22 -0
- package/apps/panel/public/naviyra-logo.png +0 -0
- package/bin/naviyra.mjs +177 -0
- package/db/001_init.sql +28 -0
- package/db/002_native_runtimes.sql +3 -0
- package/db/003_databases.sql +8 -0
- package/db/004_database_engines.sql +4 -0
- package/deploy/naviyra-agent.service +17 -0
- package/docker-compose.yml +18 -0
- package/docs/managed-runtime.md +52 -0
- package/docs/native-hosts.md +48 -0
- package/package.json +61 -4
- package/packages/runtime/artifacts.json +32 -0
- package/packages/runtime/index.cjs +526 -0
- package/packages/runtime/index.mjs +10 -0
- package/packages/runtime/package.json +6 -0
- package/packages/shared/index.mjs +85 -0
- package/packages/shared/package.json +9 -0
- package/scripts/migrate.mjs +18 -0
- package/scripts/postgres-local.mjs +97 -0
- package/scripts/runtime-download-manifest.mjs +30 -0
- package/scripts/runtime.mjs +20 -0
- package/scripts/seed.mjs +35 -0
- package/scripts/service-supervisor.mjs +164 -0
- package/scripts/setup-local.mjs +215 -0
- package/scripts/start-naviyra.mjs +303 -0
- 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
|
+
}
|