From 86b365ac52c7cab241ed181705a1f4d14dea1d94 Mon Sep 17 00:00:00 2001 From: Dasemu Date: Thu, 8 Oct 2026 06:12:51 +0200 Subject: [PATCH] feat: redesign admin panel as a modern dashboard 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 --- apps/web/index.html | 9 +- apps/web/src/App.tsx | 12 +- apps/web/src/components/Shell.tsx | 51 ++ apps/web/src/components/StatusBadge.tsx | 21 + apps/web/src/main.tsx | 1 + apps/web/src/pages/InstallWizard.tsx | 68 +- apps/web/src/pages/InstallationDetail.tsx | 381 ++++++---- apps/web/src/pages/InstallationList.tsx | 81 ++- apps/web/src/pages/Login.tsx | 43 +- apps/web/src/styles.css | 803 ++++++++++++++++++++++ 10 files changed, 1244 insertions(+), 226 deletions(-) create mode 100644 apps/web/src/components/Shell.tsx create mode 100644 apps/web/src/components/StatusBadge.tsx create mode 100644 apps/web/src/styles.css diff --git a/apps/web/index.html b/apps/web/index.html index 2a1192a..1679768 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,7 +2,14 @@ - + + + + + remote-mcp-manager diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b84219a..541bf0e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -15,14 +15,14 @@ export function App() { if (authenticated === null) return null; if (!authenticated) return setAuthenticated(true)} />; + const onLoggedOut = () => setAuthenticated(false); + return ( -
- - } /> - } /> - -
+ + } /> + } /> +
); } diff --git a/apps/web/src/components/Shell.tsx b/apps/web/src/components/Shell.tsx new file mode 100644 index 0000000..34bca9d --- /dev/null +++ b/apps/web/src/components/Shell.tsx @@ -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 ( +
+ +
+
+

{title}

+ {actions &&
{actions}
} +
+
{children}
+
+
+ ); +} diff --git a/apps/web/src/components/StatusBadge.tsx b/apps/web/src/components/StatusBadge.tsx new file mode 100644 index 0000000..b98dd60 --- /dev/null +++ b/apps/web/src/components/StatusBadge.tsx @@ -0,0 +1,21 @@ +import type { Installation } from "../api/client"; + +const LABELS: Record = { + draft: "Draft", + cloning: "Cloning", + building: "Building", + built: "Built", + starting: "Starting", + running: "Running", + stopped: "Stopped", + error: "Error", +}; + +export function StatusBadge({ status }: { status: Installation["status"] }) { + return ( + + + {LABELS[status]} + + ); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 1c711a7..9891129 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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( diff --git a/apps/web/src/pages/InstallWizard.tsx b/apps/web/src/pages/InstallWizard.tsx index 1845429..5ebf08f 100644 --- a/apps/web/src/pages/InstallWizard.tsx +++ b/apps/web/src/pages/InstallWizard.tsx @@ -29,37 +29,45 @@ export function InstallWizard({ } return ( -
-

New installation

-
- - - -
- {" "} - +
e.target === e.currentTarget && onCancel()}> +
+
+

+ New installation +

- {error &&

{error}

} - +
+
+ + setName(e.target.value)} required autoFocus /> +
+
+ + setRepoUrl(e.target.value)} + placeholder="https://github.com/user/repo.git" + required + /> +
+
+ + setGitRef(e.target.value)} placeholder="main" /> + Branch, tag, or commit. Defaults to the repository's default branch. +
+ {error &&
{error}
} +
+ + +
+
+
); } diff --git a/apps/web/src/pages/InstallationDetail.tsx b/apps/web/src/pages/InstallationDetail.tsx index d395fd1..2b2688e 100644 --- a/apps/web/src/pages/InstallationDetail.tsx +++ b/apps/web/src/pages/InstallationDetail.tsx @@ -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 ( +
+ {value} + +
+ ); +} + +const DEPLOY_LOCKED_STATUSES = new Set(["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(null); const [error, setError] = useState(null); @@ -45,83 +73,144 @@ export function InstallationDetail() { } } - if (!installation) return

Loading…

; + async function loadLogs() { + if (!installation) return; + await withBusy("logs", async () => { + const { log } = await api.logs(installation.id); + setLog(log); + setLogLoaded(true); + }); + } + + if (!installation) { + return ( + +

Loading…

+
+ ); + } + + const deployLocked = DEPLOY_LOCKED_STATUSES.has(installation.status); return ( -
- -

{installation.name}

-

- Status: {installation.status} - {installation.statusDetail && — {installation.statusDetail}} -

+ + { + e.preventDefault(); + navigate("/"); + }} + > + ← Back to installations + + +
+
+
+

{installation.name}

+ +
+
{installation.slug}
+ {installation.statusDetail &&

{installation.statusDetail}

} +
+
+ + {error &&
{error}
} {installation.status === "running" && installation.bearerToken && ( -
- Public URL: {installation.publicUrl} -
- Bearer token: {installation.bearerToken}{" "} - +
+
+ Access + Used by MCP clients to reach this server +
+
+
Public URL
+
+ +
+
Bearer token
+
+
+ {installation.bearerToken} + + +
+
+
)} -

Configuration

- - - {envRows.map(([k, v], idx) => ( - - - - - - ))} - -
- { - const next = [...envRows]; - next[idx] = [e.target.value, v]; - setEnvRows(next); - }} - /> - - { - const next = [...envRows]; - next[idx] = [k, e.target.value]; - setEnvRows(next); - }} - /> - - -
- +
+
+ Configuration + Environment variables and runtime wiring +
-
- - - -
+ {envRows.length === 0 &&

No environment variables configured.

} + {envRows.map(([k, v], idx) => ( +
+ { + const next = [...envRows]; + next[idx] = [e.target.value, v]; + setEnvRows(next); + }} + /> + { + const next = [...envRows]; + next[idx] = [k, e.target.value]; + setEnvRows(next); + }} + /> + +
+ ))} + + +
+
+ + setPortEnvVar(e.target.value)} /> +
+
+ + setHostEnvVar(e.target.value)} /> +
+
+ + setHttpPath(e.target.value)} /> +
+
-
-

