Delete dead pages, stores, components, and presentation utils

Twelfth cleanup round. The Explore audit surfaced a further batch of
files with zero non-test consumers. Each was verified by static-import
grep + JSX-usage grep + lazy-import scan before deletion.

Pages (defined but never wired into App.tsx routing):
- pages/HostedSignup.tsx + test
- pages/CloudPricing.tsx + test

Stores (re-export wrapper + dead utility):
- stores/demoMode.ts (re-export shim around sessionPresentationPolicy)
- stores/metricsCollector.ts + test (retired with the live-metrics
  collector decision — App.architecture.test.ts still enforces that
  App.tsx must not re-import it)

Hooks / state:
- hooks/useDeployWizard.ts + test (consumed only by deleted deploy
  wizard steps in Cleanup K)
- components/shared/useInfrastructureDetailsDrawerState.ts and
  infrastructureDetailsDrawerModel.ts (only consumed by the deleted
  InfrastructureDetailsDrawer)
- components/shared/useCollapsibleSearchInputState.ts,
  useDensityMapState.ts, useScrollToTopButtonState.ts (model files
  for shared components deleted in Cleanup K)
- components/Storage/useZFSHealthMapModel.ts + test,
  useDiskLiveMetricModel.ts + test (models for Storage components
  deleted in Cleanup K)

Shared primitives:
- components/shared/PageControls.tsx + test + guardrails test —
  the legacy filter UI fully replaced by FilterBar across every
  surface; no consumer remains
- components/shared/responsive/ResponsiveHeader.tsx and
  useGridTemplate.ts (no consumer)

Other components:
- components/PMG/PMGInstancePanel.tsx and MailGateway.tsx (PMG
  surface superseded by ProxmoxMailGatewayDrawer in the inline-drawer
  unification)
- components/Commercial/MonitoredSystemDefinitionDisclosure.tsx + test
  (only used by the deleted MonitoredSystemLedgerPanel)
- components/Infrastructure/deploy/ErrorDetail.tsx + test and
  DeployStatusBadge.tsx + test (deploy wizard step helpers)

Utils:
- utils/deployFlowPresentation.ts + test (deploy wizard presentation)
- utils/recoveryLocationPresentation.ts + test,
  recoveryIssuePresentation.ts + test (Recovery surface utils)
- utils/snooze.ts (no consumer)

