From c26665a63a2d82d5f5835e83d04b365dc7a903cf Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 26 May 2026 19:58:15 +0100 Subject: [PATCH] Delete more orphans: deploy wizard steps, storage diagnostics, recovery hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleventh round of orphan deletion. The platform-first IA migration left many UI components and presentation utilities behind whose only consumers were already-deleted aggregate surfaces, deploy wizard, or recovery pages. Storage: - ZFSHealthMap.tsx — diagnostic indicator only used by the deleted Storage standalone summary - DiskLiveMetric.tsx — live metric bar only used by deleted summary - storageSourceOptions.ts + test — options helper for deleted controls Infrastructure: - resourceBadges.ts + test — badge helpers only used by deleted ConfiguredNodeTables / MonitoredSystemLedgerPanel - deploy/CandidatesStep, ConfirmStep, DeployingStep, PreflightStep, ResultsStep + their tests — the deploy wizard retired with AgentDeployModal Shared: - DensityMap, SparklineSkeleton, InfrastructureDetailsDrawer (and tests) — primitives only consumed by deleted summary chrome - CollapsibleSearchInput.tsx — collapsing search variant with no current consumer - ScrollToTopButton.tsx + test — only used by deleted WorkloadsSurface chrome Utils: - clusterEndpointPresentation, recoveryTablePresentation, configuredNodeCapabilityPresentation, configuredNodeStatusPresentation (with tests) — all consumed only by deleted surfaces Hooks: - useRecoveryPointsSeries / useRecoveryPointsFacets / useRecoveryRollups — recovery hooks for the retired aggregate surface - useDebouncedValue + test — generic debounce with no consumer Test files: - DeployStepComponents.test.tsx — entire file tested deleted wizard - SearchInput.test.tsx — dropped 3 tests that exercised CollapsibleSearchInput plus the now-unused focusActiveTypeToSearch import Strips ?raw guardrail imports + assertions for every deleted file from SharedPrimitives.guardrails and frontendResourceTypeBoundaries. --- .../__tests__/DeployStepComponents.test.tsx | 462 ------------- .../__tests__/resourceBadges.test.ts | 77 --- .../Infrastructure/deploy/CandidatesStep.tsx | 201 ------ .../Infrastructure/deploy/ConfirmStep.tsx | 114 ---- .../Infrastructure/deploy/DeployingStep.tsx | 105 --- .../Infrastructure/deploy/PreflightStep.tsx | 93 --- .../Infrastructure/deploy/ResultsStep.tsx | 245 ------- .../deploy/__tests__/CandidatesStep.test.tsx | 569 ---------------- .../deploy/__tests__/ConfirmStep.test.tsx | 282 -------- .../deploy/__tests__/DeployingStep.test.tsx | 421 ------------ .../deploy/__tests__/PreflightStep.test.tsx | 496 -------------- .../deploy/__tests__/ResultsStep.test.tsx | 614 ------------------ .../Infrastructure/resourceBadges.ts | 12 - .../src/components/Storage/DiskLiveMetric.tsx | 15 - .../src/components/Storage/ZFSHealthMap.tsx | 83 --- .../__tests__/storageSourceOptions.test.ts | 87 --- .../Storage/storageSourceOptions.ts | 7 - .../shared/CollapsibleSearchInput.tsx | 60 -- .../src/components/shared/DensityMap.tsx | 118 ---- .../shared/InfrastructureDetailsDrawer.tsx | 120 ---- .../shared/ScrollToTopButton.test.tsx | 57 -- .../components/shared/ScrollToTopButton.tsx | 24 - .../SharedPrimitives.guardrails.test.ts | 64 -- .../components/shared/SparklineSkeleton.tsx | 49 -- .../shared/__tests__/DensityMap.test.tsx | 475 -------------- .../InfrastructureDetailsDrawer.test.tsx | 190 ------ .../shared/__tests__/SearchInput.test.tsx | 85 --- .../hooks/__tests__/useDebouncedValue.test.ts | 91 --- .../src/hooks/useDebouncedValue.ts | 22 - .../src/hooks/useRecoveryPointsFacets.ts | 152 ----- .../src/hooks/useRecoveryPointsSeries.ts | 138 ---- .../src/hooks/useRecoveryRollups.ts | 142 ---- .../clusterEndpointPresentation.test.ts | 38 -- ...nfiguredNodeCapabilityPresentation.test.ts | 81 --- .../configuredNodeStatusPresentation.test.ts | 51 -- .../frontendResourceTypeBoundaries.test.ts | 126 ---- .../recoveryTablePresentation.test.ts | 178 ----- .../src/utils/clusterEndpointPresentation.ts | 60 -- .../configuredNodeCapabilityPresentation.ts | 111 ---- .../utils/configuredNodeStatusPresentation.ts | 73 --- .../src/utils/recoveryTablePresentation.ts | 241 ------- 41 files changed, 6629 deletions(-) delete mode 100644 frontend-modern/src/components/Infrastructure/__tests__/DeployStepComponents.test.tsx delete mode 100644 frontend-modern/src/components/Infrastructure/__tests__/resourceBadges.test.ts delete mode 100644 frontend-modern/src/components/Infrastructure/deploy/CandidatesStep.tsx delete mode 100644 frontend-modern/src/components/Infrastructure/deploy/ConfirmStep.tsx delete mode 100644 frontend-modern/src/components/Infrastructure/deploy/DeployingStep.tsx delete mode 100644 frontend-modern/src/components/Infrastructure/deploy/PreflightStep.tsx delete mode 100644 frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx delete mode 100644 frontend-modern/src/components/Infrastructure/deploy/__tests__/CandidatesStep.test.tsx delete mode 100644 frontend-modern/src/components/Infrastructure/deploy/__tests__/ConfirmStep.test.tsx delete mode 100644 frontend-modern/src/components/Infrastructure/deploy/__tests__/DeployingStep.test.tsx delete mode 100644 frontend-modern/src/components/Infrastructure/deploy/__tests__/PreflightStep.test.tsx delete mode 100644 frontend-modern/src/components/Infrastructure/deploy/__tests__/ResultsStep.test.tsx delete mode 100644 frontend-modern/src/components/Infrastructure/resourceBadges.ts delete mode 100644 frontend-modern/src/components/Storage/DiskLiveMetric.tsx delete mode 100644 frontend-modern/src/components/Storage/ZFSHealthMap.tsx delete mode 100644 frontend-modern/src/components/Storage/__tests__/storageSourceOptions.test.ts delete mode 100644 frontend-modern/src/components/Storage/storageSourceOptions.ts delete mode 100644 frontend-modern/src/components/shared/CollapsibleSearchInput.tsx delete mode 100644 frontend-modern/src/components/shared/DensityMap.tsx delete mode 100644 frontend-modern/src/components/shared/InfrastructureDetailsDrawer.tsx delete mode 100644 frontend-modern/src/components/shared/ScrollToTopButton.test.tsx delete mode 100644 frontend-modern/src/components/shared/ScrollToTopButton.tsx delete mode 100644 frontend-modern/src/components/shared/SparklineSkeleton.tsx delete mode 100644 frontend-modern/src/components/shared/__tests__/DensityMap.test.tsx delete mode 100644 frontend-modern/src/components/shared/__tests__/InfrastructureDetailsDrawer.test.tsx delete mode 100644 frontend-modern/src/hooks/__tests__/useDebouncedValue.test.ts delete mode 100644 frontend-modern/src/hooks/useDebouncedValue.ts delete mode 100644 frontend-modern/src/hooks/useRecoveryPointsFacets.ts delete mode 100644 frontend-modern/src/hooks/useRecoveryPointsSeries.ts delete mode 100644 frontend-modern/src/hooks/useRecoveryRollups.ts delete mode 100644 frontend-modern/src/utils/__tests__/clusterEndpointPresentation.test.ts delete mode 100644 frontend-modern/src/utils/__tests__/configuredNodeCapabilityPresentation.test.ts delete mode 100644 frontend-modern/src/utils/__tests__/configuredNodeStatusPresentation.test.ts delete mode 100644 frontend-modern/src/utils/__tests__/recoveryTablePresentation.test.ts delete mode 100644 frontend-modern/src/utils/clusterEndpointPresentation.ts delete mode 100644 frontend-modern/src/utils/configuredNodeCapabilityPresentation.ts delete mode 100644 frontend-modern/src/utils/configuredNodeStatusPresentation.ts delete mode 100644 frontend-modern/src/utils/recoveryTablePresentation.ts diff --git a/frontend-modern/src/components/Infrastructure/__tests__/DeployStepComponents.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/DeployStepComponents.test.tsx deleted file mode 100644 index 7bc933e17a..0000000000 --- a/frontend-modern/src/components/Infrastructure/__tests__/DeployStepComponents.test.tsx +++ /dev/null @@ -1,462 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, cleanup, waitFor } from '@solidjs/testing-library'; -import type { Accessor } from 'solid-js'; -import type { DeployTarget } from '@/types/agentDeploy'; - -/* ── hoisted mocks ────────────────────────────────────────────── */ - -vi.mock('../deploy/DeployStatusBadge', () => ({ - DeployStatusBadge: (props: { status: string }) => ( - {props.status} - ), -})); - -vi.mock('lucide-solid/icons/check', () => ({ default: () => })); -vi.mock('lucide-solid/icons/alert-circle', () => ({ default: () => })); -vi.mock('lucide-solid/icons/check-circle-2', () => ({ default: () => })); -vi.mock('lucide-solid/icons/x-circle', () => ({ default: () => })); -vi.mock('lucide-solid/icons/loader-2', () => ({ default: () => })); -vi.mock('lucide-solid/icons/chevron-down', () => ({ default: () => })); -vi.mock('lucide-solid/icons/chevron-right', () => ({ default: () => })); - -const { getAgentInstallCommandMock } = vi.hoisted(() => ({ - getAgentInstallCommandMock: vi.fn(), -})); - -vi.mock('@/api/nodes', () => ({ - NodesAPI: { - getAgentInstallCommand: getAgentInstallCommandMock, - }, -})); - -/* ── component imports (after mocks) ──────────────────────────── */ - -import { CandidatesStep } from '../deploy/CandidatesStep'; -import { PreflightStep } from '../deploy/PreflightStep'; -import { ConfirmStep } from '../deploy/ConfirmStep'; -import { DeployingStep } from '../deploy/DeployingStep'; -import { ResultsStep } from '../deploy/ResultsStep'; - -/* ── helpers ──────────────────────────────────────────────────── */ - -function makeTarget(overrides: Partial = {}): DeployTarget { - return { - id: 't1', - jobId: 'j1', - nodeId: 'n1', - nodeName: 'node1', - nodeIP: '10.0.0.1', - status: 'pending', - attempts: 0, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -/** Creates a minimal mock wizard with only the signals needed by the component. */ -function createMockWizard(overrides: Record = {}) { - const sig = (key: string, def: T): Accessor => { - const val = overrides[key] as T | undefined; - return () => val ?? def; - }; - - return { - // Candidates - candidates: sig('candidates', []), - candidatesLoading: sig('candidatesLoading', false), - candidatesError: sig('candidatesError', ''), - sourceAgents: sig('sourceAgents', []), - onlineSourceAgents: sig('onlineSourceAgents', []), - selectedSourceAgent: sig('selectedSourceAgent', ''), - setSelectedSourceAgent: (overrides.setSelectedSourceAgent as (v: string) => void) ?? vi.fn(), - selectedNodeIds: sig('selectedNodeIds', new Set()), - toggleNodeSelection: (overrides.toggleNodeSelection as (id: string) => void) ?? vi.fn(), - selectAllNodes: (overrides.selectAllNodes as () => void) ?? vi.fn(), - deselectAllNodes: (overrides.deselectAllNodes as () => void) ?? vi.fn(), - deployableNodes: sig('deployableNodes', []), - // Preflight - preflightTargets: sig('preflightTargets', []), - preflightError: sig('preflightError', ''), - readyNodes: sig('readyNodes', []), - failedPreflightNodes: sig('failedPreflightNodes', []), - // Confirm - confirmSelectedNodeIds: sig('confirmSelectedNodeIds', new Set()), - toggleConfirmNode: (overrides.toggleConfirmNode as (id: string) => void) ?? vi.fn(), - // Deploy - jobTargets: sig('jobTargets', []), - deployError: sig('deployError', ''), - // Results - succeededTargets: sig('succeededTargets', []), - failedTargets: sig('failedTargets', []), - retryableTargets: sig('retryableTargets', []), - skippedTargets: sig('skippedTargets', []), - canceledTargets: sig('canceledTargets', []), - } as never; // Cast to avoid full DeployWizardState typing -} - -/* ── lifecycle ────────────────────────────────────────────────── */ - -beforeEach(() => vi.clearAllMocks()); -afterEach(() => cleanup()); - -/* ================================================================ - CandidatesStep - ================================================================ */ - -describe('CandidatesStep', () => { - it('shows loading state', () => { - const wizard = createMockWizard({ candidatesLoading: true }); - render(() => ); - expect(screen.getByText('Loading cluster nodes...')).toBeInTheDocument(); - }); - - it('shows error message', () => { - const wizard = createMockWizard({ candidatesError: 'Network failure' }); - render(() => ); - expect(screen.getByText('Network failure')).toBeInTheDocument(); - }); - - it('shows empty state when no candidates', () => { - const wizard = createMockWizard({ candidates: [] }); - render(() => ); - expect(screen.getByText('No nodes found in this cluster.')).toBeInTheDocument(); - }); - - it('renders candidate nodes in table', () => { - const wizard = createMockWizard({ - candidates: [ - { nodeId: 'n1', name: 'pve-node1', ip: '10.0.0.1', hasAgent: false, deployable: true }, - { nodeId: 'n2', name: 'pve-node2', ip: '10.0.0.2', hasAgent: true, deployable: false }, - ], - deployableNodes: [{ nodeId: 'n1' }], - selectedNodeIds: new Set(['n1']), - }); - render(() => ); - - expect(screen.getByText('pve-node1')).toBeInTheDocument(); - expect(screen.getByText('pve-node2')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.1')).toBeInTheDocument(); - expect(screen.getByText('Already monitored')).toBeInTheDocument(); - }); - - it('shows selection count', () => { - const wizard = createMockWizard({ - candidates: [{ nodeId: 'n1', name: 'a', ip: '1', hasAgent: false, deployable: true }], - deployableNodes: [{ nodeId: 'n1' }], - selectedNodeIds: new Set(['n1']), - }); - render(() => ); - expect(screen.getByText('1 of 1 nodes selected')).toBeInTheDocument(); - }); - - it('shows source agent warning when none online', () => { - const wizard = createMockWizard({ - candidates: [{ nodeId: 'n1', name: 'a', ip: '1', hasAgent: false, deployable: true }], - onlineSourceAgents: [], - }); - render(() => ); - expect(screen.getByText(/No online source agents found/)).toBeInTheDocument(); - }); - - it('shows source agent dropdown when multiple online', () => { - const wizard = createMockWizard({ - candidates: [{ nodeId: 'n1', name: 'a', ip: '1', hasAgent: false, deployable: true }], - onlineSourceAgents: [ - { agentId: 'a1', nodeId: 'n1', online: true }, - { agentId: 'a2', nodeId: 'n2', online: true }, - ], - deployableNodes: [{ nodeId: 'n1' }], - selectedNodeIds: new Set(), - }); - render(() => ); - expect(screen.getByText('Source Agent')).toBeInTheDocument(); - expect(screen.getByText('Select a source agent...')).toBeInTheDocument(); - }); - - it('calls toggleNodeSelection on row click', () => { - const toggleMock = vi.fn(); - const wizard = createMockWizard({ - candidates: [{ nodeId: 'n1', name: 'clickable', ip: '1', hasAgent: false, deployable: true }], - deployableNodes: [{ nodeId: 'n1' }], - selectedNodeIds: new Set(), - toggleNodeSelection: toggleMock, - }); - render(() => ); - fireEvent.click(screen.getByText('clickable')); - expect(toggleMock).toHaveBeenCalledWith('n1'); - }); -}); - -/* ================================================================ - PreflightStep - ================================================================ */ - -describe('PreflightStep', () => { - it('shows progress when checks are ongoing', () => { - const wizard = createMockWizard({ - preflightTargets: [ - makeTarget({ id: 't1', status: 'ready' }), - makeTarget({ id: 't2', nodeName: 'node2', status: 'preflighting' }), - ], - }); - render(() => ); - expect(screen.getByText(/Checking 1 of 2 nodes/)).toBeInTheDocument(); - }); - - it('shows completion when all checks done', () => { - const wizard = createMockWizard({ - preflightTargets: [ - makeTarget({ id: 't1', status: 'ready' }), - makeTarget({ id: 't2', status: 'failed_permanent' }), - ], - }); - render(() => ); - expect(screen.getByText('Preflight checks complete')).toBeInTheDocument(); - }); - - it('renders target rows with status badges', () => { - const wizard = createMockWizard({ - preflightTargets: [ - makeTarget({ id: 't1', nodeName: 'node-a', nodeIP: '10.0.0.1', status: 'ready' }), - ], - }); - render(() => ); - expect(screen.getByText('node-a')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.1')).toBeInTheDocument(); - expect(screen.getByTestId('badge-ready')).toBeInTheDocument(); - }); - - it('shows error message for failed targets', () => { - const wizard = createMockWizard({ - preflightTargets: [ - makeTarget({ status: 'failed_permanent', errorMessage: 'SSH unreachable' }), - ], - }); - render(() => ); - expect(screen.getByText('SSH unreachable')).toBeInTheDocument(); - }); - - it('shows preflight error banner', () => { - const wizard = createMockWizard({ - preflightError: 'Connection lost', - preflightTargets: [], - }); - render(() => ); - expect(screen.getByText('Connection lost')).toBeInTheDocument(); - }); -}); - -/* ================================================================ - ConfirmStep - ================================================================ */ - -describe('ConfirmStep', () => { - it('renders ready nodes with checkboxes', () => { - const wizard = createMockWizard({ - readyNodes: [ - makeTarget({ id: 't1', nodeId: 'n1', nodeName: 'node-1', nodeIP: '10.0.0.1', status: 'ready' }), - ], - confirmSelectedNodeIds: new Set(['n1']), - }); - render(() => ); - expect(screen.getByText('node-1')).toBeInTheDocument(); - expect(screen.getByText('Ready to deploy (1)')).toBeInTheDocument(); - }); - - it('renders failed preflight nodes', () => { - const wizard = createMockWizard({ - failedPreflightNodes: [ - makeTarget({ id: 't1', nodeName: 'bad-node', status: 'failed_permanent', errorMessage: 'SSH failed' }), - ], - }); - render(() => ); - expect(screen.getByText('bad-node')).toBeInTheDocument(); - expect(screen.getByText('Cannot deploy (1)')).toBeInTheDocument(); - expect(screen.getByText('SSH failed')).toBeInTheDocument(); - }); - - it('calls toggleConfirmNode on row click', () => { - const toggleMock = vi.fn(); - const wizard = createMockWizard({ - readyNodes: [ - makeTarget({ id: 't1', nodeId: 'n1', nodeName: 'click-me', status: 'ready' }), - ], - confirmSelectedNodeIds: new Set(), - toggleConfirmNode: toggleMock, - }); - render(() => ); - fireEvent.click(screen.getByText('click-me')); - expect(toggleMock).toHaveBeenCalledWith('n1'); - }); -}); - -/* ================================================================ - DeployingStep - ================================================================ */ - -describe('DeployingStep', () => { - it('shows progress count', () => { - const wizard = createMockWizard({ - jobTargets: [ - makeTarget({ id: 't1', status: 'succeeded' }), - makeTarget({ id: 't2', nodeName: 'node2', status: 'installing' }), - makeTarget({ id: 't3', nodeName: 'node3', status: 'pending' }), - ], - }); - render(() => ); - expect(screen.getByText(/Installing 1 of 3 nodes/)).toBeInTheDocument(); - expect(screen.getByText(/1 in progress/)).toBeInTheDocument(); - }); - - it('renders target rows with status badges', () => { - const wizard = createMockWizard({ - jobTargets: [ - makeTarget({ id: 't1', nodeName: 'node-x', nodeIP: '10.1.1.1', status: 'installing' }), - ], - }); - render(() => ); - expect(screen.getByText('node-x')).toBeInTheDocument(); - expect(screen.getByText('10.1.1.1')).toBeInTheDocument(); - expect(screen.getByTestId('badge-installing')).toBeInTheDocument(); - }); - - it('shows deploy error banner', () => { - const wizard = createMockWizard({ - deployError: 'SSE connection lost', - jobTargets: [], - }); - render(() => ); - expect(screen.getByText('SSE connection lost')).toBeInTheDocument(); - }); - - it('shows error message for failed targets', () => { - const wizard = createMockWizard({ - jobTargets: [ - makeTarget({ status: 'failed_retryable', errorMessage: 'Exit 1: permission denied' }), - ], - }); - render(() => ); - expect(screen.getByText('Exit 1: permission denied')).toBeInTheDocument(); - }); -}); - -/* ================================================================ - ResultsStep - ================================================================ */ - -describe('ResultsStep', () => { - it('shows succeeded section with count', () => { - const wizard = createMockWizard({ - succeededTargets: [ - makeTarget({ id: 't1', nodeName: 'good-node', status: 'succeeded' }), - ], - }); - render(() => ); - expect(screen.getByText('Deployed (1)')).toBeInTheDocument(); - expect(screen.getByText('good-node')).toBeInTheDocument(); - }); - - it('shows failed section with error messages', () => { - const wizard = createMockWizard({ - failedTargets: [ - makeTarget({ id: 't1', nodeName: 'bad-node', status: 'failed_retryable', errorMessage: 'Install error' }), - ], - }); - render(() => ); - expect(screen.getByText('Failed (1)')).toBeInTheDocument(); - expect(screen.getByText('bad-node')).toBeInTheDocument(); - expect(screen.getByText('Install error')).toBeInTheDocument(); - }); - - it('shows skipped section', () => { - const wizard = createMockWizard({ - skippedTargets: [ - makeTarget({ id: 't1', nodeName: 'skip-node', status: 'skipped_already_agent' }), - ], - }); - render(() => ); - expect(screen.getByText('Skipped (1)')).toBeInTheDocument(); - expect(screen.getByText('skip-node')).toBeInTheDocument(); - }); - - it('shows canceled section', () => { - const wizard = createMockWizard({ - canceledTargets: [ - makeTarget({ id: 't1', nodeName: 'cancel-node', status: 'canceled' }), - ], - }); - render(() => ); - expect(screen.getByText('Canceled (1)')).toBeInTheDocument(); - expect(screen.getByText('cancel-node')).toBeInTheDocument(); - }); - - it('hides sections with zero targets', () => { - const wizard = createMockWizard({ - succeededTargets: [], - failedTargets: [], - skippedTargets: [], - canceledTargets: [], - }); - render(() => ); - expect(screen.queryByText(/Deployed/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Failed/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Skipped/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Canceled/)).not.toBeInTheDocument(); - }); - - it('toggles manual install instructions on click', () => { - const wizard = createMockWizard({ - failedTargets: [makeTarget({ status: 'failed_retryable' })], - }); - render(() => ); - - // Initially hidden - expect(screen.queryByText(/SSHing into the node/)).not.toBeInTheDocument(); - - // Click to expand - fireEvent.click(screen.getByText('Manual install instructions')); - expect(screen.getByText(/SSHing into the node/)).toBeInTheDocument(); - - // Click to collapse - fireEvent.click(screen.getByText('Manual install instructions')); - expect(screen.queryByText(/SSHing into the node/)).not.toBeInTheDocument(); - }); - - it('fetches the canonical backend install command for manual fallback', async () => { - getAgentInstallCommandMock.mockResolvedValue({ - command: 'curl -fsSL https://pulse.example/install.sh | bash -s -- --url https://pulse.example --token token-123', - }); - const wizard = createMockWizard({ - failedTargets: [makeTarget({ status: 'failed_retryable' })], - }); - - render(() => ); - - fireEvent.click(screen.getByText('Manual install instructions')); - - await waitFor(() => { - expect(getAgentInstallCommandMock).toHaveBeenCalledWith({ type: 'pve', enableProxmox: true }); - }); - expect(screen.getByText(/curl -fsSL https:\/\/pulse\.example\/install\.sh/)).toBeInTheDocument(); - }); - - it('surfaces canonical install-command response validation failures in manual fallback', async () => { - getAgentInstallCommandMock.mockRejectedValue( - new Error('Invalid agent install command response'), - ); - const wizard = createMockWizard({ - failedTargets: [makeTarget({ status: 'failed_retryable' })], - }); - - render(() => ); - - fireEvent.click(screen.getByText('Manual install instructions')); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent( - 'Invalid agent install command response', - ); - }); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceBadges.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceBadges.test.ts deleted file mode 100644 index 780fa9e1d5..0000000000 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceBadges.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { getTypeBadge, getUnifiedSourceBadges } from '../resourceBadges'; - -describe('getUnifiedSourceBadges', () => { - it('returns empty array for null/undefined/empty sources', () => { - expect(getUnifiedSourceBadges(null)).toEqual([]); - expect(getUnifiedSourceBadges(undefined)).toEqual([]); - expect(getUnifiedSourceBadges([])).toEqual([]); - }); - - it('returns badges for known sources', () => { - const badges = getUnifiedSourceBadges(['proxmox-pve', 'docker']); - expect(badges).toHaveLength(2); - expect(badges[0].label).toBe('PVE'); - expect(badges[1].label).toBe('Docker / Podman'); - }); - - it('includes TrueNAS source badge', () => { - const badges = getUnifiedSourceBadges(['truenas']); - expect(badges).toHaveLength(1); - expect(badges[0].label).toBe('TrueNAS'); - expect(badges[0].title).toBe('TrueNAS'); - expect(badges[0].classes).toContain('bg-blue-100'); - }); - - it('normalizes source names to lowercase', () => { - const badges = getUnifiedSourceBadges(['TrueNAS', 'PROXMOX']); - expect(badges).toHaveLength(2); - expect(badges.map((badge) => badge.label)).toContain('TrueNAS'); - expect(badges.map((badge) => badge.label)).toContain('PVE'); - }); - - it('deduplicates sources', () => { - const badges = getUnifiedSourceBadges(['truenas', 'TrueNAS', 'truenas']); - expect(badges).toHaveLength(1); - }); - - it('filters out unknown sources', () => { - const badges = getUnifiedSourceBadges(['truenas', 'unknown', 'foobar']); - expect(badges).toHaveLength(1); - expect(badges[0].label).toBe('TrueNAS'); - }); - - it('returns badges for all supported sources', () => { - const allSources = [ - 'proxmox-pve', - 'agent', - 'docker', - 'proxmox-pbs', - 'proxmox-pmg', - 'kubernetes', - 'truenas', - ]; - const badges = getUnifiedSourceBadges(allSources); - expect(badges).toHaveLength(7); - }); -}); - -describe('getTypeBadge', () => { - it('returns Agent badge for canonical agent type', () => { - const badge = getTypeBadge('agent'); - expect(badge?.label).toBe('Agent'); - expect(badge?.title).toBe('agent'); - }); - - it('normalizes legacy host alias to Agent', () => { - const badge = getTypeBadge('host'); - expect(badge?.label).toBe('Agent'); - expect(badge?.title).toBe('agent'); - }); - - it('normalizes legacy docker host alias tokens to Container Runtime', () => { - const badge = getTypeBadge('docker_host'); - expect(badge?.label).toBe('Container Runtime'); - expect(badge?.title).toBe('docker-host'); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/deploy/CandidatesStep.tsx b/frontend-modern/src/components/Infrastructure/deploy/CandidatesStep.tsx deleted file mode 100644 index d2b6d3f674..0000000000 --- a/frontend-modern/src/components/Infrastructure/deploy/CandidatesStep.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { Component, For, Show, createMemo } from 'solid-js'; -import type { DeployWizardState } from '@/hooks/useDeployWizard'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/shared/Table'; -import { - getDeployCandidatesLoadingState, - getDeployNoCandidatesState, - getDeployNoSourceAgentsState, -} from '@/utils/deployFlowPresentation'; -import CheckIcon from 'lucide-solid/icons/check'; -import AlertCircleIcon from 'lucide-solid/icons/alert-circle'; - -interface CandidatesStepProps { - wizard: DeployWizardState; -} - -export const CandidatesStep: Component = (props) => { - const w = props.wizard; - const allSelected = createMemo( - () => w.deployableNodes().length > 0 && w.selectedNodeIds().size === w.deployableNodes().length, - ); - - return ( -
- - - - - -
-
- {getDeployCandidatesLoadingState()} -
- - - - {/* Source agent selection */} - 1}> -
- - -

- The source agent will SSH to other nodes to install Pulse agents. -

-
-
- - - - - - {/* Node selection table */} - 0}> -
-
- - {w.selectedNodeIds().size} of {w.deployableNodes().length} nodes selected - -
- - -
-
- - - - - - Node - IP - Status - - - - - {(node) => { - const isDisabled = () => node.hasAgent || !node.deployable; - const isChecked = () => w.selectedNodeIds().has(node.nodeId); - - return ( - { - if (!isDisabled()) w.toggleNodeSelection(node.nodeId); - }} - onKeyDown={(e) => { - if (!isDisabled() && (e.key === 'Enter' || e.key === ' ')) { - e.preventDefault(); - w.toggleNodeSelection(node.nodeId); - } - }} - > - - w.toggleNodeSelection(node.nodeId)} - onClick={(e) => e.stopPropagation()} - class="rounded border-border" - /> - - - {node.name} - - - {node.ip || '--'} - - - - - {node.reason || 'Not deployable'} - - } - > - Available - - } - > - - - Already monitored - - - - - ); - }} - - -
-
-
- - -
{getDeployNoCandidatesState()}
-
-
-
- ); -}; diff --git a/frontend-modern/src/components/Infrastructure/deploy/ConfirmStep.tsx b/frontend-modern/src/components/Infrastructure/deploy/ConfirmStep.tsx deleted file mode 100644 index de6cc9a328..0000000000 --- a/frontend-modern/src/components/Infrastructure/deploy/ConfirmStep.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { Component, For, Show } from 'solid-js'; -import type { DeployWizardState } from '@/hooks/useDeployWizard'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/shared/Table'; -import CheckCircleIcon from 'lucide-solid/icons/check-circle-2'; - -interface ConfirmStepProps { - wizard: DeployWizardState; -} - -export const ConfirmStep: Component = (props) => { - const w = props.wizard; - - return ( -
- {/* Ready nodes */} - 0}> -
-

- - Ready to deploy ({w.readyNodes().length}) -

- - - - - Node - IP - Arch - - - - - {(target) => ( - w.toggleConfirmNode(target.nodeId)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - w.toggleConfirmNode(target.nodeId); - } - }} - > - - w.toggleConfirmNode(target.nodeId)} - onClick={(e) => e.stopPropagation()} - class="rounded border-border" - /> - - - {target.nodeName} - - - {target.nodeIP} - - - {target.arch || 'amd64'} - - - )} - - -
-
-
- - {/* Failed preflight nodes */} - 0}> -
-

- Cannot deploy ({w.failedPreflightNodes().length}) -

- - - - Node - IP - Reason - - - - - {(target) => ( - - - {target.nodeName} - - - {target.nodeIP} - - - {target.errorMessage || 'Preflight failed'} - - - )} - - -
-
-
-
- ); -}; diff --git a/frontend-modern/src/components/Infrastructure/deploy/DeployingStep.tsx b/frontend-modern/src/components/Infrastructure/deploy/DeployingStep.tsx deleted file mode 100644 index 79a0c455cc..0000000000 --- a/frontend-modern/src/components/Infrastructure/deploy/DeployingStep.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { Component, For, Show, createMemo } from 'solid-js'; -import type { DeployWizardState } from '@/hooks/useDeployWizard'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/shared/Table'; -import { DeployStatusBadge } from './DeployStatusBadge'; -import { ErrorDetail } from './ErrorDetail'; -import LoaderIcon from 'lucide-solid/icons/loader-2'; -import CheckCircleIcon from 'lucide-solid/icons/check-circle-2'; - -interface DeployingStepProps { - wizard: DeployWizardState; -} - -export const DeployingStep: Component = (props) => { - const w = props.wizard; - - const inProgressStatuses = new Set(['installing', 'enrolling', 'verifying']); - - const completedCount = createMemo( - () => - w - .jobTargets() - .filter( - (t) => - t.status === 'succeeded' || - t.status === 'failed_retryable' || - t.status === 'failed_permanent' || - t.status === 'skipped_already_agent' || - t.status === 'canceled', - ).length, - ); - - const inProgressCount = createMemo( - () => w.jobTargets().filter((t) => inProgressStatuses.has(t.status)).length, - ); - - const totalCount = createMemo(() => w.jobTargets().length); - - return ( -
- - - - - {/* Progress summary */} -
-
- } - > - - - - Installing {completedCount()} of {totalCount()} nodes... - 0}> ({inProgressCount()} in progress) - -
-
- - {/* Per-node rows */} - - - - Node - IP - Status - Details - - - - - {(target) => ( - - - {target.nodeName} - - - {target.nodeIP} - - - - - - - - - )} - - -
-
- ); -}; diff --git a/frontend-modern/src/components/Infrastructure/deploy/PreflightStep.tsx b/frontend-modern/src/components/Infrastructure/deploy/PreflightStep.tsx deleted file mode 100644 index bbebf79872..0000000000 --- a/frontend-modern/src/components/Infrastructure/deploy/PreflightStep.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { Component, For, Show, createMemo } from 'solid-js'; -import type { DeployWizardState } from '@/hooks/useDeployWizard'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/shared/Table'; -import { DeployStatusBadge } from './DeployStatusBadge'; -import { ErrorDetail } from './ErrorDetail'; -import CheckCircleIcon from 'lucide-solid/icons/check-circle-2'; -import LoaderIcon from 'lucide-solid/icons/loader-2'; - -interface PreflightStepProps { - wizard: DeployWizardState; -} - -export const PreflightStep: Component = (props) => { - const w = props.wizard; - - const completedCount = createMemo( - () => - w.preflightTargets().filter((t) => t.status !== 'pending' && t.status !== 'preflighting') - .length, - ); - - const totalCount = createMemo(() => w.preflightTargets().length); - - return ( -
- - - - - {/* Progress summary */} -
- } - > - - - - - Checking {completedCount()} of {totalCount()} nodes... - - -
- - {/* Per-node rows */} - - - - Node - IP - Status - Details - - - - - {(target) => ( - - - {target.nodeName} - - - {target.nodeIP} - - - - - - - - - )} - - -
-
- ); -}; diff --git a/frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx b/frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx deleted file mode 100644 index ec4a3966b7..0000000000 --- a/frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { Component, For, Show, createSignal, onCleanup } from 'solid-js'; -import type { DeployWizardState } from '@/hooks/useDeployWizard'; -import { Table, TableBody, TableCell, TableRow } from '@/components/shared/Table'; -import { NodesAPI } from '@/api/nodes'; -import { copyToClipboard } from '@/utils/clipboard'; -import { getDeployInstallCommandLoadingState } from '@/utils/deployFlowPresentation'; -import { DeployStatusBadge } from './DeployStatusBadge'; -import { ErrorDetail } from './ErrorDetail'; -import CheckCircleIcon from 'lucide-solid/icons/check-circle-2'; -import XCircleIcon from 'lucide-solid/icons/x-circle'; -import AlertCircleIcon from 'lucide-solid/icons/alert-circle'; -import ChevronDownIcon from 'lucide-solid/icons/chevron-down'; -import ChevronRightIcon from 'lucide-solid/icons/chevron-right'; -import CopyIcon from 'lucide-solid/icons/copy'; -import CheckIcon from 'lucide-solid/icons/check'; - -interface ResultsStepProps { - wizard: DeployWizardState; -} - -export const ResultsStep: Component = (props) => { - const w = props.wizard; - const [manualInstallOpen, setManualInstallOpen] = createSignal(false); - const [installCommand, setInstallCommand] = createSignal(''); - const [installCommandLoading, setInstallCommandLoading] = createSignal(false); - const [installCommandError, setInstallCommandError] = createSignal(''); - const [copied, setCopied] = createSignal(false); - - let commandFetched = false; - let copyTimer: ReturnType | undefined; - onCleanup(() => { - if (copyTimer !== undefined) clearTimeout(copyTimer); - }); - - async function fetchInstallCommand() { - if (commandFetched) return; - commandFetched = true; - setInstallCommandLoading(true); - setInstallCommandError(''); - try { - const response = await NodesAPI.getAgentInstallCommand({ type: 'pve', enableProxmox: true }); - setInstallCommand(response.command); - } catch (err) { - setInstallCommandError(err instanceof Error ? err.message : 'Failed to load install command'); - commandFetched = false; - } finally { - setInstallCommandLoading(false); - } - } - - async function handleCopy() { - const cmd = installCommand(); - if (!cmd) return; - const ok = await copyToClipboard(cmd); - if (ok) { - setCopied(true); - if (copyTimer !== undefined) clearTimeout(copyTimer); - copyTimer = setTimeout(() => setCopied(false), 2000); - } - } - - function handleAccordionToggle() { - const opening = !manualInstallOpen(); - setManualInstallOpen(opening); - if (opening) void fetchInstallCommand(); - } - - return ( -
- {/* Succeeded */} - 0}> -
-

- - Deployed ({w.succeededTargets().length}) -

- - - - {(target) => ( - - - {target.nodeName} - - - {target.nodeIP} - - - - - - )} - - -
-
-
- - {/* Failed */} - 0}> -
-

