Delete more orphans: deploy wizard steps, storage diagnostics, recovery hooks

Eleventh round of orphan deletion. The platform-first IA migration
left many UI components and presentation utilities behind whose only
consumers were already-deleted aggregate surfaces, deploy wizard, or
recovery pages.

Storage:
- ZFSHealthMap.tsx — diagnostic indicator only used by the deleted
  Storage standalone summary
- DiskLiveMetric.tsx — live metric bar only used by deleted summary
- storageSourceOptions.ts + test — options helper for deleted controls

Infrastructure:
- resourceBadges.ts + test — badge helpers only used by deleted
  ConfiguredNodeTables / MonitoredSystemLedgerPanel
- deploy/CandidatesStep, ConfirmStep, DeployingStep, PreflightStep,
  ResultsStep + their tests — the deploy wizard retired with
  AgentDeployModal

Shared:
- DensityMap, SparklineSkeleton, InfrastructureDetailsDrawer (and
  tests) — primitives only consumed by deleted summary chrome
- CollapsibleSearchInput.tsx — collapsing search variant with no
  current consumer
- ScrollToTopButton.tsx + test — only used by deleted WorkloadsSurface
  chrome

Utils:
- clusterEndpointPresentation, recoveryTablePresentation,
  configuredNodeCapabilityPresentation, configuredNodeStatusPresentation
  (with tests) — all consumed only by deleted surfaces

Hooks:
- useRecoveryPointsSeries / useRecoveryPointsFacets /
  useRecoveryRollups — recovery hooks for the retired aggregate surface
- useDebouncedValue + test — generic debounce with no consumer

Test files:
- DeployStepComponents.test.tsx — entire file tested deleted wizard
- SearchInput.test.tsx — dropped 3 tests that exercised
  CollapsibleSearchInput plus the now-unused focusActiveTypeToSearch
  import

