Compare commits

...

15 Commits

Author SHA1 Message Date
f11a23fb00 fix: re-pull the repo on every rebuild instead of reusing the stale clone
Some checks failed
CI / test (push) Has been cancelled
buildInstallation ran nixpacks against whatever was cloned at draft
creation time and never re-synced — every "rebuild" from the panel
silently rebuilt the same old commit forever, even after pushing new
commits. Confirmed repeatedly while iterating against a live repo
(had to git pull manually each time to actually pick up a fix).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 21:03:57 +02:00
7b6e1b5855 feat: support host bind-mount volumes per installation
Some checks failed
CI / test (push) Has been cancelled
Needed for state that must survive rebuilds and that nothing else
manages — e.g. a Playwright browser profile holding an existing
logged-in session for a browser-automation MCP. hostVolumes is a list
of {hostPath, containerPath} pairs, bind-mounted via dockerode same as
the manager's own compose volumes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 07:16:45 +02:00
c918b32e72 feat: support publishing a container's port to host loopback
Some checks failed
CI / test (push) Has been cancelled
Some installations need to stay reachable by a native process on the
same host outside the manager's own proxy — e.g. a legacy nginx
location serving media/signed URLs for an app that also happens to be
an MCP server. Previously this required manually recreating the
container outside the manager's lifecycle (lost on the next
build/redeploy). publishHostPort makes it a real, persisted setting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 07:05:56 +02:00
1efba2461a feat: support an explicit start command override per installation
Some checks failed
CI / test (push) Has been cancelled
Needed to install third-party repos we don't control (e.g. the
upstream gitea.com/gitea/gitea-mcp project) where nixpacks detects a
working build but the wrong/no start command, and pushing a
nixpacks.toml to the source repo isn't an option. Passed through as
nixpacks' own --start-cmd flag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 06:46:28 +02:00
86b365ac52 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>
2026-10-08 06:12:51 +02:00
fb59d422a2 feat: support optional per-installation Basic Auth on the MCP proxy
All checks were successful
CI / test (push) Successful in 31s
Lets a client still using Basic Auth credentials (e.g. against the
previous nginx + htpasswd setup) keep working without switching to a
bearer token immediately. Bearer auth remains the default and the
only option when no Basic Auth credentials are configured.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 05:05:11 +02:00
021fa2220e fix: pass NIXPACKS_NODE_VERSION as a CLI flag, not a process env var
All checks were successful
CI / test (push) Successful in 33s
nixpacks doesn't read NIXPACKS_* config from the process environment
at all, only from -e/--env on its own CLI — the "fix" from the
previous commit was silently a no-op (nixpacks plan still showed
nodejs_18 for a repo pinned to 20 that way). Caught because the first
real container built with it still crashed the same way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 04:51:31 +02:00
9f88769922 fix: default unhinted Node repos to Node 20 instead of Nixpacks' own default
All checks were successful
CI / test (push) Successful in 40s
Nixpacks' current default (18) predates stable globalThis.crypto —
confirmed in production against ZenvairoSocial-MCP (no .nvmrc/engines.node
at all): build succeeded, container crashed immediately with "crypto is
not defined". A repo with its own version hint still wins; this only
changes the fallback for repos with none.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 04:48:25 +02:00
8eaccfbc47 fix: scope express.json() to /api so the MCP proxy stops hanging
All checks were successful
CI / test (push) Successful in 31s
A global body parser drained every incoming request body, including
on the /mcp/:slug proxy route, before http-proxy could forward it —
every proxied POST reached the backend with zero body bytes and hung
forever. This broke the proxy for every real MCP call, caught only
once a container was actually tested through it end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 04:43:49 +02:00
1019672981 fix: allow disabling per-container memory/CPU caps (CONTAINER_*=0)
All checks were successful
CI / test (push) Successful in 37s
Some hosts can't enforce cgroup limits for nested containers at all
(confirmed on an OpenRC-based unprivileged LXC with no systemd to
delegate cgroup controllers) — docker run fails outright trying to
write memory.max. 0 now skips requesting that cap instead of erroring
every deploy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 04:38:45 +02:00
698173c79d fix: copy the docker buildx plugin so nixpacks builds actually work
All checks were successful
CI / test (push) Successful in 48s
nixpacks shells out to `docker build`, which defaults to BuildKit;
without the buildx CLI plugin every build failed outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 04:31:55 +02:00
223b1ab8eb fix: enable git-over-ssh cloning for private repo installs
All checks were successful
CI / test (push) Successful in 32s
The manager image had git but not openssh-client, so ssh:// clones
failed outright ("cannot run ssh"). Install openssh-client, and mount
an SSH_DIR (key + known_hosts) into the container read-only so private
repos can actually authenticate — confirmed both gaps while deploying
against real private Zenvairo repos.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 04:27:49 +02:00
359d4369e8 fix: session cookie secure flag was never true-to-request
All checks were successful
CI / test (push) Successful in 52s
cookie.secure was tied to NODE_ENV=production instead of whether the
request actually arrived over HTTPS, so in a production build the
admin login cookie was never stored at all when accessed over plain
HTTP (e.g. direct container access, or a proxy that doesn't terminate
TLS in front of the admin panel) — confirmed while deploying to
production, login returned 200 but no session persisted. "auto" uses
req.secure via the existing trust-proxy setting instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 04:25:06 +02:00
306043e5b9 docs: add lesson about pinned-tag images changing contents upstream
All checks were successful
CI / test (push) Successful in 44s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 04:22:18 +02:00
06d43f4ed8 fix: install nixpacks from its pinned GitHub release instead of ghcr.io image
Some checks failed
CI / test (push) Has been cancelled
ghcr.io/railwayapp/nixpacks no longer ships the nixpacks CLI binary at
/usr/bin/nixpacks (the image was repurposed upstream for something
else — confirmed while deploying to production, build failed with
"not found" for the pinned 1.41.0 tag). Download the exact release
tarball from GitHub instead, still pinned to the same version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 04:22:02 +02:00
24 changed files with 1612 additions and 264 deletions

View File

@@ -25,6 +25,14 @@ SESSION_SECRET=change-me-too
# installation env vars and bearer tokens at rest # installation env vars and bearer tokens at rest
MASTER_KEY=change-me-to-a-random-string-of-at-least-32-characters MASTER_KEY=change-me-to-a-random-string-of-at-least-32-characters
# Resource caps applied to every installed MCP container (same limit for all, no per-installation override in V1) # Resource caps applied to every installed MCP container (same limit for all, no per-installation
# override in V1). Set either to 0 to disable that cap — needed on hosts that can't enforce cgroup
# limits for nested containers (e.g. an unprivileged, OpenRC-based LXC with no systemd to delegate
# cgroup controllers; `docker run` fails outright there if a limit is requested).
CONTAINER_MEMORY_MB=512 CONTAINER_MEMORY_MB=512
CONTAINER_CPUS=1 CONTAINER_CPUS=1
# Absolute path to an SSH dir (key + known_hosts) with clone access to the repos you'll
# install, mounted read-only into the manager container so `git clone ssh://...` works for
# private repos. Required only if you install from private/SSH repo URLs.
SSH_DIR=/root/.ssh

