mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-25 04:33:03 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user