Strips ?raw guardrail imports + assertions for every deleted file from
SharedPrimitives.guardrails and frontendResourceTypeBoundaries.
This commit is contained in:
rcourtman
2026-05-26 19:58:15 +01:00
parent ae65c5e9a2
commit c26665a63a
41 changed files with 0 additions and 6629 deletions
@@ -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 }) => (
<span data-testid={`badge-${props.status}`}>{props.status}</span>
),
}));
vi.mock('lucide-solid/icons/check', () => ({ default: () => <span /> }));
vi.mock('lucide-solid/icons/alert-circle', () => ({ default: () => <span /> }));
vi.mock('lucide-solid/icons/check-circle-2', () => ({ default: () => <span /> }));
vi.mock('lucide-solid/icons/x-circle', () => ({ default: () => <span /> }));
vi.mock('lucide-solid/icons/loader-2', () => ({ default: () => <span /> }));
vi.mock('lucide-solid/icons/chevron-down', () => ({ default: () => <span /> }));
vi.mock('lucide-solid/icons/chevron-right', () => ({ default: () => <span /> }));
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> = {}): 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<string, unknown> = {}) {
const sig = <T,>(key: string, def: T): Accessor<T> => {
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<string>()),
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<string>()),
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(() => <CandidatesStep wizard={wizard} />);
expect(screen.getByText('Loading cluster nodes...')).toBeInTheDocument();
});
it('shows error message', () => {
const wizard = createMockWizard({ candidatesError: 'Network failure' });
render(() => <CandidatesStep wizard={wizard} />);
expect(screen.getByText('Network failure')).toBeInTheDocument();
});
it('shows empty state when no candidates', () => {
const wizard = createMockWizard({ candidates: [] });
render(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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<string>(),
});
render(() => <CandidatesStep wizard={wizard} />);
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<string>(),
toggleNodeSelection: toggleMock,
});
render(() => <CandidatesStep wizard={wizard} />);
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(() => <PreflightStep wizard={wizard} />);
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(() => <PreflightStep wizard={wizard} />);
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(() => <PreflightStep wizard={wizard} />);
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(() => <PreflightStep wizard={wizard} />);
expect(screen.getByText('SSH unreachable')).toBeInTheDocument();
});
it('shows preflight error banner', () => {
const wizard = createMockWizard({
preflightError: 'Connection lost',
preflightTargets: [],
});
render(() => <PreflightStep wizard={wizard} />);
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(() => <ConfirmStep wizard={wizard} />);
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(() => <ConfirmStep wizard={wizard} />);
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<string>(),
toggleConfirmNode: toggleMock,
});
render(() => <ConfirmStep wizard={wizard} />);
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(() => <DeployingStep wizard={wizard} />);
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(() => <DeployingStep wizard={wizard} />);
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(() => <DeployingStep wizard={wizard} />);
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(() => <DeployingStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
// 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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
fireEvent.click(screen.getByText('Manual install instructions'));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(
'Invalid agent install command response',
);
});
});
});
@@ -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');
});
});
@@ -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<CandidatesStepProps> = (props) => {
const w = props.wizard;
const allSelected = createMemo(
() => w.deployableNodes().length > 0 && w.selectedNodeIds().size === w.deployableNodes().length,
);
return (
<div class="space-y-4">
<Show when={w.candidatesError()}>
<div
role="alert"
class="rounded-md bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300"
>
{w.candidatesError()}
</div>
</Show>
<Show when={w.candidatesLoading()}>
<div
role="status"
aria-live="polite"
class="flex items-center justify-center py-8 text-sm text-muted"
>
<div class="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent mr-2" />
{getDeployCandidatesLoadingState()}
</div>
</Show>
<Show when={!w.candidatesLoading() && !w.candidatesError()}>
{/* Source agent selection */}
<Show when={w.onlineSourceAgents().length > 1}>
<div class="space-y-1">
<label class="text-xs font-medium text-muted" for="source-agent-select">
Source Agent
</label>
<select
id="source-agent-select"
value={w.selectedSourceAgent()}
onChange={(e) => 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"
>
<option value="">Select a source agent...</option>
<For each={w.onlineSourceAgents()}>
{(agent) => (
<option value={agent.agentId}>
{agent.nodeId} ({agent.agentId})
</option>
)}
</For>
</select>
<p class="text-[11px] text-muted">
The source agent will SSH to other nodes to install Pulse agents.
</p>
</div>
</Show>
<Show when={w.onlineSourceAgents().length === 0 && !w.candidatesLoading()}>
<div
role="alert"
class="rounded-md bg-amber-50 dark:bg-amber-900/20 p-3 text-sm text-amber-700 dark:text-amber-300 flex items-start gap-2"
>
<AlertCircleIcon class="w-4 h-4 mt-0.5 shrink-0" />
<span>{getDeployNoSourceAgentsState()}</span>
</div>
</Show>
{/* Node selection table */}
<Show when={w.candidates().length > 0}>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-muted">
{w.selectedNodeIds().size} of {w.deployableNodes().length} nodes selected
</span>
<div class="flex gap-2">
<button
type="button"
onClick={() => w.selectAllNodes()}
disabled={allSelected()}
class="text-xs text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50 disabled:no-underline"
>
Select All
</button>
<button
type="button"
onClick={() => 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
</button>
</div>
</div>
<Table wrapperClass="rounded-md border border-border" class="text-sm">
<TableHeader>
<TableRow class="bg-surface-alt text-left">
<TableHead class="w-8 px-3 py-2" />
<TableHead class="px-3 py-2 font-medium text-muted text-xs">Node</TableHead>
<TableHead class="px-3 py-2 font-medium text-muted text-xs">IP</TableHead>
<TableHead class="px-3 py-2 font-medium text-muted text-xs">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For each={w.candidates()}>
{(node) => {
const isDisabled = () => node.hasAgent || !node.deployable;
const isChecked = () => w.selectedNodeIds().has(node.nodeId);
return (
<TableRow
class={`transition-colors ${
isDisabled() ? 'opacity-50' : 'hover:bg-surface-hover cursor-pointer'
}`}
tabIndex={isDisabled() ? undefined : 0}
onClick={() => {
if (!isDisabled()) w.toggleNodeSelection(node.nodeId);
}}
onKeyDown={(e) => {
if (!isDisabled() && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
w.toggleNodeSelection(node.nodeId);
}
}}
>
<TableCell class="px-3 py-2">
<input
type="checkbox"
checked={isChecked()}
disabled={isDisabled()}
onChange={() => w.toggleNodeSelection(node.nodeId)}
onClick={(e) => e.stopPropagation()}
class="rounded border-border"
/>
</TableCell>
<TableCell class="px-3 py-2 font-medium text-base-content">
{node.name}
</TableCell>
<TableCell class="px-3 py-2 text-muted font-mono text-xs">
{node.ip || '--'}
</TableCell>
<TableCell class="px-3 py-2">
<Show
when={node.hasAgent}
fallback={
<Show
when={node.deployable}
fallback={
<span class="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<AlertCircleIcon class="w-3 h-3" />
{node.reason || 'Not deployable'}
</span>
}
>
<span class="text-xs text-muted">Available</span>
</Show>
}
>
<span class="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<CheckIcon class="w-3 h-3" />
Already monitored
</span>
</Show>
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</div>
</Show>
<Show when={w.candidates().length === 0 && !w.candidatesLoading()}>
<div class="text-center py-8 text-sm text-muted">{getDeployNoCandidatesState()}</div>
</Show>
</Show>
</div>
);
};
@@ -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<ConfirmStepProps> = (props) => {
const w = props.wizard;
return (
<div class="space-y-4">
{/* Ready nodes */}
<Show when={w.readyNodes().length > 0}>
<div class="space-y-2">
<h4 class="text-xs font-semibold text-base-content flex items-center gap-1.5">
<CheckCircleIcon class="w-3.5 h-3.5 text-emerald-500" />
Ready to deploy ({w.readyNodes().length})
</h4>
<Table wrapperClass="rounded-md border border-border" class="text-sm">
<TableHeader>
<TableRow class="bg-surface-alt text-left">
<TableHead class="w-8 px-3 py-2" />
<TableHead class="px-3 py-2 font-medium text-muted text-xs">Node</TableHead>
<TableHead class="px-3 py-2 font-medium text-muted text-xs">IP</TableHead>
<TableHead class="px-3 py-2 font-medium text-muted text-xs">Arch</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For each={w.readyNodes()}>
{(target) => (
<TableRow
class="hover:bg-surface-hover cursor-pointer"
tabIndex={0}
onClick={() => w.toggleConfirmNode(target.nodeId)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
w.toggleConfirmNode(target.nodeId);
}
}}
>
<TableCell class="px-3 py-2">
<input
type="checkbox"
checked={w.confirmSelectedNodeIds().has(target.nodeId)}
onChange={() => w.toggleConfirmNode(target.nodeId)}
onClick={(e) => e.stopPropagation()}
class="rounded border-border"
/>
</TableCell>
<TableCell class="px-3 py-2 font-medium text-base-content">
{target.nodeName}
</TableCell>
<TableCell class="px-3 py-2 text-muted font-mono text-xs">
{target.nodeIP}
</TableCell>
<TableCell class="px-3 py-2 text-muted text-xs">
{target.arch || 'amd64'}
</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</div>
</Show>
{/* Failed preflight nodes */}
<Show when={w.failedPreflightNodes().length > 0}>
<div class="space-y-2">
<h4 class="text-xs font-semibold text-muted">
Cannot deploy ({w.failedPreflightNodes().length})
</h4>
<Table wrapperClass="rounded-md border border-border" class="text-sm">
<TableHeader>
<TableRow class="bg-surface-alt text-left">
<TableHead class="px-3 py-2 font-medium text-muted text-xs">Node</TableHead>
<TableHead class="px-3 py-2 font-medium text-muted text-xs">IP</TableHead>
<TableHead class="px-3 py-2 font-medium text-muted text-xs">Reason</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For each={w.failedPreflightNodes()}>
{(target) => (
<TableRow class="opacity-60">
<TableCell class="px-3 py-2 font-medium text-base-content">
{target.nodeName}
</TableCell>
<TableCell class="px-3 py-2 text-muted font-mono text-xs">
{target.nodeIP}
</TableCell>
<TableCell class="px-3 py-2 text-xs text-red-600 dark:text-red-400">
{target.errorMessage || 'Preflight failed'}
</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</div>
</Show>
</div>
);
};
@@ -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<DeployingStepProps> = (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 (
<div class="space-y-4">
<Show when={w.deployError()}>
<div
role="alert"
class="rounded-md bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300"
>
{w.deployError()}
</div>
</Show>
{/* Progress summary */}
<div class="flex items-center justify-between">
<div role="status" aria-live="polite" class="flex items-center gap-2 text-sm text-muted">
<Show
when={totalCount() === 0 || completedCount() < totalCount()}
fallback={<CheckCircleIcon class="w-4 h-4 text-emerald-500" />}
>
<LoaderIcon class="w-4 h-4 animate-spin" />
</Show>
<span>
Installing {completedCount()} of {totalCount()} nodes...
<Show when={inProgressCount() > 0}> ({inProgressCount()} in progress)</Show>
</span>
</div>
</div>
{/* Per-node rows */}
<Table wrapperClass="rounded-md border border-border" class="text-sm">
<TableHeader>
<TableRow class="bg-surface-alt text-left">
<TableHead class="px-3 py-2 font-medium text-muted text-xs">Node</TableHead>
<TableHead class="px-3 py-2 font-medium text-muted text-xs">IP</TableHead>
<TableHead class="px-3 py-2 font-medium text-muted text-xs">Status</TableHead>
<TableHead class="px-3 py-2 font-medium text-muted text-xs">Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For each={w.jobTargets()}>
{(target) => (
<TableRow>
<TableCell class="px-3 py-2 font-medium text-base-content">
{target.nodeName}
</TableCell>
<TableCell class="px-3 py-2 text-muted font-mono text-xs">
{target.nodeIP}
</TableCell>
<TableCell class="px-3 py-2">
<DeployStatusBadge status={target.status} />
</TableCell>
<TableCell class="px-3 py-2">
<ErrorDetail message={target.errorMessage} />
</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</div>
);
};
@@ -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<PreflightStepProps> = (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 (
<div class="space-y-4">
<Show when={w.preflightError()}>
<div
role="alert"
class="rounded-md bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300"
>
{w.preflightError()}
</div>
</Show>
{/* Progress summary */}
<div role="status" aria-live="polite" class="flex items-center gap-2 text-sm text-muted">
<Show
when={totalCount() === 0 || completedCount() < totalCount()}
fallback={<CheckCircleIcon class="w-4 h-4 text-emerald-500" />}
>
<LoaderIcon class="w-4 h-4 animate-spin" />
</Show>
<span>
<Show
when={totalCount() === 0 || completedCount() < totalCount()}
fallback="Preflight checks complete"
>
Checking {completedCount()} of {totalCount()} nodes...
</Show>
</span>
</div>
{/* Per-node rows */}
<Table wrapperClass="rounded-md border border-border" class="text-sm">
<TableHeader>
<TableRow class="bg-surface-alt text-left">
<TableHead class="px-3 py-2 font-medium text-muted text-xs">Node</TableHead>
<TableHead class="px-3 py-2 font-medium text-muted text-xs">IP</TableHead>
<TableHead class="px-3 py-2 font-medium text-muted text-xs">Status</TableHead>
<TableHead class="px-3 py-2 font-medium text-muted text-xs">Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For each={w.preflightTargets()}>
{(target) => (
<TableRow>
<TableCell class="px-3 py-2 font-medium text-base-content">
{target.nodeName}
</TableCell>
<TableCell class="px-3 py-2 text-muted font-mono text-xs">
{target.nodeIP}
</TableCell>
<TableCell class="px-3 py-2">
<DeployStatusBadge status={target.status} />
</TableCell>
<TableCell class="px-3 py-2">
<ErrorDetail message={target.errorMessage} />
</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</div>
);
};
@@ -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<ResultsStepProps> = (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<typeof setTimeout> | 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 (
<div class="space-y-4">
{/* Succeeded */}
<Show when={w.succeededTargets().length > 0}>
<div class="space-y-2">
<h4 class="text-xs font-semibold text-emerald-600 dark:text-emerald-400 flex items-center gap-1.5">
<CheckCircleIcon class="w-3.5 h-3.5" />
Deployed ({w.succeededTargets().length})
</h4>
<Table
wrapperClass="rounded-md border border-emerald-200 dark:border-emerald-800"
class="text-sm"
>
<TableBody class="divide-y divide-emerald-100 dark:divide-emerald-900">
<For each={w.succeededTargets()}>
{(target) => (
<TableRow>
<TableCell class="px-3 py-2 font-medium text-base-content">
{target.nodeName}
</TableCell>
<TableCell class="px-3 py-2 text-muted font-mono text-xs">
{target.nodeIP}
</TableCell>
<TableCell class="px-3 py-2">
<DeployStatusBadge status={target.status} />
</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</div>
</Show>
{/* Failed */}
<Show when={w.failedTargets().length > 0}>
<div class="space-y-2">
<h4 class="text-xs font-semibold text-red-600 dark:text-red-400 flex items-center gap-1.5">
<XCircleIcon class="w-3.5 h-3.5" />
Failed ({w.failedTargets().length})
</h4>
<Table
wrapperClass="rounded-md border border-red-200 dark:border-red-800"
class="text-sm"
>
<TableBody class="divide-y divide-red-100 dark:divide-red-900">
<For each={w.failedTargets()}>
{(target) => (
<TableRow>
<TableCell class="px-3 py-2 font-medium text-base-content">
{target.nodeName}
</TableCell>
<TableCell class="px-3 py-2 text-muted font-mono text-xs">
{target.nodeIP}
</TableCell>
<TableCell class="px-3 py-2">
<DeployStatusBadge status={target.status} />
</TableCell>
<TableCell class="px-3 py-2">
<ErrorDetail message={target.errorMessage} />
</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
{/* Manual install accordion for failed nodes */}
<button
type="button"
onClick={handleAccordionToggle}
aria-expanded={manualInstallOpen()}
aria-controls="manual-install-content"
class="flex items-center gap-1 text-xs text-muted hover:text-base-content transition-colors"
>
<Show when={manualInstallOpen()} fallback={<ChevronRightIcon class="w-3 h-3" />}>
<ChevronDownIcon class="w-3 h-3" />
</Show>
Manual install instructions
</button>
<Show when={manualInstallOpen()}>
<div
id="manual-install-content"
class="rounded-md bg-surface-alt p-3 text-xs space-y-2"
>
<p class="text-muted">
For nodes that failed SSH-based deployment, you can install the agent manually by
SSHing into the node and running:
</p>
<Show when={installCommandLoading()}>
<div class="flex items-center gap-2 py-2 text-muted">
<div class="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
{getDeployInstallCommandLoadingState()}
</div>
</Show>
<Show when={installCommandError()}>
<div role="alert" class="text-red-600 dark:text-red-400">
{installCommandError()}
</div>
</Show>
<Show when={installCommand()}>
<div class="relative">
{/* prettier-ignore */}
<pre class="bg-surface border border-border rounded p-2 pr-10 text-[11px] font-mono text-base-content overflow-x-auto whitespace-pre-wrap break-all">{installCommand()}</pre>
<button
type="button"
onClick={handleCopy}
class="absolute top-1.5 right-1.5 rounded p-1 text-muted hover:text-base-content hover:bg-surface-hover transition-colors"
aria-label={copied() ? 'Copied' : 'Copy to clipboard'}
>
<Show when={copied()} fallback={<CopyIcon class="w-3.5 h-3.5" />}>
<CheckIcon class="w-3.5 h-3.5 text-emerald-500" />
</Show>
</button>
</div>
</Show>
</div>
</Show>
</div>
</Show>
{/* Skipped */}
<Show when={w.skippedTargets().length > 0}>
<div class="space-y-2">
<h4 class="text-xs font-semibold text-muted flex items-center gap-1.5">
<AlertCircleIcon class="w-3.5 h-3.5" />
Skipped ({w.skippedTargets().length})
</h4>
<Table wrapperClass="rounded-md border border-border" class="text-sm">
<TableBody>
<For each={w.skippedTargets()}>
{(target) => (
<TableRow class="opacity-60">
<TableCell class="px-3 py-2 font-medium text-base-content">
{target.nodeName}
</TableCell>
<TableCell class="px-3 py-2 text-muted font-mono text-xs">
{target.nodeIP}
</TableCell>
<TableCell class="px-3 py-2">
<DeployStatusBadge status={target.status} />
</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</div>
</Show>
{/* Canceled */}
<Show when={w.canceledTargets().length > 0}>
<div class="space-y-2">
<h4 class="text-xs font-semibold text-muted">Canceled ({w.canceledTargets().length})</h4>
<Table wrapperClass="rounded-md border border-border" class="text-sm">
<TableBody>
<For each={w.canceledTargets()}>
{(target) => (
<TableRow class="opacity-60">
<TableCell class="px-3 py-2 font-medium text-base-content">
{target.nodeName}
</TableCell>
<TableCell class="px-3 py-2 text-muted font-mono text-xs">
{target.nodeIP}
</TableCell>
<TableCell class="px-3 py-2">
<DeployStatusBadge status={target.status} />
</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</div>
</Show>
</div>
);
};
@@ -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<string>;
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<Set<string>>(
overrides.selectedNodeIds ?? new Set<string>(),
);
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<string>());
});
// 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<CandidateNode[]>,
candidatesLoading,
candidatesError,
selectedNodeIds,
deployableNodes: (() => deployableData) as Accessor<CandidateNode[]>,
onlineSourceAgents: (() => overrides.onlineSourceAgents ?? []) as Accessor<SourceAgentInfo[]>,
selectedSourceAgent,
setSelectedSourceAgent,
toggleNodeSelection,
selectAllNodes,
deselectAllNodes,
// Stubs for fields not used by CandidatesStep but required by the type.
step: (() => 'candidates') as Accessor<string>,
setStep: vi.fn(),
sourceAgents: (() => []) as Accessor<SourceAgentInfo[]>,
preflightId: (() => '') as Accessor<string>,
preflightTargets: (() => []) as Accessor<unknown[]>,
preflightStatus: (() => '') as Accessor<string>,
preflightError: (() => '') as Accessor<string>,
preflightStream: {} as unknown,
readyNodes: (() => []) as Accessor<unknown[]>,
failedPreflightNodes: (() => []) as Accessor<unknown[]>,
confirmSelectedNodeIds: (() => new Set<string>()) as Accessor<Set<string>>,
toggleConfirmNode: vi.fn(),
jobId: (() => '') as Accessor<string>,
jobTargets: (() => []) as Accessor<unknown[]>,
jobStatus: (() => '') as Accessor<string>,
deployError: (() => '') as Accessor<string>,
deployStream: {} as unknown,
succeededTargets: (() => []) as Accessor<unknown[]>,
failedTargets: (() => []) as Accessor<unknown[]>,
retryableTargets: (() => []) as Accessor<unknown[]>,
skippedTargets: (() => []) as Accessor<unknown[]>,
canceledTargets: (() => []) as Accessor<unknown[]>,
startingPreflight: (() => false) as Accessor<boolean>,
startingDeploy: (() => false) as Accessor<boolean>,
retrying: (() => false) as Accessor<boolean>,
canceling: (() => false) as Accessor<boolean>,
isOperationActive: (() => false) as Accessor<boolean>,
loadCandidates: vi.fn(),
startPreflight: vi.fn(),
startDeploy: vi.fn(),
cancelDeploy: vi.fn(),
retryFailed: vi.fn(),
} as unknown as DeployWizardState;
}
function makeCandidate(overrides: Partial<CandidateNode> = {}): CandidateNode {
return {
nodeId: 'node-1',
name: 'pve1',
ip: '192.168.1.10',
hasAgent: false,
deployable: true,
...overrides,
};
}
function makeSourceAgent(overrides: Partial<SourceAgentInfo> = {}): 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(() => <CandidatesStep wizard={wizard} />);
expect(screen.getByText('Loading cluster nodes...')).toBeInTheDocument();
});
it('does not show node table or empty state while loading', () => {
const wizard = createMockWizard({ candidatesLoading: true });
render(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
expect(screen.getByText('--')).toBeInTheDocument();
});
it('shows "Already monitored" for nodes with agents', () => {
const wizard = createMockWizard({
candidates: [makeCandidate({ hasAgent: true })],
candidatesLoading: false,
});
render(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
expect(screen.getByText('Offline (last seen 2h ago)')).toBeInTheDocument();
});
it('renders table headers', () => {
const wizard = createMockWizard({
candidates: [makeCandidate()],
candidatesLoading: false,
});
render(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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<string>(),
candidatesLoading: false,
});
render(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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<string>(),
candidatesLoading: false,
});
render(() => <CandidatesStep wizard={wizard} />);
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(() => <CandidatesStep wizard={wizard} />);
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();
});
});
});
@@ -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> = {}): 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<string>;
readyNodes?: MockTarget[];
failedPreflightNodes?: MockTarget[];
toggleConfirmNode?: ReturnType<typeof vi.fn>;
}
function createMockWizard(overrides: WizardOverrides = {}) {
const selectedIds = overrides.confirmSelectedNodeIds ?? new Set<string>();
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(() => <ConfirmStep wizard={wizard as any} />);
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();
});
});
});
@@ -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> = {}): 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<DeployTarget[]>,
deployError,
// Stubs for fields not used by DeployingStep but required by the type.
step: (() => 'deploying') as Accessor<string>,
setStep: vi.fn(),
candidates: (() => []) as Accessor<unknown[]>,
candidatesLoading: (() => false) as Accessor<boolean>,
candidatesError: (() => '') as Accessor<string>,
sourceAgents: (() => []) as Accessor<SourceAgentInfo[]>,
onlineSourceAgents: (() => []) as Accessor<SourceAgentInfo[]>,
selectedSourceAgent: (() => '') as Accessor<string>,
setSelectedSourceAgent: vi.fn(),
selectedNodeIds: (() => new Set<string>()) as Accessor<Set<string>>,
deployableNodes: (() => []) as Accessor<unknown[]>,
toggleNodeSelection: vi.fn(),
selectAllNodes: vi.fn(),
deselectAllNodes: vi.fn(),
loadCandidates: vi.fn(),
preflightId: (() => '') as Accessor<string>,
preflightTargets: (() => []) as Accessor<unknown[]>,
preflightStatus: (() => '') as Accessor<string>,
preflightError: (() => '') as Accessor<string>,
preflightStream: {} as unknown,
readyNodes: (() => []) as Accessor<unknown[]>,
failedPreflightNodes: (() => []) as Accessor<unknown[]>,
confirmSelectedNodeIds: (() => new Set<string>()) as Accessor<Set<string>>,
toggleConfirmNode: vi.fn(),
jobId: (() => '') as Accessor<string>,
jobStatus: (() => '') as Accessor<string>,
deployStream: {} as unknown,
succeededTargets: (() => []) as Accessor<unknown[]>,
failedTargets: (() => []) as Accessor<unknown[]>,
retryableTargets: (() => []) as Accessor<unknown[]>,
skippedTargets: (() => []) as Accessor<unknown[]>,
canceledTargets: (() => []) as Accessor<unknown[]>,
startingPreflight: (() => false) as Accessor<boolean>,
startingDeploy: (() => false) as Accessor<boolean>,
retrying: (() => false) as Accessor<boolean>,
canceling: (() => false) as Accessor<boolean>,
isOperationActive: (() => false) as Accessor<boolean>,
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(() => <DeployingStep wizard={wizard} />);
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(() => <DeployingStep wizard={wizard} />);
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(() => <DeployingStep wizard={wizard} />);
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(() => <DeployingStep wizard={wizard} />);
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(() => <DeployingStep wizard={wizard} />);
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(() => <DeployingStep wizard={wizard} />);
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(() => <DeployingStep wizard={wizard} />);
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(() => <DeployingStep wizard={wizard} />);
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(() => <DeployingStep wizard={wizard} />);
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(() => <DeployingStep wizard={wizard} />);
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(() => <DeployingStep wizard={wizard} />);
// 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(() => <DeployingStep wizard={wizard} />);
// 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(() => <DeployingStep wizard={wizard} />);
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(() => <DeployingStep wizard={wizard} />);
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(() => <DeployingStep wizard={wizard} />);
// 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(() => <DeployingStep wizard={wizard} />);
// 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(() => <DeployingStep wizard={wizard} />);
// 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(() => <DeployingStep wizard={wizard} />);
// 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(() => <DeployingStep wizard={wizard} />);
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(() => <DeployingStep wizard={wizard} />);
// 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(() => <DeployingStep wizard={wizard} />);
// 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();
});
});
});
@@ -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> = {}): 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<DeployTarget[]>,
preflightError,
// Stubs for fields not used by PreflightStep but required by the type.
step: (() => 'preflight') as Accessor<string>,
setStep: vi.fn(),
candidates: (() => []) as Accessor<unknown[]>,
candidatesLoading: (() => false) as Accessor<boolean>,
candidatesError: (() => '') as Accessor<string>,
selectedNodeIds: (() => new Set<string>()) as Accessor<Set<string>>,
deployableNodes: (() => []) as Accessor<unknown[]>,
onlineSourceAgents: (() => []) as Accessor<SourceAgentInfo[]>,
sourceAgents: (() => []) as Accessor<SourceAgentInfo[]>,
selectedSourceAgent: (() => '') as Accessor<string>,
setSelectedSourceAgent: vi.fn(),
toggleNodeSelection: vi.fn(),
selectAllNodes: vi.fn(),
deselectAllNodes: vi.fn(),
preflightId: (() => '') as Accessor<string>,
preflightStatus: (() => '') as Accessor<string>,
preflightStream: {} as unknown,
readyNodes: (() => []) as Accessor<unknown[]>,
failedPreflightNodes: (() => []) as Accessor<unknown[]>,
confirmSelectedNodeIds: (() => new Set<string>()) as Accessor<Set<string>>,
toggleConfirmNode: vi.fn(),
jobId: (() => '') as Accessor<string>,
jobTargets: (() => []) as Accessor<unknown[]>,
jobStatus: (() => '') as Accessor<string>,
deployError: (() => '') as Accessor<string>,
deployStream: {} as unknown,
succeededTargets: (() => []) as Accessor<unknown[]>,
failedTargets: (() => []) as Accessor<unknown[]>,
retryableTargets: (() => []) as Accessor<unknown[]>,
skippedTargets: (() => []) as Accessor<unknown[]>,
canceledTargets: (() => []) as Accessor<unknown[]>,
startingPreflight: (() => false) as Accessor<boolean>,
startingDeploy: (() => false) as Accessor<boolean>,
retrying: (() => false) as Accessor<boolean>,
canceling: (() => false) as Accessor<boolean>,
isOperationActive: (() => false) as Accessor<boolean>,
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(() => <PreflightStep wizard={wizard} />);
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(() => <PreflightStep wizard={wizard} />);
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(() => <PreflightStep wizard={wizard} />);
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(() => <PreflightStep wizard={wizard} />);
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(() => <PreflightStep wizard={wizard} />);
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(() => <PreflightStep wizard={wizard} />);
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(() => <PreflightStep wizard={wizard} />);
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(() => <PreflightStep wizard={wizard} />);
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(() => <PreflightStep wizard={wizard} />);
// 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(() => <PreflightStep wizard={wizard} />);
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(() => <PreflightStep wizard={wizard} />);
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(() => <PreflightStep wizard={wizard} />);
// 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(() => <PreflightStep wizard={wizard} />);
expect(screen.getByText('Pending')).toBeInTheDocument();
});
it('shows "Checking" badge for preflighting targets', () => {
const wizard = createMockWizard({
preflightTargets: [makeTarget({ status: 'preflighting' })],
});
render(() => <PreflightStep wizard={wizard} />);
expect(screen.getByText('Checking')).toBeInTheDocument();
});
it('shows "Ready" badge for ready targets', () => {
const wizard = createMockWizard({
preflightTargets: [makeTarget({ status: 'ready' })],
});
render(() => <PreflightStep wizard={wizard} />);
expect(screen.getByText('Ready')).toBeInTheDocument();
});
it('shows "Failed" badge for failed_permanent targets', () => {
const wizard = createMockWizard({
preflightTargets: [makeTarget({ status: 'failed_permanent' })],
});
render(() => <PreflightStep wizard={wizard} />);
expect(screen.getByText('Failed')).toBeInTheDocument();
});
it('shows "Failed" badge for failed_retryable targets', () => {
const wizard = createMockWizard({
preflightTargets: [makeTarget({ status: 'failed_retryable' })],
});
render(() => <PreflightStep wizard={wizard} />);
expect(screen.getByText('Failed')).toBeInTheDocument();
});
it('shows "Canceled" badge for canceled targets', () => {
const wizard = createMockWizard({
preflightTargets: [makeTarget({ status: 'canceled' })],
});
render(() => <PreflightStep wizard={wizard} />);
expect(screen.getByText('Canceled')).toBeInTheDocument();
});
it('shows "Installing" badge for installing targets', () => {
const wizard = createMockWizard({
preflightTargets: [makeTarget({ status: 'installing' })],
});
render(() => <PreflightStep wizard={wizard} />);
expect(screen.getByText('Installing')).toBeInTheDocument();
});
it('shows "Enrolling" badge for enrolling targets', () => {
const wizard = createMockWizard({
preflightTargets: [makeTarget({ status: 'enrolling' })],
});
render(() => <PreflightStep wizard={wizard} />);
expect(screen.getByText('Enrolling')).toBeInTheDocument();
});
it('shows "Verifying" badge for verifying targets', () => {
const wizard = createMockWizard({
preflightTargets: [makeTarget({ status: 'verifying' })],
});
render(() => <PreflightStep wizard={wizard} />);
expect(screen.getByText('Verifying')).toBeInTheDocument();
});
it('shows "Deployed" badge for succeeded targets', () => {
const wizard = createMockWizard({
preflightTargets: [makeTarget({ status: 'succeeded' })],
});
render(() => <PreflightStep wizard={wizard} />);
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(() => <PreflightStep wizard={wizard} />);
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(() => <PreflightStep wizard={wizard} />);
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(() => <PreflightStep wizard={wizard} />);
// 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(() => <PreflightStep wizard={wizard} />);
// 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(() => <PreflightStep wizard={wizard} />);
// 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(() => <PreflightStep wizard={wizard} />);
// 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...');
});
});
});
@@ -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: () => <span data-testid="icon-check-circle" />,
}));
vi.mock('lucide-solid/icons/x-circle', () => ({
default: () => <span data-testid="icon-x-circle" />,
}));
vi.mock('lucide-solid/icons/alert-circle', () => ({
default: () => <span data-testid="icon-alert-circle" />,
}));
vi.mock('lucide-solid/icons/chevron-down', () => ({
default: () => <span data-testid="icon-chevron-down" />,
}));
vi.mock('lucide-solid/icons/chevron-right', () => ({
default: () => <span data-testid="icon-chevron-right" />,
}));
vi.mock('lucide-solid/icons/copy', () => ({
default: () => <span data-testid="icon-copy" />,
}));
vi.mock('lucide-solid/icons/check', () => ({
default: () => <span data-testid="icon-check" />,
}));
/* ── component import ──────────────────────────────────────────── */
import { ResultsStep } from '../ResultsStep';
/* ── helpers ────────────────────────────────────────────────────── */
function makeTarget(overrides: Partial<DeployTarget> = {}): 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<DeployTarget[]>,
failedTargets: (() => overrides.failedTargets ?? []) as Accessor<DeployTarget[]>,
skippedTargets: (() => overrides.skippedTargets ?? []) as Accessor<DeployTarget[]>,
canceledTargets: (() => overrides.canceledTargets ?? []) as Accessor<DeployTarget[]>,
// Stubs for fields not used by ResultsStep but required by the type.
step: (() => 'results') as Accessor<string>,
setStep: vi.fn(),
candidates: (() => []) as Accessor<unknown[]>,
candidatesLoading: (() => false) as Accessor<boolean>,
candidatesError: (() => '') as Accessor<string>,
sourceAgents: (() => []) as Accessor<SourceAgentInfo[]>,
onlineSourceAgents: (() => []) as Accessor<SourceAgentInfo[]>,
selectedSourceAgent: (() => '') as Accessor<string>,
setSelectedSourceAgent: vi.fn(),
selectedNodeIds: (() => new Set<string>()) as Accessor<Set<string>>,
deployableNodes: (() => []) as Accessor<unknown[]>,
toggleNodeSelection: vi.fn(),
selectAllNodes: vi.fn(),
deselectAllNodes: vi.fn(),
loadCandidates: vi.fn(),
preflightId: (() => '') as Accessor<string>,
preflightTargets: (() => []) as Accessor<unknown[]>,
preflightStatus: (() => '') as Accessor<string>,
preflightError: (() => '') as Accessor<string>,
preflightStream: {} as unknown,
readyNodes: (() => []) as Accessor<unknown[]>,
failedPreflightNodes: (() => []) as Accessor<unknown[]>,
confirmSelectedNodeIds: (() => new Set<string>()) as Accessor<Set<string>>,
toggleConfirmNode: vi.fn(),
jobId: (() => '') as Accessor<string>,
jobStatus: (() => '') as Accessor<string>,
jobTargets: (() => []) as Accessor<DeployTarget[]>,
deployError: (() => '') as Accessor<string>,
deployStream: {} as unknown,
retryableTargets: (() => []) as Accessor<unknown[]>,
startingPreflight: (() => false) as Accessor<boolean>,
startingDeploy: (() => false) as Accessor<boolean>,
retrying: (() => false) as Accessor<boolean>,
canceling: (() => false) as Accessor<boolean>,
isOperationActive: (() => false) as Accessor<boolean>,
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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
// 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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
// 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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
}
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(() => <ResultsStep wizard={wizard} />);
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(() => <ResultsStep wizard={wizard} />);
// 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();
});
});
});
@@ -1,12 +0,0 @@
export type { ResourceBadge } from '@/utils/resourceBadgePresentation';
export {
getContainerRuntimeBadge,
getContainerRuntimeBadgeForRuntime,
getInfrastructureSystemIdentityBadges,
getInfrastructureSystemIdentitySortLabel,
getInfrastructurePlatformBadges,
getPlatformBadge,
getSourceBadge,
getTypeBadge,
getUnifiedSourceBadges,
} from '@/utils/resourceBadgePresentation';
@@ -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 <span class={`font-mono text-[11px] sm:text-xs ${colorClass()}`}>{formatted()}</span>;
}
@@ -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<ZFSHealthMapProps> = (props) => {
const {
devices,
hoveredDevice,
hoveredTooltip,
tooltipPos,
isResilvering,
handleMouseEnter,
handleMouseLeave,
} = useZFSHealthMapModel(() => props.pool);
return (
<div class={ZFS_HEALTH_MAP_ROOT_CLASS}>
<For each={devices()}>
{(device) => (
<div
class={getZfsHealthMapDeviceClass(
getZfsDeviceBlockClass(device),
isResilvering(device),
)}
onMouseEnter={(e) => handleMouseEnter(e, device)}
onMouseLeave={handleMouseLeave}
/>
)}
</For>
<Show when={hoveredTooltip()}>
<Portal mount={document.body}>
<div
class={ZFS_HEALTH_MAP_TOOLTIP_PORTAL_CLASS}
style={getZfsHealthMapTooltipStyle(tooltipPos().x, tooltipPos().y)}
>
<div class={ZFS_HEALTH_MAP_TOOLTIP_CARD_CLASS}>
<div class={ZFS_HEALTH_MAP_TOOLTIP_NAME_CLASS}>{hoveredTooltip()?.name}</div>
<div class={ZFS_HEALTH_MAP_TOOLTIP_TYPE_CLASS}>{hoveredTooltip()?.type}</div>
<div class={ZFS_HEALTH_MAP_TOOLTIP_STATE_ROW_CLASS}>
<span class={`${ZFS_HEALTH_MAP_TOOLTIP_STATE_TEXT_CLASS} ${getZfsDeviceStateTextClass(hoveredDevice()!)}`}>
{hoveredTooltip()?.state}
</span>
<Show when={hoveredTooltip()?.hasErrors}>
<span class={getZfsHealthMapErrorSummaryClass()}>
({hoveredTooltip()?.errorSummary})
</span>
</Show>
</div>
<Show when={hoveredTooltip()?.message}>
<div class={getZfsHealthMapMessageClass()}>
{hoveredTooltip()?.message}
</div>
</Show>
</div>
</div>
</Portal>
</Show>
</div>
);
};
@@ -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>): 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']);
});
});
@@ -1,7 +0,0 @@
export type { StorageSourceOption, StorageSourceTone } from '@/utils/storageSources';
export {
buildStorageSourceOptions,
DEFAULT_STORAGE_SOURCE_OPTIONS,
normalizeStorageSourceKey,
resolveStorageSourceKey,
} from '@/utils/storageSources';
@@ -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<CollapsibleSearchInputProps> = (props) => {
const collapsible = useCollapsibleSearchInputState(props);
return (
<div
ref={collapsible.setRootRef}
class={collapsible.rootClass()}
onFocusOut={collapsible.handleFocusOut}
>
<Show
when={collapsible.showExpanded()}
fallback={
<div class="inline-flex rounded-md bg-surface-hover p-0.5">
<button
type="button"
onClick={() => 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'} (/)`}
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<span>{collapsible.triggerLabel()}</span>
</button>
</div>
}
>
<SearchInput
value={props.value}
onChange={props.onChange}
placeholder={props.placeholder}
title={props.title}
history={props.history}
tips={props.tips}
inputRef={(el) => {
collapsible.setInputRef(el);
props.inputRef?.(el);
}}
onBeforeAutoFocus={props.onBeforeAutoFocus}
typeToSearch={false}
/>
</Show>
</div>
);
};
@@ -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<DensityMapProps> = (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 (
<div
class={`relative w-full h-full flex flex-col group transition-opacity duration-150 ease-out ${
interactionState() === 'inactive' ? 'opacity-35' : 'opacity-100'
}`.trim()}
data-summary-chart-kind="density-map"
data-active-hover-timestamp={densityMap.activeHoverTimestamp() ?? ''}
data-highlight-series-id={props.highlightSeriesId ?? ''}
data-highlight-series-active={densityMap.externalSeriesIndex() !== null ? 'true' : 'false'}
data-rendered-series-count={densityMap.chartData().series.length}
data-summary-chart-state={interactionState()}
>
<div class="flex-1 relative min-h-0 bg-transparent flex">
{/* Y-axis: typically in a density map we might just show "Top Nodes" */}
<div class="absolute left-0 top-0 h-full w-full pointer-events-none flex flex-col justify-between py-1 opacity-0 group-hover:opacity-100 transition-opacity">
{/* We could overlay series names here, but it might get messy. Let's rely on tooltip. */}
</div>
<div class="h-full ml-1 mr-1 flex-1">
<canvas
ref={densityMap.setCanvasRef}
class="w-full h-full cursor-crosshair block"
onMouseMove={densityMap.handleMouseMove}
onMouseLeave={densityMap.handleMouseLeave}
/>
</div>
</div>
{/* X-axis labels */}
<div class="relative h-4 mt-1 pointer-events-none mx-1">
<span class="absolute left-0 top-0 text-[9px] font-medium leading-none text-slate-500 transition-all duration-300">
-{props.rangeLabel || props.timeRange}
</span>
<span class="absolute right-0 top-0 text-[9px] font-medium leading-none text-slate-500 transition-all duration-300">
now
</span>
</div>
<Show when={densityMap.hoveredState()}>
{(hover) => (
<TooltipPortal when={true} x={hover().tooltipX} y={hover().tooltipY + 2}>
<div class="min-w-[152px] max-w-[236px]" data-density-map-tooltip="true">
<div class="mb-1 grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1.5 border-b border-border pb-1 text-[10px] leading-none">
<span class="h-2 w-2 rounded-sm" style={{ background: hover().seriesColor }} />
<span class="min-w-0 truncate font-semibold tracking-[0.12em] text-base-content">
{hover().seriesName}
</span>
<span class="whitespace-nowrap text-muted">
{formatDensityMapHoverTime(hover().timestamp)}
</span>
</div>
<div class="flex items-center gap-2.5">
<div class="flex items-baseline gap-1">
<span class="text-[9px] uppercase tracking-wide text-muted">Current</span>
<span class="whitespace-nowrap text-[11px] font-semibold text-emerald-400">
{densityMap.formatValue(hover().value)}
</span>
</div>
<Show
when={hoveredFocusDetail()}
fallback={
<div class="flex items-baseline gap-1">
<span class="text-[9px] uppercase tracking-wide text-muted">Peak</span>
<span class="whitespace-nowrap text-[11px] font-semibold text-base-content">
No sample
</span>
</div>
}
>
{(detail) => (
<Show
when={hasDensityMapFocusActivity(detail())}
fallback={
<div class="flex items-baseline gap-1">
<span class="text-[9px] uppercase tracking-wide text-muted">Peak</span>
<span class="text-[11px] font-semibold text-base-content">
{props.focusEmptyStateLabel ?? 'No activity in range'}
</span>
</div>
}
>
<div class="flex items-baseline gap-1">
<span class="text-[9px] uppercase tracking-wide text-muted">Peak</span>
<span class="whitespace-nowrap text-[11px] font-semibold text-base-content">
{formatDetailValue(detail().peakValue)}
</span>
</div>
</Show>
)}
</Show>
</div>
</div>
</TooltipPortal>
)}
</Show>
</div>
);
};
export default DensityMap;
@@ -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<InfrastructureDetailsDrawerProps> = (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 (
<div class="space-y-3">
<div class="flex items-center gap-2 min-w-0">
<StatusDot
size="sm"
variant={headerIndicator().variant}
title={headerIndicator().label}
ariaLabel={headerIndicator().label}
/>
<h2
class="text-sm font-semibold text-base-content truncate m-0"
title={displayName()}
>
{displayName()}
</h2>
</div>
{/* Tabs */}
<div class="flex items-center gap-6 border-b border-border px-1 mb-1">
<button
onClick={() => 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' && (
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-t-full" />
)}
</button>
<button
onClick={() => 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' && (
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-t-full" />
)}
</button>
</div>
{/* Overview Tab */}
<div
class={drawer.activeTab() === 'overview' ? '' : 'hidden'}
style={{ 'overflow-anchor': 'none' }}
>
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.75rem)] [&>*]:min-w-[200px] [&>*]:max-w-full [&>*]:overflow-hidden">
<SystemInfoCard variant="node" node={props.node} />
<HardwareCard variant="node" node={props.node} />
<RootDiskCard node={props.node} />
<NetworkInterfacesCard interfaces={props.agent?.networkInterfaces} />
<DisksCard disks={props.agent?.disks} />
</div>
<div class="mt-3">
<WebInterfaceUrlField
metadataKind="agent"
metadataId={metadataId()}
targetLabel="agent"
customUrl={props.customUrl}
onCustomUrlChange={(url) => props.onCustomUrlChange?.(metadataId(), url)}
/>
</div>
</div>
{/* Discovery Tab */}
<div
class={drawer.activeTab() === 'discovery' ? '' : 'hidden'}
style={{ 'overflow-anchor': 'none' }}
>
<Suspense
fallback={
<div class="flex items-center justify-center py-8">
<div class="animate-spin h-6 w-6 border-2 border-blue-500 border-t-transparent rounded-full" />
<span class="ml-2 text-sm text-muted">{getDiscoveryLoadingState().text}</span>
</div>
}
>
<DiscoveryTab
resourceType="agent"
agentId={metadataId()}
resourceId={metadataId()}
hostname={discoveryHostname()}
/>
</Suspense>
</div>
</div>
);
};
@@ -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(() => <ScrollToTopButton />, { 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' });
});
});
@@ -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 (
<>
<div ref={state.setSentinelRef} class="hidden" />
<button
type="button"
onClick={state.scrollToTop}
class={getScrollToTopButtonClass(state.visible())}
aria-label={SCROLL_TO_TOP_BUTTON_ARIA_LABEL}
>
<ArrowUpIcon class="h-4 w-4" />
</button>
</>
);
}
@@ -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('<table');
@@ -728,13 +707,6 @@ describe('shared primitive guardrails', () => {
});
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',
@@ -1,49 +0,0 @@
import { Component } from 'solid-js';
interface SparklineSkeletonProps {
class?: string;
}
export const SparklineSkeleton: Component<SparklineSkeletonProps> = (props) => {
return (
<div
class={`w-full h-full min-h-[88px] flex flex-col ${props.class || ''}`}
data-testid="sparkline-skeleton"
>
<div class="relative flex-1 min-h-0 pl-7 pr-3 max-w-full overflow-hidden">
{/* Y-axis placeholders */}
<div class="absolute inset-y-0 left-0 w-7 flex flex-col justify-between py-[2%]">
<div class="h-1.5 w-4 bg-surface-hover rounded animate-pulse" />
<div class="h-1.5 w-4 bg-surface-hover rounded animate-pulse" />
<div class="h-1.5 w-4 bg-surface-hover rounded animate-pulse" />
<div class="h-1.5 w-4 bg-surface-hover rounded animate-pulse" />
</div>
{/* Center line / chart placeholder */}
<div class="h-full w-full relative flex items-center justify-center border-l border-b border-transparent">
{/* Subtle grid lines */}
<div class="absolute w-full top-[25%] border-t border-border-subtle" />
<div class="absolute w-full top-[50%] border-t border-border" />
<div class="absolute w-full top-[75%] border-t border-border-subtle" />
{/* Shimmer bar placeholder */}
<div class="w-full h-full flex items-end px-1 gap-[3px]">
<div class="flex-1 h-[40%] bg-surface-hover rounded-sm animate-pulse" />
<div class="flex-1 h-[55%] bg-surface-hover rounded-sm animate-pulse" />
<div class="flex-1 h-[35%] bg-surface-hover rounded-sm animate-pulse" />
<div class="flex-1 h-[60%] bg-surface-hover rounded-sm animate-pulse" />
<div class="flex-1 h-[45%] bg-surface-hover rounded-sm animate-pulse" />
<div class="flex-1 h-[50%] bg-surface-hover rounded-sm animate-pulse" />
</div>
</div>
</div>
{/* X-axis placeholders */}
<div class="h-4 pl-7 pr-3 mt-1 flex justify-between">
<div class="h-1.5 w-6 bg-surface-hover rounded animate-pulse" />
<div class="h-1.5 w-6 bg-surface-hover rounded animate-pulse" />
<div class="h-1.5 w-4 bg-surface-hover rounded animate-pulse" />
</div>
</div>
);
};
@@ -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(() => (
<DensityMap
timeRange="1h"
series={[
{
id: 'cpu',
name: 'CPU',
color: '#10b981',
data: [
{ timestamp: now - 30_000, value: 25 },
{ timestamp: now - 10_000, value: 55 },
],
},
]}
/>
));
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(() => (
<DensityMap
timeRange="1h"
series={[
{
id: 'cpu',
name: 'CPU',
color: '#10b981',
data: [
{ timestamp: now - 30_000, value: 25 },
{ timestamp: now - 10_000, value: 55 },
],
},
{
id: 'memory',
name: 'Memory',
color: '#f59e0b',
data: [
{ timestamp: now - 30_000, value: 45 },
{ timestamp: now - 10_000, value: 65 },
],
},
]}
/>
));
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(() => (
<DensityMap
timeRange="1h"
hoverSourceKey="diskio"
onHoverSyncChange={onHoverSyncChange}
series={[
{
id: 'alpha',
name: 'Alpha',
color: '#10b981',
data: [
{ timestamp: now - 30_000, value: 25 },
{ timestamp: now - 10_000, value: 55 },
],
},
]}
/>
));
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(() => (
<DensityMap
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 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(() => (
<DensityMap
timeRange="1h"
highlightSeriesId="alpha"
focusEmptyStateLabel="No disk activity in range"
series={[
{
id: 'alpha',
name: 'Alpha',
color: '#10b981',
data: [],
},
{
id: 'beta',
name: 'Beta',
color: '#3b82f6',
data: [
{ timestamp: now - 30_000, value: 18 },
{ timestamp: now - 5_000, value: 24 },
],
},
]}
/>
));
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(() => (
<DensityMap
timeRange="1h"
highlightSeriesId="alpha"
focusEmptyStateLabel="No disk activity in range"
series={[
{
id: 'alpha',
name: 'Alpha',
color: '#10b981',
data: [],
},
{
id: 'beta',
name: 'Beta',
color: '#3b82f6',
data: [
{ timestamp: now - 30_000, value: 18 },
{ timestamp: now - 5_000, value: 24 },
],
},
]}
/>
));
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();
});
});
@@ -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 <div data-testid="discovery-tab">discovery</div>;
},
}));
vi.mock('@/components/shared/WebInterfaceUrlField', () => ({
WebInterfaceUrlField: (props: unknown) => {
webInterfaceUrlFieldMock(props);
return <div data-testid="web-interface-url-field">url-field</div>;
},
}));
vi.mock('@/components/shared/cards/SystemInfoCard', () => ({
SystemInfoCard: () => <div data-testid="system-info-card">system</div>,
}));
vi.mock('@/components/shared/cards/HardwareCard', () => ({
HardwareCard: () => <div data-testid="hardware-card">hardware</div>,
}));
vi.mock('@/components/shared/cards/RootDiskCard', () => ({
RootDiskCard: () => <div data-testid="root-disk-card">disk</div>,
}));
vi.mock('@/components/shared/cards/NetworkInterfacesCard', () => ({
NetworkInterfacesCard: () => <div data-testid="network-interfaces-card">net</div>,
}));
vi.mock('@/components/shared/cards/DisksCard', () => ({
DisksCard: () => <div data-testid="disks-card">disks</div>,
}));
const makeNode = (overrides: Partial<Node> = {}): 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> = {}): 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(() => (
<InfrastructureDetailsDrawer node={makeNode({ linkedAgentId: 'agent-host-1' })} />
));
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(() => (
<InfrastructureDetailsDrawer
node={makeNode({ linkedAgentId: 'agent-host-1' })}
agent={makeAgent({ id: 'agent-explicit-1', hostname: 'pve1.explicit' })}
/>
));
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(() => (
<InfrastructureDetailsDrawer
node={makeNode({ linkedAgentId: 'agent-linked-1' })}
agent={makeAgent({
id: 'agent-explicit-1',
hostname: 'pve1.explicit',
platformData: {
linkedAgentId: 'agent-linked-1',
},
} as Partial<Agent> & { 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',
}),
);
});
});
@@ -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(() => <SearchHarness />);
@@ -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 (
<div>
<button type="button">Outside</button>
<CollapsibleSearchInput
value={value}
onChange={setValue}
placeholder="Collapsed search"
/>
</div>
);
};
render(() => <CollapsibleHarness />);
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 (
<div>
<button type="button">Outside</button>
<CollapsibleSearchInput value={value} onChange={setValue} placeholder="Helper search" />
</div>
);
};
render(() => <CollapsibleHarness />);
expect(focusActiveTypeToSearch({ selectText: true })).toBe(true);
await waitFor(() => {
const input = screen.getByPlaceholderText('Helper search');
expect(document.activeElement).toBe(input);
});
});
});
@@ -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 });
});
});
@@ -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<T>(value: Accessor<T>, delay: number = 300): Accessor<T> {
const [debouncedValue, setDebouncedValue] = createSignal<T>(value());
createEffect(() => {
const currentValue = value();
const timer = setTimeout(() => {
setDebouncedValue(() => currentValue);
}, delay);
onCleanup(() => clearTimeout(timer));
});
return debouncedValue;
}
@@ -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<RecoveryPointsFacetsResponse> {
const url = buildURL(query);
const response = await apiFetchJSON<RawRecoveryPointsFacetsResponse>(url);
return {
data: normalizeFacets(response.data || {}),
};
}
export function useRecoveryPointsFacets(query?: Accessor<RecoveryFacetsQuery | null | undefined>) {
const source = createMemo<string | null>(() => {
if (!query) return '__all__';
const q = query();
if (!q) return null;
return serializeQuery(q);
});
const state = createNonSuspendingQuery<RecoveryPointsFacetsResponse, string>({
source,
cacheKey: (key) => `recovery-facets:${key}`,
fetcher: async (key) => fetchFacets(parseSerializedQuery(key)),
initialValue: { data: {} },
pollMs: REFRESH_MS,
});
const facets = createMemo<RecoveryPointsFacets>(() => state.value().data || {});
const response = {
get error() {
return state.error();
},
get loading() {
return state.loading();
},
};
return {
response,
facets,
refetch: state.refetch,
resolvedOnce: state.resolvedOnce,
};
}
@@ -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<RecoveryPointsSeriesResponse> {
const url = buildURL(query);
return apiFetchJSON<RecoveryPointsSeriesResponse>(url);
}
export function useRecoveryPointsSeries(query?: Accessor<RecoverySeriesQuery | null | undefined>) {
const source = createMemo<string | null>(() => {
if (!query) return '__all__';
const q = query();
if (!q) return null;
return serializeQuery(q);
});
const state = createNonSuspendingQuery<RecoveryPointsSeriesResponse, string>({
source,
cacheKey: (key) => `recovery-series:${key}`,
fetcher: async (key) => fetchSeries(parseSerializedQuery(key)),
initialValue: { data: [] },
pollMs: REFRESH_MS,
});
const series = createMemo<RecoveryPointsSeriesBucket[]>(() => state.value().data || []);
const response = {
get error() {
return state.error();
},
get loading() {
return state.loading();
},
};
return {
response,
series,
refetch: state.refetch,
resolvedOnce: state.resolvedOnce,
};
}
@@ -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<ProtectionRollup[]> {
const rollups: ProtectionRollup[] = [];
for (let page = 1; page <= MAX_PAGES; page += 1) {
const url = buildURL(page, PAGE_LIMIT, query);
const response = await apiFetchJSON<RecoveryRollupsTransportResponse>(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<string | null>(() => {
if (!query) return '__all__';
const q = query();
if (!q) return null;
return serializeQuery(q);
});
const state = createNonSuspendingQuery<ProtectionRollup[], string>({
source,
cacheKey: (key) => `recovery-rollups:${key}`,
fetcher: async (key) => fetchRecoveryRollups(parseSerializedQuery(key)),
initialValue: [],
pollMs: REFRESH_MS,
});
const rollups = (() => state.value()) as Accessor<ProtectionRollup[]> & {
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,
};
}
@@ -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');
});
});
@@ -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);
});
});
@@ -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' });
});
});
@@ -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<DeployTargetStatus');
expect(deployCandidatesStepSource).toContain('getDeployCandidatesLoadingState');
expect(deployCandidatesStepSource).toContain('getDeployNoSourceAgentsState');
expect(deployCandidatesStepSource).toContain('getDeployNoCandidatesState');
expect(deployCandidatesStepSource).not.toContain('Loading cluster nodes...');
expect(deployCandidatesStepSource).not.toContain('No nodes found in this cluster.');
expect(deployCandidatesStepSource).not.toContain('No online source agents found.');
expect(deployResultsStepSource).toContain('getDeployInstallCommandLoadingState');
expect(deployResultsStepSource).not.toContain('Loading install command...');
for (const source of [
deployCandidatesStepSource,
deployConfirmStepSource,
deployDeployingStepSource,
deployPreflightStepSource,
deployResultsStepSource,
]) {
expect(source).toContain("from '@/components/shared/Table'");
expect(source).not.toContain('<table');
@@ -3509,18 +3395,6 @@ describe('frontend resource type boundaries', () => {
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');
@@ -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');
});
});
@@ -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<ClusterEndpoint, 'pulseReachable'>,
): ClusterEndpointPulseStatus {
if (endpoint.pulseReachable === null || endpoint.pulseReachable === undefined) return 'checking';
return endpoint.pulseReachable ? 'reachable' : 'unreachable';
}
export function getClusterEndpointPresentation(
endpoint: Pick<ClusterEndpoint, 'online' | 'pulseReachable'>,
): 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,
};
}
@@ -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;
}
@@ -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<string, unknown>)
: undefined;
return resolveConfiguredNodeStatusIndicator({
configuredStatus: node.status,
liveStatus: stateNode?.status,
connectionHealth: platformData?.connectionHealth as string | undefined,
});
}
export function resolveConfiguredInstanceStatusIndicator(
node: Pick<NodeConfigWithStatus, 'status' | 'name'>,
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,
});
}
@@ -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<string, string> = {
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<string, { headerClass: string; minWidthPx: number }> =
{
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';
}