From b92f8b75ccee1bfd2a43b7bf0be3d81a7dbbfd85 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 22 Mar 2026 21:51:03 +0000 Subject: [PATCH] Extract alert resource incidents runtime owner --- .../v6/internal/subsystems/alerts.md | 10 +- .../subsystems/frontend-primitives.md | 10 +- .../features/alerts/useAlertHistoryState.ts | 88 +++--------------- .../alerts/useAlertResourceIncidentsState.ts | 91 +++++++++++++++++++ .../pages/__tests__/Alerts.helpers.test.ts | 8 +- .../frontendResourceTypeBoundaries.test.ts | 8 +- 6 files changed, 131 insertions(+), 84 deletions(-) create mode 100644 frontend-modern/src/features/alerts/useAlertResourceIncidentsState.ts diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 2c0ead4c1..4faa8c649 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -288,10 +288,12 @@ primitive. The history tab itself now follows the same shell-versus-runtime rule. The canonical history runtime owner is `frontend-modern/src/features/alerts/useAlertHistoryState.ts`, which now owns -alert-history fetch, persistent filter state, resource-incident panel loading, -and history-clear flow, while the pure analytics model for history-item -projection, trend buckets, group labels, axis ticks, and selected bucket -detail now lives in +alert-history fetch, persistent filter state, history-clear flow, and +composition of the derived history owners. Resource-incident panel loading, +refresh, and expansion state now live in +`frontend-modern/src/features/alerts/useAlertResourceIncidentsState.ts`, while +the pure analytics model for history-item projection, trend buckets, group +labels, axis ticks, and selected bucket detail now lives in `frontend-modern/src/features/alerts/alertHistoryModel.ts`. The tab shell in `frontend-modern/src/features/alerts/tabs/HistoryTab.tsx` now composes `frontend-modern/src/features/alerts/AlertHistoryFrequencyCard.tsx`, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 2a73aa4cf..95051ba66 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -551,10 +551,12 @@ back into a mixed shell. Alert history runtime now follows that same pattern. The shell stays in `frontend-modern/src/features/alerts/tabs/HistoryTab.tsx`, while `frontend-modern/src/features/alerts/useAlertHistoryState.ts` owns history -fetch, persistent filters, resource-incident panel state, and history-clear -behavior, while `frontend-modern/src/features/alerts/alertHistoryModel.ts` -owns grouped/trend derivation and the bucket/range analytics contract. The -render-heavy surfaces now route through +fetch, persistent filters, history-clear behavior, and composition of the +derived history owners. Resource-incident panel runtime now lives in +`frontend-modern/src/features/alerts/useAlertResourceIncidentsState.ts`, while +`frontend-modern/src/features/alerts/alertHistoryModel.ts` owns grouped/trend +derivation and the bucket/range analytics contract. The render-heavy surfaces +now route through `frontend-modern/src/features/alerts/AlertHistoryFrequencyCard.tsx`, `frontend-modern/src/features/alerts/AlertHistoryFiltersCard.tsx`, `frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx`, diff --git a/frontend-modern/src/features/alerts/useAlertHistoryState.ts b/frontend-modern/src/features/alerts/useAlertHistoryState.ts index bdaf5e05c..6ded3c401 100644 --- a/frontend-modern/src/features/alerts/useAlertHistoryState.ts +++ b/frontend-modern/src/features/alerts/useAlertHistoryState.ts @@ -5,7 +5,7 @@ import { AlertsAPI } from '@/api/alerts'; import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import { eventBus } from '@/stores/events'; import { notificationStore } from '@/stores/notifications'; -import type { Alert, Incident } from '@/types/api'; +import type { Alert } from '@/types/api'; import type { Resource } from '@/types/resource'; import { STORAGE_KEYS } from '@/utils/localStorage'; import { logger } from '@/utils/logger'; @@ -13,7 +13,6 @@ import { getAlertAdministrationClearHistoryError, getAlertAdministrationClearHistoryConfirmation, } from '@/utils/alertAdministrationPresentation'; -import { getAlertResourceIncidentLoadFailure } from '@/utils/alertIncidentPresentation'; import { applyAlertHistoryWindow, @@ -33,7 +32,7 @@ import { type HistoryItem, } from './alertHistoryModel'; import { useAlertIncidentTimelineState } from './useAlertIncidentTimelineState'; -import { INCIDENT_EVENT_TYPES } from './types'; +import { useAlertResourceIncidentsState } from './useAlertResourceIncidentsState'; export interface UseAlertHistoryStateProps { activeAlerts: Accessor>; @@ -61,21 +60,8 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) { const [alertHistory, setAlertHistory] = createSignal([]); const [loading, setLoading] = createSignal(true); const [selectedBarIndex, setSelectedBarIndex] = createSignal(null); - const [resourceIncidentPanel, setResourceIncidentPanel] = createSignal<{ - resourceId: string; - resourceName: string; - } | null>(null); - const [resourceIncidents, setResourceIncidents] = createSignal>({}); - const [resourceIncidentLoading, setResourceIncidentLoading] = createSignal< - Record - >({}); - const [expandedResourceIncidentIds, setExpandedResourceIncidentIds] = createSignal>( - new Set(), - ); - const [resourceIncidentEventFilters, setResourceIncidentEventFilters] = createSignal>( - new Set(INCIDENT_EVENT_TYPES), - ); const [filtersOpen, setFiltersOpen] = createSignal(false); + const resourceIncidentsState = useAlertResourceIncidentsState(); const { incidentTimelines, @@ -150,10 +136,7 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) { const unsubscribeOrgSwitched = eventBus.on('org_switched', () => { setAlertHistory([]); setSelectedBarIndex(null); - setResourceIncidentPanel(null); - setResourceIncidents({}); - setResourceIncidentLoading({}); - setExpandedResourceIncidentIds(new Set()); + resourceIncidentsState.resetResourceIncidentsState(); resetState(); void fetchHistory(timeFilter()); }); @@ -174,49 +157,6 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) { void fetchHistory(range); }); - const loadResourceIncidents = async (resourceId: string, limit = 10) => { - if (!resourceId) return; - - setResourceIncidentLoading((prev) => ({ ...prev, [resourceId]: true })); - try { - const incidents = await AlertsAPI.getIncidentsForResource(resourceId, limit); - setResourceIncidents((prev) => ({ ...prev, [resourceId]: incidents })); - } catch (error) { - logger.error(getAlertResourceIncidentLoadFailure(), error); - notificationStore.error(getAlertResourceIncidentLoadFailure()); - } finally { - setResourceIncidentLoading((prev) => ({ ...prev, [resourceId]: false })); - } - }; - - const openResourceIncidentPanel = async (resourceId: string, resourceName: string) => { - if (!resourceId) return; - - setResourceIncidentPanel({ resourceId, resourceName }); - setExpandedResourceIncidentIds(new Set()); - if (!(resourceId in resourceIncidents())) { - await loadResourceIncidents(resourceId); - } - }; - - const refreshResourceIncidentPanel = async () => { - const selection = resourceIncidentPanel(); - if (!selection) return; - await loadResourceIncidents(selection.resourceId); - }; - - const toggleResourceIncidentDetails = (incidentId: string) => { - setExpandedResourceIncidentIds((prev) => { - const next = new Set(prev); - if (next.has(incidentId)) { - next.delete(incidentId); - } else { - next.add(incidentId); - } - return next; - }); - }; - const allHistoryData = createMemo(() => { return buildAlertHistoryItems({ activeAlerts: props.activeAlerts() || {}, @@ -285,13 +225,13 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) { loading, selectedBarIndex, setSelectedBarIndex, - resourceIncidentPanel, - setResourceIncidentPanel, - resourceIncidents, - resourceIncidentLoading, - expandedResourceIncidentIds, - resourceIncidentEventFilters, - setResourceIncidentEventFilters, + resourceIncidentPanel: resourceIncidentsState.resourceIncidentPanel, + setResourceIncidentPanel: resourceIncidentsState.setResourceIncidentPanel, + resourceIncidents: resourceIncidentsState.resourceIncidents, + resourceIncidentLoading: resourceIncidentsState.resourceIncidentLoading, + expandedResourceIncidentIds: resourceIncidentsState.expandedResourceIncidentIds, + resourceIncidentEventFilters: resourceIncidentsState.resourceIncidentEventFilters, + setResourceIncidentEventFilters: resourceIncidentsState.setResourceIncidentEventFilters, filtersOpen, setFiltersOpen, activeFilterCount, @@ -307,9 +247,9 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) { toggleIncidentTimeline, setIncidentNoteDraft, saveIncidentNote, - openResourceIncidentPanel, - refreshResourceIncidentPanel, - toggleResourceIncidentDetails, + openResourceIncidentPanel: resourceIncidentsState.openResourceIncidentPanel, + refreshResourceIncidentPanel: resourceIncidentsState.refreshResourceIncidentPanel, + toggleResourceIncidentDetails: resourceIncidentsState.toggleResourceIncidentDetails, alertData, groupedAlerts, alertTrends, diff --git a/frontend-modern/src/features/alerts/useAlertResourceIncidentsState.ts b/frontend-modern/src/features/alerts/useAlertResourceIncidentsState.ts new file mode 100644 index 000000000..4a0a13031 --- /dev/null +++ b/frontend-modern/src/features/alerts/useAlertResourceIncidentsState.ts @@ -0,0 +1,91 @@ +import { createSignal } from 'solid-js'; + +import { AlertsAPI } from '@/api/alerts'; +import { notificationStore } from '@/stores/notifications'; +import type { Incident } from '@/types/api'; +import { logger } from '@/utils/logger'; +import { getAlertResourceIncidentLoadFailure } from '@/utils/alertIncidentPresentation'; + +import { INCIDENT_EVENT_TYPES } from './types'; + +export function useAlertResourceIncidentsState() { + const [resourceIncidentPanel, setResourceIncidentPanel] = createSignal<{ + resourceId: string; + resourceName: string; + } | null>(null); + const [resourceIncidents, setResourceIncidents] = createSignal>({}); + const [resourceIncidentLoading, setResourceIncidentLoading] = createSignal< + Record + >({}); + const [expandedResourceIncidentIds, setExpandedResourceIncidentIds] = createSignal>( + new Set(), + ); + const [resourceIncidentEventFilters, setResourceIncidentEventFilters] = createSignal>( + new Set(INCIDENT_EVENT_TYPES), + ); + + const loadResourceIncidents = async (resourceId: string, limit = 10) => { + if (!resourceId) return; + + setResourceIncidentLoading((prev) => ({ ...prev, [resourceId]: true })); + try { + const incidents = await AlertsAPI.getIncidentsForResource(resourceId, limit); + setResourceIncidents((prev) => ({ ...prev, [resourceId]: incidents })); + } catch (error) { + logger.error(getAlertResourceIncidentLoadFailure(), error); + notificationStore.error(getAlertResourceIncidentLoadFailure()); + } finally { + setResourceIncidentLoading((prev) => ({ ...prev, [resourceId]: false })); + } + }; + + const openResourceIncidentPanel = async (resourceId: string, resourceName: string) => { + if (!resourceId) return; + + setResourceIncidentPanel({ resourceId, resourceName }); + setExpandedResourceIncidentIds(new Set()); + if (!(resourceId in resourceIncidents())) { + await loadResourceIncidents(resourceId); + } + }; + + const refreshResourceIncidentPanel = async () => { + const selection = resourceIncidentPanel(); + if (!selection) return; + await loadResourceIncidents(selection.resourceId); + }; + + const toggleResourceIncidentDetails = (incidentId: string) => { + setExpandedResourceIncidentIds((prev) => { + const next = new Set(prev); + if (next.has(incidentId)) { + next.delete(incidentId); + } else { + next.add(incidentId); + } + return next; + }); + }; + + const resetResourceIncidentsState = () => { + setResourceIncidentPanel(null); + setResourceIncidents({}); + setResourceIncidentLoading({}); + setExpandedResourceIncidentIds(new Set()); + setResourceIncidentEventFilters(new Set(INCIDENT_EVENT_TYPES)); + }; + + return { + resourceIncidentPanel, + setResourceIncidentPanel, + resourceIncidents, + resourceIncidentLoading, + expandedResourceIncidentIds, + resourceIncidentEventFilters, + setResourceIncidentEventFilters, + openResourceIncidentPanel, + refreshResourceIncidentPanel, + toggleResourceIncidentDetails, + resetResourceIncidentsState, + }; +} diff --git a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts index 2ef0f760f..7ae0bb66c 100644 --- a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts +++ b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts @@ -16,6 +16,7 @@ import alertHistoryTableGroupRowSource from '@/features/alerts/AlertHistoryTable import alertHistoryTableSectionSource from '@/features/alerts/AlertHistoryTableSection.tsx?raw'; import alertResourceIncidentsPanelSource from '@/features/alerts/AlertResourceIncidentsPanel.tsx?raw'; import alertHistoryStateSource from '@/features/alerts/useAlertHistoryState.ts?raw'; +import alertResourceIncidentsStateSource from '@/features/alerts/useAlertResourceIncidentsState.ts?raw'; import alertHistoryModelSource from '@/features/alerts/alertHistoryModel.ts?raw'; import alertIncidentTimelineStateSource from '@/features/alerts/useAlertIncidentTimelineState.ts?raw'; import alertOverviewActiveAlertsSectionSource from '@/features/alerts/AlertOverviewActiveAlertsSection.tsx?raw'; @@ -336,9 +337,10 @@ describe('tab path helpers', () => { expect(alertHistoryStateSource).toContain('export function useAlertHistoryState'); expect(alertHistoryStateSource).toContain('export type AlertHistoryState'); expect(alertHistoryStateSource).toContain('AlertsAPI.getHistory'); - expect(alertHistoryStateSource).toContain('AlertsAPI.getIncidentsForResource'); expect(alertHistoryStateSource).toContain('AlertsAPI.clearHistory'); + expect(alertHistoryStateSource).toContain('useAlertResourceIncidentsState'); expect(alertHistoryStateSource).toContain('useAlertIncidentTimelineState'); + expect(alertHistoryStateSource).not.toContain('AlertsAPI.getIncidentsForResource'); expect(alertHistoryStateSource).toContain('buildAlertHistoryItems'); expect(alertHistoryStateSource).toContain('buildAlertTrends'); expect(alertHistoryStateSource).toContain('groupAlertHistoryItems'); @@ -350,6 +352,10 @@ describe('tab path helpers', () => { expect(alertHistoryModelSource).toContain('export function buildAlertTrends'); expect(alertHistoryModelSource).toContain('export function groupAlertHistoryItems'); expect(alertHistoryModelSource).toContain('export const MS_PER_HOUR'); + expect(alertResourceIncidentsStateSource).toContain( + 'export function useAlertResourceIncidentsState', + ); + expect(alertResourceIncidentsStateSource).toContain('AlertsAPI.getIncidentsForResource'); expect(alertIncidentTimelineStateSource).toContain( 'export function useAlertIncidentTimelineState', ); diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index d057502d6..474a799c5 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -340,6 +340,7 @@ import alertHistoryTableGroupRowSource from '@/features/alerts/AlertHistoryTable import alertHistoryTableSectionSource from '@/features/alerts/AlertHistoryTableSection.tsx?raw'; import alertResourceIncidentsPanelSource from '@/features/alerts/AlertResourceIncidentsPanel.tsx?raw'; import alertHistoryStateSource from '@/features/alerts/useAlertHistoryState.ts?raw'; +import alertResourceIncidentsStateSource from '@/features/alerts/useAlertResourceIncidentsState.ts?raw'; import alertHistoryModelSource from '@/features/alerts/alertHistoryModel.ts?raw'; import alertIncidentTimelineStateSource from '@/features/alerts/useAlertIncidentTimelineState.ts?raw'; import alertOverviewActiveAlertsSectionSource from '@/features/alerts/AlertOverviewActiveAlertsSection.tsx?raw'; @@ -2937,9 +2938,10 @@ describe('frontend resource type boundaries', () => { expect(alertHistoryStateSource).toContain('export function useAlertHistoryState'); expect(alertHistoryStateSource).toContain('export type AlertHistoryState'); expect(alertHistoryStateSource).toContain('AlertsAPI.getHistory'); - expect(alertHistoryStateSource).toContain('AlertsAPI.getIncidentsForResource'); expect(alertHistoryStateSource).toContain('AlertsAPI.clearHistory'); + expect(alertHistoryStateSource).toContain('useAlertResourceIncidentsState'); expect(alertHistoryStateSource).toContain('useAlertIncidentTimelineState'); + expect(alertHistoryStateSource).not.toContain('AlertsAPI.getIncidentsForResource'); expect(alertHistoryStateSource).toContain('buildAlertHistoryItems'); expect(alertHistoryStateSource).toContain('buildAlertTrends'); expect(alertHistoryStateSource).toContain('groupAlertHistoryItems'); @@ -2951,6 +2953,10 @@ describe('frontend resource type boundaries', () => { expect(alertHistoryModelSource).toContain('export function buildAlertTrends'); expect(alertHistoryModelSource).toContain('export function groupAlertHistoryItems'); expect(alertHistoryModelSource).toContain('export const MS_PER_HOUR'); + expect(alertResourceIncidentsStateSource).toContain( + 'export function useAlertResourceIncidentsState', + ); + expect(alertResourceIncidentsStateSource).toContain('AlertsAPI.getIncidentsForResource'); expect(alertsPageSource).toContain('getAlertsSidebarTabClass'); expect(alertsPageSource).toContain('getAlertsMobileTabClass'); expect(alertsPageSource).toContain('getAlertsTabTitle');