feat(awooop): surface source flow on overview
This commit is contained in:
@@ -1706,6 +1706,33 @@
|
|||||||
"ready": "In Sync",
|
"ready": "In Sync",
|
||||||
"loading": "Loading",
|
"loading": "Loading",
|
||||||
"degraded": "Degraded",
|
"degraded": "Degraded",
|
||||||
|
"sourceFlow": {
|
||||||
|
"title": "Source Flow and Work Progress",
|
||||||
|
"subtitle": "Reads recent Channel Event recurrence data so the overview shows source persistence, run linkage, work items, and source correlation state.",
|
||||||
|
"sourceEvents": "{count} source events",
|
||||||
|
"unavailable": "Unavailable",
|
||||||
|
"loadFailed": "Unable to load the source flow overview. Check the Work Chain or Run Monitor recurrence API.",
|
||||||
|
"empty": "No source event data is available yet.",
|
||||||
|
"metrics": {
|
||||||
|
"linkedRuns": "Run Linkage",
|
||||||
|
"linkedRunsDetail": "Unlinked events: {unlinked}",
|
||||||
|
"openWork": "Open Work",
|
||||||
|
"openWorkDetail": "No repair {gap} / manual gates {manual} / failed repairs {failed}",
|
||||||
|
"sourceDecision": "Source Decision",
|
||||||
|
"sourceDecisionNone": "No Review",
|
||||||
|
"sourceDecisionDetail": "Recorded reviews: {recorded}",
|
||||||
|
"latest": "Latest Event",
|
||||||
|
"latestDetail": "{groups} recurrence groups"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"linked": "Source to Run Coverage",
|
||||||
|
"linkedDetail": "Whether source events can be traced back to Run / Incident",
|
||||||
|
"work": "Work Item Cleanup",
|
||||||
|
"workDetail": "Whether recurrence groups still have open work",
|
||||||
|
"decision": "Source Match Decision",
|
||||||
|
"decisionDetail": "Whether source review / apply has a decision record"
|
||||||
|
}
|
||||||
|
},
|
||||||
"quality": {
|
"quality": {
|
||||||
"title": "Automation Quality",
|
"title": "Automation Quality",
|
||||||
"subtitle": "Whether recent alerts actually reached AI auto-repair, verification, and learning writeback in the last 24 hours.",
|
"subtitle": "Whether recent alerts actually reached AI auto-repair, verification, and learning writeback in the last 24 hours.",
|
||||||
|
|||||||
@@ -1707,6 +1707,33 @@
|
|||||||
"ready": "同步中",
|
"ready": "同步中",
|
||||||
"loading": "讀取中",
|
"loading": "讀取中",
|
||||||
"degraded": "降級",
|
"degraded": "降級",
|
||||||
|
"sourceFlow": {
|
||||||
|
"title": "來源流程與工作進度",
|
||||||
|
"subtitle": "從 Channel Event recurrence 讀取最近來源事件,讓首頁直接呈現來源落庫、Run 連結、工作項與 source correlation 狀態。",
|
||||||
|
"sourceEvents": "來源事件 {count}",
|
||||||
|
"unavailable": "無法讀取",
|
||||||
|
"loadFailed": "無法讀取來源流程總覽。請回工作鏈路或 Run 監控檢查 recurrence API。",
|
||||||
|
"empty": "尚無來源事件資料。",
|
||||||
|
"metrics": {
|
||||||
|
"linkedRuns": "Run 連結",
|
||||||
|
"linkedRunsDetail": "未連結事件:{unlinked}",
|
||||||
|
"openWork": "待處理工作",
|
||||||
|
"openWorkDetail": "無修復 {gap} / 人工閘門 {manual} / 修復失敗 {failed}",
|
||||||
|
"sourceDecision": "來源決策",
|
||||||
|
"sourceDecisionNone": "無待審",
|
||||||
|
"sourceDecisionDetail": "已記錄審核:{recorded}",
|
||||||
|
"latest": "最新事件",
|
||||||
|
"latestDetail": "共 {groups} 個 recurrence group"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"linked": "來源到 Run 覆蓋",
|
||||||
|
"linkedDetail": "來源事件是否已能回到 Run / Incident",
|
||||||
|
"work": "工作項清理",
|
||||||
|
"workDetail": "recurrence group 是否仍有待處理項",
|
||||||
|
"decision": "來源配對決策",
|
||||||
|
"decisionDetail": "source review / apply 是否已有決策紀錄"
|
||||||
|
}
|
||||||
|
},
|
||||||
"quality": {
|
"quality": {
|
||||||
"title": "自動化品質",
|
"title": "自動化品質",
|
||||||
"subtitle": "最近 24 小時告警是否真正走到 AI 自動修復、驗證與學習回寫。",
|
"subtitle": "最近 24 小時告警是否真正走到 AI 自動修復、驗證與學習回寫。",
|
||||||
|
|||||||
@@ -84,6 +84,27 @@ type AutomationQualitySummary = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SourceFlowSummary = {
|
||||||
|
source_event_total: number;
|
||||||
|
recurrence_group_total: number;
|
||||||
|
linked_run_total: number;
|
||||||
|
unlinked_event_total: number;
|
||||||
|
open_work_item_group_total?: number;
|
||||||
|
manual_gate_group_total?: number;
|
||||||
|
automation_gap_group_total?: number;
|
||||||
|
failed_repair_group_total?: number;
|
||||||
|
source_correlation_review_group_total?: number;
|
||||||
|
source_correlation_decision_recorded_group_total?: number;
|
||||||
|
source_correlation_applied_group_total?: number;
|
||||||
|
latest_received_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SourceFlowResponse = {
|
||||||
|
project_id: string;
|
||||||
|
limit: number;
|
||||||
|
summary: SourceFlowSummary;
|
||||||
|
};
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||||
|
|
||||||
const emptySnapshot: Snapshot = {
|
const emptySnapshot: Snapshot = {
|
||||||
@@ -304,6 +325,171 @@ function QualityMetric({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function percentValue(numerator: number, denominator: number): number {
|
||||||
|
if (denominator <= 0) return 0;
|
||||||
|
return Math.max(0, Math.min(100, (numerator / denominator) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
detail,
|
||||||
|
percent,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
detail: string;
|
||||||
|
percent: number;
|
||||||
|
tone: "good" | "warn" | "neutral";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="border border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-[#77736a]">{label}</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-[#5f5b52]">{detail}</p>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-sm font-semibold text-[#141413]">{value}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-2 overflow-hidden border border-[#d8d3c7] bg-white">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full",
|
||||||
|
tone === "good" && "bg-[#6aa879]",
|
||||||
|
tone === "warn" && "bg-[#d9b36f]",
|
||||||
|
tone === "neutral" && "bg-[#9bb6d9]"
|
||||||
|
)}
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceFlowOverviewPanel({
|
||||||
|
summary,
|
||||||
|
error,
|
||||||
|
locale,
|
||||||
|
}: {
|
||||||
|
summary: SourceFlowSummary | null;
|
||||||
|
error: boolean;
|
||||||
|
locale: string;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("awooop.home.sourceFlow");
|
||||||
|
const sourceTotal = summary?.source_event_total ?? 0;
|
||||||
|
const groupTotal = summary?.recurrence_group_total ?? 0;
|
||||||
|
const linkedRuns = summary?.linked_run_total ?? 0;
|
||||||
|
const unlinkedEvents = summary?.unlinked_event_total ?? 0;
|
||||||
|
const openWork = summary?.open_work_item_group_total ?? 0;
|
||||||
|
const automationGaps = summary?.automation_gap_group_total ?? 0;
|
||||||
|
const manualGates = summary?.manual_gate_group_total ?? 0;
|
||||||
|
const failedRepairs = summary?.failed_repair_group_total ?? 0;
|
||||||
|
const sourceReview = summary?.source_correlation_review_group_total ?? 0;
|
||||||
|
const sourceReviewRecorded = summary?.source_correlation_decision_recorded_group_total ?? 0;
|
||||||
|
const sourceApplied = summary?.source_correlation_applied_group_total ?? 0;
|
||||||
|
const sourceDecisionKnown = sourceReviewRecorded + sourceApplied;
|
||||||
|
const sourceDecisionTotal = sourceReview + sourceDecisionKnown;
|
||||||
|
const linkedPercent = percentValue(linkedRuns, sourceTotal);
|
||||||
|
const workClearPercent = percentValue(Math.max(0, groupTotal - openWork), groupTotal);
|
||||||
|
const sourceDecisionPercent = sourceDecisionTotal === 0
|
||||||
|
? 100
|
||||||
|
: percentValue(sourceDecisionKnown, sourceDecisionTotal);
|
||||||
|
const latestTime = summary?.latest_received_at
|
||||||
|
? new Date(summary.latest_received_at).toLocaleTimeString(locale === "zh-TW" ? "zh-TW" : "en-US", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
: "--";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="border border-[#e0ddd4] bg-white">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<GitBranch className="mt-0.5 h-4 w-4 text-[#d97757]" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-[#77736a]">{t("subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex border px-2 py-0.5 text-xs font-semibold",
|
||||||
|
error && "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
|
||||||
|
!error && sourceTotal > 0 && "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
|
||||||
|
!error && sourceTotal === 0 && "border-[#d8d3c7] bg-white text-[#5f5b52]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{error ? t("unavailable") : t("sourceEvents", { count: sourceTotal })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error || !summary ? (
|
||||||
|
<div className="px-4 py-5 text-sm leading-6 text-[#5f5b52]">
|
||||||
|
{error ? t("loadFailed") : t("empty")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<QualityMetric
|
||||||
|
label={t("metrics.linkedRuns")}
|
||||||
|
value={`${linkedRuns}/${sourceTotal}`}
|
||||||
|
detail={t("metrics.linkedRunsDetail", { unlinked: unlinkedEvents })}
|
||||||
|
/>
|
||||||
|
<QualityMetric
|
||||||
|
label={t("metrics.openWork")}
|
||||||
|
value={openWork}
|
||||||
|
detail={t("metrics.openWorkDetail", {
|
||||||
|
gap: automationGaps,
|
||||||
|
manual: manualGates,
|
||||||
|
failed: failedRepairs,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<QualityMetric
|
||||||
|
label={t("metrics.sourceDecision")}
|
||||||
|
value={
|
||||||
|
sourceDecisionTotal === 0
|
||||||
|
? t("metrics.sourceDecisionNone")
|
||||||
|
: `${sourceDecisionKnown}/${sourceDecisionTotal}`
|
||||||
|
}
|
||||||
|
detail={t("metrics.sourceDecisionDetail", { recorded: sourceReviewRecorded })}
|
||||||
|
/>
|
||||||
|
<QualityMetric
|
||||||
|
label={t("metrics.latest")}
|
||||||
|
value={latestTime}
|
||||||
|
detail={t("metrics.latestDetail", { groups: groupTotal })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 xl:grid-cols-3">
|
||||||
|
<ProgressRow
|
||||||
|
label={t("progress.linked")}
|
||||||
|
value={`${linkedPercent.toFixed(0)}%`}
|
||||||
|
detail={t("progress.linkedDetail")}
|
||||||
|
percent={linkedPercent}
|
||||||
|
tone={unlinkedEvents === 0 ? "good" : "warn"}
|
||||||
|
/>
|
||||||
|
<ProgressRow
|
||||||
|
label={t("progress.work")}
|
||||||
|
value={`${workClearPercent.toFixed(0)}%`}
|
||||||
|
detail={t("progress.workDetail")}
|
||||||
|
percent={workClearPercent}
|
||||||
|
tone={openWork === 0 ? "good" : "warn"}
|
||||||
|
/>
|
||||||
|
<ProgressRow
|
||||||
|
label={t("progress.decision")}
|
||||||
|
value={`${sourceDecisionPercent.toFixed(0)}%`}
|
||||||
|
detail={t("progress.decisionDetail")}
|
||||||
|
percent={sourceDecisionPercent}
|
||||||
|
tone={sourceReview === 0 ? "good" : "neutral"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AutomationQualityPanel({
|
function AutomationQualityPanel({
|
||||||
summary,
|
summary,
|
||||||
error,
|
error,
|
||||||
@@ -463,6 +649,8 @@ export default function AwoooPPage() {
|
|||||||
const [snapshot, setSnapshot] = useState<Snapshot>(emptySnapshot);
|
const [snapshot, setSnapshot] = useState<Snapshot>(emptySnapshot);
|
||||||
const [qualitySummary, setQualitySummary] = useState<AutomationQualitySummary | null>(null);
|
const [qualitySummary, setQualitySummary] = useState<AutomationQualitySummary | null>(null);
|
||||||
const [qualityError, setQualityError] = useState(false);
|
const [qualityError, setQualityError] = useState(false);
|
||||||
|
const [sourceFlowSummary, setSourceFlowSummary] = useState<SourceFlowSummary | null>(null);
|
||||||
|
const [sourceFlowError, setSourceFlowError] = useState(false);
|
||||||
const [status, setStatus] = useState<SnapshotStatus>("loading");
|
const [status, setStatus] = useState<SnapshotStatus>("loading");
|
||||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||||
|
|
||||||
@@ -474,13 +662,19 @@ export default function AwoooPPage() {
|
|||||||
hours: "24",
|
hours: "24",
|
||||||
limit: "50",
|
limit: "50",
|
||||||
});
|
});
|
||||||
const [tenantRes, runRes, approvalRes, contractRes, qualityRes] = await Promise.all([
|
const sourceFlowParams = new URLSearchParams({
|
||||||
|
project_id: "awoooi",
|
||||||
|
limit: "100",
|
||||||
|
});
|
||||||
|
const [tenantRes, runRes, approvalRes, contractRes, qualityRes, sourceFlowRes] = await Promise.all([
|
||||||
fetch(`${API_BASE}/api/v1/platform/tenants`),
|
fetch(`${API_BASE}/api/v1/platform/tenants`),
|
||||||
fetch(`${API_BASE}/api/v1/platform/runs/list?per_page=1`),
|
fetch(`${API_BASE}/api/v1/platform/runs/list?per_page=1`),
|
||||||
fetch(`${API_BASE}/api/v1/platform/approvals`),
|
fetch(`${API_BASE}/api/v1/platform/approvals`),
|
||||||
fetch(`${API_BASE}/api/v1/platform/contracts?per_page=1`),
|
fetch(`${API_BASE}/api/v1/platform/contracts?per_page=1`),
|
||||||
fetch(`${API_BASE}/api/v1/platform/truth-chain/quality/summary?${qualityParams.toString()}`)
|
fetch(`${API_BASE}/api/v1/platform/truth-chain/quality/summary?${qualityParams.toString()}`)
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
|
fetch(`${API_BASE}/api/v1/platform/events/dossier/recurrence?${sourceFlowParams.toString()}`)
|
||||||
|
.catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (![tenantRes, runRes, approvalRes, contractRes].every((res) => res.ok)) {
|
if (![tenantRes, runRes, approvalRes, contractRes].every((res) => res.ok)) {
|
||||||
@@ -511,12 +705,22 @@ export default function AwoooPPage() {
|
|||||||
setQualitySummary(null);
|
setQualitySummary(null);
|
||||||
setQualityError(true);
|
setQualityError(true);
|
||||||
}
|
}
|
||||||
|
if (sourceFlowRes?.ok) {
|
||||||
|
const sourceFlowData = await sourceFlowRes.json() as SourceFlowResponse;
|
||||||
|
setSourceFlowSummary(sourceFlowData.summary);
|
||||||
|
setSourceFlowError(false);
|
||||||
|
} else {
|
||||||
|
setSourceFlowSummary(null);
|
||||||
|
setSourceFlowError(true);
|
||||||
|
}
|
||||||
setLastUpdated(new Date());
|
setLastUpdated(new Date());
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("degraded");
|
setStatus("degraded");
|
||||||
setQualitySummary(null);
|
setQualitySummary(null);
|
||||||
setQualityError(true);
|
setQualityError(true);
|
||||||
|
setSourceFlowSummary(null);
|
||||||
|
setSourceFlowError(true);
|
||||||
setLastUpdated(new Date());
|
setLastUpdated(new Date());
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -631,6 +835,12 @@ export default function AwoooPPage() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<SourceFlowOverviewPanel
|
||||||
|
summary={sourceFlowSummary}
|
||||||
|
error={sourceFlowError}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
|
||||||
<AutomationQualityPanel summary={qualitySummary} error={qualityError} />
|
<AutomationQualityPanel summary={qualitySummary} error={qualityError} />
|
||||||
|
|
||||||
<section className="border border-[#e0ddd4] bg-[#e0ddd4]">
|
<section className="border border-[#e0ddd4] bg-[#e0ddd4]">
|
||||||
|
|||||||
Reference in New Issue
Block a user