feat: surface BI data source health
This commit is contained in:
@@ -163,6 +163,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
|
||||
- 已完成电能数据截至时间和实际趋势月展示。
|
||||
- 已完成氢能结构化故障状态与重试体验。
|
||||
- 已完成里程今日仪表快照复用:普通查询命中分钟级快照,手动刷新生成一致的分页快照。
|
||||
- 已在各 BI 模块头部提供当前数据源的真实请求状态、失败率和最后成功/失败时间。
|
||||
- 待恢复氢能数据库后进行总览、站点、客户三层对账。
|
||||
- 待 ETC 同步后进行通行记录、账单和收款三层对账。
|
||||
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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={<MetricCatalogButton domain="electric" />}
|
||||
actions={<BiHeaderActions domain="electric" />}
|
||||
>
|
||||
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||
<AnimatePresence mode="wait">
|
||||
|
||||
@@ -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={<MetricCatalogButton domain="etc" />}
|
||||
actions={<BiHeaderActions domain="etc" />}
|
||||
>
|
||||
<ETCView />
|
||||
</PageFrame>
|
||||
|
||||
@@ -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={<MetricCatalogButton domain="hydrogen" />}
|
||||
actions={<BiHeaderActions domain="hydrogen" />}
|
||||
>
|
||||
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||
<AnimatePresence mode="wait">
|
||||
|
||||
@@ -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={<MetricCatalogButton domain="mileage" />}
|
||||
actions={<BiHeaderActions domain="mileage" />}
|
||||
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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 业务数据库' },
|
||||
};
|
||||
Reference in New Issue
Block a user