feat: add Mission Control operator console and workspace wiring

- New mission-control Next.js app: runbook catalog, GO execution, SSE stream, audit ZIP export

- Generated doc-manifest from docs runbooks; curated JSON specs; health-check script

- pnpm workspace package, root scripts, README updates

- Resilience: Windows-safe path checks, optional MISSION_CONTROL_PROJECT_ROOT fallback, system fonts

- Bump mcp-proxmox submodule to tracked main

Made-with: Cursor
This commit is contained in:
2026-03-28 14:50:11 +08:00
parent 4f85e0bf0e
commit 18767b7d8b
57 changed files with 9360 additions and 18621 deletions

View File

@@ -90,6 +90,14 @@ From the root directory, you can run:
- `pnpm frontend:build` - Build the ProxmoxVE frontend for production
- `pnpm frontend:start` - Start the production frontend server
### Mission Control (unified operator console)
- `pnpm mission-control:dev` - Next.js console on **http://localhost:3010** (launchpad + guided runbooks + live run trace + audit ZIP)
- `pnpm mission-control:build` / `pnpm mission-control:start` - Production build and server
- `pnpm mission-control:test` - Executor smoke test (real allowlisted child process)
See [mission-control/README.md](mission-control/README.md) and [mission-control/TIMELINE.md](mission-control/TIMELINE.md).
### Testing
- `pnpm test` - Run tests (if available)

View File

@@ -0,0 +1,4 @@
{
"extends": "next/core-web-vitals",
"root": true
}

6
mission-control/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.next
node_modules
.data
*.tsbuildinfo
coverage
playwright-report

71
mission-control/README.md Normal file
View File

@@ -0,0 +1,71 @@
# Mission Control (unified operator console)
Next.js application in this monorepo: **launchpad** links to existing UIs, **guided runbooks** collect inputs and execute **allowlisted** repo scripts with **live SSE trace**, **graded touchpoints**, **compliance assertions**, and a **downloadable ZIP audit pack** (manifest, events, logs, checksums).
## Run locally
From the **monorepo root**:
```bash
pnpm install
pnpm mission-control:dev
```
Open **http://localhost:3010** (Proxmox helper site can stay on 3000).
### Runbook catalog
- **Hand-written specs:** `mission-control/runbooks/specs/*.json` (short ids like `health-self-check`).
- **All documentation runbooks:** `mission-control/runbooks/doc-manifest.json` is generated from every `docs/**/**RUNBOOK**.md` (excluding master index files). Each entry runs **real** `scripts/...` or `explorer-monorepo/scripts/...` paths extracted from that markdown (up to 14 steps), with **Proxmox host**, **RPC override**, and **Practice mode** inputs.
Regenerate the doc manifest after editing runbook markdown:
```bash
pnpm --filter mission-control run generate:runbooks
```
`pnpm mission-control:build` runs **prebuild**`generate:runbooks` automatically.
### Environment
| Variable | Purpose |
|----------|---------|
| `MISSION_CONTROL_PROJECT_ROOT` | Optional absolute monorepo root. If set but the path does not exist, Mission Control logs a warning and auto-detects from cwd instead (avoids a hard 500). |
| `GIT_BASH_PATH` | Windows: full path to `bash.exe` if not under default Git paths. |
| `NEXT_PUBLIC_HELPER_SCRIPTS_URL` | Launchpad link for helper site (default `http://localhost:3000`). |
| `NEXT_PUBLIC_EXPLORER_URL` | Launchpad link for explorer (default `https://explorer.d-bis.org`). |
## Test
```bash
pnpm mission-control:test
```
Runs a real **health-self-check** (Node child process) against the allowlisted executor.
## Build / production
```bash
pnpm mission-control:build
pnpm mission-control:start
```
Use a **production process manager** (systemd, PM2, container) with `NODE_ENV=production`. The runner executes **only** scripts mapped in `src/lib/allowlist.ts`—no arbitrary shell from the UI.
## Security notes
- Treat this console as **privileged**: anyone who can POST `/api/runs` can trigger allowlisted automation on the host.
- Place **authentication / network restrictions** in front (reverse proxy, VPN, mTLS) for non-local use.
- Secrets in runbook forms: mark `sensitive: true` in JSON specs; values are redacted in `inputs.redacted.json` inside the audit bundle.
## Adding a runbook
**Option A — markdown in `docs/`:** Name the file with `RUNBOOK` in the filename. Reference scripts as `scripts/...` or `explorer-monorepo/scripts/...`. Run `pnpm --filter mission-control run generate:runbooks` and commit the updated `doc-manifest.json`.
**Option B — curated JSON:** Add `runbooks/specs/<id>.json` (see `src/lib/runbook-schema.ts`). Every spec must include an **`execution`** block with allowlisted script paths. Hand-written specs override doc-manifest entries if they share the same `id`.
Execution is allowlisted by path prefix only: **`scripts/`** and **`explorer-monorepo/scripts/`** (see `src/lib/execution-path-validator.ts`).
## Timeline
See [TIMELINE.md](./TIMELINE.md) for phased delivery and estimates.

View File

@@ -0,0 +1,17 @@
# Mission Control — delivery timeline
Estimates assume one engineer familiar with the monorepo. Parallel work (UI + runner hardening) can compress calendar time.
| Phase | Scope | Estimate | Status (this PR) |
|-------|--------|----------|-------------------|
| **P0** | Workspace package, routing, TARDIS-themed shell, launchpad links | 12 days | **Done** |
| **P1** | Runbook JSON schema, catalog UI, help tooltips, GO button, POST `/api/runs` | 23 days | **Done** |
| **P2** | Allowlisted executor (bash + node), job store, SSE stream, live panels | 34 days | **Done** |
| **P3** | Touchpoint grading, compliance assertions, audit ZIP + checksums | 23 days | **Done** |
| **P4** | Vitest smoke test, docs, env knobs for Windows/Git Bash | 1 day | **Done** |
| **P5** | AuthN/Z (OIDC/API key), rate limits, queue (Redis) for multi-instance | 12 weeks | *Future* |
| **P6** | Map remaining `docs/**` runbooks to specs + narrow allowlist expansion | Ongoing | *Future* |
**Total (P0P4):** roughly **913** engineering days for a production-capable v1 on a **trusted network**.
**Wall-clock if focused:** about **2 weeks** including review, hardening, and operator dry-runs on LAN.

6
mission-control/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
serverExternalPackages: ['archiver'],
};
export default nextConfig;

View File

@@ -0,0 +1,40 @@
{
"name": "mission-control",
"version": "1.0.0",
"private": true,
"description": "Unified console: launchpad, guided runbooks, live execution, compliance evidence, audit export",
"scripts": {
"generate:runbooks": "node ./scripts/generate-doc-runbook-manifest.mjs",
"prebuild": "pnpm run generate:runbooks",
"dev": "next dev -p 3010",
"build": "next build",
"start": "next start -p 3010",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"archiver": "^7.0.1",
"clsx": "^2.1.1",
"lucide-react": "^0.561.0",
"next": "15.5.8",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/archiver": "^7.0.0",
"@types/node": "^22.19.3",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.2",
"eslint-config-next": "15.5.8",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vitest": "^2.1.9"
}
}

View File

@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
{
"id": "health-self-check",
"title": "Mission Control pipeline check",
"summary": "Runs a tiny built-in command to prove the console can start processes and record results.",
"whyItMatters": "If this fails, the problem is the console or Node on this machine—not your network or Proxmox.",
"audienceHelp": "You do not need to know what Node is. Press the button; green means the control room is working.",
"docPath": "mission-control/README.md",
"prerequisites": ["You are on the machine where Mission Control is installed."],
"steps": [
{
"title": "What happens",
"plainText": "The system runs one safe line of code that prints a short success message. Nothing on your network is changed.",
"technicalNote": "Executes scripts/mission-control/health-check.mjs",
"example": "Output line: MISSION_CONTROL_HEALTH_OK"
}
],
"inputs": [],
"touchpoints": [
{
"id": "stdout_marker",
"label": "Success marker in output",
"description": "Proves stdout was captured.",
"passCondition": "stdout_contains",
"pattern": "MISSION_CONTROL_HEALTH_OK"
},
{
"id": "clean_exit",
"label": "Process exit",
"description": "Proves the child process ended without error.",
"passCondition": "exit_zero"
}
],
"complianceFramework": "DBIS-MC-INTERNAL-1",
"execution": {
"steps": [
{
"interpreter": "node",
"scriptRelative": "scripts/mission-control/health-check.mjs",
"args": []
}
]
}
}

View File

@@ -0,0 +1,42 @@
{
"id": "reconcile-env-canonical",
"title": "Print canonical Chain 138 environment lines",
"summary": "Emits the recommended contract address lines for smom-dbis-138/.env from the documentation source of truth.",
"whyItMatters": "Keeps deploy and tooling aligned with the same addresses your docs say are canonical—without opening large markdown files by hand.",
"audienceHelp": "You are not editing secrets here. The script only prints suggested lines; you copy them into your env file if your operator approves.",
"docPath": "scripts/verify/reconcile-env-canonical.sh",
"prerequisites": ["Bash available.", "docs/11-references/CONTRACT_ADDRESSES_REFERENCE.md exists in the repo."],
"steps": [
{
"title": "Review output",
"plainText": "The console will show lines like COMPLIANT_USDT=0x… Compare them to your smom-dbis-138/.env with your team lead.",
"technicalNote": "Runs reconcile-env-canonical.sh --print"
}
],
"inputs": [],
"touchpoints": [
{
"id": "canonical_marker",
"label": "Canonical lines emitted",
"description": "Output includes known canonical variable names.",
"passCondition": "stdout_contains",
"pattern": "COMPLIANCE_REGISTRY="
},
{
"id": "exit_ok",
"label": "Script exit",
"description": "Script finished successfully.",
"passCondition": "exit_zero"
}
],
"complianceFramework": "DBIS-MC-INTERNAL-1",
"execution": {
"steps": [
{
"interpreter": "bash",
"scriptRelative": "scripts/verify/reconcile-env-canonical.sh",
"args": ["--print"]
}
]
}
}

View File

@@ -0,0 +1,59 @@
{
"id": "run-completable-anywhere",
"title": "Run “completable from anywhere” validation suite",
"summary": "Runs config validation, optional on-chain checks, full validation (genesis skipped), and env reconciliation printout.",
"whyItMatters": "This is the same high-level health pass documented for machines that are not on the operator LAN.",
"audienceHelp": "Start with Practice mode. A full run can take several minutes and may try to reach Chain 138 RPC if your network allows it.",
"docPath": "scripts/run-completable-tasks-from-anywhere.sh",
"prerequisites": ["Bash available.", "Network access optional for some steps."],
"steps": [
{
"title": "Practice mode",
"plainText": "Lists the four steps without executing them.",
"example": "You should see “Completable from anywhere (--dry-run”"
},
{
"title": "Full run",
"plainText": "Executes all four steps. Some steps tolerate RPC failure; read the live log if anything is yellow or red.",
"technicalNote": "See MASTER_INDEX.md “completable from anywhere”"
}
],
"inputs": [
{
"name": "dryRun",
"label": "Practice mode (dry run)",
"type": "boolean",
"help": "Safe preview of what would run.",
"default": true
}
],
"touchpoints": [
{
"id": "done_banner",
"label": "Completion signal",
"description": "Detects section headers printed in both dry-run and full execution.",
"passCondition": "stdout_contains",
"pattern": "==="
},
{
"id": "exit_ok",
"label": "Exit code",
"description": "Process exited zero.",
"passCondition": "exit_zero"
}
],
"complianceFramework": "DBIS-MC-INTERNAL-1",
"execution": {
"steps": [
{
"interpreter": "bash",
"scriptRelative": "scripts/run-completable-tasks-from-anywhere.sh",
"args": [],
"supportsDryRun": true,
"whenInputTrue": {
"dryRun": ["--dry-run"]
}
}
]
}
}

