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,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());
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<T = Record<string, unknown>>(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<T = Record<string, unknown>>(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 *<id>.json). */
|
||||
export function readTrace(traceDir: string, traceId: string): Record<string, unknown> | null {
|
||||
if (!existsSync(traceDir)) return null;
|
||||
// trace files are named <harness>-<uuid>.json; find by suffix match on trace_id
|
||||
for (const f of readdirSync(traceDir)) {
|
||||
if (!f.endsWith('.json')) continue;
|
||||
const rec = readJson<Record<string, unknown>>(join(traceDir, f));
|
||||
if (rec && (rec.trace_id === traceId || f.includes(traceId))) return rec;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user