mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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,95 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { FleetUpdateTrackerService } from '../services/FleetUpdateTrackerService';
|
||||
|
||||
// The service is a process-local singleton. Each test clears any entries left
|
||||
// by a prior test so assertions on size() are deterministic.
|
||||
function clearAll(svc: FleetUpdateTrackerService) {
|
||||
for (const [nodeId] of [...svc.entries()]) svc.delete(nodeId);
|
||||
}
|
||||
|
||||
describe('FleetUpdateTrackerService', () => {
|
||||
let svc: FleetUpdateTrackerService;
|
||||
|
||||
beforeEach(() => {
|
||||
svc = FleetUpdateTrackerService.getInstance();
|
||||
clearAll(svc);
|
||||
});
|
||||
|
||||
it('returns the same singleton instance', () => {
|
||||
expect(FleetUpdateTrackerService.getInstance()).toBe(svc);
|
||||
});
|
||||
|
||||
it('create() starts an updating tracker with startedAt and no resolvedAt', () => {
|
||||
const before = Date.now();
|
||||
const tracker = svc.create('updating', '1.0.0', 1234);
|
||||
expect(tracker.status).toBe('updating');
|
||||
expect(tracker.previousVersion).toBe('1.0.0');
|
||||
expect(tracker.previousProcessStart).toBe(1234);
|
||||
expect(tracker.wasOffline).toBe(false);
|
||||
expect(tracker.startedAt).toBeGreaterThanOrEqual(before);
|
||||
expect(tracker.resolvedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('create() with a terminal status sets resolvedAt immediately', () => {
|
||||
const tracker = svc.create('failed', '1.0.0', null, 'pull failed');
|
||||
expect(tracker.status).toBe('failed');
|
||||
expect(tracker.error).toBe('pull failed');
|
||||
expect(tracker.resolvedAt).toBeTypeOf('number');
|
||||
});
|
||||
|
||||
it('set()/get() round-trips a tracker by node id', () => {
|
||||
const tracker = svc.create('updating', '1.0.0', null);
|
||||
svc.set(7, tracker);
|
||||
expect(svc.get(7)).toEqual(tracker);
|
||||
expect(svc.get(999)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolve() returns a terminal copy without mutating the original', () => {
|
||||
const original = svc.create('updating', '1.0.0', null);
|
||||
const resolved = svc.resolve(original, 'completed');
|
||||
expect(resolved).not.toBe(original);
|
||||
expect(original.status).toBe('updating');
|
||||
expect(original.resolvedAt).toBeUndefined();
|
||||
expect(resolved.status).toBe('completed');
|
||||
expect(resolved.resolvedAt).toBeTypeOf('number');
|
||||
// Carries forward the immutable fields.
|
||||
expect(resolved.startedAt).toBe(original.startedAt);
|
||||
expect(resolved.previousVersion).toBe(original.previousVersion);
|
||||
});
|
||||
|
||||
it('resolve() to timeout/failed carries the error message', () => {
|
||||
const original = svc.create('updating', '1.0.0', null);
|
||||
const timedOut = svc.resolve(original, 'timeout', 'did not come back');
|
||||
expect(timedOut.status).toBe('timeout');
|
||||
expect(timedOut.error).toBe('did not come back');
|
||||
});
|
||||
|
||||
it('delete() removes a tracker and reports whether one existed', () => {
|
||||
svc.set(3, svc.create('completed', '1.0.0', null));
|
||||
expect(svc.size()).toBe(1);
|
||||
expect(svc.delete(3)).toBe(true);
|
||||
expect(svc.delete(3)).toBe(false);
|
||||
expect(svc.get(3)).toBeUndefined();
|
||||
expect(svc.size()).toBe(0);
|
||||
});
|
||||
|
||||
it('entries() iterates every stored tracker keyed by node id', () => {
|
||||
svc.set(1, svc.create('updating', '1.0.0', null));
|
||||
svc.set(2, svc.create('completed', '1.0.0', null));
|
||||
const ids = [...svc.entries()].map(([id]) => id).sort();
|
||||
expect(ids).toEqual([1, 2]);
|
||||
expect(svc.size()).toBe(2);
|
||||
});
|
||||
|
||||
it('models the full updating -> completed transition the way the poll loop uses it', () => {
|
||||
// create updating, store, then resolve and store the terminal copy.
|
||||
const updating = svc.create('updating', '1.0.0', 100);
|
||||
svc.set(5, updating);
|
||||
expect(svc.get(5)?.status).toBe('updating');
|
||||
|
||||
const completed = svc.resolve(svc.get(5)!, 'completed');
|
||||
svc.set(5, completed);
|
||||
expect(svc.get(5)?.status).toBe('completed');
|
||||
expect(svc.get(5)?.resolvedAt).toBeTypeOf('number');
|
||||
});
|
||||
});
|
||||
@@ -826,6 +826,7 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
||||
|
||||
const nodeStatuses = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
console.warn(`[Fleet] Update-status poll failed for node ${nodes[i].name}:`, r.reason);
|
||||
return {
|
||||
nodeId: nodes[i].id,
|
||||
name: nodes[i].name,
|
||||
@@ -995,6 +996,9 @@ fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Respon
|
||||
const skipped = nodes.filter(n => !candidates.includes(n)).map(n => n.name);
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const r = results[i];
|
||||
if (r.status === 'rejected') {
|
||||
console.warn(`[Fleet] Update-all failed for node ${candidates[i].name}:`, r.reason);
|
||||
}
|
||||
const val = r.status === 'fulfilled' ? r.value : { name: candidates[i].name, triggered: false };
|
||||
(val.triggered ? updating : skipped).push(val.name);
|
||||
}
|
||||
|
||||
@@ -246,7 +246,7 @@ The latest-version label is resolved from the GitHub Releases API (with a Docker
|
||||
|
||||
When you click **Update** on a remote row, Sencho dispatches the update command to that remote's API. The remote pulls the latest Docker image directly, then spawns a short-lived helper container that bind-mounts the compose working directory from the host and runs `docker compose up -d --force-recreate <service>`. The remote restarts with the new image; the table cell flips from **Updating** to a green **Updated** badge once the gateway detects the version change. The **Updated** state stays visible for a few seconds before the row settles back to **Up to date**.
|
||||
|
||||
When you click **Update** on the local row, a confirmation dialog appears first ("Update local node"). Confirming kicks off the same pull-and-recreate flow on the local Sencho instance. Because the gateway is restarting itself, a full-screen reconnecting overlay takes over the browser tab. The overlay polls `/api/health` every 3 seconds and dismisses itself once the new gateway answers; if the overlay does not clear within 5 minutes it surfaces a *Try Reloading* button instead of waiting indefinitely.
|
||||
When you click **Update** on the local row, a confirmation dialog appears first ("Update local node"). Confirming kicks off the same pull-and-recreate flow on the local Sencho instance. Because the gateway is restarting itself, a full-screen reconnecting overlay takes over the browser tab. The overlay polls `/api/health` every 3 seconds and dismisses itself once the new gateway answers. If the gateway has not returned within 5 minutes the overlay switches to a *Taking longer than expected* state with a **Reload to check** button rather than waiting indefinitely. A large image pull can legitimately run past that window, so this state is not a failure on its own.
|
||||
|
||||
<Note>
|
||||
Triggering updates (per-row and **Update all**) requires the resolved user to have the admin role. Operator and viewer roles can read update status but cannot initiate updates.
|
||||
@@ -275,6 +275,9 @@ Fleet View queries every registered node in parallel. Each node responds indepen
|
||||
<Accordion title="The Update button returns 'Admin access required'">
|
||||
Fleet update operations (per-row and **Update all**) are admin-only. Viewer and operator roles can see the table and the badges but the button returns a 403 when clicked. Ask a Sencho administrator either to run the update for you or to grant your account the admin role.
|
||||
</Accordion>
|
||||
<Accordion title="The local-update overlay says 'Taking longer than expected'">
|
||||
When you update the local (control) node, Sencho restarts itself and a full-screen overlay waits for the new gateway to answer. If it has not come back within five minutes the overlay stops waiting and offers a **Reload to check** button. On its own this is not a failure: a large image pull can take longer than the reconnect window. Give the pull a little more time and click **Reload to check**. If the page still does not load after the Sencho container has restarted, inspect it on the Docker host with `docker ps` and `docker logs`.
|
||||
</Accordion>
|
||||
<Accordion title="Free-mode topology positions don't persist">
|
||||
Free mode stores node positions in your browser's local storage. The arrangement is per-browser; it does not sync across devices or users. If positions reset on reload, the most common causes are a browser session that has local storage disabled (private or incognito windows, strict tracking-prevention modes, which fall back to in-memory state for the session only), storage cleared by browser cleanup tools or a profile reset, or simply a different browser or profile. To verify storage is writable, open DevTools → Application → Local Storage on the Sencho origin and confirm the `sencho-topology-preferences` key exists after switching modes or dragging a node.
|
||||
</Accordion>
|
||||
|
||||
@@ -7,9 +7,15 @@ interface ReconnectingOverlayProps {
|
||||
preUpdateStartedAt: number | null;
|
||||
}
|
||||
|
||||
// Mirrors the backend UPDATE_TIMEOUT_MS (5 minutes) in routes/fleet.ts. Past
|
||||
// this point we stop asserting the update is in flight and hand control back to
|
||||
// the operator, but we do not claim failure: a large image pull can legitimately
|
||||
// run longer than the auto-reload budget.
|
||||
const RECONNECT_TIMEOUT_SECONDS = 5 * 60;
|
||||
|
||||
export function ReconnectingOverlay({ preUpdateStartedAt }: ReconnectingOverlayProps) {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const timedOut = elapsed >= 300; // 5 minutes
|
||||
const timedOut = elapsed >= RECONNECT_TIMEOUT_SECONDS;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setElapsed(s => s + 1), 1000);
|
||||
@@ -54,12 +60,12 @@ export function ReconnectingOverlay({ preUpdateStartedAt }: ReconnectingOverlayP
|
||||
{timedOut ? (
|
||||
<>
|
||||
<AlertTriangle className="w-10 h-10 text-warning mx-auto" strokeWidth={1.5} />
|
||||
<h2 className="text-lg font-medium">Update timed out</h2>
|
||||
<h2 className="text-lg font-medium">Taking longer than expected</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
The server has not come back within 5 minutes. Check the Docker host directly.
|
||||
Sencho has not come back online yet. A large image pull can take a while, so the update may still be finishing. Reload to check, or inspect the Docker host if it persists.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
|
||||
Try Reloading
|
||||
Reload to check
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
const fetchForNodeMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
fetchForNode: (...args: unknown[]) => fetchForNodeMock(...args),
|
||||
}));
|
||||
|
||||
import { useFleetLabels, labelPaletteKey } from '../useFleetLabels';
|
||||
import type { FleetNode } from '../../types';
|
||||
|
||||
function okJson(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function node(id: number, status: FleetNode['status'] = 'online'): FleetNode {
|
||||
return {
|
||||
id, name: `node-${id}`, type: id === 1 ? 'local' : 'remote', status,
|
||||
stats: null, systemStats: null, stacks: ['web'],
|
||||
cordoned: false, cordoned_at: null, cordoned_reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Route the mock by the requested path so a node returns both its labels and
|
||||
// its assignments.
|
||||
function routeByPath(labels: unknown, assignments: unknown) {
|
||||
return (path: string) => {
|
||||
if (path === '/labels') return Promise.resolve(okJson(labels));
|
||||
if (path === '/labels/assignments') return Promise.resolve(okJson(assignments));
|
||||
return Promise.resolve(new Response('{}', { status: 404 }));
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchForNodeMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useFleetLabels (stack labels are a Community feature)', () => {
|
||||
it('fetches labels for online nodes without any tier gate', async () => {
|
||||
fetchForNodeMock.mockImplementation(
|
||||
routeByPath(
|
||||
[{ id: 1, name: 'prod', color: 'rose' }],
|
||||
{ web: [{ id: 1, name: 'prod', color: 'rose' }] },
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useFleetLabels({ nodes: [node(2)] }));
|
||||
|
||||
await waitFor(() => expect(result.current.fleetPalette.length).toBe(1));
|
||||
expect(result.current.fleetPalette[0]).toMatchObject({ name: 'prod', color: 'rose' });
|
||||
expect(result.current.fleetStackLabelMap[2]).toEqual({ web: [{ id: 1, name: 'prod', color: 'rose' }] });
|
||||
// Both endpoints hit for the one online node.
|
||||
const paths = fetchForNodeMock.mock.calls.map(c => c[0]);
|
||||
expect(paths).toContain('/labels');
|
||||
expect(paths).toContain('/labels/assignments');
|
||||
});
|
||||
|
||||
it('skips offline nodes but still aggregates online ones', async () => {
|
||||
fetchForNodeMock.mockImplementation(
|
||||
routeByPath([{ id: 1, name: 'edge', color: 'blue' }], { web: [{ id: 1, name: 'edge', color: 'blue' }] }),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFleetLabels({ nodes: [node(2, 'offline'), node(3, 'online')] }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.fleetPalette.length).toBe(1));
|
||||
// Only node 3 (online) was queried.
|
||||
const queriedNodeIds = new Set(fetchForNodeMock.mock.calls.map(c => c[1]));
|
||||
expect(queriedNodeIds.has(3)).toBe(true);
|
||||
expect(queriedNodeIds.has(2)).toBe(false);
|
||||
expect(result.current.fleetStackLabelMap[2]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('dedupes the palette across nodes that share a label name+color', async () => {
|
||||
fetchForNodeMock.mockImplementation(
|
||||
routeByPath([{ id: 9, name: 'prod', color: 'rose' }], { web: [{ id: 9, name: 'prod', color: 'rose' }] }),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useFleetLabels({ nodes: [node(2), node(3)] }));
|
||||
|
||||
await waitFor(() => expect(Object.keys(result.current.fleetStackLabelMap).length).toBe(2));
|
||||
// Same name+color from two nodes collapses to a single palette entry.
|
||||
expect(result.current.fleetPalette).toHaveLength(1);
|
||||
expect(result.current.fleetPalette[0].key).toBe(labelPaletteKey('prod', 'rose'));
|
||||
});
|
||||
|
||||
it('does not fetch when there are no nodes', () => {
|
||||
renderHook(() => useFleetLabels({ nodes: [] }));
|
||||
expect(fetchForNodeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tolerates an unreachable node without losing the others', async () => {
|
||||
fetchForNodeMock.mockImplementation((path: string, nodeId: number) => {
|
||||
if (nodeId === 2) return Promise.reject(new Error('timeout'));
|
||||
if (path === '/labels') return Promise.resolve(okJson([{ id: 1, name: 'ok', color: 'green' }]));
|
||||
return Promise.resolve(okJson({ web: [{ id: 1, name: 'ok', color: 'green' }] }));
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFleetLabels({ nodes: [node(2), node(3)] }));
|
||||
|
||||
await waitFor(() => expect(result.current.fleetPalette.length).toBe(1));
|
||||
expect(result.current.fleetStackLabelMap[3]).toBeDefined();
|
||||
expect(result.current.fleetStackLabelMap[2]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
|
||||
const apiFetchMock = vi.fn();
|
||||
const fetchForNodeMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
|
||||
fetchForNode: (...args: unknown[]) => fetchForNodeMock(...args),
|
||||
}));
|
||||
|
||||
import { useFleetOverview } from '../useFleetOverview';
|
||||
import type { FleetNode, FleetPreferences } from '../../types';
|
||||
|
||||
function okJson(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
function sys(cpu: string, mem = '40.0', disk = '30.0') {
|
||||
return {
|
||||
cpu: { usage: cpu, cores: 4 },
|
||||
memory: { total: 100, used: 40, free: 60, usagePercent: mem },
|
||||
disk: { total: 100, used: 30, free: 70, usagePercent: disk },
|
||||
};
|
||||
}
|
||||
|
||||
const NODES: FleetNode[] = [
|
||||
{ id: 1, name: 'Alpha', type: 'local', status: 'online', stats: { active: 2, managed: 2, unmanaged: 0, exited: 0, total: 2 }, systemStats: sys('10.0'), stacks: ['web'], cordoned: false, cordoned_at: null, cordoned_reason: null },
|
||||
{ id: 2, name: 'Bravo', type: 'remote', status: 'online', stats: { active: 5, managed: 5, unmanaged: 0, exited: 0, total: 5 }, systemStats: sys('95.0'), stacks: ['db'], cordoned: false, cordoned_at: null, cordoned_reason: null },
|
||||
{ id: 3, name: 'Charlie', type: 'remote', status: 'offline', stats: null, systemStats: null, stacks: null, cordoned: false, cordoned_at: null, cordoned_reason: null },
|
||||
];
|
||||
|
||||
const DEFAULT_PREFS: FleetPreferences = { sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false };
|
||||
|
||||
function setup(prefs: Partial<FleetPreferences> = {}) {
|
||||
const updatePrefs = vi.fn();
|
||||
const merged = { ...DEFAULT_PREFS, ...prefs };
|
||||
const hook = renderHook(
|
||||
(p: { prefs: FleetPreferences }) => useFleetOverview({ isPaid: false, prefs: p.prefs, updatePrefs, updateStatuses: [] }),
|
||||
{ initialProps: { prefs: merged } },
|
||||
);
|
||||
return { ...hook, updatePrefs };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
apiFetchMock.mockReset();
|
||||
fetchForNodeMock.mockReset();
|
||||
apiFetchMock.mockImplementation((path: string) => {
|
||||
if (path === '/fleet/overview') return Promise.resolve(okJson(NODES));
|
||||
if (path === '/node-labels') return Promise.resolve(okJson({}));
|
||||
return Promise.resolve(okJson({}));
|
||||
});
|
||||
fetchForNodeMock.mockResolvedValue(okJson([]));
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('useFleetOverview', () => {
|
||||
it('loads nodes and computes masthead stats', async () => {
|
||||
const { result } = setup();
|
||||
await act(async () => { await result.current.fetchOverview(); });
|
||||
|
||||
expect(result.current.nodes).toHaveLength(3);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.mastheadStats.nodeCount).toBe(3);
|
||||
expect(result.current.mastheadStats.onlineCount).toBe(2);
|
||||
// Bravo at 95% CPU is critical.
|
||||
expect(result.current.mastheadStats.criticalCount).toBe(1);
|
||||
expect(result.current.lastSyncAt).toBeTypeOf('number');
|
||||
});
|
||||
|
||||
it('filters by search query across node name and stack names', async () => {
|
||||
const { result } = setup();
|
||||
await act(async () => { await result.current.fetchOverview(); });
|
||||
act(() => result.current.setSearchQuery('db'));
|
||||
await waitFor(() => expect(result.current.processedNodes).toHaveLength(1));
|
||||
expect(result.current.processedNodes[0].name).toBe('Bravo');
|
||||
});
|
||||
|
||||
it('filters by status=offline', async () => {
|
||||
const { result } = setup({ filterStatus: 'offline' });
|
||||
await act(async () => { await result.current.fetchOverview(); });
|
||||
expect(result.current.processedNodes.map(n => n.name)).toEqual(['Charlie']);
|
||||
});
|
||||
|
||||
it('filters critical-only to the high-CPU node', async () => {
|
||||
const { result } = setup({ filterCritical: true });
|
||||
await act(async () => { await result.current.fetchOverview(); });
|
||||
expect(result.current.processedNodes.map(n => n.name)).toEqual(['Bravo']);
|
||||
});
|
||||
|
||||
it('sorts by cpu descending', async () => {
|
||||
const { result } = setup({ sortBy: 'cpu', sortDir: 'asc' });
|
||||
await act(async () => { await result.current.fetchOverview(); });
|
||||
// cpu sort is inherently descending (b - a); offline node reads 0.
|
||||
expect(result.current.processedNodes.map(n => n.name)).toEqual(['Bravo', 'Alpha', 'Charlie']);
|
||||
});
|
||||
|
||||
it('ignores an aborted fetch without surfacing an error', async () => {
|
||||
const { result } = setup();
|
||||
apiFetchMock.mockImplementationOnce(() => Promise.reject(new DOMException('aborted', 'AbortError')));
|
||||
await act(async () => { await result.current.fetchOverview(); });
|
||||
// No throw; nodes stay empty, loading cleared.
|
||||
expect(result.current.nodes).toHaveLength(0);
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('clearFilters resets prefs and label filters', async () => {
|
||||
const { result, updatePrefs } = setup({ filterStatus: 'online' });
|
||||
await act(async () => { await result.current.fetchOverview(); });
|
||||
act(() => result.current.clearFilters());
|
||||
expect(updatePrefs).toHaveBeenCalledWith({ filterStatus: 'all', filterType: 'all', filterCritical: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
|
||||
import { useFleetPolling } from '../useFleetPolling';
|
||||
import type { NodeUpdateStatus } from '../../types';
|
||||
|
||||
function updating(status: NodeUpdateStatus['updateStatus']): NodeUpdateStatus[] {
|
||||
return [{ nodeId: 1, name: 'n', type: 'remote', version: '1', latestVersion: '1', updateAvailable: false, updateStatus: status }];
|
||||
}
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); });
|
||||
|
||||
describe('useFleetPolling', () => {
|
||||
it('fetches both endpoints once on mount', () => {
|
||||
const fetchOverview = vi.fn();
|
||||
const fetchUpdateStatus = vi.fn();
|
||||
renderHook(() => useFleetPolling({ fetchOverview, fetchUpdateStatus, updateStatuses: [] }));
|
||||
expect(fetchOverview).toHaveBeenCalledTimes(1);
|
||||
expect(fetchUpdateStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('polls overview every 30s and update-status every 2m at baseline', () => {
|
||||
const fetchOverview = vi.fn();
|
||||
const fetchUpdateStatus = vi.fn();
|
||||
renderHook(() => useFleetPolling({ fetchOverview, fetchUpdateStatus, updateStatuses: [] }));
|
||||
fetchOverview.mockClear();
|
||||
fetchUpdateStatus.mockClear();
|
||||
|
||||
// 90s -> overview fires 3 times, update-status 0 times (120s interval, no fast poll since not updating).
|
||||
act(() => { vi.advanceTimersByTime(90_000); });
|
||||
expect(fetchOverview).toHaveBeenCalledTimes(3);
|
||||
expect(fetchUpdateStatus).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Reaching 120s total fires the update-status baseline.
|
||||
act(() => { vi.advanceTimersByTime(30_000); });
|
||||
expect(fetchUpdateStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fast-polls both endpoints every 5s while a node is updating', () => {
|
||||
const fetchOverview = vi.fn();
|
||||
const fetchUpdateStatus = vi.fn();
|
||||
const { rerender } = renderHook(
|
||||
({ statuses }) => useFleetPolling({ fetchOverview, fetchUpdateStatus, updateStatuses: statuses }),
|
||||
{ initialProps: { statuses: updating(null) } },
|
||||
);
|
||||
|
||||
// No fast poll while nothing is updating.
|
||||
act(() => { vi.advanceTimersByTime(5_000); });
|
||||
|
||||
// Flip a node to 'updating' -> the 5s tick now drives both fetchers.
|
||||
rerender({ statuses: updating('updating') });
|
||||
fetchOverview.mockClear();
|
||||
fetchUpdateStatus.mockClear();
|
||||
act(() => { vi.advanceTimersByTime(5_000); });
|
||||
|
||||
expect(fetchOverview).toHaveBeenCalled();
|
||||
expect(fetchUpdateStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears all intervals on unmount', () => {
|
||||
const fetchOverview = vi.fn();
|
||||
const fetchUpdateStatus = vi.fn();
|
||||
const { unmount } = renderHook(() => useFleetPolling({ fetchOverview, fetchUpdateStatus, updateStatuses: updating('updating') }));
|
||||
fetchOverview.mockClear();
|
||||
fetchUpdateStatus.mockClear();
|
||||
unmount();
|
||||
act(() => { vi.advanceTimersByTime(120_000); });
|
||||
expect(fetchOverview).not.toHaveBeenCalled();
|
||||
expect(fetchUpdateStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
|
||||
import { useFleetPreferences } from '../useFleetPreferences';
|
||||
|
||||
const PREFS_KEY = 'sencho-fleet-preferences';
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => { localStorage.clear(); vi.restoreAllMocks(); });
|
||||
|
||||
describe('useFleetPreferences', () => {
|
||||
it('starts from defaults when nothing is stored', () => {
|
||||
const { result } = renderHook(() => useFleetPreferences());
|
||||
expect(result.current.prefs).toEqual({
|
||||
sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('persists updates to localStorage', () => {
|
||||
const { result } = renderHook(() => useFleetPreferences());
|
||||
act(() => result.current.updatePrefs({ sortBy: 'cpu', sortDir: 'desc' }));
|
||||
|
||||
expect(result.current.prefs.sortBy).toBe('cpu');
|
||||
expect(result.current.prefs.sortDir).toBe('desc');
|
||||
const stored = JSON.parse(localStorage.getItem(PREFS_KEY) ?? '{}');
|
||||
expect(stored.sortBy).toBe('cpu');
|
||||
expect(stored.sortDir).toBe('desc');
|
||||
});
|
||||
|
||||
it('merges stored prefs over defaults on mount', () => {
|
||||
localStorage.setItem(PREFS_KEY, JSON.stringify({ filterStatus: 'online' }));
|
||||
const { result } = renderHook(() => useFleetPreferences());
|
||||
expect(result.current.prefs.filterStatus).toBe('online');
|
||||
// Unspecified fields fall back to defaults.
|
||||
expect(result.current.prefs.sortBy).toBe('name');
|
||||
});
|
||||
|
||||
it('falls back to defaults when stored JSON is corrupt', () => {
|
||||
localStorage.setItem(PREFS_KEY, '{ not json');
|
||||
const { result } = renderHook(() => useFleetPreferences());
|
||||
expect(result.current.prefs.sortBy).toBe('name');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
|
||||
const apiFetchMock = vi.fn();
|
||||
const toastSuccess = vi.fn();
|
||||
const toastError = vi.fn();
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
|
||||
}));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { success: (...a: unknown[]) => toastSuccess(...a), error: (...a: unknown[]) => toastError(...a) },
|
||||
}));
|
||||
|
||||
import { useFleetUpdateStatus } from '../useFleetUpdateStatus';
|
||||
import type { NodeUpdateStatus } from '../../types';
|
||||
|
||||
function okJson(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
const STATUSES: NodeUpdateStatus[] = [
|
||||
{ nodeId: 1, name: 'Local', type: 'local', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: true, updateStatus: null },
|
||||
{ nodeId: 2, name: 'Edge', type: 'remote', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: true, updateStatus: null },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
apiFetchMock.mockReset();
|
||||
toastSuccess.mockReset();
|
||||
toastError.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('useFleetUpdateStatus', () => {
|
||||
it('fetchUpdateStatus populates updateStatuses from the response', async () => {
|
||||
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
|
||||
const { result } = renderHook(() => useFleetUpdateStatus());
|
||||
|
||||
await act(async () => { await result.current.fetchUpdateStatus(); });
|
||||
expect(result.current.updateStatuses).toHaveLength(2);
|
||||
expect(result.current.updateStatuses[1].name).toBe('Edge');
|
||||
});
|
||||
|
||||
it('logs (does not swallow) a failed update-status poll without toasting', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
apiFetchMock.mockRejectedValue(new Error('network down'));
|
||||
const { result } = renderHook(() => useFleetUpdateStatus());
|
||||
|
||||
await act(async () => { await result.current.fetchUpdateStatus(); });
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('[Fleet] Failed to fetch update status:', expect.any(Error));
|
||||
// Polled call must not toast on failure.
|
||||
expect(toastError).not.toHaveBeenCalled();
|
||||
expect(result.current.updateStatuses).toEqual([]);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs a non-ok update-status response (HTTP error) without toasting', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
apiFetchMock.mockResolvedValue(new Response('boom', { status: 500 }));
|
||||
const { result } = renderHook(() => useFleetUpdateStatus());
|
||||
|
||||
await act(async () => { await result.current.fetchUpdateStatus(); });
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('[Fleet] update-status returned HTTP', 500);
|
||||
expect(toastError).not.toHaveBeenCalled();
|
||||
expect(result.current.updateStatuses).toEqual([]);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('preserves the last-known statuses when a later poll fails', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
// First poll succeeds and seeds two statuses.
|
||||
apiFetchMock.mockResolvedValueOnce(okJson({ nodes: STATUSES }));
|
||||
const { result } = renderHook(() => useFleetUpdateStatus());
|
||||
await act(async () => { await result.current.fetchUpdateStatus(); });
|
||||
expect(result.current.updateStatuses).toHaveLength(2);
|
||||
|
||||
// A subsequent poll fails; the table must keep the seeded statuses.
|
||||
apiFetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
await act(async () => { await result.current.fetchUpdateStatus(); });
|
||||
|
||||
expect(result.current.updateStatuses).toHaveLength(2);
|
||||
expect(result.current.updateStatuses[0].name).toBe('Local');
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(toastError).not.toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('triggerNodeUpdate on a local node opens the confirm dialog instead of POSTing', async () => {
|
||||
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
|
||||
const { result } = renderHook(() => useFleetUpdateStatus());
|
||||
await act(async () => { await result.current.fetchUpdateStatus(); });
|
||||
apiFetchMock.mockClear();
|
||||
|
||||
await act(async () => { await result.current.triggerNodeUpdate(1); });
|
||||
|
||||
expect(result.current.localUpdateConfirm).toBe(1);
|
||||
// No update POST should have fired for the local node.
|
||||
expect(apiFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggerNodeUpdate on a remote node POSTs and toasts success', async () => {
|
||||
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
|
||||
const { result } = renderHook(() => useFleetUpdateStatus());
|
||||
await act(async () => { await result.current.fetchUpdateStatus(); });
|
||||
|
||||
apiFetchMock.mockResolvedValue(okJson({ message: 'ok' }));
|
||||
await act(async () => { await result.current.triggerNodeUpdate(2); });
|
||||
|
||||
expect(apiFetchMock).toHaveBeenCalledWith('/fleet/nodes/2/update', expect.objectContaining({ method: 'POST', localOnly: true }));
|
||||
expect(toastSuccess).toHaveBeenCalledWith(expect.stringContaining('Edge'));
|
||||
});
|
||||
|
||||
it('triggerNodeUpdate surfaces a server error message via toast', async () => {
|
||||
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
|
||||
const { result } = renderHook(() => useFleetUpdateStatus());
|
||||
await act(async () => { await result.current.fetchUpdateStatus(); });
|
||||
|
||||
apiFetchMock.mockResolvedValue(new Response(JSON.stringify({ error: 'node busy' }), { status: 409 }));
|
||||
await act(async () => { await result.current.triggerNodeUpdate(2); });
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('node busy');
|
||||
});
|
||||
|
||||
it('dismissNodeUpdate issues a DELETE then refetches', async () => {
|
||||
apiFetchMock.mockResolvedValue(okJson({ nodes: [] }));
|
||||
const { result } = renderHook(() => useFleetUpdateStatus());
|
||||
|
||||
await act(async () => { await result.current.dismissNodeUpdate(2); });
|
||||
|
||||
expect(apiFetchMock).toHaveBeenCalledWith('/fleet/nodes/2/update-status', expect.objectContaining({ method: 'DELETE' }));
|
||||
});
|
||||
|
||||
it('triggerUpdateAll reports the number of nodes updating', async () => {
|
||||
apiFetchMock.mockResolvedValue(okJson({ updating: ['a', 'b'], skipped: [] }));
|
||||
const { result } = renderHook(() => useFleetUpdateStatus());
|
||||
|
||||
await act(async () => { await result.current.triggerUpdateAll(); });
|
||||
|
||||
expect(apiFetchMock).toHaveBeenCalledWith('/fleet/update-all', expect.objectContaining({ method: 'POST' }));
|
||||
expect(toastSuccess).toHaveBeenCalledWith(expect.stringContaining('2 nodes'));
|
||||
});
|
||||
|
||||
it('checkUpdates opens the modal and toggles the checking flag', async () => {
|
||||
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
|
||||
const { result } = renderHook(() => useFleetUpdateStatus());
|
||||
|
||||
await act(async () => { await result.current.checkUpdates(); });
|
||||
|
||||
expect(result.current.showUpdateModal).toBe(true);
|
||||
expect(result.current.checkingUpdates).toBe(false);
|
||||
await waitFor(() => expect(result.current.updateStatuses).toHaveLength(2));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
const apiFetchMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
|
||||
}));
|
||||
|
||||
import { useNodeLabels } from '../useNodeLabels';
|
||||
import type { FleetNode } from '../../types';
|
||||
|
||||
function okJson(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
function node(id: number): FleetNode {
|
||||
return {
|
||||
id, name: `node-${id}`, type: 'remote', status: 'online',
|
||||
stats: null, systemStats: null, stacks: null,
|
||||
cordoned: false, cordoned_at: null, cordoned_reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => apiFetchMock.mockReset());
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('useNodeLabels (node-tag aggregation stays paid)', () => {
|
||||
it('does not fetch and reports unavailable when not paid', async () => {
|
||||
const { result } = renderHook(() => useNodeLabels({ isPaid: false, nodes: [node(2)] }));
|
||||
expect(result.current.isAvailable).toBe(false);
|
||||
expect(result.current.labelsByNodeId).toEqual({});
|
||||
// Allow any effect to flush; still no call.
|
||||
await Promise.resolve();
|
||||
expect(apiFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches and normalizes string keys to numbers when paid', async () => {
|
||||
apiFetchMock.mockResolvedValue(okJson({ '2': ['prod', 'edge'], '3': ['db'] }));
|
||||
|
||||
const { result } = renderHook(() => useNodeLabels({ isPaid: true, nodes: [node(2), node(3)] }));
|
||||
|
||||
await waitFor(() => expect(Object.keys(result.current.labelsByNodeId).length).toBe(2));
|
||||
expect(result.current.isAvailable).toBe(true);
|
||||
expect(result.current.labelsByNodeId[2]).toEqual(['prod', 'edge']);
|
||||
expect(result.current.labelsByNodeId[3]).toEqual(['db']);
|
||||
// distinctLabels is the sorted union.
|
||||
expect(result.current.distinctLabels).toEqual(['db', 'edge', 'prod']);
|
||||
expect(apiFetchMock).toHaveBeenCalledWith('/node-labels', expect.objectContaining({ localOnly: true }));
|
||||
});
|
||||
|
||||
it('clears the map on a non-ok response', async () => {
|
||||
apiFetchMock.mockResolvedValue(new Response('nope', { status: 403 }));
|
||||
const { result } = renderHook(() => useNodeLabels({ isPaid: true, nodes: [node(2)] }));
|
||||
await waitFor(() => expect(apiFetchMock).toHaveBeenCalled());
|
||||
expect(result.current.labelsByNodeId).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -8,16 +8,19 @@ export function labelPaletteKey(name: string, color: LabelColor): string {
|
||||
}
|
||||
|
||||
interface UseFleetLabelsOptions {
|
||||
isPaid: boolean;
|
||||
nodes: FleetNode[];
|
||||
}
|
||||
|
||||
export function useFleetLabels({ isPaid, nodes }: UseFleetLabelsOptions) {
|
||||
// Stack labels are a Community feature (the per-node `/labels` and
|
||||
// `/labels/assignments` reads are open to any authenticated user), so the
|
||||
// fleet-wide palette and per-stack chips render on every tier. Node-level tag
|
||||
// aggregation stays paid and lives in useNodeLabels.
|
||||
export function useFleetLabels({ nodes }: UseFleetLabelsOptions) {
|
||||
const [fleetPalette, setFleetPalette] = useState<FleetPaletteEntry[]>([]);
|
||||
const [fleetStackLabelMap, setFleetStackLabelMap] = useState<Record<number, Record<string, StackLabel[]>>>({});
|
||||
|
||||
const fetchLabelsForNodes = useCallback(async (fleetNodes: FleetNode[]) => {
|
||||
if (!isPaid || fleetNodes.length === 0) return;
|
||||
if (fleetNodes.length === 0) return;
|
||||
|
||||
const paletteMap = new Map<string, FleetPaletteEntry>();
|
||||
const stackLabelMap: Record<number, Record<string, StackLabel[]>> = {};
|
||||
@@ -48,7 +51,7 @@ export function useFleetLabels({ isPaid, nodes }: UseFleetLabelsOptions) {
|
||||
|
||||
setFleetPalette(Array.from(paletteMap.values()).sort((a, b) => a.name.localeCompare(b.name)));
|
||||
setFleetStackLabelMap(stackLabelMap);
|
||||
}, [isPaid]);
|
||||
}, []);
|
||||
|
||||
// Refetch labels only when the set of online nodes actually changes,
|
||||
// not on every fetchOverview tick (which mints a new nodes ref).
|
||||
@@ -62,10 +65,10 @@ export function useFleetLabels({ isPaid, nodes }: UseFleetLabelsOptions) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPaid || nodes.length === 0) return;
|
||||
if (nodes.length === 0) return;
|
||||
fetchLabelsForNodes(nodes);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isPaid, onlineNodeKey, fetchLabelsForNodes]);
|
||||
}, [onlineNodeKey, fetchLabelsForNodes]);
|
||||
|
||||
return { fleetPalette, fleetStackLabelMap };
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses }:
|
||||
const [labelFilters, setLabelFilters] = useState<Set<string>>(new Set());
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const { fleetPalette, fleetStackLabelMap } = useFleetLabels({ isPaid, nodes });
|
||||
const { fleetPalette, fleetStackLabelMap } = useFleetLabels({ nodes });
|
||||
const { labelsByNodeId, distinctLabels, isAvailable: nodeLabelsAvailable } = useNodeLabels({ isPaid, nodes });
|
||||
|
||||
const fetchOverview = useCallback(async (showRefresh = false) => {
|
||||
|
||||
@@ -26,8 +26,18 @@ export function useFleetUpdateStatus() {
|
||||
setUpdateStatuses(prev =>
|
||||
JSON.stringify(prev) === JSON.stringify(next) ? prev : next
|
||||
);
|
||||
} else {
|
||||
// apiFetch only throws on 401/network, so an HTTP error (500/403/
|
||||
// 502) lands here, not in the catch. Log it so the breadcrumb
|
||||
// covers backend failures too; keep last-known statuses.
|
||||
console.warn('[Fleet] update-status returned HTTP', res.status);
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
} catch (error) {
|
||||
// Polled call (every 5s while updating, 2m otherwise): log for
|
||||
// diagnosis but stay silent in the UI so a transient failure does
|
||||
// not toast on every tick. The view keeps its last-known statuses.
|
||||
console.warn('[Fleet] Failed to fetch update status:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const triggerNodeUpdate = useCallback(async (nodeId: number) => {
|
||||
|
||||
Reference in New Issue
Block a user