feat(mcpd+deploy): serve web UI at /ui + smoke tests + docs (Stage 6)
Some checks failed
CI/CD / lint (pull_request) Successful in 54s
CI/CD / test (pull_request) Failing after 1m8s
CI/CD / typecheck (pull_request) Successful in 2m35s
CI/CD / smoke (pull_request) Has been skipped
CI/CD / build (pull_request) Has been skipped
CI/CD / publish (pull_request) Has been skipped
Some checks failed
CI/CD / lint (pull_request) Successful in 54s
CI/CD / test (pull_request) Failing after 1m8s
CI/CD / typecheck (pull_request) Successful in 2m35s
CI/CD / smoke (pull_request) Has been skipped
CI/CD / build (pull_request) Has been skipped
CI/CD / publish (pull_request) Has been skipped
The closing stage. mcpd now hosts the Stage 5 SPA, the Docker image bundles the build artifact, a smoke test exercises the personality HTTP surface end-to-end, and the user-facing docs spell out the mental model. mcpd: - Add @fastify/static dep. - New routes/web-ui.ts: registers /ui/* against a static bundle. Looks for the bundle at $MCPD_WEB_ROOT, then /usr/share/mcpd/web (the Docker image path), then a dev-tree fallback. Logs and skips cleanly if missing — API-only deploys keep working. - SPA fallback: any /ui/<path> that doesn't match a file falls through to index.html so direct hits to react-router URLs work. - /ui/* falls through to `kind: skip` in mapUrlToPermission, so the static assets are served unauthenticated. Each API call from the SPA still carries the bearer token. Deploy: - Dockerfile.mcpd builds the @mcpctl/web bundle in the same builder stage and copies dist/ to /usr/share/mcpd/web in the runtime image. Smoke (personality.smoke.test.ts): - Live mcpd flow: create secret/llm/agent/personality, attach an agent-direct prompt, verify the binding listing, reject double- attach (409) + foreign-agent prompt (400), set defaultPersonality by name, detach + delete cleanup. Docs: - New docs/personalities.md: VLAN-on-ethernet model, system-block ordering table, three prompt scopes, CLI walkthrough, web UI walkthrough, full API surface, RBAC notes. - agents.md and chat.md cross-link. - README's Agents section gains a Personalities subsection. Test count after Stage 6: mcpd: 801/801 cli: 430/430 web: 7/7 db: 58/62 (4 pre-existing) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"@fastify/cors": "^10.0.0",
|
||||
"@fastify/helmet": "^12.0.0",
|
||||
"@fastify/rate-limit": "^10.0.0",
|
||||
"@fastify/static": "^8.0.0",
|
||||
"@kubernetes/client-node": "^1.4.0",
|
||||
"@mcpctl/db": "workspace:*",
|
||||
"@mcpctl/shared": "workspace:*",
|
||||
|
||||
@@ -47,6 +47,7 @@ import { PromptRequestRepository } from './repositories/prompt-request.repositor
|
||||
import { PersonalityRepository } from './repositories/personality.repository.js';
|
||||
import { PersonalityService } from './services/personality.service.js';
|
||||
import { registerPersonalityRoutes } from './routes/personalities.js';
|
||||
import { registerWebUi } from './routes/web-ui.js';
|
||||
import { bootstrapSystemProject } from './bootstrap/system-project.js';
|
||||
import {
|
||||
McpServerService,
|
||||
@@ -725,6 +726,11 @@ async function main(): Promise<void> {
|
||||
});
|
||||
});
|
||||
|
||||
// Web UI: served from /ui (static SPA bundle). Falls through to API
|
||||
// routes when the prefix doesn't match. Skipped silently if the bundle
|
||||
// isn't installed (dev tree without `pnpm --filter @mcpctl/web build`).
|
||||
await registerWebUi(app);
|
||||
|
||||
// Start
|
||||
await app.listen({ port: config.port, host: config.host });
|
||||
app.log.info(`mcpd listening on ${config.host}:${config.port}`);
|
||||
|
||||
74
src/mcpd/src/routes/web-ui.ts
Normal file
74
src/mcpd/src/routes/web-ui.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* /ui — serves the @mcpctl/web SPA bundle.
|
||||
*
|
||||
* In production the bundle lives at /usr/share/mcpd/web (installed by the
|
||||
* RPM in Stage 6); in dev it lives at <repo>/src/web/dist after a
|
||||
* `pnpm --filter @mcpctl/web build`. The location is overridable via the
|
||||
* `MCPD_WEB_ROOT` env var so deployers can move it freely.
|
||||
*
|
||||
* If the directory is missing we log a warning and skip — mcpd still serves
|
||||
* the API. That lets the dev tree run without forcing a web build first.
|
||||
*
|
||||
* SPA routing: anything under /ui/<path> that's not a file falls back to
|
||||
* index.html so client-side react-router routes work on direct hits.
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
|
||||
const DEFAULT_PROD_ROOT = '/usr/share/mcpd/web';
|
||||
|
||||
function resolveWebRoot(): string | null {
|
||||
const fromEnv = process.env['MCPD_WEB_ROOT'];
|
||||
if (fromEnv !== undefined && fromEnv !== '') {
|
||||
return existsSync(fromEnv) ? fromEnv : null;
|
||||
}
|
||||
if (existsSync(DEFAULT_PROD_ROOT)) return DEFAULT_PROD_ROOT;
|
||||
|
||||
// Dev fallback: walk up from this file to find <repo>/src/web/dist.
|
||||
// After bun compile this path doesn't resolve, which is fine — prod uses
|
||||
// DEFAULT_PROD_ROOT or MCPD_WEB_ROOT instead.
|
||||
try {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const candidate = path.resolve(here, '../../../web/dist');
|
||||
if (existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
// import.meta.url unavailable in some bundled envs — skip.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function registerWebUi(app: FastifyInstance): Promise<void> {
|
||||
const root = resolveWebRoot();
|
||||
if (root === null) {
|
||||
app.log.warn(
|
||||
`web UI bundle not found (set MCPD_WEB_ROOT, or place a build at ${DEFAULT_PROD_ROOT}); /ui will return 404`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!statSync(root).isDirectory()) {
|
||||
app.log.warn({ root }, 'web UI root is not a directory; /ui will return 404');
|
||||
return;
|
||||
}
|
||||
|
||||
await app.register(fastifyStatic, {
|
||||
root,
|
||||
prefix: '/ui/',
|
||||
wildcard: false,
|
||||
decorateReply: false,
|
||||
});
|
||||
|
||||
// SPA fallback — react-router URLs like /ui/agents/foo/personalities/bar
|
||||
// need index.html to bootstrap the app.
|
||||
app.get('/ui/*', (_request, reply) => {
|
||||
return reply.sendFile('index.html', root);
|
||||
});
|
||||
// Cover the bare /ui (no trailing slash) too.
|
||||
app.get('/ui', (_request, reply) => {
|
||||
return reply.redirect('/ui/');
|
||||
});
|
||||
|
||||
app.log.info({ root }, 'web UI mounted at /ui');
|
||||
}
|
||||
Reference in New Issue
Block a user