Watch
1
0
Fork
You've already forked RedFlag
0

web: fix 13 dashboard criticals from the UI/UX audit

- confirm-deps used a raw fetch with no auth headers -> API layer
- retry/cancel had two copies with divergent invalidation (one hit a dead
  ['active'] key); unified in useCommands, bulk-approve now refreshes counts
- Docker stat-card filter was sent server-side where it mapped to the severity
  column and matched nothing; moved client-side with two distinct values
- wired dead Quick Actions buttons, notification deep-links, ws reconnect+backoff
- useMemo side-effect -> useEffect, agent-events dedup, stale-closure toast
- dropped dead agent memory block, labeled security health as fleet-wide
- maintenance banner claimed installs are "blocked"; they wait for the window
This commit is contained in:
Fimeg 2026-06-15 09:07:19 -04:00
commit f04fbe8b1f
16 changed files with 137 additions and 132 deletions

View file

@ -545,7 +545,12 @@ export function AgentHealth({ agentId }: AgentHealthProps) {
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<Shield className="h-5 w-5 text-blue-600" />
<h3 className="text-base font-semibold text-gray-900">Security Health</h3>
<div>
<h3 className="text-base font-semibold text-gray-900">Security Health</h3>
{/* This surface reads the fleet-wide security overview, not this
agent's status — label it so it isn't mistaken for per-agent. */}
<p className="text-xs text-gray-500">Fleet-wide not specific to this agent</p>
</div>
</div>
<button
onClick={() => queryClient.invalidateQueries({ queryKey: ['security-overview'] })}

View file

@ -3,7 +3,6 @@ import { useQuery } from '@tanstack/react-query';
import {
HardDrive,
RefreshCw,
MemoryStick,
} from 'lucide-react';
import { formatBytes, formatRelativeTime } from '@/lib/utils';
import { agentApi } from '@/lib/api';
@ -233,32 +232,11 @@ export function AgentStorage({ agentId }: AgentStorageProps) {
</button>
</div>
{/* Memory & Disk - matching Overview styling */}
{/* Disk usage. Memory/CPU are not carried by the storage-metrics endpoint
(they live in the system-metrics feed) the dead memory block that
read hardcoded zeros was removed. Wiring real memory here is a separate
task against /agents/:id/metrics/system. */}
<div className="space-y-4">
{/* Memory - GREEN to differentiate from disks */}
{storageMetrics && storageMetrics.memory_total_gb > 0 && (
<div>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600 flex items-center">
<MemoryStick className="h-4 w-4 mr-1" />
Memory
</p>
<p className="text-sm font-medium text-gray-900">
{storageMetrics.memory_used_gb.toFixed(1)} GB / {storageMetrics.memory_total_gb.toFixed(1)} GB
</p>
</div>
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
<div
className="bg-green-600 h-2 rounded-full transition-all"
style={{ width: `${Math.min(storageMetrics.memory_percent, 100)}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">
{storageMetrics.memory_percent.toFixed(0)}% used
</p>
</div>
)}
{/* Quick Overview - Simple disk bars for at-a-glance view */}
{disks.length > 0 && (
<div className="space-y-3">

View file

@ -37,7 +37,9 @@ export function AgentUpdate({ agent, onUpdateComplete, className }: AgentUpdateP
if (hasUpdate && availableVersion) {
setShowConfirmDialog(true);
} else if (!hasUpdate && hasChecked) {
} else if (!hasUpdate) {
// We're already inside the post-check path, so no need to re-read
// hasChecked (its closure value is still the pre-setState false).
toast('Agent is already at latest version');
}
} catch (error) {

View file

@ -313,6 +313,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
onClick={() => {
markNotificationRead(notification.id);
setIsNotificationDropdownOpen(false);
if (notification.href) {
navigate(notification.href);
}
}}
>
<div className="flex items-start space-x-3">

View file

@ -17,6 +17,8 @@ import { securityHealthColor } from '@/components/primitives/statusColors';
const SecurityStatusCard: React.FC<SecurityStatusCardProps> = ({
status,
onRefresh,
onViewLogs,
onMonitorEvents,
loading = false,
}) => {
const getStatusIcon = () => {
@ -199,15 +201,27 @@ const SecurityStatusCard: React.FC<SecurityStatusCardProps> = ({
Quick Actions
</p>
<div className="flex flex-wrap gap-2">
<button className="px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-lg hover:border-gray-400 flex items-center gap-2">
<button
onClick={onViewLogs}
disabled={!onViewLogs}
className="px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-lg hover:border-gray-400 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Eye className="w-3 h-3" />
View Security Logs
</button>
<button className="px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-lg hover:border-gray-400 flex items-center gap-2">
<button
onClick={onRefresh}
disabled={!onRefresh || loading}
className="px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-lg hover:border-gray-400 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Shield className="w-3 h-3" />
Run Security Check
</button>
<button className="px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-lg hover:border-gray-400 flex items-center gap-2">
<button
onClick={onMonitorEvents}
disabled={!onMonitorEvents}
className="px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-lg hover:border-gray-400 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Activity className="w-3 h-3" />
Monitor Events
</button>

View file

@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import { useRealtimeStore } from '@/lib/store';
@ -31,6 +31,8 @@ export const useAgentEvents = (
options: UseAgentEventsOptions = {}
) => {
const { addNotification } = useRealtimeStore();
// Track seen event IDs so we notify once per event, not once per poll tick.
const seenRef = useRef<Set<string>>(new Set());
const {
severity = 'error,critical,warning',
limit = 50,
@ -62,6 +64,10 @@ export const useAgentEvents = (
if (data?.events && data.events.length > 0) {
// Map system events to notification format and add to notification store
data.events.forEach((event) => {
// Only push events we haven't seen this session.
if (seenRef.current.has(event.id)) return;
seenRef.current.add(event.id);
// Map severity to notification type
const type =
event.severity === 'critical'

View file

@ -32,16 +32,26 @@ export const useRecentCommands = (limit?: number): UseQueryResult<{ commands: Ac
});
};
// Canonical owner of the command retry/cancel mutations. useUpdates re-exports
// these so the Updates page and LiveOperations share one invalidation set —
// a retry/cancel from either surface must refresh both views plus dashboard
// counts. (Previously a divergent copy in useUpdates invalidated a dead
// ['active'] key and skipped activeCommands, so LiveOperations went stale.)
const invalidateCommandViews = (queryClient: ReturnType<typeof useQueryClient>) => {
queryClient.invalidateQueries({ queryKey: ['activeCommands'] });
queryClient.invalidateQueries({ queryKey: ['recentCommands'] });
queryClient.invalidateQueries({ queryKey: ['updates'] });
queryClient.invalidateQueries({ queryKey: ['update'] });
queryClient.invalidateQueries({ queryKey: ['logs'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
};
export const useRetryCommand = (): UseMutationResult<{ message: string; command_id: string; new_id: string }, Error, string, unknown> => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateApi.retryCommand,
onSuccess: () => {
// Invalidate active and recent commands queries
queryClient.invalidateQueries({ queryKey: ['activeCommands'] });
queryClient.invalidateQueries({ queryKey: ['recentCommands'] });
},
onSuccess: () => invalidateCommandViews(queryClient),
});
};
@ -50,11 +60,7 @@ export const useCancelCommand = (): UseMutationResult<{ message: string }, Error
return useMutation({
mutationFn: updateApi.cancelCommand,
onSuccess: () => {
// Invalidate active and recent commands queries
queryClient.invalidateQueries({ queryKey: ['activeCommands'] });
queryClient.invalidateQueries({ queryKey: ['recentCommands'] });
},
onSuccess: () => invalidateCommandViews(queryClient),
});
};

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useFilterUrl, type FilterConfig } from './useFilterUrl';
import { useQueryParser, type ParsedPill } from './useQueryParser';
import { buildQueryString } from '@/lib/queryParser';
@ -55,17 +55,17 @@ export function useMultimodalFilter<C extends FilterConfig>(
const url = useFilterUrl(config);
const parser = useQueryParser(knownKeys, opts);
// When parsed filters change, sync to URL
// (only for keys that are in the config)
const lastSynced = useMemo(() => ({} as Record<string, string>), []);
// When parsed filters change, sync to URL (only for keys that are in the config).
// This writes to the URL, so it's a side effect — must live in useEffect, not
// useMemo (which React may run twice in StrictMode / skip arbitrarily).
const lastSynced = useRef<Record<string, string>>({});
// Sync parsed filters → URL (debounced via parser)
useMemo(() => {
useEffect(() => {
for (const key of knownKeys) {
const parsedVal = parser.parsed.filters[key] ?? '';
const currentUrlVal = (url.values as any)[key] ?? '';
if (parsedVal !== lastSynced[key]) {
lastSynced[key] = parsedVal;
if (parsedVal !== lastSynced.current[key]) {
lastSynced.current[key] = parsedVal;
if (parsedVal !== currentUrlVal) {
url.setFilter(key, parsedVal);
}

View file

@ -344,48 +344,57 @@ export const useSecurityWebSocket = () => {
const ws = React.useRef<WebSocket | null>(null);
React.useEffect(() => {
// Initialize WebSocket connection
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/v1/security/ws`;
ws.current = new WebSocket(wsUrl, []);
let reconnectAttempts = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
// Set on unmount so a pending close handler doesn't resurrect the socket.
let stopped = false;
ws.current.onopen = () => {
setConnected(true);
clientLogger.debug('Security WebSocket connected');
};
const connect = () => {
if (stopped) return;
const socket = new WebSocket(wsUrl, []);
ws.current = socket;
ws.current.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'security_event') {
setEvents(prev => [message.data, ...prev.slice(0, 999)]); // Keep last 1000 events
socket.onopen = () => {
reconnectAttempts = 0;
setConnected(true);
clientLogger.debug('Security WebSocket connected');
};
socket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'security_event') {
setEvents(prev => [message.data, ...prev.slice(0, 999)]); // Keep last 1000 events
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
socket.onerror = (error) => {
console.error('Security WebSocket error:', error);
setConnected(false);
};
socket.onclose = () => {
setConnected(false);
clientLogger.debug('Security WebSocket disconnected');
if (stopped) return;
// Exponential backoff capped at 30s: 1s, 2s, 4s, 8s, 16s, 30s…
const delay = Math.min(30000, 1000 * 2 ** reconnectAttempts);
reconnectAttempts += 1;
reconnectTimer = setTimeout(connect, delay);
};
};
ws.current.onerror = (error) => {
console.error('Security WebSocket error:', error);
setConnected(false);
};
ws.current.onclose = () => {
setConnected(false);
clientLogger.debug('Security WebSocket disconnected');
// Attempt to reconnect after 5 seconds
setTimeout(() => {
if (!ws.current || ws.current.readyState === WebSocket.CLOSED) {
// Re-initialize connection
}
}, 5000);
};
connect();
return () => {
if (ws.current) {
ws.current.close();
}
stopped = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (ws.current) ws.current.close();
};
}, []);

View file

@ -106,10 +106,12 @@ export const useApproveMultipleUpdates = (): UseMutationResult<void, Error, Upda
return useMutation({
mutationFn: (request: UpdateApprovalRequest) => updateApi.approveUpdates(request),
onSuccess: () => {
// Invalidate all updates queries to trigger refetch
// Match useApproveUpdate's set — bulk approve must refresh dashboard
// counts and LiveOperations too, not just the updates list.
queryClient.invalidateQueries({ queryKey: ['updates'] });
// Also invalidate specific update queries
queryClient.invalidateQueries({ queryKey: ['update'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
queryClient.invalidateQueries({ queryKey: ['activeCommands'] });
},
});
};
@ -144,22 +146,11 @@ export const useInstallUpdate = (): UseMutationResult<void, Error, string, unkno
});
};
export const useRetryCommand = (): UseMutationResult<{ message: string; command_id: string; new_id: string }, Error, string, unknown> => {
const queryClient = useQueryClient();
// Command retry/cancel live in useCommands (canonical, shared invalidation set).
// Re-exported here so existing Updates-page imports keep resolving.
export { useRetryCommand, useCancelCommand } from './useCommands';
return useMutation({
mutationFn: updateApi.retryCommand,
onSuccess: () => {
// Invalidate all updates queries to trigger refetch
queryClient.invalidateQueries({ queryKey: ['updates'] });
// Also invalidate logs and active operations queries
queryClient.invalidateQueries({ queryKey: ['logs'] });
queryClient.invalidateQueries({ queryKey: ['active'] });
},
});
};
export const useReopenUpdate = (): UseMutationResult<{ message: string }, Error, string, unknown> => {
export const useReopenUpdate =(): UseMutationResult<{ message: string }, Error, string, unknown> => {
const queryClient = useQueryClient();
return useMutation({
@ -185,21 +176,6 @@ export const useResolveUpdate = (): UseMutationResult<{ message: string }, Error
});
};
export const useCancelCommand = (): UseMutationResult<{ message: string }, Error, string, unknown> => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateApi.cancelCommand,
onSuccess: () => {
// Invalidate all updates queries to trigger refetch
queryClient.invalidateQueries({ queryKey: ['updates'] });
// Also invalidate logs and active operations queries
queryClient.invalidateQueries({ queryKey: ['logs'] });
queryClient.invalidateQueries({ queryKey: ['active'] });
},
});
};
// Capability token status (LIFECYCLE-005)
export const useCapabilityTokenStatus = (
agentId: string,

View file

@ -179,6 +179,8 @@ interface RealtimeState {
message: string;
timestamp: string;
read: boolean;
// Optional deep-link target; clicking the notification navigates here.
href?: string;
}>;
setConnected: (connected: boolean) => void;
setLastUpdate: (timestamp: string) => void;

View file

@ -73,7 +73,9 @@ const Docker: React.FC = () => {
const { data: dockerData, isPending, error } = useDockerContainers({
search: searchText || undefined,
status: values.status || undefined,
// NOTE: `status` is filtered client-side below (see filteredUnified). The server
// reused the `status` query param as a severity filter, so sending update-state
// values here matched nothing.
page: 1,
page_size: 50,
});
@ -166,6 +168,12 @@ const Docker: React.FC = () => {
);
}
if (values.status === 'update-available') {
rows = rows.filter(r => r.availableVersion != null);
} else if (values.status === 'pending-approval') {
rows = rows.filter(r => r.status === 'update-available');
}
if (values.severity) {
rows = rows.filter(r => r.severity === values.severity);
}
@ -336,8 +344,8 @@ const Docker: React.FC = () => {
<FilterCountButton
count={pendingApproval}
label="Pending Approval"
isSelected={values.status === 'update-available'}
onClick={() => setFilter('status', values.status === 'update-available' ? '' : 'update-available')}
isSelected={values.status === 'pending-approval'}
onClick={() => setFilter('status', values.status === 'pending-approval' ? '' : 'pending-approval')}
color="orange"
icon={<AlertTriangle className="h-8 w-8 text-orange-400" />}
/>

View file

@ -382,6 +382,8 @@ const SecuritySettings: React.FC = () => {
last_updated: new Date().toISOString(),
}}
onRefresh={refetch}
onViewLogs={() => setActiveTab('audit')}
onMonitorEvents={() => setActiveTab('events')}
loading={loading}
/>
)}