Updates useAppRuntimeState.test.ts to import demo-mode helpers
directly from sessionPresentationPolicy now that the demoMode wrapper
is gone.
This commit is contained in:
rcourtman
2026-05-26 20:06:47 +01:00
parent c26665a63a
commit c40455806f
39 changed files with 3 additions and 5829 deletions
@@ -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([
@@ -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 (
<div class={props.class ?? 'space-y-2'}>
<Show when={props.showSummary}>
<p class={props.summaryClass ?? 'text-xs text-muted'}>
{getMonitoredSystemBriefSummary()}
</p>
</Show>
<div class="space-y-2">
<button
type="button"
class={
props.buttonClass ??
'text-xs font-medium text-muted underline-offset-2 transition-colors hover:text-base-content hover:underline'
}
aria-expanded={open()}
onClick={() => setOpen((current) => !current)}
>
{getMonitoredSystemDisclosureToggleLabel(open())}
</button>
<Show when={open()}>
<p class={props.detailClass ?? 'max-w-2xl text-xs text-muted'}>
{getMonitoredSystemDisclosureDefinition()}
</p>
</Show>
</div>
</div>
);
};
export default MonitoredSystemDefinitionDisclosure;
@@ -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(() => <MonitoredSystemDefinitionDisclosure showSummary />);
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(() => <MonitoredSystemDefinitionDisclosure />);
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(() => <MonitoredSystemDefinitionDisclosure defaultOpen />);
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();
});
});
@@ -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(() => <DeployStatusBadge status={status} />);
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(() => <DeployStatusBadge status={status} />);
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(() => <DeployStatusBadge status={status} />);
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(() => <DeployStatusBadge status={status} />);
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(() => <DeployStatusBadge status={status} />);
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(() => <DeployStatusBadge status={status} />);
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(() => <DeployStatusBadge status={status} />);
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<string, string> = {
preflighting: 'Checking',
installing: 'Installing',
enrolling: 'Enrolling',
verifying: 'Verifying',
};
for (const status of Object.keys(labels) as DeployTargetStatus[]) {
const { unmount } = render(() => <DeployStatusBadge status={status} />);
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(() => <DeployStatusBadge status="pending" />);
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(() => <DeployStatusBadge status={'bogus' as DeployTargetStatus} />);
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<DeployTargetStatus>('pending');
render(() => <DeployStatusBadge status={status()} />);
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');
});
});
@@ -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<DeployStatusBadgeProps> = (props) => {
const config = () => getDeployStatusPresentation(props.status);
return (
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${config().className}`}
>
{config().label}
</span>
);
};
@@ -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<ErrorDetailProps> = (props) => {
const [expanded, setExpanded] = createSignal(false);
const isLong = () => (props.message?.length ?? 0) > INLINE_MAX;
const hint = () => (props.message ? friendlyErrorHint(props.message) : undefined);
return (
<Show when={props.message}>
<div class="text-xs text-red-600 dark:text-red-400">
<Show
when={isLong()}
fallback={
<>
<span>{props.message}</span>
<Show when={hint()}>
<p class="text-[11px] text-muted mt-0.5">{hint()}</p>
</Show>
</>
}
>
<Show
when={expanded()}
fallback={
<span>
{props.message!.slice(0, INLINE_MAX)}…{' '}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setExpanded(true);
}}
class="text-blue-600 dark:text-blue-400 hover:underline"
>
more
</button>
</span>
}
>
<span>{props.message}</span>{' '}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setExpanded(false);
}}
class="text-blue-600 dark:text-blue-400 hover:underline"
>
less
</button>
<Show when={hint()}>
<p class="text-[11px] text-muted mt-0.5">{hint()}</p>
</Show>
</Show>
</Show>
</div>
</Show>
);
};
@@ -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(() => <ErrorDetail message={undefined} />);
expect(container.innerHTML).toBe('');
});
it('renders nothing when message is empty string', () => {
// Show when={props.message} is falsy for empty string
const { container } = render(() => <ErrorDetail message="" />);
expect(container.innerHTML).toBe('');
});
it('renders a short message inline without truncation', () => {
render(() => <ErrorDetail message="Something went wrong" />);
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(() => <ErrorDetail message={longMessage} />);
// 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(() => <ErrorDetail message={longMessage} />);
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(() => <ErrorDetail message={longMessage} />);
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(() => (
<div onClick={parentHandler}>
<ErrorDetail message={longMessage} />
</div>
));
await fireEvent.click(screen.getByText('more'));
expect(parentHandler).not.toHaveBeenCalled();
});
it('stops event propagation on "less" click', async () => {
const parentHandler = vi.fn();
render(() => (
<div onClick={parentHandler}>
<ErrorDetail message={longMessage} />
</div>
));
// 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(() => <ErrorDetail message={exact60} />);
expect(screen.getByText(exact60)).toBeInTheDocument();
expect(screen.queryByText('more')).not.toBeInTheDocument();
});
it('truncates a message of 61 characters', () => {
const msg61 = 'B'.repeat(61);
render(() => <ErrorDetail message={msg61} />);
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(() => <ErrorDetail message="dial tcp: connection refused" />);
expect(screen.getByText(/SSH connection.*Verify SSH is running/)).toBeInTheDocument();
});
it('shows hint for "permission denied" errors', () => {
render(() => <ErrorDetail message="ssh: permission denied (publickey)" />);
expect(screen.getByText(/SSH authentication failed/)).toBeInTheDocument();
});
it('shows hint for "timed out" errors', () => {
render(() => <ErrorDetail message="dial tcp: i/o timed out" />);
expect(screen.getByText(/connection timed out.*network connectivity/)).toBeInTheDocument();
});
it('shows hint for "timeout" errors', () => {
render(() => <ErrorDetail message="context deadline exceeded (timeout)" />);
expect(screen.getByText(/connection timed out.*network connectivity/)).toBeInTheDocument();
});
it('shows hint for "no route to host" errors', () => {
render(() => <ErrorDetail message="connect: no route to host" />);
expect(screen.getByText(/No network route.*Verify the IP address/)).toBeInTheDocument();
});
it('shows hint for "host key verification" errors', () => {
render(() => <ErrorDetail message="Host key verification failed" />);
expect(screen.getByText(/host key verification failed.*known_hosts/)).toBeInTheDocument();
});
it('does not show a hint for unknown error messages', () => {
render(() => <ErrorDetail message="something unexpected" />);
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(() => <ErrorDetail message={longConnectionError} />);
// 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(() => <ErrorDetail message="CONNECTION REFUSED on port 22" />);
expect(screen.getByText(/SSH connection.*Verify SSH is running/)).toBeInTheDocument();
});
});
@@ -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 (
<div class="space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="text-muted">{props.label}</span>
<span class={`font-medium ${presentation().textClass}`}>
{formatCompact(props.count)} ({formatPct(props.percent)})
</span>
</div>
<div class="h-1.5 bg-surface-hover rounded-full overflow-hidden">
<div
class={`h-full ${presentation().barClass} transition-all duration-500 rounded-full`}
style={{ width: `${Math.min(props.percent * 10, 100)}%` }} // Scale up for visibility (10% threat = full bar)
/>
</div>
</div>
);
};
// 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 (
<>
<div
class="cursor-help"
onMouseEnter={(e) => {
if (props.queue) tip.onMouseEnter(e);
}}
onMouseLeave={tip.onMouseLeave}
>
<Show when={hasQueue()} fallback={<span class="text-xs text-slate-400">Empty</span>}>
<span
class={`text-xs font-medium ${getPMGQueueTextClass(queueTotal(), 'text-green-600 dark:text-green-400')}`}
>
{formatNum(queueTotal())} msgs
</span>
</Show>
</div>
<TooltipPortal when={tip.show() && !!props.queue} x={tip.pos().x} y={tip.pos().y}>
<div class="min-w-[140px]">
<div class="font-medium mb-1.5 text-slate-300 border-b border-border pb-1">
Queue Breakdown
</div>
<div class="space-y-0.5 text-[11px]">
<div class="flex justify-between">
<span class="text-slate-400">Active</span>
<span>{props.queue?.active || 0}</span>
</div>
<div class="flex justify-between">
<span class="text-slate-400">Deferred</span>
<span>{props.queue?.deferred || 0}</span>
</div>
<div class="flex justify-between">
<span class="text-slate-400">Hold</span>
<span>{props.queue?.hold || 0}</span>
</div>
<div class="flex justify-between">
<span class="text-slate-400">Incoming</span>
<span>{props.queue?.incoming || 0}</span>
</div>
<Show when={(props.queue?.oldestAge || 0) > 0}>
<div class="flex justify-between pt-1 mt-1 border-t border-border">
<span class="text-slate-400">Oldest</span>
<span class={getPMGOldestAgeTextClass(props.queue?.oldestAge)}>
{Math.floor((props.queue?.oldestAge || 0) / 60)}m
</span>
</div>
</Show>
</div>
</div>
</TooltipPortal>
</>
);
};
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<typeof instance> => 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 (
<div class="space-y-4">
{/* Loading State */}
<Show when={isLoading()}>
<Card padding="lg">
<EmptyState
icon={
<svg
class="h-12 w-12 animate-spin text-blue-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
}
title={PMG_LOADING_STATE_TITLE}
description={PMG_LOADING_STATE_DESCRIPTION}
/>
</Card>
</Show>
{/* Disconnected State */}
<Show when={!connected() && !isLoading()}>
<Card padding="lg" tone="danger">
<EmptyState
icon={
<svg
class="h-12 w-12 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
}
title={disconnectedState().title}
description={disconnectedState().description}
tone="danger"
actions={
disconnectedState().actionLabel ? (
<button
onClick={() => reconnect()}
class="mt-2 inline-flex items-center px-4 py-2 text-xs font-medium rounded bg-red-600 text-white hover:bg-red-700 transition-colors"
>
{disconnectedState().actionLabel}
</button>
) : undefined
}
/>
</Card>
</Show>
<Show when={connected() && initialDataReceived()}>
{/* No Instances Empty State */}
<Show when={!hasInstances()}>
<Card padding="lg">
<EmptyState
icon={
<svg
class="h-12 w-12 text-slate-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
}
title={PMG_EMPTY_STATE_TITLE}
description={PMG_EMPTY_STATE_DESCRIPTION}
/>
</Card>
</Show>
{/* Has Instances - Show Content */}
<Show when={hasInstances()}>
{/* Summary Cards */}
<div class="grid gap-3 grid-cols-2 lg:grid-cols-5">
{/* Total Mail */}
<Card padding="sm" tone="card">
<div class="flex items-center justify-between">
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide">
Mail (24h)
</div>
<div class="text-2xl font-bold text-base-content mt-1">
{formatCompact(aggregateStats().totalMail)}
</div>
<div class="text-xs text-muted">
~{formatNum(Math.round(aggregateStats().totalMail / 24))}/hr
</div>
</div>
<div class="p-2 bg-blue-100 dark:bg-blue-900 rounded-md">
<svg
class="w-5 h-5 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
</div>
</div>
</Card>
{/* Spam Rate */}
<Card padding="sm" tone={aggregateStats().spamRate > 50 ? 'warning' : 'card'}>
<div class="flex items-center justify-between">
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide">Spam</div>
<div
class={`text-2xl font-bold mt-1 ${aggregateStats().spamRate > 50 ? 'text-orange-600 dark:text-orange-400' : 'text-base-content'}`}
>
{formatPct(aggregateStats().spamRate)}
</div>
<div class="text-xs text-muted">
{formatCompact(aggregateStats().totalSpam)} caught
</div>
</div>
<div
class={`p-2 rounded-md ${aggregateStats().spamRate > 50 ? 'bg-orange-100 dark:bg-orange-900' : 'bg-orange-100 dark:bg-orange-900'}`}
>
<svg
class="w-5 h-5 text-orange-600 dark:text-orange-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
</div>
</div>
</Card>
{/* Virus Blocked */}
<Card padding="sm" tone={aggregateStats().totalVirus > 0 ? 'danger' : 'card'}>
<div class="flex items-center justify-between">
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide">Viruses</div>
<div
class={`text-2xl font-bold mt-1 ${aggregateStats().totalVirus > 0 ? 'text-red-600 dark:text-red-400' : 'text-base-content'}`}
>
{formatCompact(aggregateStats().totalVirus)}
</div>
<div class="text-xs text-muted">blocked today</div>
</div>
<div
class={`p-2 rounded-md ${aggregateStats().totalVirus > 0 ? 'bg-red-100 dark:bg-red-900' : 'bg-surface-alt'}`}
>
<svg
class={`w-5 h-5 ${aggregateStats().totalVirus > 0 ? 'text-red-600 dark:text-red-400' : 'text-slate-400'}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z"
/>
</svg>
</div>
</div>
</Card>
{/* Quarantine */}
<Card padding="sm" tone="card">
<div class="flex items-center justify-between">
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide">
Quarantine
</div>
<div class="text-2xl font-bold text-base-content mt-1">
{formatCompact(aggregateStats().totalQuarantine)}
</div>
<div class="text-xs text-muted">items held</div>
</div>
<div class="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-md">
<svg
class="w-5 h-5 text-yellow-600 dark:text-yellow-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25 2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
</div>
</div>
</Card>
{/* Queue Status */}
<Card padding="sm" tone={aggregateStats().totalQueue > 50 ? 'warning' : 'card'}>
<div class="flex items-center justify-between">
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide">Queue</div>
<div
class={`text-2xl font-bold mt-1 ${
aggregateStats().totalQueue > 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)}
</div>
<div class="text-xs text-muted">
{aggregateStats().totalQueue === 0 ? 'all clear' : 'pending'}
</div>
</div>
<div
class={`p-2 rounded-md ${
aggregateStats().totalQueue > 50
? 'bg-yellow-100 dark:bg-yellow-900'
: 'bg-green-100 dark:bg-green-900'
}`}
>
<svg
class={`w-5 h-5 ${
aggregateStats().totalQueue > 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"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z"
/>
</svg>
</div>
</div>
</Card>
</div>
{/* Search Bar (only show if multiple instances) */}
<Show when={instances().length > 1}>
<Card padding="sm" tone="card">
<SearchInput
value={searchTerm}
onChange={setSearchTerm}
placeholder={PMG_SEARCH_PLACEHOLDER}
class="max-w-md"
clearOnEscape
/>
</Card>
</Show>
{/* Instance Cards */}
<Show
when={filteredInstances().length > 0}
fallback={
<Card padding="lg">
<div class="text-center py-4">
<svg
class="w-10 h-10 text-slate-300 mx-auto mb-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<div class="text-muted text-sm">{searchEmptyState().description}</div>
<button
onClick={() => setSearchTerm('')}
class="mt-2 text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
{searchEmptyState().actionLabel}
</button>
</div>
</Card>
}
>
<div class="space-y-4">
<For each={filteredInstances()}>
{(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 (
<Card padding="none" tone="card" class="overflow-hidden">
{/* Instance Header */}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 px-4 py-3 bg-surface-alt border-b border-border">
<div class="flex items-center gap-3">
<a
href={pmg.host || `https://${pmg.name}:8006`}
target="_blank"
rel="noopener noreferrer"
class="text-base font-semibold text-base-content hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
{pmg.name}
</a>
<ServiceHealthBadge
status={pmg.status || ''}
health={pmg.connectionHealth}
/>
<Show when={pmg.version}>
<span class="text-xs text-muted bg-surface-hover px-1.5 py-0.5 rounded">
v{pmg.version}
</span>
</Show>
</div>
<div class="flex items-center gap-4 text-xs text-muted">
<QueueIndicator queue={instanceQueue()} />
<span>Updated {lastUpdated()}</span>
</div>
</div>
{/* Main Content */}
<div class="p-4">
{/* Threat Rate Bars */}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
<ThreatBar
percent={stats().spamPct}
color="spam"
label="Spam Rate"
count={stats().spam}
/>
<ThreatBar
percent={stats().virusPct}
color="virus"
label="Virus Rate"
count={stats().virus}
/>
<ThreatBar
percent={stats().quarantinePct}
color="quarantine"
label="Quarantine Rate"
count={stats().qTotal}
/>
</div>
{/* Stats Grid */}
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-3">
{/* Mail Flow */}
<div class="bg-surface-alt rounded-md p-3">
<div class="text-xs text-muted mb-1">Total (24h)</div>
<div class="text-lg font-bold text-base-content">
{formatCompact(stats().total)}
</div>
<div class="text-[10px] ">
{formatNum(Math.round(stats().total / 24))}/hr
</div>
</div>
<div class="bg-surface-alt rounded-md p-3">
<div class="text-xs text-muted mb-1">Inbound</div>
<div class="text-lg font-bold text-base-content">
{formatCompact(stats().inbound)}
</div>
<div class="text-[10px] ">{formatBytes(stats().bytesIn)}</div>
</div>
<div class="bg-surface-alt rounded-md p-3">
<div class="text-xs text-muted mb-1">Outbound</div>
<div class="text-lg font-bold text-base-content">
{formatCompact(stats().outbound)}
</div>
<div class="text-[10px] ">{formatBytes(stats().bytesOut)}</div>
</div>
{/* Threats */}
<div class="bg-orange-50 dark:bg-orange-900 rounded-md p-3">
<div class="text-xs text-orange-600 dark:text-orange-400 mb-1">
Spam
</div>
<div class="text-lg font-bold text-orange-600 dark:text-orange-400">
{formatCompact(stats().spam)}
</div>
<div class="text-[10px] text-orange-500">
{formatPct(stats().spamPct)} rate
</div>
</div>
<div class="bg-red-50 dark:bg-red-900 rounded-md p-3">
<div class="text-xs text-red-600 dark:text-red-400 mb-1">Viruses</div>
<div class="text-lg font-bold text-red-600 dark:text-red-400">
{formatCompact(stats().virus)}
</div>
<div class="text-[10px] text-red-500">
{formatPct(stats().virusPct)} rate
</div>
</div>
<div class="bg-yellow-50 dark:bg-yellow-900 rounded-md p-3">
<div class="text-xs text-yellow-600 dark:text-yellow-400 mb-1">
Quarantine
</div>
<div class="text-lg font-bold text-yellow-600 dark:text-yellow-400">
{formatCompact(stats().qTotal)}
</div>
<div class="text-[10px] text-yellow-500">
{formatPct(stats().quarantinePct)} of inbound
</div>
</div>
</div>
{/* Delivery Health Row */}
<div class="mt-3 pt-3 border-t border-border">
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-2">
Delivery Health
</div>
<div class="flex flex-wrap gap-x-6 gap-y-1 text-xs">
<div class="flex items-center gap-2">
<span class="text-muted">RBL Rejects:</span>
<span class="font-medium text-base-content">
{formatNum(stats().rbl)}
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted">Pregreet:</span>
<span class="font-medium text-base-content">
{formatNum(stats().pregreet)}
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted">Greylisted:</span>
<span class="font-medium text-base-content">
{formatNum(stats().greylist)}
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted">Bounces In/Out:</span>
<span class="font-medium text-base-content">
{formatNum(stats().bouncesIn)}/{formatNum(stats().bouncesOut)}
</span>
</div>
<Show when={pmg.mailStats?.averageProcessTimeMs}>
<div class="flex items-center gap-2">
<span class="text-muted">Avg Process:</span>
<span class="font-medium text-base-content">
{formatDec((pmg.mailStats?.averageProcessTimeMs || 0) / 1000, 2)}s
</span>
</div>
</Show>
</div>
</div>
{/* Cluster Nodes */}
<Show when={(pmg.nodes?.length ?? 0) > 0}>
<div class="mt-3 pt-3 border-t border-border">
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-2">
Cluster Nodes ({pmg.nodes?.length})
</div>
<Table class="w-full min-w-[600px] text-xs">
<TableHeader>
<TableRow class="text-left text-[10px] uppercase tracking-wide text-muted border-b border-border">
<TableHead class="pb-1.5 font-medium">Node</TableHead>
<TableHead class="pb-1.5 font-medium">Status</TableHead>
<TableHead class="pb-1.5 font-medium">Uptime</TableHead>
<TableHead class="pb-1.5 font-medium">Load</TableHead>
<TableHead class="pb-1.5 font-medium">Queue</TableHead>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border-subtle">
<For each={pmg.nodes}>
{(node) => {
const isOnline = (node.status || '').toLowerCase() === 'online';
return (
<TableRow class="hover:bg-surface-hover">
<TableCell class="py-1.5 font-medium text-base-content">
{node.name}
</TableCell>
<TableCell class="py-1.5">
<span
class={`inline-flex items-center gap-1 ${isOnline ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
>
<span
class={`w-1.5 h-1.5 rounded-full ${isOnline ? 'bg-green-500' : 'bg-red-500'}`}
/>
{node.status || 'unknown'}
</span>
</TableCell>
<TableCell class="py-1.5 text-muted">
<Show when={node.uptime} fallback="—">
{Math.floor((node.uptime ?? 0) / 86400)}d{' '}
{Math.floor(((node.uptime ?? 0) % 86400) / 3600)}h
</Show>
</TableCell>
<TableCell class="py-1.5 text-muted">
{node.loadAvg || '—'}
</TableCell>
<TableCell class="py-1.5">
<QueueIndicator queue={node.queueStatus} />
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</div>
</Show>
{/* Spam Distribution */}
<Show when={(pmg.spamDistribution?.length ?? 0) > 0}>
<div class="mt-3 pt-3 border-t border-border">
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-2">
Spam Score Distribution
</div>
<div class="flex gap-1 overflow-x-auto pb-1">
<For each={pmg.spamDistribution}>
{(bucket) => {
const totalScored =
pmg.spamDistribution?.reduce((sum, b) => sum + b.count, 0) || 1;
const pct = (bucket.count / totalScored) * 100;
return (
<div class="flex-shrink-0 text-center bg-surface-alt rounded px-2 py-1.5 min-w-[50px]">
<div class="text-[10px] text-muted">{bucket.score}</div>
<div class="text-xs font-semibold text-base-content">
{formatCompact(bucket.count)}
</div>
<div class="text-[10px] text-slate-400">
{formatDec(pct)}%
</div>
</div>
);
}}
</For>
</div>
</div>
</Show>
</div>
</Card>
);
}}
</For>
</div>
</Show>
</Show>
</Show>
</div>
);
};
export default MailGateway;
@@ -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 (
<div class="space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="text-muted">{props.label}</span>
<span class={`font-medium ${presentation().textClass}`}>
{formatCompact(props.count)} ({formatPct(props.percent)})
</span>
</div>
<div class="h-1.5 bg-surface-hover rounded-full overflow-hidden">
<div
class={`h-full ${presentation().barClass} transition-all duration-500 rounded-full`}
style={{ width: `${Math.min(props.percent * 10, 100)}%` }} // Scale up for visibility (10% threat = full bar)
/>
</div>
</div>
);
};
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 (
<>
<div
class="cursor-help"
onMouseEnter={(e) => {
if (props.queue) tip.onMouseEnter(e);
}}
onMouseLeave={() => tip.onMouseLeave()}
>
<Show when={hasQueue()} fallback={<span class="text-xs text-slate-400">No queue</span>}>
<span class={`text-xs font-medium ${getPMGQueueTextClass(queueTotal())}`}>
{formatNum(queueTotal())} msgs
</span>
</Show>
</div>
<TooltipPortal when={tip.show() && !!props.queue} x={tip.pos().x} y={tip.pos().y}>
<div class="bg-surface text-base-content text-xs rounded-md px-3 py-2 shadow-sm border border-border">
<div class="space-y-1">
<div class="flex justify-between">
<span class="text-muted">Active</span>
<span>{props.queue?.active || 0}</span>
</div>
<div class="flex justify-between">
<span class="text-muted">Deferred</span>
<span>{props.queue?.deferred || 0}</span>
</div>
<div class="flex justify-between">
<span class="text-muted">Hold</span>
<span>{props.queue?.hold || 0}</span>
</div>
<div class="flex justify-between">
<span class="text-muted">Incoming</span>
<span>{props.queue?.incoming || 0}</span>
</div>
<Show when={(props.queue?.oldestAge || 0) > 0}>
<div class="flex justify-between border-t border-border pt-1 mt-1">
<span class="text-muted">Oldest</span>
<span class={getPMGOldestAgeTextClass(props.queue?.oldestAge)}>
{Math.floor((props.queue?.oldestAge || 0) / 60)}m
</span>
</div>
</Show>
</div>
</div>
</TooltipPortal>
</>
);
};
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<string, { mail: number; spam: number; virus: number; bytes: number }>();
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<string>();
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 (
<Card padding="none" tone="card" class="overflow-hidden">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 px-4 py-3 bg-surface-alt border-b border-border">
<div class="flex items-center gap-3">
<a
href={props.pmg.host || `https://${props.pmg.name}:8006`}
target="_blank"
rel="noopener noreferrer"
class="text-base font-semibold text-base-content hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
{props.pmg.name}
</a>
<ServiceHealthBadge status={props.pmg.status || ''} health={props.pmg.connectionHealth} />
<Show when={props.pmg.version}>
<span class="text-xs text-muted bg-surface-hover px-1.5 py-0.5 rounded">
v{props.pmg.version}
</span>
</Show>
</div>
<div class="flex items-center gap-4 text-xs text-muted">
<QueueIndicator queue={instanceQueue()} />
<span>Updated {lastUpdated()}</span>
</div>
</div>
<div class="p-4">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
<ThreatBar
percent={stats().spamPct}
color="spam"
label="Spam Rate"
count={stats().spam}
/>
<ThreatBar
percent={stats().virusPct}
color="virus"
label="Virus Rate"
count={stats().virus}
/>
<ThreatBar
percent={stats().quarantinePct}
color="quarantine"
label="Quarantine Rate"
count={stats().qTotal}
/>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-3">
<div class="bg-surface-alt rounded-md p-3">
<div class="text-xs text-muted mb-1">Total (24h)</div>
<div class="text-lg font-bold text-base-content">{formatCompact(stats().total)}</div>
<div class="text-[10px] ">{formatNum(Math.round(stats().total / 24))}/hr</div>
</div>
<div class="bg-surface-alt rounded-md p-3">
<div class="text-xs text-muted mb-1">Inbound</div>
<div class="text-lg font-bold text-base-content">{formatCompact(stats().inbound)}</div>
<div class="text-[10px] ">{formatBytes(stats().bytesIn)}</div>
</div>
<div class="bg-surface-alt rounded-md p-3">
<div class="text-xs text-muted mb-1">Outbound</div>
<div class="text-lg font-bold text-base-content">{formatCompact(stats().outbound)}</div>
<div class="text-[10px] ">{formatBytes(stats().bytesOut)}</div>
</div>
<div class="bg-orange-50 dark:bg-orange-900 rounded-md p-3">
<div class="text-xs text-orange-600 dark:text-orange-400 mb-1">Spam</div>
<div class="text-lg font-bold text-orange-600 dark:text-orange-400">
{formatCompact(stats().spam)}
</div>
<div class="text-[10px] text-orange-500">{formatPct(stats().spamPct)} rate</div>
</div>
<div class="bg-red-50 dark:bg-red-900 rounded-md p-3">
<div class="text-xs text-red-600 dark:text-red-400 mb-1">Viruses</div>
<div class="text-lg font-bold text-red-600 dark:text-red-400">
{formatCompact(stats().virus)}
</div>
<div class="text-[10px] text-red-500">{formatPct(stats().virusPct)} rate</div>
</div>
<div class="bg-yellow-50 dark:bg-yellow-900 rounded-md p-3">
<div class="text-xs text-yellow-600 dark:text-yellow-400 mb-1">Quarantine</div>
<div class="text-lg font-bold text-yellow-600 dark:text-yellow-400">
{formatCompact(stats().qTotal)}
</div>
<div class="text-[10px] text-yellow-500">
{formatPct(stats().quarantinePct)} of inbound
</div>
</div>
</div>
<div class="mt-3 pt-3 border-t border-border">
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-2">
Delivery Health
</div>
<div class="flex flex-wrap gap-x-6 gap-y-1 text-xs">
<div class="flex items-center gap-2">
<span class="text-muted">RBL Rejects:</span>
<span class="font-medium text-base-content">{formatNum(stats().rbl)}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted">Pregreet:</span>
<span class="font-medium text-base-content">{formatNum(stats().pregreet)}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted">Greylisted:</span>
<span class="font-medium text-base-content">{formatNum(stats().greylist)}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted">Bounces In/Out:</span>
<span class="font-medium text-base-content">
{formatNum(stats().bouncesIn)}/{formatNum(stats().bouncesOut)}
</span>
</div>
<Show when={props.pmg.mailStats?.averageProcessTimeMs}>
<div class="flex items-center gap-2">
<span class="text-muted">Avg Process:</span>
<span class="font-medium text-base-content">
{formatDec((props.pmg.mailStats?.averageProcessTimeMs || 0) / 1000, 2)}s
</span>
</div>
</Show>
</div>
</div>
<Show when={(props.pmg.nodes?.length ?? 0) > 0}>
<div class="mt-3 pt-3 border-t border-border">
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-2">
Cluster Nodes ({props.pmg.nodes?.length})
</div>
<Table class="w-full min-w-[600px] text-xs">
<TableHeader>
<TableRow class="text-left text-[10px] uppercase tracking-wide text-muted border-b border-border">
<TableHead class="pb-1.5 font-medium">Node</TableHead>
<TableHead class="pb-1.5 font-medium">Status</TableHead>
<TableHead class="pb-1.5 font-medium">Uptime</TableHead>
<TableHead class="pb-1.5 font-medium">Load</TableHead>
<TableHead class="pb-1.5 font-medium">Queue</TableHead>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border-subtle">
<For each={props.pmg.nodes}>
{(node) => {
const isOnline = (node.status || '').toLowerCase() === 'online';
return (
<TableRow class="hover:bg-surface-hover">
<TableCell class="py-1.5 font-medium text-base-content">
{node.name}
</TableCell>
<TableCell class="py-1.5">
<span
class={`inline-flex items-center gap-1 ${isOnline ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
>
<span
class={`w-1.5 h-1.5 rounded-full ${isOnline ? 'bg-green-500' : 'bg-red-500'}`}
/>
{node.status || 'unknown'}
</span>
</TableCell>
<TableCell class="py-1.5 text-muted">
<Show when={node.uptime} fallback="—">
{Math.floor((node.uptime ?? 0) / 86400)}d{' '}
{Math.floor(((node.uptime ?? 0) % 86400) / 3600)}h
</Show>
</TableCell>
<TableCell class="py-1.5 text-muted">{node.loadAvg || '—'}</TableCell>
<TableCell class="py-1.5">
<QueueIndicator queue={node.queueStatus} />
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</div>
</Show>
<Show when={(props.pmg.spamDistribution?.length ?? 0) > 0}>
<div class="mt-3 pt-3 border-t border-border">
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-2">
Spam Score Distribution
</div>
<div class="flex gap-1 overflow-x-auto pb-1">
<For each={props.pmg.spamDistribution}>
{(bucket) => {
const totalScored =
props.pmg.spamDistribution?.reduce((sum, b) => sum + b.count, 0) || 1;
const pct = (bucket.count / totalScored) * 100;
return (
<div class="flex-shrink-0 text-center bg-surface-alt rounded px-2 py-1.5 min-w-[50px]">
<div class="text-[10px] text-muted">{bucket.score}</div>
<div class="text-xs font-semibold text-base-content">
{formatCompact(bucket.count)}
</div>
<div class="text-[10px] ">{formatDec(pct)}%</div>
</div>
);
}}
</For>
</div>
</div>
</Show>
<Show when={(props.pmg.relayDomains?.length ?? 0) > 0}>
<div class="mt-3 pt-3 border-t border-border">
<div class="flex items-center justify-between gap-3 mb-2">
<div class="text-xs font-medium text-muted uppercase tracking-wide">
Relay Domains (24h)
</div>
<div class="flex items-center gap-3 text-xs text-muted">
<Show when={domainStatsAsOfRelative()}>
<span>As of {domainStatsAsOfRelative()}</span>
</Show>
<span>{formatNum(props.pmg.relayDomains?.length ?? 0)}</span>
</div>
</div>
<Table
wrapperClass="max-h-[240px] overflow-y-auto rounded-md border border-border"
class="w-full min-w-[780px] text-xs"
>
<TableHeader class="bg-surface-alt">
<TableRow class="text-left text-[10px] uppercase tracking-wide text-muted border-b border-border">
<TableHead class="px-3 py-2 font-medium">Domain</TableHead>
<TableHead class="px-3 py-2 font-medium">Mail</TableHead>
<TableHead class="px-3 py-2 font-medium">Spam</TableHead>
<TableHead class="px-3 py-2 font-medium">Virus</TableHead>
<TableHead class="px-3 py-2 font-medium">Spam Rate</TableHead>
<TableHead class="px-3 py-2 font-medium">Bytes</TableHead>
<TableHead class="px-3 py-2 font-medium">Comment</TableHead>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border-subtle">
<For each={relayDomainsWithStats()}>
{(row) => (
<TableRow class="hover:bg-surface-hover">
<TableCell class="px-3 py-2 font-medium text-base-content">
{row.domain}
</TableCell>
<TableCell class="px-3 py-2 text-base-content">
{row.hasStats ? fmtMaybe(row.mail) : '—'}
</TableCell>
<TableCell class="px-3 py-2 text-orange-700 dark:text-orange-300">
{row.hasStats ? fmtMaybe(row.spam) : '—'}
</TableCell>
<TableCell class="px-3 py-2 text-red-700 dark:text-red-300">
{row.hasStats ? fmtMaybe(row.virus) : '—'}
</TableCell>
<TableCell class="px-3 py-2 text-base-content">
{row.hasStats ? formatPct(row.spamRate) : '—'}
</TableCell>
<TableCell class="px-3 py-2 text-muted">
{row.hasStats && row.bytes > 0
? formatBytes(row.bytes)
: row.hasStats
? '—'
: '—'}
</TableCell>
<TableCell class="px-3 py-2 text-muted">{row.comment || '—'}</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</div>
</Show>
<Show when={(otherDomainStatsRows().length ?? 0) > 0}>
<div class="mt-3 pt-3 border-t border-border">
<details class="group">
<summary class="cursor-pointer select-none list-none">
<div class="flex items-center justify-between gap-3 mb-2">
<div class="text-xs font-medium text-muted uppercase tracking-wide">
Other Domains (24h)
</div>
<div class="flex items-center gap-3 text-xs text-muted">
<Show when={domainStatsAsOfRelative()}>
<span>As of {domainStatsAsOfRelative()}</span>
</Show>
<span>{formatNum(otherDomainStatsRows().length)}</span>
</div>
</div>
<div class="text-[11px] text-muted -mt-1 mb-2">
Domains seen in the last 24 hours that are not configured as relay domains.
</div>
</summary>
<Table
wrapperClass="max-h-[260px] overflow-y-auto rounded-md border border-border"
class="w-full min-w-[720px] text-xs"
>
<TableHeader class="bg-surface-alt">
<TableRow class="text-left text-[10px] uppercase tracking-wide text-muted border-b border-border">
<TableHead class="px-3 py-2 font-medium">Domain</TableHead>
<TableHead class="px-3 py-2 font-medium">Mail</TableHead>
<TableHead class="px-3 py-2 font-medium">Spam</TableHead>
<TableHead class="px-3 py-2 font-medium">Virus</TableHead>
<TableHead class="px-3 py-2 font-medium">Spam Rate</TableHead>
<TableHead class="px-3 py-2 font-medium">Bytes</TableHead>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border-subtle">
<For each={otherDomainStatsRows()}>
{(row) => (
<TableRow class="hover:bg-surface-hover">
<TableCell class="px-3 py-2 font-medium text-base-content">
{row.domain}
</TableCell>
<TableCell class="px-3 py-2 text-base-content">
{fmtMaybe(row.mail)}
</TableCell>
<TableCell class="px-3 py-2 text-orange-700 dark:text-orange-300">
{fmtMaybe(row.spam)}
</TableCell>
<TableCell class="px-3 py-2 text-red-700 dark:text-red-300">
{fmtMaybe(row.virus)}
</TableCell>
<TableCell class="px-3 py-2 text-base-content">
{formatPct(row.spamRate)}
</TableCell>
<TableCell class="px-3 py-2 text-muted">
{row.bytes > 0 ? formatBytes(row.bytes) : '—'}
</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</details>
</div>
</Show>
<Show
when={
(props.pmg.domainStats?.length ?? 0) > 0 && (props.pmg.relayDomains?.length ?? 0) === 0
}
>
<div class="mt-3 pt-3 border-t border-border">
<div class="flex items-center justify-between gap-3 mb-2">
<div class="text-xs font-medium text-muted uppercase tracking-wide">
Domains (24h)
</div>
<div class="flex items-center gap-3 text-xs text-muted">
<Show when={domainStatsAsOfRelative()}>
<span>As of {domainStatsAsOfRelative()}</span>
</Show>
<span>{formatNum(domainStatsRows().length)}</span>
</div>
</div>
<Table
wrapperClass="max-h-[260px] overflow-y-auto rounded-md border border-border"
class="w-full min-w-[720px] text-xs"
>
<TableHeader class="bg-surface-alt">
<TableRow class="text-left text-[10px] uppercase tracking-wide text-muted border-b border-border">
<TableHead class="px-3 py-2 font-medium">Domain</TableHead>
<TableHead class="px-3 py-2 font-medium">Mail</TableHead>
<TableHead class="px-3 py-2 font-medium">Spam</TableHead>
<TableHead class="px-3 py-2 font-medium">Virus</TableHead>
<TableHead class="px-3 py-2 font-medium">Spam Rate</TableHead>
<TableHead class="px-3 py-2 font-medium">Bytes</TableHead>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border-subtle">
<For each={domainStatsRows()}>
{(row) => (
<TableRow class="hover:bg-surface-hover">
<TableCell class="px-3 py-2 font-medium text-base-content">
{row.domain}
</TableCell>
<TableCell class="px-3 py-2 text-base-content">
{fmtMaybe(row.mail)}
</TableCell>
<TableCell class="px-3 py-2 text-orange-700 dark:text-orange-300">
{fmtMaybe(row.spam)}
</TableCell>
<TableCell class="px-3 py-2 text-red-700 dark:text-red-300">
{fmtMaybe(row.virus)}
</TableCell>
<TableCell class="px-3 py-2 text-base-content">
{formatPct(row.spamRate)}
</TableCell>
<TableCell class="px-3 py-2 text-muted">
{row.bytes > 0 ? formatBytes(row.bytes) : '—'}
</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</div>
</Show>
</div>
</Card>
);
};
export default PMGInstancePanel;
@@ -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');
});
});
@@ -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();
});
});
@@ -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<string>;
type: Accessor<DiskLiveMetricType>;
};
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,
};
};
@@ -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<ZFSDevice | null>(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,
};
};
@@ -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<string, string>;
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('<FilterHeader');
expect(pageControlsSource).toContain('{...divProps}');
expect(pageControlsSource).toContain('searchTrailing?: JSX.Element;');
expect(pageControlsSource).toContain(
'const mobileControlsEnabled = () => 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
// <ColumnPicker> tag now appears in WorkloadsFilter via the FilterBar
// viewOptionsTrailing slot, so the regression guard is the more specific
// FilterHeader check.
expect(workloadsFilterSource).not.toContain('<FilterHeader');
// StorageFilter retired (legacy 3-layer indirection deleted); Storage's
// page-level shell is StoragePageControls + FilterBar now.
// RecoveryProtectedInventorySection migrated to FilterBar; defensive
// assertions remain to catch any regression that reintroduces FilterHeader
// / ColumnPicker forks here.
// RecoveryHistorySection migrated to FilterBar; defensive guards remain.
});
it('limits raw FilterHeader and ColumnPicker usage to the known allowlist', () => {
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 = /<FilterHeader(?:\s|>)/;
const columnPickerTagPattern = /<ColumnPicker(?:\s|>)/;
const mobileToggleTagPattern = /<FilterMobileToggleButton(?:\s|>)/;
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'");
});
});
@@ -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(() => (
<PageControls
search={<div data-testid="search">Search</div>}
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' }}
>
<div>Filters</div>
</PageControls>
));
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<HTMLElement>('.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(() => (
<PageControls
search={<div data-testid="search">Search</div>}
showFilters={true}
mobileFilters={{ enabled: false, count: 0, onToggle: vi.fn() }}
mobileTrailing={<div data-testid="scope-slot">mobile scope</div>}
utilityActions={<div data-testid="scope-slot">desktop scope</div>}
>
<div>Filters</div>
</PageControls>
));
expect(screen.getAllByTestId('scope-slot')).toHaveLength(1);
expect(screen.getByTestId('scope-slot')).toHaveTextContent('desktop scope');
unmount();
render(() => (
<PageControls
search={<div data-testid="search">Search</div>}
showFilters={true}
mobileFilters={{ enabled: true, count: 0, onToggle: vi.fn() }}
mobileTrailing={<div data-testid="scope-slot">mobile scope</div>}
utilityActions={<div data-testid="scope-slot">desktop scope</div>}
>
<div>Filters</div>
</PageControls>
));
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(() => (
<PageControls
search={<div data-testid="search">Search</div>}
showFilters={true}
mobileFilters={{ enabled: true, count: 1, onToggle: vi.fn() }}
toolbarTrailing={<div data-testid="toolbar-trailing">Grouped List</div>}
columnVisibility={{
availableToggles: () => [{ id: 'subject', label: 'Subject' }],
isHiddenByUser: () => false,
toggle: vi.fn(),
resetToDefaults: vi.fn(),
}}
utilityActions={<div data-testid="desktop-utility">Desktop utility</div>}
>
<div>Filters</div>
</PageControls>
));
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<HTMLElement>('.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(() => (
<PageControls
search={<div data-testid="search">Search</div>}
showFilters={true}
actionsLayout="inline"
toolbarTrailing={<div data-testid="toolbar-trailing">Grouped List</div>}
columnVisibility={{
availableToggles: () => [{ id: 'subject', label: 'Subject' }],
isHiddenByUser: () => false,
toggle: vi.fn(),
resetToDefaults: vi.fn(),
}}
>
<div>Filters</div>
</PageControls>
));
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(() => (
<PageControls
search={<div data-testid="search">Search</div>}
showFilters={true}
actionsLayout="stacked"
controlDeckClass="page-controls-control-deck"
filterControlsClass="page-controls-filter-controls compact-filters"
toolbarActionsClass="page-controls-toolbar-actions compact-actions"
toolbarTrailing={<div data-testid="toolbar-trailing">Grouped List</div>}
columnVisibility={{
availableToggles: () => [{ id: 'subject', label: 'Subject' }],
isHiddenByUser: () => false,
toggle: vi.fn(),
resetToDefaults: vi.fn(),
}}
>
<div>Filters</div>
</PageControls>
));
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');
});
});
@@ -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<HTMLDivElement> {
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<PageControlsProps> = (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 = () => (
<Show when={local.mobileFilters?.enabled}>
<FilterMobileToggleButton
onClick={() => local.mobileFilters?.onToggle()}
count={local.mobileFilters?.count ?? 0}
/>
</Show>
);
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 = () => (
<>
<div class={filterControlsClass()}>{local.children}</div>
<Show when={hasTrailingActions()}>
<div class={toolbarActionsClass()}>
<Show when={local.toolbarTrailing}>{local.toolbarTrailing}</Show>
<Show when={activeUtilityActions()}>
<Show when={showDividerBeforeUtilityActions()}>
<FilterDivider />
</Show>
{activeUtilityActions()}
</Show>
<Show when={showColumnVisibility()}>
<Show when={showDividerBeforeColumnVisibility()}>
<FilterDivider />
</Show>
<ColumnPicker
columns={local.columnVisibility!.availableToggles()}
isHidden={local.columnVisibility!.isHiddenByUser}
onToggle={local.columnVisibility!.toggle}
onReset={local.columnVisibility!.resetToDefaults}
/>
</Show>
<Show when={showResetAction()}>
<Show when={showDividerBeforeResetAction()}>
<FilterDivider />
</Show>
<FilterActionButton
onClick={() => local.resetAction?.onClick()}
title={local.resetAction?.title}
class={local.resetAction?.class}
>
{local.resetAction?.icon}
{local.resetAction?.label ?? 'Reset'}
</FilterActionButton>
</Show>
</div>
</Show>
</>
);
return (
<FilterHeader
{...divProps}
searchLeading={local.searchLeading}
search={local.search}
searchAccessory={activeSearchTrailing() ?? mobileSearchAccessory()}
showFilters={local.showFilters}
contentClass={local.contentClass}
searchRowClass={local.searchRowClass}
toolbarClass={local.toolbarClass}
mobileRowClass={local.mobileRowClass}
mobileLeading={local.mobileLeading}
mobileTrailing={activeMobileTrailing()}
class={local.class}
>
<Show when={resolvedControlDeckClass()} fallback={toolbarControls()}>
{(controlDeckClass) => <div class={controlDeckClass()}>{toolbarControls()}</div>}
</Show>
</FilterHeader>
);
};
@@ -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);
@@ -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
* <ResponsiveHeader
* column={STANDARD_COLUMNS.cpu}
* sortState={sortState()}
* onSort={handleSort}
* showMobile={isMobile()}
* />
* ```
*/
export const ResponsiveHeader: Component<ResponsiveHeaderProps> = (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 (
<div
class={baseClass()}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={props.column.sortable ? 0 : undefined}
role={props.column.sortable ? 'button' : undefined}
aria-label={
props.column.sortable
? `Sort by ${props.column.label} ${isSorted() ? (props.sortState?.direction === 'asc' ? 'ascending' : 'descending') : ''}`
: undefined
}
>
{/* Mobile: Show icon if available */}
<Show when={props.showMobile && Icon}>
<span class="md:hidden" title={props.column.label}>
{Icon && <Icon class="w-4 h-4" />}
</span>
</Show>
{/* Desktop: Show label (or always if no icon) */}
<span class={props.showMobile && Icon ? 'hidden md:inline' : ''}>{props.column.label}</span>
{/* Sort indicator */}
<Show when={sortIndicator()}>
<span class="ml-0.5">{sortIndicator()}</span>
</Show>
</div>
);
};
/**
* Sticky header variant for name columns
*/
export const StickyHeader: Component<ResponsiveHeaderProps & { width?: string }> = (props) => {
return (
<div
class={`sticky left-0 z-30 bg-surface-alt border-r md:border-r-0 border-border ${props.width || 'w-[160px] sm:w-[200px] md:w-full'}`}
>
<ResponsiveHeader {...props} />
</div>
);
};
@@ -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<ColumnConfig[]>;
/** 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<string>;
/** Columns that are currently visible */
visibleColumns: Accessor<ColumnConfig[]>;
/** Check if a specific column is visible */
isColumnVisible: (columnId: string) => boolean;
/** Current breakpoint */
breakpoint: Accessor<Breakpoint>;
/** Whether currently in mobile view */
isMobile: Accessor<boolean>;
}
/**
* 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 (
* <div
* class="grid"
* style={{ 'grid-template-columns': gridTemplate() }}
* >
* <For each={visibleColumns()}>
* {col => <div>{col.label}</div>}
* </For>
* </div>
* );
* ```
*/
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<Breakpoint, string> {
const breakpoints: Breakpoint[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl'];
return Object.fromEntries(
breakpoints.map((bp) => [bp, generateStaticGridTemplate(columns, bp)]),
) as Record<Breakpoint, string>;
}
/**
* 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}`;
}
@@ -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,
};
}
@@ -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<DensityMapHoveredState | null>(null);
const [canvasRef, setCanvasRef] = createSignal<HTMLCanvasElement>();
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<number | null>(() => {
const hoverSync = props.hoverSync;
if (!hoverSync) {
return null;
}
if (props.hoverSourceKey && hoverSync.sourceKey === props.hoverSourceKey) {
return null;
}
return hoverSync.timestamp;
});
const activeHoveredState = createMemo<DensityMapHoveredState | null>(() => {
return hoveredState() ?? synchronizedHoveredState();
});
const activeHoverTimestamp = createMemo<number | null>(() => {
return hoveredState()?.timestamp ?? synchronizedHoverTimestamp();
});
const activeColumnIndex = createMemo<number | null>(() =>
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<typeof useDensityMapState>;
@@ -1,12 +0,0 @@
import { createSignal } from 'solid-js';
export type InfrastructureDetailsDrawerTab = 'overview' | 'discovery';
export function useInfrastructureDetailsDrawerState() {
const [activeTab, setActiveTab] = createSignal<InfrastructureDetailsDrawerTab>('overview');
return {
activeTab,
setActiveTab,
};
}
@@ -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,
};
}
@@ -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();
});
});
});
@@ -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<WizardStep>('candidates');
// -- Candidates --
const [candidates, setCandidates] = createSignal<CandidateNode[]>([]);
const [sourceAgents, setSourceAgents] = createSignal<SourceAgentInfo[]>([]);
const [candidatesLoading, setCandidatesLoading] = createSignal(false);
const [candidatesError, setCandidatesError] = createSignal('');
const [selectedSourceAgent, setSelectedSourceAgent] = createSignal('');
const [selectedNodeIds, setSelectedNodeIds] = createSignal<Set<string>>(new Set());
// -- Preflight --
const [preflightId, setPreflightId] = createSignal('');
const [preflightTargets, setPreflightTargets] = createSignal<DeployTarget[]>([]);
const [preflightStatus, setPreflightStatus] = createSignal<DeployJobStatus | ''>('');
const [preflightEventsUrl, setPreflightEventsUrl] = createSignal<string | null>(null);
const [preflightError, setPreflightError] = createSignal('');
// -- Confirm step selection (nodes user wants to deploy) --
const [confirmSelectedNodeIds, setConfirmSelectedNodeIds] = createSignal<Set<string>>(
new Set<string>(),
);
// -- Deploy --
const [jobId, setJobId] = createSignal('');
const [jobTargets, setJobTargets] = createSignal<DeployTarget[]>([]);
const [jobStatus, setJobStatus] = createSignal<DeployJobStatus | ''>('');
const [deployEventsUrl, setDeployEventsUrl] = createSignal<string | null>(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<typeof setTimeout> | undefined;
let pollTimerDeploy: ReturnType<typeof setTimeout> | 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<string>());
}
// 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<typeof useDeployWizard>;
-147
View File
@@ -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 (
<Card
padding="lg"
class={
t.highlighted
? 'relative ring-2 ring-blue-600 dark:ring-blue-500 border-blue-200 dark:border-blue-900'
: 'relative'
}
>
<Show when={price.campaignBadge}>
<div class="absolute right-4 top-4">
<span class="inline-flex items-center rounded-full bg-amber-500 px-2.5 py-1 text-[11px] font-semibold text-white shadow-sm">
{price.campaignBadge}
</span>
</div>
</Show>
<h2 class="text-lg font-semibold text-base-content">{t.name}</h2>
<Show when={price.compareAtMonthlyPrice}>
<div class="mt-2 text-2xl font-semibold tracking-tight text-amber-600 dark:text-amber-400">
{price.monthlyPrice}
<span class="text-base font-normal text-muted">{price.cadence}</span>
</div>
<div class="text-sm text-muted line-through">
{price.compareAtMonthlyPrice}
{price.cadence}
</div>
</Show>
<Show when={!price.compareAtMonthlyPrice}>
<div class="mt-2 text-3xl font-semibold tracking-tight text-base-content">
{price.monthlyPrice}
<span class="text-base font-normal text-muted">{price.cadence}</span>
</div>
</Show>
<div class="mt-1 text-sm text-muted">{price.annualSummary}</div>
<dl class="mt-4 space-y-3 text-sm text-base-content">
<div class="flex items-center justify-between gap-3">
<dt class="text-muted">Monitored systems</dt>
<dd class="font-semibold">{t.monitoredSystems}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-muted">Support</dt>
<dd class="font-semibold">{t.support}</dd>
</div>
</dl>
<div class="mt-6">
<A
href={`/cloud/signup?tier=${t.tier}`}
class="w-full inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
>
{`Choose ${t.name}`}
</A>
</div>
</Card>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export default function CloudPricing() {
return (
<div class="space-y-8">
<PageHeader
title={CLOUD_COMMERCIAL_PRESENTATION.pageTitle}
description={CLOUD_COMMERCIAL_PRESENTATION.pageDescription}
/>
{/* Tier cards */}
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<For each={CLOUD_PLAN_DEFINITIONS}>{(tier) => <CloudTierCard tier={tier} />}</For>
</div>
{/* What's included in all Cloud plans */}
<Card padding="lg">
<h2 class="text-base font-semibold text-base-content">
{CLOUD_COMMERCIAL_PRESENTATION.includedInAllHeading}
</h2>
<ul class="mt-3 grid grid-cols-1 gap-x-8 gap-y-2 sm:grid-cols-2 text-sm text-base-content">
<For each={CLOUD_COMMERCIAL_PRESENTATION.includedInAllItems}>
{(item) => (
<li class="flex gap-2">
<span class="text-emerald-600 dark:text-emerald-400 shrink-0 font-bold">✓</span>
<span>{item}</span>
</li>
)}
</For>
</ul>
</Card>
{/* How it works */}
<Card padding="lg">
<h2 class="text-base font-semibold text-base-content">
{CLOUD_COMMERCIAL_PRESENTATION.setupHeading}
</h2>
<ol class="mt-3 list-decimal space-y-2 pl-5 text-sm text-base-content">
<For each={CLOUD_COMMERCIAL_PRESENTATION.setupSteps}>{(step) => <li>{step}</li>}</For>
</ol>
</Card>
{/* Footer links */}
<div class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-muted">
<span>
Already have a Cloud account?{' '}
<A href="/portal" class="text-blue-600 hover:underline dark:text-blue-400">
Open Pulse Account
</A>
</span>
<span>
Prefer self-hosting?{' '}
<a href={getPublicPricingUrl()} class="text-blue-600 hover:underline dark:text-blue-400">
See self-hosted plans
</a>
</span>
<span>
Managing multiple clients?{' '}
<a
href="mailto:hello@pulserelay.pro?subject=Pulse%20MSP%20Inquiry"
class="text-blue-600 hover:underline dark:text-blue-400"
>
Ask about MSP plans
</a>
</span>
</div>
</div>
);
}
-298
View File
@@ -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<SignupStatus>('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 (
<div class="space-y-6">
<PageHeader
title={`${HOSTED_SIGNUP_PRESENTATION.pageTitlePrefix} ${selectedPlan().name}`}
description={HOSTED_SIGNUP_PRESENTATION.pageDescription}
/>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card padding="lg" class="space-y-4">
<h2 class="text-lg font-semibold text-base-content">
{HOSTED_SIGNUP_PRESENTATION.workspaceHeading}
</h2>
<form class="space-y-3" onSubmit={submitSignup}>
<label class="block text-sm font-medium text-base-content" for="hosted-email">
Work Email
</label>
<input
id="hosted-email"
type="email"
class="w-full rounded-md border border-border bg-base px-3 py-2 text-sm text-base-content outline-none focus:border-blue-600"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
placeholder="you@company.com"
autocomplete="email"
required
/>
<label class="block text-sm font-medium text-base-content" for="hosted-org-name">
Organization Name
</label>
<input
id="hosted-org-name"
type="text"
class="w-full rounded-md border border-border bg-base px-3 py-2 text-sm text-base-content outline-none focus:border-blue-600"
value={orgName()}
onInput={(e) => setOrgName(e.currentTarget.value)}
placeholder="Acme Infrastructure"
autocomplete="organization"
required
/>
<button
type="submit"
class="w-full inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
disabled={!canSubmit()}
>
<Show
when={status() === 'submitting'}
fallback={HOSTED_SIGNUP_PRESENTATION.createWorkspaceLabel}
>
{HOSTED_SIGNUP_PRESENTATION.creatingWorkspaceLabel}
</Show>
</button>
</form>
<Show when={message()}>
<div
classList={{
'rounded-md px-3 py-2 text-sm': true,
'bg-emerald-100 text-emerald-900 dark:bg-emerald-900/30 dark:text-emerald-200':
status() === 'success',
'bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200':
status() === 'unavailable',
'bg-rose-100 text-rose-900 dark:bg-rose-900/30 dark:text-rose-200':
status() === 'error',
}}
>
{message()}
</div>
</Show>
<Show when={status() === 'unavailable'}>
<UpgradeLink
destination={cloudPortalDestination()}
class="inline-flex items-center text-sm font-medium text-blue-700 hover:underline dark:text-blue-300"
>
Open hosted signup portal
</UpgradeLink>
</Show>
</Card>
<Card padding="lg" class="space-y-4">
<h2 class="text-lg font-semibold text-base-content">
{HOSTED_SIGNUP_PRESENTATION.planHeading}
</h2>
<div class="rounded-md border border-border bg-surface-alt p-4">
<div class="flex items-baseline justify-between gap-3">
<div>
<p class="text-sm font-semibold text-base-content">{selectedPlan().name}</p>
<p class="text-xs text-muted">{selectedPlanPrice().annualSummary}</p>
</div>
<div class="text-right">
<p class="text-sm font-semibold text-base-content">
{selectedPlanPrice().monthlyPrice}
{selectedPlanPrice().cadence}
</p>
<Show when={selectedPlanPrice().compareAtMonthlyPrice}>
<p class="text-xs text-muted line-through">
{selectedPlanPrice().compareAtMonthlyPrice}
{selectedPlanPrice().cadence}
</p>
</Show>
</div>
</div>
<dl class="mt-3 grid grid-cols-2 gap-3 text-sm text-base-content">
<div>
<dt class="text-xs uppercase tracking-wide text-muted">Monitored Systems</dt>
<dd class="mt-1 font-medium">{selectedPlan().monitoredSystems}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-muted">Support</dt>
<dd class="mt-1 font-medium">{selectedPlan().support}</dd>
</div>
</dl>
</div>
<h3 class="text-sm font-semibold text-base-content">
{HOSTED_SIGNUP_PRESENTATION.nextHeading}
</h3>
<ol class="list-decimal space-y-2 pl-5 text-sm text-base-content">
<For each={HOSTED_SIGNUP_PRESENTATION.nextSteps}>{(step) => <li>{step}</li>}</For>
</ol>
<div class="border-t border-border pt-4">
<h3 class="text-sm font-semibold text-base-content">
{HOSTED_SIGNUP_PRESENTATION.existingAccountHeading}
</h3>
<p class="mt-1 text-sm text-muted">
{HOSTED_SIGNUP_PRESENTATION.existingAccountDescription}
</p>
<form class="mt-3 space-y-3" onSubmit={requestMagicLink}>
<input
id="hosted-magic-link-email"
type="email"
class="w-full rounded-md border border-border bg-base px-3 py-2 text-sm text-base-content outline-none focus:border-blue-600"
value={magicLinkEmail()}
onInput={(e) => setMagicLinkEmail(e.currentTarget.value)}
placeholder="you@company.com"
autocomplete="email"
required
/>
<button
type="submit"
class="w-full inline-flex items-center justify-center rounded-md border border-border bg-surface px-4 py-2 text-sm font-semibold text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60"
disabled={requestingMagicLink()}
>
<Show
when={requestingMagicLink()}
fallback={HOSTED_SIGNUP_PRESENTATION.emailSignInLinkLabel}
>
{HOSTED_SIGNUP_PRESENTATION.sendingSignInLinkLabel}
</Show>
</button>
</form>
<Show when={magicLinkMessage()}>
<div class="mt-2 text-xs text-muted">{magicLinkMessage()}</div>
</Show>
</div>
</Card>
</div>
</div>
);
}
@@ -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(() => (
<Router>
<Route path="/" component={CloudPricing} />
</Router>
));
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();
});
});
@@ -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(() => (
<Router>
<Route path="/cloud/signup" component={HostedSignup} />
</Router>
));
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(() => (
<Router>
<Route path="/cloud/signup" component={HostedSignup} />
</Router>
));
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();
});
});
@@ -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();
});
});
-14
View File
@@ -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<SecurityStatus, 'sessionCapabilities' | 'presentationPolicy'> | null,
): boolean {
return syncSessionPresentationPolicy(status).demoMode;
}
export { demoModeEnabled, demoModeResolved };
@@ -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<string, DiskCounterState>();
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
value && typeof value === 'object' ? (value as Record<string, unknown>) : 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<string>();
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();
});
@@ -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...');
});
});
@@ -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');
});
});
@@ -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' },
]);
});
});
@@ -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...';
}
@@ -1,12 +0,0 @@
export type RecoveryIssueTone = 'none' | 'amber' | 'rose' | 'blue';
export function getRecoveryIssueRailClass(tone: Exclude<RecoveryIssueTone, 'none'>): string {
switch (tone) {
case 'rose':
return 'bg-rose-500';
case 'blue':
return 'bg-blue-500';
default:
return 'bg-amber-400';
}
}
@@ -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 !== '');
}
-23
View File
@@ -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.
}
}