mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-06 21:03:34 +00:00
Extract alert history state owner
This commit is contained in:
@@ -198,6 +198,15 @@ runtime presentation, tab-local interaction logic, and any history-table
|
||||
presentation or thresholds-table adapter logic that does not belong in a shared
|
||||
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 owns alert
|
||||
history fetch, persistent filter state, trend-bucket derivation, grouped-row
|
||||
projection, resource-incident panel loading, and history-clear flow. Future
|
||||
alert history control-flow work should extend that feature hook instead of
|
||||
putting data fetch or resource-incident state back into
|
||||
`frontend-modern/src/features/alerts/tabs/HistoryTab.tsx`.
|
||||
|
||||
Alert configuration load/save state, notification config reloads, and threshold
|
||||
override normalization now route through
|
||||
`frontend-modern/src/features/alerts/AlertsConfigurationSurface.tsx` instead of
|
||||
|
||||
@@ -429,6 +429,12 @@ while `frontend-modern/src/features/alerts/OverviewTab.tsx` and
|
||||
surface composition. Future incident timeline fetch, note-save, or expansion
|
||||
control flow should extend that feature hook rather than forking back into
|
||||
either tab surface.
|
||||
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, grouped/trend derivation, resource-incident panel
|
||||
state, and history-clear behavior. Future alert-history control flow should
|
||||
extend that hook rather than rebuilding fetch and panel state in the tab shell.
|
||||
Top-level settings surfaces must route through `Settings.tsx`,
|
||||
`SettingsPageShell.tsx`, and
|
||||
`frontend-modern/src/components/shared/SettingsPanel.tsx` instead of
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { renderHook, waitFor } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AlertsAPI } from '@/api/alerts';
|
||||
import { eventBus } from '@/stores/events';
|
||||
|
||||
import { useAlertHistoryState } from '../useAlertHistoryState';
|
||||
|
||||
vi.mock('@/api/alerts', () => ({
|
||||
AlertsAPI: {
|
||||
addIncidentNote: vi.fn(),
|
||||
clearHistory: vi.fn(),
|
||||
getHistory: vi.fn(),
|
||||
getIncidentTimeline: vi.fn(),
|
||||
getIncidentsForResource: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/events', () => ({
|
||||
eventBus: {
|
||||
on: vi.fn(() => vi.fn()),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('useAlertHistoryState', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(AlertsAPI.getHistory).mockReset();
|
||||
vi.mocked(AlertsAPI.getIncidentsForResource).mockReset();
|
||||
vi.mocked(AlertsAPI.clearHistory).mockReset();
|
||||
vi.mocked(eventBus.on).mockClear();
|
||||
vi.stubGlobal('confirm', vi.fn(() => true));
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('owns alert history fetch, filters, resource incidents, and clear behavior outside the render tab', async () => {
|
||||
const [activeAlerts] = createSignal({});
|
||||
const now = Date.now();
|
||||
const startTime = new Date(now - 30 * 60 * 1000).toISOString();
|
||||
const lastSeen = new Date(now - 10 * 60 * 1000).toISOString();
|
||||
|
||||
vi.mocked(AlertsAPI.getHistory).mockResolvedValue([
|
||||
{
|
||||
id: 'alert-1',
|
||||
type: 'cpu',
|
||||
level: 'warning',
|
||||
startTime,
|
||||
lastSeen,
|
||||
resourceId: 'resource-1',
|
||||
resourceName: 'db-01',
|
||||
message: 'CPU high',
|
||||
acknowledged: false,
|
||||
},
|
||||
] as any);
|
||||
vi.mocked(AlertsAPI.getIncidentsForResource).mockResolvedValue([
|
||||
{
|
||||
id: 'incident-1',
|
||||
alertType: 'CPU',
|
||||
level: 'warning',
|
||||
status: 'resolved',
|
||||
openedAt: startTime,
|
||||
closedAt: lastSeen,
|
||||
events: [],
|
||||
},
|
||||
] as any);
|
||||
vi.mocked(AlertsAPI.clearHistory).mockResolvedValue(undefined as any);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAlertHistoryState({
|
||||
activeAlerts,
|
||||
getResource: () => undefined,
|
||||
allResources: () => [],
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(AlertsAPI.getHistory).toHaveBeenCalledTimes(1));
|
||||
expect(result.alertData()).toHaveLength(1);
|
||||
expect(eventBus.on).toHaveBeenCalledWith('org_switched', expect.any(Function));
|
||||
|
||||
await result.openResourceIncidentPanel('resource-1', 'db-01');
|
||||
|
||||
expect(AlertsAPI.getIncidentsForResource).toHaveBeenCalledWith('resource-1', 10);
|
||||
expect(result.resourceIncidentPanel()).toEqual({
|
||||
resourceId: 'resource-1',
|
||||
resourceName: 'db-01',
|
||||
});
|
||||
expect(result.resourceIncidents()['resource-1']).toHaveLength(1);
|
||||
|
||||
result.setTimeFilter('24h');
|
||||
await waitFor(() => expect(AlertsAPI.getHistory).toHaveBeenCalledTimes(2));
|
||||
|
||||
await result.clearAlertHistory();
|
||||
expect(AlertsAPI.clearHistory).toHaveBeenCalledTimes(1);
|
||||
expect(result.alertHistory()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js';
|
||||
import { createMemo, For, Show } from 'solid-js';
|
||||
|
||||
import type { Alert, Incident } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { AlertsAPI } from '@/api/alerts';
|
||||
import { useWebSocket } from '@/App';
|
||||
import { IncidentEventFilters } from '@/components/Alerts/IncidentEventFilters';
|
||||
import { IncidentTimelineEventCard } from '@/components/Alerts/IncidentTimelineEventCard';
|
||||
@@ -22,15 +20,8 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/shared/Table';
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
|
||||
import { eventBus } from '@/stores/events';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { hideTooltip, showTooltip } from '@/components/shared/Tooltip';
|
||||
import {
|
||||
getAlertAdministrationClearHistoryConfirmation,
|
||||
getAlertAdministrationClearHistoryError,
|
||||
getAlertAdministrationClearHistoryLabel,
|
||||
getAlertAdministrationSectionDescription,
|
||||
getAlertAdministrationSectionTitle,
|
||||
@@ -63,7 +54,6 @@ import {
|
||||
getAlertResourceIncidentCountLabel,
|
||||
getAlertResourceIncidentEmptyState,
|
||||
getAlertResourceIncidentFilteredEventsEmptyState,
|
||||
getAlertResourceIncidentLoadFailure,
|
||||
getAlertResourceIncidentLoadingState,
|
||||
getAlertResourceIncidentPanelTitle,
|
||||
getAlertResourceIncidentRefreshLabel,
|
||||
@@ -76,18 +66,8 @@ import {
|
||||
import { getAlertSeverityDotClass } from '@/utils/alertSeverityPresentation';
|
||||
import { getTypeColumnLabel } from '@/utils/typeColumnPresentation';
|
||||
|
||||
import {
|
||||
alertTypeDisplayLabel,
|
||||
unifiedTypeToAlertDisplayType,
|
||||
} from '../helpers';
|
||||
import { useAlertIncidentTimelineState } from '../useAlertIncidentTimelineState';
|
||||
import {
|
||||
filterIncidentEvents,
|
||||
INCIDENT_EVENT_TYPES,
|
||||
summarizeIncidentEvents,
|
||||
} from '../types';
|
||||
|
||||
const MS_PER_HOUR = 60 * 60 * 1000;
|
||||
import { useAlertHistoryState } from '../useAlertHistoryState';
|
||||
import { filterIncidentEvents, summarizeIncidentEvents } from '../types';
|
||||
|
||||
export interface HistoryTabProps {
|
||||
hasAIAlertsFeature: () => boolean;
|
||||
@@ -101,737 +81,58 @@ export function HistoryTab(props: HistoryTabProps) {
|
||||
const alertFrequencySelectionPresentation = createMemo(() =>
|
||||
getAlertFrequencySelectionPresentation(),
|
||||
);
|
||||
|
||||
const [timeFilter, setTimeFilter] = usePersistentSignal<'24h' | '7d' | '30d' | 'all'>(
|
||||
'alertHistoryTimeFilter',
|
||||
'7d',
|
||||
{
|
||||
deserialize: (raw) =>
|
||||
raw === '24h' || raw === '7d' || raw === '30d' || raw === 'all' ? raw : '7d',
|
||||
},
|
||||
);
|
||||
const [severityFilter, setSeverityFilter] = usePersistentSignal<'all' | 'warning' | 'critical'>(
|
||||
'alertHistorySeverityFilter',
|
||||
'all',
|
||||
{
|
||||
deserialize: (raw) => (raw === 'warning' || raw === 'critical' ? raw : 'all'),
|
||||
},
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = createSignal('');
|
||||
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 { isMobile } = useBreakpoint();
|
||||
const [filtersOpen, setFiltersOpen] = createSignal(false);
|
||||
const {
|
||||
STORAGE_KEYS,
|
||||
timeFilter,
|
||||
setTimeFilter,
|
||||
severityFilter,
|
||||
setSeverityFilter,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
alertHistory,
|
||||
loading,
|
||||
selectedBarIndex,
|
||||
setSelectedBarIndex,
|
||||
resourceIncidentPanel,
|
||||
setResourceIncidentPanel,
|
||||
resourceIncidents,
|
||||
resourceIncidentLoading,
|
||||
expandedResourceIncidentIds,
|
||||
resourceIncidentEventFilters,
|
||||
setResourceIncidentEventFilters,
|
||||
filtersOpen,
|
||||
setFiltersOpen,
|
||||
activeFilterCount,
|
||||
incidentTimelines,
|
||||
incidentLoading,
|
||||
incidentErrors,
|
||||
expandedIncidents,
|
||||
incidentNoteDrafts,
|
||||
incidentNoteSaving,
|
||||
eventFilters: historyIncidentEventFilters,
|
||||
setEventFilters: setHistoryIncidentEventFilters,
|
||||
resetState: resetIncidentTimelineState,
|
||||
historyIncidentEventFilters,
|
||||
setHistoryIncidentEventFilters,
|
||||
loadIncidentTimeline,
|
||||
toggleIncidentTimeline,
|
||||
setIncidentNoteDraft,
|
||||
saveIncidentNote,
|
||||
} = useAlertIncidentTimelineState();
|
||||
|
||||
const activeFilterCount = createMemo(() => {
|
||||
let count = 0;
|
||||
if (timeFilter() !== '7d') count++;
|
||||
if (severityFilter() !== 'all') count++;
|
||||
return count;
|
||||
});
|
||||
|
||||
const userLocale =
|
||||
Intl.DateTimeFormat().resolvedOptions().locale ||
|
||||
(typeof navigator !== 'undefined' ? navigator.language : undefined) ||
|
||||
'en-US';
|
||||
|
||||
const buildHistoryParams = (range: string) => {
|
||||
const params: { limit?: number; startTime?: string } = {};
|
||||
const now = Date.now();
|
||||
|
||||
switch (range) {
|
||||
case '24h':
|
||||
params.limit = 2000;
|
||||
params.startTime = new Date(now - 24 * MS_PER_HOUR).toISOString();
|
||||
break;
|
||||
case '7d':
|
||||
params.limit = 10000;
|
||||
params.startTime = new Date(now - 7 * 24 * MS_PER_HOUR).toISOString();
|
||||
break;
|
||||
case '30d':
|
||||
params.limit = 10000;
|
||||
params.startTime = new Date(now - 30 * 24 * MS_PER_HOUR).toISOString();
|
||||
break;
|
||||
case 'all':
|
||||
params.limit = 0;
|
||||
break;
|
||||
default:
|
||||
params.limit = 1000;
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
let fetchRequestId = 0;
|
||||
const fetchHistory = async (range: string) => {
|
||||
const requestId = ++fetchRequestId;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const params = buildHistoryParams(range);
|
||||
const alertHistoryData = await AlertsAPI.getHistory(params);
|
||||
|
||||
if (requestId === fetchRequestId) {
|
||||
setAlertHistory(alertHistoryData);
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId === fetchRequestId) {
|
||||
logger.error('Failed to load history:', error);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === fetchRequestId) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let lastTimeFilterValue: string | null = null;
|
||||
createEffect(() => {
|
||||
const current = timeFilter();
|
||||
if (lastTimeFilterValue !== null && current !== lastTimeFilterValue) {
|
||||
setSelectedBarIndex(null);
|
||||
}
|
||||
lastTimeFilterValue = current;
|
||||
});
|
||||
|
||||
let lastSeverityFilterValue: string | null = null;
|
||||
createEffect(() => {
|
||||
const current = severityFilter();
|
||||
if (lastSeverityFilterValue !== null && current !== lastSeverityFilterValue) {
|
||||
setSelectedBarIndex(null);
|
||||
}
|
||||
lastSeverityFilterValue = current;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
void fetchHistory(timeFilter());
|
||||
|
||||
const unsubscribeOrgSwitched = eventBus.on('org_switched', () => {
|
||||
setAlertHistory([]);
|
||||
setSelectedBarIndex(null);
|
||||
setResourceIncidentPanel(null);
|
||||
setResourceIncidents({});
|
||||
setResourceIncidentLoading({});
|
||||
setExpandedResourceIncidentIds(new Set<string>());
|
||||
resetIncidentTimelineState();
|
||||
void fetchHistory(timeFilter());
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
unsubscribeOrgSwitched();
|
||||
fetchRequestId++;
|
||||
});
|
||||
});
|
||||
|
||||
let skipInitialFetchEffect = true;
|
||||
createEffect(() => {
|
||||
const range = timeFilter();
|
||||
if (skipInitialFetchEffect) {
|
||||
skipInitialFetchEffect = false;
|
||||
return;
|
||||
}
|
||||
void fetchHistory(range);
|
||||
});
|
||||
|
||||
const formatDuration = (startTime: string, endTime?: string) => {
|
||||
const start = new Date(startTime).getTime();
|
||||
const end = endTime ? new Date(endTime).getTime() : Date.now();
|
||||
const duration = end - start;
|
||||
|
||||
if (duration < 0) {
|
||||
return '0m';
|
||||
}
|
||||
|
||||
const minutes = Math.floor(duration / 60000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `${days}d ${hours % 24}h`;
|
||||
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
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 formatBucketRange = (startMs: number, endMs: number) => {
|
||||
const start = new Date(startMs);
|
||||
const end = new Date(endMs);
|
||||
|
||||
const sameDay =
|
||||
start.getFullYear() === end.getFullYear() &&
|
||||
start.getMonth() === end.getMonth() &&
|
||||
start.getDate() === end.getDate();
|
||||
|
||||
const startDay = start.toLocaleDateString(userLocale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: start.getFullYear() !== end.getFullYear() ? 'numeric' : undefined,
|
||||
});
|
||||
const endDay = end.toLocaleDateString(userLocale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const timeFormatter: Intl.DateTimeFormatOptions = {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
||||
const startTimeStr = start.toLocaleTimeString(userLocale, timeFormatter);
|
||||
const endTimeStr = end.toLocaleTimeString(userLocale, timeFormatter);
|
||||
|
||||
if (sameDay) {
|
||||
return `${startDay}, ${startTimeStr} – ${endTimeStr}`;
|
||||
}
|
||||
|
||||
return `${startDay}, ${startTimeStr} → ${endDay}, ${endTimeStr}`;
|
||||
};
|
||||
|
||||
const getResourceType = (
|
||||
resourceName: string,
|
||||
metadata?: Record<string, unknown> | undefined,
|
||||
resourceId?: string,
|
||||
) => {
|
||||
const metadataType =
|
||||
typeof metadata?.resourceType === 'string' ? (metadata.resourceType as string) : undefined;
|
||||
if (metadataType && metadataType.trim().length > 0) {
|
||||
return metadataType;
|
||||
}
|
||||
|
||||
if (resourceId) {
|
||||
const resource = props.getResource(resourceId);
|
||||
if (resource) {
|
||||
return unifiedTypeToAlertDisplayType(resource.type);
|
||||
}
|
||||
}
|
||||
|
||||
const byName = props
|
||||
.allResources()
|
||||
.find((resource) => resource.name === resourceName || resource.displayName === resourceName);
|
||||
if (byName) {
|
||||
return unifiedTypeToAlertDisplayType(byName.type);
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
type HistoryItemSource = 'alert' | 'ai';
|
||||
interface HistoryItem {
|
||||
id: string;
|
||||
source: HistoryItemSource;
|
||||
status: string;
|
||||
startTime: string;
|
||||
endTime?: string;
|
||||
duration: string;
|
||||
resourceName: string;
|
||||
resourceType: string;
|
||||
resourceId?: string;
|
||||
node?: string;
|
||||
nodeDisplayName?: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
rawAlertType?: string;
|
||||
description?: string;
|
||||
acknowledged?: boolean;
|
||||
}
|
||||
|
||||
const allHistoryData = createMemo(() => {
|
||||
const items: HistoryItem[] = [];
|
||||
|
||||
Object.values(activeAlerts || {}).forEach((alert) => {
|
||||
items.push({
|
||||
id: alert.id,
|
||||
source: 'alert',
|
||||
status: 'active',
|
||||
startTime: alert.startTime,
|
||||
duration: formatDuration(alert.startTime),
|
||||
resourceName: alert.resourceName,
|
||||
resourceType: getResourceType(alert.resourceName, alert.metadata, alert.resourceId),
|
||||
resourceId: alert.resourceId,
|
||||
node: alert.node,
|
||||
nodeDisplayName: alert.nodeDisplayName,
|
||||
severity: alert.level,
|
||||
title: alertTypeDisplayLabel(alert.type),
|
||||
rawAlertType: alert.type,
|
||||
description: alert.message,
|
||||
acknowledged: false,
|
||||
});
|
||||
});
|
||||
|
||||
const activeAlertIds = new Set(Object.keys(activeAlerts || {}));
|
||||
|
||||
alertHistory().forEach((alert) => {
|
||||
if (activeAlertIds.has(alert.id)) return;
|
||||
|
||||
items.push({
|
||||
id: alert.id,
|
||||
source: 'alert',
|
||||
status: alert.acknowledged ? 'acknowledged' : 'resolved',
|
||||
startTime: alert.startTime,
|
||||
endTime: alert.lastSeen,
|
||||
duration: formatDuration(alert.startTime, alert.lastSeen),
|
||||
resourceName: alert.resourceName,
|
||||
resourceType: getResourceType(alert.resourceName, alert.metadata, alert.resourceId),
|
||||
resourceId: alert.resourceId,
|
||||
node: alert.node,
|
||||
nodeDisplayName: alert.nodeDisplayName,
|
||||
severity: alert.level,
|
||||
title: alertTypeDisplayLabel(alert.type),
|
||||
rawAlertType: alert.type,
|
||||
description: alert.message,
|
||||
acknowledged: alert.acknowledged,
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const severityAndSearchFilteredItems = createMemo(() => {
|
||||
let filtered = allHistoryData();
|
||||
|
||||
if (severityFilter() !== 'all') {
|
||||
const currentSeverityFilter = severityFilter();
|
||||
filtered = filtered.filter((item) => item.severity === currentSeverityFilter);
|
||||
}
|
||||
|
||||
if (searchTerm()) {
|
||||
const term = searchTerm().toLowerCase();
|
||||
filtered = filtered.filter((item) => {
|
||||
const name = item.resourceName?.toLowerCase() ?? '';
|
||||
const title = item.title?.toLowerCase() ?? '';
|
||||
const description = item.description?.toLowerCase() ?? '';
|
||||
const nodeName = item.node?.toLowerCase() ?? '';
|
||||
return (
|
||||
name.includes(term) ||
|
||||
title.includes(term) ||
|
||||
description.includes(term) ||
|
||||
nodeName.includes(term)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
const alertData = createMemo(() => {
|
||||
let filtered = severityAndSearchFilteredItems();
|
||||
const currentTimeFilter = timeFilter();
|
||||
|
||||
if (selectedBarIndex() !== null) {
|
||||
const trends = alertTrends();
|
||||
const index = selectedBarIndex()!;
|
||||
const bucketStart = trends.bucketTimes[index];
|
||||
const bucketEnd = bucketStart + trends.bucketSize * MS_PER_HOUR;
|
||||
|
||||
filtered = filtered.filter((alert) => {
|
||||
const alertTime = new Date(alert.startTime).getTime();
|
||||
return alertTime >= bucketStart && alertTime < bucketEnd;
|
||||
});
|
||||
} else if (currentTimeFilter !== 'all') {
|
||||
const now = Date.now();
|
||||
const cutoffMap: Record<'24h' | '7d' | '30d', number> = {
|
||||
'24h': now - 24 * 60 * 60 * 1000,
|
||||
'7d': now - 7 * 24 * 60 * 60 * 1000,
|
||||
'30d': now - 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
const cutoff = cutoffMap[currentTimeFilter];
|
||||
|
||||
if (cutoff) {
|
||||
filtered = filtered.filter((alert) => new Date(alert.startTime).getTime() > cutoff);
|
||||
}
|
||||
}
|
||||
|
||||
return [...filtered].sort(
|
||||
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
const monthNames = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
const getDaySuffix = (day: number) => {
|
||||
if (day >= 11 && day <= 13) return 'th';
|
||||
switch (day % 10) {
|
||||
case 1:
|
||||
return 'st';
|
||||
case 2:
|
||||
return 'nd';
|
||||
case 3:
|
||||
return 'rd';
|
||||
default:
|
||||
return 'th';
|
||||
}
|
||||
};
|
||||
|
||||
const formatAlertGroupLabel = (date: Date, todayStart: number, yesterdayStart: number) => {
|
||||
const month = monthNames[date.getMonth()];
|
||||
const day = date.getDate();
|
||||
const suffix = getDaySuffix(day);
|
||||
const absoluteDate = `${month} ${day}${suffix}`;
|
||||
|
||||
if (date.getTime() === todayStart) {
|
||||
return `Today (${absoluteDate})`;
|
||||
}
|
||||
|
||||
if (date.getTime() === yesterdayStart) {
|
||||
return `Yesterday (${absoluteDate})`;
|
||||
}
|
||||
|
||||
return absoluteDate;
|
||||
};
|
||||
|
||||
type AlertHistoryRow = ReturnType<typeof alertData>[number];
|
||||
const getIncidentRowKey = (alert: AlertHistoryRow) => `${alert.id}::${alert.startTime}`;
|
||||
|
||||
const groupedAlerts = createMemo(() => {
|
||||
const now = new Date();
|
||||
const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const todayStart = todayDate.getTime();
|
||||
const yesterdayDate = new Date(todayDate);
|
||||
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||
const yesterdayStart = yesterdayDate.getTime();
|
||||
|
||||
const groups = new Map<
|
||||
number,
|
||||
{
|
||||
date: Date;
|
||||
label: string;
|
||||
fullLabel: string;
|
||||
alerts: AlertHistoryRow[];
|
||||
}
|
||||
>();
|
||||
|
||||
alertData().forEach((alert) => {
|
||||
const alertDate = new Date(alert.startTime);
|
||||
const dateOnly = new Date(alertDate.getFullYear(), alertDate.getMonth(), alertDate.getDate());
|
||||
const dateKey = dateOnly.getTime();
|
||||
|
||||
if (!groups.has(dateKey)) {
|
||||
groups.set(dateKey, {
|
||||
date: dateOnly,
|
||||
label: formatAlertGroupLabel(dateOnly, todayStart, yesterdayStart),
|
||||
fullLabel: dateOnly.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}),
|
||||
alerts: [],
|
||||
});
|
||||
}
|
||||
|
||||
groups.get(dateKey)!.alerts.push(alert);
|
||||
});
|
||||
|
||||
return Array.from(groups.values()).sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
});
|
||||
|
||||
const alertTrends = createMemo(() => {
|
||||
const now = Date.now();
|
||||
const filteredAlerts = severityAndSearchFilteredItems();
|
||||
const niceBucketSizes = [1, 2, 3, 6, 12, 24, 48, 72, 168, 336, 720, 1440];
|
||||
const maxBuckets = 30;
|
||||
|
||||
let bucketSizeHours: number;
|
||||
let computedRangeHours: number;
|
||||
let startTime: number;
|
||||
|
||||
const filter = timeFilter();
|
||||
if (filter === '24h') {
|
||||
bucketSizeHours = 1;
|
||||
computedRangeHours = 24;
|
||||
startTime = now - computedRangeHours * MS_PER_HOUR;
|
||||
} else if (filter === '7d') {
|
||||
bucketSizeHours = 6;
|
||||
computedRangeHours = 7 * 24;
|
||||
startTime = now - computedRangeHours * MS_PER_HOUR;
|
||||
} else if (filter === '30d') {
|
||||
bucketSizeHours = 24;
|
||||
computedRangeHours = 30 * 24;
|
||||
startTime = now - computedRangeHours * MS_PER_HOUR;
|
||||
} else {
|
||||
if (!filteredAlerts.length) {
|
||||
bucketSizeHours = 24;
|
||||
computedRangeHours = 24;
|
||||
startTime = now - computedRangeHours * MS_PER_HOUR;
|
||||
} else {
|
||||
const earliest = filteredAlerts.reduce((min, alert) => {
|
||||
const alertTime = new Date(alert.startTime).getTime();
|
||||
return Math.min(min, alertTime);
|
||||
}, now);
|
||||
const rawRangeHours = Math.max(1, Math.ceil((now - earliest) / MS_PER_HOUR));
|
||||
const rawBucketSize = Math.max(1, Math.ceil(rawRangeHours / maxBuckets));
|
||||
bucketSizeHours = niceBucketSizes.find((size) => size >= rawBucketSize) ?? rawBucketSize;
|
||||
computedRangeHours = Math.max(rawRangeHours, bucketSizeHours);
|
||||
const bucketsNeeded = Math.min(
|
||||
Math.max(1, Math.ceil(computedRangeHours / bucketSizeHours)),
|
||||
maxBuckets,
|
||||
);
|
||||
startTime = now - bucketsNeeded * bucketSizeHours * MS_PER_HOUR;
|
||||
}
|
||||
}
|
||||
|
||||
const bucketCount = Math.min(
|
||||
Math.max(1, Math.ceil(computedRangeHours / bucketSizeHours)),
|
||||
maxBuckets,
|
||||
);
|
||||
startTime = Math.min(startTime, now - bucketCount * bucketSizeHours * MS_PER_HOUR);
|
||||
|
||||
const buckets = new Array(bucketCount).fill(0);
|
||||
const bucketTimes = new Array(bucketCount)
|
||||
.fill(0)
|
||||
.map((_, index) => startTime + index * bucketSizeHours * MS_PER_HOUR);
|
||||
|
||||
const windowStart = startTime;
|
||||
const windowEnd = now;
|
||||
|
||||
filteredAlerts.forEach((alert) => {
|
||||
const alertTime = new Date(alert.startTime).getTime();
|
||||
if (alertTime < windowStart || alertTime > windowEnd) {
|
||||
return;
|
||||
}
|
||||
const rawIndex = Math.floor((alertTime - windowStart) / (bucketSizeHours * MS_PER_HOUR));
|
||||
const bucketIndex = Math.min(bucketCount - 1, Math.max(0, rawIndex));
|
||||
if (bucketIndex >= 0 && bucketIndex < bucketCount) {
|
||||
buckets[bucketIndex]++;
|
||||
}
|
||||
});
|
||||
|
||||
const max = Math.max(...buckets, 1);
|
||||
|
||||
return {
|
||||
buckets,
|
||||
max,
|
||||
bucketSize: bucketSizeHours,
|
||||
bucketTimes,
|
||||
rangeStart: windowStart,
|
||||
rangeHours: bucketCount * bucketSizeHours,
|
||||
};
|
||||
});
|
||||
|
||||
const bucketDurationLabel = createMemo(() => {
|
||||
const bucketHours = alertTrends().bucketSize;
|
||||
if (!Number.isFinite(bucketHours) || bucketHours <= 0) {
|
||||
return '—';
|
||||
}
|
||||
if (bucketHours % 24 === 0) {
|
||||
const days = bucketHours / 24;
|
||||
return `${days} day${days === 1 ? '' : 's'}`;
|
||||
}
|
||||
return `${bucketHours} hour${bucketHours === 1 ? '' : 's'}`;
|
||||
});
|
||||
|
||||
const formatAxisTickLabel = (
|
||||
timestamp: number,
|
||||
bucketHours: number,
|
||||
totalHours: number,
|
||||
isEnd = false,
|
||||
) => {
|
||||
if (!Number.isFinite(timestamp)) return '—';
|
||||
|
||||
if (isEnd && Math.abs(Date.now() - timestamp) < bucketHours * MS_PER_HOUR * 0.75) {
|
||||
return 'Now';
|
||||
}
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const options: Intl.DateTimeFormatOptions = {};
|
||||
|
||||
if (totalHours <= 48) {
|
||||
options.month = 'short';
|
||||
options.day = 'numeric';
|
||||
options.hour = '2-digit';
|
||||
options.minute = '2-digit';
|
||||
} else if (totalHours <= 24 * 90) {
|
||||
options.month = 'short';
|
||||
options.day = 'numeric';
|
||||
if (bucketHours <= 12 || totalHours <= 24 * 14) {
|
||||
options.hour = '2-digit';
|
||||
}
|
||||
} else {
|
||||
options.year = 'numeric';
|
||||
options.month = 'short';
|
||||
options.day = 'numeric';
|
||||
}
|
||||
|
||||
return date.toLocaleString(userLocale, options);
|
||||
};
|
||||
|
||||
const rangeSummary = createMemo(() => {
|
||||
const trends = alertTrends();
|
||||
if (!trends.bucketTimes.length || trends.bucketSize <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bucketHours = trends.bucketSize;
|
||||
const totalHours = Math.max(trends.rangeHours ?? bucketHours, bucketHours);
|
||||
const start = trends.bucketTimes[0];
|
||||
const end = start + trends.buckets.length * bucketHours * MS_PER_HOUR;
|
||||
|
||||
return {
|
||||
startLabel: formatAxisTickLabel(start, bucketHours, totalHours),
|
||||
endLabel: formatAxisTickLabel(end, bucketHours, totalHours, true),
|
||||
};
|
||||
});
|
||||
|
||||
const axisTicks = createMemo(() => {
|
||||
const trends = alertTrends();
|
||||
if (!trends.bucketTimes.length || trends.bucketSize <= 0) {
|
||||
return [] as Array<{ position: number; label: string; align: 'start' | 'center' | 'end' }>;
|
||||
}
|
||||
|
||||
const bucketHours = trends.bucketSize;
|
||||
const totalHours = Math.max(trends.rangeHours ?? bucketHours, bucketHours);
|
||||
const start = trends.bucketTimes[0];
|
||||
const totalDurationMs = Math.max(
|
||||
trends.buckets.length * bucketHours * MS_PER_HOUR,
|
||||
bucketHours * MS_PER_HOUR,
|
||||
);
|
||||
const end = start + totalDurationMs;
|
||||
|
||||
const desiredTicks = Math.min(5, trends.bucketTimes.length + 1);
|
||||
const step = Math.max(1, Math.round(trends.bucketTimes.length / Math.max(1, desiredTicks - 1)));
|
||||
const ticks: Array<{ position: number; label: string }> = [];
|
||||
|
||||
for (let index = 0; index < trends.bucketTimes.length; index += step) {
|
||||
const ts = trends.bucketTimes[index];
|
||||
const position = Math.min(1, Math.max(0, (ts - start) / (totalDurationMs || 1)));
|
||||
ticks.push({
|
||||
position,
|
||||
label: formatAxisTickLabel(ts, bucketHours, totalHours),
|
||||
});
|
||||
}
|
||||
|
||||
if (!ticks.length || ticks[0].position > 0.01) {
|
||||
ticks.unshift({
|
||||
position: 0,
|
||||
label: formatAxisTickLabel(start, bucketHours, totalHours),
|
||||
});
|
||||
} else {
|
||||
ticks[0] = {
|
||||
position: 0,
|
||||
label: formatAxisTickLabel(start, bucketHours, totalHours),
|
||||
};
|
||||
}
|
||||
|
||||
const lastTick = ticks[ticks.length - 1];
|
||||
if (!lastTick || Math.abs(lastTick.position - 1) > 0.01) {
|
||||
ticks.push({
|
||||
position: 1,
|
||||
label: formatAxisTickLabel(end, bucketHours, totalHours, true),
|
||||
});
|
||||
} else {
|
||||
ticks[ticks.length - 1] = {
|
||||
position: 1,
|
||||
label: formatAxisTickLabel(end, bucketHours, totalHours, true),
|
||||
};
|
||||
}
|
||||
|
||||
return ticks.map((tick, index, arr) => ({
|
||||
position: tick.position,
|
||||
label: tick.label,
|
||||
align: index === 0 ? 'start' : index === arr.length - 1 ? 'end' : 'center',
|
||||
}));
|
||||
});
|
||||
|
||||
const selectedBucketDetails = createMemo(() => {
|
||||
const index = selectedBarIndex();
|
||||
if (index === null) return null;
|
||||
const trends = alertTrends();
|
||||
const bucketStart = trends.bucketTimes[index];
|
||||
const bucketEnd = bucketStart + trends.bucketSize * MS_PER_HOUR;
|
||||
return {
|
||||
rangeLabel: formatBucketRange(bucketStart, bucketEnd),
|
||||
start: bucketStart,
|
||||
end: bucketEnd,
|
||||
};
|
||||
openResourceIncidentPanel,
|
||||
refreshResourceIncidentPanel,
|
||||
toggleResourceIncidentDetails,
|
||||
alertData,
|
||||
groupedAlerts,
|
||||
alertTrends,
|
||||
bucketDurationLabel,
|
||||
rangeSummary,
|
||||
axisTicks,
|
||||
selectedBucketDetails,
|
||||
formatBucketRange,
|
||||
getIncidentRowKey,
|
||||
clearAlertHistory,
|
||||
} = useAlertHistoryState({
|
||||
activeAlerts: () => activeAlerts || {},
|
||||
getResource: props.getResource,
|
||||
allResources: props.allResources,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -1528,16 +829,8 @@ export function HistoryTab(props: HistoryTabProps) {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (confirm(getAlertAdministrationClearHistoryConfirmation())) {
|
||||
try {
|
||||
await AlertsAPI.clearHistory();
|
||||
setAlertHistory([]);
|
||||
} catch (error) {
|
||||
logger.error(getAlertAdministrationClearHistoryError(), error);
|
||||
notificationStore.error(getAlertAdministrationClearHistoryError());
|
||||
}
|
||||
}
|
||||
onClick={() => {
|
||||
void clearAlertHistory();
|
||||
}}
|
||||
class="flex-shrink-0 rounded-md border border-red-300 px-3 py-2 text-xs text-red-600 transition-colors hover:bg-red-50 dark:border-red-600 dark:text-red-400 dark:hover:bg-red-900"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,799 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import type { Accessor } from 'solid-js';
|
||||
|
||||
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 { Resource } from '@/types/resource';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import { logger } from '@/utils/logger';
|
||||
import {
|
||||
getAlertAdministrationClearHistoryError,
|
||||
getAlertAdministrationClearHistoryConfirmation,
|
||||
} from '@/utils/alertAdministrationPresentation';
|
||||
import { getAlertResourceIncidentLoadFailure } from '@/utils/alertIncidentPresentation';
|
||||
|
||||
import { alertTypeDisplayLabel, unifiedTypeToAlertDisplayType } from './helpers';
|
||||
import { useAlertIncidentTimelineState } from './useAlertIncidentTimelineState';
|
||||
import { INCIDENT_EVENT_TYPES } from './types';
|
||||
|
||||
const MS_PER_HOUR = 60 * 60 * 1000;
|
||||
|
||||
type AlertHistoryRange = '24h' | '7d' | '30d' | 'all';
|
||||
type AlertSeverityFilter = 'all' | 'warning' | 'critical';
|
||||
|
||||
export interface UseAlertHistoryStateProps {
|
||||
activeAlerts: Accessor<Record<string, Alert>>;
|
||||
getResource: (resourceId: string) => Resource | undefined;
|
||||
allResources: () => Resource[];
|
||||
}
|
||||
|
||||
interface HistoryItem {
|
||||
id: string;
|
||||
source: 'alert' | 'ai';
|
||||
status: string;
|
||||
startTime: string;
|
||||
endTime?: string;
|
||||
duration: string;
|
||||
resourceName: string;
|
||||
resourceType: string;
|
||||
resourceId?: string;
|
||||
node?: string;
|
||||
nodeDisplayName?: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
rawAlertType?: string;
|
||||
description?: string;
|
||||
acknowledged?: boolean;
|
||||
}
|
||||
|
||||
export function useAlertHistoryState(props: UseAlertHistoryStateProps) {
|
||||
const [timeFilter, setTimeFilter] = usePersistentSignal<AlertHistoryRange>(
|
||||
'alertHistoryTimeFilter',
|
||||
'7d',
|
||||
{
|
||||
deserialize: (raw) =>
|
||||
raw === '24h' || raw === '7d' || raw === '30d' || raw === 'all' ? raw : '7d',
|
||||
},
|
||||
);
|
||||
const [severityFilter, setSeverityFilter] = usePersistentSignal<AlertSeverityFilter>(
|
||||
'alertHistorySeverityFilter',
|
||||
'all',
|
||||
{
|
||||
deserialize: (raw) => (raw === 'warning' || raw === 'critical' ? raw : 'all'),
|
||||
},
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = createSignal('');
|
||||
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 {
|
||||
incidentTimelines,
|
||||
incidentLoading,
|
||||
incidentErrors,
|
||||
expandedIncidents,
|
||||
incidentNoteDrafts,
|
||||
incidentNoteSaving,
|
||||
eventFilters,
|
||||
setEventFilters,
|
||||
resetState,
|
||||
loadIncidentTimeline,
|
||||
toggleIncidentTimeline,
|
||||
setIncidentNoteDraft,
|
||||
saveIncidentNote,
|
||||
} = useAlertIncidentTimelineState();
|
||||
|
||||
const activeFilterCount = createMemo(() => {
|
||||
let count = 0;
|
||||
if (timeFilter() !== '7d') count++;
|
||||
if (severityFilter() !== 'all') count++;
|
||||
return count;
|
||||
});
|
||||
|
||||
const userLocale =
|
||||
Intl.DateTimeFormat().resolvedOptions().locale ||
|
||||
(typeof navigator !== 'undefined' ? navigator.language : undefined) ||
|
||||
'en-US';
|
||||
|
||||
const buildHistoryParams = (range: AlertHistoryRange) => {
|
||||
const params: { limit?: number; startTime?: string } = {};
|
||||
const now = Date.now();
|
||||
|
||||
switch (range) {
|
||||
case '24h':
|
||||
params.limit = 2000;
|
||||
params.startTime = new Date(now - 24 * MS_PER_HOUR).toISOString();
|
||||
break;
|
||||
case '7d':
|
||||
params.limit = 10000;
|
||||
params.startTime = new Date(now - 7 * 24 * MS_PER_HOUR).toISOString();
|
||||
break;
|
||||
case '30d':
|
||||
params.limit = 10000;
|
||||
params.startTime = new Date(now - 30 * 24 * MS_PER_HOUR).toISOString();
|
||||
break;
|
||||
case 'all':
|
||||
params.limit = 0;
|
||||
break;
|
||||
default:
|
||||
params.limit = 1000;
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
let fetchRequestId = 0;
|
||||
const fetchHistory = async (range: AlertHistoryRange) => {
|
||||
const requestId = ++fetchRequestId;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const alertHistoryData = await AlertsAPI.getHistory(buildHistoryParams(range));
|
||||
if (requestId === fetchRequestId) {
|
||||
setAlertHistory(alertHistoryData);
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId === fetchRequestId) {
|
||||
logger.error('Failed to load history:', error);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === fetchRequestId) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let lastTimeFilterValue: string | null = null;
|
||||
createEffect(() => {
|
||||
const current = timeFilter();
|
||||
if (lastTimeFilterValue !== null && current !== lastTimeFilterValue) {
|
||||
setSelectedBarIndex(null);
|
||||
}
|
||||
lastTimeFilterValue = current;
|
||||
});
|
||||
|
||||
let lastSeverityFilterValue: string | null = null;
|
||||
createEffect(() => {
|
||||
const current = severityFilter();
|
||||
if (lastSeverityFilterValue !== null && current !== lastSeverityFilterValue) {
|
||||
setSelectedBarIndex(null);
|
||||
}
|
||||
lastSeverityFilterValue = current;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
void fetchHistory(timeFilter());
|
||||
|
||||
const unsubscribeOrgSwitched = eventBus.on('org_switched', () => {
|
||||
setAlertHistory([]);
|
||||
setSelectedBarIndex(null);
|
||||
setResourceIncidentPanel(null);
|
||||
setResourceIncidents({});
|
||||
setResourceIncidentLoading({});
|
||||
setExpandedResourceIncidentIds(new Set<string>());
|
||||
resetState();
|
||||
void fetchHistory(timeFilter());
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
unsubscribeOrgSwitched();
|
||||
fetchRequestId++;
|
||||
});
|
||||
});
|
||||
|
||||
let skipInitialFetchEffect = true;
|
||||
createEffect(() => {
|
||||
const range = timeFilter();
|
||||
if (skipInitialFetchEffect) {
|
||||
skipInitialFetchEffect = false;
|
||||
return;
|
||||
}
|
||||
void fetchHistory(range);
|
||||
});
|
||||
|
||||
const formatDuration = (startTime: string, endTime?: string) => {
|
||||
const start = new Date(startTime).getTime();
|
||||
const end = endTime ? new Date(endTime).getTime() : Date.now();
|
||||
const duration = end - start;
|
||||
|
||||
if (duration < 0) return '0m';
|
||||
|
||||
const minutes = Math.floor(duration / 60000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `${days}d ${hours % 24}h`;
|
||||
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
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 formatBucketRange = (startMs: number, endMs: number) => {
|
||||
const start = new Date(startMs);
|
||||
const end = new Date(endMs);
|
||||
const sameDay =
|
||||
start.getFullYear() === end.getFullYear() &&
|
||||
start.getMonth() === end.getMonth() &&
|
||||
start.getDate() === end.getDate();
|
||||
|
||||
const startDay = start.toLocaleDateString(userLocale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: start.getFullYear() !== end.getFullYear() ? 'numeric' : undefined,
|
||||
});
|
||||
const endDay = end.toLocaleDateString(userLocale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
const timeFormatter: Intl.DateTimeFormatOptions = {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
||||
const startTimeStr = start.toLocaleTimeString(userLocale, timeFormatter);
|
||||
const endTimeStr = end.toLocaleTimeString(userLocale, timeFormatter);
|
||||
|
||||
if (sameDay) {
|
||||
return `${startDay}, ${startTimeStr} – ${endTimeStr}`;
|
||||
}
|
||||
|
||||
return `${startDay}, ${startTimeStr} → ${endDay}, ${endTimeStr}`;
|
||||
};
|
||||
|
||||
const getResourceType = (
|
||||
resourceName: string,
|
||||
metadata?: Record<string, unknown> | undefined,
|
||||
resourceId?: string,
|
||||
) => {
|
||||
const metadataType =
|
||||
typeof metadata?.resourceType === 'string' ? (metadata.resourceType as string) : undefined;
|
||||
if (metadataType && metadataType.trim().length > 0) {
|
||||
return metadataType;
|
||||
}
|
||||
|
||||
if (resourceId) {
|
||||
const resource = props.getResource(resourceId);
|
||||
if (resource) {
|
||||
return unifiedTypeToAlertDisplayType(resource.type);
|
||||
}
|
||||
}
|
||||
|
||||
const byName = props
|
||||
.allResources()
|
||||
.find((resource) => resource.name === resourceName || resource.displayName === resourceName);
|
||||
if (byName) {
|
||||
return unifiedTypeToAlertDisplayType(byName.type);
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
const allHistoryData = createMemo<HistoryItem[]>(() => {
|
||||
const items: HistoryItem[] = [];
|
||||
const activeAlerts = props.activeAlerts() || {};
|
||||
|
||||
Object.values(activeAlerts).forEach((alert) => {
|
||||
items.push({
|
||||
id: alert.id,
|
||||
source: 'alert',
|
||||
status: 'active',
|
||||
startTime: alert.startTime,
|
||||
duration: formatDuration(alert.startTime),
|
||||
resourceName: alert.resourceName,
|
||||
resourceType: getResourceType(alert.resourceName, alert.metadata, alert.resourceId),
|
||||
resourceId: alert.resourceId,
|
||||
node: alert.node,
|
||||
nodeDisplayName: alert.nodeDisplayName,
|
||||
severity: alert.level,
|
||||
title: alertTypeDisplayLabel(alert.type),
|
||||
rawAlertType: alert.type,
|
||||
description: alert.message,
|
||||
acknowledged: false,
|
||||
});
|
||||
});
|
||||
|
||||
const activeAlertIds = new Set(Object.keys(activeAlerts));
|
||||
alertHistory().forEach((alert) => {
|
||||
if (activeAlertIds.has(alert.id)) return;
|
||||
|
||||
items.push({
|
||||
id: alert.id,
|
||||
source: 'alert',
|
||||
status: alert.acknowledged ? 'acknowledged' : 'resolved',
|
||||
startTime: alert.startTime,
|
||||
endTime: alert.lastSeen,
|
||||
duration: formatDuration(alert.startTime, alert.lastSeen),
|
||||
resourceName: alert.resourceName,
|
||||
resourceType: getResourceType(alert.resourceName, alert.metadata, alert.resourceId),
|
||||
resourceId: alert.resourceId,
|
||||
node: alert.node,
|
||||
nodeDisplayName: alert.nodeDisplayName,
|
||||
severity: alert.level,
|
||||
title: alertTypeDisplayLabel(alert.type),
|
||||
rawAlertType: alert.type,
|
||||
description: alert.message,
|
||||
acknowledged: alert.acknowledged,
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const severityAndSearchFilteredItems = createMemo(() => {
|
||||
let filtered = allHistoryData();
|
||||
|
||||
if (severityFilter() !== 'all') {
|
||||
filtered = filtered.filter((item) => item.severity === severityFilter());
|
||||
}
|
||||
|
||||
if (searchTerm()) {
|
||||
const term = searchTerm().toLowerCase();
|
||||
filtered = filtered.filter((item) => {
|
||||
const name = item.resourceName?.toLowerCase() ?? '';
|
||||
const title = item.title?.toLowerCase() ?? '';
|
||||
const description = item.description?.toLowerCase() ?? '';
|
||||
const nodeName = item.node?.toLowerCase() ?? '';
|
||||
return (
|
||||
name.includes(term) ||
|
||||
title.includes(term) ||
|
||||
description.includes(term) ||
|
||||
nodeName.includes(term)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
const alertData = createMemo(() => {
|
||||
let filtered = severityAndSearchFilteredItems();
|
||||
const currentTimeFilter = timeFilter();
|
||||
|
||||
if (selectedBarIndex() !== null) {
|
||||
const trends = alertTrends();
|
||||
const index = selectedBarIndex()!;
|
||||
const bucketStart = trends.bucketTimes[index];
|
||||
const bucketEnd = bucketStart + trends.bucketSize * MS_PER_HOUR;
|
||||
|
||||
filtered = filtered.filter((alert) => {
|
||||
const alertTime = new Date(alert.startTime).getTime();
|
||||
return alertTime >= bucketStart && alertTime < bucketEnd;
|
||||
});
|
||||
} else if (currentTimeFilter !== 'all') {
|
||||
const now = Date.now();
|
||||
const cutoffMap: Record<'24h' | '7d' | '30d', number> = {
|
||||
'24h': now - 24 * 60 * 60 * 1000,
|
||||
'7d': now - 7 * 24 * 60 * 60 * 1000,
|
||||
'30d': now - 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
const cutoff = cutoffMap[currentTimeFilter];
|
||||
if (cutoff) {
|
||||
filtered = filtered.filter((alert) => new Date(alert.startTime).getTime() > cutoff);
|
||||
}
|
||||
}
|
||||
|
||||
return [...filtered].sort(
|
||||
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
const monthNames = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
const getDaySuffix = (day: number) => {
|
||||
if (day >= 11 && day <= 13) return 'th';
|
||||
switch (day % 10) {
|
||||
case 1:
|
||||
return 'st';
|
||||
case 2:
|
||||
return 'nd';
|
||||
case 3:
|
||||
return 'rd';
|
||||
default:
|
||||
return 'th';
|
||||
}
|
||||
};
|
||||
|
||||
const formatAlertGroupLabel = (date: Date, todayStart: number, yesterdayStart: number) => {
|
||||
const month = monthNames[date.getMonth()];
|
||||
const day = date.getDate();
|
||||
const suffix = getDaySuffix(day);
|
||||
const absoluteDate = `${month} ${day}${suffix}`;
|
||||
|
||||
if (date.getTime() === todayStart) return `Today (${absoluteDate})`;
|
||||
if (date.getTime() === yesterdayStart) return `Yesterday (${absoluteDate})`;
|
||||
return absoluteDate;
|
||||
};
|
||||
|
||||
const getIncidentRowKey = (alert: HistoryItem) => `${alert.id}::${alert.startTime}`;
|
||||
|
||||
const groupedAlerts = createMemo(() => {
|
||||
const now = new Date();
|
||||
const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const todayStart = todayDate.getTime();
|
||||
const yesterdayDate = new Date(todayDate);
|
||||
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||
const yesterdayStart = yesterdayDate.getTime();
|
||||
|
||||
const groups = new Map<
|
||||
number,
|
||||
{
|
||||
date: Date;
|
||||
label: string;
|
||||
fullLabel: string;
|
||||
alerts: HistoryItem[];
|
||||
}
|
||||
>();
|
||||
|
||||
alertData().forEach((alert) => {
|
||||
const alertDate = new Date(alert.startTime);
|
||||
const dateOnly = new Date(alertDate.getFullYear(), alertDate.getMonth(), alertDate.getDate());
|
||||
const dateKey = dateOnly.getTime();
|
||||
|
||||
if (!groups.has(dateKey)) {
|
||||
groups.set(dateKey, {
|
||||
date: dateOnly,
|
||||
label: formatAlertGroupLabel(dateOnly, todayStart, yesterdayStart),
|
||||
fullLabel: dateOnly.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}),
|
||||
alerts: [],
|
||||
});
|
||||
}
|
||||
|
||||
groups.get(dateKey)!.alerts.push(alert);
|
||||
});
|
||||
|
||||
return Array.from(groups.values()).sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
});
|
||||
|
||||
const alertTrends = createMemo(() => {
|
||||
const now = Date.now();
|
||||
const filteredAlerts = severityAndSearchFilteredItems();
|
||||
const niceBucketSizes = [1, 2, 3, 6, 12, 24, 48, 72, 168, 336, 720, 1440];
|
||||
const maxBuckets = 30;
|
||||
|
||||
let bucketSizeHours: number;
|
||||
let computedRangeHours: number;
|
||||
let startTime: number;
|
||||
|
||||
const filter = timeFilter();
|
||||
if (filter === '24h') {
|
||||
bucketSizeHours = 1;
|
||||
computedRangeHours = 24;
|
||||
startTime = now - computedRangeHours * MS_PER_HOUR;
|
||||
} else if (filter === '7d') {
|
||||
bucketSizeHours = 6;
|
||||
computedRangeHours = 7 * 24;
|
||||
startTime = now - computedRangeHours * MS_PER_HOUR;
|
||||
} else if (filter === '30d') {
|
||||
bucketSizeHours = 24;
|
||||
computedRangeHours = 30 * 24;
|
||||
startTime = now - computedRangeHours * MS_PER_HOUR;
|
||||
} else if (!filteredAlerts.length) {
|
||||
bucketSizeHours = 24;
|
||||
computedRangeHours = 24;
|
||||
startTime = now - computedRangeHours * MS_PER_HOUR;
|
||||
} else {
|
||||
const earliest = filteredAlerts.reduce((min, alert) => {
|
||||
const alertTime = new Date(alert.startTime).getTime();
|
||||
return Math.min(min, alertTime);
|
||||
}, now);
|
||||
const rawRangeHours = Math.max(1, Math.ceil((now - earliest) / MS_PER_HOUR));
|
||||
const rawBucketSize = Math.max(1, Math.ceil(rawRangeHours / maxBuckets));
|
||||
bucketSizeHours = niceBucketSizes.find((size) => size >= rawBucketSize) ?? rawBucketSize;
|
||||
computedRangeHours = Math.max(rawRangeHours, bucketSizeHours);
|
||||
const bucketsNeeded = Math.min(
|
||||
Math.max(1, Math.ceil(computedRangeHours / bucketSizeHours)),
|
||||
maxBuckets,
|
||||
);
|
||||
startTime = now - bucketsNeeded * bucketSizeHours * MS_PER_HOUR;
|
||||
}
|
||||
|
||||
const bucketCount = Math.min(
|
||||
Math.max(1, Math.ceil(computedRangeHours / bucketSizeHours)),
|
||||
maxBuckets,
|
||||
);
|
||||
startTime = Math.min(startTime, now - bucketCount * bucketSizeHours * MS_PER_HOUR);
|
||||
|
||||
const buckets = new Array(bucketCount).fill(0);
|
||||
const bucketTimes = new Array(bucketCount)
|
||||
.fill(0)
|
||||
.map((_, index) => startTime + index * bucketSizeHours * MS_PER_HOUR);
|
||||
|
||||
const windowStart = startTime;
|
||||
const windowEnd = now;
|
||||
|
||||
filteredAlerts.forEach((alert) => {
|
||||
const alertTime = new Date(alert.startTime).getTime();
|
||||
if (alertTime < windowStart || alertTime > windowEnd) return;
|
||||
|
||||
const rawIndex = Math.floor((alertTime - windowStart) / (bucketSizeHours * MS_PER_HOUR));
|
||||
const bucketIndex = Math.min(bucketCount - 1, Math.max(0, rawIndex));
|
||||
if (bucketIndex >= 0 && bucketIndex < bucketCount) {
|
||||
buckets[bucketIndex]++;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
buckets,
|
||||
max: Math.max(...buckets, 1),
|
||||
bucketSize: bucketSizeHours,
|
||||
bucketTimes,
|
||||
rangeStart: windowStart,
|
||||
rangeHours: bucketCount * bucketSizeHours,
|
||||
};
|
||||
});
|
||||
|
||||
const bucketDurationLabel = createMemo(() => {
|
||||
const bucketHours = alertTrends().bucketSize;
|
||||
if (!Number.isFinite(bucketHours) || bucketHours <= 0) return '—';
|
||||
if (bucketHours % 24 === 0) {
|
||||
const days = bucketHours / 24;
|
||||
return `${days} day${days === 1 ? '' : 's'}`;
|
||||
}
|
||||
return `${bucketHours} hour${bucketHours === 1 ? '' : 's'}`;
|
||||
});
|
||||
|
||||
const formatAxisTickLabel = (
|
||||
timestamp: number,
|
||||
bucketHours: number,
|
||||
totalHours: number,
|
||||
isEnd = false,
|
||||
) => {
|
||||
if (!Number.isFinite(timestamp)) return '—';
|
||||
|
||||
if (isEnd && Math.abs(Date.now() - timestamp) < bucketHours * MS_PER_HOUR * 0.75) {
|
||||
return 'Now';
|
||||
}
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const options: Intl.DateTimeFormatOptions = {};
|
||||
|
||||
if (totalHours <= 48) {
|
||||
options.month = 'short';
|
||||
options.day = 'numeric';
|
||||
options.hour = '2-digit';
|
||||
options.minute = '2-digit';
|
||||
} else if (totalHours <= 24 * 90) {
|
||||
options.month = 'short';
|
||||
options.day = 'numeric';
|
||||
if (bucketHours <= 12 || totalHours <= 24 * 14) {
|
||||
options.hour = '2-digit';
|
||||
}
|
||||
} else {
|
||||
options.year = 'numeric';
|
||||
options.month = 'short';
|
||||
options.day = 'numeric';
|
||||
}
|
||||
|
||||
return date.toLocaleString(userLocale, options);
|
||||
};
|
||||
|
||||
const rangeSummary = createMemo(() => {
|
||||
const trends = alertTrends();
|
||||
if (!trends.bucketTimes.length || trends.bucketSize <= 0) return null;
|
||||
|
||||
const bucketHours = trends.bucketSize;
|
||||
const totalHours = Math.max(trends.rangeHours ?? bucketHours, bucketHours);
|
||||
const start = trends.bucketTimes[0];
|
||||
const end = start + trends.buckets.length * bucketHours * MS_PER_HOUR;
|
||||
|
||||
return {
|
||||
startLabel: formatAxisTickLabel(start, bucketHours, totalHours),
|
||||
endLabel: formatAxisTickLabel(end, bucketHours, totalHours, true),
|
||||
};
|
||||
});
|
||||
|
||||
const axisTicks = createMemo(() => {
|
||||
const trends = alertTrends();
|
||||
if (!trends.bucketTimes.length || trends.bucketSize <= 0) {
|
||||
return [] as Array<{ position: number; label: string; align: 'start' | 'center' | 'end' }>;
|
||||
}
|
||||
|
||||
const bucketHours = trends.bucketSize;
|
||||
const totalHours = Math.max(trends.rangeHours ?? bucketHours, bucketHours);
|
||||
const start = trends.bucketTimes[0];
|
||||
const totalDurationMs = Math.max(
|
||||
trends.buckets.length * bucketHours * MS_PER_HOUR,
|
||||
bucketHours * MS_PER_HOUR,
|
||||
);
|
||||
const end = start + totalDurationMs;
|
||||
const desiredTicks = Math.min(5, trends.bucketTimes.length + 1);
|
||||
const step = Math.max(1, Math.round(trends.bucketTimes.length / Math.max(1, desiredTicks - 1)));
|
||||
const ticks: Array<{ position: number; label: string }> = [];
|
||||
|
||||
for (let index = 0; index < trends.bucketTimes.length; index += step) {
|
||||
const ts = trends.bucketTimes[index];
|
||||
const position = Math.min(1, Math.max(0, (ts - start) / (totalDurationMs || 1)));
|
||||
ticks.push({
|
||||
position,
|
||||
label: formatAxisTickLabel(ts, bucketHours, totalHours),
|
||||
});
|
||||
}
|
||||
|
||||
if (!ticks.length || ticks[0].position > 0.01) {
|
||||
ticks.unshift({
|
||||
position: 0,
|
||||
label: formatAxisTickLabel(start, bucketHours, totalHours),
|
||||
});
|
||||
} else {
|
||||
ticks[0] = {
|
||||
position: 0,
|
||||
label: formatAxisTickLabel(start, bucketHours, totalHours),
|
||||
};
|
||||
}
|
||||
|
||||
const lastTick = ticks[ticks.length - 1];
|
||||
if (!lastTick || Math.abs(lastTick.position - 1) > 0.01) {
|
||||
ticks.push({
|
||||
position: 1,
|
||||
label: formatAxisTickLabel(end, bucketHours, totalHours, true),
|
||||
});
|
||||
} else {
|
||||
ticks[ticks.length - 1] = {
|
||||
position: 1,
|
||||
label: formatAxisTickLabel(end, bucketHours, totalHours, true),
|
||||
};
|
||||
}
|
||||
|
||||
return ticks.map((tick, index, arr) => ({
|
||||
position: tick.position,
|
||||
label: tick.label,
|
||||
align: index === 0 ? 'start' : index === arr.length - 1 ? 'end' : 'center',
|
||||
}));
|
||||
});
|
||||
|
||||
const selectedBucketDetails = createMemo(() => {
|
||||
const index = selectedBarIndex();
|
||||
if (index === null) return null;
|
||||
|
||||
const trends = alertTrends();
|
||||
const bucketStart = trends.bucketTimes[index];
|
||||
const bucketEnd = bucketStart + trends.bucketSize * MS_PER_HOUR;
|
||||
return {
|
||||
rangeLabel: formatBucketRange(bucketStart, bucketEnd),
|
||||
start: bucketStart,
|
||||
end: bucketEnd,
|
||||
};
|
||||
});
|
||||
|
||||
const clearAlertHistory = async () => {
|
||||
if (!confirm(getAlertAdministrationClearHistoryConfirmation())) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await AlertsAPI.clearHistory();
|
||||
setAlertHistory([]);
|
||||
} catch (error) {
|
||||
logger.error(getAlertAdministrationClearHistoryError(), error);
|
||||
notificationStore.error(getAlertAdministrationClearHistoryError());
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
STORAGE_KEYS,
|
||||
timeFilter,
|
||||
setTimeFilter,
|
||||
severityFilter,
|
||||
setSeverityFilter,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
alertHistory,
|
||||
loading,
|
||||
selectedBarIndex,
|
||||
setSelectedBarIndex,
|
||||
resourceIncidentPanel,
|
||||
setResourceIncidentPanel,
|
||||
resourceIncidents,
|
||||
resourceIncidentLoading,
|
||||
expandedResourceIncidentIds,
|
||||
resourceIncidentEventFilters,
|
||||
setResourceIncidentEventFilters,
|
||||
filtersOpen,
|
||||
setFiltersOpen,
|
||||
activeFilterCount,
|
||||
incidentTimelines,
|
||||
incidentLoading,
|
||||
incidentErrors,
|
||||
expandedIncidents,
|
||||
incidentNoteDrafts,
|
||||
incidentNoteSaving,
|
||||
historyIncidentEventFilters: eventFilters,
|
||||
setHistoryIncidentEventFilters: setEventFilters,
|
||||
loadIncidentTimeline,
|
||||
toggleIncidentTimeline,
|
||||
setIncidentNoteDraft,
|
||||
saveIncidentNote,
|
||||
openResourceIncidentPanel,
|
||||
refreshResourceIncidentPanel,
|
||||
toggleResourceIncidentDetails,
|
||||
alertData,
|
||||
groupedAlerts,
|
||||
alertTrends,
|
||||
bucketDurationLabel,
|
||||
rangeSummary,
|
||||
axisTicks,
|
||||
selectedBucketDetails,
|
||||
formatBucketRange,
|
||||
getIncidentRowKey,
|
||||
clearAlertHistory,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import alertsPageSource from '@/pages/Alerts.tsx?raw';
|
||||
import alertsConfigurationSurfaceSource from '@/features/alerts/AlertsConfigurationSurface.tsx?raw';
|
||||
import alertsConfigurationStateSource from '@/features/alerts/useAlertsConfigurationState.ts?raw';
|
||||
import alertDestinationsStateSource from '@/features/alerts/useAlertDestinationsState.ts?raw';
|
||||
import alertHistoryStateSource from '@/features/alerts/useAlertHistoryState.ts?raw';
|
||||
import alertIncidentTimelineStateSource from '@/features/alerts/useAlertIncidentTimelineState.ts?raw';
|
||||
import alertDestinationsTabSource from '@/features/alerts/tabs/DestinationsTab.tsx?raw';
|
||||
import alertHistoryTabSource from '@/features/alerts/tabs/HistoryTab.tsx?raw';
|
||||
@@ -197,14 +198,24 @@ describe('tab path helpers', () => {
|
||||
"import { HistoryTab } from '@/features/alerts/tabs/HistoryTab';",
|
||||
);
|
||||
expect(alertsPageSource).not.toContain('function HistoryTab(');
|
||||
expect(alertHistoryTabSource).toContain('useAlertIncidentTimelineState');
|
||||
expect(alertHistoryTabSource).toContain('useAlertHistoryState');
|
||||
expect(alertDestinationsTabSource).toContain('NotificationsAPI.getWebhooks');
|
||||
expect(alertHistoryTabSource).toContain('AlertsAPI.getHistory');
|
||||
expect(alertHistoryTabSource).toContain('IncidentTimelinePanel');
|
||||
expect(alertHistoryTabSource).not.toContain('useAlertIncidentTimelineState');
|
||||
expect(alertHistoryTabSource).not.toContain('AlertsAPI.getHistory');
|
||||
expect(alertHistoryTabSource).not.toContain('AlertsAPI.getIncidentsForResource');
|
||||
expect(alertHistoryTabSource).not.toContain('AlertsAPI.clearHistory');
|
||||
expect(alertHistoryTabSource).not.toContain('AlertsAPI.getIncidentTimeline');
|
||||
expect(alertHistoryTabSource).not.toContain('AlertsAPI.addIncidentNote');
|
||||
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(alertHistoryStateSource).toContain('export function useAlertHistoryState');
|
||||
expect(alertHistoryStateSource).toContain('AlertsAPI.getHistory');
|
||||
expect(alertHistoryStateSource).toContain('AlertsAPI.getIncidentsForResource');
|
||||
expect(alertHistoryStateSource).toContain('AlertsAPI.clearHistory');
|
||||
expect(alertHistoryStateSource).toContain('useAlertIncidentTimelineState');
|
||||
expect(alertIncidentTimelineStateSource).toContain(
|
||||
'export function useAlertIncidentTimelineState',
|
||||
);
|
||||
|
||||
@@ -327,6 +327,7 @@ import alertOverviewTabSource from '@/features/alerts/OverviewTab.tsx?raw';
|
||||
import alertsConfigurationSurfaceSource from '@/features/alerts/AlertsConfigurationSurface.tsx?raw';
|
||||
import alertsConfigurationStateSource from '@/features/alerts/useAlertsConfigurationState.ts?raw';
|
||||
import alertDestinationsStateSource from '@/features/alerts/useAlertDestinationsState.ts?raw';
|
||||
import alertHistoryStateSource from '@/features/alerts/useAlertHistoryState.ts?raw';
|
||||
import alertIncidentTimelineStateSource from '@/features/alerts/useAlertIncidentTimelineState.ts?raw';
|
||||
import alertDestinationsTabSource from '@/features/alerts/tabs/DestinationsTab.tsx?raw';
|
||||
import alertHistoryTabSource from '@/features/alerts/tabs/HistoryTab.tsx?raw';
|
||||
@@ -2791,7 +2792,7 @@ describe('frontend resource type boundaries', () => {
|
||||
);
|
||||
expect(alertsPageSource).not.toContain('function HistoryTab(');
|
||||
expect(alertOverviewTabSource).toContain('useAlertIncidentTimelineState');
|
||||
expect(alertHistoryTabSource).toContain('useAlertIncidentTimelineState');
|
||||
expect(alertHistoryTabSource).toContain('useAlertHistoryState');
|
||||
expect(alertHistoryTabSource).toContain('getAlertHistoryStatusPresentation');
|
||||
expect(alertHistoryTabSource).toContain('getAlertHistorySourcePresentation');
|
||||
expect(alertHistoryTabSource).toContain('getAlertHistoryResourceTypeBadgeClass');
|
||||
@@ -2823,10 +2824,15 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(alertHistoryTabSource).toContain('getAlertAdministrationSectionTitle');
|
||||
expect(alertHistoryTabSource).toContain('getAlertAdministrationSectionDescription');
|
||||
expect(alertHistoryTabSource).toContain('getAlertAdministrationClearHistoryLabel');
|
||||
expect(alertHistoryTabSource).toContain('getAlertAdministrationClearHistoryError');
|
||||
expect(alertHistoryTabSource).toContain('getAlertAdministrationClearHistoryConfirmation');
|
||||
expect(alertHistoryTabSource).not.toContain('getAlertAdministrationClearHistoryError');
|
||||
expect(alertHistoryTabSource).not.toContain('getAlertAdministrationClearHistoryConfirmation');
|
||||
expect(alertHistoryTabSource).not.toContain('useAlertIncidentTimelineState');
|
||||
expect(alertHistoryTabSource).not.toContain('AlertsAPI.getHistory');
|
||||
expect(alertHistoryTabSource).not.toContain('AlertsAPI.getIncidentsForResource');
|
||||
expect(alertHistoryTabSource).not.toContain('AlertsAPI.clearHistory');
|
||||
expect(alertHistoryTabSource).not.toContain('AlertsAPI.getIncidentTimeline');
|
||||
expect(alertHistoryTabSource).not.toContain('AlertsAPI.addIncidentNote');
|
||||
expect(alertHistoryTabSource).not.toContain('usePersistentSignal(');
|
||||
expect(alertsPageSource).toContain('getAlertActivationPresentation');
|
||||
expect(alertsPageSource).toContain('getAlertActivationSuccess');
|
||||
expect(alertsPageSource).toContain('getAlertActivationFailure');
|
||||
@@ -2835,6 +2841,11 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(alertHistoryTabSource).toContain('getAlertFrequencySelectionPresentation');
|
||||
expect(alertHistoryTabSource).toContain('getAlertFrequencyClearFilterButtonClass');
|
||||
expect(alertHistoryTabSource).toContain('getAlertSeverityDotClass');
|
||||
expect(alertHistoryStateSource).toContain('export function useAlertHistoryState');
|
||||
expect(alertHistoryStateSource).toContain('AlertsAPI.getHistory');
|
||||
expect(alertHistoryStateSource).toContain('AlertsAPI.getIncidentsForResource');
|
||||
expect(alertHistoryStateSource).toContain('AlertsAPI.clearHistory');
|
||||
expect(alertHistoryStateSource).toContain('useAlertIncidentTimelineState');
|
||||
expect(alertsPageSource).toContain('getAlertsSidebarTabClass');
|
||||
expect(alertsPageSource).toContain('getAlertsMobileTabClass');
|
||||
expect(alertsPageSource).toContain('getAlertsTabTitle');
|
||||
|
||||
Reference in New Issue
Block a user