View File

@@ -0,0 +1,63 @@
{
"id": "validate-config-files",
"title": "Validate repository configuration files",
"summary": "Checks that key config files (IPs, token lists, mappings) exist and look structurally valid.",
"whyItMatters": "Broken or missing config causes silent failures later when you deploy or run operator scripts.",
"audienceHelp": "Use Practice mode first—it only shows what would be checked. Turn it off when you want a real check.",
"docPath": "scripts/validation/validate-config-files.sh",
"prerequisites": [
"Bash available (macOS/Linux, WSL, or Git for Windows).",
"Repository root is the monorepo (contains config/ and pnpm-workspace.yaml)."
],
"steps": [
{
"title": "Practice mode (recommended first)",
"plainText": "When Practice mode is on, the script lists what it would validate and exits successfully without touching files.",
"example": "You will see lines starting with === Validation (--dry-run"
},
{
"title": "Full check",
"plainText": "Turn Practice mode off to scan the repo. jq may be used if installed for JSON validation.",
"technicalNote": "Script: scripts/validation/validate-config-files.sh"
}
],
"inputs": [
{
"name": "dryRun",
"label": "Practice mode (dry run)",
"type": "boolean",
"help": "When enabled, no real file checks run—only a safe preview.",
"example": "Start with this ON, then run again with it OFF.",
"default": true
}
],
"touchpoints": [
{
"id": "exit_ok",
"label": "Script completed without crash",
"passCondition": "exit_zero",
"description": "Non-zero exit means validation reported errors."
},
{
"id": "signal_ok",
"label": "Expected log signal",
"description": "Detects either dry-run banner or success line.",
"passCondition": "stdout_contains",
"pattern": "Validation"
}
],
"complianceFramework": "DBIS-MC-INTERNAL-1",
"execution": {
"steps": [
{
"interpreter": "bash",
"scriptRelative": "scripts/validation/validate-config-files.sh",
"args": [],
"supportsDryRun": true,
"whenInputTrue": {
"dryRun": ["--dry-run"]
}
}
]
}
}

View File

@@ -0,0 +1,35 @@
{
"id": "verify-ws-rpc-chain138",
"title": "Verify WebSocket RPC (Chain 138)",
"summary": "Runs the repository script that checks WebSocket connectivity to the configured Chain 138 RPC endpoint.",
"whyItMatters": "Wallets and some services use WebSockets; HTTP-only checks are not enough.",
"audienceHelp": "You need network reachability to the RPC URL in your environment. If this fails, ask whether you are on the correct network or VPN.",
"docPath": "scripts/verify-ws-rpc-chain138.mjs",
"prerequisites": ["Node.js on PATH.", "RPC/WebSocket URL reachable from this machine (see root package.json verify:ws-chain138)."],
"steps": [
{
"title": "Run check",
"plainText": "The script prints connection results. Green in the live log usually means the socket answered.",
"technicalNote": "pnpm verify:ws-chain138 from repo root is equivalent."
}
],
"inputs": [],
"touchpoints": [
{
"id": "exit_ok",
"label": "Script exit",
"description": "verify-ws-rpc-chain138.mjs exited 0.",
"passCondition": "exit_zero"
}
],
"complianceFramework": "DBIS-MC-INTERNAL-1",
"execution": {
"steps": [
{
"interpreter": "node",
"scriptRelative": "scripts/verify-ws-rpc-chain138.mjs",
"args": []
}
]
}
}

View File

@@ -0,0 +1,238 @@
#!/usr/bin/env node
/**
* Scans docs for markdown files whose names contain RUNBOOK.
* Writes mission-control/runbooks/doc-manifest.json with executable steps.
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const MC_ROOT = path.resolve(__dirname, '..');
const REPO_ROOT = path.resolve(MC_ROOT, '..');
const OUT = path.join(MC_ROOT, 'runbooks', 'doc-manifest.json');
const EXCLUDE_NAMES = new Set([
'RUNBOOKS_MASTER_INDEX.md',
'OPERATIONAL_RUNBOOKS.md',
'TEZOS_CCIP_RUNBOOKS_INDEX.md',
'OMNL_OFFICE_MASTER_RUNBOOK_INDEX.md',
]);
const SCRIPT_RE =
/(?:^|[\s"'`(])\.?\/?((?:scripts|explorer-monorepo\/scripts)\/[a-zA-Z0-9_.\/-]+\.(?:sh|mjs))/g;
const MAX_STEPS = 14;
const FALLBACK_SCRIPT = 'scripts/validation/validate-config-files.sh';
/** Paths meant to be sourced (running them as a step is misleading). */
const SKIP_SCRIPT_PATHS = new Set([
'scripts/lib/load-project-env.sh',
'scripts/lib/load-contract-addresses.sh',
]);
const STANDARD_INPUTS = [
{
name: 'proxmoxHost',
label: 'Proxmox host',
type: 'string',
help: 'Used as PROXMOX_HOST in the environment for scripts that read it (e.g. 192.168.11.10).',
example: '192.168.11.10',
default: '192.168.11.10',
},
{
name: 'rpcUrlOverride',
label: 'RPC URL override (optional)',
type: 'string',
help: 'If non-empty, set as RPC_URL_138 for scripts that use Chain 138 RPC.',
example: 'http://192.168.11.211:8545',
default: '',
},
{
name: 'practiceMode',
label: 'Practice mode (--dry-run where supported)',
type: 'boolean',
help: 'When enabled, each step whose script advertises --dry-run receives that flag.',
default: false,
},
];
function walkDocs(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const name of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, name.name);
if (name.isDirectory()) walkDocs(p, acc);
else {
const up = name.name.toUpperCase();
if (!up.includes('RUNBOOK') || !name.name.toLowerCase().endsWith('.md')) continue;
if (EXCLUDE_NAMES.has(name.name)) continue;
acc.push(p);
}
}
return acc;
}
function relFromRepo(abs) {
return path.relative(REPO_ROOT, abs).split(path.sep).join('/');
}
function makeId(rel) {
const slug = rel
.replace(/^docs[/\\]/, '')
.replace(/\.md$/i, '')
.split(/[/\\]/)
.join('-')
.replace(/[^a-zA-Z0-9-]+/g, '-')
.toLowerCase()
.replace(/^-|-$/g, '');
const base = `doc-${slug}`.slice(0, 120);
const h = crypto.createHash('sha256').update(rel).digest('hex').slice(0, 8);
return `${base}-${h}`;
}
function extractTitle(content) {
const m = content.match(/^#\s+(.+)$/m);
return m ? m[1].trim() : 'Runbook';
}
function extractSummary(content) {
const lines = content.split('\n');
for (const line of lines) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
if (t.startsWith('```')) continue;
return t.slice(0, 400);
}
return 'Operational procedure from repository documentation.';
}
function normalizeScript(raw) {
let s = raw.replace(/^\.\//, '');
if (s.startsWith('/')) return null;
if (s.includes('..')) return null;
return s;
}
function extractScripts(content) {
const seen = new Set();
const ordered = [];
let m;
const re = new RegExp(SCRIPT_RE.source, 'g');
while ((m = re.exec(content)) !== null) {
const n = normalizeScript(m[1]);
if (!n || seen.has(n) || SKIP_SCRIPT_PATHS.has(n)) continue;
const abs = path.join(REPO_ROOT, n);
if (!fs.existsSync(abs)) continue;
seen.add(n);
ordered.push(n);
if (ordered.length >= MAX_STEPS) break;
}
return ordered;
}
function scriptSupportsDryRun(scriptRel) {
try {
const abs = path.join(REPO_ROOT, scriptRel);
const chunk = fs.readFileSync(abs, 'utf8').slice(0, 12000);
return /--dry-run\b/.test(chunk);
} catch {
return false;
}
}
function buildEntry(absPath) {
const rel = relFromRepo(absPath);
const content = fs.readFileSync(absPath, 'utf8');
const title = extractTitle(content);
const summary = extractSummary(content);
const scripts = extractScripts(content);
let usedFallback = false;
let steps = scripts.map((scriptRelative) => ({
interpreter: scriptRelative.endsWith('.mjs') ? 'node' : 'bash',
scriptRelative,
args: [],
supportsDryRun: scriptRelative.endsWith('.sh') && scriptSupportsDryRun(scriptRelative),
}));
if (steps.length === 0) {
usedFallback = true;
const fr = FALLBACK_SCRIPT;
if (fs.existsSync(path.join(REPO_ROOT, fr))) {
steps = [
{
interpreter: 'bash',
scriptRelative: fr,
args: [],
supportsDryRun: scriptSupportsDryRun(fr),
},
];
}
}
const id = makeId(rel);
const why = usedFallback
? 'No shell/Node script paths were detected in this markdown. Mission Control runs repository config validation so you still get an automated check; follow the documentation for the full manual procedure.'
: 'Automated steps are the scripts explicitly referenced in this runbook. Review the documentation for prerequisites (SSH, VPN, secrets) before running in production.';
const spec = {
id,
title,
summary,
whyItMatters:
'This links documentation to executable automation in the monorepo. Operators get repeatable runs and an audit trail.',
audienceHelp:
'Use Practice mode when a script supports it. Set Proxmox host and RPC override when your environment differs from defaults.',
docPath: rel,
prerequisites: [
'Read the linked markdown runbook for safety and ordering.',
'Bash (Linux, macOS, WSL, or Git Bash on Windows) for .sh steps; Node for .mjs.',
'Network, SSH, or API access as required by the underlying scripts.',
],
steps: [
{
title: 'Documentation',
plainText: `Open and follow: ${rel}`,
technicalNote: 'Automated steps below are derived from script paths mentioned in that file.',
},
],
inputs: STANDARD_INPUTS,
execution: { steps },
touchpoints: [
{
id: 'pipeline_exit',
label: 'All automated steps completed',
description: 'Aggregate exit status of the script chain.',
passCondition: 'exit_zero',
},
],
complianceFramework: 'DBIS-MC-DOC-RUNBOOK-1',
executionNote: why,
};
return spec;
}
function main() {
const docsRoot = path.join(REPO_ROOT, 'docs');
const files = walkDocs(docsRoot);
files.sort((a, b) => relFromRepo(a).localeCompare(relFromRepo(b)));
const entries = [];
const ids = new Set();
for (const f of files) {
const spec = buildEntry(f);
if (ids.has(spec.id)) {
spec.id = `${spec.id}-x${crypto.randomBytes(2).toString('hex')}`;
}
ids.add(spec.id);
entries.push(spec);
}
fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, JSON.stringify({ generatedAt: new Date().toISOString(), runbooks: entries }, null, 2), 'utf8');
console.error(`Wrote ${entries.length} doc-derived runbooks to ${path.relative(REPO_ROOT, OUT)}`);
}
main();

