mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-01 05:07:59 +00:00
feat(fleet): show stack-label filtering in Fleet View on every tier (#1268)
* feat(fleet): show stack-label filtering in Fleet View on every tier Stack labels and their assignments are a Community feature: the per-node label reads are available to any authenticated user and are already used in the per-node Stacks view. Fleet View, however, only fetched the fleet-wide label palette and per-stack chips when the instance was on a paid tier, so a Community user who had labelled their stacks saw no label dots on node cards and no Tags filter in the Overview toolbar. Drop the paid gate on the fleet label fetch so the palette, the per-stack chips, and the Tags filter render for everyone who has labels. Node-level tag aggregation (used for topology grouping) stays paid and is unchanged. * fix(fleet): surface update-status failures and soften the reconnect timeout Three reliability fixes in the Fleet View update path: - The fleet update-status poll swallowed fetch errors in an empty catch, so a failing poll left a silently stale table with no breadcrumb. It now logs the failure (both thrown errors and non-ok HTTP responses) without toasting on every tick, and keeps the last-known statuses. - The local-update reconnecting overlay declared "Update timed out" after five minutes, which falsely reported failure when a large image pull simply ran longer than the reconnect window. It now shows a non-failure "Taking longer than expected" state with a "Reload to check" action, and the timeout is a named constant that mirrors the backend update timeout. - The fleet overview fan-out already logged a node that failed to report; the update-status and update-all fan-outs now log the rejected node and reason too instead of discarding it. * test(fleet): backfill Fleet View hook, component, and update-tracker coverage Adds unit and component coverage for the previously untested Fleet View surface: all six Overview hooks (overview, update-status, polling cadence, preferences, fleet labels, node labels), the NodeCard, OverviewTab, OverviewToolbar, NodeUpdatesSheet, UpdateStatusBadge, and ReconnectingOverlay components, and the FleetUpdateTrackerService state transitions. * test(fleet): assert update-status poll preserves last-known statuses on failure Adds an explicit case that seeds statuses from a successful poll, then fails the next poll, and verifies the table keeps the seeded statuses (and logs without toasting) rather than relying on the implementation implicitly.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const useLicenseMock = vi.fn();
|
||||
const useAuthMock = vi.fn();
|
||||
const useNodesMock = vi.fn();
|
||||
|
||||
vi.mock('@/context/LicenseContext', () => ({ useLicense: () => useLicenseMock() }));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => useAuthMock() }));
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => useNodesMock() }));
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/lib/nodesApi', () => ({ cordonNode: vi.fn(), uncordonNode: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { NodeCard } from '../NodeCard';
|
||||
import type { FleetNode } from '../types';
|
||||
|
||||
function onlineNode(): FleetNode {
|
||||
return {
|
||||
id: 2, name: 'Edge', type: 'remote', status: 'online',
|
||||
stats: { active: 3, managed: 3, unmanaged: 0, exited: 1, total: 4 },
|
||||
systemStats: { cpu: { usage: '20.0', cores: 4 }, memory: { total: 100, used: 40, free: 60, usagePercent: '40.0' }, disk: { total: 100, used: 30, free: 70, usagePercent: '30.0' } },
|
||||
stacks: ['web'], cordoned: false, cordoned_at: null, cordoned_reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
function offlineNode(): FleetNode {
|
||||
return { ...onlineNode(), status: 'offline', stats: null, systemStats: null, stacks: null };
|
||||
}
|
||||
|
||||
function baseProps(node: FleetNode) {
|
||||
return { node, onNavigate: vi.fn() };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useNodesMock.mockReturnValue({ nodes: [] });
|
||||
useAuthMock.mockReturnValue({ isAdmin: true });
|
||||
useLicenseMock.mockReturnValue({ isPaid: false, license: null });
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('NodeCard', () => {
|
||||
it('renders stats and the online badge for an online node', () => {
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
expect(screen.getByText('Online')).toBeInTheDocument();
|
||||
expect(screen.getByText('Running')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Node unreachable')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the unreachable placeholder and hides stats for an offline node', () => {
|
||||
render(<NodeCard {...baseProps(offlineNode())} />);
|
||||
expect(screen.getByText('Offline')).toBeInTheDocument();
|
||||
expect(screen.getByText('Node unreachable')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Running')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the actions menu for a non-admiral user', () => {
|
||||
useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'skipper' } });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the actions menu (cordon entry point) for an admiral user', () => {
|
||||
useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'admiral' } });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
// The actions menu only renders when cordon (admiral-only here) is available.
|
||||
expect(screen.getByRole('button', { name: 'Node actions' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
|
||||
const apiFetchMock = vi.fn();
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: (...a: unknown[]) => apiFetchMock(...a) }));
|
||||
|
||||
import { NodeUpdatesSheet } from '../NodeUpdatesSheet';
|
||||
import type { NodeUpdateStatus } from '../types';
|
||||
|
||||
const STATUSES: NodeUpdateStatus[] = [
|
||||
{ nodeId: 1, name: 'Local', type: 'local', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: false, updateStatus: 'completed' },
|
||||
{ nodeId: 2, name: 'Edge', type: 'remote', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: true, updateStatus: null },
|
||||
{ nodeId: 3, name: 'Db', type: 'remote', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: false, updateStatus: 'failed', error: 'pull failed' },
|
||||
];
|
||||
|
||||
function baseProps(overrides: Partial<React.ComponentProps<typeof NodeUpdatesSheet>> = {}) {
|
||||
return {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
checkingUpdates: false,
|
||||
updateStatuses: STATUSES,
|
||||
updatingNodeId: null,
|
||||
fetchUpdateStatus: vi.fn(async () => {}),
|
||||
triggerNodeUpdate: vi.fn(),
|
||||
retryNodeUpdate: vi.fn(),
|
||||
dismissNodeUpdate: vi.fn(),
|
||||
triggerUpdateAll: vi.fn(async () => {}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => apiFetchMock.mockReset());
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('NodeUpdatesSheet', () => {
|
||||
it('renders the per-node table rows', () => {
|
||||
render(<NodeUpdatesSheet {...baseProps()} />);
|
||||
expect(screen.getByText('Local')).toBeInTheDocument();
|
||||
expect(screen.getByText('Edge')).toBeInTheDocument();
|
||||
expect(screen.getByText('Db')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a checking spinner state', () => {
|
||||
render(<NodeUpdatesSheet {...baseProps({ checkingUpdates: true })} />);
|
||||
expect(screen.getByText('Checking for updates...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty state with no nodes', () => {
|
||||
render(<NodeUpdatesSheet {...baseProps({ updateStatuses: [] })} />);
|
||||
expect(screen.getByText('No nodes found.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('triggers a per-node update from the Update button', () => {
|
||||
const triggerNodeUpdate = vi.fn();
|
||||
render(<NodeUpdatesSheet {...baseProps({ triggerNodeUpdate })} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Update$/ }));
|
||||
expect(triggerNodeUpdate).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('filters the node table by the search box', () => {
|
||||
render(<NodeUpdatesSheet {...baseProps()} />);
|
||||
fireEvent.change(screen.getByPlaceholderText('Filter nodes...'), { target: { value: 'edge' } });
|
||||
expect(screen.getByText('Edge')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Local')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
// Stub the heavy children so this test exercises OverviewTab's own
|
||||
// branch-selection logic, not NodeCard/FleetTopology internals.
|
||||
vi.mock('../NodeCard', () => ({ NodeCard: ({ node }: { node: { name: string } }) => <div data-testid="node-card">{node.name}</div> }));
|
||||
vi.mock('../../fleet/FleetTopology', () => ({ FleetTopology: () => <div data-testid="topology" /> }));
|
||||
|
||||
import { OverviewTab } from '../OverviewTab';
|
||||
import type { FleetNode, FleetPreferences } from '../types';
|
||||
|
||||
const PREFS: FleetPreferences = { sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false };
|
||||
|
||||
function node(id: number, name: string): FleetNode {
|
||||
return { id, name, type: 'remote', status: 'online', stats: null, systemStats: null, stacks: null, cordoned: false, cordoned_at: null, cordoned_reason: null };
|
||||
}
|
||||
|
||||
function props(overrides: Partial<React.ComponentProps<typeof OverviewTab>> = {}) {
|
||||
return {
|
||||
loading: false,
|
||||
nodes: [node(1, 'Alpha')],
|
||||
processedNodes: [node(1, 'Alpha')],
|
||||
allNodes: [node(1, 'Alpha')],
|
||||
topologyNodes: [],
|
||||
viewMode: 'grid' as const,
|
||||
onViewModeChange: vi.fn(),
|
||||
searchQuery: '',
|
||||
onSearchQueryChange: vi.fn(),
|
||||
prefs: PREFS,
|
||||
onPrefsChange: vi.fn(),
|
||||
fleetPalette: [],
|
||||
labelFilters: new Set<string>(),
|
||||
onLabelFiltersChange: vi.fn(),
|
||||
onClearFilters: vi.fn(),
|
||||
fleetStackLabelMap: {},
|
||||
updateStatusMap: new Map(),
|
||||
onNavigateToNode: vi.fn(),
|
||||
updatingNodeId: null,
|
||||
isPaid: false,
|
||||
topologyMode: 'hub' as const,
|
||||
onTopologyModeChange: vi.fn(),
|
||||
topologyPositions: {},
|
||||
onTopologyPositionsChange: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('OverviewTab', () => {
|
||||
it('renders node cards when nodes are present', () => {
|
||||
render(<OverviewTab {...props()} />);
|
||||
expect(screen.getByTestId('node-card')).toHaveTextContent('Alpha');
|
||||
});
|
||||
|
||||
it('shows the empty state when no nodes are configured', () => {
|
||||
render(<OverviewTab {...props({ nodes: [], processedNodes: [], allNodes: [] })} />);
|
||||
expect(screen.getByText('No nodes configured')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the no-match state when filters exclude every node', () => {
|
||||
render(<OverviewTab {...props({ processedNodes: [], allNodes: [] })} />);
|
||||
expect(screen.getByText('No nodes match your filters')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the topology view in topology mode', () => {
|
||||
render(<OverviewTab {...props({ viewMode: 'topology' })} />);
|
||||
expect(screen.getByTestId('topology')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
|
||||
import { OverviewToolbar } from '../OverviewToolbar';
|
||||
import type { FleetPaletteEntry, FleetPreferences } from '../types';
|
||||
|
||||
const PREFS: FleetPreferences = { sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false };
|
||||
|
||||
function props(overrides: Partial<React.ComponentProps<typeof OverviewToolbar>> = {}) {
|
||||
return {
|
||||
viewMode: 'grid' as const,
|
||||
onViewModeChange: vi.fn(),
|
||||
searchQuery: '',
|
||||
onSearchQueryChange: vi.fn(),
|
||||
prefs: PREFS,
|
||||
onPrefsChange: vi.fn(),
|
||||
fleetPalette: [] as FleetPaletteEntry[],
|
||||
labelFilters: new Set<string>(),
|
||||
onLabelFiltersChange: vi.fn(),
|
||||
onClearFilters: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('OverviewToolbar', () => {
|
||||
it('shows search and sort controls in grid mode', () => {
|
||||
render(<OverviewToolbar {...props()} />);
|
||||
expect(screen.getByPlaceholderText('Search nodes or stacks...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides grid controls in topology mode', () => {
|
||||
render(<OverviewToolbar {...props({ viewMode: 'topology' })} />);
|
||||
expect(screen.queryByPlaceholderText('Search nodes or stacks...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('forwards search input changes', () => {
|
||||
const onSearchQueryChange = vi.fn();
|
||||
render(<OverviewToolbar {...props({ onSearchQueryChange })} />);
|
||||
fireEvent.change(screen.getByPlaceholderText('Search nodes or stacks...'), { target: { value: 'web' } });
|
||||
expect(onSearchQueryChange).toHaveBeenCalledWith('web');
|
||||
});
|
||||
|
||||
it('exposes the Tags filter once a palette exists (no tier gate)', () => {
|
||||
const palette: FleetPaletteEntry[] = [{ key: 'prod|rose', name: 'prod', color: 'rose' }];
|
||||
render(<OverviewToolbar {...props({ fleetPalette: palette })} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Filters/ }));
|
||||
// "Tags" appears as both the section label and the multiselect placeholder.
|
||||
expect(screen.getAllByText('Tags').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('omits the Tags filter when the palette is empty', () => {
|
||||
render(<OverviewToolbar {...props()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Filters/ }));
|
||||
expect(screen.queryByText('Tags')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
|
||||
import { ReconnectingOverlay } from '../ReconnectingOverlay';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// Health poll resolves with a startedAt equal to the captured one so the
|
||||
// overlay never triggers a reload during these timing assertions.
|
||||
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(
|
||||
new Response(JSON.stringify({ startedAt: 1000 }), { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
)));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('ReconnectingOverlay', () => {
|
||||
it('shows the in-progress state before the timeout', () => {
|
||||
render(<ReconnectingOverlay preUpdateStartedAt={1000} />);
|
||||
expect(screen.getByText('Updating Sencho...')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Taking longer than expected')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to a non-failure "taking longer" state at the 5 minute mark', async () => {
|
||||
render(<ReconnectingOverlay preUpdateStartedAt={1000} />);
|
||||
|
||||
// Advance to the 5-minute reconnect budget.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5 * 60 * 1000); });
|
||||
|
||||
expect(screen.getByText('Taking longer than expected')).toBeInTheDocument();
|
||||
// The copy must not assert failure; it offers a reload affordance.
|
||||
expect(screen.getByRole('button', { name: 'Reload to check' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Update timed out')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
|
||||
import { UpdateStatusBadge } from '../UpdateStatusBadge';
|
||||
|
||||
describe('UpdateStatusBadge', () => {
|
||||
it('renders nothing for a null status', () => {
|
||||
const { container } = render(<UpdateStatusBadge status={null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders the updating and completed states', () => {
|
||||
const { rerender } = render(<UpdateStatusBadge status="updating" />);
|
||||
expect(screen.getByText('Updating')).toBeInTheDocument();
|
||||
rerender(<UpdateStatusBadge status="completed" />);
|
||||
expect(screen.getByText('Updated')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels timeout and failed distinctly', () => {
|
||||
const { rerender } = render(<UpdateStatusBadge status="timeout" />);
|
||||
expect(screen.getByText('Timed out')).toBeInTheDocument();
|
||||
rerender(<UpdateStatusBadge status="failed" />);
|
||||
expect(screen.getByText('Failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires retry and dismiss handlers without bubbling the card click', () => {
|
||||
const onRetry = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
render(<UpdateStatusBadge status="failed" error="pull error" onRetry={onRetry} onDismiss={onDismiss} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry update' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user