fix: harden notification polling and simplify pagination controls
This commit is contained in:
@@ -1,11 +1,28 @@
|
||||
import { request, withQuery } from '../core/httpClient';
|
||||
import type { InfrastructureAlertSettings, InfrastructureAlertThresholds, InfrastructureMonitoringOverview, InfrastructureMonitoringRange } from '../types';
|
||||
import type {
|
||||
InfrastructureAlertSettings,
|
||||
InfrastructureAlertThresholds,
|
||||
InfrastructureMonitoringOverview,
|
||||
InfrastructureMonitoringRange,
|
||||
} from '../types';
|
||||
|
||||
export const adminInfrastructureMonitoringApi = {
|
||||
getInfrastructureMonitoringOverview: (range: InfrastructureMonitoringRange) =>
|
||||
request<InfrastructureMonitoringOverview>(withQuery('/admin/infrastructure-monitoring/overview', { range })),
|
||||
getInfrastructureMonitoringNotificationSummary: () => request<{ count: number; criticalCount: number }>('/admin/infrastructure-monitoring/notification-summary'),
|
||||
getInfrastructureAlertThresholds: () => request<InfrastructureAlertSettings>('/admin/infrastructure-monitoring/alert-thresholds'),
|
||||
updateInfrastructureAlertThresholds: (body: { configVersion: number; thresholds: InfrastructureAlertThresholds }) => request<InfrastructureAlertSettings>('/admin/infrastructure-monitoring/alert-thresholds', { method: 'PUT', body: JSON.stringify(body) }),
|
||||
markInfrastructureAlertRead: (fingerprint: string, activeAt: string) => request<{ fingerprint: string; activeAt: string; acknowledged: true; acknowledgedAt: string }>(`/admin/infrastructure-monitoring/alerts/${fingerprint}/read`, { method: 'POST', body: JSON.stringify({ activeAt }) }),
|
||||
getInfrastructureMonitoringNotificationSummary: (signal?: AbortSignal) =>
|
||||
request<{ count: number; criticalCount: number }>('/admin/infrastructure-monitoring/notification-summary', {
|
||||
signal,
|
||||
}),
|
||||
getInfrastructureAlertThresholds: () =>
|
||||
request<InfrastructureAlertSettings>('/admin/infrastructure-monitoring/alert-thresholds'),
|
||||
updateInfrastructureAlertThresholds: (body: { configVersion: number; thresholds: InfrastructureAlertThresholds }) =>
|
||||
request<InfrastructureAlertSettings>('/admin/infrastructure-monitoring/alert-thresholds', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
markInfrastructureAlertRead: (fingerprint: string, activeAt: string) =>
|
||||
request<{ fingerprint: string; activeAt: string; acknowledged: true; acknowledgedAt: string }>(
|
||||
`/admin/infrastructure-monitoring/alerts/${fingerprint}/read`,
|
||||
{ method: 'POST', body: JSON.stringify({ activeAt }) },
|
||||
),
|
||||
};
|
||||
|
||||
@@ -39,8 +39,8 @@ import type {
|
||||
export const adminOperationsApi = {
|
||||
getDashboard: (tenantId?: string) =>
|
||||
request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
||||
getPendingAudits: (tenantId?: string) =>
|
||||
request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId })),
|
||||
getPendingAudits: (tenantId?: string, signal?: AbortSignal) =>
|
||||
request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId }), { signal }),
|
||||
getSendQuality: (date?: string) =>
|
||||
request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })),
|
||||
getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||
|
||||
@@ -2,15 +2,35 @@ import { request, withQuery } from '../core/httpClient';
|
||||
import type { SecurityAlert, SecurityBlock, SecurityOverview, SecurityProtectedNetwork, SecurityRule } from '../types';
|
||||
|
||||
export const adminSecurityDetectionApi = {
|
||||
getSecurityOverview: (range = '24h') => request<SecurityOverview>(withQuery('/admin/security-detection/overview', { range })),
|
||||
getSecurityNotificationSummary: () => request<{ count: number; criticalCount: number }>('/admin/security-detection/notification-summary'),
|
||||
listSecurityAlerts: (query: Record<string, string | number | undefined> = {}) => request<{ items: SecurityAlert[]; total: number }> (withQuery('/admin/security-detection/alerts', query)),
|
||||
getSecurityOverview: (range = '24h') =>
|
||||
request<SecurityOverview>(withQuery('/admin/security-detection/overview', { range })),
|
||||
getSecurityNotificationSummary: (signal?: AbortSignal) =>
|
||||
request<{ count: number; criticalCount: number }>('/admin/security-detection/notification-summary', { signal }),
|
||||
listSecurityAlerts: (query: Record<string, string | number | undefined> = {}) =>
|
||||
request<{ items: SecurityAlert[]; total: number }>(withQuery('/admin/security-detection/alerts', query)),
|
||||
listSecurityRules: () => request<SecurityRule[]>('/admin/security-detection/rules'),
|
||||
updateSecurityRule: (id: string, body: Partial<SecurityRule>) => request<SecurityRule>(`/admin/security-detection/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
blockSecurityAlert: (id: string, body: { durationSeconds: number; reason: string }) => request<SecurityBlock>(`/admin/security-detection/alerts/${id}/block`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
ignoreSecurityAlert: (id: string, reason: string) => request<{ success: boolean }>(`/admin/security-detection/alerts/${id}/ignore`, { method: 'POST', body: JSON.stringify({ reason }) }),
|
||||
updateSecurityRule: (id: string, body: Partial<SecurityRule>) =>
|
||||
request<SecurityRule>(`/admin/security-detection/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
blockSecurityAlert: (id: string, body: { durationSeconds: number; reason: string }) =>
|
||||
request<SecurityBlock>(`/admin/security-detection/alerts/${id}/block`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
ignoreSecurityAlert: (id: string, reason: string) =>
|
||||
request<{ success: boolean }>(`/admin/security-detection/alerts/${id}/ignore`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason }),
|
||||
}),
|
||||
listSecurityBlocks: () => request<SecurityBlock[]>('/admin/security-detection/blocks'),
|
||||
unblockSecurityBlock: (id: string, reason: string) => request<SecurityBlock>(`/admin/security-detection/blocks/${id}/unblock`, { method: 'POST', body: JSON.stringify({ reason }) }),
|
||||
unblockSecurityBlock: (id: string, reason: string) =>
|
||||
request<SecurityBlock>(`/admin/security-detection/blocks/${id}/unblock`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason }),
|
||||
}),
|
||||
listProtectedNetworks: () => request<SecurityProtectedNetwork[]>('/admin/security-detection/protected-networks'),
|
||||
addProtectedNetwork: (body: { network: string; name: string; reason: string }) => request<SecurityProtectedNetwork>('/admin/security-detection/protected-networks', { method: 'POST', body: JSON.stringify(body) }),
|
||||
addProtectedNetwork: (body: { network: string; name: string; reason: string }) =>
|
||||
request<SecurityProtectedNetwork>('/admin/security-detection/protected-networks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -12,26 +12,75 @@ import type {
|
||||
} from '../types';
|
||||
|
||||
export const adminSignatureRetirementApi = {
|
||||
getSignatureRetirementConfiguration: () => request<{ rules: SignatureRetirementRule[]; webhooks: SignatureRetirementWebhook[] }>('/admin/signature-retirement/configuration'),
|
||||
getSignatureRetirementConfiguration: () =>
|
||||
request<{ rules: SignatureRetirementRule[]; webhooks: SignatureRetirementWebhook[] }>(
|
||||
'/admin/signature-retirement/configuration',
|
||||
),
|
||||
saveSignatureRetirementRule: (body: {
|
||||
ruleType: SignatureRetirementRuleType; targetId?: string; enabled: boolean;
|
||||
mobileWindowDays: number; mobileThreshold: number; unicomWindowDays: number; unicomThreshold: number;
|
||||
telecomWindowDays: number; telecomThreshold: number; messageTemplate?: string;
|
||||
}) => request<SignatureRetirementRule>('/admin/signature-retirement/rules', { method: 'PUT', body: JSON.stringify(body) }),
|
||||
ruleType: SignatureRetirementRuleType;
|
||||
targetId?: string;
|
||||
enabled: boolean;
|
||||
mobileWindowDays: number;
|
||||
mobileThreshold: number;
|
||||
unicomWindowDays: number;
|
||||
unicomThreshold: number;
|
||||
telecomWindowDays: number;
|
||||
telecomThreshold: number;
|
||||
messageTemplate?: string;
|
||||
}) =>
|
||||
request<SignatureRetirementRule>('/admin/signature-retirement/rules', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
createSignatureRetirementWebhook: (body: { name: string; platform: 'wecom' | 'feishu'; url: string }) =>
|
||||
request<SignatureRetirementWebhook>('/admin/signature-retirement/webhooks', { method: 'POST', body: JSON.stringify(body) }),
|
||||
deleteSignatureRetirementWebhook: (id: string) => request<SignatureRetirementWebhook>(`/admin/signature-retirement/webhooks/${id}`, { method: 'DELETE' }),
|
||||
listSignatureRetirementMessages: (query: { dateFrom?: string; dateTo?: string; dimensionType?: string; tenantId?: string; applicationId?: string; signatureKeyword?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResult<SignatureRetirementMessage>>(withQuery('/admin/signature-retirement/messages', query)),
|
||||
getSignatureRetirementUnreadCount: () => request<{ count: number }>('/admin/signature-retirement/unread-count'),
|
||||
readSignatureRetirementMessage: (id: string) => request<SignatureRetirementMessage>(`/admin/signature-retirement/messages/${id}/read`, { method: 'POST' }),
|
||||
readAllSignatureRetirementMessagesToday: () => request<{ count: number }>('/admin/signature-retirement/messages/read-all-today', { method: 'POST' }),
|
||||
suppressSignatureRetirementMessage: (id: string, body: { mode: 'temporary' | 'permanent'; days?: number; reason?: string }) =>
|
||||
request<SignatureRetirementSuppression>(`/admin/signature-retirement/messages/${id}/suppress`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
listSignatureRetirementSuppressions: () => request<SignatureRetirementSuppression[]>('/admin/signature-retirement/suppressions'),
|
||||
request<SignatureRetirementWebhook>('/admin/signature-retirement/webhooks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
deleteSignatureRetirementWebhook: (id: string) =>
|
||||
request<SignatureRetirementWebhook>(`/admin/signature-retirement/webhooks/${id}`, { method: 'DELETE' }),
|
||||
listSignatureRetirementMessages: (
|
||||
query: {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
dimensionType?: string;
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
signatureKeyword?: string;
|
||||
channelId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
) => request<PagedResult<SignatureRetirementMessage>>(withQuery('/admin/signature-retirement/messages', query)),
|
||||
getSignatureRetirementUnreadCount: (signal?: AbortSignal) =>
|
||||
request<{ count: number }>('/admin/signature-retirement/unread-count', { signal }),
|
||||
readSignatureRetirementMessage: (id: string) =>
|
||||
request<SignatureRetirementMessage>(`/admin/signature-retirement/messages/${id}/read`, { method: 'POST' }),
|
||||
readAllSignatureRetirementMessagesToday: () =>
|
||||
request<{ count: number }>('/admin/signature-retirement/messages/read-all-today', { method: 'POST' }),
|
||||
suppressSignatureRetirementMessage: (
|
||||
id: string,
|
||||
body: { mode: 'temporary' | 'permanent'; days?: number; reason?: string },
|
||||
) =>
|
||||
request<SignatureRetirementSuppression>(`/admin/signature-retirement/messages/${id}/suppress`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
listSignatureRetirementSuppressions: () =>
|
||||
request<SignatureRetirementSuppression[]>('/admin/signature-retirement/suppressions'),
|
||||
cancelSignatureRetirementSuppression: (id: string, reason: string) =>
|
||||
request<SignatureRetirementSuppression>(`/admin/signature-retirement/suppressions/${id}/cancel`, { method: 'POST', body: JSON.stringify({ reason }) }),
|
||||
getSignatureRetirementHeatmap: (date?: string) => request<{ date: string; dimensions: SignatureRetirementHeatmapDimension[]; items: SignatureRetirementHeatmapItem[] }>(withQuery('/admin/signature-retirement/heatmap', { date })),
|
||||
request<SignatureRetirementSuppression>(`/admin/signature-retirement/suppressions/${id}/cancel`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason }),
|
||||
}),
|
||||
getSignatureRetirementHeatmap: (date?: string) =>
|
||||
request<{
|
||||
date: string;
|
||||
dimensions: SignatureRetirementHeatmapDimension[];
|
||||
items: SignatureRetirementHeatmapItem[];
|
||||
}>(withQuery('/admin/signature-retirement/heatmap', { date })),
|
||||
getUnreportedSignatures: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResult<UnreportedSignatureItem> & { date: string }>(withQuery('/admin/signature-retirement/unreported-signatures', query)),
|
||||
request<PagedResult<UnreportedSignatureItem> & { date: string }>(
|
||||
withQuery('/admin/signature-retirement/unreported-signatures', query),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -34,12 +34,47 @@ describe('Pagination compatibility', () => {
|
||||
|
||||
it('derives total pages from the opted-in size when totalPages is omitted', () => {
|
||||
const changePage = vi.fn();
|
||||
render(<Pagination total={60} pageSize={25} onPageSizeChange={vi.fn()} onPageChange={changePage} />);
|
||||
const changePageSize = vi.fn();
|
||||
render(<Pagination total={60} pageSize={25} onPageSizeChange={changePageSize} onPageChange={changePage} />);
|
||||
|
||||
expect(screen.getByLabelText(/^每页数量/)).toHaveTextContent('25 条/页');
|
||||
expect(screen.getByLabelText(/^每页数量/)).toHaveAccessibleName(/^每页数量/);
|
||||
expect(screen.getByText('每页数量')).toHaveClass('sr-only');
|
||||
expect(screen.getByLabelText('跳转页码')).toHaveAttribute('max', '3');
|
||||
fireEvent.click(screen.getByRole('button', { name: '末页' }));
|
||||
expect(changePage).toHaveBeenLastCalledWith(3);
|
||||
for (const size of [10, 25, 50, 100]) {
|
||||
fireEvent.click(screen.getByLabelText(/^每页数量/));
|
||||
expect(screen.getAllByRole('option').map((option) => option.textContent)).toEqual([
|
||||
'10 条/页',
|
||||
'25 条/页',
|
||||
'50 条/页',
|
||||
'100 条/页',
|
||||
]);
|
||||
fireEvent.click(screen.getByRole('option', { name: `${size} 条/页` }));
|
||||
expect(changePageSize).toHaveBeenLastCalledWith(size);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps hidden page-size labels uniquely associated across multiple paginations', () => {
|
||||
const firstChange = vi.fn();
|
||||
const secondChange = vi.fn();
|
||||
render(
|
||||
<>
|
||||
<Pagination pageSize={25} onPageSizeChange={firstChange} />
|
||||
<Pagination pageSize={50} onPageSizeChange={secondChange} />
|
||||
</>,
|
||||
);
|
||||
|
||||
const controls = screen.getAllByLabelText(/^每页数量/);
|
||||
expect(controls).toHaveLength(2);
|
||||
expect(controls[0]).toHaveAccessibleName(/^每页数量/);
|
||||
expect(controls[1]).toHaveAccessibleName(/^每页数量/);
|
||||
expect(controls[0].id).not.toBe(controls[1].id);
|
||||
fireEvent.click(controls[1]);
|
||||
fireEvent.click(screen.getByRole('option', { name: '100 条/页' }));
|
||||
expect(secondChange).toHaveBeenCalledWith(100);
|
||||
expect(firstChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets only the jump draft when the page changes and does not restore an older draft', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useId, useState, type ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
|
||||
@@ -80,6 +80,7 @@ export function Pagination({
|
||||
pageSizeOptions = [10, 25, 50, 100],
|
||||
onPageSizeChange,
|
||||
}: PaginationProps) {
|
||||
const pageSizeId = useId();
|
||||
const pages = Math.max(1, totalPages ?? (typeof total === 'number' ? Math.ceil(total / (pageSize ?? 10)) : page));
|
||||
|
||||
function changePage(nextPage: number) {
|
||||
@@ -91,12 +92,17 @@ export function Pagination({
|
||||
{typeof total === 'number' ? <span>显示 {total} 条记录</span> : <span />}
|
||||
<div>
|
||||
{typeof pageSize === 'number' && onPageSizeChange ? (
|
||||
<Select
|
||||
label="每页数量"
|
||||
value={String(pageSize)}
|
||||
options={pageSizeOptions.map((value) => ({ value: String(value), label: `${value} 条/页` }))}
|
||||
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
||||
/>
|
||||
<>
|
||||
<label className="sr-only" htmlFor={pageSizeId}>
|
||||
每页数量
|
||||
</label>
|
||||
<Select
|
||||
id={pageSizeId}
|
||||
value={String(pageSize)}
|
||||
options={pageSizeOptions.map((value) => ({ value: String(value), label: `${value} 条/页` }))}
|
||||
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Button disabled={previousDisabled} onClick={onPrevious} size="sm" variant="ghost">
|
||||
上一页
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { readSession, writeSession } from '@/api/session';
|
||||
import { AdminLayout } from './AdminLayout';
|
||||
import { AppShell } from './AppShell';
|
||||
|
||||
@@ -11,9 +12,11 @@ const api = vi.hoisted(() => ({
|
||||
getSecurityNotificationSummary: vi.fn(),
|
||||
getInfrastructureMonitoringNotificationSummary: vi.fn(),
|
||||
current: vi.fn(),
|
||||
request: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/adminApi', () => ({ adminApi: api, portalSessionApi: { current: api.current } }));
|
||||
vi.mock('@/api/core/httpClient', () => ({ request: api.request }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -37,15 +40,18 @@ beforeEach(() => {
|
||||
api.getSignatureRetirementUnreadCount.mockResolvedValue({ count: 7 });
|
||||
api.getSecurityNotificationSummary.mockResolvedValue({ count: 2, criticalCount: 1 });
|
||||
api.getInfrastructureMonitoringNotificationSummary.mockResolvedValue({ count: 5, criticalCount: 0 });
|
||||
api.request.mockResolvedValue({ count: 0 });
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <p data-testid="route">{location.pathname + location.search}</p>;
|
||||
}
|
||||
|
||||
async function renderAdmin() {
|
||||
render(
|
||||
function renderAdminShell() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/admin']}>
|
||||
<Routes>
|
||||
<Route element={<AdminLayout />}>
|
||||
@@ -54,6 +60,10 @@ async function renderAdmin() {
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
async function renderAdmin() {
|
||||
renderAdminShell();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '报备任务提醒' })).toHaveTextContent('7'), {
|
||||
timeout: 5000,
|
||||
});
|
||||
@@ -156,3 +166,164 @@ it('does not add notification controls to a client shell without notification it
|
||||
expect(screen.queryByRole('button', { name: '报备任务提醒' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '预警通知' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const advancePolling = (ms: number) => act(() => vi.advanceTimersByTimeAsync(ms));
|
||||
|
||||
function openNotificationMenu(button: string) {
|
||||
fireEvent.click(screen.getByRole('button', { name: button }));
|
||||
}
|
||||
|
||||
it('retains all six known counts on failure and clears unavailable descriptions after a successful retry', async () => {
|
||||
vi.useFakeTimers();
|
||||
api.request.mockImplementation((path: string) =>
|
||||
Promise.resolve({ count: path.includes('sending-monitor') ? 13 : 11 }),
|
||||
);
|
||||
const { unmount } = renderAdminShell();
|
||||
await act(() => Promise.resolve());
|
||||
await advancePolling(0);
|
||||
expect(screen.getByRole('button', { name: '报备任务提醒' })).toHaveTextContent('18');
|
||||
expect(screen.getByRole('button', { name: '预警通知' })).toHaveTextContent('20');
|
||||
expect(screen.getByRole('button', { name: '通知' })).toHaveTextContent('4');
|
||||
|
||||
for (const endpoint of [
|
||||
api.getPendingAudits,
|
||||
api.getSignatureRetirementUnreadCount,
|
||||
api.getSecurityNotificationSummary,
|
||||
api.getInfrastructureMonitoringNotificationSummary,
|
||||
api.request,
|
||||
]) {
|
||||
endpoint.mockRejectedValue(new Error('network unavailable'));
|
||||
}
|
||||
await act(async () => fireEvent(window, new Event('cmpp-retirement-count-refresh')));
|
||||
openNotificationMenu('报备任务提醒');
|
||||
const reporting = screen.getByRole('menu', { name: '报备任务提醒' });
|
||||
expect(within(reporting).getAllByText('计数暂不可用,显示上次结果')).toHaveLength(2);
|
||||
expect(
|
||||
within(reporting)
|
||||
.getByRole('menuitem', { name: /签名清退预警/ })
|
||||
.querySelector('strong'),
|
||||
).toHaveTextContent('7');
|
||||
expect(
|
||||
within(reporting)
|
||||
.getByRole('menuitem', { name: /报备状态变化通知/ })
|
||||
.querySelector('strong'),
|
||||
).toHaveTextContent('11');
|
||||
openNotificationMenu('预警通知');
|
||||
const alerts = screen.getByRole('menu', { name: '预警中心' });
|
||||
expect(within(alerts).getAllByText('计数暂不可用,显示上次结果')).toHaveLength(3);
|
||||
for (const [label, count] of [
|
||||
['发送质量告警', '13'],
|
||||
['安全检测与封禁', '2'],
|
||||
['系统监控告警', '5'],
|
||||
]) {
|
||||
expect(
|
||||
within(alerts)
|
||||
.getByRole('menuitem', { name: new RegExp(label) })
|
||||
.querySelector('strong'),
|
||||
).toHaveTextContent(count);
|
||||
}
|
||||
openNotificationMenu('通知');
|
||||
expect(screen.getAllByRole('menuitem', { name: /计数暂不可用/ })).toHaveLength(6);
|
||||
expect(screen.getByRole('button', { name: '通知' })).toHaveTextContent('4');
|
||||
expect(screen.getByRole('menuitem', { name: /签名导入待审/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/admin/signatures?tab=import',
|
||||
);
|
||||
|
||||
api.getPendingAudits.mockResolvedValue({
|
||||
enterpriseCertifications: 0,
|
||||
smsAudits: 0,
|
||||
templates: 0,
|
||||
signatures: 0,
|
||||
signatureImports: 2,
|
||||
drainageInfos: 0,
|
||||
total: 2,
|
||||
});
|
||||
api.getSignatureRetirementUnreadCount.mockResolvedValue({ count: 3 });
|
||||
api.getSecurityNotificationSummary.mockResolvedValue({ count: 1, criticalCount: 0 });
|
||||
api.getInfrastructureMonitoringNotificationSummary.mockResolvedValue({ count: 0, criticalCount: 0 });
|
||||
api.request.mockResolvedValue({ count: 0 });
|
||||
await advancePolling(30000);
|
||||
expect(screen.queryByRole('menuitem', { name: /计数暂不可用/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '通知' })).toHaveTextContent('2');
|
||||
expect(screen.getByRole('button', { name: '报备任务提醒' })).toHaveTextContent('3');
|
||||
expect(screen.getByRole('button', { name: '预警通知' })).toHaveTextContent('1');
|
||||
openNotificationMenu('报备任务提醒');
|
||||
expect(screen.getByText('今日未读且未抑制')).toBeVisible();
|
||||
expect(screen.queryByText(/计数暂不可用/)).not.toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('marks unknown counts as loading then unavailable, while retaining successful domains at timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
let resolveStalled!: (value: { count: number }) => void;
|
||||
api.getSignatureRetirementUnreadCount.mockImplementation(
|
||||
() =>
|
||||
new Promise<{ count: number }>((resolve) => {
|
||||
resolveStalled = resolve;
|
||||
}),
|
||||
);
|
||||
const { unmount } = renderAdminShell();
|
||||
await act(() => Promise.resolve());
|
||||
await advancePolling(0);
|
||||
openNotificationMenu('报备任务提醒');
|
||||
expect(screen.getAllByText('计数加载中')).toHaveLength(2);
|
||||
openNotificationMenu('通知');
|
||||
expect(screen.getAllByRole('menuitem', { name: /计数加载中/ })).toHaveLength(6);
|
||||
const signal = api.getSignatureRetirementUnreadCount.mock.calls[0][0] as AbortSignal;
|
||||
expect(api.getPendingAudits.mock.calls[0]).toEqual([undefined, signal]);
|
||||
expect(api.getSecurityNotificationSummary.mock.calls[0]).toEqual([signal]);
|
||||
expect(api.getInfrastructureMonitoringNotificationSummary.mock.calls[0]).toEqual([signal]);
|
||||
expect(api.request.mock.calls.slice(0, 2).every(([, options]) => options.signal === signal)).toBe(true);
|
||||
await advancePolling(15000);
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(screen.getByRole('button', { name: '通知' })).toHaveTextContent('4');
|
||||
expect(screen.getByRole('button', { name: '预警通知' })).toHaveTextContent('7');
|
||||
openNotificationMenu('报备任务提醒');
|
||||
const retirement = screen.getByRole('menuitem', { name: /签名清退预警/ });
|
||||
expect(retirement).toHaveTextContent('计数暂不可用,尚未取得结果');
|
||||
expect(screen.getByText('按企业与小时汇总的未读消息')).toBeVisible();
|
||||
await act(async () => resolveStalled({ count: 999 }));
|
||||
expect(retirement.querySelector('strong')).toHaveTextContent('0');
|
||||
expect(screen.getByRole('button', { name: '报备任务提醒' })).not.toHaveTextContent('999');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('keeps the last monitor count when its successful HTTP response says unavailable', async () => {
|
||||
vi.useFakeTimers();
|
||||
api.request.mockResolvedValue({ count: 8 });
|
||||
const { unmount } = renderAdminShell();
|
||||
await act(() => Promise.resolve());
|
||||
await advancePolling(0);
|
||||
api.request.mockResolvedValue({ count: 0, unavailable: true });
|
||||
await act(async () => fireEvent(window, new Event('cmpp-monitor-alert-refresh')));
|
||||
openNotificationMenu('预警通知');
|
||||
const monitor = screen.getByRole('menuitem', { name: /发送质量告警/ });
|
||||
expect(monitor.querySelector('strong')).toHaveTextContent('8');
|
||||
expect(monitor).toHaveTextContent('计数暂不可用,显示上次结果');
|
||||
expect(screen.getByText('1 条严重告警待处置')).toBeVisible();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not apply or repeat an old account request after the local session identity changes', async () => {
|
||||
vi.useFakeTimers();
|
||||
let resolveStalled!: (value: { count: number }) => void;
|
||||
api.getSignatureRetirementUnreadCount.mockImplementation(
|
||||
() =>
|
||||
new Promise<{ count: number }>((resolve) => {
|
||||
resolveStalled = resolve;
|
||||
}),
|
||||
);
|
||||
const { unmount } = renderAdminShell();
|
||||
await act(() => Promise.resolve());
|
||||
await advancePolling(0);
|
||||
const session = readSession('admin')!;
|
||||
writeSession({ ...session, user: { ...session.user, id: 'another-admin' } });
|
||||
await act(async () => resolveStalled({ count: 999 }));
|
||||
expect(screen.getByRole('button', { name: '报备任务提醒' })).not.toHaveTextContent('999');
|
||||
fireEvent(window, new Event('focus'));
|
||||
fireEvent(window, new Event('cmpp-audit-count-refresh'));
|
||||
await advancePolling(60000);
|
||||
expect(api.getPendingAudits).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
});
|
||||
|
||||
+108
-96
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
@@ -35,6 +35,7 @@ import { getLastUserActivityAt, readSession, type LoginSession } from '@/api/ses
|
||||
import { AppShell } from '@/layouts/AppShell';
|
||||
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
||||
import { request } from '@/api/core/httpClient';
|
||||
import { settleNotificationRequest, useNotificationPolling } from './useNotificationPolling';
|
||||
|
||||
const EMPTY_PENDING_AUDITS: Omit<PendingAuditCounts, 'total'> = {
|
||||
enterpriseCertifications: 0,
|
||||
@@ -44,92 +45,95 @@ const EMPTY_PENDING_AUDITS: Omit<PendingAuditCounts, 'total'> = {
|
||||
drainageInfos: 0,
|
||||
};
|
||||
|
||||
type NotificationValue<T> = { value: T; status: 'loading' | 'ready' | 'unavailable'; hasValue: boolean };
|
||||
|
||||
function initialNotification<T>(value: T): NotificationValue<T> {
|
||||
return { value, status: 'loading', hasValue: false };
|
||||
}
|
||||
|
||||
function updateNotification<T>(
|
||||
previous: NotificationValue<T>,
|
||||
result: PromiseSettledResult<T>,
|
||||
available = true,
|
||||
): NotificationValue<T> {
|
||||
return result.status === 'fulfilled' && available
|
||||
? { value: result.value, status: 'ready', hasValue: true }
|
||||
: { ...previous, status: 'unavailable' };
|
||||
}
|
||||
|
||||
function notificationDescription<T>(notification: NotificationValue<T>, description: string) {
|
||||
if (notification.status === 'loading') return '计数加载中';
|
||||
if (notification.status === 'unavailable') {
|
||||
return notification.hasValue ? '计数暂不可用,显示上次结果' : '计数暂不可用,尚未取得结果';
|
||||
}
|
||||
return description;
|
||||
}
|
||||
|
||||
function canPollNotifications(userId: string) {
|
||||
const session = readSession('admin');
|
||||
return Boolean(
|
||||
session &&
|
||||
session.user.id === userId &&
|
||||
!session.locked &&
|
||||
Date.now() - getLastUserActivityAt() < session.idleTimeoutSeconds * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminLayout() {
|
||||
return (
|
||||
<PortalSessionBoundary portal="admin">
|
||||
{(session) => <AdminAuthenticatedLayout session={session} />}
|
||||
{(session) => <AdminAuthenticatedLayout key={session.user.id} session={session} />}
|
||||
</PortalSessionBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
const [pendingAudits, setPendingAudits] = useState(EMPTY_PENDING_AUDITS);
|
||||
const [retirementUnreadCount, setRetirementUnreadCount] = useState(0);
|
||||
const [reportSummary, setReportSummary] = useState({ count: 0, unavailable: false });
|
||||
const [monitorSummary, setMonitorSummary] = useState({ count: 0, unavailable: false });
|
||||
const [securityAlertSummary, setSecurityAlertSummary] = useState({ count: 0, criticalCount: 0 });
|
||||
const [infrastructureAlertSummary, setInfrastructureAlertSummary] = useState({ count: 0, criticalCount: 0 });
|
||||
const [pendingAudits, setPendingAudits] = useState(() => initialNotification(EMPTY_PENDING_AUDITS));
|
||||
const [retirementSummary, setRetirementSummary] = useState(() => initialNotification({ count: 0 }));
|
||||
const [reportSummary, setReportSummary] = useState(() => initialNotification({ count: 0 }));
|
||||
const [monitorSummary, setMonitorSummary] = useState(() => initialNotification({ count: 0 }));
|
||||
const [securityAlertSummary, setSecurityAlertSummary] = useState(() =>
|
||||
initialNotification({ count: 0, criticalCount: 0 }),
|
||||
);
|
||||
const [infrastructureAlertSummary, setInfrastructureAlertSummary] = useState(() =>
|
||||
initialNotification({ count: 0, criticalCount: 0 }),
|
||||
);
|
||||
const [sessionLocked, setSessionLocked] = useState(Boolean(session.locked));
|
||||
const loadPendingAuditCount = useCallback(() => {
|
||||
const currentSession = readSession('admin');
|
||||
if (
|
||||
!currentSession ||
|
||||
currentSession.locked ||
|
||||
Date.now() - getLastUserActivityAt() >= currentSession.idleTimeoutSeconds * 1000
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const canPoll = useCallback(() => canPollNotifications(session.user.id), [session.user.id]);
|
||||
const loadPendingAuditCount = useCallback(async (signal: AbortSignal, isCurrent: () => boolean) => {
|
||||
// This runs globally and on a timer, so it must not fan out through the full dashboard aggregation.
|
||||
Promise.allSettled([
|
||||
adminApi.getPendingAudits(),
|
||||
adminApi.getSignatureRetirementUnreadCount(),
|
||||
adminApi.getSecurityNotificationSummary(),
|
||||
adminApi.getInfrastructureMonitoringNotificationSummary(),
|
||||
request<{ count: number }>('/admin/report-notifications/summary'),
|
||||
request<{ count: number; unavailable?: boolean }>('/admin/sending-monitor/notification-summary'),
|
||||
])
|
||||
.then(([audits, retirement, security, infrastructure, reporting, monitor]) => {
|
||||
setMonitorSummary((previous) =>
|
||||
monitor.status === 'fulfilled'
|
||||
? { count: monitor.value.count, unavailable: Boolean(monitor.value.unavailable) }
|
||||
: { ...previous, unavailable: true },
|
||||
);
|
||||
setReportSummary((previous) =>
|
||||
reporting.status === 'fulfilled'
|
||||
? { count: reporting.value.count, unavailable: false }
|
||||
: { ...previous, unavailable: true },
|
||||
);
|
||||
setPendingAudits(audits.status === 'fulfilled' ? audits.value : EMPTY_PENDING_AUDITS);
|
||||
setRetirementUnreadCount(retirement.status === 'fulfilled' ? retirement.value.count : 0);
|
||||
setSecurityAlertSummary(security.status === 'fulfilled' ? security.value : { count: 0, criticalCount: 0 });
|
||||
setInfrastructureAlertSummary(
|
||||
infrastructure.status === 'fulfilled' ? infrastructure.value : { count: 0, criticalCount: 0 },
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setPendingAudits(EMPTY_PENDING_AUDITS);
|
||||
setRetirementUnreadCount(0);
|
||||
setSecurityAlertSummary({ count: 0, criticalCount: 0 });
|
||||
setInfrastructureAlertSummary({ count: 0, criticalCount: 0 });
|
||||
});
|
||||
const results = await Promise.allSettled([
|
||||
settleNotificationRequest(adminApi.getPendingAudits(undefined, signal), signal),
|
||||
settleNotificationRequest(adminApi.getSignatureRetirementUnreadCount(signal), signal),
|
||||
settleNotificationRequest(adminApi.getSecurityNotificationSummary(signal), signal),
|
||||
settleNotificationRequest(adminApi.getInfrastructureMonitoringNotificationSummary(signal), signal),
|
||||
settleNotificationRequest(request<{ count: number }>('/admin/report-notifications/summary', { signal }), signal),
|
||||
settleNotificationRequest(
|
||||
request<{ count: number; unavailable?: boolean }>('/admin/sending-monitor/notification-summary', { signal }),
|
||||
signal,
|
||||
),
|
||||
]);
|
||||
if (!isCurrent()) return false;
|
||||
const [audits, retirement, security, infrastructure, reporting, monitor] = results;
|
||||
const monitorAvailable = monitor.status === 'fulfilled' && !monitor.value.unavailable;
|
||||
setPendingAudits((previous) => updateNotification(previous, audits));
|
||||
setRetirementSummary((previous) => updateNotification(previous, retirement));
|
||||
setSecurityAlertSummary((previous) => updateNotification(previous, security));
|
||||
setInfrastructureAlertSummary((previous) => updateNotification(previous, infrastructure));
|
||||
setReportSummary((previous) => updateNotification(previous, reporting));
|
||||
setMonitorSummary((previous) => updateNotification(previous, monitor, monitorAvailable));
|
||||
return results.every((result) => result.status === 'fulfilled') && monitorAvailable;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (session.portal !== 'admin' || sessionLocked) {
|
||||
return;
|
||||
}
|
||||
loadPendingAuditCount();
|
||||
const timer = window.setInterval(loadPendingAuditCount, 30000);
|
||||
const onFocus = () => loadPendingAuditCount();
|
||||
const onAuditRefresh = () => loadPendingAuditCount();
|
||||
window.addEventListener('focus', onFocus);
|
||||
window.addEventListener('cmpp-audit-count-refresh', onAuditRefresh);
|
||||
window.addEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
|
||||
window.addEventListener('cmpp-report-notification-refresh', onAuditRefresh);
|
||||
window.addEventListener('cmpp-monitor-alert-refresh', onAuditRefresh);
|
||||
window.addEventListener('cmpp-security-alert-count-refresh', onAuditRefresh);
|
||||
window.addEventListener('cmpp-infrastructure-alert-count-refresh', onAuditRefresh);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
window.removeEventListener('cmpp-audit-count-refresh', onAuditRefresh);
|
||||
window.removeEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
|
||||
window.removeEventListener('cmpp-report-notification-refresh', onAuditRefresh);
|
||||
window.removeEventListener('cmpp-monitor-alert-refresh', onAuditRefresh);
|
||||
window.removeEventListener('cmpp-security-alert-count-refresh', onAuditRefresh);
|
||||
window.removeEventListener('cmpp-infrastructure-alert-count-refresh', onAuditRefresh);
|
||||
};
|
||||
}, [loadPendingAuditCount, session.portal, sessionLocked]);
|
||||
useNotificationPolling({
|
||||
enabled: session.portal === 'admin' && !sessionLocked,
|
||||
canPoll,
|
||||
poll: loadPendingAuditCount,
|
||||
});
|
||||
const auditLabel = (label: string) =>
|
||||
pendingAudits.status === 'ready'
|
||||
? label
|
||||
: `${label}(${pendingAudits.status === 'loading' ? '计数加载中' : '计数暂不可用'})`;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@@ -144,14 +148,14 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
reportingNotifications={[
|
||||
{
|
||||
label: '报备状态变化通知',
|
||||
count: reportSummary.count,
|
||||
description: reportSummary.unavailable ? '消息计数暂不可用,请重试' : '按企业与小时汇总的未读消息',
|
||||
count: reportSummary.value.count,
|
||||
description: notificationDescription(reportSummary, '按企业与小时汇总的未读消息'),
|
||||
to: '/admin/report-records?tab=readiness',
|
||||
},
|
||||
{
|
||||
label: '签名清退预警',
|
||||
count: retirementUnreadCount,
|
||||
description: '今日未读且未抑制',
|
||||
count: retirementSummary.value.count,
|
||||
description: notificationDescription(retirementSummary, '今日未读且未抑制'),
|
||||
to: '/admin/signature-retirement',
|
||||
},
|
||||
{
|
||||
@@ -163,40 +167,48 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
alertNotifications={[
|
||||
{
|
||||
label: '发送质量告警',
|
||||
count: monitorSummary.count,
|
||||
description: monitorSummary.unavailable ? '告警计数暂不可用,请重试' : '未读活动发送质量告警',
|
||||
count: monitorSummary.value.count,
|
||||
description: notificationDescription(monitorSummary, '未读活动发送质量告警'),
|
||||
to: '/admin/monitor?tab=alerts',
|
||||
},
|
||||
{
|
||||
label: '安全检测与封禁',
|
||||
count: securityAlertSummary.count,
|
||||
description:
|
||||
securityAlertSummary.criticalCount > 0
|
||||
? `${securityAlertSummary.criticalCount} 条严重告警待处置`
|
||||
count: securityAlertSummary.value.count,
|
||||
description: notificationDescription(
|
||||
securityAlertSummary,
|
||||
securityAlertSummary.value.criticalCount > 0
|
||||
? `${securityAlertSummary.value.criticalCount} 条严重告警待处置`
|
||||
: '待处置安全告警',
|
||||
),
|
||||
to: '/admin/security-detection',
|
||||
},
|
||||
{
|
||||
label: '系统监控告警',
|
||||
count: infrastructureAlertSummary.count,
|
||||
description:
|
||||
infrastructureAlertSummary.criticalCount > 0
|
||||
? `${infrastructureAlertSummary.criticalCount} 条 Prometheus 严重告警`
|
||||
count: infrastructureAlertSummary.value.count,
|
||||
description: notificationDescription(
|
||||
infrastructureAlertSummary,
|
||||
infrastructureAlertSummary.value.criticalCount > 0
|
||||
? `${infrastructureAlertSummary.value.criticalCount} 条 Prometheus 严重告警`
|
||||
: 'Prometheus 活动告警',
|
||||
),
|
||||
to: '/admin/system-monitoring#active-alerts',
|
||||
},
|
||||
]}
|
||||
auditNotifications={[
|
||||
{ label: '企业认证待审', count: pendingAudits.enterpriseCertifications, to: '/admin/enterprise-audit' },
|
||||
{ label: '短信审核待审', count: pendingAudits.smsAudits, to: '/admin/sms-audit' },
|
||||
{ label: '模板待审', count: pendingAudits.templates, to: '/admin/templates' },
|
||||
{ label: '签名待审', count: pendingAudits.signatures, to: '/admin/signatures' },
|
||||
{
|
||||
label: '签名导入待审',
|
||||
count: pendingAudits.signatureImports ?? 0,
|
||||
label: auditLabel('企业认证待审'),
|
||||
count: pendingAudits.value.enterpriseCertifications,
|
||||
to: '/admin/enterprise-audit',
|
||||
},
|
||||
{ label: auditLabel('短信审核待审'), count: pendingAudits.value.smsAudits, to: '/admin/sms-audit' },
|
||||
{ label: auditLabel('模板待审'), count: pendingAudits.value.templates, to: '/admin/templates' },
|
||||
{ label: auditLabel('签名待审'), count: pendingAudits.value.signatures, to: '/admin/signatures' },
|
||||
{
|
||||
label: auditLabel('签名导入待审'),
|
||||
count: pendingAudits.value.signatureImports ?? 0,
|
||||
to: '/admin/signatures?tab=import',
|
||||
},
|
||||
{ label: '引流信息待审', count: pendingAudits.drainageInfos, to: '/admin/drainage-audits' },
|
||||
{ label: auditLabel('引流信息待审'), count: pendingAudits.value.drainageInfos, to: '/admin/drainage-audits' },
|
||||
]}
|
||||
navSections={[
|
||||
{
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { adminOperationsApi } from '@/api/admin/operations.api';
|
||||
import { adminSignatureRetirementApi } from '@/api/admin/signature-retirement.api';
|
||||
import { adminSecurityDetectionApi } from '@/api/admin/security-detection.api';
|
||||
import { adminInfrastructureMonitoringApi } from '@/api/admin/infrastructure-monitoring.api';
|
||||
import { settleNotificationRequest, useNotificationPolling } from './useNotificationPolling';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const canPoll = () => true;
|
||||
const refresh = () => window.dispatchEvent(new Event('cmpp-audit-count-refresh'));
|
||||
const focus = () => window.dispatchEvent(new Event('focus'));
|
||||
const advance = (ms: number) => act(() => vi.advanceTimersByTimeAsync(ms));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(document, 'hidden', 'get').mockReturnValue(false);
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('keeps a single batch active and coalesces business events into one follow-up, without focus bursts', async () => {
|
||||
const first = deferred<boolean>();
|
||||
const second = deferred<boolean>();
|
||||
const poll = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise).mockResolvedValue(true);
|
||||
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
|
||||
await advance(0);
|
||||
act(() => {
|
||||
refresh();
|
||||
refresh();
|
||||
window.dispatchEvent(new Event('cmpp-security-alert-count-refresh'));
|
||||
focus();
|
||||
});
|
||||
expect(poll).toHaveBeenCalledTimes(1);
|
||||
await act(async () => first.resolve(true));
|
||||
expect(poll).toHaveBeenCalledTimes(2);
|
||||
act(() => {
|
||||
focus();
|
||||
focus();
|
||||
});
|
||||
await act(async () => second.resolve(true));
|
||||
await advance(29999);
|
||||
expect(poll).toHaveBeenCalledTimes(2);
|
||||
await advance(1);
|
||||
expect(poll).toHaveBeenCalledTimes(3);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('aborts stalled requests at 15 seconds and retries at 30, 60, then at most 120 seconds despite focus or refresh', async () => {
|
||||
const stalled = deferred<boolean>();
|
||||
const signals: AbortSignal[] = [];
|
||||
const poll = vi.fn((signal: AbortSignal) => {
|
||||
signals.push(signal);
|
||||
return settleNotificationRequest(stalled.promise, signal);
|
||||
});
|
||||
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
|
||||
await advance(0);
|
||||
for (const [attempt, delay] of [30000, 60000, 120000, 120000].entries()) {
|
||||
await advance(14999);
|
||||
expect(signals[attempt].aborted).toBe(false);
|
||||
await advance(1);
|
||||
expect(signals[attempt].aborted).toBe(true);
|
||||
expect(signals[attempt].reason.name).toBe('TimeoutError');
|
||||
act(() => {
|
||||
focus();
|
||||
refresh();
|
||||
focus();
|
||||
});
|
||||
await advance(delay - 1);
|
||||
expect(poll).toHaveBeenCalledTimes(attempt + 1);
|
||||
await advance(1);
|
||||
expect(poll).toHaveBeenCalledTimes(attempt + 2);
|
||||
}
|
||||
unmount();
|
||||
await act(async () => stalled.resolve(true));
|
||||
});
|
||||
|
||||
it('resets backoff after recovery and allows an explicit business refresh again', async () => {
|
||||
const poll = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValue(true);
|
||||
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
|
||||
await advance(0);
|
||||
await advance(30000);
|
||||
expect(poll).toHaveBeenCalledTimes(2);
|
||||
await advance(60000);
|
||||
expect(poll).toHaveBeenCalledTimes(3);
|
||||
await advance(30000);
|
||||
expect(poll).toHaveBeenCalledTimes(4);
|
||||
await act(async () => refresh());
|
||||
expect(poll).toHaveBeenCalledTimes(5);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each(['hidden', 'offline'] as const)(
|
||||
'cancels while %s, ignores a late result, and resumes only one batch',
|
||||
async (state) => {
|
||||
const first = deferred<number>();
|
||||
const values: number[] = [];
|
||||
const signals: AbortSignal[] = [];
|
||||
const poll = vi.fn(async (signal: AbortSignal, isCurrent: () => boolean) => {
|
||||
signals.push(signal);
|
||||
const value = await settleNotificationRequest(first.promise, signal);
|
||||
if (isCurrent()) values.push(value);
|
||||
return true;
|
||||
});
|
||||
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
|
||||
await advance(0);
|
||||
const setAvailable = (available: boolean) => {
|
||||
if (state === 'hidden') {
|
||||
vi.spyOn(document, 'hidden', 'get').mockReturnValue(!available);
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
} else {
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(available);
|
||||
window.dispatchEvent(new Event(available ? 'online' : 'offline'));
|
||||
}
|
||||
};
|
||||
await act(async () => setAvailable(false));
|
||||
expect(signals[0].aborted).toBe(true);
|
||||
await act(async () => first.resolve(9));
|
||||
await advance(120000);
|
||||
act(() => {
|
||||
focus();
|
||||
refresh();
|
||||
});
|
||||
expect(values).toEqual([]);
|
||||
expect(poll).toHaveBeenCalledTimes(1);
|
||||
act(() => {
|
||||
setAvailable(true);
|
||||
setAvailable(true);
|
||||
});
|
||||
await advance(0);
|
||||
expect(poll).toHaveBeenCalledTimes(2);
|
||||
expect(values).toEqual([9]);
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves a failed retry deadline across hidden, offline, and locked transitions', async () => {
|
||||
const poll = vi.fn().mockResolvedValue(false);
|
||||
const { rerender, unmount } = renderHook(({ enabled }) => useNotificationPolling({ enabled, canPoll, poll }), {
|
||||
initialProps: { enabled: true },
|
||||
});
|
||||
await advance(0);
|
||||
await advance(30000);
|
||||
expect(poll).toHaveBeenCalledTimes(2);
|
||||
rerender({ enabled: false });
|
||||
await advance(1000);
|
||||
rerender({ enabled: true });
|
||||
act(() => {
|
||||
focus();
|
||||
refresh();
|
||||
window.dispatchEvent(new Event('online'));
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
await advance(58999);
|
||||
expect(poll).toHaveBeenCalledTimes(2);
|
||||
await advance(1);
|
||||
expect(poll).toHaveBeenCalledTimes(3);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('invalidates an old batch on lock or unmount even if its source ignores cancellation', async () => {
|
||||
const requests = [deferred<number>(), deferred<number>()];
|
||||
const applied: number[] = [];
|
||||
const signals: AbortSignal[] = [];
|
||||
const poll = vi.fn(async (signal: AbortSignal, isCurrent: () => boolean) => {
|
||||
const request = requests[signals.length];
|
||||
signals.push(signal);
|
||||
const value = await request.promise;
|
||||
if (isCurrent()) applied.push(value);
|
||||
return true;
|
||||
});
|
||||
const { rerender, unmount } = renderHook(({ enabled }) => useNotificationPolling({ enabled, canPoll, poll }), {
|
||||
initialProps: { enabled: true },
|
||||
});
|
||||
await advance(0);
|
||||
rerender({ enabled: false });
|
||||
expect(signals[0].aborted).toBe(true);
|
||||
rerender({ enabled: true });
|
||||
await advance(0);
|
||||
expect(poll).toHaveBeenCalledTimes(2);
|
||||
await act(async () => requests[0].resolve(100));
|
||||
expect(applied).toEqual([]);
|
||||
unmount();
|
||||
expect(signals[1].aborted).toBe(true);
|
||||
await act(async () => requests[1].resolve(200));
|
||||
expect(applied).toEqual([]);
|
||||
await advance(120000);
|
||||
expect(poll).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('starts only one batch under StrictMode and none when discarded before setup completes', async () => {
|
||||
const poll = vi.fn().mockResolvedValue(true);
|
||||
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }), {
|
||||
wrapper: StrictMode,
|
||||
});
|
||||
expect(poll).not.toHaveBeenCalled();
|
||||
await advance(0);
|
||||
expect(poll).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
const discarded = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
|
||||
discarded.unmount();
|
||||
await advance(0);
|
||||
expect(poll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cancels all four notification API fetches and preserves the existing tenant filter argument', async () => {
|
||||
const calls: { url: string; options: RequestInit | undefined }[] = [];
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation((url, options) => {
|
||||
calls.push({ url: String(url), options });
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true });
|
||||
});
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
const pending = Promise.allSettled([
|
||||
adminOperationsApi.getPendingAudits('tenant with spaces', signal),
|
||||
adminSignatureRetirementApi.getSignatureRetirementUnreadCount(signal),
|
||||
adminSecurityDetectionApi.getSecurityNotificationSummary(signal),
|
||||
adminInfrastructureMonitoringApi.getInfrastructureMonitoringNotificationSummary(signal),
|
||||
]);
|
||||
expect(calls).toHaveLength(4);
|
||||
expect(new URL(calls[0].url, 'http://localhost').searchParams.get('tenantId')).toBe('tenant with spaces');
|
||||
expect(calls.every(({ options }) => options?.signal === signal && (options.method ?? 'GET') === 'GET')).toBe(true);
|
||||
controller.abort();
|
||||
const results = await pending;
|
||||
expect(results.every((result) => result.status === 'rejected' && result.reason.name === 'AbortError')).toBe(true);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ total: 4 }), { status: 200 }));
|
||||
await expect(adminOperationsApi.getPendingAudits('existing-tenant')).resolves.toEqual({ total: 4 });
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const REFRESH_EVENTS = [
|
||||
'cmpp-audit-count-refresh',
|
||||
'cmpp-retirement-count-refresh',
|
||||
'cmpp-report-notification-refresh',
|
||||
'cmpp-monitor-alert-refresh',
|
||||
'cmpp-security-alert-count-refresh',
|
||||
'cmpp-infrastructure-alert-count-refresh',
|
||||
];
|
||||
const POLL_INTERVAL_MS = 30000;
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
|
||||
// Also settles an aborted request if it is waiting outside fetch (for example, for authentication).
|
||||
export function settleNotificationRequest<T>(request: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const abort = () => reject(signal.reason);
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
request.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
if (signal.aborted) {
|
||||
signal.removeEventListener('abort', abort);
|
||||
abort();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type NotificationPollingOptions = {
|
||||
enabled: boolean;
|
||||
canPoll: () => boolean;
|
||||
poll: (signal: AbortSignal, isCurrent: () => boolean) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function useNotificationPolling({ enabled, canPoll, poll }: NotificationPollingOptions) {
|
||||
const retry = useRef({ failures: 0, nextEligibleAt: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
let disposed = false;
|
||||
let generation = 0;
|
||||
let active: AbortController | undefined;
|
||||
let timer: number | undefined;
|
||||
let timeout: number | undefined;
|
||||
let refreshPending = false;
|
||||
const isAvailable = () => !document.hidden && navigator.onLine && canPoll();
|
||||
const clearTimer = () => window.clearTimeout(timer);
|
||||
const schedule = () => {
|
||||
clearTimer();
|
||||
if (disposed || active || !isAvailable()) return;
|
||||
timer = window.setTimeout(() => start(false), Math.max(0, retry.current.nextEligibleAt - Date.now()));
|
||||
};
|
||||
|
||||
const start = (businessRefresh: boolean) => {
|
||||
if (disposed || !isAvailable()) return;
|
||||
if (active) {
|
||||
refreshPending ||= businessRefresh;
|
||||
return;
|
||||
}
|
||||
if (Date.now() < retry.current.nextEligibleAt && (!businessRefresh || retry.current.failures > 0)) {
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimer();
|
||||
refreshPending = false;
|
||||
const controller = new AbortController();
|
||||
active = controller;
|
||||
const batchGeneration = generation;
|
||||
const isCurrent = () => !disposed && generation === batchGeneration && isAvailable();
|
||||
timeout = window.setTimeout(
|
||||
() => controller.abort(new DOMException('通知计数请求超时', 'TimeoutError')),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
void poll(controller.signal, isCurrent)
|
||||
.catch(() => false)
|
||||
.then((successful) => {
|
||||
window.clearTimeout(timeout);
|
||||
active = undefined;
|
||||
if (disposed) return;
|
||||
if (!isCurrent()) {
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
retry.current.failures = successful ? 0 : Math.min(retry.current.failures + 1, 3);
|
||||
const delay = successful ? POLL_INTERVAL_MS : POLL_INTERVAL_MS * 2 ** (retry.current.failures - 1);
|
||||
retry.current.nextEligibleAt = Date.now() + delay;
|
||||
if (refreshPending && successful) {
|
||||
start(true);
|
||||
} else {
|
||||
schedule();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const refresh = () => start(true);
|
||||
const focus = () => start(false);
|
||||
const availabilityChanged = () => {
|
||||
if (!isAvailable()) {
|
||||
clearTimer();
|
||||
window.clearTimeout(timeout);
|
||||
generation += 1;
|
||||
refreshPending = false;
|
||||
active?.abort();
|
||||
} else {
|
||||
schedule();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', focus);
|
||||
window.addEventListener('online', availabilityChanged);
|
||||
window.addEventListener('offline', availabilityChanged);
|
||||
document.addEventListener('visibilitychange', availabilityChanged);
|
||||
REFRESH_EVENTS.forEach((event) => window.addEventListener(event, refresh));
|
||||
// StrictMode's discarded setup must not dispatch a second HTTP batch.
|
||||
schedule();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
generation += 1;
|
||||
clearTimer();
|
||||
window.clearTimeout(timeout);
|
||||
active?.abort();
|
||||
window.removeEventListener('focus', focus);
|
||||
window.removeEventListener('online', availabilityChanged);
|
||||
window.removeEventListener('offline', availabilityChanged);
|
||||
document.removeEventListener('visibilitychange', availabilityChanged);
|
||||
REFRESH_EVENTS.forEach((event) => window.removeEventListener(event, refresh));
|
||||
};
|
||||
}, [enabled, canPoll, poll]);
|
||||
}
|
||||
Reference in New Issue
Block a user