View File

@@ -1,6 +1,16 @@
FROM ghcr.io/railwayapp/nixpacks:1.41.0 AS nixpacks
FROM docker:27-cli AS dockercli FROM docker:27-cli AS dockercli
FROM debian:12-slim AS nixpacks
# ghcr.io/railwayapp/nixpacks no longer ships the CLI binary in its image (repurposed
# upstream for something else) — pull the pinned release tarball from GitHub instead.
ARG NIXPACKS_VERSION=1.41.0
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& curl -fsSL -o /tmp/nixpacks.tar.gz \
"https://github.com/railwayapp/nixpacks/releases/download/v${NIXPACKS_VERSION}/nixpacks-v${NIXPACKS_VERSION}-x86_64-unknown-linux-gnu.tar.gz" \
&& tar -xzf /tmp/nixpacks.tar.gz -C /usr/bin nixpacks \
&& rm /tmp/nixpacks.tar.gz
FROM node:20-slim AS build FROM node:20-slim AS build
WORKDIR /repo WORKDIR /repo
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
@@ -13,10 +23,13 @@ RUN npm run build --workspace apps/web
RUN npm run build --workspace apps/server RUN npm run build --workspace apps/server
FROM node:20-slim FROM node:20-slim
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \ RUN apt-get update && apt-get install -y --no-install-recommends git openssh-client ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=nixpacks /usr/bin/nixpacks /usr/local/bin/nixpacks COPY --from=nixpacks /usr/bin/nixpacks /usr/local/bin/nixpacks
COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker
# nixpacks shells out to `docker build`, which defaults to BuildKit — needs the buildx
# plugin present or it fails with "BuildKit is enabled but the buildx component is missing".
COPY --from=dockercli /usr/local/libexec/docker/cli-plugins/docker-buildx /usr/local/libexec/docker/cli-plugins/docker-buildx
WORKDIR /repo WORKDIR /repo
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./

View File

@@ -2,11 +2,83 @@
Recurring mistakes and gotchas found while building and operating this project, and how to avoid or work around them. Recurring mistakes and gotchas found while building and operating this project, and how to avoid or work around them.
## "Rebuild" must re-clone, not reuse the original checkout
`buildInstallation` used to run `nixpacks build` directly against the repo directory left over
from `createDraftInstallation`'s one-time clone — it never re-pulled. Every "rebuild" from the
panel silently rebuilt the exact same old commit forever, even after pushing new commits to the
repo; it looked like the rebuild worked (new image, new timestamp) but the code never changed.
Found repeatedly while iterating on `nixpacks.toml` fixes against a live repo. Fixed by having
`buildInstallation` call `cloneOrUpdateRepo` again (same as the initial clone) before every
build.
## A pinned external image's *contents* can still change
The Dockerfile pinned `ghcr.io/railwayapp/nixpacks:1.41.0` and copied `/usr/bin/nixpacks` out
of it. That path existed when the plan was written but was gone by the time of the first
real production deploy — the image got repurposed upstream (now ships a Nix daemon, not the
nixpacks CLI) even though the tag `1.41.0` still resolves.
**Avoid it:** pinning a tag is not enough to guarantee what's inside an externally-published
image stays the same — only pinning by digest guarantees bytes. For a single binary like this,
prefer downloading the pinned release asset directly from the project's GitHub releases (a
specific `vX.Y.Z` tag + exact filename) over extracting it from someone else's Docker image.
## `git clone ssh://...` needs an actual `ssh` binary, and a key
`node:20-slim` ships `git` but not `openssh-client` — cloning an `https://` repo works, but
`ssh://` fails with "cannot run ssh: No such file or directory". Fixed by installing
`openssh-client` in the final image. Separately, even with `ssh` present, cloning a private
repo needs a key with access mounted into the container (`SSH_DIR` in `docker-compose.yml`,
reusing the host's own deploy key) — confirmed both failures back to back while deploying.
## `docker build` needs the buildx plugin present to use BuildKit
Nixpacks shells out to `docker build`, which defaults to BuildKit. The manager's image only
copied the bare `docker` CLI binary from `docker:27-cli`, not its buildx plugin, so every
build failed with "BuildKit is enabled but the buildx component is missing or broken" —
confirmed on the first real build attempt. Fixed by also copying
`/usr/local/libexec/docker/cli-plugins/docker-buildx` from the same `docker:27-cli` image.
## Per-container memory/CPU cgroup limits need the host to delegate controllers
`docker run --memory=...` fails outright ("error setting cgroup config... memory.max: no
such file or directory") on a host where `cgroup.subtree_control` isn't populated down to
where the container's cgroup lives. Confirmed on an unprivileged, OpenRC-based nested LXC
(Alpine, no systemd) with `nesting=1`: systemd normally does this delegation automatically
at boot; OpenRC doesn't, so `cgroup.subtree_control` stays empty at the LXC's own root and
no controller is ever available to nested Docker containers. `CONTAINER_MEMORY_MB`/
`CONTAINER_CPUS` can both be set to `0` to skip asking for a cap at all — on a host like this
the outer LXC's own memory limit (if Proxmox/systemd-nspawn/etc. sets one) is the only
enforced ceiling either way. Properly fixing the delegation means moving every process
already in the LXC's root cgroup into a leaf scope and then writing the wanted controllers
to the root's `cgroup.subtree_control` — not done here, since it's a live production LXC and
restructuring its cgroup tree wasn't something to risk without asking first.
## A global body parser breaks a reverse proxy mounted on the same app
`app.use(express.json())` with no path restriction runs for *every* request, including the
`/mcp/:slug` proxy route. By the time `http-proxy` tries to forward the request, body-parser
has already fully drained the incoming stream — so the proxied request reaches the backend
with the right `Content-Length` header but zero actual body bytes, and the backend just hangs
waiting for a body that will never arrive. Every proxied POST (i.e. every real MCP call)
hung with no response — found while testing the very first container through the proxy, not
during earlier plain curl-to-container testing (which never went through Express's full
middleware stack). Fixed by scoping the JSON parser to `/api` only (`app.use("/api",
express.json())`), since the proxy route needs the raw stream untouched.
## Nixpacks silently picks its own default runtime version ## Nixpacks silently picks its own default runtime version
Nixpacks does not fail when a repo's `engines`/version requirement isn't met — it silently falls back to its own default (e.g. picked Node 18 for a repo requiring Node >=20 in `Social-MCP`). The build goes green, then the container crashes at runtime (`crypto is not defined`, missing globals, etc.). Nixpacks does not fail when a repo's `engines`/version requirement isn't met — it silently falls back to its own default (e.g. picked Node 18 for a repo requiring Node >=20 in `Social-MCP`). The build goes green, then the container crashes at runtime (`crypto is not defined`, missing globals, etc.).
**Avoid it:** don't trust a green build as proof the runtime is correct. The manager now detects a Node version hint from the repo itself (`.nvmrc`, else `package.json` `engines.node`) and passes it to Nixpacks as `NIXPACKS_NODE_VERSION` (`apps/server/src/build/nixpacks.ts`, `detectNodeVersion`), so the build uses the version the repo actually declares instead of Nixpacks' own default. Repos with no version hint at all still get Nixpacks' default, unchanged. **Avoid it:** don't trust a green build as proof the runtime is correct. The manager now detects a Node version hint from the repo itself (`.nvmrc`, else `package.json` `engines.node`) and passes it to Nixpacks (`apps/server/src/build/nixpacks.ts`, `detectNodeVersion`), so the build uses the version the repo actually declares instead of Nixpacks' own default. A Node repo (has `package.json`) with no hint of its own gets `DEFAULT_NODE_VERSION` (currently `20`) instead of falling through to Nixpacks' own default — confirmed necessary against a real repo (`ZenvairoSocial-MCP`, no `.nvmrc`/`engines.node` at all): build green, container crashed with "crypto is not defined" because Nixpacks picked Node 18, which predates stable `globalThis.crypto`.
**Gotcha inside the gotcha:** `nixpacks` does **not** read `NIXPACKS_*` config from the
process environment at all — only from its own `-e`/`--env` CLI flag. Setting
`NIXPACKS_NODE_VERSION` as a plain env var on the spawned process is silently ignored
(`nixpacks plan` still showed `nodejs_18`); the fix above only actually took effect once
passed as `nixpacks build ... -e NIXPACKS_NODE_VERSION=20`. Caught because the very first
container built after adding the "fix" still crashed the same way.
## Nixpacks needs a dependency manifest, and fails cleanly without one ## Nixpacks needs a dependency manifest, and fails cleanly without one

View File

@@ -58,9 +58,31 @@ installationsRouter.post("/", async (req, res) => {
}); });
installationsRouter.patch("/:id/config", (req, res) => { installationsRouter.patch("/:id/config", (req, res) => {
const { env, portEnvVar, hostEnvVar, httpPath, internalPort } = req.body ?? {}; const {
env,
portEnvVar,
hostEnvVar,
httpPath,
internalPort,
basicAuthUsername,
basicAuthPassword,
startCommand,
publishHostPort,
hostVolumes,
} = req.body ?? {};
try { try {
const installation = configureInstallation(req.params.id, { env, portEnvVar, hostEnvVar, httpPath, internalPort }); const installation = configureInstallation(req.params.id, {
env,
portEnvVar,
hostEnvVar,
httpPath,
internalPort,
basicAuthUsername,
basicAuthPassword,
startCommand,
publishHostPort,
hostVolumes,
});
res.json(toPublicJson(installation)); res.json(toPublicJson(installation));
} catch (err) { } catch (err) {
res.status(404).json({ error: (err as Error).message }); res.status(404).json({ error: (err as Error).message });

View File

@@ -28,14 +28,20 @@ describe("detectNodeVersion", () => {
expect(detectNodeVersion(dir)).toBe("20"); expect(detectNodeVersion(dir)).toBe("20");
}); });
it("returns undefined when there is no version hint", () => { it("returns undefined for a non-Node repo (no package.json at all)", () => {
const dir = mkTmpRepo(); const dir = mkTmpRepo();
expect(detectNodeVersion(dir)).toBeUndefined(); expect(detectNodeVersion(dir)).toBeUndefined();
}); });
it("returns undefined for malformed package.json instead of throwing", () => { it("falls back to the default version for a Node repo with no version hint", () => {
const dir = mkTmpRepo();
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "x" }));
expect(detectNodeVersion(dir)).toBe("20");
});
it("falls back to the default version for malformed package.json instead of throwing", () => {
const dir = mkTmpRepo(); const dir = mkTmpRepo();
fs.writeFileSync(path.join(dir, "package.json"), "{not valid json"); fs.writeFileSync(path.join(dir, "package.json"), "{not valid json");
expect(detectNodeVersion(dir)).toBeUndefined(); expect(detectNodeVersion(dir)).toBe("20");
}); });
}); });

