mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-08 05:41:31 +00:00
Deduplicate alert incident timeline UI
This commit is contained in:
@@ -119,6 +119,13 @@ route through `frontend-modern/src/utils/alertIncidentPresentation.ts`
|
||||
instead of keeping duplicate timeline card structure inline in the alerts
|
||||
page and overview timelines.
|
||||
|
||||
Expanded alert incident detail now also routes through
|
||||
`frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx` and
|
||||
`frontend-modern/src/components/Alerts/IncidentEventFilters.tsx` so the
|
||||
overview surface and the history table share the same loading/error states,
|
||||
canonical timeline meta row, note editor, and event-filter controls instead
|
||||
of maintaining two independent incident-detail implementations.
|
||||
|
||||
Resource incident panel cards, summary rows, and toggle-button presentation
|
||||
now also route through `frontend-modern/src/utils/alertIncidentPresentation.ts`
|
||||
instead of remaining inline inside `frontend-modern/src/pages/Alerts.tsx`.
|
||||
|
||||
@@ -144,6 +144,12 @@ Alert incident timeline event card structure must also route through
|
||||
alerts page and overview timeline share one canonical event-card renderer
|
||||
instead of reimplementing the same summary/detail/output block twice.
|
||||
|
||||
The full expanded alert incident detail panel and event-filter controls must
|
||||
also route through `frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx`
|
||||
and `frontend-modern/src/components/Alerts/IncidentEventFilters.tsx` rather
|
||||
than rebuilding loading/error copy, filter controls, note-editor wiring, or
|
||||
event-card composition separately inside the alerts page and overview tab.
|
||||
|
||||
Resource incident panel card and summary-row presentation must also route
|
||||
through `frontend-modern/src/utils/alertIncidentPresentation.ts` instead of
|
||||
maintaining page-local incident panel styling inside
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { For, type Accessor } from 'solid-js';
|
||||
import { INCIDENT_EVENT_LABELS, INCIDENT_EVENT_TYPES } from '@/features/alerts/types';
|
||||
import {
|
||||
getAlertIncidentEventFilterActionButtonClass,
|
||||
getAlertIncidentEventFilterChipClass,
|
||||
getAlertIncidentEventFilterContainerClass,
|
||||
getAlertIncidentEventFilterLabelClass,
|
||||
type AlertIncidentEventFilterVariant,
|
||||
} from '@/utils/alertIncidentPresentation';
|
||||
|
||||
export interface IncidentEventFiltersProps {
|
||||
filters: Accessor<Set<string>>;
|
||||
setFilters: (next: Set<string>) => void;
|
||||
variant: AlertIncidentEventFilterVariant;
|
||||
showQuickSelection?: boolean;
|
||||
}
|
||||
|
||||
export function IncidentEventFilters(props: IncidentEventFiltersProps) {
|
||||
const toggleFilter = (type: (typeof INCIDENT_EVENT_TYPES)[number]) => {
|
||||
const next = new Set(props.filters());
|
||||
if (next.has(type)) {
|
||||
next.delete(type);
|
||||
} else {
|
||||
next.add(type);
|
||||
}
|
||||
props.setFilters(next);
|
||||
};
|
||||
|
||||
const label = () => (props.variant === 'panel' ? 'Filter events:' : 'Filters');
|
||||
|
||||
return (
|
||||
<div class={getAlertIncidentEventFilterContainerClass(props.variant)}>
|
||||
<span class={getAlertIncidentEventFilterLabelClass(props.variant)}>{label()}</span>
|
||||
<For each={props.showQuickSelection ? ['All', 'None'] : []}>
|
||||
{(action) => (
|
||||
<button
|
||||
type="button"
|
||||
class={getAlertIncidentEventFilterActionButtonClass()}
|
||||
onClick={() =>
|
||||
props.setFilters(action === 'All' ? new Set(INCIDENT_EVENT_TYPES) : new Set())
|
||||
}
|
||||
>
|
||||
{action}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<For each={INCIDENT_EVENT_TYPES}>
|
||||
{(type) => {
|
||||
const selected = () => props.filters().has(type);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={getAlertIncidentEventFilterChipClass(selected(), props.variant)}
|
||||
onClick={() => toggleFilter(type)}
|
||||
>
|
||||
{INCIDENT_EVENT_LABELS[type]}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Show, For, createMemo, type Accessor } from 'solid-js';
|
||||
import type { Incident } from '@/types/api';
|
||||
import { filterIncidentEvents } from '@/features/alerts/types';
|
||||
import { IncidentEventFilters } from '@/components/Alerts/IncidentEventFilters';
|
||||
import { IncidentTimelineEventCard } from '@/components/Alerts/IncidentTimelineEventCard';
|
||||
import {
|
||||
getAlertTimelineEmptyState,
|
||||
getAlertTimelineFailureState,
|
||||
getAlertTimelineFilterEmptyState,
|
||||
getAlertTimelineLoadingState,
|
||||
getAlertTimelineUnavailableState,
|
||||
} from '@/utils/alertOverviewPresentation';
|
||||
import {
|
||||
type AlertIncidentEventFilterVariant,
|
||||
getAlertIncidentAcknowledgedBadgeClass,
|
||||
getAlertIncidentNoteSaveButtonClass,
|
||||
getAlertIncidentNoteTextareaClass,
|
||||
getAlertIncidentTimelineHeadingClass,
|
||||
getAlertIncidentTimelineMetaRowClass,
|
||||
getAlertResourceIncidentNotePlaceholder,
|
||||
getAlertResourceIncidentSaveNoteLabel,
|
||||
} from '@/utils/alertIncidentPresentation';
|
||||
|
||||
export interface IncidentTimelinePanelProps {
|
||||
timeline?: Incident | null;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
filters: Accessor<Set<string>>;
|
||||
setFilters: (next: Set<string>) => void;
|
||||
filterVariant: AlertIncidentEventFilterVariant;
|
||||
eventCardVariant: 'surface' | 'alt';
|
||||
noteDraft: string;
|
||||
onNoteDraftChange: (value: string) => void;
|
||||
noteSaving: boolean;
|
||||
onSaveNote: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export function IncidentTimelinePanel(props: IncidentTimelinePanelProps) {
|
||||
const timeline = () => props.timeline;
|
||||
const events = createMemo(() => timeline()?.events || []);
|
||||
const filteredEvents = createMemo(() => filterIncidentEvents(events(), props.filters()));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={props.loading}>
|
||||
<p class="text-xs text-muted">{getAlertTimelineLoadingState().text}</p>
|
||||
</Show>
|
||||
<Show when={!props.loading && timeline()}>
|
||||
{(loadedTimeline) => (
|
||||
<div class="space-y-3">
|
||||
<div class={getAlertIncidentTimelineMetaRowClass()}>
|
||||
<span class={getAlertIncidentTimelineHeadingClass()}>Incident</span>
|
||||
<span>{loadedTimeline().status}</span>
|
||||
<Show when={loadedTimeline().acknowledged}>
|
||||
<span class={getAlertIncidentAcknowledgedBadgeClass()}>acknowledged</span>
|
||||
</Show>
|
||||
<Show when={loadedTimeline().openedAt}>
|
||||
<span>opened {new Date(loadedTimeline().openedAt).toLocaleString()}</span>
|
||||
</Show>
|
||||
<Show when={loadedTimeline().closedAt}>
|
||||
<span>closed {new Date(loadedTimeline().closedAt as string).toLocaleString()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={events().length > 0}>
|
||||
<IncidentEventFilters
|
||||
filters={props.filters}
|
||||
setFilters={props.setFilters}
|
||||
variant={props.filterVariant}
|
||||
showQuickSelection={props.filterVariant === 'compact'}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={filteredEvents().length > 0}>
|
||||
<div class="space-y-2">
|
||||
<For each={filteredEvents()}>
|
||||
{(event) => (
|
||||
<IncidentTimelineEventCard event={event} variant={props.eventCardVariant} />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={events().length > 0 && filteredEvents().length === 0}>
|
||||
<p class="text-xs text-muted">{getAlertTimelineFilterEmptyState().text}</p>
|
||||
</Show>
|
||||
<Show when={events().length === 0}>
|
||||
<p class="text-xs text-muted">{getAlertTimelineEmptyState().text}</p>
|
||||
</Show>
|
||||
<div class="flex flex-col gap-2">
|
||||
<textarea
|
||||
class={getAlertIncidentNoteTextareaClass()}
|
||||
rows={2}
|
||||
placeholder={getAlertResourceIncidentNotePlaceholder()}
|
||||
value={props.noteDraft}
|
||||
onInput={(event) => props.onNoteDraftChange(event.currentTarget.value)}
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
class={getAlertIncidentNoteSaveButtonClass()}
|
||||
disabled={props.noteSaving || !props.noteDraft.trim()}
|
||||
onClick={() => props.onSaveNote()}
|
||||
>
|
||||
{getAlertResourceIncidentSaveNoteLabel(props.noteSaving)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!props.loading && !timeline()}>
|
||||
<Show
|
||||
when={props.error}
|
||||
fallback={<p class="text-xs text-muted">{getAlertTimelineUnavailableState().text}</p>}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-xs text-error">{getAlertTimelineFailureState().text}</p>
|
||||
<button class="text-xs text-primary hover:underline" onClick={() => props.onRetry()}>
|
||||
{getAlertTimelineFailureState().actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { IncidentEventFilters } from '../IncidentEventFilters';
|
||||
|
||||
describe('IncidentEventFilters', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders compact filters with quick selection controls', () => {
|
||||
const [filters, setFilters] = createSignal(new Set(['alert_fired', 'note']));
|
||||
|
||||
render(() => (
|
||||
<IncidentEventFilters
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
variant="compact"
|
||||
showQuickSelection
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('Filters')).toBeInTheDocument();
|
||||
expect(screen.getByText('All')).toBeInTheDocument();
|
||||
expect(screen.getByText('None')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('None'));
|
||||
expect(filters().size).toBe(0);
|
||||
|
||||
fireEvent.click(screen.getByText('All'));
|
||||
expect(filters().has('alert_fired')).toBe(true);
|
||||
expect(filters().has('note')).toBe(true);
|
||||
expect(filters().has('runbook')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders panel filters without quick selection controls', () => {
|
||||
const [filters, setFilters] = createSignal(new Set(['alert_fired']));
|
||||
|
||||
render(() => (
|
||||
<IncidentEventFilters filters={filters} setFilters={setFilters} variant="panel" />
|
||||
));
|
||||
|
||||
expect(screen.getByText('Filter events:')).toBeInTheDocument();
|
||||
expect(screen.queryByText('All')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('None')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Ack'));
|
||||
expect(filters().has('alert_acknowledged')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { IncidentTimelinePanel } from '../IncidentTimelinePanel';
|
||||
import type { Incident } from '@/types/api';
|
||||
|
||||
function makeTimeline(overrides: Partial<Incident> = {}): Incident {
|
||||
return {
|
||||
id: 'incident-1',
|
||||
alertIdentifier: 'alert-1',
|
||||
alertType: 'cpu',
|
||||
level: 'warning',
|
||||
resourceId: 'vm-100',
|
||||
resourceName: 'test-vm',
|
||||
status: 'open',
|
||||
openedAt: '2026-03-20T10:00:00Z',
|
||||
acknowledged: false,
|
||||
events: [
|
||||
{
|
||||
id: 'event-1',
|
||||
type: 'command',
|
||||
timestamp: '2026-03-20T10:05:00Z',
|
||||
summary: 'Command executed',
|
||||
details: {
|
||||
command: 'systemctl status pulse',
|
||||
output_excerpt: 'Active: active (running)',
|
||||
},
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('IncidentTimelinePanel', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders loading, error, and unavailable states through shared copy', () => {
|
||||
const [filters, setFilters] = createSignal(new Set(['command']));
|
||||
const [state, setState] = createSignal<{
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
timeline?: Incident | null;
|
||||
}>({
|
||||
loading: true,
|
||||
error: false,
|
||||
timeline: undefined,
|
||||
});
|
||||
|
||||
render(() => (
|
||||
<IncidentTimelinePanel
|
||||
loading={state().loading}
|
||||
error={state().error}
|
||||
timeline={state().timeline}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
filterVariant="panel"
|
||||
eventCardVariant="alt"
|
||||
noteDraft=""
|
||||
onNoteDraftChange={vi.fn()}
|
||||
noteSaving={false}
|
||||
onSaveNote={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('Loading timeline...')).toBeInTheDocument();
|
||||
|
||||
setState({ loading: false, error: true, timeline: undefined });
|
||||
|
||||
expect(screen.getByText('Failed to load timeline.')).toBeInTheDocument();
|
||||
|
||||
setState({ loading: false, error: false, timeline: undefined });
|
||||
|
||||
expect(screen.getByText('No incident timeline available.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders shared timeline content and note handling', () => {
|
||||
const [filters, setFilters] = createSignal(new Set(['command']));
|
||||
const handleNoteDraftChange = vi.fn();
|
||||
const handleSave = vi.fn();
|
||||
|
||||
render(() => (
|
||||
<IncidentTimelinePanel
|
||||
loading={false}
|
||||
error={false}
|
||||
timeline={makeTimeline()}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
filterVariant="compact"
|
||||
eventCardVariant="surface"
|
||||
noteDraft="operator note"
|
||||
onNoteDraftChange={handleNoteDraftChange}
|
||||
noteSaving={false}
|
||||
onSaveNote={handleSave}
|
||||
onRetry={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('Incident')).toBeInTheDocument();
|
||||
expect(screen.getByText('Command executed')).toBeInTheDocument();
|
||||
expect(screen.getByText('systemctl status pulse')).toBeInTheDocument();
|
||||
expect(screen.getByText('Active: active (running)')).toBeInTheDocument();
|
||||
expect(screen.getByText('All')).toBeInTheDocument();
|
||||
expect(screen.getByText('None')).toBeInTheDocument();
|
||||
|
||||
fireEvent.input(screen.getByPlaceholderText('Add a note for this incident...'), {
|
||||
target: { value: 'updated note' },
|
||||
currentTarget: { value: 'updated note' },
|
||||
});
|
||||
expect(handleNoteDraftChange).toHaveBeenCalledWith('updated note');
|
||||
|
||||
fireEvent.click(screen.getByText('Save Note'));
|
||||
expect(handleSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -5,13 +5,13 @@ import { useLocation } from '@solidjs/router';
|
||||
import type { Incident } from '@/types/api';
|
||||
|
||||
import { AlertsAPI } from '@/api/alerts';
|
||||
import type { Alert, IncidentEvent } from '@/types/api';
|
||||
import type { Alert } from '@/types/api';
|
||||
import type { Override } from './types';
|
||||
import { alertTypeDisplayLabel } from './helpers';
|
||||
import { getCanonicalAlertId } from './identity';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||
import { IncidentTimelineEventCard } from '@/components/Alerts/IncidentTimelineEventCard';
|
||||
import { IncidentTimelinePanel } from '@/components/Alerts/IncidentTimelinePanel';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { logger } from '@/utils/logger';
|
||||
import {
|
||||
@@ -23,89 +23,8 @@ import {
|
||||
getAlertOverviewPrimaryActionClass,
|
||||
getAlertOverviewSecondaryActionClass,
|
||||
getAlertOverviewStartedAtClass,
|
||||
getAlertTimelineEmptyState,
|
||||
getAlertTimelineFailureState,
|
||||
getAlertTimelineFilterEmptyState,
|
||||
getAlertTimelineLoadingState,
|
||||
getAlertTimelineUnavailableState,
|
||||
} from '@/utils/alertOverviewPresentation';
|
||||
import {
|
||||
getAlertIncidentAcknowledgedBadgeClass,
|
||||
getAlertIncidentEventFilterChipClass,
|
||||
getAlertIncidentEventFilterContainerClass,
|
||||
getAlertIncidentEventFilterLabelClass,
|
||||
getAlertIncidentNoteSaveButtonClass,
|
||||
getAlertIncidentNoteTextareaClass,
|
||||
getAlertIncidentTimelineHeadingClass,
|
||||
getAlertIncidentTimelineMetaRowClass,
|
||||
getAlertResourceIncidentNotePlaceholder,
|
||||
getAlertResourceIncidentSaveNoteLabel,
|
||||
} from '@/utils/alertIncidentPresentation';
|
||||
|
||||
const INCIDENT_EVENT_TYPES = [
|
||||
'alert_fired',
|
||||
'alert_acknowledged',
|
||||
'alert_unacknowledged',
|
||||
'alert_resolved',
|
||||
'ai_analysis',
|
||||
'command',
|
||||
'runbook',
|
||||
'note',
|
||||
] as const;
|
||||
|
||||
const INCIDENT_EVENT_LABELS: Record<(typeof INCIDENT_EVENT_TYPES)[number], string> = {
|
||||
alert_fired: 'Fired',
|
||||
alert_acknowledged: 'Ack',
|
||||
alert_unacknowledged: 'Unack',
|
||||
alert_resolved: 'Resolved',
|
||||
ai_analysis: 'Patrol',
|
||||
command: 'Cmd',
|
||||
runbook: 'Runbook',
|
||||
note: 'Note',
|
||||
};
|
||||
|
||||
function filterIncidentEvents(
|
||||
events: IncidentEvent[] | undefined,
|
||||
filters: Set<string>,
|
||||
): IncidentEvent[] {
|
||||
if (!events) return [];
|
||||
if (filters.size === 0) return events;
|
||||
return events.filter((e) => filters.has(e.type));
|
||||
}
|
||||
|
||||
function IncidentEventFilters(props: {
|
||||
filters: () => Set<string>;
|
||||
setFilters: (next: Set<string>) => void;
|
||||
}) {
|
||||
const toggleFilter = (type: (typeof INCIDENT_EVENT_TYPES)[number]) => {
|
||||
const next = new Set(props.filters());
|
||||
if (next.has(type)) {
|
||||
next.delete(type);
|
||||
} else {
|
||||
next.add(type);
|
||||
}
|
||||
props.setFilters(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={getAlertIncidentEventFilterContainerClass('panel')}>
|
||||
<span class={getAlertIncidentEventFilterLabelClass('panel')}>Filter events:</span>
|
||||
<For each={INCIDENT_EVENT_TYPES}>
|
||||
{(type) => {
|
||||
const selected = () => props.filters().has(type);
|
||||
return (
|
||||
<button
|
||||
onClick={() => toggleFilter(type)}
|
||||
class={getAlertIncidentEventFilterChipClass(selected(), 'panel')}
|
||||
>
|
||||
{INCIDENT_EVENT_LABELS[type]}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { INCIDENT_EVENT_TYPES } from './types';
|
||||
|
||||
// Overview Tab - Shows current alert status
|
||||
export function OverviewTab(props: {
|
||||
@@ -679,133 +598,29 @@ export function OverviewTab(props: {
|
||||
</div>
|
||||
<Show when={expandedIncidents().has(getCanonicalAlertId(alert))}>
|
||||
<div class="mt-3 border-t border-border pt-3">
|
||||
<Show when={incidentLoading()[getCanonicalAlertId(alert)]}>
|
||||
<p class="text-xs text-muted">{getAlertTimelineLoadingState().text}</p>
|
||||
</Show>
|
||||
<Show when={!incidentLoading()[getCanonicalAlertId(alert)]}>
|
||||
<Show when={incidentTimelines()[getCanonicalAlertId(alert)]}>
|
||||
{(timeline) => (
|
||||
<div class="space-y-3">
|
||||
<div class={getAlertIncidentTimelineMetaRowClass()}>
|
||||
<span class={getAlertIncidentTimelineHeadingClass()}>Incident</span>
|
||||
<span>{timeline().status}</span>
|
||||
<Show when={timeline().acknowledged}>
|
||||
<span class={getAlertIncidentAcknowledgedBadgeClass()}>
|
||||
acknowledged
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={timeline().openedAt}>
|
||||
<span>
|
||||
opened {new Date(timeline().openedAt).toLocaleString()}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={timeline().closedAt}>
|
||||
<span>
|
||||
closed{' '}
|
||||
{new Date(timeline().closedAt as string).toLocaleString()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
{(() => {
|
||||
const events = timeline().events || [];
|
||||
const filteredEvents = filterIncidentEvents(
|
||||
events,
|
||||
incidentEventFilters(),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Show when={events.length > 0}>
|
||||
<IncidentEventFilters
|
||||
filters={incidentEventFilters}
|
||||
setFilters={setIncidentEventFilters}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={filteredEvents.length > 0}>
|
||||
<div class="space-y-2">
|
||||
<For each={filteredEvents}>
|
||||
{(event) => (
|
||||
<IncidentTimelineEventCard event={event} variant="alt" />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={events.length > 0 && filteredEvents.length === 0}>
|
||||
<p class="text-xs text-muted">
|
||||
{getAlertTimelineFilterEmptyState().text}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={events.length === 0}>
|
||||
<p class="text-xs text-muted">
|
||||
{getAlertTimelineEmptyState().text}
|
||||
</p>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
<div class="flex flex-col gap-2">
|
||||
<textarea
|
||||
class={getAlertIncidentNoteTextareaClass()}
|
||||
rows={2}
|
||||
placeholder={getAlertResourceIncidentNotePlaceholder()}
|
||||
value={incidentNoteDrafts()[getCanonicalAlertId(alert)] || ''}
|
||||
onInput={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setIncidentNoteDrafts((prev) => ({
|
||||
...prev,
|
||||
[getCanonicalAlertId(alert)]: value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
class={getAlertIncidentNoteSaveButtonClass()}
|
||||
disabled={
|
||||
incidentNoteSaving().has(getCanonicalAlertId(alert)) ||
|
||||
!(
|
||||
incidentNoteDrafts()[getCanonicalAlertId(alert)] || ''
|
||||
).trim()
|
||||
}
|
||||
onClick={() => {
|
||||
void saveIncidentNote(
|
||||
getCanonicalAlertId(alert),
|
||||
alert.startTime,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{getAlertResourceIncidentSaveNoteLabel(
|
||||
incidentNoteSaving().has(getCanonicalAlertId(alert)),
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!incidentTimelines()[getCanonicalAlertId(alert)]}>
|
||||
<Show
|
||||
when={incidentErrors()[getCanonicalAlertId(alert)]}
|
||||
fallback={
|
||||
<p class="text-xs text-muted">
|
||||
{getAlertTimelineUnavailableState().text}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-xs text-error">
|
||||
{getAlertTimelineFailureState().text}
|
||||
</p>
|
||||
<button
|
||||
class="text-xs text-primary hover:underline"
|
||||
onClick={() =>
|
||||
loadIncidentTimeline(getCanonicalAlertId(alert), alert.startTime)
|
||||
}
|
||||
>
|
||||
{getAlertTimelineFailureState().actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
<IncidentTimelinePanel
|
||||
loading={incidentLoading()[getCanonicalAlertId(alert)]}
|
||||
error={incidentErrors()[getCanonicalAlertId(alert)]}
|
||||
timeline={incidentTimelines()[getCanonicalAlertId(alert)]}
|
||||
filters={incidentEventFilters}
|
||||
setFilters={setIncidentEventFilters}
|
||||
filterVariant="panel"
|
||||
eventCardVariant="alt"
|
||||
noteDraft={incidentNoteDrafts()[getCanonicalAlertId(alert)] || ''}
|
||||
onNoteDraftChange={(value) =>
|
||||
setIncidentNoteDrafts((prev) => ({
|
||||
...prev,
|
||||
[getCanonicalAlertId(alert)]: value,
|
||||
}))
|
||||
}
|
||||
noteSaving={incidentNoteSaving().has(getCanonicalAlertId(alert))}
|
||||
onSaveNote={() => {
|
||||
void saveIncidentNote(getCanonicalAlertId(alert), alert.startTime);
|
||||
}}
|
||||
onRetry={() => {
|
||||
void loadIncidentTimeline(getCanonicalAlertId(alert), alert.startTime);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -220,9 +220,12 @@ export const filterIncidentEvents = (
|
||||
if (!events || events.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (filters.size === 0 || filters.size === INCIDENT_EVENT_TYPES.length) {
|
||||
if (filters.size === INCIDENT_EVENT_TYPES.length) {
|
||||
return events;
|
||||
}
|
||||
if (filters.size === 0) {
|
||||
return [];
|
||||
}
|
||||
return events.filter((event) => filters.has(event.type));
|
||||
};
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ import { SearchInput } from '@/components/shared/SearchInput';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
|
||||
import { EmailProviderSelect } from '@/components/Alerts/EmailProviderSelect';
|
||||
import { IncidentEventFilters } from '@/components/Alerts/IncidentEventFilters';
|
||||
import { IncidentTimelinePanel } from '@/components/Alerts/IncidentTimelinePanel';
|
||||
import { WebhookConfig } from '@/components/Alerts/WebhookConfig';
|
||||
import { ThresholdsTable } from '@/components/Alerts/ThresholdsTable';
|
||||
import { InvestigateAlertButton } from '@/components/Alerts/InvestigateAlertButton';
|
||||
@@ -64,17 +66,9 @@ import type { EmailConfig, AppriseConfig } from '@/api/notifications';
|
||||
import { pbsInstanceFromResource, pmgInstanceFromResource } from '@/utils/resourceStateAdapters';
|
||||
import { isAppContainerDiscoveryResourceType } from '@/utils/discoveryTarget';
|
||||
import { getActionableAgentIdFromResource, hasAgentFacet } from '@/utils/agentResources';
|
||||
import { IncidentTimelineEventCard } from '@/components/Alerts/IncidentTimelineEventCard';
|
||||
import {
|
||||
getAlertHistoryStatusPresentation,
|
||||
getAlertIncidentAcknowledgedBadgeClass,
|
||||
getAlertIncidentEventFilterActionButtonClass,
|
||||
getAlertIncidentEventFilterChipClass,
|
||||
getAlertIncidentEventFilterContainerClass,
|
||||
getAlertIncidentEventFilterLabelClass,
|
||||
getAlertIncidentLevelBadgeClass,
|
||||
getAlertIncidentNoteSaveButtonClass,
|
||||
getAlertIncidentNoteTextareaClass,
|
||||
getAlertIncidentTimelineHeadingClass,
|
||||
getAlertIncidentTimelineMetaRowClass,
|
||||
getAlertIncidentTimelineOutputClass,
|
||||
@@ -89,10 +83,8 @@ import {
|
||||
getAlertResourceIncidentLoadingState,
|
||||
getAlertResourceIncidentNoteSaveFailure,
|
||||
getAlertResourceIncidentNoteSavedLabel,
|
||||
getAlertResourceIncidentNotePlaceholder,
|
||||
getAlertResourceIncidentPanelTitle,
|
||||
getAlertResourceIncidentRefreshLabel,
|
||||
getAlertResourceIncidentSaveNoteLabel,
|
||||
getAlertResourceIncidentSummaryRowClass,
|
||||
getAlertResourceIncidentTimelineFailure,
|
||||
getAlertResourceIncidentToggleLabel,
|
||||
@@ -197,11 +189,6 @@ import {
|
||||
getAlertHistoryLoadingState,
|
||||
getAlertHistoryEmptyState,
|
||||
getAlertHistorySearchPlaceholder,
|
||||
getAlertTimelineEmptyState,
|
||||
getAlertTimelineFailureState,
|
||||
getAlertTimelineFilterEmptyState,
|
||||
getAlertTimelineLoadingState,
|
||||
getAlertTimelineUnavailableState,
|
||||
} from '@/utils/alertOverviewPresentation';
|
||||
import {
|
||||
ALERT_CONFIG_COOLDOWN_DESCRIPTION,
|
||||
@@ -285,7 +272,6 @@ import {
|
||||
type EscalationNotifyTarget,
|
||||
INCIDENT_EVENT_TYPES,
|
||||
GROUPING_WINDOW_DEFAULT_SECONDS,
|
||||
INCIDENT_EVENT_LABELS,
|
||||
summarizeIncidentEvents,
|
||||
fallbackCooldownMinutes,
|
||||
clampCooldownMinutes,
|
||||
@@ -3830,55 +3816,6 @@ function ScheduleTab(props: ScheduleTabProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function IncidentEventFilters(props: {
|
||||
filters: () => Set<string>;
|
||||
setFilters: (next: Set<string>) => void;
|
||||
}) {
|
||||
const toggleFilter = (type: (typeof INCIDENT_EVENT_TYPES)[number]) => {
|
||||
const next = new Set(props.filters());
|
||||
if (next.has(type)) {
|
||||
next.delete(type);
|
||||
} else {
|
||||
next.add(type);
|
||||
}
|
||||
props.setFilters(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={getAlertIncidentEventFilterContainerClass('compact')}>
|
||||
<span class={getAlertIncidentEventFilterLabelClass('compact')}>Filters</span>
|
||||
<button
|
||||
type="button"
|
||||
class={getAlertIncidentEventFilterActionButtonClass()}
|
||||
onClick={() => props.setFilters(new Set(INCIDENT_EVENT_TYPES))}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={getAlertIncidentEventFilterActionButtonClass()}
|
||||
onClick={() => props.setFilters(new Set())}
|
||||
>
|
||||
None
|
||||
</button>
|
||||
<For each={INCIDENT_EVENT_TYPES}>
|
||||
{(type) => {
|
||||
const selected = () => props.filters().has(type);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={getAlertIncidentEventFilterChipClass(selected(), 'compact')}
|
||||
onClick={() => toggleFilter(type)}
|
||||
>
|
||||
{INCIDENT_EVENT_LABELS[type]}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// History Tab - Comprehensive alert table
|
||||
function HistoryTab(props: {
|
||||
hasAIAlertsFeature: () => boolean;
|
||||
@@ -4984,6 +4921,8 @@ function HistoryTab(props: {
|
||||
<IncidentEventFilters
|
||||
filters={resourceIncidentEventFilters}
|
||||
setFilters={setResourceIncidentEventFilters}
|
||||
variant="compact"
|
||||
showQuickSelection
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -5369,157 +5308,33 @@ function HistoryTab(props: {
|
||||
>
|
||||
<TableRow class="bg-surface-alt border-b border-border">
|
||||
<TableCell colspan={11} class="p-3">
|
||||
<Show when={incidentLoading()[rowKey]}>
|
||||
<p class="text-xs text-muted">
|
||||
{getAlertTimelineLoadingState().text}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={!incidentLoading()[rowKey]}>
|
||||
<Show when={incidentTimelines()[rowKey]}>
|
||||
{(timeline) => (
|
||||
<div class="space-y-3">
|
||||
<div class={getAlertIncidentTimelineMetaRowClass()}>
|
||||
<span class={getAlertIncidentTimelineHeadingClass()}>
|
||||
Incident
|
||||
</span>
|
||||
<span>{timeline().status}</span>
|
||||
<Show when={timeline().acknowledged}>
|
||||
<span
|
||||
class={getAlertIncidentAcknowledgedBadgeClass()}
|
||||
>
|
||||
acknowledged
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={timeline().openedAt}>
|
||||
<span>
|
||||
opened{' '}
|
||||
{new Date(
|
||||
timeline().openedAt,
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={timeline().closedAt}>
|
||||
<span>
|
||||
closed{' '}
|
||||
{new Date(
|
||||
timeline().closedAt as string,
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
{(() => {
|
||||
const events = timeline().events || [];
|
||||
const filteredEvents = filterIncidentEvents(
|
||||
events,
|
||||
historyIncidentEventFilters(),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Show when={events.length > 0}>
|
||||
<IncidentEventFilters
|
||||
filters={historyIncidentEventFilters}
|
||||
setFilters={
|
||||
setHistoryIncidentEventFilters
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={filteredEvents.length > 0}>
|
||||
<div class="space-y-2">
|
||||
<For each={filteredEvents}>
|
||||
{(event) => (
|
||||
<IncidentTimelineEventCard
|
||||
event={event}
|
||||
variant="surface"
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
events.length > 0 &&
|
||||
filteredEvents.length === 0
|
||||
}
|
||||
>
|
||||
<p class="text-xs text-muted">
|
||||
{getAlertTimelineFilterEmptyState().text}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={events.length === 0}>
|
||||
<p class="text-xs text-muted">
|
||||
{getAlertTimelineEmptyState().text}
|
||||
</p>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
<div class="flex flex-col gap-2">
|
||||
<textarea
|
||||
class={getAlertIncidentNoteTextareaClass()}
|
||||
rows={2}
|
||||
placeholder={getAlertResourceIncidentNotePlaceholder()}
|
||||
value={incidentNoteDrafts()[rowKey] || ''}
|
||||
onInput={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setIncidentNoteDrafts((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
class={getAlertIncidentNoteSaveButtonClass()}
|
||||
disabled={
|
||||
incidentNoteSaving().has(rowKey) ||
|
||||
!(incidentNoteDrafts()[rowKey] || '').trim()
|
||||
}
|
||||
onClick={() => {
|
||||
void saveIncidentNote(
|
||||
rowKey,
|
||||
alert.id,
|
||||
alert.startTime,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{getAlertResourceIncidentSaveNoteLabel(
|
||||
incidentNoteSaving().has(rowKey),
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!incidentTimelines()[rowKey]}>
|
||||
<Show
|
||||
when={incidentErrors()[rowKey]}
|
||||
fallback={
|
||||
<p class="text-xs text-muted">
|
||||
{getAlertTimelineUnavailableState().text}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-xs text-error">
|
||||
{getAlertTimelineFailureState().text}
|
||||
</p>
|
||||
<button
|
||||
class="text-xs text-primary hover:underline"
|
||||
onClick={() =>
|
||||
loadIncidentTimeline(
|
||||
rowKey,
|
||||
alert.id,
|
||||
alert.startTime,
|
||||
)
|
||||
}
|
||||
>
|
||||
{getAlertTimelineFailureState().actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
<IncidentTimelinePanel
|
||||
loading={incidentLoading()[rowKey]}
|
||||
error={incidentErrors()[rowKey]}
|
||||
timeline={incidentTimelines()[rowKey]}
|
||||
filters={historyIncidentEventFilters}
|
||||
setFilters={setHistoryIncidentEventFilters}
|
||||
filterVariant="compact"
|
||||
eventCardVariant="surface"
|
||||
noteDraft={incidentNoteDrafts()[rowKey] || ''}
|
||||
onNoteDraftChange={(value) =>
|
||||
setIncidentNoteDrafts((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: value,
|
||||
}))
|
||||
}
|
||||
noteSaving={incidentNoteSaving().has(rowKey)}
|
||||
onSaveNote={() => {
|
||||
void saveIncidentNote(rowKey, alert.id, alert.startTime);
|
||||
}}
|
||||
onRetry={() => {
|
||||
void loadIncidentTimeline(
|
||||
rowKey,
|
||||
alert.id,
|
||||
alert.startTime,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Show>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ALERT_TAB_SEGMENTS,
|
||||
filterIncidentEvents,
|
||||
pathForTab,
|
||||
summarizeIncidentEvents,
|
||||
tabFromPath,
|
||||
@@ -292,6 +293,30 @@ describe('incident timeline presentation helpers', () => {
|
||||
});
|
||||
|
||||
describe('incident event summaries', () => {
|
||||
it('treats a fully selected filter set as all events and an empty set as no events', () => {
|
||||
const events = [
|
||||
{ id: '1', type: 'alert_fired', timestamp: '2026-03-20T10:00:00Z', summary: 'Fired' },
|
||||
{ id: '2', type: 'note', timestamp: '2026-03-20T10:01:00Z', summary: 'Noted' },
|
||||
];
|
||||
|
||||
expect(
|
||||
filterIncidentEvents(
|
||||
events,
|
||||
new Set([
|
||||
'alert_fired',
|
||||
'alert_acknowledged',
|
||||
'alert_unacknowledged',
|
||||
'alert_resolved',
|
||||
'ai_analysis',
|
||||
'command',
|
||||
'runbook',
|
||||
'note',
|
||||
]),
|
||||
),
|
||||
).toEqual(events);
|
||||
expect(filterIncidentEvents(events, new Set())).toEqual([]);
|
||||
});
|
||||
|
||||
it('summarizes incident events in canonical order and retains unknown event types', () => {
|
||||
expect(
|
||||
summarizeIncidentEvents([
|
||||
|
||||
@@ -140,6 +140,8 @@ import useStorageAlertStateSource from '@/components/Storage/useStorageAlertStat
|
||||
import temperatureUtilSource from '@/utils/temperature.ts?raw';
|
||||
import pmgInstanceDrawerSource from '@/components/PMG/PMGInstanceDrawer.tsx?raw';
|
||||
import serviceHealthPresentationSource from '@/utils/serviceHealthPresentation.ts?raw';
|
||||
import alertIncidentEventFiltersSource from '@/components/Alerts/IncidentEventFilters.tsx?raw';
|
||||
import alertIncidentTimelinePanelSource from '@/components/Alerts/IncidentTimelinePanel.tsx?raw';
|
||||
import alertIncidentTimelineEventCardSource from '@/components/Alerts/IncidentTimelineEventCard.tsx?raw';
|
||||
import swarmServicesDrawerSource from '@/components/Docker/SwarmServicesDrawer.tsx?raw';
|
||||
import swarmPresentationSource from '@/utils/swarmPresentation.ts?raw';
|
||||
@@ -2207,14 +2209,8 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(alertsPageSource).toContain('getAlertResourceIncidentAcknowledgedByLabel');
|
||||
expect(alertsPageSource).toContain('getAlertResourceIncidentToggleLabel');
|
||||
expect(alertsPageSource).toContain('getAlertResourceIncidentFilteredEventsEmptyState');
|
||||
expect(alertsPageSource).toContain('getAlertResourceIncidentNotePlaceholder');
|
||||
expect(alertsPageSource).toContain('getAlertResourceIncidentSaveNoteLabel');
|
||||
expect(alertsPageSource).toContain('getAlertIncidentEventFilterContainerClass');
|
||||
expect(alertsPageSource).toContain('getAlertIncidentEventFilterActionButtonClass');
|
||||
expect(alertsPageSource).toContain('getAlertIncidentEventFilterChipClass');
|
||||
expect(alertsPageSource).toContain('getAlertIncidentAcknowledgedBadgeClass');
|
||||
expect(alertsPageSource).toContain('getAlertIncidentNoteTextareaClass');
|
||||
expect(alertsPageSource).toContain('getAlertIncidentNoteSaveButtonClass');
|
||||
expect(alertsPageSource).toContain('IncidentEventFilters');
|
||||
expect(alertsPageSource).toContain('IncidentTimelinePanel');
|
||||
expect(alertsPageSource).toContain('getAlertIncidentTimelineMetaRowClass');
|
||||
expect(alertsPageSource).toContain('getAlertIncidentTimelineHeadingClass');
|
||||
expect(alertsPageSource).toContain('IncidentTimelineEventCard');
|
||||
@@ -2854,19 +2850,21 @@ describe('frontend resource type boundaries', () => {
|
||||
});
|
||||
|
||||
it('keeps alert incident timeline state copy in a shared presentation utility', () => {
|
||||
expect(alertOverviewTabSource).toContain('getAlertTimelineLoadingState');
|
||||
expect(alertOverviewTabSource).toContain('getAlertTimelineFilterEmptyState');
|
||||
expect(alertOverviewTabSource).toContain('getAlertTimelineEmptyState');
|
||||
expect(alertOverviewTabSource).toContain('getAlertTimelineUnavailableState');
|
||||
expect(alertOverviewTabSource).toContain('getAlertTimelineFailureState');
|
||||
expect(alertOverviewTabSource).toContain('getAlertIncidentEventFilterContainerClass');
|
||||
expect(alertOverviewTabSource).toContain('getAlertIncidentEventFilterChipClass');
|
||||
expect(alertOverviewTabSource).toContain('getAlertIncidentAcknowledgedBadgeClass');
|
||||
expect(alertOverviewTabSource).toContain('getAlertIncidentNoteTextareaClass');
|
||||
expect(alertOverviewTabSource).toContain('getAlertIncidentNoteSaveButtonClass');
|
||||
expect(alertOverviewTabSource).toContain('getAlertIncidentTimelineMetaRowClass');
|
||||
expect(alertOverviewTabSource).toContain('getAlertIncidentTimelineHeadingClass');
|
||||
expect(alertOverviewTabSource).toContain('IncidentTimelineEventCard');
|
||||
expect(alertOverviewTabSource).toContain('IncidentTimelinePanel');
|
||||
expect(alertIncidentEventFiltersSource).toContain('getAlertIncidentEventFilterContainerClass');
|
||||
expect(alertIncidentEventFiltersSource).toContain('getAlertIncidentEventFilterChipClass');
|
||||
expect(alertIncidentEventFiltersSource).toContain('getAlertIncidentEventFilterActionButtonClass');
|
||||
expect(alertIncidentTimelinePanelSource).toContain('getAlertTimelineLoadingState');
|
||||
expect(alertIncidentTimelinePanelSource).toContain('getAlertTimelineFilterEmptyState');
|
||||
expect(alertIncidentTimelinePanelSource).toContain('getAlertTimelineEmptyState');
|
||||
expect(alertIncidentTimelinePanelSource).toContain('getAlertTimelineUnavailableState');
|
||||
expect(alertIncidentTimelinePanelSource).toContain('getAlertTimelineFailureState');
|
||||
expect(alertIncidentTimelinePanelSource).toContain('getAlertIncidentAcknowledgedBadgeClass');
|
||||
expect(alertIncidentTimelinePanelSource).toContain('getAlertIncidentNoteTextareaClass');
|
||||
expect(alertIncidentTimelinePanelSource).toContain('getAlertIncidentNoteSaveButtonClass');
|
||||
expect(alertIncidentTimelinePanelSource).toContain('getAlertIncidentTimelineMetaRowClass');
|
||||
expect(alertIncidentTimelinePanelSource).toContain('getAlertIncidentTimelineHeadingClass');
|
||||
expect(alertIncidentTimelinePanelSource).toContain('IncidentTimelineEventCard');
|
||||
expect(alertOverviewTabSource).not.toContain('getAlertIncidentTimelineEventCardClass');
|
||||
expect(alertOverviewTabSource).not.toContain('getAlertIncidentTimelineDetailClass');
|
||||
expect(alertOverviewTabSource).not.toContain('getAlertIncidentTimelineCommandClass');
|
||||
@@ -2967,6 +2965,8 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(alertIncidentTimelineEventCardSource).toContain(
|
||||
'export function IncidentTimelineEventCard',
|
||||
);
|
||||
expect(alertIncidentEventFiltersSource).toContain('export function IncidentEventFilters');
|
||||
expect(alertIncidentTimelinePanelSource).toContain('export function IncidentTimelinePanel');
|
||||
expect(alertIncidentTimelineEventCardSource).toContain(
|
||||
'getAlertIncidentTimelineEventCardClass',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user