Files
lislgosms/src/apps/admin/sending-monitor/MonitorDetails.tsx
T

357 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}