Watch
1
0
Fork
You've already forked RedFlag
0

feat: process explorer — on-demand /proc scanning with full osquery parity

Agent-side: reads /proc for all PIDs with 25+ fields (identity, resources,
state, disk I/O, elevation) plus related data on drill-down (open files,
sockets, pipes, env keys, memory map, namespaces, listening ports). Pure
/proc reads, no subprocess spawns.

Server-side: dedicated tables (agent_process_snapshots, agent_processes,
agent_process_related) with JSONB for related data. On-demand scan via
scan_processes command, last-10-snapshot retention. Four endpoints:
report, get latest, get detail, trigger scan.

UI: new Processes tab in agent detail with sortable/filterable table,
search by name/cmdline, state/user filters, and ProcessDetailModal with
tabs for Overview, Network, Files, Environment, Memory, Namespaces.
This commit is contained in:
Fimeg 2026-06-10 21:28:34 -04:00
commit 244d9091ee
31 changed files with 4127 additions and 13 deletions

View file

@ -0,0 +1,356 @@
import React, { useState } from 'react';
import { X, Activity, Network, FileText, Key, Layers, Box } from 'lucide-react';
import { useProcessDetail } from '@/hooks/useProcesses';
import { cn } from '@/lib/utils';
// Safe JSON parse — returns null on malformed data instead of crashing.
const safeParse = (data: any): any => {
if (typeof data === 'string') {
try { return JSON.parse(data); } catch { return null; }
}
return data;
};
interface ProcessDetailModalProps {
agentId: string;
processId: string;
onClose: () => void;
}
type DetailTab = 'overview' | 'network' | 'files' | 'environment' | 'memory' | 'namespaces';
export const ProcessDetailModal: React.FC<ProcessDetailModalProps> = ({
agentId,
processId,
onClose,
}) => {
const [activeTab, setActiveTab] = useState<DetailTab>('overview');
const { data, isLoading } = useProcessDetail(agentId, processId, true);
if (isLoading) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl p-8">
<div className="flex items-center gap-3 text-gray-500">
<Activity className="h-5 w-5 animate-spin" />
Loading process detail...
</div>
</div>
</div>
);
}
if (!data) return null;
const proc = data.process;
const formatBytes = (bytes: number) => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
};
const formatTime = (seconds: number) => {
if (!seconds) return '—';
const d = new Date(seconds * 1000);
return d.toLocaleString();
};
const tabs: { key: DetailTab; label: string; icon: React.ReactNode; count?: number }[] = [
{ key: 'overview', label: 'Overview', icon: <Activity className="h-4 w-4" /> },
{ key: 'network', label: 'Network', icon: <Network className="h-4 w-4" />, count: (data.open_sockets?.length ?? 0) + (data.listening_ports?.length ?? 0) },
{ key: 'files', label: 'Files', icon: <FileText className="h-4 w-4" />, count: data.open_files?.length },
{ key: 'environment', label: 'Env', icon: <Key className="h-4 w-4" />, count: data.environment?.length },
{ key: 'memory', label: 'Memory', icon: <Layers className="h-4 w-4" />, count: data.memory_map?.length },
{ key: 'namespaces', label: 'Namespaces', icon: <Box className="h-4 w-4" />, count: data.namespaces?.length },
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[85vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b">
<div>
<h3 className="text-lg font-medium text-gray-900">{proc.name}</h3>
<p className="text-xs text-gray-500 font-mono">PID {proc.pid} · {proc.user} · {proc.path || 'no path'}</p>
</div>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded">
<X className="h-5 w-5 text-gray-400" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b px-6">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={cn(
'flex items-center gap-1.5 px-3 py-2.5 text-sm border-b-2 -mb-px transition-colors',
activeTab === tab.key
? 'border-red-500 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
)}
>
{tab.icon}
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span className="ml-1 text-xs bg-gray-100 text-gray-600 rounded-full px-1.5">{tab.count}</span>
)}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{activeTab === 'overview' && (
<div className="grid grid-cols-2 gap-4">
<Field label="Command" value={proc.cmdline || '—'} mono fullWidth />
<Field label="Path" value={proc.path || '—'} mono fullWidth />
<Field label="Working Directory" value={proc.cwd || '—'} mono />
<Field label="State" value={stateLabel(proc.state)} />
<Field label="UID / GID" value={`${proc.uid} / ${proc.gid}`} />
<Field label="EUID / EGID" value={`${proc.euid} / ${proc.egid}`} />
<Field label="Elevation" value={proc.elevation_status || 'none'} />
<Field label="CPU%" value={`${proc.cpu_percent.toFixed(2)}%`} />
<Field label="Mem%" value={`${proc.mem_percent.toFixed(2)}%`} />
<Field label="RSS" value={formatBytes(proc.rss_bytes)} />
<Field label="VMS" value={formatBytes(proc.vms_bytes)} />
<Field label="Threads" value={String(proc.threads)} />
<Field label="Nice" value={String(proc.nice)} />
<Field label="Parent PID" value={String(proc.parent_pid)} />
<Field label="Process Group" value={String(proc.process_group_id)} />
<Field label="TTY" value={proc.tty_name || String(proc.tty)} />
<Field label="Started" value={formatTime(proc.start_time_seconds)} />
<Field label="Disk Read" value={formatBytes(proc.disk_bytes_read)} />
<Field label="Disk Written" value={formatBytes(proc.disk_bytes_written)} />
<Field label="On Disk" value={proc.on_disk === 1 ? 'yes' : proc.on_disk === 0 ? 'no (deleted)' : 'unknown'} />
</div>
)}
{activeTab === 'network' && (
<div className="space-y-6">
{data.listening_ports && data.listening_ports.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Listening Ports</h4>
<table className="table w-full text-xs">
<thead>
<tr className="table-header">
<th className="text-left py-1">Protocol</th>
<th className="text-left py-1">Address</th>
<th className="text-right py-1">Port</th>
</tr>
</thead>
<tbody>
{data.listening_ports.map((lp, i) => {
const d = safeParse(lp.data);
return (
<tr key={i} className="table-row">
<td className="table-cell">{d.protocol}</td>
<td className="table-cell font-mono">{d.local_addr || '0.0.0.0'}</td>
<td className="table-cell text-right font-mono">{d.local_port}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{data.open_sockets && data.open_sockets.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Open Sockets</h4>
<table className="table w-full text-xs">
<thead>
<tr className="table-header">
<th className="text-left py-1">Family</th>
<th className="text-left py-1">Protocol</th>
<th className="text-left py-1">Local</th>
<th className="text-left py-1">Remote</th>
<th className="text-left py-1">State</th>
</tr>
</thead>
<tbody>
{data.open_sockets.map((s, i) => {
const d = safeParse(s.data);
return (
<tr key={i} className="table-row">
<td className="table-cell">{d.family}</td>
<td className="table-cell">{d.protocol}</td>
<td className="table-cell font-mono">{d.local_addr}:{d.local_port}</td>
<td className="table-cell font-mono">{d.remote_addr}:{d.remote_port}</td>
<td className="table-cell">{d.state || '—'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{(!data.listening_ports || data.listening_ports.length === 0) &&
(!data.open_sockets || data.open_sockets.length === 0) && (
<p className="text-sm text-gray-500 italic">No network activity for this process.</p>
)}
</div>
)}
{activeTab === 'files' && (
<div>
{data.open_files && data.open_files.length > 0 ? (
<table className="table w-full text-xs">
<thead>
<tr className="table-header">
<th className="text-right py-1">FD</th>
<th className="text-left py-1">Type</th>
<th className="text-left py-1">Path</th>
</tr>
</thead>
<tbody>
{data.open_files.map((f, i) => {
const d = safeParse(f.data);
return (
<tr key={i} className="table-row">
<td className="table-cell text-right font-mono">{d.fd}</td>
<td className="table-cell">{d.type}</td>
<td className="table-cell font-mono truncate max-w-[400px]" title={d.path}>{d.path}</td>
</tr>
);
})}
</tbody>
</table>
) : (
<p className="text-sm text-gray-500 italic">No open files recorded.</p>
)}
</div>
)}
{activeTab === 'environment' && (
<div>
{data.environment && data.environment.length > 0 ? (
<div className="space-y-1">
<p className="text-xs text-gray-500 mb-3">Environment variable names only (values omitted for security).</p>
{data.environment.map((e, i) => {
const d = safeParse(e.data);
// d is an array of key names
if (Array.isArray(d)) {
return (
<div key={i} className="flex flex-wrap gap-1">
{d.map((key: string, j: number) => (
<span key={j} className="chip font-mono text-xs">{key}</span>
))}
</div>
);
}
return null;
})}
</div>
) : (
<p className="text-sm text-gray-500 italic">No environment data (may require elevated permissions).</p>
)}
</div>
)}
{activeTab === 'memory' && (
<div>
{data.memory_map && data.memory_map.length > 0 ? (
<div className="overflow-x-auto">
<table className="table w-full text-xs">
<thead>
<tr className="table-header">
<th className="text-left py-1">Address Range</th>
<th className="text-left py-1">Perms</th>
<th className="text-left py-1">Offset</th>
<th className="text-left py-1">Path</th>
</tr>
</thead>
<tbody>
{data.memory_map.slice(0, 200).map((m, i) => {
const d = safeParse(m.data);
return (
<tr key={i} className="table-row">
<td className="table-cell font-mono">
0x{d.start?.toString(16)}-0x{d.end?.toString(16)}
</td>
<td className="table-cell font-mono">{d.permissions}</td>
<td className="table-cell font-mono">0x{d.offset?.toString(16)}</td>
<td className="table-cell font-mono truncate max-w-[300px]" title={d.path}>{d.path || '—'}</td>
</tr>
);
})}
</tbody>
</table>
{data.memory_map.length > 200 && (
<p className="text-xs text-gray-400 mt-2">Showing 200 of {data.memory_map.length} regions.</p>
)}
</div>
) : (
<p className="text-sm text-gray-500 italic">No memory map data.</p>
)}
</div>
)}
{activeTab === 'namespaces' && (
<div>
{data.namespaces && data.namespaces.length > 0 ? (
<table className="table w-full text-xs">
<thead>
<tr className="table-header">
<th className="text-left py-1">Namespace</th>
<th className="text-left py-1">Inode</th>
</tr>
</thead>
<tbody>
{data.namespaces.map((ns, i) => {
const d = safeParse(ns.data);
return (
<tr key={i} className="table-row">
<td className="table-cell">{d.type}</td>
<td className="table-cell font-mono">{d.inode}</td>
</tr>
);
})}
</tbody>
</table>
) : (
<p className="text-sm text-gray-500 italic">No namespace data.</p>
)}
</div>
)}
</div>
</div>
</div>
);
};
// Helper components
const Field: React.FC<{ label: string; value: string; mono?: boolean; fullWidth?: boolean }> = ({
label,
value,
mono,
fullWidth,
}) => (
<div className={fullWidth ? 'col-span-2' : ''}>
<dt className="text-xs text-gray-500">{label}</dt>
<dd className={cn('text-sm text-gray-900 mt-0.5', mono && 'font-mono break-all')}>{value}</dd>
</div>
);
const stateLabel = (state: string) => {
const map: Record<string, string> = {
R: 'Running',
S: 'Sleeping',
D: 'Disk Sleep',
Z: 'Zombie',
T: 'Stopped',
t: 'Tracing Stop',
X: 'Dead',
x: 'Dead',
K: 'Wakekill',
W: 'Waking',
P: 'Parked',
};
return map[state] ?? state;
};

View file

@ -0,0 +1,237 @@
import React, { useState, useMemo } from 'react';
import { Activity, Search, RefreshCw, ArrowUpDown, Clock, Users } from 'lucide-react';
import { useProcessSnapshot, useTriggerProcessScan } from '@/hooks/useProcesses';
import { ProcessDetailModal } from '@/components/ProcessDetailModal';
import type { Process, ProcessFilter } from '@/types/process';
import { cn } from '@/lib/utils';
interface ProcessesTabProps {
agentId: string;
}
export const ProcessesTab: React.FC<ProcessesTabProps> = ({ agentId }) => {
const [selectedProcessId, setSelectedProcessId] = useState<string | null>(null);
const [filter, setFilter] = useState<ProcessFilter>({
sort_by: 'cpu',
sort_dir: 'desc',
limit: 500,
});
const [searchText, setSearchText] = useState('');
const { data, isLoading, isFetching } = useProcessSnapshot(agentId, filter);
const triggerScan = useTriggerProcessScan();
const processes = data?.processes ?? [];
const snapshot = data?.snapshot;
const total = data?.total ?? 0;
// Client-side search filtering (name/cmdline)
const filteredProcesses = useMemo(() => {
if (!searchText) return processes;
const lower = searchText.toLowerCase();
return processes.filter(
(p) =>
p.name.toLowerCase().includes(lower) ||
p.cmdline.toLowerCase().includes(lower)
);
}, [processes, searchText]);
const handleSort = (col: ProcessFilter['sort_by']) => {
setFilter((prev) => ({
...prev,
sort_by: col,
sort_dir: prev.sort_by === col && prev.sort_dir === 'desc' ? 'asc' : 'desc',
}));
};
const handleScan = () => {
triggerScan.mutate(agentId);
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
};
const formatDuration = (ms: number) => {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
};
const SortHeader: React.FC<{ label; col: ProcessFilter['sort_by']; className?: string }> = ({
label,
col,
className,
}) => (
<th
className={cn('text-left py-2 px-2 font-medium cursor-pointer hover:text-gray-900 select-none', className)}
onClick={() => handleSort(col)}
>
<span className="inline-flex items-center gap-1">
{label}
{filter.sort_by === col && (
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</span>
</th>
);
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-gray-500" />
<div>
<h2 className="text-lg font-medium text-gray-900">Processes</h2>
{snapshot && (
<p className="text-xs text-gray-500">
{total} processes · scanned {formatDuration(snapshot.scan_duration_ms)} ·{' '}
{new Date(snapshot.scanned_at).toLocaleString()}
</p>
)}
</div>
</div>
<button
onClick={handleScan}
disabled={triggerScan.isPending}
className={cn(
'btn btn-secondary flex items-center gap-2',
triggerScan.isPending && 'opacity-50 cursor-not-allowed'
)}
>
<RefreshCw className={cn('h-4 w-4', triggerScan.isPending && 'animate-spin')} />
{triggerScan.isPending ? 'Scanning...' : 'Scan Now'}
</button>
</div>
{/* Search and filters */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Filter by name or command..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="pl-9 pr-4 py-2 w-full border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
<select
value={filter.state ?? ''}
onChange={(e) => setFilter((prev) => ({ ...prev, state: e.target.value || undefined }))}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">All states</option>
<option value="R">Running</option>
<option value="S">Sleeping</option>
<option value="D">Disk Sleep</option>
<option value="Z">Zombie</option>
<option value="T">Stopped</option>
</select>
<select
value={filter.user ?? ''}
onChange={(e) => setFilter((prev) => ({ ...prev, user: e.target.value || undefined }))}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">All users</option>
{/* Populated from process data */}
{[...new Set(processes.map((p) => p.user))].filter(Boolean).sort().map((u) => (
<option key={u} value={u}>{u}</option>
))}
</select>
</div>
{/* Process table */}
{isLoading || (isFetching && !snapshot) ? (
<div className="flex items-center justify-center py-12 text-gray-500">
<RefreshCw className="h-5 w-5 animate-spin mr-2" />
Loading process data...
</div>
) : !snapshot ? (
<div className="text-center py-12 text-gray-500">
<Activity className="h-8 w-8 mx-auto mb-3 text-gray-300" />
<p className="text-sm">No process data available.</p>
<p className="text-xs text-gray-400 mt-1">Click Scan to collect process information.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="table w-full">
<thead>
<tr className="table-header">
<SortHeader label="Name" col="name" />
<SortHeader label="PID" col="pid" className="text-right" />
<th className="text-left py-2 px-2 font-medium">User</th>
<th className="text-left py-2 px-2 font-medium">State</th>
<SortHeader label="CPU%" col="cpu" className="text-right" />
<SortHeader label="Mem%" col="mem" className="text-right" />
<SortHeader label="RSS" col="rss" className="text-right" />
<SortHeader label="Threads" col="threads" className="text-right" />
<th className="text-right py-2 px-2 font-medium">Nice</th>
</tr>
</thead>
<tbody>
{filteredProcesses.map((proc) => (
<tr
key={proc.id}
className="table-row cursor-pointer hover:bg-gray-50"
onClick={() => setSelectedProcessId(proc.id)}
>
<td className="table-cell font-medium max-w-[200px] truncate" title={proc.cmdline}>
{proc.name}
</td>
<td className="table-cell text-right text-gray-600 font-mono text-xs">
{proc.pid}
</td>
<td className="table-cell text-gray-600">{proc.user || '—'}</td>
<td className="table-cell">
<span
className={cn(
'badge badge-sm',
proc.state === 'R' && 'badge-success',
proc.state === 'S' && 'badge-info',
proc.state === 'D' && 'badge-warning',
proc.state === 'Z' && 'badge-danger',
proc.state === 'T' && 'badge-warning'
)}
>
{proc.state}
</span>
</td>
<td className="table-cell text-right font-mono text-xs">
{proc.cpu_percent > 0 ? `${proc.cpu_percent.toFixed(1)}%` : '—'}
</td>
<td className="table-cell text-right font-mono text-xs">
{proc.mem_percent > 0 ? `${proc.mem_percent.toFixed(1)}%` : '—'}
</td>
<td className="table-cell text-right font-mono text-xs">
{formatBytes(proc.rss_bytes)}
</td>
<td className="table-cell text-right">{proc.threads}</td>
<td className="table-cell text-right">{proc.nice}</td>
</tr>
))}
</tbody>
</table>
{filteredProcesses.length === 0 && processes.length > 0 && (
<p className="text-center py-6 text-sm text-gray-500">
No processes match your filter.
</p>
)}
</div>
)}
{/* Process detail modal */}
{selectedProcessId && (
<ProcessDetailModal
agentId={agentId}
processId={selectedProcessId}
onClose={() => setSelectedProcessId(null)}
/>
)}
</div>
);
};

View file

@ -0,0 +1,36 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { agentApi } from '@/lib/api';
import type { ProcessSnapshotResponse, ProcessDetailResponse, ProcessFilter } from '@/types/process';
// Fetch the latest process snapshot for an agent.
export function useProcessSnapshot(agentId: string, filter?: ProcessFilter) {
return useQuery<ProcessSnapshotResponse>({
queryKey: ['process-snapshot', agentId, filter],
queryFn: () => agentApi.getLatestProcessSnapshot(agentId, filter as Record<string, string>),
staleTime: 24 * 60 * 60 * 1000, // 24h — data only changes on explicit scan
refetchInterval: false,
enabled: !!agentId,
});
}
// Trigger an on-demand process scan (mutation).
export function useTriggerProcessScan() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (agentId: string) => agentApi.triggerProcessScan(agentId),
onSuccess: (_data, agentId) => {
// Invalidate snapshot so it refetches after scan completes
queryClient.invalidateQueries({ queryKey: ['process-snapshot', agentId] });
},
});
}
// Fetch process detail with related data (for drill-down modal).
export function useProcessDetail(agentId: string, processId: string, enabled: boolean) {
return useQuery<ProcessDetailResponse>({
queryKey: ['process-detail', agentId, processId],
queryFn: () => agentApi.getProcessDetail(agentId, processId),
enabled: enabled && !!agentId && !!processId,
staleTime: 24 * 60 * 60 * 1000,
});
}

View file

@ -272,6 +272,20 @@ export const agentApi = {
return response.data;
},
// Process Explorer — on-demand process scanning
getLatestProcessSnapshot: async (agentId: string, params?: Record<string, string>): Promise<any> => {
const response = await api.get(`/agents/${agentId}/processes`, { params });
return response.data;
},
getProcessDetail: async (agentId: string, processId: string): Promise<any> => {
const response = await api.get(`/agents/${agentId}/processes/${processId}`);
return response.data;
},
triggerProcessScan: async (agentId: string): Promise<any> => {
const response = await api.post(`/agents/${agentId}/processes/scan`);
return response.data;
},
// Get agent system metrics
getAgentSystemMetrics: async (agentId: string): Promise<any> => {
const response = await api.get(`/agents/${agentId}/metrics/system`);

View file

@ -46,11 +46,12 @@ import { AgentUpdatesModal } from '@/components/AgentUpdatesModal';
import { BulkAgentUpdate } from '@/components/RelayList';
import ChatTimeline from '@/components/ChatTimeline';
import AgentSoftwareBindings from '@/components/AgentSoftwareBindings';
import { ProcessesTab } from '@/components/ProcessesTab';
import { readIntegrations, resolveState } from '@/types/integrations';
type AgentDetailTab = 'overview' | 'storage' | 'updates' | 'software' | 'scanners' | 'history';
type AgentDetailTab = 'overview' | 'processes' | 'storage' | 'updates' | 'software' | 'scanners' | 'history';
const AGENT_DETAIL_TABS: AgentDetailTab[] = ['overview', 'storage', 'updates', 'software', 'scanners', 'history'];
const AGENT_DETAIL_TABS: AgentDetailTab[] = ['overview', 'processes', 'storage', 'updates', 'software', 'scanners', 'history'];
const parseAgentDetailTab = (tab: string | null): AgentDetailTab => {
return AGENT_DETAIL_TABS.includes(tab as AgentDetailTab) ? tab as AgentDetailTab : 'overview';
@ -487,6 +488,18 @@ const Agents: React.FC = () => {
>
<span>Overview</span>
</button>
<button
onClick={() => selectActiveTab('processes')}
className={cn(
'py-3 px-4 border-b-2 font-medium text-sm transition-colors whitespace-nowrap flex items-center space-x-2',
activeTab === 'processes'
? 'border-primary-500 text-primary-600 bg-primary-50 rounded-t-lg'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 hover:bg-gray-50'
)}
>
<Activity className="h-4 w-4" />
<span>Processes</span>
</button>
<button
onClick={() => selectActiveTab('storage')}
className={cn(
@ -768,7 +781,7 @@ const Agents: React.FC = () => {
className="col-span-full"
processes={topProcesses}
processCount={meta.processes}
onSeeMore={() => navigate(`/updates?agent=${selectedAgent.id}`)}
onSeeMore={() => selectActiveTab('processes')}
/>
</div>
</div>
@ -778,6 +791,10 @@ const Agents: React.FC = () => {
</div>
)}
{activeTab === 'processes' && (
<ProcessesTab agentId={selectedAgent.id} />
)}
{activeTab === 'storage' && (
<AgentStorage agentId={selectedAgent.id} />
)}

82
web/src/types/process.ts Normal file
View file

@ -0,0 +1,82 @@
// Process Explorer types — mirror the server models.
export interface ProcessSnapshot {
id: string;
agent_id: string;
command_id: string;
process_count: number;
scanned_at: string;
scan_duration_ms: number;
created_at: string;
}
export interface Process {
id: string;
snapshot_id: string;
agent_id: string;
pid: number;
name: string;
path: string;
cmdline: string;
cwd: string;
state: string;
uid: number;
gid: number;
euid: number;
egid: number;
user: string;
group: string;
tty: number;
tty_name: string;
cpu_seconds_user: number;
cpu_seconds_system: number;
cpu_percent: number;
rss_bytes: number;
vms_bytes: number;
mem_percent: number;
threads: number;
nice: number;
start_time_seconds: number;
parent_pid: number;
process_group_id: number;
elevation_status: string;
on_disk: number;
disk_bytes_read: number;
disk_bytes_written: number;
created_at: string;
}
export interface ProcessSnapshotResponse {
snapshot: ProcessSnapshot | null;
processes: Process[];
total: number;
}
export interface ProcessDetailResponse {
process: Process;
open_files?: ProcessRelated[];
open_sockets?: ProcessRelated[];
open_pipes?: ProcessRelated[];
environment?: ProcessRelated[];
memory_map?: ProcessRelated[];
namespaces?: ProcessRelated[];
listening_ports?: ProcessRelated[];
}
export interface ProcessRelated {
id: string;
process_id: string;
relation_type: string;
data: Record<string, any>;
created_at: string;
}
export interface ProcessFilter {
name?: string;
user?: string;
state?: string;
sort_by?: 'cpu' | 'mem' | 'pid' | 'name' | 'threads' | 'rss';
sort_dir?: 'asc' | 'desc';
limit?: number;
offset?: number;
}