Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame528 lines · 1 contributor
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";
22import { softAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24import { isSiteAdmin } from "../lib/admin";
25import {
26 createTarget,
27 deleteEnv,
28 deleteTarget,
29 finishDeployRow,
30 getTarget,
31 listEnv,
32 listTargets,
33 recentDeploys,
34 recordPin,
35 resolveEnv,
36 startDeployRow,
37 upsertEnv,
38} from "../lib/server-target-store";
39import { deployToTarget, testConnection } from "../lib/server-targets";
40
41const admin = new Hono<AuthEnv>();
42admin.use("*", softAuth);
43
44async function gate(c: any): Promise<{ userId: string } | Response> {
45 const user = c.get("user");
46 if (!user) return c.redirect("/login?next=/admin/servers");
47 if (!(await isSiteAdmin(user.id))) {
48 return c.html(
49 <Layout title="Forbidden" user={user}>
50 <main style="max-width:640px;margin:40px auto;padding:0 20px">
51 <h1>403 — site admin required</h1>
52 <p>The server-targets section is admin-only in v1.</p>
53 </main>
54 </Layout>,
55 403
56 );
57 }
58 return { userId: user.id };
59}
60
61function flash(msg: string | undefined, kind: "ok" | "err"): any {
62 if (!msg) return null;
63 const bg = kind === "ok" ? "#0f2a18" : "#2a1212";
64 const border = kind === "ok" ? "#1f5a32" : "#5a1f1f";
65 const fg = kind === "ok" ? "#7fe1a3" : "#ffb4b4";
66 return (
67 <div
68 style={`background:${bg};border:1px solid ${border};color:${fg};padding:10px 14px;border-radius:8px;margin-bottom:16px;font-size:14px`}
69 >
70 {msg}
71 </div>
72 );
73}
74
75const wrap =
76 "max-width:980px;margin:32px auto;padding:0 20px;color:#e5e7eb;font-family:system-ui,sans-serif";
77const card =
78 "background:#0e1117;border:1px solid #1f2937;border-radius:10px;padding:18px 20px;margin-bottom:18px";
79const inputStyle =
80 "width:100%;background:#0b0e13;border:1px solid #1f2937;color:#e5e7eb;padding:8px 10px;border-radius:6px;font-family:ui-monospace,monospace";
81const btn =
82 "background:#1f6feb;border:0;color:#fff;padding:8px 14px;border-radius:6px;cursor:pointer;font-size:14px";
83const btnDanger =
84 "background:#a02020;border:0;color:#fff;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:13px";
85const btnSecondary =
86 "background:#1f2937;border:0;color:#e5e7eb;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:13px";
87const label = "display:block;font-size:13px;color:#9ca3af;margin:10px 0 4px";
88
89// ─── GET /admin/servers ─────────────────────────────────────────────────────
90
91admin.get("/admin/servers", async (c) => {
92 const g = await gate(c);
93 if (g instanceof Response) return g;
94 const user = c.get("user")!;
95 const targets = await listTargets();
96 const ok = c.req.query("ok") ?? undefined;
97 const err = c.req.query("err") ?? undefined;
98
99 return c.html(
100 <Layout title="Server targets — admin" user={user}>
101 <main style={wrap}>
102 <h1 style="margin:0 0 6px;font-size:24px">Server targets</h1>
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>
176 </Layout>
177 );
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(
197 <Layout title="New server target" user={user}>
198 <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 <h1 style="margin:0 0 16px;font-size:22px">New server target</h1>
201 <form method="post" action="/admin/servers" style={card}>
202 <label style={label}>Name (unique identifier)</label>
9ecf5a4Claude203 <input name="name" required pattern="[a-z0-9-]+" placeholder="vapron-prod-1" style={inputStyle} />
783dd46Claude204
205 <label style={label}>Host</label>
9ecf5a4Claude206 <input name="host" required placeholder="1.2.3.4 or box.vapron.ai" style={inputStyle} />
783dd46Claude207
208 <div style="display:flex;gap:14px">
209 <div style="flex:1">
210 <label style={label}>SSH user</label>
211 <input name="ssh_user" required placeholder="deploy" style={inputStyle} />
212 </div>
213 <div style="width:120px">
214 <label style={label}>Port</label>
215 <input name="port" type="number" value="22" style={inputStyle} />
216 </div>
217 </div>
218
219 <label style={label}>Private SSH key (OpenSSH PEM)</label>
220 <textarea
221 name="private_key"
222 required
223 rows={8}
224 placeholder="-----BEGIN OPENSSH PRIVATE KEY-----..."
225 style={inputStyle + ";font-size:12px"}
226 />
227 <p style="color:#6b7280;font-size:12px;margin:6px 0 0">
228 Encrypted at rest with <code>SERVER_TARGETS_KEY</code>. Never
229 displayed again after save.
230 </p>
231
232 <label style={label}>Deploy path on the box</label>
233 <input name="deploy_path" value="/var/www/app" style={inputStyle} />
234
235 <label style={label}>Deploy script</label>
236 <input name="deploy_script" value="bash deploy.sh" style={inputStyle} />
237 <p style="color:#6b7280;font-size:12px;margin:6px 0 0">
238 Runs inside the deploy path. <code>./.env.gluecron</code> is
239 sourced first so all env vars are available.
240 </p>
241
242 <div style="display:flex;gap:14px;margin-top:6px">
243 <div style="flex:1">
244 <label style={label}>Watched repo (optional)</label>
245 <select name="watched_repository_id" style={inputStyle}>
246 <option value="">— none —</option>
247 {repos.map((r) => (
248 <option value={r.id}>{r.ownerName}/{r.name}</option>
249 ))}
250 </select>
251 </div>
252 <div style="flex:1">
253 <label style={label}>Watched branch</label>
254 <input name="watched_branch" placeholder="main" style={inputStyle} />
255 </div>
256 </div>
257
258 <div style="margin-top:18px">
259 <button type="submit" style={btn}>Create target</button>
260 </div>
261 </form>
262 </main>
263 </Layout>
264 );
265});
266
267// ─── POST /admin/servers ────────────────────────────────────────────────────
268
269admin.post("/admin/servers", async (c) => {
270 const g = await gate(c);
271 if (g instanceof Response) return g;
272 const form = await c.req.parseBody();
273 const name = String(form.name || "").trim();
274 const host = String(form.host || "").trim();
275 const sshUser = String(form.ssh_user || "").trim();
276 const port = Number(form.port || 22);
277 const privateKey = String(form.private_key || "");
278 const deployPath = String(form.deploy_path || "/var/www/app").trim();
279 const deployScript = String(form.deploy_script || "bash deploy.sh");
280 const watchedRepositoryId = String(form.watched_repository_id || "") || null;
281 const watchedBranch = String(form.watched_branch || "").trim() || null;
282
283 if (!name || !host || !sshUser || !privateKey) {
284 return c.redirect("/admin/servers?err=missing+required+fields");
285 }
286 if (!/^[a-z0-9-]+$/.test(name)) {
287 return c.redirect("/admin/servers?err=name+must+be+lowercase+alphanumeric+with+dashes");
288 }
289
290 const out = await createTarget({
291 name,
292 host,
293 port,
294 sshUser,
295 privateKey,
296 deployPath,
297 deployScript,
298 watchedRepositoryId,
299 watchedBranch,
300 createdBy: g.userId,
301 });
302 if (!out.ok) {
303 return c.redirect(`/admin/servers?err=${encodeURIComponent(out.error)}`);
304 }
305 return c.redirect(`/admin/servers/${out.target.id}?ok=created`);
306});
307
308// ─── GET /admin/servers/:id ─────────────────────────────────────────────────
309
310admin.get("/admin/servers/:id", async (c) => {
311 const g = await gate(c);
312 if (g instanceof Response) return g;
313 const user = c.get("user")!;
314 const id = c.req.param("id");
315 const target = await getTarget(id);
316 if (!target) return c.notFound();
317
318 const envRows = await listEnv(id);
319 const deploys = await recentDeploys(id, 10);
320 const ok = c.req.query("ok") ?? undefined;
321 const err = c.req.query("err") ?? undefined;
322
323 return c.html(
324 <Layout title={`${target.name} — server target`} user={user}>
325 <main style={wrap}>
326 <p style="margin:0 0 6px"><a href="/admin/servers" style="color:#9ca3af;text-decoration:none">← Server targets</a></p>
327 <h1 style="margin:0 0 4px;font-size:22px">{target.name}</h1>
328 <p style="margin:0 0 18px;color:#9ca3af;font-size:14px;font-family:ui-monospace,monospace">
329 {target.sshUser}@{target.host}:{target.port} · {target.deployPath}
330 </p>
331 {flash(ok, "ok")}
332 {flash(err, "err")}
333
334 {/* Connection / status */}
335 <div style={card}>
336 <h2 style="margin:0 0 10px;font-size:16px">Connection</h2>
337 <p style="margin:0 0 10px;font-size:14px">
338 Status: <strong>{target.status}</strong>
339 {target.hostFingerprint && (
340 <span style="color:#9ca3af;font-family:ui-monospace,monospace;font-size:12px">
341 {" "}· pinned {target.hostFingerprint}
342 </span>
343 )}
344 </p>
345 <form method="post" action={`/admin/servers/${target.id}/test`} style="display:inline-block;margin-right:8px">
346 <button type="submit" style={btnSecondary}>Test connection</button>
347 </form>
348 <form method="post" action={`/admin/servers/${target.id}/deploy`} style="display:inline-block;margin-right:8px">
349 <button type="submit" style={btn}>Deploy now</button>
350 </form>
351 <form method="post" action={`/admin/servers/${target.id}/delete`} style="display:inline-block;float:right" onsubmit="return confirm('Delete this target?')">
352 <button type="submit" style={btnDanger}>Delete</button>
353 </form>
354 </div>
355
356 {/* Env vars */}
357 <div style={card}>
358 <h2 style="margin:0 0 10px;font-size:16px">Environment variables</h2>
359 <p style="margin:0 0 12px;color:#9ca3af;font-size:13px">
360 Encrypted at rest. Materialised as <code>./.env.gluecron</code>
361 on the box and sourced before the deploy script runs.
362 </p>
363
364 {envRows.length === 0 ? (
365 <p style="color:#6b7280;font-size:14px;margin:0 0 12px">No env vars yet.</p>
366 ) : (
367 <table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:12px">
368 <tbody>
369 {envRows.map((row) => (
370 <tr style="border-bottom:1px solid #1f2937">
371 <td style="padding:8px 6px;font-family:ui-monospace,monospace;color:#cbd5e1;width:35%">{row.name}</td>
372 <td style="padding:8px 6px;font-family:ui-monospace,monospace;color:#6b7280">
373 {row.isSecret ? "••••••••" : "(non-secret)"}
374 </td>
375 <td style="padding:8px 6px;color:#6b7280;font-size:12px">
376 {row.isSecret ? "secret" : "value"}
377 </td>
378 <td style="padding:8px 6px;text-align:right">
379 <form method="post" action={`/admin/servers/${target.id}/env/${encodeURIComponent(row.name)}/delete`} style="display:inline">
380 <button type="submit" style={btnDanger}>Delete</button>
381 </form>
382 </td>
383 </tr>
384 ))}
385 </tbody>
386 </table>
387 )}
388
389 <form method="post" action={`/admin/servers/${target.id}/env`} style="display:grid;grid-template-columns:1fr 2fr auto;gap:8px;align-items:center">
390 <input name="name" placeholder="VAR_NAME" required pattern="[A-Z_][A-Z0-9_]*" style={inputStyle} />
391 <input name="value" placeholder="value" required style={inputStyle} />
392 <button type="submit" style={btn}>Save</button>
393 <label style="grid-column:1/-1;font-size:12px;color:#9ca3af;display:flex;gap:6px;align-items:center;margin-top:2px">
394 <input type="checkbox" name="is_secret" value="1" checked />
395 Treat as secret (mask in UI)
396 </label>
397 </form>
398 </div>
399
400 {/* Recent deploys */}
401 <div style={card}>
402 <h2 style="margin:0 0 10px;font-size:16px">Recent deploys</h2>
403 {deploys.length === 0 ? (
404 <p style="color:#6b7280;font-size:14px;margin:0">No deploys yet.</p>
405 ) : (
406 <table style="width:100%;border-collapse:collapse;font-size:13px">
407 <thead>
408 <tr style="text-align:left;color:#9ca3af;border-bottom:1px solid #1f2937">
409 <th style="padding:6px">When</th>
410 <th style="padding:6px">Commit</th>
411 <th style="padding:6px">Source</th>
412 <th style="padding:6px">Status</th>
413 </tr>
414 </thead>
415 <tbody>
416 {deploys.map((d) => (
417 <tr style="border-bottom:1px solid #1f2937">
418 <td style="padding:6px;color:#9ca3af">{d.startedAt.toISOString()}</td>
419 <td style="padding:6px;font-family:ui-monospace,monospace">{d.commitSha?.slice(0, 7) ?? "—"}</td>
420 <td style="padding:6px;color:#9ca3af">{d.triggerSource}</td>
421 <td style={`padding:6px;color:${d.status === "success" ? "#7fe1a3" : d.status === "failed" ? "#ffb4b4" : "#e1c47f"}`}>
422 {d.status} {d.exitCode != null ? `(${d.exitCode})` : ""}
423 </td>
424 </tr>
425 ))}
426 </tbody>
427 </table>
428 )}
429 </div>
430 </main>
431 </Layout>
432 );
433});
434
435// ─── POST /admin/servers/:id/env ────────────────────────────────────────────
436
437admin.post("/admin/servers/:id/env", async (c) => {
438 const g = await gate(c);
439 if (g instanceof Response) return g;
440 const id = c.req.param("id");
441 const target = await getTarget(id);
442 if (!target) return c.notFound();
443 const form = await c.req.parseBody();
444 const name = String(form.name || "").trim();
445 const value = String(form.value || "");
446 const isSecret = !!form.is_secret;
447 const out = await upsertEnv({
448 targetId: id,
449 name,
450 value,
451 isSecret,
452 actorId: g.userId,
453 });
454 if (!out.ok) {
455 return c.redirect(`/admin/servers/${id}?err=${encodeURIComponent(out.error)}`);
456 }
457 return c.redirect(`/admin/servers/${id}?ok=saved+${encodeURIComponent(name)}`);
458});
459
460admin.post("/admin/servers/:id/env/:name/delete", async (c) => {
461 const g = await gate(c);
462 if (g instanceof Response) return g;
463 const id = c.req.param("id");
464 const name = c.req.param("name");
465 await deleteEnv({ targetId: id, name, actorId: g.userId });
466 return c.redirect(`/admin/servers/${id}?ok=deleted+${encodeURIComponent(name)}`);
467});
468
469// ─── POST /admin/servers/:id/test ───────────────────────────────────────────
470
471admin.post("/admin/servers/:id/test", async (c) => {
472 const g = await gate(c);
473 if (g instanceof Response) return g;
474 const id = c.req.param("id");
475 const target = await getTarget(id);
476 if (!target) return c.notFound();
477 const result = await testConnection(target);
478 if (result.ok) {
479 await recordPin(id, result.fingerprint, g.userId);
480 return c.redirect(`/admin/servers/${id}?ok=connection+verified`);
481 }
482 return c.redirect(
483 `/admin/servers/${id}?err=${encodeURIComponent(`${result.stage}: ${result.error}`)}`
484 );
485});
486
487// ─── POST /admin/servers/:id/deploy ─────────────────────────────────────────
488
489admin.post("/admin/servers/:id/deploy", async (c) => {
490 const g = await gate(c);
491 if (g instanceof Response) return g;
492 const id = c.req.param("id");
493 const target = await getTarget(id);
494 if (!target) return c.notFound();
495 const env = await resolveEnv(id);
496 const deployId = await startDeployRow({
497 targetId: id,
498 triggeredBy: g.userId,
499 triggerSource: "manual",
500 });
501 const result = await deployToTarget(target, { env });
502 if (deployId) {
503 await finishDeployRow({
504 id: deployId,
505 exitCode: result.exitCode,
506 stdout: result.stdout,
507 stderr: result.stderr,
508 });
509 }
510 if (result.ok) {
511 return c.redirect(`/admin/servers/${id}?ok=deploy+succeeded`);
512 }
513 return c.redirect(
514 `/admin/servers/${id}?err=${encodeURIComponent(`deploy exit ${result.exitCode}: ${result.stderr.slice(0, 200)}`)}`
515 );
516});
517
518// ─── POST /admin/servers/:id/delete ─────────────────────────────────────────
519
520admin.post("/admin/servers/:id/delete", async (c) => {
521 const g = await gate(c);
522 if (g instanceof Response) return g;
523 const id = c.req.param("id");
524 await deleteTarget(id, g.userId);
525 return c.redirect("/admin/servers?ok=deleted");
526});
527
528export default admin;