@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,293 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useState } from "react";
|
|
3
|
+
import { Copy, Database, Plus, Trash2 } from "lucide-react";
|
|
4
|
+
import { SkeletonTable } from "./skeleton";
|
|
5
|
+
|
|
6
|
+
async function api(url, options = {}) {
|
|
7
|
+
const response = await fetch(`/api/${url}`, {
|
|
8
|
+
...options,
|
|
9
|
+
headers: { "Content-Type": "application/json", ...options.headers },
|
|
10
|
+
});
|
|
11
|
+
const body = await response.json();
|
|
12
|
+
if (response.status === 401) {
|
|
13
|
+
window.location.href = "/login";
|
|
14
|
+
throw Error("Session expired");
|
|
15
|
+
}
|
|
16
|
+
if (!response.ok) throw Error(body.error || "Request failed");
|
|
17
|
+
return body;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function DatabaseScreen({ canWrite }) {
|
|
21
|
+
const [data, setData] = useState(null);
|
|
22
|
+
const [loading, setLoading] = useState(true);
|
|
23
|
+
const [busy, setBusy] = useState(false);
|
|
24
|
+
const [error, setError] = useState("");
|
|
25
|
+
const [secret, setSecret] = useState(null);
|
|
26
|
+
const load = useCallback(async () => {
|
|
27
|
+
try {
|
|
28
|
+
setData(await api("databases"));
|
|
29
|
+
setError("");
|
|
30
|
+
} catch (e) {
|
|
31
|
+
setError(e.message);
|
|
32
|
+
} finally {
|
|
33
|
+
setLoading(false);
|
|
34
|
+
}
|
|
35
|
+
}, []);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
load();
|
|
38
|
+
}, [load]);
|
|
39
|
+
const create = async (event) => {
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
const form = event.currentTarget;
|
|
42
|
+
const name = new FormData(form).get("name");
|
|
43
|
+
const engine = new FormData(form).get("engine");
|
|
44
|
+
setBusy(true);
|
|
45
|
+
setError("");
|
|
46
|
+
try {
|
|
47
|
+
const created = await api("databases", {
|
|
48
|
+
method: "POST",
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
name,
|
|
51
|
+
...(engine === "native" ? {} : { engine }),
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
form.reset();
|
|
55
|
+
setSecret(created);
|
|
56
|
+
await load();
|
|
57
|
+
} catch (e) {
|
|
58
|
+
setError(e.message);
|
|
59
|
+
} finally {
|
|
60
|
+
setBusy(false);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const resetPassword = async (item) => {
|
|
64
|
+
if (
|
|
65
|
+
!window.confirm(
|
|
66
|
+
`Replace the password for ${item.database}? The current password will stop working.`,
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
return;
|
|
70
|
+
setBusy(true);
|
|
71
|
+
setError("");
|
|
72
|
+
try {
|
|
73
|
+
setSecret(await api(`databases/${item.id}/password`, { method: "POST" }));
|
|
74
|
+
} catch (e) {
|
|
75
|
+
setError(e.message);
|
|
76
|
+
} finally {
|
|
77
|
+
setBusy(false);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const remove = async (item) => {
|
|
81
|
+
if (
|
|
82
|
+
!window.confirm(
|
|
83
|
+
`Delete database ${item.database}? Applications using it will lose this data.`,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
return;
|
|
87
|
+
setBusy(true);
|
|
88
|
+
setError("");
|
|
89
|
+
try {
|
|
90
|
+
await api(`databases/${item.id}`, { method: "DELETE" });
|
|
91
|
+
if (secret?.id === item.id) setSecret(null);
|
|
92
|
+
await load();
|
|
93
|
+
} catch (e) {
|
|
94
|
+
setError(e.message);
|
|
95
|
+
} finally {
|
|
96
|
+
setBusy(false);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const copy = async (value) => {
|
|
100
|
+
try {
|
|
101
|
+
await navigator.clipboard.writeText(value);
|
|
102
|
+
} catch {
|
|
103
|
+
setError("Could not copy to the clipboard.");
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const rows = data?.databases || [];
|
|
107
|
+
return (
|
|
108
|
+
<>
|
|
109
|
+
<div className="page-heading">
|
|
110
|
+
<div>
|
|
111
|
+
<h1>
|
|
112
|
+
Databases<span className="title-dot">.</span>
|
|
113
|
+
</h1>
|
|
114
|
+
<p>
|
|
115
|
+
Choose a database for applications on this server. Missing engines
|
|
116
|
+
download automatically.
|
|
117
|
+
</p>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
<div className="stack database-screen">
|
|
121
|
+
{error && <div className="error">{error}</div>}
|
|
122
|
+
{secret && (
|
|
123
|
+
<section className="credential">
|
|
124
|
+
<strong>Password for {secret.database}</strong>
|
|
125
|
+
<p>Save this password. It is shown only this time.</p>
|
|
126
|
+
<div className="credential-row">
|
|
127
|
+
<input
|
|
128
|
+
readOnly
|
|
129
|
+
value={secret.password}
|
|
130
|
+
aria-label="Database password"
|
|
131
|
+
/>
|
|
132
|
+
<button
|
|
133
|
+
className="secondary"
|
|
134
|
+
type="button"
|
|
135
|
+
onClick={() => copy(secret.password)}
|
|
136
|
+
>
|
|
137
|
+
<Copy size={14} />
|
|
138
|
+
Copy
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
141
|
+
<button
|
|
142
|
+
className="text-button"
|
|
143
|
+
type="button"
|
|
144
|
+
onClick={() =>
|
|
145
|
+
copy(
|
|
146
|
+
`${secret.engine === "postgresql" ? "postgresql" : "mysql"}://${secret.user}:${encodeURIComponent(secret.password)}@${secret.host}:${secret.port}/${secret.database}`,
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
>
|
|
150
|
+
Copy connection string
|
|
151
|
+
</button>
|
|
152
|
+
</section>
|
|
153
|
+
)}
|
|
154
|
+
<section className="panel">
|
|
155
|
+
<form className="database-create" onSubmit={create}>
|
|
156
|
+
<label>
|
|
157
|
+
Database engine
|
|
158
|
+
<small>
|
|
159
|
+
Naviyra installs and manages the runtime automatically.
|
|
160
|
+
</small>
|
|
161
|
+
<select
|
|
162
|
+
name="engine"
|
|
163
|
+
aria-label="Database engine"
|
|
164
|
+
disabled={!canWrite || busy}
|
|
165
|
+
>
|
|
166
|
+
{(
|
|
167
|
+
data?.engines || [
|
|
168
|
+
{ value: "postgresql", label: "PostgreSQL" },
|
|
169
|
+
{ value: "mysql", label: "MySQL" },
|
|
170
|
+
{ value: "mariadb", label: "MariaDB" },
|
|
171
|
+
]
|
|
172
|
+
).map((engine) => (
|
|
173
|
+
<option key={engine.value} value={engine.value}>
|
|
174
|
+
{engine.label}
|
|
175
|
+
</option>
|
|
176
|
+
))}
|
|
177
|
+
<option value="native">
|
|
178
|
+
PostgreSQL (existing native server)
|
|
179
|
+
</option>
|
|
180
|
+
</select>
|
|
181
|
+
</label>
|
|
182
|
+
<label>
|
|
183
|
+
Database name
|
|
184
|
+
<small>Lowercase letters, numbers, and underscores.</small>
|
|
185
|
+
<input
|
|
186
|
+
name="name"
|
|
187
|
+
placeholder="crm"
|
|
188
|
+
aria-label="Database name"
|
|
189
|
+
autoComplete="off"
|
|
190
|
+
required
|
|
191
|
+
disabled={!canWrite || busy}
|
|
192
|
+
/>
|
|
193
|
+
</label>
|
|
194
|
+
<button className="primary" disabled={!canWrite || busy}>
|
|
195
|
+
<Plus size={16} />
|
|
196
|
+
{busy ? "Working…" : "Create database"}
|
|
197
|
+
</button>
|
|
198
|
+
</form>
|
|
199
|
+
{busy && (
|
|
200
|
+
<p role="status">
|
|
201
|
+
Preparing the runtime and database. First setup can take several
|
|
202
|
+
minutes. Keep this page open.
|
|
203
|
+
</p>
|
|
204
|
+
)}
|
|
205
|
+
</section>
|
|
206
|
+
<section className="panel">
|
|
207
|
+
{!data && loading ? (
|
|
208
|
+
<SkeletonTable
|
|
209
|
+
label="Loading databases"
|
|
210
|
+
columns={[
|
|
211
|
+
"Name",
|
|
212
|
+
"Engine",
|
|
213
|
+
"User",
|
|
214
|
+
"Host",
|
|
215
|
+
"Port",
|
|
216
|
+
"Created",
|
|
217
|
+
"",
|
|
218
|
+
]}
|
|
219
|
+
/>
|
|
220
|
+
) : rows.length ? (
|
|
221
|
+
<div className="table-wrap">
|
|
222
|
+
<table>
|
|
223
|
+
<thead>
|
|
224
|
+
<tr>
|
|
225
|
+
<th>Name</th>
|
|
226
|
+
<th>Engine</th>
|
|
227
|
+
<th>User</th>
|
|
228
|
+
<th>Host</th>
|
|
229
|
+
<th>Port</th>
|
|
230
|
+
<th>Created</th>
|
|
231
|
+
<th></th>
|
|
232
|
+
</tr>
|
|
233
|
+
</thead>
|
|
234
|
+
<tbody>
|
|
235
|
+
{rows.map((item) => (
|
|
236
|
+
<tr key={item.id}>
|
|
237
|
+
<td className="mono">{item.database}</td>
|
|
238
|
+
<td>
|
|
239
|
+
{{
|
|
240
|
+
postgresql: "PostgreSQL",
|
|
241
|
+
mysql: "MySQL",
|
|
242
|
+
mariadb: "MariaDB",
|
|
243
|
+
}[item.engine] || item.engine}
|
|
244
|
+
</td>
|
|
245
|
+
<td className="mono">{item.user}</td>
|
|
246
|
+
<td className="mono">{item.host}</td>
|
|
247
|
+
<td>{item.port}</td>
|
|
248
|
+
<td>{new Date(item.created_at).toLocaleDateString()}</td>
|
|
249
|
+
<td>
|
|
250
|
+
{canWrite && (
|
|
251
|
+
<span className="row-actions">
|
|
252
|
+
<button
|
|
253
|
+
className="text-button"
|
|
254
|
+
type="button"
|
|
255
|
+
disabled={busy}
|
|
256
|
+
onClick={() => resetPassword(item)}
|
|
257
|
+
>
|
|
258
|
+
New password
|
|
259
|
+
</button>
|
|
260
|
+
<button
|
|
261
|
+
className="text-button danger-text"
|
|
262
|
+
type="button"
|
|
263
|
+
disabled={busy}
|
|
264
|
+
onClick={() => remove(item)}
|
|
265
|
+
>
|
|
266
|
+
<Trash2 size={14} />
|
|
267
|
+
Delete
|
|
268
|
+
</button>
|
|
269
|
+
</span>
|
|
270
|
+
)}
|
|
271
|
+
</td>
|
|
272
|
+
</tr>
|
|
273
|
+
))}
|
|
274
|
+
</tbody>
|
|
275
|
+
</table>
|
|
276
|
+
</div>
|
|
277
|
+
) : (
|
|
278
|
+
<div className="empty compact-empty">
|
|
279
|
+
<div className="empty-icon">
|
|
280
|
+
<Database size={28} />
|
|
281
|
+
</div>
|
|
282
|
+
<h3>No databases yet</h3>
|
|
283
|
+
<p>
|
|
284
|
+
Create one and use its host, user, and password in your
|
|
285
|
+
application.
|
|
286
|
+
</p>
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
</section>
|
|
290
|
+
</div>
|
|
291
|
+
</>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
export function SkeletonTable({ label, columns, rows = 6, tableClass = "" }) {
|
|
2
|
+
return (
|
|
3
|
+
<div className="table-wrap skeleton-table-wrap">
|
|
4
|
+
<table className={tableClass} aria-busy="true" aria-label={label}>
|
|
5
|
+
<thead>
|
|
6
|
+
<tr>
|
|
7
|
+
{columns.map((column) => (
|
|
8
|
+
<th key={column || "actions"}>{column}</th>
|
|
9
|
+
))}
|
|
10
|
+
</tr>
|
|
11
|
+
</thead>
|
|
12
|
+
<tbody>
|
|
13
|
+
{Array.from({ length: rows }, (_, row) => (
|
|
14
|
+
<tr key={row} className="skeleton-row">
|
|
15
|
+
{columns.map((column, index) => (
|
|
16
|
+
<td key={`${column}-${index}`}>
|
|
17
|
+
{column ? <span className="skeleton" /> : null}
|
|
18
|
+
</td>
|
|
19
|
+
))}
|
|
20
|
+
</tr>
|
|
21
|
+
))}
|
|
22
|
+
</tbody>
|
|
23
|
+
</table>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function SkeletonStats({ count = 4, label = "Loading" }) {
|
|
29
|
+
return (
|
|
30
|
+
<div className="stats" aria-busy="true" aria-label={label}>
|
|
31
|
+
{Array.from({ length: count }, (_, index) => (
|
|
32
|
+
<div key={index} className="stat skeleton-card">
|
|
33
|
+
<span className="skeleton" />
|
|
34
|
+
<span className="skeleton" />
|
|
35
|
+
<span className="skeleton" />
|
|
36
|
+
</div>
|
|
37
|
+
))}
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function SkeletonRows({ rows = 5, label = "Loading" }) {
|
|
43
|
+
return (
|
|
44
|
+
<div aria-busy="true" aria-label={label}>
|
|
45
|
+
{Array.from({ length: rows }, (_, index) => (
|
|
46
|
+
<div key={index} className="skeleton-line">
|
|
47
|
+
<span className="skeleton sq" />
|
|
48
|
+
<span className="skeleton grow" />
|
|
49
|
+
<span className="skeleton mid" />
|
|
50
|
+
</div>
|
|
51
|
+
))}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function ProjectSkeleton() {
|
|
57
|
+
return (
|
|
58
|
+
<div aria-busy="true" aria-label="Loading project">
|
|
59
|
+
<div className="page-heading">
|
|
60
|
+
<span className="skeleton title" />
|
|
61
|
+
<span className="skeleton button" />
|
|
62
|
+
</div>
|
|
63
|
+
<SkeletonStats label="Loading project" />
|
|
64
|
+
<section className="panel">
|
|
65
|
+
<SkeletonRows rows={4} label="Loading project" />
|
|
66
|
+
</section>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function EmailSkeleton() {
|
|
72
|
+
return (
|
|
73
|
+
<div className="stack" aria-busy="true" aria-label="Loading email routing">
|
|
74
|
+
<section className="panel skeleton-status">
|
|
75
|
+
<span className="skeleton" />
|
|
76
|
+
<span className="skeleton" />
|
|
77
|
+
</section>
|
|
78
|
+
<section className="panel">
|
|
79
|
+
<div className="section-heading">
|
|
80
|
+
<h2>Destination addresses</h2>
|
|
81
|
+
</div>
|
|
82
|
+
<SkeletonTable
|
|
83
|
+
label="Loading destination addresses"
|
|
84
|
+
columns={["Email", "Verified", ""]}
|
|
85
|
+
rows={3}
|
|
86
|
+
/>
|
|
87
|
+
</section>
|
|
88
|
+
<section className="panel">
|
|
89
|
+
<div className="section-heading">
|
|
90
|
+
<h2>Routing rules</h2>
|
|
91
|
+
</div>
|
|
92
|
+
<SkeletonTable
|
|
93
|
+
label="Loading routing rules"
|
|
94
|
+
columns={["Name", "Match", "Action", "Enabled", ""]}
|
|
95
|
+
rows={3}
|
|
96
|
+
/>
|
|
97
|
+
</section>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function PageSkeleton({ view }) {
|
|
103
|
+
if (view === "project") return <ProjectSkeleton />;
|
|
104
|
+
if (view === "infrastructure") {
|
|
105
|
+
return (
|
|
106
|
+
<>
|
|
107
|
+
<div className="page-heading">
|
|
108
|
+
<h1>
|
|
109
|
+
Infrastructure<span className="title-dot">.</span>
|
|
110
|
+
</h1>
|
|
111
|
+
</div>
|
|
112
|
+
<SkeletonStats label="Loading infrastructure" />
|
|
113
|
+
<section className="panel">
|
|
114
|
+
<SkeletonRows rows={4} label="Loading infrastructure" />
|
|
115
|
+
</section>
|
|
116
|
+
</>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (view === "activity") {
|
|
120
|
+
return (
|
|
121
|
+
<>
|
|
122
|
+
<div className="page-heading">
|
|
123
|
+
<h1>
|
|
124
|
+
Project activity<span className="title-dot">.</span>
|
|
125
|
+
</h1>
|
|
126
|
+
</div>
|
|
127
|
+
<section className="panel">
|
|
128
|
+
<SkeletonRows label="Loading activity" />
|
|
129
|
+
</section>
|
|
130
|
+
</>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (view === "dns") {
|
|
134
|
+
return (
|
|
135
|
+
<>
|
|
136
|
+
<div className="page-heading">
|
|
137
|
+
<h1>
|
|
138
|
+
DNS records<span className="title-dot">.</span>
|
|
139
|
+
</h1>
|
|
140
|
+
</div>
|
|
141
|
+
<section className="panel">
|
|
142
|
+
<SkeletonTable
|
|
143
|
+
label="Loading DNS records"
|
|
144
|
+
tableClass="dns-table"
|
|
145
|
+
columns={["Type", "Name", "Content", "Proxy", "TTL", ""]}
|
|
146
|
+
/>
|
|
147
|
+
</section>
|
|
148
|
+
</>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (view === "databases") {
|
|
152
|
+
return (
|
|
153
|
+
<>
|
|
154
|
+
<div className="page-heading">
|
|
155
|
+
<h1>
|
|
156
|
+
Databases<span className="title-dot">.</span>
|
|
157
|
+
</h1>
|
|
158
|
+
</div>
|
|
159
|
+
<section className="panel">
|
|
160
|
+
<SkeletonTable
|
|
161
|
+
label="Loading databases"
|
|
162
|
+
columns={["Name", "User", "Host", "Port", "Created", ""]}
|
|
163
|
+
/>
|
|
164
|
+
</section>
|
|
165
|
+
</>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (view === "email") {
|
|
169
|
+
return (
|
|
170
|
+
<>
|
|
171
|
+
<div className="page-heading">
|
|
172
|
+
<h1>
|
|
173
|
+
Email routing<span className="title-dot">.</span>
|
|
174
|
+
</h1>
|
|
175
|
+
</div>
|
|
176
|
+
<EmailSkeleton />
|
|
177
|
+
</>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return (
|
|
181
|
+
<>
|
|
182
|
+
<div className="page-heading">
|
|
183
|
+
<h1>
|
|
184
|
+
Projects<span className="title-dot">.</span>
|
|
185
|
+
</h1>
|
|
186
|
+
</div>
|
|
187
|
+
<SkeletonStats label="Loading projects" />
|
|
188
|
+
<section className="panel">
|
|
189
|
+
<SkeletonTable
|
|
190
|
+
label="Loading projects"
|
|
191
|
+
columns={["Project", "Status", "Domain", "Port", "Updated", ""]}
|
|
192
|
+
/>
|
|
193
|
+
</section>
|
|
194
|
+
</>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
import { Moon, Sun } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
export function ThemeSync() {
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
|
8
|
+
const apply = () => {
|
|
9
|
+
if (localStorage.getItem("naviyra-theme")) return;
|
|
10
|
+
document.documentElement.dataset.theme = media.matches ? "dark" : "light";
|
|
11
|
+
};
|
|
12
|
+
media.addEventListener("change", apply);
|
|
13
|
+
return () => media.removeEventListener("change", apply);
|
|
14
|
+
}, []);
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ThemeToggle({ className = "" }) {
|
|
19
|
+
return (
|
|
20
|
+
<button
|
|
21
|
+
type="button"
|
|
22
|
+
className={`theme-toggle ${className}`.trim()}
|
|
23
|
+
aria-label="Toggle color theme"
|
|
24
|
+
onClick={() => {
|
|
25
|
+
const next =
|
|
26
|
+
document.documentElement.dataset.theme === "dark" ? "light" : "dark";
|
|
27
|
+
document.documentElement.dataset.theme = next;
|
|
28
|
+
localStorage.setItem("naviyra-theme", next);
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
<Sun className="theme-sun" size={16} aria-hidden="true" />
|
|
32
|
+
<Moon className="theme-moon" size={16} aria-hidden="true" />
|
|
33
|
+
</button>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { db } from "./db.mjs";
|
|
2
|
+
export async function agent(route, options = {}) {
|
|
3
|
+
const base = process.env.AGENT_URL || "http://127.0.0.1:4001";
|
|
4
|
+
const url = new URL(base);
|
|
5
|
+
if (
|
|
6
|
+
url.protocol !== "https:" &&
|
|
7
|
+
!["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)
|
|
8
|
+
)
|
|
9
|
+
throw Error("Remote agents require HTTPS");
|
|
10
|
+
if ((process.env.AGENT_TOKEN?.length || 0) < 32)
|
|
11
|
+
throw Error("Configure AGENT_TOKEN");
|
|
12
|
+
const response = await fetch(base + route, {
|
|
13
|
+
...options,
|
|
14
|
+
headers: {
|
|
15
|
+
Authorization: `Bearer ${process.env.AGENT_TOKEN}`,
|
|
16
|
+
"Content-Type": "application/json",
|
|
17
|
+
...options.headers,
|
|
18
|
+
},
|
|
19
|
+
cache: "no-store",
|
|
20
|
+
signal: AbortSignal.timeout(30000),
|
|
21
|
+
});
|
|
22
|
+
const body = await response.json();
|
|
23
|
+
if (!response.ok)
|
|
24
|
+
throw Object.assign(Error(body.error || "Agent request failed"), {
|
|
25
|
+
status: response.status,
|
|
26
|
+
});
|
|
27
|
+
await db.query("UPDATE agents SET last_seen=now() WHERE name='primary'");
|
|
28
|
+
return body;
|
|
29
|
+
}
|
|
30
|
+
export async function reconcile(project) {
|
|
31
|
+
const remote = await agent(`/projects/${project.id}`);
|
|
32
|
+
const client = await db.connect();
|
|
33
|
+
try {
|
|
34
|
+
await client.query("BEGIN");
|
|
35
|
+
await client.query(
|
|
36
|
+
"UPDATE projects SET status=$2,port=$3,folder_path=$4,updated_at=CASE WHEN status IS DISTINCT FROM $2 THEN now() ELSE updated_at END WHERE id=$1",
|
|
37
|
+
[project.id, remote.status, remote.port, remote.folder_path],
|
|
38
|
+
);
|
|
39
|
+
await client.query(
|
|
40
|
+
"UPDATE deployments SET status='failed',error='Agent did not accept this deployment. Please retry.',finished_at=now() WHERE project_id=$1 AND status IN ('queued','deploying') AND id IS DISTINCT FROM $2::uuid AND created_at < now() - interval '2 minutes'",
|
|
41
|
+
[project.id, remote.deployment_id || null],
|
|
42
|
+
);
|
|
43
|
+
if (remote.deployment_id)
|
|
44
|
+
await client.query(
|
|
45
|
+
"UPDATE deployments SET status=$2,error=$3,logs_ref=$4,finished_at=CASE WHEN $2 IN ('running','failed') THEN COALESCE(finished_at,now()) ELSE NULL END WHERE id=$1 AND project_id=$5",
|
|
46
|
+
[
|
|
47
|
+
remote.deployment_id,
|
|
48
|
+
remote.status,
|
|
49
|
+
remote.error || null,
|
|
50
|
+
`agent:${project.id}`,
|
|
51
|
+
project.id,
|
|
52
|
+
],
|
|
53
|
+
);
|
|
54
|
+
if (remote.tunnel) {
|
|
55
|
+
const t = remote.tunnel;
|
|
56
|
+
await client.query(
|
|
57
|
+
"INSERT INTO tunnels(project_id,tunnel_id,tunnel_name,credentials_path,config_path) VALUES($1,$2,$3,$4,$5) ON CONFLICT(project_id) DO UPDATE SET tunnel_id=$2,tunnel_name=$3,credentials_path=$4,config_path=$5",
|
|
58
|
+
[
|
|
59
|
+
project.id,
|
|
60
|
+
t.tunnel_id,
|
|
61
|
+
t.tunnel_name,
|
|
62
|
+
t.credentials_path,
|
|
63
|
+
t.config_path,
|
|
64
|
+
],
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
await client.query("COMMIT");
|
|
68
|
+
} catch (error) {
|
|
69
|
+
await client.query("ROLLBACK");
|
|
70
|
+
throw error;
|
|
71
|
+
} finally {
|
|
72
|
+
client.release();
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
...project,
|
|
76
|
+
status: remote.status,
|
|
77
|
+
port: remote.port,
|
|
78
|
+
folder_path: remote.folder_path,
|
|
79
|
+
tunnel: remote.tunnel,
|
|
80
|
+
error: remote.error,
|
|
81
|
+
has_upload: remote.has_upload,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { SignJWT, jwtVerify } from "jose";
|
|
2
|
+
import { cookies } from "next/headers";
|
|
3
|
+
import { db } from "./db.mjs";
|
|
4
|
+
function key() {
|
|
5
|
+
if ((process.env.JWT_SECRET?.length || 0) < 32)
|
|
6
|
+
throw Error("JWT_SECRET must contain at least 32 characters");
|
|
7
|
+
return new TextEncoder().encode(process.env.JWT_SECRET);
|
|
8
|
+
}
|
|
9
|
+
export async function session() {
|
|
10
|
+
const token = (await cookies()).get("naviyra_session")?.value;
|
|
11
|
+
if (!token) return null;
|
|
12
|
+
let payload;
|
|
13
|
+
try {
|
|
14
|
+
({ payload } = await jwtVerify(token, key(), {
|
|
15
|
+
algorithms: ["HS256"],
|
|
16
|
+
issuer: "naviyra",
|
|
17
|
+
audience: "naviyra-panel",
|
|
18
|
+
}));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return (
|
|
23
|
+
(
|
|
24
|
+
await db.query("SELECT id,email,role FROM users WHERE id=$1", [
|
|
25
|
+
payload.sub,
|
|
26
|
+
])
|
|
27
|
+
).rows[0] || null
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
export async function signIn(user) {
|
|
31
|
+
const token = await new SignJWT({})
|
|
32
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
33
|
+
.setSubject(user.id)
|
|
34
|
+
.setIssuer("naviyra")
|
|
35
|
+
.setAudience("naviyra-panel")
|
|
36
|
+
.setIssuedAt()
|
|
37
|
+
.setExpirationTime("8h")
|
|
38
|
+
.sign(key());
|
|
39
|
+
(await cookies()).set("naviyra_session", token, {
|
|
40
|
+
httpOnly: true,
|
|
41
|
+
sameSite: "strict",
|
|
42
|
+
secure:
|
|
43
|
+
process.env.PUBLIC_ORIGIN?.startsWith("https://") ||
|
|
44
|
+
process.env.NODE_ENV === "production",
|
|
45
|
+
path: "/",
|
|
46
|
+
maxAge: 28800,
|
|
47
|
+
});
|
|
48
|
+
}
|