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,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<string, any>;
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<string, number> = {};
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<string, number> = {};
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),
};
}
}