diff --git a/docs/bi-refactor-roadmap.md b/docs/bi-refactor-roadmap.md index a778a23..6d68025 100644 --- a/docs/bi-refactor-roadmap.md +++ b/docs/bi-refactor-roadmap.md @@ -163,6 +163,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0) - 已完成电能数据截至时间和实际趋势月展示。 - 已完成氢能结构化故障状态与重试体验。 - 已完成里程今日仪表快照复用:普通查询命中分钟级快照,手动刷新生成一致的分页快照。 +- 已在各 BI 模块头部提供当前数据源的真实请求状态、失败率和最后成功/失败时间。 - 待恢复氢能数据库后进行总览、站点、客户三层对账。 - 待 ETC 同步后进行通行记录、账单和收款三层对账。 diff --git a/src/components/BiHeaderActions.tsx b/src/components/BiHeaderActions.tsx new file mode 100644 index 0000000..cb21da8 --- /dev/null +++ b/src/components/BiHeaderActions.tsx @@ -0,0 +1,12 @@ +import type { MetricDomain } from '../shared/analytics/catalog'; +import DataSourceStatusButton from './DataSourceStatusButton'; +import MetricCatalogButton from './MetricCatalogButton'; + +export default function BiHeaderActions({ domain }: { domain: MetricDomain }) { + return ( + <> + + + + ); +} diff --git a/src/components/DataSourceStatusButton.tsx b/src/components/DataSourceStatusButton.tsx new file mode 100644 index 0000000..813c3b0 --- /dev/null +++ b/src/components/DataSourceStatusButton.tsx @@ -0,0 +1,228 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Activity, AlertTriangle, CheckCircle2, Clock3, Loader2, RefreshCw, X } from 'lucide-react'; +import { AnimatePresence, motion } from 'motion/react'; +import { fetchJson } from '../auth/api-client'; +import type { MetricDomain } from '../shared/analytics/catalog'; +import { + BI_SOURCE_BY_DOMAIN, + type DataSourceId, + type DataSourceState, +} from '../shared/analytics/source-status'; + +interface SourceTelemetry { + source: DataSourceId; + state: DataSourceState; + attempts: number; + failures: number; + failureRate: number | null; + lastSuccessAt: string | null; + lastFailureAt: string | null; +} + +interface SourceTelemetryResponse { + semantics: 'observed-requests'; + windowSize: number; + processStartedAt: string; + checkedAt: string; + sources: SourceTelemetry[]; +} + +const STATE_META = { + available: { + label: '最近请求成功', + icon: CheckCircle2, + className: 'border-emerald-200 bg-emerald-50 text-emerald-700', + }, + failing: { + label: '最近请求失败', + icon: AlertTriangle, + className: 'border-rose-200 bg-rose-50 text-rose-700', + }, + unobserved: { + label: '启动后尚无真实请求', + icon: Clock3, + className: 'border-slate-200 bg-slate-50 text-slate-600', + }, +} as const; + +function formatObservedAt(value: string | null): string { + if (!value) return '暂无'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).format(date); +} + +export default function DataSourceStatusButton({ domain }: { domain: MetricDomain }) { + const [open, setOpen] = useState(false); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const triggerRef = useRef(null); + const focusCloseButton = useCallback((node: HTMLButtonElement | null) => node?.focus(), []); + const sourceMeta = BI_SOURCE_BY_DOMAIN[domain]; + const source = useMemo( + () => data?.sources.find(item => item.source === sourceMeta.source) || null, + [data, sourceMeta.source], + ); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setData(await fetchJson('/api/analytics/data-sources')); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : String(loadError)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (open) void load(); + }, [load, open]); + + useEffect(() => { + if (!open) return undefined; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + document.addEventListener('keydown', handleKeyDown); + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener('keydown', handleKeyDown); + triggerRef.current?.focus(); + }; + }, [open]); + + const stateMeta = source ? STATE_META[source.state] : STATE_META.unobserved; + const StateIcon = stateMeta.icon; + const triggerTone = source?.state === 'failing' + ? 'border-rose-200 bg-rose-50 text-rose-600' + : source?.state === 'available' + ? 'border-emerald-200 bg-emerald-50 text-emerald-600' + : 'border-slate-200 bg-white text-slate-500'; + + return ( + <> + + + {createPortal( + {open ? ( +
+ setOpen(false)} + className="absolute inset-0 h-full w-full bg-slate-950/35 backdrop-blur-[2px]" + /> + +
+ + + +
+

数据源运行状态

+

{sourceMeta.label}

+
+ + +
+ +
+ {error ? ( +
{error}
+ ) : loading && !data ? ( +
+ 正在读取运行状态 +
+ ) : source ? ( + <> +
+ + {stateMeta.label} +
+
+
+
观察窗口
+
最近 {data?.windowSize ?? 100} 次真实读取
+
+
+
请求 / 失败
+
{source.attempts} / {source.failures}
+
+
+
滚动失败率
+
{source.failureRate == null ? '暂无' : `${(source.failureRate * 100).toFixed(1)}%`}
+
+
+
最后成功
+
{formatObservedAt(source.lastSuccessAt)}
+
+
+
最后失败
+
{formatObservedAt(source.lastFailureAt)}
+
+
+

+ 状态来自本进程真实数据读取;缓存命中不计入,最近成功不代表持续可达。 +

+ + ) : null} +
+
+
+ ) : null} +
, document.body)} + + ); +} diff --git a/src/modules/energy/ElectricModule.tsx b/src/modules/energy/ElectricModule.tsx index 7d0309a..ac95f49 100644 --- a/src/modules/energy/ElectricModule.tsx +++ b/src/modules/energy/ElectricModule.tsx @@ -4,7 +4,7 @@ import ElectricView, { type ElectricSubTab } from './ElectricView'; import SubTabs from './SubTabs'; import { useHashSubTab } from './useHashSubTab'; import { FadeIn, PageFrame } from '../../components/ui/surface'; -import MetricCatalogButton from '../../components/MetricCatalogButton'; +import BiHeaderActions from '../../components/BiHeaderActions'; const SUB_TABS = [ { id: 'daily', label: '每日', icon: CalendarDays }, @@ -22,7 +22,7 @@ export default function ElectricModule() { icon={CalendarDays} eyebrow="ELECTRIC BI" meta="时间单位清晰标注 · 支持日/总览切换" - actions={} + actions={} > diff --git a/src/modules/energy/EtcModule.tsx b/src/modules/energy/EtcModule.tsx index eca1d74..73a00d4 100644 --- a/src/modules/energy/EtcModule.tsx +++ b/src/modules/energy/EtcModule.tsx @@ -1,7 +1,7 @@ import { Receipt } from 'lucide-react'; import ETCView from './ETCView'; import { PageFrame } from '../../components/ui/surface'; -import MetricCatalogButton from '../../components/MetricCatalogButton'; +import BiHeaderActions from '../../components/BiHeaderActions'; export default function EtcModule() { return ( @@ -11,7 +11,7 @@ export default function EtcModule() { icon={Receipt} eyebrow="ETC BI" meta="真实接入状态 · 指标口径已发布" - actions={} + actions={} > diff --git a/src/modules/energy/HydrogenModule.tsx b/src/modules/energy/HydrogenModule.tsx index ebca318..d7dc1a0 100644 --- a/src/modules/energy/HydrogenModule.tsx +++ b/src/modules/energy/HydrogenModule.tsx @@ -4,7 +4,7 @@ import HydrogenView, { type HydrogenSubTab } from './HydrogenView'; import SubTabs from './SubTabs'; import { useHashSubTab } from './useHashSubTab'; import { FadeIn, PageFrame } from '../../components/ui/surface'; -import MetricCatalogButton from '../../components/MetricCatalogButton'; +import BiHeaderActions from '../../components/BiHeaderActions'; const SUB_TABS = [ { id: 'daily', label: '每日', icon: CalendarDays }, @@ -22,7 +22,7 @@ export default function HydrogenModule() { icon={CalendarDays} eyebrow="ENERGY BI" meta="数据单位清晰标注 · 支持日/总览切换" - actions={} + actions={} > diff --git a/src/modules/mileage/MileageModule.tsx b/src/modules/mileage/MileageModule.tsx index ce29d10..dfbf4f5 100644 --- a/src/modules/mileage/MileageModule.tsx +++ b/src/modules/mileage/MileageModule.tsx @@ -6,7 +6,7 @@ import DailyReportView from './DailyReportView'; import { useHashSubTab } from '../energy/useHashSubTab'; import RotatingFooterHint from '../../components/RotatingFooterHint'; import { FadeIn, PageFrame, SegmentedNav } from '../../components/ui/surface'; -import MetricCatalogButton from '../../components/MetricCatalogButton'; +import BiHeaderActions from '../../components/BiHeaderActions'; type MileageSubTab = 'monitoring' | 'statistics' | 'report'; @@ -28,7 +28,7 @@ export default function MileageModule() { icon={LayoutDashboard} eyebrow="MILEAGE BI" meta="实时监控 · 统计报表 · 每日汇报" - actions={} + actions={} compactInfo >
diff --git a/src/shared/analytics/source-status.test.ts b/src/shared/analytics/source-status.test.ts new file mode 100644 index 0000000..626787e --- /dev/null +++ b/src/shared/analytics/source-status.test.ts @@ -0,0 +1,8 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { BI_SOURCE_BY_DOMAIN } from './source-status.js'; + +test('maps every published BI domain to one observable data source', () => { + assert.deepEqual(Object.keys(BI_SOURCE_BY_DOMAIN).sort(), ['electric', 'etc', 'hydrogen', 'mileage']); + assert.equal(new Set(Object.values(BI_SOURCE_BY_DOMAIN).map(item => item.source)).size, 4); +}); diff --git a/src/shared/analytics/source-status.ts b/src/shared/analytics/source-status.ts new file mode 100644 index 0000000..5e20021 --- /dev/null +++ b/src/shared/analytics/source-status.ts @@ -0,0 +1,14 @@ +import type { MetricDomain } from './catalog.js'; + +export type DataSourceId = 'oneOsMileage' | 'hydrogenDatabase' | 'electricDatabase' | 'etcDatabase'; +export type DataSourceState = 'available' | 'failing' | 'unobserved'; + +export const BI_SOURCE_BY_DOMAIN: Record = { + mileage: { source: 'oneOsMileage', label: 'OneOS 里程开放平台' }, + hydrogen: { source: 'hydrogenDatabase', label: '氢能业务数据库' }, + electric: { source: 'electricDatabase', label: '电能业务数据库' }, + etc: { source: 'etcDatabase', label: 'ETC 业务数据库' }, +};