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:
12
apps/web/index.html
Normal file
12
apps/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>remote-mcp-manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
apps/web/package.json
Normal file
22
apps/web/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@remote-mcp-manager/web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router-dom": "6.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.3.26",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "5.4.20"
|
||||
}
|
||||
}
|
||||
28
apps/web/src/App.tsx
Normal file
28
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { api } from "./api/client";
|
||||
import { Login } from "./pages/Login";
|
||||
import { InstallationList } from "./pages/InstallationList";
|
||||
import { InstallationDetail } from "./pages/InstallationDetail";
|
||||
|
||||
export function App() {
|
||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.session().then((s) => setAuthenticated(s.authenticated));
|
||||
}, []);
|
||||
|
||||
if (authenticated === null) return null;
|
||||
if (!authenticated) return <Login onLoggedIn={() => setAuthenticated(true)} />;
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div style={{ maxWidth: 900, margin: "0 auto", padding: 16, fontFamily: "sans-serif" }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<InstallationList />} />
|
||||
<Route path="/installations/:id" element={<InstallationDetail />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
60
apps/web/src/api/client.ts
Normal file
60
apps/web/src/api/client.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export interface Installation {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
repoUrl: string;
|
||||
gitRef: string | null;
|
||||
status: "draft" | "cloning" | "building" | "built" | "starting" | "running" | "stopped" | "error";
|
||||
statusDetail: string | null;
|
||||
imageTag: string | null;
|
||||
containerName: string;
|
||||
internalPort: number;
|
||||
httpPath: string;
|
||||
portEnvVar: string;
|
||||
hostEnvVar: string;
|
||||
env: Record<string, string>;
|
||||
bearerToken: string | null;
|
||||
publicUrl: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(body.error ?? `request failed with ${res.status}`);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
session: () => request<{ authenticated: boolean }>("/api/auth/session"),
|
||||
login: (password: string) => request<{ ok: true }>("/api/auth/login", { method: "POST", body: JSON.stringify({ password }) }),
|
||||
logout: () => request<{ ok: true }>("/api/auth/logout", { method: "POST" }),
|
||||
|
||||
listInstallations: () => request<Installation[]>("/api/installations"),
|
||||
getInstallation: (id: string) => request<Installation>(`/api/installations/${id}`),
|
||||
createInstallation: (input: { name: string; repoUrl: string; gitRef?: string }) =>
|
||||
request<{ installation: Installation; detectedEnvKeys: string[] }>("/api/installations", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
configureInstallation: (
|
||||
id: string,
|
||||
patch: { env?: Record<string, string>; portEnvVar?: string; hostEnvVar?: string; httpPath?: string },
|
||||
) => request<Installation>(`/api/installations/${id}/config`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
build: (id: string) => request<Installation>(`/api/installations/${id}/build`, { method: "POST" }),
|
||||
deploy: (id: string) => request<Installation>(`/api/installations/${id}/deploy`, { method: "POST" }),
|
||||
redeploy: (id: string, rebuild: boolean) =>
|
||||
request<Installation>(`/api/installations/${id}/redeploy`, { method: "POST", body: JSON.stringify({ rebuild }) }),
|
||||
regenerateToken: (id: string) =>
|
||||
request<{ bearerToken: string }>(`/api/installations/${id}/regenerate-token`, { method: "POST" }),
|
||||
remove: (id: string) => request<void>(`/api/installations/${id}`, { method: "DELETE" }),
|
||||
logs: (id: string) => request<{ log: string }>(`/api/installations/${id}/logs`),
|
||||
};
|
||||
9
apps/web/src/main.tsx
Normal file
9
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
14
apps/web/tsconfig.json
Normal file
14
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
11
apps/web/vite.config.ts
Normal file
11
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:3000",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user