import { Gauge, LayoutList, Loader2, MoreHorizontal, Pause, Play, Skull, XCircle, } from 'lucide-react'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useI18n } from '../../application/i18n/I18nProvider'; import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend'; import { getProcessFlags, getProcessStatusLabelKey, getProcessTone, } from '../../domain/systemManager/processState'; import type { SystemProcessInfo } from '../../domain/systemManager/types'; import { systemProcessInfoEqual } from '../../domain/systemManager/pollEquals'; import { cn } from '../../lib/utils'; import { VariableSizeVirtualList } from '../ui/VariableSizeVirtualList'; import { ResourceBar } from './ResourceBar'; import { useStableListOrder, mergePollListByKey } from './listStable'; import { SystemPanelDetailStrip, SystemPanelEmpty, SystemPanelError, SystemPanelInlineError, SystemPanelList, SystemPanelMetaBar, SystemPanelRefreshButton, SystemPanelRoundButton, SystemPanelRow, SystemPanelSearch, SystemPanelSegmented, SystemPanelShell, SystemPanelStatusBadge, SystemPanelToolbar, } from './SystemPanelUi'; import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog'; import { SystemPanelPromptDialog } from './SystemPanelPromptDialog'; import { usePolling, useStableTranslate } from '../../application/state/useSystemManager'; import { getCachedProcessList, setCachedProcessList, } from './processListCache'; type Backend = ReturnType; type SortKey = 'cpuPercent' | 'memPercent' | 'pid' | 'command' | 'user'; type ProcessFilter = 'all' | 'running'; type ProcessSignal = 'STOP' | 'CONT' | 'TERM' | 'KILL'; interface PendingProcessSignal { pid: number; signal: ProcessSignal; } function processSignalTitleKey(signal: ProcessSignal): string { switch (signal) { case 'STOP': return 'systemManager.processes.stop'; case 'CONT': return 'systemManager.processes.cont'; case 'TERM': return 'systemManager.processes.term'; case 'KILL': return 'systemManager.processes.kill'; } } const PROCESS_ROW_HEIGHT = 56; const PROCESS_DETAIL_HEIGHT = 112; const PROCESS_OVERSCAN_ROWS = 8; const SORT_OPTIONS: Array<{ key: SortKey; labelKey: string }> = [ { key: 'cpuPercent', labelKey: 'systemManager.processes.sort.cpu' }, { key: 'memPercent', labelKey: 'systemManager.processes.sort.mem' }, { key: 'pid', labelKey: 'systemManager.processes.sort.pid' }, { key: 'command', labelKey: 'systemManager.processes.sort.command' }, { key: 'user', labelKey: 'systemManager.processes.sort.user' }, ]; function formatKb(kb: number): string { if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)} GB`; if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`; return `${kb} KB`; } function isProcessRunning(stat: string): boolean { return /R/i.test(stat); } const mergeProcesses = ( prev: SystemProcessInfo[] | null, next: SystemProcessInfo[], ) => mergePollListByKey(prev, next, (p) => p.pid, systemProcessInfoEqual); const ProcessListLoading = memo(function ProcessListLoading({ message, }: { message: string; }) { return (
{message}
); }); interface ProcessRowProps { proc: SystemProcessInfo; selected: boolean; onToggle: (pid: number) => void; onSignal: (pid: number, signal: string) => void; onRenice: (pid: number) => void; } const ProcessRow = memo(function ProcessRow({ proc, selected, onToggle, onSignal, onRenice, }: ProcessRowProps) { const { t } = useI18n(); const { isStopped, isZombie } = getProcessFlags(proc); // Extract a clean display name from the command line. // Windows PowerShell returns full paths; Linux often returns just the binary. const rawName = String(proc.command || ''); const displayName = rawName ? (rawName.includes('/') || rawName.includes('\\') ? rawName.split(/[\/\\]/).pop() || rawName : rawName.split(/\s+/)[0] || rawName) : `PID ${proc.pid}`; const isWindowsProc = rawName.toLowerCase().match(/\.exe(?:\s|$)/); const mainActions = (
onSignal(proc.pid, 'TERM')} > onSignal(proc.pid, 'KILL')} >
); return (
onToggle(proc.pid)} title={rawName || displayName} > {/* Process name + PID */}
{displayName} {proc.user || '—'} · PID {proc.pid}{proc.ppid ? ` · PPID ${proc.ppid}` : ''}
{/* CPU % */}
{proc.cpuPercent.toFixed(1)}%
{/* MEM % */}
{proc.memPercent.toFixed(1)}%
{/* Status badge */}
{t(getProcessStatusLabelKey(proc))}
{/* Actions */} {mainActions}
{selected && (
{t('systemManager.processes.ppid')}: {proc.ppid} {t('systemManager.processes.stat')}: {proc.stat} {t('systemManager.processes.elapsed')}: {proc.elapsed || '—'} RSS: {formatKb(proc.rssKb)} VSZ: {formatKb(proc.vszKb)} User: {proc.user || '—'}
{rawName && (
{rawName}
)} {/* Extra actions — hidden on Windows hosts (no POSIX signals) */} {!isWindowsProc && (
{!isStopped && !isZombie && ( onSignal(proc.pid, 'STOP')} > 暂停 )} {isStopped && !isZombie && ( onSignal(proc.pid, 'CONT')} > 继续 )} onRenice(proc.pid)} > 优先级
)}
)}
); }); interface ProcessVirtualListProps { processes: SystemProcessInfo[]; selectedPid: number | null; onToggle: (pid: number) => void; onSignal: (pid: number, signal: string) => void; onRenice: (pid: number) => void; } const ProcessVirtualList = memo(function ProcessVirtualList({ processes, selectedPid, onToggle, onSignal, onRenice, }: ProcessVirtualListProps) { const getItemHeight = useCallback( (proc: SystemProcessInfo) => ( proc.pid === selectedPid ? PROCESS_ROW_HEIGHT + PROCESS_DETAIL_HEIGHT : PROCESS_ROW_HEIGHT ), [selectedPid], ); const renderItem = useCallback((proc: SystemProcessInfo) => ( ), [onRenice, onSignal, onToggle, selectedPid]); return ( items={processes} getItemHeight={getItemHeight} className="flex-1 min-h-0" overscan={PROCESS_OVERSCAN_ROWS} getItemKey={(proc) => String(proc.pid)} renderItem={renderItem} /> ); }); interface ProcessManagerTabProps { sessionId: string; isVisible: boolean; backend: Backend; refreshIntervalSec: number; } export const ProcessManagerTab = memo(function ProcessManagerTab({ sessionId, isVisible, backend, refreshIntervalSec, }: ProcessManagerTabProps) { const { t } = useI18n(); const stableT = useStableTranslate(); const [query, setQuery] = useState(''); const [sortKey, setSortKey] = useState('cpuPercent'); const [sortAsc, setSortAsc] = useState(false); const [filter, setFilter] = useState('all'); const [selectedPid, setSelectedPid] = useState(null); const [reniceTarget, setReniceTarget] = useState(null); const [pendingSignal, setPendingSignal] = useState(null); const [signalBusy, setSignalBusy] = useState(false); const [actionError, setActionError] = useState(null); const [cachedProcesses, setCachedProcesses] = useState(() => getCachedProcessList(sessionId)); const [cachedProcessesSessionId, setCachedProcessesSessionId] = useState(sessionId); const [processListPending, setProcessListPending] = useState(false); const processFetchGenerationRef = useRef(0); const currentSessionIdRef = useRef(sessionId); if (currentSessionIdRef.current !== sessionId) { currentSessionIdRef.current = sessionId; processFetchGenerationRef.current += 1; } useEffect(() => { processFetchGenerationRef.current += 1; setCachedProcesses(getCachedProcessList(sessionId)); setCachedProcessesSessionId(sessionId); setProcessListPending(false); // Drop in-flight dialogs so a confirm cannot act on a different host/session. setPendingSignal(null); setSignalBusy(false); setReniceTarget(null); setSelectedPid(null); setActionError(null); }, [sessionId]); useEffect(() => () => { processFetchGenerationRef.current += 1; }, []); const fetcher = useCallback(async () => { const fetchGeneration = processFetchGenerationRef.current; const fetchSessionId = sessionId; const isCurrentFetch = () => ( processFetchGenerationRef.current === fetchGeneration && currentSessionIdRef.current === fetchSessionId ); try { const result = await backend.listSystemProcesses(sessionId); if (!isCurrentFetch()) return null; if (result.pending) { setProcessListPending(true); return null; } setProcessListPending(false); if (!result.success || !result.processes) { throw new Error(result.error || stableT('systemManager.errors.loadProcesses')); } return result.processes; } catch (err) { if (!isCurrentFetch()) return null; setProcessListPending(false); throw err; } }, [backend, sessionId, stableT]); const intervalMs = Math.max(2, refreshIntervalSec) * 1000; const { data: processes, error, loading, refresh } = usePolling( fetcher, intervalMs, isVisible, mergeProcesses, { resetKey: sessionId }, ); useEffect(() => { if (!processes) return; setCachedProcessList(sessionId, processes); setCachedProcesses(processes); setCachedProcessesSessionId(sessionId); }, [processes, sessionId]); const sessionCachedProcesses = cachedProcessesSessionId === sessionId ? cachedProcesses : getCachedProcessList(sessionId); const visibleProcesses = processes ?? sessionCachedProcesses; const showingCachedProcesses = processes === null && sessionCachedProcesses !== null; const matched = useMemo(() => { const list = visibleProcesses ?? []; const q = query.trim().toLowerCase(); return list.filter((p) => { if (filter === 'running' && !isProcessRunning(p.stat)) return false; if (!q) return true; return String(p.pid).includes(q) || String(p.ppid).includes(q) || p.user.toLowerCase().includes(q) || p.command.toLowerCase().includes(q); }); }, [visibleProcesses, query, filter]); const compareProcesses = useCallback((a: SystemProcessInfo, b: SystemProcessInfo) => { let cmp = 0; if (sortKey === 'command' || sortKey === 'user') { cmp = a[sortKey].localeCompare(b[sortKey]); } else { const av = a[sortKey]; const bv = b[sortKey]; cmp = Number(av) < Number(bv) ? -1 : Number(av) > Number(bv) ? 1 : 0; } const primary = sortAsc ? cmp : -cmp; if (primary !== 0) return primary; return a.pid - b.pid; }, [sortAsc, sortKey]); const sortToken = `${sortKey}|${sortAsc}|${filter}|${query}`; const displayList = useStableListOrder( matched, (p) => p.pid, sortToken, compareProcesses, ); const isProcessRefreshActive = loading || processListPending; const showInitialLoading = isProcessRefreshActive && displayList.length === 0; const showBlockingError = Boolean(error && !isProcessRefreshActive && displayList.length === 0); const showInlineRefreshError = Boolean(error && !isProcessRefreshActive && displayList.length > 0); const cycleSort = (key: SortKey) => { if (sortKey === key) setSortAsc((v) => !v); else { setSortKey(key); setSortAsc(key === 'command' || key === 'user'); } }; const togglePid = useCallback((pid: number) => { setSelectedPid((cur) => (cur === pid ? null : pid)); }, []); const requestSignal = useCallback((pid: number, signal: string) => { if (signal !== 'STOP' && signal !== 'CONT' && signal !== 'TERM' && signal !== 'KILL') return; setPendingSignal({ pid, signal }); }, []); const executeSignal = useCallback(async (pid: number, signal: ProcessSignal) => { setSignalBusy(true); setActionError(null); try { const result = await backend.signalSystemProcess({ sessionId, pid, signal }); if (!result.success) { setActionError(result.error || t('systemManager.errors.actionFailed')); return; } void refresh(); } finally { setSignalBusy(false); } }, [backend, refresh, sessionId, t]); const reniceProcess = useCallback(async (pid: number, nice: number) => { setActionError(null); const result = await backend.signalSystemProcess({ sessionId, pid, nice }); if (!result.success) { setActionError(result.error || t('systemManager.errors.actionFailed')); return; } void refresh(); }, [backend, refresh, sessionId, t]); const openRenicePrompt = useCallback((pid: number) => { setReniceTarget(pid); }, []); return ( void refresh()} /> )} > {SORT_OPTIONS.map(({ key, labelKey }) => ( ))}
)}> {showingCachedProcesses && isProcessRefreshActive && } {t('systemManager.processes.meta', { count: String(displayList.length) })} {actionError && } {showInlineRefreshError && error && } {(showBlockingError || showInitialLoading || (!error && displayList.length === 0 && !loading && !showInitialLoading)) ? ( {showBlockingError && error && ( void refresh()} retryLabel={t('history.action.retry')} loading={loading} /> )} {showInitialLoading && ( )} {!error && displayList.length === 0 && !loading && !showInitialLoading && ( )} ) : ( )} { if (!open && !signalBusy) setPendingSignal(null); }} onConfirm={() => { const target = pendingSignal; if (!target) return; setPendingSignal(null); void executeSignal(target.pid, target.signal); }} /> { const nice = Number(values.nice); if (!Number.isFinite(nice) || nice < -20 || nice > 19) { return t('systemManager.processes.reniceInvalid'); } return null; }} onOpenChange={(open) => { if (!open) setReniceTarget(null); }} onSubmit={(values) => { const pid = reniceTarget; setReniceTarget(null); if (pid === null) return; void reniceProcess(pid, Number(values.nice)); }} /> ); });