Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

admin-server-targets.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

admin-server-targets.tsxBlame526 lines · 2 contributors
783dd46Claude1/**
2 * Block ST — Admin server targets UI + mutation endpoints.
3 *
4 * GET /admin/servers — list targets
5 * GET /admin/servers/new — new-target form
6 * POST /admin/servers — create
7 * GET /admin/servers/:id — detail (env vars + recent deploys)
8 * POST /admin/servers/:id/env — upsert an env var
9 * POST /admin/servers/:id/env/:name/delete — delete an env var
10 * POST /admin/servers/:id/test — test connection (pin fingerprint)
11 * POST /admin/servers/:id/deploy — manual deploy
12 * POST /admin/servers/:id/delete — delete target
13 *
14 * v1 is site-admin only. Customer scoping arrives in a follow-up block.
15 */
16
17import { Hono } from "hono";
18import { eq } from "drizzle-orm";
19import { db } from "../db";
20import { repositories, users } from "../db/schema";
21import { Layout } from "../views/layout";
f4a1547ccantynz-alt22import { AdminShell } from "../views/admin-shell";
783dd46Claude23import { softAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25import { isSiteAdmin } from "../lib/admin";
26import {
27 createTarget,
28 deleteEnv,
29 deleteTarget,
30 finishDeployRow,
31 getTarget,
32 listEnv,
33 listTargets,
34 recentDeploys,
35 recordPin,
36 resolveEnv,
37 startDeployRow,
38 upsertEnv,
39} from "../lib/server-target-store";
40import { deployToTarget, testConnection } from "../lib/server-targets";
41
42const admin = new Hono<AuthEnv>();
43admin.use("*", softAuth);
44
45async function gate(c: any): Promise<{ userId: string } | Response> {
46 const user = c.get("user");
47 if (!user) return c.redirect("/login?next=/admin/servers");
48 if (!(await isSiteAdmin(user.id))) {
49 return c.html(
50 <Layout title="Forbidden" user={user}>
51 <main style="max-width:640px;margin:40px auto;padding:0 20px">
52 <h1>403 — site admin required</h1>
53 <p>The server-targets section is admin-only in v1.</p>
54 </main>
55 </Layout>,
56 403
57 );
58 }
59 return { userId: user.id };
60}
61
62function flash(msg: string | undefined, kind: "ok" | "err"): any {
63 if (!msg) return null;
64 const bg = kind === "ok" ? "#0f2a18" : "#2a1212";
65 const border = kind === "ok" ? "#1f5a32" : "#5a1f1f";
66 const fg = kind === "ok" ? "#7fe1a3" : "#ffb4b4";
67 return (
68 <div
69 style={`background:${bg};border:1px solid ${border};color:${fg};padding:10px 14px;border-radius:8px;margin-bottom:16px;font-size:14px`}
70 >
71 {msg}
72 </div>
73 );
74}
75
76const wrap =
77 "max-width:980px;margin:32px auto;padding:0 20px;color:#e5e7eb;font-family:system-ui,sans-serif";
78const card =
79 "background:#0e1117;border:1px solid #1f2937;border-radius:10px;padding:18px 20px;margin-bottom:18px";
80const inputStyle =
81 "width:100%;background:#0b0e13;border:1px solid #1f2937;color:#e5e7eb;padding:8px 10px;border-radius:6px;font-family:ui-monospace,monospace";
82const btn =
e589f77ccantynz-alt83 "background:var(--accent);border:0;color:#fff;padding:8px 14px;border-radius:6px;cursor:pointer;font-size:14px";
783dd46Claude84const btnDanger =
85 "background:#a02020;border:0;color:#fff;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:13px";
86const btnSecondary =
87 "background:#1f2937;border:0;color:#e5e7eb;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:13px";
88const label = "display:block;font-size:13px;color:#9ca3af;margin:10px 0 4px";
89
90// ─── GET /admin/servers ─────────────────────────────────────────────────────
91
92admin.get("/admin/servers", async (c) => {
93 const g = await gate(c);
94 if (g instanceof Response) return g;
95 const user = c.get("user")!;
96 const targets = await listTargets();
97 const ok = c.req.query("ok") ?? undefined;
98 const err = c.req.query("err") ?? undefined;
99
100 return c.html(
f4a1547ccantynz-alt101 <AdminShell active="servers" title="Server targets" user={user}>
783dd46Claude102 <main style={wrap}>
103 <p style="margin:0 0 20px;color:#9ca3af;font-size:14px">
104 Boxes Gluecron can SSH into. Admin-only. A push to a watched
105 branch fires the target's deploy script with its env vars
106 materialised on the box.
107 </p>
108 {flash(ok, "ok")}
109 {flash(err, "err")}
110
111 <div style="margin-bottom:16px">
112 <a href="/admin/servers/new" style={btn + ";text-decoration:none"}>
113 + New target
114 </a>
115 </div>
116
117 <div style={card}>
118 {targets.length === 0 ? (
119 <p style="color:#9ca3af;margin:0">
120 No targets yet. Add your first box.
121 </p>
122 ) : (
123 <table style="width:100%;border-collapse:collapse;font-size:14px">
124 <thead>
125 <tr style="text-align:left;color:#9ca3af;border-bottom:1px solid #1f2937">
126 <th style="padding:8px 6px">Name</th>
127 <th style="padding:8px 6px">Host</th>
128 <th style="padding:8px 6px">Watch</th>
129 <th style="padding:8px 6px">Status</th>
130 <th style="padding:8px 6px;text-align:right">—</th>
131 </tr>
132 </thead>
133 <tbody>
134 {targets.map((t) => (
135 <tr style="border-bottom:1px solid #1f2937">
136 <td style="padding:10px 6px">
137 <a
138 href={`/admin/servers/${t.id}`}
139 style="color:#7aa2f7;text-decoration:none;font-weight:600"
140 >
141 {t.name}
142 </a>
143 </td>
144 <td style="padding:10px 6px;font-family:ui-monospace,monospace;color:#cbd5e1">
145 {t.sshUser}@{t.host}:{t.port}
146 </td>
147 <td style="padding:10px 6px;color:#9ca3af;font-size:13px">
148 {t.watchedRepositoryId && t.watchedBranch
149 ? `${t.watchedBranch}`
150 : "—"}
151 </td>
152 <td style="padding:10px 6px">
153 <span
154 style={
155 "padding:2px 8px;border-radius:99px;font-size:12px;" +
156 (t.status === "verified"
157 ? "background:#0f2a18;color:#7fe1a3"
158 : "background:#2a2410;color:#e1c47f")
159 }
160 >
161 {t.status}
162 </span>
163 </td>
164 <td style="padding:10px 6px;text-align:right">
165 <a href={`/admin/servers/${t.id}`} style="color:#9ca3af;font-size:13px;text-decoration:none">
166 open →
167 </a>
168 </td>
169 </tr>
170 ))}
171 </tbody>
172 </table>
173 )}
174 </div>
175 </main>
f4a1547ccantynz-alt176 </AdminShell>
783dd46Claude177 );
178});
179
180// ─── GET /admin/servers/new ─────────────────────────────────────────────────
181
182admin.get("/admin/servers/new", async (c) => {
183 const g = await gate(c);
184 if (g instanceof Response) return g;
185 const user = c.get("user")!;
186 const repos = await db
187 .select({
188 id: repositories.id,
189 name: repositories.name,
190 ownerName: users.username,
191 })
192 .from(repositories)
193 .innerJoin(users, eq(repositories.ownerId, users.id))
194 .limit(200);
195
196 return c.html(
f4a1547ccantynz-alt197 <AdminShell active="servers" title="New server target" user={user}>
783dd46Claude198 <main style={wrap}>
199 <p style="margin:0 0 6px"><a href="/admin/servers" style="color:#9ca3af;text-decoration:none">← Server targets</a></p>
200 <form method="post" action="/admin/servers" style={card}>
201 <label style={label}>Name (unique identifier)</label>
9ecf5a4Claude202 <input name="name" required pattern="[a-z0-9-]+" placeholder="vapron-prod-1" style={inputStyle} />
783dd46Claude203
204 <label style={label}>Host</label>
9ecf5a4Claude205 <input name="host" required placeholder="1.2.3.4 or box.vapron.ai" style={inputStyle} />
783dd46Claude206
207 <div style="display:flex;gap:14px">
208 <div style="flex:1">
209 <label style={label}>SSH user</label>
210 <input name="ssh_user" required placeholder="deploy" style={inputStyle} />
211 </div>
212 <div style="width:120px">
213 <label style={label}>Port</label>
214 <input name="port" type="number" value="22" style={inputStyle} />
215 </div>
216 </div>
217
218 <label style={label}>Private SSH key (OpenSSH PEM)</label>
219 <textarea
220 name="private_key"
221 required
222 rows={8}
223 placeholder="-----BEGIN OPENSSH PRIVATE KEY-----..."
224 style={inputStyle + ";font-size:12px"}
225 />
226 <p style="color:#6b7280;font-size:12px;margin:6px 0 0">
227 Encrypted at rest with <code>SERVER_TARGETS_KEY</code>. Never
228 displayed again after save.
229 </p>
230
231 <label style={label}>Deploy path on the box</label>
232 <input name="deploy_path" value="/var/www/app" style={inputStyle} />
233
234 <label style={label}>Deploy script</label>
235 <input name="deploy_script" value="bash deploy.sh" style={inputStyle} />
236 <p style="color:#6b7280;font-size:12px;margin:6px 0 0">
237 Runs inside the deploy path. <code>./.env.gluecron</code> is
238 sourced first so all env vars are available.
239 </p>
240
241 <div style="display:flex;gap:14px;margin-top:6px">
242 <div style="flex:1">
243 <label style={label}>Watched repo (optional)</label>
244 <select name="watched_repository_id" style={inputStyle}>
245 <option value="">— none —</option>
246 {repos.map((r) => (
247 <option value={r.id}>{r.ownerName}/{r.name}</option>
248 ))}
249 </select>
250 </div>
251 <div style="flex:1">
252 <label style={label}>Watched branch</label>
253 <input name="watched_branch" placeholder="main" style={inputStyle} />
254 </div>
255 </div>
256
257 <div style="margin-top:18px">
258 <button type="submit" style={btn}>Create target</button>
259 </div>
260 </form>
261 </main>
f4a1547ccantynz-alt262 </AdminShell>
783dd46Claude263 );
264});
265
266// ─── POST /admin/servers ────────────────────────────────────────────────────
267
268admin.post("/admin/servers", async (c) => {
269 const g = await gate(c);
270 if (g instanceof Response) return g;
271 const form = await c.req.parseBody();
272 const name = String(form.name || "").trim();
273 const host = String(form.host || "").trim();
274 const sshUser = String(form.ssh_user || "").trim();
275 const port = Number(form.port || 22);
276 const privateKey = String(form.private_key || "");
277 const deployPath = String(form.deploy_path || "/var/www/app").trim();
278 const deployScript = String(form.deploy_script || "bash deploy.sh");
279 const watchedRepositoryId = String(form.watched_repository_id || "") || null;
280 const watchedBranch = String(form.watched_branch || "").trim() || null;
281
282 if (!name || !host || !sshUser || !privateKey) {
283 return c.redirect("/admin/servers?err=missing+required+fields");
284 }
285 if (!/^[a-z0-9-]+$/.test(name)) {
286 return c.redirect("/admin/servers?err=name+must+be+lowercase+alphanumeric+with+dashes");
287 }
288
289 const out = await createTarget({
290 name,
291 host,
292 port,
293 sshUser,
294 privateKey,
295 deployPath,
296 deployScript,
297 watchedRepositoryId,
298 watchedBranch,
299 createdBy: g.userId,
300 });
301 if (!out.ok) {
302 return c.redirect(`/admin/servers?err=${encodeURIComponent(out.error)}`);
303 }
304 return c.redirect(`/admin/servers/${out.target.id}?ok=created`);
305});
306
307// ─── GET /admin/servers/:id ─────────────────────────────────────────────────
308
309admin.get("/admin/servers/:id", async (c) => {
310 const g = await gate(c);
311 if (g instanceof Response) return g;
312 const user = c.get("user")!;
313 const id = c.req.param("id");
314 const target = await getTarget(id);
315 if (!target) return c.notFound();
316
317 const envRows = await listEnv(id);
318 const deploys = await recentDeploys(id, 10);
319 const ok = c.req.query("ok") ?? undefined;
320 const err = c.req.query("err") ?? undefined;
321
322 return c.html(
f4a1547ccantynz-alt323 <AdminShell active="servers" title={target.name} user={user}>
783dd46Claude324 <main style={wrap}>
325 <p style="margin:0 0 6px"><a href="/admin/servers" style="color:#9ca3af;text-decoration:none">← Server targets</a></p>
326 <p style="margin:0 0 18px;color:#9ca3af;font-size:14px;font-family:ui-monospace,monospace">
327 {target.sshUser}@{target.host}:{target.port} · {target.deployPath}
328 </p>
329 {flash(ok, "ok")}
330 {flash(err, "err")}
331
332 {/* Connection / status */}
333 <div style={card}>
334 <h2 style="margin:0 0 10px;font-size:16px">Connection</h2>
335 <p style="margin:0 0 10px;font-size:14px">
336 Status: <strong>{target.status}</strong>
337 {target.hostFingerprint && (
338 <span style="color:#9ca3af;font-family:ui-monospace,monospace;font-size:12px">
339 {" "}· pinned {target.hostFingerprint}
340 </span>
341 )}
342 </p>
343 <form method="post" action={`/admin/servers/${target.id}/test`} style="display:inline-block;margin-right:8px">
344 <button type="submit" style={btnSecondary}>Test connection</button>
345 </form>
346 <form method="post" action={`/admin/servers/${target.id}/deploy`} style="display:inline-block;margin-right:8px">
347 <button type="submit" style={btn}>Deploy now</button>
348 </form>
349 <form method="post" action={`/admin/servers/${target.id}/delete`} style="display:inline-block;float:right" onsubmit="return confirm('Delete this target?')">
350 <button type="submit" style={btnDanger}>Delete</button>
351 </form>
352 </div>
353
354 {/* Env vars */}
355 <div style={card}>
356 <h2 style="margin:0 0 10px;font-size:16px">Environment variables</h2>
357 <p style="margin:0 0 12px;color:#9ca3af;font-size:13px">
358 Encrypted at rest. Materialised as <code>./.env.gluecron</code>
359 on the box and sourced before the deploy script runs.
360 </p>
361
362 {envRows.length === 0 ? (
363 <p style="color:#6b7280;font-size:14px;margin:0 0 12px">No env vars yet.</p>
364 ) : (
365 <table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:12px">
366 <tbody>
367 {envRows.map((row) => (
368 <tr style="border-bottom:1px solid #1f2937">
369 <td style="padding:8px 6px;font-family:ui-monospace,monospace;color:#cbd5e1;width:35%">{row.name}</td>
370 <td style="padding:8px 6px;font-family:ui-monospace,monospace;color:#6b7280">
371 {row.isSecret ? "••••••••" : "(non-secret)"}
372 </td>
373 <td style="padding:8px 6px;color:#6b7280;font-size:12px">
374 {row.isSecret ? "secret" : "value"}
375 </td>
376 <td style="padding:8px 6px;text-align:right">
377 <form method="post" action={`/admin/servers/${target.id}/env/${encodeURIComponent(row.name)}/delete`} style="display:inline">
378 <button type="submit" style={btnDanger}>Delete</button>
379 </form>
380 </td>
381 </tr>
382 ))}
383 </tbody>
384 </table>
385 )}
386
387 <form method="post" action={`/admin/servers/${target.id}/env`} style="display:grid;grid-template-columns:1fr 2fr auto;gap:8px;align-items:center">
388 <input name="name" placeholder="VAR_NAME" required pattern="[A-Z_][A-Z0-9_]*" style={inputStyle} />
389 <input name="value" placeholder="value" required style={inputStyle} />
390 <button type="submit" style={btn}>Save</button>
391 <label style="grid-column:1/-1;font-size:12px;color:#9ca3af;display:flex;gap:6px;align-items:center;margin-top:2px">
392 <input type="checkbox" name="is_secret" value="1" checked />
393 Treat as secret (mask in UI)
394 </label>
395 </form>
396 </div>
397
398 {/* Recent deploys */}
399 <div style={card}>
400 <h2 style="margin:0 0 10px;font-size:16px">Recent deploys</h2>
401 {deploys.length === 0 ? (
402 <p style="color:#6b7280;font-size:14px;margin:0">No deploys yet.</p>
403 ) : (
404 <table style="width:100%;border-collapse:collapse;font-size:13px">
405 <thead>
406 <tr style="text-align:left;color:#9ca3af;border-bottom:1px solid #1f2937">
407 <th style="padding:6px">When</th>
408 <th style="padding:6px">Commit</th>
409 <th style="padding:6px">Source</th>
410 <th style="padding:6px">Status</th>
411 </tr>
412 </thead>
413 <tbody>
414 {deploys.map((d) => (
415 <tr style="border-bottom:1px solid #1f2937">
416 <td style="padding:6px;color:#9ca3af">{d.startedAt.toISOString()}</td>
417 <td style="padding:6px;font-family:ui-monospace,monospace">{d.commitSha?.slice(0, 7) ?? "—"}</td>
418 <td style="padding:6px;color:#9ca3af">{d.triggerSource}</td>
419 <td style={`padding:6px;color:${d.status === "success" ? "#7fe1a3" : d.status === "failed" ? "#ffb4b4" : "#e1c47f"}`}>
420 {d.status} {d.exitCode != null ? `(${d.exitCode})` : ""}
421 </td>
422 </tr>
423 ))}
424 </tbody>
425 </table>
426 )}
427 </div>
428 </main>
f4a1547ccantynz-alt429 </AdminShell>
783dd46Claude430 );
431});
432
433// ─── POST /admin/servers/:id/env ────────────────────────────────────────────
434
435admin.post("/admin/servers/:id/env", async (c) => {
436 const g = await gate(c);
437 if (g instanceof Response) return g;
438 const id = c.req.param("id");
439 const target = await getTarget(id);
440 if (!target) return c.notFound();
441 const form = await c.req.parseBody();
442 const name = String(form.name || "").trim();
443 const value = String(form.value || "");
444 const isSecret = !!form.is_secret;
445 const out = await upsertEnv({
446 targetId: id,
447 name,
448 value,
449 isSecret,
450 actorId: g.userId,
451 });
452 if (!out.ok) {
453 return c.redirect(`/admin/servers/${id}?err=${encodeURIComponent(out.error)}`);
454 }
455 return c.redirect(`/admin/servers/${id}?ok=saved+${encodeURIComponent(name)}`);
456});
457
458admin.post("/admin/servers/:id/env/:name/delete", async (c) => {
459 const g = await gate(c);
460 if (g instanceof Response) return g;
461 const id = c.req.param("id");
462 const name = c.req.param("name");
463 await deleteEnv({ targetId: id, name, actorId: g.userId });
464 return c.redirect(`/admin/servers/${id}?ok=deleted+${encodeURIComponent(name)}`);
465});
466
467// ─── POST /admin/servers/:id/test ───────────────────────────────────────────
468
469admin.post("/admin/servers/:id/test", async (c) => {
470 const g = await gate(c);
471 if (g instanceof Response) return g;
472 const id = c.req.param("id");
473 const target = await getTarget(id);
474 if (!target) return c.notFound();
475 const result = await testConnection(target);
476 if (result.ok) {
477 await recordPin(id, result.fingerprint, g.userId);
478 return c.redirect(`/admin/servers/${id}?ok=connection+verified`);
479 }
480 return c.redirect(
481 `/admin/servers/${id}?err=${encodeURIComponent(`${result.stage}: ${result.error}`)}`
482 );
483});
484
485// ─── POST /admin/servers/:id/deploy ─────────────────────────────────────────
486
487admin.post("/admin/servers/:id/deploy", async (c) => {
488 const g = await gate(c);
489 if (g instanceof Response) return g;
490 const id = c.req.param("id");
491 const target = await getTarget(id);
492 if (!target) return c.notFound();
493 const env = await resolveEnv(id);
494 const deployId = await startDeployRow({
495 targetId: id,
496 triggeredBy: g.userId,
497 triggerSource: "manual",
498 });
499 const result = await deployToTarget(target, { env });
500 if (deployId) {
501 await finishDeployRow({
502 id: deployId,
503 exitCode: result.exitCode,
504 stdout: result.stdout,
505 stderr: result.stderr,
506 });
507 }
508 if (result.ok) {
509 return c.redirect(`/admin/servers/${id}?ok=deploy+succeeded`);
510 }
511 return c.redirect(
512 `/admin/servers/${id}?err=${encodeURIComponent(`deploy exit ${result.exitCode}: ${result.stderr.slice(0, 200)}`)}`
513 );
514});
515
516// ─── POST /admin/servers/:id/delete ─────────────────────────────────────────
517
518admin.post("/admin/servers/:id/delete", async (c) => {
519 const g = await gate(c);
520 if (g instanceof Response) return g;
521 const id = c.req.param("id");
522 await deleteTarget(id, g.userId);
523 return c.redirect("/admin/servers?ok=deleted");
524});
525
526export default admin;