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 ( - - - - {w.candidatesError()} - - - - - - - {getDeployCandidatesLoadingState()} - - - - - {/* Source agent selection */} - 1}> - - - Source Agent - - w.setSelectedSourceAgent(e.currentTarget.value)} - class="w-full rounded-md border border-border bg-surface px-3 py-1.5 text-sm text-base-content focus:outline-none focus:ring-2 focus:ring-blue-500" - > - Select a source agent... - - {(agent) => ( - - {agent.nodeId} ({agent.agentId}) - - )} - - - - The source agent will SSH to other nodes to install Pulse agents. - - - - - - - - {getDeployNoSourceAgentsState()} - - - - {/* Node selection table */} - 0}> - - - - {w.selectedNodeIds().size} of {w.deployableNodes().length} nodes selected - - - w.selectAllNodes()} - disabled={allSelected()} - class="text-xs text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50 disabled:no-underline" - > - Select All - - w.deselectAllNodes()} - disabled={w.selectedNodeIds().size === 0} - class="text-xs text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50 disabled:no-underline" - > - Deselect All - - - - - - - - - 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 ( - - - - {w.deployError()} - - - - {/* 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 ( - - - - {w.preflightError()} - - - - {/* 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 */} - - }> - - - Manual install instructions - - - - - For nodes that failed SSH-based deployment, you can install the agent manually by - SSHing into the node and running: - - - - - {getDeployInstallCommandLoadingState()} - - - - - {installCommandError()} - - - - - {/* 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.expandSearch(false)} - class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 text-muted hover:text-base-content hover:bg-surface-hover" - aria-label={props.title ?? props.placeholder ?? 'Open search'} - title={`${props.placeholder ?? 'Search'} (/)`} - > - - - - {collapsible.triggerLabel()} - - - } - > - { - 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 */} - - drawer.setActiveTab('overview')} - class={`pb-2 text-sm font-medium transition-colors relative ${ - drawer.activeTab() === 'overview' - ? 'text-blue-600 dark:text-blue-400' - : ' hover:text-muted' - }`} - > - Overview - {drawer.activeTab() === 'overview' && ( - - )} - - drawer.setActiveTab('discovery')} - class={`pb-2 text-sm font-medium transition-colors relative ${ - drawer.activeTab() === 'discovery' - ? 'text-blue-600 dark:text-blue-400' - : ' hover:text-muted' - }`} - > - Discovery - {drawer.activeTab() === 'discovery' && ( - - )} - - - - {/* 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 ( - <> - - - - - > - ); -} diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index c24552018b..a90489b1e9 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -9,11 +9,9 @@ import columnPickerSource from '@/components/shared/ColumnPicker.tsx?raw'; import columnPickerModelSource from '@/components/shared/columnPickerModel.ts?raw'; import tagInputSource from '@/components/shared/TagInput.tsx?raw'; import tagInputModelSource from '@/components/shared/tagInputModel.ts?raw'; -import collapsibleSearchInputSource from '@/components/shared/CollapsibleSearchInput.tsx?raw'; import collapsibleSearchInputModelSource from '@/components/shared/collapsibleSearchInputModel.ts?raw'; import containerUpdateBadgeSource from '@/components/shared/ContainerUpdateBadge.tsx?raw'; import containerUpdateBadgeModelSource from '@/components/shared/containerUpdateBadgeModel.ts?raw'; -import densityMapSource from '@/components/shared/DensityMap.tsx?raw'; import densityMapModelSource from '@/components/shared/densityMapModel.ts?raw'; import dialogSource from '@/components/shared/Dialog.tsx?raw'; import dialogModelSource from '@/components/shared/dialogModel.ts?raw'; @@ -28,7 +26,6 @@ import historyChartOverlaySource from '@/components/shared/HistoryChartOverlay.t import historyChartSource from '@/components/shared/HistoryChart.tsx?raw'; import historyChartModelSource from '@/components/shared/historyChartModel.ts?raw'; import historyChartTooltipSource from '@/components/shared/HistoryChartTooltip.tsx?raw'; -import infrastructureDetailsDrawerSource from '@/components/shared/InfrastructureDetailsDrawer.tsx?raw'; import infrastructureDetailsDrawerModelSource from '@/components/shared/infrastructureDetailsDrawerModel.ts?raw'; import mobileNavBarSource from '@/components/shared/MobileNavBar.tsx?raw'; import mobileNavBarModelSource from '@/components/shared/mobileNavBarModel.ts?raw'; @@ -41,7 +38,6 @@ import searchInputSource from '@/components/shared/SearchInput.tsx?raw'; import searchInputEnhancementsSource from '@/components/shared/SearchInputEnhancements.tsx?raw'; import searchInputEnhancementsModelSource from '@/components/shared/searchInputEnhancementsModel.ts?raw'; import searchInputModelSource from '@/components/shared/searchInputModel.ts?raw'; -import scrollToTopButtonSource from '@/components/shared/ScrollToTopButton.tsx?raw'; import scrollToTopButtonModelSource from '@/components/shared/scrollToTopButtonModel.ts?raw'; import statusBadgeSource from '@/components/shared/StatusBadge.tsx?raw'; import statusBadgeModelSource from '@/components/shared/statusBadgeModel.ts?raw'; @@ -126,16 +122,10 @@ import storagePoolRowSource from '@/components/Storage/StoragePoolRow.tsx?raw'; import storageContentCardSource from '@/components/Storage/StorageContentCard.tsx?raw'; import storagePoolsTableSource from '@/components/Storage/StoragePoolsTable.tsx?raw'; import diskListSource from '@/components/Storage/DiskList.tsx?raw'; -import recoveryTablePresentationSource from '@/utils/recoveryTablePresentation.ts?raw'; import alertHistoryTableSectionSource from '@/features/alerts/AlertHistoryTableSection.tsx?raw'; import alertHistoryTableGroupRowSource from '@/features/alerts/AlertHistoryTableGroupRow.tsx?raw'; import alertResourceTableDesktopSource from '@/components/Alerts/AlertResourceTableDesktop.tsx?raw'; import aiCostDashboardSource from '@/components/AI/AICostDashboard.tsx?raw'; -import deployCandidatesStepSource from '@/components/Infrastructure/deploy/CandidatesStep.tsx?raw'; -import deployConfirmStepSource from '@/components/Infrastructure/deploy/ConfirmStep.tsx?raw'; -import deployDeployingStepSource from '@/components/Infrastructure/deploy/DeployingStep.tsx?raw'; -import deployPreflightStepSource from '@/components/Infrastructure/deploy/PreflightStep.tsx?raw'; -import deployResultsStepSource from '@/components/Infrastructure/deploy/ResultsStep.tsx?raw'; import pmgMailGatewaySource from '@/components/PMG/MailGateway.tsx?raw'; import pmgInstancePanelSource from '@/components/PMG/PMGInstancePanel.tsx?raw'; import resourceDetailSummarySource from '@/components/Infrastructure/ResourceDetailSummary.tsx?raw'; @@ -469,7 +459,6 @@ describe('shared primitive guardrails', () => { expect(workloadPanelSource).toContain('getGroupedTableRowCellClass'); expect(unifiedResourceHostTableCardSource).toContain('getInteractiveGroupedTableRowClass'); expect(unifiedResourceHostTableCardSource).toContain('getGroupedTableRowCellClass'); - expect(recoveryTablePresentationSource).toContain('getGroupedTableRowCellClass'); expect(alertHistoryTableGroupRowSource).toContain('getGroupedTableRowClass'); expect(alertHistoryTableGroupRowSource).toContain('getGroupedTableRowCellClass'); expect(alertHistoryTableGroupRowSource).not.toContain('class="bg-surface-alt"'); @@ -509,11 +498,6 @@ describe('shared primitive guardrails', () => { diskListSource, infrastructureSourceManagerSource, aiCostDashboardSource, - deployCandidatesStepSource, - deployConfirmStepSource, - deployDeployingStepSource, - deployPreflightStepSource, - deployResultsStepSource, proxmoxMailGatewayDrawerSource, pmgInstancePanelSource, pmgMailGatewaySource, @@ -579,11 +563,6 @@ describe('shared primitive guardrails', () => { expect(userAssignmentsPanelSource.match(/frame="flush"/g) ?? []).toHaveLength(1); for (const source of [ - deployCandidatesStepSource, - deployConfirmStepSource, - deployDeployingStepSource, - deployPreflightStepSource, - deployResultsStepSource, ]) { expect(source).toContain("from '@/components/shared/Table'"); expect(source).not.toContain(' { }); 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'); @@ -881,23 +853,6 @@ describe('shared primitive guardrails', () => { }); it('keeps density map on shell, runtime, and model owners', () => { - expect(densityMapSource).toContain('useDensityMapState'); - expect(densityMapSource).not.toContain('Latest'); - expect(densityMapSource).not.toContain('Top activity overview'); - expect(densityMapSource).not.toContain('detail().currentValue'); - expect(densityMapSource).toContain('data-density-map-tooltip="true"'); - expect(densityMapSource).not.toContain('data-density-map-tooltip-sparkline="true"'); - expect(densityMapSource).toContain('grid-cols-[auto_minmax(0,1fr)_auto]'); - expect(densityMapSource).not.toContain('max-w-[94px]'); - expect(densityMapSource).toContain( - 'whitespace-nowrap text-[11px] font-semibold text-emerald-400', - ); - expect(densityMapSource).toContain( - 'whitespace-nowrap text-[11px] font-semibold text-base-content', - ); - expect(densityMapSource).not.toContain('timeRangeToMs'); - expect(densityMapSource).not.toContain('createSignal'); - expect(densityMapSource).not.toContain('ctx.fillRect'); expect(densityMapStateSource).toContain('export function useDensityMapState'); expect(densityMapStateSource).toContain('createSignal'); @@ -1058,16 +1013,6 @@ describe('shared primitive guardrails', () => { }); it('keeps infrastructure details drawer on shell, runtime, and model owners', () => { - expect(infrastructureDetailsDrawerSource).toContain('useInfrastructureDetailsDrawerState'); - expect(infrastructureDetailsDrawerSource).toContain( - 'resolveInfrastructureDetailsDrawerMetadataId', - ); - expect(infrastructureDetailsDrawerSource).toContain( - 'resolveInfrastructureDetailsDrawerDiscoveryHostname', - ); - expect(infrastructureDetailsDrawerSource).not.toContain('createSignal'); - expect(infrastructureDetailsDrawerSource).not.toContain('getInfrastructureMetadataId'); - expect(infrastructureDetailsDrawerSource).not.toContain('getInfrastructureDiscoveryHostname'); expect(infrastructureDetailsDrawerStateSource).toContain( 'export function useInfrastructureDetailsDrawerState', @@ -1290,15 +1235,6 @@ describe('shared primitive guardrails', () => { }); it('keeps collapsible search input on shell, runtime, and model owners', () => { - expect(collapsibleSearchInputSource).toContain('useCollapsibleSearchInputState'); - expect(collapsibleSearchInputSource).not.toContain('createSignal'); - expect(collapsibleSearchInputSource).not.toContain('useTypeToSearch'); - expect(collapsibleSearchInputSource).not.toContain( - "const triggerLabel = () => props.triggerLabel ?? 'Search'", - ); - expect(collapsibleSearchInputSource).not.toContain( - "const layoutClass = showExpanded() ? 'order-last basis-full w-full' : 'shrink-0 md:ml-auto'", - ); expect(collapsibleSearchInputStateSource).toContain( 'export function useCollapsibleSearchInputState', diff --git a/frontend-modern/src/components/shared/SparklineSkeleton.tsx b/frontend-modern/src/components/shared/SparklineSkeleton.tsx deleted file mode 100644 index 143727e0b8..0000000000 --- a/frontend-modern/src/components/shared/SparklineSkeleton.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Component } from 'solid-js'; - -interface SparklineSkeletonProps { - class?: string; -} - -export const SparklineSkeleton: Component = (props) => { - return ( - - - {/* Y-axis placeholders */} - - - - - - - - {/* Center line / chart placeholder */} - - {/* Subtle grid lines */} - - - - - {/* Shimmer bar placeholder */} - - - - - - - - - - - - {/* X-axis placeholders */} - - - - - - - ); -}; diff --git a/frontend-modern/src/components/shared/__tests__/DensityMap.test.tsx b/frontend-modern/src/components/shared/__tests__/DensityMap.test.tsx deleted file mode 100644 index 009dac4ad5..0000000000 --- a/frontend-modern/src/components/shared/__tests__/DensityMap.test.tsx +++ /dev/null @@ -1,475 +0,0 @@ -import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; -import { describe, expect, it, vi } from 'vitest'; -import densityMapSource from '@/components/shared/DensityMap.tsx?raw'; -import densityMapModelSource from '@/components/shared/densityMapModel.ts?raw'; -import densityMapStateSource from '@/components/shared/useDensityMapState.ts?raw'; -import { DensityMap } from '@/components/shared/DensityMap'; -import { - buildDensityMapChartData, - buildDensityMapFocusDetail, - buildDensityMapSynchronizedReadout, - getDensityMapExternalSeriesIndex, -} from '@/components/shared/densityMapModel'; - -HTMLCanvasElement.prototype.getContext = vi.fn(() => ({ - clearRect: vi.fn(), - setTransform: vi.fn(), - scale: vi.fn(), - beginPath: vi.fn(), - roundRect: vi.fn(), - save: vi.fn(), - restore: vi.fn(), - stroke: vi.fn(), - strokeRect: vi.fn(), - fill: vi.fn(), - fillRect: vi.fn(), - lineWidth: 1, - strokeStyle: '', - globalAlpha: 1, - fillStyle: '', -})) as unknown as typeof HTMLCanvasElement.prototype.getContext; - -describe('DensityMap', () => { - it('keeps the density map on shell, runtime, and model owners', () => { - expect(densityMapSource).toContain('useDensityMapState'); - expect(densityMapSource).not.toContain('timeRangeToMs'); - expect(densityMapSource).not.toContain('createSignal'); - expect(densityMapSource).not.toContain('ctx.fillRect'); - - expect(densityMapStateSource).toContain('createSignal'); - expect(densityMapStateSource).toContain('canvas.getContext'); - expect(densityMapStateSource).toContain('window.addEventListener'); - expect(densityMapStateSource).toContain('export function useDensityMapState'); - - expect(densityMapModelSource).toContain('buildDensityMapChartData'); - expect(densityMapModelSource).toContain('buildDensityMapFocusDetail'); - expect(densityMapModelSource).toContain('buildDensityMapHoveredState'); - expect(densityMapModelSource).toContain('getDensityMapExternalSeriesIndex'); - expect(densityMapModelSource).toContain('getDensityMapCellOpacity'); - }); - - it('renders the time range labels and hover tooltip', async () => { - const now = Date.now(); - const { container } = render(() => ( - - )); - - expect(screen.getByText('-1h')).toBeInTheDocument(); - expect(screen.getByText('now')).toBeInTheDocument(); - - const canvas = container.querySelector('canvas'); - expect(canvas).not.toBeNull(); - if (!canvas) return; - - (canvas as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () => - ({ - left: 0, - top: 0, - width: 180, - height: 80, - right: 180, - bottom: 80, - x: 0, - y: 0, - toJSON: () => ({}), - }) as unknown as DOMRect; - - fireEvent.mouseMove(canvas, { clientX: 160, clientY: 20 }); - - expect(await screen.findByText('CPU')).toBeInTheDocument(); - expect(screen.getByText('Current')).toBeInTheDocument(); - expect(screen.getByText('Peak')).toBeInTheDocument(); - expect(document.querySelector('[data-density-map-tooltip="true"]')).not.toBeNull(); - expect(document.querySelector('[data-density-map-tooltip-sparkline="true"]')).toBeNull(); - }); - - it('repositions the shared tooltip portal while the density-map hover stays visible', async () => { - vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { - callback(0); - return 1; - }); - - const now = Date.now(); - const { container } = render(() => ( - - )); - - const canvas = container.querySelector('canvas'); - expect(canvas).not.toBeNull(); - if (!canvas) return; - - (canvas as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () => - ({ - left: 0, - top: 0, - width: 180, - height: 80, - right: 180, - bottom: 80, - x: 0, - y: 0, - toJSON: () => ({}), - }) as unknown as DOMRect; - - const readTooltipTop = () => - Number.parseFloat( - document - .querySelector('[data-density-map-tooltip="true"]') - ?.closest('foreignObject') - ?.getAttribute('y') || 'NaN', - ); - - fireEvent.mouseMove(canvas, { clientX: 160, clientY: 10 }); - await waitFor(() => { - expect(document.querySelector('[data-density-map-tooltip="true"]')).not.toBeNull(); - }); - const firstTop = readTooltipTop(); - - fireEvent.mouseMove(canvas, { clientX: 160, clientY: 70 }); - - await waitFor(() => { - expect(readTooltipTop()).toBeGreaterThan(firstTop + 20); - }); - }); - - it('publishes synchronized hover identity and clears it on leave', () => { - const now = Date.now(); - const onHoverSyncChange = vi.fn(); - const { container } = render(() => ( - - )); - - const canvas = container.querySelector('canvas'); - expect(canvas).not.toBeNull(); - if (!canvas) return; - - (canvas as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () => - ({ - left: 0, - top: 0, - width: 180, - height: 80, - right: 180, - bottom: 80, - x: 0, - y: 0, - toJSON: () => ({}), - }) as unknown as DOMRect; - - fireEvent.mouseMove(canvas, { clientX: 160, clientY: 20 }); - - const publishedHover = onHoverSyncChange.mock.calls.at(-1)?.[0]; - expect(publishedHover).toMatchObject({ - sourceKey: 'diskio', - seriesId: 'alpha', - }); - expect(typeof publishedHover?.timestamp).toBe('number'); - expect(publishedHover?.timestamp).toBeGreaterThanOrEqual(now - 3_600_000); - expect(publishedHover?.timestamp).toBeLessThanOrEqual(now); - - fireEvent.mouseLeave(canvas); - expect(onHoverSyncChange).toHaveBeenLastCalledWith(null); - }); - - it('builds a compact synchronized readout for sibling density-map cards', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2024-01-01T12:00:00Z')); - const now = Date.now(); - - expect( - buildDensityMapSynchronizedReadout({ - formatValue: (value) => `${value.toFixed(1)} MB/s`, - hoverSourceKey: 'network', - hoverSync: { - sourceKey: 'diskio', - seriesId: 'alpha', - timestamp: now - 10_000, - }, - series: [ - { - id: 'alpha', - name: 'Alpha', - color: '#10b981', - data: [ - { timestamp: now - 30_000, value: 25 }, - { timestamp: now - 10_000, value: 55 }, - ], - }, - ], - timeRange: '1h', - }), - ).toEqual({ - timestamp: now - 10_000, - value: '55.0 MB/s', - }); - - expect( - buildDensityMapSynchronizedReadout({ - emptyValue: 'No sample', - formatValue: (value) => `${value.toFixed(1)} MB/s`, - hoverSourceKey: 'network', - hoverSync: { - sourceKey: 'diskio', - seriesId: 'alpha', - timestamp: now - 50 * 60_000, - }, - series: [ - { - id: 'alpha', - name: 'Alpha', - color: '#10b981', - data: [ - { timestamp: now - 30_000, value: 25 }, - { timestamp: now - 10_000, value: 55 }, - ], - }, - ], - timeRange: '1h', - }), - ).toEqual({ - empty: true, - timestamp: now - 50 * 60_000, - value: 'No sample', - }); - }); - - it('keeps an externally highlighted series visible when it falls outside the default density top set', () => { - const now = Date.now(); - const series = Array.from({ length: 24 }, (_, index) => ({ - id: `series-${index + 1}`, - name: `Series ${index + 1}`, - color: '#10b981', - data: - index === 23 - ? [] - : [ - { timestamp: now - 30_000, value: 100 - index }, - { timestamp: now - 10_000, value: 100 - index }, - ], - })); - - const chartData = buildDensityMapChartData({ - series, - timeRange: '1h', - now, - highlightSeriesId: 'series-24', - }); - - expect(chartData.series.map((entry) => entry.id)).toContain('series-24'); - expect(getDensityMapExternalSeriesIndex(chartData, 'series-24')).not.toBeNull(); - }); - - it('builds focused detail from the active density-map series without replacing the overview model', () => { - const now = Date.now(); - const chartData = buildDensityMapChartData({ - now, - timeRange: '1h', - highlightSeriesId: 'alpha', - series: [ - { - id: 'alpha', - name: 'Alpha', - color: '#10b981', - data: [ - { timestamp: now - 40_000, value: 12 }, - { timestamp: now - 10_000, value: 32 }, - ], - }, - { - id: 'beta', - name: 'Beta', - color: '#3b82f6', - data: [ - { timestamp: now - 30_000, value: 18 }, - { timestamp: now - 5_000, value: 24 }, - ], - }, - ], - }); - - const detail = buildDensityMapFocusDetail({ - data: chartData, - highlightSeriesId: 'alpha', - }); - - expect(chartData.series).toHaveLength(2); - expect(detail).toMatchObject({ - seriesId: 'alpha', - seriesName: 'Alpha', - peakValue: 32, - }); - }); - - it('keeps the density-map card free of persistent focus chrome while preserving overview count', () => { - const now = Date.now(); - const { container } = render(() => ( - - )); - - const root = container.firstElementChild; - expect(root?.getAttribute('data-summary-chart-kind')).toBe('density-map'); - expect(root?.getAttribute('data-active-hover-timestamp')).toBe(''); - expect(root?.getAttribute('data-rendered-series-count')).toBe('2'); - expect(screen.queryByText('Top activity overview')).toBeNull(); - expect(screen.queryByText('Peak')).toBeNull(); - expect(screen.queryByText('Alpha')).toBeNull(); - expect(container.querySelector('[data-density-map-focus-detail="true"]')).toBeNull(); - }); - - it('keeps empty focused density maps free of persistent empty-state chrome', () => { - const now = Date.now(); - const { container } = render(() => ( - - )); - - expect(container.querySelector('[data-density-map-focus-detail="true"]')).toBeNull(); - expect(screen.queryByText('No disk activity in range')).toBeNull(); - expect(screen.queryByText('Peak')).toBeNull(); - }); - - it('shows empty-state peak copy in the tooltip when the hovered series has no activity in range', async () => { - const now = Date.now(); - const { container } = render(() => ( - - )); - - const canvas = container.querySelector('canvas'); - expect(canvas).not.toBeNull(); - if (!canvas) return; - - (canvas as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () => - ({ - left: 0, - top: 0, - width: 180, - height: 80, - right: 180, - bottom: 80, - x: 0, - y: 0, - toJSON: () => ({}), - }) as unknown as DOMRect; - - fireEvent.mouseMove(canvas, { clientX: 20, clientY: 60 }); - - expect(await screen.findByText('Alpha')).toBeInTheDocument(); - expect(screen.getByText('Peak')).toBeInTheDocument(); - expect(screen.getByText('No disk activity in range')).toBeInTheDocument(); - expect(document.querySelector('[data-density-map-tooltip-sparkline="true"]')).toBeNull(); - }); -}); diff --git a/frontend-modern/src/components/shared/__tests__/InfrastructureDetailsDrawer.test.tsx b/frontend-modern/src/components/shared/__tests__/InfrastructureDetailsDrawer.test.tsx deleted file mode 100644 index e9287f1a8f..0000000000 --- a/frontend-modern/src/components/shared/__tests__/InfrastructureDetailsDrawer.test.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen } from '@solidjs/testing-library'; -import infrastructureDetailsDrawerSource from '@/components/shared/InfrastructureDetailsDrawer.tsx?raw'; -import infrastructureDetailsDrawerModelSource from '@/components/shared/infrastructureDetailsDrawerModel.ts?raw'; -import infrastructureDetailsDrawerStateSource from '@/components/shared/useInfrastructureDetailsDrawerState.ts?raw'; -import { InfrastructureDetailsDrawer } from '@/components/shared/InfrastructureDetailsDrawer'; -import type { Node, Agent } from '@/types/api'; - -const discoveryTabMock = vi.fn(); -const webInterfaceUrlFieldMock = vi.fn(); - -vi.mock('@/components/Discovery/DiscoveryTab', () => ({ - DiscoveryTab: (props: unknown) => { - discoveryTabMock(props); - return discovery; - }, -})); - -vi.mock('@/components/shared/WebInterfaceUrlField', () => ({ - WebInterfaceUrlField: (props: unknown) => { - webInterfaceUrlFieldMock(props); - return url-field; - }, -})); - -vi.mock('@/components/shared/cards/SystemInfoCard', () => ({ - SystemInfoCard: () => system, -})); - -vi.mock('@/components/shared/cards/HardwareCard', () => ({ - HardwareCard: () => hardware, -})); - -vi.mock('@/components/shared/cards/RootDiskCard', () => ({ - RootDiskCard: () => disk, -})); - -vi.mock('@/components/shared/cards/NetworkInterfacesCard', () => ({ - NetworkInterfacesCard: () => net, -})); - -vi.mock('@/components/shared/cards/DisksCard', () => ({ - DisksCard: () => disks, -})); - -const makeNode = (overrides: Partial = {}): Node => ({ - id: 'node-1', - name: 'pve1', - displayName: 'pve1', - instance: 'cluster-a', - host: 'pve1', - status: 'online', - type: 'node', - cpu: 0.2, - memory: { total: 8, used: 4, free: 4, usage: 50 }, - disk: { total: 100, used: 30, free: 70, usage: 30 }, - uptime: 123, - loadAverage: [0.1, 0.2, 0.3], - kernelVersion: '6.8.12', - pveVersion: 'pve-manager/8.3.0', - cpuInfo: { model: 'EPYC', cores: 8, sockets: 1, mhz: '3200' }, - lastSeen: new Date().toISOString(), - connectionHealth: 'online', - linkedAgentId: undefined, - ...overrides, -}); - -const makeAgent = (overrides: Partial = {}): Agent => ({ - id: 'agent-1', - hostname: 'pve1.local', - displayName: 'PVE Agent', - memory: { total: 8, used: 4, free: 4, usage: 50 }, - status: 'online', - lastSeen: Date.now(), - ...overrides, -}); - -describe('InfrastructureDetailsDrawer', () => { - it('keeps infrastructure details drawer on shell, runtime, and model owners', () => { - expect(infrastructureDetailsDrawerSource).toContain('useInfrastructureDetailsDrawerState'); - expect(infrastructureDetailsDrawerSource).toContain( - 'resolveInfrastructureDetailsDrawerMetadataId', - ); - expect(infrastructureDetailsDrawerSource).toContain( - 'resolveInfrastructureDetailsDrawerDiscoveryHostname', - ); - expect(infrastructureDetailsDrawerSource).not.toContain('createSignal'); - expect(infrastructureDetailsDrawerSource).not.toContain('getInfrastructureMetadataId'); - expect(infrastructureDetailsDrawerSource).not.toContain( - 'getInfrastructureDiscoveryHostname', - ); - - expect(infrastructureDetailsDrawerStateSource).toContain( - 'export function useInfrastructureDetailsDrawerState', - ); - expect(infrastructureDetailsDrawerStateSource).toContain('createSignal'); - expect(infrastructureDetailsDrawerStateSource).toContain("type InfrastructureDetailsDrawerTab = 'overview' | 'discovery'"); - - expect(infrastructureDetailsDrawerModelSource).toContain( - 'resolveInfrastructureDetailsDrawerMetadataId', - ); - expect(infrastructureDetailsDrawerModelSource).toContain( - 'resolveInfrastructureDetailsDrawerDiscoveryHostname', - ); - expect(infrastructureDetailsDrawerModelSource).toContain('getInfrastructureMetadataId'); - expect(infrastructureDetailsDrawerModelSource).toContain( - 'getInfrastructureDiscoveryHostname', - ); - }); - - it('uses linkedAgentId when no agent object is provided', async () => { - discoveryTabMock.mockClear(); - webInterfaceUrlFieldMock.mockClear(); - - render(() => ( - - )); - - expect(webInterfaceUrlFieldMock).toHaveBeenCalledWith( - expect.objectContaining({ metadataId: 'agent-host-1' }), - ); - - await fireEvent.click(screen.getByRole('button', { name: 'Discovery' })); - - expect(discoveryTabMock).toHaveBeenCalledWith( - expect.objectContaining({ - agentId: 'agent-host-1', - resourceId: 'agent-host-1', - }), - ); - }); - - it('prefers explicit agent identity over linkedAgentId', async () => { - discoveryTabMock.mockClear(); - webInterfaceUrlFieldMock.mockClear(); - - render(() => ( - - )); - - expect(webInterfaceUrlFieldMock).toHaveBeenCalledWith( - expect.objectContaining({ metadataId: 'agent-explicit-1' }), - ); - - await fireEvent.click(screen.getByRole('button', { name: 'Discovery' })); - - expect(discoveryTabMock).toHaveBeenCalledWith( - expect.objectContaining({ - agentId: 'agent-explicit-1', - resourceId: 'agent-explicit-1', - hostname: 'pve1.explicit', - }), - ); - }); - - it('falls back to canonical agent metadata ids when the agent id is not the best identifier', async () => { - discoveryTabMock.mockClear(); - webInterfaceUrlFieldMock.mockClear(); - - render(() => ( - & { platformData: { linkedAgentId: string } })} - /> - )); - - expect(webInterfaceUrlFieldMock).toHaveBeenCalledWith( - expect.objectContaining({ metadataId: 'agent-linked-1' }), - ); - - await fireEvent.click(screen.getByRole('button', { name: 'Discovery' })); - - expect(discoveryTabMock).toHaveBeenCalledWith( - expect.objectContaining({ - agentId: 'agent-linked-1', - resourceId: 'agent-linked-1', - hostname: 'pve1.explicit', - }), - ); - }); -}); diff --git a/frontend-modern/src/components/shared/__tests__/SearchInput.test.tsx b/frontend-modern/src/components/shared/__tests__/SearchInput.test.tsx index 265aaeae35..92b4d87ddf 100644 --- a/frontend-modern/src/components/shared/__tests__/SearchInput.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/SearchInput.test.tsx @@ -1,7 +1,6 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; import { afterEach, describe, expect, it } from 'vitest'; import { createSignal } from 'solid-js'; -import { CollapsibleSearchInput } from '@/components/shared/CollapsibleSearchInput'; import { SearchInput } from '@/components/shared/SearchInput'; import searchInputSource from '@/components/shared/SearchInput.tsx?raw'; import searchInputModelSource from '@/components/shared/searchInputModel.ts?raw'; @@ -9,10 +8,6 @@ import searchInputEnhancementsSource from '@/components/shared/SearchInputEnhanc import searchInputEnhancementsModelSource from '@/components/shared/searchInputEnhancementsModel.ts?raw'; import searchInputEnhancementsStateSource from '@/components/shared/useSearchInputEnhancements.ts?raw'; import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw'; -import collapsibleSearchInputSource from '@/components/shared/CollapsibleSearchInput.tsx?raw'; -import collapsibleSearchInputModelSource from '@/components/shared/collapsibleSearchInputModel.ts?raw'; -import collapsibleSearchInputStateSource from '@/components/shared/useCollapsibleSearchInputState.ts?raw'; -import { focusActiveTypeToSearch } from '@/hooks/useTypeToSearch'; const SearchHarness = (props: { typeToSearch?: boolean; @@ -84,35 +79,6 @@ describe('SearchInput', () => { expect(searchInputEnhancementsModelSource).toContain('SEARCH_HISTORY_MENU_CLASS'); }); - it('keeps collapsible search input on shell, runtime, and model owners', () => { - expect(collapsibleSearchInputSource).toContain('useCollapsibleSearchInputState'); - expect(collapsibleSearchInputSource).not.toContain('createSignal'); - expect(collapsibleSearchInputSource).not.toContain('useTypeToSearch'); - expect(collapsibleSearchInputSource).not.toContain("const triggerLabel = () => props.triggerLabel ?? 'Search'"); - expect(collapsibleSearchInputSource).not.toContain( - "const layoutClass = showExpanded() ? 'order-last basis-full w-full' : 'shrink-0 md:ml-auto'", - ); - - expect(collapsibleSearchInputStateSource).toContain( - 'export function useCollapsibleSearchInputState', - ); - expect(collapsibleSearchInputStateSource).toContain('createSignal'); - expect(collapsibleSearchInputStateSource).toContain('useTypeToSearch'); - expect(collapsibleSearchInputStateSource).toContain('queueMicrotask'); - expect(collapsibleSearchInputStateSource).toContain('setIsExpanded(true)'); - - expect(collapsibleSearchInputModelSource).toContain( - 'getCollapsibleSearchTriggerLabel', - ); - expect(collapsibleSearchInputModelSource).toContain( - 'shouldShowCollapsibleSearchExpanded', - ); - expect(collapsibleSearchInputModelSource).toContain( - 'getCollapsibleSearchRootClass', - ); - expect(collapsibleSearchInputModelSource).toContain('order-last basis-full w-full'); - }); - it('captures typed characters by default when focus is outside the input', async () => { render(() => ); @@ -260,55 +226,4 @@ describe('SearchInput', () => { }); }); - it('expands a collapsible search and inserts typed characters through the shared registry', async () => { - const CollapsibleHarness = () => { - const [value, setValue] = createSignal(''); - - return ( - - Outside - - - ); - }; - - render(() => ); - - const outside = screen.getByRole('button', { name: 'Outside' }); - outside.focus(); - - fireEvent.keyDown(document, { key: 'm' }); - - await waitFor(() => { - const input = screen.getByPlaceholderText('Collapsed search'); - expect(input).toHaveValue('m'); - expect(document.activeElement).toBe(input); - }); - }); - - it('focuses the active search through the shared focus helper', async () => { - const CollapsibleHarness = () => { - const [value, setValue] = createSignal(''); - - return ( - - Outside - - - ); - }; - - render(() => ); - - expect(focusActiveTypeToSearch({ selectText: true })).toBe(true); - - await waitFor(() => { - const input = screen.getByPlaceholderText('Helper search'); - expect(document.activeElement).toBe(input); - }); - }); }); diff --git a/frontend-modern/src/hooks/__tests__/useDebouncedValue.test.ts b/frontend-modern/src/hooks/__tests__/useDebouncedValue.test.ts deleted file mode 100644 index f434204366..0000000000 --- a/frontend-modern/src/hooks/__tests__/useDebouncedValue.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { useDebouncedValue } from '../useDebouncedValue'; -import { createSignal } from 'solid-js'; - -describe('useDebouncedValue', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('returns initial value immediately', () => { - const [value] = createSignal('initial'); - const debounced = useDebouncedValue(value, 300); - - expect(debounced()).toBe('initial'); - }); - - it('updates debounced value after delay', () => { - const [value, setValue] = createSignal('initial'); - const debounced = useDebouncedValue(value, 300); - - expect(debounced()).toBe('initial'); - - setValue('updated'); - - expect(debounced()).toBe('initial'); - - vi.advanceTimersByTime(300); - - expect(debounced()).toBe('updated'); - }); - - it('uses default delay of 300ms', () => { - const [value, setValue] = createSignal('initial'); - const debounced = useDebouncedValue(value); - - setValue('updated'); - - vi.advanceTimersByTime(299); - expect(debounced()).toBe('initial'); - - vi.advanceTimersByTime(1); - expect(debounced()).toBe('updated'); - }); - - it('resets timer on rapid changes', () => { - const [value, setValue] = createSignal('initial'); - const debounced = useDebouncedValue(value, 300); - - setValue('first'); - vi.advanceTimersByTime(200); - setValue('second'); - vi.advanceTimersByTime(200); - setValue('third'); - - expect(debounced()).toBe('initial'); - - vi.advanceTimersByTime(100); - expect(debounced()).toBe('initial'); - - vi.advanceTimersByTime(200); - expect(debounced()).toBe('third'); - }); - - it('handles different types', () => { - const [value, setValue] = createSignal(42); - const debounced = useDebouncedValue(value, 100); - - expect(debounced()).toBe(42); - - setValue(100); - vi.advanceTimersByTime(100); - - expect(debounced()).toBe(100); - }); - - it('handles objects', () => { - const [value, setValue] = createSignal<{ a: number; b?: number }>({ a: 1 }); - const debounced = useDebouncedValue(value, 100); - - expect(debounced()).toEqual({ a: 1 }); - - setValue({ a: 2, b: 2 }); - vi.advanceTimersByTime(100); - - expect(debounced()).toEqual({ a: 2, b: 2 }); - }); -}); diff --git a/frontend-modern/src/hooks/useDebouncedValue.ts b/frontend-modern/src/hooks/useDebouncedValue.ts deleted file mode 100644 index 53da585c22..0000000000 --- a/frontend-modern/src/hooks/useDebouncedValue.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createSignal, createEffect, onCleanup, Accessor } from 'solid-js'; - -/** - * Creates a debounced version of a signal value - * @param value - The signal accessor to debounce - * @param delay - Delay in milliseconds (default: 300ms) - * @returns Debounced signal accessor - */ -export function useDebouncedValue(value: Accessor, delay: number = 300): Accessor { - const [debouncedValue, setDebouncedValue] = createSignal(value()); - - createEffect(() => { - const currentValue = value(); - const timer = setTimeout(() => { - setDebouncedValue(() => currentValue); - }, delay); - - onCleanup(() => clearTimeout(timer)); - }); - - return debouncedValue; -} diff --git a/frontend-modern/src/hooks/useRecoveryPointsFacets.ts b/frontend-modern/src/hooks/useRecoveryPointsFacets.ts deleted file mode 100644 index 101d16f3cd..0000000000 --- a/frontend-modern/src/hooks/useRecoveryPointsFacets.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { Accessor, createMemo } from 'solid-js'; -import { apiFetchJSON } from '@/utils/apiClient'; -import type { RecoveryPointsFacets, RecoveryPointsFacetsResponse } from '@/types/recovery'; -import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery'; - -const RECOVERY_FACETS_URL = '/api/recovery/facets'; -const REFRESH_MS = 30_000; - -export type RecoveryFacetsQuery = { - rollupId?: string | null; - platform?: string | null; - kind?: string | null; - mode?: string | null; - outcome?: string | null; - itemType?: string | null; - - q?: string | null; - cluster?: string | null; - node?: string | null; - namespace?: string | null; - scope?: 'workload' | null; - verification?: 'verified' | 'unverified' | 'unknown' | null; - - from?: string | null; // RFC3339 - to?: string | null; // RFC3339 -}; - -const normalizeQuery = (query: RecoveryFacetsQuery | undefined): RecoveryFacetsQuery => { - const q = query || {}; - const norm = (value: string | null | undefined) => (value || '').trim(); - return { - rollupId: norm(q.rollupId) || null, - platform: norm(q.platform) || null, - kind: norm(q.kind) || null, - mode: norm(q.mode) || null, - outcome: norm(q.outcome) || null, - itemType: norm(q.itemType) || null, - - q: norm(q.q) || null, - cluster: norm(q.cluster) || null, - node: norm(q.node) || null, - namespace: norm(q.namespace) || null, - scope: q.scope || null, - verification: q.verification || null, - - from: norm(q.from) || null, - to: norm(q.to) || null, - }; -}; - -const serializeQuery = (query: RecoveryFacetsQuery | undefined): string => - JSON.stringify(normalizeQuery(query)); - -const parseSerializedQuery = (value: string | null): RecoveryFacetsQuery | undefined => { - if (value == null) return undefined; - if (value === '__all__') return undefined; - try { - return JSON.parse(value) as RecoveryFacetsQuery; - } catch { - return undefined; - } -}; - -const buildURL = (query: RecoveryFacetsQuery | undefined): string => { - const q = normalizeQuery(query); - const params = new URLSearchParams(); - - if (q.rollupId) params.set('rollupId', q.rollupId); - if (q.platform) params.set('platform', q.platform); - if (q.kind) params.set('kind', q.kind); - if (q.mode) params.set('mode', q.mode); - if (q.outcome) params.set('outcome', q.outcome); - if (q.itemType) params.set('itemType', q.itemType); - - if (q.q) params.set('q', q.q); - if (q.cluster) params.set('cluster', q.cluster); - if (q.node) params.set('node', q.node); - if (q.namespace) params.set('namespace', q.namespace); - if (q.scope) params.set('scope', q.scope); - if (q.verification) params.set('verification', q.verification); - - if (q.from) params.set('from', q.from); - if (q.to) params.set('to', q.to); - - return `${RECOVERY_FACETS_URL}?${params.toString()}`; -}; - -type RawRecoveryPointsFacets = RecoveryPointsFacets & { - nodesHosts?: string[]; -}; - -type RawRecoveryPointsFacetsResponse = { - data: RawRecoveryPointsFacets; -}; - -const normalizeFacetValues = (values: unknown): string[] => { - if (!Array.isArray(values)) return []; - return values - .map((value) => (typeof value === 'string' ? value.trim() : '')) - .filter((value) => value.length > 0); -}; - -const normalizeFacets = (facets: RawRecoveryPointsFacets): RecoveryPointsFacets => ({ - ...facets, - itemTypes: normalizeFacetValues(facets.itemTypes), - nodesAgents: normalizeFacetValues(facets.nodesAgents ?? facets.nodesHosts), -}); - -async function fetchFacets( - query: RecoveryFacetsQuery | undefined, -): Promise { - const url = buildURL(query); - const response = await apiFetchJSON(url); - return { - data: normalizeFacets(response.data || {}), - }; -} - -export function useRecoveryPointsFacets(query?: Accessor) { - const source = createMemo(() => { - if (!query) return '__all__'; - const q = query(); - if (!q) return null; - return serializeQuery(q); - }); - - const state = createNonSuspendingQuery({ - source, - cacheKey: (key) => `recovery-facets:${key}`, - fetcher: async (key) => fetchFacets(parseSerializedQuery(key)), - initialValue: { data: {} }, - pollMs: REFRESH_MS, - }); - - const facets = createMemo(() => state.value().data || {}); - - const response = { - get error() { - return state.error(); - }, - get loading() { - return state.loading(); - }, - }; - - return { - response, - facets, - refetch: state.refetch, - resolvedOnce: state.resolvedOnce, - }; -} diff --git a/frontend-modern/src/hooks/useRecoveryPointsSeries.ts b/frontend-modern/src/hooks/useRecoveryPointsSeries.ts deleted file mode 100644 index 2b4d10e180..0000000000 --- a/frontend-modern/src/hooks/useRecoveryPointsSeries.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Accessor, createMemo } from 'solid-js'; -import { apiFetchJSON } from '@/utils/apiClient'; -import type { RecoveryPointsSeriesBucket, RecoveryPointsSeriesResponse } from '@/types/recovery'; -import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery'; - -const RECOVERY_SERIES_URL = '/api/recovery/series'; -const REFRESH_MS = 30_000; - -export type RecoverySeriesQuery = { - rollupId?: string | null; - platform?: string | null; - kind?: string | null; - mode?: string | null; - outcome?: string | null; - itemType?: string | null; - - q?: string | null; - cluster?: string | null; - node?: string | null; - namespace?: string | null; - scope?: 'workload' | null; - verification?: 'verified' | 'unverified' | 'unknown' | null; - - from?: string | null; // RFC3339 - to?: string | null; // RFC3339 - - tzOffsetMinutes?: number | null; // UTC -> local offset (east positive) -}; - -const normalizeQuery = (query: RecoverySeriesQuery | undefined): RecoverySeriesQuery => { - const q = query || {}; - const norm = (value: string | null | undefined) => (value || '').trim(); - const tzOffsetMinutes = - typeof q.tzOffsetMinutes === 'number' && Number.isFinite(q.tzOffsetMinutes) - ? Math.trunc(q.tzOffsetMinutes) - : 0; - return { - rollupId: norm(q.rollupId) || null, - platform: norm(q.platform) || null, - kind: norm(q.kind) || null, - mode: norm(q.mode) || null, - outcome: norm(q.outcome) || null, - itemType: norm(q.itemType) || null, - - q: norm(q.q) || null, - cluster: norm(q.cluster) || null, - node: norm(q.node) || null, - namespace: norm(q.namespace) || null, - scope: q.scope || null, - verification: q.verification || null, - - from: norm(q.from) || null, - to: norm(q.to) || null, - - tzOffsetMinutes, - }; -}; - -const serializeQuery = (query: RecoverySeriesQuery | undefined): string => - JSON.stringify(normalizeQuery(query)); - -const parseSerializedQuery = (value: string | null): RecoverySeriesQuery | undefined => { - if (value == null) return undefined; - if (value === '__all__') return undefined; - try { - return JSON.parse(value) as RecoverySeriesQuery; - } catch { - return undefined; - } -}; - -const buildURL = (query: RecoverySeriesQuery | undefined): string => { - const q = normalizeQuery(query); - const params = new URLSearchParams(); - - if (q.rollupId) params.set('rollupId', q.rollupId); - if (q.platform) params.set('platform', q.platform); - if (q.kind) params.set('kind', q.kind); - if (q.mode) params.set('mode', q.mode); - if (q.outcome) params.set('outcome', q.outcome); - if (q.itemType) params.set('itemType', q.itemType); - - if (q.q) params.set('q', q.q); - if (q.cluster) params.set('cluster', q.cluster); - if (q.node) params.set('node', q.node); - if (q.namespace) params.set('namespace', q.namespace); - if (q.scope) params.set('scope', q.scope); - if (q.verification) params.set('verification', q.verification); - - if (q.from) params.set('from', q.from); - if (q.to) params.set('to', q.to); - - params.set('tzOffsetMinutes', String(q.tzOffsetMinutes || 0)); - - return `${RECOVERY_SERIES_URL}?${params.toString()}`; -}; - -async function fetchSeries( - query: RecoverySeriesQuery | undefined, -): Promise { - const url = buildURL(query); - return apiFetchJSON(url); -} - -export function useRecoveryPointsSeries(query?: Accessor) { - const source = createMemo(() => { - if (!query) return '__all__'; - const q = query(); - if (!q) return null; - return serializeQuery(q); - }); - - const state = createNonSuspendingQuery({ - source, - cacheKey: (key) => `recovery-series:${key}`, - fetcher: async (key) => fetchSeries(parseSerializedQuery(key)), - initialValue: { data: [] }, - pollMs: REFRESH_MS, - }); - - const series = createMemo(() => state.value().data || []); - - const response = { - get error() { - return state.error(); - }, - get loading() { - return state.loading(); - }, - }; - - return { - response, - series, - refetch: state.refetch, - resolvedOnce: state.resolvedOnce, - }; -} diff --git a/frontend-modern/src/hooks/useRecoveryRollups.ts b/frontend-modern/src/hooks/useRecoveryRollups.ts deleted file mode 100644 index 9165fd279d..0000000000 --- a/frontend-modern/src/hooks/useRecoveryRollups.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { createMemo, type Accessor } from 'solid-js'; -import { apiFetchJSON } from '@/utils/apiClient'; -import type { ProtectionRollup, RecoveryRollupsTransportResponse } from '@/types/recovery'; -import { normalizeRecoveryRollupsResponse } from '@/utils/recoveryPlatformModel'; -import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery'; - -const RECOVERY_ROLLUPS_URL = '/api/recovery/rollups'; -const PAGE_LIMIT = 500; -const MAX_PAGES = 10; // hard cap to avoid pathological DB sizes causing huge client pulls -const REFRESH_MS = 30_000; - -export type RecoveryRollupsQuery = { - rollupId?: string | null; - platform?: string | null; - kind?: string | null; - mode?: string | null; - outcome?: string | null; - itemType?: string | null; - itemResourceId?: string | null; - subjectResourceId?: string | null; - q?: string | null; - cluster?: string | null; - node?: string | null; - namespace?: string | null; - scope?: 'workload' | null; - verification?: 'verified' | 'unverified' | 'unknown' | null; - from?: string | null; - to?: string | null; -}; - -const normalizeQuery = (query: RecoveryRollupsQuery | undefined): RecoveryRollupsQuery => { - const q = query || {}; - const norm = (value: string | null | undefined) => (value || '').trim(); - return { - rollupId: norm(q.rollupId) || null, - platform: norm(q.platform) || null, - kind: norm(q.kind) || null, - mode: norm(q.mode) || null, - outcome: norm(q.outcome) || null, - itemType: norm(q.itemType) || null, - itemResourceId: norm(q.itemResourceId) || norm(q.subjectResourceId) || null, - subjectResourceId: null, - q: norm(q.q) || null, - cluster: norm(q.cluster) || null, - node: norm(q.node) || null, - namespace: norm(q.namespace) || null, - scope: q.scope || null, - verification: q.verification || null, - from: norm(q.from) || null, - to: norm(q.to) || null, - }; -}; - -const serializeQuery = (query: RecoveryRollupsQuery | undefined): string => - JSON.stringify(normalizeQuery(query)); - -const parseSerializedQuery = (value: string | null): RecoveryRollupsQuery | undefined => { - if (value == null) return undefined; - if (value === '__all__') return undefined; - try { - return JSON.parse(value) as RecoveryRollupsQuery; - } catch { - return undefined; - } -}; - -const buildURL = (page: number, limit: number, query: RecoveryRollupsQuery | undefined): string => { - const q = normalizeQuery(query); - const params = new URLSearchParams(); - params.set('page', String(page)); - params.set('limit', String(limit)); - if (q.rollupId) params.set('rollupId', q.rollupId); - if (q.platform) params.set('platform', q.platform); - if (q.kind) params.set('kind', q.kind); - if (q.mode) params.set('mode', q.mode); - if (q.outcome) params.set('outcome', q.outcome); - if (q.itemType) params.set('itemType', q.itemType); - if (q.itemResourceId) params.set('itemResourceId', q.itemResourceId); - if (q.q) params.set('q', q.q); - if (q.cluster) params.set('cluster', q.cluster); - if (q.node) params.set('node', q.node); - if (q.namespace) params.set('namespace', q.namespace); - if (q.scope) params.set('scope', q.scope); - if (q.verification) params.set('verification', q.verification); - if (q.from) params.set('from', q.from); - if (q.to) params.set('to', q.to); - return `${RECOVERY_ROLLUPS_URL}?${params.toString()}`; -}; - -async function fetchRecoveryRollups( - query: RecoveryRollupsQuery | undefined, -): Promise { - const rollups: ProtectionRollup[] = []; - - for (let page = 1; page <= MAX_PAGES; page += 1) { - const url = buildURL(page, PAGE_LIMIT, query); - const response = await apiFetchJSON(url); - const resp = normalizeRecoveryRollupsResponse(response); - rollups.push(...resp.data); - if (!resp?.meta || (resp.data || []).length < PAGE_LIMIT || page >= resp.meta.totalPages) { - break; - } - } - - return rollups; -} - -export function useRecoveryRollups(query?: () => RecoveryRollupsQuery | null | undefined) { - const source = createMemo(() => { - if (!query) return '__all__'; - const q = query(); - if (!q) return null; - return serializeQuery(q); - }); - - const state = createNonSuspendingQuery({ - source, - cacheKey: (key) => `recovery-rollups:${key}`, - fetcher: async (key) => fetchRecoveryRollups(parseSerializedQuery(key)), - initialValue: [], - pollMs: REFRESH_MS, - }); - - const rollups = (() => state.value()) as Accessor & { - readonly error: unknown; - readonly loading: boolean; - }; - Object.defineProperties(rollups, { - error: { - get: () => state.error(), - }, - loading: { - get: () => state.loading(), - }, - }); - - return { - rollups, - refetch: state.refetch, - resolvedOnce: state.resolvedOnce, - }; -} diff --git a/frontend-modern/src/utils/__tests__/clusterEndpointPresentation.test.ts b/frontend-modern/src/utils/__tests__/clusterEndpointPresentation.test.ts deleted file mode 100644 index 6b9f7c4c28..0000000000 --- a/frontend-modern/src/utils/__tests__/clusterEndpointPresentation.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - getClusterEndpointPresentation, - getClusterEndpointPulseStatus, -} from '@/utils/clusterEndpointPresentation'; - -describe('clusterEndpointPresentation', () => { - it('maps reachable online endpoints to success styling', () => { - const presentation = getClusterEndpointPresentation({ online: true, pulseReachable: true }); - expect(presentation.panelClass).toContain('border-green-200'); - expect(presentation.proxmoxLabel).toBe('Online'); - expect(presentation.pulseLabel).toBe('Reachable'); - }); - - it('maps unreachable pulse endpoints to warning styling', () => { - const presentation = getClusterEndpointPresentation({ online: true, pulseReachable: false }); - expect(presentation.panelClass).toContain('border-amber-200'); - expect(presentation.pulseLabel).toBe('Unreachable'); - }); - - it('maps online checking endpoints to informational styling', () => { - const presentation = getClusterEndpointPresentation({ online: true, pulseReachable: null }); - expect(presentation.panelClass).toContain('border-blue-200'); - expect(presentation.pulseLabel).toBe('Checking...'); - }); - - it('maps offline endpoints to muted styling', () => { - const presentation = getClusterEndpointPresentation({ online: false, pulseReachable: null }); - expect(presentation.panelClass).toContain('bg-surface-alt'); - expect(presentation.proxmoxLabel).toBe('Offline'); - }); - - it('normalizes pulse reachability to canonical statuses', () => { - expect(getClusterEndpointPulseStatus({ pulseReachable: true })).toBe('reachable'); - expect(getClusterEndpointPulseStatus({ pulseReachable: false })).toBe('unreachable'); - expect(getClusterEndpointPulseStatus({ pulseReachable: undefined })).toBe('checking'); - }); -}); diff --git a/frontend-modern/src/utils/__tests__/configuredNodeCapabilityPresentation.test.ts b/frontend-modern/src/utils/__tests__/configuredNodeCapabilityPresentation.test.ts deleted file mode 100644 index 1f850e0ab7..0000000000 --- a/frontend-modern/src/utils/__tests__/configuredNodeCapabilityPresentation.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { NodeConfigWithStatus } from '@/types/nodes'; -import { - getConfiguredNodeCapabilityBadges, - isConfiguredNodeTemperatureMonitoringEnabled, -} from '@/utils/configuredNodeCapabilityPresentation'; - -describe('configuredNodeCapabilityPresentation', () => { - it('builds canonical PVE capability badges', () => { - const node = { - type: 'pve', - monitorVMs: true, - monitorContainers: true, - monitorStorage: true, - monitorBackups: false, - monitorPhysicalDisks: true, - temperatureMonitoringEnabled: null, - } as NodeConfigWithStatus; - - expect(getConfiguredNodeCapabilityBadges(node, true).map((badge) => badge.label)).toEqual([ - 'VMs', - 'Containers', - 'Storage', - 'Physical Disks', - 'Temperature', - ]); - }); - - it('builds canonical PBS and PMG capability badges', () => { - const pbsNode = { - type: 'pbs', - monitorDatastores: true, - monitorSyncJobs: false, - monitorVerifyJobs: true, - monitorPruneJobs: true, - monitorGarbageJobs: true, - temperatureMonitoringEnabled: false, - } as NodeConfigWithStatus; - - const pmgNode = { - type: 'pmg', - monitorMailStats: true, - monitorQueues: true, - monitorQuarantine: false, - monitorDomainStats: true, - } as NodeConfigWithStatus; - - expect(getConfiguredNodeCapabilityBadges(pbsNode, true).map((badge) => badge.label)).toEqual([ - 'Datastores', - 'Verify Jobs', - 'Prune Jobs', - 'Garbage Collection', - ]); - expect(getConfiguredNodeCapabilityBadges(pmgNode, true).map((badge) => badge.label)).toEqual([ - 'Mail stats', - 'Queues', - 'Domain stats', - ]); - }); - - it('resolves temperature monitoring from node override or global setting', () => { - expect( - isConfiguredNodeTemperatureMonitoringEnabled( - { temperatureMonitoringEnabled: true } as NodeConfigWithStatus, - false, - ), - ).toBe(true); - expect( - isConfiguredNodeTemperatureMonitoringEnabled( - { temperatureMonitoringEnabled: false } as NodeConfigWithStatus, - true, - ), - ).toBe(false); - expect( - isConfiguredNodeTemperatureMonitoringEnabled( - { temperatureMonitoringEnabled: null } as NodeConfigWithStatus, - true, - ), - ).toBe(true); - }); -}); diff --git a/frontend-modern/src/utils/__tests__/configuredNodeStatusPresentation.test.ts b/frontend-modern/src/utils/__tests__/configuredNodeStatusPresentation.test.ts deleted file mode 100644 index 4b708f32b8..0000000000 --- a/frontend-modern/src/utils/__tests__/configuredNodeStatusPresentation.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - resolveConfiguredInstanceStatusIndicator, - resolveConfiguredNodeStatusIndicator, -} from '@/utils/configuredNodeStatusPresentation'; - -describe('configuredNodeStatusPresentation', () => { - it('resolves configured/live/connection state into canonical status indicators', () => { - expect( - resolveConfiguredNodeStatusIndicator({ - configuredStatus: 'connected', - }), - ).toMatchObject({ variant: 'success', label: 'Online' }); - - expect( - resolveConfiguredNodeStatusIndicator({ - configuredStatus: 'pending', - }), - ).toMatchObject({ variant: 'warning', label: 'Pending' }); - - expect( - resolveConfiguredNodeStatusIndicator({ - configuredStatus: 'connected', - connectionHealth: 'degraded', - }), - ).toMatchObject({ variant: 'warning', label: 'Degraded' }); - - expect( - resolveConfiguredNodeStatusIndicator({ - configuredStatus: 'connected', - connectionHealth: 'error', - }), - ).toMatchObject({ variant: 'danger', label: 'Offline' }); - }); - - it('resolves PBS/PMG-like instance state through the shared helper', () => { - expect( - resolveConfiguredInstanceStatusIndicator( - { name: 'pbs-1', status: 'connected' } as never, - [{ name: 'pbs-1', status: 'online', connectionHealth: 'healthy' }], - ), - ).toMatchObject({ variant: 'success', label: 'Online' }); - - expect( - resolveConfiguredInstanceStatusIndicator( - { name: 'pmg-1', status: 'connected' } as never, - [{ name: 'pmg-1', status: 'offline', connectionHealth: 'error' }], - ), - ).toMatchObject({ variant: 'danger', label: 'Offline' }); - }); -}); diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 11cd717d18..68957ceb41 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -9,18 +9,15 @@ import chartsApiSource from '@/api/charts.ts?raw'; import investigateAlertButtonSource from '@/components/Alerts/InvestigateAlertButton.tsx?raw'; import alertAssistantHandoffModelSource from '@/components/Alerts/alertAssistantHandoffModel.ts?raw'; import alertTargetTypesSource from '@/utils/alertTargetTypes.ts?raw'; -import resourceBadgesSource from '@/components/Infrastructure/resourceBadges.ts?raw'; import commandPaletteModalSource from '@/components/shared/CommandPaletteModal.tsx?raw'; import commandPaletteModelSource from '@/components/shared/commandPaletteModel.ts?raw'; import columnPickerSource from '@/components/shared/ColumnPicker.tsx?raw'; import columnPickerModelSource from '@/components/shared/columnPickerModel.ts?raw'; import tagInputSource from '@/components/shared/TagInput.tsx?raw'; import tagInputModelSource from '@/components/shared/tagInputModel.ts?raw'; -import collapsibleSearchInputSource from '@/components/shared/CollapsibleSearchInput.tsx?raw'; import collapsibleSearchInputModelSource from '@/components/shared/collapsibleSearchInputModel.ts?raw'; import containerUpdateBadgeSource from '@/components/shared/ContainerUpdateBadge.tsx?raw'; import containerUpdateBadgeModelSource from '@/components/shared/containerUpdateBadgeModel.ts?raw'; -import densityMapSource from '@/components/shared/DensityMap.tsx?raw'; import densityMapModelSource from '@/components/shared/densityMapModel.ts?raw'; import dialogSource from '@/components/shared/Dialog.tsx?raw'; import dialogModelSource from '@/components/shared/dialogModel.ts?raw'; @@ -43,7 +40,6 @@ import searchInputSource from '@/components/shared/SearchInput.tsx?raw'; import searchInputEnhancementsSource from '@/components/shared/SearchInputEnhancements.tsx?raw'; import searchInputEnhancementsModelSource from '@/components/shared/searchInputEnhancementsModel.ts?raw'; import searchInputModelSource from '@/components/shared/searchInputModel.ts?raw'; -import scrollToTopButtonSource from '@/components/shared/ScrollToTopButton.tsx?raw'; import scrollToTopButtonModelSource from '@/components/shared/scrollToTopButtonModel.ts?raw'; import statusBadgeSource from '@/components/shared/StatusBadge.tsx?raw'; import statusBadgeModelSource from '@/components/shared/statusBadgeModel.ts?raw'; @@ -138,7 +134,6 @@ import recoveryOutcomePresentationSource from '@/utils/recoveryOutcomePresentati import recoveryArtifactModePresentationSource from '@/utils/recoveryArtifactModePresentation.ts?raw'; import recoveryIssuePresentationSource from '@/utils/recoveryIssuePresentation.ts?raw'; import recoveryDatePresentationSource from '@/utils/recoveryDatePresentation.ts?raw'; -import recoveryTablePresentationSource from '@/utils/recoveryTablePresentation.ts?raw'; import recoveryTimelineChartPresentationSource from '@/utils/recoveryTimelineChartPresentation.ts?raw'; import recoveryTimelinePresentationSource from '@/utils/recoveryTimelinePresentation.ts?raw'; import workloadsSurfaceSource from '@/components/Workloads/WorkloadsSurface.tsx?raw'; @@ -214,7 +209,6 @@ import useDiskListModelSource from '@/components/Storage/useDiskListModel.ts?raw import diskDetailSource from '@/components/Storage/DiskDetail.tsx?raw'; import storageDetailKeyValueRowSource from '@/components/Storage/StorageDetailKeyValueRow.tsx?raw'; import storageDetailMetricCardSource from '@/components/Storage/StorageDetailMetricCard.tsx?raw'; -import diskLiveMetricSource from '@/components/Storage/DiskLiveMetric.tsx?raw'; import useDiskLiveMetricModelSource from '@/components/Storage/useDiskLiveMetricModel.ts?raw'; import storagePageControlsSource from '@/components/Storage/StoragePageControls.tsx?raw'; import storageContentCardSource from '@/components/Storage/StorageContentCard.tsx?raw'; @@ -225,7 +219,6 @@ import useZFSHealthMapModelSource from '@/components/Storage/useZFSHealthMapMode import storagePoolsTableSource from '@/components/Storage/StoragePoolsTable.tsx?raw'; import useStoragePoolsTableModelSource from '@/components/Storage/useStoragePoolsTableModel.ts?raw'; import storageGroupRowSource from '@/components/Storage/StorageGroupRow.tsx?raw'; -import zfsHealthMapSource from '@/components/Storage/ZFSHealthMap.tsx?raw'; import enhancedStorageBarSource from '@/components/Storage/EnhancedStorageBar.tsx?raw'; import useEnhancedStorageBarModelSource from '@/components/Storage/useEnhancedStorageBarModel.ts?raw'; import storagePoolDetailSource from '@/components/Storage/StoragePoolDetail.tsx?raw'; @@ -337,11 +330,6 @@ import zfsPresentationSource from '@/features/storageBackups/zfsPresentation.ts? import zfsHealthMapPresentationSource from '@/features/storageBackups/zfsHealthMapPresentation.ts?raw'; import storageAdaptersSource from '@/features/storageBackups/storageAdapters.ts?raw'; import deployStatusBadgeSource from '@/components/Infrastructure/deploy/DeployStatusBadge.tsx?raw'; -import deployCandidatesStepSource from '@/components/Infrastructure/deploy/CandidatesStep.tsx?raw'; -import deployConfirmStepSource from '@/components/Infrastructure/deploy/ConfirmStep.tsx?raw'; -import deployDeployingStepSource from '@/components/Infrastructure/deploy/DeployingStep.tsx?raw'; -import deployPreflightStepSource from '@/components/Infrastructure/deploy/PreflightStep.tsx?raw'; -import deployResultsStepSource from '@/components/Infrastructure/deploy/ResultsStep.tsx?raw'; import deployFlowPresentationSource from '@/utils/deployFlowPresentation.ts?raw'; import deployStatusPresentationSource from '@/utils/deployStatusPresentation.ts?raw'; import alertsPageSource from '@/pages/Alerts.tsx?raw'; @@ -436,8 +424,6 @@ import alertSeverityPresentationSource from '@/utils/alertSeverityPresentation.t import alertTabsPresentationSource from '@/utils/alertTabsPresentation.ts?raw'; import alertGroupingPresentationSource from '@/utils/alertGroupingPresentation.ts?raw'; import alertSchedulePresentationSource from '@/utils/alertSchedulePresentation.ts?raw'; -import configuredNodeCapabilityPresentationSource from '@/utils/configuredNodeCapabilityPresentation.ts?raw'; -import configuredNodeStatusPresentationSource from '@/utils/configuredNodeStatusPresentation.ts?raw'; import rbacPresentationSource from '@/utils/rbacPresentation.ts?raw'; import upgradePresentationSource from '@/utils/upgradePresentation.ts?raw'; import agentProfilesPanelSource from '@/components/Settings/AgentProfilesPanel.tsx?raw'; @@ -521,7 +507,6 @@ import modelSelectorSource from '@/components/AI/Chat/ModelSelector.tsx?raw'; import remediationStatusSource from '@/components/patrol/RemediationStatus.tsx?raw'; import remediationPresentationSource from '@/utils/remediationPresentation.ts?raw'; import aiChatPresentationSource from '@/utils/aiChatPresentation.ts?raw'; -import infrastructureDetailsDrawerSource from '@/components/shared/InfrastructureDetailsDrawer.tsx?raw'; import infrastructureDetailsDrawerModelSource from '@/components/shared/infrastructureDetailsDrawerModel.ts?raw'; import infrastructureDetailsDrawerStateSource from '@/components/shared/useInfrastructureDetailsDrawerState.ts?raw'; @@ -606,9 +591,7 @@ describe('frontend resource type boundaries', () => { expect(investigateAlertButtonSource).not.toContain('canonicalizeFrontendResourceType'); expect(alertAssistantHandoffModelSource).not.toContain('canonicalizeFrontendResourceType'); expect(alertTargetTypesSource).toContain('canonicalizeFrontendResourceType'); - expect(resourceBadgesSource).toContain('@/utils/resourceBadgePresentation'); expect(resourceBadgePresentationSource).toContain('getResourceTypePresentation'); - expect(resourceBadgesSource).not.toContain('function formatType('); expect(workloadTypePresentationSource).toContain('canonicalizeFrontendResourceType'); expect(workloadTypePresentationSource).toContain('titleCaseDelimitedLabel'); expect(workloadTypeBadgesSource).not.toContain('canonicalizeFrontendResourceType'); @@ -968,14 +951,6 @@ describe('frontend resource type boundaries', () => { 'export function getRecoveryArtifactModePresentation', ); expect(recoveryIssuePresentationSource).toContain('export function getRecoveryIssueRailClass'); - expect(recoveryTablePresentationSource).toContain('getAllFilterOptionLabel'); - expect(recoveryTablePresentationSource).toContain( - 'export function getRecoveryArtifactColumnHeaderClass', - ); - expect(recoveryTablePresentationSource).toContain('getGroupedTableRowCellClass'); - expect(recoveryTablePresentationSource).toContain('getRecoveryItemTypeLabel'); - expect(recoveryTablePresentationSource).toContain('getRecoveryLocationFacetLabel'); - expect(recoveryTablePresentationSource).not.toContain('const titleize ='); expect(recoveryDatePresentationSource).toContain( 'export function recoveryDateKeyFromTimestamp', ); @@ -995,34 +970,6 @@ describe('frontend resource type boundaries', () => { expect(recoveryTimelineChartPresentationSource).toContain( 'export function getRecoveryTimelineLabelEvery', ); - expect(recoveryTablePresentationSource).toContain( - 'export function getRecoveryArtifactRowClass', - ); - expect(recoveryTablePresentationSource).toContain( - 'export function getRecoveryEventTimeTextClass', - ); - expect(recoveryTablePresentationSource).toContain( - 'export function getRecoverySubjectTypeBadgeClass', - ); - expect(recoveryTablePresentationSource).toContain( - 'export function getRecoverySubjectTypeLabel', - ); - expect(recoveryTablePresentationSource).toContain('export function getRecoveryRollupIssueTone'); - expect(recoveryTablePresentationSource).toContain( - 'export function getRecoveryRollupAgeTextClass', - ); - expect(recoveryTablePresentationSource).toContain( - 'export function getRecoveryGroupNoTimestampLabel', - ); - expect(recoveryTablePresentationSource).toContain( - 'export function getRecoveryProtectedSearchPlaceholder', - ); - expect(recoveryTablePresentationSource).toContain( - 'export function getRecoveryHistorySearchPlaceholder', - ); - expect(recoveryTablePresentationSource).toContain( - 'export function getRecoverySearchHistoryEmptyMessage', - ); expect(recoveryTimelinePresentationSource).toContain( 'export function getRecoveryTimelineColumnButtonClass', ); @@ -1374,12 +1321,6 @@ describe('frontend resource type boundaries', () => { expect(diskDetailSource).not.toContain('class="relative"'); expect(useDiskDetailModelSource).toContain('extractPhysicalDiskPresentationData'); expect(useDiskDetailModelSource).toContain('resolvePhysicalDiskHistoryResourceId'); - expect(diskLiveMetricSource).toContain('useDiskLiveMetricModel'); - expect(diskLiveMetricSource).not.toContain('const latestMetric = createMemo(() => {'); - expect(diskLiveMetricSource).not.toContain('const formatted = createMemo(() => {'); - expect(diskLiveMetricSource).not.toContain( - "if (v > 90) return 'text-red-600 dark:text-red-400 font-bold'", - ); expect(useDiskLiveMetricModelSource).toContain('getDiskLiveMetricFormattedValue'); expect(useDiskLiveMetricModelSource).toContain('getDiskLiveMetricTextClass'); expect(storagePoolRowSource).toContain('getStoragePoolProtectionTextClass'); @@ -1485,14 +1426,6 @@ describe('frontend resource type boundaries', () => { expect(storageGroupPresentationSource).toContain( 'export const buildStorageGroupRowPresentation', ); - expect(zfsHealthMapSource).toContain('getZfsDeviceBlockClass'); - expect(zfsHealthMapSource).toContain('getZfsDeviceStateTextClass'); - expect(zfsHealthMapSource).toContain('getZfsHealthMapDeviceClass'); - expect(zfsHealthMapSource).toContain('getZfsHealthMapErrorSummaryClass'); - expect(zfsHealthMapSource).toContain('getZfsHealthMapMessageClass'); - expect(zfsHealthMapSource).toContain('useZFSHealthMapModel'); - expect(zfsHealthMapSource).not.toContain('const getDeviceColor = (device: ZFSDevice) => {'); - expect(zfsHealthMapSource).not.toContain('const isResilvering = (device: ZFSDevice) => {'); expect(enhancedStorageBarSource).toContain('getZfsPoolStateTextClass'); expect(enhancedStorageBarSource).toContain('getZfsPoolErrorOverlayClass'); expect(enhancedStorageBarSource).toContain('getZfsScanTextClass'); @@ -1704,11 +1637,6 @@ describe('frontend resource type boundaries', () => { expect(useZFSHealthMapModelSource).toContain('getZfsHealthMapDevices'); expect(useZFSHealthMapModelSource).toContain('isZfsHealthMapDeviceResilvering'); expect(useZFSHealthMapModelSource).toContain('getZfsHealthMapTooltipPresentation'); - expect(zfsHealthMapSource).toContain('ZFS_HEALTH_MAP_TOOLTIP_CARD_CLASS'); - expect(zfsHealthMapSource).not.toContain('fixed z-[9999] pointer-events-none'); - expect(zfsHealthMapSource).not.toContain( - 'bg-surface text-base-content text-[10px] rounded-md shadow-sm px-2 py-1.5 min-w-[120px] border border-border', - ); expect(zfsPresentationSource).toContain('export const getZfsPoolStateTextClass'); expect(diskPresentationSource).toContain('export function getPhysicalDiskHealthStatus'); expect(diskPresentationSource).toContain('export function getPhysicalDiskHealthSummary'); @@ -2400,13 +2328,6 @@ describe('frontend resource type boundaries', () => { expect(tagInputModelSource).toContain('getTagInputPlaceholder'); expect(tagInputModelSource).toContain('getNextTagsAfterRemove'); expect(tagInputModelSource).toContain('getTagInputRemoveTitle'); - 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('createSignal'); expect(scrollToTopButtonStateSource).toContain('onMount'); expect(scrollToTopButtonStateSource).toContain('addEventListener'); @@ -2523,12 +2444,6 @@ describe('frontend resource type boundaries', () => { expect(dialogModelSource).toContain('getDialogViewportClass'); expect(dialogModelSource).toContain('getDialogAlignmentClass'); expect(dialogModelSource).toContain('getDialogPanelClass'); - expect(collapsibleSearchInputSource).toContain('useCollapsibleSearchInputState'); - expect(collapsibleSearchInputSource).not.toContain('createSignal'); - expect(collapsibleSearchInputSource).not.toContain('useTypeToSearch'); - expect(collapsibleSearchInputSource).not.toContain( - "const triggerLabel = () => props.triggerLabel ?? 'Search'", - ); expect(collapsibleSearchInputStateSource).toContain('createSignal'); expect(collapsibleSearchInputStateSource).toContain('useTypeToSearch'); expect(collapsibleSearchInputStateSource).toContain('queueMicrotask'); @@ -2543,10 +2458,6 @@ describe('frontend resource type boundaries', () => { expect(interactiveSparklineStateSource).toContain('createSignal'); expect(interactiveSparklineModelSource).toContain('buildInteractiveSparklineChartData'); expect(interactiveSparklineModelSource).toContain('computeInteractiveSparklineHoverState'); - expect(densityMapSource).toContain('useDensityMapState'); - expect(densityMapSource).not.toContain('timeRangeToMs'); - expect(densityMapSource).not.toContain('createSignal'); - expect(densityMapSource).not.toContain('ctx.fillRect'); expect(densityMapStateSource).toContain('createSignal'); expect(densityMapStateSource).toContain('canvas.getContext'); expect(densityMapStateSource).toContain('window.addEventListener'); @@ -2606,16 +2517,6 @@ describe('frontend resource type boundaries', () => { expect(mobileNavBarModelSource).toContain('buildOrderedMobileNavUtilityTabs'); expect(mobileNavBarModelSource).toContain('getMobileNavAlertBadgeCounts'); expect(mobileNavBarModelSource).toContain('getMobileNavFadeState'); - expect(infrastructureDetailsDrawerSource).toContain('useInfrastructureDetailsDrawerState'); - expect(infrastructureDetailsDrawerSource).toContain( - 'resolveInfrastructureDetailsDrawerMetadataId', - ); - expect(infrastructureDetailsDrawerSource).toContain( - 'resolveInfrastructureDetailsDrawerDiscoveryHostname', - ); - expect(infrastructureDetailsDrawerSource).not.toContain('createSignal'); - expect(infrastructureDetailsDrawerSource).not.toContain('getInfrastructureMetadataId'); - expect(infrastructureDetailsDrawerSource).not.toContain('getInfrastructureDiscoveryHostname'); expect(infrastructureDetailsDrawerStateSource).toContain('createSignal'); expect(infrastructureDetailsDrawerStateSource).toContain( "type InfrastructureDetailsDrawerTab = 'overview' | 'discovery'", @@ -2700,7 +2601,6 @@ describe('frontend resource type boundaries', () => { expect(guestDrawerModelSource).toContain('export const getGuestDrawerBackupPresentation'); expect(workloadsUtilitySource).toContain('export const getDiscoveryResourceTypeForWorkload'); expect(workloadsUtilitySource).toContain('export const getWebInterfaceTargetLabelForWorkload'); - expect(infrastructureDetailsDrawerSource).toContain('getDiscoveryLoadingState'); expect(resourceDetailDrawerSource).toContain('getDiscoveryLoadingState'); expect(discoveryTabSource).not.toContain('const getURLSuggestionSourceLabel ='); expect(discoveryPresentationSource).toContain( @@ -2727,7 +2627,6 @@ describe('frontend resource type boundaries', () => { expect(discoveryTabSource).not.toContain('No discovery data yet'); expect(discoveryTabSource).not.toContain('Loading discovery...'); expect(guestDrawerSource).not.toContain('Loading discovery...'); - expect(infrastructureDetailsDrawerSource).not.toContain('Loading discovery...'); expect(resourceDetailDrawerSource).not.toContain('Loading discovery...'); expect(discoveryTabSource).not.toContain('No suggested URL available'); expect(discoveryTabSource).not.toContain( @@ -2900,20 +2799,7 @@ describe('frontend resource type boundaries', () => { expect(storageDomainSource).toContain('export const getCephPoolsSearchEmptyStatePresentation'); expect(deployStatusBadgeSource).toContain('getDeployStatusPresentation'); expect(deployStatusBadgeSource).not.toContain('const statusConfig: Record { expect(alertSchedulePresentationSource).toContain( 'export function getAlertQuietSuppressCheckboxClass', ); - expect(configuredNodeCapabilityPresentationSource).toContain( - 'export function getConfiguredNodeCapabilityBadges', - ); - expect(configuredNodeStatusPresentationSource).toContain( - 'export function resolveConfiguredNodeStatusIndicator', - ); - expect(configuredNodeStatusPresentationSource).toContain( - 'export function resolveConfiguredPveNodeStatusIndicator', - ); - expect(configuredNodeStatusPresentationSource).toContain( - 'export function resolveConfiguredInstanceStatusIndicator', - ); expect(ssoProvidersPanelSource).toContain('@/utils/ssoProviderPresentation'); expect(ssoProvidersPanelSource).toContain('@/components/Settings/useSSOProvidersState'); expect(ssoProvidersPanelSource).toContain('getSSOProviderTypeLabel'); diff --git a/frontend-modern/src/utils/__tests__/recoveryTablePresentation.test.ts b/frontend-modern/src/utils/__tests__/recoveryTablePresentation.test.ts deleted file mode 100644 index 14db5aa81f..0000000000 --- a/frontend-modern/src/utils/__tests__/recoveryTablePresentation.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { ProtectionRollup, RecoveryPoint } from '@/types/recovery'; -import { - getRecoveryArtifactColumnLabel, - getRecoveryArtifactColumnHeaderClass, - getRecoveryArtifactRowClass, - getRecoveryArtifactTableMinWidth, - getRecoveryAllHistoryLabel, - getRecoveryAllItemTypesLabel, - getRecoveryAllPlatformsLabel, - getRecoveryAnyItemLabel, - getRecoveryEventTimeTextClass, - getRecoveryGroupNoTimestampLabel, - getRecoveryHistorySearchPlaceholder, - getRecoveryPointItemTypeBadgeClass, - getRecoveryPointItemTypeLabel, - getRecoveryProtectedSearchPlaceholder, - getRecoveryRollupAgeTextClass, - getRecoveryRollupInventoryStatus, - getRecoveryRollupIssueTone, - getRecoverySearchHistoryEmptyMessage, - isRecoveryRollupStale, - RECOVERY_ADVANCED_FILTER_FIELD_CLASS, - RECOVERY_ADVANCED_FILTER_LABEL_CLASS, - RECOVERY_ALL_HISTORY_LABEL, - RECOVERY_ALL_ITEM_TYPES_LABEL, - RECOVERY_ALL_PLATFORMS_LABEL, - RECOVERY_ANY_ITEM_LABEL, - RECOVERY_ARTIFACT_COLUMN_LABELS, - RECOVERY_GROUP_HEADER_ROW_CLASS, - RECOVERY_GROUP_HEADER_TEXT_CLASS, - RECOVERY_GROUP_NO_TIMESTAMP_LABEL, - RECOVERY_HISTORY_SEARCH_PLACEHOLDER, - RECOVERY_PROTECTED_SEARCH_PLACEHOLDER, - RECOVERY_SEARCH_HISTORY_EMPTY_MESSAGE, -} from '@/utils/recoveryTablePresentation'; - -describe('recoveryTablePresentation', () => { - it('exposes shared recovery table classes', () => { - expect(RECOVERY_GROUP_HEADER_ROW_CLASS).toContain('grouped-table-row'); - expect(RECOVERY_GROUP_HEADER_TEXT_CLASS).toContain('font-semibold'); - expect(RECOVERY_ADVANCED_FILTER_LABEL_CLASS).toContain('text-muted'); - expect(RECOVERY_ADVANCED_FILTER_FIELD_CLASS).toContain('focus:border-blue-500'); - expect(RECOVERY_GROUP_NO_TIMESTAMP_LABEL).toBe('No Timestamp'); - expect(RECOVERY_PROTECTED_SEARCH_PLACEHOLDER).toBe('Search protection coverage...'); - expect(RECOVERY_HISTORY_SEARCH_PLACEHOLDER).toBe( - 'Search recovery history or enter a date...', - ); - expect(RECOVERY_SEARCH_HISTORY_EMPTY_MESSAGE).toBe('Recent searches appear here.'); - expect(RECOVERY_ALL_HISTORY_LABEL).toBe('All history'); - expect(RECOVERY_ALL_ITEM_TYPES_LABEL).toBe('All item types'); - expect(RECOVERY_ALL_PLATFORMS_LABEL).toBe('All platforms'); - expect(RECOVERY_ANY_ITEM_LABEL).toBe('Any item'); - expect(getRecoveryGroupNoTimestampLabel()).toBe('No Timestamp'); - expect(getRecoveryProtectedSearchPlaceholder()).toBe('Search protection coverage...'); - expect(getRecoveryHistorySearchPlaceholder()).toBe( - 'Search recovery history or enter a date...', - ); - expect(getRecoverySearchHistoryEmptyMessage()).toBe('Recent searches appear here.'); - expect(getRecoveryAllHistoryLabel()).toBe('All history'); - expect(getRecoveryAllItemTypesLabel()).toBe('All item types'); - expect(getRecoveryAllPlatformsLabel()).toBe('All platforms'); - expect(getRecoveryAnyItemLabel()).toBe('Any item'); - expect(RECOVERY_ARTIFACT_COLUMN_LABELS.type).toBe('Item Type'); - expect(RECOVERY_ARTIFACT_COLUMN_LABELS.item).toBe('Item'); - expect(RECOVERY_ARTIFACT_COLUMN_LABELS.platform).toBe('Platform'); - expect(RECOVERY_ARTIFACT_COLUMN_LABELS.cluster).toBe('Cluster / Site'); - expect(RECOVERY_ARTIFACT_COLUMN_LABELS.nodeAgent).toBe('Host / Agent'); - expect(RECOVERY_ARTIFACT_COLUMN_LABELS.namespace).toBe('Namespace / Group'); - expect(getRecoveryArtifactColumnLabel('type', 'Type')).toBe('Item Type'); - expect(getRecoveryArtifactColumnLabel('item', 'Item')).toBe('Item'); - expect(getRecoveryArtifactColumnLabel('platform', 'Platform')).toBe('Platform'); - expect(getRecoveryArtifactColumnLabel('cluster', 'Cluster')).toBe('Cluster / Site'); - expect(getRecoveryArtifactColumnLabel('nodeAgent', 'Node')).toBe('Host / Agent'); - expect(getRecoveryArtifactColumnLabel('namespace', 'Namespace')).toBe('Namespace / Group'); - expect(getRecoveryArtifactColumnLabel('outcome', 'Outcome')).toBe('Outcome'); - expect(getRecoveryArtifactColumnLabel('subject', 'Subject')).toBe('Subject'); - expect(getRecoveryArtifactColumnLabel('source', 'Source')).toBe('Source'); - }); - - it('derives time text classes from event recency', () => { - const now = Date.UTC(2026, 2, 9, 12, 0, 0); - expect(getRecoveryEventTimeTextClass(now - 2 * 60 * 60 * 1000, now)).toContain( - 'text-emerald-600', - ); - expect(getRecoveryEventTimeTextClass(now - 3 * 24 * 60 * 60 * 1000, now)).toContain( - 'text-amber-600', - ); - expect(getRecoveryEventTimeTextClass(now - 14 * 24 * 60 * 60 * 1000, now)).toContain( - 'text-orange-600', - ); - expect(getRecoveryEventTimeTextClass(0, now)).toBe('text-muted'); - }); - - it('derives workload subject badges from canonical workload presentation', () => { - const point = { - display: { subjectType: 'proxmox-vm' }, - subjectRef: { type: 'proxmox-vm' }, - } as RecoveryPoint; - - expect(getRecoveryPointItemTypeLabel(point)).toBe('VM'); - expect(getRecoveryPointItemTypeBadgeClass(point)).toContain('bg-blue-100'); - expect(getRecoveryPointItemTypeBadgeClass(point)).toContain('text-blue-700'); - expect(getRecoveryPointItemTypeBadgeClass(point)).toContain('px-1 py-0.5'); - }); - - it('normalizes proxmox lxc subjects to the canonical LXC badge', () => { - const point = { - display: { subjectType: 'proxmox-lxc' }, - subjectRef: { type: 'proxmox-lxc' }, - } as RecoveryPoint; - - expect(getRecoveryPointItemTypeLabel(point)).toBe('LXC'); - expect(getRecoveryPointItemTypeBadgeClass(point)).toContain('bg-green-100'); - expect(getRecoveryPointItemTypeBadgeClass(point)).toContain('text-green-700'); - expect(getRecoveryPointItemTypeBadgeClass(point)).toContain('px-1 py-0.5'); - }); - - it('falls back cleanly for unknown subject types', () => { - const point = { - display: { subjectType: 'custom-thing' }, - subjectRef: { type: 'custom-thing' }, - } as RecoveryPoint; - - expect(getRecoveryPointItemTypeLabel(point)).toBe('Custom Thing'); - expect(getRecoveryPointItemTypeBadgeClass(point)).toContain('bg-surface-alt'); - expect(getRecoveryPointItemTypeBadgeClass(point)).toContain('text-base-content'); - }); - - it('derives artifact header and row classes', () => { - expect(getRecoveryArtifactColumnHeaderClass('time')).toContain('text-right'); - expect(getRecoveryArtifactColumnHeaderClass('type')).toContain('w-[96px]'); - expect(getRecoveryArtifactColumnHeaderClass('item')).toContain('w-[248px]'); - expect(getRecoveryArtifactColumnHeaderClass('subject')).toBe(''); - expect(getRecoveryArtifactTableMinWidth(['time', 'item', 'outcome'])).toBe('980px'); - expect( - getRecoveryArtifactTableMinWidth([ - 'time', - 'type', - 'item', - 'platform', - 'verified', - 'size', - 'method', - 'repository', - 'details', - 'outcome', - ]), - ).toBe('1198px'); - expect(getRecoveryArtifactRowClass(true)).toContain('outline-blue-200/80'); - expect(getRecoveryArtifactRowClass(false)).toBe('hover:bg-surface-hover'); - }); - - it('derives rollup issue tone, stale state, and age class from outcome and timing', () => { - const now = Date.UTC(2026, 2, 9, 12, 0, 0); - const failed = { - lastOutcome: 'failed', - lastSuccessAt: '2026-03-09T10:00:00.000Z', - } as ProtectionRollup; - const stale = { - lastOutcome: 'success', - lastSuccessAt: '2026-02-28T12:00:00.000Z', - } as ProtectionRollup; - const aging = { - lastOutcome: 'success', - lastSuccessAt: '2026-03-06T12:00:00.000Z', - } as ProtectionRollup; - - expect(getRecoveryRollupIssueTone(failed, now)).toBe('rose'); - expect(getRecoveryRollupIssueTone(stale, now)).toBe('amber'); - expect(getRecoveryRollupInventoryStatus(failed, now)).toBe('failed'); - expect(getRecoveryRollupInventoryStatus(stale, now)).toBe('stale'); - expect(getRecoveryRollupInventoryStatus(aging, now)).toBe('healthy'); - expect(isRecoveryRollupStale(stale, now)).toBe(true); - expect(getRecoveryRollupAgeTextClass(stale, now)).toContain('text-rose-700'); - expect(getRecoveryRollupAgeTextClass(aging, now)).toContain('text-amber-700'); - }); -}); diff --git a/frontend-modern/src/utils/clusterEndpointPresentation.ts b/frontend-modern/src/utils/clusterEndpointPresentation.ts deleted file mode 100644 index 907c853665..0000000000 --- a/frontend-modern/src/utils/clusterEndpointPresentation.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { ClusterEndpoint } from '@/types/nodes'; - -export type ClusterEndpointPulseStatus = 'reachable' | 'unreachable' | 'checking'; - -export interface ClusterEndpointPresentation { - panelClass: string; - proxmoxLabel: 'Online' | 'Offline'; - pulseLabel: 'Reachable' | 'Unreachable' | 'Checking...'; - pulseStatus: ClusterEndpointPulseStatus; -} - -export function getClusterEndpointPulseStatus( - endpoint: Pick, -): ClusterEndpointPulseStatus { - if (endpoint.pulseReachable === null || endpoint.pulseReachable === undefined) return 'checking'; - return endpoint.pulseReachable ? 'reachable' : 'unreachable'; -} - -export function getClusterEndpointPresentation( - endpoint: Pick, -): ClusterEndpointPresentation { - const pulseStatus = getClusterEndpointPulseStatus(endpoint); - - if (endpoint.online && pulseStatus === 'reachable') { - return { - panelClass: - 'border-green-200 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-900 dark:text-green-300', - proxmoxLabel: 'Online', - pulseLabel: 'Reachable', - pulseStatus, - }; - } - - if (pulseStatus === 'unreachable') { - return { - panelClass: - 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-300', - proxmoxLabel: endpoint.online ? 'Online' : 'Offline', - pulseLabel: 'Unreachable', - pulseStatus, - }; - } - - if (endpoint.online) { - return { - panelClass: - 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-300', - proxmoxLabel: 'Online', - pulseLabel: 'Checking...', - pulseStatus, - }; - } - - return { - panelClass: 'border-border bg-surface-alt text-muted', - proxmoxLabel: 'Offline', - pulseLabel: pulseStatus === 'reachable' ? 'Reachable' : 'Checking...', - pulseStatus, - }; -} diff --git a/frontend-modern/src/utils/configuredNodeCapabilityPresentation.ts b/frontend-modern/src/utils/configuredNodeCapabilityPresentation.ts deleted file mode 100644 index 6b5449a9e0..0000000000 --- a/frontend-modern/src/utils/configuredNodeCapabilityPresentation.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { - NodeConfigWithStatus, - PBSNodeConfig, - PMGNodeConfig, - PVENodeConfig, -} from '@/types/nodes'; - -export interface ConfiguredNodeCapabilityBadge { - label: string; - className: string; -} - -const DEFAULT_CAPABILITY_BADGE_CLASS = - 'text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded'; -const TEMPERATURE_CAPABILITY_BADGE_CLASS = - 'text-xs px-2 py-1 bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded'; - -type PVENodeWithStatus = NodeConfigWithStatus & PVENodeConfig & { type: 'pve' }; -type PBSNodeWithStatus = NodeConfigWithStatus & PBSNodeConfig & { type: 'pbs' }; -type PMGNodeWithStatus = NodeConfigWithStatus & PMGNodeConfig & { type: 'pmg' }; - -function isPVENode(node: NodeConfigWithStatus): node is PVENodeWithStatus { - return node.type === 'pve' && 'monitorVMs' in node; -} - -function isPBSNode(node: NodeConfigWithStatus): node is PBSNodeWithStatus { - return node.type === 'pbs' && 'monitorDatastores' in node; -} - -function isPMGNode(node: NodeConfigWithStatus): node is PMGNodeWithStatus { - return node.type === 'pmg' && 'monitorMailStats' in node; -} - -export function isConfiguredNodeTemperatureMonitoringEnabled( - node: NodeConfigWithStatus, - globalEnabled: boolean, -): boolean { - if ( - node.temperatureMonitoringEnabled !== undefined && - node.temperatureMonitoringEnabled !== null - ) { - return node.temperatureMonitoringEnabled; - } - return globalEnabled; -} - -export function getConfiguredNodeCapabilityBadges( - node: NodeConfigWithStatus, - globalTemperatureMonitoringEnabled: boolean, -): ConfiguredNodeCapabilityBadge[] { - const badges: ConfiguredNodeCapabilityBadge[] = []; - - if (isPVENode(node)) { - if (node.monitorVMs) badges.push({ label: 'VMs', className: DEFAULT_CAPABILITY_BADGE_CLASS }); - if (node.monitorContainers) { - badges.push({ label: 'Containers', className: DEFAULT_CAPABILITY_BADGE_CLASS }); - } - if (node.monitorStorage) { - badges.push({ label: 'Storage', className: DEFAULT_CAPABILITY_BADGE_CLASS }); - } - if (node.monitorBackups) { - badges.push({ label: 'Recovery', className: DEFAULT_CAPABILITY_BADGE_CLASS }); - } - if (node.monitorPhysicalDisks) { - badges.push({ label: 'Physical Disks', className: DEFAULT_CAPABILITY_BADGE_CLASS }); - } - if (isConfiguredNodeTemperatureMonitoringEnabled(node, globalTemperatureMonitoringEnabled)) { - badges.push({ label: 'Temperature', className: TEMPERATURE_CAPABILITY_BADGE_CLASS }); - } - return badges; - } - - if (isPBSNode(node)) { - if (node.monitorDatastores) { - badges.push({ label: 'Datastores', className: DEFAULT_CAPABILITY_BADGE_CLASS }); - } - if (node.monitorSyncJobs) { - badges.push({ label: 'Sync Jobs', className: DEFAULT_CAPABILITY_BADGE_CLASS }); - } - if (node.monitorVerifyJobs) { - badges.push({ label: 'Verify Jobs', className: DEFAULT_CAPABILITY_BADGE_CLASS }); - } - if (node.monitorPruneJobs) { - badges.push({ label: 'Prune Jobs', className: DEFAULT_CAPABILITY_BADGE_CLASS }); - } - if (node.monitorGarbageJobs) { - badges.push({ label: 'Garbage Collection', className: DEFAULT_CAPABILITY_BADGE_CLASS }); - } - if (isConfiguredNodeTemperatureMonitoringEnabled(node, globalTemperatureMonitoringEnabled)) { - badges.push({ label: 'Temperature', className: TEMPERATURE_CAPABILITY_BADGE_CLASS }); - } - return badges; - } - - if (isPMGNode(node)) { - if (node.monitorMailStats) { - badges.push({ label: 'Mail stats', className: DEFAULT_CAPABILITY_BADGE_CLASS }); - } - if (node.monitorQueues) { - badges.push({ label: 'Queues', className: DEFAULT_CAPABILITY_BADGE_CLASS }); - } - if (node.monitorQuarantine) { - badges.push({ label: 'Quarantine', className: DEFAULT_CAPABILITY_BADGE_CLASS }); - } - if (node.monitorDomainStats) { - badges.push({ label: 'Domain stats', className: DEFAULT_CAPABILITY_BADGE_CLASS }); - } - } - - return badges; -} diff --git a/frontend-modern/src/utils/configuredNodeStatusPresentation.ts b/frontend-modern/src/utils/configuredNodeStatusPresentation.ts deleted file mode 100644 index 9817cb2147..0000000000 --- a/frontend-modern/src/utils/configuredNodeStatusPresentation.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { unwrap } from 'solid-js/store'; -import type { Resource } from '@/types/resource'; -import type { NodeConfigWithStatus } from '@/types/nodes'; -import { - getSimpleStatusIndicator, - type StatusIndicator, -} from '@/utils/status'; - -export function resolveConfiguredNodeStatusIndicator(options: { - configuredStatus?: string | null; - liveStatus?: string | null; - connectionHealth?: string | null; -}): StatusIndicator { - if ( - options.connectionHealth === 'unhealthy' || - options.connectionHealth === 'error' || - options.liveStatus === 'offline' || - options.liveStatus === 'disconnected' - ) { - return getSimpleStatusIndicator('offline'); - } - if (options.connectionHealth === 'degraded') { - return getSimpleStatusIndicator('degraded'); - } - if (options.liveStatus === 'online' || options.connectionHealth === 'healthy') { - return getSimpleStatusIndicator('online'); - } - - switch (options.configuredStatus) { - case 'connected': - return getSimpleStatusIndicator('online'); - case 'pending': - return getSimpleStatusIndicator('pending'); - case 'disconnected': - case 'offline': - case 'error': - return getSimpleStatusIndicator('offline'); - default: - return getSimpleStatusIndicator('unknown'); - } -} - -export function resolveConfiguredPveNodeStatusIndicator( - node: NodeConfigWithStatus, - stateNodes: Resource[], -): StatusIndicator { - const stateNode = stateNodes.find((n) => n.platformId === node.name || n.name === node.name); - const platformData = stateNode?.platformData - ? (unwrap(stateNode.platformData) as Record) - : undefined; - - return resolveConfiguredNodeStatusIndicator({ - configuredStatus: node.status, - liveStatus: stateNode?.status, - connectionHealth: platformData?.connectionHealth as string | undefined, - }); -} - -export function resolveConfiguredInstanceStatusIndicator( - node: Pick, - instances: Array<{ - name?: string | null; - status?: string | null; - connectionHealth?: string | null; - }>, -): StatusIndicator { - const instance = instances.find((item) => item.name === node.name); - return resolveConfiguredNodeStatusIndicator({ - configuredStatus: node.status, - liveStatus: instance?.status, - connectionHealth: instance?.connectionHealth, - }); -} diff --git a/frontend-modern/src/utils/recoveryTablePresentation.ts b/frontend-modern/src/utils/recoveryTablePresentation.ts deleted file mode 100644 index 2fcd69160f..0000000000 --- a/frontend-modern/src/utils/recoveryTablePresentation.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { - getGroupedTableRowCellClass, - getGroupedTableRowClass, -} from '@/components/shared/groupedTableRowPresentation'; -import { getAllFilterOptionLabel } from '@/components/shared/filterOptionPresentation'; -import type { ProtectionRollup, RecoveryOutcome, RecoveryPoint } from '@/types/recovery'; -import { - getRecoveryItemTypeBadgeClass, - getRecoveryItemTypeLabel, - getRecoveryPointItemTypeKey, -} from '@/utils/recoveryItemTypePresentation'; -import { getRecoveryLocationFacetLabel } from '@/utils/recoveryLocationPresentation'; -import { - getRecoveryOutcomeTextClass, - normalizeRecoveryOutcome, -} from '@/utils/recoveryOutcomePresentation'; -import type { RecoveryIssueTone } from '@/utils/recoveryIssuePresentation'; - -export const STALE_ISSUE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; -export const AGING_THRESHOLD_MS = 2 * 24 * 60 * 60 * 1000; - -export const RECOVERY_GROUP_HEADER_ROW_CLASS = getGroupedTableRowClass(); -export const RECOVERY_GROUP_HEADER_TEXT_CLASS = getGroupedTableRowCellClass(); -export const RECOVERY_ADVANCED_FILTER_LABEL_CLASS = 'text-[11px] font-medium text-muted'; -export const RECOVERY_ADVANCED_FILTER_FIELD_CLASS = - 'min-h-[2.25rem] w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm text-base-content outline-none focus:border-blue-500'; -export const RECOVERY_GROUP_NO_TIMESTAMP_LABEL = 'No Timestamp'; -export const RECOVERY_PROTECTED_SEARCH_PLACEHOLDER = 'Search protection coverage...'; -export const RECOVERY_HISTORY_SEARCH_PLACEHOLDER = 'Search recovery history or enter a date...'; -export const RECOVERY_SEARCH_HISTORY_EMPTY_MESSAGE = 'Recent searches appear here.'; -export const RECOVERY_ALL_HISTORY_LABEL = getAllFilterOptionLabel('history'); -export const RECOVERY_ALL_ITEM_TYPES_LABEL = getAllFilterOptionLabel('item types'); -export const RECOVERY_ALL_PLATFORMS_LABEL = getAllFilterOptionLabel('platforms'); -export const RECOVERY_ANY_ITEM_LABEL = 'Any item'; -export const RECOVERY_ARTIFACT_METADATA_TEXT_CLASS = 'text-[11px] font-medium text-base-content/80'; -export const RECOVERY_ARTIFACT_COLUMN_LABELS: Record = { - cluster: getRecoveryLocationFacetLabel('cluster'), - nodeAgent: getRecoveryLocationFacetLabel('node'), - namespace: getRecoveryLocationFacetLabel('namespace'), - type: 'Item Type', - item: 'Item', - platform: 'Platform', -}; - -export type RecoveryRollupInventoryStatus = - | 'healthy' - | 'warning' - | 'failed' - | 'running' - | 'stale' - | 'never-succeeded' - | 'unknown'; - -const RECOVERY_ARTIFACT_COLUMN_SPECS: Record = - { - time: { headerClass: 'w-[76px] text-right', minWidthPx: 76 }, - type: { headerClass: 'w-[96px] text-center', minWidthPx: 96 }, - item: { headerClass: 'w-[248px]', minWidthPx: 248 }, - platform: { headerClass: 'w-[78px] text-center', minWidthPx: 78 }, - entityId: { headerClass: 'w-[84px]', minWidthPx: 84 }, - cluster: { headerClass: 'w-[120px]', minWidthPx: 120 }, - nodeAgent: { headerClass: 'w-[120px]', minWidthPx: 120 }, - namespace: { headerClass: 'w-[120px]', minWidthPx: 120 }, - verified: { headerClass: 'w-[56px] text-center', minWidthPx: 56 }, - size: { headerClass: 'w-[92px] text-right', minWidthPx: 92 }, - method: { headerClass: 'w-[84px] text-center', minWidthPx: 84 }, - repository: { headerClass: 'w-[160px]', minWidthPx: 160 }, - details: { headerClass: 'w-[220px]', minWidthPx: 220 }, - outcome: { headerClass: 'w-[88px] text-center', minWidthPx: 88 }, - }; - -export function getRecoveryEventTimeTextClass( - timestamp: number, - nowMs: number = Date.now(), -): string { - if (!timestamp) return 'text-muted'; - const ageMs = nowMs - timestamp; - if (ageMs <= 24 * 60 * 60 * 1000) return 'text-emerald-600 dark:text-emerald-400 font-medium'; - if (ageMs <= 7 * 24 * 60 * 60 * 1000) return 'text-amber-600 dark:text-amber-400 font-medium'; - if (ageMs <= 30 * 24 * 60 * 60 * 1000) return 'text-orange-600 dark:text-orange-400'; - return 'text-muted'; -} - -export function getRecoveryGroupNoTimestampLabel(): string { - return RECOVERY_GROUP_NO_TIMESTAMP_LABEL; -} - -export function getRecoveryProtectedSearchPlaceholder(): string { - return RECOVERY_PROTECTED_SEARCH_PLACEHOLDER; -} - -export function getRecoveryHistorySearchPlaceholder(): string { - return RECOVERY_HISTORY_SEARCH_PLACEHOLDER; -} - -export function getRecoverySearchHistoryEmptyMessage(): string { - return RECOVERY_SEARCH_HISTORY_EMPTY_MESSAGE; -} - -export function getRecoveryAllItemTypesLabel(): string { - return RECOVERY_ALL_ITEM_TYPES_LABEL; -} - -export function getRecoveryAllHistoryLabel(): string { - return RECOVERY_ALL_HISTORY_LABEL; -} - -export function getRecoveryAllPlatformsLabel(): string { - return RECOVERY_ALL_PLATFORMS_LABEL; -} - -export function getRecoveryAnyItemLabel(): string { - return RECOVERY_ANY_ITEM_LABEL; -} - -export function getRecoveryArtifactColumnLabel(id: string, fallback?: string): string { - return RECOVERY_ARTIFACT_COLUMN_LABELS[id] || fallback || id; -} - -export function getRecoveryPointItemTypeBadgeClass(point: RecoveryPoint): string { - return getRecoveryItemTypeBadgeClass(getRecoveryPointItemTypeKey(point)); -} - -export function getRecoveryPointItemTypeLabel(point: RecoveryPoint): string { - return getRecoveryItemTypeLabel(getRecoveryPointItemTypeKey(point)); -} - -export function getRecoveryArtifactMetadataTextClass( - tone: 'default' | 'muted' = 'default', -): string { - return tone === 'muted' - ? `${RECOVERY_ARTIFACT_METADATA_TEXT_CLASS} text-muted` - : RECOVERY_ARTIFACT_METADATA_TEXT_CLASS; -} - -export function getRecoveryArtifactOutcomeTextClass(outcome: RecoveryOutcome): string { - return `text-[11px] font-medium ${getRecoveryOutcomeTextClass(outcome)}`; -} - -export function getRecoverySubjectTypeBadgeClass(point: RecoveryPoint): string { - return getRecoveryPointItemTypeBadgeClass(point); -} - -export function getRecoverySubjectTypeLabel(point: RecoveryPoint): string { - return getRecoveryPointItemTypeLabel(point); -} - -export function getRecoveryArtifactColumnHeaderClass(id: string): string { - return RECOVERY_ARTIFACT_COLUMN_SPECS[id]?.headerClass || ''; -} - -export function getRecoveryArtifactTableMinWidth(columnIds: readonly string[]): string { - const totalWidth = columnIds.reduce( - (sum, id) => sum + (RECOVERY_ARTIFACT_COLUMN_SPECS[id]?.minWidthPx || 140), - 0, - ); - return `${Math.max(980, totalWidth)}px`; -} - -export function getRecoveryArtifactRowClass(selected: boolean): string { - return selected - ? 'bg-blue-50/40 dark:bg-blue-900/20 outline outline-1 -outline-offset-1 outline-blue-200/80 dark:outline-blue-800/80' - : 'hover:bg-surface-hover'; -} - -export function getRecoveryRollupTimestampMs(rollup: ProtectionRollup): number { - const raw = rollup.lastSuccessAt || rollup.lastAttemptAt || ''; - const ms = raw ? Date.parse(raw) : 0; - return Number.isFinite(ms) ? ms : 0; -} - -export function isRecoveryRollupStale(rollup: ProtectionRollup, nowMs: number): boolean { - const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0; - if (Number.isFinite(successMs) && successMs > 0) - return nowMs - successMs >= STALE_ISSUE_THRESHOLD_MS; - const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0; - if (Number.isFinite(attemptMs) && attemptMs > 0) - return nowMs - attemptMs >= STALE_ISSUE_THRESHOLD_MS; - return false; -} - -export function getRecoveryRollupIssueTone( - rollup: ProtectionRollup, - nowMs: number, -): RecoveryIssueTone { - const status = getRecoveryRollupInventoryStatus(rollup, nowMs); - if (status === 'failed' || status === 'never-succeeded') return 'rose'; - if (status === 'running') return 'blue'; - if (status === 'warning' || status === 'stale') return 'amber'; - return 'none'; -} - -export function getRecoveryRollupInventoryStatus( - rollup: ProtectionRollup, - nowMs: number = Date.now(), -): RecoveryRollupInventoryStatus { - const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0; - const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0; - const neverSucceeded = - (!Number.isFinite(successMs) || successMs <= 0) && Number.isFinite(attemptMs) && attemptMs > 0; - const outcome: RecoveryOutcome = normalizeRecoveryOutcome(rollup.lastOutcome); - - if (neverSucceeded) return 'never-succeeded'; - if (outcome === 'failed') return 'failed'; - if (isRecoveryRollupStale(rollup, nowMs)) return 'stale'; - if (outcome === 'warning') return 'warning'; - if (outcome === 'running') return 'running'; - if (outcome === 'success') return 'healthy'; - return 'unknown'; -} - -export function getRecoveryRollupInventoryPriority( - rollup: ProtectionRollup, - nowMs: number = Date.now(), -): number { - switch (getRecoveryRollupInventoryStatus(rollup, nowMs)) { - case 'never-succeeded': - return 6; - case 'failed': - return 5; - case 'stale': - return 4; - case 'warning': - return 3; - case 'running': - return 2; - case 'healthy': - return 1; - default: - return 0; - } -} - -export function getRecoveryRollupAgeTextClass(rollup: ProtectionRollup, nowMs: number): string { - const ts = getRecoveryRollupTimestampMs(rollup); - if (!ts || ts <= 0) return 'text-muted'; - const ageMs = nowMs - ts; - if (ageMs >= STALE_ISSUE_THRESHOLD_MS) return 'text-rose-700 dark:text-rose-300'; - if (ageMs >= AGING_THRESHOLD_MS) return 'text-amber-700 dark:text-amber-300'; - return 'text-muted'; -}
- The source agent will SSH to other nodes to install Pulse agents. -
- For nodes that failed SSH-based deployment, you can install the agent manually by - SSHing into the node and running: -
{installCommand()}