feat: redesign admin panel as a modern dashboard
Some checks failed
CI / test (push) Has been cancelled
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:
@@ -2,7 +2,14 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>remote-mcp-manager</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -15,14 +15,14 @@ export function App() {
|
||||
if (authenticated === null) return null;
|
||||
if (!authenticated) return <Login onLoggedIn={() => setAuthenticated(true)} />;
|
||||
|
||||
const onLoggedOut = () => setAuthenticated(false);
|
||||
|
||||
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>
|
||||
<Routes>
|
||||
<Route path="/" element={<InstallationList onLoggedOut={onLoggedOut} />} />
|
||||
<Route path="/installations/:id" element={<InstallationDetail onLoggedOut={onLoggedOut} />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
51
apps/web/src/components/Shell.tsx
Normal file
51
apps/web/src/components/Shell.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
|
||||
export function Shell({
|
||||
title,
|
||||
actions,
|
||||
children,
|
||||
onLoggedOut,
|
||||
}: {
|
||||
title: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
onLoggedOut: () => void;
|
||||
}) {
|
||||
async function logout() {
|
||||
await api.logout();
|
||||
onLoggedOut();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<span className="brand-mark">mcp</span>
|
||||
<div>
|
||||
<div className="brand-name">remote-mcp-manager</div>
|
||||
<div className="brand-sub">Server control room</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
<NavLink to="/" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`} end>
|
||||
Installations
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={logout}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="main">
|
||||
<header className="topbar">
|
||||
<h1>{title}</h1>
|
||||
{actions && <div className="topbar-actions">{actions}</div>}
|
||||
</header>
|
||||
<div className="content">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
apps/web/src/components/StatusBadge.tsx
Normal file
21
apps/web/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Installation } from "../api/client";
|
||||
|
||||
const LABELS: Record<Installation["status"], string> = {
|
||||
draft: "Draft",
|
||||
cloning: "Cloning",
|
||||
building: "Building",
|
||||
built: "Built",
|
||||
starting: "Starting",
|
||||
running: "Running",
|
||||
stopped: "Stopped",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: Installation["status"] }) {
|
||||
return (
|
||||
<span className={`badge badge-${status}`}>
|
||||
<span className="badge-dot" />
|
||||
{LABELS[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
803
apps/web/src/styles.css
Normal file
803
apps/web/src/styles.css
Normal file
@@ -0,0 +1,803 @@
|
||||
/* ---------------------------------------------------------------------
|
||||
remote-mcp-manager — design tokens
|
||||
A control room for MCP server installations: dense, legible, calm.
|
||||
Base palette tuned for a dark "ops console" feel, with a light
|
||||
counterpart derived from the same hues for prefers-color-scheme.
|
||||
------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
/* surfaces */
|
||||
--bg: #f5f6fb;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #eef0f8;
|
||||
--surface-sunken: #e7e9f3;
|
||||
--border: #dde1ee;
|
||||
--border-strong: #c7cce0;
|
||||
|
||||
/* text */
|
||||
--text: #161a2c;
|
||||
--text-dim: #5b6180;
|
||||
--text-faint: #8d93ad;
|
||||
|
||||
/* accent (signal teal — the one spot of color on an otherwise quiet UI) */
|
||||
--accent: #0d9488;
|
||||
--accent-strong: #0b7f74;
|
||||
--accent-tint: rgba(13, 148, 136, 0.1);
|
||||
--accent-contrast: #ffffff;
|
||||
|
||||
--danger: #dc2626;
|
||||
--danger-tint: rgba(220, 38, 38, 0.1);
|
||||
|
||||
/* status hues — one per installation state */
|
||||
--status-draft: #71778f;
|
||||
--status-cloning: #2563eb;
|
||||
--status-building: #b45309;
|
||||
--status-built: #7c3aed;
|
||||
--status-starting: #0e7490;
|
||||
--status-running: #15803d;
|
||||
--status-stopped: #52576e;
|
||||
--status-error: #dc2626;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius: 10px;
|
||||
--radius-lg: 14px;
|
||||
--shadow: 0 1px 2px rgba(22, 26, 44, 0.06), 0 8px 24px -12px rgba(22, 26, 44, 0.12);
|
||||
--font-ui: "Inter", -apple-system, "Segoe UI", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "SFMono-Regular", Consolas, Menlo, monospace;
|
||||
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--bg: #0a0e1a;
|
||||
--surface: #111728;
|
||||
--surface-2: #161d33;
|
||||
--surface-sunken: #080b15;
|
||||
--border: #262f4b;
|
||||
--border-strong: #37416a;
|
||||
|
||||
--text: #e9ebf5;
|
||||
--text-dim: #9aa0bf;
|
||||
--text-faint: #6a7093;
|
||||
|
||||
--accent: #2dd4bf;
|
||||
--accent-strong: #5eead4;
|
||||
--accent-tint: rgba(45, 212, 191, 0.14);
|
||||
--accent-contrast: #06201c;
|
||||
|
||||
--danger: #f87171;
|
||||
--danger-tint: rgba(248, 113, 113, 0.14);
|
||||
|
||||
--status-draft: #9aa1c2;
|
||||
--status-cloning: #60a5fa;
|
||||
--status-building: #fbbf24;
|
||||
--status-built: #c4b5fd;
|
||||
--status-starting: #22d3ee;
|
||||
--status-running: #4ade80;
|
||||
--status-stopped: #9aa1c2;
|
||||
--status-error: #f87171;
|
||||
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 28px -14px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
code,
|
||||
pre,
|
||||
kbd {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.001ms !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------- app shell ---------------------------- */
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 20px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-dim);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--surface-2);
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--accent-tint);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 28px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 28px;
|
||||
max-width: 1040px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ------------------------------ buttons ------------------------------ */
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 7px 13px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: background-color 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-strong);
|
||||
border-color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--danger);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--danger-tint);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 9px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ------------------------------ badges ------------------------------- */
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 9px 3px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge-draft {
|
||||
color: var(--status-draft);
|
||||
background: color-mix(in srgb, var(--status-draft) 14%, transparent);
|
||||
}
|
||||
.badge-cloning {
|
||||
color: var(--status-cloning);
|
||||
background: color-mix(in srgb, var(--status-cloning) 14%, transparent);
|
||||
}
|
||||
.badge-building {
|
||||
color: var(--status-building);
|
||||
background: color-mix(in srgb, var(--status-building) 16%, transparent);
|
||||
}
|
||||
.badge-built {
|
||||
color: var(--status-built);
|
||||
background: color-mix(in srgb, var(--status-built) 14%, transparent);
|
||||
}
|
||||
.badge-starting {
|
||||
color: var(--status-starting);
|
||||
background: color-mix(in srgb, var(--status-starting) 14%, transparent);
|
||||
}
|
||||
.badge-running {
|
||||
color: var(--status-running);
|
||||
background: color-mix(in srgb, var(--status-running) 14%, transparent);
|
||||
}
|
||||
.badge-stopped {
|
||||
color: var(--status-stopped);
|
||||
background: color-mix(in srgb, var(--status-stopped) 14%, transparent);
|
||||
}
|
||||
.badge-error {
|
||||
color: var(--status-error);
|
||||
background: color-mix(in srgb, var(--status-error) 14%, transparent);
|
||||
}
|
||||
|
||||
.badge-draft .badge-dot { background: var(--status-draft); }
|
||||
.badge-cloning .badge-dot { background: var(--status-cloning); }
|
||||
.badge-building .badge-dot { background: var(--status-building); }
|
||||
.badge-built .badge-dot { background: var(--status-built); }
|
||||
.badge-starting .badge-dot { background: var(--status-starting); }
|
||||
.badge-running .badge-dot { background: var(--status-running); }
|
||||
.badge-stopped .badge-dot { background: var(--status-stopped); }
|
||||
.badge-error .badge-dot { background: var(--status-error); }
|
||||
|
||||
.badge-building .badge-dot,
|
||||
.badge-cloning .badge-dot,
|
||||
.badge-starting .badge-dot {
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* ------------------------------- cards -------------------------------- */
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card + .card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-hint {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* -------------------------------- table -------------------------------- */
|
||||
|
||||
.table-wrap {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-faint);
|
||||
text-transform: none;
|
||||
padding: 11px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 13px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
tbody tr.row-link:hover {
|
||||
background: var(--surface-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cell-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cell-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.cell-dim {
|
||||
color: var(--text-faint);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* -------------------------------- forms -------------------------------- */
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
.input {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
font-size: 13.5px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
.input:focus-visible {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.input-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.row > .field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ------------------------------ env rows ------------------------------- */
|
||||
|
||||
.env-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.env-empty {
|
||||
color: var(--text-faint);
|
||||
font-size: 13px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* -------------------------------- kv / code -------------------------------- */
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 10px 16px;
|
||||
}
|
||||
|
||||
.kv dt {
|
||||
color: var(--text-faint);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.copy-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.code-chip {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 5px 9px;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* -------------------------------- log -------------------------------- */
|
||||
|
||||
.log-pane {
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
padding: 14px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.log-pane:empty::before,
|
||||
.log-pane.log-empty::before {
|
||||
content: "No log output yet.";
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* ------------------------------- alerts ------------------------------- */
|
||||
|
||||
.alert {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 10px 13px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
background: var(--danger-tint);
|
||||
color: var(--danger);
|
||||
border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ------------------------------- modal ------------------------------- */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, var(--surface-sunken) 72%, transparent);
|
||||
backdrop-filter: blur(2px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ------------------------------ page header ------------------------------ */
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.back-link:hover {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.detail-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail-sub {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--text-faint);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status-detail {
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-gap {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 56px 24px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ------------------------------ login ------------------------------ */
|
||||
|
||||
.login-screen {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-brand .brand-mark {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.login-sub {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-faint);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.nav {
|
||||
flex-direction: row;
|
||||
}
|
||||
.sidebar-footer {
|
||||
margin-top: 0;
|
||||
}
|
||||
.content {
|
||||
padding: 18px;
|
||||
}
|
||||
.topbar {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
.kv {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.env-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user