feat(reports): 新增報表資料源健康 read model
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user