357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import type { EChartsOption } from 'echarts';
|
||
import { Link } from 'react-router-dom';
|
||
import { Button, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||
import {
|
||
monitorApi,
|
||
names,
|
||
ruleSource,
|
||
states,
|
||
time,
|
||
title,
|
||
type Alert,
|
||
type Metric,
|
||
type Page,
|
||
type Snapshot,
|
||
} from './monitorApi';
|
||
|
||
const TrendChart = lazy(() => import('@/components/ui/Chart').then((m) => ({ default: m.Chart })));
|
||
const closeReasons: Record<string, string> = {
|
||
recovered: '已连续恢复',
|
||
data_corrected: '迟到数据修正',
|
||
disabled: '规则已停用',
|
||
rule_changed: '规则版本变化',
|
||
enrollment_removed: '已移出监控',
|
||
};
|
||
export function Rate({ value }: { value: Metric }) {
|
||
return (
|
||
<div className={`sending-monitor__rate${value.bad ? ' sending-monitor__rate--bad' : ''}`}>
|
||
<strong>{value.rate === null ? '—' : `${value.rate.toFixed(2)}%`}</strong>
|
||
<span>
|
||
{value.success.toLocaleString()} / {value.mature.toLocaleString()} 成熟
|
||
</span>
|
||
<small>
|
||
{value.observing} 条观察中{value.insufficient ? ' · 样本不足' : ''}
|
||
{value.bad ? ' · 低于下限' : ''}
|
||
</small>
|
||
</div>
|
||
);
|
||
}
|
||
export function MonitorHistory({ row, onClose, alert }: { row: Snapshot; onClose: () => void; alert?: Alert }) {
|
||
const [range, setRange] = useState('2h'),
|
||
[items, setItems] = useState<Snapshot[]>([]),
|
||
[error, setError] = useState('');
|
||
useEffect(() => {
|
||
let live = true;
|
||
monitorApi
|
||
.history(row, range)
|
||
.then((r) => {
|
||
if (live) {
|
||
setItems(r);
|
||
setError('');
|
||
}
|
||
})
|
||
.catch((e) => {
|
||
if (live) setError(e.message);
|
||
});
|
||
return () => {
|
||
live = false;
|
||
};
|
||
}, [row, range]);
|
||
const chartOption = useMemo<EChartsOption>(
|
||
() => ({
|
||
tooltip: { trigger: 'axis', confine: true },
|
||
legend: { type: 'scroll', bottom: 0 },
|
||
grid: { left: 45, right: 20, top: 20, bottom: 65 },
|
||
xAxis: {
|
||
type: 'category',
|
||
data: items.map((item) =>
|
||
new Date(item.evaluationAt).toLocaleTimeString('zh-CN', {
|
||
timeZone: 'Asia/Shanghai',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
}),
|
||
),
|
||
},
|
||
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%' } },
|
||
series: row.metrics.metrics.flatMap((m, i) => [
|
||
{
|
||
name: `${m.seconds}秒到达率`,
|
||
type: 'line' as const,
|
||
connectNulls: false,
|
||
data: items.map((item) => item.metrics.metrics[i]?.rate ?? null),
|
||
showSymbol: false,
|
||
},
|
||
{
|
||
name: `${m.seconds}秒下限`,
|
||
type: 'line' as const,
|
||
step: 'end' as const,
|
||
connectNulls: false,
|
||
data: items.map((item) => (item.rule?.config.enabled ? (item.metrics.metrics[i]?.threshold ?? null) : null)),
|
||
showSymbol: false,
|
||
lineStyle: { type: 'dashed' as const },
|
||
},
|
||
]),
|
||
}),
|
||
[items, row.metrics.metrics],
|
||
);
|
||
return (
|
||
<Modal open title="发送质量趋势与详情" size="xl" onClose={onClose} footer={<Button onClick={onClose}>关闭</Button>}>
|
||
<div className="sending-monitor">
|
||
<h3>{title(row.dimensions)}</h3>
|
||
{alert && (
|
||
<div>
|
||
<p>
|
||
告警开始:{time(alert.openedAt)} · 最近评估:{time(alert.lastEvaluatedAt)} · {states[alert.state]}{' '}
|
||
{closeReasons[alert.closeReason ?? ''] ?? ''}
|
||
</p>
|
||
<p>
|
||
最差快照:{time(alert.worst.evaluationAt)} ·{' '}
|
||
{alert.worst.metrics.metrics
|
||
.map(
|
||
(m) =>
|
||
`${m.seconds}秒 ${m.rate === null ? '—' : m.rate.toFixed(2) + '%'}(${m.success}/${m.mature})`,
|
||
)
|
||
.join(';')}
|
||
</p>
|
||
</div>
|
||
)}
|
||
<Link to={`/admin/sms-records?monitorSnapshotId=${encodeURIComponent(row.id)}`}>
|
||
查看该窗口样本(沿用短信详情权限,样本保留72小时)
|
||
</Link>
|
||
<p>
|
||
统计窗口:{time(row.windowFrom)} 至 {time(row.evaluationAt)}(不含结束时刻)
|
||
</p>
|
||
<p>
|
||
观察截止:{time(row.observedUntil)} · {row.stage === 'final' ? '定稿' : '初评'} · 修订 {row.revision}
|
||
</p>
|
||
<p>
|
||
{ruleSource(row.rule)}{' '}
|
||
{row.rule
|
||
? `v${row.rule.version},最低成熟 ${row.rule.config.minSamples} 条,下限 ${row.rule.config.thresholds.map((v) => (v === null ? '未启用' : `${v}%`)).join(' / ')}`
|
||
: '等待配置阈值'}
|
||
</p>
|
||
<p>
|
||
不可评估 {row.metrics.unassessable} 条。{row.completeness.reason}
|
||
</p>
|
||
<Select
|
||
label="历史范围"
|
||
value={range}
|
||
options={[
|
||
{ value: '2h', label: '最近2小时' },
|
||
{ value: '24h', label: '最近24小时' },
|
||
]}
|
||
onChange={(e) => setRange(e.target.value)}
|
||
/>
|
||
{error && (
|
||
<p role="alert" className="form-error">
|
||
{error}
|
||
</p>
|
||
)}
|
||
{items.length > 0 && (
|
||
<Suspense fallback={<p>正在加载趋势图…</p>}>
|
||
<TrendChart option={chartOption} height={280} />
|
||
</Suspense>
|
||
)}
|
||
<div className="sending-monitor__trend" aria-label="时效到达率历史趋势">
|
||
{items.map((item) => (
|
||
<div className="sending-monitor__trend-point" key={item.id}>
|
||
<time>{time(item.evaluationAt)}</time>
|
||
<span>
|
||
{item.metrics.metrics
|
||
.map(
|
||
(m) =>
|
||
`${m.seconds}秒:${m.rate === null ? '—' : `${m.rate.toFixed(2)}%`} (${m.success}/${m.mature})`,
|
||
)
|
||
.join(' · ')}
|
||
</span>
|
||
<small>
|
||
{states[item.status]} · {ruleSource(item.rule)} v{item.rule?.version ?? '—'} · revision {item.revision}
|
||
</small>
|
||
<div className="sending-monitor__bars">
|
||
{item.metrics.metrics.map((m) => (
|
||
<span
|
||
key={m.seconds}
|
||
title={`${m.seconds}秒 ${m.rate ?? '不可评估'}% / 下限${m.threshold ?? '未配置'}`}
|
||
style={{ width: `${m.rate ?? 0}%` }}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
{!items.length && !error && <p>暂无该维度历史快照</p>}
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
export function MonitorPager({
|
||
page,
|
||
total,
|
||
onChange,
|
||
}: {
|
||
page: number;
|
||
total: number;
|
||
onChange: (page: number) => void;
|
||
}) {
|
||
return (
|
||
<div className="sending-monitor__actions">
|
||
<span>
|
||
共 {total} 条 · 第 {page} 页
|
||
</span>
|
||
<Button variant="secondary" disabled={page === 1} onClick={() => onChange(page - 1)}>
|
||
上一页
|
||
</Button>
|
||
<Button variant="secondary" disabled={page * 20 >= total} onClick={() => onChange(page + 1)}>
|
||
下一页
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
export function MonitorAlerts() {
|
||
const [data, setData] = useState<Page<Alert>>({ items: [], total: 0, page: 1, pageSize: 20 }),
|
||
[page, setPage] = useState(1),
|
||
[state, setState] = useState(''),
|
||
[readStatus, setReadStatus] = useState(''),
|
||
[error, setError] = useState('');
|
||
const [detail, setDetail] = useState<Alert | null>(null),
|
||
[busy, setBusy] = useState(false);
|
||
const requestSequence = useRef(0);
|
||
const load = useCallback(async () => {
|
||
const sequence = ++requestSequence.current;
|
||
try {
|
||
const result = await monitorApi.alerts(page, state, readStatus);
|
||
if (sequence !== requestSequence.current) return;
|
||
setData(result);
|
||
setError('');
|
||
} catch (failure) {
|
||
if (sequence === requestSequence.current) setError(failure instanceof Error ? failure.message : '告警加载失败');
|
||
}
|
||
}, [page, state, readStatus]);
|
||
useEffect(() => {
|
||
void load();
|
||
const timer = setInterval(() => {
|
||
if (!document.hidden) void load();
|
||
}, 30000);
|
||
return () => {
|
||
clearInterval(timer);
|
||
requestSequence.current += 1;
|
||
};
|
||
}, [load]);
|
||
async function read(row: Alert) {
|
||
setBusy(true);
|
||
try {
|
||
await monitorApi.read(row.id);
|
||
setDetail((current) => (current?.id === row.id ? { ...current, unread: false } : current));
|
||
window.dispatchEvent(new Event('cmpp-monitor-alert-refresh'));
|
||
await load();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : '标记已读失败');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
const columns: TableColumn<Alert>[] = [
|
||
{
|
||
key: 'name',
|
||
title: '异常对象',
|
||
width: '300px',
|
||
render: (a) => (
|
||
<div className="ui-table__long-text">
|
||
{title(a.dimensions)}
|
||
<p>{names[a.type]}</p>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'state',
|
||
title: '状态',
|
||
width: '120px',
|
||
render: (a) => (
|
||
<Tag tone={a.state === 'active' ? 'danger' : 'neutral'}>
|
||
{states[a.state]}
|
||
{a.unread ? ' · 未读' : ''}
|
||
</Tag>
|
||
),
|
||
},
|
||
{
|
||
key: 'time',
|
||
title: '开始 / 最近评估',
|
||
width: '230px',
|
||
render: (a) => (
|
||
<>
|
||
{time(a.openedAt)}
|
||
<p>{time(a.lastEvaluatedAt)}</p>
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
key: 'metrics',
|
||
title: '命中指标 / 关闭原因',
|
||
width: '280px',
|
||
render: (a) => (
|
||
<div className="ui-table__long-text">
|
||
{a.latest.metrics.metrics
|
||
.filter((m) => m.bad)
|
||
.map((m) => `${m.seconds}秒 ${m.rate?.toFixed(2)}% < ${m.threshold}%`)
|
||
.join(';') ||
|
||
closeReasons[a.closeReason ?? ''] ||
|
||
'暂停评估或等待恢复'}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'actions',
|
||
title: '操作',
|
||
width: '180px',
|
||
render: (a) => (
|
||
<>
|
||
<Button variant="ghost" size="sm" onClick={() => setDetail(a)}>
|
||
详情
|
||
</Button>
|
||
<Button variant="ghost" size="sm" disabled={busy || !a.unread} onClick={() => void read(a)}>
|
||
标记已读
|
||
</Button>
|
||
</>
|
||
),
|
||
},
|
||
];
|
||
return (
|
||
<div className="page-stack">
|
||
<Select
|
||
label="阅读状态"
|
||
value={readStatus}
|
||
options={[
|
||
{ value: '', label: '全部' },
|
||
{ value: 'unread', label: '未读' },
|
||
{ value: 'read', label: '已读' },
|
||
]}
|
||
onChange={(event) => {
|
||
setReadStatus(event.target.value);
|
||
setPage(1);
|
||
}}
|
||
/>
|
||
<Select
|
||
label="告警状态"
|
||
value={state}
|
||
options={[
|
||
{ value: '', label: '全部' },
|
||
...['active', 'recovered', 'closed'].map((value) => ({ value, label: states[value] })),
|
||
]}
|
||
onChange={(e) => {
|
||
setState(e.target.value);
|
||
setPage(1);
|
||
}}
|
||
/>
|
||
{error && (
|
||
<p role="alert" className="form-error">
|
||
{error}
|
||
</p>
|
||
)}
|
||
<Table columns={columns} data={data.items} rowKey="id" />
|
||
<MonitorPager page={page} total={data.total} onChange={setPage} />
|
||
{detail && <MonitorHistory alert={detail} row={detail.latest} onClose={() => setDetail(null)} />}
|
||
</div>
|
||
);
|
||
}
|