Split alert history table row owners

This commit is contained in:
rcourtman
2026-03-22 21:31:14 +00:00
parent db3f89267d
commit d99f1f255d
7 changed files with 267 additions and 228 deletions
@@ -292,7 +292,9 @@ detail now lives in
`frontend-modern/src/features/alerts/AlertHistoryFrequencyCard.tsx`,
`frontend-modern/src/features/alerts/AlertHistoryFiltersCard.tsx`,
`frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx`,
`frontend-modern/src/features/alerts/AlertHistoryTableSection.tsx`, and
`frontend-modern/src/features/alerts/AlertHistoryTableSection.tsx`,
`frontend-modern/src/features/alerts/AlertHistoryTableGroupRow.tsx`,
`frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx`, and
`frontend-modern/src/features/alerts/AlertHistoryAdministrationCard.tsx`.
Future alert-history control-flow work should extend the feature hook, new
grouping or trend semantics should extend the history model, and render-heavy
@@ -554,7 +554,9 @@ render-heavy surfaces now route through
`frontend-modern/src/features/alerts/AlertHistoryFrequencyCard.tsx`,
`frontend-modern/src/features/alerts/AlertHistoryFiltersCard.tsx`,
`frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx`,
`frontend-modern/src/features/alerts/AlertHistoryTableSection.tsx`, and
`frontend-modern/src/features/alerts/AlertHistoryTableSection.tsx`,
`frontend-modern/src/features/alerts/AlertHistoryTableGroupRow.tsx`,
`frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx`, and
`frontend-modern/src/features/alerts/AlertHistoryAdministrationCard.tsx`.
Future alert-history control flow should extend the hook, pure history analytics
should extend the model, and section rendering should extend those owners
@@ -0,0 +1,180 @@
import { Show } from 'solid-js';
import { IncidentTimelinePanel } from '@/components/Alerts/IncidentTimelinePanel';
import { InvestigateAlertButton } from '@/components/Alerts/InvestigateAlertButton';
import { TableCell, TableRow } from '@/components/shared/Table';
import {
getAlertHistoryResourceTypeBadgeClass,
getAlertHistorySourcePresentation,
} from '@/utils/alertHistoryPresentation';
import {
getAlertHistoryStatusPresentation,
getAlertIncidentLevelBadgeClass,
getAlertResourceIncidentViewTitle,
} from '@/utils/alertIncidentPresentation';
import type { AlertHistoryState } from './useAlertHistoryState';
type AlertHistoryAlert = ReturnType<AlertHistoryState['groupedAlerts']>[number]['alerts'][number];
interface AlertHistoryTableAlertRowProps {
alert: AlertHistoryAlert;
state: AlertHistoryState;
hasAIAlertsFeature: () => boolean;
licenseLoading: () => boolean;
}
export function AlertHistoryTableAlertRow(props: AlertHistoryTableAlertRowProps) {
const rowKey = () => props.state.getIncidentRowKey(props.alert);
const historyStatusPresentation = () => getAlertHistoryStatusPresentation(props.alert.status);
const sourcePresentation = () => getAlertHistorySourcePresentation(props.alert.source);
return (
<>
<TableRow
class={`border-b border-border hover:bg-surface-hover ${historyStatusPresentation().rowClassName}`}
>
<TableCell class="p-1 px-1 font-mono whitespace-nowrap text-muted sm:p-1.5 sm:px-2">
{new Date(props.alert.startTime).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
})}
</TableCell>
<TableCell class="p-1 px-1 text-center sm:p-1.5 sm:px-2">
<span class={sourcePresentation().className}>{sourcePresentation().label}</span>
</TableCell>
<TableCell class="max-w-[150px] truncate p-1 px-1 font-medium text-base-content sm:p-1.5 sm:px-2">
{props.alert.resourceName}
</TableCell>
<TableCell class="p-1 px-1 sm:p-1.5 sm:px-2">
<span class={getAlertHistoryResourceTypeBadgeClass(props.alert.resourceType)}>
{props.alert.resourceType}
</span>
</TableCell>
<TableCell class="p-1 px-1 text-center sm:p-1.5 sm:px-2">
<span class={getAlertIncidentLevelBadgeClass(props.alert.severity)}>
{props.alert.severity}
</span>
</TableCell>
<TableCell
class="max-w-[300px] truncate p-1 px-1 text-base-content sm:p-1.5 sm:px-2"
title={props.alert.description}
>
{props.alert.description}
</TableCell>
<TableCell class="p-1 px-1 text-center text-muted sm:p-1.5 sm:px-2">
{props.alert.duration}
</TableCell>
<TableCell class="p-1 px-1 text-center sm:p-1.5 sm:px-2">
<span class={historyStatusPresentation().className}>
{historyStatusPresentation().label}
</span>
</TableCell>
<TableCell class="truncate p-1 px-1 text-muted sm:p-1.5 sm:px-2">
{props.alert.nodeDisplayName || props.alert.node || '—'}
</TableCell>
<TableCell class="p-1 px-1 text-center sm:p-1.5 sm:px-2">
<div class="flex items-center justify-center gap-1">
<Show when={props.alert.source === 'alert'}>
<button
type="button"
class="rounded-md border border-border px-2 py-1 text-[10px] text-muted hover:bg-surface-hover"
onClick={() => {
void props.state.toggleIncidentTimeline(
rowKey(),
props.alert.id,
props.alert.startTime,
);
}}
>
{props.state.expandedIncidents().has(rowKey()) ? 'Hide' : 'Timeline'}
</button>
</Show>
<Show when={props.alert.source === 'alert' && props.alert.resourceId}>
<button
type="button"
class="rounded-md border border-border px-2 py-1 text-[10px] text-muted hover:bg-surface-hover"
title={getAlertResourceIncidentViewTitle()}
onClick={() => {
void props.state.openResourceIncidentPanel(
props.alert.resourceId as string,
props.alert.resourceName,
);
}}
>
Resource
</button>
</Show>
<Show
when={
props.alert.source === 'alert' &&
(props.alert.status === 'active' || props.alert.status === 'acknowledged')
}
>
<InvestigateAlertButton
alert={{
id: props.alert.id,
type: props.alert.rawAlertType || props.alert.title,
level: props.alert.severity as 'warning' | 'critical',
resourceId: props.alert.resourceId || '',
resourceName: props.alert.resourceName,
node: props.alert.node || '',
nodeDisplayName: props.alert.nodeDisplayName,
instance: '',
message: props.alert.description || '',
value: 0,
threshold: 0,
startTime: props.alert.startTime,
lastSeen: props.alert.startTime,
acknowledged: props.alert.status === 'acknowledged',
}}
resourceType={props.alert.resourceType}
variant="icon"
size="sm"
licenseLocked={!props.hasAIAlertsFeature() && !props.licenseLoading()}
/>
</Show>
</div>
</TableCell>
</TableRow>
<Show when={props.alert.source === 'alert' && props.state.expandedIncidents().has(rowKey())}>
<TableRow class="border-b border-border bg-surface-alt">
<TableCell colspan={11} class="p-3">
<IncidentTimelinePanel
loading={props.state.incidentLoading()[rowKey()]}
error={props.state.incidentErrors()[rowKey()]}
timeline={props.state.incidentTimelines()[rowKey()]}
filters={props.state.historyIncidentEventFilters}
setFilters={props.state.setHistoryIncidentEventFilters}
filterVariant="compact"
eventCardVariant="surface"
noteDraft={props.state.incidentNoteDrafts()[rowKey()] || ''}
onNoteDraftChange={(value) => props.state.setIncidentNoteDraft(rowKey(), value)}
noteSaving={props.state.incidentNoteSaving().has(rowKey())}
onSaveNote={() => {
void props.state.saveIncidentNote(rowKey(), props.alert.id, props.alert.startTime);
}}
onRetry={() => {
void props.state.loadIncidentTimeline(
rowKey(),
props.alert.id,
props.alert.startTime,
);
}}
/>
</TableCell>
</TableRow>
</Show>
</>
);
}
@@ -0,0 +1,43 @@
import { TableCell, TableRow } from '@/components/shared/Table';
import type { AlertHistoryState } from './useAlertHistoryState';
type AlertHistoryGroup = ReturnType<AlertHistoryState['groupedAlerts']>[number];
interface AlertHistoryTableGroupRowProps {
group: AlertHistoryGroup;
}
function getGroupSummaryLabel(group: AlertHistoryGroup) {
const alertCount = group.alerts.filter((alert) => alert.source === 'alert').length;
const aiCount = group.alerts.filter((alert) => alert.source === 'ai').length;
const parts = [];
if (alertCount > 0) {
parts.push(`${alertCount} alert${alertCount === 1 ? '' : 's'}`);
}
if (aiCount > 0) {
parts.push(`${aiCount} patrol insight${aiCount === 1 ? '' : 's'}`);
}
return (
parts.join(', ') || `${group.alerts.length} item${group.alerts.length === 1 ? '' : 's'}`
);
}
export function AlertHistoryTableGroupRow(props: AlertHistoryTableGroupRowProps) {
return (
<TableRow class="bg-surface-alt">
<TableCell colspan={10} class="py-1.5 pr-3 pl-4 text-[12px] font-semibold sm:text-sm">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span class="truncate" title={props.group.fullLabel}>
{props.group.label}
</span>
<span class="text-[10px] font-medium text-muted">
{getGroupSummaryLabel(props.group)}
</span>
</div>
</TableCell>
</TableRow>
);
}
@@ -1,11 +1,8 @@
import { For, Show } from 'solid-js';
import { IncidentTimelinePanel } from '@/components/Alerts/IncidentTimelinePanel';
import { InvestigateAlertButton } from '@/components/Alerts/InvestigateAlertButton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
@@ -14,17 +11,10 @@ import {
getAlertHistoryEmptyState,
getAlertHistoryLoadingState,
} from '@/utils/alertOverviewPresentation';
import {
getAlertHistoryResourceTypeBadgeClass,
getAlertHistorySourcePresentation,
} from '@/utils/alertHistoryPresentation';
import {
getAlertHistoryStatusPresentation,
getAlertIncidentLevelBadgeClass,
getAlertResourceIncidentViewTitle,
} from '@/utils/alertIncidentPresentation';
import { getTypeColumnLabel } from '@/utils/typeColumnPresentation';
import { AlertHistoryTableAlertRow } from './AlertHistoryTableAlertRow';
import { AlertHistoryTableGroupRow } from './AlertHistoryTableGroupRow';
import type { AlertHistoryState } from './useAlertHistoryState';
interface AlertHistoryTableSectionProps {
@@ -88,213 +78,17 @@ export function AlertHistoryTableSection(props: AlertHistoryTableSectionProps) {
<For each={props.state.groupedAlerts()}>
{(group) => (
<>
<TableRow class="bg-surface-alt">
<TableCell
colspan={10}
class="py-1.5 pr-3 pl-4 text-[12px] font-semibold sm:text-sm"
>
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span class="truncate" title={group.fullLabel}>
{group.label}
</span>
<span class="text-[10px] font-medium text-muted">
{(() => {
const alertCount = group.alerts.filter(
(alert) => alert.source === 'alert',
).length;
const aiCount = group.alerts.filter(
(alert) => alert.source === 'ai',
).length;
const parts = [];
if (alertCount > 0) {
parts.push(`${alertCount} alert${alertCount === 1 ? '' : 's'}`);
}
if (aiCount > 0) {
parts.push(
`${aiCount} patrol insight${aiCount === 1 ? '' : 's'}`,
);
}
return (
parts.join(', ') ||
`${group.alerts.length} item${group.alerts.length === 1 ? '' : 's'}`
);
})()}
</span>
</div>
</TableCell>
</TableRow>
<AlertHistoryTableGroupRow group={group} />
<For each={group.alerts}>
{(alert) => {
const rowKey = props.state.getIncidentRowKey(alert);
const historyStatusPresentation = getAlertHistoryStatusPresentation(
alert.status,
);
const sourcePresentation = getAlertHistorySourcePresentation(
alert.source,
);
return (
<>
<TableRow
class={`border-b border-border hover:bg-surface-hover ${historyStatusPresentation.rowClassName}`}
>
<TableCell class="p-1 px-1 font-mono whitespace-nowrap text-muted sm:p-1.5 sm:px-2">
{new Date(alert.startTime).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
})}
</TableCell>
<TableCell class="p-1 px-1 text-center sm:p-1.5 sm:px-2">
<span class={sourcePresentation.className}>
{sourcePresentation.label}
</span>
</TableCell>
<TableCell class="max-w-[150px] truncate p-1 px-1 font-medium text-base-content sm:p-1.5 sm:px-2">
{alert.resourceName}
</TableCell>
<TableCell class="p-1 px-1 sm:p-1.5 sm:px-2">
<span class={getAlertHistoryResourceTypeBadgeClass(alert.resourceType)}>
{alert.resourceType}
</span>
</TableCell>
<TableCell class="p-1 px-1 text-center sm:p-1.5 sm:px-2">
<span class={getAlertIncidentLevelBadgeClass(alert.severity)}>
{alert.severity}
</span>
</TableCell>
<TableCell
class="max-w-[300px] truncate p-1 px-1 text-base-content sm:p-1.5 sm:px-2"
title={alert.description}
>
{alert.description}
</TableCell>
<TableCell class="p-1 px-1 text-center text-muted sm:p-1.5 sm:px-2">
{alert.duration}
</TableCell>
<TableCell class="p-1 px-1 text-center sm:p-1.5 sm:px-2">
<span class={historyStatusPresentation.className}>
{historyStatusPresentation.label}
</span>
</TableCell>
<TableCell class="truncate p-1 px-1 text-muted sm:p-1.5 sm:px-2">
{alert.nodeDisplayName || alert.node || '—'}
</TableCell>
<TableCell class="p-1 px-1 text-center sm:p-1.5 sm:px-2">
<div class="flex items-center justify-center gap-1">
<Show when={alert.source === 'alert'}>
<button
type="button"
class="rounded-md border border-border px-2 py-1 text-[10px] text-muted hover:bg-surface-hover"
onClick={() => {
void props.state.toggleIncidentTimeline(
rowKey,
alert.id,
alert.startTime,
);
}}
>
{props.state.expandedIncidents().has(rowKey)
? 'Hide'
: 'Timeline'}
</button>
</Show>
<Show when={alert.source === 'alert' && alert.resourceId}>
<button
type="button"
class="rounded-md border border-border px-2 py-1 text-[10px] text-muted hover:bg-surface-hover"
title={getAlertResourceIncidentViewTitle()}
onClick={() => {
void props.state.openResourceIncidentPanel(
alert.resourceId as string,
alert.resourceName,
);
}}
>
Resource
</button>
</Show>
<Show
when={
alert.source === 'alert' &&
(alert.status === 'active' ||
alert.status === 'acknowledged')
}
>
<InvestigateAlertButton
alert={{
id: alert.id,
type: alert.rawAlertType || alert.title,
level: alert.severity as 'warning' | 'critical',
resourceId: alert.resourceId || '',
resourceName: alert.resourceName,
node: alert.node || '',
nodeDisplayName: alert.nodeDisplayName,
instance: '',
message: alert.description || '',
value: 0,
threshold: 0,
startTime: alert.startTime,
lastSeen: alert.startTime,
acknowledged: alert.status === 'acknowledged',
}}
resourceType={alert.resourceType}
variant="icon"
size="sm"
licenseLocked={
!props.hasAIAlertsFeature() && !props.licenseLoading()
}
/>
</Show>
</div>
</TableCell>
</TableRow>
<Show when={alert.source === 'alert' && props.state.expandedIncidents().has(rowKey)}>
<TableRow class="border-b border-border bg-surface-alt">
<TableCell colspan={11} class="p-3">
<IncidentTimelinePanel
loading={props.state.incidentLoading()[rowKey]}
error={props.state.incidentErrors()[rowKey]}
timeline={props.state.incidentTimelines()[rowKey]}
filters={props.state.historyIncidentEventFilters}
setFilters={props.state.setHistoryIncidentEventFilters}
filterVariant="compact"
eventCardVariant="surface"
noteDraft={props.state.incidentNoteDrafts()[rowKey] || ''}
onNoteDraftChange={(value) =>
props.state.setIncidentNoteDraft(rowKey, value)
}
noteSaving={props.state.incidentNoteSaving().has(rowKey)}
onSaveNote={() => {
void props.state.saveIncidentNote(
rowKey,
alert.id,
alert.startTime,
);
}}
onRetry={() => {
void props.state.loadIncidentTimeline(
rowKey,
alert.id,
alert.startTime,
);
}}
/>
</TableCell>
</TableRow>
</Show>
</>
);
}}
{(alert) => (
<AlertHistoryTableAlertRow
alert={alert}
state={props.state}
hasAIAlertsFeature={props.hasAIAlertsFeature}
licenseLoading={props.licenseLoading}
/>
)}
</For>
</>
)}
@@ -11,6 +11,8 @@ import alertAcknowledgementStateSource from '@/features/alerts/useAlertAcknowled
import alertHistoryAdministrationCardSource from '@/features/alerts/AlertHistoryAdministrationCard.tsx?raw';
import alertHistoryFiltersCardSource from '@/features/alerts/AlertHistoryFiltersCard.tsx?raw';
import alertHistoryFrequencyCardSource from '@/features/alerts/AlertHistoryFrequencyCard.tsx?raw';
import alertHistoryTableAlertRowSource from '@/features/alerts/AlertHistoryTableAlertRow.tsx?raw';
import alertHistoryTableGroupRowSource from '@/features/alerts/AlertHistoryTableGroupRow.tsx?raw';
import alertHistoryTableSectionSource from '@/features/alerts/AlertHistoryTableSection.tsx?raw';
import alertResourceIncidentsPanelSource from '@/features/alerts/AlertResourceIncidentsPanel.tsx?raw';
import alertHistoryStateSource from '@/features/alerts/useAlertHistoryState.ts?raw';
@@ -315,8 +317,15 @@ describe('tab path helpers', () => {
expect(alertResourceIncidentsPanelSource).toContain('IncidentEventFilters');
expect(alertResourceIncidentsPanelSource).toContain('IncidentTimelineEventCard');
expect(alertHistoryTableSectionSource).toContain('export function AlertHistoryTableSection');
expect(alertHistoryTableSectionSource).toContain('IncidentTimelinePanel');
expect(alertHistoryTableSectionSource).toContain('InvestigateAlertButton');
expect(alertHistoryTableSectionSource).toContain('AlertHistoryTableGroupRow');
expect(alertHistoryTableSectionSource).toContain('AlertHistoryTableAlertRow');
expect(alertHistoryTableSectionSource).not.toContain('IncidentTimelinePanel');
expect(alertHistoryTableSectionSource).not.toContain('InvestigateAlertButton');
expect(alertHistoryTableGroupRowSource).toContain('export function AlertHistoryTableGroupRow');
expect(alertHistoryTableGroupRowSource).toContain('getGroupSummaryLabel');
expect(alertHistoryTableAlertRowSource).toContain('export function AlertHistoryTableAlertRow');
expect(alertHistoryTableAlertRowSource).toContain('IncidentTimelinePanel');
expect(alertHistoryTableAlertRowSource).toContain('InvestigateAlertButton');
expect(alertHistoryAdministrationCardSource).toContain(
'export function AlertHistoryAdministrationCard',
);
@@ -335,6 +335,8 @@ import alertAcknowledgementStateSource from '@/features/alerts/useAlertAcknowled
import alertHistoryAdministrationCardSource from '@/features/alerts/AlertHistoryAdministrationCard.tsx?raw';
import alertHistoryFiltersCardSource from '@/features/alerts/AlertHistoryFiltersCard.tsx?raw';
import alertHistoryFrequencyCardSource from '@/features/alerts/AlertHistoryFrequencyCard.tsx?raw';
import alertHistoryTableAlertRowSource from '@/features/alerts/AlertHistoryTableAlertRow.tsx?raw';
import alertHistoryTableGroupRowSource from '@/features/alerts/AlertHistoryTableGroupRow.tsx?raw';
import alertHistoryTableSectionSource from '@/features/alerts/AlertHistoryTableSection.tsx?raw';
import alertResourceIncidentsPanelSource from '@/features/alerts/AlertResourceIncidentsPanel.tsx?raw';
import alertHistoryStateSource from '@/features/alerts/useAlertHistoryState.ts?raw';
@@ -2803,8 +2805,10 @@ describe('frontend resource type boundaries', () => {
'export function getDeployInstallCommandLoadingState',
);
expect(deployStatusPresentationSource).toContain('export const getDeployStatusPresentation');
expect(alertHistoryTableSectionSource).toContain('getAlertHistoryStatusPresentation');
expect(alertHistoryTableSectionSource).toContain('getAlertIncidentLevelBadgeClass');
expect(alertHistoryTableSectionSource).toContain('AlertHistoryTableGroupRow');
expect(alertHistoryTableSectionSource).toContain('AlertHistoryTableAlertRow');
expect(alertHistoryTableAlertRowSource).toContain('getAlertHistoryStatusPresentation');
expect(alertHistoryTableAlertRowSource).toContain('getAlertIncidentLevelBadgeClass');
expect(alertsPageSource).toContain(
"import { AlertsConfigurationSurface } from '@/features/alerts/AlertsConfigurationSurface';",
);
@@ -2910,13 +2914,18 @@ describe('frontend resource type boundaries', () => {
expect(alertResourceIncidentsPanelSource).not.toContain('getAlertIncidentTimelineEventCardClass');
expect(alertResourceIncidentsPanelSource).not.toContain('getAlertIncidentTimelineDetailClass');
expect(alertResourceIncidentsPanelSource).not.toContain('getAlertIncidentTimelineCommandClass');
expect(alertHistoryTableSectionSource).toContain('getAlertHistoryStatusPresentation');
expect(alertHistoryTableSectionSource).toContain('getAlertHistorySourcePresentation');
expect(alertHistoryTableSectionSource).toContain('getAlertHistoryResourceTypeBadgeClass');
expect(alertHistoryTableSectionSource).toContain('IncidentTimelinePanel');
expect(alertHistoryTableSectionSource).toContain('InvestigateAlertButton');
expect(alertHistoryTableSectionSource).toContain('AlertHistoryTableGroupRow');
expect(alertHistoryTableSectionSource).toContain('AlertHistoryTableAlertRow');
expect(alertHistoryTableSectionSource).toContain('getAlertHistoryEmptyState');
expect(alertHistoryTableSectionSource).toContain('getAlertHistoryLoadingState');
expect(alertHistoryTableSectionSource).not.toContain('IncidentTimelinePanel');
expect(alertHistoryTableSectionSource).not.toContain('InvestigateAlertButton');
expect(alertHistoryTableGroupRowSource).toContain('getGroupSummaryLabel');
expect(alertHistoryTableAlertRowSource).toContain('getAlertHistoryStatusPresentation');
expect(alertHistoryTableAlertRowSource).toContain('getAlertHistorySourcePresentation');
expect(alertHistoryTableAlertRowSource).toContain('getAlertHistoryResourceTypeBadgeClass');
expect(alertHistoryTableAlertRowSource).toContain('IncidentTimelinePanel');
expect(alertHistoryTableAlertRowSource).toContain('InvestigateAlertButton');
expect(alertHistoryAdministrationCardSource).toContain('getAlertAdministrationSectionTitle');
expect(alertHistoryAdministrationCardSource).toContain(
'getAlertAdministrationSectionDescription',