From df06fe84b2af2ebdad26604deedb53919da7676e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 17 Apr 2026 18:47:02 +0100 Subject: [PATCH] Normalize alerts and patrol page headers --- .../patrol/PatrolIntelligenceHeader.tsx | 9 +- frontend-modern/src/pages/Alerts.tsx | 348 +++++++++--------- .../pages/__tests__/AIIntelligence.test.tsx | 11 +- .../pages/__tests__/Alerts.helpers.test.ts | 91 +++-- .../alertOverviewPresentation.test.ts | 63 +++- .../__tests__/patrolPagePresentation.test.ts | 20 + .../src/utils/alertOverviewPresentation.ts | 45 ++- .../src/utils/patrolPagePresentation.ts | 10 + .../tests/60-page-header-consistency.spec.ts | 86 +++++ 9 files changed, 447 insertions(+), 236 deletions(-) create mode 100644 frontend-modern/src/utils/__tests__/patrolPagePresentation.test.ts create mode 100644 frontend-modern/src/utils/patrolPagePresentation.ts create mode 100644 tests/integration/tests/60-page-header-consistency.spec.ts diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx index d771d56e1..d6f0cdae7 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx @@ -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
- Patrol + {headerMeta.title} } class="mb-3" diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index a4f684793..274f86999 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -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([]); 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 ( -
- {/* Header with better styling */} - -
- +
+
@@ -284,180 +276,172 @@ export function Alerts() {
-
- + } + /> -
- + +
-
- -
-

Alerts

- -
-
- + +
+

Alerts

- -
- - {(group) => ( -
- -

- {group.label} -

-
-
- - {(item) => ( - - )} - -
-
- )} -
-
-
-
- -
- 0}> -
-
-
- - {(tab) => ( - - )} - -
-
- - {/* Tab Content */} -
- - - - - - - - - - - + + + +
+ + {(group) => ( +
+ +

+ {group.label} +

+
+
+ + {(item) => ( + + )} + +
+
+ )} +
- -
+
+ +
+ 0}> +
+
+
+ + {(tab) => ( + + )} + +
+
+
+
+ +
+ + + + + + + + + + + +
+
+
); } diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx index cfe675588..594287ddf 100644 --- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx +++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx @@ -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 }) => (

{props.title}

+

{props.description}

{props.actions as any}
), @@ -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(); }); diff --git a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts index 18eb50bdc..61c410bea 100644 --- a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts +++ b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts @@ -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(' { 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) => {', ); @@ -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', ); diff --git a/frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts b/frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts index 52b47d80b..295a2e95a 100644 --- a/frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts @@ -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.', + }, }); }); diff --git a/frontend-modern/src/utils/__tests__/patrolPagePresentation.test.ts b/frontend-modern/src/utils/__tests__/patrolPagePresentation.test.ts new file mode 100644 index 000000000..ace1d7c49 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/patrolPagePresentation.test.ts @@ -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.', + }); + }); +}); diff --git a/frontend-modern/src/utils/alertOverviewPresentation.ts b/frontend-modern/src/utils/alertOverviewPresentation.ts index fac12ef1e..4c7fead3c 100644 --- a/frontend-modern/src/utils/alertOverviewPresentation.ts +++ b/frontend-modern/src/utils/alertOverviewPresentation.ts @@ -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; } diff --git a/frontend-modern/src/utils/patrolPagePresentation.ts b/frontend-modern/src/utils/patrolPagePresentation.ts new file mode 100644 index 000000000..adf8d5bb6 --- /dev/null +++ b/frontend-modern/src/utils/patrolPagePresentation.ts @@ -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; +} diff --git a/tests/integration/tests/60-page-header-consistency.spec.ts b/tests/integration/tests/60-page-header-consistency.spec.ts new file mode 100644 index 000000000..61c26e713 --- /dev/null +++ b/tests/integration/tests/60-page-header-consistency.spec.ts @@ -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}`, + ); + }); + } +});