feat: add protocol interaction observability

This commit is contained in:
hectorzhao
2026-07-24 10:47:47 +08:00
parent 4f4fa5fcb7
commit 0bfeb0839e
17 changed files with 740 additions and 42 deletions
+33
View File
@@ -959,6 +959,37 @@ export type OperationLogResponse = {
modules: string[];
};
export type ProtocolInteractionLogItem = {
id: string;
protocol: 'cmpp' | 'http';
direction: 'client_to_platform' | 'platform_to_channel' | 'channel_to_platform' | 'platform_to_client';
eventType: string;
status: 'received' | 'accepted' | 'success' | 'failed' | 'retrying';
tenantId?: string | null;
applicationId?: string | null;
channelId?: string | null;
account?: string | null;
messageId?: string | null;
gatewayMessageId?: string | null;
traceId?: string | null;
requestId?: string | null;
phoneMasked?: string | null;
resultCode?: string | null;
durationMs?: number | null;
payloadBytes?: number | null;
retryCount?: number | null;
detail?: Record<string, unknown> | null;
createdAt: string;
};
export type ProtocolInteractionLogResponse = {
items: ProtocolInteractionLogItem[];
total: number;
page: number;
pageSize: number;
eventTypes: string[];
};
export type SystemLogExportResult = {
operationId: string;
status: 'completed';
@@ -1366,6 +1397,8 @@ export const adminApi = {
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
listProtocolInteractionLogs: (query: { protocol?: string; direction?: string; eventType?: string; status?: string; keyword?: string; range?: string; page?: number; pageSize?: number }) =>
request<ProtocolInteractionLogResponse>(withQuery('/admin/system-logs/protocol-interactions', query)),
exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string }) =>
request<SystemLogExportResult>('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'),
+131 -30
View File
@@ -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>
);
}
+76
View File
@@ -4783,6 +4783,82 @@ h3 {
font-size: var(--font-size-xs);
}
.protocol-log-hint {
background: var(--color-info-soft);
border: 1px solid color-mix(in srgb, var(--color-info) 24%, transparent);
border-radius: var(--radius-md);
color: var(--color-text);
font-size: var(--font-size-sm);
padding: var(--space-3) var(--space-4);
}
.protocol-log-filters {
grid-template-columns: minmax(260px, 1.4fr) repeat(4, minmax(135px, .55fr));
}
.protocol-log-table .ui-table {
min-width: 1320px;
}
.protocol-log-table .ui-table th:last-child,
.protocol-log-table .ui-table td:last-child {
background: var(--color-surface);
box-shadow: -10px 0 18px -18px rgba(15, 23, 42, .6);
position: sticky;
right: 0;
z-index: 1;
}
.protocol-log-identifiers,
.protocol-log-result {
display: grid;
gap: 4px;
}
.protocol-log-identifiers small,
.protocol-log-result small {
color: var(--color-text-muted);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
overflow-wrap: anywhere;
}
.protocol-log-detail {
display: grid;
gap: var(--space-3);
margin: 0;
}
.protocol-log-detail > div {
display: grid;
gap: var(--space-1);
grid-template-columns: minmax(130px, .35fr) minmax(0, 1fr);
}
.protocol-log-detail dt {
color: var(--color-text-muted);
}
.protocol-log-detail dd {
margin: 0;
overflow-wrap: anywhere;
}
@media (max-width: 1024px) {
.protocol-log-filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.protocol-log-filters {
grid-template-columns: 1fr;
}
.protocol-log-detail > div {
grid-template-columns: 1fr;
}
}
.enterprise-page {
gap: 28px;
}