feat: SQLite-backed installations, REST API, admin auth and web UI
Replaces the slice-2 hardcoded installation with a full install/deploy pipeline: - SQLite persistence (better-sqlite3) with a single `installations` table and a draft -> cloning -> building -> built -> starting -> running/error/stopped state machine; env vars and bearer tokens are encrypted at rest (AES-256-GCM, key derived from MASTER_KEY). - Build/run orchestration: git clone (+ .env.example key detection), `nixpacks build` as a logged subprocess, dockerode container run/stop with forced host/port env vars and a TCP-connect health check before flipping an installation to "running". - REST API (installations CRUD, build/deploy/redeploy, logs, token regeneration) behind single-operator session-cookie auth with a basic login lockout. - Vite+React admin SPA: login, installation list, install wizard, and a detail page to configure env vars, trigger build/deploy/redeploy, view the build log, and copy the authenticated /mcp/<slug> URL. - Dockerfile (multi-stage, pulls in nixpacks + the Docker CLI) and docker-compose.yml for production deployment. Validated end-to-end against a real cloned repo: draft creation with env-key detection, build, the error path on a failed health check, and delete, all through the live API. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
65
apps/web/src/pages/InstallWizard.tsx
Normal file
65
apps/web/src/pages/InstallWizard.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
|
||||
export function InstallWizard({
|
||||
onCreated,
|
||||
onCancel,
|
||||
}: {
|
||||
onCreated: (id: string, detectedEnvKeys: string[]) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [repoUrl, setRepoUrl] = useState("");
|
||||
const [gitRef, setGitRef] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { installation, detectedEnvKeys } = await api.createInstallation({ name, repoUrl, gitRef: gitRef || undefined });
|
||||
onCreated(installation.id, detectedEnvKeys);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ border: "1px solid #ccc", padding: 16, marginBottom: 16 }}>
|
||||
<h2>New installation</h2>
|
||||
<form onSubmit={submit}>
|
||||
<label>
|
||||
Name
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} required style={{ display: "block", width: "100%" }} />
|
||||
</label>
|
||||
<label>
|
||||
Repo URL
|
||||
<input
|
||||
value={repoUrl}
|
||||
onChange={(e) => setRepoUrl(e.target.value)}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
required
|
||||
style={{ display: "block", width: "100%" }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Git ref (optional)
|
||||
<input value={gitRef} onChange={(e) => setGitRef(e.target.value)} style={{ display: "block", width: "100%" }} />
|
||||
</label>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? "Cloning…" : "Clone & continue"}
|
||||
</button>{" "}
|
||||
<button type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
apps/web/src/pages/InstallationDetail.tsx
Normal file
211
apps/web/src/pages/InstallationDetail.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { api, type Installation } from "../api/client";
|
||||
|
||||
export function InstallationDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [installation, setInstallation] = useState<Installation | null>(null);
|
||||
const [envRows, setEnvRows] = useState<[string, string][]>([]);
|
||||
const [portEnvVar, setPortEnvVar] = useState("PORT");
|
||||
const [hostEnvVar, setHostEnvVar] = useState("HOST");
|
||||
const [httpPath, setHttpPath] = useState("/mcp");
|
||||
const [log, setLog] = useState("");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
if (!id) return;
|
||||
const data = await api.getInstallation(id);
|
||||
setInstallation(data);
|
||||
setPortEnvVar(data.portEnvVar);
|
||||
setHostEnvVar(data.hostEnvVar);
|
||||
setHttpPath(data.httpPath);
|
||||
|
||||
const detectedEnvKeys = (location.state as { detectedEnvKeys?: string[] } | null)?.detectedEnvKeys ?? [];
|
||||
const keys = new Set([...Object.keys(data.env), ...detectedEnvKeys]);
|
||||
setEnvRows([...keys].map((k) => [k, data.env[k] ?? ""]));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
async function withBusy(label: string, fn: () => Promise<void>) {
|
||||
setBusy(label);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!installation) return <p>Loading…</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => navigate("/")}>← Back</button>
|
||||
<h1>{installation.name}</h1>
|
||||
<p>
|
||||
Status: <strong>{installation.status}</strong>
|
||||
{installation.statusDetail && <span style={{ color: "crimson" }}> — {installation.statusDetail}</span>}
|
||||
</p>
|
||||
|
||||
{installation.status === "running" && installation.bearerToken && (
|
||||
<div style={{ border: "1px solid #ccc", padding: 12, marginBottom: 16 }}>
|
||||
<strong>Public URL:</strong> <code>{installation.publicUrl}</code>
|
||||
<br />
|
||||
<strong>Bearer token:</strong> <code>{installation.bearerToken}</code>{" "}
|
||||
<button
|
||||
onClick={() =>
|
||||
withBusy("regen", async () => {
|
||||
await api.regenerateToken(installation.id);
|
||||
await refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
Regenerate token
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2>Configuration</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
{envRows.map(([k, v], idx) => (
|
||||
<tr key={idx}>
|
||||
<td>
|
||||
<input
|
||||
value={k}
|
||||
onChange={(e) => {
|
||||
const next = [...envRows];
|
||||
next[idx] = [e.target.value, v];
|
||||
setEnvRows(next);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
value={v}
|
||||
onChange={(e) => {
|
||||
const next = [...envRows];
|
||||
next[idx] = [k, e.target.value];
|
||||
setEnvRows(next);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button onClick={() => setEnvRows(envRows.filter((_, i) => i !== idx))}>remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<button onClick={() => setEnvRows([...envRows, ["", ""]])}>Add env var</button>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<label>
|
||||
Port env var name <input value={portEnvVar} onChange={(e) => setPortEnvVar(e.target.value)} />
|
||||
</label>
|
||||
<label style={{ marginLeft: 12 }}>
|
||||
Host env var name <input value={hostEnvVar} onChange={(e) => setHostEnvVar(e.target.value)} />
|
||||
</label>
|
||||
<label style={{ marginLeft: 12 }}>
|
||||
HTTP path <input value={httpPath} onChange={(e) => setHttpPath(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
withBusy("config", async () => {
|
||||
const env = Object.fromEntries(envRows.filter(([k]) => k.trim() !== ""));
|
||||
await api.configureInstallation(installation.id, { env, portEnvVar, hostEnvVar, httpPath });
|
||||
await refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
Save configuration
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2>Actions</h2>
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
withBusy("build", async () => {
|
||||
await api.build(installation.id);
|
||||
await refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
{busy === "build" ? "Building…" : "Build"}
|
||||
</button>{" "}
|
||||
<button
|
||||
disabled={busy !== null || installation.status === "cloning" || installation.status === "building" || installation.status === "starting"}
|
||||
onClick={() =>
|
||||
withBusy("deploy", async () => {
|
||||
await api.deploy(installation.id);
|
||||
await refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
{busy === "deploy" ? "Deploying…" : "Deploy"}
|
||||
</button>{" "}
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
withBusy("redeploy", async () => {
|
||||
await api.redeploy(installation.id, false);
|
||||
await refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
Redeploy (env only)
|
||||
</button>{" "}
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
withBusy("rebuild", async () => {
|
||||
await api.redeploy(installation.id, true);
|
||||
await refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
Rebuild & redeploy
|
||||
</button>{" "}
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
withBusy("delete", async () => {
|
||||
await api.remove(installation.id);
|
||||
navigate("/");
|
||||
})
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
|
||||
<h2>Build log</h2>
|
||||
<button
|
||||
onClick={() =>
|
||||
withBusy("logs", async () => {
|
||||
const { log } = await api.logs(installation.id);
|
||||
setLog(log);
|
||||
})
|
||||
}
|
||||
>
|
||||
Refresh log
|
||||
</button>
|
||||
<pre style={{ background: "#111", color: "#0f0", padding: 12, maxHeight: 300, overflow: "auto" }}>{log}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
apps/web/src/pages/InstallationList.tsx
Normal file
61
apps/web/src/pages/InstallationList.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { api, type Installation } from "../api/client";
|
||||
import { InstallWizard } from "./InstallWizard";
|
||||
|
||||
export function InstallationList() {
|
||||
const [installations, setInstallations] = useState<Installation[]>([]);
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function refresh() {
|
||||
setInstallations(await api.listInstallations());
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h1>Installations</h1>
|
||||
<button onClick={() => setShowWizard((v) => !v)}>{showWizard ? "Close" : "New installation"}</button>
|
||||
</div>
|
||||
|
||||
{showWizard && (
|
||||
<InstallWizard
|
||||
onCancel={() => setShowWizard(false)}
|
||||
onCreated={(id, detectedEnvKeys) => {
|
||||
setShowWizard(false);
|
||||
navigate(`/installations/${id}`, { state: { detectedEnvKeys } });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left">Name</th>
|
||||
<th align="left">Slug</th>
|
||||
<th align="left">Status</th>
|
||||
<th align="left">Repo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{installations.map((i) => (
|
||||
<tr key={i.id} style={{ borderTop: "1px solid #eee" }}>
|
||||
<td>
|
||||
<Link to={`/installations/${i.id}`}>{i.name}</Link>
|
||||
</td>
|
||||
<td>{i.slug}</td>
|
||||
<td>{i.status}</td>
|
||||
<td>{i.repoUrl}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{installations.length === 0 && !showWizard && <p>No installations yet.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
apps/web/src/pages/Login.tsx
Normal file
42
apps/web/src/pages/Login.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
|
||||
export function Login({ onLoggedIn }: { onLoggedIn: () => void }) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.login(password);
|
||||
onLoggedIn();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 320, margin: "10vh auto" }}>
|
||||
<h1>remote-mcp-manager</h1>
|
||||
<form onSubmit={submit}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Admin password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
style={{ width: "100%", padding: 8, marginBottom: 8 }}
|
||||
/>
|
||||
<button type="submit" disabled={loading} style={{ width: "100%", padding: 8 }}>
|
||||
{loading ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user