feat(iwooos): add live security control plane
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m22s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m22s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
Boxes,
|
||||
CheckCircle2,
|
||||
RefreshCw,
|
||||
Radar,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
isSecurityAssetControlPlanePayload,
|
||||
securityControlPlaneTone,
|
||||
topSecurityControlPlaneWorkItems,
|
||||
type SecurityAssetControlPlanePayload,
|
||||
type SecurityControlPlaneStage,
|
||||
} from "@/lib/security-asset-control-plane";
|
||||
import { getRuntimeApiBaseUrl } from "@/lib/runtime-api-base";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Copy = {
|
||||
title: string;
|
||||
live: string;
|
||||
degraded: string;
|
||||
assets: string;
|
||||
assetTypes: string;
|
||||
nist: string;
|
||||
siem: string;
|
||||
aiLoop: string;
|
||||
verified: string;
|
||||
functions: string;
|
||||
siemFlow: string;
|
||||
aiFlow: string;
|
||||
scope: string;
|
||||
priority: string;
|
||||
audit: string;
|
||||
aiTrace: string;
|
||||
sameRunMissing: string;
|
||||
unavailable: string;
|
||||
refresh: string;
|
||||
};
|
||||
|
||||
const COPY: Record<"zh" | "en", Copy> = {
|
||||
zh: {
|
||||
title: "AI 資安控制平面",
|
||||
live: "LIVE",
|
||||
degraded: "DEGRADED",
|
||||
assets: "納管資產",
|
||||
assetTypes: "資產類型",
|
||||
nist: "NIST 控制",
|
||||
siem: "SIEM 閉環",
|
||||
aiLoop: "AI 閉環",
|
||||
verified: "24h 已驗證",
|
||||
functions: "治理六功能",
|
||||
siemFlow: "SIEM 八段",
|
||||
aiFlow: "AI Agent 七段",
|
||||
scope: "全資產範圍",
|
||||
priority: "優先處理",
|
||||
audit: "稽核軌跡",
|
||||
aiTrace: "AI 軌跡",
|
||||
sameRunMissing: "尚無同 run 閉環證據",
|
||||
unavailable: "Live readback 無法取得",
|
||||
refresh: "重新整理資安控制平面",
|
||||
},
|
||||
en: {
|
||||
title: "AI Security Control Plane",
|
||||
live: "LIVE",
|
||||
degraded: "DEGRADED",
|
||||
assets: "Managed assets",
|
||||
assetTypes: "Asset types",
|
||||
nist: "NIST control",
|
||||
siem: "SIEM closure",
|
||||
aiLoop: "AI closure",
|
||||
verified: "Verified 24h",
|
||||
functions: "Six functions",
|
||||
siemFlow: "SIEM pipeline",
|
||||
aiFlow: "AI Agent loop",
|
||||
scope: "Asset scope",
|
||||
priority: "Priorities",
|
||||
audit: "Audit trail",
|
||||
aiTrace: "AI trace",
|
||||
sameRunMissing: "No same-run closure evidence",
|
||||
unavailable: "Live readback unavailable",
|
||||
refresh: "Refresh security control plane",
|
||||
},
|
||||
};
|
||||
|
||||
const TONE_CLASS = {
|
||||
healthy: "border-[#8fc39c] bg-[#f2faf4] text-[#17602a]",
|
||||
warning: "border-[#d9b36f] bg-[#fff8e8] text-[#8a5a08]",
|
||||
critical: "border-[#e2a29b] bg-[#fff2f0] text-[#9f2f25]",
|
||||
};
|
||||
|
||||
function stageCount(stage: SecurityControlPlaneStage) {
|
||||
return stage.evidence_count_24h ?? stage.receipt_count_24h ?? 0;
|
||||
}
|
||||
|
||||
async function fetchControlPlane(): Promise<SecurityAssetControlPlanePayload | null> {
|
||||
const base = getRuntimeApiBaseUrl();
|
||||
const path = "/api/v1/iwooos/security-asset-control-plane";
|
||||
const urls = Array.from(new Set([path, base ? `${base}${path}` : ""].filter(Boolean)));
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) continue;
|
||||
const payload: unknown = await response.json();
|
||||
if (isSecurityAssetControlPlanePayload(payload)) return payload;
|
||||
} catch {
|
||||
// Try the next same-origin/runtime API candidate.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function MetricCell({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: typeof ShieldCheck;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 bg-white px-3 py-3">
|
||||
<div className="flex items-center gap-2 text-[11px] font-medium text-[#6f6d66]">
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
<div className="mt-2 font-mono text-xl font-semibold tracking-normal text-[#141413]">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StageStrip({
|
||||
title,
|
||||
stages,
|
||||
}: {
|
||||
title: string;
|
||||
stages: SecurityControlPlaneStage[];
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 text-[11px] font-semibold text-[#4f4d47]">{title}</div>
|
||||
<div className="grid grid-cols-4 gap-1 sm:grid-cols-7 lg:grid-cols-8">
|
||||
{stages.map((stage) => {
|
||||
const observed = stage.status === "observed";
|
||||
return (
|
||||
<div
|
||||
key={stage.stage_id}
|
||||
className={cn(
|
||||
"min-w-0 border px-1.5 py-2 text-center",
|
||||
observed
|
||||
? "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"
|
||||
: "border-[#e2a29b] bg-[#fff2f0] text-[#9f2f25]"
|
||||
)}
|
||||
title={`${stage.label}: ${stageCount(stage)}`}
|
||||
>
|
||||
<div className="truncate text-[10px] font-semibold">{stage.label}</div>
|
||||
<div className="mt-1 font-mono text-xs">{stageCount(stage)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SecurityAssetControlPlaneCockpit({
|
||||
locale = "zh-TW",
|
||||
className,
|
||||
}: {
|
||||
locale?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const copy = COPY[locale.toLowerCase().startsWith("zh") ? "zh" : "en"];
|
||||
const [payload, setPayload] = useState<SecurityAssetControlPlanePayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const next = await fetchControlPlane();
|
||||
setPayload(next);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const timer = window.setInterval(() => void load(), 60_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
const tone = payload ? securityControlPlaneTone(payload) : "critical";
|
||||
const priorityItems = useMemo(
|
||||
() => (payload ? topSecurityControlPlaneWorkItems(payload, 3) : []),
|
||||
[payload]
|
||||
);
|
||||
|
||||
if (!payload && !loading) {
|
||||
return (
|
||||
<section
|
||||
className={cn("border border-[#e2a29b] bg-[#fff2f0] px-4 py-4", className)}
|
||||
data-testid="security-asset-control-plane-cockpit"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[#9f2f25]">
|
||||
<ShieldAlert className="h-4 w-4" aria-hidden="true" />
|
||||
{copy.unavailable}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"overflow-hidden border border-[#d8d3c7] bg-[#f6f4ee]",
|
||||
className
|
||||
)}
|
||||
data-testid="security-asset-control-plane-cockpit"
|
||||
aria-busy={loading}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#d8d3c7] bg-[#141413] px-4 py-3 text-white">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<ShieldCheck className="h-5 w-5 shrink-0 text-[#7fd29b]" aria-hidden="true" />
|
||||
<h2 className="truncate text-sm font-semibold tracking-normal">{copy.title}</h2>
|
||||
<span
|
||||
className={cn(
|
||||
"border px-2 py-0.5 font-mono text-[10px] font-semibold",
|
||||
TONE_CLASS[tone]
|
||||
)}
|
||||
>
|
||||
{payload?.status === "ready" ? copy.live : copy.degraded}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load()}
|
||||
className="inline-flex h-8 w-8 items-center justify-center border border-[#5b5953] text-[#d8d3c7] hover:border-white hover:text-white"
|
||||
aria-label={copy.refresh}
|
||||
title={copy.refresh}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-px bg-[#d8d3c7] sm:grid-cols-3 lg:grid-cols-6">
|
||||
<MetricCell label={copy.assets} value={payload?.summary.managed_asset_count ?? "--"} icon={Boxes} />
|
||||
<MetricCell
|
||||
label={copy.assetTypes}
|
||||
value={payload ? `${payload.summary.asset_type_coverage_percent}%` : "--"}
|
||||
icon={Activity}
|
||||
/>
|
||||
<MetricCell
|
||||
label={copy.nist}
|
||||
value={payload ? `${payload.summary.nist_controlled_percent}%` : "--"}
|
||||
icon={ShieldCheck}
|
||||
/>
|
||||
<MetricCell
|
||||
label={copy.siem}
|
||||
value={payload ? `${payload.summary.siem_observed_stage_count}/${payload.summary.siem_stage_count}` : "--"}
|
||||
icon={Radar}
|
||||
/>
|
||||
<MetricCell
|
||||
label={copy.aiLoop}
|
||||
value={payload ? `${payload.summary.ai_loop_observed_stage_count}/${payload.summary.ai_loop_stage_count}` : "--"}
|
||||
icon={Bot}
|
||||
/>
|
||||
<MetricCell
|
||||
label={copy.verified}
|
||||
value={payload?.summary.verified_remediation_receipt_count_24h ?? "--"}
|
||||
icon={CheckCircle2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{payload ? (
|
||||
<div className="grid gap-4 p-4 xl:grid-cols-[1.1fr_1.4fr]">
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div>
|
||||
<div className="mb-2 text-[11px] font-semibold text-[#4f4d47]">{copy.functions}</div>
|
||||
<div className="grid grid-cols-3 gap-1 sm:grid-cols-6">
|
||||
{payload.nist_csf_functions.map((item) => (
|
||||
<div key={item.function_id} className="border border-[#d8d3c7] bg-white px-2 py-2">
|
||||
<div className="truncate text-[10px] text-[#6f6d66]">{item.label}</div>
|
||||
<div className="mt-1 font-mono text-sm font-semibold text-[#141413]">
|
||||
{item.controlled_percent}%
|
||||
</div>
|
||||
<div className="mt-1 h-1 bg-[#ebe8df]">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full",
|
||||
item.controlled_percent >= 80
|
||||
? "bg-[#2f8a50]"
|
||||
: item.controlled_percent > 0
|
||||
? "bg-[#c58a2e]"
|
||||
: "bg-[#c9584d]"
|
||||
)}
|
||||
style={{ width: `${Math.min(100, Math.max(0, item.controlled_percent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-[11px] font-semibold text-[#4f4d47]">
|
||||
<span>{copy.scope}</span>
|
||||
<span className="font-mono text-[#77736a]">
|
||||
{payload.summary.controlled_scope_count}/{payload.summary.scope_count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-1 sm:grid-cols-2">
|
||||
{payload.asset_scopes.map((scope) => (
|
||||
<div key={scope.scope_id} className="flex items-center gap-2 border border-[#d8d3c7] bg-white px-2 py-2">
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] text-[#4f4d47]">{scope.label}</span>
|
||||
<span className="font-mono text-[11px] font-semibold text-[#141413]">
|
||||
{scope.managed_asset_count}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0",
|
||||
scope.status === "controlled"
|
||||
? "bg-[#2f8a50]"
|
||||
: scope.status === "partial"
|
||||
? "bg-[#c58a2e]"
|
||||
: "bg-[#c9584d]"
|
||||
)}
|
||||
title={`${scope.type_coverage_percent}%`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-4">
|
||||
<StageStrip title={copy.siemFlow} stages={payload.siem.pipeline} />
|
||||
<StageStrip title={copy.aiFlow} stages={payload.ai_automation.stages} />
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_1.35fr]">
|
||||
<div className="border border-[#d8d3c7] bg-white p-3">
|
||||
<div className="mb-2 text-[11px] font-semibold text-[#4f4d47]">{copy.audit}</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<div className="font-mono text-sm font-semibold text-[#141413]">
|
||||
{payload.security_audit.automation_operation_count_24h}
|
||||
</div>
|
||||
<div className="mt-1 text-[9px] text-[#77736a]">OPS</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-mono text-sm font-semibold text-[#141413]">
|
||||
{payload.security_audit.mcp_tool_audit_count_24h}
|
||||
</div>
|
||||
<div className="mt-1 text-[9px] text-[#77736a]">MCP</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-mono text-sm font-semibold text-[#141413]">
|
||||
{payload.security_audit.ai_trace_count_24h}
|
||||
</div>
|
||||
<div className="mt-1 text-[9px] text-[#77736a]">{copy.aiTrace}</div>
|
||||
</div>
|
||||
</div>
|
||||
{!payload.ai_automation.same_run_closed_loop_proven ? (
|
||||
<div className="mt-3 flex items-center gap-1.5 border-t border-[#ebe8df] pt-2 text-[10px] font-medium text-[#9f2f25]">
|
||||
<TriangleAlert className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
{copy.sameRunMissing}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="border border-[#d8d3c7] bg-white p-3">
|
||||
<div className="mb-2 text-[11px] font-semibold text-[#4f4d47]">{copy.priority}</div>
|
||||
<div className="space-y-1.5">
|
||||
{priorityItems.map((item) => (
|
||||
<div key={item.work_item_id} className="flex min-w-0 items-center gap-2">
|
||||
<span className="border border-[#e2a29b] bg-[#fff2f0] px-1.5 py-0.5 font-mono text-[9px] font-semibold text-[#9f2f25]">
|
||||
{item.priority}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] font-medium text-[#34332f]" title={item.title}>
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-[#77736a]">{item.gap_count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64 animate-pulse bg-[#ebe8df]" />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user