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:
co-authored by
Claude Opus 4.8
parent
98d699d844
commit
63dd44a11b
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user