feat: add protocol interaction observability
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { CalendarDays, FileText, Search } from 'lucide-react';
|
||||
import { Button, Input, Pagination, Select, SystemLogExport, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type OperationLogItem } from '@/api/adminApi';
|
||||
import { Button, Input, Modal, Pagination, Select, SystemLogExport, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type OperationLogItem, type ProtocolInteractionLogItem } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
type LogLevel = 'info' | 'success' | 'warning' | 'error';
|
||||
@@ -21,6 +21,7 @@ const levelToneMap: Record<LogLevel, 'info' | 'success' | 'warning' | 'danger'>
|
||||
};
|
||||
|
||||
export function AdminSystemLogsPage() {
|
||||
const [activeTab, setActiveTab] = useState('operations');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [level, setLevel] = useState('all');
|
||||
const [module, setModule] = useState('all');
|
||||
@@ -92,16 +93,8 @@ export function AdminSystemLogsPage() {
|
||||
{ key: 'ip', title: 'IP', width: '120px', render: (record) => <span className="muted">{record.ip}</span> },
|
||||
], []);
|
||||
|
||||
return (
|
||||
<section className="page-stack system-page">
|
||||
<div className="system-page-toolbar">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon"><FileText size={22} /></span>
|
||||
<h1>系统日志</h1>
|
||||
</div>
|
||||
<SystemLogExport exportLogs={adminApi.exportSystemLogs} filters={filters} portal="admin" />
|
||||
</div>
|
||||
|
||||
const operationContent = (
|
||||
<div className="page-stack">
|
||||
<div className="system-log-filters">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
@@ -127,24 +120,7 @@ export function AdminSystemLogsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="system-log-range">
|
||||
<span><CalendarDays size={18} /> 时间范围:</span>
|
||||
{[
|
||||
{ label: '今天', value: 'today' },
|
||||
{ label: '近7天', value: '7d' },
|
||||
{ label: '近30天', value: '30d' },
|
||||
{ label: '全部', value: 'all' },
|
||||
].map((item) => (
|
||||
<Button
|
||||
key={item.value}
|
||||
onClick={() => setRange(item.value)}
|
||||
size="sm"
|
||||
variant={range === item.value ? 'primary' : 'secondary'}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<LogRange range={range} onChange={setRange} />
|
||||
|
||||
<div className="surface system-table-card">
|
||||
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} pagination={false} rowKey="id" />
|
||||
@@ -159,6 +135,131 @@ export function AdminSystemLogsPage() {
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="page-stack system-page">
|
||||
<div className="system-page-toolbar">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon"><FileText size={22} /></span>
|
||||
<h1>系统日志</h1>
|
||||
</div>
|
||||
{activeTab === 'operations' ? <SystemLogExport exportLogs={adminApi.exportSystemLogs} filters={filters} portal="admin" /> : null}
|
||||
</div>
|
||||
<Tabs
|
||||
items={[
|
||||
{ label: '系统与操作日志', value: 'operations', content: operationContent },
|
||||
{ label: '通讯交互日志', value: 'protocol', content: <ProtocolInteractionPanel active={activeTab === 'protocol'} /> },
|
||||
]}
|
||||
onChange={setActiveTab}
|
||||
value={activeTab}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const directionLabels: Record<ProtocolInteractionLogItem['direction'], string> = {
|
||||
client_to_platform: '客户 → 平台',
|
||||
platform_to_channel: '平台 → 通道',
|
||||
channel_to_platform: '通道 → 平台',
|
||||
platform_to_client: '平台 → 客户',
|
||||
};
|
||||
|
||||
const protocolStatusTone: Record<ProtocolInteractionLogItem['status'], 'info' | 'success' | 'warning' | 'danger'> = {
|
||||
received: 'info',
|
||||
accepted: 'success',
|
||||
success: 'success',
|
||||
retrying: 'warning',
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
function ProtocolInteractionPanel({ active }: { active: boolean }) {
|
||||
const [inputs, setInputs] = useState({ keyword: '', protocol: 'all', direction: 'all', eventType: 'all', status: 'all', range: 'today' });
|
||||
const [filters, setFilters] = useState(inputs);
|
||||
const [items, setItems] = useState<ProtocolInteractionLogItem[]>([]);
|
||||
const [eventTypes, setEventTypes] = useState<string[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [error, setError] = useState('');
|
||||
const [detail, setDetail] = useState<ProtocolInteractionLogItem | null>(null);
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
let cancelled = false;
|
||||
adminApi.listProtocolInteractionLogs({ ...filters, page, pageSize })
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setItems(data.items);
|
||||
setEventTypes(data.eventTypes);
|
||||
setTotal(data.total);
|
||||
setError('');
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (cancelled) return;
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setError(reason instanceof Error ? reason.message : '通讯交互日志加载失败');
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [active, filters, page]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const columns = useMemo<Array<TableColumn<ProtocolInteractionLogItem>>>(() => [
|
||||
{ key: 'createdAt', title: '时间', width: '170px', render: (record) => <span className="muted">{formatDateTime(record.createdAt)}</span> },
|
||||
{ key: 'protocol', title: '协议', width: '90px', render: (record) => <Tag tone={record.protocol === 'cmpp' ? 'info' : 'success'}>{record.protocol.toUpperCase()}</Tag> },
|
||||
{ key: 'direction', title: '方向', width: '150px', render: (record) => directionLabels[record.direction] },
|
||||
{ key: 'eventType', title: '事件', width: '150px', render: (record) => <strong>{record.eventType}</strong> },
|
||||
{ key: 'messageId', title: '消息标识', width: '220px', render: (record) => <div className="protocol-log-identifiers"><span>{record.messageId || '-'}</span><small>{record.gatewayMessageId || record.requestId || ''}</small></div> },
|
||||
{ key: 'target', title: '对象', width: '160px', render: (record) => <div className="protocol-log-identifiers"><span>{record.phoneMasked || record.account || '-'}</span><small>{record.channelId || record.applicationId || ''}</small></div> },
|
||||
{ key: 'status', title: '结果', width: '130px', render: (record) => <div className="protocol-log-result"><Tag tone={protocolStatusTone[record.status]}>{record.status}</Tag><small>{record.resultCode || ''}</small></div> },
|
||||
{ key: 'durationMs', title: '耗时', width: '90px', render: (record) => record.durationMs == null ? '-' : `${record.durationMs} ms` },
|
||||
{ key: 'detail', title: '详情', width: '90px', render: (record) => <Button onClick={() => setDetail(record)} size="sm" variant="ghost">查看</Button> },
|
||||
], []);
|
||||
|
||||
function query() {
|
||||
setPage(1);
|
||||
setFilters({ ...inputs, keyword: inputs.keyword.trim() });
|
||||
}
|
||||
|
||||
function reset() {
|
||||
const next = { keyword: '', protocol: 'all', direction: 'all', eventType: 'all', status: 'all', range: 'today' };
|
||||
setInputs(next);
|
||||
setFilters(next);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack protocol-log-panel">
|
||||
<div className="protocol-log-hint">仅记录业务交互和异常,不逐包记录 CMPP 心跳;手机号已脱敏,短信内容、密钥和鉴权头不会入库。</div>
|
||||
<div className="system-log-filters protocol-log-filters">
|
||||
<Input onChange={(event) => setInputs((value) => ({ ...value, keyword: event.target.value }))} placeholder="消息ID、请求ID、账号、脱敏手机号或结果码" prefix={<Search size={16} />} value={inputs.keyword} />
|
||||
<Select onChange={(event) => setInputs((value) => ({ ...value, protocol: event.target.value }))} options={[{ label: '全部协议', value: 'all' }, { label: 'CMPP', value: 'cmpp' }, { label: 'HTTP', value: 'http' }]} value={inputs.protocol} />
|
||||
<Select onChange={(event) => setInputs((value) => ({ ...value, direction: event.target.value }))} options={[{ label: '全部方向', value: 'all' }, ...Object.entries(directionLabels).map(([value, label]) => ({ value, label }))]} value={inputs.direction} />
|
||||
<Select onChange={(event) => setInputs((value) => ({ ...value, eventType: event.target.value }))} options={[{ label: '全部事件', value: 'all' }, ...eventTypes.map((value) => ({ label: value, value }))]} value={inputs.eventType} />
|
||||
<Select onChange={(event) => setInputs((value) => ({ ...value, status: event.target.value }))} options={[{ label: '全部结果', value: 'all' }, { label: '已接收', value: 'received' }, { label: '已受理', value: 'accepted' }, { label: '成功', value: 'success' }, { label: '重试中', value: 'retrying' }, { label: '失败', value: 'failed' }]} value={inputs.status} />
|
||||
<div className="system-log-filters__actions"><Button icon={<Search size={16} />} onClick={query}>查询</Button><Button onClick={reset} variant="ghost">重置</Button></div>
|
||||
</div>
|
||||
<LogRange range={inputs.range} onChange={(range) => setInputs((value) => ({ ...value, range }))} />
|
||||
<div className="surface system-table-card protocol-log-table">
|
||||
<Table columns={columns} data={items} emptyText={error || '暂无通讯交互日志'} pagination={false} rowKey="id" />
|
||||
<Pagination nextDisabled={page >= totalPages} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} onPrevious={() => setPage((value) => Math.max(1, value - 1))} page={Math.min(page, totalPages)} totalPages={totalPages} onPageChange={setPage} previousDisabled={page <= 1} total={total} />
|
||||
</div>
|
||||
<Modal footer={<Button onClick={() => setDetail(null)}>关闭</Button>} onClose={() => setDetail(null)} open={Boolean(detail)} title="通讯交互详情">
|
||||
{detail ? <dl className="protocol-log-detail">{Object.entries(detail).filter(([, value]) => value !== null && value !== undefined && value !== '').map(([key, value]) => <div key={key}><dt>{key}</dt><dd>{typeof value === 'object' ? JSON.stringify(value) : String(value)}</dd></div>)}</dl> : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogRange({ range, onChange }: { range: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<div className="system-log-range">
|
||||
<span><CalendarDays size={18} /> 时间范围:</span>
|
||||
{[{ label: '今天', value: 'today' }, { label: '近7天', value: '7d' }, { label: '近30天', value: '30d' }, { label: '全部', value: 'all' }].map((item) => (
|
||||
<Button key={item.value} onClick={() => onChange(item.value)} size="sm" variant={range === item.value ? 'primary' : 'secondary'}>{item.label}</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user