feat(reports): 新增報表資料源健康 read model
All checks were successful
Code Review / ai-code-review (push) Successful in 12s
CD Pipeline / tests (push) Successful in 1m33s
CD Pipeline / build-and-deploy (push) Successful in 4m49s
CD Pipeline / post-deploy-checks (push) Successful in 1m30s

This commit is contained in:
Your Name
2026-06-18 19:20:59 +08:00
parent 172d129280
commit 27d9f394e8
6 changed files with 620 additions and 22 deletions

View File

@@ -127,6 +127,58 @@ interface ReportStatusBoardSnapshot {
}
}
interface ReportSourceHealthItem {
source_id: string
display_name: string
source_ok: boolean
state: string
freshness: string
confidence_percent: number
metrics: Record<string, number | string | null | undefined>
work_item_id: string
next_action: string
}
interface ReportNoSendPreview {
cadence_id: string
source_ready_count: number
source_total_count: number
blocked_by_source_gap: boolean
live_send_allowed: boolean
gateway_queue_write_allowed: boolean
}
interface ReportAutomationAsset {
asset_id: string
label: string
state: string
done_count: number
blocked_count: number
next_action: string
}
interface ReportSourceHealthSnapshot {
source_health: ReportSourceHealthItem[]
all_zero_assessment: {
all_zero_observed: boolean
confidence_percent: number
verdict: string
blocking_reason: string
next_action: string
}
no_send_previews: ReportNoSendPreview[]
automation_assets: ReportAutomationAsset[]
rollups: {
source_count: number
source_ok_count: number
source_gap_count: number
confidence_percent: number
report_work_item_count: number
live_send_allowed_count: number
runtime_gate_count: number
}
}
type SourceKey = 'incident' | 'resolution' | 'disposition' | 'statusBoard'
type SourceState = Record<SourceKey, boolean>
@@ -154,6 +206,7 @@ export default function ReportsPage({ params }: { params: { locale: string } })
const [resolution, setResolution] = useState<ResolutionStats | null>(null)
const [disposition, setDisposition] = useState<DispositionResponse | null>(null)
const [reportBoard, setReportBoard] = useState<ReportStatusBoardSnapshot | null>(null)
const [sourceHealth, setSourceHealth] = useState<ReportSourceHealthSnapshot | null>(null)
const [sourceState, setSourceState] = useState<SourceState>(initialSourceState)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
@@ -162,23 +215,38 @@ export default function ReportsPage({ params }: { params: { locale: string } })
setLoading(true)
setError(false)
Promise.all([
Promise.resolve(null as IncidentSummary | null),
Promise.resolve(null as ResolutionStats | null),
fetchJson<DispositionResponse>('/api/v1/stats/disposition'),
fetchJson<ReportStatusBoardSnapshot>('/api/v1/agents/agent-report-status-board'),
fetchJson<ReportSourceHealthSnapshot>('/api/v1/agents/agent-report-source-health'),
])
.then(([incidentSummary, resolutionStats, dispositionStats, statusBoard]) => {
.then(([dispositionStats, statusBoard, reportSourceHealth]) => {
const healthById = new Map((reportSourceHealth?.source_health ?? []).map(item => [item.source_id, item]))
const incidentMetrics = healthById.get('incident_summary')?.metrics ?? {}
const resolutionMetrics = healthById.get('resolution_stats')?.metrics ?? {}
const incidentSummary: IncidentSummary | null = healthById.get('incident_summary')?.source_ok
? {
total_incidents: Number(incidentMetrics.total ?? 0),
resolved_rate: Number(incidentMetrics.resolved_rate ?? 0),
}
: null
const resolutionStats: ResolutionStats | null = healthById.get('resolution_stats')?.source_ok
? {
resolutionRate: Number(resolutionMetrics.resolution_rate ?? 0),
avg_minutes: typeof resolutionMetrics.avg_minutes === 'number' ? resolutionMetrics.avg_minutes : null,
}
: null
setSummary(incidentSummary)
setResolution(resolutionStats)
setDisposition(dispositionStats)
setReportBoard(statusBoard)
setSourceHealth(reportSourceHealth)
setSourceState({
incident: Boolean(incidentSummary),
resolution: Boolean(resolutionStats),
disposition: Boolean(dispositionStats),
statusBoard: Boolean(statusBoard),
incident: Boolean(healthById.get('incident_summary')?.source_ok),
resolution: Boolean(healthById.get('resolution_stats')?.source_ok),
disposition: Boolean(healthById.get('disposition_stats')?.source_ok),
statusBoard: Boolean(healthById.get('report_status_board')?.source_ok),
})
setError(!incidentSummary && !resolutionStats && !dispositionStats && !statusBoard)
setError(!dispositionStats && !statusBoard && !reportSourceHealth)
})
.catch(() => setError(true))
.finally(() => setLoading(false))
@@ -199,9 +267,9 @@ export default function ReportsPage({ params }: { params: { locale: string } })
const avgResolutionTime = resolution?.avgResolutionTime ?? (
resolution?.avg_minutes != null ? `${Math.round(resolution.avg_minutes)}m` : undefined
)
const sourceOkCount = Object.values(sourceState).filter(Boolean).length
const sourceTotal = Object.keys(sourceState).length
const allZeroSignal = Boolean(
const sourceOkCount = sourceHealth?.rollups.source_ok_count ?? Object.values(sourceState).filter(Boolean).length
const sourceTotal = sourceHealth?.rollups.source_count ?? Object.keys(sourceState).length
const allZeroSignal = sourceHealth?.all_zero_assessment.all_zero_observed ?? Boolean(
sourceOkCount > 0 &&
incidentTotal === 0 &&
(ds?.total ?? 0) === 0 &&
@@ -216,13 +284,24 @@ export default function ReportsPage({ params }: { params: { locale: string } })
const liveDelivery = reportBoard?.rollups?.live_delivery_count ?? 0
const liveOptimization = reportBoard?.rollups?.live_auto_optimization_count ?? 0
const sourceRows = useMemo(
() => [
{ key: 'incident' as const, label: t('sources.incident'), ok: sourceState.incident, next: t('sourceNext.incident') },
{ key: 'resolution' as const, label: t('sources.resolution'), ok: sourceState.resolution, next: t('sourceNext.resolution') },
{ key: 'disposition' as const, label: t('sources.disposition'), ok: sourceState.disposition, next: t('sourceNext.disposition') },
{ key: 'statusBoard' as const, label: t('sources.statusBoard'), ok: sourceState.statusBoard, next: t('sourceNext.statusBoard') },
],
[sourceState, t]
() => sourceHealth?.source_health?.length
? sourceHealth.source_health.map(row => ({
key: row.source_id,
label: row.display_name,
ok: row.source_ok,
next: row.work_item_id ? `${row.work_item_id} · ${row.next_action}` : row.next_action,
}))
: [
{ key: 'incident', label: t('sources.incident'), ok: sourceState.incident, next: t('sourceNext.incident') },
{ key: 'resolution', label: t('sources.resolution'), ok: sourceState.resolution, next: t('sourceNext.resolution') },
{ key: 'disposition', label: t('sources.disposition'), ok: sourceState.disposition, next: t('sourceNext.disposition') },
{ key: 'statusBoard', label: t('sources.statusBoard'), ok: sourceState.statusBoard, next: t('sourceNext.statusBoard') },
],
[sourceHealth, sourceState, t]
)
const previewByCadence = useMemo(
() => new Map((sourceHealth?.no_send_previews ?? []).map(preview => [preview.cadence_id, preview])),
[sourceHealth]
)
const pcts = useMemo(() => {
@@ -358,6 +437,10 @@ export default function ReportsPage({ params }: { params: { locale: string } })
t('chip.sections', { count: card.sections_count }),
t('chip.charts', { count: card.chart_count }),
t('chip.work', { count: card.work_units_total }),
t('chip.sourceReady', {
ok: previewByCadence.get(card.cadence_id)?.source_ready_count ?? sourceOkCount,
total: previewByCadence.get(card.cadence_id)?.source_total_count ?? sourceTotal,
}),
t('chip.live', { count: card.live_delivery_count }),
]}
/>
@@ -384,10 +467,23 @@ export default function ReportsPage({ params }: { params: { locale: string } })
<div className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
<SectionHeader icon={<ShieldCheck className="h-4 w-4" />} title={t('assets.title')} subtitle={t('assets.subtitle')} />
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<AssetCard label={t('assets.reports')} value={reportBoard?.rollups?.report_card_count ?? '--'} detail={t('assets.reportsDetail')} />
<AssetCard label={t('assets.agents')} value={reportBoard?.rollups?.agent_status_count ?? '--'} detail={t('assets.agentsDetail')} />
<AssetCard label={t('assets.workload')} value={`${reportBoard?.rollups?.workload_done_total ?? 0}/${reportBoard?.rollups?.workload_unit_total ?? 0}`} detail={t('assets.workloadDetail')} />
<AssetCard label={t('assets.waiting')} value={waitingApproval} detail={t('assets.waitingDetail')} danger={waitingApproval > 0} />
{(sourceHealth?.automation_assets ?? []).map(asset => (
<AssetCard
key={asset.asset_id}
label={asset.label}
value={`${asset.done_count}/${asset.done_count + asset.blocked_count}`}
detail={`${asset.state} · ${asset.next_action}`}
danger={asset.blocked_count > 0}
/>
))}
{!sourceHealth?.automation_assets?.length && (
<>
<AssetCard label={t('assets.reports')} value={reportBoard?.rollups?.report_card_count ?? '--'} detail={t('assets.reportsDetail')} />
<AssetCard label={t('assets.agents')} value={reportBoard?.rollups?.agent_status_count ?? '--'} detail={t('assets.agentsDetail')} />
<AssetCard label={t('assets.workload')} value={`${reportBoard?.rollups?.workload_done_total ?? 0}/${reportBoard?.rollups?.workload_unit_total ?? 0}`} detail={t('assets.workloadDetail')} />
<AssetCard label={t('assets.waiting')} value={waitingApproval} detail={t('assets.waitingDetail')} danger={waitingApproval > 0} />
</>
)}
</div>
</div>
</section>