Files
Redflag/aggregator-web/src/components/ChatTimeline.tsx

1220 lines
50 KiB
TypeScript

import React, { useState } from 'react';
import {
CheckCircle,
XCircle,
AlertTriangle,
Package,
Search,
Terminal,
RefreshCw,
ChevronDown,
ChevronRight,
User,
Clock,
Activity,
Copy,
HardDrive,
Cpu,
Container,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { logApi } from '@/lib/api';
import { useRetryCommand } from '@/hooks/useCommands';
import { cn } from '@/lib/utils';
import toast from 'react-hot-toast';
import { Highlight, themes } from 'prism-react-renderer';
interface HistoryEntry {
id: string;
agent_id: string;
type: string; // "command" or "log"
action: string;
status?: string;
result: string;
package_name?: string;
package_type?: string;
stdout?: string;
stderr?: string;
exit_code?: number;
duration_seconds?: number;
created_at: string;
metadata?: Record<string, string>;
params?: Record<string, any>;
hostname?: string;
}
interface ChatTimelineProps {
agentId?: string;
className?: string;
isScopedView?: boolean; // true for agent-specific view, false for global view
externalSearch?: string; // external search query from parent
}
// Helper function to create smart summaries for package operations
const createPackageOperationSummary = (entry: HistoryEntry): string => {
const action = entry.action.replace(/_/g, ' ');
const result = entry.result || 'unknown';
// Extract package name from stdout or params
let packageName = 'unknown package';
if (entry.params?.package_name) {
packageName = entry.params.package_name as string;
} else if (entry.stdout) {
// Look for package patterns in stdout
const packageMatch = entry.stdout.match(/(?:Upgrading|Installing|Package):\s+(\S+)/i);
if (packageMatch) {
packageName = packageMatch[1];
} else {
// Look for "Packages installed: [pkg]" pattern
const installedMatch = entry.stdout.match(/Packages installed:\s*\[([^\]]+)\]/i);
if (installedMatch) {
packageName = installedMatch[1];
}
}
}
// Extract duration if available
let durationInfo = '';
if (entry.created_at) {
try {
const loggedTime = new Date(entry.created_at).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
durationInfo = ` at ${loggedTime}`;
if (entry.duration_seconds) {
durationInfo += ` (${entry.duration_seconds}s)`;
}
} catch (e) {
// Ignore date parsing errors
}
}
// Create action-specific summaries
switch (entry.action) {
case 'upgrade':
case 'install':
case 'confirm_dependencies':
if (result === 'success' || result === 'completed') {
return `Successfully ${action}d ${packageName}${durationInfo}`;
} else if (result === 'failed' || result === 'error') {
return `Failed to ${action} ${packageName}${durationInfo}`;
} else {
return `${action.charAt(0).toUpperCase() + action.slice(1)} ${packageName}${durationInfo}`;
}
case 'dry_run_update':
if (result === 'success' || result === 'completed') {
return `Dry run completed for ${packageName}${durationInfo}`;
} else {
return `Dry run for ${packageName}${durationInfo}`;
}
default:
return `${action} ${packageName}${durationInfo}`;
}
};
const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScopedView = false, externalSearch }) => {
const [statusFilter, setStatusFilter] = useState('all'); // 'all', 'success', 'failed', 'pending', 'completed', 'running', 'timed_out'
const [expandedEntries, setExpandedEntries] = useState<Set<string>>(new Set());
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
// Retry command hook
const retryCommandMutation = useRetryCommand();
// Query parameters for API
const [queryParams, setQueryParams] = useState({
page: 1,
page_size: 50,
agent_id: agentId || '',
result: statusFilter !== 'all' ? statusFilter : '',
search: externalSearch || '',
});
// Update query params when external search changes
React.useEffect(() => {
setQueryParams(prev => ({
...prev,
search: externalSearch || '',
}));
}, [externalSearch]);
// Fetch history data
const { data: historyData, isLoading, refetch, isFetching } = useQuery({
queryKey: ['history', queryParams],
queryFn: async () => {
try {
const params: any = {
page: queryParams.page,
page_size: queryParams.page_size,
};
if (queryParams.agent_id) {
params.agent_id = queryParams.agent_id;
}
if (queryParams.result) {
params.result = queryParams.result;
}
if (queryParams.search) {
params.search = queryParams.search;
}
const response = await logApi.getAllLogs(params);
return response;
} catch (error) {
console.error('Failed to fetch history:', error);
toast.error('Failed to fetch history');
return { logs: [], total: 0, page: 1, page_size: 50 };
}
},
refetchInterval: 30000,
});
const allEntries: HistoryEntry[] = historyData?.logs || [];
// Filter entries based on selected agents
const filteredEntries = allEntries.filter(entry => {
// Agent filter
if (selectedAgents.length > 0 && !selectedAgents.includes(entry.agent_id)) {
return false;
}
return true;
});
// Group entries by date with timestamp dividers and timeline connector
const createTimelineWithDividers = (entries: HistoryEntry[]) => {
const timeline: JSX.Element[] = [];
let lastDate: string | null = null;
entries.forEach((entry, index) => {
const entryDate = new Date(entry.created_at);
const dateKey = entryDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
// Add date divider if date changed
if (dateKey !== lastDate) {
timeline.push(
<div key={`date-${dateKey}`} className="flex items-center justify-center my-6">
<div className="flex-1 h-px bg-gray-300"></div>
<span className="px-3 py-1 bg-gray-100 text-gray-600 text-sm font-medium rounded-full">
{dateKey}
</span>
<div className="flex-1 h-px bg-gray-300"></div>
</div>
);
lastDate = dateKey;
}
// Check if this is the last entry to determine if we should show the connector
const isLastEntry = index === entries.length - 1;
// Add the event bubble
timeline.push(
<EventBubble
key={entry.id}
entry={entry}
isExpanded={expandedEntries.has(entry.id)}
isScopedView={isScopedView}
onToggle={() => {
const newExpanded = new Set(expandedEntries);
if (newExpanded.has(entry.id)) {
newExpanded.delete(entry.id);
} else {
newExpanded.add(entry.id);
}
setExpandedEntries(newExpanded);
}}
/>
);
});
return timeline;
};
// Get action icon
const getActionIcon = (action: string, type: string) => {
if (type === 'command') {
switch (action) {
case 'scan_updates':
return <Search className="h-4 w-4" />;
case 'scan_storage':
return <HardDrive className="h-4 w-4" />;
case 'scan_system':
return <Cpu className="h-4 w-4" />;
case 'scan_docker':
return <Container className="h-4 w-4" />;
case 'dry_run_update':
return <Terminal className="h-4 w-4" />;
case 'confirm_dependencies':
return <CheckCircle className="h-4 w-4" />;
case 'install_update':
return <Package className="h-4 w-4" />;
default:
return <Clock className="h-4 w-4" />;
}
} else {
return <Activity className="h-4 w-4" />;
}
};
// Get result icon and color
const getResultInfo = (entry: HistoryEntry) => {
const status = entry.status || entry.result;
let icon, color, title, bgColor;
switch (status) {
case 'success':
case 'completed':
icon = <CheckCircle className="h-4 w-4" />;
color = 'text-green-600';
title = 'Success';
bgColor = 'bg-green-50';
break;
case 'failed':
case 'error':
icon = <XCircle className="h-4 w-4" />;
color = 'text-red-600';
title = 'Failed';
bgColor = 'bg-red-50';
break;
case 'running':
case 'pending':
icon = <RefreshCw className="h-4 w-4 animate-spin" />;
color = 'text-blue-600';
title = 'Running';
bgColor = 'bg-blue-50';
break;
case 'timed_out':
icon = <AlertTriangle className="h-4 w-4" />;
color = 'text-orange-600';
title = 'Timed Out';
bgColor = 'bg-orange-50';
break;
default:
icon = <AlertTriangle className="h-4 w-4" />;
color = 'text-gray-600';
title = 'Info';
bgColor = 'bg-gray-50';
}
return { icon, color, title, bgColor };
};
// Format timestamp
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
// Interface for narrative event summary
interface NarrativeSummary {
sentence: string;
statusType: 'success' | 'failed' | 'running' | 'warning';
statusIcon: React.ReactNode;
hoverColor: string;
borderColor: string;
subject: string;
}
// Create narrative event summary
const getNarrativeSummary = (entry: HistoryEntry): NarrativeSummary => {
const action = entry.action.replace(/_/g, ' ');
const result = entry.result || entry.status || 'unknown';
// Determine status type and corresponding colors/icons
let statusType: 'success' | 'failed' | 'running' | 'warning' | 'info' | 'pending';
let statusIcon: React.ReactNode;
let hoverColor: string;
let borderColor: string;
if (result === 'success' || result === 'completed') {
statusType = 'success';
statusIcon = <CheckCircle className="h-4 w-4" />;
hoverColor = 'hover:bg-green-50';
borderColor = 'border-l-green-300';
} else if (result === 'failed' || result === 'error') {
statusType = 'failed';
statusIcon = <XCircle className="h-4 w-4" />;
hoverColor = 'hover:bg-red-50';
borderColor = 'border-l-red-300';
} else if (result === 'running') {
statusType = 'running';
statusIcon = <RefreshCw className="h-4 w-4 animate-spin" />;
hoverColor = 'hover:bg-blue-50';
borderColor = 'border-l-blue-300';
} else if (result === 'pending' || result === 'sent') {
statusType = 'pending';
statusIcon = <Clock className="h-4 w-4" />;
hoverColor = 'hover:bg-purple-50';
borderColor = 'border-l-purple-300';
} else if (result === 'timed_out') {
statusType = 'warning';
statusIcon = <AlertTriangle className="h-4 w-4" />;
hoverColor = 'hover:bg-amber-50';
borderColor = 'border-l-amber-300';
} else {
statusType = 'info';
statusIcon = <Activity className="h-4 w-4" />;
hoverColor = 'hover:bg-gray-50';
borderColor = 'border-l-gray-300';
}
// Extract subject (package name or target)
let subject = '';
if (entry.stdout) {
// Priority 1: Extract actual package/installation details from stdout
const stdout = entry.stdout;
// Pattern 1: "Packages installed: [Update Name]" (Windows Update success)
const packagesInstalledMatch = stdout.match(/Packages installed:\s*\[([^\]]+)\]/i);
if (packagesInstalledMatch) {
subject = packagesInstalledMatch[1].trim();
} else {
// Pattern 2: Bullet point format "• Update Name" (Dry run results)
const bulletMatch = stdout.match(/•\s*([^\n]+)/);
if (bulletMatch) {
subject = bulletMatch[1].trim();
} else {
// Pattern 3: Package line format
const packageMatch = stdout.match(/Package:\s*([^\n]+)/i);
if (packageMatch) {
subject = packageMatch[1].trim();
} else {
// Pattern 4: Windows Update full name patterns
// Look for Windows Update with KB numbers - more comprehensive pattern
const windowsUpdateMatch = stdout.match(/([A-Z][^-\n]*\bUpdate\b[^-\n]*\bKB\d{7,8}\b[^\n]*)/);
if (windowsUpdateMatch) {
subject = windowsUpdateMatch[1].trim();
} else {
// Pattern 5: Generic update patterns (full line)
const updateMatch = stdout.match(/([A-Z][^\n]*\bUpdate\b[^\n]*\bKB\d{7,8}\b[^\n]*)/);
if (updateMatch) {
subject = updateMatch[1].trim();
} else {
// Pattern 6: Look for Security Intelligence Update or similar specific patterns
const securityUpdateMatch = stdout.match(/([A-Z][^-\n]*Security Intelligence Update[^-\n]*KB\d{7,8}[^\n]*)/);
if (securityUpdateMatch) {
subject = securityUpdateMatch[1].trim();
} else {
// Pattern 7: Extract from dependency confirmation broken sentences
// Fix: "Dependency check for 'Windows Updates installation initiated via wuauclt Packages installed'"
const dependencyBrokenMatch = stdout.match(/Packages installed:\s*\[([^\]]+)\]/i);
if (dependencyBrokenMatch) {
subject = dependencyBrokenMatch[1].trim();
} else {
// Pattern 8: Look for any line with "Update" and treat it as subject
const lines = stdout.split('\n');
for (const line of lines) {
if (line.includes('Update') && line.includes('KB') && line.length > 20) {
subject = line.trim();
break;
}
}
}
}
}
}
}
}
}
// Clean up common artifacts
if (subject) {
subject = subject
.replace(/\s*-\s*Current Channel\s*\(Broad\)$/i, '') // Remove Windows Update channel info
.replace(/\s*-\s*Version\s*[\d.]+$/i, '') // Remove version numbers for readability
.replace(/\s*Method:\s*.*$/i, '') // Remove method info
.replace(/\s*Requires:\s*.*$/i, '') // Remove requirement info
.replace(/^Dry run\s*[-:]\s*/i, '') // Remove "Dry run -" prefix
.replace(/^The following updates would be installed:\s*/i, '') // Remove generic dry run prefix
.trim();
}
}
// Fallback subject - provide better action labels
if (!subject) {
// Map action to more readable labels
const actionLabels: Record<string, string> = {
'scan updates': 'Package Updates',
'scan storage': 'Disk Usage',
'scan system': 'System Metrics',
'scan docker': 'Docker Images',
'update agent': 'Agent Update',
'dry run update': 'Update Dry Run',
'confirm dependencies': 'Dependency Check',
'install update': 'Update Installation',
'collect specs': 'System Specifications',
'enable heartbeat': 'Heartbeat Enable',
'disable heartbeat': 'Heartbeat Disable',
'reboot': 'System Reboot',
'process command': 'Command Processing'
};
// Prioritize metadata subsystem label for better descriptions
subject = entry.metadata?.subsystem_label || entry.package_name || actionLabels[action] || action;
}
// Build narrative sentence - system thought style
let sentence = '';
const isInProgress = result === 'running' || result === 'pending' || result === 'sent';
if (entry.type === 'command') {
if (entry.action.startsWith('scan_')) {
const display = getCommandDisplay(entry.action);
const verb = display.verb;
const noun = display.noun;
if (isInProgress) {
sentence = `${verb} ${noun} initiated`;
} else if (statusType === 'success') {
sentence = `${verb} ${noun} completed`;
} else if (statusType === 'failed') {
sentence = `${verb} ${noun} failed`;
} else {
sentence = `${verb} ${noun} results`;
}
} else if (action === 'update agent') {
if (isInProgress) {
sentence = `Agent Update initiated to version ${subject}`;
} else if (statusType === 'success') {
sentence = `Agent updated to version ${subject}`;
} else if (statusType === 'failed') {
sentence = `Agent update failed for version ${subject}`;
} else {
sentence = `Agent update to version ${subject}`;
}
} else if (action === 'dry run update') {
if (isInProgress) {
sentence = `Dry run initiated for ${subject}`;
} else if (statusType === 'success') {
sentence = `Dry run completed: ${subject} available`;
} else if (statusType === 'failed') {
sentence = `Dry run failed for ${subject}`;
} else {
sentence = `Dry run results: ${subject} available`;
}
} else if (action === 'confirm dependencies') {
if (isInProgress) {
sentence = `Dependency confirmation initiated for '${subject}'`;
} else if (statusType === 'success') {
sentence = `Dependencies confirmed for '${subject}'`;
} else if (statusType === 'failed') {
sentence = `Dependency confirmation failed for '${subject}'`;
} else {
sentence = `Dependency check for '${subject}'`;
}
} else if (action === 'install update' || action === 'install') {
if (isInProgress) {
sentence = `${subject} installation initiated`;
} else if (statusType === 'success') {
sentence = `${subject} installed successfully`;
} else if (statusType === 'failed') {
sentence = `${subject} installation failed`;
} else {
sentence = `${subject} installation`;
}
} else {
// Generic action - simplified system thought style
if (isInProgress) {
sentence = `${action} initiated for '${subject}'`;
} else if (statusType === 'success') {
sentence = `${action} completed for '${subject}'`;
} else if (statusType === 'failed') {
sentence = `${action} failed for '${subject}'`;
} else {
sentence = `${action} for '${subject}'`;
}
}
} else {
// Log entry - extract meaningful content (only if not already set by command processing)
if (!sentence) {
if (entry.stdout) {
try {
const parsed = JSON.parse(entry.stdout);
if (parsed.message) {
sentence = parsed.message;
} else {
sentence = `System log: ${entry.action}`;
}
} catch {
// Create smart summary for package management operations
if (['upgrade', 'install', 'confirm_dependencies', 'dry_run_update'].includes(entry.action)) {
sentence = createPackageOperationSummary(entry);
} else {
const lines = entry.stdout.split('\n');
const firstLine = lines[0]?.trim();
// Clean up common prefixes for more elegant system thoughts
if (firstLine) {
sentence = firstLine
.replace(/^(INFO|WARN|ERROR|DEBUG):\s*/i, '')
.replace(/^Step \d+:\s*/i, '')
.replace(/^Command:\s*/i, '')
.replace(/^Output:\s*/i, '')
.trim() || `System log: ${entry.action}`;
} else {
sentence = `System log: ${entry.action}`;
}
}
}
} else {
sentence = `System event: ${entry.action}`;
}
}
}
// Add agent location for global view
if (!isScopedView && entry.hostname) {
sentence += ` on ${entry.hostname}`;
}
// Add inline timestamp and duration
const timeStr = formatTimestamp(entry.created_at);
const duration = entry.duration_seconds || 0;
let durationStr = '';
if (duration > 0) {
// Format duration nicely
if (duration < 60) {
durationStr = ` (${duration}s)`;
} else if (duration < 3600) {
const minutes = Math.floor(duration / 60);
const seconds = duration % 60;
durationStr = ` (${minutes}m ${seconds}s)`;
} else {
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
durationStr = ` (${hours}h ${minutes}m)`;
}
} else {
// Show minimum 1s for null/zero duration to avoid empty parentheses
durationStr = ' (1s)';
}
sentence += ` at ${timeStr}${durationStr}`;
return {
sentence,
statusType,
statusIcon,
hoverColor,
borderColor,
subject,
};
};
// Get fallback summary for search (legacy function for compatibility)
const getSummary = (entry: HistoryEntry) => {
const narrative = getNarrativeSummary(entry);
return narrative.sentence;
};
// Copy to clipboard utility
const copyToClipboard = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(`Copied ${label} to clipboard`);
} catch (err) {
console.error('Failed to copy:', err);
toast.error('Failed to copy to clipboard');
}
};
// Event Bubble Component with professional narrative design and pastel color-coding
const EventBubble: React.FC<{
entry: HistoryEntry;
isExpanded: boolean;
isScopedView: boolean;
onToggle: () => void;
}> = ({ entry, isExpanded, isScopedView, onToggle }) => {
const narrative = getNarrativeSummary(entry);
return (
<div className="group rounded-lg transition-all duration-200">
<div className="p-2 rounded-lg transition-all duration-200 bg-white">
{/* Narrative content with inline status indicator */}
<div
className="flex items-center justify-between cursor-pointer group"
onClick={onToggle}
>
{/* Narrative sentence with status indicator */}
<div className="flex items-center space-x-3 text-gray-700 flex-1 min-w-0">
{/* Status indicator */}
<div className="flex items-center space-x-2 flex-shrink-0">
{narrative.statusType === 'success' && (
<>
<CheckCircle className="h-3 w-3 text-green-600" />
<span className="font-mono text-xs bg-green-100 text-green-800 px-1.5 py-0.5 rounded">
SUCCESS
</span>
</>
)}
{narrative.statusType === 'failed' && (
<>
<XCircle className="h-3 w-3 text-red-600" />
<span className="font-mono text-xs bg-red-100 text-red-800 px-1.5 py-0.5 rounded">
FAILED
</span>
</>
)}
{narrative.statusType === 'running' && (
<>
<RefreshCw className="h-3 w-3 text-blue-600 animate-spin" />
<span className="font-mono text-xs bg-blue-100 text-blue-800 px-1.5 py-0.5 rounded">
RUNNING
</span>
</>
)}
{narrative.statusType === 'pending' && (
<>
<Clock className="h-3 w-3 text-amber-600" />
<span className="font-mono text-xs bg-amber-100 text-amber-800 px-1.5 py-0.5 rounded">
PENDING
</span>
</>
)}
{narrative.statusType === 'warning' && (
<>
<AlertTriangle className="h-3 w-3 text-amber-600" />
<span className="font-mono text-xs bg-amber-100 text-amber-800 px-1.5 py-0.5 rounded">
TIMEOUT
</span>
</>
)}
{narrative.statusType === 'info' && (
<>
<Activity className="h-3 w-3 text-gray-600" />
<span className="font-mono text-xs bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded">
INFO
</span>
</>
)}
</div>
<span className="text-sm leading-relaxed flex-1 break-words">
{narrative.sentence}
</span>
</div>
{/* Expand/collapse icon - aligned inline */}
<div className="flex-shrink-0 ml-3 text-gray-400 group-hover:text-gray-600 transition-colors">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</div>
{/* Critical vitals - always visible in collapsed view */}
<div className="mt-2 ml-8 text-xs text-gray-600 space-y-1">
<div className="flex flex-wrap gap-x-4 gap-y-1">
<span>
<span className="font-medium">Action:</span> {entry.action.replace(/_/g, ' ')}
</span>
<span>
<span className="font-medium">Result:</span> {entry.result}
{entry.exit_code !== undefined && (
<span className="text-gray-500"> (Exit Code: {entry.exit_code})</span>
)}
</span>
{entry.package_name && (
<span>
<span className="font-medium">Package:</span> {entry.package_name}
</span>
)}
{narrative.subject && narrative.subject !== 'system operation' && narrative.subject !== entry.package_name && (
<span>
<span className="font-medium">Target:</span> {narrative.subject.length > 50 ? narrative.subject.substring(0, 50) + '...' : narrative.subject}
</span>
)}
</div>
</div>
{/* Expanded details with integrated frosted glass effect */}
{isExpanded && (
<div className="mt-2 ml-4">
{/* Integrated frosted glass pane container */}
<div className="relative bg-white/90 backdrop-blur-md rounded-lg shadow-xl transition-all duration-200">
{/* Copy button */}
<div className="absolute top-2 right-2 z-10">
<button
onClick={(e) => {
e.stopPropagation();
const fullOutput = [
entry.stdout ? `STDOUT:\n${entry.stdout}` : '',
entry.stderr ? `STDERR:\n${entry.stderr}` : '',
].filter(Boolean).join('\n\n');
copyToClipboard(fullOutput, 'output');
}}
className="p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded transition-colors"
title="Copy output to clipboard"
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
<div className="p-4 space-y-4">
{/* System Information */}
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
<h4 className="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-3 flex items-center">
<Activity className="h-3 w-3 mr-1.5" />
System Information
</h4>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-xs">
<div className="flex flex-col">
<span className="text-gray-500 font-medium">Command ID</span>
<span className="font-mono text-gray-800 break-all">{entry.id}</span>
</div>
{entry.package_name && (
<div className="flex flex-col">
<span className="text-gray-500 font-medium">Package</span>
<span className="text-gray-800 truncate" title={entry.package_name}>
{entry.package_name}
</span>
</div>
)}
<div className="flex flex-col">
<span className="text-gray-500 font-medium">Exit Code</span>
<span className={cn(
"font-mono",
entry.exit_code === 0 ? "text-green-600" :
entry.exit_code ? "text-red-600" : "text-gray-600"
)}>
{entry.exit_code !== undefined ? entry.exit_code : 'N/A'}
</span>
</div>
</div>
</div>
{/* Parsed Details from stdout */}
{entry.stdout && (
<div className="bg-blue-50 rounded-lg p-3 border border-blue-200">
<h4 className="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-3 flex items-center">
{entry.action === 'scan_storage' ? (
<HardDrive className="h-3 w-3 mr-1.5" />
) : entry.action === 'scan_system' ? (
<Cpu className="h-3 w-3 mr-1.5" />
) : entry.action === 'scan_docker' ? (
<Container className="h-3 w-3 mr-1.5" />
) : (
<Package className="h-3 w-3 mr-1.5" />
)}
{entry.action === 'scan_updates' ? 'Package Analysis Results' :
entry.action === 'scan_storage' ? 'Disk Usage Report' :
entry.action === 'scan_system' ? 'System Metrics Report' :
entry.action === 'scan_docker' ? 'Docker Image Analysis' :
'Operation Details'}
</h4>
<div className="space-y-2 text-xs">
{(() => {
const stdout = entry.stdout;
const details: Array<{label: string, value: string}> = [];
// Handle scan results specifically
if (entry.action === 'scan_updates') {
// Extract update counts
const updateCountMatch = stdout.match(/Found\s+(\d+)\s+([^:\n]+)/i);
if (updateCountMatch) {
details.push({
label: "Updates Found",
value: `${updateCountMatch[1]} ${updateCountMatch[2].trim()}`
});
}
const totalUpdatesMatch = stdout.match(/Total Updates Found:\s*(\d+)/i);
if (totalUpdatesMatch) {
details.push({
label: "Total Updates",
value: totalUpdatesMatch[1]
});
}
// Extract scanner availability
const availableScanners: string[] = [];
const unavailableScanners: string[] = [];
const scannerLines = stdout.match(/([A-Z][a-z]+)\s+scanner\s+(not\s+available|available)/gi);
if (scannerLines) {
scannerLines.forEach(line => {
const match = line.match(/([A-Z][a-z]+)\s+scanner\s+(not\s+available|available)/i);
if (match) {
if (match[2].toLowerCase().includes('not')) {
unavailableScanners.push(match[1]);
} else {
availableScanners.push(match[1]);
}
}
});
}
if (availableScanners.length > 0) {
details.push({
label: "Available Scanners",
value: availableScanners.join(", ")
});
}
// Extract scan errors
const scanErrorsMatch = stdout.match(/Scan Errors:\s*\n([\s\S]*?)(?=\n\n|\n[A-Z]|\n$)/);
if (scanErrorsMatch) {
details.push({
label: "Scan Errors",
value: scanErrorsMatch[1].replace(/\\n/g, ' ').trim()
});
}
// Extract individual scanner failures
const failureLines = stdout.match(/^([A-Z][a-z]+)\s+scan\s+failed:\s*([^\n]+)/gm);
if (failureLines) {
failureLines.forEach(line => {
const match = line.match(/([A-Z][a-z]+)\s+scan\s+failed:\s*([^\n]+)/);
if (match) {
details.push({
label: `${match[1]} Scanner`,
value: `Failed: ${match[2].replace(/\\n/g, ' ').trim()}`
});
}
});
}
} else if (entry.action === 'scan_storage') {
// Parse storage/disk usage information
// Look for disk metrics in the stdout
const diskLines = stdout.split('\n');
diskLines.forEach(line => {
// Match patterns like "Mount: /dev/sda1" or "Usage: 85%"
const mountMatch = line.match(/(?:Mount|Filesystem|Path):\s*([^\s]+)/i);
const usageMatch = line.match(/(?:Usage|Used):\s*(\d+\.?\d*%?)/i);
const sizeMatch = line.match(/(?:Size|Total):\s*([^\s]+)/i);
const availMatch = line.match(/(?:Available|Free):\s*([^\s]+)/i);
if (mountMatch || usageMatch || sizeMatch || availMatch) {
if (mountMatch) details.push({ label: "Mount Point", value: mountMatch[1] });
if (usageMatch) details.push({ label: "Usage", value: usageMatch[1] });
if (sizeMatch) details.push({ label: "Total Size", value: sizeMatch[1] });
if (availMatch) details.push({ label: "Available", value: availMatch[1] });
}
});
} else if (entry.action === 'scan_system') {
// Parse system metrics (CPU, memory, processes, uptime)
const cpuMatch = stdout.match(/(?:CPU|Processor):\s*([^\n]+)/i);
if (cpuMatch) {
details.push({ label: "CPU", value: cpuMatch[1].trim() });
}
const memoryMatch = stdout.match(/(?:Memory|RAM):\s*([^\n]+)/i);
if (memoryMatch) {
details.push({ label: "Memory", value: memoryMatch[1].trim() });
}
const processMatch = stdout.match(/(?:Processes|Process Count):\s*(\d+)/i);
if (processMatch) {
details.push({ label: "Running Processes", value: processMatch[1] });
}
const uptimeMatch = stdout.match(/(?:Uptime|Up Time):\s*([^\n]+)/i);
if (uptimeMatch) {
details.push({ label: "System Uptime", value: uptimeMatch[1].trim() });
}
const loadMatch = stdout.match(/(?:Load Average|Load):\s*([^\n]+)/i);
if (loadMatch) {
details.push({ label: "Load Average", value: loadMatch[1].trim() });
}
} else if (entry.action === 'scan_docker') {
// Parse Docker image/container information
const containerCountMatch = stdout.match(/(?:Containers|Container Count):\s*(\d+)/i);
if (containerCountMatch) {
details.push({ label: "Containers", value: containerCountMatch[1] });
}
const imageCountMatch = stdout.match(/(?:Images|Image Count):\s*(\d+)/i);
if (imageCountMatch) {
details.push({ label: "Images", value: imageCountMatch[1] });
}
const updateCountMatch = stdout.match(/(?:Updates Available|Updatable Images):\s*(\d+)/i);
if (updateCountMatch) {
details.push({ label: "Updates Available", value: updateCountMatch[1] });
}
const runningMatch = stdout.match(/(?:Running Containers):\s*(\d+)/i);
if (runningMatch) {
details.push({ label: "Running", value: runningMatch[1] });
}
}
// Extract "Packages installed" info
const packagesMatch = stdout.match(/Packages installed:\s*\[([^\]]+)\]/i);
if (packagesMatch) {
details.push({
label: "Installed Package",
value: packagesMatch[1].trim()
});
}
// Extract KB articles
const kbMatch = stdout.match(/KB(\d{7,8})/g);
if (kbMatch) {
details.push({
label: "KB Articles",
value: kbMatch.join(", ")
});
}
// Extract version info
const versionMatch = stdout.match(/Version\s*([\d.]+)/i);
if (versionMatch) {
details.push({
label: "Version",
value: versionMatch[1]
});
}
// Extract method info
const methodMatch = stdout.match(/Method:\s*([^\n]+)/i);
if (methodMatch) {
details.push({
label: "Method",
value: methodMatch[1].trim()
});
}
// Extract requirements
const requiresMatch = stdout.match(/Requires:\s*([^\n]+)/i);
if (requiresMatch) {
details.push({
label: "Requirements",
value: requiresMatch[1].trim()
});
}
return details.length > 0 ? (
details.map((detail, idx) => (
<div key={idx} className="flex flex-col sm:flex-row sm:gap-2">
<span className="text-gray-600 font-medium min-w-0 sm:min-w-[120px]">
{detail.label}:
</span>
<span className="text-gray-800 break-all font-mono">
{detail.value.replace(/\\n/g, ' ').trim()}
</span>
</div>
))
) : (
<div className="text-gray-500 italic">
No structured details found in output
</div>
);
})()}
</div>
</div>
)}
{/* Contextual Navigation Links */}
<div className="flex flex-wrap gap-2 text-xs">
<a
href={`/agents/${entry.agent_id}`}
className="inline-flex items-center px-2.5 py-1.5 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors font-medium"
onClick={(e) => {
e.preventDefault();
// Handle navigation - would integrate with router
window.location.href = `/agents/${entry.agent_id}`;
}}
>
<User className="h-3 w-3 mr-1" />
View Agent
</a>
{/* Add other relevant links based on event type */}
{entry.type === 'command' && entry.action === 'install_update' && (
<a
href={`/updates`}
className="inline-flex items-center px-2.5 py-1.5 bg-green-50 text-green-700 rounded-md hover:bg-green-100 transition-colors font-medium"
onClick={(e) => {
e.preventDefault();
window.location.href = `/updates`;
}}
>
<Package className="h-3 w-3 mr-1" />
View Updates
</a>
)}
{(entry.result === 'failed' || entry.result === 'timed_out') && (
<button
onClick={async (e) => {
e.stopPropagation();
try {
await retryCommandMutation.mutateAsync(entry.id);
toast.success(`Retry command sent to ${entry.hostname || 'agent'}`);
} catch (error: any) {
toast.error(`Failed to retry command: ${error.message || 'Unknown error'}`);
}
}}
disabled={retryCommandMutation.isPending}
className="inline-flex items-center px-2.5 py-1.5 bg-amber-50 text-amber-700 rounded-md hover:bg-amber-100 transition-colors font-medium"
>
{retryCommandMutation.isPending ? (
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
) : (
<RefreshCw className="h-3 w-3 mr-1" />
)}
{retryCommandMutation.isPending ? 'Retrying...' : 'Retry Command'}
</button>
)}
</div>
{/* Output Section */}
{(entry.stdout || entry.stderr) && (
<div className="space-y-3">
{entry.stdout && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-600">Output</span>
<button
onClick={(e) => {
e.stopPropagation();
copyToClipboard(entry.stdout!, 'output');
}}
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
<Copy className="h-3 w-3 inline mr-1" />
Copy
</button>
</div>
<div className="bg-gray-900 rounded-md border border-gray-200">
<Highlight
theme={themes.vsDark}
code={entry.stdout}
language="bash"
>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre className={cn("p-3 text-xs overflow-x-auto font-mono leading-relaxed", className)} style={style}>
{tokens.map((line, i) => (
<div key={i} {...getLineProps({ line })} className="hover:bg-gray-800 px-1 -mx-1 rounded transition-colors">
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</div>
))}
</pre>
)}
</Highlight>
</div>
</div>
)}
{entry.stderr && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-red-600">Error Output</span>
<button
onClick={(e) => {
e.stopPropagation();
copyToClipboard(entry.stderr!, 'error output');
}}
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
<Copy className="h-3 w-3 inline mr-1" />
Copy
</button>
</div>
<div className="bg-red-950/90 rounded-md border border-red-200">
<Highlight
theme={themes.vsDark}
code={entry.stderr}
language="bash"
>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre className={cn("p-3 text-xs overflow-x-auto font-mono leading-relaxed text-red-300", className)} style={style}>
{tokens.map((line, i) => (
<div key={i} {...getLineProps({ line })} className="hover:bg-red-900/30 px-1 -mx-1 rounded transition-colors">
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</div>
))}
</pre>
)}
</Highlight>
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
};
// Get unique agents for filter dropdown
const uniqueAgents = Array.from(new Set(allEntries.map(e => e.hostname).filter(Boolean)));
return (
<div className={cn("space-y-6", className)}>
{/* Loading state */}
{isLoading && (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-6 w-6 animate-spin text-gray-400" />
<span className="ml-2 text-gray-600">Loading events...</span>
</div>
)}
{/* Timeline */}
{!isLoading && filteredEntries.length === 0 ? (
<div className="text-center py-12">
<Search className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No events found</h3>
<p className="mt-1 text-sm text-gray-500">
{externalSearch || statusFilter !== 'all' || selectedAgents.length > 0
? 'Try adjusting your search or filters.'
: 'No events have been recorded yet.'}
</p>
</div>
) : (
<div className={cn(
isScopedView ? "bg-gray-50 rounded-lg border border-gray-200 p-4" : "",
"space-y-4"
)}>
{createTimelineWithDividers(filteredEntries)}
</div>
)}
{/* Load More */}
{historyData && historyData.total > filteredEntries.length && (
<div className="flex justify-center mt-6">
<button
onClick={() => setQueryParams(prev => ({ ...prev, page: prev.page + 1 }))}
disabled={isFetching}
className="flex items-center space-x-2 px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isFetching ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<span>Load More Events</span>
)}
</button>
</div>
)}
</div>
);
};
export default ChatTimeline;