Files
Redflag/aggregator-web/src/components/HistoryTimeline.tsx
jpetree331 f97d4845af feat(security): A-1 Ed25519 key rotation + A-2 replay attack fixes
Complete RedFlag codebase with two major security audit implementations.

== A-1: Ed25519 Key Rotation Support ==

Server:
- SignCommand sets SignedAt timestamp and KeyID on every signature
- signing_keys database table (migration 020) for multi-key rotation
- InitializePrimaryKey registers active key at startup
- /api/v1/public-keys endpoint for rotation-aware agents
- SigningKeyQueries for key lifecycle management

Agent:
- Key-ID-aware verification via CheckKeyRotation
- FetchAndCacheAllActiveKeys for rotation pre-caching
- Cache metadata with TTL and staleness fallback
- SecurityLogger events for key rotation and command signing

== A-2: Replay Attack Fixes (F-1 through F-7) ==

F-5 CRITICAL - RetryCommand now signs via signAndCreateCommand
F-1 HIGH     - v3 format: "{agent_id}:{cmd_id}:{type}:{hash}:{ts}"
F-7 HIGH     - Migration 026: expires_at column with partial index
F-6 HIGH     - GetPendingCommands/GetStuckCommands filter by expires_at
F-2 HIGH     - Agent-side executedIDs dedup map with cleanup
F-4 HIGH     - commandMaxAge reduced from 24h to 4h
F-3 CRITICAL - Old-format commands rejected after 48h via CreatedAt