View file

@ -252,16 +252,8 @@ const Updates: React.FC = () => {
const handleConfirmDependencies = async (updateId: string) => {
setDependencyLoading(true);
try {
const response = await fetch(`/api/v1/updates/${updateId}/confirm-dependencies`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to confirm dependencies');
}
// Route through the API layer so auth headers are injected (was a raw fetch).
await updateApi.confirmDependencies(updateId);
toast.success('Dependency installation confirmed');
setShowDependencyModal(false);
@ -270,7 +262,7 @@ const Updates: React.FC = () => {
// Refresh the update data
queryClient.invalidateQueries({ queryKey: ['updates'] });
queryClient.invalidateQueries({ queryKey: ['update', dependencyUpdateId] });
queryClient.invalidateQueries({ queryKey: ['update', updateId] });
queryClient.invalidateQueries({ queryKey: ['activeCommands'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
} catch (error) {

View file

@ -104,7 +104,7 @@ const MaintenanceWindowsPage: React.FC = () => {
<span className={`text-sm font-medium ${check?.inside_window ? 'text-green-800' : 'text-gray-600'}`}>
{check?.inside_window
? 'Currently inside a maintenance window — installations will proceed'
: 'Outside maintenance windows — installations will be blocked during scheduled windows'}
: 'Outside maintenance windows — approved installs are held until the next scheduled window'}
</span>
</div>
</div>

View file

@ -134,6 +134,8 @@ export interface SecurityFeatureStatus {
export interface SecurityStatusCardProps {
status: SecurityStatus;
onRefresh?: () => void;
onViewLogs?: () => void;
onMonitorEvents?: () => void;
loading?: boolean;
}