fix(nodes): per-category client email lists behind the node count chips
NodeView carried only the category counts, so the nodes page chips could hover to nothing but the category title. GetAll/recountByGuid now emit online/active/disabled/depleted email lists from the same bucketing pass as the counts (statuses gain the client email; the online list is the counted set), and the chips pop the list over with the inbounds page's popover pattern.
This commit is contained in:
@@ -127,6 +127,17 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Category chip hover list (same values as the inbounds page). */
|
||||
.client-email-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.client-email-list > div {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.card-history {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge, Button, Card, Dropdown, Modal, Space, Switch, Table, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Dropdown,
|
||||
Modal,
|
||||
Popover,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import type { BadgeProps } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
@@ -48,6 +60,45 @@ function isUpdateEligible(n: NodeRecord): boolean {
|
||||
return !!n.enable && n.status === 'online';
|
||||
}
|
||||
|
||||
// One category chip of the nodes table's clients cell. When the backend
|
||||
// sent the category's email list, hovering pops the list over (same
|
||||
// pattern as the inbounds page); without a list it keeps the plain
|
||||
// category tooltip. Chip styling is identical either way.
|
||||
function CategoryChip({
|
||||
title,
|
||||
emails,
|
||||
color,
|
||||
count,
|
||||
}: {
|
||||
title: string;
|
||||
emails?: string[];
|
||||
color?: string;
|
||||
count: number;
|
||||
}) {
|
||||
const tag = (
|
||||
<Tag color={color} className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
|
||||
{count}
|
||||
</Tag>
|
||||
);
|
||||
if (emails && emails.length > 0) {
|
||||
return (
|
||||
<Popover
|
||||
title={title}
|
||||
content={
|
||||
<div className="client-email-list">
|
||||
{emails.map((e) => (
|
||||
<div key={e}>{e}</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{tag}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
return <Tooltip title={title}>{tag}</Tooltip>;
|
||||
}
|
||||
|
||||
interface NodeRow extends NodeRecord {
|
||||
url: string;
|
||||
key: string | number;
|
||||
@@ -495,44 +546,35 @@ export default function NodeList({
|
||||
<TeamOutlined /> {record.clientCount || 0}
|
||||
</Tag>
|
||||
{record.activeCount ? (
|
||||
<Tooltip title={t('subscription.active')}>
|
||||
<Tag
|
||||
color="green"
|
||||
className="client-count-tag"
|
||||
style={{ margin: 0, padding: '0 2px' }}
|
||||
>
|
||||
{record.activeCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<CategoryChip
|
||||
title={t('subscription.active')}
|
||||
emails={record.activeEmails}
|
||||
color="green"
|
||||
count={record.activeCount}
|
||||
/>
|
||||
) : null}
|
||||
{record.disabledCount ? (
|
||||
<Tooltip title={t('disabled')}>
|
||||
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
|
||||
{record.disabledCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<CategoryChip
|
||||
title={t('disabled')}
|
||||
emails={record.disabledEmails}
|
||||
count={record.disabledCount}
|
||||
/>
|
||||
) : null}
|
||||
{record.depletedCount ? (
|
||||
<Tooltip title={t('depleted')}>
|
||||
<Tag
|
||||
color="red"
|
||||
className="client-count-tag"
|
||||
style={{ margin: 0, padding: '0 2px' }}
|
||||
>
|
||||
{record.depletedCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<CategoryChip
|
||||
title={t('depleted')}
|
||||
emails={record.depletedEmails}
|
||||
color="red"
|
||||
count={record.depletedCount}
|
||||
/>
|
||||
) : null}
|
||||
{record.onlineCount ? (
|
||||
<Tooltip title={t('online')}>
|
||||
<Tag
|
||||
color="blue"
|
||||
className="client-count-tag"
|
||||
style={{ margin: 0, padding: '0 2px' }}
|
||||
>
|
||||
{record.onlineCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<CategoryChip
|
||||
title={t('online')}
|
||||
emails={record.onlineEmails}
|
||||
color="blue"
|
||||
count={record.onlineCount}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
|
||||
@@ -25,6 +25,12 @@ export const NodeRecordSchema = z
|
||||
activeCount: z.number().optional(),
|
||||
disabledCount: z.number().optional(),
|
||||
depletedCount: z.number().optional(),
|
||||
// Per-category client email lists behind the counts above; the nodes
|
||||
// page category chips pop these over on hover.
|
||||
onlineEmails: z.array(z.string()).optional(),
|
||||
activeEmails: z.array(z.string()).optional(),
|
||||
disabledEmails: z.array(z.string()).optional(),
|
||||
depletedEmails: z.array(z.string()).optional(),
|
||||
lastHeartbeat: z.number().optional(),
|
||||
lastError: z.string().optional(),
|
||||
// Xray state captured from the remote node's own /panel/api/server/status.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { fireEvent, waitFor, within } from '@testing-library/react';
|
||||
|
||||
import NodeList from '@/pages/nodes/NodeList';
|
||||
import type { NodeRecord } from '@/schemas/node';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function renderNodes(nodes: NodeRecord[]) {
|
||||
return renderWithProviders(
|
||||
<NodeList
|
||||
nodes={nodes}
|
||||
isMobile={false}
|
||||
selectedIds={[]}
|
||||
onSelectionChange={noop}
|
||||
onAdd={noop}
|
||||
onMtls={noop}
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
onProbe={noop}
|
||||
onToggleEnable={noop}
|
||||
onUpdateNode={noop}
|
||||
onUpdateSelected={noop}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function rowFor(container: HTMLElement, name: string): HTMLElement {
|
||||
const cell = within(container).getByText(name);
|
||||
const row = cell.closest('tr');
|
||||
if (!row) throw new Error(`row not found for ${name}`);
|
||||
return row as HTMLElement;
|
||||
}
|
||||
|
||||
// The nodes page category chips used to hover to a bare category title.
|
||||
// When the backend sends the category's email list, hovering a chip must
|
||||
// pop the list over (same pattern as the inbounds page); without a list
|
||||
// the chip keeps the plain title tooltip.
|
||||
describe('NodeList clients-cell category chips', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('pops the online and depleted email lists on hover', async () => {
|
||||
const { container } = renderNodes([
|
||||
{
|
||||
id: 5,
|
||||
name: 'agent-node',
|
||||
enable: true,
|
||||
status: 'online',
|
||||
nodeType: 'agent',
|
||||
clientCount: 9,
|
||||
activeCount: 2,
|
||||
activeEmails: ['active-a@x', 'active-b@x'],
|
||||
depletedCount: 3,
|
||||
depletedEmails: ['dep-a@x', 'dep-b@x', 'dep-c@x'],
|
||||
onlineCount: 1,
|
||||
onlineEmails: ['online@x'],
|
||||
},
|
||||
]);
|
||||
|
||||
const row = rowFor(container, 'agent-node');
|
||||
|
||||
fireEvent.mouseEnter(within(row).getByText('1'));
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(document.body.textContent).toContain('online@x');
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
fireEvent.mouseEnter(within(row).getByText('3'));
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(document.body.textContent).toContain('dep-b@x');
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the title tooltip when a category has no email list', async () => {
|
||||
const { container } = renderNodes([
|
||||
{
|
||||
id: 6,
|
||||
name: 'bare-node',
|
||||
enable: true,
|
||||
status: 'online',
|
||||
clientCount: 4,
|
||||
onlineCount: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const row = rowFor(container, 'bare-node');
|
||||
fireEvent.mouseEnter(within(row).getByText('1'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.textContent).toContain('Online');
|
||||
});
|
||||
expect(document.querySelector('.client-email-list')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user