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:
parent
469d61c0dc
commit
244d9091ee
31 changed files with 4127 additions and 13 deletions
356
web/src/components/ProcessDetailModal.tsx
Normal file
356
web/src/components/ProcessDetailModal.tsx
Normal 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;
|
||||
};
|
||||
237
web/src/components/ProcessesTab.tsx
Normal file
237
web/src/components/ProcessesTab.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
36
web/src/hooks/useProcesses.ts
Normal file
36
web/src/hooks/useProcesses.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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
82
web/src/types/process.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue