diff --git a/frontend-modern/src/components/Infrastructure/AgentDeployModal.tsx b/frontend-modern/src/components/Infrastructure/AgentDeployModal.tsx deleted file mode 100644 index fe7c13e4c7..0000000000 --- a/frontend-modern/src/components/Infrastructure/AgentDeployModal.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import { Component, Show, Switch, Match, createMemo } from 'solid-js'; -import { Dialog } from '@/components/shared/Dialog'; -import { StepIndicator } from '@/components/SetupWizard/StepIndicator'; -import { useDeployWizard, type WizardStep } from '@/hooks/useDeployWizard'; -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'; -import XIcon from 'lucide-solid/icons/x'; - -interface AgentDeployModalProps { - isOpen: boolean; - clusterId: string; - clusterName: string; - onClose: () => void; -} - -const STEP_LABELS = ['Select Hosts', 'Preflight', 'Confirm', 'Deploy', 'Results']; - -const stepToIndex: Record = { - candidates: 0, - preflight: 1, - confirm: 2, - deploying: 3, - results: 4, -}; - -export const AgentDeployModal: Component = (props) => { - const wizard = useDeployWizard({ - clusterId: props.clusterId, - clusterName: props.clusterName, - }); - - const currentStepIndex = createMemo(() => stepToIndex[wizard.step()]); - - const canRunPreflight = createMemo( - () => - wizard.selectedNodeIds().size > 0 && - wizard.selectedSourceAgent() !== '' && - !wizard.startingPreflight(), - ); - - const handleClose = () => { - if (wizard.isOperationActive()) { - // Don't block close — just warn (closing won't cancel). - // The deployment continues in the background. - } - props.onClose(); - }; - - return ( - - {/* Header */} -
-

Deploy Agents — {props.clusterName}

- -
- - {/* Step indicator */} - - - {/* Body */} -
- - - - - - - - - - - - - - - - - -
- - {/* Footer */} -
-
- - - - - - - - - Preflight in progress... - - - - Closing won't cancel the deployment. - - - -
- -
- - - - - - - - - - - - 0}> - - - - - -
-
-
- ); -}; diff --git a/frontend-modern/src/components/Infrastructure/__tests__/AgentDeployModal.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/AgentDeployModal.test.tsx deleted file mode 100644 index c7d47f84e8..0000000000 --- a/frontend-modern/src/components/Infrastructure/__tests__/AgentDeployModal.test.tsx +++ /dev/null @@ -1,682 +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'; - -/* ── hoisted mocks ────────────────────────────────────────────── */ - -// Build a controllable mock wizard factory. -// Each test can override individual fields before render. -const { useDeployWizardMock } = vi.hoisted(() => ({ - useDeployWizardMock: vi.fn(), -})); - -vi.mock('@/hooks/useDeployWizard', () => ({ - useDeployWizard: useDeployWizardMock, -})); - -// Track ConfirmStep props to verify parent wiring. -const { confirmStepPropsSpy } = vi.hoisted(() => ({ - confirmStepPropsSpy: vi.fn(), -})); - -// Stub child step components — we don't test their internals here. -vi.mock('../deploy/CandidatesStep', () => ({ - CandidatesStep: () =>
CandidatesStep
, -})); -vi.mock('../deploy/PreflightStep', () => ({ - PreflightStep: () =>
PreflightStep
, -})); -vi.mock('../deploy/ConfirmStep', () => ({ - ConfirmStep: (props: Record) => { - confirmStepPropsSpy(props); - return
ConfirmStep
; - }, -})); -vi.mock('../deploy/DeployingStep', () => ({ - DeployingStep: () =>
DeployingStep
, -})); -vi.mock('../deploy/ResultsStep', () => ({ - ResultsStep: () =>
ResultsStep
, -})); - -// Stub StepIndicator to expose the currentStep prop for assertion. -vi.mock('@/components/SetupWizard/StepIndicator', () => ({ - StepIndicator: (props: { steps: string[]; currentStep: number }) => ( -
- {props.steps.join(' | ')} -
- ), -})); - -/* ── component import (after mocks) ──────────────────────────── */ -import { AgentDeployModal } from '../AgentDeployModal'; - -/* ── helpers ──────────────────────────────────────────────────── */ - -type WizardStep = 'candidates' | 'preflight' | 'confirm' | 'deploying' | 'results'; - -/** Creates a full mock wizard with sensible defaults. */ -function createMockWizard(overrides: Partial> = {}) { - const [step, setStep] = createSignal((overrides.step as WizardStep) ?? 'candidates'); - const [selectedNodeIds] = createSignal>( - (overrides.selectedNodeIds as Set) ?? new Set(), - ); - const [selectedSourceAgent] = createSignal( - (overrides.selectedSourceAgent as string) ?? '', - ); - const [startingPreflight] = createSignal( - (overrides.startingPreflight as boolean) ?? false, - ); - const [startingDeploy] = createSignal((overrides.startingDeploy as boolean) ?? false); - const [canceling] = createSignal((overrides.canceling as boolean) ?? false); - const [retrying] = createSignal((overrides.retrying as boolean) ?? false); - - const [confirmSelectedNodeIds] = createSignal>( - (overrides.confirmSelectedNodeIds as Set) ?? new Set(), - ); - - const readyNodesData = (overrides.readyNodes as unknown[]) ?? []; - const readyNodes: Accessor = () => readyNodesData; - - const retryableTargetsData = (overrides.retryableTargets as unknown[]) ?? []; - const retryableTargets: Accessor = () => retryableTargetsData; - - const isOperationActive: Accessor = - (overrides.isOperationActive as Accessor) ?? - (() => step() === 'preflight' || step() === 'deploying'); - - return { - step, - setStep, - selectedNodeIds, - selectedSourceAgent, - confirmSelectedNodeIds, - startingPreflight, - startingDeploy, - canceling, - retrying, - readyNodes, - retryableTargets, - isOperationActive, - startPreflight: overrides.startPreflight ?? vi.fn(), - startDeploy: overrides.startDeploy ?? vi.fn(), - cancelDeploy: overrides.cancelDeploy ?? vi.fn(), - retryFailed: overrides.retryFailed ?? vi.fn(), - }; -} - -const defaultProps = () => ({ - isOpen: true, - clusterId: 'cluster-1', - clusterName: 'My Cluster', - onClose: vi.fn(), -}); - -/* ── lifecycle ────────────────────────────────────────────────── */ -beforeEach(() => { - vi.clearAllMocks(); - // Default: return a basic wizard in 'candidates' step. - useDeployWizardMock.mockReturnValue(createMockWizard()); -}); - -afterEach(() => { - cleanup(); - document.body.style.overflow = ''; -}); - -/* ================================================================ - Tests - ================================================================ */ - -describe('AgentDeployModal', () => { - /* ── Rendering ─────────────────────────────────────────────── */ - - describe('rendering', () => { - it('renders the dialog with cluster name in the title', () => { - render(() => ); - - expect(screen.getByText('Deploy Agents — My Cluster')).toBeInTheDocument(); - }); - - it('sets aria-label on the dialog with cluster name', () => { - render(() => ); - - const dialog = screen.getByRole('dialog'); - expect(dialog).toHaveAttribute('aria-label', 'Deploy Agents — My Cluster'); - }); - - it('renders the StepIndicator with step labels', () => { - render(() => ); - - const indicator = screen.getByTestId('step-indicator'); - expect(indicator).toHaveTextContent('Select Hosts | Preflight | Confirm | Deploy | Results'); - }); - - it('has a close X button with aria-label', () => { - render(() => ); - - expect(screen.getByLabelText('Close')).toBeInTheDocument(); - }); - - it('does not render when isOpen is false', () => { - const props = defaultProps(); - props.isOpen = false; - render(() => ); - - expect(screen.queryByText('Deploy Agents — My Cluster')).not.toBeInTheDocument(); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(document.querySelector('[data-dialog-backdrop]')).toBeNull(); - }); - }); - - /* ── Step routing ──────────────────────────────────────────── */ - - describe('step routing', () => { - const allStepTestIds = [ - 'candidates-step', - 'preflight-step', - 'confirm-step', - 'deploying-step', - 'results-step', - ]; - - function expectOnlyStep(activeTestId: string) { - expect(screen.getByTestId(activeTestId)).toBeInTheDocument(); - for (const id of allStepTestIds) { - if (id !== activeTestId) { - expect(screen.queryByTestId(id)).not.toBeInTheDocument(); - } - } - } - - it('shows only CandidatesStep when step is candidates', () => { - useDeployWizardMock.mockReturnValue(createMockWizard({ step: 'candidates' })); - render(() => ); - - expectOnlyStep('candidates-step'); - }); - - it('shows only PreflightStep when step is preflight', () => { - useDeployWizardMock.mockReturnValue(createMockWizard({ step: 'preflight' })); - render(() => ); - - expectOnlyStep('preflight-step'); - }); - - it('shows only ConfirmStep when step is confirm', () => { - useDeployWizardMock.mockReturnValue(createMockWizard({ step: 'confirm' })); - render(() => ); - - expectOnlyStep('confirm-step'); - }); - - it('shows only DeployingStep when step is deploying', () => { - useDeployWizardMock.mockReturnValue(createMockWizard({ step: 'deploying' })); - render(() => ); - - expectOnlyStep('deploying-step'); - }); - - it('shows only ResultsStep when step is results', () => { - useDeployWizardMock.mockReturnValue(createMockWizard({ step: 'results' })); - render(() => ); - - expectOnlyStep('results-step'); - }); - }); - - /* ── Step indicator index ──────────────────────────────────── */ - - describe('step indicator index', () => { - const cases: [WizardStep, number][] = [ - ['candidates', 0], - ['preflight', 1], - ['confirm', 2], - ['deploying', 3], - ['results', 4], - ]; - - it.each(cases)('maps wizard step "%s" to indicator index %d', (wizardStep, expectedIndex) => { - useDeployWizardMock.mockReturnValue(createMockWizard({ step: wizardStep })); - render(() => ); - - const indicator = screen.getByTestId('step-indicator'); - expect(indicator.dataset.currentStep).toBe(String(expectedIndex)); - }); - }); - - /* ── Candidates step footer ────────────────────────────────── */ - - describe('candidates step footer', () => { - it('shows Cancel and Run Preflight buttons', () => { - useDeployWizardMock.mockReturnValue(createMockWizard({ step: 'candidates' })); - render(() => ); - - expect(screen.getByText('Cancel')).toBeInTheDocument(); - expect(screen.getByText('Run Preflight')).toBeInTheDocument(); - }); - - it('Cancel calls onClose', () => { - useDeployWizardMock.mockReturnValue(createMockWizard({ step: 'candidates' })); - const props = defaultProps(); - render(() => ); - - fireEvent.click(screen.getByText('Cancel')); - expect(props.onClose).toHaveBeenCalledTimes(1); - }); - - it('disables Run Preflight when no nodes selected', () => { - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'candidates', - selectedNodeIds: new Set(), - selectedSourceAgent: 'agent-1', - }), - ); - render(() => ); - - expect(screen.getByText('Run Preflight')).toBeDisabled(); - }); - - it('disables Run Preflight when no source agent selected', () => { - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'candidates', - selectedNodeIds: new Set(['node-1']), - selectedSourceAgent: '', - }), - ); - render(() => ); - - expect(screen.getByText('Run Preflight')).toBeDisabled(); - }); - - it('enables Run Preflight when nodes and source agent are selected', () => { - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'candidates', - selectedNodeIds: new Set(['node-1']), - selectedSourceAgent: 'agent-1', - }), - ); - render(() => ); - - expect(screen.getByText('Run Preflight')).not.toBeDisabled(); - }); - - it('disables and shows Starting... when startingPreflight is true', () => { - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'candidates', - selectedNodeIds: new Set(['node-1']), - selectedSourceAgent: 'agent-1', - startingPreflight: true, - }), - ); - render(() => ); - - expect(screen.getByText('Starting...')).toBeInTheDocument(); - // The button containing "Starting..." should be disabled. - const btn = screen.getByText('Starting...').closest('button'); - expect(btn).toBeDisabled(); - }); - - it('calls startPreflight when Run Preflight is clicked', () => { - const startPreflightMock = vi.fn(); - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'candidates', - selectedNodeIds: new Set(['node-1']), - selectedSourceAgent: 'agent-1', - startPreflight: startPreflightMock, - }), - ); - render(() => ); - - fireEvent.click(screen.getByText('Run Preflight')); - expect(startPreflightMock).toHaveBeenCalledTimes(1); - }); - }); - - /* ── Preflight step footer ─────────────────────────────────── */ - - describe('preflight step footer', () => { - it('shows "Preflight in progress..." message', () => { - useDeployWizardMock.mockReturnValue(createMockWizard({ step: 'preflight' })); - render(() => ); - - expect(screen.getByText('Preflight in progress...')).toBeInTheDocument(); - }); - }); - - /* ── Confirm step footer ───────────────────────────────────── */ - - describe('confirm step footer', () => { - it('shows Back button that returns to candidates step', () => { - const wizard = createMockWizard({ step: 'confirm' }); - useDeployWizardMock.mockReturnValue(wizard); - render(() => ); - - expect(screen.getByText('Back')).toBeInTheDocument(); - fireEvent.click(screen.getByText('Back')); - expect(wizard.step()).toBe('candidates'); - }); - - it('shows Deploy button with confirmed node count', () => { - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'confirm', - confirmSelectedNodeIds: new Set(['n1', 'n2', 'n3']), - }), - ); - render(() => ); - - expect(screen.getByText('Deploy 3 Hosts')).toBeInTheDocument(); - }); - - it('uses singular "Node" when exactly 1 confirmed node', () => { - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'confirm', - confirmSelectedNodeIds: new Set(['n1']), - }), - ); - render(() => ); - - expect(screen.getByText('Deploy 1 Host')).toBeInTheDocument(); - }); - - it('disables Deploy button when no confirmed nodes', () => { - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'confirm', - confirmSelectedNodeIds: new Set(), - }), - ); - render(() => ); - - expect(screen.getByText('Deploy 0 Hosts')).toBeDisabled(); - }); - - it('disables and shows Starting... when startingDeploy is true', () => { - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'confirm', - confirmSelectedNodeIds: new Set(['n1']), - startingDeploy: true, - }), - ); - render(() => ); - - expect(screen.getByText('Starting...')).toBeInTheDocument(); - const btn = screen.getByText('Starting...').closest('button'); - expect(btn).toBeDisabled(); - }); - - it('calls startDeploy when Deploy button is clicked', () => { - const startDeployMock = vi.fn(); - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'confirm', - confirmSelectedNodeIds: new Set(['n1']), - startDeploy: startDeployMock, - }), - ); - render(() => ); - - fireEvent.click(screen.getByText('Deploy 1 Host')); - expect(startDeployMock).toHaveBeenCalledTimes(1); - }); - }); - - /* ── Deploying step footer ─────────────────────────────────── */ - - describe('deploying step footer', () => { - it('shows Cancel Deployment button', () => { - useDeployWizardMock.mockReturnValue(createMockWizard({ step: 'deploying' })); - render(() => ); - - expect(screen.getByText('Cancel Deployment')).toBeInTheDocument(); - }); - - it('calls cancelDeploy when Cancel Deployment is clicked', () => { - const cancelDeployMock = vi.fn(); - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'deploying', - cancelDeploy: cancelDeployMock, - }), - ); - render(() => ); - - fireEvent.click(screen.getByText('Cancel Deployment')); - expect(cancelDeployMock).toHaveBeenCalledTimes(1); - }); - - it('shows Canceling... when canceling is true', () => { - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'deploying', - canceling: true, - }), - ); - render(() => ); - - expect(screen.getByText('Canceling...')).toBeInTheDocument(); - const btn = screen.getByText('Canceling...').closest('button'); - expect(btn).toBeDisabled(); - }); - - it('shows warning about closing during active operation', () => { - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'deploying', - isOperationActive: () => true, - }), - ); - render(() => ); - - expect(screen.getByText("Closing won't cancel the deployment.")).toBeInTheDocument(); - }); - - it('hides close-warning when operation is not active', () => { - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'deploying', - isOperationActive: () => false, - }), - ); - render(() => ); - - expect(screen.queryByText("Closing won't cancel the deployment.")).not.toBeInTheDocument(); - }); - }); - - /* ── Results step footer ───────────────────────────────────── */ - - describe('results step footer', () => { - it('shows Close button', () => { - useDeployWizardMock.mockReturnValue(createMockWizard({ step: 'results' })); - render(() => ); - - expect(screen.getByText('Close')).toBeInTheDocument(); - }); - - it('Close button calls onClose', () => { - useDeployWizardMock.mockReturnValue(createMockWizard({ step: 'results' })); - const props = defaultProps(); - render(() => ); - - fireEvent.click(screen.getByText('Close')); - expect(props.onClose).toHaveBeenCalledTimes(1); - }); - - it('shows Retry button when retryable targets exist', () => { - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'results', - retryableTargets: [{ id: 't1' }, { id: 't2' }], - }), - ); - render(() => ); - - expect(screen.getByText('Retry 2 Failed')).toBeInTheDocument(); - }); - - it('hides Retry button when no retryable targets', () => { - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'results', - retryableTargets: [], - }), - ); - render(() => ); - - expect(screen.queryByText(/Retry/)).not.toBeInTheDocument(); - }); - - it('calls retryFailed when Retry button is clicked', () => { - const retryFailedMock = vi.fn(); - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'results', - retryableTargets: [{ id: 't1' }], - retryFailed: retryFailedMock, - }), - ); - render(() => ); - - fireEvent.click(screen.getByText('Retry 1 Failed')); - expect(retryFailedMock).toHaveBeenCalledTimes(1); - }); - - it('disables and shows Retrying... when retrying is true', () => { - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'results', - retryableTargets: [{ id: 't1' }], - retrying: true, - }), - ); - render(() => ); - - expect(screen.getByText('Retrying...')).toBeInTheDocument(); - const btn = screen.getByText('Retrying...').closest('button'); - expect(btn).toBeDisabled(); - }); - }); - - /* ── Close behavior ────────────────────────────────────────── */ - - describe('close behavior', () => { - it('X button calls onClose', () => { - const props = defaultProps(); - render(() => ); - - fireEvent.click(screen.getByLabelText('Close')); - expect(props.onClose).toHaveBeenCalledTimes(1); - }); - - it('X button still calls onClose when operation is active', () => { - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'deploying', - isOperationActive: () => true, - }), - ); - const props = defaultProps(); - render(() => ); - - fireEvent.click(screen.getByLabelText('Close')); - expect(props.onClose).toHaveBeenCalledTimes(1); - }); - - it('disables backdrop close when operation is active', () => { - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'deploying', - isOperationActive: () => true, - }), - ); - const props = defaultProps(); - render(() => ); - - // Click on backdrop — the Dialog component receives closeOnBackdrop=false. - // We verify this by clicking the backdrop and seeing that onClose is NOT called. - const backdrop = document.querySelector('[data-dialog-backdrop]'); - expect(backdrop).not.toBeNull(); - fireEvent.click(backdrop!); - expect(props.onClose).not.toHaveBeenCalled(); - }); - - it('allows backdrop close when operation is not active', () => { - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'candidates', - isOperationActive: () => false, - }), - ); - const props = defaultProps(); - render(() => ); - - const backdrop = document.querySelector('[data-dialog-backdrop]'); - expect(backdrop).not.toBeNull(); - fireEvent.click(backdrop!); - expect(props.onClose).toHaveBeenCalledTimes(1); - }); - }); - - /* ── Wizard initialization ─────────────────────────────────── */ - - describe('wizard initialization', () => { - it('passes clusterId and clusterName to useDeployWizard', () => { - render(() => ); - - expect(useDeployWizardMock).toHaveBeenCalledWith({ - clusterId: 'cluster-1', - clusterName: 'My Cluster', - }); - }); - }); - - /* ── Keyboard close (Escape) ───────────────────────────────── */ - - describe('keyboard close', () => { - it('calls onClose when Escape key is pressed', () => { - const props = defaultProps(); - render(() => ); - - fireEvent.keyDown(document, { key: 'Escape' }); - expect(props.onClose).toHaveBeenCalledTimes(1); - }); - - it('calls onClose on Escape even when operation is active', () => { - useDeployWizardMock.mockReturnValue( - createMockWizard({ - step: 'deploying', - isOperationActive: () => true, - }), - ); - const props = defaultProps(); - render(() => ); - - fireEvent.keyDown(document, { key: 'Escape' }); - expect(props.onClose).toHaveBeenCalledTimes(1); - }); - }); - - /* ── ConfirmStep prop wiring ───────────────────────────────── */ - - describe('ConfirmStep prop wiring', () => { - it('passes wizard to ConfirmStep', () => { - const wizard = createMockWizard({ step: 'confirm' }); - useDeployWizardMock.mockReturnValue(wizard); - render(() => ); - - expect(confirmStepPropsSpy).toHaveBeenCalled(); - const receivedProps = confirmStepPropsSpy.mock.calls[0][0]; - expect(receivedProps.wizard).toBe(wizard); - }); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/AgentDeployWizardE2E.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/AgentDeployWizardE2E.test.tsx deleted file mode 100644 index 7950b80fbc..0000000000 --- a/frontend-modern/src/components/Infrastructure/__tests__/AgentDeployWizardE2E.test.tsx +++ /dev/null @@ -1,1027 +0,0 @@ -/** - * E2E integration test for the full Agent Deploy Wizard flow. - * - * Unlike the unit tests in AgentDeployModal.test.tsx and DeployStepComponents.test.tsx, - * this test renders real step components with the real useDeployWizard hook, - * mocking only external dependencies (API, SSE, license store, clipboard, icons). - */ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, cleanup, waitFor } from '@solidjs/testing-library'; - -/* ── hoisted mocks (must be before imports) ──────────────────── */ - -const { agentDeployAPIMock, nodesAPIMock, getLimitMock, copyToClipboardMock, loggerMock } = - vi.hoisted(() => ({ - agentDeployAPIMock: { - getCandidates: vi.fn(), - createPreflight: vi.fn(), - getPreflight: vi.fn(), - createJob: vi.fn(), - getJob: vi.fn(), - cancelJob: vi.fn(), - retryJob: vi.fn(), - }, - nodesAPIMock: { - getAgentInstallCommand: vi.fn(), - }, - getLimitMock: vi.fn(), - copyToClipboardMock: vi.fn(), - loggerMock: { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }, - })); - -vi.mock('@/api/agentDeploy', () => ({ AgentDeployAPI: agentDeployAPIMock })); -vi.mock('@/api/nodes', () => ({ NodesAPI: nodesAPIMock })); -vi.mock('@/stores/license', () => ({ getRuntimeLimit: getLimitMock })); -vi.mock('@/utils/clipboard', () => ({ copyToClipboard: copyToClipboardMock })); -vi.mock('@/utils/logger', () => ({ logger: loggerMock })); - -// Stub icons — they don't render visually in jsdom -vi.mock('lucide-solid/icons/check', () => ({ default: () => })); -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/loader-2', () => ({ - default: () => , -})); -vi.mock('lucide-solid/icons/chevron-down', () => ({ - default: () => , -})); -vi.mock('lucide-solid/icons/chevron-right', () => ({ - default: () => , -})); -vi.mock('lucide-solid/icons/x', () => ({ default: () => })); -vi.mock('lucide-solid/icons/copy', () => ({ default: () => })); - -/* ── MockEventSource ──────────────────────────────────────────── */ - -class MockEventSource { - static instances: MockEventSource[] = []; - - readonly url: string; - onopen: ((event: Event) => void) | null = null; - onmessage: ((event: MessageEvent) => void) | null = null; - onerror: ((event: Event) => void) | null = null; - readyState = 0; - withCredentials = false; - closed = false; - - constructor(url: string) { - this.url = url; - MockEventSource.instances.push(this); - } - - close() { - this.closed = true; - this.readyState = 2; - } - - emitOpen() { - this.readyState = 1; - this.onopen?.(new Event('open')); - } - - emitMessage(payload: unknown) { - const evt = { data: JSON.stringify(payload), lastEventId: '' } as MessageEvent; - this.onmessage?.(evt); - } - - emitError() { - this.onerror?.(new Event('error')); - } -} - -/* ── Component import (after mocks) ───────────────────────────── */ - -import { AgentDeployModal } from '../AgentDeployModal'; -import type { DeployTarget, DeployJob } from '@/types/agentDeploy'; - -/* ── Test data factories ──────────────────────────────────────── */ - -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, - }; -} - -function makeJob(overrides: Partial = {}): DeployJob { - return { - id: 'j1', - clusterId: 'cluster-1', - clusterName: 'My Cluster', - sourceAgentId: 'agent-1', - status: 'running', - targets: [], - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -const CANDIDATES_RESPONSE = { - clusterId: 'cluster-1', - clusterName: 'My Cluster', - sourceAgents: [{ agentId: 'agent-1', nodeId: 'pve1', online: true }], - nodes: [ - { nodeId: 'n1', name: 'pve1', ip: '10.0.0.1', hasAgent: true, deployable: false }, - { nodeId: 'n2', name: 'pve2', ip: '10.0.0.2', hasAgent: false, deployable: true }, - { nodeId: 'n3', name: 'pve3', ip: '10.0.0.3', hasAgent: false, deployable: true }, - ], -}; - -/* ── Lifecycle ─────────────────────────────────────────────────── */ - -const originalEventSource = globalThis.EventSource; - -beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - MockEventSource.instances = []; - (globalThis as unknown as Record).EventSource = - MockEventSource as unknown as typeof EventSource; - getLimitMock.mockReturnValue({ limit: 10 }); - copyToClipboardMock.mockResolvedValue(true); -}); - -afterEach(() => { - cleanup(); - vi.useRealTimers(); - globalThis.EventSource = originalEventSource; - document.body.style.overflow = ''; -}); - -/* ── Helpers ───────────────────────────────────────────────────── */ - -function renderWizard(onClose = vi.fn()) { - return render(() => ( - - )); -} - -/** Wait for candidates to load (API resolves, loading spinner disappears). */ -async function waitForCandidates() { - await vi.advanceTimersByTimeAsync(0); - await waitFor(() => { - expect(screen.getByText('pve2')).toBeInTheDocument(); - }); -} - -/** Get the latest MockEventSource instance. */ -function latestES(): MockEventSource { - const instances = MockEventSource.instances; - return instances[instances.length - 1]; -} - -/* ================================================================ - Test Cases - ================================================================ */ - -describe('Agent Deploy Wizard E2E', () => { - /* ── Test 1: Happy path ──────────────────────────────────────── */ - - it('completes full happy path: candidates → preflight → confirm → deploy → results → close', async () => { - // Setup: getCandidates returns 2 deployable + 1 with agent - agentDeployAPIMock.getCandidates.mockResolvedValue(CANDIDATES_RESPONSE); - - // Setup: createPreflight - agentDeployAPIMock.createPreflight.mockResolvedValue({ - preflightId: 'pf1', - status: 'running', - eventsUrl: '/api/agent-deploy/preflights/pf1/events', - }); - - // Setup: getPreflight (initial fetch returns pending targets) - const preflightTargets = [ - makeTarget({ - id: 'pt1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'pending', - }), - makeTarget({ - id: 'pt2', - nodeId: 'n3', - nodeName: 'pve3', - nodeIP: '10.0.0.3', - status: 'pending', - }), - ]; - agentDeployAPIMock.getPreflight.mockResolvedValue( - makeJob({ id: 'pf1', targets: preflightTargets }), - ); - - const onClose = vi.fn(); - renderWizard(onClose); - - // Step 1: Candidates step loads - await waitForCandidates(); - - // pve1 already has agent, pve2+pve3 should be selectable - expect(screen.getByText('Already monitored')).toBeInTheDocument(); - expect(screen.getByText('2 of 2 nodes selected')).toBeInTheDocument(); - - // Click "Run Preflight" - const preflightBtn = screen.getByText('Run Preflight'); - expect(preflightBtn).not.toBeDisabled(); - await fireEvent.click(preflightBtn); - await vi.advanceTimersByTimeAsync(0); - - // Step 2: Preflight step — SSE events arrive - const preflightES = latestES(); - preflightES.emitOpen(); - - // Emit preflight results for both nodes - preflightES.emitMessage({ - id: 'e1', - jobId: 'pf1', - targetId: 'pt1', - type: 'preflight_result', - message: 'ready', - data: JSON.stringify({ ssh_reachable: true, pulse_reachable: true, arch: 'amd64' }), - createdAt: '2026-01-01T00:00:00Z', - }); - preflightES.emitMessage({ - id: 'e2', - jobId: 'pf1', - targetId: 'pt2', - type: 'preflight_result', - message: 'ready', - data: JSON.stringify({ ssh_reachable: true, pulse_reachable: true, arch: 'arm64' }), - createdAt: '2026-01-01T00:00:00Z', - }); - - // Emit job_complete - agentDeployAPIMock.getPreflight.mockResolvedValue( - makeJob({ - id: 'pf1', - status: 'succeeded', - targets: [ - makeTarget({ - id: 'pt1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'ready', - arch: 'amd64', - }), - makeTarget({ - id: 'pt2', - nodeId: 'n3', - nodeName: 'pve3', - nodeIP: '10.0.0.3', - status: 'ready', - arch: 'arm64', - }), - ], - }), - ); - - preflightES.emitMessage({ type: 'job_complete', status: 'succeeded' }); - await vi.advanceTimersByTimeAsync(0); - - // Step 3: Confirm step — both nodes ready - await waitFor(() => { - expect(screen.getByText('Ready to deploy (2)')).toBeInTheDocument(); - }); - - // Setup: createJob - agentDeployAPIMock.createJob.mockResolvedValue({ - jobId: 'j1', - acceptedTargets: ['n2', 'n3'], - skippedTargets: [], - eventsUrl: '/api/agent-deploy/jobs/j1/events', - }); - - const deployTargets = [ - makeTarget({ - id: 'dt1', - jobId: 'j1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'pending', - }), - makeTarget({ - id: 'dt2', - jobId: 'j1', - nodeId: 'n3', - nodeName: 'pve3', - nodeIP: '10.0.0.3', - status: 'pending', - }), - ]; - agentDeployAPIMock.getJob.mockResolvedValue(makeJob({ id: 'j1', targets: deployTargets })); - - // Click "Deploy 2 Hosts" - const deployBtn = screen.getByText('Deploy 2 Hosts'); - await fireEvent.click(deployBtn); - await vi.advanceTimersByTimeAsync(0); - - // Step 4: Deploying step — SSE events arrive - const deployES = latestES(); - deployES.emitOpen(); - - // Emit job_complete with all succeeded - agentDeployAPIMock.getJob.mockResolvedValue( - makeJob({ - id: 'j1', - status: 'succeeded', - targets: [ - makeTarget({ - id: 'dt1', - jobId: 'j1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'succeeded', - }), - makeTarget({ - id: 'dt2', - jobId: 'j1', - nodeId: 'n3', - nodeName: 'pve3', - nodeIP: '10.0.0.3', - status: 'succeeded', - }), - ], - }), - ); - - deployES.emitMessage({ type: 'job_complete', status: 'succeeded' }); - await vi.advanceTimersByTimeAsync(0); - - // Step 5: Results step — both deployed - await waitFor(() => { - expect(screen.getByText('Deployed (2)')).toBeInTheDocument(); - }); - - // Close the wizard - await fireEvent.click(screen.getByText('Close')); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - /* ── Test 2: Partial failure + retry ─────────────────────────── */ - - it('handles partial failure and retry flow', async () => { - agentDeployAPIMock.getCandidates.mockResolvedValue(CANDIDATES_RESPONSE); - - // Preflight setup — same as happy path - agentDeployAPIMock.createPreflight.mockResolvedValue({ - preflightId: 'pf1', - status: 'running', - eventsUrl: '/api/agent-deploy/preflights/pf1/events', - }); - agentDeployAPIMock.getPreflight.mockResolvedValue( - makeJob({ - id: 'pf1', - targets: [ - makeTarget({ - id: 'pt1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'pending', - }), - makeTarget({ - id: 'pt2', - nodeId: 'n3', - nodeName: 'pve3', - nodeIP: '10.0.0.3', - status: 'pending', - }), - ], - }), - ); - - renderWizard(); - await waitForCandidates(); - - // Run preflight - await fireEvent.click(screen.getByText('Run Preflight')); - await vi.advanceTimersByTimeAsync(0); - - const pfES = latestES(); - pfES.emitOpen(); - - // Both pass preflight - agentDeployAPIMock.getPreflight.mockResolvedValue( - makeJob({ - id: 'pf1', - status: 'succeeded', - targets: [ - makeTarget({ - id: 'pt1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'ready', - }), - makeTarget({ - id: 'pt2', - nodeId: 'n3', - nodeName: 'pve3', - nodeIP: '10.0.0.3', - status: 'ready', - }), - ], - }), - ); - pfES.emitMessage({ type: 'job_complete', status: 'succeeded' }); - await vi.advanceTimersByTimeAsync(0); - - await waitFor(() => { - expect(screen.getByText('Ready to deploy (2)')).toBeInTheDocument(); - }); - - // Deploy — one succeeds, one fails - agentDeployAPIMock.createJob.mockResolvedValue({ - jobId: 'j1', - acceptedTargets: ['n2', 'n3'], - skippedTargets: [], - eventsUrl: '/api/agent-deploy/jobs/j1/events', - }); - agentDeployAPIMock.getJob.mockResolvedValue( - makeJob({ - id: 'j1', - targets: [ - makeTarget({ - id: 'dt1', - jobId: 'j1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'pending', - }), - makeTarget({ - id: 'dt2', - jobId: 'j1', - nodeId: 'n3', - nodeName: 'pve3', - nodeIP: '10.0.0.3', - status: 'pending', - }), - ], - }), - ); - - await fireEvent.click(screen.getByText('Deploy 2 Hosts')); - await vi.advanceTimersByTimeAsync(0); - - const deployES = latestES(); - deployES.emitOpen(); - - // Job completes with partial success - agentDeployAPIMock.getJob.mockResolvedValue( - makeJob({ - id: 'j1', - status: 'partial_success', - targets: [ - makeTarget({ - id: 'dt1', - jobId: 'j1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'succeeded', - }), - makeTarget({ - id: 'dt2', - jobId: 'j1', - nodeId: 'n3', - nodeName: 'pve3', - nodeIP: '10.0.0.3', - status: 'failed_retryable', - errorMessage: 'SSH connection refused', - }), - ], - }), - ); - - deployES.emitMessage({ type: 'job_complete', status: 'partial_success' }); - await vi.advanceTimersByTimeAsync(0); - - // Results step — 1 succeeded, 1 failed - await waitFor(() => { - expect(screen.getByText('Deployed (1)')).toBeInTheDocument(); - expect(screen.getByText('Failed (1)')).toBeInTheDocument(); - }); - - // Retry button should be visible - expect(screen.getByText('Retry 1 Failed')).toBeInTheDocument(); - - // Setup retry - agentDeployAPIMock.retryJob.mockResolvedValue({ - jobId: 'j1', - retryTargets: 1, - status: 'running', - eventsUrl: '/api/agent-deploy/jobs/j1/events', - }); - agentDeployAPIMock.getJob.mockResolvedValue( - makeJob({ - id: 'j1', - targets: [ - makeTarget({ - id: 'dt1', - jobId: 'j1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'succeeded', - }), - makeTarget({ - id: 'dt2', - jobId: 'j1', - nodeId: 'n3', - nodeName: 'pve3', - nodeIP: '10.0.0.3', - status: 'pending', - }), - ], - }), - ); - - // Click retry - await fireEvent.click(screen.getByText('Retry 1 Failed')); - await vi.advanceTimersByTimeAsync(0); - - // Should be back on deploying step - const retryES = latestES(); - retryES.emitOpen(); - - // Retry succeeds - agentDeployAPIMock.getJob.mockResolvedValue( - makeJob({ - id: 'j1', - status: 'succeeded', - targets: [ - makeTarget({ - id: 'dt1', - jobId: 'j1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'succeeded', - }), - makeTarget({ - id: 'dt2', - jobId: 'j1', - nodeId: 'n3', - nodeName: 'pve3', - nodeIP: '10.0.0.3', - status: 'succeeded', - }), - ], - }), - ); - - retryES.emitMessage({ type: 'job_complete', status: 'succeeded' }); - await vi.advanceTimersByTimeAsync(0); - - await waitFor(() => { - expect(screen.getByText('Deployed (2)')).toBeInTheDocument(); - }); - expect(screen.queryByText(/Failed/)).not.toBeInTheDocument(); - }); - - /* ── Test 3: Cancel during deploy ────────────────────────────── */ - - it('handles cancel during deploy', async () => { - agentDeployAPIMock.getCandidates.mockResolvedValue(CANDIDATES_RESPONSE); - - agentDeployAPIMock.createPreflight.mockResolvedValue({ - preflightId: 'pf1', - status: 'running', - eventsUrl: '/api/agent-deploy/preflights/pf1/events', - }); - agentDeployAPIMock.getPreflight.mockResolvedValue( - makeJob({ - id: 'pf1', - targets: [ - makeTarget({ - id: 'pt1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'pending', - }), - ], - }), - ); - - renderWizard(); - await waitForCandidates(); - - // Deselect pve3, keep only pve2 - const pve3Row = screen.getByText('pve3').closest('tr')!; - await fireEvent.click(pve3Row); - await vi.advanceTimersByTimeAsync(0); - - // Run preflight with only pve2 - await fireEvent.click(screen.getByText('Run Preflight')); - await vi.advanceTimersByTimeAsync(0); - - const pfES = latestES(); - pfES.emitOpen(); - - agentDeployAPIMock.getPreflight.mockResolvedValue( - makeJob({ - id: 'pf1', - status: 'succeeded', - targets: [ - makeTarget({ - id: 'pt1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'ready', - }), - ], - }), - ); - pfES.emitMessage({ type: 'job_complete', status: 'succeeded' }); - await vi.advanceTimersByTimeAsync(0); - - await waitFor(() => { - expect(screen.getByText('Ready to deploy (1)')).toBeInTheDocument(); - }); - - // Start deploy - agentDeployAPIMock.createJob.mockResolvedValue({ - jobId: 'j1', - acceptedTargets: ['n2'], - skippedTargets: [], - eventsUrl: '/api/agent-deploy/jobs/j1/events', - }); - agentDeployAPIMock.getJob.mockResolvedValue( - makeJob({ - id: 'j1', - targets: [ - makeTarget({ - id: 'dt1', - jobId: 'j1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'installing', - }), - ], - }), - ); - - await fireEvent.click(screen.getByText('Deploy 1 Host')); - await vi.advanceTimersByTimeAsync(0); - - const deployES = latestES(); - deployES.emitOpen(); - - // Cancel the deployment - agentDeployAPIMock.cancelJob.mockResolvedValue(undefined); - await fireEvent.click(screen.getByText('Cancel Deployment')); - await vi.advanceTimersByTimeAsync(0); - - expect(agentDeployAPIMock.cancelJob).toHaveBeenCalledWith('j1'); - - // Backend sends job_complete with canceled status - agentDeployAPIMock.getJob.mockResolvedValue( - makeJob({ - id: 'j1', - status: 'canceled', - targets: [ - makeTarget({ - id: 'dt1', - jobId: 'j1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'canceled', - }), - ], - }), - ); - - deployES.emitMessage({ type: 'job_complete', status: 'canceled' }); - await vi.advanceTimersByTimeAsync(0); - - // Results step — should show canceled - await waitFor(() => { - expect(screen.getByText('Canceled (1)')).toBeInTheDocument(); - }); - }); - - /* ── Test 4: Manual install on failure ───────────────────────── */ - - it('shows manual install with real API command and copy button', async () => { - agentDeployAPIMock.getCandidates.mockResolvedValue({ - ...CANDIDATES_RESPONSE, - nodes: [ - { nodeId: 'n1', name: 'pve1', ip: '10.0.0.1', hasAgent: true, deployable: false }, - { nodeId: 'n2', name: 'pve2', ip: '10.0.0.2', hasAgent: false, deployable: true }, - ], - }); - - agentDeployAPIMock.createPreflight.mockResolvedValue({ - preflightId: 'pf1', - status: 'running', - eventsUrl: '/api/agent-deploy/preflights/pf1/events', - }); - agentDeployAPIMock.getPreflight.mockResolvedValue( - makeJob({ - id: 'pf1', - targets: [ - makeTarget({ - id: 'pt1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'pending', - }), - ], - }), - ); - - renderWizard(); - await waitForCandidates(); - - // Run preflight - await fireEvent.click(screen.getByText('Run Preflight')); - await vi.advanceTimersByTimeAsync(0); - - const pfES = latestES(); - pfES.emitOpen(); - - agentDeployAPIMock.getPreflight.mockResolvedValue( - makeJob({ - id: 'pf1', - status: 'succeeded', - targets: [ - makeTarget({ - id: 'pt1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'ready', - }), - ], - }), - ); - pfES.emitMessage({ type: 'job_complete', status: 'succeeded' }); - await vi.advanceTimersByTimeAsync(0); - - await waitFor(() => { - expect(screen.getByText('Ready to deploy (1)')).toBeInTheDocument(); - }); - - // Deploy — fails - agentDeployAPIMock.createJob.mockResolvedValue({ - jobId: 'j1', - acceptedTargets: ['n2'], - skippedTargets: [], - eventsUrl: '/api/agent-deploy/jobs/j1/events', - }); - agentDeployAPIMock.getJob.mockResolvedValue( - makeJob({ - id: 'j1', - targets: [ - makeTarget({ - id: 'dt1', - jobId: 'j1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'pending', - }), - ], - }), - ); - - await fireEvent.click(screen.getByText('Deploy 1 Host')); - await vi.advanceTimersByTimeAsync(0); - - const deployES = latestES(); - deployES.emitOpen(); - - agentDeployAPIMock.getJob.mockResolvedValue( - makeJob({ - id: 'j1', - status: 'failed', - targets: [ - makeTarget({ - id: 'dt1', - jobId: 'j1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'failed_retryable', - errorMessage: 'SSH connection refused', - }), - ], - }), - ); - - deployES.emitMessage({ type: 'job_complete', status: 'failed' }); - await vi.advanceTimersByTimeAsync(0); - - // Results — failed, manual install accordion visible - await waitFor(() => { - expect(screen.getByText('Failed (1)')).toBeInTheDocument(); - }); - - // Setup API for install command - nodesAPIMock.getAgentInstallCommand.mockResolvedValue({ - command: 'curl -fsSL http://10.0.0.100:7655/api/agent/install.sh | PULSE_TOKEN=abc123 bash', - }); - - // Open manual install accordion - const accordionBtn = screen.getByText('Manual install instructions'); - expect(accordionBtn).toHaveAttribute('aria-expanded', 'false'); - await fireEvent.click(accordionBtn); - expect(accordionBtn).toHaveAttribute('aria-expanded', 'true'); - - // Should call the API - await vi.advanceTimersByTimeAsync(0); - expect(nodesAPIMock.getAgentInstallCommand).toHaveBeenCalledWith({ - type: 'pve', - enableProxmox: true, - }); - - // Command should appear - await waitFor(() => { - expect( - screen.getByText( - 'curl -fsSL http://10.0.0.100:7655/api/agent/install.sh | PULSE_TOKEN=abc123 bash', - ), - ).toBeInTheDocument(); - }); - - // Copy button should be visible - const copyBtn = screen.getByLabelText('Copy to clipboard'); - await fireEvent.click(copyBtn); - expect(copyToClipboardMock).toHaveBeenCalledWith( - 'curl -fsSL http://10.0.0.100:7655/api/agent/install.sh | PULSE_TOKEN=abc123 bash', - ); - - // Should show "Copied" state - await vi.advanceTimersByTimeAsync(0); - expect(screen.getByLabelText('Copied')).toBeInTheDocument(); - - // After 2s, reverts back - await vi.advanceTimersByTimeAsync(2000); - expect(screen.getByLabelText('Copy to clipboard')).toBeInTheDocument(); - }); - - /* ── Test 5: Preflight failure ───────────────────────────────── */ - - it('handles preflight failures — shows "Cannot deploy" section and deploys only ready nodes', async () => { - agentDeployAPIMock.getCandidates.mockResolvedValue(CANDIDATES_RESPONSE); - - agentDeployAPIMock.createPreflight.mockResolvedValue({ - preflightId: 'pf1', - status: 'running', - eventsUrl: '/api/agent-deploy/preflights/pf1/events', - }); - agentDeployAPIMock.getPreflight.mockResolvedValue( - makeJob({ - id: 'pf1', - targets: [ - makeTarget({ - id: 'pt1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'pending', - }), - makeTarget({ - id: 'pt2', - nodeId: 'n3', - nodeName: 'pve3', - nodeIP: '10.0.0.3', - status: 'pending', - }), - ], - }), - ); - - renderWizard(); - await waitForCandidates(); - - // Run preflight - await fireEvent.click(screen.getByText('Run Preflight')); - await vi.advanceTimersByTimeAsync(0); - - const pfES = latestES(); - pfES.emitOpen(); - - // One passes, one fails - agentDeployAPIMock.getPreflight.mockResolvedValue( - makeJob({ - id: 'pf1', - status: 'partial_success', - targets: [ - makeTarget({ - id: 'pt1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'ready', - }), - makeTarget({ - id: 'pt2', - nodeId: 'n3', - nodeName: 'pve3', - nodeIP: '10.0.0.3', - status: 'failed_permanent', - errorMessage: 'SSH permission denied', - }), - ], - }), - ); - - pfES.emitMessage({ type: 'job_complete', status: 'partial_success' }); - await vi.advanceTimersByTimeAsync(0); - - // Confirm step — shows ready and failed sections - await waitFor(() => { - expect(screen.getByText('Ready to deploy (1)')).toBeInTheDocument(); - }); - expect(screen.getByText('Cannot deploy (1)')).toBeInTheDocument(); - expect(screen.getByText('SSH permission denied')).toBeInTheDocument(); - - // Deploy the 1 ready node - agentDeployAPIMock.createJob.mockResolvedValue({ - jobId: 'j1', - acceptedTargets: ['n2'], - skippedTargets: [], - eventsUrl: '/api/agent-deploy/jobs/j1/events', - }); - agentDeployAPIMock.getJob.mockResolvedValue( - makeJob({ - id: 'j1', - targets: [ - makeTarget({ - id: 'dt1', - jobId: 'j1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'pending', - }), - ], - }), - ); - - await fireEvent.click(screen.getByText('Deploy 1 Host')); - await vi.advanceTimersByTimeAsync(0); - - const deployES = latestES(); - deployES.emitOpen(); - - agentDeployAPIMock.getJob.mockResolvedValue( - makeJob({ - id: 'j1', - status: 'succeeded', - targets: [ - makeTarget({ - id: 'dt1', - jobId: 'j1', - nodeId: 'n2', - nodeName: 'pve2', - nodeIP: '10.0.0.2', - status: 'succeeded', - }), - ], - }), - ); - - deployES.emitMessage({ type: 'job_complete', status: 'succeeded' }); - await vi.advanceTimersByTimeAsync(0); - - await waitFor(() => { - expect(screen.getByText('Deployed (1)')).toBeInTheDocument(); - }); - }); -}); diff --git a/frontend-modern/src/components/shared/MigrationNoticeBanner.tsx b/frontend-modern/src/components/shared/MigrationNoticeBanner.tsx deleted file mode 100644 index cc795fa966..0000000000 --- a/frontend-modern/src/components/shared/MigrationNoticeBanner.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Show, type Component } from 'solid-js'; -import XIcon from 'lucide-solid/icons/x'; - -interface MigrationNoticeBannerProps { - title: string; - message: string; - learnMoreHref?: string; - onDismiss: () => void; -} - -export const MigrationNoticeBanner: Component = (props) => { - return ( -
-
-
-
- Navigation update -
-
{props.title}
-
{props.message}
- - - See full migration guide - - -
- -
-
- ); -}; - -export default MigrationNoticeBanner; diff --git a/frontend-modern/src/components/shared/SummaryPanel.tsx b/frontend-modern/src/components/shared/SummaryPanel.tsx deleted file mode 100644 index 3ff3e04aba..0000000000 --- a/frontend-modern/src/components/shared/SummaryPanel.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { For, Show, type JSX } from 'solid-js'; -import { - SUMMARY_TIME_RANGES, - SUMMARY_TIME_RANGE_LABEL, - type SummaryTimeRange, -} from './summaryTimeRange'; - -export interface SummaryPanelProps { - /** Left-side header content (resource counts, badges). */ - headerLeft: JSX.Element; - /** Currently selected time range (highlights the active button). */ - timeRange?: string; - /** Called when the user clicks a time range button. Omit to hide the buttons. */ - onTimeRangeChange?: (range: SummaryTimeRange) => void; - /** Custom time range options. Defaults to SUMMARY_TIME_RANGES. */ - timeRanges?: readonly string[]; - /** Custom labels for time range buttons. Defaults to SUMMARY_TIME_RANGE_LABEL. */ - timeRangeLabels?: Record; - /** Card grid content. */ - children: JSX.Element; - /** data-testid for the outer wrapper. */ - testId?: string; - /** Extra CSS classes on the outer container. */ - class?: string; - /** Optional shared density override for compact monitoring surfaces. */ - density?: 'default' | 'compact'; -} - -export function SummaryPanel(props: SummaryPanelProps) { - const ranges = () => (props.timeRanges ?? SUMMARY_TIME_RANGES) as readonly string[]; - const labels = () => - props.timeRangeLabels ?? (SUMMARY_TIME_RANGE_LABEL as Record); - const isCompact = () => props.density === 'compact'; - - return ( -
-
-
{props.headerLeft}
- -
- - {(range) => ( - - )} - -
-
-
- -
- {props.children} -
-
- ); -} diff --git a/frontend-modern/src/utils/localStorage.ts b/frontend-modern/src/utils/localStorage.ts index 9d1fded1e2..a1c5579f60 100644 --- a/frontend-modern/src/utils/localStorage.ts +++ b/frontend-modern/src/utils/localStorage.ts @@ -167,20 +167,10 @@ export const STORAGE_KEYS = { ALERT_HISTORY_TIME_FILTER: 'alertHistoryTimeFilter', ALERT_HISTORY_SEVERITY_FILTER: 'alertHistorySeverityFilter', - // Storage settings - STORAGE_SHOW_FILTERS: 'storageShowFilters', - STORAGE_VIEW_MODE: 'storageViewMode', - STORAGE_SOURCE_FILTER: 'storageSourceFilter', - // Recovery settings - RECOVERY_SHOW_FILTERS: 'backupsShowFilters', - RECOVERY_USE_RELATIVE_TIME: 'backupsUseRelativeTime', RECOVERY_SEARCH_HISTORY: 'backupsSearchHistory', // Workloads settings - WORKLOADS_SHOW_FILTERS: 'workloadsShowFilters', - WORKLOADS_CARD_VIEW: 'workloadsCardView', - WORKLOADS_AUTO_REFRESH: 'workloadsAutoRefresh', WORKLOADS_SEARCH_HISTORY: 'workloadsSearchHistory', WORKLOADS_METRIC_DISPLAY_MODE: 'workloadsMetricDisplayMode', WORKLOADS_METRIC_HISTORY_RANGE: 'workloadsMetricHistoryRange', diff --git a/frontend-modern/src/utils/storageSummaryTrendCache.ts b/frontend-modern/src/utils/storageSummaryTrendCache.ts deleted file mode 100644 index 1ed4f0ddbf..0000000000 --- a/frontend-modern/src/utils/storageSummaryTrendCache.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { ChartsAPI, type StorageSummaryTrendResponse, type TimeRange } from '@/api/charts'; -import { eventBus } from '@/stores/events'; -import { getOrgID } from '@/utils/apiClient'; -import { normalizeOrgScope } from '@/utils/orgScope'; - -const STORAGE_SUMMARY_TREND_CACHE_VERSION = 1; - -function cacheKeyFor( - range: TimeRange, - orgScope: string = normalizeOrgScope(getOrgID()), -): string { - return `${STORAGE_SUMMARY_TREND_CACHE_VERSION}::${orgScope}::${range}`; -} - -const inMemoryCache = new Map(); -const inFlightFetches = new Map>(); - -export function readStorageSummaryTrendCache(range: TimeRange): StorageSummaryTrendResponse | null { - return inMemoryCache.get(cacheKeyFor(range)) ?? null; -} - -export function fetchStorageSummaryTrendAndCache( - range: TimeRange, - _options?: { caller?: string }, -): Promise { - const inFlightKey = cacheKeyFor(range); - const existing = inFlightFetches.get(inFlightKey); - if (existing) { - return existing; - } - - const request = ChartsAPI.getStorageSummaryTrend(range) - .then((response) => { - inMemoryCache.set(inFlightKey, response); - return response; - }) - .finally(() => { - inFlightFetches.delete(inFlightKey); - }); - - inFlightFetches.set(inFlightKey, request); - return request; -} - -export function __resetStorageSummaryTrendCacheForTests(): void { - inMemoryCache.clear(); - inFlightFetches.clear(); -} - -const unsubscribeStorageOrgSwitch = eventBus.on('org_switched', () => { - inMemoryCache.clear(); - inFlightFetches.clear(); -}); - -if (import.meta.hot) { - import.meta.hot.dispose(() => { - unsubscribeStorageOrgSwitch(); - }); -}