diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 413a0541a..540e8943f 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 4e88a0ed3..ca272f9c1 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -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` diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 93ccf7827..dc08e1250 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/frontend-modern/src/components/Alerts/IncidentAssistantHandoffButton.tsx b/frontend-modern/src/components/Alerts/IncidentAssistantHandoffButton.tsx new file mode 100644 index 000000000..9e3cc9d29 --- /dev/null +++ b/frontend-modern/src/components/Alerts/IncidentAssistantHandoffButton.tsx @@ -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 ( + + ); +} diff --git a/frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx b/frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx index c10b1b119..7bb98a28e 100644 --- a/frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx +++ b/frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx @@ -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) { {(loadedTimeline) => (
-
- Incident - {loadedTimeline().status} - - acknowledged - - - opened {new Date(loadedTimeline().openedAt).toLocaleString()} - - - closed {new Date(loadedTimeline().closedAt as string).toLocaleString()} - +
+
+ Incident + {loadedTimeline().status} + + acknowledged + + + opened {new Date(loadedTimeline().openedAt).toLocaleString()} + + + + closed {new Date(loadedTimeline().closedAt as string).toLocaleString()} + + +
+
0}> = {}): Incident { @@ -33,8 +34,11 @@ function makeTimeline(overrides: Partial = {}): 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(() => ( + + )); + + 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]; + 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'); + }); }); diff --git a/frontend-modern/src/components/Alerts/__tests__/incidentAssistantHandoffModel.test.ts b/frontend-modern/src/components/Alerts/__tests__/incidentAssistantHandoffModel.test.ts new file mode 100644 index 000000000..c2f921bf0 --- /dev/null +++ b/frontend-modern/src/components/Alerts/__tests__/incidentAssistantHandoffModel.test.ts @@ -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 { + 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'); + }); +}); diff --git a/frontend-modern/src/components/Alerts/incidentAssistantHandoffModel.ts b/frontend-modern/src/components/Alerts/incidentAssistantHandoffModel.ts new file mode 100644 index 000000000..351dd307a --- /dev/null +++ b/frontend-modern/src/components/Alerts/incidentAssistantHandoffModel.ts @@ -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; +} + +interface SanitizedIncidentEvent { + id: string; + type: string; + timestamp: string; + summary: string; +} + +const MAX_BRIEFING_EVENTS = 3; +const MAX_CONTEXT_EVENTS = 8; +const LABEL_INITIALISMS: Record = { + 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(' '); +} diff --git a/frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx b/frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx index 2ef87a9b4..8987d2306 100644 --- a/frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx +++ b/frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx @@ -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 0} fallback={ -

- {getAlertResourceIncidentEmptyState().text} -

+

{getAlertResourceIncidentEmptyState().text}

} >
@@ -164,31 +163,32 @@ export function AlertResourceIncidentsPanel(props: AlertResourceIncidentsPanelPr return (
-
- - {incident.alertType} - - - {incident.level} - - - {statusPresentation.label} - - opened {new Date(incident.openedAt).toLocaleString()} - - - closed {new Date(incident.closedAt as string).toLocaleString()} +
+
+ + {incident.alertType} - + + {incident.level} + + + {statusPresentation.label} + + opened {new Date(incident.openedAt).toLocaleString()} + + + closed {new Date(incident.closedAt as string).toLocaleString()} + + +
+

{incident.message}

- {getAlertResourceIncidentAcknowledgedByLabel( - incident.ackUser ?? '', - )} + {getAlertResourceIncidentAcknowledgedByLabel(incident.ackUser ?? '')}

0}> diff --git a/frontend-modern/src/features/alerts/__tests__/AlertResourceIncidentsPanel.test.tsx b/frontend-modern/src/features/alerts/__tests__/AlertResourceIncidentsPanel.test.tsx index 0824d723c..af1dfcaf9 100644 --- a/frontend-modern/src/features/alerts/__tests__/AlertResourceIncidentsPanel.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/AlertResourceIncidentsPanel.test.tsx @@ -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(() => ( ({ - 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(), - resourceIncidentEventFilters: () => new Set(['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(), + resourceIncidentEventFilters: () => new Set(['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(() => ( + ({ + 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(), + resourceIncidentEventFilters: () => new Set(['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]; + 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'); + }); }); diff --git a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts index 9975ae8f3..1f926791d 100644 --- a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts +++ b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts @@ -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');