feat: add control panel

This commit is contained in:
thanhnv
2026-07-08 19:07:35 +09:00
parent a07b15e489
commit 3be9970c15
104 changed files with 3639 additions and 461 deletions
@@ -1,5 +1,5 @@
// 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).
// Single axios client for the Ops Console API. Mirrors the OKR app's api.ts:
// relative baseURL, unwrap response.data.data.
import axios from 'axios';
const client = axios.create({
@@ -11,6 +11,16 @@ async function get<T>(path: string): Promise<T> {
return res.data.data as T;
}
async function getWithHeaders<T>(path: string, headers: Record<string, string>): Promise<T> {
const res = await client.get(`/${path}`, { headers });
return res.data.data as T;
}
async function post<T>(path: string, body: unknown, headers: Record<string, string>): Promise<T> {
const res = await client.post(`/${path}`, body, { headers });
return res.data.data as T;
}
export interface Freshness { stale: boolean; age_s: number | null; stale_after_s: number }
export interface Overview extends Freshness {
@@ -23,6 +33,73 @@ export interface Overview extends Freshness {
audit_chain: { records: number; head: string | null; last_decision: string | null };
}
export interface SettingsActor {
actor: string;
role: string;
project: string;
tenant: string;
}
export interface KillSwitchState {
count: number;
engaged: Array<{ scope: string; id: string; reason?: string; engaged_at?: string; actor?: string; raw?: string }>;
}
export interface ApprovalsState {
count: number;
proposals: any[];
oversight: any[];
audit_verify: { ok: boolean; output: string };
}
export interface CommandEnvelope {
source: string;
artifact_path: string;
commit: string | null;
run_at: string | null;
verified: boolean;
status: 'verified' | 'present_unverified' | 'missing';
}
export interface CommandWidget {
id: string;
title: string;
status: string;
summary: string;
metrics: Record<string, unknown>;
envelope: CommandEnvelope;
evidence: Record<string, unknown>;
}
export interface CommandCenterState extends Freshness {
generated_at: string;
widgets: Record<string, CommandWidget>;
briefing: Array<{ label: string; value: string; status: string; widget: string }>;
ticker: Array<{ at: string | null; kind: string; text: string; status: string }>;
}
export interface SettingsState {
actor: SettingsActor;
capabilities: {
can_write_standard: boolean;
can_write_sensitive: boolean;
can_rollback: boolean;
};
policy: Record<string, { securitySensitive: boolean; description: string }>;
settings: Record<string, { value: unknown; version: number; updatedAt: string; actor: string; reason: string }>;
audit: any[];
audit_verify: { ok: boolean; output: string };
}
function actorHeaders(actor: SettingsActor): Record<string, string> {
return {
'x-casan-actor': actor.actor,
'x-casan-role': actor.role,
'x-casan-project': actor.project,
'x-casan-tenant': actor.tenant,
};
}
export const api = {
overview: () => get<Overview>('overview'),
runs: (limit = 50) => get<Freshness & { count: number; runs: any[] }>(`runs?limit=${limit}`),
@@ -31,6 +108,22 @@ export const api = {
incidents: () => get<Freshness & { total: number; kill_switch_scopes: string[]; incidents: any[] }>('incidents'),
traceability: () => get<Freshness & { matrix: any }>('traceability'),
cost: () => get<Freshness & { provider_tokens: number; provider_cost: number; by_provider: any[]; business_kpi: any }>('cost'),
command: () => get<CommandCenterState>('command'),
settings: (actor: SettingsActor) => getWithHeaders<SettingsState>('settings', actorHeaders(actor)),
setSetting: (actor: SettingsActor, body: { key: string; value: unknown; reason: string; approval?: string }) =>
post<{ key: string; setting: any; audit_verify: { ok: boolean; output: string } }>('settings', body, actorHeaders(actor)),
rollbackSetting: (actor: SettingsActor, body: { key: string; reason: string }) =>
post<{ key: string; setting: any; audit_verify: { ok: boolean; output: string } }>('settings/rollback', body, actorHeaders(actor)),
killSwitch: (actor: SettingsActor) => getWithHeaders<KillSwitchState>('kill-switch', actorHeaders(actor)),
engageKillSwitch: (actor: SettingsActor, body: { scope: string; id: string; reason: string }) =>
post<{ output: string; status: KillSwitchState }>('kill-switch/engage', body, actorHeaders(actor)),
clearKillSwitch: (actor: SettingsActor, body: { scope: string; id: string; reason: string }) =>
post<{ output: string; status: KillSwitchState }>('kill-switch/clear', body, actorHeaders(actor)),
approvals: (actor: SettingsActor, status = 'pending') => getWithHeaders<ApprovalsState>(`approvals?status=${status}`, actorHeaders(actor)),
submitApproval: (actor: SettingsActor, body: { action: string; target: string; risk?: string; sensitive?: boolean; reason: string; payload?: Record<string, unknown> }) =>
post<{ proposal: any; audit_verify: { ok: boolean; output: string } }>('approvals/submit', body, actorHeaders(actor)),
decideApproval: (actor: SettingsActor, body: { id: string; decision: 'approve' | 'reject'; reason: string }) =>
post<{ proposal: any; applied: any; audit_verify: { ok: boolean; output: string } }>('approvals/decide', body, actorHeaders(actor)),
};
// Health is raw (not enveloped) + carries HTTP status.