- - Failed ({w.failedTargets().length}) -

- - - - {(target) => ( - - - {target.nodeName} - - - {target.nodeIP} - - - - - - - - - )} - - -
- - {/* Manual install accordion for failed nodes */} - - -
-

- For nodes that failed SSH-based deployment, you can install the agent manually by - SSHing into the node and running: -

- -
-
- {getDeployInstallCommandLoadingState()} -
- - - - - -
- {/* prettier-ignore */} -
{installCommand()}
- -
-
-
-
-
-
- - {/* Skipped */} - 0}> -
-

- - Skipped ({w.skippedTargets().length}) -

- - - - {(target) => ( - - - {target.nodeName} - - - {target.nodeIP} - - - - - - )} - - -
-
-
- - {/* Canceled */} - 0}> -
-

Canceled ({w.canceledTargets().length})

- - - - {(target) => ( - - - {target.nodeName} - - - {target.nodeIP} - - - - - - )} - - -
-
-
-
- ); -}; diff --git a/frontend-modern/src/components/Infrastructure/deploy/__tests__/CandidatesStep.test.tsx b/frontend-modern/src/components/Infrastructure/deploy/__tests__/CandidatesStep.test.tsx deleted file mode 100644 index a984a19cc8..0000000000 --- a/frontend-modern/src/components/Infrastructure/deploy/__tests__/CandidatesStep.test.tsx +++ /dev/null @@ -1,569 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@solidjs/testing-library'; -import { createSignal } from 'solid-js'; -import type { Accessor } from 'solid-js'; -import type { CandidateNode, SourceAgentInfo } from '@/types/agentDeploy'; -import type { DeployWizardState } from '@/hooks/useDeployWizard'; - -/* ── component import ──────────────────────────────────────────── */ -import { CandidatesStep } from '../CandidatesStep'; - -/* ── helpers ────────────────────────────────────────────────────── */ - -/** Creates a mock DeployWizardState with sensible defaults. */ -function createMockWizard( - overrides: Partial<{ - candidates: CandidateNode[]; - candidatesLoading: boolean; - candidatesError: string; - selectedNodeIds: Set; - deployableNodes: CandidateNode[]; - onlineSourceAgents: SourceAgentInfo[]; - selectedSourceAgent: string; - }> = {}, -): DeployWizardState { - const candidatesData = overrides.candidates ?? []; - const deployableData = - overrides.deployableNodes ?? candidatesData.filter((n) => n.deployable && !n.hasAgent); - const [candidatesLoading] = createSignal(overrides.candidatesLoading ?? false); - const [candidatesError] = createSignal(overrides.candidatesError ?? ''); - const [selectedNodeIds, setSelectedNodeIds] = createSignal>( - overrides.selectedNodeIds ?? new Set(), - ); - const [selectedSourceAgent, setSelectedSourceAgent] = createSignal( - overrides.selectedSourceAgent ?? '', - ); - - const toggleNodeSelection = vi.fn((nodeId: string) => { - setSelectedNodeIds((prev) => { - const next = new Set(prev); - if (next.has(nodeId)) { - next.delete(nodeId); - } else { - next.add(nodeId); - } - return next; - }); - }); - - const selectAllNodes = vi.fn(() => { - setSelectedNodeIds(new Set(deployableData.map((n) => n.nodeId))); - }); - - const deselectAllNodes = vi.fn(() => { - setSelectedNodeIds(new Set()); - }); - - // Build a minimal mock that satisfies the fields CandidatesStep accesses. - // CandidatesStep only reads: candidatesError, candidatesLoading, candidates, - // onlineSourceAgents, selectedSourceAgent, setSelectedSourceAgent, - // deployableNodes, selectedNodeIds, selectAllNodes, deselectAllNodes, - // toggleNodeSelection. - return { - candidates: (() => candidatesData) as Accessor, - candidatesLoading, - candidatesError, - selectedNodeIds, - deployableNodes: (() => deployableData) as Accessor, - onlineSourceAgents: (() => overrides.onlineSourceAgents ?? []) as Accessor, - selectedSourceAgent, - setSelectedSourceAgent, - toggleNodeSelection, - selectAllNodes, - deselectAllNodes, - // Stubs for fields not used by CandidatesStep but required by the type. - step: (() => 'candidates') as Accessor, - setStep: vi.fn(), - sourceAgents: (() => []) as Accessor, - preflightId: (() => '') as Accessor, - preflightTargets: (() => []) as Accessor, - preflightStatus: (() => '') as Accessor, - preflightError: (() => '') as Accessor, - preflightStream: {} as unknown, - readyNodes: (() => []) as Accessor, - failedPreflightNodes: (() => []) as Accessor, - confirmSelectedNodeIds: (() => new Set()) as Accessor>, - toggleConfirmNode: vi.fn(), - jobId: (() => '') as Accessor, - jobTargets: (() => []) as Accessor, - jobStatus: (() => '') as Accessor, - deployError: (() => '') as Accessor, - deployStream: {} as unknown, - succeededTargets: (() => []) as Accessor, - failedTargets: (() => []) as Accessor, - retryableTargets: (() => []) as Accessor, - skippedTargets: (() => []) as Accessor, - canceledTargets: (() => []) as Accessor, - startingPreflight: (() => false) as Accessor, - startingDeploy: (() => false) as Accessor, - retrying: (() => false) as Accessor, - canceling: (() => false) as Accessor, - isOperationActive: (() => false) as Accessor, - loadCandidates: vi.fn(), - startPreflight: vi.fn(), - startDeploy: vi.fn(), - cancelDeploy: vi.fn(), - retryFailed: vi.fn(), - } as unknown as DeployWizardState; -} - -function makeCandidate(overrides: Partial = {}): CandidateNode { - return { - nodeId: 'node-1', - name: 'pve1', - ip: '192.168.1.10', - hasAgent: false, - deployable: true, - ...overrides, - }; -} - -function makeSourceAgent(overrides: Partial = {}): SourceAgentInfo { - return { - agentId: 'agent-1', - nodeId: 'node-1', - online: true, - ...overrides, - }; -} - -/* ── lifecycle ──────────────────────────────────────────────────── */ -beforeEach(() => { - vi.clearAllMocks(); -}); - -afterEach(() => { - cleanup(); -}); - -/* ================================================================ - Tests - ================================================================ */ - -describe('CandidatesStep', () => { - /* ── Loading state ──────────────────────────────────────────── */ - - describe('loading state', () => { - it('shows a loading spinner and message while loading', () => { - const wizard = createMockWizard({ candidatesLoading: true }); - render(() => ); - - expect(screen.getByText('Loading cluster nodes...')).toBeInTheDocument(); - }); - - it('does not show node table or empty state while loading', () => { - const wizard = createMockWizard({ candidatesLoading: true }); - render(() => ); - - expect(screen.queryByText('Select All')).not.toBeInTheDocument(); - expect(screen.queryByText('No nodes found in this cluster.')).not.toBeInTheDocument(); - }); - }); - - /* ── Error state ────────────────────────────────────────────── */ - - describe('error state', () => { - it('displays the error message', () => { - const wizard = createMockWizard({ candidatesError: 'Network timeout' }); - render(() => ); - - expect(screen.getByText('Network timeout')).toBeInTheDocument(); - }); - }); - - /* ── Empty state ────────────────────────────────────────────── */ - - describe('empty state', () => { - it('shows empty message when no candidates and not loading', () => { - const wizard = createMockWizard({ candidates: [], candidatesLoading: false }); - render(() => ); - - expect(screen.getByText('No nodes found in this cluster.')).toBeInTheDocument(); - }); - }); - - /* ── No online source agents ────────────────────────────────── */ - - describe('no online source agents', () => { - it('shows a warning when no online source agents are available', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate()], - onlineSourceAgents: [], - candidatesLoading: false, - }); - render(() => ); - - expect(screen.getByText(/No online source agents found/)).toBeInTheDocument(); - }); - }); - - /* ── Source agent selector ──────────────────────────────────── */ - - describe('source agent selector', () => { - it('renders a source agent dropdown when there are multiple online agents', () => { - const agents = [ - makeSourceAgent({ agentId: 'a1', nodeId: 'n1' }), - makeSourceAgent({ agentId: 'a2', nodeId: 'n2' }), - ]; - const wizard = createMockWizard({ - candidates: [makeCandidate()], - onlineSourceAgents: agents, - candidatesLoading: false, - }); - render(() => ); - - const select = screen.getByLabelText('Source Agent') as HTMLSelectElement; - expect(select).toBeInTheDocument(); - // It should have a placeholder + 2 agent options - expect(select.options.length).toBe(3); - }); - - it('does not render a source agent dropdown when there is exactly one agent', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate()], - onlineSourceAgents: [makeSourceAgent()], - candidatesLoading: false, - }); - render(() => ); - - expect(screen.queryByLabelText('Source Agent')).not.toBeInTheDocument(); - }); - - it('calls setSelectedSourceAgent on dropdown change', () => { - const agents = [ - makeSourceAgent({ agentId: 'a1', nodeId: 'n1' }), - makeSourceAgent({ agentId: 'a2', nodeId: 'n2' }), - ]; - const wizard = createMockWizard({ - candidates: [makeCandidate()], - onlineSourceAgents: agents, - candidatesLoading: false, - }); - render(() => ); - - const select = screen.getByLabelText('Source Agent') as HTMLSelectElement; - fireEvent.change(select, { target: { value: 'a2' } }); - // The setSelectedSourceAgent is the real signal setter, check value updated - expect(wizard.selectedSourceAgent()).toBe('a2'); - }); - }); - - /* ── Node table rendering ──────────────────────────────────── */ - - describe('node table', () => { - it('renders a row for each candidate node', () => { - const candidates = [ - makeCandidate({ nodeId: 'n1', name: 'pve1', ip: '10.0.0.1' }), - makeCandidate({ nodeId: 'n2', name: 'pve2', ip: '10.0.0.2' }), - makeCandidate({ nodeId: 'n3', name: 'pve3', ip: '10.0.0.3' }), - ]; - const wizard = createMockWizard({ candidates, candidatesLoading: false }); - render(() => ); - - expect(screen.getByText('pve1')).toBeInTheDocument(); - expect(screen.getByText('pve2')).toBeInTheDocument(); - expect(screen.getByText('pve3')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.1')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.2')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.3')).toBeInTheDocument(); - }); - - it('shows "--" when a node has no IP', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate({ ip: '' })], - candidatesLoading: false, - }); - render(() => ); - - expect(screen.getByText('--')).toBeInTheDocument(); - }); - - it('shows "Already monitored" for nodes with agents', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate({ hasAgent: true })], - candidatesLoading: false, - }); - render(() => ); - - expect(screen.getByText('Already monitored')).toBeInTheDocument(); - }); - - it('shows "Available" for deployable nodes without agents', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate({ deployable: true, hasAgent: false })], - candidatesLoading: false, - }); - render(() => ); - - expect(screen.getByText('Available')).toBeInTheDocument(); - }); - - it('shows "Not deployable" for non-deployable nodes without a reason', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate({ deployable: false, hasAgent: false })], - candidatesLoading: false, - }); - render(() => ); - - expect(screen.getByText('Not deployable')).toBeInTheDocument(); - }); - - it('shows the custom reason for non-deployable nodes', () => { - const wizard = createMockWizard({ - candidates: [ - makeCandidate({ - deployable: false, - hasAgent: false, - reason: 'Offline (last seen 2h ago)', - }), - ], - candidatesLoading: false, - }); - render(() => ); - - expect(screen.getByText('Offline (last seen 2h ago)')).toBeInTheDocument(); - }); - - it('renders table headers', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate()], - candidatesLoading: false, - }); - render(() => ); - - expect(screen.getByText('Node')).toBeInTheDocument(); - expect(screen.getByText('IP')).toBeInTheDocument(); - expect(screen.getByText('Status')).toBeInTheDocument(); - }); - }); - - /* ── Selection count display ────────────────────────────────── */ - - describe('selection count', () => { - it('shows "X of Y nodes selected"', () => { - const candidates = [ - makeCandidate({ nodeId: 'n1', name: 'pve1' }), - makeCandidate({ nodeId: 'n2', name: 'pve2' }), - makeCandidate({ nodeId: 'n3', name: 'pve3' }), - ]; - const wizard = createMockWizard({ - candidates, - selectedNodeIds: new Set(['n1', 'n3']), - candidatesLoading: false, - }); - render(() => ); - - expect(screen.getByText('2 of 3 nodes selected')).toBeInTheDocument(); - }); - }); - - /* ── Select All / Deselect All ──────────────────────────────── */ - - describe('select/deselect all', () => { - it('renders Select All and Deselect All buttons', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate()], - candidatesLoading: false, - }); - render(() => ); - - expect(screen.getByText('Select All')).toBeInTheDocument(); - expect(screen.getByText('Deselect All')).toBeInTheDocument(); - }); - - it('calls selectAllNodes when Select All is clicked', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate({ nodeId: 'n1' }), makeCandidate({ nodeId: 'n2' })], - candidatesLoading: false, - }); - render(() => ); - - fireEvent.click(screen.getByText('Select All')); - expect(wizard.selectAllNodes).toHaveBeenCalledOnce(); - }); - - it('calls deselectAllNodes when Deselect All is clicked', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate()], - selectedNodeIds: new Set(['node-1']), - candidatesLoading: false, - }); - render(() => ); - - fireEvent.click(screen.getByText('Deselect All')); - expect(wizard.deselectAllNodes).toHaveBeenCalledOnce(); - }); - - it('disables Select All when all deployable nodes are selected', () => { - const candidates = [makeCandidate({ nodeId: 'n1' })]; - const wizard = createMockWizard({ - candidates, - selectedNodeIds: new Set(['n1']), - candidatesLoading: false, - }); - render(() => ); - - const selectAllBtn = screen.getByText('Select All'); - expect(selectAllBtn).toBeDisabled(); - }); - - it('disables Deselect All when no nodes are selected', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate()], - selectedNodeIds: new Set(), - candidatesLoading: false, - }); - render(() => ); - - const deselectAllBtn = screen.getByText('Deselect All'); - expect(deselectAllBtn).toBeDisabled(); - }); - }); - - /* ── Checkbox / row interaction ─────────────────────────────── */ - - describe('node selection interaction', () => { - it('calls toggleNodeSelection exactly once when a deployable row is clicked', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate({ nodeId: 'n1', name: 'pve1' })], - candidatesLoading: false, - }); - render(() => ); - - fireEvent.click(screen.getByText('pve1')); - expect(wizard.toggleNodeSelection).toHaveBeenCalledOnce(); - expect(wizard.toggleNodeSelection).toHaveBeenCalledWith('n1'); - }); - - it('calls toggleNodeSelection exactly once when the checkbox itself is clicked (no double-toggle)', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate({ nodeId: 'n1', name: 'pve1' })], - candidatesLoading: false, - }); - render(() => ); - - const checkbox = screen.getByRole('checkbox'); - fireEvent.click(checkbox); - // The checkbox has stopPropagation + onChange, and the row has onClick. - // Clicking the checkbox directly should trigger toggle exactly once, not twice. - expect(wizard.toggleNodeSelection).toHaveBeenCalledOnce(); - expect(wizard.toggleNodeSelection).toHaveBeenCalledWith('n1'); - }); - - it('does not call toggleNodeSelection when clicking a non-deployable row', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate({ nodeId: 'n1', name: 'pve1', deployable: false })], - candidatesLoading: false, - }); - render(() => ); - - fireEvent.click(screen.getByText('pve1')); - expect(wizard.toggleNodeSelection).not.toHaveBeenCalled(); - }); - - it('does not call toggleNodeSelection when clicking a row that already has an agent', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate({ nodeId: 'n1', name: 'pve1', hasAgent: true })], - candidatesLoading: false, - }); - render(() => ); - - fireEvent.click(screen.getByText('pve1')); - expect(wizard.toggleNodeSelection).not.toHaveBeenCalled(); - }); - - it('disables checkbox for nodes with agents', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate({ hasAgent: true })], - candidatesLoading: false, - }); - render(() => ); - - const checkbox = screen.getByRole('checkbox'); - expect(checkbox).toBeDisabled(); - }); - - it('disables checkbox for non-deployable nodes', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate({ deployable: false })], - candidatesLoading: false, - }); - render(() => ); - - const checkbox = screen.getByRole('checkbox'); - expect(checkbox).toBeDisabled(); - }); - - it('shows checkbox as checked for selected nodes', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate({ nodeId: 'n1' })], - selectedNodeIds: new Set(['n1']), - candidatesLoading: false, - }); - render(() => ); - - const checkbox = screen.getByRole('checkbox'); - expect(checkbox).toBeChecked(); - }); - - it('shows checkbox as unchecked for unselected nodes', () => { - const wizard = createMockWizard({ - candidates: [makeCandidate({ nodeId: 'n1' })], - selectedNodeIds: new Set(), - candidatesLoading: false, - }); - render(() => ); - - const checkbox = screen.getByRole('checkbox'); - expect(checkbox).not.toBeChecked(); - }); - }); - - /* ── Mixed candidates scenario ──────────────────────────────── */ - - describe('mixed candidates scenario', () => { - it('renders correct status for each node type in a mixed list', () => { - const candidates = [ - makeCandidate({ nodeId: 'n1', name: 'with-agent', hasAgent: true }), - makeCandidate({ nodeId: 'n2', name: 'deployable', deployable: true, hasAgent: false }), - makeCandidate({ - nodeId: 'n3', - name: 'not-deployable', - deployable: false, - hasAgent: false, - reason: 'Needs SSH key', - }), - ]; - const wizard = createMockWizard({ - candidates, - candidatesLoading: false, - // Only n2 is deployable - deployableNodes: [candidates[1]], - selectedNodeIds: new Set(['n2']), - }); - render(() => ); - - expect(screen.getByText('Already monitored')).toBeInTheDocument(); - expect(screen.getByText('Available')).toBeInTheDocument(); - expect(screen.getByText('Needs SSH key')).toBeInTheDocument(); - - // Selection count shows 1 of 1 (only 1 deployable node) - expect(screen.getByText('1 of 1 nodes selected')).toBeInTheDocument(); - - // Should have 3 checkboxes total - const checkboxes = screen.getAllByRole('checkbox'); - expect(checkboxes).toHaveLength(3); - - // First checkbox (agent present) and third (not deployable) should be disabled - expect(checkboxes[0]).toBeDisabled(); - expect(checkboxes[2]).toBeDisabled(); - - // Second checkbox (deployable) should be enabled and checked - expect(checkboxes[1]).not.toBeDisabled(); - expect(checkboxes[1]).toBeChecked(); - - // Select All should be disabled since all deployable nodes are selected - expect(screen.getByText('Select All')).toBeDisabled(); - }); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/deploy/__tests__/ConfirmStep.test.tsx b/frontend-modern/src/components/Infrastructure/deploy/__tests__/ConfirmStep.test.tsx deleted file mode 100644 index f84ec6f293..0000000000 --- a/frontend-modern/src/components/Infrastructure/deploy/__tests__/ConfirmStep.test.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import { describe, expect, it, vi, afterEach } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@solidjs/testing-library'; -import { createSignal } from 'solid-js'; -import { ConfirmStep } from '../ConfirmStep'; - -// ---------- helpers ---------- - -interface MockTarget { - nodeId: string; - nodeName: string; - nodeIP: string; - arch?: string; - status?: string; - errorMessage?: string; -} - -function makeTarget(overrides: Partial = {}): MockTarget { - return { - nodeId: overrides.nodeId ?? 'node-1', - nodeName: overrides.nodeName ?? 'pve-node1', - nodeIP: overrides.nodeIP ?? '192.168.1.10', - arch: overrides.arch, - status: overrides.status ?? 'ready', - errorMessage: overrides.errorMessage, - }; -} - -interface WizardOverrides { - confirmSelectedNodeIds?: Set; - readyNodes?: MockTarget[]; - failedPreflightNodes?: MockTarget[]; - toggleConfirmNode?: ReturnType; -} - -function createMockWizard(overrides: WizardOverrides = {}) { - const selectedIds = overrides.confirmSelectedNodeIds ?? new Set(); - const ready = overrides.readyNodes ?? []; - const failed = overrides.failedPreflightNodes ?? []; - - const [confirmSelectedNodeIds] = createSignal(selectedIds); - const [readyNodes] = createSignal(ready); - const [failedPreflightNodes] = createSignal(failed); - - return { - confirmSelectedNodeIds, - readyNodes, - failedPreflightNodes, - toggleConfirmNode: overrides.toggleConfirmNode ?? vi.fn(), - }; -} - -function renderConfirm(overrides: WizardOverrides = {}) { - const wizard = createMockWizard(overrides); - const result = render(() => ); - return { ...result, wizard }; -} - -// ---------- tests ---------- - -describe('ConfirmStep', () => { - afterEach(() => { - cleanup(); - }); - - // --- Ready nodes table --- - - describe('ready nodes table', () => { - it('does not show ready section when there are no ready nodes', () => { - renderConfirm({ readyNodes: [] }); - expect(screen.queryByText(/Ready to deploy/)).not.toBeInTheDocument(); - }); - - it('shows ready nodes heading with count', () => { - renderConfirm({ - readyNodes: [makeTarget({ nodeId: 'a' }), makeTarget({ nodeId: 'b' })], - }); - expect(screen.getByText(/Ready to deploy \(2\)/)).toBeInTheDocument(); - }); - - it('renders node name, IP, and default arch', () => { - renderConfirm({ - readyNodes: [makeTarget({ nodeId: 'n1', nodeName: 'myhost', nodeIP: '10.0.0.1' })], - }); - expect(screen.getByText('myhost')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.1')).toBeInTheDocument(); - expect(screen.getByText('amd64')).toBeInTheDocument(); - }); - - it('renders explicit arch when provided', () => { - renderConfirm({ - readyNodes: [makeTarget({ nodeId: 'n1', arch: 'arm64' })], - }); - expect(screen.getByText('arm64')).toBeInTheDocument(); - expect(screen.queryByText('amd64')).not.toBeInTheDocument(); - }); - - it('shows checkbox as checked for selected nodes', () => { - renderConfirm({ - readyNodes: [makeTarget({ nodeId: 'x' })], - confirmSelectedNodeIds: new Set(['x']), - }); - const checkbox = screen.getByRole('checkbox'); - expect(checkbox).toBeChecked(); - }); - - it('shows checkbox as unchecked for unselected nodes', () => { - renderConfirm({ - readyNodes: [makeTarget({ nodeId: 'x' })], - confirmSelectedNodeIds: new Set(), - }); - const checkbox = screen.getByRole('checkbox'); - expect(checkbox).not.toBeChecked(); - }); - - it('calls toggleConfirmNode when row is clicked', async () => { - const toggle = vi.fn(); - renderConfirm({ - readyNodes: [makeTarget({ nodeId: 'abc', nodeName: 'clickable-host' })], - toggleConfirmNode: toggle, - }); - await fireEvent.click(screen.getByText('clickable-host')); - expect(toggle).toHaveBeenCalledWith('abc'); - }); - - it('calls toggleConfirmNode when checkbox onChange fires', async () => { - const toggle = vi.fn(); - renderConfirm({ - readyNodes: [makeTarget({ nodeId: 'def' })], - toggleConfirmNode: toggle, - }); - const checkbox = screen.getByRole('checkbox'); - await fireEvent.click(checkbox); - expect(toggle).toHaveBeenCalledWith('def'); - }); - - it('stops propagation on checkbox click so row handler is not triggered twice', async () => { - const toggle = vi.fn(); - renderConfirm({ - readyNodes: [makeTarget({ nodeId: 'dup' })], - toggleConfirmNode: toggle, - }); - const checkbox = screen.getByRole('checkbox'); - await fireEvent.click(checkbox); - // Checkbox click has stopPropagation, so toggle should be called - // from the checkbox onChange but NOT from the row onClick - // The click event on the checkbox triggers onChange but not the row - expect(toggle).toHaveBeenCalledTimes(1); - }); - - it('toggles the correct node when clicking a specific row in a multi-row table', async () => { - const toggle = vi.fn(); - renderConfirm({ - readyNodes: [ - makeTarget({ nodeId: 'first', nodeName: 'host-alpha' }), - makeTarget({ nodeId: 'second', nodeName: 'host-beta' }), - makeTarget({ nodeId: 'third', nodeName: 'host-gamma' }), - ], - toggleConfirmNode: toggle, - }); - await fireEvent.click(screen.getByText('host-beta')); - expect(toggle).toHaveBeenCalledWith('second'); - expect(toggle).toHaveBeenCalledTimes(1); - }); - - it('renders multiple ready nodes', () => { - renderConfirm({ - readyNodes: [ - makeTarget({ nodeId: '1', nodeName: 'host-a', nodeIP: '10.0.0.1' }), - makeTarget({ nodeId: '2', nodeName: 'host-b', nodeIP: '10.0.0.2' }), - makeTarget({ nodeId: '3', nodeName: 'host-c', nodeIP: '10.0.0.3' }), - ], - }); - expect(screen.getByText('host-a')).toBeInTheDocument(); - expect(screen.getByText('host-b')).toBeInTheDocument(); - expect(screen.getByText('host-c')).toBeInTheDocument(); - expect(screen.getAllByRole('checkbox')).toHaveLength(3); - }); - }); - - // --- Failed preflight nodes --- - - describe('failed preflight nodes', () => { - it('does not show failed section when there are no failed nodes', () => { - renderConfirm({ failedPreflightNodes: [] }); - expect(screen.queryByText(/Cannot deploy/)).not.toBeInTheDocument(); - }); - - it('shows failed nodes heading with count', () => { - renderConfirm({ - failedPreflightNodes: [ - makeTarget({ nodeId: 'f1', errorMessage: 'timeout' }), - makeTarget({ nodeId: 'f2', errorMessage: 'auth fail' }), - ], - }); - expect(screen.getByText(/Cannot deploy \(2\)/)).toBeInTheDocument(); - }); - - it('renders node name, IP, and error message', () => { - renderConfirm({ - failedPreflightNodes: [ - makeTarget({ - nodeId: 'bad', - nodeName: 'broken-host', - nodeIP: '10.0.0.99', - errorMessage: 'SSH connection refused', - }), - ], - }); - expect(screen.getByText('broken-host')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.99')).toBeInTheDocument(); - expect(screen.getByText('SSH connection refused')).toBeInTheDocument(); - }); - - it('shows default "Preflight failed" when errorMessage is empty', () => { - renderConfirm({ - failedPreflightNodes: [makeTarget({ nodeId: 'no-err', errorMessage: '' })], - }); - expect(screen.getByText('Preflight failed')).toBeInTheDocument(); - }); - - it('shows default "Preflight failed" when errorMessage is undefined', () => { - renderConfirm({ - failedPreflightNodes: [makeTarget({ nodeId: 'undef' })], - }); - expect(screen.getByText('Preflight failed')).toBeInTheDocument(); - }); - - it('does not render checkboxes for failed nodes', () => { - renderConfirm({ - readyNodes: [], - failedPreflightNodes: [makeTarget({ nodeId: 'f1' })], - }); - expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); - }); - }); - - // --- Combined states --- - - describe('combined ready and failed nodes', () => { - it('shows both sections when there are ready and failed nodes', () => { - renderConfirm({ - readyNodes: [makeTarget({ nodeId: 'r1', nodeName: 'good-host' })], - failedPreflightNodes: [ - makeTarget({ nodeId: 'f1', nodeName: 'bad-host', errorMessage: 'denied' }), - ], - }); - expect(screen.getByText(/Ready to deploy \(1\)/)).toBeInTheDocument(); - expect(screen.getByText(/Cannot deploy \(1\)/)).toBeInTheDocument(); - expect(screen.getByText('good-host')).toBeInTheDocument(); - expect(screen.getByText('bad-host')).toBeInTheDocument(); - }); - - it('renders nothing when both lists are empty', () => { - renderConfirm({ - readyNodes: [], - failedPreflightNodes: [], - }); - // Only the outer wrapper div exists, with no visible child content - expect(screen.queryByText(/Ready to deploy/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Cannot deploy/)).not.toBeInTheDocument(); - }); - }); - - // --- Table headers --- - - describe('table headers', () => { - it('shows correct headers for ready nodes table', () => { - renderConfirm({ readyNodes: [makeTarget()] }); - expect(screen.getByText('Node')).toBeInTheDocument(); - expect(screen.getByText('IP')).toBeInTheDocument(); - expect(screen.getByText('Arch')).toBeInTheDocument(); - }); - - it('shows correct headers for failed nodes table', () => { - renderConfirm({ - failedPreflightNodes: [makeTarget({ nodeId: 'f', errorMessage: 'err' })], - }); - // Failed table has Node, IP, Reason columns - expect(screen.getByText('Reason')).toBeInTheDocument(); - }); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/deploy/__tests__/DeployingStep.test.tsx b/frontend-modern/src/components/Infrastructure/deploy/__tests__/DeployingStep.test.tsx deleted file mode 100644 index 50beb70e12..0000000000 --- a/frontend-modern/src/components/Infrastructure/deploy/__tests__/DeployingStep.test.tsx +++ /dev/null @@ -1,421 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, cleanup } from '@solidjs/testing-library'; -import { createSignal } from 'solid-js'; -import type { Accessor } from 'solid-js'; -import type { DeployTarget, SourceAgentInfo } from '@/types/agentDeploy'; -import type { DeployWizardState } from '@/hooks/useDeployWizard'; - -/* ── component import ──────────────────────────────────────────── */ -import { DeployingStep } from '../DeployingStep'; - -/* ── helpers ────────────────────────────────────────────────────── */ - -function makeTarget(overrides: Partial = {}): DeployTarget { - return { - id: 'target-1', - jobId: 'job-1', - nodeId: 'node-1', - nodeName: 'pve1', - nodeIP: '192.168.1.10', - status: 'pending', - attempts: 0, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -/** Creates a mock DeployWizardState with sensible defaults for DeployingStep. */ -function createMockWizard( - overrides: Partial<{ - jobTargets: DeployTarget[]; - deployError: string; - }> = {}, -): DeployWizardState { - const jobTargetsData = overrides.jobTargets ?? []; - const [deployError] = createSignal(overrides.deployError ?? ''); - - return { - // Fields used by DeployingStep - jobTargets: (() => jobTargetsData) as Accessor, - deployError, - - // Stubs for fields not used by DeployingStep but required by the type. - step: (() => 'deploying') as Accessor, - setStep: vi.fn(), - candidates: (() => []) as Accessor, - candidatesLoading: (() => false) as Accessor, - candidatesError: (() => '') as Accessor, - sourceAgents: (() => []) as Accessor, - onlineSourceAgents: (() => []) as Accessor, - selectedSourceAgent: (() => '') as Accessor, - setSelectedSourceAgent: vi.fn(), - selectedNodeIds: (() => new Set()) as Accessor>, - deployableNodes: (() => []) as Accessor, - toggleNodeSelection: vi.fn(), - selectAllNodes: vi.fn(), - deselectAllNodes: vi.fn(), - loadCandidates: vi.fn(), - preflightId: (() => '') as Accessor, - preflightTargets: (() => []) as Accessor, - preflightStatus: (() => '') as Accessor, - preflightError: (() => '') as Accessor, - preflightStream: {} as unknown, - readyNodes: (() => []) as Accessor, - failedPreflightNodes: (() => []) as Accessor, - confirmSelectedNodeIds: (() => new Set()) as Accessor>, - toggleConfirmNode: vi.fn(), - jobId: (() => '') as Accessor, - jobStatus: (() => '') as Accessor, - deployStream: {} as unknown, - succeededTargets: (() => []) as Accessor, - failedTargets: (() => []) as Accessor, - retryableTargets: (() => []) as Accessor, - skippedTargets: (() => []) as Accessor, - canceledTargets: (() => []) as Accessor, - startingPreflight: (() => false) as Accessor, - startingDeploy: (() => false) as Accessor, - retrying: (() => false) as Accessor, - canceling: (() => false) as Accessor, - isOperationActive: (() => false) as Accessor, - startPreflight: vi.fn(), - startDeploy: vi.fn(), - cancelDeploy: vi.fn(), - retryFailed: vi.fn(), - } as unknown as DeployWizardState; -} - -/** Returns the progress summary element (the one with aria-live="polite"). */ -function getProgressSummary(): HTMLElement { - const el = document.querySelector('[role="status"][aria-live="polite"]'); - if (!el) throw new Error('Progress summary not found'); - return el as HTMLElement; -} - -/* ── lifecycle ──────────────────────────────────────────────────── */ -beforeEach(() => { - vi.clearAllMocks(); -}); - -afterEach(() => { - cleanup(); -}); - -/* ================================================================ - Tests - ================================================================ */ - -describe('DeployingStep', () => { - /* ── Deploy error display ─────────────────────────────────────── */ - - describe('deploy error', () => { - it('shows error alert when deployError is set', () => { - const wizard = createMockWizard({ deployError: 'Connection lost to source agent' }); - render(() => ); - - const alert = screen.getByRole('alert'); - expect(alert).toBeInTheDocument(); - expect(alert).toHaveTextContent('Connection lost to source agent'); - }); - - it('does not show error alert when deployError is empty', () => { - const wizard = createMockWizard({ deployError: '' }); - render(() => ); - - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - }); - }); - - /* ── Progress summary ─────────────────────────────────────────── */ - - describe('progress summary', () => { - it('shows "Installing 0 of N nodes..." when all are pending', () => { - const targets = [ - makeTarget({ id: 't1', nodeName: 'pve1', status: 'pending' }), - makeTarget({ id: 't2', nodeName: 'pve2', status: 'pending' }), - makeTarget({ id: 't3', nodeName: 'pve3', status: 'pending' }), - ]; - const wizard = createMockWizard({ jobTargets: targets }); - render(() => ); - - expect(getProgressSummary()).toHaveTextContent('Installing 0 of 3 nodes...'); - }); - - it('counts succeeded targets as completed', () => { - const targets = [ - makeTarget({ id: 't1', status: 'succeeded' }), - makeTarget({ id: 't2', status: 'pending' }), - ]; - const wizard = createMockWizard({ jobTargets: targets }); - render(() => ); - - expect(getProgressSummary()).toHaveTextContent('Installing 1 of 2 nodes...'); - }); - - it('counts failed_retryable as completed', () => { - const targets = [ - makeTarget({ id: 't1', status: 'failed_retryable' }), - makeTarget({ id: 't2', status: 'installing' }), - ]; - const wizard = createMockWizard({ jobTargets: targets }); - render(() => ); - - expect(getProgressSummary()).toHaveTextContent('Installing 1 of 2 nodes...'); - }); - - it('counts failed_permanent as completed', () => { - const targets = [ - makeTarget({ id: 't1', status: 'failed_permanent' }), - makeTarget({ id: 't2', status: 'pending' }), - ]; - const wizard = createMockWizard({ jobTargets: targets }); - render(() => ); - - expect(getProgressSummary()).toHaveTextContent('Installing 1 of 2 nodes...'); - }); - - it('counts skipped_already_agent as completed', () => { - const targets = [ - makeTarget({ id: 't1', status: 'skipped_already_agent' }), - makeTarget({ id: 't2', status: 'pending' }), - ]; - const wizard = createMockWizard({ jobTargets: targets }); - render(() => ); - - expect(getProgressSummary()).toHaveTextContent('Installing 1 of 2 nodes...'); - }); - - it('counts canceled as completed', () => { - const targets = [ - makeTarget({ id: 't1', status: 'canceled' }), - makeTarget({ id: 't2', status: 'pending' }), - ]; - const wizard = createMockWizard({ jobTargets: targets }); - render(() => ); - - expect(getProgressSummary()).toHaveTextContent('Installing 1 of 2 nodes...'); - }); - - it('shows in-progress count for installing/enrolling/verifying targets', () => { - const targets = [ - makeTarget({ id: 't1', status: 'installing' }), - makeTarget({ id: 't2', status: 'enrolling' }), - makeTarget({ id: 't3', status: 'verifying' }), - makeTarget({ id: 't4', status: 'pending' }), - ]; - const wizard = createMockWizard({ jobTargets: targets }); - render(() => ); - - expect(getProgressSummary()).toHaveTextContent('(3 in progress)'); - }); - - it('does not show in-progress count when no targets are in progress', () => { - const targets = [ - makeTarget({ id: 't1', status: 'succeeded' }), - makeTarget({ id: 't2', status: 'pending' }), - ]; - const wizard = createMockWizard({ jobTargets: targets }); - render(() => ); - - expect(getProgressSummary()).not.toHaveTextContent('in progress'); - }); - - it('shows spinner icon when deployment is still in progress', () => { - const targets = [ - makeTarget({ id: 't1', status: 'succeeded' }), - makeTarget({ id: 't2', status: 'installing' }), - ]; - const wizard = createMockWizard({ jobTargets: targets }); - const { container } = render(() => ); - - // Loader icon has animate-spin class - const spinner = container.querySelector('.animate-spin'); - expect(spinner).toBeInTheDocument(); - }); - - it('shows check icon when all targets are completed', () => { - const targets = [ - makeTarget({ id: 't1', status: 'succeeded' }), - makeTarget({ id: 't2', status: 'failed_permanent' }), - ]; - const wizard = createMockWizard({ jobTargets: targets }); - const { container } = render(() => ); - - // Check icon has text-emerald-500 class - const checkIcon = container.querySelector('.text-emerald-500'); - expect(checkIcon).toBeInTheDocument(); - // No spinner - expect(container.querySelector('.animate-spin')).not.toBeInTheDocument(); - }); - }); - - /* ── Table rendering ──────────────────────────────────────────── */ - - describe('table rendering', () => { - it('renders table headers', () => { - const wizard = createMockWizard({ - jobTargets: [makeTarget()], - }); - render(() => ); - - expect(screen.getByText('Node')).toBeInTheDocument(); - expect(screen.getByText('IP')).toBeInTheDocument(); - expect(screen.getByText('Status')).toBeInTheDocument(); - expect(screen.getByText('Details')).toBeInTheDocument(); - }); - - it('renders a row for each target with node name and IP', () => { - const targets = [ - makeTarget({ id: 't1', nodeName: 'pve1', nodeIP: '10.0.0.1', status: 'installing' }), - makeTarget({ id: 't2', nodeName: 'pve2', nodeIP: '10.0.0.2', status: 'succeeded' }), - makeTarget({ id: 't3', nodeName: 'pve3', nodeIP: '10.0.0.3', status: 'pending' }), - ]; - const wizard = createMockWizard({ jobTargets: targets }); - render(() => ); - - expect(screen.getByText('pve1')).toBeInTheDocument(); - expect(screen.getByText('pve2')).toBeInTheDocument(); - expect(screen.getByText('pve3')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.1')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.2')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.3')).toBeInTheDocument(); - }); - - it('renders DeployStatusBadge for each target', () => { - const targets = [ - makeTarget({ id: 't1', status: 'succeeded' }), - makeTarget({ id: 't2', status: 'failed_permanent' }), - ]; - const wizard = createMockWizard({ jobTargets: targets }); - render(() => ); - - // Progress summary has role="status"; badges render label text from getDeployStatusPresentation - expect(screen.getAllByRole('status')).toHaveLength(1); - expect(screen.getByText('Deployed')).toBeInTheDocument(); - expect(screen.getByText('Failed')).toBeInTheDocument(); - }); - - it('renders empty table body when no targets', () => { - const wizard = createMockWizard({ jobTargets: [] }); - render(() => ); - - // Table headers should still appear - expect(screen.getByText('Node')).toBeInTheDocument(); - // Progress shows 0 of 0 - expect(getProgressSummary()).toHaveTextContent('Installing 0 of 0 nodes...'); - }); - }); - - /* ── Mixed status scenario ────────────────────────────────────── */ - - describe('mixed status scenario', () => { - it('correctly computes counts with all deploy-phase statuses', () => { - // Note: preflighting and ready are preflight-phase statuses, - // not expected in jobTargets during the deploying step. - const targets = [ - makeTarget({ id: 't1', status: 'succeeded' }), - makeTarget({ id: 't2', status: 'failed_retryable' }), - makeTarget({ id: 't3', status: 'failed_permanent' }), - makeTarget({ id: 't4', status: 'skipped_already_agent' }), - makeTarget({ id: 't5', status: 'canceled' }), - makeTarget({ id: 't6', status: 'installing' }), - makeTarget({ id: 't7', status: 'enrolling' }), - makeTarget({ id: 't8', status: 'verifying' }), - makeTarget({ id: 't9', status: 'pending' }), - ]; - const wizard = createMockWizard({ jobTargets: targets }); - render(() => ); - - // 5 completed (succeeded, failed_retryable, failed_permanent, skipped_already_agent, canceled) - // 3 in progress (installing, enrolling, verifying) - // 1 pending - const summary = getProgressSummary(); - expect(summary).toHaveTextContent('Installing 5 of 9 nodes...'); - expect(summary).toHaveTextContent('(3 in progress)'); - }); - - it('treats preflighting and ready as neither completed nor in-progress', () => { - // These are preflight-phase statuses — if they appear in jobTargets, - // they should not be counted as completed or in-progress. - const targets = [ - makeTarget({ id: 't1', status: 'preflighting' }), - makeTarget({ id: 't2', status: 'ready' }), - makeTarget({ id: 't3', status: 'succeeded' }), - ]; - const wizard = createMockWizard({ jobTargets: targets }); - render(() => ); - - // Only succeeded counts as completed - const summary = getProgressSummary(); - expect(summary).toHaveTextContent('Installing 1 of 3 nodes...'); - // No in-progress since preflighting/ready aren't in the inProgressStatuses set - expect(summary).not.toHaveTextContent('in progress'); - }); - }); - - /* ── Error detail rendering ───────────────────────────────────── */ - - describe('error details', () => { - it('shows error message on failed targets', () => { - const targets = [ - makeTarget({ - id: 't1', - status: 'failed_permanent', - errorMessage: 'SSH connection refused', - }), - ]; - const wizard = createMockWizard({ jobTargets: targets }); - render(() => ); - - expect(screen.getByText('SSH connection refused')).toBeInTheDocument(); - }); - - it('does not show error detail for targets without error messages', () => { - const targets = [makeTarget({ id: 't1', status: 'succeeded', errorMessage: undefined })]; - const wizard = createMockWizard({ jobTargets: targets }); - render(() => ); - - // The Details column cell (4th td) should be empty - const rows = screen.getAllByRole('row'); - const detailsCells = rows[1].querySelectorAll('td'); - // 4th cell is the Details column - expect(detailsCells[3].textContent).toBe(''); - // No expand/collapse buttons from ErrorDetail - expect(screen.queryByText('more')).not.toBeInTheDocument(); - expect(screen.queryByText('less')).not.toBeInTheDocument(); - }); - }); - - /* ── Both deploy error and target errors ──────────────────────── */ - - describe('deploy error with target errors', () => { - it('shows both deploy-level error and per-target errors', () => { - const targets = [ - makeTarget({ - id: 't1', - nodeName: 'pve1', - status: 'failed_permanent', - errorMessage: 'Permission denied', - }), - makeTarget({ - id: 't2', - nodeName: 'pve2', - status: 'succeeded', - }), - ]; - const wizard = createMockWizard({ - jobTargets: targets, - deployError: 'Job timed out', - }); - render(() => ); - - // Deploy-level error - expect(screen.getByRole('alert')).toHaveTextContent('Job timed out'); - // Per-target error - expect(screen.getByText('Permission denied')).toBeInTheDocument(); - // Node names still visible - expect(screen.getByText('pve1')).toBeInTheDocument(); - expect(screen.getByText('pve2')).toBeInTheDocument(); - }); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/deploy/__tests__/PreflightStep.test.tsx b/frontend-modern/src/components/Infrastructure/deploy/__tests__/PreflightStep.test.tsx deleted file mode 100644 index 0d95fde343..0000000000 --- a/frontend-modern/src/components/Infrastructure/deploy/__tests__/PreflightStep.test.tsx +++ /dev/null @@ -1,496 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, cleanup } from '@solidjs/testing-library'; -import { createSignal } from 'solid-js'; -import type { Accessor } from 'solid-js'; -import type { DeployTarget, DeployTargetStatus, SourceAgentInfo } from '@/types/agentDeploy'; -import type { DeployWizardState } from '@/hooks/useDeployWizard'; - -/* ── component import ──────────────────────────────────────────── */ -import { PreflightStep } from '../PreflightStep'; - -/* ── helpers ────────────────────────────────────────────────────── */ - -function makeTarget(overrides: Partial = {}): DeployTarget { - return { - id: 'target-1', - jobId: 'job-1', - nodeId: 'node-1', - nodeName: 'pve1', - nodeIP: '192.168.1.10', - arch: 'amd64', - status: 'pending', - errorMessage: undefined, - attempts: 0, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -/** - * Returns the progress summary element (the one with aria-live="polite"). - * DeployStatusBadge also uses role="status", so we need to distinguish. - */ -function getProgressSummary(): HTMLElement { - const statusElements = screen.getAllByRole('status'); - const progress = statusElements.find((el) => el.getAttribute('aria-live') === 'polite'); - if (!progress) throw new Error('Could not find progress summary element with aria-live="polite"'); - return progress; -} - -/** Creates a mock DeployWizardState with sensible defaults for PreflightStep. */ -function createMockWizard( - overrides: Partial<{ - preflightTargets: DeployTarget[]; - preflightError: string; - }> = {}, -): DeployWizardState { - const targets = overrides.preflightTargets ?? []; - const [preflightError] = createSignal(overrides.preflightError ?? ''); - - return { - preflightTargets: (() => targets) as Accessor, - preflightError, - // Stubs for fields not used by PreflightStep but required by the type. - step: (() => 'preflight') as Accessor, - setStep: vi.fn(), - candidates: (() => []) as Accessor, - candidatesLoading: (() => false) as Accessor, - candidatesError: (() => '') as Accessor, - selectedNodeIds: (() => new Set()) as Accessor>, - deployableNodes: (() => []) as Accessor, - onlineSourceAgents: (() => []) as Accessor, - sourceAgents: (() => []) as Accessor, - selectedSourceAgent: (() => '') as Accessor, - setSelectedSourceAgent: vi.fn(), - toggleNodeSelection: vi.fn(), - selectAllNodes: vi.fn(), - deselectAllNodes: vi.fn(), - preflightId: (() => '') as Accessor, - preflightStatus: (() => '') as Accessor, - preflightStream: {} as unknown, - readyNodes: (() => []) as Accessor, - failedPreflightNodes: (() => []) as Accessor, - confirmSelectedNodeIds: (() => new Set()) as Accessor>, - toggleConfirmNode: vi.fn(), - jobId: (() => '') as Accessor, - jobTargets: (() => []) as Accessor, - jobStatus: (() => '') as Accessor, - deployError: (() => '') as Accessor, - deployStream: {} as unknown, - succeededTargets: (() => []) as Accessor, - failedTargets: (() => []) as Accessor, - retryableTargets: (() => []) as Accessor, - skippedTargets: (() => []) as Accessor, - canceledTargets: (() => []) as Accessor, - startingPreflight: (() => false) as Accessor, - startingDeploy: (() => false) as Accessor, - retrying: (() => false) as Accessor, - canceling: (() => false) as Accessor, - isOperationActive: (() => false) as Accessor, - loadCandidates: vi.fn(), - startPreflight: vi.fn(), - startDeploy: vi.fn(), - cancelDeploy: vi.fn(), - retryFailed: vi.fn(), - } as unknown as DeployWizardState; -} - -/* ── lifecycle ──────────────────────────────────────────────────── */ -beforeEach(() => { - vi.clearAllMocks(); -}); - -afterEach(() => { - cleanup(); -}); - -/* ================================================================ - Tests - ================================================================ */ - -describe('PreflightStep', () => { - /* ── Error banner ────────────────────────────────────────────── */ - - describe('error banner', () => { - it('shows the preflight error when present', () => { - const wizard = createMockWizard({ preflightError: 'SSE connection lost' }); - render(() => ); - - const alert = screen.getByRole('alert'); - expect(alert).toBeInTheDocument(); - expect(alert).toHaveTextContent('SSE connection lost'); - }); - - it('does not render an alert when there is no error', () => { - const wizard = createMockWizard({ preflightError: '' }); - render(() => ); - - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - }); - }); - - /* ── Progress summary ────────────────────────────────────────── */ - - describe('progress summary', () => { - it('shows "Checking X of Y nodes..." while checks are in progress', () => { - const targets = [ - makeTarget({ id: 't1', nodeName: 'pve1', status: 'ready' }), - makeTarget({ id: 't2', nodeName: 'pve2', status: 'preflighting' }), - makeTarget({ id: 't3', nodeName: 'pve3', status: 'pending' }), - ]; - const wizard = createMockWizard({ preflightTargets: targets }); - render(() => ); - - expect(getProgressSummary()).toHaveTextContent('Checking 1 of 3 nodes...'); - }); - - it('shows "Preflight checks complete" when all nodes are done', () => { - const targets = [ - makeTarget({ id: 't1', status: 'ready' }), - makeTarget({ id: 't2', status: 'failed_permanent' }), - ]; - const wizard = createMockWizard({ preflightTargets: targets }); - render(() => ); - - expect(getProgressSummary()).toHaveTextContent('Preflight checks complete'); - }); - - it('shows "Preflight checks complete" when a single node completes', () => { - const targets = [makeTarget({ id: 't1', status: 'ready' })]; - const wizard = createMockWizard({ preflightTargets: targets }); - render(() => ); - - expect(getProgressSummary()).toHaveTextContent('Preflight checks complete'); - }); - - it('counts "pending" as not completed', () => { - const targets = [ - makeTarget({ id: 't1', status: 'pending' }), - makeTarget({ id: 't2', status: 'pending' }), - ]; - const wizard = createMockWizard({ preflightTargets: targets }); - render(() => ); - - expect(getProgressSummary()).toHaveTextContent('Checking 0 of 2 nodes...'); - }); - - it('counts "preflighting" as not completed', () => { - const targets = [ - makeTarget({ id: 't1', status: 'preflighting' }), - makeTarget({ id: 't2', status: 'ready' }), - ]; - const wizard = createMockWizard({ preflightTargets: targets }); - render(() => ); - - expect(getProgressSummary()).toHaveTextContent('Checking 1 of 2 nodes...'); - }); - }); - - /* ── Completed count logic ───────────────────────────────────── */ - - describe('completed count logic', () => { - const terminalStatuses: DeployTargetStatus[] = [ - 'ready', - 'failed_retryable', - 'failed_permanent', - 'skipped_already_agent', - 'canceled', - 'succeeded', - 'installing', - 'enrolling', - 'verifying', - ]; - - it.each(terminalStatuses)('counts status "%s" as completed', (status) => { - const targets = [makeTarget({ id: 't1', status })]; - const wizard = createMockWizard({ preflightTargets: targets }); - render(() => ); - - expect(getProgressSummary()).toHaveTextContent('Preflight checks complete'); - }); - - it('counts only non-pending/non-preflighting as completed in a mixed set', () => { - const targets = [ - makeTarget({ id: 't1', status: 'ready' }), - makeTarget({ id: 't2', status: 'failed_permanent' }), - makeTarget({ id: 't3', status: 'preflighting' }), - makeTarget({ id: 't4', status: 'pending' }), - makeTarget({ id: 't5', status: 'canceled' }), - ]; - const wizard = createMockWizard({ preflightTargets: targets }); - render(() => ); - - // 3 completed (ready, failed_permanent, canceled) out of 5 - expect(getProgressSummary()).toHaveTextContent('Checking 3 of 5 nodes...'); - }); - }); - - /* ── Table structure ─────────────────────────────────────────── */ - - describe('table structure', () => { - it('renders column headers', () => { - const wizard = createMockWizard({ preflightTargets: [makeTarget()] }); - render(() => ); - - expect(screen.getByText('Node')).toBeInTheDocument(); - expect(screen.getByText('IP')).toBeInTheDocument(); - expect(screen.getByText('Status')).toBeInTheDocument(); - expect(screen.getByText('Details')).toBeInTheDocument(); - }); - - it('renders a row for each target', () => { - const targets = [ - makeTarget({ id: 't1', nodeName: 'pve1', nodeIP: '10.0.0.1' }), - makeTarget({ id: 't2', nodeName: 'pve2', nodeIP: '10.0.0.2' }), - makeTarget({ id: 't3', nodeName: 'pve3', nodeIP: '10.0.0.3' }), - ]; - const wizard = createMockWizard({ preflightTargets: targets }); - render(() => ); - - expect(screen.getByText('pve1')).toBeInTheDocument(); - expect(screen.getByText('pve2')).toBeInTheDocument(); - expect(screen.getByText('pve3')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.1')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.2')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.3')).toBeInTheDocument(); - }); - - it('renders no rows when targets list is empty', () => { - const wizard = createMockWizard({ preflightTargets: [] }); - render(() => ); - - // Table should exist (headers) but no target content - expect(screen.getByText('Node')).toBeInTheDocument(); - expect(screen.queryByText('pve1')).not.toBeInTheDocument(); - }); - }); - - /* ── Status badges ───────────────────────────────────────────── */ - - describe('status badges', () => { - it('shows "Pending" badge for pending targets', () => { - const wizard = createMockWizard({ - preflightTargets: [makeTarget({ status: 'pending' })], - }); - render(() => ); - - expect(screen.getByText('Pending')).toBeInTheDocument(); - }); - - it('shows "Checking" badge for preflighting targets', () => { - const wizard = createMockWizard({ - preflightTargets: [makeTarget({ status: 'preflighting' })], - }); - render(() => ); - - expect(screen.getByText('Checking')).toBeInTheDocument(); - }); - - it('shows "Ready" badge for ready targets', () => { - const wizard = createMockWizard({ - preflightTargets: [makeTarget({ status: 'ready' })], - }); - render(() => ); - - expect(screen.getByText('Ready')).toBeInTheDocument(); - }); - - it('shows "Failed" badge for failed_permanent targets', () => { - const wizard = createMockWizard({ - preflightTargets: [makeTarget({ status: 'failed_permanent' })], - }); - render(() => ); - - expect(screen.getByText('Failed')).toBeInTheDocument(); - }); - - it('shows "Failed" badge for failed_retryable targets', () => { - const wizard = createMockWizard({ - preflightTargets: [makeTarget({ status: 'failed_retryable' })], - }); - render(() => ); - - expect(screen.getByText('Failed')).toBeInTheDocument(); - }); - - it('shows "Canceled" badge for canceled targets', () => { - const wizard = createMockWizard({ - preflightTargets: [makeTarget({ status: 'canceled' })], - }); - render(() => ); - - expect(screen.getByText('Canceled')).toBeInTheDocument(); - }); - - it('shows "Installing" badge for installing targets', () => { - const wizard = createMockWizard({ - preflightTargets: [makeTarget({ status: 'installing' })], - }); - render(() => ); - - expect(screen.getByText('Installing')).toBeInTheDocument(); - }); - - it('shows "Enrolling" badge for enrolling targets', () => { - const wizard = createMockWizard({ - preflightTargets: [makeTarget({ status: 'enrolling' })], - }); - render(() => ); - - expect(screen.getByText('Enrolling')).toBeInTheDocument(); - }); - - it('shows "Verifying" badge for verifying targets', () => { - const wizard = createMockWizard({ - preflightTargets: [makeTarget({ status: 'verifying' })], - }); - render(() => ); - - expect(screen.getByText('Verifying')).toBeInTheDocument(); - }); - - it('shows "Deployed" badge for succeeded targets', () => { - const wizard = createMockWizard({ - preflightTargets: [makeTarget({ status: 'succeeded' })], - }); - render(() => ); - - expect(screen.getByText('Deployed')).toBeInTheDocument(); - }); - - it('shows "Already monitored" badge for skipped_already_agent targets', () => { - const wizard = createMockWizard({ - preflightTargets: [makeTarget({ status: 'skipped_already_agent' })], - }); - render(() => ); - - expect(screen.getByText('Already monitored')).toBeInTheDocument(); - }); - - }); - - /* ── Error details ───────────────────────────────────────────── */ - - describe('error details', () => { - it('shows error message for a target with an error', () => { - const wizard = createMockWizard({ - preflightTargets: [ - makeTarget({ status: 'failed_permanent', errorMessage: 'connection refused' }), - ], - }); - render(() => ); - - expect(screen.getByText(/connection refused/)).toBeInTheDocument(); - }); - - it('does not show error text for targets without errors', () => { - const wizard = createMockWizard({ - preflightTargets: [makeTarget({ status: 'ready', errorMessage: undefined })], - }); - render(() => ); - - // ErrorDetail renders nothing when message is undefined — no error UI elements - expect(screen.queryByText('more')).not.toBeInTheDocument(); - expect(screen.queryByText('less')).not.toBeInTheDocument(); - // No red error text containers rendered (ErrorDetail wraps in text-red-600) - const redText = document.querySelector('.text-red-600, .text-red-400'); - expect(redText).toBeNull(); - }); - }); - - /* ── Mixed scenario ──────────────────────────────────────────── */ - - describe('mixed scenario', () => { - it('renders correct state for multiple targets in various states', () => { - const targets = [ - makeTarget({ - id: 't1', - nodeName: 'node-ready', - nodeIP: '10.0.0.1', - status: 'ready', - }), - makeTarget({ - id: 't2', - nodeName: 'node-checking', - nodeIP: '10.0.0.2', - status: 'preflighting', - }), - makeTarget({ - id: 't3', - nodeName: 'node-failed', - nodeIP: '10.0.0.3', - status: 'failed_permanent', - errorMessage: 'permission denied', - }), - makeTarget({ - id: 't4', - nodeName: 'node-pending', - nodeIP: '10.0.0.4', - status: 'pending', - }), - ]; - const wizard = createMockWizard({ preflightTargets: targets }); - render(() => ); - - // All node names rendered - expect(screen.getByText('node-ready')).toBeInTheDocument(); - expect(screen.getByText('node-checking')).toBeInTheDocument(); - expect(screen.getByText('node-failed')).toBeInTheDocument(); - expect(screen.getByText('node-pending')).toBeInTheDocument(); - - // All IPs rendered - expect(screen.getByText('10.0.0.1')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.2')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.3')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.4')).toBeInTheDocument(); - - // Status badges: Ready, Checking, Failed, Pending - expect(screen.getByText('Ready')).toBeInTheDocument(); - expect(screen.getByText('Checking')).toBeInTheDocument(); - expect(screen.getByText('Failed')).toBeInTheDocument(); - expect(screen.getByText('Pending')).toBeInTheDocument(); - - // Error message present for failed node - expect(screen.getByText(/permission denied/)).toBeInTheDocument(); - - // Progress: 2 completed (ready + failed_permanent) out of 4 - expect(getProgressSummary()).toHaveTextContent('Checking 2 of 4 nodes...'); - }); - }); - - /* ── Error banner + targets together ─────────────────────────── */ - - describe('error banner with targets', () => { - it('shows both the error banner and the targets table', () => { - const wizard = createMockWizard({ - preflightError: 'Stream disconnected', - preflightTargets: [ - makeTarget({ id: 't1', nodeName: 'pve1', status: 'ready' }), - makeTarget({ id: 't2', nodeName: 'pve2', status: 'pending' }), - ], - }); - render(() => ); - - // Error banner visible - expect(screen.getByRole('alert')).toHaveTextContent('Stream disconnected'); - - // Targets still rendered - expect(screen.getByText('pve1')).toBeInTheDocument(); - expect(screen.getByText('pve2')).toBeInTheDocument(); - }); - }); - - /* ── Empty targets with no error ─────────────────────────────── */ - - describe('empty targets', () => { - it('shows the "Checking" summary when targets list is empty', () => { - const wizard = createMockWizard({ preflightTargets: [] }); - render(() => ); - - // totalCount=0 short-circuits to the "Checking..." branch so the summary - // never prematurely declares "complete" before targets populate. - expect(getProgressSummary()).toHaveTextContent('Checking 0 of 0 nodes...'); - }); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/deploy/__tests__/ResultsStep.test.tsx b/frontend-modern/src/components/Infrastructure/deploy/__tests__/ResultsStep.test.tsx deleted file mode 100644 index 33bbfdcb09..0000000000 --- a/frontend-modern/src/components/Infrastructure/deploy/__tests__/ResultsStep.test.tsx +++ /dev/null @@ -1,614 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, cleanup, waitFor } from '@solidjs/testing-library'; -import type { Accessor } from 'solid-js'; -import type { DeployTarget, SourceAgentInfo } from '@/types/agentDeploy'; -import type { DeployWizardState } from '@/hooks/useDeployWizard'; - -/* ── hoisted mocks (must be before imports) ──────────────────── */ - -const { nodesAPIMock, copyToClipboardMock } = vi.hoisted(() => ({ - nodesAPIMock: { - getAgentInstallCommand: vi.fn(), - }, - copyToClipboardMock: vi.fn(), -})); - -vi.mock('@/api/nodes', () => ({ NodesAPI: nodesAPIMock })); -vi.mock('@/utils/clipboard', () => ({ copyToClipboard: copyToClipboardMock })); - -// Stub lucide icons — they don't render visually in jsdom -vi.mock('lucide-solid/icons/check-circle-2', () => ({ - default: () => , -})); -vi.mock('lucide-solid/icons/x-circle', () => ({ - default: () => , -})); -vi.mock('lucide-solid/icons/alert-circle', () => ({ - default: () => , -})); -vi.mock('lucide-solid/icons/chevron-down', () => ({ - default: () => , -})); -vi.mock('lucide-solid/icons/chevron-right', () => ({ - default: () => , -})); -vi.mock('lucide-solid/icons/copy', () => ({ - default: () => , -})); -vi.mock('lucide-solid/icons/check', () => ({ - default: () => , -})); - -/* ── component import ──────────────────────────────────────────── */ -import { ResultsStep } from '../ResultsStep'; - -/* ── helpers ────────────────────────────────────────────────────── */ - -function makeTarget(overrides: Partial = {}): DeployTarget { - return { - id: 'target-1', - jobId: 'job-1', - nodeId: 'node-1', - nodeName: 'pve1', - nodeIP: '192.168.1.10', - status: 'pending', - attempts: 0, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -/** Creates a mock DeployWizardState with sensible defaults for ResultsStep. */ -function createMockWizard( - overrides: Partial<{ - succeededTargets: DeployTarget[]; - failedTargets: DeployTarget[]; - skippedTargets: DeployTarget[]; - canceledTargets: DeployTarget[]; - }> = {}, -): DeployWizardState { - return { - succeededTargets: (() => overrides.succeededTargets ?? []) as Accessor, - failedTargets: (() => overrides.failedTargets ?? []) as Accessor, - skippedTargets: (() => overrides.skippedTargets ?? []) as Accessor, - canceledTargets: (() => overrides.canceledTargets ?? []) as Accessor, - - // Stubs for fields not used by ResultsStep but required by the type. - step: (() => 'results') as Accessor, - setStep: vi.fn(), - candidates: (() => []) as Accessor, - candidatesLoading: (() => false) as Accessor, - candidatesError: (() => '') as Accessor, - sourceAgents: (() => []) as Accessor, - onlineSourceAgents: (() => []) as Accessor, - selectedSourceAgent: (() => '') as Accessor, - setSelectedSourceAgent: vi.fn(), - selectedNodeIds: (() => new Set()) as Accessor>, - deployableNodes: (() => []) as Accessor, - toggleNodeSelection: vi.fn(), - selectAllNodes: vi.fn(), - deselectAllNodes: vi.fn(), - loadCandidates: vi.fn(), - preflightId: (() => '') as Accessor, - preflightTargets: (() => []) as Accessor, - preflightStatus: (() => '') as Accessor, - preflightError: (() => '') as Accessor, - preflightStream: {} as unknown, - readyNodes: (() => []) as Accessor, - failedPreflightNodes: (() => []) as Accessor, - confirmSelectedNodeIds: (() => new Set()) as Accessor>, - toggleConfirmNode: vi.fn(), - jobId: (() => '') as Accessor, - jobStatus: (() => '') as Accessor, - jobTargets: (() => []) as Accessor, - deployError: (() => '') as Accessor, - deployStream: {} as unknown, - retryableTargets: (() => []) as Accessor, - startingPreflight: (() => false) as Accessor, - startingDeploy: (() => false) as Accessor, - retrying: (() => false) as Accessor, - canceling: (() => false) as Accessor, - isOperationActive: (() => false) as Accessor, - startPreflight: vi.fn(), - startDeploy: vi.fn(), - cancelDeploy: vi.fn(), - retryFailed: vi.fn(), - } as unknown as DeployWizardState; -} - -/* ── lifecycle ──────────────────────────────────────────────────── */ -beforeEach(() => { - vi.clearAllMocks(); -}); - -afterEach(() => { - vi.useRealTimers(); - cleanup(); -}); - -/* ================================================================ - Tests - ================================================================ */ - -describe('ResultsStep', () => { - /* ── Succeeded section ──────────────────────────────────────── */ - - describe('succeeded targets', () => { - it('shows the Deployed header with count when there are succeeded targets', () => { - const wizard = createMockWizard({ - succeededTargets: [ - makeTarget({ id: 't1', nodeName: 'pve1', status: 'succeeded' }), - makeTarget({ id: 't2', nodeName: 'pve2', status: 'succeeded' }), - ], - }); - render(() => ); - - expect(screen.getByText('Deployed (2)')).toBeInTheDocument(); - }); - - it('renders node names and IPs in the succeeded table', () => { - const wizard = createMockWizard({ - succeededTargets: [ - makeTarget({ id: 't1', nodeName: 'pve1', nodeIP: '10.0.0.1', status: 'succeeded' }), - makeTarget({ id: 't2', nodeName: 'pve2', nodeIP: '10.0.0.2', status: 'succeeded' }), - ], - }); - render(() => ); - - expect(screen.getByText('pve1')).toBeInTheDocument(); - expect(screen.getByText('pve2')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.1')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.2')).toBeInTheDocument(); - }); - - it('renders DeployStatusBadge for each succeeded target', () => { - const wizard = createMockWizard({ - succeededTargets: [ - makeTarget({ id: 't1', status: 'succeeded' }), - makeTarget({ id: 't2', status: 'succeeded' }), - ], - }); - render(() => ); - - // DeployStatusBadge renders a badge span for each succeeded row - const badges = screen.getAllByText('Deployed'); - expect(badges).toHaveLength(2); - }); - - it('does not show the Deployed section when there are no succeeded targets', () => { - const wizard = createMockWizard({ succeededTargets: [] }); - render(() => ); - - expect(screen.queryByText(/Deployed \(/)).not.toBeInTheDocument(); - }); - }); - - /* ── Failed section ─────────────────────────────────────────── */ - - describe('failed targets', () => { - it('shows the Failed header with count', () => { - const wizard = createMockWizard({ - failedTargets: [ - makeTarget({ id: 't1', status: 'failed_permanent' }), - makeTarget({ id: 't2', status: 'failed_retryable' }), - makeTarget({ id: 't3', status: 'failed_permanent' }), - ], - }); - render(() => ); - - expect(screen.getByText('Failed (3)')).toBeInTheDocument(); - }); - - it('renders node names, IPs, and error details for failed targets', () => { - const wizard = createMockWizard({ - failedTargets: [ - makeTarget({ - id: 't1', - nodeName: 'pve3', - nodeIP: '10.0.0.3', - status: 'failed_permanent', - errorMessage: 'SSH connection refused', - }), - ], - }); - render(() => ); - - expect(screen.getByText('pve3')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.3')).toBeInTheDocument(); - expect(screen.getByText('SSH connection refused')).toBeInTheDocument(); - }); - - it('does not show the Failed section when there are no failed targets', () => { - const wizard = createMockWizard({ failedTargets: [] }); - render(() => ); - - expect(screen.queryByText(/Failed \(/)).not.toBeInTheDocument(); - }); - }); - - /* ── Skipped section ────────────────────────────────────────── */ - - describe('skipped targets', () => { - it('shows the Skipped header with count', () => { - const wizard = createMockWizard({ - skippedTargets: [ - makeTarget({ id: 't1', status: 'skipped_already_agent' }), - ], - }); - render(() => ); - - expect(screen.getByText('Skipped (1)')).toBeInTheDocument(); - }); - - it('renders node names and IPs in the skipped table', () => { - const wizard = createMockWizard({ - skippedTargets: [ - makeTarget({ - id: 't1', - nodeName: 'pve4', - nodeIP: '10.0.0.4', - status: 'skipped_already_agent', - }), - ], - }); - render(() => ); - - expect(screen.getByText('pve4')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.4')).toBeInTheDocument(); - }); - - it('does not show the Skipped section when there are no skipped targets', () => { - const wizard = createMockWizard({ skippedTargets: [] }); - render(() => ); - - expect(screen.queryByText(/Skipped \(/)).not.toBeInTheDocument(); - }); - }); - - /* ── Canceled section ───────────────────────────────────────── */ - - describe('canceled targets', () => { - it('shows the Canceled header with count', () => { - const wizard = createMockWizard({ - canceledTargets: [makeTarget({ id: 't1', status: 'canceled' })], - }); - render(() => ); - - expect(screen.getByText('Canceled (1)')).toBeInTheDocument(); - }); - - it('renders node names and IPs in the canceled table', () => { - const wizard = createMockWizard({ - canceledTargets: [ - makeTarget({ id: 't1', nodeName: 'pve5', nodeIP: '10.0.0.5', status: 'canceled' }), - ], - }); - render(() => ); - - expect(screen.getByText('pve5')).toBeInTheDocument(); - expect(screen.getByText('10.0.0.5')).toBeInTheDocument(); - }); - - it('does not show the Canceled section when there are no canceled targets', () => { - const wizard = createMockWizard({ canceledTargets: [] }); - render(() => ); - - expect(screen.queryByText(/Canceled \(/)).not.toBeInTheDocument(); - }); - }); - - /* ── Empty state ────────────────────────────────────────────── */ - - describe('empty state', () => { - it('renders nothing visible when all target arrays are empty', () => { - const wizard = createMockWizard(); - const { container } = render(() => ); - - // The root div exists but has no visible sections - expect(screen.queryByText(/Deployed/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Failed/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Skipped/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Canceled/)).not.toBeInTheDocument(); - // Only the outer container div - expect(container.querySelector('table')).not.toBeInTheDocument(); - }); - }); - - /* ── Multiple sections ──────────────────────────────────────── */ - - describe('multiple sections visible', () => { - it('renders all four sections when each has targets', () => { - const wizard = createMockWizard({ - succeededTargets: [makeTarget({ id: 's1', nodeName: 'pve-ok', status: 'succeeded' })], - failedTargets: [makeTarget({ id: 'f1', nodeName: 'pve-fail', status: 'failed_permanent' })], - skippedTargets: [ - makeTarget({ id: 'sk1', nodeName: 'pve-skip', status: 'skipped_already_agent' }), - ], - canceledTargets: [makeTarget({ id: 'c1', nodeName: 'pve-cancel', status: 'canceled' })], - }); - render(() => ); - - expect(screen.getByText('Deployed (1)')).toBeInTheDocument(); - expect(screen.getByText('Failed (1)')).toBeInTheDocument(); - expect(screen.getByText('Skipped (1)')).toBeInTheDocument(); - expect(screen.getByText('Canceled (1)')).toBeInTheDocument(); - - expect(screen.getByText('pve-ok')).toBeInTheDocument(); - expect(screen.getByText('pve-fail')).toBeInTheDocument(); - expect(screen.getByText('pve-skip')).toBeInTheDocument(); - expect(screen.getByText('pve-cancel')).toBeInTheDocument(); - }); - }); - - /* ── Manual install accordion ───────────────────────────────── */ - - describe('manual install accordion', () => { - function renderWithFailedTargets() { - const wizard = createMockWizard({ - failedTargets: [ - makeTarget({ id: 'f1', status: 'failed_permanent', errorMessage: 'Timeout' }), - ], - }); - return render(() => ); - } - - it('shows the "Manual install instructions" button when failed targets exist', () => { - renderWithFailedTargets(); - - expect(screen.getByText('Manual install instructions')).toBeInTheDocument(); - }); - - it('does not show manual install button when there are no failed targets', () => { - const wizard = createMockWizard({ failedTargets: [] }); - render(() => ); - - expect(screen.queryByText('Manual install instructions')).not.toBeInTheDocument(); - }); - - it('starts with accordion collapsed (aria-expanded=false)', () => { - renderWithFailedTargets(); - - const btn = screen.getByText('Manual install instructions'); - expect(btn.closest('button')).toHaveAttribute('aria-expanded', 'false'); - }); - - it('expands the accordion on click and fetches install command', async () => { - nodesAPIMock.getAgentInstallCommand.mockResolvedValueOnce({ - command: 'curl -s https://example.com | bash', - }); - renderWithFailedTargets(); - - const btn = screen.getByText('Manual install instructions'); - fireEvent.click(btn); - - // Button should now be expanded - expect(btn.closest('button')).toHaveAttribute('aria-expanded', 'true'); - - // Should have called the API - expect(nodesAPIMock.getAgentInstallCommand).toHaveBeenCalledWith({ - type: 'pve', - enableProxmox: true, - }); - - // Instruction text appears - expect(screen.getByText(/For nodes that failed SSH-based deployment/)).toBeInTheDocument(); - - // Wait for command to load - await waitFor(() => { - expect(screen.getByText('curl -s https://example.com | bash')).toBeInTheDocument(); - }); - }); - - it('collapses the accordion on second click', async () => { - nodesAPIMock.getAgentInstallCommand.mockResolvedValueOnce({ command: 'curl ...' }); - renderWithFailedTargets(); - - const btn = screen.getByText('Manual install instructions'); - fireEvent.click(btn); // open - expect(btn.closest('button')).toHaveAttribute('aria-expanded', 'true'); - - fireEvent.click(btn); // close - expect(btn.closest('button')).toHaveAttribute('aria-expanded', 'false'); - }); - - it('shows loading state while fetching the install command', async () => { - // Never-resolving promise to keep loading - nodesAPIMock.getAgentInstallCommand.mockReturnValueOnce(new Promise(() => {})); - renderWithFailedTargets(); - - fireEvent.click(screen.getByText('Manual install instructions')); - - expect(screen.getByText('Loading install command...')).toBeInTheDocument(); - }); - - it('shows error when install command fetch fails', async () => { - nodesAPIMock.getAgentInstallCommand.mockRejectedValueOnce(new Error('Network error')); - renderWithFailedTargets(); - - fireEvent.click(screen.getByText('Manual install instructions')); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent('Network error'); - }); - }); - - it('surfaces canonical install-command response validation errors', async () => { - nodesAPIMock.getAgentInstallCommand.mockRejectedValueOnce( - new Error('Invalid agent install command response'), - ); - renderWithFailedTargets(); - - fireEvent.click(screen.getByText('Manual install instructions')); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent( - 'Invalid agent install command response', - ); - }); - }); - - it('shows generic error message for non-Error exceptions', async () => { - nodesAPIMock.getAgentInstallCommand.mockRejectedValueOnce('string error'); - renderWithFailedTargets(); - - fireEvent.click(screen.getByText('Manual install instructions')); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent('Failed to load install command'); - }); - }); - - it('only fetches the install command once even if accordion is toggled multiple times', async () => { - nodesAPIMock.getAgentInstallCommand.mockResolvedValueOnce({ command: 'curl install' }); - renderWithFailedTargets(); - - const btn = screen.getByText('Manual install instructions'); - fireEvent.click(btn); // open — triggers fetch - - await waitFor(() => { - expect(screen.getByText('curl install')).toBeInTheDocument(); - }); - - fireEvent.click(btn); // close - fireEvent.click(btn); // open again — should NOT fetch again - - expect(nodesAPIMock.getAgentInstallCommand).toHaveBeenCalledTimes(1); - }); - - it('does not call API before accordion is opened', () => { - renderWithFailedTargets(); - - // Accordion exists but API should not have been called - expect(nodesAPIMock.getAgentInstallCommand).not.toHaveBeenCalled(); - }); - - it('allows retry after fetch error', async () => { - nodesAPIMock.getAgentInstallCommand - .mockRejectedValueOnce(new Error('first failure')) - .mockResolvedValueOnce({ command: 'curl retry' }); - renderWithFailedTargets(); - - const btn = screen.getByText('Manual install instructions'); - fireEvent.click(btn); // open — triggers fetch (will fail) - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent('first failure'); - }); - - // Close and reopen to retry - fireEvent.click(btn); - fireEvent.click(btn); - - await waitFor(() => { - expect(screen.getByText('curl retry')).toBeInTheDocument(); - }); - - expect(nodesAPIMock.getAgentInstallCommand).toHaveBeenCalledTimes(2); - }); - - it('clears error UI when retry succeeds', async () => { - nodesAPIMock.getAgentInstallCommand - .mockRejectedValueOnce(new Error('temporary failure')) - .mockResolvedValueOnce({ command: 'curl success' }); - renderWithFailedTargets(); - - const btn = screen.getByText('Manual install instructions'); - fireEvent.click(btn); - - // Error appears - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent('temporary failure'); - }); - - // Close and reopen to retry - fireEvent.click(btn); - fireEvent.click(btn); - - // Error should be cleared and command shown - await waitFor(() => { - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - expect(screen.getByText('curl success')).toBeInTheDocument(); - }); - }); - }); - - /* ── Copy button ────────────────────────────────────────────── */ - - describe('copy to clipboard', () => { - async function renderWithCommand() { - nodesAPIMock.getAgentInstallCommand.mockResolvedValueOnce({ command: 'curl install-cmd' }); - const wizard = createMockWizard({ - failedTargets: [makeTarget({ id: 'f1', status: 'failed_permanent' })], - }); - render(() => ); - - // Open accordion and wait for command - fireEvent.click(screen.getByText('Manual install instructions')); - await waitFor(() => { - expect(screen.getByText('curl install-cmd')).toBeInTheDocument(); - }); - } - - it('renders the copy button with "Copy to clipboard" label', async () => { - await renderWithCommand(); - - expect(screen.getByLabelText('Copy to clipboard')).toBeInTheDocument(); - }); - - it('copies the install command on click', async () => { - copyToClipboardMock.mockResolvedValueOnce(true); - await renderWithCommand(); - - fireEvent.click(screen.getByLabelText('Copy to clipboard')); - - expect(copyToClipboardMock).toHaveBeenCalledWith('curl install-cmd'); - }); - - it('shows "Copied" label after successful copy', async () => { - copyToClipboardMock.mockResolvedValueOnce(true); - await renderWithCommand(); - - fireEvent.click(screen.getByLabelText('Copy to clipboard')); - - await waitFor(() => { - expect(screen.getByLabelText('Copied')).toBeInTheDocument(); - }); - }); - - it('does not change label when copy fails', async () => { - copyToClipboardMock.mockResolvedValueOnce(false); - await renderWithCommand(); - - fireEvent.click(screen.getByLabelText('Copy to clipboard')); - - // Wait for the async copy call to resolve, then verify label is unchanged - await waitFor(() => { - expect(copyToClipboardMock).toHaveBeenCalled(); - }); - expect(screen.getByLabelText('Copy to clipboard')).toBeInTheDocument(); - expect(screen.queryByLabelText('Copied')).not.toBeInTheDocument(); - }); - - it('reverts "Copied" label back to "Copy to clipboard" after timeout', async () => { - vi.useFakeTimers(); - copyToClipboardMock.mockResolvedValueOnce(true); - await renderWithCommand(); - - fireEvent.click(screen.getByLabelText('Copy to clipboard')); - - await waitFor(() => { - expect(screen.getByLabelText('Copied')).toBeInTheDocument(); - }); - - // Advance past the 2s timer - vi.advanceTimersByTime(2100); - - await waitFor(() => { - expect(screen.getByLabelText('Copy to clipboard')).toBeInTheDocument(); - }); - - vi.useRealTimers(); - }); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/resourceBadges.ts b/frontend-modern/src/components/Infrastructure/resourceBadges.ts deleted file mode 100644 index 42dbc5a4f2..0000000000 --- a/frontend-modern/src/components/Infrastructure/resourceBadges.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type { ResourceBadge } from '@/utils/resourceBadgePresentation'; -export { - getContainerRuntimeBadge, - getContainerRuntimeBadgeForRuntime, - getInfrastructureSystemIdentityBadges, - getInfrastructureSystemIdentitySortLabel, - getInfrastructurePlatformBadges, - getPlatformBadge, - getSourceBadge, - getTypeBadge, - getUnifiedSourceBadges, -} from '@/utils/resourceBadgePresentation'; diff --git a/frontend-modern/src/components/Storage/DiskLiveMetric.tsx b/frontend-modern/src/components/Storage/DiskLiveMetric.tsx deleted file mode 100644 index 2b692cd1ad..0000000000 --- a/frontend-modern/src/components/Storage/DiskLiveMetric.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { useDiskLiveMetricModel } from './useDiskLiveMetricModel'; - -interface DiskLiveMetricProps { - resourceId: string; - type: 'read' | 'write' | 'ioTime'; -} - -export function DiskLiveMetric(props: DiskLiveMetricProps) { - const { formatted, colorClass } = useDiskLiveMetricModel({ - resourceId: () => props.resourceId, - type: () => props.type, - }); - - return {formatted()}; -} diff --git a/frontend-modern/src/components/Storage/ZFSHealthMap.tsx b/frontend-modern/src/components/Storage/ZFSHealthMap.tsx deleted file mode 100644 index 7500445618..0000000000 --- a/frontend-modern/src/components/Storage/ZFSHealthMap.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Component, For, Show } from 'solid-js'; -import { Portal } from 'solid-js/web'; -import { - getZfsDeviceBlockClass, - getZfsDeviceStateTextClass, -} from '@/features/storageBackups/zfsPresentation'; -import { - getZfsHealthMapDeviceClass, - getZfsHealthMapErrorSummaryClass, - getZfsHealthMapMessageClass, - getZfsHealthMapTooltipStyle, - ZFS_HEALTH_MAP_ROOT_CLASS, - ZFS_HEALTH_MAP_TOOLTIP_CARD_CLASS, - ZFS_HEALTH_MAP_TOOLTIP_NAME_CLASS, - ZFS_HEALTH_MAP_TOOLTIP_PORTAL_CLASS, - ZFS_HEALTH_MAP_TOOLTIP_STATE_ROW_CLASS, - ZFS_HEALTH_MAP_TOOLTIP_STATE_TEXT_CLASS, - ZFS_HEALTH_MAP_TOOLTIP_TYPE_CLASS, -} from '@/features/storageBackups/zfsHealthMapPresentation'; -import type { ZFSPool } from '@/types/api'; -import { useZFSHealthMapModel } from './useZFSHealthMapModel'; - -interface ZFSHealthMapProps { - pool: ZFSPool; -} - -export const ZFSHealthMap: Component = (props) => { - const { - devices, - hoveredDevice, - hoveredTooltip, - tooltipPos, - isResilvering, - handleMouseEnter, - handleMouseLeave, - } = useZFSHealthMapModel(() => props.pool); - - return ( -
- - {(device) => ( -
handleMouseEnter(e, device)} - onMouseLeave={handleMouseLeave} - /> - )} - - - - -
-
-
{hoveredTooltip()?.name}
-
{hoveredTooltip()?.type}
-
- - {hoveredTooltip()?.state} - - - - ({hoveredTooltip()?.errorSummary}) - - -
- -
- {hoveredTooltip()?.message} -
-
-
-
-
-
-
- ); -}; diff --git a/frontend-modern/src/components/Storage/__tests__/storageSourceOptions.test.ts b/frontend-modern/src/components/Storage/__tests__/storageSourceOptions.test.ts deleted file mode 100644 index 416057052f..0000000000 --- a/frontend-modern/src/components/Storage/__tests__/storageSourceOptions.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { Storage } from '@/types/api'; -import { - buildStorageSourceOptions, - buildStorageSourceOptionsFromKeys, - getStorageSourceOption, - normalizeStorageSourceKey, - resolveStorageSourceKey, -} from '@/utils/storageSources'; - -const makeStorage = (partial: Partial): Storage => ({ - id: partial.id || 'storage-1', - name: partial.name || 'pool', - node: partial.node || 'node-1', - instance: partial.instance || 'cluster-a', - type: partial.type || 'dir', - status: partial.status || 'available', - total: partial.total ?? 1, - used: partial.used ?? 0, - free: partial.free ?? 1, - usage: partial.usage ?? 0, - content: partial.content || 'images', - shared: partial.shared ?? false, - enabled: partial.enabled ?? true, - active: partial.active ?? true, - nodes: partial.nodes, - nodeIds: partial.nodeIds, - nodeCount: partial.nodeCount, - pbsNames: partial.pbsNames, - zfsPool: partial.zfsPool, -}); - -describe('storageSourceOptions', () => { - it('normalizes known source aliases', () => { - expect(normalizeStorageSourceKey('PVE')).toBe('proxmox-pve'); - expect(normalizeStorageSourceKey('proxmox-pbs')).toBe('proxmox-pbs'); - expect(normalizeStorageSourceKey('rbd')).toBe('ceph'); - expect(normalizeStorageSourceKey('k8s')).toBe('kubernetes'); - }); - - it('resolves storage source from storage type', () => { - expect(resolveStorageSourceKey(makeStorage({ type: 'pbs' }))).toBe('proxmox-pbs'); - expect(resolveStorageSourceKey(makeStorage({ type: 'cephfs' }))).toBe('ceph'); - expect(resolveStorageSourceKey(makeStorage({ type: 'lvmthin' }))).toBe('proxmox-pve'); - }); - - it('builds data-driven source options with stable ordering and all option', () => { - const options = buildStorageSourceOptions([ - makeStorage({ type: 'ceph' }), - makeStorage({ id: '2', type: 'pbs' }), - makeStorage({ id: '3', type: 'dir' }), - makeStorage({ id: '4', type: 'custom-backend' }), - ]); - - expect(options[0]).toMatchObject({ key: 'all', label: 'All sources' }); - expect(options.map((option) => option.key)).toEqual([ - 'all', - 'proxmox-pve', - 'proxmox-pbs', - 'ceph', - 'custom-backend', - ]); - }); - - it('derives canonical storage source presentation from shared source contracts', () => { - expect(getStorageSourceOption('pve')).toMatchObject({ - key: 'proxmox-pve', - label: 'PVE', - tone: 'orange', - }); - expect(getStorageSourceOption('pbs')).toMatchObject({ - key: 'proxmox-pbs', - label: 'PBS', - tone: 'indigo', - }); - expect(getStorageSourceOption('truenas')).toMatchObject({ - key: 'truenas', - label: 'TrueNAS', - tone: 'blue', - }); - expect( - buildStorageSourceOptionsFromKeys(['all', 'truenas', 'pbs', 'ceph']).map( - (option) => option.key, - ), - ).toEqual(['all', 'proxmox-pbs', 'ceph', 'truenas']); - }); -}); diff --git a/frontend-modern/src/components/Storage/storageSourceOptions.ts b/frontend-modern/src/components/Storage/storageSourceOptions.ts deleted file mode 100644 index 41aa57c2ec..0000000000 --- a/frontend-modern/src/components/Storage/storageSourceOptions.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type { StorageSourceOption, StorageSourceTone } from '@/utils/storageSources'; -export { - buildStorageSourceOptions, - DEFAULT_STORAGE_SOURCE_OPTIONS, - normalizeStorageSourceKey, - resolveStorageSourceKey, -} from '@/utils/storageSources'; diff --git a/frontend-modern/src/components/shared/CollapsibleSearchInput.tsx b/frontend-modern/src/components/shared/CollapsibleSearchInput.tsx deleted file mode 100644 index 057a73cd4e..0000000000 --- a/frontend-modern/src/components/shared/CollapsibleSearchInput.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Component, Show } from 'solid-js'; -import { SearchInput } from '@/components/shared/SearchInput'; -import { - type CollapsibleSearchInputProps, -} from './collapsibleSearchInputModel'; -import { useCollapsibleSearchInputState } from './useCollapsibleSearchInputState'; - -export type { CollapsibleSearchInputProps } from './collapsibleSearchInputModel'; - -export const CollapsibleSearchInput: Component = (props) => { - const collapsible = useCollapsibleSearchInputState(props); - - return ( -
- - -
- } - > - { - collapsible.setInputRef(el); - props.inputRef?.(el); - }} - onBeforeAutoFocus={props.onBeforeAutoFocus} - typeToSearch={false} - /> - -
- ); -}; diff --git a/frontend-modern/src/components/shared/DensityMap.tsx b/frontend-modern/src/components/shared/DensityMap.tsx deleted file mode 100644 index f28e7c1184..0000000000 --- a/frontend-modern/src/components/shared/DensityMap.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { Component, Show } from 'solid-js'; -import { - formatDensityMapHoverTime, - hasDensityMapFocusActivity, - type DensityMapProps, -} from './densityMapModel'; -import { TooltipPortal } from './TooltipPortal'; -import { useDensityMapState } from './useDensityMapState'; - -export type { DensityMapProps } from './densityMapModel'; - -export const DensityMap: Component = (props) => { - const densityMap = useDensityMapState(props); - const interactionState = () => props.interactionState ?? 'default'; - const formatDetailValue = (value: number | null) => - value === null ? 'No sample' : densityMap.formatValue(value); - const hoveredFocusDetail = () => (densityMap.hoveredState() ? densityMap.focusDetail() : null); - - return ( -
-
- {/* Y-axis: typically in a density map we might just show "Top Nodes" */} -
- {/* We could overlay series names here, but it might get messy. Let's rely on tooltip. */} -
- -
- -
-
- - {/* X-axis labels */} -
- - -{props.rangeLabel || props.timeRange} - - - now - -
- - - {(hover) => ( - -
-
- - - {hover().seriesName} - - - {formatDensityMapHoverTime(hover().timestamp)} - -
-
-
- Current - - {densityMap.formatValue(hover().value)} - -
- - Peak - - No sample - -
- } - > - {(detail) => ( - - Peak - - {props.focusEmptyStateLabel ?? 'No activity in range'} - -
- } - > -
- Peak - - {formatDetailValue(detail().peakValue)} - -
-
- )} - -
-
- - )} -
-
- ); -}; - -export default DensityMap; diff --git a/frontend-modern/src/components/shared/InfrastructureDetailsDrawer.tsx b/frontend-modern/src/components/shared/InfrastructureDetailsDrawer.tsx deleted file mode 100644 index 964f63f947..0000000000 --- a/frontend-modern/src/components/shared/InfrastructureDetailsDrawer.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { Component, Suspense, createMemo } from 'solid-js'; -import { DiscoveryTab } from '../Discovery/DiscoveryTab'; -import { SystemInfoCard } from '@/components/shared/cards/SystemInfoCard'; -import { HardwareCard } from '@/components/shared/cards/HardwareCard'; -import { RootDiskCard } from '@/components/shared/cards/RootDiskCard'; -import { NetworkInterfacesCard } from '@/components/shared/cards/NetworkInterfacesCard'; -import { DisksCard } from '@/components/shared/cards/DisksCard'; -import { StatusDot } from '@/components/shared/StatusDot'; -import { WebInterfaceUrlField } from '@/components/shared/WebInterfaceUrlField'; -import { getDiscoveryLoadingState } from '@/utils/discoveryPresentation'; -import { getNodeDisplayName } from '@/utils/nodes'; -import { getSimpleStatusIndicator } from '@/utils/status'; -import { - resolveInfrastructureDetailsDrawerDiscoveryHostname, - resolveInfrastructureDetailsDrawerMetadataId, - type InfrastructureDetailsDrawerProps, -} from './infrastructureDetailsDrawerModel'; -import { useInfrastructureDetailsDrawerState } from './useInfrastructureDetailsDrawerState'; - -export const InfrastructureDetailsDrawer: Component = (props) => { - const drawer = useInfrastructureDetailsDrawerState(); - const metadataId = () => resolveInfrastructureDetailsDrawerMetadataId(props.node, props.agent); - const discoveryHostname = () => - resolveInfrastructureDetailsDrawerDiscoveryHostname(props.node, props.agent); - const displayName = createMemo(() => getNodeDisplayName(props.node)); - const headerIndicator = createMemo(() => getSimpleStatusIndicator(props.node.status)); - - return ( -
-
- -

- {displayName()} -

-
- {/* Tabs */} -
- - -
- - {/* Overview Tab */} -
-
- - - - - -
-
- props.onCustomUrlChange?.(metadataId(), url)} - /> -
-
- - {/* Discovery Tab */} -
- -
- {getDiscoveryLoadingState().text} -
- } - > - -
-
-
- ); -}; diff --git a/frontend-modern/src/components/shared/ScrollToTopButton.test.tsx b/frontend-modern/src/components/shared/ScrollToTopButton.test.tsx deleted file mode 100644 index f08adf96ed..0000000000 --- a/frontend-modern/src/components/shared/ScrollToTopButton.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ScrollToTopButton } from './ScrollToTopButton'; -import scrollToTopButtonSource from './ScrollToTopButton.tsx?raw'; -import scrollToTopButtonModelSource from './scrollToTopButtonModel.ts?raw'; -import scrollToTopButtonStateSource from './useScrollToTopButtonState.ts?raw'; - -describe('ScrollToTopButton', () => { - afterEach(() => { - cleanup(); - }); - - it('keeps scroll-to-top button on shell, runtime, and model owners', () => { - expect(scrollToTopButtonSource).toContain('useScrollToTopButtonState'); - expect(scrollToTopButtonSource).toContain('SCROLL_TO_TOP_BUTTON_ARIA_LABEL'); - expect(scrollToTopButtonSource).toContain('getScrollToTopButtonClass'); - expect(scrollToTopButtonSource).not.toContain('createSignal'); - expect(scrollToTopButtonSource).not.toContain('onMount'); - expect(scrollToTopButtonSource).not.toContain('scrollHeight'); - expect(scrollToTopButtonSource).not.toContain('SCROLL_THRESHOLD'); - - expect(scrollToTopButtonStateSource).toContain('export function useScrollToTopButtonState'); - expect(scrollToTopButtonStateSource).toContain('createSignal'); - expect(scrollToTopButtonStateSource).toContain('onMount'); - expect(scrollToTopButtonStateSource).toContain('addEventListener'); - expect(scrollToTopButtonStateSource).toContain('scrollTo({ top: 0, behavior: \'smooth\' })'); - expect(scrollToTopButtonStateSource).toContain('findNearestScrollableAncestor'); - - expect(scrollToTopButtonModelSource).toContain('SCROLL_TO_TOP_BUTTON_THRESHOLD'); - expect(scrollToTopButtonModelSource).toContain('SCROLL_TO_TOP_BUTTON_ARIA_LABEL'); - expect(scrollToTopButtonModelSource).toContain('findNearestScrollableAncestor'); - expect(scrollToTopButtonModelSource).toContain('isScrollToTopButtonVisible'); - expect(scrollToTopButtonModelSource).toContain('getScrollToTopButtonClass'); - }); - - it('shows after scrolling past the threshold and scrolls the container to top', async () => { - const scroller = document.createElement('div'); - scroller.style.overflowY = 'auto'; - Object.defineProperty(scroller, 'scrollHeight', { value: 1000, configurable: true }); - Object.defineProperty(scroller, 'clientHeight', { value: 200, configurable: true }); - Object.defineProperty(scroller, 'scrollTop', { value: 0, writable: true, configurable: true }); - scroller.scrollTo = vi.fn(); - document.body.appendChild(scroller); - - render(() => , { container: scroller }); - - const button = screen.getByRole('button', { name: 'Scroll to top' }); - expect(button.className).toContain('pointer-events-none'); - - scroller.scrollTop = 500; - fireEvent.scroll(scroller); - expect(button.className).toContain('opacity-100'); - - fireEvent.click(button); - expect(scroller.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); - }); -}); diff --git a/frontend-modern/src/components/shared/ScrollToTopButton.tsx b/frontend-modern/src/components/shared/ScrollToTopButton.tsx deleted file mode 100644 index f0721f9367..0000000000 --- a/frontend-modern/src/components/shared/ScrollToTopButton.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import ArrowUpIcon from 'lucide-solid/icons/arrow-up'; -import { - SCROLL_TO_TOP_BUTTON_ARIA_LABEL, - getScrollToTopButtonClass, -} from '@/components/shared/scrollToTopButtonModel'; -import { useScrollToTopButtonState } from '@/components/shared/useScrollToTopButtonState'; - -export function ScrollToTopButton() { - const state = useScrollToTopButtonState(); - - return ( - <> -