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:
Muse
2026-10-22 15:33:46 +00:00
parent e310abcba0
commit c91fb454a8
9 changed files with 376 additions and 45 deletions
+11
View File
@@ -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;
+75 -33
View File
@@ -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>
),
+6
View File
@@ -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();
});
});