diff --git a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts index ca7171d9a4..4cf36bf36f 100644 --- a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts +++ b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts @@ -291,14 +291,14 @@ describe('useAppRuntimeState', () => { throw new Error(`Unhandled apiFetch URL: ${url}`); }); - const demoModeModule = await import('@/stores/demoMode'); + const presentationPolicyModule = await import('@/stores/sessionPresentationPolicy'); const { hookState, dispose } = mountHook(); await flushAsync(); await flushAsync(); - expect(demoModeModule.demoModeResolved()).toBe(true); - expect(demoModeModule.demoModeEnabled()).toBe(true); + expect(presentationPolicyModule.sessionPresentationPolicyResolved()).toBe(true); + expect(presentationPolicyModule.presentationPolicyIsDemoMode()).toBe(true); expect(aiChatSetEnabledMock).toHaveBeenCalledWith(true); expect(orgsListMock).not.toHaveBeenCalled(); expect(hookState.organizations()).toEqual([ diff --git a/frontend-modern/src/components/Commercial/MonitoredSystemDefinitionDisclosure.tsx b/frontend-modern/src/components/Commercial/MonitoredSystemDefinitionDisclosure.tsx deleted file mode 100644 index d79284c351..0000000000 --- a/frontend-modern/src/components/Commercial/MonitoredSystemDefinitionDisclosure.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Show, createEffect, createSignal, type Component } from 'solid-js'; -import { - getMonitoredSystemBriefSummary, - getMonitoredSystemDisclosureDefinition, - getMonitoredSystemDisclosureToggleLabel, -} from '@/utils/monitoredSystemPresentation'; - -interface MonitoredSystemDefinitionDisclosureProps { - showSummary?: boolean; - defaultOpen?: boolean; - class?: string; - summaryClass?: string; - buttonClass?: string; - detailClass?: string; -} - -export const MonitoredSystemDefinitionDisclosure: Component< - MonitoredSystemDefinitionDisclosureProps -> = (props) => { - const [open, setOpen] = createSignal(Boolean(props.defaultOpen)); - - createEffect(() => { - setOpen(Boolean(props.defaultOpen)); - }); - - return ( -
- -

- {getMonitoredSystemBriefSummary()} -

-
- -
- - - -

- {getMonitoredSystemDisclosureDefinition()} -

-
-
-
- ); -}; - -export default MonitoredSystemDefinitionDisclosure; diff --git a/frontend-modern/src/components/Commercial/__tests__/MonitoredSystemDefinitionDisclosure.test.tsx b/frontend-modern/src/components/Commercial/__tests__/MonitoredSystemDefinitionDisclosure.test.tsx deleted file mode 100644 index 002117d27b..0000000000 --- a/frontend-modern/src/components/Commercial/__tests__/MonitoredSystemDefinitionDisclosure.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { fireEvent, render, screen } from '@solidjs/testing-library'; - -import { MonitoredSystemDefinitionDisclosure } from '../MonitoredSystemDefinitionDisclosure'; - -describe('MonitoredSystemDefinitionDisclosure', () => { - it('shows concise copy first and reveals the full definition on demand', async () => { - render(() => ); - - expect( - screen.getByText( - 'Pulse counts top-level monitored systems. Child resources underneath them are included.', - ), - ).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'View counting rules' })).toHaveAttribute( - 'aria-expanded', - 'false', - ); - expect( - screen.queryByText(/a monitored system is a top-level monitored root/i), - ).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'View counting rules' })); - - expect(screen.getByRole('button', { name: 'Hide counting rules' })).toHaveAttribute( - 'aria-expanded', - 'true', - ); - expect( - screen.getByText(/a monitored system is a top-level monitored root/i), - ).toBeInTheDocument(); - expect(screen.getByText(/docker host, kubernetes cluster, proxmox node/i)).toBeInTheDocument(); - }); - - it('does not expose brief summary copy unless requested', () => { - render(() => ); - - expect( - screen.queryByText( - 'Pulse counts top-level monitored systems. Child resources underneath them are included.', - ), - ).not.toBeInTheDocument(); - }); - - it('supports route-driven default-open disclosure state', () => { - render(() => ); - - expect(screen.getByRole('button', { name: 'Hide counting rules' })).toHaveAttribute( - 'aria-expanded', - 'true', - ); - expect( - screen.getByText(/a monitored system is a top-level monitored root/i), - ).toBeInTheDocument(); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/DeployStatusBadge.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/DeployStatusBadge.test.tsx deleted file mode 100644 index 076f1b2c0e..0000000000 --- a/frontend-modern/src/components/Infrastructure/__tests__/DeployStatusBadge.test.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, expect, it, afterEach } from 'vitest'; -import { render, screen, cleanup } from '@solidjs/testing-library'; -import { createSignal } from 'solid-js'; -import { DeployStatusBadge } from '../deploy/DeployStatusBadge'; -import type { DeployTargetStatus } from '@/types/agentDeploy'; - -afterEach(() => cleanup()); - -describe('DeployStatusBadge', () => { - const cases: [DeployTargetStatus, string][] = [ - ['pending', 'Pending'], - ['preflighting', 'Checking'], - ['ready', 'Ready'], - ['installing', 'Installing'], - ['enrolling', 'Enrolling'], - ['verifying', 'Verifying'], - ['succeeded', 'Deployed'], - ['failed_retryable', 'Failed'], - ['failed_permanent', 'Failed'], - ['skipped_already_agent', 'Already monitored'], - ['canceled', 'Canceled'], - ]; - - it.each(cases)('renders "%s" status as "%s"', (status, label) => { - render(() => ); - expect(screen.getByText(label)).toBeInTheDocument(); - }); - - it('applies animate-pulse for in-progress statuses', () => { - const inProgress: DeployTargetStatus[] = [ - 'preflighting', - 'installing', - 'enrolling', - 'verifying', - ]; - for (const status of inProgress) { - const { unmount } = render(() => ); - const badge = screen.getByText( - status === 'preflighting' - ? 'Checking' - : status === 'installing' - ? 'Installing' - : status === 'enrolling' - ? 'Enrolling' - : 'Verifying', - ); - expect(badge.className).toContain('animate-pulse'); - unmount(); - } - }); - - it('does not apply animate-pulse for non-progress statuses', () => { - const nonProgress: [DeployTargetStatus, string][] = [ - ['pending', 'Pending'], - ['ready', 'Ready'], - ['succeeded', 'Deployed'], - ['failed_retryable', 'Failed'], - ['failed_permanent', 'Failed'], - ['skipped_already_agent', 'Already monitored'], - ['canceled', 'Canceled'], - ]; - for (const [status, label] of nonProgress) { - const { unmount } = render(() => ); - const badge = screen.getByText(label); - expect(badge.className).not.toContain('animate-pulse'); - unmount(); - } - }); - - it('uses theme tokens (not gray-*) for pending and canceled', () => { - for (const status of ['pending', 'canceled'] as DeployTargetStatus[]) { - const { unmount } = render(() => ); - const badge = screen.getByText(status === 'pending' ? 'Pending' : 'Canceled'); - expect(badge.className).toContain('bg-surface-alt'); - expect(badge.className).toContain('text-muted'); - expect(badge.className).not.toContain('gray'); - unmount(); - } - }); - - it('applies emerald classes to success statuses (ready, succeeded)', () => { - for (const status of ['ready', 'succeeded'] as DeployTargetStatus[]) { - const { unmount } = render(() => ); - const badge = screen.getByText(status === 'ready' ? 'Ready' : 'Deployed'); - expect(badge.className).toContain('bg-emerald-100'); - expect(badge.className).toContain('text-emerald-700'); - unmount(); - } - }); - - it('applies red classes to failure statuses', () => { - for (const status of ['failed_retryable', 'failed_permanent'] as DeployTargetStatus[]) { - const { unmount } = render(() => ); - const badge = screen.getByText('Failed'); - expect(badge.className).toContain('bg-red-100'); - expect(badge.className).toContain('text-red-700'); - unmount(); - } - }); - - it('applies amber classes to skipped statuses', () => { - for (const status of ['skipped_already_agent'] as DeployTargetStatus[]) { - const { unmount } = render(() => ); - const badge = screen.getByText('Already monitored'); - expect(badge.className).toContain('bg-amber-100'); - expect(badge.className).toContain('text-amber-700'); - unmount(); - } - }); - - it('applies blue classes to in-progress statuses', () => { - const labels: Record = { - preflighting: 'Checking', - installing: 'Installing', - enrolling: 'Enrolling', - verifying: 'Verifying', - }; - for (const status of Object.keys(labels) as DeployTargetStatus[]) { - const { unmount } = render(() => ); - const badge = screen.getByText(labels[status]); - expect(badge.className).toContain('bg-blue-100'); - expect(badge.className).toContain('text-blue-700'); - unmount(); - } - }); - - it('renders as an inline-flex rounded-full span with whitespace-nowrap', () => { - render(() => ); - const badge = screen.getByText('Pending'); - expect(badge.tagName).toBe('SPAN'); - expect(badge.className).toContain('inline-flex'); - expect(badge.className).toContain('rounded-full'); - expect(badge.className).toContain('whitespace-nowrap'); - }); - - it('falls back to pending config for an unknown status value', () => { - render(() => ); - const badge = screen.getByText('Pending'); - expect(badge.className).toContain('bg-surface-alt'); - expect(badge.className).toContain('text-muted'); - }); - - it('updates label and classes reactively when status changes', () => { - const [status, setStatus] = createSignal('pending'); - render(() => ); - - const pendingBadge = screen.getByText('Pending'); - expect(pendingBadge.className).toContain('bg-surface-alt'); - - setStatus('succeeded'); - const deployedBadge = screen.getByText('Deployed'); - expect(deployedBadge.className).toContain('bg-emerald-100'); - expect(deployedBadge.className).not.toContain('bg-surface-alt'); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/deploy/DeployStatusBadge.tsx b/frontend-modern/src/components/Infrastructure/deploy/DeployStatusBadge.tsx deleted file mode 100644 index 16f464841e..0000000000 --- a/frontend-modern/src/components/Infrastructure/deploy/DeployStatusBadge.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { Component } from 'solid-js'; -import type { DeployTargetStatus } from '@/types/agentDeploy'; -import { getDeployStatusPresentation } from '@/utils/deployStatusPresentation'; - -interface DeployStatusBadgeProps { - status: DeployTargetStatus; -} - -export const DeployStatusBadge: Component = (props) => { - const config = () => getDeployStatusPresentation(props.status); - - return ( - - {config().label} - - ); -}; diff --git a/frontend-modern/src/components/Infrastructure/deploy/ErrorDetail.tsx b/frontend-modern/src/components/Infrastructure/deploy/ErrorDetail.tsx deleted file mode 100644 index 7c05679160..0000000000 --- a/frontend-modern/src/components/Infrastructure/deploy/ErrorDetail.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Component, Show, createSignal } from 'solid-js'; - -/** Maps common backend error substrings to user-friendly hints. */ -function friendlyErrorHint(msg: string): string | undefined { - const lower = msg.toLowerCase(); - if (lower.includes('connection refused')) - return 'The target node refused the SSH connection. Verify SSH is running and the port is open.'; - if (lower.includes('permission denied')) - return 'SSH authentication failed. Check that the source agent has key-based access to this node.'; - if (lower.includes('timed out') || lower.includes('timeout')) - return 'The connection timed out. Check network connectivity and firewall rules.'; - if (lower.includes('no route to host')) - return 'No network route to the target. Verify the IP address and that both nodes are on the same network.'; - if (lower.includes('host key verification')) - return 'SSH host key verification failed. The target may need to be added to known_hosts.'; - return undefined; -} - -const INLINE_MAX = 60; - -interface ErrorDetailProps { - message: string | undefined; -} - -export const ErrorDetail: Component = (props) => { - const [expanded, setExpanded] = createSignal(false); - - const isLong = () => (props.message?.length ?? 0) > INLINE_MAX; - const hint = () => (props.message ? friendlyErrorHint(props.message) : undefined); - - return ( - -
- - {props.message} - -

{hint()}

-
- - } - > - - {props.message!.slice(0, INLINE_MAX)}…{' '} - - - } - > - {props.message}{' '} - - -

{hint()}

-
-
-
-
-
- ); -}; diff --git a/frontend-modern/src/components/Infrastructure/deploy/__tests__/ErrorDetail.test.tsx b/frontend-modern/src/components/Infrastructure/deploy/__tests__/ErrorDetail.test.tsx deleted file mode 100644 index ebdeee699c..0000000000 --- a/frontend-modern/src/components/Infrastructure/deploy/__tests__/ErrorDetail.test.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it, vi, afterEach } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@solidjs/testing-library'; -import { ErrorDetail } from '../ErrorDetail'; - -afterEach(cleanup); - -describe('ErrorDetail', () => { - /* ── Rendering basics ──────────────────────────────────────────── */ - - it('renders nothing when message is undefined', () => { - const { container } = render(() => ); - expect(container.innerHTML).toBe(''); - }); - - it('renders nothing when message is empty string', () => { - // Show when={props.message} is falsy for empty string - const { container } = render(() => ); - expect(container.innerHTML).toBe(''); - }); - - it('renders a short message inline without truncation', () => { - render(() => ); - expect(screen.getByText('Something went wrong')).toBeInTheDocument(); - // No "more" button for short messages - expect(screen.queryByText('more')).not.toBeInTheDocument(); - }); - - /* ── Long messages (> 60 chars) ────────────────────────────────── */ - - const longMessage = - 'This is a very long error message that exceeds the sixty character inline limit for display purposes'; - - it('truncates long messages and shows "more" button', () => { - render(() => ); - // Should show truncated text (first 60 chars + ellipsis) - expect( - screen.getByText(/This is a very long error message that exceeds the sixty ch/), - ).toBeInTheDocument(); - expect(screen.getByText('more')).toBeInTheDocument(); - // Full message should NOT be visible initially - expect(screen.queryByText(longMessage)).not.toBeInTheDocument(); - }); - - it('expands to full message when "more" is clicked', async () => { - render(() => ); - await fireEvent.click(screen.getByText('more')); - // Full message should now be visible - expect(screen.getByText(longMessage)).toBeInTheDocument(); - // "less" button should appear - expect(screen.getByText('less')).toBeInTheDocument(); - // "more" button should be gone - expect(screen.queryByText('more')).not.toBeInTheDocument(); - }); - - it('collapses back when "less" is clicked', async () => { - render(() => ); - await fireEvent.click(screen.getByText('more')); - expect(screen.getByText('less')).toBeInTheDocument(); - - await fireEvent.click(screen.getByText('less')); - // Should be truncated again - expect(screen.getByText('more')).toBeInTheDocument(); - expect(screen.queryByText(longMessage)).not.toBeInTheDocument(); - }); - - it('stops event propagation on "more" click', async () => { - const parentHandler = vi.fn(); - render(() => ( -
- -
- )); - await fireEvent.click(screen.getByText('more')); - expect(parentHandler).not.toHaveBeenCalled(); - }); - - it('stops event propagation on "less" click', async () => { - const parentHandler = vi.fn(); - render(() => ( -
- -
- )); - // Expand first - await fireEvent.click(screen.getByText('more')); - // Now collapse - await fireEvent.click(screen.getByText('less')); - expect(parentHandler).not.toHaveBeenCalled(); - }); - - /* ── Boundary: exactly 60 chars ─────────────────────────────────── */ - - it('does not truncate a message of exactly 60 characters', () => { - const exact60 = 'A'.repeat(60); - render(() => ); - expect(screen.getByText(exact60)).toBeInTheDocument(); - expect(screen.queryByText('more')).not.toBeInTheDocument(); - }); - - it('truncates a message of 61 characters', () => { - const msg61 = 'B'.repeat(61); - render(() => ); - expect(screen.getByText('more')).toBeInTheDocument(); - // Verify truncated portion is shown (first 60 chars + ellipsis) - expect(screen.getByText(/B{60}…/)).toBeInTheDocument(); - // Full message should not be visible - expect(screen.queryByText(msg61)).not.toBeInTheDocument(); - }); - - /* ── Friendly error hints ──────────────────────────────────────── */ - - it('shows hint for "connection refused" errors', () => { - render(() => ); - expect(screen.getByText(/SSH connection.*Verify SSH is running/)).toBeInTheDocument(); - }); - - it('shows hint for "permission denied" errors', () => { - render(() => ); - expect(screen.getByText(/SSH authentication failed/)).toBeInTheDocument(); - }); - - it('shows hint for "timed out" errors', () => { - render(() => ); - expect(screen.getByText(/connection timed out.*network connectivity/)).toBeInTheDocument(); - }); - - it('shows hint for "timeout" errors', () => { - render(() => ); - expect(screen.getByText(/connection timed out.*network connectivity/)).toBeInTheDocument(); - }); - - it('shows hint for "no route to host" errors', () => { - render(() => ); - expect(screen.getByText(/No network route.*Verify the IP address/)).toBeInTheDocument(); - }); - - it('shows hint for "host key verification" errors', () => { - render(() => ); - expect(screen.getByText(/host key verification failed.*known_hosts/)).toBeInTheDocument(); - }); - - it('does not show a hint for unknown error messages', () => { - render(() => ); - expect(screen.getByText('something unexpected')).toBeInTheDocument(); - // No hint paragraph — only the error text should be present - const container = screen.getByText('something unexpected').closest('div'); - expect(container?.querySelectorAll('p').length).toBe(0); - }); - - it('does not show hint while long message is collapsed, shows it when expanded', async () => { - const longConnectionError = - 'Failed to connect to node-01.example.com:22 — dial tcp 10.0.0.1:22: connection refused after 3 retries'; - render(() => ); - - // While collapsed, hint should NOT be visible - expect(screen.queryByText(/SSH connection.*Verify SSH is running/)).not.toBeInTheDocument(); - - // Expand - await fireEvent.click(screen.getByText('more')); - - // After expanding, hint and full message should be visible - expect(screen.getByText(/SSH connection.*Verify SSH is running/)).toBeInTheDocument(); - expect(screen.getByText(longConnectionError)).toBeInTheDocument(); - }); - - it('hint matching is case-insensitive', () => { - render(() => ); - expect(screen.getByText(/SSH connection.*Verify SSH is running/)).toBeInTheDocument(); - }); -}); diff --git a/frontend-modern/src/components/PMG/MailGateway.tsx b/frontend-modern/src/components/PMG/MailGateway.tsx deleted file mode 100644 index 1d02172145..0000000000 --- a/frontend-modern/src/components/PMG/MailGateway.tsx +++ /dev/null @@ -1,898 +0,0 @@ -import { Component, Show, For, createMemo, createSignal } from 'solid-js'; -import { useTooltip } from '@/hooks/useTooltip'; -import { TooltipPortal } from '@/components/shared/TooltipPortal'; -import { useWebSocket } from '@/contexts/appRuntime'; -import { Card } from '@/components/shared/Card'; -import { - Table, - TableHeader, - TableBody, - TableRow, - TableHead, - TableCell, -} from '@/components/shared/Table'; -import { EmptyState } from '@/components/shared/EmptyState'; -import { SearchInput } from '@/components/shared/SearchInput'; -import { formatRelativeTime, formatBytes } from '@/utils/format'; -import { getPMGOldestAgeTextClass, getPMGQueueTextClass } from '@/utils/pmgQueuePresentation'; -import { - getPMGDisconnectedState, - getPMGSearchEmptyState, - PMG_EMPTY_STATE_DESCRIPTION, - PMG_EMPTY_STATE_TITLE, - PMG_LOADING_STATE_DESCRIPTION, - PMG_LOADING_STATE_TITLE, - PMG_SEARCH_PLACEHOLDER, -} from '@/utils/pmgPresentation'; -import { getPMGThreatPresentation } from '@/utils/pmgThreatPresentation'; -import { pmgInstanceFromResource } from '@/utils/resourceStateAdapters'; -import { ServiceHealthBadge } from './ServiceHealthBadge'; - -// Format large numbers with K/M suffixes -const formatCompact = (value?: number | null): string => { - if (value === undefined || value === null || Number.isNaN(value)) return '—'; - if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`; - if (value >= 1000) return `${(value / 1000).toFixed(1)}K`; - return Math.round(value).toLocaleString(); -}; - -const formatNum = (value?: number | null) => { - if (value === undefined || value === null || Number.isNaN(value)) return '—'; - return Math.round(value).toLocaleString(); -}; - -const formatDec = (value?: number | null, digits = 1) => { - if (value === undefined || value === null || Number.isNaN(value)) return '—'; - return value.toFixed(digits); -}; - -const formatPct = (value?: number | null) => { - if (value === undefined || value === null || Number.isNaN(value)) return '0%'; - return `${value.toFixed(1)}%`; -}; - -const parseTimestamp = (value?: string) => { - if (!value) return undefined; - const time = Date.parse(value); - return Number.isNaN(time) ? undefined : time; -}; - -// Threat rate bar component -const ThreatBar: Component<{ - percent: number; - color: 'spam' | 'virus' | 'quarantine'; - label: string; - count: number; -}> = (props) => { - const presentation = () => getPMGThreatPresentation(props.color); - - return ( -
-
- {props.label} - - {formatCompact(props.count)} ({formatPct(props.percent)}) - -
-
-
-
-
- ); -}; - -// Queue depth indicator with tooltip -const QueueIndicator: Component<{ - queue?: { - total: number; - active?: number; - deferred?: number; - hold?: number; - incoming?: number; - oldestAge?: number; - }; -}> = (props) => { - const tip = useTooltip(); - - const queueTotal = () => props.queue?.total || 0; - const hasQueue = () => queueTotal() > 0; - - return ( - <> -
{ - if (props.queue) tip.onMouseEnter(e); - }} - onMouseLeave={tip.onMouseLeave} - > - Empty}> - - {formatNum(queueTotal())} msgs - - -
- - -
-
- Queue Breakdown -
-
-
- Active - {props.queue?.active || 0} -
-
- Deferred - {props.queue?.deferred || 0} -
-
- Hold - {props.queue?.hold || 0} -
-
- Incoming - {props.queue?.incoming || 0} -
- 0}> -
- Oldest - - {Math.floor((props.queue?.oldestAge || 0) / 60)}m - -
-
-
-
-
- - ); -}; - -const MailGateway: Component = () => { - const { state, connected, reconnecting, reconnect, initialDataReceived } = useWebSocket(); - - const [searchTerm, setSearchTerm] = createSignal(''); - const disconnectedState = createMemo(() => getPMGDisconnectedState(reconnecting())); - const searchEmptyState = createMemo(() => getPMGSearchEmptyState(searchTerm())); - - const instances = createMemo(() => - (state.resources || []) - .filter((resource) => resource.type === 'pmg') - .map(pmgInstanceFromResource) - .filter((instance): instance is NonNullable => Boolean(instance)), - ); - const hasInstances = createMemo(() => instances().length > 0); - - // Filter instances by search - const filteredInstances = createMemo(() => { - const term = searchTerm().toLowerCase().trim(); - if (!term) return instances(); - return instances().filter( - (pmg) => - pmg.name.toLowerCase().includes(term) || - (pmg.host || '').toLowerCase().includes(term) || - (pmg.version || '').toLowerCase().includes(term), - ); - }); - - // Aggregate stats across all instances - const aggregateStats = createMemo(() => { - const pmgs = instances(); - let totalMail = 0; - let totalSpam = 0; - let totalVirus = 0; - let totalQuarantine = 0; - let totalQueue = 0; - let healthyCount = 0; - let issueCount = 0; - - for (const pmg of pmgs) { - totalMail += pmg.mailStats?.countTotal || 0; - totalSpam += pmg.mailStats?.spamIn || 0; - totalVirus += pmg.mailStats?.virusIn || 0; - - const q = pmg.quarantine; - if (q) { - totalQuarantine += - (q.spam || 0) + (q.virus || 0) + (q.attachment || 0) + (q.blacklisted || 0); - } - - // Sum queue across all nodes - if (pmg.nodes) { - for (const node of pmg.nodes) { - totalQueue += node.queueStatus?.total || 0; - } - } - - const status = (pmg.connectionHealth || pmg.status || '').toLowerCase(); - if (status.includes('healthy') || status === 'online') { - healthyCount++; - } else { - issueCount++; - } - } - - const spamRate = totalMail > 0 ? (totalSpam / totalMail) * 100 : 0; - const virusRate = totalMail > 0 ? (totalVirus / totalMail) * 100 : 0; - - return { - totalMail, - totalSpam, - totalVirus, - totalQuarantine, - totalQueue, - spamRate, - virusRate, - healthyCount, - issueCount, - instanceCount: pmgs.length, - }; - }); - - const isLoading = createMemo(() => connected() && !initialDataReceived()); - - return ( -
- {/* Loading State */} - - - - - - - } - title={PMG_LOADING_STATE_TITLE} - description={PMG_LOADING_STATE_DESCRIPTION} - /> - - - - {/* Disconnected State */} - - - - - - } - title={disconnectedState().title} - description={disconnectedState().description} - tone="danger" - actions={ - disconnectedState().actionLabel ? ( - - ) : undefined - } - /> - - - - - {/* No Instances Empty State */} - - - - - - } - title={PMG_EMPTY_STATE_TITLE} - description={PMG_EMPTY_STATE_DESCRIPTION} - /> - - - - {/* Has Instances - Show Content */} - - {/* Summary Cards */} -
- {/* Total Mail */} - -
-
-
- Mail (24h) -
-
- {formatCompact(aggregateStats().totalMail)} -
-
- ~{formatNum(Math.round(aggregateStats().totalMail / 24))}/hr -
-
-
- - - -
-
-
- - {/* Spam Rate */} - 50 ? 'warning' : 'card'}> -
-
-
Spam
-
50 ? 'text-orange-600 dark:text-orange-400' : 'text-base-content'}`} - > - {formatPct(aggregateStats().spamRate)} -
-
- {formatCompact(aggregateStats().totalSpam)} caught -
-
-
50 ? 'bg-orange-100 dark:bg-orange-900' : 'bg-orange-100 dark:bg-orange-900'}`} - > - - - -
-
-
- - {/* Virus Blocked */} - 0 ? 'danger' : 'card'}> -
-
-
Viruses
-
0 ? 'text-red-600 dark:text-red-400' : 'text-base-content'}`} - > - {formatCompact(aggregateStats().totalVirus)} -
-
blocked today
-
-
0 ? 'bg-red-100 dark:bg-red-900' : 'bg-surface-alt'}`} - > - 0 ? 'text-red-600 dark:text-red-400' : 'text-slate-400'}`} - fill="none" - viewBox="0 0 24 24" - stroke="currentColor" - stroke-width="1.5" - > - - -
-
-
- - {/* Quarantine */} - -
-
-
- Quarantine -
-
- {formatCompact(aggregateStats().totalQuarantine)} -
-
items held
-
-
- - - -
-
-
- - {/* Queue Status */} - 50 ? 'warning' : 'card'}> -
-
-
Queue
-
100 - ? 'text-red-600 dark:text-red-400' - : aggregateStats().totalQueue > 20 - ? 'text-yellow-600 dark:text-yellow-400' - : 'text-green-600 dark:text-green-400' - }`} - > - {formatNum(aggregateStats().totalQueue)} -
-
- {aggregateStats().totalQueue === 0 ? 'all clear' : 'pending'} -
-
-
50 - ? 'bg-yellow-100 dark:bg-yellow-900' - : 'bg-green-100 dark:bg-green-900' - }`} - > - 50 - ? 'text-yellow-600 dark:text-yellow-400' - : 'text-green-600 dark:text-green-400' - }`} - fill="none" - viewBox="0 0 24 24" - stroke="currentColor" - stroke-width="1.5" - > - - -
-
-
-
- - {/* Search Bar (only show if multiple instances) */} - 1}> - - - - - - {/* Instance Cards */} - 0} - fallback={ - -
- - - -
{searchEmptyState().description}
- -
-
- } - > -
- - {(pmg) => { - const lastUpdated = () => { - const ts = - parseTimestamp(pmg.mailStats?.updatedAt) ?? - parseTimestamp(pmg.lastUpdated) ?? - parseTimestamp(pmg.lastSeen); - return ts ? formatRelativeTime(ts) : '—'; - }; - - const stats = createMemo(() => { - const m = pmg.mailStats; - const total = m?.countTotal || 0; - const inbound = m?.countIn || 0; - const outbound = m?.countOut || 0; - const spam = m?.spamIn || 0; - const virus = m?.virusIn || 0; - const bytesIn = m?.bytesIn || 0; - const bytesOut = m?.bytesOut || 0; - const rbl = m?.rblRejects || 0; - const pregreet = m?.pregreetRejects || 0; - const greylist = m?.greylistCount || 0; - const bouncesIn = m?.bouncesIn || 0; - const bouncesOut = m?.bouncesOut || 0; - - const q = pmg.quarantine; - const qSpam = q?.spam || 0; - const qVirus = q?.virus || 0; - const qAttachment = q?.attachment || 0; - const qBlacklist = q?.blacklisted || 0; - const qTotal = qSpam + qVirus + qAttachment + qBlacklist; - - const spamPct = total > 0 ? (spam / total) * 100 : 0; - const virusPct = total > 0 ? (virus / total) * 100 : 0; - const quarantinePct = inbound > 0 ? (qTotal / inbound) * 100 : 0; - - return { - total, - inbound, - outbound, - spam, - virus, - bytesIn, - bytesOut, - rbl, - pregreet, - greylist, - bouncesIn, - bouncesOut, - qSpam, - qVirus, - qAttachment, - qBlacklist, - qTotal, - spamPct, - virusPct, - quarantinePct, - }; - }); - - // Get aggregate queue for this instance - const instanceQueue = createMemo(() => { - if (!pmg.nodes) return { total: 0 }; - let total = 0, - active = 0, - deferred = 0, - hold = 0, - incoming = 0, - oldestAge = 0; - for (const node of pmg.nodes) { - const q = node.queueStatus; - if (q) { - total += q.total || 0; - active += q.active || 0; - deferred += q.deferred || 0; - hold += q.hold || 0; - incoming += q.incoming || 0; - if ((q.oldestAge || 0) > oldestAge) oldestAge = q.oldestAge || 0; - } - } - return { total, active, deferred, hold, incoming, oldestAge }; - }); - - return ( - - {/* Instance Header */} -
-
- - {pmg.name} - - - - - v{pmg.version} - - -
-
- - Updated {lastUpdated()} -
-
- - {/* Main Content */} -
- {/* Threat Rate Bars */} -
- - - -
- - {/* Stats Grid */} -
- {/* Mail Flow */} -
-
Total (24h)
-
- {formatCompact(stats().total)} -
-
- {formatNum(Math.round(stats().total / 24))}/hr -
-
-
-
Inbound
-
- {formatCompact(stats().inbound)} -
-
{formatBytes(stats().bytesIn)}
-
-
-
Outbound
-
- {formatCompact(stats().outbound)} -
-
{formatBytes(stats().bytesOut)}
-
- - {/* Threats */} -
-
- Spam -
-
- {formatCompact(stats().spam)} -
-
- {formatPct(stats().spamPct)} rate -
-
-
-
Viruses
-
- {formatCompact(stats().virus)} -
-
- {formatPct(stats().virusPct)} rate -
-
-
-
- Quarantine -
-
- {formatCompact(stats().qTotal)} -
-
- {formatPct(stats().quarantinePct)} of inbound -
-
-
- - {/* Delivery Health Row */} -
-
- Delivery Health -
-
-
- RBL Rejects: - - {formatNum(stats().rbl)} - -
-
- Pregreet: - - {formatNum(stats().pregreet)} - -
-
- Greylisted: - - {formatNum(stats().greylist)} - -
-
- Bounces In/Out: - - {formatNum(stats().bouncesIn)}/{formatNum(stats().bouncesOut)} - -
- -
- Avg Process: - - {formatDec((pmg.mailStats?.averageProcessTimeMs || 0) / 1000, 2)}s - -
-
-
-
- - {/* Cluster Nodes */} - 0}> -
-
- Cluster Nodes ({pmg.nodes?.length}) -
- - - - Node - Status - Uptime - Load - Queue - - - - - {(node) => { - const isOnline = (node.status || '').toLowerCase() === 'online'; - - return ( - - - {node.name} - - - - - {node.status || 'unknown'} - - - - - {Math.floor((node.uptime ?? 0) / 86400)}d{' '} - {Math.floor(((node.uptime ?? 0) % 86400) / 3600)}h - - - - {node.loadAvg || '—'} - - - - - - ); - }} - - -
-
-
- - {/* Spam Distribution */} - 0}> -
-
- Spam Score Distribution -
-
- - {(bucket) => { - const totalScored = - pmg.spamDistribution?.reduce((sum, b) => sum + b.count, 0) || 1; - const pct = (bucket.count / totalScored) * 100; - return ( -
-
{bucket.score}
-
- {formatCompact(bucket.count)} -
-
- {formatDec(pct)}% -
-
- ); - }} -
-
-
-
-
-
- ); - }} -
-
-
-
-
-
- ); -}; - -export default MailGateway; diff --git a/frontend-modern/src/components/PMG/PMGInstancePanel.tsx b/frontend-modern/src/components/PMG/PMGInstancePanel.tsx deleted file mode 100644 index d89c44852d..0000000000 --- a/frontend-modern/src/components/PMG/PMGInstancePanel.tsx +++ /dev/null @@ -1,709 +0,0 @@ -import type { Component } from 'solid-js'; -import { For, Show, createMemo } from 'solid-js'; -import { useTooltip } from '@/hooks/useTooltip'; -import { TooltipPortal } from '@/components/shared/TooltipPortal'; -import { Card } from '@/components/shared/Card'; -import { - Table, - TableHeader, - TableBody, - TableRow, - TableHead, - TableCell, -} from '@/components/shared/Table'; -import { formatRelativeTime, formatBytes } from '@/utils/format'; -import { getPMGOldestAgeTextClass, getPMGQueueTextClass } from '@/utils/pmgQueuePresentation'; -import { getPMGThreatPresentation } from '@/utils/pmgThreatPresentation'; -import type { PMGInstance } from '@/types/api'; -import { ServiceHealthBadge } from './ServiceHealthBadge'; - -// Format large numbers with K/M suffixes -const formatCompact = (value?: number | null): string => { - if (value === undefined || value === null || Number.isNaN(value)) return '—'; - if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`; - if (value >= 1000) return `${(value / 1000).toFixed(1)}K`; - return Math.round(value).toLocaleString(); -}; - -const formatNum = (value?: number | null) => { - if (value === undefined || value === null || Number.isNaN(value)) return '—'; - return Math.round(value).toLocaleString(); -}; - -const formatDec = (value?: number | null, digits = 1) => { - if (value === undefined || value === null || Number.isNaN(value)) return '—'; - return value.toFixed(digits); -}; - -const formatPct = (value?: number | null) => { - if (value === undefined || value === null || Number.isNaN(value)) return '0%'; - return `${value.toFixed(1)}%`; -}; - -const fmtMaybe = (value?: number | null) => { - if (value === undefined || value === null || Number.isNaN(value)) return '—'; - return formatCompact(value); -}; - -const parseTimestamp = (value?: string) => { - if (!value) return undefined; - const time = Date.parse(value); - return Number.isNaN(time) ? undefined : time; -}; - -const ThreatBar: Component<{ - percent: number; - color: 'spam' | 'virus' | 'quarantine'; - label: string; - count: number; -}> = (props) => { - const presentation = () => getPMGThreatPresentation(props.color); - - return ( -
-
- {props.label} - - {formatCompact(props.count)} ({formatPct(props.percent)}) - -
-
-
-
-
- ); -}; - -const QueueIndicator: Component<{ - queue?: { - total: number; - active?: number; - deferred?: number; - hold?: number; - incoming?: number; - oldestAge?: number; - }; -}> = (props) => { - const tip = useTooltip(); - const queueTotal = () => props.queue?.total || 0; - const hasQueue = () => queueTotal() > 0; - - return ( - <> -
{ - if (props.queue) tip.onMouseEnter(e); - }} - onMouseLeave={() => tip.onMouseLeave()} - > - No queue}> - - {formatNum(queueTotal())} msgs - - -
- -
-
-
- Active - {props.queue?.active || 0} -
-
- Deferred - {props.queue?.deferred || 0} -
-
- Hold - {props.queue?.hold || 0} -
-
- Incoming - {props.queue?.incoming || 0} -
- 0}> -
- Oldest - - {Math.floor((props.queue?.oldestAge || 0) / 60)}m - -
-
-
-
-
- - ); -}; - -export const PMGInstancePanel: Component<{ pmg: PMGInstance }> = (props) => { - const lastUpdated = createMemo(() => { - const ts = - parseTimestamp(props.pmg.mailStats?.updatedAt) ?? - parseTimestamp(props.pmg.lastUpdated) ?? - parseTimestamp(props.pmg.lastSeen); - return ts ? formatRelativeTime(ts) : '—'; - }); - - const stats = createMemo(() => { - const m = props.pmg.mailStats; - const total = m?.countTotal || 0; - const inbound = m?.countIn || 0; - const outbound = m?.countOut || 0; - const spam = m?.spamIn || 0; - const virus = m?.virusIn || 0; - const bytesIn = m?.bytesIn || 0; - const bytesOut = m?.bytesOut || 0; - const rbl = m?.rblRejects || 0; - const pregreet = m?.pregreetRejects || 0; - const greylist = m?.greylistCount || 0; - const bouncesIn = m?.bouncesIn || 0; - const bouncesOut = m?.bouncesOut || 0; - - const q = props.pmg.quarantine; - const qSpam = q?.spam || 0; - const qVirus = q?.virus || 0; - const qAttachment = q?.attachment || 0; - const qBlacklist = q?.blacklisted || 0; - const qTotal = qSpam + qVirus + qAttachment + qBlacklist; - - const spamPct = total > 0 ? (spam / total) * 100 : 0; - const virusPct = total > 0 ? (virus / total) * 100 : 0; - const quarantinePct = inbound > 0 ? (qTotal / inbound) * 100 : 0; - - return { - total, - inbound, - outbound, - spam, - virus, - bytesIn, - bytesOut, - rbl, - pregreet, - greylist, - bouncesIn, - bouncesOut, - qTotal, - spamPct, - virusPct, - quarantinePct, - }; - }); - - const instanceQueue = createMemo(() => { - if (!props.pmg.nodes) return { total: 0 }; - let total = 0; - let active = 0; - let deferred = 0; - let hold = 0; - let incoming = 0; - let oldestAge = 0; - for (const node of props.pmg.nodes) { - const q = node.queueStatus; - if (q) { - total += q.total || 0; - active += q.active || 0; - deferred += q.deferred || 0; - hold += q.hold || 0; - incoming += q.incoming || 0; - if ((q.oldestAge || 0) > oldestAge) oldestAge = q.oldestAge || 0; - } - } - return { total, active, deferred, hold, incoming, oldestAge }; - }); - - const domainStatsByDomain = createMemo(() => { - const by = new Map(); - for (const row of props.pmg.domainStats ?? []) { - const key = (row.domain || '').trim().toLowerCase(); - if (!key) continue; - by.set(key, { - mail: row.mailCount || 0, - spam: row.spamCount || 0, - virus: row.virusCount || 0, - bytes: row.bytes || 0, - }); - } - return by; - }); - - const relayDomainsWithStats = createMemo(() => { - const by = domainStatsByDomain(); - const rows = (props.pmg.relayDomains ?? []).map((d) => { - const key = (d.domain || '').trim().toLowerCase(); - const stat = key ? by.get(key) : undefined; - const mail = stat?.mail ?? 0; - const spam = stat?.spam ?? 0; - const virus = stat?.virus ?? 0; - const bytes = stat?.bytes ?? 0; - const spamRate = mail > 0 ? (spam / mail) * 100 : 0; - return { - domain: d.domain, - comment: d.comment, - mail, - spam, - virus, - bytes, - spamRate, - hasStats: stat !== undefined, - }; - }); - - // Stable-ish ordering: domains with stats first, then by spam desc, then by mail desc. - rows.sort((a, b) => { - if (a.hasStats !== b.hasStats) return a.hasStats ? -1 : 1; - if (b.spam !== a.spam) return b.spam - a.spam; - if (b.mail !== a.mail) return b.mail - a.mail; - return (a.domain || '').localeCompare(b.domain || ''); - }); - - return rows; - }); - - const domainStatsAsOfRelative = createMemo(() => { - const raw = props.pmg.domainStatsAsOf; - const ts = raw ? parseTimestamp(raw) : undefined; - return ts ? formatRelativeTime(ts) : ''; - }); - - const relayDomainSet = createMemo(() => { - const set = new Set(); - for (const d of props.pmg.relayDomains ?? []) { - const key = (d.domain || '').trim().toLowerCase(); - if (key) set.add(key); - } - return set; - }); - - const domainStatsRows = createMemo(() => { - const rows = (props.pmg.domainStats ?? []) - .map((row) => { - const domain = (row.domain || '').trim(); - const key = domain.toLowerCase(); - const mail = row.mailCount || 0; - const spam = row.spamCount || 0; - const virus = row.virusCount || 0; - const bytes = row.bytes || 0; - const spamRate = mail > 0 ? (spam / mail) * 100 : 0; - return { domain, key, mail, spam, virus, bytes, spamRate }; - }) - .filter((row) => row.domain !== ''); - - rows.sort((a, b) => { - if (b.spam !== a.spam) return b.spam - a.spam; - if (b.mail !== a.mail) return b.mail - a.mail; - return a.domain.localeCompare(b.domain); - }); - - return rows; - }); - - const otherDomainStatsRows = createMemo(() => { - const relaySet = relayDomainSet(); - return domainStatsRows().filter((row) => !relaySet.has(row.key)); - }); - - return ( - -
-
- - {props.pmg.name} - - - - - v{props.pmg.version} - - -
-
- - Updated {lastUpdated()} -
-
- -
-
- - - -
- -
-
-
Total (24h)
-
{formatCompact(stats().total)}
-
{formatNum(Math.round(stats().total / 24))}/hr
-
-
-
Inbound
-
{formatCompact(stats().inbound)}
-
{formatBytes(stats().bytesIn)}
-
-
-
Outbound
-
{formatCompact(stats().outbound)}
-
{formatBytes(stats().bytesOut)}
-
- -
-
Spam
-
- {formatCompact(stats().spam)} -
-
{formatPct(stats().spamPct)} rate
-
-
-
Viruses
-
- {formatCompact(stats().virus)} -
-
{formatPct(stats().virusPct)} rate
-
-
-
Quarantine
-
- {formatCompact(stats().qTotal)} -
-
- {formatPct(stats().quarantinePct)} of inbound -
-
-
- -
-
- Delivery Health -
-
-
- RBL Rejects: - {formatNum(stats().rbl)} -
-
- Pregreet: - {formatNum(stats().pregreet)} -
-
- Greylisted: - {formatNum(stats().greylist)} -
-
- Bounces In/Out: - - {formatNum(stats().bouncesIn)}/{formatNum(stats().bouncesOut)} - -
- -
- Avg Process: - - {formatDec((props.pmg.mailStats?.averageProcessTimeMs || 0) / 1000, 2)}s - -
-
-
-
- - 0}> -
-
- Cluster Nodes ({props.pmg.nodes?.length}) -
- - - - Node - Status - Uptime - Load - Queue - - - - - {(node) => { - const isOnline = (node.status || '').toLowerCase() === 'online'; - - return ( - - - {node.name} - - - - - {node.status || 'unknown'} - - - - - {Math.floor((node.uptime ?? 0) / 86400)}d{' '} - {Math.floor(((node.uptime ?? 0) % 86400) / 3600)}h - - - {node.loadAvg || '—'} - - - - - ); - }} - - -
-
-
- - 0}> -
-
- Spam Score Distribution -
-
- - {(bucket) => { - const totalScored = - props.pmg.spamDistribution?.reduce((sum, b) => sum + b.count, 0) || 1; - const pct = (bucket.count / totalScored) * 100; - return ( -
-
{bucket.score}
-
- {formatCompact(bucket.count)} -
-
{formatDec(pct)}%
-
- ); - }} -
-
-
-
- - 0}> -
-
-
- Relay Domains (24h) -
-
- - As of {domainStatsAsOfRelative()} - - {formatNum(props.pmg.relayDomains?.length ?? 0)} -
-
- - - - - Domain - Mail - Spam - Virus - Spam Rate - Bytes - Comment - - - - - {(row) => ( - - - {row.domain} - - - {row.hasStats ? fmtMaybe(row.mail) : '—'} - - - {row.hasStats ? fmtMaybe(row.spam) : '—'} - - - {row.hasStats ? fmtMaybe(row.virus) : '—'} - - - {row.hasStats ? formatPct(row.spamRate) : '—'} - - - {row.hasStats && row.bytes > 0 - ? formatBytes(row.bytes) - : row.hasStats - ? '—' - : '—'} - - {row.comment || '—'} - - )} - - -
-
-
- - 0}> -
-
- -
-
- Other Domains (24h) -
-
- - As of {domainStatsAsOfRelative()} - - {formatNum(otherDomainStatsRows().length)} -
-
-
- Domains seen in the last 24 hours that are not configured as relay domains. -
-
- - - - - Domain - Mail - Spam - Virus - Spam Rate - Bytes - - - - - {(row) => ( - - - {row.domain} - - - {fmtMaybe(row.mail)} - - - {fmtMaybe(row.spam)} - - - {fmtMaybe(row.virus)} - - - {formatPct(row.spamRate)} - - - {row.bytes > 0 ? formatBytes(row.bytes) : '—'} - - - )} - - -
-
-
-
- - 0 && (props.pmg.relayDomains?.length ?? 0) === 0 - } - > -
-
-
- Domains (24h) -
-
- - As of {domainStatsAsOfRelative()} - - {formatNum(domainStatsRows().length)} -
-
- - - - - Domain - Mail - Spam - Virus - Spam Rate - Bytes - - - - - {(row) => ( - - - {row.domain} - - - {fmtMaybe(row.mail)} - - - {fmtMaybe(row.spam)} - - - {fmtMaybe(row.virus)} - - - {formatPct(row.spamRate)} - - - {row.bytes > 0 ? formatBytes(row.bytes) : '—'} - - - )} - - -
-
-
-
-
- ); -}; - -export default PMGInstancePanel; diff --git a/frontend-modern/src/components/Storage/__tests__/useDiskLiveMetricModel.test.ts b/frontend-modern/src/components/Storage/__tests__/useDiskLiveMetricModel.test.ts deleted file mode 100644 index fa57a25d5b..0000000000 --- a/frontend-modern/src/components/Storage/__tests__/useDiskLiveMetricModel.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { renderHook } from '@solidjs/testing-library'; -import { createSignal } from 'solid-js'; -import { describe, expect, it, vi } from 'vitest'; -import { useDiskLiveMetricModel } from '@/components/Storage/useDiskLiveMetricModel'; - -vi.mock('@/stores/diskMetricsHistory', () => ({ - getDiskMetricsVersion: () => 1, - getLatestDiskMetric: () => ({ - readBps: 1024, - writeBps: 2048, - ioTime: 72, - }), -})); - -describe('useDiskLiveMetricModel', () => { - it('builds canonical live metric state', () => { - const [resourceId] = createSignal('agent-tower:sda'); - const [type] = createSignal<'read' | 'write' | 'ioTime'>('ioTime'); - - const { result } = renderHook(() => - useDiskLiveMetricModel({ - resourceId, - type, - }), - ); - - expect(result.value()).toBe(72); - expect(result.formatted()).toBe('72%'); - expect(result.colorClass()).toBe('text-yellow-600 dark:text-yellow-400 font-semibold'); - }); -}); diff --git a/frontend-modern/src/components/Storage/__tests__/useZFSHealthMapModel.test.ts b/frontend-modern/src/components/Storage/__tests__/useZFSHealthMapModel.test.ts deleted file mode 100644 index b1fb7c1198..0000000000 --- a/frontend-modern/src/components/Storage/__tests__/useZFSHealthMapModel.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { renderHook } from '@solidjs/testing-library'; -import { describe, expect, it } from 'vitest'; -import type { ZFSPool } from '@/types/api'; -import { useZFSHealthMapModel } from '@/components/Storage/useZFSHealthMapModel'; - -describe('useZFSHealthMapModel', () => { - it('builds canonical health map state', () => { - const pool = () => - ({ - scan: 'resilver in progress', - devices: [ - { - name: 'sda', - type: 'disk', - state: 'ONLINE', - message: '', - readErrors: 0, - writeErrors: 0, - checksumErrors: 0, - }, - ], - }) as unknown as ZFSPool; - - const { result } = renderHook(() => useZFSHealthMapModel(pool)); - - expect(result.devices()).toHaveLength(1); - expect(result.isResilvering(result.devices()[0])).toBe(true); - expect(result.hoveredTooltip()).toBeNull(); - }); -}); diff --git a/frontend-modern/src/components/Storage/useDiskLiveMetricModel.ts b/frontend-modern/src/components/Storage/useDiskLiveMetricModel.ts deleted file mode 100644 index 3a0aafee90..0000000000 --- a/frontend-modern/src/components/Storage/useDiskLiveMetricModel.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from 'solid-js'; -import { getLatestDiskMetric, getDiskMetricsVersion } from '@/stores/diskMetricsHistory'; -import { - getDiskLiveMetricFormattedValue, - getDiskLiveMetricTextClass, - type DiskLiveMetricType, -} from '@/features/storageBackups/diskLiveMetricPresentation'; - -type UseDiskLiveMetricModelOptions = { - resourceId: Accessor; - type: Accessor; -}; - -export const useDiskLiveMetricModel = (options: UseDiskLiveMetricModelOptions) => { - const [version, setVersion] = createSignal(getDiskMetricsVersion()); - - createEffect(() => { - const timer = setInterval(() => setVersion(getDiskMetricsVersion()), 2000); - onCleanup(() => clearInterval(timer)); - }); - - const latestMetric = createMemo(() => { - version(); - return getLatestDiskMetric(options.resourceId()); - }); - - const value = createMemo(() => { - const metric = latestMetric(); - if (!metric) return 0; - - if (options.type() === 'read') return metric.readBps; - if (options.type() === 'write') return metric.writeBps; - return metric.ioTime; - }); - - const formatted = createMemo(() => - getDiskLiveMetricFormattedValue(value(), options.type()), - ); - const colorClass = createMemo(() => getDiskLiveMetricTextClass(value(), options.type())); - - return { - value, - formatted, - colorClass, - }; -}; diff --git a/frontend-modern/src/components/Storage/useZFSHealthMapModel.ts b/frontend-modern/src/components/Storage/useZFSHealthMapModel.ts deleted file mode 100644 index 927ae2b66c..0000000000 --- a/frontend-modern/src/components/Storage/useZFSHealthMapModel.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { createSignal } from 'solid-js'; -import type { ZFSDevice, ZFSPool } from '@/types/api'; -import { - getZfsHealthMapDevices, - getZfsHealthMapTooltipPresentation, - isZfsHealthMapDeviceResilvering, -} from '@/features/storageBackups/zfsHealthMapPresentation'; - -export const useZFSHealthMapModel = (pool: () => ZFSPool) => { - const [hoveredDevice, setHoveredDevice] = createSignal(null); - const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); - - const devices = () => getZfsHealthMapDevices(pool()); - const isResilvering = (device: ZFSDevice) => isZfsHealthMapDeviceResilvering(pool(), device); - const hoveredTooltip = () => - hoveredDevice() ? getZfsHealthMapTooltipPresentation(hoveredDevice()!) : null; - - const handleMouseEnter = (e: MouseEvent, device: ZFSDevice) => { - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); - setHoveredDevice(device); - }; - - const handleMouseLeave = () => { - setHoveredDevice(null); - }; - - return { - devices, - hoveredDevice, - hoveredTooltip, - tooltipPos, - isResilvering, - handleMouseEnter, - handleMouseLeave, - }; -}; diff --git a/frontend-modern/src/components/shared/PageControls.guardrails.test.ts b/frontend-modern/src/components/shared/PageControls.guardrails.test.ts deleted file mode 100644 index cff633eff7..0000000000 --- a/frontend-modern/src/components/shared/PageControls.guardrails.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import pageControlsSource from '@/components/shared/PageControls.tsx?raw'; -import filterToolbarSource from '@/components/shared/FilterToolbar.tsx?raw'; -import workloadsFilterSource from '@/components/Workloads/WorkloadsFilter.tsx?raw'; -import subtabsSource from '@/components/shared/Subtabs.tsx?raw'; - -const tsxSources = import.meta.glob('../../**/*.tsx', { - query: '?raw', - eager: true, - import: 'default', -}) as Record; - -describe('page controls guardrails', () => { - it('keeps canonical page-level controls routed through PageControls', () => { - expect(pageControlsSource).toContain('FilterHeader'); - expect(pageControlsSource).toContain('FilterMobileToggleButton'); - expect(pageControlsSource).toContain('ColumnPicker'); - expect(pageControlsSource).toContain('searchLeading'); - expect(pageControlsSource).toContain('splitProps'); - expect(pageControlsSource).toContain(' local.mobileFilters?.enabled === true;', - ); - expect(pageControlsSource).toContain( - 'const activeMobileTrailing = () => (mobileControlsEnabled() ? local.mobileTrailing : undefined);', - ); - expect(pageControlsSource).toContain( - 'const activeUtilityActions = () => (mobileControlsEnabled() ? undefined : local.utilityActions);', - ); - expect(pageControlsSource).toContain( - 'const activeSearchTrailing = () => (mobileControlsEnabled() ? undefined : local.searchTrailing);', - ); - expect(pageControlsSource).toContain( - 'searchAccessory={activeSearchTrailing() ?? mobileSearchAccessory()}', - ); - - // WorkloadsFilter migrated to FilterBar; defensive guards remain. The - // tag now appears in WorkloadsFilter via the FilterBar - // viewOptionsTrailing slot, so the regression guard is the more specific - // FilterHeader check. - expect(workloadsFilterSource).not.toContain(' { - const runtimeEntries = Object.entries(tsxSources).filter( - ([path]) => !path.endsWith('.test.tsx'), - ); - const sharedToolbarImportPattern = - /import\s*\{[\s\S]*\b(FilterHeader|FilterMobileToggleButton)\b[\s\S]*\}\s*from\s*['"]@\/components\/shared\/FilterToolbar['"]/; - const columnPickerImportPattern = - /import\s*\{[\s\S]*\bColumnPicker\b[\s\S]*\}\s*from\s*['"]@\/components\/shared\/ColumnPicker['"]/; - const filterHeaderTagPattern = /)/; - const columnPickerTagPattern = /)/; - const mobileToggleTagPattern = /)/; - - const sharedToolbarImportUsers = runtimeEntries - .filter(([, source]) => sharedToolbarImportPattern.test(source)) - .map(([path]) => path) - .sort(); - const columnPickerImportUsers = runtimeEntries - .filter(([, source]) => columnPickerImportPattern.test(source)) - .map(([path]) => path) - .sort(); - - const filterHeaderUsers = runtimeEntries - .filter(([, source]) => filterHeaderTagPattern.test(source)) - .map(([path]) => path) - .sort(); - const columnPickerUsers = runtimeEntries - .filter(([, source]) => columnPickerTagPattern.test(source)) - .map(([path]) => path) - .sort(); - const mobileToggleUsers = runtimeEntries - .filter(([, source]) => mobileToggleTagPattern.test(source)) - .map(([path]) => path) - .sort(); - - expect(filterHeaderUsers).toEqual(['./PageControls.tsx']); - - // FilterBar.tsx imports FilterMobileToggleButton from FilterToolbar so it - // shows up in the shared-toolbar import allowlist alongside PageControls. - expect(sharedToolbarImportUsers).toEqual([ - './FilterBar/FilterBar.tsx', - './PageControls.tsx', - ]); - - // WorkloadsFilter and the Standalone AgentsMachinesTable compose - // ColumnPicker via FilterBar's viewOptionsTrailing slot; PageControls - // plus those two surfaces are the canonical ColumnPicker consumers. - expect(columnPickerUsers).toEqual([ - '../../features/standalone/AgentsMachinesTable.tsx', - '../Workloads/WorkloadsFilter.tsx', - './PageControls.tsx', - ]); - - expect(columnPickerImportUsers).toEqual([ - '../../features/standalone/AgentsMachinesTable.tsx', - '../Workloads/WorkloadsFilter.tsx', - './PageControls.tsx', - ]); - - expect(mobileToggleUsers).toEqual([ - './FilterBar/FilterBar.tsx', - './PageControls.tsx', - ]); - }); - - it('keeps search-row leading content routed through PageControls rather than local FilterHeader forks', () => { - expect(pageControlsSource).toContain('searchLeading?: JSX.Element'); - expect(pageControlsSource).toContain('searchLeading={local.searchLeading}'); - expect(pageControlsSource).toContain('{...divProps}'); - }); - - it('keeps display controls and utility actions on the shared toolbar rail', () => { - // Workloads migrated to FilterBar's viewOptionsTrailing slot; the - // toolbarTrailing PageControls prop is no longer used. The - // ChartVisibilityToggleButton and no-aria-label-Charts regression guard - // still apply. - expect(workloadsFilterSource).toContain('ChartVisibilityToggleButton'); - expect(workloadsFilterSource).not.toContain('aria-label="Charts"'); - expect(pageControlsSource).toContain('page-controls-filter-controls'); - expect(pageControlsSource).toContain('page-controls-toolbar-actions ml-auto'); - expect(pageControlsSource).toContain('pageControlsControlDeckClass'); - expect(pageControlsSource).toContain('pageControlsFilterSectionClass'); - expect(pageControlsSource).toContain('pageControlsSectionedFilterControlsClass'); - expect(pageControlsSource).toContain('filterControlsVariant?:'); - expect(pageControlsSource).toContain('actionsLayout?:'); - expect(pageControlsSource).toContain('controlDeckClass?:'); - expect(pageControlsSource).toContain('const toolbarControls = () => ('); - expect(pageControlsSource).toContain("actionsLayout() === 'stacked'"); - expect(pageControlsSource).toContain("local.actionsLayout ?? 'stacked'"); - expect(pageControlsSource).toContain( - 'shrink-0 flex-wrap items-center justify-end gap-2 self-start', - ); - expect(pageControlsSource).not.toContain('2xl:ml-auto'); - }); - - it('keeps compact stable filters on the shared labeled toggle primitive', () => { - expect(filterToolbarSource).toContain('export const LabeledFilterToggleGroup'); - expect(filterToolbarSource).toContain('COMPACT_FILTER_TOGGLE_MAX_OPTIONS = 5'); - expect(filterToolbarSource).toContain("class={local.toggleClass ?? 'hidden xl:inline-flex'}"); - expect(filterToolbarSource).toContain("groupClass={local.selectGroupClass ?? 'xl:hidden'}"); - expect(pageControlsSource).toContain('border border-border bg-surface-alt'); - expect(pageControlsSource).toContain('border border-border-subtle bg-surface'); - expect(pageControlsSource).toContain('xl:grid-cols-[minmax(0,1fr)_auto]'); - // WorkloadsFilter migrated to FilterBar — the LabeledFilterToggleGroup - // segmented Type/Status pair retired in favour of catalog chips, so the - // remaining assertions are regression guards against re-introducing the - // legacy primitives. - expect(workloadsFilterSource).not.toContain('LabeledFilterToggleGroup'); - expect(workloadsFilterSource).not.toContain('LabeledFilterSelect'); - expect(workloadsFilterSource).not.toContain('pageControlsFilterSectionClass'); - expect(workloadsFilterSource).not.toContain('filterControlsVariant'); - expect(workloadsFilterSource).not.toContain('xl:flex-col xl:items-start'); - // RecoveryHistorySection migrated to FilterBar; LabeledFilterToggleGroup - // / LabeledFilterSelect no longer rendered. - }); - - it('keeps embedded workspace tabs on the canonical shared segmented-control pattern', () => { - expect(subtabsSource).not.toContain("variant?: 'default' | 'control'"); - }); -}); diff --git a/frontend-modern/src/components/shared/PageControls.test.tsx b/frontend-modern/src/components/shared/PageControls.test.tsx deleted file mode 100644 index 49f236dffc..0000000000 --- a/frontend-modern/src/components/shared/PageControls.test.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { PageControls } from './PageControls'; - -describe('PageControls', () => { - afterEach(() => { - cleanup(); - }); - - it('renders canonical mobile controls, columns, and reset actions', () => { - const onToggleColumn = vi.fn(); - const onReset = vi.fn(); - const onToggleFilters = vi.fn(); - - render(() => ( - Search
} - showFilters={true} - mobileFilters={{ enabled: true, count: 2, onToggle: onToggleFilters }} - columnVisibility={{ - availableToggles: () => [{ id: 'subject', label: 'Subject' }], - isHiddenByUser: () => false, - toggle: onToggleColumn, - resetToDefaults: vi.fn(), - }} - resetAction={{ show: true, onClick: onReset, label: 'Reset all' }} - > -
Filters
- - )); - - expect(screen.getByTestId('search')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /filters/i })).toBeInTheDocument(); - const columnsButton = screen.getByRole('button', { name: /columns/i }); - const resetButton = screen.getByRole('button', { name: /reset all/i }); - expect(columnsButton).toBeInTheDocument(); - expect(resetButton).toBeInTheDocument(); - const trailingActions = columnsButton.closest('.page-controls-toolbar-actions'); - const deck = columnsButton.closest('.page-controls-control-deck'); - expect(trailingActions).not.toBeNull(); - expect(deck).not.toBeNull(); - expect(deck!).toHaveClass('bg-surface-alt'); - expect(trailingActions!).toContainElement(resetButton); - expect(trailingActions!).toHaveClass('page-controls-filter-section'); - expect(trailingActions!).toHaveClass('bg-surface'); - expect(trailingActions!).toHaveClass('xl:justify-self-end'); - expect(trailingActions!).not.toHaveClass('ml-auto'); - expect(trailingActions!).not.toHaveClass('2xl:ml-auto'); - const filterControls = screen - .getByText('Filters', { selector: 'div' }) - .closest('.page-controls-filter-controls'); - expect(filterControls).not.toBeNull(); - expect(filterControls!).toHaveClass('page-controls-filter-section'); - expect(filterControls!).toHaveClass('justify-self-start'); - expect(filterControls!).toHaveClass('w-fit'); - expect(filterControls!).not.toHaveClass('w-full'); - expect(filterControls!).not.toContainElement(columnsButton); - expect(deck!).toContainElement(filterControls!); - - fireEvent.click(screen.getByRole('button', { name: /filters/i })); - expect(onToggleFilters).toHaveBeenCalledTimes(1); - - fireEvent.click(screen.getByRole('button', { name: /reset all/i })); - expect(onReset).toHaveBeenCalledTimes(1); - - fireEvent.click(screen.getByRole('button', { name: /columns/i })); - fireEvent.click(screen.getByLabelText('Subject')); - expect(onToggleColumn).toHaveBeenCalledWith('subject'); - }); - - it('renders at most one scope utility slot for the active breakpoint', () => { - const { unmount } = render(() => ( - Search
} - showFilters={true} - mobileFilters={{ enabled: false, count: 0, onToggle: vi.fn() }} - mobileTrailing={
mobile scope
} - utilityActions={
desktop scope
} - > -
Filters
- - )); - - expect(screen.getAllByTestId('scope-slot')).toHaveLength(1); - expect(screen.getByTestId('scope-slot')).toHaveTextContent('desktop scope'); - - unmount(); - - render(() => ( - Search} - showFilters={true} - mobileFilters={{ enabled: true, count: 0, onToggle: vi.fn() }} - mobileTrailing={
mobile scope
} - utilityActions={
desktop scope
} - > -
Filters
-
- )); - - expect(screen.getAllByTestId('scope-slot')).toHaveLength(1); - expect(screen.getByTestId('scope-slot')).toHaveTextContent('mobile scope'); - }); - - it('keeps toolbar trailing actions grouped with columns in collapsed filter layouts', () => { - render(() => ( - Search} - showFilters={true} - mobileFilters={{ enabled: true, count: 1, onToggle: vi.fn() }} - toolbarTrailing={
Grouped List
} - columnVisibility={{ - availableToggles: () => [{ id: 'subject', label: 'Subject' }], - isHiddenByUser: () => false, - toggle: vi.fn(), - resetToDefaults: vi.fn(), - }} - utilityActions={
Desktop utility
} - > -
Filters
-
- )); - - const trailing = screen.getByTestId('toolbar-trailing'); - const columnsButton = screen.getByRole('button', { name: /columns/i }); - const trailingActions = columnsButton.closest('.page-controls-toolbar-actions'); - - expect(trailingActions).not.toBeNull(); - expect(trailingActions!).toContainElement(trailing); - expect(trailingActions!).toContainElement(columnsButton); - expect(trailingActions!).toHaveClass('page-controls-filter-section'); - expect(trailingActions!).toHaveClass('xl:justify-self-end'); - expect(trailingActions!).not.toHaveClass('ml-auto'); - const filterControls = screen - .getByText('Filters', { selector: 'div' }) - .closest('.page-controls-filter-controls'); - expect(filterControls).not.toBeNull(); - expect(filterControls!).toHaveClass('page-controls-filter-section'); - expect(filterControls!).toHaveClass('justify-self-start'); - expect(filterControls!).toHaveClass('w-fit'); - expect(filterControls!).not.toContainElement(trailing); - expect(filterControls!).not.toContainElement(columnsButton); - expect(screen.queryByTestId('desktop-utility')).not.toBeInTheDocument(); - }); - - it('can opt into the inline action layout for narrow specialist controls', () => { - render(() => ( - Search} - showFilters={true} - actionsLayout="inline" - toolbarTrailing={
Grouped List
} - columnVisibility={{ - availableToggles: () => [{ id: 'subject', label: 'Subject' }], - isHiddenByUser: () => false, - toggle: vi.fn(), - resetToDefaults: vi.fn(), - }} - > -
Filters
-
- )); - - const columnsButton = screen.getByRole('button', { name: /columns/i }); - const trailingActions = columnsButton.closest('.page-controls-toolbar-actions'); - - expect(columnsButton.closest('.page-controls-control-deck')).toBeNull(); - expect(trailingActions).not.toBeNull(); - expect(trailingActions!).toHaveClass('ml-auto'); - expect(trailingActions!).toHaveClass('shrink-0'); - expect(trailingActions!).toHaveClass('justify-end'); - expect(trailingActions!).toHaveClass('self-start'); - }); - - it('can wrap filters and trailing actions in one compact control deck', () => { - render(() => ( - Search} - showFilters={true} - actionsLayout="stacked" - controlDeckClass="page-controls-control-deck" - filterControlsClass="page-controls-filter-controls compact-filters" - toolbarActionsClass="page-controls-toolbar-actions compact-actions" - toolbarTrailing={
Grouped List
} - columnVisibility={{ - availableToggles: () => [{ id: 'subject', label: 'Subject' }], - isHiddenByUser: () => false, - toggle: vi.fn(), - resetToDefaults: vi.fn(), - }} - > -
Filters
-
- )); - - const deck = screen - .getByText('Filters', { selector: 'div' }) - .closest('.page-controls-control-deck'); - const trailing = screen.getByTestId('toolbar-trailing'); - const columnsButton = screen.getByRole('button', { name: /columns/i }); - - expect(deck).not.toBeNull(); - expect(deck!).toContainElement(screen.getByText('Filters', { selector: 'div' })); - expect(deck!).toContainElement(trailing); - expect(deck!).toContainElement(columnsButton); - expect(deck!.querySelector('.page-controls-filter-controls')).toHaveClass('compact-filters'); - expect(deck!.querySelector('.page-controls-toolbar-actions')).toHaveClass('compact-actions'); - }); -}); diff --git a/frontend-modern/src/components/shared/PageControls.tsx b/frontend-modern/src/components/shared/PageControls.tsx deleted file mode 100644 index a261b7554d..0000000000 --- a/frontend-modern/src/components/shared/PageControls.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { Component, JSX, Show, splitProps } from 'solid-js'; -import { ColumnPicker } from '@/components/shared/ColumnPicker'; -import { - FilterActionButton, - FilterDivider, - FilterHeader, - FilterMobileToggleButton, -} from '@/components/shared/FilterToolbar'; -import type { ColumnDef } from '@/hooks/useColumnVisibility'; - -interface PageControlsColumnVisibility { - availableToggles: () => ColumnDef[]; - isHiddenByUser: (id: string) => boolean; - toggle: (id: string) => void; - resetToDefaults: () => void; -} - -interface PageControlsResetAction { - show: boolean; - onClick: () => void; - title?: string; - label?: string; - class?: string; - icon?: JSX.Element; -} - -interface PageControlsMobileFilters { - enabled: boolean; - count?: number; - onToggle: () => void; -} - -export const pageControlsControlDeckClass = - 'page-controls-control-deck grid w-full min-w-0 items-start gap-2 rounded-md border border-border bg-surface-alt p-1.5 shadow-sm xl:grid-cols-[minmax(0,1fr)_auto]'; - -export const pageControlsFilterSectionClass = - 'page-controls-filter-section rounded-md border border-border-subtle bg-surface px-1.5 py-1 shadow-sm'; - -export const pageControlsSectionedFilterControlsClass = - 'page-controls-filter-controls flex w-full min-w-0 flex-wrap items-start gap-2'; - -interface PageControlsProps extends JSX.HTMLAttributes { - searchLeading?: JSX.Element; - searchTrailing?: JSX.Element; - search: JSX.Element; - showFilters: boolean; - children: JSX.Element; - contentClass?: string; - searchRowClass?: string; - toolbarClass?: string; - toolbarTrailing?: JSX.Element; - mobileRowClass?: string; - mobileLeading?: JSX.Element; - mobileTrailing?: JSX.Element; - utilityActions?: JSX.Element; - columnVisibility?: PageControlsColumnVisibility; - resetAction?: PageControlsResetAction; - mobileFilters?: PageControlsMobileFilters; - actionsLayout?: 'inline' | 'stacked'; - controlDeckClass?: string; - filterControlsClass?: string; - filterControlsVariant?: 'single-section' | 'sectioned-children'; - toolbarActionsClass?: string; -} - -export const PageControls: Component = (props) => { - const [local, divProps] = splitProps(props, [ - 'searchLeading', - 'searchTrailing', - 'search', - 'showFilters', - 'children', - 'contentClass', - 'searchRowClass', - 'toolbarClass', - 'toolbarTrailing', - 'mobileRowClass', - 'mobileLeading', - 'mobileTrailing', - 'utilityActions', - 'columnVisibility', - 'resetAction', - 'mobileFilters', - 'actionsLayout', - 'controlDeckClass', - 'filterControlsClass', - 'filterControlsVariant', - 'toolbarActionsClass', - 'class', - ]); - const mobileSearchAccessory = () => ( - - local.mobileFilters?.onToggle()} - count={local.mobileFilters?.count ?? 0} - /> - - ); - const mobileControlsEnabled = () => local.mobileFilters?.enabled === true; - const activeMobileTrailing = () => (mobileControlsEnabled() ? local.mobileTrailing : undefined); - const activeUtilityActions = () => (mobileControlsEnabled() ? undefined : local.utilityActions); - const activeSearchTrailing = () => (mobileControlsEnabled() ? undefined : local.searchTrailing); - const showColumnVisibility = () => - Boolean(local.columnVisibility && local.columnVisibility.availableToggles().length > 0); - const showResetAction = () => local.resetAction?.show === true; - const showToolbarTrailing = () => Boolean(local.toolbarTrailing); - const showUtilityActions = () => Boolean(activeUtilityActions()); - const hasTrailingActions = () => - Boolean( - showToolbarTrailing() || showUtilityActions() || showColumnVisibility() || showResetAction(), - ); - const showDividerBeforeUtilityActions = () => showToolbarTrailing() && showUtilityActions(); - const showDividerBeforeColumnVisibility = () => - (showToolbarTrailing() || showUtilityActions()) && showColumnVisibility(); - const showDividerBeforeResetAction = () => - (showToolbarTrailing() || showUtilityActions() || showColumnVisibility()) && showResetAction(); - const actionsLayout = () => local.actionsLayout ?? 'stacked'; - const filtersUseSectionedChildren = () => local.filterControlsVariant === 'sectioned-children'; - const resolvedControlDeckClass = () => - local.controlDeckClass ?? - (actionsLayout() === 'stacked' ? pageControlsControlDeckClass : undefined); - const filterControlsClass = () => - local.filterControlsClass ?? - (actionsLayout() === 'stacked' - ? filtersUseSectionedChildren() - ? pageControlsSectionedFilterControlsClass - : `page-controls-filter-controls ${pageControlsFilterSectionClass} inline-flex w-fit max-w-full min-w-0 flex-wrap items-center gap-2 justify-self-start` - : 'page-controls-filter-controls flex min-w-0 flex-1 basis-0 flex-wrap items-center gap-2'); - const toolbarActionsClass = () => - local.toolbarActionsClass ?? - (actionsLayout() === 'stacked' - ? `page-controls-toolbar-actions ${pageControlsFilterSectionClass} inline-flex max-w-full flex-wrap items-center gap-2 xl:justify-self-end` - : 'page-controls-toolbar-actions ml-auto inline-flex shrink-0 flex-wrap items-center justify-end gap-2 self-start'); - const toolbarControls = () => ( - <> -
{local.children}
- - -
- {local.toolbarTrailing} - - - - - - {activeUtilityActions()} - - - - - - - - - - - - - - local.resetAction?.onClick()} - title={local.resetAction?.title} - class={local.resetAction?.class} - > - {local.resetAction?.icon} - {local.resetAction?.label ?? 'Reset'} - - -
-
- - ); - - return ( - - - {(controlDeckClass) =>
{toolbarControls()}
} -
-
- ); -}; diff --git a/frontend-modern/src/components/shared/infrastructureDetailsDrawerModel.ts b/frontend-modern/src/components/shared/infrastructureDetailsDrawerModel.ts deleted file mode 100644 index 2e79b35f18..0000000000 --- a/frontend-modern/src/components/shared/infrastructureDetailsDrawerModel.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Agent, Node } from '@/types/api'; -import { - getInfrastructureDiscoveryHostname, - getInfrastructureMetadataId, -} from '@/utils/resourceIdentity'; - -export interface InfrastructureDetailsDrawerProps { - node: Node; - agent?: Agent; - customUrl?: string; - onCustomUrlChange?: (agentId: string, url: string) => void; -} - -export const resolveInfrastructureDetailsDrawerMetadataId = (node: Node, agent?: Agent) => - getInfrastructureMetadataId(node, agent); - -export const resolveInfrastructureDetailsDrawerDiscoveryHostname = ( - node: Node, - agent?: Agent, -) => getInfrastructureDiscoveryHostname(node, agent); diff --git a/frontend-modern/src/components/shared/responsive/ResponsiveHeader.tsx b/frontend-modern/src/components/shared/responsive/ResponsiveHeader.tsx deleted file mode 100644 index 31bfadc01e..0000000000 --- a/frontend-modern/src/components/shared/responsive/ResponsiveHeader.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { Component, Show } from 'solid-js'; -import type { ColumnConfig, SortState } from '@/types/responsive'; - -export interface ResponsiveHeaderProps { - /** Column configuration */ - column: ColumnConfig; - - /** Current sort state */ - sortState?: SortState; - - /** Sort handler - called with the column's sortKey or id */ - onSort?: (key: string) => void; - - /** Whether to show mobile (icon) view */ - showMobile?: boolean; - - /** Additional CSS classes */ - class?: string; -} - -/** - * A responsive table header cell that shows an icon on mobile - * and the full label on larger screens. - * - * @example - * ```tsx - * - * ``` - */ -export const ResponsiveHeader: Component = (props) => { - const sortKey = () => props.column.sortKey || props.column.id; - const isSorted = () => props.sortState?.key === sortKey(); - const sortIndicator = () => { - if (!isSorted()) return null; - return props.sortState?.direction === 'asc' ? '▲' : '▼'; - }; - - const handleClick = () => { - if (props.column.sortable && props.onSort) { - props.onSort(sortKey()); - } - }; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter' && props.column.sortable && props.onSort) { - props.onSort(sortKey()); - } - }; - - const alignmentClass = () => { - switch (props.column.align) { - case 'center': - return 'justify-center text-center'; - case 'right': - return 'justify-end text-right'; - default: - return 'justify-start text-left'; - } - }; - - const baseClass = () => { - const classes = [ - 'px-0.5 md:px-2 py-1', - 'text-[11px] sm:text-xs font-medium uppercase tracking-wider', - 'flex items-center h-full', - alignmentClass(), - ]; - - if (props.column.sortable) { - classes.push('cursor-pointer hover:bg-surface-hover'); - } - - if (props.class) { - classes.push(props.class); - } - - return classes.join(' '); - }; - - const Icon = props.column.icon; - - return ( -
- {/* Mobile: Show icon if available */} - - - {Icon && } - - - - {/* Desktop: Show label (or always if no icon) */} - {props.column.label} - - {/* Sort indicator */} - - {sortIndicator()} - -
- ); -}; - -/** - * Sticky header variant for name columns - */ -export const StickyHeader: Component = (props) => { - return ( -
- -
- ); -}; diff --git a/frontend-modern/src/components/shared/responsive/useGridTemplate.ts b/frontend-modern/src/components/shared/responsive/useGridTemplate.ts deleted file mode 100644 index 794c011759..0000000000 --- a/frontend-modern/src/components/shared/responsive/useGridTemplate.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { createMemo, Accessor } from 'solid-js'; -import type { ColumnConfig } from '@/types/responsive'; -import { useBreakpoint, type Breakpoint, PRIORITY_BREAKPOINTS } from '@/hooks/useBreakpoint'; - -export interface GridTemplateOptions { - /** Column configurations - can be a static array or reactive accessor */ - columns: ColumnConfig[] | Accessor; - - /** Whether to use a sticky first column on mobile */ - hasStickyColumn?: boolean; - - /** Custom gap between columns (CSS value) */ - gap?: string; - - /** If true, show all columns regardless of breakpoint (use horizontal scroll instead) */ - disableColumnHiding?: boolean; -} - -export interface GridTemplateResult { - /** CSS grid-template-columns value */ - gridTemplate: Accessor; - - /** Columns that are currently visible */ - visibleColumns: Accessor; - - /** Check if a specific column is visible */ - isColumnVisible: (columnId: string) => boolean; - - /** Current breakpoint */ - breakpoint: Accessor; - - /** Whether currently in mobile view */ - isMobile: Accessor; -} - -/** - * Generate a CSS grid-template-columns value from column configs - */ -function generateGridTemplate(columns: ColumnConfig[]): string { - return columns - .map((col) => { - const min = col.minWidth || '50px'; - const max = col.maxWidth || `${col.flex || 1}fr`; - - // If both min and max are 'auto', use just 'auto' for content-based sizing - if (min === 'auto' && max === 'auto') { - return 'auto'; - } - - // If only max is 'auto', use minmax with auto - if (max === 'auto') { - return `minmax(${min}, auto)`; - } - - // If we have both min and max as fixed values, use the appropriate one - if (col.maxWidth && !col.maxWidth.includes('fr')) { - return `minmax(${min}, ${col.maxWidth})`; - } - - // Use minmax for flexible columns - return `minmax(${min}, ${max})`; - }) - .join(' '); -} - -/** - * Filter columns based on current breakpoint - */ -function getVisibleColumns(columns: ColumnConfig[], breakpoint: Breakpoint): ColumnConfig[] { - const breakpointOrder: Breakpoint[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl']; - const currentIndex = breakpointOrder.indexOf(breakpoint); - - return columns.filter((col) => { - const colBreakpoint = PRIORITY_BREAKPOINTS[col.priority]; - const colIndex = breakpointOrder.indexOf(colBreakpoint); - return colIndex <= currentIndex; - }); -} - -/** - * Hook to generate responsive CSS grid templates based on column configuration. - * - * This hook automatically adjusts the grid template based on the current - * viewport width, showing/hiding columns according to their priority. - * - * @example - * ```tsx - * const columns = [ - * { id: 'name', label: 'Name', priority: 'essential', minWidth: '150px', flex: 1.5 }, - * { id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '100px', flex: 1 }, - * { id: 'diskRead', label: 'D Read', priority: 'detailed', minWidth: '100px', flex: 1 }, - * ]; - * - * const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns }); - * - * return ( - *
- * - * {col =>
{col.label}
} - *
- *
- * ); - * ``` - */ -export function useGridTemplate(options: GridTemplateOptions): GridTemplateResult { - const { breakpoint, isMobile } = useBreakpoint(); - - // Support both static arrays and reactive accessors - const getColumns = (): ColumnConfig[] => { - const cols = options.columns; - return typeof cols === 'function' ? cols() : cols; - }; - - const visibleColumns = createMemo(() => { - const cols = getColumns(); - if (options.disableColumnHiding) { - return cols; - } - return getVisibleColumns(cols, breakpoint()); - }); - - const gridTemplate = createMemo(() => { - const visible = visibleColumns(); - - // On mobile with sticky column, we handle layout differently - // The first column is sticky and outside the grid - if (isMobile() && options.hasStickyColumn && visible.length > 1) { - // Skip the first column (it's sticky) and generate template for the rest - return generateGridTemplate(visible.slice(1)); - } - - return generateGridTemplate(visible); - }); - - const isColumnVisible = (columnId: string): boolean => { - return visibleColumns().some((col) => col.id === columnId); - }; - - return { - gridTemplate, - visibleColumns, - isColumnVisible, - breakpoint, - isMobile, - }; -} - -/** - * Simple utility to generate a static grid template (non-reactive) - */ -export function generateStaticGridTemplate( - columns: ColumnConfig[], - breakpoint: Breakpoint = 'xl', -): string { - const visible = getVisibleColumns(columns, breakpoint); - return generateGridTemplate(visible); -} - -/** - * Generate responsive grid template classes for Tailwind - * Returns an object with breakpoint-specific templates - */ -export function generateResponsiveTemplates(columns: ColumnConfig[]): Record { - const breakpoints: Breakpoint[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl']; - - return Object.fromEntries( - breakpoints.map((bp) => [bp, generateStaticGridTemplate(columns, bp)]), - ) as Record; -} - -/** - * Get Tailwind CSS classes for column visibility based on priority - */ -export function getColumnVisibilityClass( - priority: ColumnConfig['priority'], - displayType: 'flex' | 'block' | 'table-cell' | 'grid' = 'flex', -): string { - const bp = PRIORITY_BREAKPOINTS[priority]; - - if (bp === 'xs') { - // Always visible - return displayType; - } - - return `hidden ${bp}:${displayType}`; -} diff --git a/frontend-modern/src/components/shared/useCollapsibleSearchInputState.ts b/frontend-modern/src/components/shared/useCollapsibleSearchInputState.ts deleted file mode 100644 index c0e960f4b1..0000000000 --- a/frontend-modern/src/components/shared/useCollapsibleSearchInputState.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { createEffect, createSignal } from 'solid-js'; -import { useTypeToSearch } from '@/hooks/useTypeToSearch'; -import { - getCollapsibleSearchRootClass, - getCollapsibleSearchTriggerLabel, - shouldShowCollapsibleSearchExpanded, - type CollapsibleSearchInputProps, -} from './collapsibleSearchInputModel'; - -type CollapsibleSearchInputStateOptions = Pick< - CollapsibleSearchInputProps, - 'class' | 'fullWidthWhenExpanded' | 'onBeforeAutoFocus' | 'triggerLabel' | 'value' ->; - -export function useCollapsibleSearchInputState(options: CollapsibleSearchInputStateOptions) { - const [isExpanded, setIsExpanded] = createSignal(options.value().trim().length > 0); - let rootRef: HTMLDivElement | undefined; - let inputRef: HTMLInputElement | undefined; - let suppressCollapse = false; - - const focusInput = (selectText = false) => { - queueMicrotask(() => { - if (!inputRef) return; - inputRef.focus(); - if (selectText) { - inputRef.select?.(); - } - }); - }; - - const expandSearch = (selectText = false) => { - suppressCollapse = true; - queueMicrotask(() => { - suppressCollapse = false; - }); - if (!isExpanded()) { - setIsExpanded(true); - } - focusInput(selectText); - }; - - const collapseIfEmpty = () => { - if (options.value().trim().length > 0) return; - setIsExpanded(false); - }; - - createEffect(() => { - if (options.value().trim().length > 0 && !isExpanded()) { - setIsExpanded(true); - } - }); - - useTypeToSearch({ - getInput: () => inputRef, - prepareInput: () => { - if (!isExpanded()) { - setIsExpanded(true); - } - }, - onBeforeFocus: options.onBeforeAutoFocus, - }); - - const showExpanded = () => shouldShowCollapsibleSearchExpanded(isExpanded(), options.value()); - const triggerLabel = () => getCollapsibleSearchTriggerLabel(options.triggerLabel); - const rootClass = () => - getCollapsibleSearchRootClass({ - className: options.class, - fullWidthWhenExpanded: options.fullWidthWhenExpanded, - showExpanded: showExpanded(), - }); - - const setRootRef = (element: HTMLDivElement) => { - rootRef = element; - }; - - const setInputRef = (element: HTMLInputElement) => { - inputRef = element; - }; - - const handleFocusOut = (event: FocusEvent & { relatedTarget: EventTarget | null }) => { - if (suppressCollapse) return; - const next = event.relatedTarget as Node | null; - if (next && rootRef?.contains(next)) return; - collapseIfEmpty(); - }; - - return { - expandSearch, - handleFocusOut, - rootClass, - setInputRef, - setRootRef, - showExpanded, - triggerLabel, - }; -} diff --git a/frontend-modern/src/components/shared/useDensityMapState.ts b/frontend-modern/src/components/shared/useDensityMapState.ts deleted file mode 100644 index 13672e1114..0000000000 --- a/frontend-modern/src/components/shared/useDensityMapState.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; -import { - buildDensityMapChartData, - buildDensityMapFocusDetail, - buildDensityMapHoveredState, - getDensityMapColumnIndexForTimestamp, - getDensityMapExternalSeriesIndex, - buildDensityMapSynchronizedHoveredState, - formatDensityMapValue, - getDensityMapCellOpacity, - DENSITY_MAP_COLUMNS, - DENSITY_MAP_PADDING_X, - DENSITY_MAP_PADDING_Y, - type DensityMapHoveredState, - type DensityMapProps, -} from './densityMapModel'; - -export function useDensityMapState(props: DensityMapProps) { - const [hoveredState, setHoveredState] = createSignal(null); - const [canvasRef, setCanvasRef] = createSignal(); - - const chartData = createMemo(() => - buildDensityMapChartData({ - series: props.series, - timeRange: props.timeRange, - highlightSeriesId: props.highlightSeriesId, - }), - ); - const externalSeriesIndex = createMemo(() => - getDensityMapExternalSeriesIndex(chartData(), props.highlightSeriesId), - ); - const synchronizedHoveredState = createMemo(() => { - const hoverSync = props.hoverSync; - if (!hoverSync) { - return null; - } - if (props.hoverSourceKey && hoverSync.sourceKey === props.hoverSourceKey) { - return null; - } - return buildDensityMapSynchronizedHoveredState({ - data: chartData(), - hoverSync, - }); - }); - const synchronizedHoverTimestamp = createMemo(() => { - const hoverSync = props.hoverSync; - if (!hoverSync) { - return null; - } - if (props.hoverSourceKey && hoverSync.sourceKey === props.hoverSourceKey) { - return null; - } - return hoverSync.timestamp; - }); - const activeHoveredState = createMemo(() => { - return hoveredState() ?? synchronizedHoveredState(); - }); - const activeHoverTimestamp = createMemo(() => { - return hoveredState()?.timestamp ?? synchronizedHoverTimestamp(); - }); - const activeColumnIndex = createMemo(() => - getDensityMapColumnIndexForTimestamp(chartData(), activeHoverTimestamp()), - ); - const focusDetail = createMemo(() => - buildDensityMapFocusDetail({ - activeHoveredState: activeHoveredState(), - data: chartData(), - highlightSeriesId: props.highlightSeriesId, - }), - ); - - const formatValue = (value: number) => formatDensityMapValue(value, props.formatValue); - - const drawCanvas = () => { - const canvas = canvasRef(); - if (!canvas) return; - - const context = canvas.getContext('2d'); - if (!context) return; - - const rect = canvas.getBoundingClientRect(); - const width = rect.width; - const height = rect.height; - if (width <= 0 || height <= 0) return; - - const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; - canvas.width = width * dpr; - canvas.height = height * dpr; - context.setTransform(1, 0, 0, 1, 0, 0); - context.scale(dpr, dpr); - context.clearRect(0, 0, width, height); - - const data = chartData(); - if (data.series.length === 0 || data.globalMax <= 0) return; - - const rows = data.series.length; - const cellWidth = width / DENSITY_MAP_COLUMNS; - const cellHeight = height / rows; - const interactionOpacity = props.interactionState === 'inactive' ? 0.35 : 1; - const hover = activeHoveredState(); - const highlightColumnIndex = activeColumnIndex(); - const activeSeriesIndex = hover?.seriesIndex ?? externalSeriesIndex(); - const activeSeries = - activeSeriesIndex !== null && activeSeriesIndex >= 0 && activeSeriesIndex < data.series.length - ? data.series[activeSeriesIndex] - : null; - const hasActiveSeries = activeSeriesIndex !== null; - - for (let row = 0; row < rows; row += 1) { - const cellY = row * cellHeight; - const isActiveRow = activeSeriesIndex === row; - const rowOpacity = !hasActiveSeries ? 1 : isActiveRow ? 1 : 0.42; - for (let column = 0; column < DENSITY_MAP_COLUMNS; column += 1) { - const cellX = column * cellWidth; - const value = data.grid[row][column]; - - if (value <= 0) { - context.globalAlpha = rowOpacity * interactionOpacity; - context.fillStyle = isActiveRow - ? 'rgba(148, 163, 184, 0.08)' - : 'rgba(148, 163, 184, 0.04)'; - context.fillRect( - cellX + DENSITY_MAP_PADDING_X / 2, - cellY + DENSITY_MAP_PADDING_Y / 2, - cellWidth - DENSITY_MAP_PADDING_X, - cellHeight - DENSITY_MAP_PADDING_Y, - ); - continue; - } - - context.globalAlpha = - getDensityMapCellOpacity(value, data.globalMax) * rowOpacity * interactionOpacity; - context.fillStyle = data.series[row].color; - if (context.roundRect) { - context.beginPath(); - context.roundRect( - cellX + DENSITY_MAP_PADDING_X / 2, - cellY + DENSITY_MAP_PADDING_Y / 2, - cellWidth - DENSITY_MAP_PADDING_X, - cellHeight - DENSITY_MAP_PADDING_Y, - 2, - ); - context.fill(); - } else { - context.fillRect( - cellX + DENSITY_MAP_PADDING_X / 2, - cellY + DENSITY_MAP_PADDING_Y / 2, - cellWidth - DENSITY_MAP_PADDING_X, - cellHeight - DENSITY_MAP_PADDING_Y, - ); - } - } - } - - if (activeSeries !== null && activeSeriesIndex !== null) { - const highlightY = activeSeriesIndex * cellHeight; - context.save(); - if (highlightColumnIndex !== null) { - const highlightX = highlightColumnIndex * cellWidth; - context.globalAlpha = 0.12 * interactionOpacity; - context.fillStyle = activeSeries.color; - context.fillRect(highlightX, 0, Math.max(cellWidth, 1), height); - } - context.globalAlpha = 0.12 * interactionOpacity; - context.fillStyle = activeSeries.color; - context.fillRect(0, highlightY, width, cellHeight); - context.globalAlpha = 0.7 * interactionOpacity; - context.strokeStyle = activeSeries.color; - context.lineWidth = 1.25; - if (context.roundRect) { - context.beginPath(); - context.roundRect(0.5, highlightY + 0.5, width - 1, Math.max(cellHeight - 1, 1), 4); - context.stroke(); - } else { - context.strokeRect(0.5, highlightY + 0.5, width - 1, Math.max(cellHeight - 1, 1)); - } - context.restore(); - } - - if (activeSeries === null && highlightColumnIndex !== null) { - context.save(); - const highlightX = highlightColumnIndex * cellWidth; - context.globalAlpha = 0.12 * interactionOpacity; - context.fillStyle = 'rgba(148, 163, 184, 0.75)'; - context.fillRect(highlightX, 0, Math.max(cellWidth, 1), height); - context.restore(); - } - - context.globalAlpha = 1; - }; - - createEffect(() => { - drawCanvas(); - }); - - const handleMouseMove = (event: MouseEvent) => { - const canvas = canvasRef(); - if (!canvas) return; - - const computed = buildDensityMapHoveredState({ - clientX: event.clientX, - clientY: event.clientY, - rect: canvas.getBoundingClientRect(), - data: chartData(), - }); - setHoveredState(computed); - if (!props.onHoverSyncChange || !props.hoverSourceKey || !computed) { - props.onHoverSyncChange?.(null); - return; - } - const hoveredSeries = chartData().series[computed.seriesIndex]; - const hoveredSeriesId = hoveredSeries?.id?.trim() || ''; - if (!hoveredSeriesId) { - props.onHoverSyncChange(null); - return; - } - props.onHoverSyncChange({ - sourceKey: props.hoverSourceKey, - seriesId: hoveredSeriesId, - timestamp: computed.timestamp, - }); - }; - - const handleMouseLeave = () => { - setHoveredState(null); - props.onHoverSyncChange?.(null); - }; - - createEffect(() => { - if (typeof window === 'undefined') return; - - const handleResize = () => drawCanvas(); - window.addEventListener('resize', handleResize); - onCleanup(() => window.removeEventListener('resize', handleResize)); - }); - - return { - activeHoveredState, - activeColumnIndex, - activeHoverTimestamp, - chartData, - externalSeriesIndex, - focusDetail, - formatValue, - handleMouseLeave, - handleMouseMove, - hoveredState, - setCanvasRef, - }; -} - -export type DensityMapState = ReturnType; diff --git a/frontend-modern/src/components/shared/useInfrastructureDetailsDrawerState.ts b/frontend-modern/src/components/shared/useInfrastructureDetailsDrawerState.ts deleted file mode 100644 index 7ebd22c94e..0000000000 --- a/frontend-modern/src/components/shared/useInfrastructureDetailsDrawerState.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createSignal } from 'solid-js'; - -export type InfrastructureDetailsDrawerTab = 'overview' | 'discovery'; - -export function useInfrastructureDetailsDrawerState() { - const [activeTab, setActiveTab] = createSignal('overview'); - - return { - activeTab, - setActiveTab, - }; -} diff --git a/frontend-modern/src/components/shared/useScrollToTopButtonState.ts b/frontend-modern/src/components/shared/useScrollToTopButtonState.ts deleted file mode 100644 index 94fba0b970..0000000000 --- a/frontend-modern/src/components/shared/useScrollToTopButtonState.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createSignal, onCleanup, onMount } from 'solid-js'; -import { - findNearestScrollableAncestor, - isScrollToTopButtonVisible, -} from '@/components/shared/scrollToTopButtonModel'; - -export function useScrollToTopButtonState() { - const [visible, setVisible] = createSignal(false); - let sentinelRef: HTMLDivElement | undefined; - let scroller: HTMLElement | null = null; - - const syncVisibility = () => { - if (!scroller) { - return; - } - setVisible(isScrollToTopButtonVisible(scroller.scrollTop)); - }; - - onMount(() => { - scroller = findNearestScrollableAncestor(sentinelRef); - if (!scroller) { - return; - } - - scroller.addEventListener('scroll', syncVisibility, { passive: true }); - syncVisibility(); - }); - - onCleanup(() => { - scroller?.removeEventListener('scroll', syncVisibility); - }); - - return { - scrollToTop: () => { - scroller?.scrollTo({ top: 0, behavior: 'smooth' }); - }, - setSentinelRef: (element: HTMLDivElement) => { - sentinelRef = element; - }, - visible, - }; -} diff --git a/frontend-modern/src/hooks/__tests__/useDeployWizard.test.ts b/frontend-modern/src/hooks/__tests__/useDeployWizard.test.ts deleted file mode 100644 index 70720a7127..0000000000 --- a/frontend-modern/src/hooks/__tests__/useDeployWizard.test.ts +++ /dev/null @@ -1,491 +0,0 @@ -import { createRoot } from 'solid-js'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -/* ── hoisted mocks ──────────────────────────────────────────── */ - -const { apiMock, loggerMock } = vi.hoisted(() => ({ - apiMock: { - getCandidates: vi.fn(), - createPreflight: vi.fn(), - getPreflight: vi.fn(), - createJob: vi.fn(), - getJob: vi.fn(), - cancelJob: vi.fn(), - retryJob: vi.fn(), - }, - loggerMock: { info: vi.fn(), warn: vi.fn() }, -})); - -vi.mock('@/api/agentDeploy', () => ({ AgentDeployAPI: apiMock })); -vi.mock('@/utils/logger', () => ({ logger: loggerMock })); - -// Stub useDeployStream — wizard only needs the returned state, not real SSE. -vi.mock('@/hooks/useDeployStream', () => ({ - useDeployStream: () => ({ - isStreaming: () => false, - events: () => [], - lastError: () => '', - }), -})); - -import { useDeployWizard, type DeployWizardState } from '@/hooks/useDeployWizard'; - -/* ── helpers ────────────────────────────────────────────────── */ - -function withRoot(fn: () => DeployWizardState): { wizard: DeployWizardState; dispose: () => void } { - let wizard!: DeployWizardState; - const dispose = createRoot((d) => { - wizard = fn(); - return d; - }); - return { wizard, dispose }; -} - -const defaultOpts = { clusterId: 'cluster-1', clusterName: 'My Cluster' }; - -/* ── lifecycle ──────────────────────────────────────────────── */ - -beforeEach(() => { - vi.clearAllMocks(); - // Default: getCandidates returns empty (called immediately on creation). - apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -/* ── tests ──────────────────────────────────────────────────── */ - -describe('useDeployWizard', () => { - describe('initialization', () => { - it('starts on candidates step', () => { - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - expect(wizard.step()).toBe('candidates'); - dispose(); - }); - - it('loads candidates immediately', async () => { - apiMock.getCandidates.mockResolvedValue({ - nodes: [ - { nodeId: 'n1', name: 'node1', ip: '10.0.0.1', hasAgent: false, deployable: true }, - { nodeId: 'n2', name: 'node2', ip: '10.0.0.2', hasAgent: true, deployable: false }, - ], - sourceAgents: [{ agentId: 'a1', nodeId: 'n2', online: true }], - }); - - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - expect(wizard.candidates()).toHaveLength(2); - expect(wizard.sourceAgents()).toHaveLength(1); - dispose(); - }); - - it('auto-selects deployable nodes', async () => { - apiMock.getCandidates.mockResolvedValue({ - nodes: [ - { nodeId: 'n1', name: 'node1', ip: '10.0.0.1', hasAgent: false, deployable: true }, - { nodeId: 'n2', name: 'node2', ip: '10.0.0.2', hasAgent: true, deployable: false }, - { nodeId: 'n3', name: 'node3', ip: '10.0.0.3', hasAgent: false, deployable: true }, - ], - sourceAgents: [], - }); - - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - expect(wizard.selectedNodeIds().has('n1')).toBe(true); - expect(wizard.selectedNodeIds().has('n3')).toBe(true); - expect(wizard.selectedNodeIds().has('n2')).toBe(false); - dispose(); - }); - - it('auto-selects source agent when only one is online', async () => { - apiMock.getCandidates.mockResolvedValue({ - nodes: [], - sourceAgents: [ - { agentId: 'a1', nodeId: 'n1', online: true }, - { agentId: 'a2', nodeId: 'n2', online: false }, - ], - }); - - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - expect(wizard.selectedSourceAgent()).toBe('a1'); - dispose(); - }); - - it('does not auto-select source agent when multiple are online', async () => { - apiMock.getCandidates.mockResolvedValue({ - nodes: [], - sourceAgents: [ - { agentId: 'a1', nodeId: 'n1', online: true }, - { agentId: 'a2', nodeId: 'n2', online: true }, - ], - }); - - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - expect(wizard.selectedSourceAgent()).toBe(''); - dispose(); - }); - - it('sets candidatesError on API failure', async () => { - apiMock.getCandidates.mockRejectedValue(new Error('Network error')); - - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - expect(wizard.candidatesError()).toBe('Network error'); - dispose(); - }); - }); - - describe('node selection', () => { - it('toggleNodeSelection adds and removes nodes', async () => { - apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - wizard.toggleNodeSelection('n1'); - expect(wizard.selectedNodeIds().has('n1')).toBe(true); - - wizard.toggleNodeSelection('n1'); - expect(wizard.selectedNodeIds().has('n1')).toBe(false); - dispose(); - }); - - it('selectAllNodes selects all deployable nodes', async () => { - apiMock.getCandidates.mockResolvedValue({ - nodes: [ - { nodeId: 'n1', name: 'a', ip: '1', hasAgent: false, deployable: true }, - { nodeId: 'n2', name: 'b', ip: '2', hasAgent: true, deployable: false }, - { nodeId: 'n3', name: 'c', ip: '3', hasAgent: false, deployable: true }, - ], - sourceAgents: [], - }); - - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - wizard.deselectAllNodes(); - expect(wizard.selectedNodeIds().size).toBe(0); - - wizard.selectAllNodes(); - expect(wizard.selectedNodeIds().size).toBe(2); - expect(wizard.selectedNodeIds().has('n1')).toBe(true); - expect(wizard.selectedNodeIds().has('n3')).toBe(true); - dispose(); - }); - }); - - describe('startPreflight', () => { - it('does nothing when no nodes selected', async () => { - apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - await wizard.startPreflight(); - expect(apiMock.createPreflight).not.toHaveBeenCalled(); - dispose(); - }); - - it('does nothing when no source agent selected', async () => { - apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - wizard.toggleNodeSelection('n1'); - await wizard.startPreflight(); - expect(apiMock.createPreflight).not.toHaveBeenCalled(); - dispose(); - }); - - it('calls createPreflight and transitions to preflight step', async () => { - apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); - apiMock.createPreflight.mockResolvedValue({ - preflightId: 'pf-1', - status: 'running', - eventsUrl: '/api/agent-deploy/preflights/pf-1/events', - }); - apiMock.getPreflight.mockResolvedValue({ - targets: [ - { id: 't1', nodeId: 'n1', nodeName: 'node1', nodeIP: '10.0.0.1', status: 'pending' }, - ], - }); - - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - wizard.toggleNodeSelection('n1'); - wizard.setSelectedSourceAgent('a1'); - await wizard.startPreflight(); - - expect(apiMock.createPreflight).toHaveBeenCalledWith('cluster-1', { - sourceAgentId: 'a1', - targetNodeIds: ['n1'], - }); - expect(wizard.step()).toBe('preflight'); - expect(wizard.preflightId()).toBe('pf-1'); - dispose(); - }); - - it('sets preflightError on API failure', async () => { - apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); - apiMock.createPreflight.mockRejectedValue(new Error('Server error')); - - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - wizard.toggleNodeSelection('n1'); - wizard.setSelectedSourceAgent('a1'); - await wizard.startPreflight(); - - expect(wizard.preflightError()).toBe('Server error'); - expect(wizard.step()).toBe('candidates'); // didn't transition - dispose(); - }); - }); - - describe('confirm step selection', () => { - it('toggleConfirmNode adds and removes nodes', async () => { - apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - wizard.toggleConfirmNode('n1'); - expect(wizard.confirmSelectedNodeIds().has('n1')).toBe(true); - - wizard.toggleConfirmNode('n1'); - expect(wizard.confirmSelectedNodeIds().has('n1')).toBe(false); - dispose(); - }); - }); - - describe('startDeploy', () => { - it('calls createJob and transitions to deploying step', async () => { - apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); - apiMock.createJob.mockResolvedValue({ - jobId: 'job-1', - eventsUrl: '/api/agent-deploy/jobs/job-1/events', - }); - apiMock.getJob.mockResolvedValue({ - targets: [{ id: 't1', nodeId: 'n1', status: 'pending' }], - status: 'running', - }); - - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - wizard.setSelectedSourceAgent('a1'); - wizard.toggleConfirmNode('n1'); - - // Simulate being on confirm step - wizard.setStep('confirm'); - await wizard.startDeploy(); - - expect(apiMock.createJob).toHaveBeenCalledWith( - 'cluster-1', - expect.objectContaining({ - sourceAgentId: 'a1', - targetNodeIds: ['n1'], - }), - ); - expect(wizard.step()).toBe('deploying'); - expect(wizard.jobId()).toBe('job-1'); - dispose(); - }); - - it('accepts explicit nodeIds parameter', async () => { - apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); - apiMock.createJob.mockResolvedValue({ - jobId: 'job-2', - eventsUrl: '/api/events', - }); - apiMock.getJob.mockResolvedValue({ targets: [], status: 'running' }); - - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - wizard.setSelectedSourceAgent('a1'); - await wizard.startDeploy(['n1', 'n2']); - - expect(apiMock.createJob).toHaveBeenCalledWith( - 'cluster-1', - expect.objectContaining({ - targetNodeIds: ['n1', 'n2'], - }), - ); - dispose(); - }); - - it('does nothing with empty targetNodeIds', async () => { - apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - await wizard.startDeploy([]); - expect(apiMock.createJob).not.toHaveBeenCalled(); - dispose(); - }); - - it('sets deployError on API failure', async () => { - apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); - apiMock.createJob.mockRejectedValue(new Error('Deploy failed')); - - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - wizard.setSelectedSourceAgent('a1'); - wizard.toggleConfirmNode('n1'); - await wizard.startDeploy(); - - expect(wizard.deployError()).toBe('Deploy failed'); - dispose(); - }); - }); - - describe('cancelDeploy', () => { - it('calls cancelJob API', async () => { - apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); - apiMock.cancelJob.mockResolvedValue(undefined); - apiMock.createJob.mockResolvedValue({ jobId: 'job-1', eventsUrl: '/api/events' }); - apiMock.getJob.mockResolvedValue({ targets: [], status: 'running' }); - - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - wizard.setSelectedSourceAgent('a1'); - await wizard.startDeploy(['n1']); - await wizard.cancelDeploy(); - - expect(apiMock.cancelJob).toHaveBeenCalledWith('job-1'); - dispose(); - }); - - it('does nothing when no jobId', async () => { - apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - await wizard.cancelDeploy(); - expect(apiMock.cancelJob).not.toHaveBeenCalled(); - dispose(); - }); - }); - - describe('retryFailed', () => { - it('calls retryJob with retryable target IDs', async () => { - apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); - apiMock.createJob.mockResolvedValue({ jobId: 'job-1', eventsUrl: '/api/events' }); - apiMock.getJob.mockResolvedValue({ - targets: [ - { id: 't1', nodeId: 'n1', status: 'failed_retryable' }, - { id: 't2', nodeId: 'n2', status: 'succeeded' }, - { id: 't3', nodeId: 'n3', status: 'failed_retryable' }, - ], - status: 'partial_success', - }); - apiMock.retryJob.mockResolvedValue({ - jobId: 'job-1', - retryTargets: 2, - status: 'running', - eventsUrl: '/api/events/retry', - }); - - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - wizard.setSelectedSourceAgent('a1'); - await wizard.startDeploy(['n1', 'n2', 'n3']); - await wizard.retryFailed(); - - expect(apiMock.retryJob).toHaveBeenCalledWith('job-1', ['t1', 't3']); - expect(wizard.step()).toBe('deploying'); - dispose(); - }); - }); - - describe('derived state', () => { - it('deployableNodes filters to deployable nodes without agents', async () => { - apiMock.getCandidates.mockResolvedValue({ - nodes: [ - { nodeId: 'n1', name: 'a', ip: '1', hasAgent: false, deployable: true }, - { nodeId: 'n2', name: 'b', ip: '2', hasAgent: true, deployable: false }, - { nodeId: 'n3', name: 'c', ip: '3', hasAgent: false, deployable: false }, - ], - sourceAgents: [], - }); - - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - expect(wizard.deployableNodes()).toHaveLength(1); - expect(wizard.deployableNodes()[0].nodeId).toBe('n1'); - dispose(); - }); - - it('onlineSourceAgents filters to online agents', async () => { - apiMock.getCandidates.mockResolvedValue({ - nodes: [], - sourceAgents: [ - { agentId: 'a1', nodeId: 'n1', online: true }, - { agentId: 'a2', nodeId: 'n2', online: false }, - ], - }); - - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - expect(wizard.onlineSourceAgents()).toHaveLength(1); - expect(wizard.onlineSourceAgents()[0].agentId).toBe('a1'); - dispose(); - }); - - it('succeededTargets includes succeeded, enrolling, and verifying', async () => { - apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); - apiMock.createJob.mockResolvedValue({ jobId: 'j1', eventsUrl: '/e' }); - apiMock.getJob.mockResolvedValue({ - targets: [ - { id: 't1', status: 'succeeded' }, - { id: 't2', status: 'enrolling' }, - { id: 't3', status: 'verifying' }, - { id: 't4', status: 'failed_retryable' }, - ], - status: 'partial_success', - }); - - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - wizard.setSelectedSourceAgent('a1'); - await wizard.startDeploy(['n1']); - - expect(wizard.succeededTargets()).toHaveLength(3); - expect(wizard.failedTargets()).toHaveLength(1); - dispose(); - }); - - it('isOperationActive is true during preflight and deploying', async () => { - apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - expect(wizard.isOperationActive()).toBe(false); - - wizard.setStep('preflight'); - expect(wizard.isOperationActive()).toBe(true); - - wizard.setStep('deploying'); - expect(wizard.isOperationActive()).toBe(true); - - wizard.setStep('results'); - expect(wizard.isOperationActive()).toBe(false); - dispose(); - }); - - }); -}); diff --git a/frontend-modern/src/hooks/useDeployWizard.ts b/frontend-modern/src/hooks/useDeployWizard.ts deleted file mode 100644 index 7f7ba2b7d0..0000000000 --- a/frontend-modern/src/hooks/useDeployWizard.ts +++ /dev/null @@ -1,619 +0,0 @@ -/** - * Central state machine for the agent deployment wizard. - * - * Manages the full lifecycle: candidates -> preflight -> confirm -> deploying -> results. - * Internally drives two useDeployStream instances (preflight + deploy SSE). - */ - -import { createSignal, createMemo, onCleanup } from 'solid-js'; -import { AgentDeployAPI } from '@/api/agentDeploy'; -import { useDeployStream } from '@/hooks/useDeployStream'; -import type { - CandidateNode, - SourceAgentInfo, - DeployTarget, - DeployEvent, - DeployJobStatus, - DeployTargetStatus, -} from '@/types/agentDeploy'; -import { logger } from '@/utils/logger'; - -export type WizardStep = 'candidates' | 'preflight' | 'confirm' | 'deploying' | 'results'; - -export interface UseDeployWizardOptions { - clusterId: string; - clusterName: string; -} - -export function useDeployWizard(opts: UseDeployWizardOptions) { - // -- Step state -- - const [step, setStep] = createSignal('candidates'); - - // -- Candidates -- - const [candidates, setCandidates] = createSignal([]); - const [sourceAgents, setSourceAgents] = createSignal([]); - const [candidatesLoading, setCandidatesLoading] = createSignal(false); - const [candidatesError, setCandidatesError] = createSignal(''); - const [selectedSourceAgent, setSelectedSourceAgent] = createSignal(''); - const [selectedNodeIds, setSelectedNodeIds] = createSignal>(new Set()); - - // -- Preflight -- - const [preflightId, setPreflightId] = createSignal(''); - const [preflightTargets, setPreflightTargets] = createSignal([]); - const [preflightStatus, setPreflightStatus] = createSignal(''); - const [preflightEventsUrl, setPreflightEventsUrl] = createSignal(null); - const [preflightError, setPreflightError] = createSignal(''); - - // -- Confirm step selection (nodes user wants to deploy) -- - const [confirmSelectedNodeIds, setConfirmSelectedNodeIds] = createSignal>( - new Set(), - ); - - // -- Deploy -- - const [jobId, setJobId] = createSignal(''); - const [jobTargets, setJobTargets] = createSignal([]); - const [jobStatus, setJobStatus] = createSignal(''); - const [deployEventsUrl, setDeployEventsUrl] = createSignal(null); - const [deployError, setDeployError] = createSignal(''); - - // -- Operation loading states -- - const [startingPreflight, setStartingPreflight] = createSignal(false); - const [startingDeploy, setStartingDeploy] = createSignal(false); - const [retrying, setRetrying] = createSignal(false); - const [canceling, setCanceling] = createSignal(false); - - // -- SSE streams -- - const preflightStream = useDeployStream({ - eventsUrl: preflightEventsUrl, - onEvent: (event) => handlePreflightEvent(event), - onComplete: (status) => handlePreflightComplete(status), - onError: (msg) => { - setPreflightError(msg); - // SSE retries exhausted — fallback to polling final state. - if (preflightId()) void pollPreflightFinalState(); - }, - }); - - const deployStream = useDeployStream({ - eventsUrl: deployEventsUrl, - onEvent: (event) => handleDeployEvent(event), - onComplete: (status) => handleDeployComplete(status), - onError: (msg) => { - setDeployError(msg); - // SSE retries exhausted — fallback to polling final state. - if (jobId()) void pollDeployFinalState(); - }, - }); - - // -- Derived state -- - const deployableNodes = createMemo(() => candidates().filter((n) => n.deployable && !n.hasAgent)); - - const onlineSourceAgents = createMemo(() => sourceAgents().filter((a) => a.online)); - - const readyNodes = createMemo(() => preflightTargets().filter((t) => t.status === 'ready')); - - const failedPreflightNodes = createMemo(() => - preflightTargets().filter( - (t) => t.status === 'failed_retryable' || t.status === 'failed_permanent', - ), - ); - - const succeededTargets = createMemo(() => - jobTargets().filter( - (t) => t.status === 'succeeded' || t.status === 'enrolling' || t.status === 'verifying', - ), - ); - - const failedTargets = createMemo(() => - jobTargets().filter((t) => t.status === 'failed_retryable' || t.status === 'failed_permanent'), - ); - - const retryableTargets = createMemo(() => - jobTargets().filter((t) => t.status === 'failed_retryable'), - ); - - const skippedTargets = createMemo(() => - jobTargets().filter((t) => t.status === 'skipped_already_agent'), - ); - - const canceledTargets = createMemo(() => jobTargets().filter((t) => t.status === 'canceled')); - - const isOperationActive = createMemo(() => step() === 'preflight' || step() === 'deploying'); - - // -- Event handlers -- - // - // SSE events are deploy.Event structs with: - // id, jobId, targetId, type, message, data, createdAt - // - // The `data` field is a JSON string containing: - // - Preflight complete: {ssh_reachable, pulse_reachable, has_agent, arch, error_detail} - // - Install result: {exit_code, output} - // - Empty for intermediate progress steps - // - // `phase` and `status` from the agent's progress payload do NOT reach the browser — - // they are consumed server-side for target status updates in the store. - // The frontend infers per-target status from the data blob structure and message text, - // with authoritative state coming from the final GET poll on job_complete. - - interface PreflightResultData { - ssh_reachable?: boolean; - pulse_reachable?: boolean; - has_agent?: boolean; - arch?: string; - error_detail?: string; - } - - interface InstallResultData { - exit_code?: number; - output?: string; - } - - function updateTargetInList( - setter: typeof setPreflightTargets | typeof setJobTargets, - targetId: string, - targetStatus: DeployTargetStatus, - errorMsg?: string, - ) { - setter((prev) => - prev.map((t) => { - if (t.id !== targetId) return t; - return { - ...t, - status: targetStatus, - errorMessage: errorMsg ?? t.errorMessage, - }; - }), - ); - } - - function handlePreflightEvent(event: DeployEvent) { - const targetId = event.targetId; - if (!targetId) return; - - if (event.type === 'preflight_result') { - if (event.data) { - // Preflight complete event with structured result data. - try { - const result = JSON.parse(event.data) as PreflightResultData; - if (result.has_agent) { - updateTargetInList(setPreflightTargets, targetId, 'skipped_already_agent'); - } else if (result.ssh_reachable && result.pulse_reachable) { - updateTargetInList(setPreflightTargets, targetId, 'ready'); - // Also update arch if provided. - if (result.arch) { - setPreflightTargets((prev) => - prev.map((t) => (t.id === targetId ? { ...t, arch: result.arch } : t)), - ); - } - } else { - const detail = result.error_detail || event.message || 'Preflight check failed'; - updateTargetInList(setPreflightTargets, targetId, 'failed_permanent', detail); - } - } catch { - // Structured data parse failed — use message heuristics. - if ( - event.message?.toLowerCase().includes('fail') || - event.message?.toLowerCase().includes('error') - ) { - updateTargetInList(setPreflightTargets, targetId, 'failed_permanent', event.message); - } - } - } else { - // Intermediate preflight event (no data) — mark as in-progress if still pending. - const tgt = preflightTargets().find((t) => t.id === targetId); - if (tgt && tgt.status === 'pending') { - updateTargetInList(setPreflightTargets, targetId, 'preflighting'); - } - } - } else if (event.type === 'error') { - updateTargetInList(setPreflightTargets, targetId, 'failed_permanent', event.message); - } - } - - function handleDeployEvent(event: DeployEvent) { - const targetId = event.targetId; - if (!targetId) return; - - if (event.type === 'install_output') { - if (event.data) { - // Install result with structured data. - try { - const result = JSON.parse(event.data) as InstallResultData; - if (result.exit_code !== undefined && result.exit_code !== 0) { - const detail = result.output - ? `Exit ${result.exit_code}: ${result.output.slice(0, 200)}` - : event.message || `Install failed (exit ${result.exit_code})`; - updateTargetInList(setJobTargets, targetId, 'failed_retryable', detail); - } - // Don't map exit_code=0 to succeeded — backend waits for enrollment. - // Authoritative succeeded status comes from the final GET poll. - } catch { - // Structured data parse failed — use message heuristics. - if ( - event.message?.toLowerCase().includes('fail') || - event.message?.toLowerCase().includes('error') - ) { - updateTargetInList(setJobTargets, targetId, 'failed_retryable', event.message); - } - } - } else { - // Intermediate install event (no data) — mark as installing if still pending/ready. - const tgt = jobTargets().find((t) => t.id === targetId); - if (tgt && (tgt.status === 'pending' || tgt.status === 'ready')) { - updateTargetInList(setJobTargets, targetId, 'installing'); - } - } - } else if (event.type === 'enroll_complete') { - // Enrollment done — backend has set the final status, but we optimistically - // show enrolling until the final poll. Don't set succeeded here since the - // backend may still be verifying. - const tgt = jobTargets().find((t) => t.id === targetId); - if (tgt && tgt.status === 'installing') { - updateTargetInList(setJobTargets, targetId, 'enrolling'); - } - } else if (event.type === 'error') { - updateTargetInList(setJobTargets, targetId, 'failed_retryable', event.message); - } - } - - async function handlePreflightComplete(status: string) { - setPreflightEventsUrl(null); - setPreflightStatus(status as DeployJobStatus); - // Poll GET for authoritative final state (retry once). - for (let attempt = 0; attempt < 2; attempt++) { - try { - const job = await AgentDeployAPI.getPreflight(preflightId()); - setPreflightTargets(job.targets || []); - setPreflightStatus(job.status); - break; - } catch (err) { - if (attempt === 1) { - logger.warn('[DeployWizard] Failed to poll final preflight state after retry', err); - } - } - } - // Initialize confirm selection with all ready nodes. - const ready = preflightTargets().filter((t) => t.status === 'ready'); - setConfirmSelectedNodeIds(new Set(ready.map((t) => t.nodeId))); - setStep('confirm'); - } - - async function handleDeployComplete(status: string) { - setDeployEventsUrl(null); - setJobStatus(status as DeployJobStatus); - // Poll GET for authoritative final state (retry once). - for (let attempt = 0; attempt < 2; attempt++) { - try { - const job = await AgentDeployAPI.getJob(jobId()); - setJobTargets(job.targets || []); - setJobStatus(job.status); - break; - } catch (err) { - if (attempt === 1) { - logger.warn('[DeployWizard] Failed to poll final deploy state after retry', err); - } - } - } - setStep('results'); - } - - // Fallback polling when SSE connection is lost after max retries. - // Polls every 5s until the job reaches a terminal state. - const TERMINAL_JOB_STATUSES = new Set(['succeeded', 'partial_success', 'failed', 'canceled']); - let pollTimerPreflight: ReturnType | undefined; - let pollTimerDeploy: ReturnType | undefined; - let disposed = false; - - function clearPollTimers() { - if (pollTimerPreflight !== undefined) { - clearTimeout(pollTimerPreflight); - pollTimerPreflight = undefined; - } - if (pollTimerDeploy !== undefined) { - clearTimeout(pollTimerDeploy); - pollTimerDeploy = undefined; - } - } - - function pollPreflightFinalState() { - const id = preflightId(); - if (!id || disposed) return; - const poll = async () => { - if (disposed) return; - try { - const job = await AgentDeployAPI.getPreflight(id); - if (disposed) return; // Re-check after await - setPreflightTargets(job.targets || []); - setPreflightStatus(job.status); - if (TERMINAL_JOB_STATUSES.has(job.status)) { - pollTimerPreflight = undefined; - handlePreflightComplete(job.status); - return; - } - } catch (err) { - if (disposed) return; - logger.warn('[DeployWizard] Fallback poll failed for preflight', err); - } - if (!disposed && step() === 'preflight') { - pollTimerPreflight = setTimeout(poll, 5000); - } - }; - pollTimerPreflight = setTimeout(poll, 2000); - } - - function pollDeployFinalState() { - const id = jobId(); - if (!id || disposed) return; - const poll = async () => { - if (disposed) return; - try { - const job = await AgentDeployAPI.getJob(id); - if (disposed) return; // Re-check after await - setJobTargets(job.targets || []); - setJobStatus(job.status); - if (TERMINAL_JOB_STATUSES.has(job.status)) { - pollTimerDeploy = undefined; - handleDeployComplete(job.status); - return; - } - } catch (err) { - if (disposed) return; - logger.warn('[DeployWizard] Fallback poll failed for deploy', err); - } - if (!disposed && step() === 'deploying') { - pollTimerDeploy = setTimeout(poll, 5000); - } - }; - pollTimerDeploy = setTimeout(poll, 2000); - } - - // -- Actions -- - - async function loadCandidates() { - setCandidatesLoading(true); - setCandidatesError(''); - try { - const resp = await AgentDeployAPI.getCandidates(opts.clusterId); - setCandidates(resp.nodes || []); - setSourceAgents(resp.sourceAgents || []); - - // Auto-select deployable nodes. - const deployable = (resp.nodes || []).filter((n) => n.deployable && !n.hasAgent); - setSelectedNodeIds(new Set(deployable.map((n) => n.nodeId))); - - // Auto-select source agent if only one online. - const online = (resp.sourceAgents || []).filter((a) => a.online); - if (online.length === 1) { - setSelectedSourceAgent(online[0].agentId); - } - } catch (err) { - setCandidatesError(err instanceof Error ? err.message : 'Failed to load candidates'); - } finally { - setCandidatesLoading(false); - } - } - - async function startPreflight() { - if (selectedNodeIds().size === 0 || !selectedSourceAgent()) return; - setStartingPreflight(true); - setPreflightError(''); - try { - const resp = await AgentDeployAPI.createPreflight(opts.clusterId, { - sourceAgentId: selectedSourceAgent(), - targetNodeIds: Array.from(selectedNodeIds()), - }); - setPreflightId(resp.preflightId); - setPreflightStatus(resp.status as DeployJobStatus); - - // Start SSE stream and transition immediately — this is the critical path. - setPreflightEventsUrl(resp.eventsUrl); - setStep('preflight'); - - // Fetch initial targets (retry once on failure — targets must be populated - // before SSE events arrive so updates don't get dropped). - for (let attempt = 0; attempt < 2; attempt++) { - try { - const job = await AgentDeployAPI.getPreflight(resp.preflightId); - setPreflightTargets(job.targets || []); - break; - } catch (err) { - if (attempt === 1) { - logger.warn( - '[DeployWizard] Failed to fetch initial preflight targets after retry', - err, - ); - } - } - } - } catch (err) { - setPreflightError(err instanceof Error ? err.message : 'Failed to start preflight'); - } finally { - setStartingPreflight(false); - } - } - - function toggleConfirmNode(nodeId: string) { - setConfirmSelectedNodeIds((prev) => { - const next = new Set(prev); - if (next.has(nodeId)) { - next.delete(nodeId); - } else { - next.add(nodeId); - } - return next; - }); - } - - async function startDeploy(nodeIds?: string[]) { - const targetNodeIds = nodeIds || Array.from(confirmSelectedNodeIds()); - if (targetNodeIds.length === 0) return; - setStartingDeploy(true); - setDeployError(''); - try { - const resp = await AgentDeployAPI.createJob(opts.clusterId, { - sourceAgentId: selectedSourceAgent(), - preflightId: preflightId(), - targetNodeIds, - }); - setJobId(resp.jobId); - - // Start SSE stream and transition immediately — this is the critical path. - setDeployEventsUrl(resp.eventsUrl); - setStep('deploying'); - - // Fetch initial targets (retry once on failure — targets must be populated - // before SSE events arrive so updates don't get dropped). - for (let attempt = 0; attempt < 2; attempt++) { - try { - const job = await AgentDeployAPI.getJob(resp.jobId); - setJobTargets(job.targets || []); - setJobStatus(job.status); - break; - } catch (err) { - if (attempt === 1) { - logger.warn('[DeployWizard] Failed to fetch initial deploy targets after retry', err); - } - } - } - } catch (err) { - setDeployError(err instanceof Error ? err.message : 'Failed to start deployment'); - } finally { - setStartingDeploy(false); - } - } - - async function cancelDeploy() { - if (!jobId()) return; - setCanceling(true); - try { - await AgentDeployAPI.cancelJob(jobId()); - } catch (err) { - setDeployError(err instanceof Error ? err.message : 'Failed to cancel'); - } finally { - setCanceling(false); - } - } - - async function retryFailed() { - if (!jobId()) return; - const targetIds = retryableTargets().map((t) => t.id); - if (targetIds.length === 0) return; - setRetrying(true); - setDeployError(''); - try { - const resp = await AgentDeployAPI.retryJob(jobId(), targetIds); - setJobStatus(resp.status as DeployJobStatus); - - // Restart SSE stream and transition immediately. - setDeployEventsUrl(resp.eventsUrl); - setStep('deploying'); - - // Refresh targets (retry once on failure). - for (let attempt = 0; attempt < 2; attempt++) { - try { - const job = await AgentDeployAPI.getJob(jobId()); - setJobTargets(job.targets || []); - break; - } catch (err) { - if (attempt === 1) { - logger.warn('[DeployWizard] Failed to refresh targets after retry', err); - } - } - } - } catch (err) { - setDeployError(err instanceof Error ? err.message : 'Failed to retry'); - } finally { - setRetrying(false); - } - } - - function toggleNodeSelection(nodeId: string) { - setSelectedNodeIds((prev) => { - const next = new Set(prev); - if (next.has(nodeId)) { - next.delete(nodeId); - } else { - next.add(nodeId); - } - return next; - }); - } - - function selectAllNodes() { - setSelectedNodeIds(new Set(deployableNodes().map((n) => n.nodeId))); - } - - function deselectAllNodes() { - setSelectedNodeIds(new Set()); - } - - // Load candidates immediately. - void loadCandidates(); - - // Cleanup poll timers on disposal (component unmount). - onCleanup(() => { - disposed = true; - clearPollTimers(); - }); - - return { - // Step - step, - setStep, - - // Candidates - candidates, - candidatesLoading, - candidatesError, - sourceAgents, - onlineSourceAgents, - selectedSourceAgent, - setSelectedSourceAgent, - selectedNodeIds, - toggleNodeSelection, - selectAllNodes, - deselectAllNodes, - deployableNodes, - - // Preflight - preflightId, - preflightTargets, - preflightStatus, - preflightError, - preflightStream, - readyNodes, - failedPreflightNodes, - - // Confirm step - confirmSelectedNodeIds, - toggleConfirmNode, - - // Deploy - jobId, - jobTargets, - jobStatus, - deployError, - deployStream, - succeededTargets, - failedTargets, - retryableTargets, - skippedTargets, - canceledTargets, - - // Operation states - startingPreflight, - startingDeploy, - retrying, - canceling, - isOperationActive, - - // Actions - loadCandidates, - startPreflight, - startDeploy, - cancelDeploy, - retryFailed, - }; -} - -export type DeployWizardState = ReturnType; diff --git a/frontend-modern/src/pages/CloudPricing.tsx b/frontend-modern/src/pages/CloudPricing.tsx deleted file mode 100644 index 34a408432b..0000000000 --- a/frontend-modern/src/pages/CloudPricing.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { For, Show } from 'solid-js'; -import { A } from '@solidjs/router'; -import { Card } from '@/components/shared/Card'; -import { PageHeader } from '@/components/shared/PageHeader'; -import { - CLOUD_COMMERCIAL_PRESENTATION, - CLOUD_PLAN_DEFINITIONS, - getCloudPlanPricePresentation, - type CloudPlanDefinition, -} from '@/utils/cloudPlans'; -import { getPublicPricingUrl } from '@/utils/pricingHandoff'; - -function CloudTierCard(props: { tier: CloudPlanDefinition }) { - const t = props.tier; - const price = getCloudPlanPricePresentation(t); - - return ( - - -
- - {price.campaignBadge} - -
-
- -

{t.name}

- - -
- {price.monthlyPrice} - {price.cadence} -
-
- {price.compareAtMonthlyPrice} - {price.cadence} -
-
- -
- {price.monthlyPrice} - {price.cadence} -
-
-
{price.annualSummary}
- -
-
-
Monitored systems
-
{t.monitoredSystems}
-
-
-
Support
-
{t.support}
-
-
- - -
- ); -} - -// --------------------------------------------------------------------------- -// Main page -// --------------------------------------------------------------------------- - -export default function CloudPricing() { - return ( -
- - - {/* Tier cards */} -
- {(tier) => } -
- - {/* What's included in all Cloud plans */} - -

- {CLOUD_COMMERCIAL_PRESENTATION.includedInAllHeading} -

-
    - - {(item) => ( -
  • - ✓ - {item} -
  • - )} -
    -
-
- - {/* How it works */} - -

- {CLOUD_COMMERCIAL_PRESENTATION.setupHeading} -

-
    - {(step) =>
  1. {step}
  2. }
    -
-
- - {/* Footer links */} -
- - Already have a Cloud account?{' '} - - Open Pulse Account - - - - Prefer self-hosting?{' '} - - See self-hosted plans - - - - Managing multiple clients?{' '} - - Ask about MSP plans - - -
-
- ); -} diff --git a/frontend-modern/src/pages/HostedSignup.tsx b/frontend-modern/src/pages/HostedSignup.tsx deleted file mode 100644 index f06c96fb1a..0000000000 --- a/frontend-modern/src/pages/HostedSignup.tsx +++ /dev/null @@ -1,298 +0,0 @@ -import { For, Show, createMemo, createSignal } from 'solid-js'; -import { useLocation } from '@solidjs/router'; -import { Card } from '@/components/shared/Card'; -import { PageHeader } from '@/components/shared/PageHeader'; -import { UpgradeLink } from '@/components/shared/UpgradeLink'; -import { HostedSignupAPI, type HostedAPIError } from '@/api/hostedSignup'; -import { getUpgradeActionDestination } from '@/stores/licenseCommercial'; -import { logger } from '@/utils/logger'; -import { - HOSTED_SIGNUP_PRESENTATION, - getCloudPlanForTier, - getCloudPlanPricePresentation, -} from '@/utils/cloudPlans'; - -type SignupStatus = 'idle' | 'submitting' | 'success' | 'unavailable' | 'error'; - -function isValidEmail(value: string): boolean { - const email = value.trim(); - if (email === '' || email.includes(' ')) { - return false; - } - const at = email.indexOf('@'); - if (at <= 0 || at >= email.length - 1) { - return false; - } - const domain = email.slice(at + 1); - const dot = domain.indexOf('.'); - return dot > 0 && dot < domain.length - 1; -} - -function unavailableError(err: HostedAPIError, status: number): boolean { - if (status === 404 || status === 501 || status === 503) { - return true; - } - return [ - 'orgs_unavailable', - 'provisioner_not_available', - 'magic_links_unavailable', - 'public_url_missing', - ].includes(err.code); -} - -export default function HostedSignup() { - const location = useLocation(); - const [email, setEmail] = createSignal(''); - const [orgName, setOrgName] = createSignal(''); - const [status, setStatus] = createSignal('idle'); - const [message, setMessage] = createSignal(''); - - const [requestingMagicLink, setRequestingMagicLink] = createSignal(false); - const [magicLinkEmail, setMagicLinkEmail] = createSignal(''); - const [magicLinkMessage, setMagicLinkMessage] = createSignal(''); - - const selectedPlan = createMemo(() => { - const params = new URLSearchParams(location.search); - return getCloudPlanForTier(params.get('tier')); - }); - const selectedPlanPrice = createMemo(() => getCloudPlanPricePresentation(selectedPlan())); - const cloudPortalDestination = createMemo(() => getUpgradeActionDestination('cloud')); - - const canSubmit = createMemo(() => { - return isValidEmail(email()) && orgName().trim().length >= 3 && status() !== 'submitting'; - }); - - const submitSignup = async (event: Event) => { - event.preventDefault(); - setMessage(''); - - const cleanEmail = email().trim().toLowerCase(); - const cleanOrgName = orgName().trim(); - if (!isValidEmail(cleanEmail)) { - setStatus('error'); - setMessage('Enter a valid email address.'); - return; - } - if (cleanOrgName.length < 3) { - setStatus('error'); - setMessage('Organization name must be at least 3 characters.'); - return; - } - - setStatus('submitting'); - const result = await HostedSignupAPI.signup({ - email: cleanEmail, - org_name: cleanOrgName, - tier: selectedPlan().tier, - }); - - if (result.ok) { - const checkoutURL = result.data.checkout_url?.trim(); - if (checkoutURL) { - window.location.assign(checkoutURL); - return; - } - setStatus('success'); - setMessage(result.data.message || "If that email can finish signup, you'll receive a Pulse Account sign-in link shortly."); - return; - } - - logger.warn('[HostedSignup] Signup failed', result); - if (unavailableError(result.error, result.status)) { - setStatus('unavailable'); - setMessage('Hosted signup is not enabled on this deployment yet.'); - return; - } - setStatus('error'); - setMessage(result.error.message || 'Signup failed. Please try again.'); - }; - - const requestMagicLink = async (event: Event) => { - event.preventDefault(); - setMagicLinkMessage(''); - - const cleanEmail = magicLinkEmail().trim().toLowerCase(); - if (!isValidEmail(cleanEmail)) { - setMagicLinkMessage('Enter a valid email address first.'); - return; - } - - setRequestingMagicLink(true); - try { - const result = await HostedSignupAPI.requestMagicLink(cleanEmail); - if (result.ok) { - setMagicLinkMessage( - result.data.message || - "If that email is registered, you'll receive a Pulse Account sign-in link shortly.", - ); - } else { - setMagicLinkMessage(result.error.message || 'Failed to request sign-in link.'); - } - } finally { - setRequestingMagicLink(false); - } - }; - - return ( -
- - -
- -

- {HOSTED_SIGNUP_PRESENTATION.workspaceHeading} -

- -
- - setEmail(e.currentTarget.value)} - placeholder="you@company.com" - autocomplete="email" - required - /> - - - setOrgName(e.currentTarget.value)} - placeholder="Acme Infrastructure" - autocomplete="organization" - required - /> - - -
- - -
- {message()} -
-
- - - - Open hosted signup portal - - -
- - -

- {HOSTED_SIGNUP_PRESENTATION.planHeading} -

-
-
-
-

{selectedPlan().name}

-

{selectedPlanPrice().annualSummary}

-
-
-

- {selectedPlanPrice().monthlyPrice} - {selectedPlanPrice().cadence} -

- -

- {selectedPlanPrice().compareAtMonthlyPrice} - {selectedPlanPrice().cadence} -

-
-
-
-
-
-
Monitored Systems
-
{selectedPlan().monitoredSystems}
-
-
-
Support
-
{selectedPlan().support}
-
-
-
-

- {HOSTED_SIGNUP_PRESENTATION.nextHeading} -

-
    - {(step) =>
  1. {step}
  2. }
    -
- -
-

- {HOSTED_SIGNUP_PRESENTATION.existingAccountHeading} -

-

- {HOSTED_SIGNUP_PRESENTATION.existingAccountDescription} -

-
- setMagicLinkEmail(e.currentTarget.value)} - placeholder="you@company.com" - autocomplete="email" - required - /> - -
- -
{magicLinkMessage()}
-
-
-
-
-
- ); -} diff --git a/frontend-modern/src/pages/__tests__/CloudPricing.test.tsx b/frontend-modern/src/pages/__tests__/CloudPricing.test.tsx deleted file mode 100644 index c5818f4994..0000000000 --- a/frontend-modern/src/pages/__tests__/CloudPricing.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { cleanup, render, screen } from '@solidjs/testing-library'; -import { Router, Route } from '@solidjs/router'; - -import CloudPricing from '@/pages/CloudPricing'; - -describe('CloudPricing', () => { - afterEach(() => { - cleanup(); - }); - - it('routes each cloud plan CTA to signup with the canonical tier key', async () => { - render(() => ( - - - - )); - - expect(await screen.findByRole('link', { name: 'Choose Starter' })).toHaveAttribute( - 'href', - '/cloud/signup?tier=starter', - ); - expect(screen.getByText('Founding rate')).toBeInTheDocument(); - expect(screen.getByText('$19')).toBeInTheDocument(); - expect(screen.getByText('$29/month')).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Choose Power' })).toHaveAttribute( - 'href', - '/cloud/signup?tier=power', - ); - expect(screen.getByRole('link', { name: 'Choose Max' })).toHaveAttribute( - 'href', - '/cloud/signup?tier=max', - ); - expect(screen.queryByText('Starter founding rate')).not.toBeInTheDocument(); - expect(screen.getAllByText('All Pro features')).toHaveLength(1); - expect(screen.getByText('Managed hosting')).toBeInTheDocument(); - expect( - screen.getByText('Choose a Cloud plan and confirm billing in secure checkout.'), - ).toBeInTheDocument(); - expect(screen.getByText('How it works')).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Open Pulse Account' })).toHaveAttribute( - 'href', - '/portal', - ); - expect(screen.getByRole('link', { name: 'See self-hosted plans' })).toHaveAttribute( - 'href', - 'https://pulserelay.pro/pricing?utm_source=pulse&utm_medium=app&utm_campaign=upgrade', - ); - expect(screen.queryByText(/provisioned in under 60 seconds/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/no maintenance ever/i)).not.toBeInTheDocument(); - }); -}); diff --git a/frontend-modern/src/pages/__tests__/HostedSignup.test.tsx b/frontend-modern/src/pages/__tests__/HostedSignup.test.tsx deleted file mode 100644 index 53c77a7f1e..0000000000 --- a/frontend-modern/src/pages/__tests__/HostedSignup.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; -import { Router, Route } from '@solidjs/router'; - -import HostedSignup from '@/pages/HostedSignup'; - -const signupMock = vi.fn(); -const requestMagicLinkMock = vi.fn(); - -vi.mock('@/api/hostedSignup', () => ({ - HostedSignupAPI: { - signup: (...args: unknown[]) => signupMock(...args), - requestMagicLink: (...args: unknown[]) => requestMagicLinkMock(...args), - }, -})); - -vi.mock('@/stores/licenseCommercial', () => ({ - getUpgradeActionDestination: () => ({ href: '/cloud', external: false }), - getUpgradeActionUrlOrFallback: () => 'https://cloud.pulserelay.pro', -})); - -vi.mock('@/utils/logger', () => ({ - logger: { - warn: vi.fn(), - }, -})); - -describe('HostedSignup', () => { - beforeEach(() => { - signupMock.mockReset(); - requestMagicLinkMock.mockReset(); - signupMock.mockResolvedValue({ - ok: true, - status: 202, - data: { - message: - "If that email can finish signup, you'll receive a Pulse Account sign-in link shortly.", - }, - }); - requestMagicLinkMock.mockResolvedValue({ - ok: true, - status: 200, - data: { - success: true, - message: - "If that email is registered, you'll receive a Pulse Account sign-in link shortly.", - }, - }); - window.history.replaceState({}, '', '/cloud/signup?tier=power'); - }); - - afterEach(() => { - cleanup(); - window.history.replaceState({}, '', '/'); - }); - - it('uses the selected tier from the URL in both copy and signup payload', async () => { - render(() => ( - - - - )); - - expect(await screen.findByText('Workspace')).toBeInTheDocument(); - expect( - screen.getByText( - 'Create a hosted Pulse workspace. Stripe handles billing before provisioning.', - ), - ).toBeInTheDocument(); - expect(screen.getByText('Plan')).toBeInTheDocument(); - expect(screen.getByText('How it works')).toBeInTheDocument(); - expect( - screen.getByText('Choose a Cloud plan and confirm billing in secure checkout.'), - ).toBeInTheDocument(); - expect(screen.getByText('Already signed up?')).toBeInTheDocument(); - expect(screen.getByText('Request a fresh Pulse Account sign-in link.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Email Pulse Account Link' })).toBeInTheDocument(); - - fireEvent.input(screen.getByLabelText('Work Email'), { - target: { value: 'owner@example.com' }, - }); - fireEvent.input(screen.getByLabelText('Organization Name'), { - target: { value: 'Pulse Labs' }, - }); - fireEvent.submit(screen.getByRole('button', { name: 'Continue to Checkout' }).closest('form')!); - - await waitFor(() => { - expect(signupMock).toHaveBeenCalledWith({ - email: 'owner@example.com', - org_name: 'Pulse Labs', - tier: 'power', - }); - }); - }); - - it('renders signup plan pricing from the shared cloud pricing contract', async () => { - window.history.replaceState({}, '', '/cloud/signup?tier=starter'); - - render(() => ( - - - - )); - - expect(await screen.findByText('Plan')).toBeInTheDocument(); - expect(screen.getByText('$19/month')).toBeInTheDocument(); - expect(screen.getByText('$29/month')).toBeInTheDocument(); - expect(screen.getByText('or $249/year (save 29%)')).toBeInTheDocument(); - }); -}); diff --git a/frontend-modern/src/stores/__tests__/metricsCollector.test.ts b/frontend-modern/src/stores/__tests__/metricsCollector.test.ts deleted file mode 100644 index a2d7f65962..0000000000 --- a/frontend-modern/src/stores/__tests__/metricsCollector.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; - -const mockRecordDiskMetric = vi.fn(); -let mockResources: unknown[] = []; -let mockCachedUnifiedResources: unknown[] = []; - -vi.mock('@/stores/websocket-global', () => ({ - getGlobalWebSocketStore: () => ({ - state: { - resources: mockResources, - }, - }), -})); - -vi.mock('@/utils/logger', () => ({ - logger: { - info: vi.fn(), - }, -})); - -vi.mock('@/stores/events', () => ({ - eventBus: { - on: vi.fn(), - }, -})); - -vi.mock('@/hooks/useUnifiedResources', () => ({ - getCachedUnifiedResources: () => mockCachedUnifiedResources, -})); - -vi.mock('../diskMetricsHistory', () => ({ - recordDiskMetric: (...args: unknown[]) => mockRecordDiskMetric(...args), -})); - -describe('metricsCollector', () => { - beforeEach(() => { - mockResources = []; - mockCachedUnifiedResources = []; - mockRecordDiskMetric.mockReset(); - vi.useRealTimers(); - }); - - it('collects diskIo counters from unified agent resources using camel-case diskIo', async () => { - mockResources = [ - { - id: 'agent-node', - agent: { - agentId: 'agent-123', - diskIo: [ - { - device: 'nvme0n1', - readBytes: 1000, - writeBytes: 2000, - readOps: 10, - writeOps: 20, - ioTimeMs: 100, - }, - ], - }, - }, - ]; - - vi.useFakeTimers(); - const { startMetricsCollector, stopMetricsCollector } = await import('../metricsCollector'); - - startMetricsCollector(); - - mockResources = [ - { - id: 'agent-node', - agent: { - agentId: 'agent-123', - diskIo: [ - { - device: 'nvme0n1', - readBytes: 3000, - writeBytes: 6000, - readOps: 30, - writeOps: 60, - ioTimeMs: 300, - }, - ], - }, - }, - ]; - - vi.advanceTimersByTime(2000); - - expect(mockRecordDiskMetric).toHaveBeenCalledWith('agent-123:nvme0n1', 1000, 2000, 10, 20, 10); - - stopMetricsCollector(); - }); - - it('falls back to cached unified resources when websocket resources do not include diskIo', async () => { - mockResources = [ - { - id: 'node-1', - type: 'node', - name: 'delly', - }, - ]; - mockCachedUnifiedResources = [ - { - id: 'node-1', - agent: { - agentId: 'agent-456', - diskIo: [ - { - device: 'sda', - readBytes: 100, - writeBytes: 200, - readOps: 1, - writeOps: 2, - ioTimeMs: 50, - }, - ], - }, - }, - ]; - - vi.useFakeTimers(); - const { startMetricsCollector, stopMetricsCollector } = await import('../metricsCollector'); - - startMetricsCollector(); - - mockCachedUnifiedResources = [ - { - id: 'node-1', - agent: { - agentId: 'agent-456', - diskIo: [ - { - device: 'sda', - readBytes: 2100, - writeBytes: 4200, - readOps: 21, - writeOps: 42, - ioTimeMs: 250, - }, - ], - }, - }, - ]; - - vi.advanceTimersByTime(2000); - - expect(mockRecordDiskMetric).toHaveBeenCalledWith('agent-456:sda', 1000, 2000, 10, 20, 10); - - stopMetricsCollector(); - }); -}); diff --git a/frontend-modern/src/stores/demoMode.ts b/frontend-modern/src/stores/demoMode.ts deleted file mode 100644 index 51f94df56d..0000000000 --- a/frontend-modern/src/stores/demoMode.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { SecurityStatus } from '@/types/config'; -import { - presentationPolicyIsDemoMode as demoModeEnabled, - sessionPresentationPolicyResolved as demoModeResolved, - syncSessionPresentationPolicy, -} from '@/stores/sessionPresentationPolicy'; - -export function syncDemoModeFromSecurityStatus( - status?: Pick | null, -): boolean { - return syncSessionPresentationPolicy(status).demoMode; -} - -export { demoModeEnabled, demoModeResolved }; diff --git a/frontend-modern/src/stores/metricsCollector.ts b/frontend-modern/src/stores/metricsCollector.ts deleted file mode 100644 index a40a1b456c..0000000000 --- a/frontend-modern/src/stores/metricsCollector.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { getGlobalWebSocketStore } from './websocket-global'; -import { logger } from '@/utils/logger'; -import { recordDiskMetric } from './diskMetricsHistory'; -import { eventBus } from '@/stores/events'; -import { getCachedUnifiedResources } from '@/hooks/useUnifiedResources'; - -const SAMPLE_INTERVAL_MS = 2000; - -interface DiskCounterState { - timestamp: number; - readBytes: number; - writeBytes: number; - readOps: number; - writeOps: number; - ioTimeMs: number; -} - -interface DiskCounterSample { - agentId: string; - device: string; - readBytes: number; - writeBytes: number; - readOps: number; - writeOps: number; - ioTimeMs: number; -} - -const previousDiskCounters = new Map(); - -const asRecord = (value: unknown): Record | undefined => - value && typeof value === 'object' ? (value as Record) : undefined; -const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : []); -const asString = (value: unknown): string | undefined => - typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; -const asNumber = (value: unknown): number | undefined => - typeof value === 'number' && Number.isFinite(value) ? value : undefined; - -const getResourceDiskCounters = (resource: unknown): DiskCounterSample[] => { - const raw = asRecord(resource); - if (!raw) return []; - - const agent = asRecord(raw.agent); - const discoveryTarget = asRecord(raw.discoveryTarget); - const agentId = - asString(agent?.agentId) || - asString(discoveryTarget?.agentId) || - asString(raw.id) || - asString(raw.platformId); - if (!agentId) return []; - - const platformData = asRecord(raw.platformData); - const platformAgent = asRecord(platformData?.agent); - - const diskIoCandidates = [ - platformData?.diskIo, - platformData?.diskIO, - platformAgent?.diskIo, - platformAgent?.diskIO, - agent?.diskIo, - agent?.diskIO, - ] as unknown[]; - const diskIoRaw = diskIoCandidates.find((value) => Array.isArray(value)); - const diskIo = asArray(diskIoRaw); - if (diskIo.length === 0) return []; - - const counters: DiskCounterSample[] = []; - for (const entry of diskIo) { - const disk = asRecord(entry); - const device = asString(disk?.device); - if (!device) continue; - counters.push({ - agentId, - device, - readBytes: asNumber(disk?.readBytes) ?? 0, - writeBytes: asNumber(disk?.writeBytes) ?? 0, - readOps: asNumber(disk?.readOps) ?? 0, - writeOps: asNumber(disk?.writeOps) ?? 0, - ioTimeMs: asNumber(disk?.ioTimeMs) ?? 0, - }); - } - - return counters; -}; - -function sampleMetrics(): void { - const wsStore = getGlobalWebSocketStore(); - const wsResources = wsStore.state.resources || []; - const cachedUnifiedResources = getCachedUnifiedResources({ cacheKey: 'all-resources' }); - const sourceResources = wsResources.some( - (resource) => getResourceDiskCounters(resource).length > 0, - ) - ? wsResources - : cachedUnifiedResources; - const now = Date.now(); - const observedKeys = new Set(); - - for (const resource of sourceResources) { - const diskCounters = getResourceDiskCounters(resource); - if (diskCounters.length === 0) continue; - - for (const disk of diskCounters) { - const key = `${disk.agentId}:${disk.device}`; - if (observedKeys.has(key)) continue; - observedKeys.add(key); - - const prev = previousDiskCounters.get(key); - const current: DiskCounterState = { - timestamp: now, - readBytes: disk.readBytes, - writeBytes: disk.writeBytes, - readOps: disk.readOps, - writeOps: disk.writeOps, - ioTimeMs: disk.ioTimeMs, - }; - - if (prev) { - const deltaSeconds = (now - prev.timestamp) / 1000; - - if (deltaSeconds > 0) { - const readBps = Math.max(0, (current.readBytes - prev.readBytes) / deltaSeconds); - const writeBps = Math.max(0, (current.writeBytes - prev.writeBytes) / deltaSeconds); - const readIops = Math.max(0, (current.readOps - prev.readOps) / deltaSeconds); - const writeIops = Math.max(0, (current.writeOps - prev.writeOps) / deltaSeconds); - const deltaIoTime = Math.max(0, current.ioTimeMs - prev.ioTimeMs); - const utilPercent = Math.min(100, (deltaIoTime / (deltaSeconds * 1000)) * 100); - - recordDiskMetric(key, readBps, writeBps, readIops, writeIops, utilPercent); - } - } - previousDiskCounters.set(key, current); - } - } -} - -let collectorInterval: number | null = null; - -export function startMetricsCollector() { - if (collectorInterval !== null) return; - - logger.info('[MetricsCollector] Starting Disk I/O collection service...'); - - // Initial sample - sampleMetrics(); - - collectorInterval = window.setInterval(() => { - sampleMetrics(); - }, SAMPLE_INTERVAL_MS); -} - -export function stopMetricsCollector() { - if (collectorInterval !== null) { - window.clearInterval(collectorInterval); - collectorInterval = null; - logger.info('[MetricsCollector] Stopped'); - } -} - -// Clear stale disk counter state on org switch -eventBus.on('org_switched', () => { - previousDiskCounters.clear(); -}); diff --git a/frontend-modern/src/utils/__tests__/deployFlowPresentation.test.ts b/frontend-modern/src/utils/__tests__/deployFlowPresentation.test.ts deleted file mode 100644 index cba898351a..0000000000 --- a/frontend-modern/src/utils/__tests__/deployFlowPresentation.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - getDeployCandidatesLoadingState, - getDeployInstallCommandLoadingState, - getDeployNoCandidatesState, - getDeployNoSourceAgentsState, -} from '@/utils/deployFlowPresentation'; - -describe('deployFlowPresentation', () => { - it('returns canonical deploy flow loading and empty-state copy', () => { - expect(getDeployCandidatesLoadingState()).toBe('Loading cluster nodes...'); - expect(getDeployNoSourceAgentsState()).toBe( - 'No online source agents found. At least one node in this cluster must have a connected Pulse agent to deploy to other nodes.', - ); - expect(getDeployNoCandidatesState()).toBe('No nodes found in this cluster.'); - expect(getDeployInstallCommandLoadingState()).toBe('Loading install command...'); - }); -}); diff --git a/frontend-modern/src/utils/__tests__/recoveryIssuePresentation.test.ts b/frontend-modern/src/utils/__tests__/recoveryIssuePresentation.test.ts deleted file mode 100644 index 88ef666804..0000000000 --- a/frontend-modern/src/utils/__tests__/recoveryIssuePresentation.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { getRecoveryIssueRailClass } from '@/utils/recoveryIssuePresentation'; - -describe('recoveryIssuePresentation', () => { - it('returns canonical issue rail classes', () => { - expect(getRecoveryIssueRailClass('amber')).toBe('bg-amber-400'); - expect(getRecoveryIssueRailClass('rose')).toBe('bg-rose-500'); - expect(getRecoveryIssueRailClass('blue')).toBe('bg-blue-500'); - }); -}); diff --git a/frontend-modern/src/utils/__tests__/recoveryLocationPresentation.test.ts b/frontend-modern/src/utils/__tests__/recoveryLocationPresentation.test.ts deleted file mode 100644 index 6e489b7762..0000000000 --- a/frontend-modern/src/utils/__tests__/recoveryLocationPresentation.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - getRecoveryLocationFacetAllLabel, - getRecoveryLocationFacetLabel, - getRecoveryPointLocationEntries, -} from '@/utils/recoveryLocationPresentation'; - -describe('recoveryLocationPresentation', () => { - it('returns platform-neutral placement labels for recovery facets', () => { - expect(getRecoveryLocationFacetLabel('cluster')).toBe('Cluster / Site'); - expect(getRecoveryLocationFacetLabel('node')).toBe('Host / Agent'); - expect(getRecoveryLocationFacetLabel('namespace')).toBe('Namespace / Group'); - expect(getRecoveryLocationFacetAllLabel('cluster')).toBe('Any cluster or site'); - expect(getRecoveryLocationFacetAllLabel('node')).toBe('Any host or agent'); - expect(getRecoveryLocationFacetAllLabel('namespace')).toBe('Any namespace or group'); - }); - - it('builds recovery point placement entries from the canonical display contract', () => { - expect( - getRecoveryPointLocationEntries({ - id: 'p1', - platform: 'proxmox-pve', - kind: 'backup', - mode: 'local', - outcome: 'success', - cluster: 'cluster-a', - node: 'node-a', - namespace: 'tenant-a', - display: { - clusterLabel: 'Lab Cluster', - nodeHostLabel: 'pve-01', - namespaceLabel: 'Tenant A', - }, - }), - ).toEqual([ - { key: 'cluster', label: 'Cluster / Site', value: 'Lab Cluster' }, - { key: 'node', label: 'Host / Agent', value: 'pve-01' }, - { key: 'namespace', label: 'Namespace / Group', value: 'Tenant A' }, - ]); - }); -}); diff --git a/frontend-modern/src/utils/deployFlowPresentation.ts b/frontend-modern/src/utils/deployFlowPresentation.ts deleted file mode 100644 index 69cd634265..0000000000 --- a/frontend-modern/src/utils/deployFlowPresentation.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function getDeployCandidatesLoadingState(): string { - return 'Loading cluster nodes...'; -} - -export function getDeployNoSourceAgentsState(): string { - return 'No online source agents found. At least one node in this cluster must have a connected Pulse agent to deploy to other nodes.'; -} - -export function getDeployNoCandidatesState(): string { - return 'No nodes found in this cluster.'; -} - -export function getDeployInstallCommandLoadingState(): string { - return 'Loading install command...'; -} diff --git a/frontend-modern/src/utils/recoveryIssuePresentation.ts b/frontend-modern/src/utils/recoveryIssuePresentation.ts deleted file mode 100644 index a960a45543..0000000000 --- a/frontend-modern/src/utils/recoveryIssuePresentation.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type RecoveryIssueTone = 'none' | 'amber' | 'rose' | 'blue'; - -export function getRecoveryIssueRailClass(tone: Exclude): string { - switch (tone) { - case 'rose': - return 'bg-rose-500'; - case 'blue': - return 'bg-blue-500'; - default: - return 'bg-amber-400'; - } -} diff --git a/frontend-modern/src/utils/recoveryLocationPresentation.ts b/frontend-modern/src/utils/recoveryLocationPresentation.ts deleted file mode 100644 index 43f3470276..0000000000 --- a/frontend-modern/src/utils/recoveryLocationPresentation.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { RecoveryPoint } from '@/types/recovery'; - -export type RecoveryLocationFacetKind = 'cluster' | 'node' | 'namespace'; - -interface RecoveryLocationFacetPresentation { - allLabel: string; - label: string; -} - -const RECOVERY_LOCATION_FACET_PRESENTATION: Record< - RecoveryLocationFacetKind, - RecoveryLocationFacetPresentation -> = { - cluster: { - allLabel: 'Any cluster or site', - label: 'Cluster / Site', - }, - node: { - allLabel: 'Any host or agent', - label: 'Host / Agent', - }, - namespace: { - allLabel: 'Any namespace or group', - label: 'Namespace / Group', - }, -}; - -export function getRecoveryLocationFacetLabel(kind: RecoveryLocationFacetKind): string { - return RECOVERY_LOCATION_FACET_PRESENTATION[kind].label; -} - -export function getRecoveryLocationFacetAllLabel(kind: RecoveryLocationFacetKind): string { - return RECOVERY_LOCATION_FACET_PRESENTATION[kind].allLabel; -} - -export function getRecoveryPointLocationEntries( - point: RecoveryPoint, -): Array<{ key: RecoveryLocationFacetKind; label: string; value: string }> { - const cluster = String(point.display?.clusterLabel || point.cluster || '').trim(); - const node = String( - point.display?.nodeHostLabel || point.display?.nodeAgentLabel || point.node || '', - ).trim(); - const namespace = String(point.display?.namespaceLabel || point.namespace || '').trim(); - - const entries: Array<{ key: RecoveryLocationFacetKind; label: string; value: string }> = [ - { key: 'cluster', label: getRecoveryLocationFacetLabel('cluster'), value: cluster }, - { key: 'node', label: getRecoveryLocationFacetLabel('node'), value: node }, - { key: 'namespace', label: getRecoveryLocationFacetLabel('namespace'), value: namespace }, - ]; - return entries.filter((entry) => entry.value !== ''); -} diff --git a/frontend-modern/src/utils/snooze.ts b/frontend-modern/src/utils/snooze.ts deleted file mode 100644 index caeb2278ae..0000000000 --- a/frontend-modern/src/utils/snooze.ts +++ /dev/null @@ -1,23 +0,0 @@ -const SNOOZE_MS = 7 * 24 * 60 * 60 * 1000; - -export function isUpsellSnoozed(key: string): boolean { - if (typeof window === 'undefined') return false; - try { - const raw = window.localStorage.getItem(key); - if (!raw) return false; - const expiresAt = Number(raw); - if (!Number.isFinite(expiresAt)) return false; - return Date.now() < expiresAt; - } catch { - return false; - } -} - -export function snoozeUpsell(key: string): void { - if (typeof window === 'undefined') return; - try { - window.localStorage.setItem(key, String(Date.now() + SNOOZE_MS)); - } catch { - // Ignore storage quota / privacy-mode failures. - } -}