feat(web): 前移報表 AI 接管總控
This commit is contained in:
@@ -1,34 +1,39 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 報表 Page — 完整處置統計儀表板
|
||||
* ====================================
|
||||
* Sprint 4 E1: 告警處置統計主戰場
|
||||
*
|
||||
* 串接:
|
||||
* GET /api/v1/stats/incident-summary
|
||||
* GET /api/v1/stats/resolution-stats
|
||||
* GET /api/v1/stats/disposition (Sprint 4 新增)
|
||||
*
|
||||
* 專案鐵律: 禁止假數據!無資料顯示 '--'
|
||||
* i18n: 100% next-intl
|
||||
*
|
||||
* 2026-04-02 Claude Code — 初版
|
||||
* 2026-04-07 Claude Code — Sprint 4 E1 處置統計升級
|
||||
* 報表 Page — AI Agent 報表與告警接管總控
|
||||
* ========================================
|
||||
* 這一頁保留原本處置統計,同時前移日報 / 週報 / 月報資料源健康、
|
||||
* 全 0 判讀、AI 接手率、KM / PlayBook / Verifier 沉澱與 no-send 邊界。
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Bot, UserCheck, Wrench, Sparkles, TrendingUp, BarChart3, Inbox, RefreshCw } from 'lucide-react'
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
BellRing,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
Database,
|
||||
FileText,
|
||||
Gauge,
|
||||
GitBranch,
|
||||
Inbox,
|
||||
Layers3,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
UserCheck,
|
||||
Wrench,
|
||||
} from 'lucide-react'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||
|
||||
// =============================================================================
|
||||
// Types (對齊後端 DispositionResponse)
|
||||
// =============================================================================
|
||||
|
||||
interface DispositionSummary {
|
||||
total: number
|
||||
auto_repair: number
|
||||
@@ -61,9 +66,81 @@ interface ResolutionStats {
|
||||
avgResolutionTime?: string | number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Page
|
||||
// =============================================================================
|
||||
interface ReportStatusCard {
|
||||
cadence_id: 'daily' | 'weekly' | 'monthly'
|
||||
display_name: string
|
||||
owner_agent: string
|
||||
completion_percent: number
|
||||
delivery_state: string
|
||||
sections_count: number
|
||||
chart_count: number
|
||||
work_units_total: number
|
||||
live_delivery_count: number
|
||||
next_gate: string
|
||||
}
|
||||
|
||||
interface AgentStatusReport {
|
||||
agent_id: string
|
||||
display_name: string
|
||||
primary_role: string
|
||||
current_state: string
|
||||
work_units_total: number
|
||||
work_units_done: number
|
||||
work_units_waiting_approval: number
|
||||
live_runtime_work_units_24h: number
|
||||
telegram_policy: string
|
||||
learning_state: string
|
||||
}
|
||||
|
||||
interface ReportStatusBoardSnapshot {
|
||||
generated_at: string
|
||||
program_status: {
|
||||
overall_completion_percent: number
|
||||
current_task_id: string
|
||||
next_task_id: string
|
||||
status_note: string
|
||||
}
|
||||
report_completion_truth: {
|
||||
live_report_delivery_enabled: boolean
|
||||
live_telegram_send_count_24h: number
|
||||
live_auto_optimization_count_24h: number
|
||||
truth_note: string
|
||||
}
|
||||
report_status_cards: ReportStatusCard[]
|
||||
agent_status_reports: AgentStatusReport[]
|
||||
rollups: {
|
||||
report_card_count: number
|
||||
agent_status_count: number
|
||||
visible_chart_count: number
|
||||
workload_unit_total: number
|
||||
workload_done_total: number
|
||||
workload_waiting_approval_total: number
|
||||
live_delivery_count: number
|
||||
live_telegram_send_count: number
|
||||
live_runtime_work_units: number
|
||||
live_auto_optimization_count: number
|
||||
}
|
||||
}
|
||||
|
||||
type SourceKey = 'incident' | 'resolution' | 'disposition' | 'statusBoard'
|
||||
type SourceState = Record<SourceKey, boolean>
|
||||
|
||||
const initialSourceState: SourceState = {
|
||||
incident: false,
|
||||
resolution: false,
|
||||
disposition: false,
|
||||
statusBoard: false,
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}${path}`)
|
||||
if (!response.ok) return null
|
||||
return await response.json()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default function ReportsPage({ params }: { params: { locale: string } }) {
|
||||
const t = useTranslations('reports')
|
||||
@@ -71,6 +148,8 @@ export default function ReportsPage({ params }: { params: { locale: string } })
|
||||
const [summary, setSummary] = useState<IncidentSummary | null>(null)
|
||||
const [resolution, setResolution] = useState<ResolutionStats | null>(null)
|
||||
const [disposition, setDisposition] = useState<DispositionResponse | null>(null)
|
||||
const [reportBoard, setReportBoard] = useState<ReportStatusBoardSnapshot | null>(null)
|
||||
const [sourceState, setSourceState] = useState<SourceState>(initialSourceState)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
@@ -78,14 +157,23 @@ export default function ReportsPage({ params }: { params: { locale: string } })
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
Promise.all([
|
||||
fetch(`${API_BASE}/api/v1/stats/incident-summary`).then(r => r.json()).catch(() => null),
|
||||
fetch(`${API_BASE}/api/v1/stats/resolution-stats`).then(r => r.json()).catch(() => null),
|
||||
fetch(`${API_BASE}/api/v1/stats/disposition`).then(r => r.json()).catch(() => null),
|
||||
fetchJson<IncidentSummary>('/api/v1/stats/incident-summary'),
|
||||
fetchJson<ResolutionStats>('/api/v1/stats/resolution-stats'),
|
||||
fetchJson<DispositionResponse>('/api/v1/stats/disposition'),
|
||||
fetchJson<ReportStatusBoardSnapshot>('/api/v1/agents/agent-report-status-board'),
|
||||
])
|
||||
.then(([s, r, d]) => {
|
||||
setSummary(s)
|
||||
setResolution(r)
|
||||
setDisposition(d)
|
||||
.then(([incidentSummary, resolutionStats, dispositionStats, statusBoard]) => {
|
||||
setSummary(incidentSummary)
|
||||
setResolution(resolutionStats)
|
||||
setDisposition(dispositionStats)
|
||||
setReportBoard(statusBoard)
|
||||
setSourceState({
|
||||
incident: Boolean(incidentSummary),
|
||||
resolution: Boolean(resolutionStats),
|
||||
disposition: Boolean(dispositionStats),
|
||||
statusBoard: Boolean(statusBoard),
|
||||
})
|
||||
setError(!incidentSummary && !resolutionStats && !dispositionStats && !statusBoard)
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false))
|
||||
@@ -94,180 +182,306 @@ export default function ReportsPage({ params }: { params: { locale: string } })
|
||||
useEffect(() => { fetchAll() }, [])
|
||||
|
||||
const ds = disposition?.summary
|
||||
const sourceOkCount = Object.values(sourceState).filter(Boolean).length
|
||||
const sourceTotal = Object.keys(sourceState).length
|
||||
const allZeroSignal = Boolean(
|
||||
sourceOkCount > 0 &&
|
||||
(summary?.total ?? 0) === 0 &&
|
||||
(ds?.total ?? 0) === 0 &&
|
||||
(ds?.auto_repair ?? 0) === 0 &&
|
||||
(ds?.human_approved ?? 0) === 0
|
||||
)
|
||||
const dataConfidence = sourceTotal > 0 ? Math.round((sourceOkCount / sourceTotal) * 100) : 0
|
||||
const autoHandled = (ds?.auto_repair ?? 0) + (ds?.cold_start_trust ?? 0)
|
||||
const totalDispositions = ds?.total ?? 0
|
||||
const aiHandoffRate = totalDispositions > 0 ? Math.round((autoHandled / totalDispositions) * 100) : 0
|
||||
const waitingApproval = reportBoard?.rollups?.workload_waiting_approval_total ?? 0
|
||||
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]
|
||||
)
|
||||
|
||||
// 處置分佈百分比 (用於堆疊條)
|
||||
const pcts = useMemo(() => {
|
||||
if (!ds || ds.total === 0) return null
|
||||
const t = ds.total
|
||||
const total = ds.total
|
||||
return {
|
||||
auto: Math.round((ds.auto_repair / t) * 100),
|
||||
human: Math.round((ds.human_approved / t) * 100),
|
||||
manual: Math.round((ds.manual_resolved / t) * 100),
|
||||
cold: Math.round((ds.cold_start_trust / t) * 100),
|
||||
auto: Math.round((ds.auto_repair / total) * 100),
|
||||
human: Math.round((ds.human_approved / total) * 100),
|
||||
manual: Math.round((ds.manual_resolved / total) * 100),
|
||||
cold: Math.round((ds.cold_start_trust / total) * 100),
|
||||
}
|
||||
}, [ds])
|
||||
|
||||
return (
|
||||
<AppLayout locale={params.locale}>
|
||||
<div className="p-6 space-y-6 min-h-full">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight">{t('title')}</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{t('subtitle')}</p>
|
||||
<div className="min-h-full space-y-5 p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">{t('eyebrow')}</p>
|
||||
<h1 className="mt-1 text-xl font-semibold tracking-tight text-foreground">{t('title')}</h1>
|
||||
<p className="mt-1 max-w-3xl text-sm leading-6 text-muted-foreground">{t('subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchAll}
|
||||
className="p-2 rounded-lg hover:bg-muted transition-colors"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-[7px] border border-border bg-card text-muted-foreground transition-colors hover:bg-muted"
|
||||
title={tc('refresh')}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4 text-muted-foreground', loading && 'animate-spin')} />
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-16 text-muted-foreground text-sm">{tc('loading')}</div>
|
||||
<div className="rounded-[7px] border border-border bg-card py-16 text-center text-sm text-muted-foreground">
|
||||
{tc('loading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="text-center py-16 text-red-500 text-sm">{t('fetchError')}</div>
|
||||
<div className="rounded-[7px] border border-red-200 bg-red-50 py-16 text-center text-sm text-red-600">
|
||||
{t('fetchError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{/* ── 頂部 KPI 3 卡 ── */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<KPICard
|
||||
icon={<BarChart3 className="w-5 h-5 text-blue-500" />}
|
||||
label={t('totalDispositions')}
|
||||
value={ds?.total ?? '--'}
|
||||
color="text-blue-500"
|
||||
/>
|
||||
<KPICard
|
||||
icon={<Bot className="w-5 h-5 text-green-500" />}
|
||||
label={t('autoRate')}
|
||||
value={ds ? `${Math.round(ds.auto_rate * 100)}%` : '--'}
|
||||
color="text-green-500"
|
||||
/>
|
||||
<KPICard
|
||||
icon={<UserCheck className="w-5 h-5 text-orange-500" />}
|
||||
label={t('humanRate')}
|
||||
value={ds ? `${Math.round(ds.human_rate * 100)}%` : '--'}
|
||||
color="text-orange-500"
|
||||
/>
|
||||
</div>
|
||||
<section className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-[7px] bg-blue-50 text-blue-600">
|
||||
<Gauge className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold text-foreground">{t('command.title')}</h2>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{t('command.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 四大計數卡片 ── */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<DispositionCard
|
||||
icon={<Bot className="w-4 h-4" />}
|
||||
label={t('autoRepair')}
|
||||
count={ds?.auto_repair ?? 0}
|
||||
color="text-green-500"
|
||||
bg="bg-green-500/10"
|
||||
border="border-green-500/25"
|
||||
/>
|
||||
<DispositionCard
|
||||
icon={<UserCheck className="w-4 h-4" />}
|
||||
label={t('humanApproved')}
|
||||
count={ds?.human_approved ?? 0}
|
||||
color="text-orange-500"
|
||||
bg="bg-orange-500/10"
|
||||
border="border-orange-500/25"
|
||||
/>
|
||||
<DispositionCard
|
||||
icon={<Wrench className="w-4 h-4" />}
|
||||
label={t('manualResolved')}
|
||||
count={ds?.manual_resolved ?? 0}
|
||||
color="text-purple-500"
|
||||
bg="bg-purple-500/10"
|
||||
border="border-purple-500/25"
|
||||
/>
|
||||
<DispositionCard
|
||||
icon={<Sparkles className="w-4 h-4" />}
|
||||
label={t('coldStartTrust')}
|
||||
count={ds?.cold_start_trust ?? 0}
|
||||
color="text-blue-500"
|
||||
bg="bg-blue-500/10"
|
||||
border="border-blue-500/25"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<CommandMetric
|
||||
icon={<Database className="h-4 w-4" />}
|
||||
label={t('command.metrics.confidence')}
|
||||
value={`${dataConfidence}%`}
|
||||
detail={allZeroSignal ? t('command.details.allZero') : t('command.details.sourceOk', { ok: sourceOkCount, total: sourceTotal })}
|
||||
tone={allZeroSignal || dataConfidence < 100 ? 'warn' : 'ok'}
|
||||
/>
|
||||
<CommandMetric
|
||||
icon={<Activity className="h-4 w-4" />}
|
||||
label={t('command.metrics.alerts')}
|
||||
value={summary?.total ?? '--'}
|
||||
detail={allZeroSignal ? t('command.details.noSignal') : t('command.details.alertSignal')}
|
||||
tone={allZeroSignal ? 'warn' : 'neutral'}
|
||||
/>
|
||||
<CommandMetric
|
||||
icon={<Bot className="h-4 w-4" />}
|
||||
label={t('command.metrics.aiHandoff')}
|
||||
value={`${aiHandoffRate}%`}
|
||||
detail={t('command.details.aiHandoff', { auto: autoHandled, total: totalDispositions })}
|
||||
tone={aiHandoffRate > 0 ? 'ok' : 'warn'}
|
||||
/>
|
||||
<CommandMetric
|
||||
icon={<BellRing className="h-4 w-4" />}
|
||||
label={t('command.metrics.liveDelivery')}
|
||||
value={liveDelivery}
|
||||
detail={t('command.details.noSend', { optimization: liveOptimization })}
|
||||
tone="warn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 rounded-[7px] border border-amber-200 bg-amber-50 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-amber-950">{allZeroSignal ? t('quality.allZeroTitle') : t('quality.title')}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-amber-900">
|
||||
{allZeroSignal ? t('quality.allZeroBody') : t('quality.body')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2">
|
||||
<NextActionLine label={t('quality.actions.stats')} active={allZeroSignal || !sourceState.disposition} />
|
||||
<NextActionLine label={t('quality.actions.git')} active={allZeroSignal} />
|
||||
<NextActionLine label={t('quality.actions.cost')} active={allZeroSignal} />
|
||||
<NextActionLine label={t('quality.actions.noSend')} active />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[0.95fr_1.05fr]">
|
||||
<div className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
|
||||
<SectionHeader icon={<GitBranch className="h-4 w-4" />} title={t('sourceMatrix.title')} subtitle={t('sourceMatrix.subtitle')} />
|
||||
<div className="mt-4 grid gap-2">
|
||||
{sourceRows.map(row => (
|
||||
<div key={row.key} className="grid gap-2 rounded-[7px] border border-border bg-background p-3 sm:grid-cols-[180px_90px_1fr] sm:items-center">
|
||||
<span className="text-sm font-medium text-foreground">{row.label}</span>
|
||||
<StatusPill ok={row.ok} okLabel={t('sourceMatrix.ok')} failLabel={t('sourceMatrix.fail')} />
|
||||
<span className="text-xs leading-5 text-muted-foreground">{row.next}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
|
||||
<SectionHeader icon={<FileText className="h-4 w-4" />} title={t('cadence.title')} subtitle={t('cadence.subtitle')} />
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
{(reportBoard?.report_status_cards ?? []).map(card => (
|
||||
<CadenceCard
|
||||
key={card.cadence_id}
|
||||
card={card}
|
||||
chips={[
|
||||
t('chip.sections', { count: card.sections_count }),
|
||||
t('chip.charts', { count: card.chart_count }),
|
||||
t('chip.work', { count: card.work_units_total }),
|
||||
t('chip.live', { count: card.live_delivery_count }),
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
{!reportBoard?.report_status_cards?.length && (
|
||||
<EmptyPanel label={t('cadence.empty')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
|
||||
<SectionHeader icon={<Layers3 className="h-4 w-4" />} title={t('funnel.title')} subtitle={t('funnel.subtitle')} />
|
||||
<div className="mt-4 grid gap-3">
|
||||
<FunnelBar label={t('funnel.alerts')} value={summary?.total ?? 0} max={Math.max(summary?.total ?? 0, totalDispositions, 1)} tone="neutral" />
|
||||
<FunnelBar label={t('funnel.dispositions')} value={totalDispositions} max={Math.max(summary?.total ?? 0, totalDispositions, 1)} tone="neutral" />
|
||||
<FunnelBar label={t('funnel.auto')} value={autoHandled} max={Math.max(totalDispositions, 1)} tone="ok" />
|
||||
<FunnelBar label={t('funnel.human')} value={(ds?.human_approved ?? 0) + (ds?.manual_resolved ?? 0)} max={Math.max(totalDispositions, 1)} tone="warn" />
|
||||
<FunnelBar label={t('funnel.waiting')} value={waitingApproval} max={Math.max(waitingApproval, reportBoard?.rollups?.workload_unit_total ?? 1)} tone={waitingApproval > 0 ? 'danger' : 'ok'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
|
||||
<SectionHeader icon={<Bot className="h-4 w-4" />} title={t('agents.title')} subtitle={t('agents.subtitle')} />
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-3">
|
||||
{(reportBoard?.agent_status_reports ?? []).map(agent => (
|
||||
<AgentCard
|
||||
key={agent.agent_id}
|
||||
agent={agent}
|
||||
chips={[
|
||||
t('chip.workDone', { done: agent.work_units_done, total: agent.work_units_total }),
|
||||
t('chip.approval', { count: agent.work_units_waiting_approval }),
|
||||
t('chip.live', { count: agent.live_runtime_work_units_24h }),
|
||||
agent.telegram_policy,
|
||||
agent.learning_state,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
{!reportBoard?.agent_status_reports?.length && (
|
||||
<EmptyPanel label={t('agents.empty')} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-3">
|
||||
<KPICard icon={<BarChart3 className="h-5 w-5 text-blue-500" />} label={t('totalDispositions')} value={ds?.total ?? '--'} color="text-blue-500" />
|
||||
<KPICard icon={<Bot className="h-5 w-5 text-green-500" />} label={t('autoRate')} value={ds ? `${Math.round(ds.auto_rate * 100)}%` : '--'} color="text-green-500" />
|
||||
<KPICard icon={<UserCheck className="h-5 w-5 text-orange-500" />} label={t('humanRate')} value={ds ? `${Math.round(ds.human_rate * 100)}%` : '--'} color="text-orange-500" />
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<DispositionCard icon={<Bot className="h-4 w-4" />} label={t('autoRepair')} count={ds?.auto_repair ?? 0} tone="green" />
|
||||
<DispositionCard icon={<UserCheck className="h-4 w-4" />} label={t('humanApproved')} count={ds?.human_approved ?? 0} tone="orange" />
|
||||
<DispositionCard icon={<Wrench className="h-4 w-4" />} label={t('manualResolved')} count={ds?.manual_resolved ?? 0} tone="purple" />
|
||||
<DispositionCard icon={<ShieldCheck className="h-4 w-4" />} label={t('coldStartTrust')} count={ds?.cold_start_trust ?? 0} tone="blue" />
|
||||
</section>
|
||||
|
||||
{/* ── 堆疊分佈條 ── */}
|
||||
{pcts && (
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">{t('dispositionBreakdown')}</p>
|
||||
<div className="flex h-4 rounded-full overflow-hidden">
|
||||
{pcts.auto > 0 && <div className="bg-green-500 transition-all" style={{ width: `${pcts.auto}%` }} title={`${t('autoRepair')}: ${pcts.auto}%`} />}
|
||||
{pcts.cold > 0 && <div className="bg-blue-500 transition-all" style={{ width: `${pcts.cold}%` }} title={`${t('coldStartTrust')}: ${pcts.cold}%`} />}
|
||||
{pcts.human > 0 && <div className="bg-orange-500 transition-all" style={{ width: `${pcts.human}%` }} title={`${t('humanApproved')}: ${pcts.human}%`} />}
|
||||
{pcts.manual > 0 && <div className="bg-purple-500 transition-all" style={{ width: `${pcts.manual}%` }} title={`${t('manualResolved')}: ${pcts.manual}%`} />}
|
||||
<section className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
|
||||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-muted-foreground">{t('dispositionBreakdown')}</p>
|
||||
<div className="flex h-4 overflow-hidden rounded-[7px] bg-muted">
|
||||
{pcts.auto > 0 && <div className="bg-green-500" style={{ width: `${pcts.auto}%` }} title={`${t('autoRepair')}: ${pcts.auto}%`} />}
|
||||
{pcts.cold > 0 && <div className="bg-blue-500" style={{ width: `${pcts.cold}%` }} title={`${t('coldStartTrust')}: ${pcts.cold}%`} />}
|
||||
{pcts.human > 0 && <div className="bg-orange-500" style={{ width: `${pcts.human}%` }} title={`${t('humanApproved')}: ${pcts.human}%`} />}
|
||||
{pcts.manual > 0 && <div className="bg-purple-500" style={{ width: `${pcts.manual}%` }} title={`${t('manualResolved')}: ${pcts.manual}%`} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-[10px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-green-500" />{t('autoRepair')} {pcts.auto}%</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-blue-500" />{t('coldStartTrust')} {pcts.cold}%</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-orange-500" />{t('humanApproved')} {pcts.human}%</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-purple-500" />{t('manualResolved')} {pcts.manual}%</span>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-4 text-[10px] text-muted-foreground">
|
||||
<Legend color="bg-green-500" label={`${t('autoRepair')} ${pcts.auto}%`} />
|
||||
<Legend color="bg-blue-500" label={`${t('coldStartTrust')} ${pcts.cold}%`} />
|
||||
<Legend color="bg-orange-500" label={`${t('humanApproved')} ${pcts.human}%`} />
|
||||
<Legend color="bg-purple-500" label={`${t('manualResolved')} ${pcts.manual}%`} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 按異常類型明細 ── */}
|
||||
{disposition?.by_anomaly && disposition.by_anomaly.length > 0 && (
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">{t('byAnomalyType')}</p>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
<th className="text-left pb-2 font-medium">{t('anomalyKey')}</th>
|
||||
<th className="text-right pb-2 font-medium">{t('autoRepair')}</th>
|
||||
<th className="text-right pb-2 font-medium">{t('humanApproved')}</th>
|
||||
<th className="text-right pb-2 font-medium">{t('manualResolved')}</th>
|
||||
<th className="text-right pb-2 font-medium">{t('coldStartTrust')}</th>
|
||||
<th className="text-right pb-2 font-medium">{t('totalDispositions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{disposition.by_anomaly.map(item => (
|
||||
<tr key={item.anomaly_key}>
|
||||
<td className="py-2 text-xs font-mono">{item.alert_name || item.anomaly_key.slice(0, 12)}</td>
|
||||
<td className="py-2 text-xs text-right text-green-500 font-semibold">{item.disposition.auto_repair}</td>
|
||||
<td className="py-2 text-xs text-right text-orange-500 font-semibold">{item.disposition.human_approved}</td>
|
||||
<td className="py-2 text-xs text-right text-purple-500 font-semibold">{item.disposition.manual_resolved}</td>
|
||||
<td className="py-2 text-xs text-right text-blue-500 font-semibold">{item.disposition.cold_start_trust}</td>
|
||||
<td className="py-2 text-xs text-right font-bold">{item.disposition.total}</td>
|
||||
<section className="overflow-hidden rounded-[7px] border border-border bg-card p-4 shadow-sm">
|
||||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-muted-foreground">{t('byAnomalyType')}</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[720px]">
|
||||
<thead>
|
||||
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="pb-2 text-left font-medium">{t('anomalyKey')}</th>
|
||||
<th className="pb-2 text-right font-medium">{t('autoRepair')}</th>
|
||||
<th className="pb-2 text-right font-medium">{t('humanApproved')}</th>
|
||||
<th className="pb-2 text-right font-medium">{t('manualResolved')}</th>
|
||||
<th className="pb-2 text-right font-medium">{t('coldStartTrust')}</th>
|
||||
<th className="pb-2 text-right font-medium">{t('totalDispositions')}</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{disposition.by_anomaly.map(item => (
|
||||
<tr key={item.anomaly_key}>
|
||||
<td className="py-2 text-xs font-mono">{item.alert_name || item.anomaly_key.slice(0, 12)}</td>
|
||||
<td className="py-2 text-right text-xs font-semibold text-green-500">{item.disposition.auto_repair}</td>
|
||||
<td className="py-2 text-right text-xs font-semibold text-orange-500">{item.disposition.human_approved}</td>
|
||||
<td className="py-2 text-right text-xs font-semibold text-purple-500">{item.disposition.manual_resolved}</td>
|
||||
<td className="py-2 text-right text-xs font-semibold text-blue-500">{item.disposition.cold_start_trust}</td>
|
||||
<td className="py-2 text-right text-xs font-bold">{item.disposition.total}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 既有: 事件摘要 ── */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">{t('incidentSummary')}</p>
|
||||
<section className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
|
||||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-muted-foreground">{t('incidentSummary')}</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<MiniStat label={t('total')} value={summary?.total ?? '--'} />
|
||||
<MiniStat label={t('resolved')} value={summary?.resolved ?? '--'} />
|
||||
<MiniStat label={t('unresolved')} value={summary?.unresolved ?? '--'} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">{t('resolutionStats')}</p>
|
||||
<div className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
|
||||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-muted-foreground">{t('resolutionStats')}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<MiniStat label={t('resolutionRate')} value={resolution?.resolutionRate != null ? `${resolution.resolutionRate}%` : '--'} />
|
||||
<MiniStat label={t('avgResolutionTime')} value={resolution?.avgResolutionTime ?? '--'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 無資料提示 */}
|
||||
{!ds || ds.total === 0 ? (
|
||||
<div className="rounded-xl border border-border bg-card p-8 flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Inbox className="w-8 h-8 mb-2 opacity-40" />
|
||||
<p className="text-sm">{t('noData')}</p>
|
||||
<div className="flex flex-col items-center justify-center rounded-[7px] border border-border bg-card p-8 text-muted-foreground shadow-sm">
|
||||
<Inbox className="mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">{allZeroSignal ? t('noDataNeedsReview') : t('noData')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
@@ -277,32 +491,151 @@ export default function ReportsPage({ params }: { params: { locale: string } })
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sub-components
|
||||
// =============================================================================
|
||||
|
||||
function KPICard({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: string | number; color: string }) {
|
||||
function SectionHeader({ icon, title, subtitle }: { icon: ReactNode; title: string; subtitle: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-5 flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="inline-flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-[7px] bg-muted text-muted-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className={cn('text-2xl font-bold tabular-nums', color)}>{value}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground mt-0.5">{label}</p>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
||||
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DispositionCard({ icon, label, count, color, bg, border }: { icon: React.ReactNode; label: string; count: number; color: string; bg: string; border: string }) {
|
||||
function CommandMetric({ icon, label, value, detail, tone }: { icon: ReactNode; label: string; value: string | number; detail: string; tone: 'ok' | 'warn' | 'neutral' }) {
|
||||
const toneClass = tone === 'ok' ? 'text-green-600 bg-green-50 border-green-200' : tone === 'warn' ? 'text-amber-700 bg-amber-50 border-amber-200' : 'text-blue-600 bg-blue-50 border-blue-200'
|
||||
return (
|
||||
<div className={cn('rounded-xl border p-4 text-center', border, bg)}>
|
||||
<div className={cn('flex items-center justify-center gap-1.5 mb-2', color)}>
|
||||
<div className="min-w-0 rounded-[7px] border border-border bg-background p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={cn('inline-flex h-7 w-7 items-center justify-center rounded-[7px] border', toneClass)}>{icon}</span>
|
||||
<p className="text-2xl font-semibold tabular-nums text-foreground">{value}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-xs font-semibold text-foreground">{label}</p>
|
||||
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">{detail}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NextActionLine({ label, active }: { label: string; active: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-950">
|
||||
{active ? <Clock3 className="h-3.5 w-3.5 text-amber-700" /> : <CheckCircle2 className="h-3.5 w-3.5 text-green-600" />}
|
||||
<span className={cn(!active && 'text-muted-foreground')}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusPill({ ok, okLabel, failLabel }: { ok: boolean; okLabel: string; failLabel: string }) {
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex w-fit items-center gap-1 rounded-[7px] border px-2 py-1 text-[11px] font-semibold',
|
||||
ok ? 'border-green-200 bg-green-50 text-green-700' : 'border-amber-200 bg-amber-50 text-amber-700'
|
||||
)}>
|
||||
{ok ? <CheckCircle2 className="h-3.5 w-3.5" /> : <AlertTriangle className="h-3.5 w-3.5" />}
|
||||
{ok ? okLabel : failLabel}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CadenceCard({ card, chips }: { card: ReportStatusCard; chips: string[] }) {
|
||||
const progress = Math.max(0, Math.min(card.completion_percent, 100))
|
||||
return (
|
||||
<div className="min-w-0 rounded-[7px] border border-border bg-background p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">{card.display_name}</p>
|
||||
<p className="text-[11px] text-muted-foreground">{card.owner_agent} · {card.delivery_state}</p>
|
||||
</div>
|
||||
<span className="text-lg font-semibold tabular-nums text-foreground">{progress}%</span>
|
||||
</div>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-[7px] bg-muted">
|
||||
<div className="h-full rounded-[7px] bg-blue-500" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5 text-[10px] text-muted-foreground">
|
||||
{chips.map(label => <Chip key={label} label={label} />)}
|
||||
</div>
|
||||
<p className="mt-3 text-xs leading-5 text-muted-foreground">{card.next_gate}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FunnelBar({ label, value, max, tone }: { label: string; value: number; max: number; tone: 'ok' | 'warn' | 'danger' | 'neutral' }) {
|
||||
const width = Math.max(4, Math.min(100, Math.round((value / Math.max(max, 1)) * 100)))
|
||||
const color = tone === 'ok' ? 'bg-green-500' : tone === 'warn' ? 'bg-amber-500' : tone === 'danger' ? 'bg-red-500' : 'bg-blue-500'
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-[150px_1fr_70px] sm:items-center">
|
||||
<span className="text-xs font-medium text-foreground">{label}</span>
|
||||
<div className="h-2 overflow-hidden rounded-[7px] bg-muted">
|
||||
<div className={cn('h-full rounded-[7px]', color)} style={{ width: `${width}%` }} />
|
||||
</div>
|
||||
<span className="text-right text-sm font-semibold tabular-nums text-foreground">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AssetCard({ label, value, detail, danger = false }: { label: string; value: string | number; detail: string; danger?: boolean }) {
|
||||
return (
|
||||
<div className={cn('rounded-[7px] border p-3', danger ? 'border-amber-200 bg-amber-50' : 'border-border bg-background')}>
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums text-foreground">{value}</p>
|
||||
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">{detail}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentCard({ agent, chips }: { agent: AgentStatusReport; chips: string[] }) {
|
||||
const progress = agent.work_units_total > 0 ? Math.round((agent.work_units_done / agent.work_units_total) * 100) : 0
|
||||
return (
|
||||
<div className="min-w-0 rounded-[7px] border border-border bg-background p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">{agent.display_name}</p>
|
||||
<p className="text-[11px] text-muted-foreground">{agent.current_state}</p>
|
||||
</div>
|
||||
<span className="text-lg font-semibold tabular-nums text-foreground">{progress}%</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">{agent.primary_role}</p>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-[7px] bg-muted">
|
||||
<div className={cn('h-full rounded-[7px]', progress >= 80 ? 'bg-green-500' : 'bg-amber-500')} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5 text-[10px] text-muted-foreground">
|
||||
{chips.map(label => <Chip key={label} label={label} />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KPICard({ icon, label, value, color }: { icon: ReactNode; label: string; value: string | number; color: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 rounded-[7px] border border-border bg-card p-5 shadow-sm">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[7px] bg-muted">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className={cn('text-2xl font-bold tabular-nums', color)}>{value}</p>
|
||||
<p className="mt-0.5 text-[10px] uppercase tracking-wider text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DispositionCard({ icon, label, count, tone }: { icon: ReactNode; label: string; count: number; tone: 'green' | 'orange' | 'purple' | 'blue' }) {
|
||||
const styles = {
|
||||
green: 'border-green-500/25 bg-green-500/10 text-green-600',
|
||||
orange: 'border-orange-500/25 bg-orange-500/10 text-orange-600',
|
||||
purple: 'border-purple-500/25 bg-purple-500/10 text-purple-600',
|
||||
blue: 'border-blue-500/25 bg-blue-500/10 text-blue-600',
|
||||
}[tone]
|
||||
return (
|
||||
<div className={cn('rounded-[7px] border p-4 text-center shadow-sm', styles)}>
|
||||
<div className="mb-2 flex items-center justify-center gap-1.5">
|
||||
{icon}
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">{label}</span>
|
||||
</div>
|
||||
<p className={cn('text-3xl font-bold tabular-nums', color)}>{count}</p>
|
||||
<p className="text-3xl font-bold tabular-nums">{count}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -311,7 +644,32 @@ function MiniStat({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<p className="text-xl font-bold tabular-nums">{value}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{label}</p>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="rounded-[7px] border border-border bg-card px-2 py-1 text-[10px] leading-none text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Legend({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className={cn('h-2 w-2 rounded-full', color)} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyPanel({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="rounded-[7px] border border-dashed border-border bg-background p-6 text-center text-sm text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user