Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-06-08 18:23:39 -04:00
commit c0a717ab26
30 changed files with 5319 additions and 258 deletions

View file

@ -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>
);
};

View file

@ -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">

View file

@ -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, {

View file

@ -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,
});

View 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;

View file

@ -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>

View file

@ -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;
}

View file

@ -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

View file

@ -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);

View file

@ -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(() => {

View 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(() => {});
},
};

View file

@ -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>
)}

View file

@ -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;