perf(web): gate alert workspace queries
This commit is contained in:
@@ -35,3 +35,30 @@ test('preserves an unsubmitted event filter draft across alert tab URL changes',
|
||||
|
||||
expect(await screen.findByPlaceholderText('车牌 / VIN / 规则名称')).toHaveValue('保留筛选草稿');
|
||||
});
|
||||
|
||||
test('loads only event dependencies on the default alert tab', async () => {
|
||||
mocks.alertSummaryV2.mockResolvedValue({ active: 0, unprocessed: 0, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
|
||||
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 });
|
||||
mocks.alertRulesV2.mockResolvedValue([]);
|
||||
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
await screen.findByText('当前筛选条件没有告警事件');
|
||||
expect(mocks.alertRulesV2).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.metricCatalog).not.toHaveBeenCalled();
|
||||
expect(mocks.alertNotificationsV2).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.alertNotificationsV2.mock.calls[0][0].toString()).toBe('unreadOnly=true&limit=100');
|
||||
});
|
||||
|
||||
test('loads one full notification query on a direct notifications entry', async () => {
|
||||
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
await screen.findByText('仅站内通道具备真实送达与已读状态');
|
||||
expect(mocks.alertRulesV2).not.toHaveBeenCalled();
|
||||
expect(mocks.metricCatalog).not.toHaveBeenCalled();
|
||||
expect(mocks.alertNotificationsV2).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.alertNotificationsV2.mock.calls[0][0].toString()).toBe('limit=100');
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FormEvent, memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { AlertEvent, AlertQuery, AlertRule, AlertRuleInput, AlertStatus, MetricDefinition } from '../../api/types';
|
||||
import type { AlertEvent, AlertNotification, AlertQuery, AlertRule, AlertRuleInput, AlertStatus, MetricDefinition, Page } from '../../api/types';
|
||||
import { actionLabels, alertValue, canAct, formatAlertTime, operatorLabels, ruleCondition, severityLabels, statusLabels, thresholdText } from '../domain/alert';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
@@ -109,19 +109,33 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri
|
||||
</div>;
|
||||
}
|
||||
|
||||
function NotificationsWorkspace({ editable }: { editable: boolean }) {
|
||||
const queryClient = useQueryClient(); const notifications = useQuery({ queryKey: ['alert-notifications-v2', 'all'], queryFn: ({ signal }) => api.alertNotificationsV2(new URLSearchParams({ limit: '100' }), signal), staleTime: 5_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
type NotificationsQuery = {
|
||||
data?: Page<AlertNotification>;
|
||||
error: Error | null;
|
||||
isError: boolean;
|
||||
refetch: () => unknown;
|
||||
};
|
||||
|
||||
function NotificationsWorkspace({ editable, notifications }: { editable: boolean; notifications: NotificationsQuery }) {
|
||||
const queryClient = useQueryClient();
|
||||
const read = useMutation({ mutationFn: api.readAlertNotificationsV2, onSuccess: async () => { await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] })]); } });
|
||||
return <div className="v2-alert-notifications"><header><div><strong>站内通知</strong><span>仅站内通道具备真实送达与已读状态</span></div>{editable ? <button disabled={!notifications.data?.items.some((item) => !item.read)} onClick={() => read.mutate(notifications.data?.items.filter((item) => !item.read).map((item) => item.id) ?? [])}>全部标为已读</button> : <span className="v2-role-badge">只读</span>}</header>{notifications.isError ? <InlineError message={notifications.error.message} onRetry={() => notifications.refetch()} /> : null}<div>{notifications.data?.items.map((item) => <article className={item.read ? 'is-read' : ''} key={item.id}><i className={`is-${item.severity}`} /><div><strong>{item.title}</strong><p>{item.content}</p><span>{formatAlertTime(item.createdAt)} · {item.read ? '已读' : '未读'}</span></div>{editable && !item.read ? <button onClick={() => read.mutate([item.id])}>标为已读</button> : null}</article>)}</div><footer><b>外部通知通道</b><span>短信(SMS)— 预留 / 未启用</span><span>邮件(Email)— 预留 / 未启用</span><span>企业微信(WeCom)— 预留 / 未启用</span></footer></div>;
|
||||
return <div className="v2-alert-notifications"><header><div><strong>站内通知</strong><span>仅站内通道具备真实送达与已读状态</span></div>{editable ? <button disabled={!notifications.data?.items.some((item) => !item.read)} onClick={() => read.mutate(notifications.data?.items.filter((item) => !item.read).map((item) => item.id) ?? [])}>全部标为已读</button> : <span className="v2-role-badge">只读</span>}</header>{notifications.isError ? <InlineError message={notifications.error?.message ?? '站内通知读取失败'} onRetry={() => notifications.refetch()} /> : null}<div>{notifications.data?.items.map((item) => <article className={item.read ? 'is-read' : ''} key={item.id}><i className={`is-${item.severity}`} /><div><strong>{item.title}</strong><p>{item.content}</p><span>{formatAlertTime(item.createdAt)} · {item.read ? '已读' : '未读'}</span></div>{editable && !item.read ? <button onClick={() => read.mutate([item.id])}>标为已读</button> : null}</article>)}</div><footer><b>外部通知通道</b><span>短信(SMS)— 预留 / 未启用</span><span>邮件(Email)— 预留 / 未启用</span><span>企业微信(WeCom)— 预留 / 未启用</span></footer></div>;
|
||||
}
|
||||
|
||||
export default function AlertsPage() {
|
||||
const { session } = usePlatformSession(); const operator = canOperate(session); const admin = canAdminister(session);
|
||||
const [params, setParams] = useSearchParams(); const initialTab = (params.get('tab') as Tab) || 'events'; const [tab, setTabState] = useState<Tab>(['events', 'rules', 'notifications'].includes(initialTab) ? initialTab : 'events');
|
||||
const initialFilters: Filters = { ...EMPTY_FILTERS, keyword: params.get('vin') ?? params.get('keyword') ?? '', severity: params.get('severity') ?? '', status: params.get('status') ?? '', ruleId: params.get('ruleId') ?? '', protocol: params.get('protocol') ?? '' };
|
||||
const [filters, setFilterState] = useState(initialFilters); const [eventDraft, setEventDraft] = useState(initialFilters); const rules = useQuery({ queryKey: ['alert-rules-v2'], queryFn: ({ signal }) => api.alertRulesV2(signal), staleTime: 30_000, gcTime: QUERY_MEMORY.summaryGcTime }); const metrics = useQuery({ queryKey: ['metric-catalog-v2'], queryFn: ({ signal }) => api.metricCatalog(signal), staleTime: 300_000, enabled: admin }); const notices = useQuery({ queryKey: ['alert-notifications-v2', 'unread'], queryFn: ({ signal }) => api.alertNotificationsV2(new URLSearchParams({ unreadOnly: 'true', limit: '100' }), signal), staleTime: 5_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
const activeTab = tab === 'rules' && !admin ? 'events' : tab;
|
||||
const [filters, setFilterState] = useState(initialFilters); const [eventDraft, setEventDraft] = useState(initialFilters);
|
||||
const rules = useQuery({ queryKey: ['alert-rules-v2'], queryFn: ({ signal }) => api.alertRulesV2(signal), staleTime: 30_000, gcTime: QUERY_MEMORY.summaryGcTime, enabled: activeTab !== 'notifications' });
|
||||
const metrics = useQuery({ queryKey: ['metric-catalog-v2'], queryFn: ({ signal }) => api.metricCatalog(signal), staleTime: 300_000, enabled: activeTab === 'rules' });
|
||||
const notices = useQuery({ queryKey: ['alert-notifications-v2', 'unread'], queryFn: ({ signal }) => api.alertNotificationsV2(new URLSearchParams({ unreadOnly: 'true', limit: '100' }), signal), staleTime: 5_000, gcTime: QUERY_MEMORY.summaryGcTime, enabled: activeTab !== 'notifications' });
|
||||
const notifications = useQuery({ queryKey: ['alert-notifications-v2', 'all'], queryFn: ({ signal }) => api.alertNotificationsV2(new URLSearchParams({ limit: '100' }), signal), staleTime: 5_000, gcTime: QUERY_MEMORY.summaryGcTime, enabled: activeTab === 'notifications' });
|
||||
const unread = activeTab === 'notifications'
|
||||
? notifications.data?.items.filter((item) => !item.read).length ?? notices.data?.total ?? 0
|
||||
: notices.data?.total ?? 0;
|
||||
const setTab = (next: Tab) => { setTabState(next); const copy = new URLSearchParams(params); copy.set('tab', next); setParams(copy, { replace: true }); };
|
||||
const setFilters = (next: Filters) => { setFilterState(next); const copy = new URLSearchParams(); if (tab !== 'events') copy.set('tab', tab); Object.entries(next).forEach(([key, value]) => { if (value) copy.set(key, value); }); setParams(copy, { replace: true }); };
|
||||
const activeTab = tab === 'rules' && !admin ? 'events' : tab;
|
||||
return <div className="v2-alert-page"><header className="v2-alert-heading"><div><h2>告警中心</h2><p>统一监控告警事件,快速发现并处置车辆运行异常,保留数据质量与处置证据。</p></div></header><nav className="v2-alert-tabs"><button className={activeTab === 'events' ? 'is-active' : ''} onClick={() => setTab('events')}><IconAlarm />告警事件</button>{admin ? <button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setTab('rules')}>规则配置</button> : null}<button className={activeTab === 'notifications' ? 'is-active' : ''} onClick={() => setTab('notifications')}><IconBell />站内通知{(notices.data?.total ?? 0) > 0 ? <b>{notices.data?.total}</b> : null}</button></nav>{rules.isError ? <InlineError message={rules.error.message} onRetry={() => rules.refetch()} /> : null}{activeTab === 'rules' && metrics.isError ? <InlineError message={metrics.error.message} onRetry={() => metrics.refetch()} /> : null}{activeTab === 'events' ? <EventWorkspace filters={filters} draft={eventDraft} setDraft={setEventDraft} setFilters={setFilters} rules={rules.data ?? []} unread={notices.data?.total ?? 0} editable={operator} onTab={setTab} /> : activeTab === 'rules' ? <RulesWorkspace rules={rules.data ?? []} metrics={metrics.data?.metrics ?? []} /> : <NotificationsWorkspace editable={operator} />}</div>;
|
||||
return <div className="v2-alert-page"><header className="v2-alert-heading"><div><h2>告警中心</h2><p>统一监控告警事件,快速发现并处置车辆运行异常,保留数据质量与处置证据。</p></div></header><nav className="v2-alert-tabs"><button className={activeTab === 'events' ? 'is-active' : ''} onClick={() => setTab('events')}><IconAlarm />告警事件</button>{admin ? <button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setTab('rules')}>规则配置</button> : null}<button className={activeTab === 'notifications' ? 'is-active' : ''} onClick={() => setTab('notifications')}><IconBell />站内通知{unread > 0 ? <b>{unread}</b> : null}</button></nav>{activeTab !== 'notifications' && rules.isError ? <InlineError message={rules.error.message} onRetry={() => rules.refetch()} /> : null}{activeTab === 'rules' && metrics.isError ? <InlineError message={metrics.error.message} onRetry={() => metrics.refetch()} /> : null}{activeTab === 'events' ? <EventWorkspace filters={filters} draft={eventDraft} setDraft={setEventDraft} setFilters={setFilters} rules={rules.data ?? []} unread={unread} editable={operator} onTab={setTab} /> : activeTab === 'rules' ? <RulesWorkspace rules={rules.data ?? []} metrics={metrics.data?.metrics ?? []} /> : <NotificationsWorkspace editable={operator} notifications={notifications} />}</div>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user