Actions

- {" "} - {" "} - {" "} - {" "} - +
+
+ Actions +
+
+ + + + + +
+
- {error &&

{error}

} - -

Build log

- -
{log}
-
+
+
+ Build log + +
+
{log}
+
+ ); } diff --git a/apps/web/src/pages/InstallationList.tsx b/apps/web/src/pages/InstallationList.tsx index 416dbe1..9d37fe8 100644 --- a/apps/web/src/pages/InstallationList.tsx +++ b/apps/web/src/pages/InstallationList.tsx @@ -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([]); +export function InstallationList({ onLoggedOut }: { onLoggedOut: () => void }) { + const [installations, setInstallations] = useState(null); const [showWizard, setShowWizard] = useState(false); const navigate = useNavigate(); @@ -17,12 +19,15 @@ export function InstallationList() { }, []); return ( -
-
-

Installations

- -
- + setShowWizard(true)}> + New installation + + } + > {showWizard && ( setShowWizard(false)} @@ -33,29 +38,39 @@ export function InstallationList() { /> )} - - - - - - - - - - - {installations.map((i) => ( - - - - - - - ))} - -
NameSlugStatusRepo
- {i.name} - {i.slug}{i.status}{i.repoUrl}
- {installations.length === 0 && !showWizard &&

No installations yet.

} -
+ {installations && installations.length === 0 && ( +
+

No installations yet

+

Add a GitHub repository to clone, build, and deploy it as an MCP server.

+
+ )} + + {installations && installations.length > 0 && ( +
+ + + + + + + + + + + {installations.map((i) => ( + navigate(`/installations/${i.id}`)}> + + + + + + ))} + +
NameStatusSlugRepository
{i.name} + + {i.slug}{i.repoUrl}
+
+ )} + ); } diff --git a/apps/web/src/pages/Login.tsx b/apps/web/src/pages/Login.tsx index e1d4b5f..754653b 100644 --- a/apps/web/src/pages/Login.tsx +++ b/apps/web/src/pages/Login.tsx @@ -21,22 +21,33 @@ export function Login({ onLoggedIn }: { onLoggedIn: () => void }) { } return ( -
-

remote-mcp-manager

-
- setPassword(e.target.value)} - autoFocus - style={{ width: "100%", padding: 8, marginBottom: 8 }} - /> - - {error &&

{error}

} -
+
+
+
+ mcp +
+

remote-mcp-manager

+
+

Sign in to manage your MCP server installations.

+
+
+
+ + setPassword(e.target.value)} + autoFocus + /> +
+ {error &&
{error}
} + +
+
); } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css new file mode 100644 index 0000000..27599b0 --- /dev/null +++ b/apps/web/src/styles.css @@ -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; + } +}