Watch
1
0
Fork
You've already forked RedFlag
0

feat: System Information grid primitives — auto-placing 2-column grid

This commit is contained in:
Fimeg 2026-06-10 13:05:57 -04:00
commit 1fa76bf665
6 changed files with 348 additions and 209 deletions

View file

@ -0,0 +1,55 @@
import React from 'react';
import { cn } from '@/lib/utils';
/**
* MetricItem a single system-info metric cell.
*
* Renders a label, value, optional icon, optional sub-text, and optional
* progress bar. Designed to be an independent grid item inside SystemInfoGrid.
*
* Usage:
* <MetricItem label="CPU" value="Intel i7" icon={Cpu} sub="4 cores" />
* <MetricItem label="Disk" value="120 / 500 GB" icon={HardDrive}
* progress={{ used: 120, total: 500 }} />
*/
interface MetricItemProps {
label: string;
value: string | number;
icon?: React.ComponentType<{ className?: string }>;
sub?: string;
progress?: { used: number; total: number };
className?: string;
}
const MetricItem: React.FC<MetricItemProps> = ({
label,
value,
icon: Icon,
sub,
progress,
className,
}) => (
<div className={cn('min-w-0', className)}>
<p className="text-xs text-gray-500 flex items-center gap-1">
{Icon && <Icon className="h-3 w-3" />}
{label}
</p>
<p className="text-sm font-medium text-gray-900">{value}</p>
{sub && <p className="text-xs text-gray-500">{sub}</p>}
{progress && progress.total > 0 && (
<>
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-1">
<div
className="bg-blue-600 h-1.5 rounded-full"
style={{ width: `${Math.round((progress.used / progress.total) * 100)}%` }}
/>
</div>
<p className="text-xs text-gray-500">
{Math.round((progress.used / progress.total) * 100)}% used
</p>
</>
)}
</div>
);
export default MetricItem;

View file

