Watch
1
0
Fork
You've already forked RedFlag
0

ui: unify per-scanner package rows into one System Update Scanner row

The agent runs apt/dnf/winget/windows on their own schedules (per-scanner
agent_subsystems rows still exist server-side) but the UI now collapses
them into a single "System Update Scanner" row. Chips on the description
line (APT, DNF, WINGET, Windows Update) light up from
agent.metadata.available_scanners — the real signal from
syncAvailableScanners — rather than the old os_type string heuristic
that sniffed "fedora" / "debian" out of the platform field.

The row is synthesized client-side from the per-scanner backers; no
'updates' DB row needed (the zombie kill landed in 7fb61a36). Toggle,
auto-run, interval, and Scan on the unified row cascade to every backing
per-scanner subsystem that actually exists for the agent.

Widened AgentSubsystem.subsystem type to string — the narrow union was
stale (DB has apt/dnf/winget/windows/storage/system/docker, plus the
synthetic 'updates' aggregate the UI produces).

Removed the os-string heuristic helper and its console.log noise.
This commit is contained in:
Fimeg 2026-05-22 14:05:28 -04:00
commit 03c72cb438
2 changed files with 87 additions and 67 deletions

View file

@ -123,6 +123,54 @@ export function AgentHealth({ agentId }: AgentHealthProps) {
const hasReportedScanners = reportedScanners.size > 0;
const scannerSubsystems = new Set(['apt', 'dnf', 'winget', 'windows', 'docker']);
// Package-manager scanners are surfaced through one unified "System Update
// Scanner" row. The agent runs each scanner on its own schedule (per-scanner
// rows still exist in agent_subsystems), but the UI collapses them.
const packageScannerNames = ['apt', 'dnf', 'winget', 'windows'] as const;
const packageScannerSet: Set<string> = new Set(packageScannerNames);
// Returns the per-scanner subsystems currently backing the unified row.
const backingScanners = subsystems.filter(s => packageScannerSet.has(s.subsystem));
// Build the unified row from backing per-scanner subsystems. Returns null
// when no package scanners exist for this agent (no row to render).
const synthesizeUpdatesRow = (): AgentSubsystem | null => {
if (backingScanners.length === 0) return null;
const intervals = backingScanners.map(s => s.interval_minutes).filter(n => n > 0);
const lastRuns = backingScanners
.map(s => s.last_run_at)
.filter((t): t is string => !!t)
.sort();
const nextRuns = backingScanners
.map(s => s.next_run_at)
.filter((t): t is string => !!t)
.sort();
return {
id: `synth-updates-${agentId}`,
agent_id: agentId,
subsystem: 'updates',
enabled: backingScanners.some(s => s.enabled),
auto_run: backingScanners.some(s => s.auto_run && s.enabled),
interval_minutes: intervals.length > 0 ? Math.min(...intervals) : 60,
last_run_at: lastRuns.length > 0 ? lastRuns[lastRuns.length - 1] : null,
next_run_at: nextRuns.length > 0 ? nextRuns[0] : null,
created_at: '',
updated_at: '',
};
};
// Final list rendered in the table: synthesized updates row first (when
// backing scanners exist), then every non-package-scanner subsystem. The
// per-scanner backing rows are not rendered — they're folded into the
// unified row above.
const updatesRow = synthesizeUpdatesRow();
const displaySubsystems: AgentSubsystem[] = [
...(updatesRow ? [updatesRow] : []),
...subsystems.filter(s => !packageScannerSet.has(s.subsystem) && s.subsystem !== 'updates'),
];
// Get security icon for subsystem type
const getSecurityIcon = (type: string) => {
switch (type) {
@ -215,46 +263,31 @@ export function AgentHealth({ agentId }: AgentHealthProps) {
},
});
// When the unified 'updates' row is acted on, cascade to every backing
// per-scanner subsystem that actually exists for this agent.
const cascadeTargets = (subsystem: string): string[] => {
if (subsystem !== 'updates') return [subsystem];
return backingScanners.map(s => s.subsystem);
};
const handleToggleEnabled = (subsystem: string, currentEnabled: boolean) => {
toggleSubsystemMutation.mutate({ subsystem, enabled: !currentEnabled });
const targets = cascadeTargets(subsystem);
targets.forEach(t => toggleSubsystemMutation.mutate({ subsystem: t, enabled: !currentEnabled }));
};
const handleIntervalChange = (subsystem: string, intervalMinutes: number) => {
updateIntervalMutation.mutate({ subsystem, intervalMinutes });
const targets = cascadeTargets(subsystem);
targets.forEach(t => updateIntervalMutation.mutate({ subsystem: t, intervalMinutes }));
};
const handleToggleAutoRun = (subsystem: string, currentAutoRun: boolean) => {
toggleAutoRunMutation.mutate({ subsystem, autoRun: !currentAutoRun });
const targets = cascadeTargets(subsystem);
targets.forEach(t => toggleAutoRunMutation.mutate({ subsystem: t, autoRun: !currentAutoRun }));
};
const handleTriggerScan = async (subsystem: string) => {
// Handle 'updates' subsystem - map to correct platform-specific scanner
if (subsystem === 'updates') {
const os = agent?.os_type?.toLowerCase() || '';
console.log('[AgentHealth] Triggering updates scan, OS type:', agent?.os_type);
if (os.includes('debian') || os.includes('ubuntu')) {
console.log('[AgentHealth] Triggering scan_apt');
triggerScanMutation.mutate('apt');
} else if (os.includes('fedora') || os.includes('rhel') || os.includes('centos')) {
console.log('[AgentHealth] Triggering scan_dnf');
triggerScanMutation.mutate('dnf');
} else if (os.includes('windows')) {
console.log('[AgentHealth] Triggering scan_windows + scan_winget');
// Windows has two scanners - trigger both
try {
await triggerScanMutation.mutateAsync('windows');
await triggerScanMutation.mutateAsync('winget');
} catch (err) {
// handled by mutation onError
}
} else {
console.log('[AgentHealth] Unknown OS type, triggering scan_dnf as fallback');
triggerScanMutation.mutate('dnf');
}
} else {
triggerScanMutation.mutate(subsystem);
}
const handleTriggerScan = (subsystem: string) => {
const targets = cascadeTargets(subsystem);
targets.forEach(t => triggerScanMutation.mutate(t));
};
const frequencyOptions = [
@ -269,36 +302,12 @@ export function AgentHealth({ agentId }: AgentHealthProps) {
{ value: 20160, label: '2 weeks' },
];
// Calculate counts directly without useMemo
const enabledCount = subsystems.filter(s => s.enabled).length;
const autoRunCount = subsystems.filter(s => s.auto_run && s.enabled).length;
// Helper functions for package manager status (used in updates row description)
const getPackageManagerStatus = (pm: string, osType: string) => {
const os = (osType || '').toLowerCase();
console.log('[AgentHealth] Package manager check:', pm, 'for OS:', osType);
let enabled = false;
switch (pm) {
case 'apt':
enabled = os.includes('debian') || os.includes('ubuntu');
break;
case 'dnf':
enabled = os.includes('fedora') || os.includes('rhel') || os.includes('red hat') || os.includes('centos');
break;
case 'winget':
enabled = os.includes('windows');
break;
case 'windows':
enabled = os.includes('windows');
break;
default:
enabled = false;
}
console.log('[AgentHealth] Package manager', pm, 'enabled:', enabled);
return enabled;
};
// Counts reflect what the user sees in the table (unified row + non-package
// subsystems), not raw DB backer rows.
const enabledCount = displaySubsystems.filter(s => s.enabled).length;
const autoRunCount = displaySubsystems.filter(s => s.auto_run && s.enabled).length;
// Chip palette for the unified row's per-scanner availability indicators.
const getPackageManagerBadgeStyle = (pm: string) => {
switch (pm) {
case 'apt': return 'bg-purple-100 text-purple-700';
@ -317,7 +326,7 @@ export function AgentHealth({ agentId }: AgentHealthProps) {
<div>
<h3 className="text-base font-semibold text-gray-900">Subsystems</h3>
<p className="text-xs text-gray-600 mt-0.5">
{enabledCount} enabled {autoRunCount} auto-running {subsystems.length} total
{enabledCount} enabled {autoRunCount} auto-running {displaySubsystems.length} total
</p>
</div>
<button
@ -358,7 +367,7 @@ export function AgentHealth({ agentId }: AgentHealthProps) {
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{subsystems.map((subsystem: AgentSubsystem) => {
{displaySubsystems.map((subsystem: AgentSubsystem) => {
const config = subsystemConfig[subsystem.subsystem] || {
icon: <Activity className="h-4 w-4" />,
name: subsystem.subsystem,
@ -400,16 +409,22 @@ export function AgentHealth({ agentId }: AgentHealthProps) {
{subsystem.subsystem === 'updates' ? (
<div className="text-xs text-gray-500">
<span>Scans for available package updates (</span>
{['apt', 'dnf', 'winget', 'windows'].map((pm, index) => {
const isEnabled = getPackageManagerStatus(pm, agent?.os_type || '');
const isLast = index === 3;
{packageScannerNames.map((pm, index) => {
// Chip lights up when the agent has reported
// this scanner as available. When no scanners
// have been reported yet, fall back to the
// presence of a backing subsystem row.
const isAvailable = hasReportedScanners
? reportedScanners.has(pm)
: backingScanners.some(b => b.subsystem === pm);
const isLast = index === packageScannerNames.length - 1;
return (
<span key={pm}>
{index > 0 && ', '}
<span className={cn(
'text-[10px] px-1 py-0.5 rounded',
isEnabled
isAvailable
? getPackageManagerBadgeStyle(pm)
: 'bg-gray-100 text-gray-500'
)}>

View file

@ -395,10 +395,15 @@ export interface RateLimitSummary {
}
// Subsystem types
//
// `subsystem` is a string because the server creates per-scanner rows (apt,
// dnf, winget, windows) in addition to the original aggregate subsystems
// (storage, system, docker). The 'updates' value is now only produced by the
// UI as a synthetic aggregate row over the per-scanner backers — no DB row.
export interface AgentSubsystem {
id: string;
agent_id: string;
subsystem: 'updates' | 'storage' | 'system' | 'docker';
subsystem: string;
enabled: boolean;
interval_minutes: number;
auto_run: boolean;