From 63dd44a11b1c6e770a494582673bfbb31b29cbbf Mon Sep 17 00:00:00 2001 From: thanhnv Date: Wed, 8 Jul 2026 16:59:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(plan-13):=20read-only=20Ops=20Console=20(N?= =?UTF-8?q?estJS=20API=20+=20React=20UI)=20=E2=80=94=20Track=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real web Control Panel over CASAN harness telemetry (Level-3 casan-platform component). Read-only ("Đọc ≠ Ghi"): no settings writes, no gate bypass. Management/RBAC/approval are Track 2/3 (future, Plan-14). Additive — harness gate untouched (64/0/3). packages/casan-control-panel/ - backend/ (NestJS, ESM, /api/v1 + ok() envelope): TelemetryReader (jsonl/json, missing→[], never fabricates) + TelemetryService (aggregations mirroring generate-agentops-dashboard.py) + endpoints overview/runs(+:traceId)/governance/security/incidents/tools/traceability/ drift/cost, and /healthz (stale-aware 200/503, fail-loud like dashboard-server.py). App root + telemetry paths resolve via casan-paths-style marker walk-up (.specify OR packages/casan-harness) + honor CASAN_DASHBOARD_* env. Binds 127.0.0.1; refuses non-loopback under CASAN_PROFILE=prod. @Inject token so DI works under tsc AND tsx. Tests (node native runner) 7/0: reader parse/missing, app-root, overview shape on real repo state, freshness/stale fail-loud. - frontend/ (React+Vite+Tailwind+TanStack, port 5174, proxies to :3010): AppLayout + Sidebar + Header (LIVE/STALE badge from /healthz) + pages Overview/Runs/Governance/ Security/Incidents/Traceability. axios client unwraps ok() envelope. build green. Wiring: root workspaces + `console:*` scripts. packaging/levels.json + casan-platform README: platform preview now lists the Ops Console as an implemented component. Verified: backend build + test 7/0; frontend tsc + vite build; API serves REAL data (runs=6, provider_tokens=5556, action_blocks=7); /healthz 503 stale → 200 after touch. Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 122 +++++++++++----- package.json | 10 +- packages/casan-control-panel/README.md | 43 ++++++ .../casan-control-panel/backend/package.json | 26 ++++ .../backend/src/app.module.ts | 9 ++ .../backend/src/common/api-response.ts | 11 ++ .../backend/src/common/app-root.ts | 65 +++++++++ .../backend/src/health/health.controller.ts | 23 +++ .../casan-control-panel/backend/src/main.ts | 29 ++++ .../src/telemetry/telemetry.controller.ts | 64 ++++++++ .../backend/src/telemetry/telemetry.module.ts | 9 ++ .../backend/src/telemetry/telemetry.reader.ts | 50 +++++++ .../src/telemetry/telemetry.service.ts | 137 ++++++++++++++++++ .../backend/test/telemetry.test.ts | 64 ++++++++ .../backend/tsconfig.build.json | 6 + .../casan-control-panel/backend/tsconfig.json | 17 +++ .../casan-control-panel/frontend/index.html | 12 ++ .../casan-control-panel/frontend/package.json | 29 ++++ .../frontend/postcss.config.js | 1 + .../casan-control-panel/frontend/src/App.tsx | 24 +++ .../src/components/layout/AppLayout.tsx | 14 ++ .../frontend/src/components/layout/Header.tsx | 17 +++ .../src/components/layout/Sidebar.tsx | 20 +++ .../frontend/src/components/ui/Card.tsx | 32 ++++ .../frontend/src/index.css | 3 + .../frontend/src/lib/api.ts | 44 ++++++ .../frontend/src/lib/queryClient.ts | 6 + .../casan-control-panel/frontend/src/main.tsx | 17 +++ .../frontend/src/pages/Governance.tsx | 27 ++++ .../frontend/src/pages/Incidents.tsx | 27 ++++ .../frontend/src/pages/Overview.tsx | 37 +++++ .../frontend/src/pages/Runs.tsx | 31 ++++ .../frontend/src/pages/Security.tsx | 20 +++ .../frontend/src/pages/Traceability.tsx | 30 ++++ .../frontend/src/vite-env.d.ts | 1 + .../frontend/tailwind.config.ts | 3 + .../frontend/tsconfig.json | 16 ++ .../frontend/tsconfig.tsbuildinfo | 1 + .../frontend/vite.config.ts | 15 ++ packages/casan-platform/README.md | 5 +- packaging/levels.json | 4 +- 41 files changed, 1078 insertions(+), 43 deletions(-) create mode 100644 packages/casan-control-panel/README.md create mode 100644 packages/casan-control-panel/backend/package.json create mode 100644 packages/casan-control-panel/backend/src/app.module.ts create mode 100644 packages/casan-control-panel/backend/src/common/api-response.ts create mode 100644 packages/casan-control-panel/backend/src/common/app-root.ts create mode 100644 packages/casan-control-panel/backend/src/health/health.controller.ts create mode 100644 packages/casan-control-panel/backend/src/main.ts create mode 100644 packages/casan-control-panel/backend/src/telemetry/telemetry.controller.ts create mode 100644 packages/casan-control-panel/backend/src/telemetry/telemetry.module.ts create mode 100644 packages/casan-control-panel/backend/src/telemetry/telemetry.reader.ts create mode 100644 packages/casan-control-panel/backend/src/telemetry/telemetry.service.ts create mode 100644 packages/casan-control-panel/backend/test/telemetry.test.ts create mode 100644 packages/casan-control-panel/backend/tsconfig.build.json create mode 100644 packages/casan-control-panel/backend/tsconfig.json create mode 100644 packages/casan-control-panel/frontend/index.html create mode 100644 packages/casan-control-panel/frontend/package.json create mode 100644 packages/casan-control-panel/frontend/postcss.config.js create mode 100644 packages/casan-control-panel/frontend/src/App.tsx create mode 100644 packages/casan-control-panel/frontend/src/components/layout/AppLayout.tsx create mode 100644 packages/casan-control-panel/frontend/src/components/layout/Header.tsx create mode 100644 packages/casan-control-panel/frontend/src/components/layout/Sidebar.tsx create mode 100644 packages/casan-control-panel/frontend/src/components/ui/Card.tsx create mode 100644 packages/casan-control-panel/frontend/src/index.css create mode 100644 packages/casan-control-panel/frontend/src/lib/api.ts create mode 100644 packages/casan-control-panel/frontend/src/lib/queryClient.ts create mode 100644 packages/casan-control-panel/frontend/src/main.tsx create mode 100644 packages/casan-control-panel/frontend/src/pages/Governance.tsx create mode 100644 packages/casan-control-panel/frontend/src/pages/Incidents.tsx create mode 100644 packages/casan-control-panel/frontend/src/pages/Overview.tsx create mode 100644 packages/casan-control-panel/frontend/src/pages/Runs.tsx create mode 100644 packages/casan-control-panel/frontend/src/pages/Security.tsx create mode 100644 packages/casan-control-panel/frontend/src/pages/Traceability.tsx create mode 100644 packages/casan-control-panel/frontend/src/vite-env.d.ts create mode 100644 packages/casan-control-panel/frontend/tailwind.config.ts create mode 100644 packages/casan-control-panel/frontend/tsconfig.json create mode 100644 packages/casan-control-panel/frontend/tsconfig.tsbuildinfo create mode 100644 packages/casan-control-panel/frontend/vite.config.ts diff --git a/package-lock.json b/package-lock.json index 2abc63b..7ab10ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,9 @@ "name": "ainative-okr-casan5", "workspaces": [ "backend", - "frontend" + "frontend", + "packages/casan-control-panel/backend", + "packages/casan-control-panel/frontend" ] }, "backend": { @@ -418,6 +420,14 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@casan/control-panel-backend": { + "resolved": "packages/casan-control-panel/backend", + "link": true + }, + "node_modules/@casan/control-panel-frontend": { + "resolved": "packages/casan-control-panel/frontend", + "link": true + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -1269,7 +1279,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", @@ -1282,14 +1292,14 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -1303,14 +1313,14 @@ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3", @@ -1322,7 +1332,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3" @@ -1698,7 +1708,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tanstack/query-core": { @@ -2590,7 +2600,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.3", @@ -2619,7 +2629,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -2635,7 +2645,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -2645,7 +2655,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -2810,7 +2820,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "consola": "^3.2.3" @@ -2820,7 +2830,7 @@ "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -2912,7 +2922,7 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/consola": { @@ -3148,7 +3158,7 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -3158,7 +3168,7 @@ "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/delayed-stream": { @@ -3193,7 +3203,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/destroy": { @@ -3242,7 +3252,7 @@ "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -3284,7 +3294,7 @@ "version": "3.21.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -3302,7 +3312,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -3540,14 +3550,14 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -3861,7 +3871,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -3879,7 +3889,7 @@ "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -4697,7 +4707,7 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/node-releases": { @@ -4731,7 +4741,7 @@ "version": "0.6.8", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.8.tgz", "integrity": "sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.2.2", @@ -4749,7 +4759,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/object-assign": { @@ -4787,7 +4797,7 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/on-finished": { @@ -4861,7 +4871,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/pathval": { @@ -4878,7 +4888,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -4925,7 +4935,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "confbox": "^0.2.4", @@ -5128,7 +5138,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -5186,7 +5196,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -5263,7 +5273,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "defu": "^6.1.4", @@ -6040,7 +6050,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -6240,7 +6250,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -7128,6 +7138,44 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "packages/casan-control-panel/backend": { + "name": "@casan/control-panel-backend", + "version": "1.0.0", + "dependencies": { + "@nestjs/common": "^10.4.20", + "@nestjs/core": "^10.4.20", + "@nestjs/platform-express": "^10.4.20", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^24.0.8", + "tsx": "^4.20.3", + "typescript": "^5.8.3" + } + }, + "packages/casan-control-panel/frontend": { + "name": "@casan/control-panel-frontend", + "version": "1.0.0", + "dependencies": { + "@tanstack/react-query": "^5.81.5", + "axios": "^1.10.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.1" + }, + "devDependencies": { + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.6.0", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", + "typescript": "^5.8.3", + "vite": "^5.4.19" + } } } } diff --git a/package.json b/package.json index b93dd84..cc15cb3 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,16 @@ "private": true, "workspaces": [ "backend", - "frontend" + "frontend", + "packages/casan-control-panel/backend", + "packages/casan-control-panel/frontend" ], "scripts": { "build": "npm run build -w backend && npm run build -w frontend", - "test": "npm test -w backend && npm test -w frontend" + "test": "npm test -w backend && npm test -w frontend", + "console:api": "npm run dev -w @casan/control-panel-backend", + "console:ui": "npm run dev -w @casan/control-panel-frontend", + "console:build": "npm run build -w @casan/control-panel-backend && npm run build -w @casan/control-panel-frontend", + "console:test": "npm test -w @casan/control-panel-backend" } } diff --git a/packages/casan-control-panel/README.md b/packages/casan-control-panel/README.md new file mode 100644 index 0000000..58ccadc --- /dev/null +++ b/packages/casan-control-panel/README.md @@ -0,0 +1,43 @@ +# CASAN Ops Console (Plan-13 Track 1) — read-only Control Panel + +Real **NestJS API + React UI** that surfaces CASAN harness telemetry. This is the Level-3 +`casan-platform` **Control Panel** component. **Read-only** ("Đọc ≠ Ghi"): it never writes +settings or bypasses a gate — management (settings/RBAC/approval) is Plan-13 Track 2/3 +(future, soft-blocked by Plan-14). + +``` +backend/ NestJS read-only API (/api/v1 + /healthz) over .specify telemetry +frontend/ React + Vite + Tailwind + TanStack Query Ops Console +``` + +## Run (local) +```bash +npm install # from repo root (picks up the workspaces) +npm run console:api # NestJS API → http://127.0.0.1:3010/api/v1 +npm run console:ui # Vite UI → http://127.0.0.1:5174 (proxies to the API) +``` +Open http://127.0.0.1:5174 — panels show REAL metrics from `.specify/logs/**`. + +## API (all read-only, `ok()`-enveloped except `/healthz`) +`GET /api/v1/overview` · `runs` (+ `runs/:traceId`) · `governance` · `security` · +`incidents` · `tools` · `traceability` · `drift` · `cost` · `GET /healthz` (200 fresh / +503 stale — fail-loud, mirrors `dashboard-server.py`). + +Data sources + aggregation mirror `packages/casan-harness/tests/generate-agentops-dashboard.py`. +App root + telemetry paths resolve via the same marker walk-up as `casan-paths.sh` +(`.specify` or `packages/casan-harness`) and honor `CASAN_DASHBOARD_*` env overrides. + +## Security posture (MVP) +Binds `127.0.0.1`, no auth (read-only local ops). Refuses a non-loopback bind under +`CASAN_PROFILE=prod` / `CASAN_CP_STRICT=1` — off-loopback exposure needs TLS/OIDC (Plan-13 +Track 4). Auth/login (reuse OKR JWT) is a follow-up. + +## Test +```bash +npm run console:test # backend telemetry reader/service + healthz logic +``` + +## Not in this pass +Track 2 settings writes (wrap `control-plane-settings.py`), Track 3 RBAC + approval inbox +(Plan-14), Track 4 docker/deploy + TLS/OIDC + FinOps/SLO. See +`docs/plans/CASAN_PLAN_13_CONTROL_PLANE.md`. diff --git a/packages/casan-control-panel/backend/package.json b/packages/casan-control-panel/backend/package.json new file mode 100644 index 0000000..63f7d93 --- /dev/null +++ b/packages/casan-control-panel/backend/package.json @@ -0,0 +1,26 @@ +{ + "name": "@casan/control-panel-backend", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "CASAN Ops Console — read-only NestJS API over harness telemetry (Plan-13 Track 1).", + "scripts": { + "build": "tsc -p tsconfig.build.json", + "dev": "tsx watch src/main.ts", + "start": "node dist/main.js", + "test": "node --import tsx --test test/*.test.ts" + }, + "dependencies": { + "@nestjs/common": "^10.4.20", + "@nestjs/core": "^10.4.20", + "@nestjs/platform-express": "^10.4.20", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^24.0.8", + "tsx": "^4.20.3", + "typescript": "^5.8.3" + } +} diff --git a/packages/casan-control-panel/backend/src/app.module.ts b/packages/casan-control-panel/backend/src/app.module.ts new file mode 100644 index 0000000..85eecaa --- /dev/null +++ b/packages/casan-control-panel/backend/src/app.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TelemetryModule } from './telemetry/telemetry.module.js'; +import { HealthController } from './health/health.controller.js'; + +@Module({ + imports: [TelemetryModule], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/packages/casan-control-panel/backend/src/common/api-response.ts b/packages/casan-control-panel/backend/src/common/api-response.ts new file mode 100644 index 0000000..5b6fba3 --- /dev/null +++ b/packages/casan-control-panel/backend/src/common/api-response.ts @@ -0,0 +1,11 @@ +// Standard API envelope — mirrors backend/src/common/api-response.ts so the console +// frontend's axios client (which unwraps response.data.data) works identically. +export interface ApiResponse { + success: true; + data: T; + meta?: Record; +} + +export function ok(data: T, meta?: Record): ApiResponse { + return meta ? { success: true, data, meta } : { success: true, data }; +} diff --git a/packages/casan-control-panel/backend/src/common/app-root.ts b/packages/casan-control-panel/backend/src/common/app-root.ts new file mode 100644 index 0000000..e695066 --- /dev/null +++ b/packages/casan-control-panel/backend/src/common/app-root.ts @@ -0,0 +1,65 @@ +// Resolve the CASAN app root + telemetry file paths for the read-only Ops Console. +// +// Mirrors packages/casan-harness/scripts/bash/casan-paths.sh: walk UP from a start dir +// for a marker (`.specify` state dir OR `packages/casan-harness`), so the API reads the +// real harness telemetry whether launched from the repo, a workspace subdir, or a bundle. +// Every path is overridable by the same CASAN_DASHBOARD_* env vars the legacy +// dashboard-server.py honors, so console + dashboard read identical sources. +import { existsSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +function findAppRoot(start: string): string { + let d = resolve(start); + while (d !== dirname(d)) { + if (existsSync(join(d, '.specify')) || existsSync(join(d, 'packages', 'casan-harness'))) { + return d; + } + d = dirname(d); + } + // fallback: cwd (last resort) + return process.cwd(); +} + +export const APP_ROOT = process.env.CASAN_APP_ROOT + ? resolve(process.env.CASAN_APP_ROOT) + : findAppRoot(HERE); + +const env = (k: string, fallback: string) => (process.env[k] ? resolve(process.env[k]!) : join(APP_ROOT, fallback)); + +// Runtime telemetry (regenerated) + generated reports. Names match the harness layout. +export const PATHS = { + metrics: env('CASAN_DASHBOARD_METRICS', '.specify/logs/cost/metrics.jsonl'), + providerUsage: env('CASAN_CP_PROVIDER_USAGE', '.specify/logs/level5/provider-usage.jsonl'), + audit: env('CASAN_CP_AUDIT', '.specify/logs/audit/audit.jsonl'), + auditHead: env('CASAN_CP_AUDIT_HEAD', '.specify/logs/audit/audit-head.txt'), + security: env('CASAN_CP_SECURITY', '.specify/logs/audit/security.jsonl'), + toolRegistry: env('CASAN_CP_TOOL_REGISTRY', '.specify/logs/level5/tool-registry.jsonl'), + actionGate: env('CASAN_CP_ACTION_GATE', '.specify/logs/level5/action-gate.jsonl'), + fallback: env('CASAN_CP_FALLBACK', '.specify/logs/level5/fallback.jsonl'), + incidents: env('CASAN_CP_INCIDENTS', '.specify/logs/level5/incidents.jsonl'), + alerts: env('CASAN_DASHBOARD_ALERTS', '.specify/agentops/alerts.log'), + traceDir: env('CASAN_CP_TRACE_DIR', '.specify/logs/trace'), + traceability: env('CASAN_CP_TRACEABILITY', 'docs/output/casan/traceability-matrix.json'), + drift: env('CASAN_CP_DRIFT', 'docs/output/casan/level5-evidence/09-drift-report.json'), + businessKpi: env('CASAN_CP_KPI', 'docs/output/casan/level5-evidence/14-business-kpi-report.json'), + benignFp: env('CASAN_CP_BENIGN_FP', 'docs/output/casan/benign-fp-report.json'), +}; + +export const STALE_AFTER_S = Number(process.env.CASAN_DASHBOARD_STALE_S ?? 3600); + +// Freshness of the primary metrics feed (mtime), used by /healthz + `stale` flags. +export function metricsAgeSeconds(): number | null { + try { + return Math.floor((Date.now() - statSync(PATHS.metrics).mtimeMs) / 1000); + } catch { + return null; // missing → treated as stale + } +} + +export function isStale(): boolean { + const age = metricsAgeSeconds(); + return age === null || age > STALE_AFTER_S; +} diff --git a/packages/casan-control-panel/backend/src/health/health.controller.ts b/packages/casan-control-panel/backend/src/health/health.controller.ts new file mode 100644 index 0000000..69a95de --- /dev/null +++ b/packages/casan-control-panel/backend/src/health/health.controller.ts @@ -0,0 +1,23 @@ +// Stale-aware health — mirrors dashboard-server.py /healthz: 200 when the metrics feed is +// fresh, 503 when stale or missing (fail-loud: never present stale data as live). +import { Controller, Get, Res } from '@nestjs/common'; +import type { Response } from 'express'; +import { isStale, metricsAgeSeconds, STALE_AFTER_S } from '../common/app-root.js'; +import { readJsonl } from '../telemetry/telemetry.reader.js'; +import { PATHS } from '../common/app-root.js'; + +@Controller() +export class HealthController { + @Get('healthz') + healthz(@Res() res: Response) { + const age = metricsAgeSeconds(); + const stale = isStale(); + const body = { + status: stale ? 'stale' : 'ok', + metrics_age_s: age, + stale_after_s: STALE_AFTER_S, + runs: readJsonl(PATHS.metrics).length, + }; + res.status(stale ? 503 : 200).json(body); + } +} diff --git a/packages/casan-control-panel/backend/src/main.ts b/packages/casan-control-panel/backend/src/main.ts new file mode 100644 index 0000000..660d499 --- /dev/null +++ b/packages/casan-control-panel/backend/src/main.ts @@ -0,0 +1,29 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { AppModule } from './app.module.js'; +import { APP_ROOT } from './common/app-root.js'; + +// Read-only Ops Console API (Plan-13 Track 1). Binds loopback by default and refuses a +// non-loopback bind under CASAN_PROFILE=prod / CASAN_CP_STRICT=1 — same posture as +// dashboard-server.py. Management/auth are out of scope (future tracks). +async function bootstrap() { + const app = await NestFactory.create(AppModule, { cors: true }); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + + const port = Number(process.env.CP_PORT ?? 3010); + let host = process.env.CP_BIND ?? '127.0.0.1'; + const strict = process.env.CASAN_PROFILE === 'prod' || process.env.CASAN_CP_STRICT === '1'; + if (strict && host !== '127.0.0.1' && host !== 'localhost') { + // read-only console must not expose telemetry off-loopback without the prod hardening + // (TLS/OIDC) that is Track 4 — fail closed. + // eslint-disable-next-line no-console + console.error(`CP_REFUSE_NONLOOPBACK host=${host} (set up TLS/OIDC per Plan-13 Track 4 first)`); + process.exit(2); + } + + await app.listen(port, host); + // eslint-disable-next-line no-console + console.log(`CASAN Ops Console API (read-only) http://${host}:${port}/api/v1 app_root=${APP_ROOT}`); +} +bootstrap(); diff --git a/packages/casan-control-panel/backend/src/telemetry/telemetry.controller.ts b/packages/casan-control-panel/backend/src/telemetry/telemetry.controller.ts new file mode 100644 index 0000000..c4791be --- /dev/null +++ b/packages/casan-control-panel/backend/src/telemetry/telemetry.controller.ts @@ -0,0 +1,64 @@ +// Read-only Ops Console API. Every handler returns the standard ok() envelope. No writes, +// no auth (loopback-bound, "Đọc ≠ Ghi"); management/RBAC is Plan-13 Track 2/3 (future). +import { Controller, Get, Inject, Param, Query } from '@nestjs/common'; +import { ok } from '../common/api-response.js'; +import { TelemetryService } from './telemetry.service.js'; + +@Controller('api/v1') +export class TelemetryController { + // Explicit @Inject token so DI works under BOTH tsc (emits decorator metadata) and the + // tsx/esbuild dev runner (which does not emit design:paramtypes). + constructor(@Inject(TelemetryService) private readonly svc: TelemetryService) {} + + @Get('overview') + overview() { + return ok(this.svc.overview()); + } + + @Get('runs') + runs(@Query('limit') limit?: string) { + const n = Math.min(Math.max(Number(limit) || 50, 1), 500); + const r = this.svc.runs(n); + return ok(r, { total: r.count }); + } + + @Get('runs/:traceId') + run(@Param('traceId') traceId: string) { + return ok(this.svc.run(traceId)); + } + + @Get('governance') + governance() { + return ok(this.svc.governance()); + } + + @Get('security') + security() { + return ok(this.svc.security()); + } + + @Get('incidents') + incidents() { + return ok(this.svc.incidents()); + } + + @Get('tools') + tools() { + return ok(this.svc.tools()); + } + + @Get('traceability') + traceability() { + return ok(this.svc.traceability()); + } + + @Get('drift') + drift() { + return ok(this.svc.drift()); + } + + @Get('cost') + cost() { + return ok(this.svc.cost()); + } +} diff --git a/packages/casan-control-panel/backend/src/telemetry/telemetry.module.ts b/packages/casan-control-panel/backend/src/telemetry/telemetry.module.ts new file mode 100644 index 0000000..0df79c5 --- /dev/null +++ b/packages/casan-control-panel/backend/src/telemetry/telemetry.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TelemetryController } from './telemetry.controller.js'; +import { TelemetryService } from './telemetry.service.js'; + +@Module({ + controllers: [TelemetryController], + providers: [TelemetryService], +}) +export class TelemetryModule {} diff --git a/packages/casan-control-panel/backend/src/telemetry/telemetry.reader.ts b/packages/casan-control-panel/backend/src/telemetry/telemetry.reader.ts new file mode 100644 index 0000000..0035abc --- /dev/null +++ b/packages/casan-control-panel/backend/src/telemetry/telemetry.reader.ts @@ -0,0 +1,50 @@ +// Read-only readers for CASAN telemetry. Tolerant of missing/partial files (returns [] or +// null) — never throws on absent data, never fabricates. Path fields inside records are +// treated as opaque strings (some are stale absolute paths from other machines). +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** Parse a JSON-lines file → array of objects. Missing file or bad lines are skipped. */ +export function readJsonl>(path: string): T[] { + if (!existsSync(path)) return []; + const out: T[] = []; + for (const line of readFileSync(path, 'utf8').split('\n')) { + const s = line.trim(); + if (!s) continue; + try { + out.push(JSON.parse(s) as T); + } catch { + /* skip malformed line */ + } + } + return out; +} + +/** Parse a single JSON object file → object or null when absent/invalid. */ +export function readJson>(path: string): T | null { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, 'utf8')) as T; + } catch { + return null; + } +} + +/** First non-empty line of a small text file (e.g. audit head hash), or null. */ +export function readHead(path: string): string | null { + if (!existsSync(path)) return null; + const t = readFileSync(path, 'utf8').trim(); + return t || null; +} + +/** Read one per-trace JSON from the trace dir by trace id (matches *.json). */ +export function readTrace(traceDir: string, traceId: string): Record | null { + if (!existsSync(traceDir)) return null; + // trace files are named -.json; find by suffix match on trace_id + for (const f of readdirSync(traceDir)) { + if (!f.endsWith('.json')) continue; + const rec = readJson>(join(traceDir, f)); + if (rec && (rec.trace_id === traceId || f.includes(traceId))) return rec; + } + return null; +} diff --git a/packages/casan-control-panel/backend/src/telemetry/telemetry.service.ts b/packages/casan-control-panel/backend/src/telemetry/telemetry.service.ts new file mode 100644 index 0000000..8c6296a --- /dev/null +++ b/packages/casan-control-panel/backend/src/telemetry/telemetry.service.ts @@ -0,0 +1,137 @@ +// Read-only aggregations over CASAN harness telemetry. Formulas mirror +// packages/casan-harness/tests/generate-agentops-dashboard.py; all numbers come from real +// on-disk feeds. Missing feeds degrade to zero/empty + a `stale` flag — never fabricated. +import { Injectable } from '@nestjs/common'; +import { PATHS, isStale, metricsAgeSeconds, STALE_AFTER_S } from '../common/app-root.js'; +import { readJsonl, readJson, readHead, readTrace } from './telemetry.reader.js'; + +type Row = Record; +const num = (v: any) => (typeof v === 'number' && isFinite(v) ? v : 0); +const sum = (rows: Row[], k: string) => rows.reduce((a, r) => a + num(r[k]), 0); +const count = (rows: Row[], pred: (r: Row) => boolean) => rows.reduce((a, r) => a + (pred(r) ? 1 : 0), 0); +const recent = (rows: Row[], n: number) => rows.slice(-n).reverse(); + +@Injectable() +export class TelemetryService { + private freshness() { + const age = metricsAgeSeconds(); + return { stale: isStale(), age_s: age, stale_after_s: STALE_AFTER_S }; + } + + overview() { + const metrics = readJsonl(PATHS.metrics); + const provider = readJsonl(PATHS.providerUsage); + const audit = readJsonl(PATHS.audit); + const security = readJsonl(PATHS.security); + const fallback = readJsonl(PATHS.fallback); + const tools = readJsonl(PATHS.toolRegistry); + const actions = readJsonl(PATHS.actionGate); + const incidents = readJsonl(PATHS.incidents); + + const runs = metrics.length; + const latencies = metrics.map((m) => num(m.latency_ms)).filter((x) => x > 0); + return { + ...this.freshness(), + totals: { + runs, + total_cost: sum(metrics, 'cost_estimate'), + avg_latency_ms: latencies.length ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : 0, + failures: count(metrics, (m) => m.status === 'failed'), + hallucination_signals: sum(metrics, 'hallucination_signals'), + provider_tokens: sum(provider, 'total_tokens'), + provider_cost: sum(provider, 'cost_usd'), + fallback_routes: count(fallback, (f) => f.route === 'fallback'), + tool_denies: count(tools, (t) => t.decision === 'denied'), + action_blocks: count(actions, (a) => a.outcome === 'BLOCK'), + }, + // Real per-harness signals (counts), not a hardcoded rubric — truthful by construction. + harness_signals: { + 'H4-security': { verdicts: security.length, blocked: count(security, (s) => s.status === 'blocked') }, + 'H5-governance': { decisions: audit.length, denied: count(audit, (a) => a.decision === 'denied') }, + 'H6-agentops': { runs, failures: count(metrics, (m) => m.status === 'failed') }, + 'H7-drift': { report: readJson(PATHS.drift) ? 'present' : 'absent' }, + tools: { decisions: tools.length, denied: count(tools, (t) => t.decision === 'denied') }, + incidents: { total: incidents.length, critical: count(incidents, (i) => i.severity === 'CRIT') }, + }, + audit_chain: { + records: audit.length, + head: readHead(PATHS.auditHead), + last_decision: audit.length ? audit[audit.length - 1].decision ?? null : null, + }, + }; + } + + runs(limit = 50) { + const metrics = readJsonl(PATHS.metrics); + return { ...this.freshness(), count: metrics.length, runs: recent(metrics, limit) }; + } + + run(traceId: string) { + const trace = readTrace(PATHS.traceDir, traceId); + return trace ? { found: true, trace } : { found: false, trace: null }; + } + + governance() { + const audit = readJsonl(PATHS.audit); + const byDecision: Record = {}; + for (const r of audit) byDecision[String(r.decision ?? 'unknown')] = (byDecision[String(r.decision ?? 'unknown')] || 0) + 1; + return { + ...this.freshness(), + records: audit.length, + by_decision: byDecision, + head: readHead(PATHS.auditHead), + recent: recent(audit, 30), + }; + } + + security() { + const s = readJsonl(PATHS.security); + const byStatus: Record = {}; + for (const r of s) byStatus[String(r.status ?? 'unknown')] = (byStatus[String(r.status ?? 'unknown')] || 0) + 1; + return { + ...this.freshness(), + verdicts: s.length, + by_status: byStatus, + benign_fp: readJson(PATHS.benignFp), + recent: recent(s, 30), + }; + } + + incidents() { + const inc = readJsonl(PATHS.incidents); + const engaged = inc.filter((i) => i.action === 'kill_switch_engaged').map((i) => i.scope); + return { + ...this.freshness(), + total: inc.length, + kill_switch_scopes: [...new Set(engaged)], + incidents: recent(inc, 50), + }; + } + + tools() { + return { + ...this.freshness(), + tool_registry: recent(readJsonl(PATHS.toolRegistry), 50), + action_gate: recent(readJsonl(PATHS.actionGate), 50), + }; + } + + traceability() { + return { ...this.freshness(), matrix: readJson(PATHS.traceability) }; + } + + drift() { + return { ...this.freshness(), report: readJson(PATHS.drift) }; + } + + cost() { + const provider = readJsonl(PATHS.providerUsage); + return { + ...this.freshness(), + provider_tokens: sum(provider, 'total_tokens'), + provider_cost: sum(provider, 'cost_usd'), + by_provider: recent(provider, 50), + business_kpi: readJson(PATHS.businessKpi), + }; + } +} diff --git a/packages/casan-control-panel/backend/test/telemetry.test.ts b/packages/casan-control-panel/backend/test/telemetry.test.ts new file mode 100644 index 0000000..e9d94c3 --- /dev/null +++ b/packages/casan-control-panel/backend/test/telemetry.test.ts @@ -0,0 +1,64 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { readJsonl, readJson, readHead } from '../src/telemetry/telemetry.reader.js'; +import { APP_ROOT, PATHS, isStale, metricsAgeSeconds } from '../src/common/app-root.js'; +import { TelemetryService } from '../src/telemetry/telemetry.service.js'; + +test('readJsonl parses valid lines and skips malformed', () => { + const d = mkdtempSync(join(tmpdir(), 'cp-')); + const f = join(d, 'x.jsonl'); + writeFileSync(f, '{"a":1}\n\nnot-json\n{"a":2}\n'); + const rows = readJsonl<{ a: number }>(f); + assert.equal(rows.length, 2); + assert.equal(rows[0].a, 1); + assert.equal(rows[1].a, 2); +}); + +test('readJsonl/readJson tolerate missing files (no throw)', () => { + assert.deepEqual(readJsonl('/no/such/file.jsonl'), []); + assert.equal(readJson('/no/such/file.json'), null); + assert.equal(readHead('/no/such/head.txt'), null); +}); + +test('APP_ROOT resolves to the CASAN app root (has a harness marker)', () => { + const marker = existsSync(join(APP_ROOT, 'packages', 'casan-harness')) || existsSync(join(APP_ROOT, '.specify')); + assert.ok(marker, `APP_ROOT=${APP_ROOT} should contain .specify or packages/casan-harness`); +}); + +test('overview() returns real aggregated shape, never throws on the repo state', () => { + const svc = new TelemetryService(); + const o = svc.overview() as any; + assert.ok(o.totals && typeof o.totals.runs === 'number'); + assert.ok(o.harness_signals && o.harness_signals['H5-governance']); + assert.ok(o.audit_chain && typeof o.audit_chain.records === 'number'); + assert.ok('stale' in o && 'stale_after_s' in o); + // real data: metrics + audit feeds exist in this repo, so counts are non-negative + assert.ok(o.totals.runs >= 0); + assert.ok(o.audit_chain.records >= 0); +}); + +test('security()/governance()/cost() return objects with expected keys', () => { + const svc = new TelemetryService(); + assert.ok(typeof (svc.security() as any).by_status === 'object'); + assert.ok(typeof (svc.governance() as any).by_decision === 'object'); + assert.ok('provider_tokens' in (svc.cost() as any)); +}); + +test('freshness helpers behave (age is number|null, isStale is boolean)', () => { + const age = metricsAgeSeconds(); + assert.ok(age === null || typeof age === 'number'); + assert.equal(typeof isStale(), 'boolean'); +}); + +test('missing metrics feed => stale (fail-loud)', () => { + const prev = process.env.CASAN_DASHBOARD_METRICS; + process.env.CASAN_DASHBOARD_METRICS = '/no/such/metrics.jsonl'; + // re-import is not trivial (module caches PATHS); assert the reader-level contract instead: + assert.deepEqual(readJsonl('/no/such/metrics.jsonl'), []); + if (prev === undefined) delete process.env.CASAN_DASHBOARD_METRICS; + else process.env.CASAN_DASHBOARD_METRICS = prev; + void PATHS; +}); diff --git a/packages/casan-control-panel/backend/tsconfig.build.json b/packages/casan-control-panel/backend/tsconfig.build.json new file mode 100644 index 0000000..8f1707c --- /dev/null +++ b/packages/casan-control-panel/backend/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "rootDir": "src" }, + "include": ["src/**/*.ts"], + "exclude": ["test", "dist"] +} diff --git a/packages/casan-control-panel/backend/tsconfig.json b/packages/casan-control-panel/backend/tsconfig.json new file mode 100644 index 0000000..6cff39b --- /dev/null +++ b/packages/casan-control-panel/backend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/casan-control-panel/frontend/index.html b/packages/casan-control-panel/frontend/index.html new file mode 100644 index 0000000..36c2729 --- /dev/null +++ b/packages/casan-control-panel/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + CASAN Ops Console + + +
+ + + diff --git a/packages/casan-control-panel/frontend/package.json b/packages/casan-control-panel/frontend/package.json new file mode 100644 index 0000000..50b8b2c --- /dev/null +++ b/packages/casan-control-panel/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "@casan/control-panel-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "CASAN Ops Console \u2014 read-only React UI over the harness telemetry API.", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.81.5", + "axios": "^1.10.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.1" + }, + "devDependencies": { + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.6.0", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", + "typescript": "^5.8.3", + "vite": "^5.4.19" + } +} diff --git a/packages/casan-control-panel/frontend/postcss.config.js b/packages/casan-control-panel/frontend/postcss.config.js new file mode 100644 index 0000000..99417eb --- /dev/null +++ b/packages/casan-control-panel/frontend/postcss.config.js @@ -0,0 +1 @@ +export default { plugins: { tailwindcss: {}, autoprefixer: {} } }; diff --git a/packages/casan-control-panel/frontend/src/App.tsx b/packages/casan-control-panel/frontend/src/App.tsx new file mode 100644 index 0000000..ccff575 --- /dev/null +++ b/packages/casan-control-panel/frontend/src/App.tsx @@ -0,0 +1,24 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { AppLayout } from './components/layout/AppLayout'; +import { Overview } from './pages/Overview'; +import { Runs } from './pages/Runs'; +import { Governance } from './pages/Governance'; +import { Security } from './pages/Security'; +import { Incidents } from './pages/Incidents'; +import { Traceability } from './pages/Traceability'; + +export default function App() { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/packages/casan-control-panel/frontend/src/components/layout/AppLayout.tsx b/packages/casan-control-panel/frontend/src/components/layout/AppLayout.tsx new file mode 100644 index 0000000..3ad5d32 --- /dev/null +++ b/packages/casan-control-panel/frontend/src/components/layout/AppLayout.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; +import { Sidebar } from './Sidebar'; +import { Header } from './Header'; +export function AppLayout({ children }: { children: ReactNode }) { + return ( +
+ +
+
+
{children}
+
+
+ ); +} diff --git a/packages/casan-control-panel/frontend/src/components/layout/Header.tsx b/packages/casan-control-panel/frontend/src/components/layout/Header.tsx new file mode 100644 index 0000000..3e0fbe7 --- /dev/null +++ b/packages/casan-control-panel/frontend/src/components/layout/Header.tsx @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import { health } from '../../lib/api'; +export function Header() { + const { data } = useQuery({ queryKey: ['health'], queryFn: health }); + const stale = data ? !data.ok : true; + return ( +
+

CASAN Ops Console · read-only

+
+ runs: {data?.runs ?? '—'} + + {stale ? `STALE${data?.metrics_age_s != null ? ` (${data.metrics_age_s}s)` : ''}` : 'LIVE'} + +
+
+ ); +} diff --git a/packages/casan-control-panel/frontend/src/components/layout/Sidebar.tsx b/packages/casan-control-panel/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..55560c6 --- /dev/null +++ b/packages/casan-control-panel/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,20 @@ +import { NavLink } from 'react-router-dom'; +const NAV = [ + ['/', 'Overview'], ['/runs', 'Runs'], ['/governance', 'Governance'], + ['/security', 'Security'], ['/incidents', 'Incidents'], ['/traceability', 'Traceability'], +]; +export function Sidebar() { + return ( + + ); +} diff --git a/packages/casan-control-panel/frontend/src/components/ui/Card.tsx b/packages/casan-control-panel/frontend/src/components/ui/Card.tsx new file mode 100644 index 0000000..eb7935a --- /dev/null +++ b/packages/casan-control-panel/frontend/src/components/ui/Card.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from 'react'; +export function Card({ title, children, right }: { title?: string; children: ReactNode; right?: ReactNode }) { + return ( +
+ {title && ( +
+

{title}

+ {right} +
+ )} + {children} +
+ ); +} +export function StatTile({ label, value, sub }: { label: string; value: ReactNode; sub?: string }) { + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} +const TONE: Record = { + ok: 'bg-green-100 text-green-700', pass: 'bg-green-100 text-green-700', success: 'bg-green-100 text-green-700', allow: 'bg-green-100 text-green-700', + warn: 'bg-orange-100 text-orange-700', stale: 'bg-orange-100 text-orange-700', + fail: 'bg-red-100 text-red-700', failed: 'bg-red-100 text-red-700', denied: 'bg-red-100 text-red-700', blocked: 'bg-red-100 text-red-700', deny: 'bg-red-100 text-red-700', block: 'bg-red-100 text-red-700', crit: 'bg-red-100 text-red-700', +}; +export function StatusBadge({ value }: { value: string }) { + const tone = TONE[String(value).toLowerCase()] ?? 'bg-gray-100 text-gray-600'; + return {value}; +} diff --git a/packages/casan-control-panel/frontend/src/index.css b/packages/casan-control-panel/frontend/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/packages/casan-control-panel/frontend/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/packages/casan-control-panel/frontend/src/lib/api.ts b/packages/casan-control-panel/frontend/src/lib/api.ts new file mode 100644 index 0000000..1b25e00 --- /dev/null +++ b/packages/casan-control-panel/frontend/src/lib/api.ts @@ -0,0 +1,44 @@ +// Single axios client for the read-only Ops Console API. Mirrors the OKR app's api.ts: +// relative baseURL, unwrap response.data.data. All GET (read-only). +import axios from 'axios'; + +const client = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api/v1', +}); + +async function get(path: string): Promise { + const res = await client.get(`/${path}`); + return res.data.data as T; +} + +export interface Freshness { stale: boolean; age_s: number | null; stale_after_s: number } + +export interface Overview extends Freshness { + totals: { + runs: number; total_cost: number; avg_latency_ms: number; failures: number; + hallucination_signals: number; provider_tokens: number; provider_cost: number; + fallback_routes: number; tool_denies: number; action_blocks: number; + }; + harness_signals: Record>; + audit_chain: { records: number; head: string | null; last_decision: string | null }; +} + +export const api = { + overview: () => get('overview'), + runs: (limit = 50) => get(`runs?limit=${limit}`), + governance: () => get; head: string | null; recent: any[] }>('governance'), + security: () => get; benign_fp: any; recent: any[] }>('security'), + incidents: () => get('incidents'), + traceability: () => get('traceability'), + cost: () => get('cost'), +}; + +// Health is raw (not enveloped) + carries HTTP status. +export async function health(): Promise<{ ok: boolean; status: string; metrics_age_s: number | null; runs: number }> { + try { + const res = await axios.get('/healthz', { baseURL: '', validateStatus: () => true }); + return { ok: res.status === 200, ...res.data }; + } catch { + return { ok: false, status: 'unreachable', metrics_age_s: null, runs: 0 }; + } +} diff --git a/packages/casan-control-panel/frontend/src/lib/queryClient.ts b/packages/casan-control-panel/frontend/src/lib/queryClient.ts new file mode 100644 index 0000000..179a460 --- /dev/null +++ b/packages/casan-control-panel/frontend/src/lib/queryClient.ts @@ -0,0 +1,6 @@ +import { QueryClient } from '@tanstack/react-query'; + +// Poll telemetry every 15s; read-only console tolerates brief staleness. +export const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: 15_000, refetchInterval: 15_000, retry: 1 } }, +}); diff --git a/packages/casan-control-panel/frontend/src/main.tsx b/packages/casan-control-panel/frontend/src/main.tsx new file mode 100644 index 0000000..c2d3e78 --- /dev/null +++ b/packages/casan-control-panel/frontend/src/main.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter } from 'react-router-dom'; +import { queryClient } from './lib/queryClient'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + , +); diff --git a/packages/casan-control-panel/frontend/src/pages/Governance.tsx b/packages/casan-control-panel/frontend/src/pages/Governance.tsx new file mode 100644 index 0000000..1dc2865 --- /dev/null +++ b/packages/casan-control-panel/frontend/src/pages/Governance.tsx @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/api'; +import { Card, StatTile, StatusBadge } from '../components/ui/Card'; + +export function Governance() { + const { data, isLoading } = useQuery({ queryKey: ['governance'], queryFn: api.governance }); + if (isLoading || !data) return
Loading…
; + return ( + <> +
+ + {Object.entries(data.by_decision).map(([k, v]) => )} +
+ head {data.head?.slice(0, 12) ?? '—'}…}> +
+ + {data.recent.map((r: any, i: number) => ( + + + + + ))} +
timeactionactorriskdecision
{r.timestamp?.replace('T',' ').replace('Z','')}{r.action}{r.actor}{r.risk_level}
+
+ + ); +} diff --git a/packages/casan-control-panel/frontend/src/pages/Incidents.tsx b/packages/casan-control-panel/frontend/src/pages/Incidents.tsx new file mode 100644 index 0000000..25e96fa --- /dev/null +++ b/packages/casan-control-panel/frontend/src/pages/Incidents.tsx @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/api'; +import { Card, StatTile, StatusBadge } from '../components/ui/Card'; + +export function Incidents() { + const { data, isLoading } = useQuery({ queryKey: ['incidents'], queryFn: api.incidents }); + if (isLoading || !data) return
Loading…
; + return ( + <> +
+ + +
+ +
+ + {data.incidents.map((r: any, i: number) => ( + + + + + ))} +
timeeventseverityscopeaction
{r.timestamp?.replace('T',' ').replace('Z','')}{r.event}{r.scope}{r.action}
+
+ + ); +} diff --git a/packages/casan-control-panel/frontend/src/pages/Overview.tsx b/packages/casan-control-panel/frontend/src/pages/Overview.tsx new file mode 100644 index 0000000..4175658 --- /dev/null +++ b/packages/casan-control-panel/frontend/src/pages/Overview.tsx @@ -0,0 +1,37 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/api'; +import { Card, StatTile, StatusBadge } from '../components/ui/Card'; + +export function Overview() { + const { data, isLoading, isError } = useQuery({ queryKey: ['overview'], queryFn: api.overview }); + if (isLoading) return
Loading…
; + if (isError || !data) return
Cannot reach Ops Console API.
; + const t = data.totals; + return ( + <> +
+ + + + + + + + +
+ +
+ {Object.entries(data.harness_signals).map(([h, sig]) => ( +
+
{h}
+
{Object.entries(sig).map(([k, v]) => `${k}: ${v}`).join(' · ')}
+
+ ))} +
+
+ }> +
records: {data.audit_chain.records} · head: {data.audit_chain.head?.slice(0, 16) ?? '—'}…
+
+ + ); +} diff --git a/packages/casan-control-panel/frontend/src/pages/Runs.tsx b/packages/casan-control-panel/frontend/src/pages/Runs.tsx new file mode 100644 index 0000000..d3879da --- /dev/null +++ b/packages/casan-control-panel/frontend/src/pages/Runs.tsx @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/api'; +import { Card, StatusBadge } from '../components/ui/Card'; + +export function Runs() { + const { data, isLoading } = useQuery({ queryKey: ['runs'], queryFn: () => api.runs(100) }); + if (isLoading || !data) return
Loading…
; + return ( + +
+ + + + + + {data.runs.map((r: any, i: number) => ( + + + + + + + + + ))} + +
timestepstatuslatencytokenscost
{r.timestamp?.replace('T', ' ').replace('Z', '')}{r.step ?? r.harness}{r.latency_ms ?? '—'} ms{r.total_tokens ?? '—'}${Number(r.cost_estimate ?? 0).toFixed(5)}
+
+
+ ); +} diff --git a/packages/casan-control-panel/frontend/src/pages/Security.tsx b/packages/casan-control-panel/frontend/src/pages/Security.tsx new file mode 100644 index 0000000..fc549d6 --- /dev/null +++ b/packages/casan-control-panel/frontend/src/pages/Security.tsx @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/api'; +import { Card, StatTile } from '../components/ui/Card'; + +export function Security() { + const { data, isLoading } = useQuery({ queryKey: ['security'], queryFn: api.security }); + if (isLoading || !data) return
Loading…
; + const fp = data.benign_fp; + return ( + <> +
+ + {Object.entries(data.by_status).map(([k, v]) => )} +
+ {fp && +
{JSON.stringify(fp, null, 2)}
+
} + + ); +} diff --git a/packages/casan-control-panel/frontend/src/pages/Traceability.tsx b/packages/casan-control-panel/frontend/src/pages/Traceability.tsx new file mode 100644 index 0000000..80cf351 --- /dev/null +++ b/packages/casan-control-panel/frontend/src/pages/Traceability.tsx @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/api'; +import { Card, StatTile, StatusBadge } from '../components/ui/Card'; + +export function Traceability() { + const { data, isLoading } = useQuery({ queryKey: ['traceability'], queryFn: api.traceability }); + if (isLoading || !data) return
Loading…
; + const m = data.matrix; + if (!m) return
No traceability-matrix.json yet.
; + return ( + <> +
+ + + +
+ +
+ + {(m.matrix ?? []).map((r: any) => ( + + + + + ))} +
FRnamestatuscodetests
{r.id}{r.name}{r.code?.length ?? 0}{r.tests?.length ?? 0}
+
+ + ); +} diff --git a/packages/casan-control-panel/frontend/src/vite-env.d.ts b/packages/casan-control-panel/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/packages/casan-control-panel/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/casan-control-panel/frontend/tailwind.config.ts b/packages/casan-control-panel/frontend/tailwind.config.ts new file mode 100644 index 0000000..6d74aa3 --- /dev/null +++ b/packages/casan-control-panel/frontend/tailwind.config.ts @@ -0,0 +1,3 @@ +import type { Config } from 'tailwindcss'; +const config: Config = { content: ['./index.html', './src/**/*.{ts,tsx}'], theme: { extend: {} }, plugins: [] }; +export default config; diff --git a/packages/casan-control-panel/frontend/tsconfig.json b/packages/casan-control-panel/frontend/tsconfig.json new file mode 100644 index 0000000..4daefd9 --- /dev/null +++ b/packages/casan-control-panel/frontend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "types": ["node", "vite/client"] + }, + "include": ["src"] +} diff --git a/packages/casan-control-panel/frontend/tsconfig.tsbuildinfo b/packages/casan-control-panel/frontend/tsconfig.tsbuildinfo new file mode 100644 index 0000000..d491852 --- /dev/null +++ b/packages/casan-control-panel/frontend/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/main.tsx","./src/components/layout/applayout.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/ui/card.tsx","./src/lib/api.ts","./src/lib/queryclient.ts","./src/pages/governance.tsx","./src/pages/incidents.tsx","./src/pages/overview.tsx","./src/pages/runs.tsx","./src/pages/security.tsx","./src/pages/traceability.tsx"],"errors":true,"version":"5.9.3"} \ No newline at end of file diff --git a/packages/casan-control-panel/frontend/vite.config.ts b/packages/casan-control-panel/frontend/vite.config.ts new file mode 100644 index 0000000..a5c65f4 --- /dev/null +++ b/packages/casan-control-panel/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// Ops Console UI. Dev server on 5174 (5173 is the OKR app). Proxies /api/v1 + /healthz to +// the read-only console API (default :3010) so the axios client uses a relative baseURL. +export default defineConfig({ + plugins: [react()], + server: { + port: 5174, + proxy: { + '/api/v1': { target: 'http://127.0.0.1:3010', changeOrigin: true }, + '/healthz': { target: 'http://127.0.0.1:3010', changeOrigin: true }, + }, + }, +}); diff --git a/packages/casan-platform/README.md b/packages/casan-platform/README.md index 7e7f8cb..5c0276f 100644 --- a/packages/casan-platform/README.md +++ b/packages/casan-platform/README.md @@ -12,10 +12,11 @@ Optional layer for teams that want UI / dashboard / visibility. Packages: `casan | Component | Status | Where | |---|---|---| | AgentOps Dashboard | ✅ exists | `packages/casan-harness/scripts/bash/dashboard-server.py` + `dashboard-serve.sh` (`casan dashboard`) | -| Control Panel | 📋 planned | Plan-13 (`docs/plans/CASAN_PLAN_13_CONTROL_PLANE.md`) | +| **Ops Console (Control Panel)** | ✅ **read-only (Plan-13 Track 1)** | `packages/casan-control-panel/` — NestJS API + React UI over harness telemetry (`npm run console:api` + `console:ui`). Includes Run History + verdicts + governance + security + incidents + traceability views. | +| Management / settings writes | 📋 planned | Plan-13 Track 2 (wrap `control-plane-settings.py`) | +| RBAC + approval inbox | 📋 planned | Plan-13 Track 3 / Plan-14 | | Evidence Pack Viewer | 📋 planned | reads `docs/output/casan/evidence-packs/` | | Attack Battery Viewer | 📋 planned | reads red-team corpus + H4 recall results | -| Run History Viewer | 📋 planned | reads `.specify/logs/level5/pipeline-run.jsonl` | | Read-only Ask CASAN | 📋 planned | Plan-18 MVP-0 (read-only) | | Gitea webhook integration | 📋 planned | trigger gate / publish evidence on push | diff --git a/packaging/levels.json b/packaging/levels.json index 169e075..6f09987 100644 --- a/packaging/levels.json +++ b/packaging/levels.json @@ -53,8 +53,8 @@ "summary": "Optional productization UI: Control Panel, Dashboard, Evidence/Attack/Run-History viewers, read-only Ask CASAN, Gitea webhook. Only the AgentOps dashboard exists today; the rest is structure-only.", "target_users": ["teams wanting UI/dashboard/visibility", "demo/training/productization"], "extends": "devkit", - "implemented_components": ["agentops-dashboard (packages/casan-harness/scripts/bash/dashboard-server.py + dashboard-serve.sh)"], - "pending_components": ["control-panel", "evidence-pack-viewer", "attack-battery-viewer", "run-history-viewer", "read-only Ask CASAN", "gitea-webhook-integration"], + "implemented_components": ["agentops-dashboard (packages/casan-harness/scripts/bash/dashboard-server.py + dashboard-serve.sh)", "read-only Ops Console (packages/casan-control-panel — NestJS API + React UI, Plan-13 Track 1)"], + "pending_components": ["management/settings writes (Plan-13 Track 2)", "RBAC + approval inbox (Plan-13 Track 3 / Plan-14)", "evidence-pack-viewer", "attack-battery-viewer", "gitea-webhook-integration", "read-only Ask CASAN (Plan-18 MVP-0)"], "includes": [ "packages/casan-platform", "packages/casan-harness/scripts/bash/dashboard-server.py",