@naviyra/cloud-panel 0.0.3-beta → 0.0.3-beta3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/agent/src/cloudflare.mjs +858 -733
- package/apps/agent/src/files.mjs +162 -0
- package/apps/agent/src/host-tools.mjs +518 -0
- package/apps/agent/src/native.mjs +157 -32
- package/apps/agent/src/runtime.mjs +70 -37
- package/apps/agent/src/server.mjs +61 -6
- package/apps/panel/app/api/[...path]/route.js +96 -14
- package/apps/panel/app/globals.css +313 -7
- package/apps/panel/app/layout.jsx +2 -1
- package/apps/panel/app/login/page.jsx +3 -8
- package/apps/panel/app/page.jsx +17 -7
- package/apps/panel/app/ui/cloudflare.jsx +72 -47
- package/apps/panel/app/ui/confirm.jsx +109 -0
- package/apps/panel/app/ui/dashboard.jsx +87 -72
- package/apps/panel/app/ui/databases.jsx +168 -59
- package/apps/panel/app/ui/project-tools.jsx +604 -0
- package/apps/panel/app/ui/toast.jsx +90 -0
- package/apps/panel/lib/agent.mjs +13 -2
- package/apps/panel/lib/databases.mjs +120 -0
- package/apps/panel/lib/request-origin.mjs +29 -0
- package/package.json +1 -1
- package/packages/shared/index.mjs +49 -0
- package/scripts/panel-control.mjs +214 -214
- package/scripts/service-supervisor.mjs +164 -164
- package/scripts/windows-boot.mjs +259 -259
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Download, Folder, Plus, Save, Trash2, Upload } from "lucide-react";
|
|
4
|
+
import { useConfirm } from "./confirm";
|
|
5
|
+
import { useToast } from "./toast";
|
|
6
|
+
|
|
7
|
+
async function api(url, options = {}) {
|
|
8
|
+
const response = await fetch(`/api/${url}`, {
|
|
9
|
+
...options,
|
|
10
|
+
headers: { "Content-Type": "application/json", ...options.headers },
|
|
11
|
+
});
|
|
12
|
+
const body = await response.json().catch(() => ({}));
|
|
13
|
+
if (response.status === 401) {
|
|
14
|
+
window.location.href = "/login";
|
|
15
|
+
throw Error("Session expired");
|
|
16
|
+
}
|
|
17
|
+
if (!response.ok) throw Error(body.error || "Request failed");
|
|
18
|
+
return body;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function joinPath(dir, name) {
|
|
22
|
+
return dir ? `${dir}/${name}` : name;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function FileManager({ project, canWrite }) {
|
|
26
|
+
const confirm = useConfirm();
|
|
27
|
+
const toast = useToast();
|
|
28
|
+
const [path, setPath] = useState("");
|
|
29
|
+
const [entries, setEntries] = useState([]);
|
|
30
|
+
const [truncated, setTruncated] = useState(false);
|
|
31
|
+
const [loading, setLoading] = useState(true);
|
|
32
|
+
const [editor, setEditor] = useState(null);
|
|
33
|
+
const [content, setContent] = useState("");
|
|
34
|
+
const [draft, setDraft] = useState("");
|
|
35
|
+
const [busy, setBusy] = useState(false);
|
|
36
|
+
const load = async (next = path) => {
|
|
37
|
+
setLoading(true);
|
|
38
|
+
try {
|
|
39
|
+
const data = await api(
|
|
40
|
+
`projects/${project.id}/files?path=${encodeURIComponent(next)}`,
|
|
41
|
+
);
|
|
42
|
+
setPath(data.path || "");
|
|
43
|
+
setEntries(data.entries || []);
|
|
44
|
+
setTruncated(Boolean(data.truncated));
|
|
45
|
+
} catch (error) {
|
|
46
|
+
toast.failed(error.message);
|
|
47
|
+
} finally {
|
|
48
|
+
setLoading(false);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
load("");
|
|
53
|
+
}, [project.id]);
|
|
54
|
+
const openEntry = async (entry) => {
|
|
55
|
+
if (entry.kind === "link") {
|
|
56
|
+
toast.failed("Links cannot be opened");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const next = joinPath(path, entry.name);
|
|
60
|
+
if (entry.kind === "dir") {
|
|
61
|
+
setEditor(null);
|
|
62
|
+
await load(next);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
setBusy(true);
|
|
66
|
+
try {
|
|
67
|
+
const file = await api(
|
|
68
|
+
`projects/${project.id}/files?path=${encodeURIComponent(next)}&read=1`,
|
|
69
|
+
);
|
|
70
|
+
if (file.binary) {
|
|
71
|
+
setEditor(null);
|
|
72
|
+
toast.failed("This file is binary. Download it or replace it with an upload.");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
setEditor(file.path);
|
|
76
|
+
setContent(file.content || "");
|
|
77
|
+
} catch (error) {
|
|
78
|
+
toast.failed(error.message);
|
|
79
|
+
} finally {
|
|
80
|
+
setBusy(false);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const save = async () => {
|
|
84
|
+
setBusy(true);
|
|
85
|
+
try {
|
|
86
|
+
await api(`projects/${project.id}/files`, {
|
|
87
|
+
method: "PUT",
|
|
88
|
+
body: JSON.stringify({ path: editor, content }),
|
|
89
|
+
});
|
|
90
|
+
toast.success("File saved. Restart the application if it is already running.");
|
|
91
|
+
} catch (error) {
|
|
92
|
+
toast.failed(error.message);
|
|
93
|
+
} finally {
|
|
94
|
+
setBusy(false);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const create = async (kind) => {
|
|
98
|
+
const name = draft.trim();
|
|
99
|
+
if (!name || name.includes("/") || name.includes("\\") || name === "..") {
|
|
100
|
+
toast.failed("Use a file or folder name without slashes");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
setBusy(true);
|
|
104
|
+
try {
|
|
105
|
+
const target = joinPath(path, name);
|
|
106
|
+
if (kind === "dir") {
|
|
107
|
+
await api(`projects/${project.id}/files`, {
|
|
108
|
+
method: "PUT",
|
|
109
|
+
body: JSON.stringify({ action: "mkdir", path: target }),
|
|
110
|
+
});
|
|
111
|
+
toast.success("Folder created.");
|
|
112
|
+
} else {
|
|
113
|
+
await api(`projects/${project.id}/files`, {
|
|
114
|
+
method: "PUT",
|
|
115
|
+
body: JSON.stringify({ path: target, content: "" }),
|
|
116
|
+
});
|
|
117
|
+
setEditor(target);
|
|
118
|
+
setContent("");
|
|
119
|
+
toast.success("File created.");
|
|
120
|
+
}
|
|
121
|
+
setDraft("");
|
|
122
|
+
await load(path);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
toast.failed(error.message);
|
|
125
|
+
} finally {
|
|
126
|
+
setBusy(false);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const remove = async (entry) => {
|
|
130
|
+
const target = joinPath(path, entry.name);
|
|
131
|
+
if (
|
|
132
|
+
!(await confirm({
|
|
133
|
+
title: `Delete ${entry.name}?`,
|
|
134
|
+
message: "This removes it from the application directory.",
|
|
135
|
+
confirmLabel: "Delete",
|
|
136
|
+
}))
|
|
137
|
+
)
|
|
138
|
+
return;
|
|
139
|
+
setBusy(true);
|
|
140
|
+
try {
|
|
141
|
+
await api(
|
|
142
|
+
`projects/${project.id}/files?path=${encodeURIComponent(target)}`,
|
|
143
|
+
{ method: "DELETE" },
|
|
144
|
+
);
|
|
145
|
+
if (editor === target) setEditor(null);
|
|
146
|
+
toast.success("Deleted.");
|
|
147
|
+
await load(path);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
toast.failed(error.message);
|
|
150
|
+
} finally {
|
|
151
|
+
setBusy(false);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const upload = async (event) => {
|
|
155
|
+
const file = event.target.files?.[0];
|
|
156
|
+
event.target.value = "";
|
|
157
|
+
if (!file) return;
|
|
158
|
+
if (file.size > 32 * 1024 * 1024) {
|
|
159
|
+
toast.failed("File is larger than 32 MB");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
setBusy(true);
|
|
163
|
+
try {
|
|
164
|
+
const target = joinPath(path, file.name);
|
|
165
|
+
const response = await fetch(
|
|
166
|
+
`/api/projects/${project.id}/files?path=${encodeURIComponent(target)}`,
|
|
167
|
+
{
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
170
|
+
body: file,
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
const body = await response.json().catch(() => ({}));
|
|
174
|
+
if (!response.ok) throw Error(body.error || "Upload failed");
|
|
175
|
+
toast.success("File uploaded.");
|
|
176
|
+
await load(path);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
toast.failed(error.message);
|
|
179
|
+
} finally {
|
|
180
|
+
setBusy(false);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const crumbs = path ? path.split("/") : [];
|
|
184
|
+
return (
|
|
185
|
+
<section className="panel">
|
|
186
|
+
<div className="section-heading">
|
|
187
|
+
<h2>File manager</h2>
|
|
188
|
+
<Folder size={17} />
|
|
189
|
+
</div>
|
|
190
|
+
<p className="muted">
|
|
191
|
+
Files in this application's deployed directory. A new deployment
|
|
192
|
+
replaces this folder.
|
|
193
|
+
</p>
|
|
194
|
+
<div className="file-crumbs">
|
|
195
|
+
<button type="button" onClick={() => load("")}>
|
|
196
|
+
app
|
|
197
|
+
</button>
|
|
198
|
+
{crumbs.map((part, index) => (
|
|
199
|
+
<button
|
|
200
|
+
key={part + index}
|
|
201
|
+
type="button"
|
|
202
|
+
onClick={() => load(crumbs.slice(0, index + 1).join("/"))}
|
|
203
|
+
>
|
|
204
|
+
{part}
|
|
205
|
+
</button>
|
|
206
|
+
))}
|
|
207
|
+
</div>
|
|
208
|
+
{canWrite && (
|
|
209
|
+
<div className="file-actions">
|
|
210
|
+
<input
|
|
211
|
+
value={draft}
|
|
212
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
213
|
+
placeholder="New name"
|
|
214
|
+
aria-label="New file or folder name"
|
|
215
|
+
/>
|
|
216
|
+
<button type="button" className="secondary" disabled={busy} onClick={() => create("file")}>
|
|
217
|
+
<Plus size={14} /> File
|
|
218
|
+
</button>
|
|
219
|
+
<button type="button" className="secondary" disabled={busy} onClick={() => create("dir")}>
|
|
220
|
+
<Folder size={14} /> Folder
|
|
221
|
+
</button>
|
|
222
|
+
<label className="secondary file-upload">
|
|
223
|
+
<Upload size={14} /> Upload
|
|
224
|
+
<input type="file" onChange={upload} disabled={busy} />
|
|
225
|
+
</label>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
{loading ? (
|
|
229
|
+
<p className="muted">Loading files…</p>
|
|
230
|
+
) : entries.length === 0 ? (
|
|
231
|
+
<p className="muted">This folder is empty.</p>
|
|
232
|
+
) : (
|
|
233
|
+
<div className="file-list">
|
|
234
|
+
{path && (
|
|
235
|
+
<button type="button" className="file-row" onClick={() => load(crumbs.slice(0, -1).join("/"))}>
|
|
236
|
+
<span>../</span>
|
|
237
|
+
</button>
|
|
238
|
+
)}
|
|
239
|
+
{entries.map((entry) => (
|
|
240
|
+
<div key={entry.name} className="file-row">
|
|
241
|
+
<button type="button" onClick={() => openEntry(entry)}>
|
|
242
|
+
<span>{entry.kind === "dir" ? `${entry.name}/` : entry.name}</span>
|
|
243
|
+
<small>
|
|
244
|
+
{entry.kind === "file" && entry.size != null
|
|
245
|
+
? `${entry.size} B`
|
|
246
|
+
: entry.kind}
|
|
247
|
+
</small>
|
|
248
|
+
</button>
|
|
249
|
+
<span className="file-row-actions">
|
|
250
|
+
{entry.kind === "file" && (
|
|
251
|
+
<a
|
|
252
|
+
href={`/api/projects/${project.id}/files?path=${encodeURIComponent(joinPath(path, entry.name))}&download=1`}
|
|
253
|
+
aria-label={`Download ${entry.name}`}
|
|
254
|
+
>
|
|
255
|
+
<Download size={15} />
|
|
256
|
+
</a>
|
|
257
|
+
)}
|
|
258
|
+
{canWrite && (
|
|
259
|
+
<button type="button" onClick={() => remove(entry)} aria-label={`Delete ${entry.name}`}>
|
|
260
|
+
<Trash2 size={15} />
|
|
261
|
+
</button>
|
|
262
|
+
)}
|
|
263
|
+
</span>
|
|
264
|
+
</div>
|
|
265
|
+
))}
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
{truncated && <p className="muted">Showing the first 500 entries.</p>}
|
|
269
|
+
{editor && (
|
|
270
|
+
<div className="file-editor-wrap">
|
|
271
|
+
<div className="section-heading">
|
|
272
|
+
<h2>{editor}</h2>
|
|
273
|
+
</div>
|
|
274
|
+
<textarea
|
|
275
|
+
className="file-editor"
|
|
276
|
+
value={content}
|
|
277
|
+
readOnly={!canWrite}
|
|
278
|
+
onChange={(event) => setContent(event.target.value)}
|
|
279
|
+
spellCheck={false}
|
|
280
|
+
/>
|
|
281
|
+
{canWrite && (
|
|
282
|
+
<button type="button" className="primary" disabled={busy} onClick={save}>
|
|
283
|
+
<Save size={15} /> Save
|
|
284
|
+
</button>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
)}
|
|
288
|
+
</section>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function ApplicationSettings({ project, canWrite }) {
|
|
293
|
+
const toast = useToast();
|
|
294
|
+
const type = project.type || "nodejs";
|
|
295
|
+
const [startup, setStartup] = useState("");
|
|
296
|
+
const [working, setWorking] = useState(".");
|
|
297
|
+
const [publicDir, setPublicDir] = useState(".");
|
|
298
|
+
const [server, setServer] = useState("asgi");
|
|
299
|
+
const [rows, setRows] = useState([]);
|
|
300
|
+
const [base, setBase] = useState(project.runtime_config || {});
|
|
301
|
+
const [busy, setBusy] = useState(false);
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
let cancel = false;
|
|
304
|
+
api(`projects/${project.id}`)
|
|
305
|
+
.then((row) => {
|
|
306
|
+
if (cancel) return;
|
|
307
|
+
const config = row.runtime_config || {};
|
|
308
|
+
setBase(config);
|
|
309
|
+
setStartup(config.startup_file || "");
|
|
310
|
+
setWorking(config.working_dir || ".");
|
|
311
|
+
setPublicDir(config.public_dir || ".");
|
|
312
|
+
setServer(config.python_server || "asgi");
|
|
313
|
+
setRows(
|
|
314
|
+
Object.entries(config.env || {}).map(([key, value]) => ({ key, value })),
|
|
315
|
+
);
|
|
316
|
+
})
|
|
317
|
+
.catch((error) => toast.failed(error.message));
|
|
318
|
+
return () => {
|
|
319
|
+
cancel = true;
|
|
320
|
+
};
|
|
321
|
+
}, [project.id, toast]);
|
|
322
|
+
const save = async () => {
|
|
323
|
+
const env = {};
|
|
324
|
+
for (const row of rows) {
|
|
325
|
+
const key = row.key.trim();
|
|
326
|
+
if (!key) continue;
|
|
327
|
+
if (env[key] !== undefined) {
|
|
328
|
+
toast.failed(`${key} is listed more than once`);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
env[key] = row.value;
|
|
332
|
+
}
|
|
333
|
+
setBusy(true);
|
|
334
|
+
try {
|
|
335
|
+
await api(`projects/${project.id}/config`, {
|
|
336
|
+
method: "PUT",
|
|
337
|
+
body: JSON.stringify({
|
|
338
|
+
type,
|
|
339
|
+
runtime_config: {
|
|
340
|
+
...base,
|
|
341
|
+
startup_file: startup.trim(),
|
|
342
|
+
working_dir: working.trim() || ".",
|
|
343
|
+
public_dir: publicDir.trim() || ".",
|
|
344
|
+
python_server: server,
|
|
345
|
+
env,
|
|
346
|
+
},
|
|
347
|
+
}),
|
|
348
|
+
});
|
|
349
|
+
toast.success("Application settings saved. Redeploy to apply them.");
|
|
350
|
+
} catch (error) {
|
|
351
|
+
toast.failed(error.message);
|
|
352
|
+
} finally {
|
|
353
|
+
setBusy(false);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
const showStartup = ["nodejs", "python", "go"].includes(type);
|
|
357
|
+
const showWorking = ["nodejs", "python", "go", "custom"].includes(type);
|
|
358
|
+
const showPublic = ["php", "static"].includes(type);
|
|
359
|
+
return (
|
|
360
|
+
<section className="panel">
|
|
361
|
+
<div className="section-heading">
|
|
362
|
+
<h2>Application</h2>
|
|
363
|
+
</div>
|
|
364
|
+
<p className="muted">
|
|
365
|
+
Startup file, working directory, and environment for this application.
|
|
366
|
+
Redeploy after saving.
|
|
367
|
+
</p>
|
|
368
|
+
<div className="app-fields">
|
|
369
|
+
{showStartup && (
|
|
370
|
+
<label>
|
|
371
|
+
Startup file
|
|
372
|
+
<input
|
|
373
|
+
value={startup}
|
|
374
|
+
disabled={!canWrite}
|
|
375
|
+
placeholder={type === "python" ? "app.py" : "server.js"}
|
|
376
|
+
onChange={(event) => setStartup(event.target.value)}
|
|
377
|
+
/>
|
|
378
|
+
</label>
|
|
379
|
+
)}
|
|
380
|
+
{showWorking && (
|
|
381
|
+
<label>
|
|
382
|
+
Working directory
|
|
383
|
+
<input
|
|
384
|
+
value={working}
|
|
385
|
+
disabled={!canWrite}
|
|
386
|
+
onChange={(event) => setWorking(event.target.value)}
|
|
387
|
+
/>
|
|
388
|
+
</label>
|
|
389
|
+
)}
|
|
390
|
+
{showPublic && (
|
|
391
|
+
<label>
|
|
392
|
+
Document root
|
|
393
|
+
<input
|
|
394
|
+
value={publicDir}
|
|
395
|
+
disabled={!canWrite}
|
|
396
|
+
onChange={(event) => setPublicDir(event.target.value)}
|
|
397
|
+
/>
|
|
398
|
+
</label>
|
|
399
|
+
)}
|
|
400
|
+
{type === "python" && (
|
|
401
|
+
<label>
|
|
402
|
+
Python server
|
|
403
|
+
<select
|
|
404
|
+
value={server}
|
|
405
|
+
disabled={!canWrite}
|
|
406
|
+
onChange={(event) => setServer(event.target.value)}
|
|
407
|
+
>
|
|
408
|
+
<option value="asgi">ASGI (uvicorn)</option>
|
|
409
|
+
<option value="wsgi">WSGI (waitress)</option>
|
|
410
|
+
</select>
|
|
411
|
+
</label>
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
<h3>Environment</h3>
|
|
415
|
+
<div className="env-list">
|
|
416
|
+
{rows.map((row, index) => (
|
|
417
|
+
<div key={index} className="env-row">
|
|
418
|
+
<input
|
|
419
|
+
aria-label="Variable name"
|
|
420
|
+
value={row.key}
|
|
421
|
+
disabled={!canWrite}
|
|
422
|
+
placeholder="NAME"
|
|
423
|
+
onChange={(event) =>
|
|
424
|
+
setRows((current) =>
|
|
425
|
+
current.map((item, itemIndex) =>
|
|
426
|
+
itemIndex === index ? { ...item, key: event.target.value } : item,
|
|
427
|
+
),
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
/>
|
|
431
|
+
<input
|
|
432
|
+
aria-label="Variable value"
|
|
433
|
+
value={row.value}
|
|
434
|
+
disabled={!canWrite}
|
|
435
|
+
placeholder="value"
|
|
436
|
+
onChange={(event) =>
|
|
437
|
+
setRows((current) =>
|
|
438
|
+
current.map((item, itemIndex) =>
|
|
439
|
+
itemIndex === index
|
|
440
|
+
? { ...item, value: event.target.value }
|
|
441
|
+
: item,
|
|
442
|
+
),
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
/>
|
|
446
|
+
{canWrite && (
|
|
447
|
+
<button
|
|
448
|
+
type="button"
|
|
449
|
+
className="secondary"
|
|
450
|
+
aria-label="Remove variable"
|
|
451
|
+
onClick={() =>
|
|
452
|
+
setRows((current) => current.filter((_, itemIndex) => itemIndex !== index))
|
|
453
|
+
}
|
|
454
|
+
>
|
|
455
|
+
<Trash2 size={14} />
|
|
456
|
+
</button>
|
|
457
|
+
)}
|
|
458
|
+
</div>
|
|
459
|
+
))}
|
|
460
|
+
</div>
|
|
461
|
+
{canWrite && (
|
|
462
|
+
<div className="file-actions">
|
|
463
|
+
<button
|
|
464
|
+
type="button"
|
|
465
|
+
className="secondary"
|
|
466
|
+
onClick={() => setRows((current) => [...current, { key: "", value: "" }])}
|
|
467
|
+
>
|
|
468
|
+
<Plus size={14} /> Variable
|
|
469
|
+
</button>
|
|
470
|
+
<button type="button" className="primary" disabled={busy} onClick={save}>
|
|
471
|
+
<Save size={15} /> Save application
|
|
472
|
+
</button>
|
|
473
|
+
</div>
|
|
474
|
+
)}
|
|
475
|
+
</section>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function connectionUri(row) {
|
|
480
|
+
const scheme = ["mysql", "mariadb"].includes(row.engine) ? "mysql" : "postgresql";
|
|
481
|
+
return `${scheme}://${encodeURIComponent(row.user)}:${encodeURIComponent(row.password)}@${row.host}:${row.port}/${encodeURIComponent(row.database)}`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function ProjectDatabase({ project, canWrite, onOpenDatabases }) {
|
|
485
|
+
const toast = useToast();
|
|
486
|
+
const [name, setName] = useState(
|
|
487
|
+
(project.name || "app").replaceAll("-", "_").slice(0, 25),
|
|
488
|
+
);
|
|
489
|
+
const [databases, setDatabases] = useState([]);
|
|
490
|
+
const [secret, setSecret] = useState(null);
|
|
491
|
+
const [busy, setBusy] = useState(false);
|
|
492
|
+
const load = async () => {
|
|
493
|
+
try {
|
|
494
|
+
const data = await api("databases");
|
|
495
|
+
setDatabases(data.databases || []);
|
|
496
|
+
} catch (error) {
|
|
497
|
+
toast.failed(error.message);
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
useEffect(() => {
|
|
501
|
+
load();
|
|
502
|
+
}, [project.id]);
|
|
503
|
+
const create = async (event) => {
|
|
504
|
+
event.preventDefault();
|
|
505
|
+
setBusy(true);
|
|
506
|
+
try {
|
|
507
|
+
const created = await api("databases", {
|
|
508
|
+
method: "POST",
|
|
509
|
+
body: JSON.stringify({ name }),
|
|
510
|
+
});
|
|
511
|
+
setSecret(created);
|
|
512
|
+
toast.success("Database created.");
|
|
513
|
+
await load();
|
|
514
|
+
} catch (error) {
|
|
515
|
+
toast.failed(error.message);
|
|
516
|
+
} finally {
|
|
517
|
+
setBusy(false);
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
const useInApplication = async () => {
|
|
521
|
+
if (!secret?.password) return;
|
|
522
|
+
setBusy(true);
|
|
523
|
+
try {
|
|
524
|
+
const current = await api(`projects/${project.id}`);
|
|
525
|
+
const config = current.runtime_config || {};
|
|
526
|
+
await api(`projects/${project.id}/config`, {
|
|
527
|
+
method: "PUT",
|
|
528
|
+
body: JSON.stringify({
|
|
529
|
+
type: current.type,
|
|
530
|
+
runtime_config: {
|
|
531
|
+
...config,
|
|
532
|
+
env: { ...(config.env || {}), DATABASE_URL: connectionUri(secret) },
|
|
533
|
+
},
|
|
534
|
+
}),
|
|
535
|
+
});
|
|
536
|
+
toast.success("Connection saved on the application. Redeploy to apply it.");
|
|
537
|
+
} catch (error) {
|
|
538
|
+
toast.failed(error.message);
|
|
539
|
+
} finally {
|
|
540
|
+
setBusy(false);
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
return (
|
|
544
|
+
<section className="panel">
|
|
545
|
+
<div className="section-heading">
|
|
546
|
+
<h2>Database</h2>
|
|
547
|
+
</div>
|
|
548
|
+
<p className="muted">
|
|
549
|
+
Create a database for this application and store its connection in the
|
|
550
|
+
application environment.
|
|
551
|
+
</p>
|
|
552
|
+
{canWrite && (
|
|
553
|
+
<form className="app-fields" onSubmit={create}>
|
|
554
|
+
<label>
|
|
555
|
+
Database name
|
|
556
|
+
<input value={name} onChange={(event) => setName(event.target.value)} required />
|
|
557
|
+
</label>
|
|
558
|
+
<button type="submit" className="primary" disabled={busy}>
|
|
559
|
+
Create database
|
|
560
|
+
</button>
|
|
561
|
+
</form>
|
|
562
|
+
)}
|
|
563
|
+
{secret?.password && (
|
|
564
|
+
<div className="connection-box">
|
|
565
|
+
<p>Password shown once. Copy it into the application now.</p>
|
|
566
|
+
<code>{connectionUri(secret)}</code>
|
|
567
|
+
{canWrite && (
|
|
568
|
+
<button type="button" className="secondary" disabled={busy} onClick={useInApplication}>
|
|
569
|
+
Save connection on this application
|
|
570
|
+
</button>
|
|
571
|
+
)}
|
|
572
|
+
</div>
|
|
573
|
+
)}
|
|
574
|
+
{databases.length > 0 && (
|
|
575
|
+
<ul className="db-mini">
|
|
576
|
+
{databases.map((item) => (
|
|
577
|
+
<li key={item.id}>
|
|
578
|
+
<strong>{item.name}</strong>
|
|
579
|
+
<span>
|
|
580
|
+
{item.engine} · {item.host}:{item.port} · {item.database}
|
|
581
|
+
</span>
|
|
582
|
+
</li>
|
|
583
|
+
))}
|
|
584
|
+
</ul>
|
|
585
|
+
)}
|
|
586
|
+
<button type="button" className="secondary" onClick={onOpenDatabases}>
|
|
587
|
+
Open database browser
|
|
588
|
+
</button>
|
|
589
|
+
</section>
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export function ProjectWorkspace({ tab, project, canWrite, onOpenDatabases }) {
|
|
594
|
+
if (tab === "files") return <FileManager project={project} canWrite={canWrite} />;
|
|
595
|
+
if (tab === "application")
|
|
596
|
+
return <ApplicationSettings project={project} canWrite={canWrite} />;
|
|
597
|
+
return (
|
|
598
|
+
<ProjectDatabase
|
|
599
|
+
project={project}
|
|
600
|
+
canWrite={canWrite}
|
|
601
|
+
onOpenDatabases={onOpenDatabases}
|
|
602
|
+
/>
|
|
603
|
+
);
|
|
604
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import {
|
|
3
|
+
createContext,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { X } from "lucide-react";
|
|
11
|
+
|
|
12
|
+
const ToastContext = createContext(null);
|
|
13
|
+
|
|
14
|
+
export function useToast() {
|
|
15
|
+
const toast = useContext(ToastContext);
|
|
16
|
+
if (!toast) throw Error("Toast is unavailable");
|
|
17
|
+
return toast;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ToastProvider({ children }) {
|
|
21
|
+
const [items, setItems] = useState([]);
|
|
22
|
+
const seq = useRef(0);
|
|
23
|
+
const dismiss = useCallback((id) => {
|
|
24
|
+
setItems((current) => current.filter((item) => item.id !== id));
|
|
25
|
+
}, []);
|
|
26
|
+
const show = useCallback((message, tone = "success") => {
|
|
27
|
+
const text = String(message || "").trim();
|
|
28
|
+
if (!text) return;
|
|
29
|
+
setItems((current) => {
|
|
30
|
+
if (current.some((item) => item.message === text && item.tone === tone))
|
|
31
|
+
return current;
|
|
32
|
+
seq.current += 1;
|
|
33
|
+
return [...current, { id: seq.current, message: text, tone }].slice(-4);
|
|
34
|
+
});
|
|
35
|
+
}, []);
|
|
36
|
+
const api = useRef({
|
|
37
|
+
success(message) {
|
|
38
|
+
show(message, "success");
|
|
39
|
+
},
|
|
40
|
+
progress(message) {
|
|
41
|
+
show(message, "progress");
|
|
42
|
+
},
|
|
43
|
+
failed(message) {
|
|
44
|
+
show(message, "failed");
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
api.current.success = (message) => show(message, "success");
|
|
48
|
+
api.current.progress = (message) => show(message, "progress");
|
|
49
|
+
api.current.failed = (message) => show(message, "failed");
|
|
50
|
+
const toast = useRef({
|
|
51
|
+
success: (message) => api.current.success(message),
|
|
52
|
+
progress: (message) => api.current.progress(message),
|
|
53
|
+
failed: (message) => api.current.failed(message),
|
|
54
|
+
}).current;
|
|
55
|
+
return (
|
|
56
|
+
<ToastContext.Provider value={toast}>
|
|
57
|
+
{children}
|
|
58
|
+
<div className="toast-stack">
|
|
59
|
+
{items.map((item) => (
|
|
60
|
+
<ToastCard key={item.id} item={item} onClose={() => dismiss(item.id)} />
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
</ToastContext.Provider>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ToastCard({ item, onClose }) {
|
|
68
|
+
const close = useRef(onClose);
|
|
69
|
+
close.current = onClose;
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const timer = setTimeout(() => close.current(), 10000);
|
|
72
|
+
return () => clearTimeout(timer);
|
|
73
|
+
}, [item.id]);
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
className={`toast ${item.tone}`}
|
|
77
|
+
role={item.tone === "failed" ? "alert" : "status"}
|
|
78
|
+
>
|
|
79
|
+
<div className="toast-row">
|
|
80
|
+
<p>{item.message}</p>
|
|
81
|
+
<button type="button" aria-label="Dismiss notification" onClick={onClose}>
|
|
82
|
+
<X size={16} />
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
<div className="toast-track" aria-hidden="true">
|
|
86
|
+
<span className="toast-progress" />
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|