Verification fixes: migration idempotency (ETHOS #4), log format
compliance (ETHOS #1), stale comments updated.

All 24 tests passing. Docker --no-cache build verified.
See docs/ for full audit reports and deviation log (DEV-001 to DEV-019).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:25:47 -04:00

470 lines
18 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
Activity,
CheckCircle,
XCircle,
AlertTriangle,
Clock,
Package,
Computer,
Calendar,
ChevronDown,
ChevronRight,
Terminal,
RefreshCw,
Filter,
Search,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { logApi } from '@/lib/api';
import { cn } from '@/lib/utils';
import { formatRelativeTime } from '@/lib/utils';
import toast from 'react-hot-toast';
interface HistoryEntry {
id: string;
agent_id: string;
update_package_id?: string;
action: string;
result: string;
stdout?: string;
stderr?: string;
exit_code: number;
duration_seconds: number;
executed_at: string;
}
interface HistoryTimelineProps {
agentId?: string; // Optional - if provided, filter to specific agent
className?: string;
}
interface TimelineGroup {
date: string;
entries: HistoryEntry[];
}
const HistoryTimeline: React.FC<HistoryTimelineProps> = ({ agentId, className }) => {
const [searchQuery, setSearchQuery] = useState('');
const [actionFilter, setActionFilter] = useState('all');
const [resultFilter, setResultFilter] = useState('all');
const [showFilters, setShowFilters] = useState(false);
const [expandedEntries, setExpandedEntries] = useState<Set<string>>(new Set());
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
// Query parameters for API
const [queryParams, setQueryParams] = useState({
page: 1,
page_size: 50,
agent_id: agentId || '',
action: actionFilter !== 'all' ? actionFilter : '',
result: resultFilter !== 'all' ? resultFilter : '',
search: searchQuery,
});
// 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.action) {
params.action = queryParams.action;
}
if (queryParams.result) {
params.result = queryParams.result;
}
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, // Refresh every 30 seconds
});
// Group entries by date
const groupEntriesByDate = (entries: HistoryEntry[]): TimelineGroup[] => {
const groups: { [key: string]: HistoryEntry[] } = {};
entries.forEach(entry => {
const date = new Date(entry.executed_at);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
let dateKey: string;
if (date.toDateString() === today.toDateString()) {
dateKey = 'Today';
} else if (date.toDateString() === yesterday.toDateString()) {
dateKey = 'Yesterday';
} else {
dateKey = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
if (!groups[dateKey]) {
groups[dateKey] = [];
}
groups[dateKey].push(entry);
});
return Object.entries(groups).map(([date, entries]) => ({
date,
entries: entries.sort((a, b) =>
new Date(b.executed_at).getTime() - new Date(a.executed_at).getTime()
),
}));
};
const timelineGroups = groupEntriesByDate(historyData?.logs || []);
// Toggle entry expansion
const toggleEntry = (entryId: string) => {
const newExpanded = new Set(expandedEntries);
if (newExpanded.has(entryId)) {
newExpanded.delete(entryId);
} else {
newExpanded.add(entryId);
}
setExpandedEntries(newExpanded);
};
// Toggle date expansion
const toggleDate = (date: string) => {
const newExpanded = new Set(expandedDates);
if (newExpanded.has(date)) {
newExpanded.delete(date);
} else {
newExpanded.add(date);
}
setExpandedDates(newExpanded);
};
// Get action icon
const getActionIcon = (action: string) => {
switch (action) {
case 'install':
case 'upgrade':
return <Package className="h-4 w-4" />;
case 'scan':
return <Search className="h-4 w-4" />;
case 'dry_run':
return <Terminal className="h-4 w-4" />;
default:
return <Activity className="h-4 w-4" />;
}
};
// Get result icon
const getResultIcon = (result: string) => {
switch (result) {
case 'success':
return <CheckCircle className="h-4 w-4 text-green-500" />;
case 'failed':
return <XCircle className="h-4 w-4 text-red-500" />;
case 'running':
return <RefreshCw className="h-4 w-4 text-blue-500 animate-spin" />;
default:
return <AlertTriangle className="h-4 w-4 text-yellow-500" />;
}
};
// Get status color
const getStatusColor = (result: string) => {
switch (result) {
case 'success':
return 'text-green-700 bg-green-100 border-green-200';
case 'failed':
return 'text-red-700 bg-red-100 border-red-200';
case 'running':
return 'text-blue-700 bg-blue-100 border-blue-200';
default:
return 'text-gray-700 bg-gray-100 border-gray-200';
}
};
// Format duration
const formatDuration = (seconds: number) => {
if (seconds < 60) {
return `${seconds}s`;
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
} else {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
};
return (
<div className={cn("space-y-6", className)}>
{/* Header with search and filters */}
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<Calendar className="h-5 w-5 text-gray-600" />
<h3 className="text-lg font-medium text-gray-900">
{agentId ? 'Agent History' : 'Universal Audit Log'}
</h3>
</div>
<button
onClick={() => refetch()}
disabled={isFetching}
className="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm font-medium transition-colors disabled:opacity-50"
>
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
<span>Refresh</span>
</button>
</div>
<div className="flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by action or result..."
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
</div>
{/* Filter toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className="flex items-center space-x-2 px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
>
<Filter className="h-4 w-4" />
<span>Filters</span>
{(actionFilter !== 'all' || resultFilter !== 'all') && (
<span className="bg-primary-100 text-primary-800 px-2 py-0.5 rounded-full text-xs">
{[actionFilter, resultFilter].filter(f => f !== 'all').length}
</span>
)}
</button>
</div>
{/* Filters */}
{showFilters && (
<div className="mt-4 pt-4 border-t border-gray-200 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Action
</label>
<select
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="all">All Actions</option>
<option value="install">Install</option>
<option value="upgrade">Upgrade</option>
<option value="scan">Scan</option>
<option value="dry_run">Dry Run</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Result
</label>
<select
value={resultFilter}
onChange={(e) => setResultFilter(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="all">All Results</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
<option value="running">Running</option>
</select>
</div>
</div>
)}
</div>
{/* 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 history...</span>
</div>
)}
{/* Timeline */}
{!isLoading && timelineGroups.length === 0 ? (
<div className="text-center py-12">
<Calendar className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No history found</h3>
<p className="mt-1 text-sm text-gray-500">
{searchQuery || actionFilter !== 'all' || resultFilter !== 'all'
? 'Try adjusting your search or filters.'
: 'No activities have been recorded yet.'}
</p>
</div>
) : (
<div className="space-y-6">
{timelineGroups.map((group) => (
<div key={group.date} className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
{/* Date header */}
<div
className="px-4 py-3 bg-gray-50 border-b border-gray-200 cursor-pointer hover:bg-gray-100 transition-colors"
onClick={() => toggleDate(group.date)}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{expandedDates.has(group.date) ? (
<ChevronDown className="h-4 w-4 text-gray-600" />
) : (
<ChevronRight className="h-4 w-4 text-gray-600" />
)}
<h4 className="font-medium text-gray-900">{group.date}</h4>
<span className="text-sm text-gray-500">
({group.entries.length} events)
</span>
</div>
</div>
</div>
{/* Timeline entries */}
{expandedDates.has(group.date) && (
<div className="divide-y divide-gray-200">
{group.entries.map((entry) => (
<div key={entry.id} className="p-4">
<div className="flex items-start space-x-3">
{/* Timeline icon */}
<div className="flex-shrink-0 mt-1">
{getResultIcon(entry.result)}
</div>
{/* Entry content */}
<div className="flex-1 min-w-0">
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => toggleEntry(entry.id)}
>
<div className="flex items-center space-x-2">
{getActionIcon(entry.action)}
<span className="font-medium text-gray-900 capitalize">
{entry.action}
</span>
<span className={cn(
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border",
getStatusColor(entry.result)
)}>
{entry.result}
</span>
</div>
<div className="flex items-center space-x-4 text-sm text-gray-500">
<span>{formatRelativeTime(entry.executed_at)}</span>
<span>{formatDuration(entry.duration_seconds)}</span>
</div>
</div>
{/* Agent info */}
<div className="mt-1 flex items-center space-x-2 text-sm text-gray-600">
<Computer className="h-3 w-3" />
<span>Agent: {entry.agent_id}</span>
</div>
{/* Expanded details */}
{expandedEntries.has(entry.id) && (
<div className="mt-3 space-y-3">
{/* Metadata */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-gray-700">Exit Code:</span>
<span className="ml-2">{entry.exit_code}</span>
</div>
<div>
<span className="font-medium text-gray-700">Duration:</span>
<span className="ml-2">{formatDuration(entry.duration_seconds)}</span>
</div>
</div>
{/* Output */}
{(entry.stdout || entry.stderr) && (
<div>
<h5 className="text-sm font-medium text-gray-900 mb-2 flex items-center space-x-2">
<Terminal className="h-4 w-4" />
<span>Output</span>
</h5>
{entry.stdout && (
<div className="bg-gray-900 text-green-400 p-3 rounded-md font-mono text-xs overflow-x-auto">
<pre className="whitespace-pre-wrap">{entry.stdout}</pre>
</div>
)}
{entry.stderr && (
<div className="bg-gray-900 text-red-400 p-3 rounded-md font-mono text-xs overflow-x-auto mt-2">
<pre className="whitespace-pre-wrap">{entry.stderr}</pre>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{/* Pagination */}
{historyData && historyData.total > historyData.page_size && (
<div className="flex items-center justify-between bg-white px-4 py-3 border border-gray-200 rounded-lg shadow-sm">
<div className="text-sm text-gray-700">
Showing {((historyData.page - 1) * historyData.page_size) + 1} to{' '}
{Math.min(historyData.page * historyData.page_size, historyData.total)} of{' '}
{historyData.total} results
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setQueryParams(prev => ({ ...prev, page: Math.max(1, prev.page - 1) }))}
disabled={historyData.page === 1}
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-gray-700">
Page {historyData.page} of {Math.ceil(historyData.total / historyData.page_size)}
</span>
<button
onClick={() => setQueryParams(prev => ({ ...prev, page: prev.page + 1 }))}
disabled={historyData.page >= Math.ceil(historyData.total / historyData.page_size)}
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
)}
</div>
);
};
export default HistoryTimeline;