Compare commits
20 Commits
5e8397bcd9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f11a23fb00 | |||
| 7b6e1b5855 | |||
| c918b32e72 | |||
| 1efba2461a | |||
| 86b365ac52 | |||
| fb59d422a2 | |||
| 021fa2220e | |||
| 9f88769922 | |||
| 8eaccfbc47 | |||
| 1019672981 | |||
| 698173c79d | |||
| 223b1ab8eb | |||
| 359d4369e8 | |||
| 306043e5b9 | |||
| 06d43f4ed8 | |||
| bd299187d6 | |||
| ccf6708309 | |||
| 4cf857fd2d | |||
| b4628ac255 | |||
| ccb7fcd842 |
12
.env.example
12
.env.example
@@ -24,3 +24,15 @@ SESSION_SECRET=change-me-too
|
|||||||
# Random string (min 32 chars) used to derive the AES-256-GCM key that encrypts
|
# Random string (min 32 chars) used to derive the AES-256-GCM key that encrypts
|
||||||
# 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). 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_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
|
||||||
|
|||||||
17
.gitea/workflows/ci.yml
Normal file
17
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run build --workspace apps/server
|
||||||
|
- run: npm test
|
||||||
17
Dockerfile
17
Dockerfile
@@ -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 ./
|
||||||
|
|||||||
74
LESSONS.md
74
LESSONS.md
@@ -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. Check the deployed container's actual runtime version against the repo's declared requirement, or pin it explicitly (`NIXPACKS_NODE_VERSION`, `.nvmrc`, etc.) — not yet automated in V1, tracked as a known gap.
|
**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
|
||||||
|
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -31,10 +31,27 @@ the manager itself must run as a container attached to `mcp-net` to reach the MC
|
|||||||
containers it manages — running it directly on the host only works for routes that
|
containers it manages — running it directly on the host only works for routes that
|
||||||
don't need to reach a sibling container (e.g. `/health`, `/api/auth`).
|
don't need to reach a sibling container (e.g. `/health`, `/api/auth`).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs the `apps/server` unit test suite (vitest): the secrets encryption round-trip, the
|
||||||
|
Nixpacks runtime-version detection, the admin-session middleware, and the installations
|
||||||
|
state machine against a temporary SQLite DB. `.gitea/workflows/ci.yml` runs the same build
|
||||||
|
and test steps on every push, provided this repo's Gitea instance has Actions enabled with
|
||||||
|
a runner.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
- Nixpacks needs a dependency manifest (`requirements.txt`/`pyproject.toml` for Python,
|
- Nixpacks needs a dependency manifest (`requirements.txt`/`pyproject.toml` for Python,
|
||||||
`package.json` for Node) in the installed repo. Repos without one will fail to build;
|
`package.json` for Node) in the installed repo. Repos without one will fail to build;
|
||||||
the full build log is always visible in the UI to diagnose this.
|
the full build log is always visible in the UI to diagnose this.
|
||||||
- No per-container resource limits, image garbage collection, or multi-user support yet.
|
- Every installed container gets the same memory/CPU caps (`CONTAINER_MEMORY_MB`,
|
||||||
|
`CONTAINER_CPUS`), no per-installation override yet.
|
||||||
|
- No multi-user support — single operator only.
|
||||||
- Builds run one at a time.
|
- Builds run one at a time.
|
||||||
|
|
||||||
|
See `LESSONS.md` for operational gotchas found while building and running this (Nixpacks
|
||||||
|
runtime-version mismatches, Podman networking, SELinux, etc.).
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "11.10.0",
|
"better-sqlite3": "11.10.0",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"@types/http-proxy": "1.17.16",
|
"@types/http-proxy": "1.17.16",
|
||||||
"@types/node": "24.9.1",
|
"@types/node": "24.9.1",
|
||||||
"tsx": "4.20.6",
|
"tsx": "4.20.6",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3",
|
||||||
|
"vitest": "^5.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
47
apps/server/src/build/nixpacks.test.ts
Normal file
47
apps/server/src/build/nixpacks.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { detectNodeVersion } from "./nixpacks.js";
|
||||||
|
|
||||||
|
function mkTmpRepo(): string {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), "nixpacks-test-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("detectNodeVersion", () => {
|
||||||
|
it("reads the major version from .nvmrc", () => {
|
||||||
|
const dir = mkTmpRepo();
|
||||||
|
fs.writeFileSync(path.join(dir, ".nvmrc"), "20.11.0\n");
|
||||||
|
expect(detectNodeVersion(dir)).toBe("20");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers .nvmrc over package.json when both are present", () => {
|
||||||
|
const dir = mkTmpRepo();
|
||||||
|
fs.writeFileSync(path.join(dir, ".nvmrc"), "18\n");
|
||||||
|
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ engines: { node: ">=22" } }));
|
||||||
|
expect(detectNodeVersion(dir)).toBe("18");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to package.json engines.node", () => {
|
||||||
|
const dir = mkTmpRepo();
|
||||||
|
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ engines: { node: ">=20.0.0" } }));
|
||||||
|
expect(detectNodeVersion(dir)).toBe("20");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for a non-Node repo (no package.json at all)", () => {
|
||||||
|
const dir = mkTmpRepo();
|
||||||
|
expect(detectNodeVersion(dir)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
fs.writeFileSync(path.join(dir, "package.json"), "{not valid json");
|
||||||
|
expect(detectNodeVersion(dir)).toBe("20");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,14 +12,78 @@ export interface BuildResult {
|
|||||||
logPath: string;
|
logPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runNixpacksBuild(slug: string, repoDir: string, imageTag: string): Promise<BuildResult> {
|
function parseMajorVersion(raw: string): string | undefined {
|
||||||
|
const match = raw.match(/(\d+)/);
|
||||||
|
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
|
||||||
|
* 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
|
||||||
|
* 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 {
|
||||||
|
const nvmrcPath = path.join(repoDir, ".nvmrc");
|
||||||
|
if (fs.existsSync(nvmrcPath)) {
|
||||||
|
const version = parseMajorVersion(fs.readFileSync(nvmrcPath, "utf8"));
|
||||||
|
if (version) return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkgPath = path.join(repoDir, "package.json");
|
||||||
|
if (!fs.existsSync(pkgPath)) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as { engines?: { node?: string } };
|
||||||
|
const engineNode = pkg.engines?.node;
|
||||||
|
if (typeof engineNode === "string") {
|
||||||
|
const version = parseMajorVersion(engineNode);
|
||||||
|
if (version) return version;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// malformed package.json — fall through to the default below
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
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 child = spawn("nixpacks", ["build", repoDir, "--name", imageTag]);
|
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);
|
||||||
|
if (nodeVersion) {
|
||||||
|
// nixpacks does NOT read NIXPACKS_* config from the process environment — only from
|
||||||
|
// its own `-e`/`--env` CLI flag (confirmed: setting it as a plain env var is silently
|
||||||
|
// 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", 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 });
|
||||||
|
|
||||||
|
|||||||
@@ -14,4 +14,7 @@ export const config = {
|
|||||||
publicBaseUrl: process.env.PUBLIC_BASE_URL ?? `http://localhost:${process.env.PORT ?? 3000}`,
|
publicBaseUrl: process.env.PUBLIC_BASE_URL ?? `http://localhost:${process.env.PORT ?? 3000}`,
|
||||||
adminPassword: process.env.ADMIN_PASSWORD,
|
adminPassword: process.env.ADMIN_PASSWORD,
|
||||||
sessionSecret: process.env.SESSION_SECRET,
|
sessionSecret: process.env.SESSION_SECRET,
|
||||||
|
// Per-container resource caps, applied to every installation (no per-installation override in V1).
|
||||||
|
containerMemoryMb: Number(process.env.CONTAINER_MEMORY_MB ?? 512),
|
||||||
|
containerCpus: Number(process.env.CONTAINER_CPUS ?? 1),
|
||||||
};
|
};
|
||||||
|
|||||||
25
apps/server/src/crypto/secrets.test.ts
Normal file
25
apps/server/src/crypto/secrets.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it, beforeAll } from "vitest";
|
||||||
|
import { encryptSecret, decryptSecret, generateBearerToken } from "./secrets.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.MASTER_KEY = "test-master-key-at-least-32-characters-long";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("secrets", () => {
|
||||||
|
it("round-trips an encrypted value", () => {
|
||||||
|
const encrypted = encryptSecret("hello world");
|
||||||
|
expect(encrypted).not.toContain("hello world");
|
||||||
|
expect(decryptSecret(encrypted)).toBe("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails to decrypt with a tampered ciphertext", () => {
|
||||||
|
const encrypted = encryptSecret("hello world");
|
||||||
|
const tampered = Buffer.from(encrypted, "base64");
|
||||||
|
tampered[tampered.length - 1] ^= 0xff;
|
||||||
|
expect(() => decryptSecret(tampered.toString("base64"))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates distinct bearer tokens", () => {
|
||||||
|
expect(generateBearerToken()).not.toBe(generateBearerToken());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
61
apps/server/src/db/installations.test.ts
Normal file
61
apps/server/src/db/installations.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
// db/index.ts opens the sqlite file as a side effect of being imported, using config
|
||||||
|
// derived from DATA_DIR/DB_PATH — so these must be set before the dynamic import below.
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcpmgr-db-test-"));
|
||||||
|
process.env.DATA_DIR = tmpDir;
|
||||||
|
process.env.DB_PATH = path.join(tmpDir, "db.sqlite");
|
||||||
|
process.env.MASTER_KEY = "test-master-key-at-least-32-characters-long";
|
||||||
|
|
||||||
|
const { createInstallation, updateInstallation, getInstallationById, slugify } = await import("./installations.js");
|
||||||
|
|
||||||
|
describe("slugify", () => {
|
||||||
|
it("normalizes names into a URL/container-safe slug", () => {
|
||||||
|
expect(slugify("My Cool MCP!")).toBe("my-cool-mcp");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("installations repository", () => {
|
||||||
|
it("creates a draft installation with a derived container name", () => {
|
||||||
|
const installation = createInstallation({
|
||||||
|
name: "Test MCP",
|
||||||
|
slug: "test-mcp",
|
||||||
|
repoUrl: "https://example.com/repo.git",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(installation.status).toBe("draft");
|
||||||
|
expect(installation.containerName).toBe("mcp-test-mcp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists status transitions", () => {
|
||||||
|
const installation = createInstallation({
|
||||||
|
name: "State MCP",
|
||||||
|
slug: "state-mcp",
|
||||||
|
repoUrl: "https://example.com/repo.git",
|
||||||
|
});
|
||||||
|
|
||||||
|
updateInstallation(installation.id, { status: "building" });
|
||||||
|
updateInstallation(installation.id, { status: "built", imageTag: "mcpmgr/state-mcp:latest" });
|
||||||
|
const final = updateInstallation(installation.id, { status: "running", bearerToken: "secret-token" });
|
||||||
|
|
||||||
|
expect(final.status).toBe("running");
|
||||||
|
expect(final.imageTag).toBe("mcpmgr/state-mcp:latest");
|
||||||
|
expect(getInstallationById(installation.id)?.status).toBe("running");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips encrypted env vars", () => {
|
||||||
|
const installation = createInstallation({
|
||||||
|
name: "Env MCP",
|
||||||
|
slug: "env-mcp",
|
||||||
|
repoUrl: "https://example.com/repo.git",
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = updateInstallation(installation.id, { env: { FOO: "bar", API_KEY: "shh" } });
|
||||||
|
|
||||||
|
expect(updated.env).toEqual({ FOO: "bar", API_KEY: "shh" });
|
||||||
|
expect(getInstallationById(installation.id)?.env).toEqual({ FOO: "bar", API_KEY: "shh" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,13 +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,
|
||||||
|
// 0 disables the cap: some hosts (e.g. an unprivileged, OpenRC-based nested LXC with no
|
||||||
|
// 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 } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,6 +67,24 @@ export async function runInstallationContainer(installation: Installation): Prom
|
|||||||
return container.id;
|
return container.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getImageId(imageTag: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const info = await docker.getImage(imageTag).inspect();
|
||||||
|
return info.Id;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort removal: leaves the image alone if it's still referenced (e.g. by a running container). */
|
||||||
|
export async function removeImageIfUnused(imageId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await docker.getImage(imageId).remove({ force: false });
|
||||||
|
} catch {
|
||||||
|
// still in use or already gone — nothing to do
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function stopInstallationContainer(containerName: string): Promise<void> {
|
export async function stopInstallationContainer(containerName: string): Promise<void> {
|
||||||
await removeContainerIfExists(containerName);
|
await removeContainerIfExists(containerName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
35
apps/server/src/middleware/requireAuth.test.ts
Normal file
35
apps/server/src/middleware/requireAuth.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { requireAuth } from "./requireAuth.js";
|
||||||
|
|
||||||
|
function mockRes() {
|
||||||
|
const res = {} as Response;
|
||||||
|
res.status = vi.fn().mockReturnValue(res);
|
||||||
|
res.json = vi.fn().mockReturnValue(res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("requireAuth", () => {
|
||||||
|
it("calls next when the session is authenticated", () => {
|
||||||
|
const req = { session: { authenticated: true } } as unknown as Request;
|
||||||
|
const res = mockRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects with 401 when the session is not authenticated", () => {
|
||||||
|
const req = { session: {} } as unknown as Request;
|
||||||
|
const res = mockRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: "authentication required" });
|
||||||
|
});
|
||||||
|
});
|
||||||
60
apps/server/src/proxy/mcpProxy.test.ts
Normal file
60
apps/server/src/proxy/mcpProxy.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type Installation,
|
type Installation,
|
||||||
|
type HostVolume,
|
||||||
createInstallation,
|
createInstallation,
|
||||||
updateInstallation,
|
updateInstallation,
|
||||||
getInstallationById,
|
getInstallationById,
|
||||||
@@ -8,7 +9,13 @@ import {
|
|||||||
} from "../db/installations.js";
|
} from "../db/installations.js";
|
||||||
import { cloneOrUpdateRepo, readEnvExampleKeys, repoDir } from "../build/git.js";
|
import { cloneOrUpdateRepo, readEnvExampleKeys, repoDir } from "../build/git.js";
|
||||||
import { runNixpacksBuild } from "../build/nixpacks.js";
|
import { runNixpacksBuild } from "../build/nixpacks.js";
|
||||||
import { runInstallationContainer, stopInstallationContainer, waitForContainerHealth } from "../docker/containers.js";
|
import {
|
||||||
|
runInstallationContainer,
|
||||||
|
stopInstallationContainer,
|
||||||
|
waitForContainerHealth,
|
||||||
|
getImageId,
|
||||||
|
removeImageIfUnused,
|
||||||
|
} from "../docker/containers.js";
|
||||||
import { generateBearerToken } from "../crypto/secrets.js";
|
import { generateBearerToken } from "../crypto/secrets.js";
|
||||||
|
|
||||||
export interface DraftResult {
|
export interface DraftResult {
|
||||||
@@ -47,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);
|
||||||
@@ -57,8 +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 result = await runNixpacksBuild(installation.slug, repoDir(installation.slug), imageTag);
|
const previousImageId = installation.imageTag ? await getImageId(installation.imageTag) : undefined;
|
||||||
|
|
||||||
|
const result = await runNixpacksBuild(
|
||||||
|
installation.slug,
|
||||||
|
repoDir(installation.slug),
|
||||||
|
imageTag,
|
||||||
|
installation.startCommand,
|
||||||
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return updateInstallation(id, {
|
return updateInstallation(id, {
|
||||||
@@ -67,6 +96,15 @@ export async function buildInstallation(id: string): Promise<Installation> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nixpacks moves the tag to the new image, leaving the previous one dangling — clean it up
|
||||||
|
// so rebuilds don't accumulate untagged images forever.
|
||||||
|
if (previousImageId) {
|
||||||
|
const newImageId = await getImageId(imageTag);
|
||||||
|
if (newImageId && newImageId !== previousImageId) {
|
||||||
|
await removeImageIfUnused(previousImageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return updateInstallation(id, { status: "built", statusDetail: null, imageTag });
|
return updateInstallation(id, { status: "built", statusDetail: null, imageTag });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,4 +136,9 @@ export async function removeInstallationContainer(id: string): Promise<void> {
|
|||||||
const installation = getInstallationById(id);
|
const installation = getInstallationById(id);
|
||||||
if (!installation) return;
|
if (!installation) return;
|
||||||
await stopInstallationContainer(installation.containerName);
|
await stopInstallationContainer(installation.containerName);
|
||||||
|
|
||||||
|
if (installation.imageTag) {
|
||||||
|
const imageId = await getImageId(installation.imageTag);
|
||||||
|
if (imageId) await removeImageIfUnused(imageId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,6 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
51
apps/web/src/components/Shell.tsx
Normal file
51
apps/web/src/components/Shell.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
|
||||||
|
export function Shell({
|
||||||
|
title,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
onLoggedOut,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
onLoggedOut: () => void;
|
||||||
|
}) {
|
||||||
|
async function logout() {
|
||||||
|
await api.logout();
|
||||||
|
onLoggedOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shell">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="brand">
|
||||||
|
<span className="brand-mark">mcp</span>
|
||||||
|
<div>
|
||||||
|
<div className="brand-name">remote-mcp-manager</div>
|
||||||
|
<div className="brand-sub">Server control room</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="nav">
|
||||||
|
<NavLink to="/" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`} end>
|
||||||
|
Installations
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={logout}>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div className="main">
|
||||||
|
<header className="topbar">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{actions && <div className="topbar-actions">{actions}</div>}
|
||||||
|
</header>
|
||||||
|
<div className="content">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/web/src/components/StatusBadge.tsx
Normal file
21
apps/web/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Installation } from "../api/client";
|
||||||
|
|
||||||
|
const LABELS: Record<Installation["status"], string> = {
|
||||||
|
draft: "Draft",
|
||||||
|
cloning: "Cloning",
|
||||||
|
building: "Building",
|
||||||
|
built: "Built",
|
||||||
|
starting: "Starting",
|
||||||
|
running: "Running",
|
||||||
|
stopped: "Stopped",
|
||||||
|
error: "Error",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: Installation["status"] }) {
|
||||||
|
return (
|
||||||
|
<span className={`badge badge-${status}`}>
|
||||||
|
<span className="badge-dot" />
|
||||||
|
{LABELS[status]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { StrictMode } from "react";
|
import { 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
803
apps/web/src/styles.css
Normal file
@@ -0,0 +1,803 @@
|
|||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
remote-mcp-manager — design tokens
|
||||||
|
A control room for MCP server installations: dense, legible, calm.
|
||||||
|
Base palette tuned for a dark "ops console" feel, with a light
|
||||||
|
counterpart derived from the same hues for prefers-color-scheme.
|
||||||
|
------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
/* surfaces */
|
||||||
|
--bg: #f5f6fb;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #eef0f8;
|
||||||
|
--surface-sunken: #e7e9f3;
|
||||||
|
--border: #dde1ee;
|
||||||
|
--border-strong: #c7cce0;
|
||||||
|
|
||||||
|
/* text */
|
||||||
|
--text: #161a2c;
|
||||||
|
--text-dim: #5b6180;
|
||||||
|
--text-faint: #8d93ad;
|
||||||
|
|
||||||
|
/* accent (signal teal — the one spot of color on an otherwise quiet UI) */
|
||||||
|
--accent: #0d9488;
|
||||||
|
--accent-strong: #0b7f74;
|
||||||
|
--accent-tint: rgba(13, 148, 136, 0.1);
|
||||||
|
--accent-contrast: #ffffff;
|
||||||
|
|
||||||
|
--danger: #dc2626;
|
||||||
|
--danger-tint: rgba(220, 38, 38, 0.1);
|
||||||
|
|
||||||
|
/* status hues — one per installation state */
|
||||||
|
--status-draft: #71778f;
|
||||||
|
--status-cloning: #2563eb;
|
||||||
|
--status-building: #b45309;
|
||||||
|
--status-built: #7c3aed;
|
||||||
|
--status-starting: #0e7490;
|
||||||
|
--status-running: #15803d;
|
||||||
|
--status-stopped: #52576e;
|
||||||
|
--status-error: #dc2626;
|
||||||
|
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--shadow: 0 1px 2px rgba(22, 26, 44, 0.06), 0 8px 24px -12px rgba(22, 26, 44, 0.12);
|
||||||
|
--font-ui: "Inter", -apple-system, "Segoe UI", system-ui, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", "SFMono-Regular", Consolas, Menlo, monospace;
|
||||||
|
|
||||||
|
padding-top: env(safe-area-inset-top, 0px);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--bg: #0a0e1a;
|
||||||
|
--surface: #111728;
|
||||||
|
--surface-2: #161d33;
|
||||||
|
--surface-sunken: #080b15;
|
||||||
|
--border: #262f4b;
|
||||||
|
--border-strong: #37416a;
|
||||||
|
|
||||||
|
--text: #e9ebf5;
|
||||||
|
--text-dim: #9aa0bf;
|
||||||
|
--text-faint: #6a7093;
|
||||||
|
|
||||||
|
--accent: #2dd4bf;
|
||||||
|
--accent-strong: #5eead4;
|
||||||
|
--accent-tint: rgba(45, 212, 191, 0.14);
|
||||||
|
--accent-contrast: #06201c;
|
||||||
|
|
||||||
|
--danger: #f87171;
|
||||||
|
--danger-tint: rgba(248, 113, 113, 0.14);
|
||||||
|
|
||||||
|
--status-draft: #9aa1c2;
|
||||||
|
--status-cloning: #60a5fa;
|
||||||
|
--status-building: #fbbf24;
|
||||||
|
--status-built: #c4b5fd;
|
||||||
|
--status-starting: #22d3ee;
|
||||||
|
--status-running: #4ade80;
|
||||||
|
--status-stopped: #9aa1c2;
|
||||||
|
--status-error: #f87171;
|
||||||
|
|
||||||
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 28px -14px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
pre,
|
||||||
|
kbd {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* {
|
||||||
|
animation-duration: 0.001ms !important;
|
||||||
|
transition-duration: 0.001ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------- app shell ---------------------------- */
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: var(--surface);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 20px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-sub {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
background: var(--accent-tint);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px 28px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 28px;
|
||||||
|
max-width: 1040px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------ buttons ------------------------------ */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 7px 13px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: background-color 0.12s, border-color 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-strong);
|
||||||
|
border-color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: var(--danger-tint);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 4px 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------ badges ------------------------------- */
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 9px 3px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-draft {
|
||||||
|
color: var(--status-draft);
|
||||||
|
background: color-mix(in srgb, var(--status-draft) 14%, transparent);
|
||||||
|
}
|
||||||
|
.badge-cloning {
|
||||||
|
color: var(--status-cloning);
|
||||||
|
background: color-mix(in srgb, var(--status-cloning) 14%, transparent);
|
||||||
|
}
|
||||||
|
.badge-building {
|
||||||
|
color: var(--status-building);
|
||||||
|
background: color-mix(in srgb, var(--status-building) 16%, transparent);
|
||||||
|
}
|
||||||
|
.badge-built {
|
||||||
|
color: var(--status-built);
|
||||||
|
background: color-mix(in srgb, var(--status-built) 14%, transparent);
|
||||||
|
}
|
||||||
|
.badge-starting {
|
||||||
|
color: var(--status-starting);
|
||||||
|
background: color-mix(in srgb, var(--status-starting) 14%, transparent);
|
||||||
|
}
|
||||||
|
.badge-running {
|
||||||
|
color: var(--status-running);
|
||||||
|
background: color-mix(in srgb, var(--status-running) 14%, transparent);
|
||||||
|
}
|
||||||
|
.badge-stopped {
|
||||||
|
color: var(--status-stopped);
|
||||||
|
background: color-mix(in srgb, var(--status-stopped) 14%, transparent);
|
||||||
|
}
|
||||||
|
.badge-error {
|
||||||
|
color: var(--status-error);
|
||||||
|
background: color-mix(in srgb, var(--status-error) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-draft .badge-dot { background: var(--status-draft); }
|
||||||
|
.badge-cloning .badge-dot { background: var(--status-cloning); }
|
||||||
|
.badge-building .badge-dot { background: var(--status-building); }
|
||||||
|
.badge-built .badge-dot { background: var(--status-built); }
|
||||||
|
.badge-starting .badge-dot { background: var(--status-starting); }
|
||||||
|
.badge-running .badge-dot { background: var(--status-running); }
|
||||||
|
.badge-stopped .badge-dot { background: var(--status-stopped); }
|
||||||
|
.badge-error .badge-dot { background: var(--status-error); }
|
||||||
|
|
||||||
|
.badge-building .badge-dot,
|
||||||
|
.badge-cloning .badge-dot,
|
||||||
|
.badge-starting .badge-dot {
|
||||||
|
animation: pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------- cards -------------------------------- */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card + .card {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hint {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------- table -------------------------------- */
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-transform: none;
|
||||||
|
padding: 11px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 13px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
transition: background-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr.row-link:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-dim {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------- forms -------------------------------- */
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
.input {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus-visible,
|
||||||
|
.input:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row > .field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------ env rows ------------------------------- */
|
||||||
|
|
||||||
|
.env-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-empty {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------- kv / code -------------------------------- */
|
||||||
|
|
||||||
|
.kv {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 140px 1fr;
|
||||||
|
gap: 10px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv dt {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-chip {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 5px 9px;
|
||||||
|
word-break: break-all;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------- log -------------------------------- */
|
||||||
|
|
||||||
|
.log-pane {
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 14px;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-pane:empty::before,
|
||||||
|
.log-pane.log-empty::before {
|
||||||
|
content: "No log output yet.";
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------- alerts ------------------------------- */
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 10px 13px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--danger-tint);
|
||||||
|
color: var(--danger);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------- modal ------------------------------- */
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: color-mix(in srgb, var(--surface-sunken) 72%, transparent);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------ page header ------------------------------ */
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sub {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-detail {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-gap {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 56px 24px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------ login ------------------------------ */
|
||||||
|
|
||||||
|
.login-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand .brand-mark {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-sub {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.nav {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.sidebar-footer {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
.kv {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.env-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,9 +13,15 @@ services:
|
|||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
SESSION_SECRET: ${SESSION_SECRET}
|
SESSION_SECRET: ${SESSION_SECRET}
|
||||||
MASTER_KEY: ${MASTER_KEY}
|
MASTER_KEY: ${MASTER_KEY}
|
||||||
|
CONTAINER_MEMORY_MB: ${CONTAINER_MEMORY_MB:-512}
|
||||||
|
CONTAINER_CPUS: ${CONTAINER_CPUS:-1}
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
1007
package-lock.json
generated
1007
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
|||||||
"apps/*"
|
"apps/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:server": "npm run dev --workspace apps/server"
|
"dev:server": "npm run dev --workspace apps/server",
|
||||||
|
"test": "npm run test --workspace apps/server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user