Extract alert resource incidents runtime owner

This commit is contained in:
rcourtman
2026-03-22 21:51:03 +00:00
parent 747c59a516
commit b92f8b75cc
6 changed files with 131 additions and 84 deletions
@@ -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`,
@@ -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`,
@@ -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<Record<string, Alert>>;
@@ -61,21 +60,8 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) {
const [alertHistory, setAlertHistory] = createSignal<Alert[]>([]);
const [loading, setLoading] = createSignal(true);
const [selectedBarIndex, setSelectedBarIndex] = createSignal<number | null>(null);
const [resourceIncidentPanel, setResourceIncidentPanel] = createSignal<{
resourceId: string;
resourceName: string;
} | null>(null);
const [resourceIncidents, setResourceIncidents] = createSignal<Record<string, Incident[]>>({});
const [resourceIncidentLoading, setResourceIncidentLoading] = createSignal<
Record<string, boolean>
>({});
const [expandedResourceIncidentIds, setExpandedResourceIncidentIds] = createSignal<Set<string>>(
new Set(),
);
const [resourceIncidentEventFilters, setResourceIncidentEventFilters] = createSignal<Set<string>>(
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<string>());
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<string>());
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<HistoryItem[]>(() => {
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,
@@ -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<Record<string, Incident[]>>({});
const [resourceIncidentLoading, setResourceIncidentLoading] = createSignal<
Record<string, boolean>
>({});
const [expandedResourceIncidentIds, setExpandedResourceIncidentIds] = createSignal<Set<string>>(
new Set(),
);
const [resourceIncidentEventFilters, setResourceIncidentEventFilters] = createSignal<Set<string>>(
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<string>());
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<string>());
setResourceIncidentEventFilters(new Set(INCIDENT_EVENT_TYPES));
};
return {
resourceIncidentPanel,
setResourceIncidentPanel,
resourceIncidents,
resourceIncidentLoading,
expandedResourceIncidentIds,
resourceIncidentEventFilters,
setResourceIncidentEventFilters,
openResourceIncidentPanel,
refreshResourceIncidentPanel,
toggleResourceIncidentDetails,
resetResourceIncidentsState,
};
}
@@ -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',
);
@@ -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');