feat: surface BI data source health

This commit is contained in:
kkfluous
2026-08-07 16:09:01 +08:00
parent 52c785f6ec
commit 47a4474608
9 changed files with 271 additions and 8 deletions
+1
View File
@@ -163,6 +163,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
- 已完成电能数据截至时间和实际趋势月展示。 - 已完成电能数据截至时间和实际趋势月展示。
- 已完成氢能结构化故障状态与重试体验。 - 已完成氢能结构化故障状态与重试体验。
- 已完成里程今日仪表快照复用:普通查询命中分钟级快照,手动刷新生成一致的分页快照。 - 已完成里程今日仪表快照复用:普通查询命中分钟级快照,手动刷新生成一致的分页快照。
- 已在各 BI 模块头部提供当前数据源的真实请求状态、失败率和最后成功/失败时间。
- 待恢复氢能数据库后进行总览、站点、客户三层对账。 - 待恢复氢能数据库后进行总览、站点、客户三层对账。
- 待 ETC 同步后进行通行记录、账单和收款三层对账。 - 待 ETC 同步后进行通行记录、账单和收款三层对账。
+12
View File
@@ -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 (
<>
<DataSourceStatusButton domain={domain} />
<MetricCatalogButton domain={domain} />
</>
);
}
+228
View File
@@ -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<SourceTelemetryResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const triggerRef = useRef<HTMLButtonElement>(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<SourceTelemetryResponse>('/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 (
<>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(true)}
className={`inline-flex h-9 w-9 items-center justify-center rounded-xl border shadow-sm transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 ${triggerTone}`}
aria-label="查看数据源运行状态"
title="数据源运行状态"
>
<Activity size={16} />
</button>
{createPortal(<AnimatePresence>
{open ? (
<div className="fixed inset-0 z-[10020]">
<motion.button
type="button"
aria-label="点击遮罩关闭数据源状态"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setOpen(false)}
className="absolute inset-0 h-full w-full bg-slate-950/35 backdrop-blur-[2px]"
/>
<motion.section
role="dialog"
aria-modal="true"
aria-labelledby={`${domain}-source-status-title`}
initial={{ opacity: 0, x: 32 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 32 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="absolute inset-y-0 right-0 flex w-full max-w-sm flex-col bg-white shadow-2xl"
>
<header className="flex shrink-0 items-center gap-3 border-b border-slate-100 px-4 py-4">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<Activity size={17} />
</span>
<div className="min-w-0 flex-1">
<h2 id={`${domain}-source-status-title`} className="text-sm font-black text-slate-900"></h2>
<p className="mt-0.5 truncate text-[10px] font-bold text-slate-400">{sourceMeta.label}</p>
</div>
<button
type="button"
onClick={() => void load()}
disabled={loading}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50 disabled:opacity-50"
aria-label="刷新数据源状态"
title="刷新"
>
<RefreshCw size={15} className={loading ? 'animate-spin' : ''} />
</button>
<button
ref={focusCloseButton}
type="button"
onClick={() => setOpen(false)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50"
aria-label="关闭数据源状态"
title="关闭"
>
<X size={16} />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
{error ? (
<div className="rounded-lg border border-rose-100 bg-rose-50 p-3 text-xs font-bold text-rose-700">{error}</div>
) : loading && !data ? (
<div className="flex items-center justify-center gap-2 py-16 text-xs font-bold text-slate-400">
<Loader2 size={16} className="animate-spin" />
</div>
) : source ? (
<>
<div className={`flex items-center gap-2 rounded-lg border px-3 py-3 ${stateMeta.className}`}>
<StateIcon size={18} />
<span className="text-sm font-black">{stateMeta.label}</span>
</div>
<dl className="mt-5 divide-y divide-slate-100 border-y border-slate-100 text-xs">
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400"></dt>
<dd className="font-black text-slate-700"> {data?.windowSize ?? 100} </dd>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400"> / </dt>
<dd className="font-black tabular-nums text-slate-700">{source.attempts} / {source.failures}</dd>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400"></dt>
<dd className="font-black tabular-nums text-slate-700">{source.failureRate == null ? '暂无' : `${(source.failureRate * 100).toFixed(1)}%`}</dd>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400"></dt>
<dd className="font-black tabular-nums text-slate-700">{formatObservedAt(source.lastSuccessAt)}</dd>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400"></dt>
<dd className="font-black tabular-nums text-slate-700">{formatObservedAt(source.lastFailureAt)}</dd>
</div>
</dl>
<p className="mt-5 text-[10px] font-bold leading-relaxed text-slate-400">
</p>
</>
) : null}
</div>
</motion.section>
</div>
) : null}
</AnimatePresence>, document.body)}
</>
);
}
+2 -2
View File
@@ -4,7 +4,7 @@ import ElectricView, { type ElectricSubTab } from './ElectricView';
import SubTabs from './SubTabs'; import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab'; import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface'; import { FadeIn, PageFrame } from '../../components/ui/surface';
import MetricCatalogButton from '../../components/MetricCatalogButton'; import BiHeaderActions from '../../components/BiHeaderActions';
const SUB_TABS = [ const SUB_TABS = [
{ id: 'daily', label: '每日', icon: CalendarDays }, { id: 'daily', label: '每日', icon: CalendarDays },
@@ -22,7 +22,7 @@ export default function ElectricModule() {
icon={CalendarDays} icon={CalendarDays}
eyebrow="ELECTRIC BI" eyebrow="ELECTRIC BI"
meta="时间单位清晰标注 · 支持日/总览切换" meta="时间单位清晰标注 · 支持日/总览切换"
actions={<MetricCatalogButton domain="electric" />} actions={<BiHeaderActions domain="electric" />}
> >
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} /> <SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
+2 -2
View File
@@ -1,7 +1,7 @@
import { Receipt } from 'lucide-react'; import { Receipt } from 'lucide-react';
import ETCView from './ETCView'; import ETCView from './ETCView';
import { PageFrame } from '../../components/ui/surface'; import { PageFrame } from '../../components/ui/surface';
import MetricCatalogButton from '../../components/MetricCatalogButton'; import BiHeaderActions from '../../components/BiHeaderActions';
export default function EtcModule() { export default function EtcModule() {
return ( return (
@@ -11,7 +11,7 @@ export default function EtcModule() {
icon={Receipt} icon={Receipt}
eyebrow="ETC BI" eyebrow="ETC BI"
meta="真实接入状态 · 指标口径已发布" meta="真实接入状态 · 指标口径已发布"
actions={<MetricCatalogButton domain="etc" />} actions={<BiHeaderActions domain="etc" />}
> >
<ETCView /> <ETCView />
</PageFrame> </PageFrame>
+2 -2
View File
@@ -4,7 +4,7 @@ import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
import SubTabs from './SubTabs'; import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab'; import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface'; import { FadeIn, PageFrame } from '../../components/ui/surface';
import MetricCatalogButton from '../../components/MetricCatalogButton'; import BiHeaderActions from '../../components/BiHeaderActions';
const SUB_TABS = [ const SUB_TABS = [
{ id: 'daily', label: '每日', icon: CalendarDays }, { id: 'daily', label: '每日', icon: CalendarDays },
@@ -22,7 +22,7 @@ export default function HydrogenModule() {
icon={CalendarDays} icon={CalendarDays}
eyebrow="ENERGY BI" eyebrow="ENERGY BI"
meta="数据单位清晰标注 · 支持日/总览切换" meta="数据单位清晰标注 · 支持日/总览切换"
actions={<MetricCatalogButton domain="hydrogen" />} actions={<BiHeaderActions domain="hydrogen" />}
> >
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} /> <SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
+2 -2
View File
@@ -6,7 +6,7 @@ import DailyReportView from './DailyReportView';
import { useHashSubTab } from '../energy/useHashSubTab'; import { useHashSubTab } from '../energy/useHashSubTab';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { FadeIn, PageFrame, SegmentedNav } from '../../components/ui/surface'; import { FadeIn, PageFrame, SegmentedNav } from '../../components/ui/surface';
import MetricCatalogButton from '../../components/MetricCatalogButton'; import BiHeaderActions from '../../components/BiHeaderActions';
type MileageSubTab = 'monitoring' | 'statistics' | 'report'; type MileageSubTab = 'monitoring' | 'statistics' | 'report';
@@ -28,7 +28,7 @@ export default function MileageModule() {
icon={LayoutDashboard} icon={LayoutDashboard}
eyebrow="MILEAGE BI" eyebrow="MILEAGE BI"
meta="实时监控 · 统计报表 · 每日汇报" meta="实时监控 · 统计报表 · 每日汇报"
actions={<MetricCatalogButton domain="mileage" />} actions={<BiHeaderActions domain="mileage" />}
compactInfo compactInfo
> >
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6"> <div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
@@ -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);
});
+14
View File
@@ -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<MetricDomain, {
source: DataSourceId;
label: string;
}> = {
mileage: { source: 'oneOsMileage', label: 'OneOS 里程开放平台' },
hydrogen: { source: 'hydrogenDatabase', label: '氢能业务数据库' },
electric: { source: 'electricDatabase', label: '电能业务数据库' },
etc: { source: 'etcDatabase', label: 'ETC 业务数据库' },
};