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:
@@ -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)
|
||||
|
||||
Submodule mcp-proxmox updated: 3cd98f979a...1d7e9c2d4e
4
mission-control/.eslintrc.json
Normal file
4
mission-control/.eslintrc.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"root": true
|
||||
}
|
||||
6
mission-control/.gitignore
vendored
Normal file
6
mission-control/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.next
|
||||
node_modules
|
||||
.data
|
||||
*.tsbuildinfo
|
||||
coverage
|
||||
playwright-report
|
||||
71
mission-control/README.md
Normal file
71
mission-control/README.md
Normal 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.
|
||||
17
mission-control/TIMELINE.md
Normal file
17
mission-control/TIMELINE.md
Normal 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 | 1–2 days | **Done** |
|
||||
| **P1** | Runbook JSON schema, catalog UI, help tooltips, GO button, POST `/api/runs` | 2–3 days | **Done** |
|
||||
| **P2** | Allowlisted executor (bash + node), job store, SSE stream, live panels | 3–4 days | **Done** |
|
||||
| **P3** | Touchpoint grading, compliance assertions, audit ZIP + checksums | 2–3 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 | 1–2 weeks | *Future* |
|
||||
| **P6** | Map remaining `docs/**` runbooks to specs + narrow allowlist expansion | Ongoing | *Future* |
|
||||
|
||||
**Total (P0–P4):** roughly **9–13** 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
6
mission-control/next-env.d.ts
vendored
Normal 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.
|
||||
7
mission-control/next.config.mjs
Normal file
7
mission-control/next.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
serverExternalPackages: ['archiver'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
40
mission-control/package.json
Normal file
40
mission-control/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
9
mission-control/postcss.config.mjs
Normal file
9
mission-control/postcss.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
4239
mission-control/runbooks/doc-manifest.json
Normal file
4239
mission-control/runbooks/doc-manifest.json
Normal file
File diff suppressed because it is too large
Load Diff
43
mission-control/runbooks/specs/health-self-check.json
Normal file
43
mission-control/runbooks/specs/health-self-check.json
Normal 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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
42
mission-control/runbooks/specs/reconcile-env-canonical.json
Normal file
42
mission-control/runbooks/specs/reconcile-env-canonical.json
Normal 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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
59
mission-control/runbooks/specs/run-completable-anywhere.json
Normal file
59
mission-control/runbooks/specs/run-completable-anywhere.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
63
mission-control/runbooks/specs/validate-config-files.json
Normal file
63
mission-control/runbooks/specs/validate-config-files.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
35
mission-control/runbooks/specs/verify-ws-rpc-chain138.json
Normal file
35
mission-control/runbooks/specs/verify-ws-rpc-chain138.json
Normal 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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
238
mission-control/scripts/generate-doc-runbook-manifest.mjs
Normal file
238
mission-control/scripts/generate-doc-runbook-manifest.mjs
Normal 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();
|
||||
17
mission-control/src/app/api/runbooks/[id]/route.ts
Normal file
17
mission-control/src/app/api/runbooks/[id]/route.ts
Normal 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);
|
||||
}
|
||||
15
mission-control/src/app/api/runbooks/route.ts
Normal file
15
mission-control/src/app/api/runbooks/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
27
mission-control/src/app/api/runs/[id]/audit/route.ts
Normal file
27
mission-control/src/app/api/runs/[id]/audit/route.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
19
mission-control/src/app/api/runs/[id]/route.ts
Normal file
19
mission-control/src/app/api/runs/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
70
mission-control/src/app/api/runs/[id]/stream/route.ts
Normal file
70
mission-control/src/app/api/runs/[id]/stream/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
43
mission-control/src/app/api/runs/route.ts
Normal file
43
mission-control/src/app/api/runs/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
29
mission-control/src/app/globals.css
Normal file
29
mission-control/src/app/globals.css
Normal 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);
|
||||
}
|
||||
20
mission-control/src/app/layout.tsx
Normal file
20
mission-control/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
mission-control/src/app/page.tsx
Normal file
62
mission-control/src/app/page.tsx
Normal 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 natives”—with 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>
|
||||
);
|
||||
}
|
||||
186
mission-control/src/app/runbooks/[runbookId]/RunbookRunner.tsx
Normal file
186
mission-control/src/app/runbooks/[runbookId]/RunbookRunner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
mission-control/src/app/runbooks/[runbookId]/not-found.tsx
Normal file
13
mission-control/src/app/runbooks/[runbookId]/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
mission-control/src/app/runbooks/[runbookId]/page.tsx
Normal file
29
mission-control/src/app/runbooks/[runbookId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
64
mission-control/src/app/runbooks/page.tsx
Normal file
64
mission-control/src/app/runbooks/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
mission-control/src/components/GoButton.tsx
Normal file
29
mission-control/src/components/GoButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
mission-control/src/components/HelpTip.tsx
Normal file
62
mission-control/src/components/HelpTip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
mission-control/src/lib/allowlist.ts
Normal file
9
mission-control/src/lib/allowlist.ts
Normal 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);
|
||||
}
|
||||
91
mission-control/src/lib/audit-zip.ts
Normal file
91
mission-control/src/lib/audit-zip.ts
Normal 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);
|
||||
}
|
||||
6
mission-control/src/lib/cn.ts
Normal file
6
mission-control/src/lib/cn.ts
Normal 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));
|
||||
}
|
||||
23
mission-control/src/lib/coerce-inputs.ts
Normal file
23
mission-control/src/lib/coerce-inputs.ts
Normal 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;
|
||||
}
|
||||
46
mission-control/src/lib/execution-path-validator.ts
Normal file
46
mission-control/src/lib/execution-path-validator.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
107
mission-control/src/lib/execution-plan.ts
Normal file
107
mission-control/src/lib/execution-plan.ts
Normal 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;
|
||||
}
|
||||
36
mission-control/src/lib/executor.test.ts
Normal file
36
mission-control/src/lib/executor.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
382
mission-control/src/lib/executor.ts
Normal file
382
mission-control/src/lib/executor.ts
Normal 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 };
|
||||
}
|
||||
111
mission-control/src/lib/job-store.ts
Normal file
111
mission-control/src/lib/job-store.ts
Normal 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;
|
||||
}
|
||||
49
mission-control/src/lib/launchpad.ts
Normal file
49
mission-control/src/lib/launchpad.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
}
|
||||
15
mission-control/src/lib/load-specs.test.ts
Normal file
15
mission-control/src/lib/load-specs.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
82
mission-control/src/lib/load-specs.ts
Normal file
82
mission-control/src/lib/load-specs.ts
Normal 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;
|
||||
}
|
||||
46
mission-control/src/lib/paths.ts
Normal file
46
mission-control/src/lib/paths.ts
Normal 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 });
|
||||
}
|
||||
19
mission-control/src/lib/redact.ts
Normal file
19
mission-control/src/lib/redact.ts
Normal 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;
|
||||
}
|
||||
80
mission-control/src/lib/run-events.ts
Normal file
80
mission-control/src/lib/run-events.ts
Normal 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>;
|
||||
61
mission-control/src/lib/runbook-schema.ts
Normal file
61
mission-control/src/lib/runbook-schema.ts
Normal 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>;
|
||||
80
mission-control/src/lib/touchpoint-evaluator.ts
Normal file
80
mission-control/src/lib/touchpoint-evaluator.ts
Normal 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()] };
|
||||
}
|
||||
45
mission-control/tailwind.config.ts
Normal file
45
mission-control/tailwind.config.ts
Normal 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;
|
||||
21
mission-control/tsconfig.json
Normal file
21
mission-control/tsconfig.json
Normal 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"]
|
||||
}
|
||||
22
mission-control/vitest.config.ts
Normal file
22
mission-control/vitest.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
20806
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
5
scripts/mission-control/health-check.mjs
Normal file
5
scripts/mission-control/health-check.mjs
Normal 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');
|
||||
Reference in New Issue
Block a user