Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions client/web/antrea-ui/.env.development
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
VITE_API_SERVER=http://localhost:8080

# Uncomment the line below to enable synthetic Agent data in development mode.
VITE_USE_SYNTHETIC_DATA=true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remember to comment the line out for the code you check in

46 changes: 46 additions & 0 deletions client/web/antrea-ui/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,49 @@
.App-logo {
height: 2rem;
}

/* Table sorting styles */
th.sort-active {
background-color: var(--cds-alias-object-container-background-shade);
}

th:hover {
background-color: var(--cds-alias-object-container-background-tint);
}

th div {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}

/* Ensure table headers are properly aligned */
table th {
text-align: center;
vertical-align: middle;
padding: 0.5rem;
cursor: pointer;
user-select: none;
}

/* Style for the sort icon */
th svg {
transition: opacity 0.2s ease-in-out;
margin-left: 4px;
}

th:hover svg {
opacity: 1 !important;
}

/* Add a subtle transform on hover */
th:hover svg {
transform: scale(1.1);
transition: transform 0.2s ease-in-out;
}

/* Add hover effect for sortable columns */
th[style*="cursor: pointer"]:hover {
background-color: var(--cds-alias-object-container-background-tint);
}
56 changes: 56 additions & 0 deletions client/web/antrea-ui/src/components/SortIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from 'react';

interface SortIconProps {
direction?: 'ascending' | 'descending';
active?: boolean;
}

export function SortIcon({ direction = 'ascending', active = false }: SortIconProps) {
if (!active) {
// Show a neutral sort icon when not active
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ opacity: 0.3 }}
aria-label="Sortable column"
>
<path d="M8 9l4-4 4 4" />
<path d="M16 15l-4 4-4-4" />
</svg>
);
}

// Show directional arrow when active
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-label={`Sorted ${direction}`}
>
{direction === 'ascending' ? (
<>
<path d="M12 19V5" />
<path d="M5 12l7-7 7 7" />
</>
) : (
<>
<path d="M12 5v14" />
<path d="M19 12l-7 7-7-7" />
</>
)}
</svg>
);
}
168 changes: 167 additions & 1 deletion client/web/antrea-ui/src/routes/summary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
* limitations under the License.
*/

import { render, screen, within } from '@testing-library/react';
import { render, screen, within, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AgentInfo, ControllerInfo, K8sRef, Condition, agentInfoAPI, controllerInfoAPI } from '../api/info';
import { FeatureGate, featureGatesAPI } from '../api/featuregates';
import Summary from './summary';
Expand Down Expand Up @@ -238,4 +239,169 @@ describe('Summary', () => {
checkFeatureGates('Controller', featureGatesControllerData);
checkFeatureGates('Agent', featureGatesAgentData);
});

describe('Sorting functionality', () => {
it('sorts agents by name in ascending and descending order', async () => {
const user = userEvent.setup();
const agents = [
makeAgentInfo('node-3', 'nodeC', 10, ['10.0.3.0/24'], [makeCondition('AgentHealthy', 'True', d1)]),
makeAgentInfo('node-1', 'nodeA', 20, ['10.0.1.0/24'], [makeCondition('AgentHealthy', 'True', d1)]),
makeAgentInfo('node-2', 'nodeB', 15, ['10.0.2.0/24'], [makeCondition('AgentHealthy', 'True', d1)])
];

mockedControllerInfoAPI.fetch.mockResolvedValueOnce(controller);
mockedAgentInfoAPI.fetchAll.mockResolvedValueOnce(agents);
mockedFeatureGatesAPI.fetch.mockResolvedValueOnce(featureGates);

render(<Summary />);

await screen.findByText('Agents');

// Find the Name header in the Agents section and click it
const agentsSection = screen.getByRole('region', { name: /^agents$/i });
const nameHeader = within(agentsSection).getByText('Name');

// Click to sort ascending
await user.click(nameHeader);

// Verify ascending order
const rows = within(agentsSection).getAllByRole('row');
expect(within(rows[1]).getByText('node-1')).toBeInTheDocument();
expect(within(rows[2]).getByText('node-2')).toBeInTheDocument();
expect(within(rows[3]).getByText('node-3')).toBeInTheDocument();

// Click again to sort descending
await user.click(nameHeader);

// Verify descending order
const rowsDesc = within(agentsSection).getAllByRole('row');
expect(within(rowsDesc[1]).getByText('node-3')).toBeInTheDocument();
expect(within(rowsDesc[2]).getByText('node-2')).toBeInTheDocument();
expect(within(rowsDesc[3]).getByText('node-1')).toBeInTheDocument();
});

it('sorts agents numerically by local pods', async () => {
const user = userEvent.setup();
const agents = [
makeAgentInfo('node-1', 'nodeA', 5, ['10.0.1.0/24'], [makeCondition('AgentHealthy', 'True', d1)]),
makeAgentInfo('node-2', 'nodeB', 20, ['10.0.2.0/24'], [makeCondition('AgentHealthy', 'True', d1)]),
makeAgentInfo('node-3', 'nodeC', 10, ['10.0.3.0/24'], [makeCondition('AgentHealthy', 'True', d1)])
];

mockedControllerInfoAPI.fetch.mockResolvedValueOnce(controller);
mockedAgentInfoAPI.fetchAll.mockResolvedValueOnce(agents);
mockedFeatureGatesAPI.fetch.mockResolvedValueOnce(featureGates);

render(<Summary />);

await screen.findByText('Agents');

// Find the Local Pods header in the Agents section and click it
const agentsSection = screen.getByRole('region', { name: /^agents$/i });
const localPodsHeader = within(agentsSection).getByText('Local Pods');

// Click to sort ascending
await user.click(localPodsHeader);

// Verify numerical ascending order
const rows = within(agentsSection).getAllByRole('row');
expect(within(rows[1]).getByText('5')).toBeInTheDocument();
expect(within(rows[2]).getByText('10')).toBeInTheDocument();
expect(within(rows[3]).getByText('20')).toBeInTheDocument();
});
});

