[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
609
components/systemManager/ProcessManagerTab.tsx
Normal file
609
components/systemManager/ProcessManagerTab.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
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<typeof useSystemManagerBackend>;
|
||||
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 (
|
||||
<div className="flex min-h-[180px] flex-col items-center justify-center px-4 py-10 text-center text-xs text-muted-foreground">
|
||||
<Loader2 size={18} className="mb-2 animate-spin opacity-70" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
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 = (
|
||||
<div className="flex w-[64px] shrink-0 items-center justify-end gap-1">
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.processes.term')}
|
||||
onClick={() => onSignal(proc.pid, 'TERM')}
|
||||
>
|
||||
<XCircle size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.processes.kill')}
|
||||
destructive
|
||||
onClick={() => onSignal(proc.pid, 'KILL')}
|
||||
>
|
||||
<Skull size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 h-14 px-3 cursor-pointer transition-colors',
|
||||
selected ? 'bg-primary/5 border-l-2 border-primary' : 'hover:bg-muted/40 border-l-2 border-transparent',
|
||||
)}
|
||||
onClick={() => onToggle(proc.pid)}
|
||||
title={rawName || displayName}
|
||||
>
|
||||
{/* Process name + PID */}
|
||||
<div className="flex min-w-0 flex-col flex-1">
|
||||
<span className="truncate text-[13px] font-medium leading-tight">{displayName}</span>
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{proc.user || '—'} · PID {proc.pid}{proc.ppid ? ` · PPID ${proc.ppid}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* CPU % */}
|
||||
<div className="flex w-[60px] shrink-0 flex-col items-end">
|
||||
<span className="text-[12px] font-mono font-medium tabular-nums">{proc.cpuPercent.toFixed(1)}%</span>
|
||||
<div className="mt-0.5 h-1 w-14 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-[width]"
|
||||
style={{ width: `${Math.min(100, proc.cpuPercent)}%`, backgroundColor: getProcessTone(proc) === 'critical' ? '#ef4444' : '#3b82f6' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MEM % */}
|
||||
<div className="flex w-[60px] shrink-0 flex-col items-end">
|
||||
<span className="text-[12px] font-mono font-medium tabular-nums">{proc.memPercent.toFixed(1)}%</span>
|
||||
<div className="mt-0.5 h-1 w-14 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] bg-emerald-500"
|
||||
style={{ width: `${Math.min(100, proc.memPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="flex w-[72px] shrink-0 items-center justify-end">
|
||||
<SystemPanelStatusBadge tone={getProcessTone(proc)}>
|
||||
{t(getProcessStatusLabelKey(proc))}
|
||||
</SystemPanelStatusBadge>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{mainActions}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<SystemPanelDetailStrip className="overflow-hidden">
|
||||
<div className="grid grid-cols-3 gap-x-3 gap-y-1 text-[11px] text-muted-foreground mb-2">
|
||||
<span>{t('systemManager.processes.ppid')}: {proc.ppid}</span>
|
||||
<span>{t('systemManager.processes.stat')}: {proc.stat}</span>
|
||||
<span>{t('systemManager.processes.elapsed')}: {proc.elapsed || '—'}</span>
|
||||
<span>RSS: {formatKb(proc.rssKb)}</span>
|
||||
<span>VSZ: {formatKb(proc.vszKb)}</span>
|
||||
<span>User: {proc.user || '—'}</span>
|
||||
</div>
|
||||
{rawName && (
|
||||
<div className="text-[10px] text-muted-foreground/80 truncate mb-2" title={rawName}>
|
||||
{rawName}
|
||||
</div>
|
||||
)}
|
||||
{/* Extra actions — hidden on Windows hosts (no POSIX signals) */}
|
||||
{!isWindowsProc && (
|
||||
<div className="flex items-center gap-1 pt-2 border-t border-border/40">
|
||||
{!isStopped && !isZombie && (
|
||||
<SystemPanelRoundButton
|
||||
size="sm"
|
||||
title={t('systemManager.processes.stop')}
|
||||
onClick={() => onSignal(proc.pid, 'STOP')}
|
||||
>
|
||||
<Pause size={11} /> <span className="text-[10px]">暂停</span>
|
||||
</SystemPanelRoundButton>
|
||||
)}
|
||||
{isStopped && !isZombie && (
|
||||
<SystemPanelRoundButton
|
||||
size="sm"
|
||||
title={t('systemManager.processes.cont')}
|
||||
onClick={() => onSignal(proc.pid, 'CONT')}
|
||||
>
|
||||
<Play size={11} /> <span className="text-[10px]">继续</span>
|
||||
</SystemPanelRoundButton>
|
||||
)}
|
||||
<SystemPanelRoundButton
|
||||
size="sm"
|
||||
title={t('systemManager.processes.renice')}
|
||||
onClick={() => onRenice(proc.pid)}
|
||||
>
|
||||
<Gauge size={11} /> <span className="text-[10px]">优先级</span>
|
||||
</SystemPanelRoundButton>
|
||||
</div>
|
||||
)}
|
||||
</SystemPanelDetailStrip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
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) => (
|
||||
<ProcessRow
|
||||
proc={proc}
|
||||
selected={selectedPid === proc.pid}
|
||||
onToggle={onToggle}
|
||||
onSignal={onSignal}
|
||||
onRenice={onRenice}
|
||||
/>
|
||||
), [onRenice, onSignal, onToggle, selectedPid]);
|
||||
|
||||
return (
|
||||
<VariableSizeVirtualList<SystemProcessInfo>
|
||||
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<SortKey>('cpuPercent');
|
||||
const [sortAsc, setSortAsc] = useState(false);
|
||||
const [filter, setFilter] = useState<ProcessFilter>('all');
|
||||
const [selectedPid, setSelectedPid] = useState<number | null>(null);
|
||||
const [reniceTarget, setReniceTarget] = useState<number | null>(null);
|
||||
const [pendingSignal, setPendingSignal] = useState<PendingProcessSignal | null>(null);
|
||||
const [signalBusy, setSignalBusy] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [cachedProcesses, setCachedProcesses] = useState<SystemProcessInfo[] | null>(() => 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<SystemProcessInfo[]>(
|
||||
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<SystemProcessInfo[]>(() => {
|
||||
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<SystemProcessInfo, number>(
|
||||
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 (
|
||||
<SystemPanelShell section="system-manager-processes">
|
||||
<SystemPanelToolbar
|
||||
trailing={(
|
||||
<SystemPanelRefreshButton
|
||||
title={t('history.action.refresh')}
|
||||
loading={isProcessRefreshActive}
|
||||
onClick={() => void refresh()}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<SystemPanelSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t('systemManager.processes.search')}
|
||||
/>
|
||||
</SystemPanelToolbar>
|
||||
|
||||
<SystemPanelSegmented
|
||||
value={filter}
|
||||
options={[
|
||||
{ id: 'all', label: t('systemManager.processes.filter.all') },
|
||||
{ id: 'running', label: t('systemManager.processes.filter.running') },
|
||||
]}
|
||||
onChange={setFilter}
|
||||
/>
|
||||
|
||||
<SystemPanelMetaBar trailing={(
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{SORT_OPTIONS.map(({ key, labelKey }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => cycleSort(key)}
|
||||
className={cn(
|
||||
'shrink-0 px-1.5 py-0.5 rounded text-[10px] transition-colors',
|
||||
sortKey === key
|
||||
? 'text-foreground bg-muted/60'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{t(labelKey)}{sortKey === key ? (sortAsc ? ' ↑' : ' ↓') : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}>
|
||||
<span className={cn(showingCachedProcesses && isProcessRefreshActive && 'inline-flex items-center gap-1.5')}>
|
||||
{showingCachedProcesses && isProcessRefreshActive && <Loader2 size={10} className="animate-spin" />}
|
||||
{t('systemManager.processes.meta', { count: String(displayList.length) })}
|
||||
</span>
|
||||
</SystemPanelMetaBar>
|
||||
|
||||
{actionError && <SystemPanelInlineError message={actionError} />}
|
||||
{showInlineRefreshError && error && <SystemPanelInlineError message={error} />}
|
||||
|
||||
{(showBlockingError || showInitialLoading || (!error && displayList.length === 0 && !loading && !showInitialLoading)) ? (
|
||||
<SystemPanelList>
|
||||
{showBlockingError && error && (
|
||||
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
|
||||
)}
|
||||
{showInitialLoading && (
|
||||
<ProcessListLoading message={t('systemManager.processes.loading')} />
|
||||
)}
|
||||
{!error && displayList.length === 0 && !loading && !showInitialLoading && (
|
||||
<SystemPanelEmpty icon={LayoutList} message={t('systemManager.empty')} />
|
||||
)}
|
||||
</SystemPanelList>
|
||||
) : (
|
||||
<ProcessVirtualList
|
||||
processes={displayList}
|
||||
selectedPid={selectedPid}
|
||||
onToggle={togglePid}
|
||||
onSignal={requestSignal}
|
||||
onRenice={openRenicePrompt}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SystemPanelConfirmDialog
|
||||
open={pendingSignal !== null}
|
||||
title={pendingSignal ? t(processSignalTitleKey(pendingSignal.signal)) : ''}
|
||||
message={pendingSignal
|
||||
? t(
|
||||
pendingSignal.signal === 'KILL'
|
||||
? 'systemManager.processes.confirmKill'
|
||||
: 'systemManager.processes.confirmSignal',
|
||||
{ pid: String(pendingSignal.pid), signal: pendingSignal.signal },
|
||||
)
|
||||
: ''}
|
||||
confirmLabel={pendingSignal ? t(processSignalTitleKey(pendingSignal.signal)) : ''}
|
||||
destructive={pendingSignal?.signal === 'KILL' || pendingSignal?.signal === 'TERM'}
|
||||
busy={signalBusy}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !signalBusy) setPendingSignal(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
const target = pendingSignal;
|
||||
if (!target) return;
|
||||
setPendingSignal(null);
|
||||
void executeSignal(target.pid, target.signal);
|
||||
}}
|
||||
/>
|
||||
|
||||
<SystemPanelPromptDialog
|
||||
open={reniceTarget !== null}
|
||||
title={t('systemManager.processes.renice')}
|
||||
fields={[{
|
||||
id: 'nice',
|
||||
label: t('systemManager.processes.renicePrompt'),
|
||||
initialValue: '0',
|
||||
mono: true,
|
||||
}]}
|
||||
confirmLabel={t('systemManager.processes.renice')}
|
||||
validate={(values) => {
|
||||
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));
|
||||
}}
|
||||
/>
|
||||
</SystemPanelShell>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user