Normalize alerts and patrol page headers

This commit is contained in:
rcourtman 2026-04-17 18:47:02 +01:00
parent 50a7d73293
commit df06fe84b2
9 changed files with 447 additions and 236 deletions

View file

@ -12,6 +12,7 @@ import { CountdownTimer } from '@/components/patrol';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { formatRelativeTime } from '@/utils/format';
import { groupModelsByProvider } from '@/utils/patrolFormat';
import { getPatrolPageHeaderMeta } from '@/utils/patrolPagePresentation';
import {
getAIQuickstartCreditsPresentation,
isPatrolQuickstartExhaustedReason,
@ -23,6 +24,7 @@ import type { PatrolIntelligenceState } from './usePatrolIntelligenceState';
export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState }) {
const state = props.state;
const headerMeta = getPatrolPageHeaderMeta();
const quickstartPresentation = createMemo(() =>
getAIQuickstartCreditsPresentation(
state.patrolStatus()?.quickstart_credits_remaining ?? 0,
@ -49,7 +51,9 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
const patrolStatus = state.patrolStatus();
if (!patrolStatus) return false;
if (patrolStatus.using_quickstart) return true;
return state.runtimeState() === 'blocked' && isPatrolQuickstartExhaustedReason(state.blockedReason());
return (
state.runtimeState() === 'blocked' && isPatrolQuickstartExhaustedReason(state.blockedReason())
);
});
const patrolModelStale = createMemo(() => {
const model = state.patrolModel();
@ -62,13 +66,14 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
<div class="flex-shrink-0 bg-surface border-b border-border px-4 py-3">
<PageHeader
id="patrol-title"
description={headerMeta.description}
title={
<span
class="inline-flex items-center gap-3"
title="Pulse Patrol continuously verifies your infrastructure, surfaces actionable findings, and can automatically fix issues based on your autonomy settings."
>
<PulsePatrolLogo class="w-6 h-6 text-base-content" />
<span>Patrol</span>
<span>{headerMeta.title}</span>
</span>
}
class="mb-3"

View file

@ -11,7 +11,7 @@ import {
import { useLocation, useNavigate } from '@solidjs/router';
import { logger } from '@/utils/logger';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { PageHeader } from '@/components/shared/PageHeader';
import { notificationStore } from '@/stores/notifications';
import Calendar from 'lucide-solid/icons/calendar';
@ -34,12 +34,8 @@ import {
getAlertsTabTitle,
isAlertsConfigurationTab,
} from '@/utils/alertTabsPresentation';
import {
getAlertsPageHeaderMeta,
} from '@/utils/alertOverviewPresentation';
import {
getAlertConfigLeaveConfirmation,
} from '@/utils/alertConfigPresentation';
import { getAlertsPageHeaderMeta } from '@/utils/alertOverviewPresentation';
import { getAlertConfigLeaveConfirmation } from '@/utils/alertConfigPresentation';
import { useAlertsActivation } from '@/stores/alertsActivation';
import LayoutDashboard from 'lucide-solid/icons/layout-dashboard';
import History from 'lucide-solid/icons/history';
@ -49,12 +45,7 @@ import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy
import { AlertsConfigurationSurface } from '@/features/alerts/AlertsConfigurationSurface';
import { OverviewTab } from '@/features/alerts/OverviewTab';
import { HistoryTab } from '@/features/alerts/tabs/HistoryTab';
import {
pathForTab,
tabFromPath,
type AlertTab,
type Override,
} from '@/features/alerts/types';
import { pathForTab, tabFromPath, type AlertTab, type Override } from '@/features/alerts/types';
export function Alerts() {
const { activeAlerts, updateAlert, removeAlerts } = useWebSocket();
@ -66,9 +57,7 @@ export function Alerts() {
const readOnlySession = createMemo(() => presentationPolicyIsReadOnly());
const isAlertsActive = createMemo(() => alertsActivation.activationState() === 'active');
const areAlertsDisabled = createMemo(() => !isAlertsActive());
const alertsConfigurationLocked = createMemo(
() => !readOnlySession() && areAlertsDisabled(),
);
const alertsConfigurationLocked = createMemo(() => !readOnlySession() && areAlertsDisabled());
const alertActivationPresentation = createMemo(() =>
getAlertActivationPresentation({
isActive: isAlertsActive(),
@ -127,8 +116,7 @@ export function Alerts() {
const [overviewOverrides, setOverviewOverrides] = createSignal<Override[]>([]);
const alertsPageHeaderMeta = getAlertsPageHeaderMeta();
const headerMeta = () =>
alertsPageHeaderMeta[activeTab()] ?? alertsPageHeaderMeta.default;
const headerMeta = () => alertsPageHeaderMeta[activeTab()] ?? alertsPageHeaderMeta.default;
createEffect(() => {
const currentPath = location.pathname;
@ -175,8 +163,12 @@ export function Alerts() {
localStorage.getItem('hideAlertsQuickTip') !== 'true',
);
const runtimeCapabilitiesLoading = createMemo(() => !runtimeCapabilitiesLoaded() || entitlementsLoading());
const hasAIAlertsFeature = createMemo(() => !runtimeCapabilitiesLoaded() || hasFeature('ai_alerts'));
const runtimeCapabilitiesLoading = createMemo(
() => !runtimeCapabilitiesLoaded() || entitlementsLoading(),
);
const hasAIAlertsFeature = createMemo(
() => !runtimeCapabilitiesLoaded() || hasFeature('ai_alerts'),
);
createEffect((wasPaywallVisible) => {
const isPaywallVisible =
@ -253,11 +245,11 @@ export function Alerts() {
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false);
return (
<div class="space-y-4">
{/* Header with better styling */}
<Card padding="md">
<div class="flex items-center justify-between gap-4">
<SectionHeader title={headerMeta().title} size="lg" />
<div class="space-y-6">
<PageHeader
title={headerMeta().title}
description={headerMeta().description}
actions={
<Show when={activeTab() === 'overview' && !readOnlySession()}>
<div class="flex items-center gap-3">
<span class={`text-sm font-medium ${alertActivationPresentation().labelClass}`}>
@ -284,180 +276,172 @@ export function Alerts() {
</label>
</div>
</Show>
</div>
</Card>
}
/>
<div>
<Card padding="none" class="relative lg:flex overflow-hidden">
<Card padding="none" class="relative lg:flex overflow-hidden">
<div
class={`hidden lg:flex lg:flex-col ${sidebarCollapsed() ? 'w-16' : 'w-72'} ${sidebarCollapsed() ? 'lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative border-b border-border lg:border-b-0 lg:border-r lg:align-top flex-shrink-0 transition-all duration-200`}
aria-label="Alerts navigation"
aria-expanded={!sidebarCollapsed()}
>
<div
class={`hidden lg:flex lg:flex-col ${sidebarCollapsed() ? 'w-16' : 'w-72'} ${sidebarCollapsed() ? 'lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative border-b border-border lg:border-b-0 lg:border-r lg:align-top flex-shrink-0 transition-all duration-200`}
aria-label="Alerts navigation"
aria-expanded={!sidebarCollapsed()}
class={`sticky top-0 ${sidebarCollapsed() ? 'px-2' : 'px-4'} py-5 space-y-5 transition-all duration-200`}
>
<div
class={`sticky top-0 ${sidebarCollapsed() ? 'px-2' : 'px-4'} py-5 space-y-5 transition-all duration-200`}
>
<Show when={!sidebarCollapsed()}>
<div class="flex items-center justify-between pb-2 border-b border-border">
<h2 class="text-sm font-semibold text-base-content">Alerts</h2>
<button
type="button"
onClick={() => setSidebarCollapsed(true)}
class="p-1 rounded-md hover:bg-surface-hover transition-colors"
aria-label="Collapse sidebar"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
/>
</svg>
</button>
</div>
</Show>
<Show when={sidebarCollapsed()}>
<Show when={!sidebarCollapsed()}>
<div class="flex items-center justify-between pb-2 border-b border-border">
<h2 class="text-sm font-semibold text-base-content">Alerts</h2>
<button
type="button"
onClick={() => setSidebarCollapsed(false)}
class="w-full p-2 rounded-md hover:bg-surface-hover transition-colors"
aria-label="Expand sidebar"
onClick={() => setSidebarCollapsed(true)}
class="p-1 rounded-md hover:bg-surface-hover transition-colors"
aria-label="Collapse sidebar"
>
<svg
class="w-5 h-5 mx-auto"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 5l7 7-7 7M5 5l7 7-7 7"
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
/>
</svg>
</button>
</Show>
<div id="alerts-sidebar-menu" class="space-y-5">
<For each={tabGroups()}>
{(group) => (
<div class="space-y-2">
<Show when={!sidebarCollapsed()}>
<p class="text-xs font-semibold uppercase tracking-wide text-muted">
{group.label}
</p>
</Show>
<div class="space-y-1.5">
<For each={group.items}>
{(item) => (
<button
type="button"
aria-current={activeTab() === item.id ? 'page' : undefined}
aria-disabled={alertsConfigurationLocked()}
disabled={alertsConfigurationLocked()}
class={getAlertsSidebarTabClass({
isActive: activeTab() === item.id,
isDisabled: alertsConfigurationLocked(),
collapsed: sidebarCollapsed(),
})}
onClick={() => handleTabChange(item.id)}
title={getAlertsTabTitle({
isDisabled: alertsConfigurationLocked(),
collapsed: sidebarCollapsed(),
label: item.label,
})}
>
{item.icon}
<Show when={!sidebarCollapsed()}>
<span class="truncate">{item.label}</span>
</Show>
</button>
)}
</For>
</div>
</div>
)}
</For>
</div>
</div>
</div>
<div class="flex-1 overflow-hidden">
<Show when={flatTabs().length > 0}>
<div class="lg:hidden border-b border-border">
<div class="p-1">
<div class="flex w-full overflow-x-auto rounded-md bg-surface-hover p-0.5 touch-scroll scrollbar-hide">
<For each={flatTabs()}>
{(tab) => (
<button
type="button"
aria-disabled={alertsConfigurationLocked()}
disabled={alertsConfigurationLocked()}
class={getAlertsMobileTabClass({
isActive: activeTab() === tab.id,
isDisabled: alertsConfigurationLocked(),
})}
onClick={() => handleTabChange(tab.id)}
title={getAlertsTabTitle({
isDisabled: alertsConfigurationLocked(),
label: tab.label,
})}
>
<span class="w-full text-center truncate block">{tab.label}</span>
</button>
)}
</For>
</div>
</div>
</div>
</Show>
{/* Tab Content */}
<div class="p-2 sm:p-6">
<Show when={activeTab() === 'overview'}>
<OverviewTab
overrides={overviewOverrides()}
activeAlerts={activeAlerts}
updateAlert={updateAlert}
showQuickTip={showQuickTip}
dismissQuickTip={dismissQuickTip}
showAcknowledged={showAcknowledged}
setShowAcknowledged={setShowAcknowledged}
alertsDisabled={alertsConfigurationLocked}
hasAIAlertsFeature={hasAIAlertsFeature}
runtimeCapabilitiesLoading={runtimeCapabilitiesLoading}
/>
</Show>
<Show when={!readOnlySession()}>
<AlertsConfigurationSurface
activeTab={activeTab}
allResources={allResources}
byType={byType}
children={children}
activeAlerts={activeAlerts}
removeAlerts={removeAlerts}
setOverviewOverrides={setOverviewOverrides}
hasUnsavedChanges={hasUnsavedChanges}
setHasUnsavedChanges={setHasUnsavedChanges}
alertsActivationState={alertsActivation.activationState}
alertsActivationConfig={alertsActivation.config}
/>
</Show>
<Show when={activeTab() === 'history'}>
<HistoryTab
hasAIAlertsFeature={hasAIAlertsFeature}
runtimeCapabilitiesLoading={runtimeCapabilitiesLoading}
getResource={getResource}
allResources={allResources}
/>
</Show>
<Show when={sidebarCollapsed()}>
<button
type="button"
onClick={() => setSidebarCollapsed(false)}
class="w-full p-2 rounded-md hover:bg-surface-hover transition-colors"
aria-label="Expand sidebar"
>
<svg class="w-5 h-5 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 5l7 7-7 7M5 5l7 7-7 7"
/>
</svg>
</button>
</Show>
<div id="alerts-sidebar-menu" class="space-y-5">
<For each={tabGroups()}>
{(group) => (
<div class="space-y-2">
<Show when={!sidebarCollapsed()}>
<p class="text-xs font-semibold uppercase tracking-wide text-muted">
{group.label}
</p>
</Show>
<div class="space-y-1.5">
<For each={group.items}>
{(item) => (
<button
type="button"
aria-current={activeTab() === item.id ? 'page' : undefined}
aria-disabled={alertsConfigurationLocked()}
disabled={alertsConfigurationLocked()}
class={getAlertsSidebarTabClass({
isActive: activeTab() === item.id,
isDisabled: alertsConfigurationLocked(),
collapsed: sidebarCollapsed(),
})}
onClick={() => handleTabChange(item.id)}
title={getAlertsTabTitle({
isDisabled: alertsConfigurationLocked(),
collapsed: sidebarCollapsed(),
label: item.label,
})}
>
{item.icon}
<Show when={!sidebarCollapsed()}>
<span class="truncate">{item.label}</span>
</Show>
</button>
)}
</For>
</div>
</div>
)}
</For>
</div>
</div>
</Card>
</div>
</div>
<div class="flex-1 overflow-hidden">
<Show when={flatTabs().length > 0}>
<div class="lg:hidden border-b border-border">
<div class="p-1">
<div class="flex w-full overflow-x-auto rounded-md bg-surface-hover p-0.5 touch-scroll scrollbar-hide">
<For each={flatTabs()}>
{(tab) => (
<button
type="button"
aria-disabled={alertsConfigurationLocked()}
disabled={alertsConfigurationLocked()}
class={getAlertsMobileTabClass({
isActive: activeTab() === tab.id,
isDisabled: alertsConfigurationLocked(),
})}
onClick={() => handleTabChange(tab.id)}
title={getAlertsTabTitle({
isDisabled: alertsConfigurationLocked(),
label: tab.label,
})}
>
<span class="w-full text-center truncate block">{tab.label}</span>
</button>
)}
</For>
</div>
</div>
</div>
</Show>
<div class="p-2 sm:p-6">
<Show when={activeTab() === 'overview'}>
<OverviewTab
overrides={overviewOverrides()}
activeAlerts={activeAlerts}
updateAlert={updateAlert}
showQuickTip={showQuickTip}
dismissQuickTip={dismissQuickTip}
showAcknowledged={showAcknowledged}
setShowAcknowledged={setShowAcknowledged}
alertsDisabled={alertsConfigurationLocked}
hasAIAlertsFeature={hasAIAlertsFeature}
runtimeCapabilitiesLoading={runtimeCapabilitiesLoading}
/>
</Show>
<Show when={!readOnlySession()}>
<AlertsConfigurationSurface
activeTab={activeTab}
allResources={allResources}
byType={byType}
children={children}
activeAlerts={activeAlerts}
removeAlerts={removeAlerts}
setOverviewOverrides={setOverviewOverrides}
hasUnsavedChanges={hasUnsavedChanges}
setHasUnsavedChanges={setHasUnsavedChanges}
alertsActivationState={alertsActivation.activationState}
alertsActivationConfig={alertsActivation.config}
/>
</Show>
<Show when={activeTab() === 'history'}>
<HistoryTab
hasAIAlertsFeature={hasAIAlertsFeature}
runtimeCapabilitiesLoading={runtimeCapabilitiesLoading}
getResource={getResource}
allResources={allResources}
/>
</Show>
</div>
</div>
</Card>
</div>
);
}

View file

@ -256,9 +256,10 @@ vi.mock('@/hooks/usePatrolStream', () => ({
}));
vi.mock('@/components/shared/PageHeader', () => ({
PageHeader: (props: { title?: string; actions?: unknown }) => (
PageHeader: (props: { title?: string; description?: string; actions?: unknown }) => (
<div>
<h1>{props.title}</h1>
<p>{props.description}</p>
<div>{props.actions as any}</div>
</div>
),
@ -387,8 +388,8 @@ describe('AIIntelligence entitlement gating', () => {
href: getPublicPricingUrl(feature),
external: true,
}));
getUpgradeActionUrlOrFallbackMock.mockImplementation(
(feature?: string) => getPublicPricingUrl(feature),
getUpgradeActionUrlOrFallbackMock.mockImplementation((feature?: string) =>
getPublicPricingUrl(feature),
);
getCorrelationsMock.mockResolvedValue({
correlations: [],
@ -659,7 +660,9 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.getByText(/Health A · 91\/100/)).toBeInTheDocument();
expect(screen.queryByText('Supporting context')).not.toBeInTheDocument();
expect(screen.queryByText('1 recent change · 4 policy-covered resources')).not.toBeInTheDocument();
expect(
screen.queryByText('1 recent change · 4 policy-covered resources'),
).not.toBeInTheDocument();
expect(screen.queryByText('Policy posture')).not.toBeInTheDocument();
});

View file

@ -217,6 +217,11 @@ describe('tab path helpers', () => {
});
it('keeps alerts configuration owned by a feature surface instead of the page shell', () => {
expect(alertsPageSource).toContain(
"import { PageHeader } from '@/components/shared/PageHeader';",
);
expect(alertsPageSource).toContain('<PageHeader');
expect(alertsPageSource).toContain('description={headerMeta().description}');
expect(alertsPageSource).toContain(
"import { AlertsConfigurationSurface } from '@/features/alerts/AlertsConfigurationSurface';",
);
@ -312,7 +317,9 @@ describe('tab path helpers', () => {
expect(alertDestinationsModelSource).toContain('export function buildAppriseConfigPayload');
expect(alertDestinationsModelSource).toContain('formatAppriseTargets');
expect(alertDestinationsModelSource).toContain('parseAppriseTargets');
expect(alertDestinationsTabStateSource).toContain('export function useAlertDestinationsTabState');
expect(alertDestinationsTabStateSource).toContain(
'export function useAlertDestinationsTabState',
);
expect(alertDestinationsTabStateSource).toContain('NotificationsAPI.testNotification');
expect(alertDestinationsTabStateSource).toContain('useAlertWebhookDestinationsState');
expect(alertDestinationsTabStateSource).not.toContain('NotificationsAPI.getWebhooks');
@ -359,21 +366,21 @@ describe('tab path helpers', () => {
expect(alertHistoryTabSource).not.toContain('const loadIncidentTimeline = async');
expect(alertHistoryTabSource).not.toContain('const saveIncidentNote = async');
expect(alertHistoryTabSource).not.toContain('usePersistentSignal(');
expect(alertHistoryTabSource).not.toContain("const [searchTerm, setSearchTerm] = createSignal");
expect(alertHistoryTabSource).not.toContain('const [searchTerm, setSearchTerm] = createSignal');
expect(alertHistoryFrequencyCardSource).toContain('export function AlertHistoryFrequencyCard');
expect(alertHistoryFrequencyCardSource).toContain('getAlertFrequencySelectionPresentation');
expect(alertHistoryFrequencyCardSource).toContain('getAlertBucketCountLabel');
expect(alertHistoryFiltersCardSource).toContain('export function AlertHistoryFiltersCard');
expect(alertHistoryFiltersCardSource).toContain('getAlertHistorySearchPlaceholder');
expect(alertResourceIncidentsPanelSource).toContain('export function AlertResourceIncidentsPanel');
expect(alertResourceIncidentsPanelSource).toContain(
'export function AlertResourceIncidentsPanel',
);
expect(alertResourceIncidentsPanelSource).toContain('IncidentEventFilters');
expect(alertResourceIncidentsPanelSource).toContain('IncidentTimelineEventCard');
expect(alertResourceIncidentsPanelSource).toContain('buildResolvedResourceSurfaceLinks');
expect(alertResourceIncidentsPanelSource).toContain('allowInfrastructureFallback: true');
expect(alertResourceIncidentsPanelSource).not.toContain('buildInfrastructureResourceLink');
expect(alertResourceIncidentsPanelSource).not.toContain(
'buildResourceSurfaceLinksForResource',
);
expect(alertResourceIncidentsPanelSource).not.toContain('buildResourceSurfaceLinksForResource');
expect(alertResourceIncidentsPanelSource).toContain('{link.compactLabel}');
expect(alertHistoryTableSectionSource).toContain('export function AlertHistoryTableSection');
expect(alertHistoryTableSectionSource).toContain('AlertHistoryTableGroupRow');
@ -441,7 +448,9 @@ describe('tab path helpers', () => {
expect(alertOverviewAlertCardSource).toContain('getAlertOverviewStartedAtClass');
expect(alertOverviewAlertCardSource).toContain('getAlertOverviewPrimaryActionClass');
expect(alertOverviewAlertCardSource).toContain('getAlertOverviewSecondaryActionClass');
expect(alertAcknowledgementStateSource).toContain('export function useAlertAcknowledgementState');
expect(alertAcknowledgementStateSource).toContain(
'export function useAlertAcknowledgementState',
);
expect(alertAcknowledgementStateSource).toContain('AlertsAPI.bulkAcknowledge');
expect(alertAcknowledgementStateSource).toContain('AlertsAPI.acknowledge');
expect(alertAcknowledgementStateSource).toContain('AlertsAPI.unacknowledge');
@ -483,28 +492,46 @@ describe('tab path helpers', () => {
expect(thresholdsTableSource).toContain(
"import { useThresholdsTableState } from '@/features/alerts/thresholds/hooks/useThresholdsTableState';",
);
expect(thresholdsTableSource).toContain("import { ThresholdsTableProxmoxTab } from './ThresholdsTableProxmoxTab';");
expect(thresholdsTableSource).toContain("import { ThresholdsTablePMGTab } from './ThresholdsTablePMGTab';");
expect(thresholdsTableSource).toContain("import { ThresholdsTableAgentsTab } from './ThresholdsTableAgentsTab';");
expect(thresholdsTableSource).toContain("import { ThresholdsTableDockerTab } from './ThresholdsTableDockerTab';");
expect(thresholdsTableSource).toContain(
"import { ThresholdsTableProxmoxTab } from './ThresholdsTableProxmoxTab';",
);
expect(thresholdsTableSource).toContain(
"import { ThresholdsTablePMGTab } from './ThresholdsTablePMGTab';",
);
expect(thresholdsTableSource).toContain(
"import { ThresholdsTableAgentsTab } from './ThresholdsTableAgentsTab';",
);
expect(thresholdsTableSource).toContain(
"import { ThresholdsTableDockerTab } from './ThresholdsTableDockerTab';",
);
expect(thresholdsTableSource).not.toContain('const [searchTerm, setSearchTerm] = createSignal');
expect(thresholdsTableSource).not.toContain('const handleTabClick =');
expect(thresholdsTableSource).not.toContain("groupedResources={state.guestsGroupedByNode()}");
expect(thresholdsTableSource).not.toContain('groupedResources={state.guestsGroupedByNode()}');
expect(thresholdsTableSource).not.toContain('dockerIgnoredPrefixesPresentation.title');
expect(thresholdsTableProxmoxTabSource).toContain('export function ThresholdsTableProxmoxTab');
expect(thresholdsTableProxmoxTabSource).toContain('ThresholdsTableProxmoxNodesSection');
expect(thresholdsTableProxmoxTabSource).toContain('ThresholdsTableProxmoxPBSSection');
expect(thresholdsTableProxmoxTabSource).toContain('ThresholdsTableProxmoxGuestsSection');
expect(thresholdsTableProxmoxTabSource).toContain('ThresholdsTableProxmoxGuestFilteringSection');
expect(thresholdsTableProxmoxTabSource).toContain(
'ThresholdsTableProxmoxGuestFilteringSection',
);
expect(thresholdsTableProxmoxTabSource).toContain('ThresholdsTableProxmoxBackupsSection');
expect(thresholdsTableProxmoxTabSource).toContain('ThresholdsTableProxmoxSnapshotsSection');
expect(thresholdsTableProxmoxTabSource).toContain('ThresholdsTableProxmoxStorageSection');
expect(thresholdsTableProxmoxTabSource).not.toContain('backupOrphanedPresentation');
expect(thresholdsTableProxmoxTabSource).not.toContain("sectionTitles.guestFiltering");
expect(thresholdsTableSectionPropsSource).toContain('export interface ThresholdsTableSectionProps');
expect(thresholdsTableProxmoxNodesSectionSource).toContain('export function ThresholdsTableProxmoxNodesSection');
expect(thresholdsTableProxmoxPBSSectionSource).toContain('export function ThresholdsTableProxmoxPBSSection');
expect(thresholdsTableProxmoxGuestsSectionSource).toContain('export function ThresholdsTableProxmoxGuestsSection');
expect(thresholdsTableProxmoxTabSource).not.toContain('sectionTitles.guestFiltering');
expect(thresholdsTableSectionPropsSource).toContain(
'export interface ThresholdsTableSectionProps',
);
expect(thresholdsTableProxmoxNodesSectionSource).toContain(
'export function ThresholdsTableProxmoxNodesSection',
);
expect(thresholdsTableProxmoxPBSSectionSource).toContain(
'export function ThresholdsTableProxmoxPBSSection',
);
expect(thresholdsTableProxmoxGuestsSectionSource).toContain(
'export function ThresholdsTableProxmoxGuestsSection',
);
expect(thresholdsTableProxmoxGuestFilteringSectionSource).toContain(
'export function ThresholdsTableProxmoxGuestFilteringSection',
);
@ -560,7 +587,9 @@ describe('tab path helpers', () => {
expect(thresholdsDataHookSource).toContain('useThresholdsGuestData(inputs)');
expect(thresholdsDataHookSource).toContain('useThresholdsInfrastructureData(inputs)');
expect(thresholdsDataHookSource).not.toContain('const hostOverrideIdCandidates =');
expect(thresholdsDataHookSource).not.toContain('const dockerContainersGroupedByHost = createMemo');
expect(thresholdsDataHookSource).not.toContain(
'const dockerContainersGroupedByHost = createMemo',
);
expect(thresholdsHostDataHookSource).toContain('export function useThresholdsHostData');
expect(thresholdsHostDataHookSource).toContain('hostOverrideIdCandidates(agentResource)');
expect(thresholdsDockerDataHookSource).toContain('export function useThresholdsDockerData');
@ -573,11 +602,15 @@ describe('tab path helpers', () => {
expect(thresholdsResourceModelSource).toContain('export function buildNodeHeaderMeta');
expect(thresholdsResourceModelSource).toContain('export const normalizeStorageStatus');
expect(thresholdsTableStateHookSource).toContain('export function useThresholdsTableState');
expect(thresholdsTableStateHookSource).toContain('useThresholdsData(props, editingId, searchTerm)');
expect(thresholdsTableStateHookSource).toContain(
'useThresholdsData(props, editingId, searchTerm)',
);
expect(thresholdsTableStateHookSource).toContain('useThresholdsRecoveryDefaultsState(props)');
expect(thresholdsTableStateHookSource).toContain('useThresholdsOverrideMutations');
expect(thresholdsTableStateHookSource).toContain('useThresholdsAvailabilityMutations');
expect(thresholdsTableStateHookSource).not.toContain('const saveEdit = (resourceId: string) => {');
expect(thresholdsTableStateHookSource).not.toContain(
'const saveEdit = (resourceId: string) => {',
);
expect(thresholdsTableStateHookSource).not.toContain(
'const toggleNodeConnectivity = (resourceId: string, forceState?: boolean) => {',
);
@ -591,7 +624,9 @@ describe('tab path helpers', () => {
expect(thresholdsOverrideMutationsHookSource).toContain(
'export function useThresholdsOverrideMutations',
);
expect(thresholdsOverrideMutationsHookSource).toContain('const saveEdit = (resourceId: string) => {');
expect(thresholdsOverrideMutationsHookSource).toContain(
'const saveEdit = (resourceId: string) => {',
);
expect(thresholdsOverrideMutationsHookSource).toContain(
'const handleSaveBulkEdit = (thresholds: Record<string, number | undefined>) => {',
);
@ -610,9 +645,7 @@ describe('tab path helpers', () => {
'const setOfflineState = (resourceId: string, state: OfflineState) => {',
);
expect(thresholdsOverrideMutationModelSource).toContain('export const upsertOverride =');
expect(thresholdsOverrideMutationModelSource).toContain(
'export const withThresholdEntries =',
);
expect(thresholdsOverrideMutationModelSource).toContain('export const withThresholdEntries =');
expect(thresholdsOverrideMutationModelSource).toContain('export const stripStateKeys =');
});
@ -738,16 +771,12 @@ describe('incident timeline presentation helpers', () => {
);
expect(getAlertIncidentTimelineHeadingClass()).toBe('font-medium text-base-content');
expect(getAlertIncidentTimelineDetailClass()).toBe('mt-1 text-xs text-base-content');
expect(getAlertIncidentTimelineCommandClass()).toBe(
'mt-1 font-mono text-xs text-base-content',
);
expect(getAlertIncidentTimelineCommandClass()).toBe('mt-1 font-mono text-xs text-base-content');
expect(getAlertIncidentTimelineOutputClass()).toBe('mt-1 text-xs text-muted');
});
it('returns the resource incident panel presentation', () => {
expect(getAlertResourceIncidentCardClass()).toBe(
'rounded border border-border bg-surface p-3',
);
expect(getAlertResourceIncidentCardClass()).toBe('rounded border border-border bg-surface p-3');
expect(getAlertResourceIncidentSummaryRowClass()).toBe(
'mt-2 flex flex-wrap items-center justify-between gap-2 text-xs text-muted',
);

View file

@ -7,10 +7,16 @@ import {
ALERT_HISTORY_SEARCH_PLACEHOLDER,
ALERTS_EMPTY_STATE,
ALERTS_PAGE_DEFAULT_TITLE,
ALERTS_PAGE_DEFAULT_DESCRIPTION,
ALERTS_PAGE_DESTINATIONS_DESCRIPTION,
ALERTS_PAGE_DESTINATIONS_TITLE,
ALERTS_PAGE_HISTORY_DESCRIPTION,
ALERTS_PAGE_HISTORY_TITLE,
ALERTS_PAGE_OVERVIEW_DESCRIPTION,
ALERTS_PAGE_OVERVIEW_TITLE,
ALERTS_PAGE_SCHEDULE_DESCRIPTION,
ALERTS_PAGE_SCHEDULE_TITLE,
ALERTS_PAGE_THRESHOLDS_DESCRIPTION,
ALERTS_PAGE_THRESHOLDS_TITLE,
ALERT_TIMELINE_EMPTY_STATE,
ALERT_TIMELINE_FAILURE_STATE,
@ -60,9 +66,7 @@ describe('alertOverviewPresentation', () => {
expect(ALERT_HISTORY_SEARCH_PLACEHOLDER).toBe('Search alerts...');
expect(getAlertHistorySearchPlaceholder()).toBe('Search alerts...');
expect(ALERT_HISTORY_EMPTY_STATE).toBe('No alerts found');
expect(ALERT_HISTORY_EMPTY_DESCRIPTION).toBe(
'Try adjusting your filters or check back later',
);
expect(ALERT_HISTORY_EMPTY_DESCRIPTION).toBe('Try adjusting your filters or check back later');
expect(getAlertHistoryEmptyState()).toEqual({
title: 'No alerts found',
description: 'Try adjusting your filters or check back later',
@ -80,13 +84,54 @@ describe('alertOverviewPresentation', () => {
expect(ALERTS_PAGE_DESTINATIONS_TITLE).toBe('Notification Destinations');
expect(ALERTS_PAGE_SCHEDULE_TITLE).toBe('Maintenance Schedule');
expect(ALERTS_PAGE_HISTORY_TITLE).toBe('Alert History');
expect(ALERTS_PAGE_DEFAULT_DESCRIPTION).toBe(
'Review active incidents, inspect alert history, and manage thresholds, notifications, and schedules.',
);
expect(ALERTS_PAGE_OVERVIEW_DESCRIPTION).toBe(
'Review active incidents, confirm alert coverage, and control whether alerts are actively monitoring this install.',
);
expect(ALERTS_PAGE_THRESHOLDS_DESCRIPTION).toBe(
'Tune thresholds and scoped overrides for infrastructure, workloads, and integrations.',
);
expect(ALERTS_PAGE_DESTINATIONS_DESCRIPTION).toBe(
'Route alert notifications to email, Apprise, and webhook destinations.',
);
expect(ALERTS_PAGE_SCHEDULE_DESCRIPTION).toBe(
'Define quiet hours, grouping, cooldowns, recovery, and escalation behavior for alert delivery.',
);
expect(ALERTS_PAGE_HISTORY_DESCRIPTION).toBe(
'Search prior alerts, review incident timelines, and inspect alert frequency over time.',
);
expect(getAlertsPageHeaderMeta()).toEqual({
overview: { title: 'Alerts Overview' },
thresholds: { title: 'Alert Thresholds' },
destinations: { title: 'Notification Destinations' },
schedule: { title: 'Maintenance Schedule' },
history: { title: 'Alert History' },
default: { title: 'Alerts' },
overview: {
title: 'Alerts Overview',
description:
'Review active incidents, confirm alert coverage, and control whether alerts are actively monitoring this install.',
},
thresholds: {
title: 'Alert Thresholds',
description:
'Tune thresholds and scoped overrides for infrastructure, workloads, and integrations.',
},
destinations: {
title: 'Notification Destinations',
description: 'Route alert notifications to email, Apprise, and webhook destinations.',
},
schedule: {
title: 'Maintenance Schedule',
description:
'Define quiet hours, grouping, cooldowns, recovery, and escalation behavior for alert delivery.',
},
history: {
title: 'Alert History',
description:
'Search prior alerts, review incident timelines, and inspect alert frequency over time.',
},
default: {
title: 'Alerts',
description:
'Review active incidents, inspect alert history, and manage thresholds, notifications, and schedules.',
},
});
});

View file

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import {
PATROL_PAGE_DESCRIPTION,
PATROL_PAGE_TITLE,
getPatrolPageHeaderMeta,
} from '@/utils/patrolPagePresentation';
describe('patrolPagePresentation', () => {
it('returns canonical Patrol page header metadata', () => {
expect(PATROL_PAGE_TITLE).toBe('Patrol');
expect(PATROL_PAGE_DESCRIPTION).toBe(
'Continuously verify infrastructure health, review findings, and control Patrol runtime behavior.',
);
expect(getPatrolPageHeaderMeta()).toEqual({
title: 'Patrol',
description:
'Continuously verify infrastructure health, review findings, and control Patrol runtime behavior.',
});
});
});

View file

@ -1,8 +1,7 @@
export const ALERTS_EMPTY_STATE = 'No active alerts';
export const ALERTS_THRESHOLD_HINT = 'Alerts will appear here when thresholds are exceeded';
export const ALERT_TIMELINE_LOADING_STATE = 'Loading timeline...';
export const ALERT_TIMELINE_FILTER_EMPTY_STATE =
'No timeline events match the selected filters.';
export const ALERT_TIMELINE_FILTER_EMPTY_STATE = 'No timeline events match the selected filters.';
export const ALERT_TIMELINE_EMPTY_STATE = 'No timeline events yet.';
export const ALERT_TIMELINE_UNAVAILABLE_STATE = 'No incident timeline available.';
export const ALERT_TIMELINE_FAILURE_STATE = 'Failed to load timeline.';
@ -18,6 +17,18 @@ export const ALERTS_PAGE_THRESHOLDS_TITLE = 'Alert Thresholds';
export const ALERTS_PAGE_DESTINATIONS_TITLE = 'Notification Destinations';
export const ALERTS_PAGE_SCHEDULE_TITLE = 'Maintenance Schedule';
export const ALERTS_PAGE_HISTORY_TITLE = 'Alert History';
export const ALERTS_PAGE_DEFAULT_DESCRIPTION =
'Review active incidents, inspect alert history, and manage thresholds, notifications, and schedules.';
export const ALERTS_PAGE_OVERVIEW_DESCRIPTION =
'Review active incidents, confirm alert coverage, and control whether alerts are actively monitoring this install.';
export const ALERTS_PAGE_THRESHOLDS_DESCRIPTION =
'Tune thresholds and scoped overrides for infrastructure, workloads, and integrations.';
export const ALERTS_PAGE_DESTINATIONS_DESCRIPTION =
'Route alert notifications to email, Apprise, and webhook destinations.';
export const ALERTS_PAGE_SCHEDULE_DESCRIPTION =
'Define quiet hours, grouping, cooldowns, recovery, and escalation behavior for alert delivery.';
export const ALERTS_PAGE_HISTORY_DESCRIPTION =
'Search prior alerts, review incident timelines, and inspect alert frequency over time.';
export type DashboardAlertTone = 'default' | 'warning' | 'danger';
@ -34,12 +45,30 @@ export interface AlertOverviewCardPresentation {
export function getAlertsPageHeaderMeta() {
return {
overview: { title: ALERTS_PAGE_OVERVIEW_TITLE },
thresholds: { title: ALERTS_PAGE_THRESHOLDS_TITLE },
destinations: { title: ALERTS_PAGE_DESTINATIONS_TITLE },
schedule: { title: ALERTS_PAGE_SCHEDULE_TITLE },
history: { title: ALERTS_PAGE_HISTORY_TITLE },
default: { title: ALERTS_PAGE_DEFAULT_TITLE },
overview: {
title: ALERTS_PAGE_OVERVIEW_TITLE,
description: ALERTS_PAGE_OVERVIEW_DESCRIPTION,
},
thresholds: {
title: ALERTS_PAGE_THRESHOLDS_TITLE,
description: ALERTS_PAGE_THRESHOLDS_DESCRIPTION,
},
destinations: {
title: ALERTS_PAGE_DESTINATIONS_TITLE,
description: ALERTS_PAGE_DESTINATIONS_DESCRIPTION,
},
schedule: {
title: ALERTS_PAGE_SCHEDULE_TITLE,
description: ALERTS_PAGE_SCHEDULE_DESCRIPTION,
},
history: {
title: ALERTS_PAGE_HISTORY_TITLE,
description: ALERTS_PAGE_HISTORY_DESCRIPTION,
},
default: {
title: ALERTS_PAGE_DEFAULT_TITLE,
description: ALERTS_PAGE_DEFAULT_DESCRIPTION,
},
} as const;
}

View file

@ -0,0 +1,10 @@
export const PATROL_PAGE_TITLE = 'Patrol';
export const PATROL_PAGE_DESCRIPTION =
'Continuously verify infrastructure health, review findings, and control Patrol runtime behavior.';
export function getPatrolPageHeaderMeta() {
return {
title: PATROL_PAGE_TITLE,
description: PATROL_PAGE_DESCRIPTION,
} as const;
}

View file

@ -0,0 +1,86 @@
import { test, expect } from "@playwright/test";
import { readRuntimeState } from "./runtime-defaults";
const PAGE_HEADER_ROUTES = [
{
slug: "operations",
route: "/operations",
title: "Operations",
description:
"Run diagnostics, review generated reports, and inspect system logs without leaving the app.",
},
{
slug: "alerts",
route: "/alerts/overview",
title: "Alerts Overview",
description:
"Review active incidents, confirm alert coverage, and control whether alerts are actively monitoring this install.",
},
{
slug: "patrol",
route: "/patrol",
title: "Patrol",
description:
"Continuously verify infrastructure health, review findings, and control Patrol runtime behavior.",
},
] as const;
const PRIMARY_API_TOKEN =
process.env.PULSE_E2E_PRIMARY_API_TOKEN?.trim() ||
(typeof readRuntimeState()?.primaryAPIToken === "string"
? readRuntimeState()!.primaryAPIToken!.trim()
: "");
test.skip(
PRIMARY_API_TOKEN === "",
"Top-level header browser proof requires a runtime API token.",
);
test.describe("Top-level page header consistency", () => {
test.setTimeout(180_000);
test.beforeEach(async ({ page }) => {
await page.addInitScript((token: string) => {
sessionStorage.setItem(
"pulse_auth",
JSON.stringify({
type: "token",
value: token,
}),
);
sessionStorage.setItem("pulse_auth_user", "admin");
localStorage.setItem("pulse_whats_new_v2_shown", "true");
}, PRIMARY_API_TOKEN);
});
for (const surface of PAGE_HEADER_ROUTES) {
test(`renders the canonical header framing on ${surface.route}`, async ({
page,
}, testInfo) => {
await page.goto(surface.route, { waitUntil: "domcontentloaded" });
const pageHeading = page.getByRole("heading", {
level: 1,
name: surface.title,
});
await expect(
pageHeading,
`${surface.route} should render a single top-level heading`,
).toBeVisible();
await expect(
page.getByText(surface.description, { exact: true }).first(),
`${surface.route} should render the canonical subheader copy`,
).toBeVisible();
await expect(
page.locator("h1"),
`${surface.route} should not render duplicate page headings`,
).toHaveCount(1);
const screenshotPath = testInfo.outputPath(`${surface.slug}.png`);
await page.screenshot({ path: screenshotPath });
console.log(
`[page-header-consistency] screenshot ${surface.slug}: ${screenshotPath}`,
);
});
}
});