View File

@@ -17,11 +17,17 @@ function parseMajorVersion(raw: string): string | undefined {
return match ? match[1] : undefined; return match ? match[1] : undefined;
} }
// Nixpacks' own current default (18) predates stable globalThis.crypto/fetch — used as the
// fallback for repos with a package.json but no version hint at all, confirmed in production
// against ZenvairoSocial-MCP: build succeeded, container crashed on "crypto is not defined".
const DEFAULT_NODE_VERSION = "20";
/** /**
* Nixpacks doesn't fail when a repo's declared runtime requirement isn't met — it silently * Nixpacks doesn't fail when a repo's declared runtime requirement isn't met — it silently
* falls back to its own default version (seen in practice: Node 18 picked for a repo requiring * falls back to its own default version (seen in practice: Node 18 picked for a repo requiring
* >=20, build green, container crashed at runtime on a missing global). Pinning * >=20, build green, container crashed at runtime on a missing global). Pinning
* NIXPACKS_NODE_VERSION from the repo's own .nvmrc/package.json closes that gap for Node repos. * NIXPACKS_NODE_VERSION from the repo's own .nvmrc/package.json closes that gap for Node repos;
* repos with no hint of their own still get DEFAULT_NODE_VERSION rather than nixpacks' default.
*/ */
export function detectNodeVersion(repoDir: string): string | undefined { export function detectNodeVersion(repoDir: string): string | undefined {
const nvmrcPath = path.join(repoDir, ".nvmrc"); const nvmrcPath = path.join(repoDir, ".nvmrc");
@@ -31,7 +37,8 @@ export function detectNodeVersion(repoDir: string): string | undefined {
} }
const pkgPath = path.join(repoDir, "package.json"); const pkgPath = path.join(repoDir, "package.json");
if (fs.existsSync(pkgPath)) { if (!fs.existsSync(pkgPath)) return undefined;
try { try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as { engines?: { node?: string } }; const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as { engines?: { node?: string } };
const engineNode = pkg.engines?.node; const engineNode = pkg.engines?.node;
@@ -40,30 +47,43 @@ export function detectNodeVersion(repoDir: string): string | undefined {
if (version) return version; if (version) return version;
} }
} catch { } catch {
// malformed package.json — nothing to pin, let nixpacks use its default // malformed package.json — fall through to the default below
}
} }
return undefined; // It's a Node project (has a package.json) but declares no version of its own.
return DEFAULT_NODE_VERSION;
} }
export function runNixpacksBuild(slug: string, repoDir: string, imageTag: string): Promise<BuildResult> { export function runNixpacksBuild(
slug: string,
repoDir: string,
imageTag: string,
startCommand?: string | null,
): Promise<BuildResult> {
return new Promise((resolve) => { return new Promise((resolve) => {
const buildId = Date.now().toString(); const buildId = Date.now().toString();
const logPath = buildLogPath(slug, buildId); const logPath = buildLogPath(slug, buildId);
fs.mkdirSync(path.dirname(logPath), { recursive: true }); fs.mkdirSync(path.dirname(logPath), { recursive: true });
const logStream = fs.createWriteStream(logPath, { flags: "a" }); const logStream = fs.createWriteStream(logPath, { flags: "a" });
const env = { ...process.env }; const args = ["build", repoDir, "--name", imageTag];
if (startCommand) {
// Lets an installation override the start command without needing a nixpacks.toml in
// the source repo — necessary for third-party repos we don't control (e.g. installing
// gitea.com/gitea/gitea-mcp as-is, which nixpacks builds fine but whose detected start
// command needs CLI flags like `-t http -p <port>` appended).
args.push("--start-cmd", startCommand);
}
const nodeVersion = detectNodeVersion(repoDir); const nodeVersion = detectNodeVersion(repoDir);
if (nodeVersion) { if (nodeVersion) {
env.NIXPACKS_NODE_VERSION = nodeVersion; // nixpacks does NOT read NIXPACKS_* config from the process environment — only from
logStream.write( // its own `-e`/`--env` CLI flag (confirmed: setting it as a plain env var is silently
`[remote-mcp-manager] pinning NIXPACKS_NODE_VERSION=${nodeVersion} (detected from .nvmrc/package.json engines.node)\n`, // ignored, `nixpacks plan` still showed nodejs_18 for a repo pinned to 20 that way).
); args.push("-e", `NIXPACKS_NODE_VERSION=${nodeVersion}`);
logStream.write(`[remote-mcp-manager] pinning NIXPACKS_NODE_VERSION=${nodeVersion}\n`);
} }
const child = spawn("nixpacks", ["build", repoDir, "--name", imageTag], { env }); const child = spawn("nixpacks", args);
child.stdout.pipe(logStream, { end: false }); child.stdout.pipe(logStream, { end: false });
child.stderr.pipe(logStream, { end: false }); child.stderr.pipe(logStream, { end: false });

View File

@@ -30,3 +30,19 @@ CREATE TABLE IF NOT EXISTS installations (
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
`); `);
// Added after the initial release — ALTER TABLE has no "ADD COLUMN IF NOT EXISTS" in SQLite,
// so just ignore the "duplicate column" error on a DB that already has it.
for (const ddl of [
"ALTER TABLE installations ADD COLUMN basic_auth_username TEXT",
"ALTER TABLE installations ADD COLUMN basic_auth_password_encrypted TEXT",
"ALTER TABLE installations ADD COLUMN start_command TEXT",
"ALTER TABLE installations ADD COLUMN publish_host_port INTEGER",
"ALTER TABLE installations ADD COLUMN host_volumes_json TEXT",
]) {
try {
db.exec(ddl);
} catch (err) {
if (!(err as Error).message.includes("duplicate column")) throw err;
}
}

View File

@@ -12,6 +12,11 @@ export type InstallationStatus =
| "stopped" | "stopped"
| "error"; | "error";
export interface HostVolume {
hostPath: string;
containerPath: string;
}
export interface Installation { export interface Installation {
id: string; id: string;
slug: string; slug: string;
@@ -29,6 +34,11 @@ export interface Installation {
hostEnvVar: string; hostEnvVar: string;
env: Record<string, string>; env: Record<string, string>;
bearerToken: string | null; bearerToken: string | null;
basicAuthUsername: string | null;
basicAuthPassword: string | null;
startCommand: string | null;
publishHostPort: number | null;
hostVolumes: HostVolume[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -50,6 +60,11 @@ interface Row {
host_env_var: string; host_env_var: string;
bearer_token_encrypted: string | null; bearer_token_encrypted: string | null;
env_json_encrypted: string | null; env_json_encrypted: string | null;
basic_auth_username: string | null;
basic_auth_password_encrypted: string | null;
start_command: string | null;
publish_host_port: number | null;
host_volumes_json: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -72,6 +87,11 @@ function rowToInstallation(row: Row): Installation {
hostEnvVar: row.host_env_var, hostEnvVar: row.host_env_var,
env: row.env_json_encrypted ? JSON.parse(decryptSecret(row.env_json_encrypted)) : {}, env: row.env_json_encrypted ? JSON.parse(decryptSecret(row.env_json_encrypted)) : {},
bearerToken: row.bearer_token_encrypted ? decryptSecret(row.bearer_token_encrypted) : null, bearerToken: row.bearer_token_encrypted ? decryptSecret(row.bearer_token_encrypted) : null,
basicAuthUsername: row.basic_auth_username,
basicAuthPassword: row.basic_auth_password_encrypted ? decryptSecret(row.basic_auth_password_encrypted) : null,
startCommand: row.start_command,
publishHostPort: row.publish_host_port,
hostVolumes: row.host_volumes_json ? JSON.parse(row.host_volumes_json) : [],
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };
@@ -132,6 +152,11 @@ export interface InstallationUpdate {
hostEnvVar?: string; hostEnvVar?: string;
env?: Record<string, string>; env?: Record<string, string>;
bearerToken?: string | null; bearerToken?: string | null;
basicAuthUsername?: string | null;
basicAuthPassword?: string | null;
startCommand?: string | null;
publishHostPort?: number | null;
hostVolumes?: HostVolume[];
gitRef?: string | null; gitRef?: string | null;
} }
@@ -150,6 +175,13 @@ export function updateInstallation(id: string, patch: InstallationUpdate): Insta
hostEnvVar: patch.hostEnvVar ?? current.hostEnvVar, hostEnvVar: patch.hostEnvVar ?? current.hostEnvVar,
env: patch.env ?? current.env, env: patch.env ?? current.env,
bearerToken: patch.bearerToken !== undefined ? patch.bearerToken : current.bearerToken, bearerToken: patch.bearerToken !== undefined ? patch.bearerToken : current.bearerToken,
basicAuthUsername:
patch.basicAuthUsername !== undefined ? patch.basicAuthUsername : current.basicAuthUsername,
basicAuthPassword:
patch.basicAuthPassword !== undefined ? patch.basicAuthPassword : current.basicAuthPassword,
startCommand: patch.startCommand !== undefined ? patch.startCommand : current.startCommand,
publishHostPort: patch.publishHostPort !== undefined ? patch.publishHostPort : current.publishHostPort,
hostVolumes: patch.hostVolumes !== undefined ? patch.hostVolumes : current.hostVolumes,
gitRef: patch.gitRef !== undefined ? patch.gitRef : current.gitRef, gitRef: patch.gitRef !== undefined ? patch.gitRef : current.gitRef,
}; };
@@ -165,6 +197,11 @@ export function updateInstallation(id: string, patch: InstallationUpdate): Insta
host_env_var = @hostEnvVar, host_env_var = @hostEnvVar,
env_json_encrypted = @envJsonEncrypted, env_json_encrypted = @envJsonEncrypted,
bearer_token_encrypted = @bearerTokenEncrypted, bearer_token_encrypted = @bearerTokenEncrypted,
basic_auth_username = @basicAuthUsername,
basic_auth_password_encrypted = @basicAuthPasswordEncrypted,
start_command = @startCommand,
publish_host_port = @publishHostPort,
host_volumes_json = @hostVolumesJson,
git_ref = @gitRef, git_ref = @gitRef,
updated_at = @updatedAt updated_at = @updatedAt
WHERE id = @id`, WHERE id = @id`,
@@ -180,6 +217,11 @@ export function updateInstallation(id: string, patch: InstallationUpdate): Insta
hostEnvVar: next.hostEnvVar, hostEnvVar: next.hostEnvVar,
envJsonEncrypted: encryptSecret(JSON.stringify(next.env)), envJsonEncrypted: encryptSecret(JSON.stringify(next.env)),
bearerTokenEncrypted: next.bearerToken ? encryptSecret(next.bearerToken) : null, bearerTokenEncrypted: next.bearerToken ? encryptSecret(next.bearerToken) : null,
basicAuthUsername: next.basicAuthUsername,
basicAuthPasswordEncrypted: next.basicAuthPassword ? encryptSecret(next.basicAuthPassword) : null,
startCommand: next.startCommand,
publishHostPort: next.publishHostPort,
hostVolumesJson: JSON.stringify(next.hostVolumes),
gitRef: next.gitRef, gitRef: next.gitRef,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); });

View File

@@ -32,15 +32,34 @@ export async function runInstallationContainer(installation: Installation): Prom
[installation.hostEnvVar]: "0.0.0.0", [installation.hostEnvVar]: "0.0.0.0",
}; };
// Installations are unreachable from outside mcp-net by design — every MCP client goes
// through the manager's own reverse proxy. publishHostPort is the one deliberate exception:
// it publishes the container's port to the host's loopback, for a native process on the same
// host (e.g. a legacy nginx location) that needs to reach this container directly.
const portBinding = installation.publishHostPort
? { [`${installation.internalPort}/tcp`]: [{ HostIp: "127.0.0.1", HostPort: String(installation.publishHostPort) }] }
: undefined;
// Bind-mounts a host directory into the container — for state that must survive rebuilds
// and that nothing else manages (e.g. a browser profile with an existing logged-in session).
const binds = installation.hostVolumes.map((v) => `${v.hostPath}:${v.containerPath}`);
const container = await docker.createContainer({ const container = await docker.createContainer({
name: installation.containerName, name: installation.containerName,
Image: installation.imageTag, Image: installation.imageTag,
Env: Object.entries(env).map(([k, v]) => `${k}=${v}`), Env: Object.entries(env).map(([k, v]) => `${k}=${v}`),
...(portBinding ? { ExposedPorts: { [`${installation.internalPort}/tcp`]: {} } } : {}),
HostConfig: { HostConfig: {
RestartPolicy: { Name: "unless-stopped" }, RestartPolicy: { Name: "unless-stopped" },
NetworkMode: config.mcpNetworkName, NetworkMode: config.mcpNetworkName,
Memory: config.containerMemoryMb * 1024 * 1024, // 0 disables the cap: some hosts (e.g. an unprivileged, OpenRC-based nested LXC with no
NanoCpus: Math.round(config.containerCpus * 1e9), // systemd to delegate cgroup controllers) can't enforce per-container memory/CPU limits
// at all — `docker run` there fails outright trying to write memory.max. Confirmed on a
// real deploy; the outer host's own cap (if any) is the only ceiling in that case.
...(config.containerMemoryMb > 0 ? { Memory: config.containerMemoryMb * 1024 * 1024 } : {}),
...(config.containerCpus > 0 ? { NanoCpus: Math.round(config.containerCpus * 1e9) } : {}),
...(portBinding ? { PortBindings: portBinding } : {}),
...(binds.length > 0 ? { Binds: binds } : {}),
}, },
}); });

View File

@@ -14,7 +14,6 @@ if (!config.sessionSecret) {
const app = express(); const app = express();
app.set("trust proxy", 1); app.set("trust proxy", 1);
app.use(express.json());
app.use( app.use(
session({ session({
secret: config.sessionSecret, secret: config.sessionSecret,
@@ -22,7 +21,12 @@ app.use(
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", // "auto": secure only if the request actually arrived over HTTPS (checks req.secure,
// which respects "trust proxy" + X-Forwarded-Proto). A flat `NODE_ENV === "production"`
// check meant the cookie was never stored at all when the admin panel is reached over
// plain HTTP (e.g. direct to the container, or behind a proxy that doesn't terminate TLS
// for it) — login would silently "succeed" but no session would ever persist.
secure: "auto",
maxAge: 1000 * 60 * 60 * 12, maxAge: 1000 * 60 * 60 * 12,
}, },
}), }),
@@ -33,6 +37,11 @@ app.get("/health", (_req, res) => res.json({ ok: true }));
// Bearer-token-gated MCP reverse proxy, public (auth handled per-installation). // Bearer-token-gated MCP reverse proxy, public (auth handled per-installation).
app.all("/mcp/:slug", handleMcpProxyRequest); app.all("/mcp/:slug", handleMcpProxyRequest);
// Scoped to /api only: a global express.json() would consume the request body of every
// request — including /mcp/:slug — before http-proxy gets a chance to forward it, leaving
// the proxied backend waiting forever for a body that already got drained. Confirmed on a
// real deploy: every proxied POST (i.e. every real MCP call) hung with zero bytes received.
app.use("/api", express.json());
app.use("/api/auth", authRouter); app.use("/api/auth", authRouter);
app.use("/api/installations", requireAuth, installationsRouter); app.use("/api/installations", requireAuth, installationsRouter);

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { isAuthorized } from "./mcpProxy.js";
import type { Installation } from "../db/installations.js";
function makeInstallation(overrides: Partial<Installation> = {}): Installation {
return {
id: "1",
slug: "test",
name: "Test",
repoUrl: "https://example.com/repo.git",
gitRef: null,
status: "running",
statusDetail: null,
imageTag: "mcpmgr/test:latest",
containerId: null,
containerName: "mcp-test",
internalPort: 8080,
httpPath: "/mcp",
portEnvVar: "PORT",
hostEnvVar: "HOST",
env: {},
bearerToken: "secret-token",
basicAuthUsername: null,
basicAuthPassword: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
describe("isAuthorized", () => {
it("accepts a matching bearer token", () => {
expect(isAuthorized(makeInstallation(), "Bearer secret-token")).toBe(true);
});
it("rejects a wrong bearer token", () => {
expect(isAuthorized(makeInstallation(), "Bearer wrong")).toBe(false);
});
it("rejects basic auth when no credentials are configured", () => {
const creds = Buffer.from("user:pass").toString("base64");
expect(isAuthorized(makeInstallation(), `Basic ${creds}`)).toBe(false);
});
it("accepts matching basic auth when credentials are configured", () => {
const installation = makeInstallation({ basicAuthUsername: "user", basicAuthPassword: "pass" });
const creds = Buffer.from("user:pass").toString("base64");
expect(isAuthorized(installation, `Basic ${creds}`)).toBe(true);
});
it("rejects wrong basic auth credentials", () => {
const installation = makeInstallation({ basicAuthUsername: "user", basicAuthPassword: "pass" });
const creds = Buffer.from("user:wrong").toString("base64");
expect(isAuthorized(installation, `Basic ${creds}`)).toBe(false);
});
it("rejects when no Authorization header is present", () => {
expect(isAuthorized(makeInstallation(), "")).toBe(false);
});
});

View File

@@ -1,7 +1,7 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import httpProxy from "http-proxy"; import httpProxy from "http-proxy";
import { getInstallationBySlug } from "../db/installations.js"; import { getInstallationBySlug, type Installation } from "../db/installations.js";
import { getContainerIp } from "../docker/containers.js"; import { getContainerIp } from "../docker/containers.js";
const proxy = httpProxy.createProxyServer(); const proxy = httpProxy.createProxyServer();
@@ -11,13 +11,33 @@ proxy.on("error", (err, _req, res) => {
} }
}); });
function tokensMatch(expected: string, provided: string): boolean { function stringsMatch(expected: string, provided: string): boolean {
const a = Buffer.from(expected); const a = Buffer.from(expected);
const b = Buffer.from(provided); const b = Buffer.from(provided);
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b); return crypto.timingSafeEqual(a, b);
} }
export function isAuthorized(installation: Installation, authHeader: string): boolean {
if (authHeader.startsWith("Bearer ")) {
const provided = authHeader.slice(7);
return Boolean(installation.bearerToken) && stringsMatch(installation.bearerToken!, provided);
}
// Optional, per-installation: lets a client still using Basic Auth against the old nginx
// setup keep its credentials instead of switching to a bearer token right away.
if (authHeader.startsWith("Basic ") && installation.basicAuthUsername && installation.basicAuthPassword) {
const decoded = Buffer.from(authHeader.slice(6), "base64").toString("utf8");
const separatorIndex = decoded.indexOf(":");
if (separatorIndex === -1) return false;
const user = decoded.slice(0, separatorIndex);
const pass = decoded.slice(separatorIndex + 1);
return stringsMatch(installation.basicAuthUsername, user) && stringsMatch(installation.basicAuthPassword, pass);
}
return false;
}
export async function handleMcpProxyRequest(req: Request, res: Response): Promise<void> { export async function handleMcpProxyRequest(req: Request, res: Response): Promise<void> {
const { slug } = req.params; const { slug } = req.params;
const installation = getInstallationBySlug(slug); const installation = getInstallationBySlug(slug);
@@ -26,9 +46,7 @@ export async function handleMcpProxyRequest(req: Request, res: Response): Promis
return; return;
} }
const authHeader = req.get("authorization") ?? ""; if (!isAuthorized(installation, req.get("authorization") ?? "")) {
const provided = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
if (!provided || !tokensMatch(installation.bearerToken, provided)) {
res.status(401).json({ error: "unauthorized" }); res.status(401).json({ error: "unauthorized" });
return; return;
} }

View File

@@ -1,5 +1,6 @@
import { import {
type Installation, type Installation,
type HostVolume,
createInstallation, createInstallation,
updateInstallation, updateInstallation,
getInstallationById, getInstallationById,
@@ -53,6 +54,11 @@ export function configureInstallation(
hostEnvVar?: string; hostEnvVar?: string;
httpPath?: string; httpPath?: string;
internalPort?: number; internalPort?: number;
basicAuthUsername?: string | null;
basicAuthPassword?: string | null;
startCommand?: string | null;
publishHostPort?: number | null;
hostVolumes?: HostVolume[];
}, },
): Installation { ): Installation {
return updateInstallation(id, patch); return updateInstallation(id, patch);
@@ -63,10 +69,25 @@ export async function buildInstallation(id: string): Promise<Installation> {
if (!installation) throw new Error("installation not found"); if (!installation) throw new Error("installation not found");
updateInstallation(id, { status: "building", statusDetail: null }); updateInstallation(id, { status: "building", statusDetail: null });
// The clone on disk is otherwise only ever made once, at draft creation — without this,
// every "rebuild" silently reused that same stale checkout forever, never picking up new
// commits pushed to the repo afterwards.
try {
await cloneOrUpdateRepo(installation.slug, installation.repoUrl, installation.gitRef);
} catch (err) {
return updateInstallation(id, { status: "error", statusDetail: `git pull failed: ${(err as Error).message}` });
}
const imageTag = `mcpmgr/${installation.slug}:latest`; const imageTag = `mcpmgr/${installation.slug}:latest`;
const previousImageId = installation.imageTag ? await getImageId(installation.imageTag) : undefined; const previousImageId = installation.imageTag ? await getImageId(installation.imageTag) : undefined;
const result = await runNixpacksBuild(installation.slug, repoDir(installation.slug), imageTag); const result = await runNixpacksBuild(
installation.slug,
repoDir(installation.slug),
imageTag,
installation.startCommand,
);
if (!result.success) { if (!result.success) {
return updateInstallation(id, { return updateInstallation(id, {

View File

@@ -2,7 +2,14 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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> <title>remote-mcp-manager</title>
</head> </head>
<body> <body>

View File

@@ -15,14 +15,14 @@ export function App() {
if (authenticated === null) return null; if (authenticated === null) return null;
if (!authenticated) return <Login onLoggedIn={() => setAuthenticated(true)} />; if (!authenticated) return <Login onLoggedIn={() => setAuthenticated(true)} />;
const onLoggedOut = () => setAuthenticated(false);
return ( return (
<BrowserRouter> <BrowserRouter>
<div style={{ maxWidth: 900, margin: "0 auto", padding: 16, fontFamily: "sans-serif" }}>
<Routes> <Routes>
<Route path="/" element={<InstallationList />} /> <Route path="/" element={<InstallationList onLoggedOut={onLoggedOut} />} />
<Route path="/installations/:id" element={<InstallationDetail />} /> <Route path="/installations/:id" element={<InstallationDetail onLoggedOut={onLoggedOut} />} />
</Routes> </Routes>
</div>
</BrowserRouter> </BrowserRouter>
); );
} }

View 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>
);
}

View 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>
);
}

View File

@@ -1,6 +1,7 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>

View File

@@ -29,37 +29,45 @@ export function InstallWizard({
} }
return ( return (
<div style={{ border: "1px solid #ccc", padding: 16, marginBottom: 16 }}> <div className="modal-overlay" onMouseDown={(e) => e.target === e.currentTarget && onCancel()}>
<h2>New installation</h2> <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>
<form onSubmit={submit}> <form onSubmit={submit}>
<label> <div className="field">
Name <label htmlFor="w-name">Name</label>
<input value={name} onChange={(e) => setName(e.target.value)} required style={{ display: "block", width: "100%" }} /> <input id="w-name" value={name} onChange={(e) => setName(e.target.value)} required autoFocus />
</label> </div>
<label> <div className="field">
Repo URL <label htmlFor="w-repo">Repository URL</label>
<input <input
id="w-repo"
className="input-mono"
value={repoUrl} value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)} onChange={(e) => setRepoUrl(e.target.value)}
placeholder="https://github.com/user/repo.git" placeholder="https://github.com/user/repo.git"
required required
style={{ display: "block", width: "100%" }}
/> />
</label> </div>
<label> <div className="field">
Git ref (optional) <label htmlFor="w-ref">Git ref (optional)</label>
<input value={gitRef} onChange={(e) => setGitRef(e.target.value)} style={{ display: "block", width: "100%" }} /> <input id="w-ref" className="input-mono" value={gitRef} onChange={(e) => setGitRef(e.target.value)} placeholder="main" />
</label> <span className="field-hint">Branch, tag, or commit. Defaults to the repository's default branch.</span>
<div style={{ marginTop: 8 }}> </div>
<button type="submit" disabled={loading}> {error && <div className="alert">{error}</div>}
<div className="actions-row">
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? "Cloning…" : "Clone & continue"} {loading ? "Cloning…" : "Clone & continue"}
</button>{" "} </button>
<button type="button" onClick={onCancel}> <button type="button" className="btn" onClick={onCancel}>
Cancel Cancel
</button> </button>
</div> </div>
{error && <p style={{ color: "crimson" }}>{error}</p>}
</form> </form>
</div> </div>
</div>
); );
} }

