diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 25e4f5b2..4c2dcfc9 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -722,7 +722,12 @@ }, "alerts": { "autoRefresh": "Auto-refresh every {seconds}s", - "incidentCount": "{count, plural, one {# incident} other {# incidents}}" + "incidentCount": "{count, plural, one {# incident} other {# incidents}}", + "pageSummary": "Showing {from}-{to} of {total}", + "statusChainWindow": "AI flow evidence: {loaded}/{shown} on this page connected to truth-chain", + "previousPage": "Previous", + "nextPage": "Next", + "pageIndicator": "Page {page} of {totalPages}" }, "navSection": { "aiCore": "AI Core", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 71d57ea4..48e2c463 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -723,7 +723,12 @@ }, "alerts": { "autoRefresh": "每 {seconds} 秒自動刷新", - "incidentCount": "{count, plural, one {# 個事件} other {# 個事件}}" + "incidentCount": "{count, plural, one {# 個事件} other {# 個事件}}", + "pageSummary": "顯示第 {from}-{to} 筆 / 共 {total} 筆", + "statusChainWindow": "AI 流程證據:本頁 {loaded}/{shown} 筆已接上 truth-chain", + "previousPage": "上一頁", + "nextPage": "下一頁", + "pageIndicator": "第 {page} / {totalPages} 頁" }, "navSection": { "aiCore": "AI 核心", diff --git a/apps/web/src/app/[locale]/alerts/page.tsx b/apps/web/src/app/[locale]/alerts/page.tsx index 4b6a14fb..4ec96faf 100644 --- a/apps/web/src/app/[locale]/alerts/page.tsx +++ b/apps/web/src/app/[locale]/alerts/page.tsx @@ -10,26 +10,29 @@ */ import { AppLayout } from '@/components/layout' +import { useEffect, useMemo, useState } from 'react' import { useTranslations } from 'next-intl' import { useIncidents } from '@/hooks/useIncidents' +import { useIncidentStatusChains } from '@/hooks/useIncidentStatusChains' import { IncidentCard } from '@/components/incident' import { cn } from '@/lib/utils' -import { Bell, BellOff, RefreshCw, AlertTriangle, AlertCircle, Info } from 'lucide-react' +import { Bell, BellOff, RefreshCw, AlertTriangle, AlertCircle, Info, ChevronLeft, ChevronRight } from 'lucide-react' // ============================================================================= // Severity helpers // ============================================================================= const SEVERITY_ORDER: Record = { P0: 0, P1: 1, P2: 2, P3: 3 } +const ALERTS_PAGE_SIZE = 50 -const SEVERITY_STYLE: Record = { - P0: { bg: 'bg-status-critical/10', text: 'text-status-critical', label: 'CRITICAL' }, - P1: { bg: 'bg-status-warning/10', text: 'text-status-warning', label: 'HIGH' }, - P2: { bg: 'bg-claw-blue/10', text: 'text-claw-blue', label: 'MEDIUM' }, - P3: { bg: 'bg-nothing-gray-100', text: 'text-nothing-gray-600', label: 'LOW' }, +const SEVERITY_STYLE: Record = { + P0: { bg: 'bg-status-critical/10', text: 'text-status-critical' }, + P1: { bg: 'bg-status-warning/10', text: 'text-status-warning' }, + P2: { bg: 'bg-claw-blue/10', text: 'text-claw-blue' }, + P3: { bg: 'bg-nothing-gray-100', text: 'text-nothing-gray-600' }, } -function SeverityBadge({ severity }: { severity: string }) { +function SeverityBadge({ severity, label }: { severity: string; label: string }) { const style = SEVERITY_STYLE[severity] ?? SEVERITY_STYLE['P3'] return ( } {severity === 'P1' && } {(severity === 'P2' || severity === 'P3') && } - {style.label} · {severity} + {label} · {severity} ) } @@ -77,14 +80,40 @@ export default function AlertsPage({ params }: { params: { locale: string } }) { const t = useTranslations() const tAlerts = useTranslations('alerts') - const { incidents, isLoading, error, refresh } = useIncidents({ + const { incidents, isLoading, error, refresh, lastUpdated } = useIncidents({ pollInterval: 15000, enablePolling: true, }) - const sorted = [...(incidents ?? [])].sort( - (a, b) => (SEVERITY_ORDER[a.severity] ?? 9) - (SEVERITY_ORDER[b.severity] ?? 9) - ) + const sorted = useMemo(() => { + return [...(incidents ?? [])].sort( + (a, b) => (SEVERITY_ORDER[a.severity] ?? 9) - (SEVERITY_ORDER[b.severity] ?? 9) + ) + }, [incidents]) + const [currentPage, setCurrentPage] = useState(1) + const totalPages = Math.max(1, Math.ceil(sorted.length / ALERTS_PAGE_SIZE)) + const pageStart = (currentPage - 1) * ALERTS_PAGE_SIZE + const pageIncidents = sorted.slice(pageStart, pageStart + ALERTS_PAGE_SIZE) + const pageFrom = sorted.length === 0 ? 0 : pageStart + 1 + const pageTo = pageStart + pageIncidents.length + + useEffect(() => { + setCurrentPage(page => Math.min(Math.max(page, 1), totalPages)) + }, [totalPages]) + + const { + statusChains, + requestedIncidentIds, + isLoading: isStatusChainLoading, + } = useIncidentStatusChains({ + incidentIds: pageIncidents.map(incident => incident.incident_id), + limit: ALERTS_PAGE_SIZE, + refreshKey: lastUpdated?.toISOString() ?? null, + }) + const truthChainLoaded = requestedIncidentIds.filter(incidentId => { + const chain = statusChains[incidentId] + return Boolean(chain?.source_id && !chain.fetch_error) + }).length const p0Count = sorted.filter(i => i.severity === 'P0').length const p1Count = sorted.filter(i => i.severity === 'P1').length @@ -127,6 +156,46 @@ export default function AlertsPage({ params }: { params: { locale: string } }) { + {sorted.length > 0 && ( +
+
+

+ {tAlerts('pageSummary', { from: pageFrom, to: pageTo, total: sorted.length })} +

+

+ {tAlerts('statusChainWindow', { + loaded: truthChainLoaded, + shown: requestedIncidentIds.length, + })} + {isStatusChainLoading ? ` · ${t('common.loading')}` : ''} +

+
+
+ + + {tAlerts('pageIndicator', { page: currentPage, totalPages })} + + +
+
+ )} + {/* Error */} {error && (
@@ -153,16 +222,16 @@ export default function AlertsPage({ params }: { params: { locale: string } }) { )} {/* Incidents grouped by severity */} - {sorted.length > 0 && ( + {pageIncidents.length > 0 && (
{(['P0', 'P1', 'P2', 'P3'] as const).map(sev => { - const group = sorted.filter(i => i.severity === sev) + const group = pageIncidents.filter(i => i.severity === sev) if (group.length === 0) return null return (
{/* Section header */}
- + {tAlerts('incidentCount', { count: group.length })} @@ -170,7 +239,11 @@ export default function AlertsPage({ params }: { params: { locale: string } }) {
{group.map(incident => ( - + ))}
diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index ddefa2bf..2131e218 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -18,6 +18,7 @@ import React from 'react' import { useTranslations } from 'next-intl' import { useState, useEffect } from 'react' import { useIncidents } from '@/hooks/useIncidents' +import { useIncidentStatusChains } from '@/hooks/useIncidentStatusChains' import { useHosts, useDashboardStore, type Host } from '@/stores/dashboard.store' import { IncidentCard } from '@/components/incident' import { OpenClawPanel } from '@/components/ai/openclaw-panel' @@ -30,7 +31,6 @@ import { PendingApprovalsCard } from '@/components/shared/pending-approvals-card import { AIModelStatus } from '@/components/shared/ai-model-status' import { FlywheelKPICard } from '@/components/dashboard/flywheel-kpi-card' import { AutomationEvidenceCard } from '@/components/dashboard/automation-evidence-card' -import type { AwoooPStatusChain } from '@/components/awooop/status-chain' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' const STATUS_CHAIN_PREFETCH_LIMIT = 25 @@ -512,54 +512,11 @@ export default function Home({ params }: { params: { locale: string } }) { pollInterval: 15000, enablePolling: true, }) - const statusChainIncidentKey = incidents - ?.slice(0, STATUS_CHAIN_PREFETCH_LIMIT) - .map(incident => incident.incident_id) - .join('|') ?? '' - const [statusChains, setStatusChains] = useState>({}) - - useEffect(() => { - const incidentIds = statusChainIncidentKey - ? statusChainIncidentKey.split('|').filter(Boolean) - : [] - if (incidentIds.length === 0) { - setStatusChains({}) - return - } - - const controller = new AbortController() - const timeout = window.setTimeout(() => controller.abort(), 12000) - - Promise.all( - incidentIds.map(async (incidentId): Promise<[string, AwoooPStatusChain | null]> => { - const params = new URLSearchParams({ project_id: 'awoooi' }) - params.append('incident_id', incidentId) - try { - const response = await fetch(`${API_BASE}/api/v1/platform/status-chain?${params.toString()}`, { - cache: 'no-store', - signal: controller.signal, - }) - if (!response.ok) return [incidentId, null] - return [incidentId, await response.json() as AwoooPStatusChain] - } catch { - return [incidentId, null] - } - }) - ) - .then(entries => { - if (controller.signal.aborted) return - setStatusChains(Object.fromEntries(entries)) - }) - .catch(() => { - if (!controller.signal.aborted) setStatusChains({}) - }) - .finally(() => window.clearTimeout(timeout)) - - return () => { - window.clearTimeout(timeout) - controller.abort() - } - }, [statusChainIncidentKey, incidentsLastUpdated]) + const { statusChains } = useIncidentStatusChains({ + incidentIds: incidents?.map(incident => incident.incident_id) ?? [], + limit: STATUS_CHAIN_PREFETCH_LIMIT, + refreshKey: incidentsLastUpdated?.toISOString() ?? null, + }) // ── Metrics 計算 ──────────────────────────────────────────────────────────── diff --git a/apps/web/src/hooks/index.ts b/apps/web/src/hooks/index.ts index 66ea88bf..84feac82 100644 --- a/apps/web/src/hooks/index.ts +++ b/apps/web/src/hooks/index.ts @@ -3,6 +3,7 @@ export * from './use-health' export * from './useSSE' export * from './useApprovalSSE' export * from './useIncidents' +export * from './useIncidentStatusChains' export * from './useGlobalPulseMetrics' export * from './useKeyboardShortcuts' export * from './useErrors' diff --git a/apps/web/src/hooks/useIncidentStatusChains.ts b/apps/web/src/hooks/useIncidentStatusChains.ts new file mode 100644 index 00000000..cdaa9076 --- /dev/null +++ b/apps/web/src/hooks/useIncidentStatusChains.ts @@ -0,0 +1,85 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' + +import type { AwoooPStatusChain } from '@/components/awooop/status-chain' +import { API_V1_URL } from '@/lib/config' + +interface UseIncidentStatusChainsOptions { + incidentIds: string[] + limit?: number + projectId?: string + refreshKey?: string | number | Date | null + timeoutMs?: number +} + +interface UseIncidentStatusChainsResult { + statusChains: Record + requestedIncidentIds: string[] + isLoading: boolean +} + +export function useIncidentStatusChains({ + incidentIds, + limit = 25, + projectId = 'awoooi', + refreshKey = null, + timeoutMs = 12000, +}: UseIncidentStatusChainsOptions): UseIncidentStatusChainsResult { + const incidentIdsKey = incidentIds.filter(Boolean).join('|') + const requestedIncidentIds = useMemo(() => { + return Array.from(new Set(incidentIdsKey ? incidentIdsKey.split('|') : [])).slice(0, limit) + }, [incidentIdsKey, limit]) + const incidentKey = requestedIncidentIds.join('|') + const [statusChains, setStatusChains] = useState>({}) + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + if (requestedIncidentIds.length === 0) { + setStatusChains({}) + setIsLoading(false) + return + } + + let active = true + const controller = new AbortController() + const timeout = window.setTimeout(() => controller.abort(), timeoutMs) + setIsLoading(true) + + Promise.all( + requestedIncidentIds.map(async (incidentId): Promise<[string, AwoooPStatusChain | null]> => { + const params = new URLSearchParams({ project_id: projectId, incident_id: incidentId }) + try { + const response = await fetch(`${API_V1_URL}/platform/status-chain?${params.toString()}`, { + cache: 'no-store', + signal: controller.signal, + }) + if (!response.ok) return [incidentId, null] + return [incidentId, await response.json() as AwoooPStatusChain] + } catch { + return [incidentId, null] + } + }) + ) + .then(entries => { + if (active) setStatusChains(Object.fromEntries(entries)) + }) + .catch(() => { + if (active) setStatusChains({}) + }) + .finally(() => { + window.clearTimeout(timeout) + if (active) setIsLoading(false) + }) + + return () => { + active = false + window.clearTimeout(timeout) + controller.abort() + } + }, [incidentKey, projectId, requestedIncidentIds, refreshKey, timeoutMs]) + + return { statusChains, requestedIncidentIds, isLoading } +} + +export type { UseIncidentStatusChainsOptions, UseIncidentStatusChainsResult }