refine Semi UI alert workspace
This commit is contained in:
@@ -71,10 +71,10 @@ test('loads only event dependencies on the default alert tab', async () => {
|
||||
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');
|
||||
expect(mocks.alertNotificationsV2.mock.calls[0][0].toString()).toBe('unreadOnly=true&limit=1');
|
||||
});
|
||||
|
||||
test('loads one full notification query on a direct notifications entry', async () => {
|
||||
test('loads a paginated notification page and an independent unread total on direct entry', async () => {
|
||||
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
@@ -84,12 +84,17 @@ test('loads one full notification query on a direct notifications entry', async
|
||||
expect(view.container.querySelector('.v2-alert-notifications')).toHaveClass('semi-card');
|
||||
expect(screen.getByRole('heading', { level: 5, name: '站内通知' })).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-notification-empty.semi-empty')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-notification-pagination')).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox', { name: '每页通知数' })).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-channel-card.semi-card')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-channel-descriptions.semi-descriptions')).toBeInTheDocument();
|
||||
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');
|
||||
expect(mocks.alertNotificationsV2).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.alertNotificationsV2.mock.calls.map(([params]) => params.toString())).toEqual(expect.arrayContaining([
|
||||
'unreadOnly=true&limit=1',
|
||||
'limit=20&offset=0'
|
||||
]));
|
||||
});
|
||||
|
||||
test('ends notification mutation feedback with a visible retryable error instead of a silent pending state', async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { AlertEvent, AlertNotification, AlertQuery, AlertRule, AlertRuleInput, AlertStatus, MetricDefinition, Page } from '../../api/types';
|
||||
import type { AlertEvent, AlertQuery, AlertRule, AlertRuleInput, AlertStatus, MetricDefinition, Page } from '../../api/types';
|
||||
import { actionLabels, alertValue, canAct, formatAlertTime, operatorLabels, ruleCondition, severityLabels, statusLabels, thresholdText } from '../domain/alert';
|
||||
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
|
||||
import { MetricActionButton } from '../shared/MetricActionButton';
|
||||
@@ -373,23 +373,30 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri
|
||||
</div>;
|
||||
}
|
||||
|
||||
type NotificationsQuery = {
|
||||
data?: Page<AlertNotification>;
|
||||
error: Error | null;
|
||||
isError: boolean;
|
||||
isPending: boolean;
|
||||
refetch: () => unknown;
|
||||
};
|
||||
|
||||
function NotificationsWorkspace({ editable, notifications }: { editable: boolean; notifications: NotificationsQuery }) {
|
||||
function NotificationsWorkspace({ editable }: { editable: boolean }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const notifications = useQuery({
|
||||
queryKey: ['alert-notifications-v2', 'all', limit, offset],
|
||||
queryFn: ({ signal }) => api.alertNotificationsV2(new URLSearchParams({ limit: String(limit), offset: String(offset) }), signal),
|
||||
staleTime: 5_000,
|
||||
gcTime: QUERY_MEMORY.highVolumeGcTime
|
||||
});
|
||||
const read = useMutation({ mutationFn: api.readAlertNotificationsV2, onSuccess: async () => { await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] })]); } });
|
||||
const items = notifications.data?.items ?? [];
|
||||
return <Card className="v2-alert-notifications" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="站内通知" description="仅站内通道具备真实送达与已读状态" meta={`${items.length.toLocaleString('zh-CN')} 条`} actions={editable ? <Button theme="light" disabled={read.isPending || !items.some((item) => !item.read)} onClick={() => read.mutate(items.filter((item) => !item.read).map((item) => item.id))}>{read.isPending ? '正在更新' : '全部标为已读'}</Button> : <span className="v2-role-badge">只读</span>} />{notifications.isError ? <InlineError message={notifications.error?.message ?? '站内通知读取失败'} onRetry={() => notifications.refetch()} /> : null}{read.isError ? <InlineError message={read.error instanceof Error ? read.error.message : '通知状态更新失败'} /> : null}
|
||||
const total = notifications.data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
const page = Math.floor(offset / limit) + 1;
|
||||
useEffect(() => {
|
||||
if (offset > 0 && offset >= total && !notifications.isPending) setOffset(Math.max(0, (totalPages - 1) * limit));
|
||||
}, [limit, notifications.isPending, offset, total, totalPages]);
|
||||
return <Card className="v2-alert-notifications" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="站内通知" description="仅站内通道具备真实送达与已读状态" meta={`共 ${total.toLocaleString('zh-CN')} 条`} actions={editable ? <Button theme="light" disabled={read.isPending || !items.some((item) => !item.read)} onClick={() => read.mutate(items.filter((item) => !item.read).map((item) => item.id))}>{read.isPending ? '正在更新' : '本页全部已读'}</Button> : <span className="v2-role-badge">只读</span>} />{notifications.isError ? <InlineError message={notifications.error?.message ?? '站内通知读取失败'} onRetry={() => notifications.refetch()} /> : null}{read.isError ? <InlineError message={read.error instanceof Error ? read.error.message : '通知状态更新失败'} /> : null}
|
||||
<div className="v2-alert-notification-list">{notifications.isPending ? <div className="v2-alert-notification-loading"><Spin size="middle" tip="正在读取站内通知" /></div> : items.length ? <CardGroup className="v2-alert-notification-cards" type="grid" spacing={0}>{items.map((item) => <Card className={`v2-alert-notification-card${item.read ? ' is-read' : ' is-unread'}`} key={item.id} title={<span className="v2-alert-notification-title"><SeverityTag severity={item.severity} /><span title={item.title}>{item.title}</span></span>} headerExtraContent={<Tag color={item.read ? 'grey' : 'orange'} type="light" size="small">{item.read ? '已读' : '未读'}</Tag>} headerLine bodyStyle={{ padding: 0 }}>
|
||||
<p className="v2-alert-notification-content" title={item.content}>{item.content}</p>
|
||||
<footer><Typography.Text type="tertiary">{formatAlertTime(item.createdAt)}</Typography.Text>{editable && !item.read ? <Button theme="borderless" disabled={read.isPending} onClick={() => read.mutate([item.id])}>{read.isPending ? '更新中' : '标为已读'}</Button> : null}</footer>
|
||||
</Card>)}</CardGroup> : !notifications.isError ? <Empty className="v2-alert-notification-empty" image={<IconBell />} title="暂无站内通知" description="告警触发或状态变更后,通知会在这里形成可追溯记录。" /> : null}</div>
|
||||
<footer className="v2-alert-notification-pagination"><TablePagination page={page} totalPages={totalPages} info={`共 ${total.toLocaleString('zh-CN')} 条 · 本页 ${items.length.toLocaleString('zh-CN')} 条`} disabled={notifications.isFetching} onPageChange={(next) => setOffset((next - 1) * limit)} pageSize={limit} pageSizeLabel="每页通知数" onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={[{ value: 20, label: '20 条/页' }, { value: 50, label: '50 条/页' }]} /></footer>
|
||||
<Card className="v2-alert-channel-card" title={<strong>外部通知通道</strong>} headerLine bodyStyle={{ padding: 0 }}><Descriptions className="v2-alert-channel-descriptions" align="left" size="small" data={[
|
||||
{ key: '短信(SMS)', value: <Tag color="grey" type="light" size="small">预留 · 未启用</Tag> },
|
||||
{ key: '邮件(Email)', value: <Tag color="grey" type="light" size="small">预留 · 未启用</Tag> },
|
||||
@@ -406,13 +413,10 @@ export default function AlertsPage() {
|
||||
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 notices = useQuery({ queryKey: ['alert-notifications-v2', 'unread'], queryFn: ({ signal }) => api.alertNotificationsV2(new URLSearchParams({ unreadOnly: 'true', limit: '1' }), signal), staleTime: 5_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
const unread = 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 tabs = [{ key: 'events' as const, label: '告警事件', icon: <IconAlarm /> }, ...(admin ? [{ key: 'rules' as const, label: '规则配置' }] : []), { key: 'notifications' as const, label: '站内通知', icon: <IconBell />, count: unread || undefined }];
|
||||
return <div className={`v2-alert-page is-${activeTab}`}><div className="v2-alert-navigation"><SegmentedTabs className="v2-alert-tabs" variant="filled" ariaLabel="告警中心分类" value={activeTab} items={tabs} onChange={setTab} /><div className="v2-alert-navigation-meta"><Typography.Text type="tertiary">{operator ? '可处置事件' : '只读查看'}</Typography.Text><Tag color={unread ? 'orange' : 'green'} type="light" size="small">{unread ? `${unread.toLocaleString('zh-CN')} 条未读` : '通知已读'}</Tag></div></div>{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>;
|
||||
return <div className={`v2-alert-page is-${activeTab}`}><div className="v2-alert-navigation"><SegmentedTabs className="v2-alert-tabs" variant="filled" ariaLabel="告警中心分类" value={activeTab} items={tabs} onChange={setTab} /><div className="v2-alert-navigation-meta"><Typography.Text type="tertiary">{operator ? '可处置事件' : '只读查看'}</Typography.Text><Tag color={unread ? 'orange' : 'green'} type="light" size="small">{unread ? `${unread.toLocaleString('zh-CN')} 条未读` : '通知已读'}</Tag></div></div>{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} />}</div>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user