View File

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { loadRunbookSpec } from '@/lib/load-specs';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export async function GET(
_req: Request,
ctx: { params: Promise<{ id: string }> },
) {
const { id } = await ctx.params;
const spec = loadRunbookSpec(id);
if (!spec) {
return NextResponse.json({ error: 'Runbook not found' }, { status: 404 });
}
return NextResponse.json(spec);
}

View File

@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { loadAllRunbookSpecs } from '@/lib/load-specs';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export async function GET() {
try {
const runbooks = loadAllRunbookSpecs();
return NextResponse.json({ runbooks });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View File

@@ -0,0 +1,27 @@
import { buildAuditZipBuffer } from '@/lib/audit-zip';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export async function GET(
_req: Request,
ctx: { params: Promise<{ id: string }> },
) {
const { id } = await ctx.params;
try {
const buf = await buildAuditZipBuffer(id);
return new Response(new Uint8Array(buf), {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="mission-control-audit-${id}.zip"`,
'Cache-Control': 'no-store',
},
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return new Response(JSON.stringify({ error: msg }), {
status: msg.includes('not found') ? 404 : 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View File

@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { getJobStore } from '@/lib/job-store';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export async function GET(
_req: Request,
ctx: { params: Promise<{ id: string }> },
) {
const { id } = await ctx.params;
const store = getJobStore();
const meta = store.readMeta(id);
if (!meta) {
return NextResponse.json({ error: 'Run not found' }, { status: 404 });
}
const events = store.readEvents(id);
return NextResponse.json({ meta, events });
}

View File

@@ -0,0 +1,70 @@
import { getJobStore } from '@/lib/job-store';
import type { RunEvent } from '@/lib/run-events';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export async function GET(
req: Request,
ctx: { params: Promise<{ id: string }> },
) {
const { id } = await ctx.params;
const store = getJobStore();
if (!store.readMeta(id)) {
return new Response(JSON.stringify({ error: 'Run not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
const encoder = new TextEncoder();
const bus = store.getRunBus(id);
const stream = new ReadableStream({
start(controller) {
const send = (ev: RunEvent) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(ev)}\n\n`));
};
for (const ev of store.readEvents(id)) {
send(ev);
}
const onEv = (ev: unknown) => send(ev as RunEvent);
bus.on('event', onEv);
let poll: ReturnType<typeof setInterval>;
const close = () => {
clearInterval(poll);
bus.off('event', onEv);
try {
controller.close();
} catch {
/* closed */
}
};
poll = setInterval(() => {
const meta = store.readMeta(id);
if (
meta?.status === 'succeeded' ||
meta?.status === 'failed' ||
meta?.status === 'error'
) {
clearInterval(poll);
setTimeout(close, 250);
}
}, 400);
req.signal.addEventListener('abort', close);
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-store, no-transform',
Connection: 'keep-alive',
},
});
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
import { z, ZodError } from 'zod';
import { queueRun } from '@/lib/executor';
import { loadRunbookSpec } from '@/lib/load-specs';
import { coerceRunbookInputs } from '@/lib/coerce-inputs';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
const postBodySchema = z.object({
runbookId: z.string().min(1),
inputs: z.record(z.string(), z.unknown()).optional().default({}),
});
export async function POST(req: Request) {
try {
let json: unknown;
try {
json = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const body = postBodySchema.parse(json);
const spec = loadRunbookSpec(body.runbookId);
if (!spec) {
return NextResponse.json({ error: 'Unknown runbook' }, { status: 404 });
}
const inputs = coerceRunbookInputs(spec, body.inputs);
const { runId } = queueRun(body.runbookId, inputs);
return NextResponse.json({
runId,
streamUrl: `/api/runs/${runId}/stream`,
auditUrl: `/api/runs/${runId}/audit`,
metaUrl: `/api/runs/${runId}`,
});
} catch (e) {
if (e instanceof ZodError) {
return NextResponse.json({ error: 'Invalid request', issues: e.issues }, { status: 400 });
}
const msg = e instanceof Error ? e.message : String(e);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View File

@@ -0,0 +1,29 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--tardis-glow: 0 180 216;
}
body {
@apply min-h-screen bg-gradient-to-b from-tardis-deep via-tardis-panel to-tardis-deep text-tardis-paper antialiased;
background-attachment: fixed;
}
/* Subtle “police box” corner accents */
.mc-panel {
@apply relative rounded-xl border border-tardis-glow/25 bg-tardis-panel/40 shadow-panel backdrop-blur-md;
box-shadow:
0 0 0 1px rgba(0, 180, 216, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.mc-panel::before {
content: '';
@apply pointer-events-none absolute inset-x-3 top-0 h-px bg-gradient-to-r from-transparent via-tardis-glow/60 to-transparent;
}
.mc-glow-text {
text-shadow: 0 0 12px rgba(0, 180, 216, 0.45);
}

View File

@@ -0,0 +1,20 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'Mission Control | DBIS Operator Console',
description:
'Unified console: launchpad, guided runbooks, live execution trace, compliance evidence, audit export.',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="font-display">{children}</body>
</html>
);
}

View File

@@ -0,0 +1,62 @@
import Link from 'next/link';
import { ExternalLink, Rocket } from 'lucide-react';
import { getLaunchDestinations } from '@/lib/launchpad';
export default function HomePage() {
const destinations = getLaunchDestinations();
return (
<main className="mx-auto max-w-5xl px-4 py-12">
<header className="mb-12 text-center">
<p className="mb-2 text-sm uppercase tracking-[0.35em] text-tardis-glow/80">
Temporal operations
</p>
<h1 className="mc-glow-text text-4xl font-bold text-tardis-paper md:text-5xl">
Mission Control
</h1>
<p className="mx-auto mt-4 max-w-2xl text-lg text-tardis-paper/75">
A calm console for people who are not infra nativeswith receipts for auditors who are.
</p>
<div className="mt-8 flex flex-wrap justify-center gap-4">
<Link
href="/runbooks"
className="inline-flex items-center gap-2 rounded-lg bg-tardis-bright px-6 py-3 font-semibold text-white shadow-tardis transition hover:bg-tardis-glow"
>
<Rocket className="h-5 w-5" aria-hidden />
Guided runbooks
</Link>
</div>
</header>
<section className="mc-panel p-6 md:p-8">
<h2 className="text-xl font-semibold text-tardis-glow">Launchpad</h2>
<p className="mt-2 text-sm text-tardis-paper/70">
Jump to tools that already exist. Start the helper site separately if you use the default port.
</p>
<ul className="mt-6 grid gap-4 md:grid-cols-2">
{destinations.map((d) => (
<li key={d.id}>
<a
href={d.href}
target="_blank"
rel="noopener noreferrer"
className="group flex h-full flex-col rounded-lg border border-white/10 bg-black/20 p-4 transition hover:border-tardis-glow/40 hover:bg-black/30"
>
<span className="flex items-start justify-between gap-2">
<span className="font-semibold text-tardis-paper">{d.title}</span>
<ExternalLink className="h-4 w-4 shrink-0 text-tardis-glow opacity-70 group-hover:opacity-100" />
</span>
<span className="mt-2 text-sm text-tardis-paper/65">{d.description}</span>
<span className="mt-3 font-mono text-xs text-tardis-amber/90">{d.href}</span>
</a>
</li>
))}
</ul>
</section>
<footer className="mt-12 text-center text-xs text-tardis-paper/45">
Operator console · evidence-first · no silent magic
</footer>
</main>
);
}

View File

@@ -0,0 +1,186 @@
'use client';
import { useRouter } from 'next/navigation';
import { useCallback, useState } from 'react';
import { FileText } from 'lucide-react';
import type { RunbookSpec } from '@/lib/runbook-schema';
import { HelpTip } from '@/components/HelpTip';
import { GoButton } from '@/components/GoButton';
import { cn } from '@/lib/cn';
type Props = {
spec: RunbookSpec;
};
export function RunbookRunner({ spec }: Props) {
const router = useRouter();
const [inputs, setInputs] = useState<Record<string, unknown>>(() => {
const o: Record<string, unknown> = {};
for (const f of spec.inputs) {
if (f.default !== undefined) o[f.name] = f.default;
}
return o;
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const docHref = `/api/runbooks/${spec.id}`;
const onRun = useCallback(async () => {
setError(null);
setLoading(true);
try {
const res = await fetch('/api/runs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ runbookId: spec.id, inputs }),
});
const data = (await res.json()) as { runId?: string; error?: string };
if (!res.ok) {
throw new Error(data.error ?? res.statusText);
}
if (!data.runId) throw new Error('No run id returned');
router.push(`/runbooks/${spec.id}/run/${data.runId}`);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [inputs, router, spec.id]);
return (
<div className="mt-6 space-y-8">
<header>
<h1 className="text-2xl font-bold text-tardis-paper">{spec.title}</h1>
<p className="mt-2 text-tardis-paper/75">{spec.summary}</p>
<div className="mt-4 rounded-lg border border-tardis-amber/30 bg-black/25 p-4 text-sm text-tardis-paper/85">
<p className="font-semibold text-tardis-amber">Why this matters</p>
<p className="mt-1">{spec.whyItMatters}</p>
</div>
{spec.executionNote ? (
<div className="mt-3 rounded-lg border border-tardis-glow/25 bg-tardis-panel/30 p-3 text-sm text-tardis-paper/80">
<p className="font-semibold text-tardis-glow">Automation note</p>
<p className="mt-1">{spec.executionNote}</p>
</div>
) : null}
<p className="mt-3 flex items-start gap-2 text-sm text-tardis-paper/70">
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-tardis-glow" aria-hidden />
<span>
Reference in repo: <code className="text-tardis-glow">{spec.docPath}</code> ·{' '}
<a className="text-tardis-glow underline" href={docHref}>
Machine-readable spec (JSON)
</a>
</span>
</p>
</header>
<section className="mc-panel p-5">
<h2 className="flex flex-wrap items-center gap-2 text-lg font-semibold text-tardis-glow">
Before you start
<HelpTip title="Plain-language note" body={spec.audienceHelp} />
</h2>
<ul className="mt-3 list-inside list-disc text-sm text-tardis-paper/80">
{spec.prerequisites.map((p) => (
<li key={p}>{p}</li>
))}
</ul>
</section>
<section className="mc-panel p-5">
<h2 className="text-lg font-semibold text-tardis-glow">What will happen</h2>
<ol className="mt-4 space-y-4">
{spec.steps.map((step, i) => (
<li key={step.title} className="flex gap-3 text-sm">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-tardis-bright/40 text-xs font-bold text-white">
{i + 1}
</span>
<div>
<p className="font-semibold text-tardis-paper">{step.title}</p>
<p className="mt-1 text-tardis-paper/75">{step.plainText}</p>
{step.technicalNote ? (
<p className="mt-1 font-mono text-xs text-tardis-glow/80">{step.technicalNote}</p>
) : null}
{step.example ? (
<pre className="mt-2 rounded bg-black/35 p-2 font-mono text-xs text-tardis-amber">
{step.example}
</pre>
) : null}
</div>
</li>
))}
</ol>
</section>
{spec.inputs.length > 0 ? (
<section className="mc-panel p-5">
<h2 className="text-lg font-semibold text-tardis-glow">Your inputs</h2>
<div className="mt-4 space-y-5">
{spec.inputs.map((field) => (
<div key={field.name}>
<label className="flex items-center gap-2 text-sm font-medium text-tardis-paper">
{field.type === 'boolean' ? (
<input
type="checkbox"
className="h-4 w-4 rounded border-tardis-glow/50 bg-black/40 text-tardis-bright"
checked={Boolean(inputs[field.name])}
onChange={(e) =>
setInputs((prev) => ({ ...prev, [field.name]: e.target.checked }))
}
/>
) : null}
<span>{field.label}</span>
<HelpTip title={field.label} body={field.help} example={field.example} />
</label>
{field.type === 'string' || field.type === 'number' ? (
<input
type={field.type === 'number' ? 'number' : 'text'}
className={cn(
'mt-2 w-full rounded-lg border border-white/15 bg-black/30 px-3 py-2 text-sm text-tardis-paper',
'focus:border-tardis-glow focus:outline-none focus:ring-1 focus:ring-tardis-glow',
)}
value={String(inputs[field.name] ?? '')}
onChange={(e) =>
setInputs((prev) => ({
...prev,
[field.name]:
field.type === 'number' ? Number(e.target.value) : e.target.value,
}))
}
/>
) : null}
{field.type === 'select' && field.options ? (
<select
className="mt-2 w-full rounded-lg border border-white/15 bg-black/30 px-3 py-2 text-sm text-tardis-paper"
value={String(inputs[field.name] ?? field.options[0]?.value ?? '')}
onChange={(e) =>
setInputs((prev) => ({ ...prev, [field.name]: e.target.value }))
}
>
{field.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : null}
</div>
))}
</div>
</section>
) : null}
<section className="mc-panel flex flex-col items-center gap-4 p-8">
<p className="text-center text-sm text-tardis-paper/65">
When you press the button, the system runs the real allowlisted script and records every step for
your audit pack.
</p>
<GoButton onClick={onRun} loading={loading} disabled={loading} />
{error ? (
<p className="text-center text-sm text-red-400" role="alert">
{error}
</p>
) : null}
</section>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import Link from 'next/link';
export default function RunbookNotFound() {
return (
<main className="mx-auto max-w-lg px-4 py-16 text-center">
<h1 className="text-2xl font-bold text-tardis-paper">Runbook not found</h1>
<p className="mt-2 text-tardis-paper/70">That procedure is not in the catalog.</p>
<Link href="/runbooks" className="mt-6 inline-block text-tardis-glow underline">
Back to runbooks
</Link>
</main>
);
}

View File

@@ -0,0 +1,29 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import type { RunbookSpec } from '@/lib/runbook-schema';
import { loadRunbookSpec } from '@/lib/load-specs';
import { RunbookRunner } from './RunbookRunner';
export default async function RunbookPage({
params,
}: {
params: Promise<{ runbookId: string }>;
}) {
const { runbookId } = await params;
const spec = loadRunbookSpec(runbookId);
if (!spec) {
notFound();
}
/** Plain JSON so the client bundle never receives non-serializable values from Zod/parse. */
const clientSpec = JSON.parse(JSON.stringify(spec)) as RunbookSpec;
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<Link href="/runbooks" className="text-sm text-tardis-glow hover:underline">
All runbooks
</Link>
<RunbookRunner spec={clientSpec} />
</main>
);
}

View File

@@ -0,0 +1,255 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { Activity, CheckCircle2, Download, Shield, Terminal } from 'lucide-react';
import type { RunEvent } from '@/lib/run-events';
import { cn } from '@/lib/cn';
function gradeClass(g: string | null | undefined): string {
if (g === 'GREEN') return 'text-emerald-400 border-emerald-500/40 bg-emerald-500/10';
if (g === 'AMBER') return 'text-tardis-amber border-tardis-amber/40 bg-tardis-amber/10';
if (g === 'RED') return 'text-red-400 border-red-500/40 bg-red-500/10';
return 'text-tardis-paper/60 border-white/20 bg-black/20';
}
function findFinishEvent(
events: RunEvent[],
): Extract<RunEvent, { type: 'run_finished' | 'run_error' }> | undefined {
for (let i = events.length - 1; i >= 0; i--) {
const e = events[i];
if (e.type === 'run_finished' || e.type === 'run_error') return e;
}
return undefined;
}
export default function RunLivePage() {
const params = useParams<{ runbookId: string; runId: string }>();
const runbookId = params.runbookId;
const runId = params.runId;
const [events, setEvents] = useState<RunEvent[]>([]);
const [logError, setLogError] = useState<string | null>(null);
useEffect(() => {
const es = new EventSource(`/api/runs/${runId}/stream`);
es.onmessage = (ev) => {
try {
const parsed = JSON.parse(ev.data) as RunEvent;
setEvents((prev) => {
const s = JSON.stringify(parsed);
if (prev.some((p) => JSON.stringify(p) === s)) return prev;
return [...prev, parsed];
});
} catch {
/* ignore */
}
};
es.onerror = () => {
setLogError('Stream interrupted. Refresh the page if the run was still active.');
es.close();
};
return () => es.close();
}, [runId]);
const finish = useMemo(() => findFinishEvent(events), [events]);
const overallGrade =
finish?.type === 'run_finished'
? finish.overallGrade
: finish?.type === 'run_error'
? 'RED'
: null;
const summary =
finish?.type === 'run_finished' ? finish.summary : finish?.type === 'run_error' ? finish.message : null;
const touchpoints = useMemo(
() =>
events.filter(
(e): e is Extract<RunEvent, { type: 'touchpoint_result' }> => e.type === 'touchpoint_result',
),
[events],
);
const compliance = useMemo(
() =>
events.filter(
(e): e is Extract<RunEvent, { type: 'compliance_assertion' }> =>
e.type === 'compliance_assertion',
),
[events],
);
const logLines = useMemo(
() =>
events.filter(
(e) => e.type === 'stdout_line' || e.type === 'stderr_line',
) as Array<Extract<RunEvent, { type: 'stdout_line' | 'stderr_line' }>>,
[events],
);
const finished = Boolean(finish);
return (
<main className="mx-auto max-w-6xl px-4 py-10">
<Link href={`/runbooks/${runbookId}`} className="text-sm text-tardis-glow hover:underline">
Back to runbook
</Link>
<header className="mt-4 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-tardis-paper">Live run</h1>
<p className="mt-1 font-mono text-xs text-tardis-glow/80">{runId}</p>
</div>
<div className="flex flex-wrap gap-2">
<span
className={cn(
'rounded-lg border px-3 py-1 text-xs font-semibold uppercase tracking-wide',
gradeClass(overallGrade),
)}
>
{overallGrade ?? (events.length ? 'RUNNING' : 'CONNECTING')}
</span>
{finished ? (
<a
href={`/api/runs/${runId}/audit`}
className="inline-flex items-center gap-2 rounded-lg border border-tardis-glow/40 bg-tardis-bright/30 px-4 py-2 text-sm font-semibold text-white transition hover:bg-tardis-bright/50"
>
<Download className="h-4 w-4" aria-hidden />
Download audit pack (ZIP)
</a>
) : null}
</div>
</header>
{summary ? (
<p className="mt-4 rounded-lg border border-white/10 bg-black/25 p-3 text-sm text-tardis-paper/85">
{summary}
</p>
) : null}
{logError ? <p className="mt-2 text-sm text-tardis-amber">{logError}</p> : null}
<div className="mt-8 grid gap-6 lg:grid-cols-2">
<section className="mc-panel p-4">
<h2 className="flex items-center gap-2 text-lg font-semibold text-tardis-glow">
<Activity className="h-5 w-5" aria-hidden />
Live tracking
</h2>
<p className="mt-1 text-xs text-tardis-paper/55">
Timeline of what the system is doing right now.
</p>
<ul className="mt-4 max-h-[420px] space-y-2 overflow-y-auto font-mono text-xs">
{events.map((e, i) => {
if (e.type === 'stdout_line' || e.type === 'stderr_line') return null;
const label = (() => {
switch (e.type) {
case 'run_queued':
return 'Queued';
case 'allowlist_verified':
return 'Allowlist OK';
case 'step_started':
return `Step ${e.stepIndex + 1}/${e.stepTotal} start`;
case 'step_finished':
return `Step ${e.stepIndex + 1}/${e.stepTotal} done (exit ${e.exitCode})`;
case 'process_spawned':
return 'Started process';
case 'touchpoint_result':
return `Touchpoint: ${e.touchpointId}`;
case 'compliance_assertion':
return `Compliance: ${e.controlId}`;
case 'run_finished':
return 'Finished';
case 'run_error':
return 'Error';
default: {
const _x: never = e;
return _x;
}
}
})();
return (
<li
key={i}
className="rounded border border-white/10 bg-black/25 px-2 py-1.5 text-tardis-paper/85"
>
<span className="text-tardis-glow/70">{e.ts}</span> · {label}
{e.type === 'run_finished' ? ` · exit ${e.exitCode}` : null}
{e.type === 'run_error' ? ` · ${e.message}` : null}
</li>
);
})}
</ul>
</section>
<section className="mc-panel p-4">
<h2 className="flex items-center gap-2 text-lg font-semibold text-tardis-glow">
<Shield className="h-5 w-5" aria-hidden />
Data & compliance
</h2>
<p className="mt-1 text-xs text-tardis-paper/55">
Every check is graded. Green means the evidence matched what we expected.
</p>
<h3 className="mt-4 text-xs font-semibold uppercase tracking-wide text-tardis-amber">
Touchpoints
</h3>
<ul className="mt-2 max-h-48 space-y-2 overflow-y-auto text-sm">
{touchpoints.map((t, i) => (
<li
key={`${t.touchpointId}-${i}`}
className={cn(
'flex items-start gap-2 rounded border px-2 py-1.5',
t.grade === 'GREEN'
? 'border-emerald-500/35 bg-emerald-500/10'
: t.grade === 'RED'
? 'border-red-500/35 bg-red-500/10'
: 'border-tardis-amber/35 bg-tardis-amber/10',
)}
>
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-tardis-glow" aria-hidden />
<div>
<p className="font-medium text-tardis-paper">{t.touchpointId}</p>
<p className="text-xs text-tardis-paper/65">{t.evidence}</p>
</div>
</li>
))}
</ul>
<h3 className="mt-4 text-xs font-semibold uppercase tracking-wide text-tardis-amber">
Compliance assertions
</h3>
<ul className="mt-2 max-h-40 space-y-2 overflow-y-auto text-sm">
{compliance.map((c, i) => (
<li
key={`${c.controlId}-${i}`}
className={cn(
'rounded border px-2 py-1.5',
c.satisfied ? 'border-emerald-500/30 bg-emerald-500/10' : 'border-red-500/30 bg-red-500/10',
)}
>
<p className="font-mono text-xs text-tardis-glow">{c.controlId}</p>
<p className="text-xs text-tardis-paper/70">{c.evidence}</p>
</li>
))}
</ul>
</section>
</div>
<section className="mc-panel mt-6 p-4">
<h2 className="flex items-center gap-2 text-lg font-semibold text-tardis-glow">
<Terminal className="h-5 w-5" aria-hidden />
Technical log (stdout / stderr)
</h2>
<pre className="mt-3 max-h-80 overflow-auto rounded-lg bg-black/50 p-3 font-mono text-[11px] leading-relaxed text-tardis-paper/90">
{logLines.map((l, i) => (
<span key={i} className={l.type === 'stderr_line' ? 'text-tardis-amber' : undefined}>
{l.line}
{'\n'}
</span>
))}
</pre>
</section>
</main>
);
}

View File

@@ -0,0 +1,64 @@
import Link from 'next/link';
import { ChevronRight, BookOpen } from 'lucide-react';
import { loadAllRunbookSpecs } from '@/lib/load-specs';
export default function RunbooksIndexPage() {
let specs: ReturnType<typeof loadAllRunbookSpecs>;
let catalogError: string | null = null;
try {
specs = loadAllRunbookSpecs();
} catch (e) {
console.error('[mission-control] Failed to load runbook catalog:', e);
specs = [];
catalogError = e instanceof Error ? e.message : String(e);
}
return (
<main className="mx-auto max-w-4xl px-4 py-10">
<div className="mb-8">
<Link href="/" className="text-sm text-tardis-glow hover:underline">
Home
</Link>
<h1 className="mt-4 flex items-center gap-3 text-3xl font-bold text-tardis-paper">
<BookOpen className="h-8 w-8 text-tardis-glow" aria-hidden />
Runbooks
</h1>
<p className="mt-2 text-tardis-paper/70">
Pick a procedure. Each page explains what it does in plain language, asks only what is needed, and
records proof when you run it.
</p>
</div>
{catalogError ? (
<div
className="mb-6 rounded-lg border border-red-500/40 bg-red-950/40 p-4 text-sm text-red-200"
role="alert"
>
<p className="font-semibold">Runbook catalog could not be loaded</p>
<p className="mt-2 font-mono text-xs opacity-90">{catalogError}</p>
<p className="mt-2 text-tardis-paper/80">
Check <code className="text-tardis-glow">MISSION_CONTROL_PROJECT_ROOT</code>, regenerate{' '}
<code className="text-tardis-glow">runbooks/doc-manifest.json</code>, and see the server log.
</p>
</div>
) : null}
<ul className="space-y-3">
{specs.map((s) => (
<li key={s.id}>
<Link
href={`/runbooks/${s.id}`}
className="mc-panel flex items-center justify-between gap-4 p-4 transition hover:border-tardis-glow/50"
>
<div>
<p className="font-semibold text-tardis-paper">{s.title}</p>
<p className="mt-1 text-sm text-tardis-paper/65">{s.summary}</p>
</div>
<ChevronRight className="h-5 w-5 shrink-0 text-tardis-glow" aria-hidden />
</Link>
</li>
))}
</ul>
</main>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { cn } from '@/lib/cn';
type Props = {
disabled?: boolean;
loading?: boolean;
onClick: () => void;
className?: string;
};
export function GoButton({ disabled, loading, onClick, className }: Props) {
return (
<button
type="button"
disabled={disabled || loading}
onClick={onClick}
className={cn(
'rounded-lg px-8 py-4 text-lg font-bold tracking-wide text-white shadow-lg transition',
'bg-red-600 hover:bg-red-500 hover:shadow-red-500/40',
'disabled:cursor-not-allowed disabled:opacity-50',
'focus:outline-none focus:ring-4 focus:ring-red-300/50',
className,
)}
>
{loading ? 'Running…' : 'GO BABY GO!'}
</button>
);
}

View File

@@ -0,0 +1,62 @@
'use client';
import { HelpCircle } from 'lucide-react';
import { useCallback, useEffect, useId, useRef, useState } from 'react';
import { cn } from '@/lib/cn';
type Props = {
title: string;
body: string;
example?: string;
className?: string;
};
export function HelpTip({ title, body, example, className }: Props) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLSpanElement>(null);
const panelId = useId();
const close = useCallback(() => setOpen(false), []);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) close();
};
document.addEventListener('click', onDoc);
return () => document.removeEventListener('click', onDoc);
}, [open, close]);
return (
<span ref={rootRef} className={cn('relative inline-flex align-middle', className)}>
<button
type="button"
className="ml-1 rounded-full border border-tardis-glow/40 bg-tardis-panel/80 p-0.5 text-tardis-glow transition hover:bg-tardis-bright/30 hover:shadow-tardis focus:outline-none focus:ring-2 focus:ring-tardis-glow"
aria-expanded={open}
aria-controls={panelId}
aria-label={`Help: ${title}`}
onClick={(e) => {
e.stopPropagation();
setOpen((o) => !o);
}}
>
<HelpCircle className="h-4 w-4" aria-hidden />
</button>
{open ? (
<span
id={panelId}
role="tooltip"
className="absolute left-0 top-full z-50 mt-2 w-72 rounded-lg border border-tardis-glow/30 bg-tardis-deep/98 p-3 text-left text-xs text-tardis-paper shadow-tardis"
>
<p className="font-semibold text-tardis-glow">{title}</p>
<p className="mt-2 leading-relaxed text-tardis-paper/90">{body}</p>
{example ? (
<pre className="mt-2 max-h-32 overflow-auto rounded bg-black/40 p-2 font-mono text-[10px] text-tardis-amber">
{example}
</pre>
) : null}
</span>
) : null}
</span>
);
}

View File

@@ -0,0 +1,9 @@
import type { RunbookSpec } from '@/lib/runbook-schema';
import { validateRunbookExecution } from '@/lib/execution-path-validator';
/**
* Ensures every script path in the runbook exists and is under allowlisted prefixes.
*/
export function assertRunbookAllowlisted(spec: RunbookSpec, repoRoot: string): void {
validateRunbookExecution(repoRoot, spec);
}

View File

@@ -0,0 +1,91 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { execSync } from 'node:child_process';
import archiver from 'archiver';
import { getJobStore } from '@/lib/job-store';
import { loadRunbookSpec } from '@/lib/load-specs';
import { getProjectRoot } from '@/lib/paths';
function gitSha(root: string): string {
try {
return execSync('git rev-parse HEAD', { cwd: root, encoding: 'utf8' }).trim();
} catch {
return 'unknown';
}
}
function sha256File(filePath: string): string {
const h = createHash('sha256');
h.update(fs.readFileSync(filePath));
return h.digest('hex');
}
/**
* Builds immutable audit bundle (zip) for a completed run.
*/
export async function buildAuditZipBuffer(runId: string): Promise<Buffer> {
const store = getJobStore();
const meta = store.readMeta(runId);
if (!meta) {
throw new Error('Run not found');
}
const dir = store.runDir(runId);
const root = getProjectRoot();
const spec = loadRunbookSpec(meta.runbookId);
const manifest = {
schema: 'mission-control.audit-bundle.v1',
runId,
runbookId: meta.runbookId,
runbookTitle: spec?.title ?? meta.runbookId,
createdAtUtc: new Date().toISOString(),
repositoryRoot: root,
gitCommit: gitSha(root),
missionControlVersion: '1.0.0',
meta,
integrityNote:
'SHA-256 checksums for payload files are listed in checksums.sha256. Retain this bundle for audit.',
complianceFramework: spec?.complianceFramework ?? 'DBIS-MC-INTERNAL-1',
};
const chunks: Buffer[] = [];
const archive = archiver('zip', { zlib: { level: 9 } });
archive.on('data', (c: Buffer) => chunks.push(c));
const done = new Promise<void>((resolve, reject) => {
archive.on('end', resolve);
archive.on('error', reject);
});
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
const filesToHash: { name: string; abs: string }[] = [];
for (const name of [
'events.jsonl',
'inputs.redacted.json',
'touchpoints.final.json',
'compliance.json',
'stdout.log',
'stderr.log',
'meta.json',
]) {
const abs = path.join(dir, name);
if (fs.existsSync(abs)) {
archive.file(abs, { name: `payload/${name}` });
filesToHash.push({ name, abs });
}
}
const checksumLines: string[] = [];
for (const f of filesToHash) {
checksumLines.push(`${sha256File(f.abs)} ${f.name}`);
}
archive.append(checksumLines.join('\n') + '\n', { name: 'checksums.sha256' });
await archive.finalize();
await done;
return Buffer.concat(chunks);
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,23 @@
import type { RunbookSpec } from '@/lib/runbook-schema';
export function coerceRunbookInputs(
spec: RunbookSpec,
raw: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = { ...raw };
for (const field of spec.inputs) {
if (out[field.name] === undefined && field.default !== undefined) {
out[field.name] = field.default;
}
if (field.type === 'boolean') {
const v = out[field.name];
if (v === 'true' || v === true) out[field.name] = true;
else if (v === 'false' || v === false) out[field.name] = false;
}
if (field.type === 'number' && typeof out[field.name] === 'string') {
const n = Number(out[field.name]);
if (!Number.isNaN(n)) out[field.name] = n;
}
}
return out;
}

View File

@@ -0,0 +1,46 @@
import fs from 'node:fs';
import path from 'node:path';
import type { RunbookSpec } from '@/lib/runbook-schema';
/** Directory prefixes under repo root (no leading/trailing slash). */
const ALLOWED_PREFIXES = ['scripts', 'explorer-monorepo/scripts'] as const;
function normalizeRelative(p: string): string {
const s = p.replace(/^\.\//, '').replace(/\\/g, '/');
if (s.includes('..') || path.isAbsolute(s)) {
throw new Error(`Unsafe script path: ${p}`);
}
return s;
}
export function assertScriptPathAllowed(repoRoot: string, scriptRelative: string): string {
const rel = normalizeRelative(scriptRelative);
const ok = ALLOWED_PREFIXES.some(
(pre) => rel === pre || rel.startsWith(`${pre}/`),
);
if (!ok) {
throw new Error(
`Script path not allowlisted (must be under ${ALLOWED_PREFIXES.join(' or ')}): ${rel}`,
);
}
const abs = path.resolve(repoRoot, rel);
const rootResolved = path.resolve(repoRoot);
const relToRoot = path.relative(rootResolved, abs);
const outside =
relToRoot.startsWith(`..${path.sep}`) ||
relToRoot === '..' ||
path.isAbsolute(relToRoot);
if (outside) {
throw new Error(`Script resolves outside repository: ${rel}`);
}
if (!fs.existsSync(abs)) {
throw new Error(`Script not found: ${rel}`);
}
return abs;
}
export function validateRunbookExecution(repoRoot: string, spec: RunbookSpec): void {
for (const step of spec.execution.steps) {
assertScriptPathAllowed(repoRoot, step.scriptRelative);
}
}

View File

@@ -0,0 +1,107 @@
import path from 'node:path';
import fs from 'node:fs';
import type { RunbookSpec, ExecutionStep } from '@/lib/runbook-schema';
import { assertScriptPathAllowed } from '@/lib/execution-path-validator';
export type ResolvedCommand = {
program: string;
args: string[];
cwd: string;
shell: boolean;
};
function resolveBash(): string {
if (process.platform === 'win32') {
const candidates = [
process.env.GIT_BASH_PATH,
'C:\\Program Files\\Git\\bin\\bash.exe',
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
].filter(Boolean) as string[];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
return 'bash';
}
return 'bash';
}
function substituteTemplates(s: string, inputs: Record<string, unknown>): string {
return s.replace(/\{\{(\w+)\}\}/g, (_, key: string) => {
const v = inputs[key];
if (v === undefined || v === null) return '';
return String(v);
});
}
function isTruthyInput(v: unknown): boolean {
if (v === true || v === 'true') return true;
if (typeof v === 'string' && v.trim().length > 0) return true;
return false;
}
function buildArgsForStep(
step: ExecutionStep,
inputs: Record<string, unknown>,
): string[] {
const base = (step.args ?? []).map((a) => substituteTemplates(a, inputs));
const extra: string[] = [];
if (step.whenInputTrue) {
for (const [inputName, flags] of Object.entries(step.whenInputTrue)) {
if (isTruthyInput(inputs[inputName])) {
extra.push(...flags.map((f) => substituteTemplates(f, inputs)));
}
}
}
const practice = inputs.practiceMode === true || inputs.practiceMode === 'true';
if (practice && step.supportsDryRun && !extra.includes('--dry-run')) {
extra.push('--dry-run');
}
return [...base, ...extra];
}
/**
* Resolves every execution step to a concrete spawn command (allowlisted paths only).
*/
export function buildExecutionPlan(
spec: RunbookSpec,
inputs: Record<string, unknown>,
repoRoot: string,
): ResolvedCommand[] {
const bash = resolveBash();
const out: ResolvedCommand[] = [];
for (const step of spec.execution.steps) {
const absScript = assertScriptPathAllowed(repoRoot, step.scriptRelative);
const args = buildArgsForStep(step, inputs);
if (step.interpreter === 'node') {
out.push({
program: process.execPath,
args: [absScript, ...args],
cwd: repoRoot,
shell: false,
});
} else {
out.push({
program: bash,
args: [absScript, ...args],
cwd: repoRoot,
shell: false,
});
}
}
return out;
}
export function checkBashAvailable(cmd: ResolvedCommand): boolean {
if (cmd.program === process.execPath) return true;
const p = cmd.program;
if ((p === 'bash' || p === 'bash.exe') && process.platform === 'win32') {
return false;
}
if (/bash\.exe$/i.test(p)) {
return fs.existsSync(p);
}
return true;
}

View File

@@ -0,0 +1,36 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { getJobStore } from '@/lib/job-store';
import { queueRun } from '@/lib/executor';
async function waitForTerminal(runId: string, maxMs = 45_000): Promise<void> {
const store = getJobStore();
const start = Date.now();
while (Date.now() - start < maxMs) {
const m = store.readMeta(runId);
if (
m?.status === 'succeeded' ||
m?.status === 'failed' ||
m?.status === 'error'
) {
return;
}
await new Promise((r) => setTimeout(r, 150));
}
throw new Error(`Run ${runId} did not finish within ${maxMs}ms`);
}
describe('mission-control executor', () => {
beforeEach(() => {
// Fresh job store per test file run in same process — UUIDs avoid collision
});
it('runs health-self-check to success', async () => {
const { runId } = queueRun('health-self-check', {});
await waitForTerminal(runId);
const meta = getJobStore().readMeta(runId);
expect(meta?.status).toBe('succeeded');
expect(meta?.overallGrade).toBe('GREEN');
const events = getJobStore().readEvents(runId);
expect(events.some((e) => e.type === 'stdout_line')).toBe(true);
});
});

View File

@@ -0,0 +1,382 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import { assertRunbookAllowlisted } from '@/lib/allowlist';
import { getJobStore, type RunMeta } from '@/lib/job-store';
import type { RunEvent } from '@/lib/run-events';
import { loadRunbookSpec } from '@/lib/load-specs';
import { redactInputs } from '@/lib/redact';
import { createTouchpointTracker } from '@/lib/touchpoint-evaluator';
import { ensureDirSync, getProjectRoot, getRunDataDir } from '@/lib/paths';
import type { RunbookSpec } from '@/lib/runbook-schema';
import { buildExecutionPlan, checkBashAvailable, type ResolvedCommand } from '@/lib/execution-plan';
function nowIso(): string {
return new Date().toISOString();
}
function redactArgs(args: string[]): string[] {
return args.map((a) => (a.length > 120 ? `${a.slice(0, 40)}...[truncated]` : a));
}
function overallGradeFrom(
exit: number | null,
touchFail: boolean,
threw: boolean,
): 'GREEN' | 'AMBER' | 'RED' {
if (threw) return 'RED';
if (exit !== 0) return 'RED';
if (touchFail) return 'AMBER';
return 'GREEN';
}
function complianceRows(
spec: RunbookSpec,
touchpoints: { id: string; status: string; evidence: string; grade: string }[],
): { controlId: string; framework: string; satisfied: boolean; evidence: string }[] {
return touchpoints.map((t) => ({
controlId: `MC-${spec.id.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}-${t.id}`,
framework: spec.complianceFramework,
satisfied: t.status === 'PASS' && t.grade !== 'RED',
evidence: t.evidence,
}));
}
function buildChildEnv(
root: string,
rawInputs: Record<string, unknown>,
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env, PROJECT_ROOT: root };
const ph = rawInputs.proxmoxHost;
if (ph !== undefined && ph !== null && String(ph).trim() !== '') {
env.PROXMOX_HOST = String(ph).trim();
}
const rpc = rawInputs.rpcUrlOverride;
if (typeof rpc === 'string' && rpc.trim() !== '') {
env.RPC_URL_138 = rpc.trim();
}
return env;
}
function runOneCommand(
runId: string,
store: ReturnType<typeof getJobStore>,
cmd: ResolvedCommand,
env: NodeJS.ProcessEnv,
onStdout: (chunk: string) => void,
onStderr: (chunk: string) => void,
): Promise<number | null> {
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(cmd.program, cmd.args, {
cwd: cmd.cwd,
shell: cmd.shell,
env,
});
child.stdout?.on('data', (d: Buffer) => {
const chunk = d.toString('utf8');
onStdout(chunk);
for (const line of chunk.split('\n')) {
if (line.length === 0) continue;
store.appendEvent(runId, { type: 'stdout_line', ts: nowIso(), line });
}
});
child.stderr?.on('data', (d: Buffer) => {
const chunk = d.toString('utf8');
onStderr(chunk);
for (const line of chunk.split('\n')) {
if (line.length === 0) continue;
store.appendEvent(runId, { type: 'stderr_line', ts: nowIso(), line });
}
});
child.on('error', (err) => {
rejectPromise(err);
});
child.on('close', (code) => {
resolvePromise(code);
});
});
}
export async function executeRunbook(
runId: string,
spec: RunbookSpec,
rawInputs: Record<string, unknown>,
): Promise<void> {
const store = getJobStore();
const root = getProjectRoot();
let meta = store.readMeta(runId);
if (!meta) {
throw new Error('Run meta missing');
}
try {
assertRunbookAllowlisted(spec, root);
const detail =
(spec.executionNote ? `${spec.executionNote} ` : '') +
`${spec.execution.steps.length} allowlisted step(s).`;
store.appendEvent(runId, {
type: 'allowlist_verified',
ts: nowIso(),
detail,
});
const plan = buildExecutionPlan(spec, rawInputs, root);
const childEnv = buildChildEnv(root, rawInputs);
for (const cmd of plan) {
if (!checkBashAvailable(cmd)) {
store.appendEvent(runId, {
type: 'run_error',
ts: nowIso(),
message:
'Git Bash not found. Install Git for Windows, set GIT_BASH_PATH to bash.exe, or run from WSL/Linux.',
overallGrade: 'RED',
});
const bashErr: RunMeta = {
id: runId,
runbookId: meta.runbookId,
createdAt: meta.createdAt,
startedAt: meta.startedAt,
status: 'error',
finishedAt: nowIso(),
exitCode: null,
overallGrade: 'RED',
summary: 'Bash missing on Windows',
};
store.writeMeta(runId, bashErr);
return;
}
}
const running: RunMeta = {
id: runId,
runbookId: meta.runbookId,
createdAt: meta.createdAt,
status: 'running',
startedAt: nowIso(),
exitCode: null,
overallGrade: null,
};
meta = running;
store.writeMeta(runId, running);
const tracker = createTouchpointTracker(spec);
let stdoutBuf = '';
let stderrBuf = '';
let lastCode: number | null = 0;
for (let i = 0; i < plan.length; i++) {
const cmd = plan[i];
const scriptArg = cmd.args[0] ?? '';
if (
(cmd.program.includes('bash') || scriptArg.endsWith('.sh')) &&
scriptArg &&
!fs.existsSync(scriptArg)
) {
store.appendEvent(runId, {
type: 'run_error',
ts: nowIso(),
message: `Script not found: ${scriptArg}`,
overallGrade: 'RED',
});
const miss: RunMeta = {
id: runId,
runbookId: meta.runbookId,
createdAt: meta.createdAt,
startedAt: meta.startedAt,
status: 'error',
finishedAt: nowIso(),
exitCode: null,
overallGrade: 'RED',
summary: 'Script missing',
};
store.writeMeta(runId, miss);
return;
}
store.appendEvent(runId, {
type: 'step_started',
ts: nowIso(),
stepIndex: i,
stepTotal: plan.length,
scriptRelative: spec.execution.steps[i]?.scriptRelative ?? '',
program: cmd.program,
argsRedacted: redactArgs(cmd.args),
});
store.appendEvent(runId, {
type: 'process_spawned',
ts: nowIso(),
program: cmd.program,
argsRedacted: redactArgs(cmd.args),
cwd: cmd.cwd,
});
try {
lastCode = await runOneCommand(
runId,
store,
cmd,
childEnv,
(chunk) => {
stdoutBuf += chunk;
tracker.ingestStdout(chunk);
},
(chunk) => {
stderrBuf += chunk;
},
);
} catch (spawnErr) {
const msg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
store.appendEvent(runId, {
type: 'step_finished',
ts: nowIso(),
stepIndex: i,
stepTotal: plan.length,
exitCode: null,
error: msg,
});
throw spawnErr;
}
store.appendEvent(runId, {
type: 'step_finished',
ts: nowIso(),
stepIndex: i,
stepTotal: plan.length,
exitCode: lastCode,
});
if (lastCode !== 0) {
break;
}
}
store.writeStdoutSnapshot(runId, stdoutBuf);
store.writeStderrSnapshot(runId, stderrBuf);
const finalTp = tracker.finalize(lastCode);
store.writeTouchpoints(runId, finalTp);
let touchFail = false;
for (const t of finalTp) {
if (t.status === 'FAIL') touchFail = true;
store.appendEvent(runId, {
type: 'touchpoint_result',
ts: nowIso(),
touchpointId: t.id,
status: t.status,
evidence: t.evidence,
grade: t.grade,
});
}
const comp = complianceRows(spec, finalTp);
store.writeCompliance(runId, comp);
for (const row of comp) {
store.appendEvent(runId, {
type: 'compliance_assertion',
ts: nowIso(),
controlId: row.controlId,
framework: row.framework,
satisfied: row.satisfied,
evidence: row.evidence,
});
}
const grade = overallGradeFrom(lastCode, touchFail, false);
const summary =
lastCode === 0 && !touchFail
? `All ${plan.length} step(s) completed successfully; touchpoints satisfied.`
: lastCode !== 0
? `Stopped after non-zero exit (code ${lastCode}).`
: 'Exit 0 but one or more touchpoints require review.';
store.appendEvent(runId, {
type: 'run_finished',
ts: nowIso(),
exitCode: lastCode,
overallGrade: grade,
summary,
});
const ok = lastCode === 0 && !touchFail;
const m = meta as RunMeta;
const finished: RunMeta = {
id: runId,
runbookId: m.runbookId,
createdAt: m.createdAt,
startedAt: m.startedAt,
status: ok ? 'succeeded' : 'failed',
finishedAt: nowIso(),
exitCode: lastCode,
overallGrade: grade,
summary,
};
store.writeMeta(runId, finished);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
store.appendEvent(runId, {
type: 'run_error',
ts: nowIso(),
message: msg,
overallGrade: 'RED',
});
const cur = store.readMeta(runId);
if (!cur) return;
const errMeta: RunMeta = {
id: runId,
runbookId: cur.runbookId,
createdAt: cur.createdAt,
startedAt: cur.startedAt,
status: 'error',
finishedAt: nowIso(),
exitCode: null,
overallGrade: 'RED',
summary: msg,
};
store.writeMeta(runId, errMeta);
}
}
export function queueRun(
runbookId: string,
rawInputs: Record<string, unknown>,
): { runId: string } {
const spec = loadRunbookSpec(runbookId);
if (!spec) {
throw new Error(`Unknown runbook: ${runbookId}`);
}
assertRunbookAllowlisted(spec, getProjectRoot());
const store = getJobStore();
const runId = store.createRunId();
const redacted = redactInputs(spec, rawInputs);
ensureDirSync(getRunDataDir());
store.writeMeta(runId, {
id: runId,
runbookId,
status: 'queued',
createdAt: nowIso(),
exitCode: null,
overallGrade: null,
});
store.writeInputs(runId, redacted);
store.appendEvent(runId, {
type: 'run_queued',
ts: nowIso(),
runbookId,
specVersion: '2.0',
});
void executeRunbook(runId, spec, rawInputs);
return { runId };
}

View File

@@ -0,0 +1,111 @@
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import type { RunEvent } from '@/lib/run-events';
import { ensureDirSync, getRunDataDir } from '@/lib/paths';
export type RunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'error';
export type RunMeta = {
id: string;
runbookId: string;
status: RunStatus;
createdAt: string;
startedAt?: string;
finishedAt?: string;
exitCode: number | null;
overallGrade: 'GREEN' | 'AMBER' | 'RED' | null;
summary?: string;
};
const g = globalThis as unknown as {
__missionControlJobStore?: JobStore;
};
class JobStore extends EventEmitter {
private readonly runBuses = new Map<string, EventEmitter>();
getRunBus(runId: string): EventEmitter {
let b = this.runBuses.get(runId);
if (!b) {
b = new EventEmitter();
this.runBuses.set(runId, b);
}
return b;
}
createRunId(): string {
return crypto.randomUUID();
}
runDir(runId: string): string {
return path.join(getRunDataDir(), runId);
}
appendEvent(runId: string, event: RunEvent): void {
const dir = this.runDir(runId);
ensureDirSync(dir);
const line = JSON.stringify(event) + '\n';
fs.appendFileSync(path.join(dir, 'events.jsonl'), line, 'utf8');
this.getRunBus(runId).emit('event', event);
this.emit('event', { runId, event });
}
writeMeta(runId: string, meta: RunMeta): void {
const dir = this.runDir(runId);
ensureDirSync(dir);
fs.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf8');
}
readMeta(runId: string): RunMeta | null {
const p = path.join(this.runDir(runId), 'meta.json');
if (!fs.existsSync(p)) return null;
return JSON.parse(fs.readFileSync(p, 'utf8')) as RunMeta;
}
readEvents(runId: string): RunEvent[] {
const p = path.join(this.runDir(runId), 'events.jsonl');
if (!fs.existsSync(p)) return [];
const text = fs.readFileSync(p, 'utf8');
const lines = text.split('\n').filter(Boolean);
return lines.map((l) => JSON.parse(l) as RunEvent);
}
writeStdoutSnapshot(runId: string, content: string): void {
const dir = this.runDir(runId);
ensureDirSync(dir);
fs.writeFileSync(path.join(dir, 'stdout.log'), content, 'utf8');
}
writeStderrSnapshot(runId: string, content: string): void {
const dir = this.runDir(runId);
ensureDirSync(dir);
fs.writeFileSync(path.join(dir, 'stderr.log'), content, 'utf8');
}
writeInputs(runId: string, inputs: Record<string, unknown>): void {
const dir = this.runDir(runId);
ensureDirSync(dir);
fs.writeFileSync(path.join(dir, 'inputs.redacted.json'), JSON.stringify(inputs, null, 2), 'utf8');
}
writeTouchpoints(runId: string, touchpoints: unknown[]): void {
const dir = this.runDir(runId);
ensureDirSync(dir);
fs.writeFileSync(path.join(dir, 'touchpoints.final.json'), JSON.stringify(touchpoints, null, 2), 'utf8');
}
writeCompliance(runId: string, rows: unknown[]): void {
const dir = this.runDir(runId);
ensureDirSync(dir);
fs.writeFileSync(path.join(dir, 'compliance.json'), JSON.stringify(rows, null, 2), 'utf8');
}
}
export function getJobStore(): JobStore {
if (!g.__missionControlJobStore) {
g.__missionControlJobStore = new JobStore();
}
return g.__missionControlJobStore;
}

View File

@@ -0,0 +1,49 @@
export type LaunchDestination = {
id: string;
title: string;
description: string;
href: string;
kind: 'external' | 'docs';
};
function envUrl(key: string, fallback: string): string {
if (typeof process === 'undefined') return fallback;
const v = process.env[key];
return v && v.length > 0 ? v : fallback;
}
export function getLaunchDestinations(): LaunchDestination[] {
return [
{
id: 'helper-site',
title: 'Proxmox helper scripts site',
description: 'Browse community Proxmox helper scripts and metadata (run separately on port 3000).',
href: envUrl('NEXT_PUBLIC_HELPER_SCRIPTS_URL', 'http://localhost:3000'),
kind: 'external',
},
{
id: 'explorer',
title: 'Chain 138 explorer',
description: 'Block explorer UI when deployed (set URL for your environment).',
href: envUrl('NEXT_PUBLIC_EXPLORER_URL', 'https://explorer.d-bis.org'),
kind: 'external',
},
{
id: 'docs-master',
title: 'Documentation index',
description: 'Master documentation index in this repository.',
href: envUrl('NEXT_PUBLIC_DOCS_MASTER_URL', 'https://gitea.d-bis.org/d-bis/proxmox/src/branch/main/docs/MASTER_INDEX.md'),
kind: 'docs',
},
{
id: 'operational-runbooks',
title: 'Operational runbooks (markdown)',
description: 'Canonical operational runbook index for deep procedures.',
href: envUrl(
'NEXT_PUBLIC_OPERATIONAL_RUNBOOKS_URL',
'https://gitea.d-bis.org/d-bis/proxmox/src/branch/main/docs/03-deployment/OPERATIONAL_RUNBOOKS.md',
),
kind: 'docs',
},
];
}

View File

@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { loadAllRunbookSpecs } from '@/lib/load-specs';
describe('runbook catalog', () => {
it('merges hand-written specs with all doc-derived runbooks', () => {
const all = loadAllRunbookSpecs();
expect(all.length).toBeGreaterThanOrEqual(56);
const ids = new Set(all.map((s) => s.id));
expect(ids.has('health-self-check')).toBe(true);
expect([...ids].some((id) => id.startsWith('doc-'))).toBe(true);
for (const s of all) {
expect(s.execution.steps.length).toBeGreaterThan(0);
}
});
});

View File

@@ -0,0 +1,82 @@
import fs from 'node:fs';
import path from 'node:path';
import { runbookSpecSchema, type RunbookSpec } from '@/lib/runbook-schema';
import { getMissionControlDir } from '@/lib/paths';
export function getSpecsDir(): string {
return path.join(getMissionControlDir(), 'runbooks', 'specs');
}
let docManifestCache: RunbookSpec[] | null = null;
function getDocManifestPath(): string {
return path.join(getMissionControlDir(), 'runbooks', 'doc-manifest.json');
}
function loadDocManifestRunbooks(): RunbookSpec[] {
if (docManifestCache) return docManifestCache;
const p = getDocManifestPath();
if (!fs.existsSync(p)) {
docManifestCache = [];
return docManifestCache;
}
const raw = JSON.parse(fs.readFileSync(p, 'utf8')) as { runbooks?: unknown[] };
const list = raw.runbooks ?? [];
const out: RunbookSpec[] = [];
for (const item of list) {
try {
out.push(runbookSpecSchema.parse(item));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error('[mission-control] Skipping invalid doc-manifest entry:', msg);
}
}
docManifestCache = out;
return out;
}
function loadJsonSpecsFromDir(): RunbookSpec[] {
const dir = getSpecsDir();
if (!fs.existsSync(dir)) {
return [];
}
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
const out: RunbookSpec[] = [];
for (const file of files) {
const fp = path.join(dir, file);
try {
const raw = fs.readFileSync(fp, 'utf8');
const parsed = JSON.parse(raw) as unknown;
out.push(runbookSpecSchema.parse(parsed));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`[mission-control] Skipping invalid runbook spec ${file}:`, msg);
}
}
return out;
}
export function loadAllRunbookSpecs(): RunbookSpec[] {
const byId = new Map<string, RunbookSpec>();
for (const spec of loadDocManifestRunbooks()) {
byId.set(spec.id, spec);
}
for (const spec of loadJsonSpecsFromDir()) {
byId.set(spec.id, spec);
}
return [...byId.values()].sort((a, b) => a.title.localeCompare(b.title));
}
export function loadRunbookSpec(id: string): RunbookSpec | null {
const fp = path.join(getSpecsDir(), `${id}.json`);
if (fs.existsSync(fp)) {
try {
const raw = fs.readFileSync(fp, 'utf8');
return runbookSpecSchema.parse(JSON.parse(raw));
} catch {
return null;
}
}
const fromDoc = loadDocManifestRunbooks().find((r) => r.id === id);
return fromDoc ?? null;
}

View File

@@ -0,0 +1,46 @@
import fs from 'node:fs';
import path from 'node:path';
/**
* Monorepo root (parent of mission-control/). Resolves from cwd or MISSION_CONTROL_PROJECT_ROOT.
*/
export function getProjectRoot(): string {
const override = process.env.MISSION_CONTROL_PROJECT_ROOT?.trim();
if (override) {
const abs = path.resolve(override);
if (!fs.existsSync(abs)) {
console.error(
`[mission-control] MISSION_CONTROL_PROJECT_ROOT does not exist (${abs}); falling back to auto-detect.`,
);
} else {
return abs;
}
}
const cwd = process.cwd();
const fromMc = path.resolve(cwd, '..');
const marker = path.join(fromMc, 'pnpm-workspace.yaml');
if (fs.existsSync(marker)) {
return fromMc;
}
const fromNested = path.resolve(cwd, '../..');
if (fs.existsSync(path.join(fromNested, 'pnpm-workspace.yaml'))) {
return fromNested;
}
return cwd;
}
export function getMissionControlDir(): string {
return path.join(getProjectRoot(), 'mission-control');
}
export function getRunDataDir(): string {
const dir = path.join(getMissionControlDir(), '.data', 'runs');
return dir;
}
export function ensureDirSync(dir: string): void {
fs.mkdirSync(dir, { recursive: true });
}

View File

@@ -0,0 +1,19 @@
import type { RunbookSpec } from '@/lib/runbook-schema';
export function redactInputs(
spec: RunbookSpec,
inputs: Record<string, unknown>,
): Record<string, unknown> {
const sensitive = new Set(
spec.inputs.filter((i) => i.sensitive).map((i) => i.name),
);
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(inputs)) {
if (sensitive.has(k)) {
out[k] = '[REDACTED]';
} else {
out[k] = v;
}
}
return out;
}

View File

@@ -0,0 +1,80 @@
import { z } from 'zod';
export const runEventSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('run_queued'),
ts: z.string(),
runbookId: z.string(),
specVersion: z.string(),
}),
z.object({
type: z.literal('allowlist_verified'),
ts: z.string(),
detail: z.string(),
}),
z.object({
type: z.literal('step_started'),
ts: z.string(),
stepIndex: z.number(),
stepTotal: z.number(),
scriptRelative: z.string(),
program: z.string(),
argsRedacted: z.array(z.string()),
}),
z.object({
type: z.literal('step_finished'),
ts: z.string(),
stepIndex: z.number(),
stepTotal: z.number(),
exitCode: z.number().nullable(),
error: z.string().optional(),
}),
z.object({
type: z.literal('process_spawned'),
ts: z.string(),
program: z.string(),
argsRedacted: z.array(z.string()),
cwd: z.string(),
}),
z.object({
type: z.literal('stdout_line'),
ts: z.string(),
line: z.string(),
}),
z.object({
type: z.literal('stderr_line'),
ts: z.string(),
line: z.string(),
}),
z.object({
type: z.literal('touchpoint_result'),
ts: z.string(),
touchpointId: z.string(),
status: z.enum(['PASS', 'FAIL', 'PENDING']),
evidence: z.string(),
grade: z.enum(['GREEN', 'AMBER', 'RED']),
}),
z.object({
type: z.literal('compliance_assertion'),
ts: z.string(),
controlId: z.string(),
framework: z.string(),
satisfied: z.boolean(),
evidence: z.string(),
}),
z.object({
type: z.literal('run_finished'),
ts: z.string(),
exitCode: z.number().nullable(),
overallGrade: z.enum(['GREEN', 'AMBER', 'RED']),
summary: z.string(),
}),
z.object({
type: z.literal('run_error'),
ts: z.string(),
message: z.string(),
overallGrade: z.literal('RED'),
}),
]);
export type RunEvent = z.infer<typeof runEventSchema>;

