ui: improve Agent Health layout and fix misaligned controls
- Move Update Agent button to Subsystem Configuration header - Remove duplicate Compact Summary box with misaligned refresh - Reduce visual separation between sections (same card styling) - Make security status details visible instead of hidden in tooltips - Fix enforced status colors (blue instead of red) - Consolidate enabled/auto-run counts in header - Reduce spacing between sections for cohesive interface The enabled/auto-run toggles now properly align with their subsystems in the table, and critical security information is immediately visible without hover interactions.
This commit is contained in:
@@ -13,12 +13,14 @@ import {
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
XCircle,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import { agentApi, securityApi } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AgentSubsystem } from '@/types';
|
||||
import { AgentUpdate } from './AgentUpdate';
|
||||
|
||||
interface AgentScannersProps {
|
||||
agentId: string;
|
||||
@@ -53,6 +55,7 @@ const subsystemConfig: Record<string, { icon: React.ReactNode; name: string; des
|
||||
};
|
||||
|
||||
export function AgentScanners({ agentId }: AgentScannersProps) {
|
||||
const [showUpdateModal, setShowUpdateModal] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch subsystems from API
|
||||
@@ -65,6 +68,13 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
});
|
||||
|
||||
// Fetch agent data for Update Agent button
|
||||
const { data: agent } = useQuery({
|
||||
queryKey: ['agent', agentId],
|
||||
queryFn: () => agentApi.getAgent(agentId),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
// Fetch security health status
|
||||
const { data: securityOverview, isLoading: securityLoading } = useQuery({
|
||||
queryKey: ['security-overview'],
|
||||
@@ -79,12 +89,16 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
|
||||
const getSecurityStatusDisplay = (status: string) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
case 'enforced':
|
||||
case 'operational':
|
||||
return {
|
||||
color: 'text-green-600 bg-green-100 border-green-200',
|
||||
icon: <CheckCircle className="h-4 w-4 text-green-600" />
|
||||
};
|
||||
case 'enforced':
|
||||
return {
|
||||
color: 'text-blue-600 bg-blue-100 border-blue-200',
|
||||
icon: <Shield className="h-4 w-4 text-blue-600" />
|
||||
};
|
||||
case 'degraded':
|
||||
return {
|
||||
color: 'text-amber-600 bg-amber-100 border-amber-200',
|
||||
@@ -227,11 +241,25 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
|
||||
const autoRunCount = subsystems.filter(s => s.auto_run && s.enabled).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
{/* Subsystem Configuration Table */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-900">Subsystem Configuration</h3>
|
||||
<div className="flex items-center space-x-4">
|
||||
<h3 className="text-sm font-medium text-gray-900">Subsystem Configuration</h3>
|
||||
<div className="flex items-center space-x-3 text-xs text-gray-600">
|
||||
<span>{enabledCount} enabled</span>
|
||||
<span>{autoRunCount} auto-running</span>
|
||||
<span className="text-gray-500">• {subsystems.length} total</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowUpdateModal(true)}
|
||||
className="flex items-center space-x-1 px-3 py-1 text-xs text-blue-600 hover:text-blue-800 border border-blue-300 hover:bg-blue-50 rounded-md transition-colors"
|
||||
>
|
||||
<Upload className="h-3 w-3" />
|
||||
<span>Update Agent</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -376,37 +404,13 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compact note */}
|
||||
<div className="text-xs text-gray-500">
|
||||
Subsystems report specific metrics to the server on scheduled intervals. Enable auto-run to schedule automatic scans, or trigger manual scans as needed.
|
||||
</div>
|
||||
|
||||
{/* Compact Summary */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center space-x-6">
|
||||
<div>
|
||||
<span className="text-gray-600">Enabled:</span>
|
||||
<span className="ml-2 font-medium text-green-600">{enabledCount}/{subsystems.length}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Auto-Run:</span>
|
||||
<span className="ml-2 font-medium text-blue-600">{autoRunCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading}
|
||||
className="flex items-center space-x-1 px-3 py-1 text-xs text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
<RefreshCw className={cn('h-3 w-3', isLoading && 'animate-spin')} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* Note */}
|
||||
<div className="text-xs text-gray-500 text-center py-2">
|
||||
Subsystems report specific metrics on scheduled intervals. Enable auto-run to schedule automatic scans, or use Actions to trigger manual scans.
|
||||
</div>
|
||||
|
||||
{/* Security Health */}
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-lg border border-gray-200/50 shadow-sm">
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200/50">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Shield className="h-5 w-5 text-blue-600" />
|
||||
@@ -516,6 +520,25 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const getDetailedSecurityInfo = (subsystemType: string, subsystem: any) => {
|
||||
if (!securityOverview?.subsystems[subsystemType]) return '';
|
||||
|
||||
const subsystemData = securityOverview.subsystems[subsystemType];
|
||||
const checks = subsystemData.checks || {};
|
||||
const metrics = subsystemData.metrics || {};
|
||||
|
||||
switch (subsystemType) {
|
||||
case 'nonce_validation':
|
||||
return `Nonces: ${metrics.total_pending_commands || 0} pending. Max age: ${checks.max_age_minutes || 5}min. Failures: ${checks.validation_failures || 0}. Format: ${checks.nonce_format || 'UUID:Timestamp'}`;
|
||||
case 'machine_binding':
|
||||
return `Machine ID: ${checks.machine_id_type || 'Hardware fingerprint'}. Bound agents: ${checks.bound_agents || 'Unknown'}. Violations: ${checks.recent_violations || 0}. Min version: ${checks.min_agent_version || 'v0.1.22'}`;
|
||||
case 'ed25519_signing':
|
||||
return `Key: ${checks.public_key_fingerprint?.substring(0, 16) || 'Not available'}... Algorithm: ${checks.algorithm || 'Ed25519'}. Valid since: ${new Date(securityOverview.timestamp).toLocaleDateString()}`;
|
||||
default:
|
||||
return `Status: ${subsystem.status}. Last check: ${new Date(securityOverview.timestamp).toLocaleString()}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
@@ -528,7 +551,7 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
|
||||
{getSecurityIcon(key)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
||||
{getSecurityDisplayName(key)}
|
||||
<CheckCircle className="w-3 h-3 text-gray-400" />
|
||||
@@ -536,15 +559,24 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
|
||||
<p className="text-xs text-gray-600 mt-0.5">
|
||||
{getEnhancedSubtitle(key, subsystem.status)}
|
||||
</p>
|
||||
{(key === 'nonce_validation' || key === 'machine_binding' || key === 'ed25519_signing') && (
|
||||
<div className="mt-2 p-2 bg-gray-50/70 rounded border border-gray-200/50">
|
||||
<p className="text-xs text-gray-700 font-mono break-all">
|
||||
{getDetailedSecurityInfo(key, subsystem)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn(
|
||||
'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium border',
|
||||
subsystem.status === 'healthy' ? 'bg-green-100 text-green-700 border-green-200' :
|
||||
subsystem.status === 'enforced' ? 'bg-blue-100 text-blue-700 border-blue-200' :
|
||||
subsystem.status === 'degraded' ? 'bg-amber-100 text-amber-700 border-amber-200' :
|
||||
'bg-red-100 text-red-700 border-red-200'
|
||||
)}>
|
||||
{subsystem.status === 'healthy' && <CheckCircle className="w-3 h-3" />}
|
||||
{subsystem.status === 'enforced' && <Shield className="w-3 h-3" />}
|
||||
{subsystem.status === 'degraded' && <AlertCircle className="w-3 h-3" />}
|
||||
{subsystem.status === 'unhealthy' && <XCircle className="w-3 h-3" />}
|
||||
{subsystem.status.toUpperCase()}
|
||||
@@ -603,6 +635,35 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Update Agent Modal */}
|
||||
{showUpdateModal && agent && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowUpdateModal(false)} />
|
||||
<div className="relative z-10 w-full max-w-lg mx-4">
|
||||
<div className="bg-white rounded-lg shadow-xl border border-gray-200">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Update Agent: {agent.hostname}</h3>
|
||||
<button
|
||||
onClick={() => setShowUpdateModal(false)}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<XCircle className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<AgentUpdate
|
||||
agent={agent}
|
||||
onUpdateComplete={() => {
|
||||
setShowUpdateModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['agent', agentId] });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -190,6 +190,36 @@ export const agentApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get agent metrics
|
||||
getAgentMetrics: async (agentId: string): Promise<any> => {
|
||||
const response = await api.get(`/agents/${agentId}/metrics`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get agent storage metrics
|
||||
getAgentStorageMetrics: async (agentId: string): Promise<any> => {
|
||||
const response = await api.get(`/agents/${agentId}/metrics/storage`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get agent system metrics
|
||||
getAgentSystemMetrics: async (agentId: string): Promise<any> => {
|
||||
const response = await api.get(`/agents/${agentId}/metrics/system`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get agent Docker images
|
||||
getAgentDockerImages: async (agentId: string, params?: any): Promise<any> => {
|
||||
const response = await api.get(`/agents/${agentId}/docker-images`, { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get agent Docker info
|
||||
getAgentDockerInfo: async (agentId: string): Promise<any> => {
|
||||
const response = await api.get(`/agents/${agentId}/docker-info`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update multiple agents (bulk)
|
||||
updateMultipleAgents: async (updateData: {
|
||||
agent_ids: string[];
|
||||
|
||||
Reference in New Issue
Block a user