feat(ui): enhance findings panel and investigation display

- Add timed_out, fix_verified, fix_verification_failed outcome types
- Fix field name mapping for investigation status from API
- Add findingsNeedingAttention computed property
- Update fixed count to include verified fixes
- Improve autonomy mode descriptions to mention verification
- Auto-switch to approvals filter when clicking approval banner
- Improve stats label for monitor vs investigating modes
This commit is contained in:
rcourtman
2026-02-01 10:12:39 +00:00
parent 519e8a637a
commit fb98042735
10 changed files with 214 additions and 59 deletions
+7 -7
View File
@@ -342,12 +342,12 @@ export interface UnifiedFindingRecord {
ai_confidence?: number;
enhanced_by_ai?: boolean;
ai_enhanced_at?: string;
// Investigation fields (camelCase from API)
investigationSessionId?: string;
investigationStatus?: string;
investigationOutcome?: string;
lastInvestigatedAt?: string;
investigationAttempts?: number;
// Investigation fields (snake_case from Go backend)
investigation_session_id?: string;
investigation_status?: string;
investigation_outcome?: string;
last_investigated_at?: string;
investigation_attempts?: number;
detected_at: string;
last_seen_at?: string;
resolved_at?: string;
@@ -476,7 +476,7 @@ export interface ApprovalExecutionResult {
// ============================================
export type InvestigationStatus = 'pending' | 'running' | 'completed' | 'failed' | 'needs_attention';
export type InvestigationOutcome = 'resolved' | 'fix_queued' | 'fix_executed' | 'fix_failed' | 'needs_attention' | 'cannot_fix';
export type InvestigationOutcome = 'resolved' | 'fix_queued' | 'fix_executed' | 'fix_failed' | 'needs_attention' | 'cannot_fix' | 'timed_out' | 'fix_verified' | 'fix_verification_failed';
export interface ProposedFix {
id: string;
+19 -1
View File
@@ -42,7 +42,7 @@ export interface Finding {
}
export type InvestigationStatus = 'pending' | 'running' | 'completed' | 'failed' | 'needs_attention';
export type InvestigationOutcome = 'resolved' | 'fix_queued' | 'fix_executed' | 'fix_failed' | 'needs_attention' | 'cannot_fix';
export type InvestigationOutcome = 'resolved' | 'fix_queued' | 'fix_executed' | 'fix_failed' | 'needs_attention' | 'cannot_fix' | 'timed_out' | 'fix_verified' | 'fix_verification_failed';
export type PatrolAutonomyLevel = 'monitor' | 'approval' | 'assisted' | 'full';
export interface PatrolAutonomySettings {
@@ -309,6 +309,24 @@ export const investigationOutcomeLabels: Record<InvestigationOutcome, string> =
fix_failed: 'Fix Failed',
needs_attention: 'Needs Attention',
cannot_fix: 'Cannot Auto-Fix',
timed_out: 'Timed Out — Will Retry',
fix_verified: 'Fix Verified',
fix_verification_failed: 'Verification Failed',
};
/**
* Investigation outcome badge colors for UI
*/
export const investigationOutcomeColors: Record<InvestigationOutcome, string> = {
resolved: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
fix_queued: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
fix_executed: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
fix_failed: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
needs_attention: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
cannot_fix: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
timed_out: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
fix_verified: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
fix_verification_failed: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
};
// =============================================================================
@@ -10,6 +10,7 @@ import { useNavigate } from '@solidjs/router';
import { getPatrolStatus, type PatrolStatus } from '../../api/patrol';
import { useAllAnomalies } from '@/hooks/useAnomalies';
import { useLearningStatus } from '@/hooks/useLearningStatus';
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
import './AIStatusIndicator.css';
@@ -53,6 +54,7 @@ export function AIStatusIndicator() {
const hasIssues = createMemo(() => {
const s = status();
if (aiIntelligenceStore.needsAttentionCount > 0 || aiIntelligenceStore.pendingApprovalCount > 0) return true;
if (!s) return false;
return s.summary.critical > 0 || s.summary.warning > 0;
});
@@ -101,6 +103,12 @@ export function AIStatusIndicator() {
if (s.summary.watch > 0) parts.push(`${s.summary.watch} watching`);
}
// Investigation status
const attentionCount = aiIntelligenceStore.needsAttentionCount;
const approvalCount = aiIntelligenceStore.pendingApprovalCount;
if (attentionCount > 0) parts.push(`${attentionCount} need${attentionCount === 1 ? 's' : ''} attention`);
if (approvalCount > 0) parts.push(`${approvalCount} awaiting approval`);
// Anomaly status
const counts = anomalyCounts();
const anomalyTotal = totalAnomalies();
@@ -17,7 +17,7 @@ import { aiIntelligenceStore, type UnifiedFinding } from '@/stores/aiIntelligenc
import { notificationStore } from '@/stores/notifications';
import { aiChatStore } from '@/stores/aiChat';
import { InvestigationSection, ApprovalSection } from '@/components/patrol';
import { investigationStatusLabels, investigationOutcomeLabels, type InvestigationStatus } from '@/api/patrol';
import { investigationStatusLabels, investigationOutcomeLabels, investigationOutcomeColors, type InvestigationStatus } from '@/api/patrol';
import { AIAPI, type RemediationPlan } from '@/api/ai';
// Severity priority for sorting (lower number = higher priority)
@@ -70,7 +70,7 @@ interface FindingsPanelProps {
showResolved?: boolean;
maxItems?: number;
onFindingClick?: (finding: UnifiedFinding) => void;
filterOverride?: 'all' | 'active' | 'resolved' | 'approvals';
filterOverride?: 'all' | 'active' | 'resolved' | 'approvals' | 'attention';
filterFindingIds?: string[];
showControls?: boolean;
nextPatrolAt?: string;
@@ -83,7 +83,7 @@ interface FindingsPanelProps {
export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
const location = useLocation();
const [filter, setFilter] = createSignal<'all' | 'active' | 'resolved' | 'approvals'>(props.filterOverride ?? 'active');
const [filter, setFilter] = createSignal<'all' | 'active' | 'resolved' | 'approvals' | 'attention'>(props.filterOverride ?? 'active');
const [sortBy, setSortBy] = createSignal<'severity' | 'time'>('severity');
const [expandedId, setExpandedId] = createSignal<string | null>(null);
const [actionLoading, setActionLoading] = createSignal<string | null>(null);
@@ -154,6 +154,16 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
}
});
// Auto-reset filter when the conditional filter buttons disappear
createEffect(() => {
if (filter() === 'attention' && aiIntelligenceStore.needsAttentionCount === 0) {
setFilter('active');
}
if (filter() === 'approvals' && aiIntelligenceStore.pendingApprovalCount === 0) {
setFilter('active');
}
});
// Filter and sort findings
const filteredFindings = createMemo(() => {
let findings = [...aiIntelligenceStore.findings];
@@ -168,6 +178,9 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
findings = findings.filter(f => f.status === 'active');
} else if (filter() === 'resolved') {
findings = findings.filter(f => f.status === 'resolved' || f.status === 'dismissed');
} else if (filter() === 'attention') {
const attentionIds = new Set(aiIntelligenceStore.findingsNeedingAttention.map(f => f.id));
findings = findings.filter(f => attentionIds.has(f.id));
} else if (filter() === 'approvals') {
const approvalFindingIds = new Set(aiIntelligenceStore.findingsWithPendingApprovals.map(f => f.id));
findings = findings.filter(f => approvalFindingIds.has(f.id));
@@ -179,8 +192,21 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
findings = findings.filter(f => idSet.has(f.id));
}
// Outcome priority: findings needing attention sort first
const outcomeOrder: Record<string, number> = {
fix_verification_failed: 0,
timed_out: 1,
needs_attention: 1,
fix_queued: 2,
};
// Sort
findings.sort((a, b) => {
// Active findings with actionable outcomes sort above others
const aOutcome = (a.status === 'active' && a.investigationOutcome) ? (outcomeOrder[a.investigationOutcome] ?? 3) : 3;
const bOutcome = (b.status === 'active' && b.investigationOutcome) ? (outcomeOrder[b.investigationOutcome] ?? 3) : 3;
if (aOutcome !== bOutcome) return aOutcome - bOutcome;
if (sortBy() === 'severity') {
const aPriority = severityOrder[a.severity] ?? 4;
const bPriority = severityOrder[b.severity] ?? 4;
@@ -362,7 +388,22 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
}
if (finding.source === 'ai-patrol') {
return `Issue no longer detected ${resolvedTime}`;
switch (finding.investigationOutcome) {
case 'fix_verified':
return `Fixed by Patrol ${resolvedTime}`;
case 'fix_executed':
return `Fix applied by Patrol ${resolvedTime}`;
case 'resolved':
return `Resolved by Patrol ${resolvedTime}`;
case 'fix_verification_failed':
return `Resolved after failed verification ${resolvedTime}`;
case 'timed_out':
return `Resolved after investigation timeout ${resolvedTime}`;
case 'cannot_fix':
return `Resolved manually ${resolvedTime}`;
default:
return `Issue no longer detected ${resolvedTime}`;
}
}
return `Resolved ${resolvedTime}`;
@@ -427,8 +468,8 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
Out of scope
</span>
</Show>
{/* Investigation status badge */}
<Show when={finding.investigationStatus}>
{/* Investigation status badge — only when no outcome badge will show */}
<Show when={finding.investigationStatus && !(finding.investigationOutcome && finding.investigationStatus !== 'running' && finding.investigationStatus !== 'pending')}>
<span
class={`px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 ${investigationStatusColors[finding.investigationStatus!]}`}
title={`Investigation: ${investigationStatusLabels[finding.investigationStatus!]}`}
@@ -439,9 +480,9 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
{investigationStatusLabels[finding.investigationStatus!]}
</span>
</Show>
{/* Investigation outcome badge */}
<Show when={finding.investigationOutcome && finding.investigationStatus === 'completed'}>
<span class="px-1.5 py-0.5 text-[10px] font-medium rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
{/* Investigation outcome badge — replaces status badge when outcome is known */}
<Show when={finding.investigationOutcome && finding.investigationStatus !== 'running' && finding.investigationStatus !== 'pending'}>
<span class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${investigationOutcomeColors[finding.investigationOutcome!] || 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'}`}>
{investigationOutcomeLabels[finding.investigationOutcome!]}
</span>
</Show>
@@ -467,6 +508,11 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
({finding.dismissedReason?.replace(/_/g, ' ')})
</span>
</Show>
<Show when={finding.status === 'active' && finding.lastInvestigatedAt}>
<span class="ml-2 text-gray-400 dark:text-gray-500">
last investigated {formatTime(finding.lastInvestigatedAt!)}
</span>
</Show>
</div>
</div>
{/* Actions */}
@@ -688,6 +734,7 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
findingId={finding.id}
investigationStatus={finding.investigationStatus}
investigationOutcome={finding.investigationOutcome}
investigationAttempts={finding.investigationAttempts}
/>
</Show>
@@ -695,7 +742,9 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
<Show when={finding.status === 'active' && (
finding.investigationOutcome === 'fix_queued' ||
finding.investigationOutcome === 'fix_executed' ||
finding.investigationOutcome === 'fix_failed'
finding.investigationOutcome === 'fix_failed' ||
finding.investigationOutcome === 'fix_verified' ||
finding.investigationOutcome === 'fix_verification_failed'
)}>
<ApprovalSection
findingId={finding.id}
@@ -833,10 +882,22 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
class={`px-2 py-1 border ${filter() === 'resolved'
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
} ${aiIntelligenceStore.pendingApprovalCount > 0 ? '' : 'rounded-r'}`}
} ${aiIntelligenceStore.needsAttentionCount > 0 || aiIntelligenceStore.pendingApprovalCount > 0 ? '' : 'rounded-r'}`}
>
Resolved
</button>
<Show when={aiIntelligenceStore.needsAttentionCount > 0}>
<button
type="button"
onClick={() => setFilter('attention')}
class={`px-2 py-1 border ${filter() === 'attention'
? 'bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300 border-amber-300 dark:border-amber-700'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
} ${aiIntelligenceStore.pendingApprovalCount > 0 ? '' : 'rounded-r'}`}
>
Needs Attention ({aiIntelligenceStore.needsAttentionCount})
</button>
</Show>
<Show when={aiIntelligenceStore.pendingApprovalCount > 0}>
<button
type="button"
@@ -940,7 +1001,13 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
</Show>
</div>
</Show>
<Show when={filter() !== 'active'}>
<Show when={filter() === 'attention'}>
No findings need attention right now.
</Show>
<Show when={filter() === 'approvals'}>
No pending approvals.
</Show>
<Show when={filter() !== 'active' && filter() !== 'attention' && filter() !== 'approvals'}>
No Patrol findings to display
</Show>
</div>
@@ -8,14 +8,13 @@
* - Multiple: count + "Review" button to scroll to first finding
*/
import { Component, Show, createMemo } from 'solid-js';
import { Component, Show, createMemo, createSignal, onCleanup } from 'solid-js';
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
import { notificationStore } from '@/stores/notifications';
import type { ApprovalRequest } from '@/api/ai';
import ShieldAlertIcon from 'lucide-solid/icons/shield-alert';
import CheckIcon from 'lucide-solid/icons/check';
import XIcon from 'lucide-solid/icons/x';
import { createSignal } from 'solid-js';
interface ApprovalBannerProps {
onScrollToFinding?: (findingId: string) => void;
@@ -23,6 +22,11 @@ interface ApprovalBannerProps {
export const ApprovalBanner: Component<ApprovalBannerProps> = (props) => {
const [actionLoading, setActionLoading] = createSignal<string | null>(null);
const [tick, setTick] = createSignal(Date.now());
// Tick every second to keep countdown live
const tickInterval = setInterval(() => setTick(Date.now()), 1000);
onCleanup(() => clearInterval(tickInterval));
const pending = createMemo(() =>
aiIntelligenceStore.pendingApprovals.filter((a: ApprovalRequest) => a.status === 'pending')
@@ -31,6 +35,7 @@ export const ApprovalBanner: Component<ApprovalBannerProps> = (props) => {
const firstApproval = createMemo(() => pending()[0] ?? null);
const timeRemaining = (expiresAt: string) => {
void tick(); // subscribe to tick signal for reactivity
const diff = new Date(expiresAt).getTime() - Date.now();
if (diff <= 0) return 'expired';
const mins = Math.floor(diff / 60000);
@@ -103,7 +108,7 @@ export const ApprovalBanner: Component<ApprovalBannerProps> = (props) => {
expires {timeRemaining(firstApproval()!.expiresAt)}
</span>
</div>
<p class="text-xs text-amber-700 dark:text-amber-300 mt-0.5 max-w-xl truncate">
<p class="text-xs text-amber-700 dark:text-amber-300 mt-0.5 max-w-xl truncate" title={firstApproval()!.context}>
{firstApproval()!.context}
</p>
</Show>
@@ -2,7 +2,7 @@
* ApprovalSection
*
* Shows when investigation has a proposed fix.
* States: Pending, Expired, Executed, Denied, Failed.
* States: Pending, Expired, Executed, Denied, Failed, Verified, VerificationFailed.
*/
import { Component, Show, createSignal, createResource, createMemo } from 'solid-js';
@@ -32,7 +32,7 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
() => props.findingId,
async (findingId) => {
// Only load if outcome indicates a fix was queued but no pending approval
if (props.investigationOutcome !== 'fix_queued' && props.investigationOutcome !== 'fix_executed' && props.investigationOutcome !== 'fix_failed') {
if (props.investigationOutcome !== 'fix_queued' && props.investigationOutcome !== 'fix_executed' && props.investigationOutcome !== 'fix_failed' && props.investigationOutcome !== 'fix_verified' && props.investigationOutcome !== 'fix_verification_failed') {
return null;
}
try {
@@ -51,11 +51,11 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
);
const isExecuted = createMemo(() =>
props.investigationOutcome === 'fix_executed' || executionResult()?.success
props.investigationOutcome === 'fix_executed' || props.investigationOutcome === 'fix_verified' || executionResult()?.success
);
const isFailed = createMemo(() =>
props.investigationOutcome === 'fix_failed' || (executionResult() && !executionResult()!.success)
props.investigationOutcome === 'fix_failed' || props.investigationOutcome === 'fix_verification_failed' || (executionResult() && !executionResult()!.success)
);
// Show section only when there's something to display
@@ -243,8 +243,26 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm font-medium">Fix executed successfully</span>
<span class="text-sm font-medium">{props.investigationOutcome === 'fix_verified' ? 'Fix verified — issue resolved' : 'Fix executed successfully'}</span>
</div>
<Show when={investigation()?.proposed_fix}>
{(fix) => (
<div class="mt-2 space-y-1 text-sm">
<div class="text-gray-600 dark:text-gray-400">{fix().description}</div>
<Show when={fix().commands && fix().commands!.length > 0}>
<div class="bg-gray-50 dark:bg-gray-800 rounded p-2 font-mono text-xs text-gray-700 dark:text-gray-300 break-all">
{fix().commands![0]}
</div>
</Show>
<Show when={fix().target_host}>
<div class="text-xs text-gray-500 dark:text-gray-400">Target: {fix().target_host}</div>
</Show>
<Show when={fix().rationale}>
<div class="text-xs text-gray-500 dark:text-gray-400 whitespace-pre-line mt-1">{fix().rationale}</div>
</Show>
</div>
)}
</Show>
</Show>
{/* Failed (from backend state, no local result) */}
@@ -253,8 +271,26 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm font-medium">Fix execution failed</span>
<span class="text-sm font-medium">{props.investigationOutcome === 'fix_verification_failed' ? 'Fix executed but issue persists' : 'Fix execution failed'}</span>
</div>
<Show when={investigation()?.proposed_fix}>
{(fix) => (
<div class="mt-2 space-y-1 text-sm">
<div class="text-gray-600 dark:text-gray-400">{fix().description}</div>
<Show when={fix().commands && fix().commands!.length > 0}>
<div class="bg-gray-50 dark:bg-gray-800 rounded p-2 font-mono text-xs text-gray-700 dark:text-gray-300 break-all">
{fix().commands![0]}
</div>
</Show>
<Show when={fix().target_host}>
<div class="text-xs text-gray-500 dark:text-gray-400">Target: {fix().target_host}</div>
</Show>
<Show when={fix().rationale}>
<div class="text-xs text-gray-500 dark:text-gray-400 whitespace-pre-line mt-1">{fix().rationale}</div>
</Show>
</div>
)}
</Show>
</Show>
</div>
</Show>
@@ -12,6 +12,7 @@ import {
reinvestigateFinding,
investigationStatusLabels,
investigationOutcomeLabels,
investigationOutcomeColors,
formatTimestamp,
} from '@/api/patrol';
import { InvestigationMessages } from './InvestigationMessages';
@@ -23,6 +24,7 @@ interface InvestigationSectionProps {
findingId: string;
investigationStatus?: string;
investigationOutcome?: string;
investigationAttempts?: number;
}
const statusColors: Record<string, string> = {
@@ -33,14 +35,6 @@ const statusColors: Record<string, string> = {
needs_attention: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
};
const outcomeColors: Record<string, string> = {
resolved: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
fix_queued: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
fix_executed: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
fix_failed: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
needs_attention: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
cannot_fix: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
};
export const InvestigationSection: Component<InvestigationSectionProps> = (props) => {
const [showThread, setShowThread] = createSignal(false);
@@ -74,7 +68,7 @@ export const InvestigationSection: Component<InvestigationSectionProps> = (props
const inv = investigation();
if (!inv) return true; // No investigation yet
if (inv.status === 'running') return false;
return inv.status === 'failed' || inv.status === 'needs_attention' || inv.outcome === 'cannot_fix';
return inv.status === 'failed' || inv.status === 'needs_attention' || inv.outcome === 'cannot_fix' || inv.outcome === 'timed_out' || inv.outcome === 'fix_verification_failed';
};
const handleReinvestigate = async (e: Event) => {
@@ -99,14 +93,23 @@ export const InvestigationSection: Component<InvestigationSectionProps> = (props
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">Investigation</span>
<Show when={investigation()?.status}>
<span class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${statusColors[investigation()!.status] || statusColors.pending}`}>
{investigationStatusLabels[investigation()!.status] || investigation()!.status}
{/* Show outcome badge when available, otherwise show status badge */}
<Show when={investigation()?.outcome}
fallback={
<Show when={investigation()?.status}>
<span class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${statusColors[investigation()!.status] || statusColors.pending}`}>
{investigationStatusLabels[investigation()!.status] || investigation()!.status}
</span>
</Show>
}
>
<span class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${investigationOutcomeColors[investigation()!.outcome!] || investigationOutcomeColors.needs_attention}`}>
{investigationOutcomeLabels[investigation()!.outcome!] || investigation()!.outcome}
</span>
</Show>
<Show when={investigation()?.outcome}>
<span class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${outcomeColors[investigation()!.outcome!] || outcomeColors.needs_attention}`}>
{investigationOutcomeLabels[investigation()!.outcome!] || investigation()!.outcome}
<Show when={props.investigationAttempts && props.investigationAttempts > 1}>
<span class="text-[10px] text-gray-500 dark:text-gray-400">
attempt {props.investigationAttempts}
</span>
</Show>
</div>
+15 -8
View File
@@ -87,6 +87,7 @@ type PatrolTab = 'findings' | 'history';
export function AIIntelligence() {
const [activeTab, setActiveTab] = createSignal<PatrolTab>('findings');
const [findingsFilterOverride, setFindingsFilterOverride] = createSignal<'all' | 'active' | 'resolved' | 'approvals' | 'attention' | undefined>(undefined);
const [isRefreshing, setIsRefreshing] = createSignal(false);
const [autonomyLevel, setAutonomyLevel] = createSignal<PatrolAutonomyLevel>('monitor');
const [isUpdatingAutonomy, setIsUpdatingAutonomy] = createSignal(false);
@@ -638,7 +639,11 @@ export function AIIntelligence() {
const infoCount = activeFindings.filter(f => f.severity === 'info').length;
const investigatingCount = patrolFindings.filter(f => f.investigationStatus === 'running').length;
const totalActive = activeFindings.length;
const fixedCount = resolvedFindings.length;
const fixedCount = patrolFindings.filter(f =>
f.investigationOutcome === 'fix_verified' ||
f.investigationOutcome === 'fix_executed' ||
f.investigationOutcome === 'resolved'
).length;
return {
criticalFindings: criticalCount,
@@ -804,15 +809,15 @@ export function AIIntelligence() {
</div>
<div>
<span class="font-semibold text-gray-900 dark:text-white">Approval</span>
<p class="text-gray-600 dark:text-gray-400">Patrol investigates findings. All fixes require your approval.</p>
<p class="text-gray-600 dark:text-gray-400">Patrol investigates findings. All fixes require your approval. Verifies fixes post-execution.</p>
</div>
<div>
<span class="font-semibold text-gray-900 dark:text-white">Assisted</span>
<p class="text-gray-600 dark:text-gray-400">Auto-fix warnings. Critical findings still need approval.</p>
<p class="text-gray-600 dark:text-gray-400">Auto-fix warnings and verify they worked. Critical findings still need approval.</p>
</div>
<div>
<span class="font-semibold text-red-600 dark:text-red-400">Full</span>
<p class="text-gray-600 dark:text-gray-400">Auto-fix everything, including critical. Must be enabled in ⚙️ settings first.</p>
<p class="text-gray-600 dark:text-gray-400">Auto-fix everything, including critical, and verify results. Must be enabled in ⚙️ settings first.</p>
</div>
</div>
</div>
@@ -1101,6 +1106,7 @@ export function AIIntelligence() {
are crossed, findings are created automatically. In <strong>Approval</strong>, <strong>Assisted</strong>, or <strong>Full</strong> mode,
Pulse Patrol investigates these findings - querying nodes, checking logs, and running diagnostics to
identify root causes. It then suggests fixes (Approval), applies safe fixes (Assisted), or applies all fixes (Full).
After a fix is applied, Patrol verifies the issue is actually resolved.
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
This is experimental. In Assisted mode, critical findings still require approval. Full mode (requires unlock in ⚙️) auto-fixes everything.
@@ -1124,7 +1130,8 @@ export function AIIntelligence() {
<ApprovalBanner
onScrollToFinding={(findingId) => {
setActiveTab('findings');
// Scroll to the finding after tab switch
setFindingsFilterOverride('approvals');
// Scroll to the finding after tab switch and filter change
requestAnimationFrame(() => {
const el = document.getElementById(`finding-${findingId}`);
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -1210,7 +1217,7 @@ export function AIIntelligence() {
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{autonomyLevel() === 'monitor' ? 'Investigating' : 'Investigating'}
{autonomyLevel() === 'monitor' ? 'Discovering' : 'Investigating'}
</p>
<p class={`text-lg font-bold ${
summaryStats().investigatingCount > 0
@@ -1306,7 +1313,7 @@ export function AIIntelligence() {
</button>
<button
type="button"
onClick={() => setActiveTab('history')}
onClick={() => { setActiveTab('history'); setFindingsFilterOverride(undefined); }}
class={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab() === 'history'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
@@ -1345,7 +1352,7 @@ export function AIIntelligence() {
nextPatrolAt={patrolStatus()?.next_patrol_at}
lastPatrolAt={patrolStatus()?.last_patrol_at}
patrolIntervalMs={patrolStatus()?.interval_ms}
filterOverride={selectedRunFindingIds() ? 'all' : undefined}
filterOverride={selectedRunFindingIds() ? 'all' : findingsFilterOverride()}
filterFindingIds={selectedRunFindingIds() ?? undefined}
scopeResourceIds={selectedRun()?.scope_resource_ids}
scopeResourceTypes={selectedRun()?.scope_resource_types}
+17 -6
View File
@@ -49,7 +49,7 @@ export interface UnifiedFinding {
// Investigation fields (Patrol Autonomy)
investigationSessionId?: string;
investigationStatus?: 'pending' | 'running' | 'completed' | 'failed' | 'needs_attention';
investigationOutcome?: 'resolved' | 'fix_queued' | 'fix_executed' | 'fix_failed' | 'needs_attention' | 'cannot_fix';
investigationOutcome?: 'resolved' | 'fix_queued' | 'fix_executed' | 'fix_failed' | 'needs_attention' | 'cannot_fix' | 'timed_out' | 'fix_verified' | 'fix_verification_failed';
lastInvestigatedAt?: string;
investigationAttempts?: number;
}
@@ -131,11 +131,11 @@ export const aiIntelligenceStore = {
status,
correlatedFindingIds: item.correlated_ids,
remediationPlanId: item.remediation_id,
investigationSessionId: item.investigationSessionId || '',
investigationStatus: item.investigationStatus as UnifiedFinding['investigationStatus'],
investigationOutcome: item.investigationOutcome as UnifiedFinding['investigationOutcome'],
lastInvestigatedAt: item.lastInvestigatedAt || undefined,
investigationAttempts: item.investigationAttempts || 0,
investigationSessionId: item.investigation_session_id || '',
investigationStatus: item.investigation_status as UnifiedFinding['investigationStatus'],
investigationOutcome: item.investigation_outcome as UnifiedFinding['investigationOutcome'],
lastInvestigatedAt: item.last_investigated_at || undefined,
investigationAttempts: item.investigation_attempts || 0,
};
});
@@ -263,6 +263,17 @@ export const aiIntelligenceStore = {
return unifiedFindings().filter(f => findingIds.has(f.id));
},
get findingsNeedingAttention() {
const actionableOutcomes = new Set(['fix_verification_failed', 'timed_out', 'needs_attention', 'cannot_fix']);
return unifiedFindings().filter(f =>
f.status === 'active' && f.investigationOutcome && actionableOutcomes.has(f.investigationOutcome)
);
},
get needsAttentionCount() {
return this.findingsNeedingAttention.length;
},
async loadPendingApprovals() {
try {
const approvals = await AIAPI.getPendingApprovals();
+1 -1
View File
@@ -49,7 +49,7 @@ export interface UnifiedFinding {
// Investigation fields (Patrol Autonomy)
investigationSessionId?: string;
investigationStatus?: 'pending' | 'running' | 'completed' | 'failed' | 'needs_attention';
investigationOutcome?: 'resolved' | 'fix_queued' | 'fix_executed' | 'fix_failed' | 'needs_attention' | 'cannot_fix';
investigationOutcome?: 'resolved' | 'fix_queued' | 'fix_executed' | 'fix_failed' | 'needs_attention' | 'cannot_fix' | 'timed_out' | 'fix_verified' | 'fix_verification_failed';
lastInvestigatedAt?: string;
investigationAttempts?: number;
}