View File

@@ -1,8 +1,35 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useLocation, useNavigate, useParams } from "react-router-dom";
import { api, type Installation } from "../api/client"; 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 { id } = useParams<{ id: string }>();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -12,6 +39,7 @@ export function InstallationDetail() {
const [hostEnvVar, setHostEnvVar] = useState("HOST"); const [hostEnvVar, setHostEnvVar] = useState("HOST");
const [httpPath, setHttpPath] = useState("/mcp"); const [httpPath, setHttpPath] = useState("/mcp");
const [log, setLog] = useState(""); const [log, setLog] = useState("");
const [logLoaded, setLogLoaded] = useState(false);
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -45,23 +73,73 @@ 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 ( return (
<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>
<button onClick={() => navigate("/")}>← Back</button> <div className="detail-title-row">
<h1>{installation.name}</h1> <h2 style={{ fontSize: 20 }}>{installation.name}</h2>
<p> <StatusBadge status={installation.status} />
Status: <strong>{installation.status}</strong> </div>
{installation.statusDetail && <span style={{ color: "crimson" }}> — {installation.statusDetail}</span>} <div className="detail-sub">{installation.slug}</div>
</p> {installation.statusDetail && <p className="status-detail">{installation.statusDetail}</p>}
</div>
</div>
{error && <div className="alert">{error}</div>}
{installation.status === "running" && installation.bearerToken && ( {installation.status === "running" && installation.bearerToken && (
<div style={{ border: "1px solid #ccc", padding: 12, marginBottom: 16 }}> <div className="card">
<strong>Public URL:</strong> <code>{installation.publicUrl}</code> <div className="card-header">
<br /> <span className="card-title">Access</span>
<strong>Bearer token:</strong> <code>{installation.bearerToken}</code>{" "} <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 <button
type="button"
className="btn btn-sm"
disabled={busy !== null}
onClick={() => onClick={() =>
withBusy("regen", async () => { withBusy("regen", async () => {
await api.regenerateToken(installation.id); await api.regenerateToken(installation.id);
@@ -69,59 +147,70 @@ export function InstallationDetail() {
}) })
} }
> >
Regenerate token {busy === "regen" ? "Regenerating…" : "Regenerate"}
</button> </button>
</div> </div>
</dd>
</dl>
</div>
)} )}
<h2>Configuration</h2> <div className="card section-gap">
<table> <div className="card-header">
<tbody> <span className="card-title">Configuration</span>
<span className="card-hint">Environment variables and runtime wiring</span>
</div>
{envRows.length === 0 && <p className="env-empty">No environment variables configured.</p>}
{envRows.map(([k, v], idx) => ( {envRows.map(([k, v], idx) => (
<tr key={idx}> <div className="env-row" key={idx}>
<td>
<input <input
className="input-mono"
value={k} value={k}
placeholder="KEY"
onChange={(e) => { onChange={(e) => {
const next = [...envRows]; const next = [...envRows];
next[idx] = [e.target.value, v]; next[idx] = [e.target.value, v];
setEnvRows(next); setEnvRows(next);
}} }}
/> />
</td>
<td>
<input <input
className="input-mono"
value={v} value={v}
placeholder="value"
onChange={(e) => { onChange={(e) => {
const next = [...envRows]; const next = [...envRows];
next[idx] = [k, e.target.value]; next[idx] = [k, e.target.value];
setEnvRows(next); setEnvRows(next);
}} }}
/> />
</td> <button type="button" className="btn btn-sm btn-ghost" onClick={() => setEnvRows(envRows.filter((_, i) => i !== idx))}>
<td> Remove
<button onClick={() => setEnvRows(envRows.filter((_, i) => i !== idx))}>remove</button> </button>
</td> </div>
</tr>
))} ))}
</tbody> <button type="button" className="btn btn-sm" onClick={() => setEnvRows([...envRows, ["", ""]])}>
</table> Add variable
<button onClick={() => setEnvRows([...envRows, ["", ""]])}>Add env var</button> </button>
<div style={{ marginTop: 12 }}> <div className="row section-gap">
<label> <div className="field">
Port env var name <input value={portEnvVar} onChange={(e) => setPortEnvVar(e.target.value)} /> <label htmlFor="port-env">Port env var</label>
</label> <input id="port-env" className="input-mono" value={portEnvVar} onChange={(e) => setPortEnvVar(e.target.value)} />
<label style={{ marginLeft: 12 }}> </div>
Host env var name <input value={hostEnvVar} onChange={(e) => setHostEnvVar(e.target.value)} /> <div className="field">
</label> <label htmlFor="host-env">Host env var</label>
<label style={{ marginLeft: 12 }}> <input id="host-env" className="input-mono" value={hostEnvVar} onChange={(e) => setHostEnvVar(e.target.value)} />
HTTP path <input value={httpPath} onChange={(e) => setHttpPath(e.target.value)} /> </div>
</label> <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>
<div style={{ marginTop: 12 }}>
<button <button
type="button"
className="btn btn-primary"
disabled={busy !== null} disabled={busy !== null}
onClick={() => onClick={() =>
withBusy("config", async () => { withBusy("config", async () => {
@@ -131,12 +220,18 @@ export function InstallationDetail() {
}) })
} }
> >
Save configuration {busy === "config" ? "Saving…" : "Save configuration"}
</button> </button>
</div> </div>
<h2>Actions</h2> <div className="card section-gap">
<div className="card-header">
<span className="card-title">Actions</span>
</div>
<div className="actions-row">
<button <button
type="button"
className="btn"
disabled={busy !== null} disabled={busy !== null}
onClick={() => onClick={() =>
withBusy("build", async () => { withBusy("build", async () => {
@@ -146,9 +241,11 @@ export function InstallationDetail() {
} }
> >
{busy === "build" ? "Building…" : "Build"} {busy === "build" ? "Building…" : "Build"}
</button>{" "} </button>
<button <button
disabled={busy !== null || installation.status === "cloning" || installation.status === "building" || installation.status === "starting"} type="button"
className="btn"
disabled={busy !== null || deployLocked}
onClick={() => onClick={() =>
withBusy("deploy", async () => { withBusy("deploy", async () => {
await api.deploy(installation.id); await api.deploy(installation.id);
@@ -157,8 +254,10 @@ export function InstallationDetail() {
} }
> >
{busy === "deploy" ? "Deploying…" : "Deploy"} {busy === "deploy" ? "Deploying…" : "Deploy"}
</button>{" "} </button>
<button <button
type="button"
className="btn"
disabled={busy !== null} disabled={busy !== null}
onClick={() => onClick={() =>
withBusy("redeploy", async () => { withBusy("redeploy", async () => {
@@ -167,9 +266,11 @@ export function InstallationDetail() {
}) })
} }
> >
Redeploy (env only) {busy === "redeploy" ? "Redeploying…" : "Redeploy (env only)"}
</button>{" "} </button>
<button <button
type="button"
className="btn"
disabled={busy !== null} disabled={busy !== null}
onClick={() => onClick={() =>
withBusy("rebuild", async () => { withBusy("rebuild", async () => {
@@ -178,34 +279,34 @@ export function InstallationDetail() {
}) })
} }
> >
Rebuild & redeploy {busy === "rebuild" ? "Rebuilding…" : "Rebuild & redeploy"}
</button>{" "} </button>
<button <button
type="button"
className="btn btn-danger"
disabled={busy !== null} disabled={busy !== null}
onClick={() => onClick={() => {
if (!window.confirm(`Delete "${installation.name}"? This stops and removes its container.`)) return;
withBusy("delete", async () => { withBusy("delete", async () => {
await api.remove(installation.id); await api.remove(installation.id);
navigate("/"); navigate("/");
}) });
} }}
> >
Delete {busy === "delete" ? "Deleting…" : "Delete"}
</button> </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> </div>
</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 { 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 { api, type Installation } from "../api/client";
import { InstallWizard } from "./InstallWizard"; import { InstallWizard } from "./InstallWizard";
import { Shell } from "../components/Shell";
import { StatusBadge } from "../components/StatusBadge";
export function InstallationList() { export function InstallationList({ onLoggedOut }: { onLoggedOut: () => void }) {
const [installations, setInstallations] = useState<Installation[]>([]); const [installations, setInstallations] = useState<Installation[] | null>(null);
const [showWizard, setShowWizard] = useState(false); const [showWizard, setShowWizard] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -17,12 +19,15 @@ export function InstallationList() {
}, []); }, []);
return ( return (
<div> <Shell
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}> title="Installations"
<h1>Installations</h1> onLoggedOut={onLoggedOut}
<button onClick={() => setShowWizard((v) => !v)}>{showWizard ? "Close" : "New installation"}</button> actions={
</div> <button type="button" className="btn btn-primary" onClick={() => setShowWizard(true)}>
New installation
</button>
}
>
{showWizard && ( {showWizard && (
<InstallWizard <InstallWizard
onCancel={() => setShowWizard(false)} onCancel={() => setShowWizard(false)}
@@ -33,29 +38,39 @@ export function InstallationList() {
/> />
)} )}
<table style={{ width: "100%", borderCollapse: "collapse" }}> {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> <thead>
<tr> <tr>
<th align="left">Name</th> <th>Name</th>
<th align="left">Slug</th> <th>Status</th>
<th align="left">Status</th> <th>Slug</th>
<th align="left">Repo</th> <th>Repository</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{installations.map((i) => ( {installations.map((i) => (
<tr key={i.id} style={{ borderTop: "1px solid #eee" }}> <tr key={i.id} className="row-link" onClick={() => navigate(`/installations/${i.id}`)}>
<td className="cell-name">{i.name}</td>
<td> <td>
<Link to={`/installations/${i.id}`}>{i.name}</Link> <StatusBadge status={i.status} />
</td> </td>
<td>{i.slug}</td> <td className="cell-mono">{i.slug}</td>
<td>{i.status}</td> <td className="cell-dim">{i.repoUrl}</td>
<td>{i.repoUrl}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{installations.length === 0 && !showWizard && <p>No installations yet.</p>}
</div> </div>
)}
</Shell>
); );
} }

View File

@@ -21,22 +21,33 @@ export function Login({ onLoggedIn }: { onLoggedIn: () => void }) {
} }
return ( return (
<div style={{ maxWidth: 320, margin: "10vh auto" }}> <div className="login-screen">
<h1>remote-mcp-manager</h1> <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}> <form onSubmit={submit}>
<div className="field">
<label htmlFor="password">Admin password</label>
<input <input
id="password"
type="password" type="password"
placeholder="Admin password" placeholder="Enter password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
autoFocus autoFocus
style={{ width: "100%", padding: 8, marginBottom: 8 }}
/> />
<button type="submit" disabled={loading} style={{ width: "100%", padding: 8 }}> </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"} {loading ? "Signing in…" : "Sign in"}
</button> </button>
{error && <p style={{ color: "crimson" }}>{error}</p>}
</form> </form>
</div> </div>
</div>
); );
} }

803
apps/web/src/styles.css Normal file
View 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;
}
}

View File

@@ -18,6 +18,10 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./data:/repo/data - ./data:/repo/data
# Reuses the host's own SSH key/known_hosts so the manager can `git clone` private
# repos over ssh:// the same way the host itself already does. Read-only.
# SSH_DIR must be an absolute path (compose doesn't expand "~").
- ${SSH_DIR}:/root/.ssh:ro
networks: networks:
- mcp-net - mcp-net