View File

@@ -0,0 +1,61 @@
import { z } from 'zod';
const inputFieldSchema = z.object({
name: z.string(),
label: z.string(),
type: z.enum(['boolean', 'string', 'number', 'select']),
help: z.string(),
example: z.string().optional(),
sensitive: z.boolean().optional(),
default: z.union([z.string(), z.boolean(), z.number()]).optional(),
options: z.array(z.object({ value: z.string(), label: z.string() })).optional(),
});
const touchpointSchema = z.object({
id: z.string(),
label: z.string(),
description: z.string(),
passCondition: z.enum(['exit_zero', 'stdout_contains', 'stdout_not_contains']),
pattern: z.string().optional(),
});
const stepSchema = z.object({
title: z.string(),
plainText: z.string(),
technicalNote: z.string().optional(),
example: z.string().optional(),
});
export const executionStepSchema = z.object({
interpreter: z.enum(['bash', 'node']),
scriptRelative: z.string(),
args: z.array(z.string()).optional(),
supportsDryRun: z.boolean().optional(),
/** When input name is truthy, append these args (after template substitution). */
whenInputTrue: z.record(z.string(), z.array(z.string())).optional(),
});
export const executionSchema = z.object({
steps: z.array(executionStepSchema).min(1),
});
export const runbookSpecSchema = z.object({
id: z.string(),
title: z.string(),
summary: z.string(),
whyItMatters: z.string(),
audienceHelp: z.string(),
docPath: z.string(),
prerequisites: z.array(z.string()),
steps: z.array(stepSchema),
inputs: z.array(inputFieldSchema),
touchpoints: z.array(touchpointSchema),
complianceFramework: z.string(),
execution: executionSchema,
executionNote: z.string().optional(),
});
export type RunbookSpec = z.infer<typeof runbookSpecSchema>;
export type RunbookInputField = z.infer<typeof inputFieldSchema>;
export type RunbookTouchpoint = z.infer<typeof touchpointSchema>;
export type ExecutionStep = z.infer<typeof executionStepSchema>;

