309 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|