@ -0,0 +1,77 @@
import React from 'react';
import { Activity } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* ProcessTable top processes display for the System Information grid.
*
* Renders a compact table (Name, PID, CPU%, Mem%) when process data is
* available, or a fallback message with the process count.
*
* Designed as an independent grid item inside SystemInfoGrid.
*/
interface ProcessInfo {
name: string;
pid: number;
cpu: number | null;
mem: number | null;
}
interface ProcessTableProps {
processes?: ProcessInfo[];
processCount?: number | string;
onSeeMore?: () => void;
className?: string;
}
const ProcessTable: React.FC<ProcessTableProps> = ({
processes,
processCount,
onSeeMore,
className,
}) => (
<div className={cn('min-w-0', className)}>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-gray-900 flex items-center gap-1">
<Activity className="h-3 w-3" /> Top Processes
</p>
{onSeeMore && (
<button onClick={onSeeMore} className="text-[10px] text-blue-600 hover:text-blue-800">
See More
</button>
)}
</div>
{processes && processes.length > 0 ? (
<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>
{processes.slice(0, 5).map((proc, i) => (
<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>
) : (
<p className="text-[10px] text-gray-400 italic">
Process details not reported. {processCount != null && `Count: ${processCount}`}
</p>
)}
</div>
);
export default ProcessTable;

View file

@ -0,0 +1,114 @@
import React from 'react';
import { MonitorPlay, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* ScreenshotCard screenshot thumbnail with sunshine overlay and capture state.
*
* Renders the agent's screenshot (or capture prompt), live/stream badges,
* and timestamp. Designed as a grid item inside SystemInfoGrid with
* col-start-1 row-span-2 to anchor top-left.
*
* Handlers are passed in as props this component doesn't own mutations.
*/
interface SunshineInfo {
web_ui?: string;
version?: string;
state: string; // 'active' | 'running' | 'stopped' | ...
}
interface ScreenshotStatus {
status: string;
completed_at?: string;
}
interface ScreenshotCardProps {
image?: string; // base64 PNG
isCapturing: boolean;
isPolling: boolean;
sunshine?: SunshineInfo;
screenshotStatus?: ScreenshotStatus;
onCapture: () => void;
formatRelativeTime?: (t: string) => string;
className?: string;
}
const ScreenshotCard: React.FC<ScreenshotCardProps> = ({
image,
isCapturing,
isPolling,
sunshine,
screenshotStatus,
onCapture,
formatRelativeTime,
className,
}) => {
const live = sunshine?.state === 'active';
const sunshineReady = live || sunshine?.state === 'running';
const hasImage = !!image;
const canClick = !isCapturing && !isPolling;
return (
<div className={cn('min-w-0', className)}>
<div
className={cn(
'relative aspect-video w-full overflow-hidden rounded border',
'bg-gradient-to-br from-slate-800 to-slate-900 border-slate-700',
'flex flex-col items-center justify-center gap-1 text-slate-400',
canClick && 'cursor-pointer hover:from-slate-700 hover:to-slate-800 transition-colors'
)}
onClick={() => {
if (sunshineReady && sunshine?.web_ui) {
window.open(sunshine.web_ui, '_blank', 'noreferrer');
} else if (canClick) {
onCapture();
}
}}
>
{hasImage ? (
<img
src={`data:image/png;base64,${image}`}
alt="Agent screenshot"
className="absolute inset-0 w-full h-full object-contain"
/>
) : (
<>
<MonitorPlay className="h-6 w-6 opacity-70" />
<span className="text-[10px]">
{isCapturing ? 'Requesting…'
: isPolling ? 'Capturing…'
: sunshineReady ? 'Open stream'
: 'Click to capture'}
</span>
</>
)}
{live && (
<span className="absolute top-1 left-1 flex items-center gap-1 rounded bg-red-600/90 px-1 py-0.5 text-[9px] font-medium text-white">
<span className="h-1.5 w-1.5 rounded-full bg-white animate-pulse" />
LIVE
</span>
)}
{sunshineReady && !live && (
<span className="absolute top-1 right-1 rounded bg-black/50 px-1 py-0.5 text-[9px] text-white">
Sunshine
</span>
)}
{(isCapturing || isPolling) && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<RefreshCw className="h-5 w-5 text-white animate-spin" />
</div>
)}
</div>
<div className="flex items-center justify-between mt-1 text-[10px] text-gray-500">
{sunshine?.version ? <span>Sunshine v{sunshine.version}</span> : <span />}
{screenshotStatus?.status === 'completed' && screenshotStatus?.completed_at && formatRelativeTime
? <span>{formatRelativeTime(screenshotStatus.completed_at)}</span>
: screenshotStatus?.status === 'failed'
? <span className="text-red-500">Failed</span>
: null}
</div>
</div>
);
};
export default ScreenshotCard;

View file

@ -0,0 +1,36 @@
import React from 'react';
import { cn } from '@/lib/utils';
/**
* SystemInfoGrid CSS Grid container for system information primitives.
*
* Items auto-place into a 2-column grid that collapses to 1 on narrow
* viewports. The screenshot (or any anchor item) gets col-start-1
* row-span-2 via its own className prop.
*
* Usage:
* <SystemInfoGrid>
* <ScreenshotCard className="col-start-1 row-span-2" ... />
* <MetricItem label="CPU" ... />
* <MetricItem label="Memory" ... />
* <ProcessTable ... />
* </SystemInfoGrid>
*/
interface SystemInfoGridProps {
children: React.ReactNode;
className?: string;
}
const SystemInfoGrid: React.FC<SystemInfoGridProps> = ({ children, className }) => (
<div
className={cn('grid gap-6', className)}
style={{
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gridAutoRows: 'min-content',
}}
>
{children}
</div>
);
export default SystemInfoGrid;

View file

@ -24,6 +24,10 @@ import {
Upload,
} from 'lucide-react';
import { SearchInput, FilterDropdown, PageState } from '@/components/primitives';
import SystemInfoGrid from '@/components/primitives/SystemInfoGrid';
import ScreenshotCard from '@/components/primitives/ScreenshotCard';
import MetricItem from '@/components/primitives/MetricItem';
import ProcessTable from '@/components/primitives/ProcessTable';
import { useDebounce } from '@/hooks/useDebounce';
import { useAgents, useAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
import { useActiveCommands, useCancelCommand, useCaptureScreenshot, useCommand } from '@/hooks/useCommands';
@ -821,218 +825,65 @@ const Agents: React.FC = () => {
)}
</div>
{/* System info — screenshot left, stats right, top processes below */}
{/* System info — independent primitives in a CSS Grid */}
<div className="card">
<h2 className="text-lg font-medium text-gray-900 mb-4">System Information</h2>
<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;
{(() => {
const integrations = readIntegrations(selectedAgent.metadata);
const sunshine = integrations.sunshine;
const osInfo = parseOSInfo(selectedAgent);
const meta = getSystemMetadata(selectedAgent);
const topProcesses = selectedAgent.metadata?.top_processes;
return (
<div className="shrink-0 w-[260px]">
<div
className={cn(
'relative aspect-video w-full overflow-hidden rounded border',
'bg-gradient-to-br from-slate-800 to-slate-900 border-slate-700',
'flex flex-col items-center justify-center gap-1 text-slate-400',
canClick && 'cursor-pointer hover:from-slate-700 hover:to-slate-800 transition-colors'
)}
onClick={() => {
if (sunshineReady && sunshine?.web_ui) {
window.open(sunshine.web_ui, '_blank', 'noreferrer');
} else if (canClick) {
handleCaptureScreenshot(selectedAgent.id);
}
}}
>
{hasImage ? (
<img
src={`data:image/png;base64,${screenshotImage}`}
alt="Agent screenshot"
className="absolute inset-0 w-full h-full object-contain"
/>
) : (
<>
<MonitorPlay className="h-6 w-6 opacity-70" />
<span className="text-[10px]">
{isCapturing ? 'Requesting…'
: isPolling ? 'Capturing…'
: sunshineReady ? 'Open stream'
: 'Click to capture'}
</span>
</>
)}
{live && (
<span className="absolute top-1 left-1 flex items-center gap-1 rounded bg-red-600/90 px-1 py-0.5 text-[9px] font-medium text-white">
<span className="h-1.5 w-1.5 rounded-full bg-white animate-pulse" />
LIVE
</span>
)}
{sunshineReady && !live && (
<span className="absolute top-1 right-1 rounded bg-black/50 px-1 py-0.5 text-[9px] text-white">
Sunshine
</span>
)}
{(isCapturing || isPolling) && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<RefreshCw className="h-5 w-5 text-white animate-spin" />
</div>
)}
</div>
<div className="flex items-center justify-between mt-1 text-[10px] text-gray-500">
{sunshine?.version ? <span>Sunshine v{sunshine.version}</span> : <span />}
{screenshotCommand?.status === 'completed' && screenshotCommand?.completed_at
? <span>{formatRelativeTime(screenshotCommand.completed_at)}</span>
: screenshotCommand?.status === 'failed'
? <span className="text-red-500">Failed</span>
: null}
</div>
{/* Platform info — under screenshot */}
{(() => {
const osInfo = parseOSInfo(selectedAgent);
return (
<div className="mt-2 space-y-1">
<div>
<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">
{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>
);
})()}
</div>
);
})()}
{/* All stats — right column, stacked flush top */}
<div className="flex-1 space-y-3 min-w-0">
{(() => {
const meta = getSystemMetadata(selectedAgent);
return (
<>
<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-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-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-1.5 mt-1">
<div
className="bg-blue-600 h-1.5 rounded-full"
style={{ width: `${Math.round((meta.diskUsed / meta.diskTotal) * 100)}%` }}
/>
</div>
<p className="text-xs text-gray-500">{Math.round((meta.diskUsed / meta.diskTotal) * 100)}% used</p>
</div>
)}
{meta.processes !== 'Unknown' && (
<div>
<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-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>
)}
</>
);
})()}
{/* Top Processes */}
<div className="pt-3 border-t border-gray-200">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-gray-900 flex items-center gap-1">
<Activity className="h-3 w-3" /> Top Processes
</p>
<button
onClick={() => navigate(`/updates?agent=${selectedAgent.id}`)}
className="text-[10px] 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-[10px] text-gray-400 italic">
Process details not reported. Count: {meta.processes}
</p>
);
})()}
</div>
</div>
</div>
return (
<SystemInfoGrid>
<ScreenshotCard
className="col-start-1 row-span-2"
image={screenshotImage}
isCapturing={captureScreenshotMutation.isPending}
isPolling={screenshotCommand && !['completed', 'failed', 'timed_out', 'cancelled'].includes(screenshotCommand.status)}
sunshine={sunshine ? { ...sunshine, state: resolveState(sunshine) } : undefined}
screenshotStatus={screenshotCommand}
onCapture={() => handleCaptureScreenshot(selectedAgent.id)}
formatRelativeTime={formatRelativeTime}
/>
<MetricItem label="Platform" value={osInfo.platform} />
<MetricItem
label="Distribution"
value={osInfo.distribution}
sub={osInfo.version ? `(${osInfo.version})` : undefined}
/>
<MetricItem
label="Architecture"
value={selectedAgent.os_architecture || selectedAgent.architecture}
/>
<MetricItem label="CPU" value={meta.cpuModel} icon={Cpu} sub={`${meta.cpuCores} cores`} />
{meta.memoryTotal > 0 && (
<MetricItem label="Memory" value={formatBytes(meta.memoryTotal)} icon={MemoryStick} />
)}
{meta.diskTotal > 0 && (
<MetricItem
label={`Disk (${meta.diskMount})`}
value={`${formatBytes(meta.diskUsed)} / ${formatBytes(meta.diskTotal)}`}
icon={HardDrive}
progress={{ used: meta.diskUsed, total: meta.diskTotal }}
/>
)}
{meta.processes !== 'Unknown' && (
<MetricItem label="Running Processes" value={meta.processes} icon={GitBranch} />
)}
{meta.uptime !== 'Unknown' && (
<MetricItem label="Uptime" value={meta.uptime} icon={Clock} />
)}
<ProcessTable
processes={topProcesses}
processCount={meta.processes}
onSeeMore={() => navigate(`/updates?agent=${selectedAgent.id}`)}
/>
</SystemInfoGrid>
);
})()}
</div>
</div>
)}