View File

@@ -0,0 +1,80 @@
import type { RunbookSpec } from '@/lib/runbook-schema';
export type TouchpointState = {
id: string;
label: string;
status: 'PENDING' | 'PASS' | 'FAIL';
evidence: string;
grade: 'GREEN' | 'AMBER' | 'RED';
};
const initialStates = (spec: RunbookSpec): Map<string, TouchpointState> => {
const m = new Map<string, TouchpointState>();
for (const tp of spec.touchpoints) {
m.set(tp.id, {
id: tp.id,
label: tp.label,
status: 'PENDING',
evidence: 'Awaiting execution output',
grade: 'AMBER',
});
}
return m;
};
export function createTouchpointTracker(spec: RunbookSpec) {
const states = initialStates(spec);
const buffer = { stdout: '' };
function ingestStdout(chunk: string): void {
buffer.stdout += chunk;
for (const tp of spec.touchpoints) {
const st = states.get(tp.id);
if (!st || st.status !== 'PENDING') continue;
if (tp.passCondition === 'exit_zero') {
continue;
}
const pat = tp.pattern;
if (!pat) continue;
if (tp.passCondition === 'stdout_contains') {
if (buffer.stdout.includes(pat)) {
st.status = 'PASS';
st.evidence = `Stdout contained required pattern: ${JSON.stringify(pat)}`;
st.grade = 'GREEN';
}
} else if (tp.passCondition === 'stdout_not_contains') {
if (buffer.stdout.includes(pat)) {
st.status = 'FAIL';
st.evidence = `Stdout contained forbidden pattern: ${JSON.stringify(pat)}`;
st.grade = 'RED';
}
}
}
}
function finalize(exitCode: number | null): TouchpointState[] {
for (const tp of spec.touchpoints) {
const st = states.get(tp.id);
if (!st) continue;
if (tp.passCondition === 'exit_zero') {
if (exitCode === 0) {
st.status = 'PASS';
st.evidence = 'Process exited with code 0';
st.grade = 'GREEN';
} else {
st.status = 'FAIL';
st.evidence = `Process exited with code ${exitCode ?? 'null'}`;
st.grade = 'RED';
}
} else if (st.status === 'PENDING') {
st.status = 'FAIL';
st.evidence = 'Expected pattern or condition not observed before process end';
st.grade = 'RED';
}
}
return [...states.values()];
}
return { ingestStdout, finalize, getStates: () => [...states.values()] };
}