describe('Search functionality', () => {
it('filters agents by name search', async () => {
const user = userEvent.setup();
const agents = [
makeAgentInfo('node-1', 'nodeA', 10, ['10.0.1.0/24'], [makeCondition('AgentHealthy', 'True', d1)]),
makeAgentInfo('node-2', 'nodeB', 20, ['10.0.2.0/24'], [makeCondition('AgentHealthy', 'True', d1)]),
makeAgentInfo('special-node-1', 'nodeC', 15, ['10.0.3.0/24'], [makeCondition('AgentHealthy', 'True', d1)])
];

mockedControllerInfoAPI.fetch.mockResolvedValueOnce(controller);
mockedAgentInfoAPI.fetchAll.mockResolvedValueOnce(agents);
mockedFeatureGatesAPI.fetch.mockResolvedValueOnce(featureGates);

render(<Summary />);

await screen.findByText('Agents');

// Find the search input
const searchInput = screen.getByPlaceholderText(/search by name/i);

// Type "special" in the search input
await user.type(searchInput, 'special');

// Verify only the matching agent is displayed
const agentsSection = screen.getByRole('region', { name: /^agents$/i });
const rows = within(agentsSection).getAllByRole('row');

// Should have 2 rows: header + 1 matching agent
expect(rows).toHaveLength(2);
expect(within(rows[1]).getByText('special-node-1')).toBeInTheDocument();
});
});

describe('Pagination functionality', () => {
it('displays pagination controls when agents exceed page size', async () => {
// Create 15 agents to test pagination (more than itemsPerPage)
const manyAgents = Array.from({ length: 15 }, (_, i) =>
makeAgentInfo(`node-${i + 1}`, `node${i + 1}`, i * 5, ['10.0.1.0/24'], [makeCondition('AgentHealthy', 'True', d1)])
);

mockedControllerInfoAPI.fetch.mockResolvedValueOnce(controller);
mockedAgentInfoAPI.fetchAll.mockResolvedValueOnce(manyAgents);
mockedFeatureGatesAPI.fetch.mockResolvedValueOnce(featureGates);

render(<Summary />);

await screen.findByText('Agents');

// Verify pagination controls are displayed
expect(screen.getByText('Previous')).toBeInTheDocument();
expect(screen.getByText('Next')).toBeInTheDocument();
expect(screen.getByText(/Page 1 of 2/)).toBeInTheDocument();

// Previous button should be disabled on first page
expect(screen.getByText('Previous')).toBeDisabled();

// Next button should be enabled
expect(screen.getByText('Next')).not.toBeDisabled();
});

it('navigates through pages correctly', async () => {
const user = userEvent.setup();
const manyAgents = Array.from({ length: 15 }, (_, i) =>
makeAgentInfo(`node-${i + 1}`, `node${i + 1}`, i * 5, ['10.0.1.0/24'], [makeCondition('AgentHealthy', 'True', d1)])
);

mockedControllerInfoAPI.fetch.mockResolvedValueOnce(controller);
mockedAgentInfoAPI.fetchAll.mockResolvedValueOnce(manyAgents);
mockedFeatureGatesAPI.fetch.mockResolvedValueOnce(featureGates);

render(<Summary />);

await screen.findByText('Agents');

// Click Next button
const nextButton = screen.getByText('Next');
await user.click(nextButton);

// Verify we're on page 2
expect(screen.getByText(/Page 2 of 2/)).toBeInTheDocument();

// Next button should now be disabled
expect(nextButton).toBeDisabled();

// Previous button should be enabled
const prevButton = screen.getByText('Previous');
expect(prevButton).not.toBeDisabled();

// Click Previous to go back to page 1
await user.click(prevButton);
expect(screen.getByText(/Page 1 of 2/)).toBeInTheDocument();
});
});
});
Loading