mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 22:17:07 +00:00
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:
@@ -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;
|
||||
-56
@@ -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>;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 !== '');
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user