View File

@@ -0,0 +1,45 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
theme: {
extend: {
colors: {
tardis: {
deep: '#001a33',
panel: '#003b6f',
bright: '#0066cc',
glow: '#00b4d8',
amber: '#ffb703',
paper: '#f8fafc',
},
},
fontFamily: {
display: [
'system-ui',
'Segoe UI',
'Roboto',
'Helvetica Neue',
'Arial',
'sans-serif',
],
mono: [
'ui-monospace',
'SFMono-Regular',
'Menlo',
'Monaco',
'Consolas',
'Liberation Mono',
'monospace',
],
},
boxShadow: {
tardis: '0 0 24px rgba(0, 180, 216, 0.35)',
panel: 'inset 0 1px 0 rgba(255, 255, 255, 0.06)',
},
},
},
plugins: [],
};
export default config;

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,22 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
const root = fileURLToPath(new URL('.', import.meta.url));
const monorepoRoot = path.resolve(root, '..');
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
testTimeout: 60_000,
env: {
MISSION_CONTROL_PROJECT_ROOT: monorepoRoot,
},
},
resolve: {
alias: {
'@': path.resolve(root, 'src'),
},
},
});

View File

@@ -24,7 +24,11 @@
"test": "pnpm --filter mcp-proxmox-server test || echo \"No tests specified\"",
"test:basic": "cd mcp-proxmox && node test-basic-tools.js",
"test:workflows": "cd mcp-proxmox && node test-workflows.js",
"verify:ws-chain138": "node scripts/verify-ws-rpc-chain138.mjs"
"verify:ws-chain138": "node scripts/verify-ws-rpc-chain138.mjs",
"mission-control:dev": "pnpm --filter mission-control dev",
"mission-control:build": "pnpm --filter mission-control build",
"mission-control:start": "pnpm --filter mission-control start",
"mission-control:test": "pnpm --filter mission-control test"
},
"keywords": [
"proxmox",

20806
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,13 +2,13 @@ packages:
- alltra-lifi-settlement
- multi-chain-execution
- mcp-proxmox
- mcp-omada
# mcp-omada / omada-api: submodule remote unavailable (ARROMIS/omada-api); omit until cloned
- mcp-unifi
- mcp-site-manager
- omada-api
- unifi-api
- site-manager-api
- ProxmoxVE/frontend
- mission-control
- rpc-translator-138
- smom-dbis-138/frontend-dapp
- smom-dbis-138/services/token-aggregation

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env node
/**
* Minimal health probe for Mission Control (allowlisted path under scripts/).
*/
console.log('MISSION_CONTROL_HEALTH_OK');