mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
Attach incident timeline briefings to Assistant
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+101
@@ -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}>
|
||||
|
||||
+116
-33
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user