Attach incident timeline briefings to Assistant

This commit is contained in:
rcourtman
2026-05-07 02:24:05 +01:00
parent af067372b6
commit adedc2ff09
11 changed files with 537 additions and 73 deletions
@@ -72,7 +72,10 @@ runtime cost control, and shared AI transport surfaces.
accessible-name safe: labelled icon SVGs may remain meaningful when rendered
standalone, but `frontend-modern/src/AppLayout.tsx` must treat them as
decorative inside tabs so the announced tab name comes from product chrome
and meaningful badge text.
and meaningful badge text rather than icon title duplication. Scoped
approval handoffs sourced from Patrol, active alerts, or alert incident
timelines must render as source-named investigation handoffs in the drawer
instead of generic dashboard briefs.
9. Add or change public AI overview wording through `docs/AI.md`; it may
describe Assistant and Patrol capabilities, but it must not revive legacy
commercial shorthand such as `incident memory` as a current product promise.
@@ -81,6 +81,8 @@ operator-facing alert routing behavior for live runtime alerts.
59. `internal/alerts/active_cleanup.go`
60. `frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx`
61. `frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts`
62. `frontend-modern/src/components/Alerts/IncidentAssistantHandoffButton.tsx`
63. `frontend-modern/src/components/Alerts/incidentAssistantHandoffModel.ts`
## Shared Boundaries
@@ -108,9 +110,14 @@ operator-facing alert routing behavior for live runtime alerts.
`frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx` and
`frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts`;
these handoffs must preserve alert context, force request-scoped approval
mode, attach a visible alert-owned Assistant drawer briefing, and must not
instruct the Assistant to execute diagnostics or remediation without operator
approval.
mode, and render a visible Alerts-owned briefing in the Assistant drawer
without transferring raw command payloads.
8. Add or change Pulse Assistant incident timeline handoffs through
`frontend-modern/src/components/Alerts/IncidentAssistantHandoffButton.tsx`
and `frontend-modern/src/components/Alerts/incidentAssistantHandoffModel.ts`;
these handoffs must preserve sanitized incident facts and timeline event
summaries, force request-scoped approval mode, and keep raw command/output
details in the incident or approval surface rather than the chat handoff.
## Forbidden Paths
@@ -697,8 +704,11 @@ That shared timeline runtime state now routes through
owns incident timeline fetch, expansion state, note-save flow, and shared
event-filter state for both `frontend-modern/src/features/alerts/OverviewTab.tsx`
and `frontend-modern/src/features/alerts/tabs/HistoryTab.tsx`. Future incident
timeline control flow should land in that feature hook instead of being
forked back into either alert surface.
timeline control flow should land in that feature hook instead of being forked
back into either alert surface. Alert incident timeline handoffs into Pulse
Assistant are now owned by the Alerts incident handoff model and carry only
sanitized incident facts plus event summaries; raw command and output details
stay in the incident timeline or approval surface.
Resource incident panel cards, summary rows, and toggle-button presentation
now also route through `frontend-modern/src/utils/alertIncidentPresentation.ts`
@@ -1049,6 +1049,9 @@ that mention the Infrastructure settings destination now consume
`frontend-modern/src/utils/infrastructureSettingsPresentation.ts` for the
canonical `Settings → Infrastructure` label and source-strategy copy. Shared
primitives must not fork that string or revive removed nested route labels.
The shared Assistant drawer owns source-named approval banners for governed
handoffs. Patrol handoffs render as Patrol, and alert plus alert incident
timeline handoffs render as alert investigations rather than dashboard briefs.
`SettingsTab` no longer includes `infrastructure-connections` or
`infrastructure-install`. The single `infrastructure-systems` entry in
@@ -0,0 +1,40 @@
import SparklesIcon from 'lucide-solid/icons/sparkles';
import { aiChatStore } from '@/stores/aiChat';
import type { Incident } from '@/types/api';
import { buildAlertIncidentAssistantHandoff } from './incidentAssistantHandoffModel';
interface IncidentAssistantHandoffButtonProps {
incident: Incident;
label?: string;
class?: string;
}
export function IncidentAssistantHandoffButton(props: IncidentAssistantHandoffButtonProps) {
if (aiChatStore.enabled !== true) {
return null;
}
const handleClick = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const handoff = buildAlertIncidentAssistantHandoff({ incident: props.incident });
aiChatStore.openWithPrompt(handoff.prompt, handoff.context);
};
return (
<button
type="button"
class={
props.class ||
'inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs font-medium text-blue-600 transition-colors hover:bg-surface-hover hover:text-blue-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:text-blue-400 dark:hover:text-blue-300'
}
title="Discuss this incident with Pulse Assistant"
aria-label={`Discuss incident ${props.incident.id} with Pulse Assistant`}
onClick={handleClick}
>
<SparklesIcon class="h-3.5 w-3.5" aria-hidden="true" />
<span>{props.label || 'Discuss with Assistant'}</span>
</button>
);
}
@@ -2,6 +2,7 @@ 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 { IncidentAssistantHandoffButton } from '@/components/Alerts/IncidentAssistantHandoffButton';
import { IncidentTimelineEventCard } from '@/components/Alerts/IncidentTimelineEventCard';
import {
getAlertTimelineEmptyState,
@@ -49,18 +50,23 @@ export function IncidentTimelinePanel(props: IncidentTimelinePanelProps) {
<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 class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<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>
<IncidentAssistantHandoffButton incident={loadedTimeline()} />
</div>
<Show when={events().length > 0}>
<IncidentEventFilters
@@ -2,6 +2,7 @@ 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 { aiChatStore } from '@/stores/aiChat';
import type { Incident } from '@/types/api';
function makeTimeline(overrides: Partial<Incident> = {}): Incident {
@@ -33,8 +34,11 @@ function makeTimeline(overrides: Partial<Incident> = {}): Incident {
describe('IncidentTimelinePanel', () => {
afterEach(() => {
aiChatStore.close();
aiChatStore.clearAllContext();
aiChatStore.setEnabled(false);
cleanup();
vi.clearAllMocks();
vi.restoreAllMocks();
});
it('renders loading, error, and unavailable states through shared copy', () => {
@@ -115,4 +119,46 @@ describe('IncidentTimelinePanel', () => {
fireEvent.click(screen.getByText('Save Note'));
expect(handleSave).toHaveBeenCalledTimes(1);
});
it('opens Assistant with a sanitized incident briefing from the loaded timeline', () => {
const [filters, setFilters] = createSignal(new Set(['command']));
const openWithPromptSpy = vi.spyOn(aiChatStore, 'openWithPrompt');
aiChatStore.setEnabled(true);
render(() => (
<IncidentTimelinePanel
loading={false}
error={false}
timeline={makeTimeline()}
filters={filters}
setFilters={setFilters}
filterVariant="compact"
eventCardVariant="surface"
noteDraft=""
onNoteDraftChange={vi.fn()}
noteSaving={false}
onSaveNote={vi.fn()}
onRetry={vi.fn()}
/>
));
fireEvent.click(
screen.getByRole('button', {
name: 'Discuss incident incident-1 with Pulse Assistant',
}),
);
expect(openWithPromptSpy).toHaveBeenCalledTimes(1);
const [prompt, context] = openWithPromptSpy.mock.calls[0] as [string, Record<string, unknown>];
expect(prompt).toContain('Discuss this Warning alert incident from Pulse Alerts.');
expect(context).toMatchObject({
autonomousMode: false,
briefing: {
sourceLabel: 'Pulse Alerts',
title: 'Incident timeline attached',
actionLabel: 'Discuss incident incident-1',
},
});
expect(JSON.stringify(context)).not.toContain('systemctl status pulse');
});
});
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import type { Incident } from '@/types/api';
import { buildAlertIncidentAssistantHandoff } from '../incidentAssistantHandoffModel';
function makeIncident(overrides: Partial<Incident> = {}): Incident {
return {
id: 'incident-1',
alertIdentifier: 'docker:app-1::docker-container-health',
alertType: 'docker-container-health',
level: 'critical',
resourceId: 'docker:app-1',
resourceName: 'checkout-api',
resourceType: 'docker-container',
node: 'edge-1',
message: 'Container health check is failing',
status: 'open',
openedAt: '2026-03-20T10:00:00Z',
acknowledged: false,
events: [
{
id: 'event-1',
type: 'command',
timestamp: '2026-03-20T10:02:00Z',
summary: 'systemctl restart checkout-api',
details: {
command: 'systemctl restart checkout-api',
output_excerpt: 'token=secret-value',
},
},
{
id: 'event-2',
type: 'ai_analysis',
timestamp: '2026-03-20T10:03:00Z',
summary: 'Health check failure correlated with recent deployment',
},
],
...overrides,
};
}
describe('incidentAssistantHandoffModel', () => {
it('builds an approval-required incident timeline handoff without raw command payloads', () => {
const handoff = buildAlertIncidentAssistantHandoff({
incident: makeIncident(),
now: new Date('2026-03-20T10:05:00Z'),
});
expect(handoff.prompt).toContain('Discuss this Critical alert incident from Pulse Alerts.');
expect(handoff.prompt).toContain('Command details and output stay in the incident');
expect(handoff.context).toMatchObject({
targetType: 'app-container',
targetId: 'docker:app-1',
autonomousMode: false,
briefing: {
sourceLabel: 'Pulse Alerts',
title: 'Incident timeline attached',
subject: 'Critical docker-container-health on checkout-api',
statusLabel: 'Critical incident · Open · 5 mins',
detailLines: [
'2 timeline events',
'Node: edge-1',
'Message: Container health check is failing',
],
evidence: [
'Command: Command event recorded',
'AI Analysis: Health check failure correlated with recent deployment',
],
actionLabel: 'Discuss incident incident-1',
safetyNote: 'Diagnostics and remediation require operator approval.',
},
context: {
alertIncidentId: 'incident-1',
alertIdentifier: 'docker:app-1::docker-container-health',
alertType: 'docker-container-health',
alertLevel: 'critical',
alertStatus: 'open',
resourceName: 'checkout-api',
resourceType: 'docker-container',
eventCount: 2,
eventSummaries: [
{
id: 'event-1',
type: 'command',
timestamp: '2026-03-20T10:02:00Z',
summary: 'Command event recorded',
},
{
id: 'event-2',
type: 'ai_analysis',
timestamp: '2026-03-20T10:03:00Z',
summary: 'Health check failure correlated with recent deployment',
},
],
},
});
expect(JSON.stringify(handoff)).not.toContain('systemctl');
expect(JSON.stringify(handoff)).not.toContain('secret-value');
});
});
@@ -0,0 +1,161 @@
import type { AIChatContext } from '@/stores/aiChat';
import type { Incident, IncidentEvent } from '@/types/api';
import { resolveAlertTargetType } from '@/utils/alertTargetTypes';
interface BuildAlertIncidentAssistantHandoffInput {
incident: Incident;
now?: Date;
}
interface AlertIncidentAssistantHandoff {
prompt: string;
context: Omit<AIChatContext, 'initialPrompt'>;
}
interface SanitizedIncidentEvent {
id: string;
type: string;
timestamp: string;
summary: string;
}
const MAX_BRIEFING_EVENTS = 3;
const MAX_CONTEXT_EVENTS = 8;
const LABEL_INITIALISMS: Record<string, string> = {
ai: 'AI',
api: 'API',
cpu: 'CPU',
io: 'I/O',
zfs: 'ZFS',
};
export function buildAlertIncidentAssistantHandoff({
incident,
now = new Date(),
}: BuildAlertIncidentAssistantHandoffInput): AlertIncidentAssistantHandoff {
const resourceLabel = incident.resourceName || incident.resourceId || 'unknown resource';
const targetType = resolveAlertTargetType({
alertType: incident.alertType,
resourceType: incident.resourceType,
resourceId: incident.resourceId,
});
const levelLabel = formatIncidentLabel(incident.level);
const statusLabel = formatIncidentLabel(incident.status);
const durationText = formatIncidentDuration(incident.openedAt, incident.closedAt, now);
const events = sanitizeIncidentEvents(incident.events ?? []);
const eventCount = events.length;
const eventCountLabel = `${eventCount} timeline event${eventCount === 1 ? '' : 's'}`;
const prompt = [
`Discuss this ${levelLabel} alert incident from Pulse Alerts.`,
'',
`**Resource:** ${resourceLabel}`,
`**Alert Type:** ${incident.alertType}`,
`**Status:** ${statusLabel}`,
`**Duration:** ${durationText}`,
incident.node ? `**Node:** ${incident.node}` : undefined,
incident.message ? `**Message:** ${incident.message}` : undefined,
'',
'Use the attached sanitized incident timeline context. Command details and output stay in the incident or approval surface; do not infer, repeat, or execute raw command text from this chat handoff.',
'',
'Please:',
'1. Explain what the incident record says happened',
'2. Identify the likely cause and any uncertainty',
'3. Call out related checks the operator should review',
'4. Ask for approval before running diagnostics or remediation',
]
.filter((line): line is string => line !== undefined)
.join('\n');
return {
prompt,
context: {
targetType,
targetId: incident.resourceId,
autonomousMode: false,
briefing: {
sourceLabel: 'Pulse Alerts',
title: 'Incident timeline attached',
subject: `${levelLabel} ${incident.alertType} on ${resourceLabel}`,
statusLabel: `${levelLabel} incident · ${statusLabel} · ${durationText}`,
detailLines: [
eventCountLabel,
incident.node ? `Node: ${incident.node}` : undefined,
incident.message ? `Message: ${incident.message}` : undefined,
].filter((line): line is string => Boolean(line)),
evidence: events
.slice(0, MAX_BRIEFING_EVENTS)
.map((event) => `${formatIncidentLabel(event.type)}: ${event.summary}`),
actionLabel: `Discuss incident ${incident.id}`,
safetyNote: 'Diagnostics and remediation require operator approval.',
},
context: {
alertIncidentId: incident.id,
alertIdentifier: incident.alertIdentifier,
alertType: incident.alertType,
alertLevel: incident.level,
alertStatus: incident.status,
alertMessage: incident.message,
resourceName: resourceLabel,
resourceType: incident.resourceType,
node: incident.node,
instance: incident.instance,
openedAt: incident.openedAt,
closedAt: incident.closedAt,
acknowledged: incident.acknowledged,
eventCount,
eventSummaries: events.slice(0, MAX_CONTEXT_EVENTS),
},
},
};
}
function sanitizeIncidentEvents(events: IncidentEvent[]): SanitizedIncidentEvent[] {
return events.map((event) => ({
id: event.id,
type: event.type,
timestamp: event.timestamp,
summary: sanitizeIncidentEventSummary(event),
}));
}
function sanitizeIncidentEventSummary(event: IncidentEvent): string {
const normalizedType = event.type.toLowerCase();
if (normalizedType.includes('command')) {
return 'Command event recorded';
}
const summary = event.summary.trim();
return summary.length > 0 ? summary : 'Timeline event recorded';
}
function formatIncidentDuration(openedAt: string, closedAt: string | undefined, now: Date): string {
const openedMs = new Date(openedAt).getTime();
const closedMs = closedAt ? new Date(closedAt).getTime() : now.getTime();
if (!Number.isFinite(openedMs) || !Number.isFinite(closedMs)) {
return 'unknown duration';
}
const durationMins = Math.floor(Math.max(0, closedMs - openedMs) / 60000);
if (durationMins < 60) {
return `${durationMins} min${durationMins === 1 ? '' : 's'}`;
}
const durationHours = Math.floor(durationMins / 60);
if (durationHours < 24) {
return `${durationHours}h ${durationMins % 60}m`;
}
return `${Math.floor(durationHours / 24)}d ${durationHours % 24}h`;
}
function formatIncidentLabel(value: string): string {
return value
.split(/[-_\s]+/)
.filter(Boolean)
.map(
(part) =>
LABEL_INITIALISMS[part.toLowerCase()] || part.charAt(0).toUpperCase() + part.slice(1),
)
.join(' ');
}
@@ -2,6 +2,7 @@ import { A } from '@solidjs/router';
import { For, Show } from 'solid-js';
import { IncidentEventFilters } from '@/components/Alerts/IncidentEventFilters';
import { IncidentAssistantHandoffButton } from '@/components/Alerts/IncidentAssistantHandoffButton';
import { IncidentTimelineEventCard } from '@/components/Alerts/IncidentTimelineEventCard';
import { Card } from '@/components/shared/Card';
import {
@@ -130,9 +131,7 @@ export function AlertResourceIncidentsPanel(props: AlertResourceIncidentsPanelPr
<Show
when={incidents().length > 0}
fallback={
<p class="mt-2 text-xs text-muted">
{getAlertResourceIncidentEmptyState().text}
</p>
<p class="mt-2 text-xs text-muted">{getAlertResourceIncidentEmptyState().text}</p>
}
>
<div class="mt-3 space-y-3">
@@ -164,31 +163,32 @@ export function AlertResourceIncidentsPanel(props: AlertResourceIncidentsPanelPr
return (
<div class={getAlertResourceIncidentCardClass()}>
<div class={getAlertIncidentTimelineMetaRowClass()}>
<span class={getAlertIncidentTimelineHeadingClass()}>
{incident.alertType}
</span>
<span class={getAlertIncidentLevelBadgeClass(incident.level)}>
{incident.level}
</span>
<span class={statusPresentation.className}>
{statusPresentation.label}
</span>
<span>opened {new Date(incident.openedAt).toLocaleString()}</span>
<Show when={incident.closedAt}>
<span>
closed {new Date(incident.closedAt as string).toLocaleString()}
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div class={getAlertIncidentTimelineMetaRowClass()}>
<span class={getAlertIncidentTimelineHeadingClass()}>
{incident.alertType}
</span>
</Show>
<span class={getAlertIncidentLevelBadgeClass(incident.level)}>
{incident.level}
</span>
<span class={statusPresentation.className}>
{statusPresentation.label}
</span>
<span>opened {new Date(incident.openedAt).toLocaleString()}</span>
<Show when={incident.closedAt}>
<span>
closed {new Date(incident.closedAt as string).toLocaleString()}
</span>
</Show>
</div>
<IncidentAssistantHandoffButton incident={incident} />
</div>
<Show when={incident.message}>
<p class={getAlertIncidentTimelineOutputClass()}>{incident.message}</p>
</Show>
<Show when={incident.acknowledged && incident.ackUser}>
<p class={getAlertIncidentTimelineOutputClass()}>
{getAlertResourceIncidentAcknowledgedByLabel(
incident.ackUser ?? '',
)}
{getAlertResourceIncidentAcknowledgedByLabel(incident.ackUser ?? '')}
</p>
</Show>
<Show when={events.length > 0}>
@@ -1,8 +1,9 @@
import { render, screen } from '@solidjs/testing-library';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import type { JSX } from 'solid-js';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AlertResourceIncidentsPanel } from '../AlertResourceIncidentsPanel';
import { aiChatStore } from '@/stores/aiChat';
vi.mock('@solidjs/router', () => ({
A: (props: { href: string; children?: JSX.Element; [key: string]: unknown }) => (
@@ -13,36 +14,46 @@ vi.mock('@solidjs/router', () => ({
}));
describe('AlertResourceIncidentsPanel', () => {
afterEach(() => {
aiChatStore.close();
aiChatStore.clearAllContext();
aiChatStore.setEnabled(false);
cleanup();
vi.restoreAllMocks();
});
it('surfaces canonical investigation handoff links for TrueNAS resources', () => {
render(() => (
<AlertResourceIncidentsPanel
state={{
resourceIncidentPanel: () => ({
resourceId: 'truenas-main',
resourceName: 'TrueNAS Main',
}),
resourceIncidents: () => ({
'truenas-main': [
{
id: 'incident-1',
alertType: 'Storage Health',
level: 'critical',
status: 'open',
acknowledged: false,
openedAt: '2026-03-30T09:00:00Z',
message: 'Pool tank is DEGRADED',
events: [],
},
],
}),
resourceIncidentLoading: () => ({ 'truenas-main': false }),
expandedResourceIncidentIds: () => new Set<string>(),
resourceIncidentEventFilters: () => new Set<string>(['opened']),
setResourceIncidentEventFilters: vi.fn(),
refreshResourceIncidentPanel: vi.fn(),
setResourceIncidentPanel: vi.fn(),
toggleResourceIncidentDetails: vi.fn(),
} as any}
state={
{
resourceIncidentPanel: () => ({
resourceId: 'truenas-main',
resourceName: 'TrueNAS Main',
}),
resourceIncidents: () => ({
'truenas-main': [
{
id: 'incident-1',
alertType: 'Storage Health',
level: 'critical',
status: 'open',
acknowledged: false,
openedAt: '2026-03-30T09:00:00Z',
message: 'Pool tank is DEGRADED',
events: [],
},
],
}),
resourceIncidentLoading: () => ({ 'truenas-main': false }),
expandedResourceIncidentIds: () => new Set<string>(),
resourceIncidentEventFilters: () => new Set<string>(['opened']),
setResourceIncidentEventFilters: vi.fn(),
refreshResourceIncidentPanel: vi.fn(),
setResourceIncidentPanel: vi.fn(),
toggleResourceIncidentDetails: vi.fn(),
} as any
}
getResource={(resourceId) =>
resourceId === 'truenas-main'
? ({
@@ -67,10 +78,7 @@ describe('AlertResourceIncidentsPanel', () => {
).toHaveAttribute('href', '/infrastructure?resource=truenas-main');
expect(
screen.getByRole('link', { name: 'Open related workloads for TrueNAS Main' }),
).toHaveAttribute(
'href',
'/workloads?type=app-container&platform=truenas&agent=truenas-main',
);
).toHaveAttribute('href', '/workloads?type=app-container&platform=truenas&agent=truenas-main');
expect(
screen.getByRole('link', { name: 'Open related storage for TrueNAS Main' }),
).toHaveAttribute('href', '/storage?source=truenas&node=truenas-main');
@@ -78,4 +86,79 @@ describe('AlertResourceIncidentsPanel', () => {
screen.getByRole('link', { name: 'Open related recovery for TrueNAS Main' }),
).toHaveAttribute('href', '/recovery?platform=truenas&node=truenas-main');
});
it('opens Assistant from a resource incident without carrying raw command details', () => {
const openWithPromptSpy = vi.spyOn(aiChatStore, 'openWithPrompt');
aiChatStore.setEnabled(true);
render(() => (
<AlertResourceIncidentsPanel
state={
{
resourceIncidentPanel: () => ({
resourceId: 'truenas-main',
resourceName: 'TrueNAS Main',
}),
resourceIncidents: () => ({
'truenas-main': [
{
id: 'incident-1',
alertIdentifier: 'storage:tank::zfs-pool-state',
alertType: 'zfs-pool-state',
level: 'critical',
resourceId: 'storage:tank',
resourceName: 'tank',
resourceType: 'storage',
status: 'open',
acknowledged: false,
openedAt: '2026-03-30T09:00:00Z',
message: 'Pool tank is DEGRADED',
events: [
{
id: 'event-1',
type: 'command',
timestamp: '2026-03-30T09:01:00Z',
summary: 'zpool clear tank',
details: {
command: 'zpool clear tank',
output_excerpt: 'secret-output',
},
},
],
},
],
}),
resourceIncidentLoading: () => ({ 'truenas-main': false }),
expandedResourceIncidentIds: () => new Set<string>(),
resourceIncidentEventFilters: () => new Set<string>(['command']),
setResourceIncidentEventFilters: vi.fn(),
refreshResourceIncidentPanel: vi.fn(),
setResourceIncidentPanel: vi.fn(),
toggleResourceIncidentDetails: vi.fn(),
} as any
}
getResource={() => undefined}
/>
));
fireEvent.click(
screen.getByRole('button', {
name: 'Discuss incident incident-1 with Pulse Assistant',
}),
);
expect(openWithPromptSpy).toHaveBeenCalledTimes(1);
const [, context] = openWithPromptSpy.mock.calls[0] as [string, Record<string, unknown>];
expect(context).toMatchObject({
targetType: 'storage',
targetId: 'storage:tank',
autonomousMode: false,
briefing: {
sourceLabel: 'Pulse Alerts',
title: 'Incident timeline attached',
},
});
expect(JSON.stringify(context)).not.toContain('zpool clear tank');
expect(JSON.stringify(context)).not.toContain('secret-output');
});
});
@@ -19,6 +19,9 @@ import alertHistoryTableAlertRowSource from '@/features/alerts/AlertHistoryTable
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 incidentAssistantHandoffButtonSource from '@/components/Alerts/IncidentAssistantHandoffButton.tsx?raw';
import incidentAssistantHandoffModelSource from '@/components/Alerts/incidentAssistantHandoffModel.ts?raw';
import incidentTimelinePanelSource from '@/components/Alerts/IncidentTimelinePanel.tsx?raw';
import alertHistoryStateSource from '@/features/alerts/useAlertHistoryState.ts?raw';
import alertResourceIncidentsStateSource from '@/features/alerts/useAlertResourceIncidentsState.ts?raw';
import alertHistoryModelSource from '@/features/alerts/alertHistoryModel.ts?raw';
@@ -466,6 +469,14 @@ describe('tab path helpers', () => {
expect(alertResourceIncidentsPanelSource).not.toContain('buildInfrastructureResourceLink');
expect(alertResourceIncidentsPanelSource).not.toContain('buildResourceSurfaceLinksForResource');
expect(alertResourceIncidentsPanelSource).toContain('{link.compactLabel}');
expect(alertResourceIncidentsPanelSource).toContain('IncidentAssistantHandoffButton');
expect(incidentTimelinePanelSource).toContain('IncidentAssistantHandoffButton');
expect(incidentAssistantHandoffButtonSource).toContain('buildAlertIncidentAssistantHandoff');
expect(incidentAssistantHandoffButtonSource).toContain('aiChatStore.openWithPrompt');
expect(incidentAssistantHandoffModelSource).toContain('autonomousMode: false');
expect(incidentAssistantHandoffModelSource).toContain('Command event recorded');
expect(incidentAssistantHandoffModelSource).not.toContain('details.command');
expect(incidentAssistantHandoffModelSource).not.toContain('output_excerpt');
expect(alertHistoryTableSectionSource).toContain('export function AlertHistoryTableSection');
expect(alertHistoryTableSectionSource).toContain('AlertHistoryTableGroupRow');
expect(alertHistoryTableSectionSource).toContain('AlertHistoryTableAlertRow');