feat(plan-13): read-only Ops Console (NestJS API + React UI) — Track 1

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) <noreply@anthropic.com>
This commit is contained in:
thanhnv
2026-07-08 16:59:56 +09:00
co-authored by Claude Opus 4.8
parent 98d699d844
commit 63dd44a11b
41 changed files with 1078 additions and 43 deletions
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
export function AppLayout({ children }: { children: ReactNode }) {
return (
<div className="flex h-screen bg-gray-50">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-auto p-6 space-y-6">{children}</main>
</div>
</div>
);
}
@@ -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 (
<header className="h-14 bg-white border-b border-gray-200 flex items-center justify-between px-6">
<h1 className="text-base font-semibold text-gray-800">CASAN Ops Console <span className="text-gray-400 font-normal">· read-only</span></h1>
<div className="flex items-center gap-3 text-xs">
<span className="text-gray-500">runs: {data?.runs ?? '—'}</span>
<span className={`px-2 py-1 rounded-full font-medium ${stale ? 'bg-orange-100 text-orange-700' : 'bg-green-100 text-green-700'}`}>
{stale ? `STALE${data?.metrics_age_s != null ? ` (${data.metrics_age_s}s)` : ''}` : 'LIVE'}
</span>
</div>
</header>
);
}
@@ -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 (
<aside className="w-56 bg-white border-r border-gray-200 flex-shrink-0">
<div className="h-14 flex items-center px-6 font-bold text-blue-600 border-b border-gray-200">CASAN</div>
<nav className="p-3 space-y-1">
{NAV.map(([to, label]) => (
<NavLink key={to} to={to} end={to === '/'}
className={({ isActive }) => `block px-3 py-2 rounded-lg text-sm ${isActive ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-800'}`}>
{label}
</NavLink>
))}
</nav>
</aside>
);
}
@@ -0,0 +1,32 @@
import type { ReactNode } from 'react';
export function Card({ title, children, right }: { title?: string; children: ReactNode; right?: ReactNode }) {
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
{title && (
<div className="flex justify-between items-center mb-4">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">{title}</h2>
{right}
</div>
)}
{children}
</div>
);
}
export function StatTile({ label, value, sub }: { label: string; value: ReactNode; sub?: string }) {
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500">{label}</div>
<div className="text-2xl font-semibold text-gray-800 mt-1">{value}</div>
{sub && <div className="text-xs text-gray-400 mt-1">{sub}</div>}
</div>
);
}
const TONE: Record<string, string> = {
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 <span className={`px-2 py-1 rounded-full text-xs font-medium ${tone}`}>{value}</span>;
}