Files
lislgosms/src/apps/admin/AdminMonitorPage.tsx
T
2026-09-06 19:22:49 +08:00

309 lines
11 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Breadcrumb, Button, Input, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { MonitorRuntimeOverview } from './MonitorRuntimeOverview';
import { MonitorRulesModal, MonitorScopePicker, MonitorTargetsModal } from './sending-monitor/MonitorConfiguration';
import { MonitorAlerts, MonitorHistory, MonitorPager, Rate } from './sending-monitor/MonitorDetails';
import {
monitorApi,
names,
ruleSource,
states,
time,
title,
type MonitorType,
type Snapshot,
} from './sending-monitor/monitorApi';
import './AdminMonitorPage.css';
export function AdminMonitorPage() {
const [params, setParams] = useSearchParams();
const tab = params.get('tab') ?? 'industry';
const type: MonitorType = tab === 'overall' || tab === 'verification' ? tab : 'industry';
const [data, setData] = useState<Awaited<ReturnType<typeof monitorApi.rows>> | null>(null);
const [summary, setSummary] = useState<Awaited<ReturnType<typeof monitorApi.overview>> | null>(null);
const [error, setError] = useState(''),
[loading, setLoading] = useState(false),
[dialog, setDialog] = useState<'rules' | 'targets' | null>(null),
[detail, setDetail] = useState<Snapshot | null>(null);
const lastLoad = useRef(0),
busy = useRef(false);
const page = Math.max(1, Number(params.get('page')) || 1),
queryKey = params.toString();
const update = (values: Record<string, string>) => {
const next = new URLSearchParams(params);
Object.entries(values).forEach(([key, value]) => (value ? next.set(key, value) : next.delete(key)));
setParams(next);
};
const load = useCallback(
async (force = false, signal?: AbortSignal) => {
if (
tab === 'alerts' ||
tab === 'runtime' ||
document.hidden ||
(busy.current && !force) ||
(!force && Date.now() - lastLoad.current < 1500)
)
return;
busy.current = true;
lastLoad.current = Date.now();
setLoading(true);
const q = new URLSearchParams(queryKey);
try {
const [rows, overview] = await Promise.all([
monitorApi.rows(
{
type,
page: q.get('page') ?? '1',
status: q.get('status') ?? '',
keyword: q.get('keyword') ?? '',
tenantId: q.get('tenantId') ?? '',
applicationId: q.get('applicationId') ?? '',
signatureId: q.get('signatureId') ?? '',
},
signal,
),
monitorApi.overview(type),
]);
if (!signal?.aborted) {
setData(rows);
setSummary(overview);
setError('');
}
} catch (e) {
if (!signal?.aborted) setError(e instanceof Error ? e.message : '监控加载失败');
} finally {
if (!signal?.aborted) {
setLoading(false);
busy.current = false;
}
}
},
[queryKey, tab, type],
);
useEffect(() => {
const controller = new AbortController();
void load(true, controller.signal);
const refresh = () => void load(false, controller.signal);
const timer = setInterval(refresh, 30000);
window.addEventListener('focus', refresh);
document.addEventListener('visibilitychange', refresh);
return () => {
controller.abort();
clearInterval(timer);
window.removeEventListener('focus', refresh);
document.removeEventListener('visibilitychange', refresh);
};
}, [load]);
const health = data?.health?.data;
const stale = !health || !health.complete || Date.now() - new Date(health.checkedAt).getTime() > 30000;
const rows = data?.items ?? [];
const columns: TableColumn<Snapshot>[] = [
{
key: 'name',
title: type === 'industry' ? '通道 / 运营商' : '企业 / 应用 / 签名',
width: '260px',
render: (row) => (
<div className="ui-table__long-text">
<strong>{title(row.dimensions)}</strong>
<p>{row.dimensions.channelId ?? row.dimensions.signatureId}</p>
</div>
),
},
{ key: 'total', title: '窗口提交量', width: '110px', render: (row) => row.metrics.total.toLocaleString() },
...(type === 'overall' ? [60, 300, 1200] : [5, 20, 60]).map((seconds, i): TableColumn<Snapshot> => ({
key: `rate${seconds}`,
title: `${seconds < 60 ? `${seconds}秒` : `${seconds / 60}分钟`}到达率`,
width: '165px',
render: (row) => <Rate value={row.metrics.metrics[i]} />,
})),
{
key: 'state',
title: '状态 / 规则',
width: '140px',
render: (row) => (
<>
<Tag tone={!stale && row.status === 'abnormal' ? 'danger' : 'neutral'}>
{stale ? '数据延迟' : states[row.status]}
</Tag>
<p>
{ruleSource(row.rule)}
{row.rule ? ` v${row.rule.version}` : ''}
</p>
</>
),
},
{
key: 'detail',
title: '操作',
width: '110px',
render: (row) => (
<Button variant="ghost" size="sm" onClick={() => setDetail(row)}>
趋势 / 详情
</Button>
),
},
];
const counts = summary?.rows ?? [];
const cards = [
['监控维度', counts.reduce((n, r) => n + r.dimensions, 0)],
['异常维度', counts.filter((r) => r.status === 'abnormal').reduce((n, r) => n + r.dimensions, 0)],
[
'样本不足',
counts
.filter((r) => ['sample_insufficient', 'unassessable'].includes(r.status))
.reduce((n, r) => n + r.dimensions, 0),
],
['窗口提交量', counts.reduce((n, r) => n + Number(r.total), 0)],
];
const content = (
<div className="page-stack">
<div className="sending-monitor__summary">
{cards.map(([label, value]) => (
<div className="surface" key={label}>
<span>{label}</span>
<strong>{data ? Number(value).toLocaleString() : '—'}</strong>
<small>{type === 'industry' ? '按通道发送尝试' : '按唯一业务短信'}</small>
</div>
))}
</div>
<div className="surface sending-monitor__filters">
<div>
<strong>评估时刻:{time(counts[0]?.evaluationAt)}</strong>
<p>
{type === 'overall' ? '10' : '5'}分钟评估,提交窗口最近{type === 'overall' ? '30' : '5'}分钟
</p>
<small>
采集检查:{time(health?.checkedAt)}
{stale ? ' · 数据延迟或等待首次计算,暂停告警判断' : ' · 已完成采集'}
</small>
</div>
<Input
aria-label="搜索监控对象"
placeholder="搜索通道、企业、应用或签名"
value={params.get('keyword') ?? ''}
onChange={(e) => update({ keyword: e.target.value, page: '1' })}
/>
<Select
label="状态"
value={params.get('status') ?? ''}
options={[
{ value: '', label: '全部' },
...['abnormal', 'normal', 'sample_insufficient', 'stale', 'unassessable', 'unconfigured', 'no_data'].map(
(value) => ({ value, label: states[value] }),
),
]}
onChange={(e) => update({ status: e.target.value, page: '1' })}
/>
</div>
{type !== 'industry' && (
<details className="surface">
<summary>按企业、应用、签名筛选</summary>
<MonitorScopePicker
scope={{
tenantId: params.get('tenantId') ?? undefined,
applicationId: params.get('applicationId') ?? undefined,
signatureId: params.get('signatureId') ?? undefined,
}}
onChange={(scope) =>
update({
tenantId: scope.tenantId ?? '',
applicationId: scope.applicationId ?? '',
signatureId: scope.signatureId ?? '',
page: '1',
})
}
/>
</details>
)}
{loading && <p role="status">正在读取监控快照…</p>}
{error && (
<p role="alert" className="form-error">
{error},保留上次结果,请重试。
</p>
)}
<div className="surface sending-monitor__desktop">
<Table columns={columns} data={rows} rowKey="id" />
</div>
<div className="sending-monitor__mobile">
{rows.map((row) => (
<article className="surface" key={row.id}>
<h3>{title(row.dimensions)}</h3>
<Tag tone="neutral">{stale ? '数据延迟' : states[row.status]}</Tag>
<p>
窗口提交 {row.metrics.total} · {ruleSource(row.rule)}
</p>
{row.metrics.metrics.map((m) => (
<div className="sending-monitor__mobile-metric" key={m.seconds}>
<span>{m.seconds}秒到达率</span>
<Rate value={m} />
</div>
))}
<Button variant="ghost" onClick={() => setDetail(row)}>
趋势 / 详情
</Button>
</article>
))}
</div>
{!loading && !rows.length && !error && (
<p className="surface">
暂无监控快照。行业通道需先加入监控;无流量不会生成应用签名维度。历史记录缺少发包时间时不制造到达率。
</p>
)}
<MonitorPager page={page} total={data?.total ?? 0} onChange={(p) => update({ page: String(p) })} />
</div>
);
return (
<section className="sending-monitor page-stack">
<div className="page-heading">
<Breadcrumb items={['运营概览', '发送监控']} />
<div className="sending-monitor__actions">
<Button variant="secondary" disabled={loading} onClick={() => void load()}>
刷新
</Button>
{!['alerts', 'runtime'].includes(tab) && (
<Button variant="secondary" onClick={() => setDialog('rules')}>
阈值设置
</Button>
)}
{tab === 'industry' && <Button onClick={() => setDialog('targets')}>监控通道</Button>}
</div>
</div>
<p className="sending-monitor__notice">
以平台收到完整成功回执为准;未满观察时长的短信不参与该时效评估。各指标分别展示成功数 / 成熟分母。
</p>
<Tabs
value={tab}
onChange={(value) => {
setData(null);
setSummary(null);
setParams({ tab: value });
}}
items={[
...Object.entries(names).map(([value, label]) => ({ value, label, content })),
{ value: 'alerts', label: '告警记录', content: <MonitorAlerts /> },
{ value: 'runtime', label: '运行概况', content: <MonitorRuntimeOverview /> },
]}
/>
{dialog === 'rules' && (
<MonitorRulesModal
type={type}
onClose={() => {
setDialog(null);
void load(true);
}}
/>
)}
{dialog === 'targets' && (
<MonitorTargetsModal
onClose={() => {
setDialog(null);
void load(true);
}}
/>
)}
{detail && <MonitorHistory row={detail} onClose={() => setDetail(null)} />}
</section>
);
}