feat(web): 顯示自動化資產總帳
This commit is contained in:
@@ -978,6 +978,150 @@ type Telemetry = {
|
||||
aiRouteStatus: AiRouteStatusResponse | null;
|
||||
};
|
||||
|
||||
type AutomationAssetLedgerKey = "km" | "playbook" | "script" | "schedule" | "verifier";
|
||||
|
||||
type AutomationAssetLedgerItem = {
|
||||
key: AutomationAssetLedgerKey;
|
||||
icon: typeof Activity;
|
||||
tone: WorkStatus;
|
||||
ready: number;
|
||||
pending: number;
|
||||
blocked: number;
|
||||
sourceKeys: string[];
|
||||
primaryRef: string;
|
||||
};
|
||||
|
||||
const toCount = (value: number | null | undefined) =>
|
||||
Number.isFinite(value) && value ? Math.max(0, Math.trunc(value)) : 0;
|
||||
|
||||
const uniqueText = (values: Array<string | null | undefined>) =>
|
||||
Array.from(
|
||||
new Set(
|
||||
values
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
);
|
||||
|
||||
function ledgerTone(item: Pick<AutomationAssetLedgerItem, "ready" | "pending" | "blocked">): WorkStatus {
|
||||
if (item.blocked > 0) return "blocked";
|
||||
if (item.pending > 0) return "in_progress";
|
||||
if (item.ready > 0) return "live";
|
||||
return "watching";
|
||||
}
|
||||
|
||||
function buildAutomationAssetLedger(
|
||||
telemetry: Telemetry,
|
||||
focusedDraft: RepairCandidateDraftFocus | null
|
||||
): AutomationAssetLedgerItem[] {
|
||||
const remediationQueue = telemetry.slo?.adr100?.verification_coverage?.remediation_queue ?? null;
|
||||
const remediationQueueItems = remediationQueue?.items ?? [];
|
||||
const statusChain = telemetry.statusChain;
|
||||
const ansible = statusChain?.execution?.ansible;
|
||||
const aiRepairEvidence = telemetry.aiRouteStatus?.repair_evidence;
|
||||
const reviewDraftTotal = toCount(telemetry.knowledgeReviewDrafts?.total);
|
||||
const staleTotal = toCount(telemetry.knowledgeStaleCandidates?.total_stale);
|
||||
const ownerPending = toCount(
|
||||
telemetry.knowledgeStaleOwnerReviews?.total
|
||||
?? telemetry.knowledgeStaleOwnerReviewCompletionQueue?.pending_count
|
||||
);
|
||||
const ownerReady = toCount(telemetry.knowledgeStaleOwnerReviewCompletionQueue?.ready_count);
|
||||
const ownerBlocked = toCount(telemetry.knowledgeStaleOwnerReviewCompletionQueue?.blocked_count);
|
||||
const ownerCompleted = toCount(
|
||||
telemetry.knowledgeStaleOwnerReviewCompletionQueue?.completed_count
|
||||
?? telemetry.knowledgeStaleOwnerReviewBurnDown?.completed_owner_reviews
|
||||
);
|
||||
const dedupeDuplicates = toCount(telemetry.knowledgeReviewDedupe?.duplicate_draft_total);
|
||||
|
||||
const playbookRefs = uniqueText([
|
||||
...(statusChain?.execution?.playbook_ids ?? []),
|
||||
...(statusChain?.execution?.playbook_paths ?? []),
|
||||
...remediationQueueItems.map((item) => item.playbook_id),
|
||||
...(telemetry.governanceQueue?.items ?? []).map((item) => item.playbook_id),
|
||||
aiRepairEvidence?.playbook_recommendation?.playbook_id,
|
||||
aiRepairEvidence?.owner_action?.playbook_id,
|
||||
]);
|
||||
const playbookPending = toCount(remediationQueue?.needs_human)
|
||||
+ (focusedDraft ? 1 : 0)
|
||||
+ toCount(aiRepairEvidence?.access_blockers?.length);
|
||||
const playbookBlocked = aiRepairEvidence?.playbook_recommendation?.safe_to_auto_execute === false ? 1 : 0;
|
||||
|
||||
const ansibleCandidates = toCount(ansible?.candidate_count ?? ansible?.candidate_playbooks?.length);
|
||||
const ansibleChecks = toCount(ansible?.check_mode_total) + toCount(ansible?.apply_total);
|
||||
const scriptPending = ansibleCandidates + (focusedDraft ? 1 : 0);
|
||||
const scriptBlocked = ansible?.not_used_reason ? 1 : 0;
|
||||
|
||||
const recurrenceOpen = recurrenceOpenItems(telemetry.eventRecurrence).length;
|
||||
const alertEvents = toCount(telemetry.channelEvents?.total);
|
||||
const callbackEvents = toCount(telemetry.callbackReplies?.total);
|
||||
const callbackTraceGap = toCount(
|
||||
telemetry.callbackReplies?.summary?.outbound_reply_markup_missing_trace_ref_recent_24h_total
|
||||
);
|
||||
|
||||
const evaluated = toCount(telemetry.quality?.evaluated_total);
|
||||
const verified = toCount(telemetry.quality?.verified_auto_repair_total);
|
||||
const verificationText = String(statusChain?.verification ?? "").toLowerCase();
|
||||
const verifierBlocked = /failed|degraded|error|timeout/.test(verificationText) ? 1 : 0;
|
||||
const verifierPending = Math.max(0, evaluated - verified)
|
||||
+ toCount(remediationQueue?.total)
|
||||
+ (statusChain?.current_stage && statusChain?.verification !== "success" ? 1 : 0);
|
||||
|
||||
const items: AutomationAssetLedgerItem[] = [
|
||||
{
|
||||
key: "km",
|
||||
icon: Database,
|
||||
ready: ownerCompleted,
|
||||
pending: reviewDraftTotal + staleTotal + ownerPending + ownerReady,
|
||||
blocked: ownerBlocked + dedupeDuplicates,
|
||||
sourceKeys: ["knowledgeBase", "ownerReview", "staleRatio"],
|
||||
primaryRef: telemetry.knowledgeStaleOwnerReviewBurnDown?.burn_down_status ?? "knowledge_degradation",
|
||||
tone: "watching",
|
||||
},
|
||||
{
|
||||
key: "playbook",
|
||||
icon: ClipboardList,
|
||||
ready: playbookRefs.length,
|
||||
pending: playbookPending,
|
||||
blocked: playbookBlocked,
|
||||
sourceKeys: ["statusChain", "remediationQueue", "aiRoute"],
|
||||
primaryRef: playbookRefs[0] ?? aiRepairEvidence?.owner_action?.next_step ?? "repair_candidate_draft",
|
||||
tone: "watching",
|
||||
},
|
||||
{
|
||||
key: "script",
|
||||
icon: GitBranch,
|
||||
ready: ansibleChecks,
|
||||
pending: scriptPending,
|
||||
blocked: scriptBlocked,
|
||||
sourceKeys: ["runs", "ansible", "safeRoute"],
|
||||
primaryRef: ansible?.latest_playbook_path ?? ansible?.not_used_reason ?? "script_or_ansible_ref",
|
||||
tone: "watching",
|
||||
},
|
||||
{
|
||||
key: "schedule",
|
||||
icon: Activity,
|
||||
ready: callbackEvents,
|
||||
pending: recurrenceOpen + alertEvents,
|
||||
blocked: callbackTraceGap,
|
||||
sourceKeys: ["recurrence", "alertmanager", "callbackTrace"],
|
||||
primaryRef: telemetry.eventRecurrence?.project_id ?? "alert_recurrence",
|
||||
tone: "watching",
|
||||
},
|
||||
{
|
||||
key: "verifier",
|
||||
icon: CheckCircle2,
|
||||
ready: verified,
|
||||
pending: verifierPending,
|
||||
blocked: verifierBlocked,
|
||||
sourceKeys: ["timeline", "quality", "remediationHistory"],
|
||||
primaryRef: statusChain?.verification ?? statusChain?.next_step ?? "verifier_result",
|
||||
tone: "watching",
|
||||
},
|
||||
];
|
||||
|
||||
return items.map((item) => ({ ...item, tone: ledgerTone(item) }));
|
||||
}
|
||||
|
||||
type IncidentTimelineEvent = {
|
||||
stage: string;
|
||||
status: string;
|
||||
@@ -2862,6 +3006,193 @@ function ProductionClaimBanner({
|
||||
);
|
||||
}
|
||||
|
||||
function AutomationAssetLedgerPanel({
|
||||
telemetry,
|
||||
focusedDraft,
|
||||
loading,
|
||||
}: {
|
||||
telemetry: Telemetry;
|
||||
focusedDraft: RepairCandidateDraftFocus | null;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const t = useTranslations("awooop.workItems.assetLedger");
|
||||
const locale = useLocale();
|
||||
const items = useMemo(
|
||||
() => buildAutomationAssetLedger(telemetry, focusedDraft),
|
||||
[focusedDraft, telemetry]
|
||||
);
|
||||
const formatCount = useCallback(
|
||||
(value: number) => value.toLocaleString(locale === "zh-TW" ? "zh-TW" : "en-US"),
|
||||
[locale]
|
||||
);
|
||||
const totals = useMemo(() => {
|
||||
const ready = items.reduce((sum, item) => sum + item.ready, 0);
|
||||
const pending = items.reduce((sum, item) => sum + item.pending, 0);
|
||||
const blocked = items.reduce((sum, item) => sum + item.blocked, 0);
|
||||
const total = ready + pending + blocked;
|
||||
return {
|
||||
ready,
|
||||
pending,
|
||||
blocked,
|
||||
total,
|
||||
rate: total > 0 ? Math.round((ready / total) * 100) : 0,
|
||||
};
|
||||
}, [items]);
|
||||
const flow = [
|
||||
{ key: "incident", value: telemetry.incidentTimeline?.events?.length ?? 0 },
|
||||
{ key: "km", value: items.find((item) => item.key === "km")?.ready ?? 0 },
|
||||
{ key: "playbook", value: items.find((item) => item.key === "playbook")?.ready ?? 0 },
|
||||
{ key: "automation", value: items.find((item) => item.key === "script")?.ready ?? 0 },
|
||||
{ key: "verifier", value: items.find((item) => item.key === "verifier")?.ready ?? 0 },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="border border-[#e0ddd4] bg-white" data-testid="automation-asset-ledger">
|
||||
<div className="grid gap-px bg-[#e0ddd4] lg:grid-cols-[0.9fr_1.1fr]">
|
||||
<div className="min-w-0 bg-white p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center border border-[#d8d3c7] bg-[#faf9f3] text-[#d97757]">
|
||||
<Database className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#d97757]">
|
||||
{t("eyebrow")}
|
||||
</p>
|
||||
<h3 className="mt-1 text-lg font-semibold tracking-normal text-[#141413]">
|
||||
{t("title")}
|
||||
</h3>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-[#5f5b52]">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-px bg-[#e0ddd4] sm:grid-cols-4">
|
||||
{[
|
||||
{ key: "assets", value: items.length },
|
||||
{ key: "ready", value: totals.ready },
|
||||
{ key: "pending", value: totals.pending },
|
||||
{ key: "blocked", value: totals.blocked },
|
||||
].map((metric) => (
|
||||
<div key={metric.key} className="min-w-0 bg-[#faf9f3] px-3 py-2">
|
||||
<p className="text-xs font-semibold text-[#77736a]">
|
||||
{t(`metrics.${metric.key}` as never)}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-xl font-semibold text-[#141413]">
|
||||
{loading ? "--" : formatCount(metric.value)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border border-[#e0ddd4] bg-[#faf9f3] p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold text-[#141413]">{t("rate")}</p>
|
||||
<span className="font-mono text-xs font-semibold text-[#17602a]">
|
||||
{loading ? "--" : `${totals.rate}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 bg-white">
|
||||
<div
|
||||
className="h-full bg-[#78a87f]"
|
||||
style={{ width: `${Math.min(100, totals.rate)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">{t("rateHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 bg-white p-4">
|
||||
<div className="grid gap-2 md:grid-cols-5">
|
||||
{flow.map((node, index) => (
|
||||
<div key={node.key} className="relative min-w-0 border border-[#e0ddd4] bg-[#faf9f3] px-3 py-2">
|
||||
{index > 0 && (
|
||||
<div className="absolute -left-2 top-1/2 hidden h-px w-4 bg-[#d8d3c7] md:block" />
|
||||
)}
|
||||
<p className="font-mono text-[11px] font-semibold text-[#77736a]">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-[#141413]">
|
||||
{t(`flow.${node.key}.title` as never)}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-lg font-semibold text-[#141413]">
|
||||
{loading ? "--" : formatCount(node.value)}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] leading-5 text-[#5f5b52]">
|
||||
{t(`flow.${node.key}.detail` as never)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-px border-t border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-5">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const denominator = item.ready + item.pending + item.blocked;
|
||||
const itemRate = denominator > 0 ? Math.round((item.ready / denominator) * 100) : 0;
|
||||
return (
|
||||
<div key={item.key} className="min-w-0 bg-white p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-[#141413]">
|
||||
{t(`items.${item.key}.title` as never)}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] leading-5 text-[#5f5b52]">
|
||||
{t(`items.${item.key}.detail` as never)}
|
||||
</p>
|
||||
</div>
|
||||
<Icon className="h-4 w-4 shrink-0 text-[#d97757]" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-px bg-[#e0ddd4]">
|
||||
{(["ready", "pending", "blocked"] as const).map((state) => (
|
||||
<div key={state} className="min-w-0 bg-[#faf9f3] px-2 py-1.5">
|
||||
<p className="text-[10px] font-semibold text-[#77736a]">
|
||||
{t(`states.${state}` as never)}
|
||||
</p>
|
||||
<p className="font-mono text-sm font-semibold text-[#141413]">
|
||||
{loading ? "--" : formatCount(item[state])}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 h-1.5 bg-[#faf9f3]">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full",
|
||||
item.tone === "blocked"
|
||||
? "bg-[#d97757]"
|
||||
: item.tone === "in_progress"
|
||||
? "bg-[#d9b36f]"
|
||||
: item.tone === "live"
|
||||
? "bg-[#78a87f]"
|
||||
: "bg-[#b8b2a7]"
|
||||
)}
|
||||
style={{ width: `${Math.min(100, itemRate)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{item.sourceKeys.map((source) => (
|
||||
<span key={source} className="border border-[#e0ddd4] bg-[#faf9f3] px-1.5 py-0.5 text-[10px] font-semibold text-[#5f5b52]">
|
||||
{t(`sources.${source}` as never)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 break-words font-mono text-[11px] leading-5 text-[#5f5b52]">
|
||||
{t("primaryRef", { ref: item.primaryRef })}
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] leading-5 text-[#5f5b52]">
|
||||
{t(`items.${item.key}.next` as never)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairCandidateDraftPanel({
|
||||
draft,
|
||||
chain,
|
||||
@@ -6337,6 +6668,12 @@ export default function AwoooPWorkItemsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AutomationAssetLedgerPanel
|
||||
telemetry={telemetry}
|
||||
focusedDraft={focusedRepairCandidateDraft}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<IncidentEvidenceHeader
|
||||
projectId={projectId}
|
||||
incidentIds={visibleIncidentIds}
|
||||
|
||||
Reference in New Issue
Block a user