fix: README, .env.example, ErrorBoundary, client-logger, HEALTHCHECK, Docker hygiene
- README: version v0.2.6.8, corrected stale gate claim, updated changelog - .env.example: merged two competing files into one, deleted bootstrap duplicate - ErrorBoundary: new component wrapping app, prevents white-screen crashes - Layout sidebar: version display from /api/health, Docs link to GitHub - client-logger: debug/trace logger gated behind localStorage.redflag_debug=1, routes through existing /logs/client-error server endpoint (ETHOS #1) - All web console.log calls rerouted through client-logger instead of deleted - Server health endpoint returns version field - Server accepts client_debug/client_trace in error_type validation - Dockerfiles: pinned alpine:latest->3.21, nginx:alpine->1.27-alpine, added HEALTHCHECK directives - docker-compose: healthcheck blocks for server and web services - .dockerignore: created to slim Docker build context
This commit is contained in:
parent
80e719acc9
commit
c0a717ab26
30 changed files with 5319 additions and 258 deletions
|
|
@ -3,6 +3,7 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
|||
import { Toaster } from 'react-hot-toast';
|
||||
import { useAuthStore, useUIStore } from '@/lib/store';
|
||||
import { authApi } from '@/lib/api';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import Layout from '@/components/Layout';
|
||||
import Dashboard from '@/pages/Dashboard';
|
||||
import Agents from '@/pages/Agents';
|
||||
|
|
@ -77,6 +78,7 @@ const App: React.FC = () => {
|
|||
}, [token]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className={`min-h-screen bg-gray-50 ${theme === 'dark' ? 'dark' : ''}`}>
|
||||
{/* Toast notifications */}
|
||||
<Toaster
|
||||
|
|
@ -161,6 +163,7 @@ const App: React.FC = () => {
|
|||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { formatBytes, formatRelativeTime } from '@/lib/utils';
|
|||
import { agentApi } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { clientLogger } from '@/lib/client-logger';
|
||||
|
||||
interface AgentStorageProps {
|
||||
agentId: string;
|
||||
|
|
@ -61,16 +62,14 @@ export function AgentStorage({ agentId }: AgentStorageProps) {
|
|||
const { data: storageData, refetch: refetchStorage, error: storageError, isLoading } = useQuery({
|
||||
queryKey: ['storage-metrics', agentId],
|
||||
queryFn: async () => {
|
||||
console.log('[DEBUG] Fetching storage metrics for agent:', agentId);
|
||||
clientLogger.debug('Fetching storage metrics for agent:', { agentId });
|
||||
try {
|
||||
const result = await agentApi.getStorageMetrics(agentId);
|
||||
console.log('[DEBUG] Storage metrics result:', result);
|
||||
console.log('[DEBUG] Result has metrics prop:', 'metrics' in result);
|
||||
console.log('[DEBUG] Result.metrics length:', result.metrics?.length || 0);
|
||||
clientLogger.debug('Storage metrics result received', { agentId, hasMetrics: 'metrics' in result, count: result.metrics?.length || 0 });
|
||||
setLastRefreshed(new Date());
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('[DEBUG] Error fetching storage metrics:', err);
|
||||
clientLogger.debug('Error fetching storage metrics', { agentId, error: err instanceof Error ? err.message : String(err) });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
|
@ -146,15 +145,8 @@ export function AgentStorage({ agentId }: AgentStorageProps) {
|
|||
};
|
||||
|
||||
|
||||
// Debug what we're rendering
|
||||
console.log('[AgentStorage] Rendering with storageData:', storageData);
|
||||
console.log('[AgentStorage] agentData:', agentData);
|
||||
console.log('[AgentStorage] error:', storageError);
|
||||
console.log('[AgentStorage] isLoading:', isLoading);
|
||||
|
||||
// Show API error if request failed
|
||||
if (storageError) {
|
||||
console.error('[AgentStorage] API Error:', storageError);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="alert alert-danger rounded-md">
|
||||
|
|
@ -197,13 +189,8 @@ export function AgentStorage({ agentId }: AgentStorageProps) {
|
|||
|
||||
const disks = parseDiskInfo();
|
||||
|
||||
// Debug disk parsing
|
||||
console.log('[AgentStorage] Parsed disks:', disks);
|
||||
console.log('[AgentStorage] storageMetrics:', storageMetrics);
|
||||
|
||||
// Show error if no data
|
||||
if (!storageData || !storageData.metrics || storageData.metrics.length === 0) {
|
||||
console.log('[AgentStorage] No storage data available');
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="alert alert-warning rounded-md">
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { agentApi, updateApi } from '@/lib/api';
|
|||
import toast from 'react-hot-toast';
|
||||
import { cn, versionCompare } from '@/lib/utils';
|
||||
import { Agent } from '@/types';
|
||||
import { clientLogger } from '@/lib/client-logger';
|
||||
|
||||
interface AgentUpdatesModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -123,7 +124,7 @@ export function AgentUpdatesModal({
|
|||
|
||||
// Generate nonce for security
|
||||
const nonceData = await agentApi.generateUpdateNonce(agentId, pkg.version);
|
||||
console.log('[UI] Update nonce generated for single agent:', nonceData);
|
||||
clientLogger.debug('Update nonce generated for single agent', { agentId, version: pkg.version });
|
||||
|
||||
// Use individual update endpoint with nonce
|
||||
return agentApi.updateAgent(agentId, {
|
||||
|
|
|
|||
|
|
@ -50,13 +50,27 @@ const AttentionPanel: React.FC = () => {
|
|||
});
|
||||
}
|
||||
|
||||
// 1b. Packages with known CVEs (OSV.dev findings)
|
||||
if (stats && stats.vulnerable_packages > 0) {
|
||||
// 1b. Installed version has known CVEs — this is the threat number.
|
||||
if (stats && stats.installed_cve_count > 0) {
|
||||
const n = stats.installed_cve_count;
|
||||
alerts.push({
|
||||
key: 'vulnerable-packages',
|
||||
key: 'installed-cves',
|
||||
severity: 'vuln',
|
||||
title: `${stats.vulnerable_packages} package${stats.vulnerable_packages === 1 ? '' : 's'} with known CVEs`,
|
||||
detail: 'Review vulnerabilities before approving updates',
|
||||
title: `${n} known CVE${n === 1 ? '' : 's'} in installed packages`,
|
||||
detail: 'Installed versions have active advisories — patch to remediate',
|
||||
href: '/updates',
|
||||
icon: ShieldAlert,
|
||||
});
|
||||
}
|
||||
|
||||
// 1c. Security updates available — remediation framing, not threat.
|
||||
if (stats && stats.security_update_count > 0) {
|
||||
const n = stats.security_update_count;
|
||||
alerts.push({
|
||||
key: 'security-updates',
|
||||
severity: 'patch',
|
||||
title: `${n} security update${n === 1 ? '' : 's'} available`,
|
||||
detail: 'Updates carry security advisories — review and approve to apply',
|
||||
href: '/updates?vuln=true',
|
||||
icon: ShieldAlert,
|
||||
});
|
||||
|
|
|
|||
61
web/src/components/ErrorBoundary.tsx
Normal file
61
web/src/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
console.error('[ErrorBoundary] Uncaught error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md text-center">
|
||||
<div className="text-4xl mb-4">⚠</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6 text-sm">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
window.location.reload();
|
||||
}}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Reload page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
RefreshCw,
|
||||
Container,
|
||||
Bell,
|
||||
BookOpen,
|
||||
} from 'lucide-react';
|
||||
import { useUIStore, useAuthStore, useRealtimeStore } from '@/lib/store';
|
||||
import { cn, formatRelativeTime } from '@/lib/utils';
|
||||
|
|
@ -33,9 +34,25 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||
const { notifications, markNotificationRead, clearNotifications } = useRealtimeStore();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isNotificationDropdownOpen, setIsNotificationDropdownOpen] = useState(false);
|
||||
const [serverVersion, setServerVersion] = useState<string | null>(null);
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
|
||||
// Fetch server version from health endpoint
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// Use a direct fetch to avoid requiring auth for health
|
||||
fetch('/api/health')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (!cancelled && data?.version) {
|
||||
setServerVersion(data.version);
|
||||
}
|
||||
})
|
||||
.catch(() => { /* health endpoint may not be reachable yet */ });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
|
|
@ -176,14 +193,31 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||
</nav>
|
||||
|
||||
{/* User section */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center w-full px-3 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<LogOut className="mr-3 h-5 w-5 text-gray-400" />
|
||||
Logout
|
||||
</button>
|
||||
<div className="absolute bottom-0 left-0 right-0 border-t border-gray-200">
|
||||
{/* Version display */}
|
||||
{serverVersion && (
|
||||
<div className="px-4 py-2 text-xs text-gray-400 text-center border-b border-gray-100">
|
||||
v{serverVersion}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 space-y-1">
|
||||
<a
|
||||
href="https://github.com/Fimeg/RedFlag"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center w-full px-3 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<BookOpen className="mr-3 h-5 w-5 text-gray-400" />
|
||||
Docs
|
||||
</a>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center w-full px-3 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<LogOut className="mr-3 h-5 w-5 text-gray-400" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { setupApi } from '@/lib/api';
|
||||
import { clientLogger } from '@/lib/client-logger';
|
||||
|
||||
interface SetupCompletionCheckerProps {
|
||||
children: React.ReactNode;
|
||||
|
|
@ -34,13 +35,13 @@ export const SetupCompletionChecker: React.FC<SetupCompletionCheckerProps> = ({
|
|||
}
|
||||
|
||||
if (wasInSetup && !currentSetupMode) {
|
||||
console.log('Setup completed - redirecting to login');
|
||||
clientLogger.debug('Setup completed — redirecting to login');
|
||||
navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (wasInSetup) {
|
||||
console.log('Setup completed (endpoint unreachable) - redirecting to login');
|
||||
clientLogger.debug('Setup completed (endpoint unreachable) — redirecting to login');
|
||||
navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { useSecurityEvents, useSecurityWebSocket } from '@/hooks/useSecuritySettings';
|
||||
import { SecurityEvent, EventFilters } from '@/types/security';
|
||||
import { clientLogger } from '@/lib/client-logger';
|
||||
|
||||
const SecurityEvents: React.FC = () => {
|
||||
const [filters, setFilters] = useState<EventFilters>({});
|
||||
|
|
@ -96,7 +97,7 @@ const SecurityEvents: React.FC = () => {
|
|||
// Export events
|
||||
const exportEvents = async (format: 'json' | 'csv') => {
|
||||
// Implementation would call API to export events
|
||||
console.log(`Exporting events as ${format}`);
|
||||
clientLogger.debug('Exporting events', { format });
|
||||
};
|
||||
|
||||
// Clear filters
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|||
import { toast } from 'react-hot-toast';
|
||||
import { agentApi } from '@/lib/api';
|
||||
import { Agent } from '@/types';
|
||||
import { clientLogger } from '@/lib/client-logger';
|
||||
|
||||
interface UseAgentUpdateReturn {
|
||||
checkForUpdate: (agentId: string) => Promise<void>;
|
||||
|
|
@ -69,8 +70,7 @@ export function useAgentUpdate(): UseAgentUpdateReturn {
|
|||
|
||||
// Step 2: Generate nonce for authorized update
|
||||
const nonceData = await agentApi.generateUpdateNonce(agent.id, targetVersion);
|
||||
|
||||
console.log('[UI] Update nonce generated:', nonceData);
|
||||
clientLogger.debug('Update nonce generated', { agentId: agent.id, targetVersion });
|
||||
|
||||
// Step 3: Trigger the actual update
|
||||
const updateResponse = await agentApi.updateAgent(agent.id, {
|
||||
|
|
@ -87,8 +87,8 @@ export function useAgentUpdate(): UseAgentUpdateReturn {
|
|||
|
||||
// Step 5: Refresh agent data in cache
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||
clientLogger.debug('Update initiated successfully', { agentId: agent.id, targetVersion });
|
||||
|
||||
console.log('[UI] Update initiated successfully:', updateResponse);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[UI] Update failed:', error);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
KeyRotationResponse,
|
||||
MachineFingerprint
|
||||
} from '@/types/security';
|
||||
import { clientLogger } from '@/lib/client-logger';
|
||||
|
||||
// Default security settings
|
||||
const defaultSecuritySettings: SecuritySettings = {
|
||||
|
|
@ -349,7 +350,7 @@ export const useSecurityWebSocket = () => {
|
|||
|
||||
ws.current.onopen = () => {
|
||||
setConnected(true);
|
||||
console.log('Security WebSocket connected');
|
||||
clientLogger.debug('Security WebSocket connected');
|
||||
};
|
||||
|
||||
ws.current.onmessage = (event) => {
|
||||
|
|
@ -370,7 +371,7 @@ export const useSecurityWebSocket = () => {
|
|||
|
||||
ws.current.onclose = () => {
|
||||
setConnected(false);
|
||||
console.log('Security WebSocket disconnected');
|
||||
clientLogger.debug('Security WebSocket disconnected');
|
||||
|
||||
// Attempt to reconnect after 5 seconds
|
||||
setTimeout(() => {
|
||||
|
|
|
|||
56
web/src/lib/client-logger.ts
Normal file
56
web/src/lib/client-logger.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* ClientLogger — sends debug/trace signals to the server-side client_errors table.
|
||||
*
|
||||
* ETHOS #1: Errors are History. Debug/trace signals are not errors, but they
|
||||
* belong in the system's logging infrastructure, not on console.log where they
|
||||
* spam operators. This module routes them through the same /logs/client-error
|
||||
* endpoint as real errors, just tagged with client_debug / client_trace so they
|
||||
* can be filtered separately.
|
||||
*
|
||||
* Toggle: set localStorage['redflag_debug'] = '1' to enable, or unset to
|
||||
* suppress. The UI could grow a toggle in Settings → General later.
|
||||
*/
|
||||
|
||||
import { api } from './api';
|
||||
|
||||
const isDebug = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return localStorage.getItem('redflag_debug') === '1';
|
||||
};
|
||||
|
||||
function getSubsystem(): string {
|
||||
if (typeof window === 'undefined') return 'unknown';
|
||||
const path = window.location.pathname;
|
||||
if (path.startsWith('/agents')) return 'agents';
|
||||
if (path.startsWith('/updates') || path.startsWith('/staging')) return 'updates';
|
||||
if (path.startsWith('/docker')) return 'docker';
|
||||
if (path.startsWith('/history')) return 'history';
|
||||
if (path.startsWith('/settings')) return 'settings';
|
||||
return 'ui';
|
||||
}
|
||||
|
||||
export const clientLogger = {
|
||||
debug: (message: string, metadata?: Record<string, unknown>) => {
|
||||
if (!isDebug()) return;
|
||||
api.post('/logs/client-error', {
|
||||
subsystem: getSubsystem(),
|
||||
error_type: 'client_debug',
|
||||
message: message.substring(0, 5000),
|
||||
metadata: metadata ?? {},
|
||||
url: window.location.href,
|
||||
}).catch(() => {
|
||||
// Best effort — don't let logging failures cascade
|
||||
});
|
||||
},
|
||||
|
||||
trace: (message: string, metadata?: Record<string, unknown>) => {
|
||||
if (!isDebug()) return;
|
||||
api.post('/logs/client-error', {
|
||||
subsystem: getSubsystem(),
|
||||
error_type: 'client_trace',
|
||||
message: message.substring(0, 5000),
|
||||
metadata: metadata ?? {},
|
||||
url: window.location.href,
|
||||
}).catch(() => {});
|
||||
},
|
||||
};
|
||||
|
|
@ -816,23 +816,25 @@ const Agents: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* System info — screenshot square lives in the card header */}
|
||||
{/* System info — screenshot left, stats right, top processes below */}
|
||||
<div className="card">
|
||||
{(() => {
|
||||
const integrations = readIntegrations(selectedAgent.metadata);
|
||||
const sunshine = integrations.sunshine;
|
||||
const state = resolveState(sunshine);
|
||||
const live = state === 'active';
|
||||
const sunshineReady = live || state === 'running';
|
||||
const isCapturing = captureScreenshotMutation.isPending;
|
||||
const isPolling = screenshotCommand && !['completed', 'failed', 'timed_out', 'cancelled'].includes(screenshotCommand.status);
|
||||
const hasImage = !!screenshotImage;
|
||||
const canClick = !isCapturing && !isPolling;
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">System Information</h2>
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between mb-4 gap-4">
|
||||
<h2 className="text-lg font-medium text-gray-900">System Information</h2>
|
||||
<div className="shrink-0 w-48">
|
||||
<div className="flex gap-6">
|
||||
{/* Screenshot — left, fixed width */}
|
||||
{(() => {
|
||||
const integrations = readIntegrations(selectedAgent.metadata);
|
||||
const sunshine = integrations.sunshine;
|
||||
const state = resolveState(sunshine);
|
||||
const live = state === 'active';
|
||||
const sunshineReady = live || state === 'running';
|
||||
const isCapturing = captureScreenshotMutation.isPending;
|
||||
const isPolling = screenshotCommand && !['completed', 'failed', 'timed_out', 'cancelled'].includes(screenshotCommand.status);
|
||||
const hasImage = !!screenshotImage;
|
||||
const canClick = !isCapturing && !isPolling;
|
||||
|
||||
return (
|
||||
<div className="shrink-0 w-[260px]">
|
||||
<div
|
||||
className={cn(
|
||||
'relative aspect-video w-full overflow-hidden rounded border',
|
||||
|
|
@ -891,125 +893,79 @@ const Agents: React.FC = () => {
|
|||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic System Info */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Platform</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{(() => {
|
||||
const osInfo = parseOSInfo(selectedAgent);
|
||||
return osInfo.platform;
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Distribution</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{(() => {
|
||||
const osInfo = parseOSInfo(selectedAgent);
|
||||
return osInfo.distribution;
|
||||
})()}
|
||||
</p>
|
||||
{(() => {
|
||||
const osInfo = parseOSInfo(selectedAgent);
|
||||
if (osInfo.version) {
|
||||
return (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Version: {osInfo.version}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Architecture</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedAgent.os_architecture || selectedAgent.architecture}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hardware Specs */}
|
||||
<div className="space-y-4">
|
||||
{/* All stats — right column, stacked flush top */}
|
||||
<div className="flex-1 space-y-3 min-w-0">
|
||||
{(() => {
|
||||
const osInfo = parseOSInfo(selectedAgent);
|
||||
const meta = getSystemMetadata(selectedAgent);
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 flex items-center">
|
||||
<Cpu className="h-4 w-4 mr-1" />
|
||||
CPU
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Platform</p>
|
||||
<p className="text-sm font-medium text-gray-900">{osInfo.platform}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Distribution</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{meta.cpuModel}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{meta.cpuCores} cores
|
||||
{osInfo.distribution}
|
||||
{osInfo.version && <span className="text-xs text-gray-500 ml-1">({osInfo.version})</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Architecture</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedAgent.os_architecture || selectedAgent.architecture}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<Cpu className="h-3 w-3" /> CPU
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">{meta.cpuModel}</p>
|
||||
<p className="text-xs text-gray-500">{meta.cpuCores} cores</p>
|
||||
</div>
|
||||
{meta.memoryTotal > 0 && (
|
||||
<div>
|
||||
<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">
|
||||
{formatBytes(meta.memoryTotal)}
|
||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<MemoryStick className="h-3 w-3" /> Memory
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">{formatBytes(meta.memoryTotal)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.diskTotal > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 flex items-center">
|
||||
<HardDrive className="h-4 w-4 mr-1" />
|
||||
Disk ({meta.diskMount})
|
||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" /> Disk ({meta.diskMount})
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{formatBytes(meta.diskUsed)} / {formatBytes(meta.diskTotal)}
|
||||
</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-1">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
className="bg-blue-600 h-1.5 rounded-full"
|
||||
style={{ width: `${Math.round((meta.diskUsed / meta.diskTotal) * 100)}%` }}
|
||||
></div>
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{Math.round((meta.diskUsed / meta.diskTotal) * 100)}% used
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{Math.round((meta.diskUsed / meta.diskTotal) * 100)}% used</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.processes !== 'Unknown' && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 flex items-center">
|
||||
<GitBranch className="h-4 w-4 mr-1" />
|
||||
Running Processes
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{meta.processes}
|
||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<GitBranch className="h-3 w-3" /> Running Processes
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">{meta.processes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.uptime !== 'Unknown' && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 flex items-center">
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
Uptime
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{meta.uptime}
|
||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" /> Uptime
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">{meta.uptime}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -1017,6 +973,54 @@ const Agents: React.FC = () => {
|
|||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Processes — below the split */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium text-gray-900 flex items-center gap-1">
|
||||
<Activity className="h-4 w-4" /> Top Processes
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate(`/updates?agent=${selectedAgent.id}`)}
|
||||
className="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
See More →
|
||||
</button>
|
||||
</div>
|
||||
{(() => {
|
||||
const meta = getSystemMetadata(selectedAgent);
|
||||
const topProcesses = selectedAgent.metadata?.top_processes;
|
||||
if (topProcesses && Array.isArray(topProcesses) && topProcesses.length > 0) {
|
||||
return (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-gray-500 border-b border-gray-100">
|
||||
<th className="text-left py-1 font-medium">Name</th>
|
||||
<th className="text-right py-1 font-medium">PID</th>
|
||||
<th className="text-right py-1 font-medium">CPU%</th>
|
||||
<th className="text-right py-1 font-medium">Mem%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topProcesses.slice(0, 5).map((proc: any, i: number) => (
|
||||
<tr key={proc.pid || i} className="border-b border-gray-50 last:border-0">
|
||||
<td className="py-1 text-gray-900 font-medium truncate max-w-[160px]">{proc.name}</td>
|
||||
<td className="py-1 text-right text-gray-600">{proc.pid}</td>
|
||||
<td className="py-1 text-right text-gray-600">{proc.cpu != null ? `${proc.cpu.toFixed(1)}%` : '—'}</td>
|
||||
<td className="py-1 text-right text-gray-600">{proc.mem != null ? `${proc.mem.toFixed(1)}%` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="text-xs text-gray-400 italic">
|
||||
Process details not reported by this agent. Showing count: {meta.processes}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -258,7 +258,8 @@ export interface DashboardStats {
|
|||
approved_updates: number;
|
||||
installed_updates: number;
|
||||
failed_updates: number;
|
||||
vulnerable_packages: number;
|
||||
security_update_count: number; // distinct advisories on available version
|
||||
installed_cve_count: number; // distinct advisories on installed version (threat)
|
||||
critical_updates: number;
|
||||
high_updates: number;
|
||||
medium_updates: number;
|
||||
|
|
|
|||
Loading…
Reference in a new issue