This commit is contained in:
@@ -1,82 +1,308 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { Breadcrumb, Button, CarrierTag, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type AdminChannel } from '@/api/adminApi';
|
||||
|
||||
const columns: Array<TableColumn<AdminChannel>> = [
|
||||
{ key: 'id', title: '通道编号', render: (record) => record.id },
|
||||
{ key: 'name', title: '通道名称', render: (record) => record.name },
|
||||
{ key: 'carrier', title: '运营商', render: (record) => {
|
||||
const carriers = record.carriers?.length ? record.carriers : record.carrier === 'all' ? ['mobile', 'unicom', 'telecom'] : record.carrier ? [record.carrier] : [];
|
||||
return carriers.length ? <span className="ui-carrier-tags">{carriers.map((carrier) => <CarrierTag carrier={carrier} key={carrier} />)}</span> : '-';
|
||||
} },
|
||||
{ key: 'gatewayHost', title: '网关地址', render: (record) => `${record.gatewayHost}:${record.gatewayPort}` },
|
||||
{ key: 'rateLimitPerSecond', title: '限速', render: (record) => `${record.rateLimitPerSecond} 条/秒` },
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'danger'}>{record.status === 'active' ? '运行中' : '已停用'}</Tag>,
|
||||
},
|
||||
];
|
||||
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 [channels, setChannels] = useState<AdminChannel[]>([]);
|
||||
const [monitor, setMonitor] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
setLoading(true);
|
||||
Promise.all([adminApi.listChannels(), adminApi.listMonitor()])
|
||||
.then(([channelItems, monitorData]) => {
|
||||
setChannels(channelItems.filter((item) => item.status !== 'deleted'));
|
||||
setMonitor(monitorData);
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '监控数据加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
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(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const enabledChannels = channels.filter((item) => item.status === 'active').length;
|
||||
const statusGroups = Array.isArray(monitor.byStatus) ? monitor.byStatus as Array<{ status: string; _count: { _all: number } }> : [];
|
||||
const totalMessages = useMemo(() => statusGroups.reduce((sum, item) => sum + (item._count?._all ?? 0), 0), [statusGroups]);
|
||||
const deliveredMessages = useMemo(() => statusGroups.filter((item) => item.status === 'delivered').reduce((sum, item) => sum + (item._count?._all ?? 0), 0), [statusGroups]);
|
||||
const successRate = totalMessages > 0 ? ((deliveredMessages / totalMessages) * 100).toFixed(1) : '0.0';
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
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>
|
||||
<Breadcrumb items={['发送监控']} />
|
||||
<strong>评估时刻:{time(counts[0]?.evaluationAt)}</strong>
|
||||
<p>
|
||||
每{type === 'overall' ? '10' : '5'}分钟评估,提交窗口最近{type === 'overall' ? '30' : '5'}分钟
|
||||
</p>
|
||||
<small>
|
||||
采集检查:{time(health?.checkedAt)}
|
||||
{stale ? ' · 数据延迟或等待首次计算,暂停告警判断' : ' · 已完成采集'}
|
||||
</small>
|
||||
</div>
|
||||
<Button icon={<Activity size={16} />} onClick={loadData} variant="ghost">实时刷新</Button>
|
||||
<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>
|
||||
{loading ? <p className="muted">正在加载监控数据...</p> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="dashboard-grid">
|
||||
<div className="surface metric-card">
|
||||
<span>运行通道</span>
|
||||
<strong>{enabledChannels}</strong>
|
||||
<small>共 {channels.length} 条通道</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>平均成功率</span>
|
||||
<strong>{successRate}%</strong>
|
||||
<small>真实消息记录</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>消息总量</span>
|
||||
<strong>{totalMessages.toLocaleString('zh-CN')}</strong>
|
||||
<small>按当前查询聚合</small>
|
||||
{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>
|
||||
<div className="surface">
|
||||
<Table columns={columns} data={channels} rowKey="id" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user