feat: redesign admin panel as a modern dashboard
Some checks failed
CI / test (push) Has been cancelled

Replace the unstyled apps/web UI with a cohesive dark/light "ops console"
design: sidebar shell, semantic status badges for all 8 installation
states, a real data table for the installation list, a modal install
wizard, and a sectioned detail view (access, configuration, actions,
build log) with copy-to-clipboard for the public URL and bearer token.
Styling is plain CSS with custom-property design tokens (no new runtime
dependency); Inter + JetBrains Mono loaded via Google Fonts. All existing
API calls and behavior are preserved unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 06:12:51 +02:00
parent fb59d422a2
commit 86b365ac52
10 changed files with 1244 additions and 226 deletions

View File

@@ -29,37 +29,45 @@ export function InstallWizard({
}
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 className="modal-overlay" onMouseDown={(e) => e.target === e.currentTarget && onCancel()}>
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="wizard-title">
<div className="modal-header">
<h2 id="wizard-title" style={{ fontSize: 15 }}>
New installation
</h2>
</div>
{error && <p style={{ color: "crimson" }}>{error}</p>}
</form>
<form onSubmit={submit}>
<div className="field">
<label htmlFor="w-name">Name</label>
<input id="w-name" value={name} onChange={(e) => setName(e.target.value)} required autoFocus />
</div>
<div className="field">
<label htmlFor="w-repo">Repository URL</label>
<input
id="w-repo"
className="input-mono"
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
placeholder="https://github.com/user/repo.git"
required
/>
</div>
<div className="field">
<label htmlFor="w-ref">Git ref (optional)</label>
<input id="w-ref" className="input-mono" value={gitRef} onChange={(e) => setGitRef(e.target.value)} placeholder="main" />
<span className="field-hint">Branch, tag, or commit. Defaults to the repository's default branch.</span>
</div>
{error && <div className="alert">{error}</div>}
<div className="actions-row">
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? "Cloning…" : "Clone & continue"}
</button>
<button type="button" className="btn" onClick={onCancel}>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,8 +1,35 @@
import { useEffect, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { api, type Installation } from "../api/client";
import { Shell } from "../components/Shell";
import { StatusBadge } from "../components/StatusBadge";
export function InstallationDetail() {
function CopyChip({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// clipboard access denied — nothing to recover, the value is still selectable
}
}
return (
<div className="copy-row">
<code className="code-chip">{value}</code>
<button type="button" className="btn btn-sm" onClick={copy}>
{copied ? "Copied" : "Copy"}
</button>
</div>
);
}
const DEPLOY_LOCKED_STATUSES = new Set<Installation["status"]>(["cloning", "building", "starting"]);
export function InstallationDetail({ onLoggedOut }: { onLoggedOut: () => void }) {
const { id } = useParams<{ id: string }>();
const location = useLocation();
const navigate = useNavigate();
@@ -12,6 +39,7 @@ export function InstallationDetail() {
const [hostEnvVar, setHostEnvVar] = useState("HOST");
const [httpPath, setHttpPath] = useState("/mcp");
const [log, setLog] = useState("");
const [logLoaded, setLogLoaded] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -45,83 +73,144 @@ export function InstallationDetail() {
}
}
if (!installation) return <p>Loading…</p>;
async function loadLogs() {
if (!installation) return;
await withBusy("logs", async () => {
const { log } = await api.logs(installation.id);
setLog(log);
setLogLoaded(true);
});
}
if (!installation) {
return (
<Shell title="Installation" onLoggedOut={onLoggedOut}>
<p className="cell-dim">Loading…</p>
</Shell>
);
}
const deployLocked = DEPLOY_LOCKED_STATUSES.has(installation.status);
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>
<Shell title={installation.name} onLoggedOut={onLoggedOut}>
<a
href="/"
className="back-link"
onClick={(e) => {
e.preventDefault();
navigate("/");
}}
>
← Back to installations
</a>
<div className="page-header">
<div>
<div className="detail-title-row">
<h2 style={{ fontSize: 20 }}>{installation.name}</h2>
<StatusBadge status={installation.status} />
</div>
<div className="detail-sub">{installation.slug}</div>
{installation.statusDetail && <p className="status-detail">{installation.statusDetail}</p>}
</div>
</div>
{error && <div className="alert">{error}</div>}
{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 className="card">
<div className="card-header">
<span className="card-title">Access</span>
<span className="card-hint">Used by MCP clients to reach this server</span>
</div>
<dl className="kv">
<dt>Public URL</dt>
<dd>
<CopyChip value={installation.publicUrl} />
</dd>
<dt>Bearer token</dt>
<dd>
<div className="copy-row">
<code className="code-chip">{installation.bearerToken}</code>
<button type="button" className="btn btn-sm" onClick={() => navigator.clipboard.writeText(installation.bearerToken!)}>
Copy
</button>
<button
type="button"
className="btn btn-sm"
disabled={busy !== null}
onClick={() =>
withBusy("regen", async () => {
await api.regenerateToken(installation.id);
await refresh();
})
}
>
{busy === "regen" ? "Regenerating…" : "Regenerate"}
</button>
</div>
</dd>
</dl>
</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 className="card section-gap">
<div className="card-header">
<span className="card-title">Configuration</span>
<span className="card-hint">Environment variables and runtime wiring</span>
</div>
<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>
{envRows.length === 0 && <p className="env-empty">No environment variables configured.</p>}
{envRows.map(([k, v], idx) => (
<div className="env-row" key={idx}>
<input
className="input-mono"
value={k}
placeholder="KEY"
onChange={(e) => {
const next = [...envRows];
next[idx] = [e.target.value, v];
setEnvRows(next);
}}
/>
<input
className="input-mono"
value={v}
placeholder="value"
onChange={(e) => {
const next = [...envRows];
next[idx] = [k, e.target.value];
setEnvRows(next);
}}
/>
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setEnvRows(envRows.filter((_, i) => i !== idx))}>
Remove
</button>
</div>
))}
<button type="button" className="btn btn-sm" onClick={() => setEnvRows([...envRows, ["", ""]])}>
Add variable
</button>
<div className="row section-gap">
<div className="field">
<label htmlFor="port-env">Port env var</label>
<input id="port-env" className="input-mono" value={portEnvVar} onChange={(e) => setPortEnvVar(e.target.value)} />
</div>
<div className="field">
<label htmlFor="host-env">Host env var</label>
<input id="host-env" className="input-mono" value={hostEnvVar} onChange={(e) => setHostEnvVar(e.target.value)} />
</div>
<div className="field">
<label htmlFor="http-path">HTTP path</label>
<input id="http-path" className="input-mono" value={httpPath} onChange={(e) => setHttpPath(e.target.value)} />
</div>
</div>
<div style={{ marginTop: 12 }}>
<button
type="button"
className="btn btn-primary"
disabled={busy !== null}
onClick={() =>
withBusy("config", async () => {
@@ -131,81 +220,93 @@ export function InstallationDetail() {
})
}
>
Save configuration
{busy === "config" ? "Saving…" : "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>
<div className="card section-gap">
<div className="card-header">
<span className="card-title">Actions</span>
</div>
<div className="actions-row">
<button
type="button"
className="btn"
disabled={busy !== null}
onClick={() =>
withBusy("build", async () => {
await api.build(installation.id);
await refresh();
})
}
>
{busy === "build" ? "Building…" : "Build"}
</button>
<button
type="button"
className="btn"
disabled={busy !== null || deployLocked}
onClick={() =>
withBusy("deploy", async () => {
await api.deploy(installation.id);
await refresh();
})
}
>
{busy === "deploy" ? "Deploying…" : "Deploy"}
</button>
<button
type="button"
className="btn"
disabled={busy !== null}
onClick={() =>
withBusy("redeploy", async () => {
await api.redeploy(installation.id, false);
await refresh();
})
}
>
{busy === "redeploy" ? "Redeploying…" : "Redeploy (env only)"}
</button>
<button
type="button"
className="btn"
disabled={busy !== null}
onClick={() =>
withBusy("rebuild", async () => {
await api.redeploy(installation.id, true);
await refresh();
})
}
>
{busy === "rebuild" ? "Rebuilding…" : "Rebuild & redeploy"}
</button>
<button
type="button"
className="btn btn-danger"
disabled={busy !== null}
onClick={() => {
if (!window.confirm(`Delete "${installation.name}"? This stops and removes its container.`)) return;
withBusy("delete", async () => {
await api.remove(installation.id);
navigate("/");
});
}}
>
{busy === "delete" ? "Deleting…" : "Delete"}
</button>
</div>
</div>
{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>
<div className="card section-gap">
<div className="card-header">
<span className="card-title">Build log</span>
<button type="button" className="btn btn-sm" disabled={busy !== null} onClick={loadLogs}>
{busy === "logs" ? "Loading…" : "Refresh log"}
</button>
</div>
<pre className={`log-pane${logLoaded && !log ? " log-empty" : ""}`}>{log}</pre>
</div>
</Shell>
);
}

View File

@@ -1,10 +1,12 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { api, type Installation } from "../api/client";
import { InstallWizard } from "./InstallWizard";
import { Shell } from "../components/Shell";
import { StatusBadge } from "../components/StatusBadge";
export function InstallationList() {
const [installations, setInstallations] = useState<Installation[]>([]);
export function InstallationList({ onLoggedOut }: { onLoggedOut: () => void }) {
const [installations, setInstallations] = useState<Installation[] | null>(null);
const [showWizard, setShowWizard] = useState(false);
const navigate = useNavigate();
@@ -17,12 +19,15 @@ export function InstallationList() {
}, []);
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>
<Shell
title="Installations"
onLoggedOut={onLoggedOut}
actions={
<button type="button" className="btn btn-primary" onClick={() => setShowWizard(true)}>
New installation
</button>
}
>
{showWizard && (
<InstallWizard
onCancel={() => setShowWizard(false)}
@@ -33,29 +38,39 @@ export function InstallationList() {
/>
)}
<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>
{installations && installations.length === 0 && (
<div className="empty-state card">
<h2 style={{ fontSize: 15 }}>No installations yet</h2>
<p>Add a GitHub repository to clone, build, and deploy it as an MCP server.</p>
</div>
)}
{installations && installations.length > 0 && (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Slug</th>
<th>Repository</th>
</tr>
</thead>
<tbody>
{installations.map((i) => (
<tr key={i.id} className="row-link" onClick={() => navigate(`/installations/${i.id}`)}>
<td className="cell-name">{i.name}</td>
<td>
<StatusBadge status={i.status} />
</td>
<td className="cell-mono">{i.slug}</td>
<td className="cell-dim">{i.repoUrl}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Shell>
);
}

View File

@@ -21,22 +21,33 @@ export function Login({ onLoggedIn }: { onLoggedIn: () => void }) {
}
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 className="login-screen">
<div className="card login-card">
<div className="login-brand">
<span className="brand-mark">mcp</span>
<div>
<h1 className="login-title">remote-mcp-manager</h1>
</div>
<p className="login-sub">Sign in to manage your MCP server installations.</p>
</div>
<form onSubmit={submit}>
<div className="field">
<label htmlFor="password">Admin password</label>
<input
id="password"
type="password"
placeholder="Enter password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
</div>
{error && <div className="alert">{error}</div>}
<button type="submit" className="btn btn-primary" disabled={loading} style={{ width: "100%", justifyContent: "center" }}>
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
</div>
</div>
);
}