mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 05:57:27 +00:00
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:
-462
@@ -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>
|
||||
);
|
||||
};
|
||||
-569
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
-421
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
-496
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user