[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
import { Loader2, Pause, Pencil, Play, Trash2, Zap } from 'lucide-react';
import React, { memo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { DockerContainerAction, DockerContainerInfo, DockerStatInfo } from '../../domain/systemManager/types';
import { getContainerFlags } from '../../domain/systemManager/containerState';
import { DockerInspectView } from './DockerInspectView';
import { ResourceBar } from './ResourceBar';
import {
SystemPanelActionChip,
SystemPanelDetailStrip,
SystemPanelInlineError,
} from './SystemPanelUi';
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
interface DockerContainerDetailProps {
container: DockerContainerInfo;
inspect: Record<string, unknown> | null;
inspectError?: string | null;
inspectLoading?: boolean;
stat?: DockerStatInfo | null;
statsLoading?: boolean;
pendingAction: DockerContainerAction | null;
onCloseInspect: () => void;
onRunAction: (containerId: string, action: DockerContainerAction, newName?: string) => Promise<void>;
}
export const DockerContainerDetail = memo(function DockerContainerDetail({
container,
inspect,
inspectError = null,
inspectLoading = false,
stat = null,
statsLoading = false,
pendingAction,
onCloseInspect,
onRunAction,
}: DockerContainerDetailProps) {
const { t } = useI18n();
const shortId = container.id.slice(0, 12);
const { isRunning, isPaused } = getContainerFlags(container);
const [renameOpen, setRenameOpen] = useState(false);
const actionBusy = pendingAction !== null;
return (
<>
<SystemPanelDetailStrip>
{container.ports && (
<div className="text-[10px] text-muted-foreground mb-2 break-all">{container.ports}</div>
)}
{stat && (
<div className="space-y-1 mb-2">
<ResourceBar label="CPU" value={stat.cpuPercent} />
<ResourceBar label="MEM" value={stat.memPercent} />
<div className="text-[10px] text-muted-foreground">{stat.netIO} · {stat.memUsage}</div>
</div>
)}
{!stat && statsLoading && (isRunning || isPaused) && (
<div className="mb-2 flex items-center gap-1.5 text-[10px] text-muted-foreground">
<Loader2 size={11} className="animate-spin" />
{t('systemManager.common.loadingStats')}
</div>
)}
<div className="flex flex-wrap items-center gap-0.5">
<SystemPanelActionChip title={t('systemManager.docker.renamePrompt')} disabled={actionBusy} onClick={() => setRenameOpen(true)}>
<Pencil size={11} /> {t('common.rename')}
</SystemPanelActionChip>
{isRunning && (
<SystemPanelActionChip title={t('systemManager.docker.pause')} disabled={actionBusy} onClick={() => void onRunAction(shortId, 'pause')}>
<Pause size={11} /> {t('systemManager.docker.pause')}
</SystemPanelActionChip>
)}
{isPaused && (
<SystemPanelActionChip title={t('systemManager.docker.unpause')} disabled={actionBusy} onClick={() => void onRunAction(shortId, 'unpause')}>
<Play size={11} /> {t('systemManager.docker.unpause')}
</SystemPanelActionChip>
)}
{(isRunning || isPaused) && (
<SystemPanelActionChip title={t('systemManager.docker.kill')} disabled={actionBusy} onClick={() => void onRunAction(shortId, 'kill')} destructive>
<Zap size={11} /> {t('systemManager.docker.kill')}
</SystemPanelActionChip>
)}
<SystemPanelActionChip title={t('systemManager.docker.confirmRemove')} disabled={actionBusy} onClick={() => void onRunAction(shortId, 'rm')} destructive>
<Trash2 size={11} />
</SystemPanelActionChip>
</div>
</SystemPanelDetailStrip>
{inspectLoading && !inspect && (
<div className="flex items-center gap-1.5 border-b border-border/40 bg-muted/20 px-3 py-2 text-[10px] text-muted-foreground">
<Loader2 size={11} className="animate-spin" />
{t('systemManager.common.loadingDetails')}
</div>
)}
{inspectError && !inspect && (
<SystemPanelInlineError message={inspectError} />
)}
{inspect && (
<DockerInspectView
kind="container"
data={inspect}
onClose={onCloseInspect}
/>
)}
<SystemPanelPromptDialog
open={renameOpen}
title={t('common.rename')}
fields={[{
id: 'name',
label: t('systemManager.docker.renamePrompt'),
initialValue: container.name || shortId,
}]}
confirmLabel={t('common.rename')}
onOpenChange={setRenameOpen}
onSubmit={(values) => {
setRenameOpen(false);
if (values.name !== container.name) {
void onRunAction(shortId, 'rename', values.name);
}
}}
/>
</>
);
});

View File

@@ -0,0 +1,513 @@
import { Box, FileText, Play, RotateCcw, Square, Terminal } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import { writeSystemManagerDiagnostic } from '../../application/state/systemManagerDiagnostics';
import type { TerminalSession } from '../../types';
import type { DockerContainerAction, DockerContainerInfo, DockerStatInfo, TerminalPopupIcon } from '../../domain/systemManager/types';
import { dockerContainerInfoEqual } from '../../domain/systemManager/pollEquals';
import { getContainerFlags, getContainerTone } from '../../domain/systemManager/containerState';
import { buildDockerExecShellCommand, buildDockerExecShellCommandWindows, buildDockerLogsCommand, buildDockerLogsCommandWindows } from '../../domain/systemManager/dockerShell';
import { DockerContainerDetail } from './DockerContainerDetail';
import { DockerImageIcon } from './DockerImageIcon';
import { useStableListOrder, mergePollListByKey } from './listStable';
import {
SystemPanelCollapsible,
SystemPanelEmpty,
SystemPanelError,
SystemPanelList,
SystemPanelLoading,
SystemPanelMetaBar,
SystemPanelRefreshButton,
SystemPanelRoundButton,
SystemPanelRow,
SystemPanelSearch,
SystemPanelSegmented,
SystemPanelStatusBadge,
SystemPanelToolbar,
} from './SystemPanelUi';
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
import { useAsyncRecordCache } from '../../application/state/systemManager/useAsyncRecordCache';
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
import { openInteractiveTerminal } from './openInteractiveTerminal';
import { showSystemManagerError } from './systemManagerToast';
type Backend = ReturnType<typeof useSystemManagerBackend>;
type ContainerFilter = 'all' | 'running' | 'stopped' | 'paused';
type PendingContainerConfirm = {
containerId: string;
action: 'rm' | 'kill';
};
async function buildContainerPopupIcon(image: string): Promise<TerminalPopupIcon> {
const {
dockerIconTileStyle,
resolveDockerIconPresentation,
resolveDockerImageIcon,
} = await import('../../domain/systemManager/dockerImageIcons');
const iconId = resolveDockerImageIcon(image);
const presentation = resolveDockerIconPresentation(iconId);
const tile = dockerIconTileStyle(presentation.displayIconId);
return {
kind: 'image',
src: presentation.iconUrl,
backgroundColor: tile.background,
alt: '',
};
}
interface DockerContainersPanelProps {
sessionId: string;
parentSession: TerminalSession;
isVisible: boolean;
warmupEnabled?: boolean;
backend: Backend;
listRefreshIntervalSec: number;
statsRefreshIntervalSec: number;
/** Target OS of the remote host — used to pick correct docker shell command. */
targetOs?: 'linux' | 'darwin' | 'win32' | 'unknown';
}
const DockerContainerRow = memo(function DockerContainerRow({
container,
selected,
pendingAction,
onSelectContainer,
onShellContainer,
onLogsContainer,
onContainerAction,
}: {
container: DockerContainerInfo;
selected: boolean;
pendingAction: DockerContainerAction | null;
onSelectContainer: (container: DockerContainerInfo) => void;
onShellContainer: (container: DockerContainerInfo) => void;
onLogsContainer: (container: DockerContainerInfo) => void;
onContainerAction: (container: DockerContainerInfo, action: DockerContainerAction) => void;
}) {
const { t } = useI18n();
const shortId = container.id.slice(0, 12);
const { isRunning, isPaused } = getContainerFlags(container);
const actionBusy = pendingAction !== null;
return (
<SystemPanelRow
selected={selected}
onClick={() => onSelectContainer(container)}
leading={<DockerImageIcon image={container.image} />}
title={container.name || shortId}
subtitle={container.image}
trailing={(
<div className="flex shrink-0 items-center gap-1">
<SystemPanelStatusBadge tone={getContainerTone(container)}>
{isRunning ? t('systemManager.docker.filter.running') : isPaused ? t('systemManager.docker.filter.paused') : t('systemManager.docker.filter.stopped')}
</SystemPanelStatusBadge>
{isRunning && (
<SystemPanelRoundButton title={t('systemManager.docker.shell')} onClick={() => onShellContainer(container)}>
<Terminal size={12} />
</SystemPanelRoundButton>
)}
<SystemPanelRoundButton title={t('systemManager.docker.logs')} onClick={() => onLogsContainer(container)}>
<FileText size={12} />
</SystemPanelRoundButton>
{isRunning && (
<>
<SystemPanelRoundButton
title={t('systemManager.docker.restart')}
disabled={actionBusy}
loading={pendingAction === 'restart'}
onClick={() => onContainerAction(container, 'restart')}
>
<RotateCcw size={12} />
</SystemPanelRoundButton>
<SystemPanelRoundButton
title={t('systemManager.docker.stop')}
disabled={actionBusy}
loading={pendingAction === 'stop'}
onClick={() => onContainerAction(container, 'stop')}
>
<Square size={12} />
</SystemPanelRoundButton>
</>
)}
{isPaused && (
<SystemPanelRoundButton
title={t('systemManager.docker.unpause')}
disabled={actionBusy}
loading={pendingAction === 'unpause'}
onClick={() => onContainerAction(container, 'unpause')}
>
<Play size={12} />
</SystemPanelRoundButton>
)}
{!isRunning && !isPaused && (
<SystemPanelRoundButton
title={t('systemManager.docker.start')}
disabled={actionBusy}
loading={pendingAction === 'start'}
onClick={() => onContainerAction(container, 'start')}
>
<Play size={12} />
</SystemPanelRoundButton>
)}
</div>
)}
/>
);
});
export const DockerContainersPanel = memo(function DockerContainersPanel({
sessionId,
parentSession,
isVisible,
warmupEnabled = false,
backend,
listRefreshIntervalSec,
statsRefreshIntervalSec,
targetOs = 'unknown',
}: DockerContainersPanelProps) {
const isWindows = targetOs === 'win32';
const { t } = useI18n();
const stableT = useStableTranslate();
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<ContainerFilter>('all');
const [selectedId, setSelectedId] = useState<string | null>(null);
// Spinner feedback while a container action (stop/restart/…) runs;
// cleared only after the follow-up list refresh lands.
const [pendingAction, setPendingAction] = useState<{ id: string; action: DockerContainerAction } | null>(null);
const [confirmAction, setConfirmAction] = useState<PendingContainerConfirm | null>(null);
useEffect(() => {
// Drop pending confirms/selection when the active terminal session changes so
// a confirm opened for host A cannot run against host B.
setConfirmAction(null);
setPendingAction(null);
setSelectedId(null);
}, [sessionId]);
const containersFetcher = useCallback(async () => {
const result = await backend.listDockerContainers(sessionId);
if (!result.success || !result.containers) {
throw new Error(result.error || stableT('systemManager.errors.loadDocker'));
}
return result.containers;
}, [backend, sessionId, stableT]);
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
const { data: containers, error, loading, refresh } = usePolling<DockerContainerInfo[]>(
containersFetcher,
listIntervalMs,
isVisible || warmupEnabled,
(prev, next) => mergePollListByKey(prev, next, (c) => c.id, dockerContainerInfoEqual),
{ poll: isVisible, resetKey: sessionId },
);
const matched = useMemo<DockerContainerInfo[]>(() => {
const q = query.trim().toLowerCase();
const containerList = containers ?? [];
return containerList.filter((container) => {
const { isRunning, isPaused } = getContainerFlags(container);
if (filter === 'running' && !isRunning) return false;
if (filter === 'stopped' && (isRunning || isPaused)) return false;
if (filter === 'paused' && !isPaused) return false;
if (!q) return true;
const shortId = container.id.slice(0, 12);
return container.name.toLowerCase().includes(q)
|| container.image.toLowerCase().includes(q)
|| shortId.toLowerCase().includes(q);
});
}, [containers, filter, query]);
const compareContainers = useCallback(
(a: DockerContainerInfo, b: DockerContainerInfo) => a.name.localeCompare(b.name),
[],
);
const displayList = useStableListOrder<DockerContainerInfo, string>(
matched,
(c) => c.id,
`${filter}|${query}`,
compareContainers,
);
const selectedContainer = useMemo(
() => displayList.find((c) => c.id === selectedId) ?? null,
[displayList, selectedId],
);
const statContainerIds = useMemo(
() => {
if (!selectedContainer) return [];
const { isRunning, isPaused } = getContainerFlags(selectedContainer);
return isRunning || isPaused ? [selectedContainer.id] : [];
},
[selectedContainer],
);
const statsFetcher = useCallback(async () => {
if (statContainerIds.length === 0) return [];
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds });
if (!result.success || !result.stats) {
throw new Error(result.error || stableT('systemManager.errors.loadDockerStats'));
}
return result.stats;
}, [backend, sessionId, stableT, statContainerIds]);
const statsIntervalMs = Math.max(2, statsRefreshIntervalSec) * 1000;
const { data: stats, loading: statsLoading } = usePolling<DockerStatInfo[]>(
statsFetcher,
statsIntervalMs,
isVisible && statContainerIds.length > 0,
undefined,
{ poll: isVisible, resetKey: `${sessionId}:${statContainerIds.join(',')}` },
);
const statsByContainerId = useMemo(() => {
const map = new Map<string, DockerStatInfo>();
for (const stat of stats ?? []) {
map.set(stat.id, stat);
map.set(stat.id.slice(0, 12), stat);
}
return map;
}, [stats]);
const getContainerInspectKey = useCallback((container: DockerContainerInfo) => (
`${sessionId}:${container.id}`
), [sessionId]);
const fetchContainerInspect = useCallback(async (container: DockerContainerInfo) => {
const result = await backend.dockerInspect({
sessionId,
containerId: container.id.slice(0, 12),
});
if (!result.success) {
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
}
return result.inspect ?? null;
}, [backend, sessionId, stableT]);
const {
records: inspectByContainerId,
loadRecord: loadContainerInspect,
refreshRecord: refreshContainerInspect,
invalidateMatching: invalidateContainerInspectMatching,
} = useAsyncRecordCache<DockerContainerInfo, Record<string, unknown>>({
items: containers ?? [],
enabled: isVisible && (containers?.length ?? 0) > 0,
getKey: getContainerInspectKey,
fetchRecord: fetchContainerInspect,
prefetchLimit: 24,
prefetchDelayMs: 40,
staleTimeMs: 20_000,
});
const runAction = useCallback(async (
containerId: string,
action: DockerContainerAction,
newName?: string,
options?: { skipConfirm?: boolean },
) => {
if (!options?.skipConfirm && (action === 'rm' || action === 'kill')) {
setConfirmAction({ containerId, action });
return;
}
setPendingAction({ id: containerId, action });
try {
const result = await backend.dockerAction({ sessionId, containerId, action, newName });
if (!result.success) {
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
return;
}
const affectedContainer = (containers ?? []).find((container) => (
container.id === containerId || container.id.startsWith(containerId)
));
invalidateContainerInspectMatching((key) => (
key === `${sessionId}:${containerId}` || key.startsWith(`${sessionId}:${containerId}`)
));
if (action === 'rm') {
setSelectedId(null);
}
await refresh();
if (affectedContainer && action !== 'rm') {
void refreshContainerInspect(affectedContainer);
}
} finally {
setPendingAction(null);
}
}, [
backend,
containers,
invalidateContainerInspectMatching,
refresh,
refreshContainerInspect,
sessionId,
t,
]);
const handleRowAction = useCallback((container: DockerContainerInfo, action: DockerContainerAction) => {
void runAction(container.id.slice(0, 12), action);
}, [runAction]);
const selectContainer = useCallback((container: DockerContainerInfo) => {
const next = selectedId === container.id ? null : container.id;
setSelectedId(next);
if (!next) return;
void loadContainerInspect(container, { force: true, urgent: true });
}, [loadContainerInspect, selectedId]);
const openShell = useCallback(async (container: DockerContainerInfo) => {
const id = container.id.slice(0, 12);
await writeSystemManagerDiagnostic('docker open shell clicked', {
sessionId,
containerId: id,
containerName: container.name,
image: container.image,
state: container.state,
});
const result = await openInteractiveTerminal(
backend,
parentSession,
`docker: ${container.name || id}`,
isWindows ? buildDockerExecShellCommandWindows(id) : buildDockerExecShellCommand(id),
{ icon: await buildContainerPopupIcon(container.image) },
);
if (!result.success) {
await writeSystemManagerDiagnostic('docker open shell failed', {
sessionId,
containerId: id,
containerName: container.name,
error: result.error,
});
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
}
}, [backend, parentSession, sessionId, t, isWindows]);
const openLogs = useCallback(async (container: DockerContainerInfo) => {
const id = container.id.slice(0, 12);
await writeSystemManagerDiagnostic('docker open logs clicked', {
sessionId,
containerId: id,
containerName: container.name,
image: container.image,
state: container.state,
});
const result = await openInteractiveTerminal(
backend,
parentSession,
`logs: ${container.name || id}`,
isWindows ? buildDockerLogsCommandWindows(id) : buildDockerLogsCommand(id),
{ icon: await buildContainerPopupIcon(container.image) },
);
if (!result.success) {
await writeSystemManagerDiagnostic('docker open logs failed', {
sessionId,
containerId: id,
containerName: container.name,
error: result.error,
});
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
}
}, [backend, parentSession, sessionId, t, isWindows]);
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="docker-containers">
<SystemPanelToolbar
trailing={(
<SystemPanelRefreshButton
title={t('history.action.refresh')}
loading={loading}
onClick={() => void refresh()}
/>
)}
>
<SystemPanelSearch
value={query}
onChange={setQuery}
placeholder={t('systemManager.docker.search')}
/>
</SystemPanelToolbar>
<SystemPanelSegmented
value={filter}
options={[
{ id: 'all', label: t('systemManager.docker.filter.all') },
{ id: 'running', label: t('systemManager.docker.filter.running') },
{ id: 'stopped', label: t('systemManager.docker.filter.stopped') },
{ id: 'paused', label: t('systemManager.docker.filter.paused') },
]}
onChange={setFilter}
/>
<SystemPanelMetaBar>
{t('systemManager.docker.meta', { count: String(displayList.length) })}
</SystemPanelMetaBar>
<SystemPanelList>
{error && (
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
)}
{!error && displayList.length === 0 && loading && (
<SystemPanelLoading message={t('systemManager.common.loading')} />
)}
{!error && displayList.length === 0 && !loading && (
<SystemPanelEmpty icon={Box} message={t('systemManager.docker.empty')} />
)}
{displayList.map((container) => {
const selected = selectedId === container.id;
const rowPending = pendingAction && pendingAction.id === container.id.slice(0, 12)
? pendingAction.action
: null;
const selectedInspectKey = selectedContainer ? getContainerInspectKey(selectedContainer) : null;
const selectedInspectRecord = selectedInspectKey ? inspectByContainerId[selectedInspectKey] : undefined;
return (
<React.Fragment key={container.id}>
<DockerContainerRow
container={container}
selected={selected}
pendingAction={rowPending}
onSelectContainer={selectContainer}
onShellContainer={openShell}
onLogsContainer={openLogs}
onContainerAction={handleRowAction}
/>
<SystemPanelCollapsible open={selected && !!selectedContainer}>
{selectedContainer && (
<DockerContainerDetail
container={selectedContainer}
inspect={selectedInspectRecord?.data ?? null}
inspectError={selectedInspectRecord?.error ?? null}
inspectLoading={selectedInspectRecord?.loading ?? false}
stat={statsByContainerId.get(selectedContainer.id) ?? statsByContainerId.get(selectedContainer.id.slice(0, 12)) ?? null}
statsLoading={statsLoading}
pendingAction={rowPending}
onCloseInspect={() => { setSelectedId(null); }}
onRunAction={runAction}
/>
)}
</SystemPanelCollapsible>
</React.Fragment>
);
})}
</SystemPanelList>
<SystemPanelConfirmDialog
open={confirmAction !== null}
title={confirmAction?.action === 'kill'
? t('systemManager.docker.kill')
: t('action.remove')}
message={confirmAction?.action === 'kill'
? t('systemManager.docker.confirmKill')
: t('systemManager.docker.confirmRemove')}
confirmLabel={confirmAction?.action === 'kill'
? t('systemManager.docker.kill')
: t('action.remove')}
destructive
busy={pendingAction !== null}
onOpenChange={(open) => { if (!open) setConfirmAction(null); }}
onConfirm={() => {
const target = confirmAction;
setConfirmAction(null);
if (!target) return;
void runAction(target.containerId, target.action, undefined, { skipConfirm: true });
}}
/>
</div>
);
});

View File

@@ -0,0 +1,78 @@
import React, { memo, useEffect, useRef, useState } from 'react';
import { cn } from '../../lib/utils';
const FALLBACK_ICON_URL = '/docker-icons/docker.svg';
const FALLBACK_TILE_BG = '#2496ED';
interface DockerImageIconProps {
image: string;
size?: number;
className?: string;
}
export const DockerImageIcon = memo(function DockerImageIcon({
image,
size = 24,
className,
}: DockerImageIconProps) {
const [iconUrl, setIconUrl] = useState(FALLBACK_ICON_URL);
const [tileBackground, setTileBackground] = useState(FALLBACK_TILE_BG);
const [imgFailed, setImgFailed] = useState(false);
const prevKeyRef = useRef('');
const resetKey = `${image}`;
useEffect(() => {
if (prevKeyRef.current !== resetKey) {
prevKeyRef.current = resetKey;
setImgFailed(false);
setIconUrl(FALLBACK_ICON_URL);
setTileBackground(FALLBACK_TILE_BG);
}
}, [resetKey]);
useEffect(() => {
let cancelled = false;
void import('../../domain/systemManager/dockerImageIcons').then((mod) => {
if (cancelled) return;
const iconId = mod.resolveDockerImageIcon(image);
const presentation = mod.resolveDockerIconPresentation(iconId, {
imageFailed: imgFailed,
});
const tile = mod.dockerIconTileStyle(presentation.displayIconId);
setIconUrl(presentation.iconUrl);
setTileBackground(tile.background);
});
return () => {
cancelled = true;
};
}, [image, imgFailed]);
const pad = 6;
const box = size + pad * 2;
return (
<div
className={cn(
'flex shrink-0 items-center justify-center rounded-md',
className,
)}
style={{
width: box,
height: box,
padding: pad,
backgroundColor: tileBackground,
}}
>
<img
src={iconUrl}
alt=""
width={size}
height={size}
loading="lazy"
decoding="async"
className="rounded object-contain"
onError={() => setImgFailed(true)}
/>
</div>
);
});

View File

@@ -0,0 +1,415 @@
import { Layers, Loader2, Tag, Trash2 } 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 { dockerImageRowKey, type DockerImageInfo } from '../../domain/systemManager/types';
import { dockerImageInfoEqual } from '../../domain/systemManager/pollEquals';
import { DockerImageIcon } from './DockerImageIcon';
import { DockerInspectView } from './DockerInspectView';
import { mergePollListByKey, useStableListOrder } from './listStable';
import {
SystemPanelCollapsible,
SystemPanelEmpty,
SystemPanelError,
SystemPanelInlineError,
SystemPanelList,
SystemPanelLoading,
SystemPanelMetaBar,
SystemPanelRefreshButton,
SystemPanelRoundButton,
SystemPanelRow,
SystemPanelSearch,
SystemPanelToolbar,
} from './SystemPanelUi';
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
import { useAsyncRecordCache } from '../../application/state/systemManager/useAsyncRecordCache';
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
import { showSystemManagerError } from './systemManagerToast';
type Backend = ReturnType<typeof useSystemManagerBackend>;
type PendingImageConfirm =
| { kind: 'remove'; image: DockerImageInfo; label: string }
| { kind: 'prune'; all: boolean };
interface DockerImagesPanelProps {
sessionId: string;
isVisible: boolean;
warmupEnabled?: boolean;
backend: Backend;
listRefreshIntervalSec: number;
}
const DockerImageRow = memo(function DockerImageRow({
image,
displayName,
selected,
onSelect,
onTag,
onRemove,
}: {
image: DockerImageInfo;
displayName: string;
selected: boolean;
onSelect: (image: DockerImageInfo) => void;
onTag: (image: DockerImageInfo) => void;
onRemove: (image: DockerImageInfo) => void;
}) {
const { t } = useI18n();
const shortId = image.id.slice(0, 12);
return (
<SystemPanelRow
selected={selected}
onClick={() => onSelect(image)}
leading={<DockerImageIcon image={displayName} />}
title={displayName}
subtitle={`${shortId} · ${image.size}${image.createdAt ? ` · ${image.createdAt}` : ''}`}
trailing={(
<div className="flex shrink-0 items-center gap-1">
<SystemPanelRoundButton
title={t('systemManager.docker.tag')}
onClick={() => onTag(image)}
>
<Tag size={12} />
</SystemPanelRoundButton>
<SystemPanelRoundButton
title={t('systemManager.docker.confirmRemoveImage', { name: displayName })}
destructive
onClick={() => onRemove(image)}
>
<Trash2 size={12} />
</SystemPanelRoundButton>
</div>
)}
/>
);
});
export const DockerImagesPanel = memo(function DockerImagesPanel({
sessionId,
isVisible,
warmupEnabled = false,
backend,
listRefreshIntervalSec,
}: DockerImagesPanelProps) {
const { t } = useI18n();
const stableT = useStableTranslate();
const [query, setQuery] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [tagTarget, setTagTarget] = useState<DockerImageInfo | null>(null);
const [confirmTarget, setConfirmTarget] = useState<PendingImageConfirm | null>(null);
const [actionBusy, setActionBusy] = useState(false);
const actionGenerationRef = useRef(0);
useEffect(() => {
actionGenerationRef.current += 1;
setSelectedId(null);
setTagTarget(null);
setConfirmTarget(null);
// Clear busy so a hung/in-flight action from the previous session cannot
// leave the new session's confirm dialog permanently disabled.
setActionBusy(false);
}, [sessionId]);
const imagesFetcher = useCallback(async () => {
const result = await backend.listDockerImages(sessionId);
if (!result.success || !result.images) {
throw new Error(result.error || stableT('systemManager.errors.loadDockerImages'));
}
return result.images;
}, [backend, sessionId, stableT]);
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
const { data: images, error, loading, refresh } = usePolling<DockerImageInfo[]>(
imagesFetcher,
listIntervalMs,
isVisible || warmupEnabled,
(prev, next) => mergePollListByKey(prev, next, dockerImageRowKey, dockerImageInfoEqual),
{ poll: isVisible, resetKey: sessionId },
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const list = images ?? [];
if (!q) return list;
return list.filter((image) => {
const shortId = image.id.slice(0, 12);
return image.repository.toLowerCase().includes(q)
|| image.tag.toLowerCase().includes(q)
|| image.name.toLowerCase().includes(q)
|| shortId.toLowerCase().includes(q);
});
}, [images, query]);
const compareImages = useCallback(
(a: DockerImageInfo, b: DockerImageInfo) => {
const repo = a.repository.localeCompare(b.repository);
if (repo !== 0) return repo;
return a.tag.localeCompare(b.tag);
},
[],
);
const displayList = useStableListOrder(filtered, dockerImageRowKey, query, compareImages);
const getImageInspectKey = useCallback((image: DockerImageInfo) => (
`${sessionId}:${dockerImageRowKey(image)}`
), [sessionId]);
const fetchImageInspect = useCallback(async (image: DockerImageInfo) => {
const result = await backend.dockerImageInspect({
sessionId,
imageId: image.id.slice(0, 12),
});
if (!result.success) {
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
}
return result.inspect ?? null;
}, [backend, sessionId, stableT]);
const {
records: inspectByImageKey,
loadRecord: loadImageInspect,
invalidateRecord: invalidateImageInspect,
} = useAsyncRecordCache<DockerImageInfo, Record<string, unknown>>({
items: images ?? [],
enabled: isVisible && (images?.length ?? 0) > 0,
getKey: getImageInspectKey,
fetchRecord: fetchImageInspect,
prefetchLimit: 24,
prefetchDelayMs: 40,
staleTimeMs: 20_000,
});
const executeRemove = useCallback(async (image: DockerImageInfo) => {
const actionGeneration = actionGenerationRef.current;
setActionBusy(true);
try {
const result = await backend.dockerImageAction({
sessionId,
action: 'rm',
imageId: image.id.slice(0, 12),
force: image.tag === '<none>',
});
if (actionGenerationRef.current !== actionGeneration) return;
if (!result.success) {
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
return;
}
if (selectedId === dockerImageRowKey(image)) {
setSelectedId(null);
}
invalidateImageInspect(getImageInspectKey(image));
await refresh();
} finally {
if (actionGenerationRef.current === actionGeneration) {
setActionBusy(false);
}
}
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, selectedId, sessionId, t]);
const handleRemove = useCallback((image: DockerImageInfo) => {
const label = image.name || image.id.slice(0, 12);
setConfirmTarget({ kind: 'remove', image, label });
}, []);
const executePrune = useCallback(async (all: boolean) => {
const actionGeneration = actionGenerationRef.current;
setActionBusy(true);
try {
const result = await backend.dockerImageAction({ sessionId, action: 'prune', all });
if (actionGenerationRef.current !== actionGeneration) return;
if (!result.success) {
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
return;
}
await refresh();
} finally {
if (actionGenerationRef.current === actionGeneration) {
setActionBusy(false);
}
}
}, [backend, refresh, sessionId, t]);
const handlePrune = useCallback((all: boolean) => {
setConfirmTarget({ kind: 'prune', all });
}, []);
const handleTagSubmit = async (image: DockerImageInfo, repository: string, tag: string) => {
const result = await backend.dockerImageAction({
sessionId,
action: 'tag',
imageId: image.id.slice(0, 12),
repository,
tag: tag || 'latest',
});
if (!result.success) {
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
return;
}
await refresh();
};
const selectImage = useCallback((image: DockerImageInfo) => {
const rowKey = dockerImageRowKey(image);
const next = selectedId === rowKey ? null : rowKey;
setSelectedId(next);
if (!next) return;
void loadImageInspect(image, { force: true, urgent: true });
}, [loadImageInspect, selectedId]);
const openTagDialog = useCallback((image: DockerImageInfo) => {
setTagTarget(image);
}, []);
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="docker-images">
<SystemPanelToolbar
trailing={(
<>
<button
type="button"
onClick={() => handlePrune(false)}
className="shrink-0 h-7 px-2 rounded-md text-[10px] text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
{t('systemManager.docker.prune')}
</button>
<button
type="button"
onClick={() => handlePrune(true)}
className="shrink-0 h-7 px-2 rounded-md text-[10px] text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
{t('systemManager.docker.pruneAll')}
</button>
<SystemPanelRefreshButton
title={t('history.action.refresh')}
loading={loading}
onClick={() => void refresh()}
/>
</>
)}
>
<SystemPanelSearch
value={query}
onChange={setQuery}
placeholder={t('systemManager.docker.searchImages')}
/>
</SystemPanelToolbar>
<SystemPanelMetaBar>
{t('systemManager.docker.imagesMeta', { count: String(displayList.length) })}
</SystemPanelMetaBar>
<SystemPanelList>
{error && (
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
)}
{!error && displayList.length === 0 && loading && (
<SystemPanelLoading message={t('systemManager.common.loading')} />
)}
{!error && displayList.length === 0 && !loading && (
<SystemPanelEmpty icon={Layers} message={t('systemManager.docker.imagesEmpty')} />
)}
{displayList.map((image) => {
const rowKey = dockerImageRowKey(image);
const inspectKey = getImageInspectKey(image);
const shortId = image.id.slice(0, 12);
const displayName = image.repository && image.tag
? `${image.repository}:${image.tag}`
: image.name || shortId;
const selected = selectedId === rowKey;
return (
<React.Fragment key={rowKey}>
<DockerImageRow
image={image}
displayName={displayName}
selected={selected}
onSelect={selectImage}
onTag={openTagDialog}
onRemove={handleRemove}
/>
<SystemPanelCollapsible open={selected}>
{inspectByImageKey[inspectKey]?.loading && !inspectByImageKey[inspectKey]?.data && (
<div className="flex items-center gap-1.5 border-b border-border/40 bg-muted/20 px-3 py-2 text-[10px] text-muted-foreground">
<Loader2 size={11} className="animate-spin" />
{t('systemManager.common.loadingDetails')}
</div>
)}
{inspectByImageKey[inspectKey]?.error && !inspectByImageKey[inspectKey]?.data && (
<SystemPanelInlineError message={inspectByImageKey[inspectKey].error} />
)}
{inspectByImageKey[inspectKey]?.data && (
<DockerInspectView
kind="image"
data={inspectByImageKey[inspectKey].data}
onClose={() => { setSelectedId(null); }}
/>
)}
</SystemPanelCollapsible>
</React.Fragment>
);
})}
</SystemPanelList>
<SystemPanelPromptDialog
open={tagTarget !== null}
title={t('systemManager.docker.tag')}
fields={[
{
id: 'repository',
label: t('systemManager.docker.tagRepoPrompt'),
initialValue: tagTarget?.repository === '<none>' ? '' : tagTarget?.repository ?? '',
mono: true,
},
{
id: 'tag',
label: t('systemManager.docker.tagNamePrompt'),
initialValue: !tagTarget?.tag || tagTarget.tag === '<none>' ? 'latest' : tagTarget.tag,
mono: true,
},
]}
confirmLabel={t('systemManager.docker.tag')}
onOpenChange={(open) => { if (!open) setTagTarget(null); }}
onSubmit={(values) => {
const image = tagTarget;
setTagTarget(null);
if (!image) return;
void handleTagSubmit(image, values.repository, values.tag);
}}
/>
<SystemPanelConfirmDialog
open={confirmTarget !== null}
title={confirmTarget?.kind === 'prune'
? (confirmTarget.all ? t('systemManager.docker.pruneAll') : t('systemManager.docker.prune'))
: t('action.remove')}
message={confirmTarget?.kind === 'prune'
? (confirmTarget.all
? t('systemManager.docker.confirmPruneAll')
: t('systemManager.docker.confirmPrune'))
: t('systemManager.docker.confirmRemoveImage', {
name: confirmTarget?.kind === 'remove' ? confirmTarget.label : '',
})}
confirmLabel={confirmTarget?.kind === 'prune'
? (confirmTarget.all ? t('systemManager.docker.pruneAll') : t('systemManager.docker.prune'))
: t('action.remove')}
destructive
busy={actionBusy}
onOpenChange={(open) => {
if (!open && !actionBusy) setConfirmTarget(null);
}}
onConfirm={() => {
const target = confirmTarget;
setConfirmTarget(null);
if (!target) return;
if (target.kind === 'remove') {
void executeRemove(target.image);
return;
}
void executePrune(target.all);
}}
/>
</div>
);
});

View File

@@ -0,0 +1,120 @@
import React, { memo, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
buildContainerInspectView,
buildImageInspectView,
} from '../../domain/systemManager/inspectView';
import { cn } from '../../lib/utils';
function InspectRow({ label, value, mono }: { label: string; value?: string; mono?: boolean }) {
if (!value) return null;
return (
<div className="flex gap-2 text-[10px] leading-relaxed">
<span className="w-16 shrink-0 text-muted-foreground">{label}</span>
<span className={cn('flex-1 min-w-0 break-all text-foreground/90', mono && 'font-mono')}>
{value}
</span>
</div>
);
}
function InspectList({ label, items }: { label: string; items: string[] }) {
if (items.length === 0) return null;
return (
<div className="text-[10px] leading-relaxed">
<div className="text-muted-foreground mb-0.5">{label}</div>
<div className="space-y-0.5 font-mono">
{items.map((item, index) => (
<div key={index} className="break-all text-foreground/90">{item}</div>
))}
</div>
</div>
);
}
interface DockerInspectViewProps {
kind: 'container' | 'image';
data: Record<string, unknown>;
onClose: () => void;
}
/** Structured rendering of docker inspect output, with a raw-JSON fallback toggle. */
export const DockerInspectView = memo(function DockerInspectView({
kind,
data,
onClose,
}: DockerInspectViewProps) {
const { t } = useI18n();
const [showRaw, setShowRaw] = useState(false);
const container = useMemo(
() => (kind === 'container' ? buildContainerInspectView(data) : null),
[kind, data],
);
const image = useMemo(
() => (kind === 'image' ? buildImageInspectView(data) : null),
[kind, data],
);
return (
<div className="border-b border-border/40 bg-muted/20 px-3 py-2" data-section="docker-inspect">
<div className="flex items-center justify-between gap-2 mb-2">
<span className="text-[11px] font-medium">
{kind === 'container' ? t('systemManager.docker.inspect') : t('systemManager.docker.imageInspect')}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowRaw((v) => !v)}
className="text-[10px] text-muted-foreground hover:text-foreground"
>
{showRaw ? t('systemManager.inspect.hideRaw') : t('systemManager.inspect.showRaw')}
</button>
<button
type="button"
onClick={onClose}
className="text-[10px] text-muted-foreground hover:text-foreground"
>
{t('systemManager.common.dismiss')}
</button>
</div>
</div>
{showRaw ? (
<pre className="font-mono text-[10px] text-muted-foreground overflow-auto max-h-48 whitespace-pre-wrap break-all leading-relaxed">
{JSON.stringify(data, null, 2)}
</pre>
) : container ? (
<div className="space-y-1.5">
<InspectRow label="ID" value={container.id} mono />
<InspectRow label={t('systemManager.inspect.status')} value={container.status} />
<InspectRow label={t('systemManager.inspect.image')} value={container.image} mono />
<InspectRow label={t('systemManager.inspect.created')} value={container.createdAt} />
<InspectRow label={t('systemManager.inspect.started')} value={container.startedAt} />
<InspectRow label={t('systemManager.inspect.restartPolicy')} value={container.restartPolicy} />
<InspectRow label={t('systemManager.inspect.command')} value={container.command} mono />
<InspectList label={t('systemManager.inspect.ports')} items={container.ports} />
<InspectList label={t('systemManager.inspect.networks')} items={container.networks} />
<InspectList label={t('systemManager.inspect.mounts')} items={container.mounts} />
<InspectList label={t('systemManager.inspect.env')} items={container.env} />
<InspectList label={t('systemManager.inspect.labels')} items={container.labels} />
</div>
) : image ? (
<div className="space-y-1.5">
<InspectRow label="ID" value={image.id} mono />
<InspectRow label={t('systemManager.inspect.size')} value={image.size} />
<InspectRow label={t('systemManager.inspect.platform')} value={image.platform} mono />
<InspectRow label={t('systemManager.inspect.created')} value={image.createdAt} />
<InspectRow label="Entrypoint" value={image.entrypoint} mono />
<InspectRow label="CMD" value={image.cmd} mono />
<InspectRow label={t('systemManager.inspect.workdir')} value={image.workdir} mono />
<InspectList label={t('systemManager.inspect.tags')} items={image.tags} />
<InspectList label={t('systemManager.inspect.digests')} items={image.digests} />
<InspectList label={t('systemManager.inspect.exposedPorts')} items={image.exposedPorts} />
<InspectList label={t('systemManager.inspect.env')} items={image.env} />
<InspectList label={t('systemManager.inspect.labels')} items={image.labels} />
</div>
) : null}
</div>
);
});

View File

@@ -0,0 +1,89 @@
import { Box, Layers } from 'lucide-react';
import React, { memo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import type { TerminalSession } from '../../types';
import { cn } from '../../lib/utils';
import { DockerContainersPanel } from './DockerContainersPanel';
import { DockerImagesPanel } from './DockerImagesPanel';
import { SystemPanelShell } from './SystemPanelUi';
type Backend = ReturnType<typeof useSystemManagerBackend>;
type DockerSubTab = 'containers' | 'images';
interface DockerManagerTabProps {
sessionId: string;
parentSession: TerminalSession;
isVisible: boolean;
warmupEnabled?: boolean;
backend: Backend;
listRefreshIntervalSec: number;
statsRefreshIntervalSec: number;
targetOs?: 'linux' | 'darwin' | 'win32' | 'unknown';
}
export const DockerManagerTab = memo(function DockerManagerTab({
sessionId,
parentSession,
isVisible,
warmupEnabled = false,
backend,
listRefreshIntervalSec,
statsRefreshIntervalSec,
targetOs = 'unknown',
}: DockerManagerTabProps) {
const { t } = useI18n();
const [subTab, setSubTab] = useState<DockerSubTab>('containers');
const tabs: { id: DockerSubTab; icon: typeof Box; label: string }[] = [
{ id: 'containers', icon: Box, label: t('systemManager.docker.subTabs.containers') },
{ id: 'images', icon: Layers, label: t('systemManager.docker.subTabs.images') },
];
return (
<SystemPanelShell section="system-manager-docker">
<div className="shrink-0 flex items-center gap-0.5 px-2 py-1 border-b border-border/30">
{tabs.map(({ id, icon: Icon, label }) => (
<button
key={id}
type="button"
className={cn(
'flex items-center gap-1.5 px-2.5 py-0.5 rounded-md text-[11px] transition-all duration-200',
subTab === id
? 'bg-primary/15 text-primary font-medium shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
)}
onClick={() => setSubTab(id)}
>
<Icon size={12} />
{label}
</button>
))}
</div>
<div className="flex-1 min-h-0 flex flex-col">
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
<DockerContainersPanel
sessionId={sessionId}
parentSession={parentSession}
isVisible={isVisible && subTab === 'containers'}
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
backend={backend}
listRefreshIntervalSec={listRefreshIntervalSec}
statsRefreshIntervalSec={statsRefreshIntervalSec}
targetOs={targetOs}
/>
</div>
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'images' && 'hidden')}>
<DockerImagesPanel
sessionId={sessionId}
isVisible={isVisible && subTab === 'images'}
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'images')}
backend={backend}
listRefreshIntervalSec={listRefreshIntervalSec}
/>
</div>
</div>
</SystemPanelShell>
);
});

View File

@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
test("gpu tab keeps loading while accelerator query is pending", () => {
const source = readFileSync(new URL("./GpuManagerTab.tsx", import.meta.url), "utf8");
assert.match(source, /setGpuListPending\(true\)/);
assert.match(source, /if \(result\.pending\)/);
assert.match(source, /isRefreshActive = loading \|\| gpuListPending/);
assert.match(source, /if \(!data\)/);
assert.doesNotMatch(
source,
/if \(result\.pending\) return null;\s*\n\s*if \(!result\.success\)/,
);
});
test("gpu tab still renders compute processes when device list is empty", () => {
const source = readFileSync(new URL("./GpuManagerTab.tsx", import.meta.url), "utf8");
assert.match(source, /if \(!devices\.length && !processes\.length\)/);
assert.match(source, /processes\.map\(\(process\) =>/);
});
test("gpu tab passes null utilization to ResourceBar instead of zero", () => {
const source = readFileSync(new URL("./GpuManagerTab.tsx", import.meta.url), "utf8");
assert.match(source, /value=\{util\}/);
assert.match(source, /value=\{memPct\}/);
assert.doesNotMatch(source, /value=\{util \?\? 0\}/);
assert.doesNotMatch(source, /value=\{memPct \?\? 0\}/);
});
test("gpu tab keeps a compact sparkline history like overview/nvtop charts", () => {
const source = readFileSync(new URL("./GpuManagerTab.tsx", import.meta.url), "utf8");
assert.match(source, /HISTORY_LIMIT/);
assert.match(source, /GpuSparkline/);
assert.match(source, /historyByDevice/);
assert.match(source, /setHistoryByDevice/);
// Sparklines sit under the resource bars, not in a side column.
assert.doesNotMatch(source, /grid-cols-\[minmax\(0,1fr\)_72px\]/);
assert.match(source, /grid grid-cols-2 gap-x-3/);
});
test("gpu device card uses vendor vector badges", () => {
const source = readFileSync(new URL("./GpuManagerTab.tsx", import.meta.url), "utf8");
const badgeSource = readFileSync(new URL("./GpuVendorBadge.tsx", import.meta.url), "utf8");
assert.match(source, /GpuVendorBadge/);
assert.match(source, /vendor=\{device\.vendor\}/);
// Process rows share the same display helper (no deleted vendorLabel).
assert.match(source, /vendorDisplayLabel\(process\.vendor/);
assert.doesNotMatch(source, /vendorLabel\(/);
assert.match(badgeSource, /NVIDIA_PATH/);
assert.match(badgeSource, /HUAWEI_PATH/);
assert.match(badgeSource, /vendor === 'nvidia'/);
assert.match(badgeSource, /vendor === 'ascend'/);
});
test("resource bar animates width and uses load-aware tones", () => {
const source = readFileSync(new URL("./ResourceBar.tsx", import.meta.url), "utf8");
assert.match(source, /transition-\[width,background-color\]/);
assert.match(source, /bg-amber-500/);
assert.match(source, /bg-destructive/);
});

View File

@@ -0,0 +1,394 @@
import { CircuitBoard, Cpu, Thermometer, Zap } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import type {
AcceleratorDeviceInfo,
AcceleratorProcessInfo,
AcceleratorSnapshot,
} from '../../domain/systemManager/types';
import { cn } from '../../lib/utils';
import { ResourceBar } from './ResourceBar';
import { GpuVendorBadge, vendorDisplayLabel } from './GpuVendorBadge';
import {
SystemPanelEmpty,
SystemPanelError,
SystemPanelInlineError,
SystemPanelList,
SystemPanelLoading,
SystemPanelRefreshButton,
SystemPanelRow,
SystemPanelShell,
SystemPanelStatusBadge,
SystemPanelToolbar,
} from './SystemPanelUi';
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
type Backend = ReturnType<typeof useSystemManagerBackend>;
interface GpuManagerTabProps {
sessionId: string;
isVisible: boolean;
backend: Backend;
refreshIntervalSec: number;
}
const HISTORY_LIMIT = 24;
interface DeviceHistorySample {
util: number;
memory: number;
}
function formatMb(mb: number | null | undefined): string {
if (!Number.isFinite(mb)) return '--';
const value = Number(mb);
if (value >= 1024) return `${(value / 1024).toFixed(1)} GB`;
return `${Math.round(value)} MB`;
}
function memoryPercent(device: AcceleratorDeviceInfo): number | null {
if (!Number.isFinite(device.memoryUsedMb) || !Number.isFinite(device.memoryTotalMb)) return null;
if (!device.memoryTotalMb || device.memoryTotalMb <= 0) return null;
return Math.max(0, Math.min(100, (Number(device.memoryUsedMb) / Number(device.memoryTotalMb)) * 100));
}
function deviceKey(device: AcceleratorDeviceInfo): string {
return `${device.vendor}-${device.index}-${device.uuid || device.name}`;
}
/** Compact nvtop-style sparkline (area + stroke), reusing the overview chart feel. */
function GpuSparkline({
values,
className,
}: {
values: number[];
className?: string;
}) {
const width = 120;
const height = 28;
const safeValues = values.length > 1 ? values : [values[0] ?? 0, values[0] ?? 0];
const points = safeValues.map((value, index) => {
const x = safeValues.length === 1 ? width : (index / (safeValues.length - 1)) * width;
const clamped = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
const y = height - (clamped / 100) * (height - 4) - 2;
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const area = `M0,${height} L${points.join(' L')} L${width},${height} Z`;
return (
<svg
className={cn('h-7 w-full overflow-visible', className)}
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-hidden
>
<path d={area} fill="currentColor" opacity="0.12" />
<polyline
points={points.join(' ')}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="transition-opacity duration-300"
/>
</svg>
);
}
const DeviceCard = memo(function DeviceCard({
device,
utilHistory,
memHistory,
}: {
device: AcceleratorDeviceInfo;
utilHistory: number[];
memHistory: number[];
}) {
const { t } = useI18n();
const memPct = memoryPercent(device);
const util = Number.isFinite(device.utilizationPercent) ? Number(device.utilizationPercent) : null;
const tone = device.vendor === 'ascend' ? 'text-orange-500' : 'text-emerald-500';
return (
<div className="border-b border-border/30 px-3 py-2.5 space-y-2">
<div className="flex items-start justify-between gap-2 min-w-0">
<div className="min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-xs font-medium truncate">
[{device.index}] {device.name}
</span>
<GpuVendorBadge vendor={device.vendor} />
{device.health ? (
<SystemPanelStatusBadge tone={/ok|healthy|good/i.test(device.health) ? 'success' : 'warning'}>
{device.health}
</SystemPanelStatusBadge>
) : null}
</div>
{device.driverVersion ? (
<div className="text-[10px] text-muted-foreground mt-0.5">
{t('systemManager.gpu.driver', { version: device.driverVersion })}
</div>
) : null}
</div>
<div className="flex items-center gap-2 shrink-0 text-[10px] text-muted-foreground tabular-nums">
{Number.isFinite(device.temperatureC) ? (
<span className="inline-flex items-center gap-0.5" title={t('systemManager.gpu.temperature')}>
<Thermometer size={10} />
{Math.round(Number(device.temperatureC))}°C
</span>
) : null}
{Number.isFinite(device.powerDrawW) ? (
<span className="inline-flex items-center gap-0.5" title={t('systemManager.gpu.power')}>
<Zap size={10} />
{Number(device.powerDrawW).toFixed(0)}
{Number.isFinite(device.powerLimitW) ? `/${Math.round(Number(device.powerLimitW))}` : ''}
W
</span>
) : null}
</div>
</div>
<div className="min-w-0 space-y-1.5">
<ResourceBar
label={t('systemManager.gpu.util')}
value={util}
size="md"
/>
<div className="flex items-center gap-2 min-w-0">
<ResourceBar
label={device.vendor === 'ascend' ? t('systemManager.gpu.hbm') : t('systemManager.gpu.memory')}
value={memPct}
className="flex-1"
size="md"
/>
<span className="text-[10px] tabular-nums text-muted-foreground shrink-0">
{formatMb(device.memoryUsedMb)} / {formatMb(device.memoryTotalMb)}
</span>
</div>
</div>
{/* History sparklines sit under the bars so they do not compete for row width. */}
<div className={cn('grid grid-cols-2 gap-x-3 gap-y-1', tone)}>
<div className="min-w-0">
<div className="mb-0.5 text-[10px] text-muted-foreground">
{t('systemManager.gpu.util')}
</div>
<GpuSparkline values={utilHistory.length ? utilHistory : [util ?? 0]} />
</div>
<div className="min-w-0">
<div className="mb-0.5 text-[10px] text-muted-foreground">
{device.vendor === 'ascend' ? t('systemManager.gpu.hbm') : t('systemManager.gpu.memory')}
</div>
<GpuSparkline
values={memHistory.length ? memHistory : [memPct ?? 0]}
className="opacity-80"
/>
</div>
</div>
{Number.isFinite(device.fanPercent) ? (
<div className="text-[10px] text-muted-foreground">
{t('systemManager.gpu.fan', { value: Math.round(Number(device.fanPercent)) })}
</div>
) : null}
</div>
);
});
const ProcessRow = memo(function ProcessRow({
process,
}: {
process: AcceleratorProcessInfo;
}) {
const { t } = useI18n();
return (
<SystemPanelRow
title={process.processName || '—'}
subtitle={`${vendorDisplayLabel(process.vendor, t)} #${process.gpuIndex}`}
trailing={(
<div className="flex items-center gap-2 text-[10px] text-muted-foreground tabular-nums">
<span>PID {process.pid}</span>
<span>{formatMb(process.memoryUsedMb)}</span>
</div>
)}
/>
);
});
export const GpuManagerTab = memo(function GpuManagerTab({
sessionId,
isVisible,
backend,
refreshIntervalSec,
}: GpuManagerTabProps) {
const { t } = useI18n();
const stableT = useStableTranslate();
const intervalMs = Math.max(2, refreshIntervalSec) * 1000;
const [gpuListPending, setGpuListPending] = useState(false);
const [historyByDevice, setHistoryByDevice] = useState<Record<string, DeviceHistorySample[]>>({});
useEffect(() => {
setGpuListPending(false);
setHistoryByDevice({});
}, [sessionId]);
const fetcher = useCallback(async (): Promise<AcceleratorSnapshot | null> => {
const result = await backend.listAccelerators(sessionId);
if (result.pending) {
setGpuListPending(true);
return null;
}
setGpuListPending(false);
if (!result.success) {
throw new Error(result.error || stableT('systemManager.errors.loadGpu'));
}
return {
devices: result.devices || [],
processes: result.processes || [],
nvidiaDriverVersion: result.nvidiaDriverVersion ?? null,
probedAt: result.probedAt || Date.now(),
};
}, [backend, sessionId, stableT]);
const { data, error, loading, refresh } = usePolling(
fetcher,
intervalMs,
isVisible,
undefined,
{ resetKey: sessionId },
);
const devices = useMemo(() => data?.devices ?? [], [data?.devices]);
const processes = useMemo(() => data?.processes ?? [], [data?.processes]);
const isRefreshActive = loading || gpuListPending;
useEffect(() => {
if (!isVisible || !devices.length) return;
setHistoryByDevice((prev) => {
const next: Record<string, DeviceHistorySample[]> = { ...prev };
for (const device of devices) {
const key = deviceKey(device);
const sample: DeviceHistorySample = {
util: Number.isFinite(device.utilizationPercent) ? Number(device.utilizationPercent) : 0,
memory: memoryPercent(device) ?? 0,
};
const series = [...(next[key] || []), sample].slice(-HISTORY_LIMIT);
next[key] = series;
}
return next;
});
}, [devices, isVisible, data?.probedAt]);
const meta = useMemo(() => {
const nvidiaCount = devices.filter((d) => d.vendor === 'nvidia').length;
const ascendCount = devices.filter((d) => d.vendor === 'ascend').length;
return t('systemManager.gpu.meta', {
devices: devices.length,
processes: processes.length,
nvidia: nvidiaCount,
ascend: ascendCount,
});
}, [devices, processes, t]);
if (!isVisible && !data) {
return null;
}
if (!data) {
if (error && !isRefreshActive) {
return (
<SystemPanelShell section="system-manager-gpu">
<SystemPanelError
message={error}
retryLabel={t('history.action.refresh')}
onRetry={() => void refresh()}
/>
</SystemPanelShell>
);
}
return (
<SystemPanelShell section="system-manager-gpu">
<SystemPanelLoading message={t('systemManager.gpu.loading')} />
</SystemPanelShell>
);
}
if (!devices.length && !processes.length) {
return (
<SystemPanelShell section="system-manager-gpu">
<SystemPanelToolbar trailing={(
<SystemPanelRefreshButton
title={t('history.action.refresh')}
loading={isRefreshActive}
onClick={() => void refresh()}
/>
)}>
<span className="text-[11px] text-muted-foreground truncate">{meta}</span>
</SystemPanelToolbar>
<SystemPanelEmpty icon={CircuitBoard} message={t('systemManager.gpu.empty')} />
</SystemPanelShell>
);
}
return (
<SystemPanelShell section="system-manager-gpu">
<SystemPanelToolbar trailing={(
<SystemPanelRefreshButton
title={t('history.action.refresh')}
loading={isRefreshActive}
onClick={() => void refresh()}
/>
)}>
<span className="text-[11px] text-muted-foreground truncate">{meta}</span>
</SystemPanelToolbar>
{error && !isRefreshActive ? (
<SystemPanelInlineError
message={error}
retryLabel={t('history.action.refresh')}
onRetry={() => void refresh()}
/>
) : null}
<SystemPanelList>
<div className="px-3 py-1.5 text-[10px] uppercase tracking-wide text-muted-foreground flex items-center gap-1">
<Cpu size={10} />
{t('systemManager.gpu.devices')}
</div>
{devices.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground">
{t('systemManager.gpu.empty')}
</div>
) : (
devices.map((device) => {
const key = deviceKey(device);
const series = historyByDevice[key] || [];
return (
<DeviceCard
key={key}
device={device}
utilHistory={series.map((sample) => sample.util)}
memHistory={series.map((sample) => sample.memory)}
/>
);
})
)}
<div className="px-3 pt-3 pb-1.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{t('systemManager.gpu.processes')}
</div>
{processes.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground">
{t('systemManager.gpu.noProcesses')}
</div>
) : (
processes.map((process) => (
<ProcessRow
key={`${process.vendor}-${process.gpuIndex}-${process.pid}-${process.processName}`}
process={process}
/>
))
)}
</SystemPanelList>
</SystemPanelShell>
);
});

View File

@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
test('gpu vendor badge embeds monochrome vector marks for nvidia and ascend', () => {
const source = readFileSync(new URL('./GpuVendorBadge.tsx', import.meta.url), 'utf8');
assert.match(source, /viewBox="0 0 24 24"/);
assert.match(source, /fill-current/);
assert.match(source, /NVIDIA_PATH/);
assert.match(source, /HUAWEI_PATH/);
assert.match(source, /GpuVendorBadge/);
assert.match(source, /vendorDisplayLabel/);
// Mark is decorative; text label is the accessible name (no double announce).
assert.match(source, /aria-hidden="true"/);
assert.doesNotMatch(source, /role="img"/);
});

View File

@@ -0,0 +1,77 @@
import React, { memo } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { AcceleratorVendor } from '../../domain/systemManager/types';
import { cn } from '../../lib/utils';
/** Simple Icons NVIDIA path (CC0-1.0) — monochrome, uses currentColor. */
const NVIDIA_PATH =
'M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z';
/** Huawei petal mark for Ascend NPU (decorative; text label carries the name). */
const HUAWEI_PATH =
'M3.67 6.14S1.82 7.91 1.72 9.78v.35c.08 1.51 1.22 2.4 1.22 2.4 1.83 1.79 6.26 4.04 7.3 4.55 0 0 .06.03.1-.01l.02-.04v-.04C7.52 10.8 3.67 6.14 3.67 6.14zM9.65 18.6c-.02-.08-.1-.08-.1-.08l-7.38.26c.8 1.43 2.15 2.53 3.56 2.2.96-.25 3.16-1.78 3.88-2.3.06-.05.04-.09.04-.09zm.08-.78C6.49 15.63.21 12.28.21 12.28c-.15.46-.2.9-.21 1.3v.07c0 1.07.4 1.82.4 1.82.8 1.69 2.34 2.2 2.34 2.2.7.3 1.4.31 1.4.31.12.02 4.4 0 5.54 0 .05 0 .08-.05.08-.05v-.06c0-.03-.03-.05-.03-.05zM9.06 3.19a3.42 3.42 0 00-2.57 3.15v.41c.03.6.16 1.05.16 1.05.66 2.9 3.86 7.65 4.55 8.65.05.05.1.03.1.03a.1.1 0 00.06-.1c1.06-10.6-1.11-13.42-1.11-13.42-.32.02-1.19.23-1.19.23zm8.299 2.27s-.49-1.8-2.44-2.28c0 0-.57-.14-1.17-.22 0 0-2.18 2.81-1.12 13.43.01.07.06.08.06.08.07.03.1-.03.1-.03.72-1.03 3.9-5.76 4.55-8.64 0 0 .36-1.4.02-2.34zm-2.92 13.07s-.07 0-.09.05c0 0-.01.07.03.1.7.51 2.85 2 3.88 2.3 0 0 .16.05.43.06h.14c.69-.02 1.9-.37 3-2.26l-7.4-.25zm7.83-8.41c.14-2.06-1.94-3.97-1.94-3.98 0 0-3.85 4.66-6.67 10.8 0 0-.03.08.02.13l.04.01h.06c1.06-.53 5.46-2.77 7.28-4.54 0 0 1.15-.93 1.21-2.42zm1.52 2.14s-6.28 3.37-9.52 5.55c0 0-.05.04-.03.11 0 0 .03.06.07.06 1.16 0 5.56 0 5.67-.02 0 0 .57-.02 1.27-.29 0 0 1.56-.5 2.37-2.27 0 0 .73-1.45.17-3.14z';
function VendorMark({
path,
className,
}: {
path: string;
className?: string;
}) {
// Decorative: visible text label next to the mark is the accessible name.
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
className={cn('h-3 w-3 shrink-0 fill-current', className)}
>
<path d={path} />
</svg>
);
}
export function vendorDisplayLabel(
vendor: AcceleratorVendor,
t: ReturnType<typeof useI18n>['t'],
): string {
return vendor === 'nvidia'
? t('systemManager.gpu.vendor.nvidia')
: t('systemManager.gpu.vendor.ascend');
}
/**
* Vendor chip with brand vector mark (NVIDIA / Huawei Ascend).
* Falls back to text for unknown vendors.
*/
export const GpuVendorBadge = memo(function GpuVendorBadge({
vendor,
className,
}: {
vendor: AcceleratorVendor;
className?: string;
}) {
const { t } = useI18n();
const label = vendorDisplayLabel(vendor, t);
const mark = vendor === 'nvidia'
? <VendorMark path={NVIDIA_PATH} />
: vendor === 'ascend'
? <VendorMark path={HUAWEI_PATH} />
: null;
return (
<span
title={label}
className={cn(
'inline-flex h-6 shrink-0 items-center justify-center gap-1 rounded-full px-2 text-[10px] font-medium',
// Match SystemPanelStatusBadge muted tone so logos stay legible.
'bg-slate-500 text-white dark:bg-slate-400 dark:text-slate-950',
className,
)}
>
{mark}
<span className="max-w-[4.5rem] truncate leading-none">{label}</span>
</span>
);
});

View File

@@ -0,0 +1,242 @@
import { Network, Skull } 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 { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
import { listeningPortInfoEqual } from '../../domain/systemManager/pollEquals';
import type { ListeningPortInfo } from '../../domain/systemManager/types';
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
import { mergePollListByKey, useStableListOrder } from './listStable';
import {
SystemPanelEmpty,
SystemPanelError,
SystemPanelInlineError,
SystemPanelList,
SystemPanelLoading,
SystemPanelMetaBar,
SystemPanelRefreshButton,
SystemPanelRoundButton,
SystemPanelRow,
SystemPanelSearch,
SystemPanelSegmented,
SystemPanelShell,
SystemPanelStatusBadge,
SystemPanelToolbar,
} from './SystemPanelUi';
type Backend = ReturnType<typeof useSystemManagerBackend>;
type PortFilter = 'all' | 'tcp' | 'udp';
const mergePorts = (
prev: ListeningPortInfo[] | null,
next: ListeningPortInfo[],
) => mergePollListByKey(prev, next, (p) => p.id, listeningPortInfoEqual);
interface PortsManagerTabProps {
sessionId: string;
isVisible: boolean;
backend: Backend;
refreshIntervalSec: number;
/** Network appliances: list only, no process terminate. */
allowMutations?: boolean;
}
export const PortsManagerTab = memo(function PortsManagerTab({
sessionId,
isVisible,
backend,
refreshIntervalSec,
allowMutations = true,
}: PortsManagerTabProps) {
const { t } = useI18n();
const stableT = useStableTranslate();
const intervalMs = Math.max(2, refreshIntervalSec) * 1000;
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<PortFilter>('all');
const [pendingKillPid, setPendingKillPid] = useState<number | null>(null);
const [killBusy, setKillBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [listPending, setListPending] = useState(false);
const sessionIdRef = useRef(sessionId);
sessionIdRef.current = sessionId;
useEffect(() => {
setListPending(false);
setPendingKillPid(null);
setKillBusy(false);
setActionError(null);
}, [sessionId]);
const fetcher = useCallback(async (): Promise<ListeningPortInfo[] | null> => {
const requestedSessionId = sessionId;
try {
const result = await backend.listListeningPorts(requestedSessionId);
if (sessionIdRef.current !== requestedSessionId) return null;
if (result.pending) {
setListPending(true);
return null;
}
setListPending(false);
if (!result.success) {
throw new Error(result.error || stableT('systemManager.errors.loadPorts'));
}
return result.ports || [];
} catch (error) {
if (sessionIdRef.current === requestedSessionId) setListPending(false);
throw error;
}
}, [backend, sessionId, stableT]);
const { data, error, loading, refresh } = usePolling(
fetcher,
intervalMs,
isVisible,
mergePorts,
{ resetKey: sessionId },
);
const isRefreshActive = loading || listPending;
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return (data || []).filter((port) => {
if (filter === 'tcp' && !port.protocol.startsWith('tcp')) return false;
if (filter === 'udp' && !port.protocol.startsWith('udp')) return false;
if (!q) return true;
return (
String(port.port).includes(q)
|| port.address.toLowerCase().includes(q)
|| port.processName.toLowerCase().includes(q)
|| (port.pid != null && String(port.pid).includes(q))
|| port.protocol.toLowerCase().includes(q)
);
});
}, [data, filter, query]);
const ports = useStableListOrder(
filtered,
(p) => p.id,
`${filter}|${query}`,
(a, b) => a.port - b.port || a.protocol.localeCompare(b.protocol),
);
const executeTerminate = useCallback(async (pid: number) => {
const requestedSessionId = sessionId;
setKillBusy(true);
setActionError(null);
try {
const result = await backend.signalSystemProcess({
sessionId: requestedSessionId,
pid,
signal: 'TERM',
});
if (sessionIdRef.current !== requestedSessionId) return;
if (result.pending) {
setActionError(t('systemManager.errors.sshChannelUnavailable'));
return;
}
if (!result.success) {
setActionError(result.error || t('systemManager.errors.actionFailed'));
return;
}
void refresh();
} finally {
if (sessionIdRef.current === requestedSessionId) setKillBusy(false);
}
}, [backend, refresh, sessionId, t]);
return (
<SystemPanelShell section="system-manager-ports">
<SystemPanelToolbar
trailing={(
<SystemPanelRefreshButton
title={t('history.action.refresh')}
loading={isRefreshActive}
onClick={() => void refresh()}
/>
)}
>
<SystemPanelSearch
value={query}
onChange={setQuery}
placeholder={t('systemManager.ports.search')}
/>
</SystemPanelToolbar>
<SystemPanelSegmented
value={filter}
onChange={setFilter}
options={[
{ id: 'all', label: t('systemManager.ports.filter.all') },
{ id: 'tcp', label: 'TCP' },
{ id: 'udp', label: 'UDP' },
]}
/>
<SystemPanelMetaBar>
{t('systemManager.ports.meta', { count: ports.length })}
</SystemPanelMetaBar>
{actionError ? <SystemPanelInlineError message={actionError} /> : null}
{error && !(data?.length) ? (
<SystemPanelError
message={error}
onRetry={() => void refresh()}
retryLabel={t('history.action.retry')}
loading={loading}
/>
) : !(data?.length) && (loading || listPending) ? (
<SystemPanelLoading message={t('systemManager.ports.loading')} />
) : !ports.length ? (
<SystemPanelEmpty icon={Network} message={t('systemManager.ports.empty')} />
) : (
<SystemPanelList>
{ports.map((port) => (
<SystemPanelRow
key={port.id}
title={`${port.address}:${port.port}`}
subtitle={
port.processName
? `${port.processName}${port.pid != null ? ` · PID ${port.pid}` : ''}`
: (port.pid != null ? `PID ${port.pid}` : t('systemManager.ports.unknownProcess'))
}
trailing={(
<SystemPanelStatusBadge tone="muted">
{port.protocol.toUpperCase()}
</SystemPanelStatusBadge>
)}
actions={allowMutations && port.pid != null ? (
<SystemPanelRoundButton
title={t('systemManager.ports.terminate')}
destructive
onClick={() => setPendingKillPid(port.pid)}
>
<Skull size={12} />
</SystemPanelRoundButton>
) : null}
/>
))}
</SystemPanelList>
)}
<SystemPanelConfirmDialog
open={allowMutations && pendingKillPid != null}
title={t('systemManager.ports.terminate')}
message={t('systemManager.ports.confirmTerminate', { pid: String(pendingKillPid ?? 0) })}
confirmLabel={t('systemManager.ports.terminate')}
destructive
busy={killBusy}
onOpenChange={(open) => {
if (!open && !killBusy) setPendingKillPid(null);
}}
onConfirm={() => {
const pid = pendingKillPid;
if (pid == null) return;
setPendingKillPid(null);
void executeTerminate(pid);
}}
/>
</SystemPanelShell>
);
});

View 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>
);
});

View File

@@ -0,0 +1,90 @@
import React, { memo } from 'react';
import { cn } from '../../lib/utils';
interface ResourceBarProps {
label: string;
value: number | null | undefined;
className?: string;
/** Slightly taller track for GPU cards. */
size?: 'sm' | 'md';
/** Accent color tone override */
tone?: 'primary' | 'sky' | 'emerald' | 'amber' | 'rose' | 'auto';
}
const toneClasses: Record<string, string> = {
primary: 'bg-primary',
sky: 'bg-sky-500',
emerald: 'bg-emerald-500',
amber: 'bg-amber-500',
rose: 'bg-rose-500',
auto: '',
};
function barToneClass(clamped: number): string {
if (clamped > 85) return 'bg-destructive';
if (clamped > 60) return 'bg-amber-500';
return 'bg-primary';
}
function barGlowStyle(clamped: number): React.CSSProperties {
if (clamped <= 60) return {};
const intensity = Math.min(1, (clamped - 60) / 40);
const color = clamped > 85
? 'hsl(var(--destructive) / 0.4)'
: 'rgba(245, 158, 11, 0.4)';
return {
boxShadow: `0 0 ${4 + intensity * 6}px ${color}`,
};
}
export const ResourceBar = memo(function ResourceBar({
label,
value,
className,
size = 'sm',
tone = 'auto',
}: ResourceBarProps) {
const finite = typeof value === 'number' && Number.isFinite(value);
const clamped = finite ? Math.max(0, Math.min(100, value)) : 0;
const fillClass = tone === 'auto' ? barToneClass(clamped) : toneClasses[tone];
return (
<div className={cn('flex items-center gap-2 min-w-0', className)}>
{label ? (
<span className="text-[10px] text-muted-foreground w-7 shrink-0">{label}</span>
) : null}
<div
className={cn(
'flex-1 rounded-full overflow-hidden min-w-[48px]',
'bg-muted/60',
'shadow-inner',
size === 'md' ? 'h-2' : 'h-1.5',
)}
>
<div
className={cn(
'h-full rounded-full transition-[width,background-color] duration-500 ease-out motion-reduce:transition-none relative',
finite ? fillClass : 'opacity-0',
)}
style={{
width: `${clamped}%`,
...(finite && clamped > 60 ? barGlowStyle(clamped) : {}),
}}
>
{/* Subtle highlight sheen */}
{finite && clamped > 5 && (
<div
className="absolute top-0 left-0 right-0 h-1/2 rounded-t-full opacity-30"
style={{
background: 'linear-gradient(to bottom, rgba(255,255,255,0.5), transparent)',
}}
/>
)}
</div>
</div>
<span className="text-[10px] tabular-nums text-muted-foreground w-10 text-right shrink-0">
{finite ? `${value.toFixed(1)}%` : '--'}
</span>
</div>
);
});

View File

@@ -0,0 +1,300 @@
import {
Play, RefreshCw, Square, Cog,
} 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 { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
import { systemdUnitInfoEqual } from '../../domain/systemManager/pollEquals';
import type { SystemdUnitAction, SystemdUnitInfo } from '../../domain/systemManager/types';
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
import { mergePollListByKey, useStableListOrder } from './listStable';
import {
SystemPanelEmpty,
SystemPanelError,
SystemPanelInlineError,
SystemPanelList,
SystemPanelLoading,
SystemPanelMetaBar,
SystemPanelRefreshButton,
SystemPanelRoundButton,
SystemPanelRow,
SystemPanelSearch,
SystemPanelSegmented,
SystemPanelShell,
SystemPanelStatusBadge,
SystemPanelToolbar,
} from './SystemPanelUi';
type Backend = ReturnType<typeof useSystemManagerBackend>;
type ServiceFilter = 'all' | 'running' | 'failed' | 'inactive';
interface PendingServiceAction {
unit: SystemdUnitInfo;
action: SystemdUnitAction;
}
const mergeUnits = (
prev: SystemdUnitInfo[] | null,
next: SystemdUnitInfo[],
) => mergePollListByKey(prev, next, (u) => `${u.scope}:${u.name}`, systemdUnitInfoEqual);
function activeTone(state: SystemdUnitInfo['activeState']): 'success' | 'warning' | 'muted' {
if (state === 'active') return 'success';
if (state === 'failed' || state === 'deactivating') return 'warning';
return 'muted';
}
function actionTitleKey(action: SystemdUnitAction): string {
switch (action) {
case 'start': return 'systemManager.services.start';
case 'stop': return 'systemManager.services.stop';
case 'restart': return 'systemManager.services.restart';
case 'enable': return 'systemManager.services.enable';
case 'disable': return 'systemManager.services.disable';
case 'reload': return 'systemManager.services.reload';
default: {
const _exhaustive: never = action;
return _exhaustive;
}
}
}
interface ServicesManagerTabProps {
sessionId: string;
isVisible: boolean;
backend: Backend;
refreshIntervalSec: number;
/** Network appliances: list only, no start/stop/restart. */
allowMutations?: boolean;
}
export const ServicesManagerTab = memo(function ServicesManagerTab({
sessionId,
isVisible,
backend,
refreshIntervalSec,
allowMutations = true,
}: ServicesManagerTabProps) {
const { t } = useI18n();
const stableT = useStableTranslate();
const intervalMs = Math.max(3, refreshIntervalSec) * 1000;
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<ServiceFilter>('all');
const [pending, setPending] = useState<PendingServiceAction | null>(null);
const [actionBusy, setActionBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [listPending, setListPending] = useState(false);
const sessionIdRef = useRef(sessionId);
sessionIdRef.current = sessionId;
useEffect(() => {
setListPending(false);
setPending(null);
setActionBusy(false);
setActionError(null);
}, [sessionId]);
const fetcher = useCallback(async (): Promise<SystemdUnitInfo[] | null> => {
const requestedSessionId = sessionId;
try {
const result = await backend.listSystemServices(requestedSessionId);
if (sessionIdRef.current !== requestedSessionId) return null;
if (result.pending) {
setListPending(true);
return null;
}
setListPending(false);
if (!result.success) {
throw new Error(result.error || stableT('systemManager.errors.loadServices'));
}
return result.units || [];
} catch (error) {
if (sessionIdRef.current === requestedSessionId) setListPending(false);
throw error;
}
}, [backend, sessionId, stableT]);
const { data, error, loading, refresh } = usePolling(
fetcher,
intervalMs,
isVisible,
mergeUnits,
{ resetKey: sessionId },
);
const isRefreshActive = loading || listPending;
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return (data || []).filter((unit) => {
if (filter === 'running' && unit.activeState !== 'active') return false;
if (filter === 'failed' && unit.activeState !== 'failed') return false;
if (filter === 'inactive' && unit.activeState !== 'inactive') return false;
if (!q) return true;
return (
unit.name.toLowerCase().includes(q)
|| unit.description.toLowerCase().includes(q)
|| unit.subState.toLowerCase().includes(q)
|| unit.scope.toLowerCase().includes(q)
);
});
}, [data, filter, query]);
const units = useStableListOrder(
filtered,
(u) => `${u.scope}:${u.name}`,
`${filter}|${query}`,
(a, b) => {
if (a.activeState === 'failed' && b.activeState !== 'failed') return -1;
if (b.activeState === 'failed' && a.activeState !== 'failed') return 1;
return a.name.localeCompare(b.name);
},
);
const executeAction = useCallback(async (unit: SystemdUnitInfo, action: SystemdUnitAction) => {
const requestedSessionId = sessionId;
setActionBusy(true);
setActionError(null);
try {
const result = await backend.systemServiceAction({
sessionId: requestedSessionId,
unitName: unit.name,
action,
scope: unit.scope,
});
if (sessionIdRef.current !== requestedSessionId) return;
if (result.pending) {
setActionError(t('systemManager.errors.sshChannelUnavailable'));
return;
}
if (!result.success) {
setActionError(result.error || t('systemManager.errors.actionFailed'));
return;
}
void refresh();
} finally {
if (sessionIdRef.current === requestedSessionId) setActionBusy(false);
}
}, [backend, refresh, sessionId, t]);
return (
<SystemPanelShell section="system-manager-services">
<SystemPanelToolbar
trailing={(
<SystemPanelRefreshButton
title={t('history.action.refresh')}
loading={isRefreshActive}
onClick={() => void refresh()}
/>
)}
>
<SystemPanelSearch
value={query}
onChange={setQuery}
placeholder={t('systemManager.services.search')}
/>
</SystemPanelToolbar>
<SystemPanelSegmented
value={filter}
onChange={setFilter}
options={[
{ id: 'all', label: t('systemManager.services.filter.all') },
{ id: 'running', label: t('systemManager.services.filter.running') },
{ id: 'failed', label: t('systemManager.services.filter.failed') },
{ id: 'inactive', label: t('systemManager.services.filter.inactive') },
]}
/>
<SystemPanelMetaBar>
{t('systemManager.services.meta', { count: units.length })}
</SystemPanelMetaBar>
{actionError ? <SystemPanelInlineError message={actionError} /> : null}
{error && !(data?.length) ? (
<SystemPanelError
message={error}
onRetry={() => void refresh()}
retryLabel={t('history.action.retry')}
loading={loading}
/>
) : !(data?.length) && (loading || listPending) ? (
<SystemPanelLoading message={t('systemManager.services.loading')} />
) : !units.length ? (
<SystemPanelEmpty icon={Cog} message={t('systemManager.services.empty')} />
) : (
<SystemPanelList>
{units.map((unit) => {
const isActive = unit.activeState === 'active';
return (
<SystemPanelRow
key={`${unit.scope}:${unit.name}`}
title={unit.name}
subtitle={
unit.description
? `${unit.description}${unit.scope === 'user' ? ` · ${t('systemManager.services.scope.user')}` : ''}`
: (unit.scope === 'user' ? t('systemManager.services.scope.user') : unit.subState)
}
trailing={(
<SystemPanelStatusBadge tone={activeTone(unit.activeState)}>
{unit.activeState}
</SystemPanelStatusBadge>
)}
actions={allowMutations ? (
<div className="flex shrink-0 items-center justify-end gap-1">
{!isActive ? (
<SystemPanelRoundButton
title={t('systemManager.services.start')}
onClick={() => setPending({ unit, action: 'start' })}
>
<Play size={12} />
</SystemPanelRoundButton>
) : (
<SystemPanelRoundButton
title={t('systemManager.services.stop')}
onClick={() => setPending({ unit, action: 'stop' })}
>
<Square size={12} />
</SystemPanelRoundButton>
)}
<SystemPanelRoundButton
title={t('systemManager.services.restart')}
onClick={() => setPending({ unit, action: 'restart' })}
>
<RefreshCw size={12} />
</SystemPanelRoundButton>
</div>
) : null}
/>
);
})}
</SystemPanelList>
)}
<SystemPanelConfirmDialog
open={allowMutations && pending !== null}
title={pending ? t(actionTitleKey(pending.action)) : ''}
message={pending
? t('systemManager.services.confirmAction', {
action: t(actionTitleKey(pending.action)),
name: pending.unit.name,
})
: ''}
confirmLabel={pending ? t(actionTitleKey(pending.action)) : ''}
destructive={pending?.action === 'stop' || pending?.action === 'disable'}
busy={actionBusy}
onOpenChange={(open) => {
if (!open && !actionBusy) setPending(null);
}}
onConfirm={() => {
const target = pending;
if (!target) return;
setPending(null);
void executeAction(target.unit, target.action);
}}
/>
</SystemPanelShell>
);
});

View File

@@ -0,0 +1,114 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import { normalizeTerminalSettings } from "../../domain/models/terminal.ts";
import type { Host, TerminalSession } from "../../types.ts";
import { TooltipProvider } from "../ui/tooltip.tsx";
import { SystemManagerSidePanel } from "./SystemManagerSidePanel.tsx";
const session: TerminalSession = {
id: "session-1",
hostId: "host-1",
hostLabel: "Demo",
username: "root",
hostname: "demo.local",
status: "connected",
protocol: "ssh",
};
const host: Host = {
id: "host-1",
label: "Demo",
hostname: "demo.local",
username: "root",
tags: [],
os: "linux",
};
test("system side panel renders the graphical overview as the first tab", () => {
const markup = renderToStaticMarkup(
<I18nProvider locale="en">
<TooltipProvider>
<SystemManagerSidePanel
session={session}
sessionHost={host}
isVisible={false}
terminalSettings={normalizeTerminalSettings()}
snippets={[]}
/>
</TooltipProvider>
</I18nProvider>,
);
assert.match(markup, /Overview/);
assert.match(markup, /data-section="system-manager-overview"/);
assert.doesNotMatch(markup, /Live server health/);
assert.doesNotMatch(markup, /System overview/);
});
test("overview tab reuses the shared server stats source", () => {
const source = readFileSync(new URL("./SystemOverviewTab.tsx", import.meta.url), "utf8");
assert.match(source, /function MetricCard\(\{[\s\S]*trendValues,\s*trendMax,\s*tone,/);
assert.match(source, /useServerStats\(\{/);
assert.match(source, /setHistory\(\[\]\)/);
assert.match(source, /if \(!isVisible \|\| !hasStats\) return/);
assert.match(source, /SystemPanelInlineError[\s\S]*onRetry=\{\(\) => void refresh\(\)\}/);
assert.match(source, /aggregateMountedDiskUsage\(stats\.disks\)/);
assert.doesNotMatch(source, /stats\.disks\.slice\(/);
assert.doesNotMatch(source, /usePolling/);
assert.doesNotMatch(source, /backend\.getServerStats/);
});
test("overview network interfaces show cumulative RX/TX totals alongside rates", () => {
const source = readFileSync(new URL("./SystemOverviewTab.tsx", import.meta.url), "utf8");
assert.match(source, /stats\.netInterfaces\.slice\(0, 5\)\.map/);
assert.match(source, /formatBytes\(iface\.rxBytes\)/);
assert.match(source, /formatBytes\(iface\.txBytes\)/);
assert.match(source, /formatThroughput\(iface\.rxSpeed\)/);
assert.match(source, /formatThroughput\(iface\.txSpeed\)/);
});
test("overview tab stays mounted and only pauses polling while another system tab is active", () => {
const source = readFileSync(new URL("./SystemManagerSidePanel.tsx", import.meta.url), "utf8");
// Must not hard-unmount Overview on tab switch (causes empty-state flash).
assert.doesNotMatch(source, /resolvedTab === 'overview' && \(/);
assert.match(source, /isVisible=\{isVisible && resolvedTab === 'overview'\}/);
assert.match(source, /<SystemOverviewTab/);
assert.match(source, /resolvedTab !== 'overview' && 'hidden'/);
});
test("system manager tab bar switches icon-only from real overflow measure", () => {
const source = readFileSync(new URL("./SystemManagerSidePanel.tsx", import.meta.url), "utf8");
const indexCss = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
assert.match(source, /system-manager-tab-bar/);
assert.match(source, /system-manager-tab-label/);
assert.match(source, /measureSystemManagerTabBarLabeledFit/);
assert.match(source, /resolveSystemManagerTabBarIconOnly/);
assert.match(source, /SYSTEM_MANAGER_TAB_BAR_ICON_ONLY_CLASS/);
assert.match(source, /scrollSystemManagerTabIntoView/);
assert.match(source, /applyHorizontalWheelToScrollContainer/);
assert.match(indexCss, /\.system-manager-tab-bar--icon-only \.system-manager-tab-label\s*\{[\s\S]*display:\s*none/);
assert.doesNotMatch(indexCss, /@container system-manager-tabs/);
});
test("system manager tab bar reuses toolbar customize context menu", () => {
const source = readFileSync(new URL("./SystemManagerSidePanel.tsx", import.meta.url), "utf8");
assert.match(source, /ToolbarCustomizeContextMenu/);
assert.match(source, /ToolbarOverflowMenu/);
assert.match(source, /useToolbarItemLayout/);
assert.match(source, /STORAGE_KEY_SYSTEM_MANAGER_TAB_LAYOUT/);
assert.match(source, /SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS/);
assert.match(source, /handleSetTabPlacement/);
assert.match(source, /tabLayout\.move/);
assert.match(source, /data-section="system-manager-tabs"/);
assert.match(source, /system-manager-tab-overflow/);
});

View File

@@ -0,0 +1,615 @@
import { Activity, Box, CircuitBoard, Cog, Gauge, LayoutList, Loader2, Network, TerminalSquare } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS } from '../../application/state/systemManagerTabLayout';
import { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import { useToolbarItemLayout } from '../../application/state/useToolbarItemLayout';
import type { TerminalSettings } from '../../domain/models';
import type { Host } from '../../domain/models/connection';
import type { SystemManagerSubTab } from '../../domain/systemManager/types';
import { resolveCapabilityPanelState } from '../../domain/systemManagerPanelState';
import {
allowSystemManagerMutations,
buildSystemManagerTabs,
shouldCollectServerStats,
} from '../../domain/systemManager/systemTarget';
import { partitionToolbarItems } from '../../domain/toolbarItemLayout';
import { STORAGE_KEY_SYSTEM_MANAGER_TAB_LAYOUT } from '../../infrastructure/config/storageKeys';
import type { Snippet, TerminalSession } from '../../types';
import { cn } from '../../lib/utils';
import { DockerManagerTab } from './DockerManagerTab';
import { GpuManagerTab } from './GpuManagerTab';
import { PortsManagerTab } from './PortsManagerTab';
import { ProcessManagerTab } from './ProcessManagerTab';
import { ServicesManagerTab } from './ServicesManagerTab';
import { SystemOverviewTab } from './SystemOverviewTab';
import { TmuxManagerTab } from './TmuxManagerTab';
import { WorkspaceSidebarHostHeader } from '../terminalLayer/WorkspaceSidebarHostHeader';
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from '../terminalLayer/terminalSidePanelChrome';
import { SystemPanelEmpty, SystemPanelShell } from './SystemPanelUi';
import { useSessionCapabilities } from '../../application/state/useSystemManager';
import {
ToolbarCustomizeContextMenu,
ToolbarOverflowMenu,
type ToolbarCustomizeItem,
} from '../ui/toolbar-item-layout';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import {
applyHorizontalWheelToScrollContainer,
measureSystemManagerTabBarLabeledFit,
resolveSystemManagerTabBarIconOnly,
scrollSystemManagerTabIntoView,
SYSTEM_MANAGER_TAB_BAR_ICON_ONLY_CLASS,
SYSTEM_MANAGER_TAB_BAR_SETTLE_MS,
} from './systemManagerTabBarScroll';
const SystemPanelChecking = memo(function SystemPanelChecking({
message,
}: {
message: string;
}) {
return (
<div className="flex h-full 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 SystemManagerSidePanelProps {
session: TerminalSession | null;
sessionHost: Host | null;
showWorkspaceHostHeader?: boolean;
isVisible: boolean;
terminalSettings: TerminalSettings;
snippets: Snippet[];
onRequestTerminalFocus?: () => void;
}
export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
session,
sessionHost,
showWorkspaceHostHeader = false,
isVisible,
terminalSettings,
snippets,
onRequestTerminalFocus,
}: SystemManagerSidePanelProps) {
const { t } = useI18n();
const backend = useSystemManagerBackend();
const sessionId = session?.id ?? null;
const isConnected = session?.status === 'connected';
const capabilitiesTtlMs = terminalSettings.systemManagerProcessRefreshInterval * 1000;
const { capabilities, refreshCapabilities } = useSessionCapabilities(sessionId, isConnected, backend, isVisible, capabilitiesTtlMs);
const availableTabs = useMemo(
() => buildSystemManagerTabs(sessionHost, capabilities, session),
[capabilities, session, sessionHost],
);
const availableTabsKey = availableTabs.join(',');
const isStatsSupportedOs = useMemo(
() => shouldCollectServerStats(sessionHost, capabilities, session),
[capabilities, session, sessionHost],
);
const allowMutations = useMemo(
() => allowSystemManagerMutations(sessionHost),
[sessionHost],
);
const tabLayout = useToolbarItemLayout(
STORAGE_KEY_SYSTEM_MANAGER_TAB_LAYOUT,
SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS,
);
const tabDefs = useMemo(
(): { id: SystemManagerSubTab; icon: LucideIcon; label: string }[] => [
{ id: 'overview', icon: Gauge, label: t('systemManager.tabs.overview') },
{ id: 'processes', icon: LayoutList, label: t('systemManager.tabs.processes') },
{ id: 'ports', icon: Network, label: t('systemManager.tabs.ports') },
{ id: 'services', icon: Cog, label: t('systemManager.tabs.services') },
{ id: 'tmux', icon: TerminalSquare, label: t('systemManager.tabs.tmux') },
{ id: 'docker', icon: Box, label: t('systemManager.tabs.docker') },
{ id: 'gpu', icon: CircuitBoard, label: t('systemManager.tabs.gpu') },
],
[t],
);
const tabDefById = useMemo(
() => new Map(tabDefs.map((tab) => [tab.id, tab])),
[tabDefs],
);
// Host-available sections only; layout order / placement still covers the full set.
const tabPartition = useMemo(
() => tabLayout.partition(availableTabs),
[availableTabs, tabLayout],
);
const shownTabs = tabPartition.shown as SystemManagerSubTab[];
const collapsedTabs = tabPartition.collapsed as SystemManagerSubTab[];
const reachableTabs = useMemo(
() => [...shownTabs, ...collapsedTabs],
[collapsedTabs, shownTabs],
);
// Customize menu lists every host-available section (including hidden) so
// users can re-show hidden tabs; order follows persisted layout.
const customizeItems = useMemo<ToolbarCustomizeItem[]>(() => {
const available = new Set(availableTabs);
return tabLayout.layout.order
.filter((id): id is SystemManagerSubTab => available.has(id as SystemManagerSubTab))
.map((id) => {
const def = tabDefById.get(id);
if (!def) return null;
const Icon = def.icon;
return {
id,
label: def.label,
icon: <Icon size={12} />,
locked: id === 'overview',
} satisfies ToolbarCustomizeItem;
})
.filter((item): item is ToolbarCustomizeItem => item != null);
}, [availableTabs, tabDefById, tabLayout.layout.order]);
const [activeTab, setActiveTab] = useState<SystemManagerSubTab>('overview');
const resolvedTab = reachableTabs.includes(activeTab)
? activeTab
: (shownTabs[0] ?? collapsedTabs[0] ?? 'overview');
const [tabBarEl, setTabBarEl] = useState<HTMLDivElement | null>(null);
const tabBarRef = useCallback((node: HTMLDivElement | null) => {
setTabBarEl((prev) => (prev === node ? prev : node));
}, []);
const [iconOnlyTabs, setIconOnlyTabs] = useState(false);
const iconOnlyTabsRef = React.useRef(iconOnlyTabs);
iconOnlyTabsRef.current = iconOnlyTabs;
const isConnectedSession = Boolean(sessionId && session && isConnected);
const shownTabsKey = shownTabs.join(',');
// Icon-only when labeled tabs would overflow the real bar width (not a rem guess).
// Debounced so side-panel drag does not thrash; re-check on pointerup.
useEffect(() => {
if (!isConnectedSession || !tabBarEl) return;
let cancelled = false;
let settleTimer: ReturnType<typeof setTimeout> | null = null;
const applyCompact = () => {
if (cancelled) return;
const next = resolveSystemManagerTabBarIconOnly(
measureSystemManagerTabBarLabeledFit(tabBarEl),
iconOnlyTabsRef.current,
);
setIconOnlyTabs((prev) => (prev === next ? prev : next));
};
const scheduleCompact = () => {
if (settleTimer != null) clearTimeout(settleTimer);
settleTimer = setTimeout(() => {
settleTimer = null;
applyCompact();
}, SYSTEM_MANAGER_TAB_BAR_SETTLE_MS);
};
// Immediate measure (and again next frame after layout from tab mount/paint).
applyCompact();
const rafId = requestAnimationFrame(() => {
if (!cancelled) applyCompact();
});
const ro = typeof ResizeObserver !== 'undefined'
? new ResizeObserver(() => {
scheduleCompact();
})
: null;
ro?.observe(tabBarEl);
const shell = tabBarEl.closest('[data-section="system-manager-panel"]');
if (shell instanceof HTMLElement && shell !== tabBarEl) {
ro?.observe(shell);
}
// Side-panel drag ends on pointerup — re-measure even if RO was quiet.
const onPointerUp = () => {
scheduleCompact();
};
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerUp);
return () => {
cancelled = true;
cancelAnimationFrame(rafId);
if (settleTimer != null) clearTimeout(settleTimer);
ro?.disconnect();
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerUp);
};
}, [isConnectedSession, isVisible, availableTabsKey, shownTabsKey, tabBarEl]);
// Keep the active sub-tab visible when the strip overflows (icon-only or labeled).
useLayoutEffect(() => {
if (!isConnectedSession || !tabBarEl) return;
const active = tabBarEl.querySelector<HTMLElement>('[data-system-tab-active="true"]');
scrollSystemManagerTabIntoView(tabBarEl, active, 'smooth');
}, [resolvedTab, availableTabsKey, isConnectedSession, isVisible, tabBarEl, shownTabsKey, iconOnlyTabs]);
// Vertical mouse wheel → horizontal scroll when the row overflows.
useEffect(() => {
if (!isConnectedSession || !tabBarEl) return;
const onWheel = (event: WheelEvent) => {
if (applyHorizontalWheelToScrollContainer(tabBarEl, event)) {
event.preventDefault();
event.stopPropagation();
}
};
tabBarEl.addEventListener('wheel', onWheel, { passive: false });
return () => {
tabBarEl.removeEventListener('wheel', onWheel);
};
}, [isConnectedSession, isVisible, availableTabsKey, tabBarEl]);
// Must be defined before early returns to comply with React rules of hooks.
const prevTabRef = React.useRef(resolvedTab);
const probingRef = React.useRef(false);
React.useEffect(() => {
const prev = prevTabRef.current;
prevTabRef.current = resolvedTab;
if (prev === resolvedTab) return;
if (resolvedTab === 'docker' && capabilities?.hasDocker !== true) {
if (!probingRef.current) {
probingRef.current = true;
refreshCapabilities().finally(() => { probingRef.current = false; });
}
} else if (resolvedTab === 'tmux' && capabilities?.hasTmux !== true) {
void refreshCapabilities();
}
}, [resolvedTab, capabilities, refreshCapabilities]);
// Auto-poll for Docker capabilities while Docker tab is active and Docker not yet detected.
// Use setTimeout recursion so the next probe only starts after the previous one finishes,
// avoiding overlapping probes (e.g. SSH timeout 8s vs user-configured interval 2s).
// First poll is delayed by one interval to avoid overlapping with the tab-switch probe above.
//
// Use a ref to store refreshCapabilities so that if its reference changes on every render,
// the useEffect below is NOT re-run (which would cancel the timer and bypass the interval).
const refreshRef = React.useRef(refreshCapabilities);
refreshRef.current = refreshCapabilities;
// Auto-poll for Docker capabilities while Docker tab is active and Docker not yet detected.
// Each effect generation gets its own cancelled flag and timerId via closure,
// preventing stale probes from surviving cleanup (unlike cancelledRef which is shared).
// First poll is delayed by one interval to avoid overlapping with the tab-switch probe.
React.useEffect(() => {
if (!isVisible || resolvedTab !== 'docker' || capabilities?.hasDocker === true) return;
let cancelled = false;
let timerId: ReturnType<typeof setTimeout>;
const pollOnce = async () => {
if (cancelled) return;
if (probingRef.current) {
// probe is in-flight, reschedule for next cycle
timerId = setTimeout(pollOnce, capabilitiesTtlMs);
return;
}
probingRef.current = true;
try {
await refreshRef.current();
} catch {
// Transient error - keep polling next round
}
probingRef.current = false;
if (cancelled) return;
timerId = setTimeout(pollOnce, capabilitiesTtlMs);
};
timerId = setTimeout(pollOnce, capabilitiesTtlMs);
return () => {
cancelled = true;
if (timerId) clearTimeout(timerId);
};
}, [isVisible, resolvedTab, capabilities?.hasDocker, capabilitiesTtlMs]);
const selectTab = useCallback((id: SystemManagerSubTab) => {
setActiveTab(id);
}, []);
const handleSetTabPlacement = useCallback(
(id: string, placement: 'show' | 'collapse' | 'hide') => {
const next = tabLayout.setPlacement(id, placement, availableTabs);
// Hide of the active tab → jump to the first still-reachable section.
if (activeTab === id && (next.placement[id] ?? 'show') === 'hide') {
const part = partitionToolbarItems(next, availableTabs);
const fallback = (part.shown[0] ?? part.collapsed[0]) as SystemManagerSubTab | undefined;
if (fallback) setActiveTab(fallback);
}
},
[activeTab, availableTabs, tabLayout],
);
const workspaceHostHeader = showWorkspaceHostHeader && sessionHost ? (
<WorkspaceSidebarHostHeader
host={sessionHost}
section="terminal-system-host-header"
/>
) : null;
if (!sessionId || !session) {
return (
<SystemPanelShell section="system-manager-panel">
{workspaceHostHeader}
<SystemPanelEmpty icon={Activity} message={t('systemManager.noSession')} />
</SystemPanelShell>
);
}
if (!isConnected) {
return (
<SystemPanelShell section="system-manager-panel">
{workspaceHostHeader}
<SystemPanelEmpty icon={Activity} message={t('systemManager.notConnected')} />
</SystemPanelShell>
);
}
const tmuxReady = capabilities?.hasTmux === true;
const dockerReady = capabilities?.hasDocker === true;
const gpuReady = capabilities?.hasNvidiaSmi === true || capabilities?.hasNpuSmi === true;
const portsReady = (
capabilities?.hasSs === true
|| capabilities?.hasNetstat === true
|| capabilities?.hasLsof === true
);
const servicesReady = capabilities?.hasSystemctl === true;
const tmuxPanelState = resolveCapabilityPanelState({
isActive: resolvedTab === 'tmux',
ready: tmuxReady,
capabilitiesKnown: capabilities !== undefined,
});
const dockerPanelState = resolveCapabilityPanelState({
isActive: resolvedTab === 'docker',
ready: dockerReady,
capabilitiesKnown: capabilities !== undefined,
});
const gpuPanelState = resolveCapabilityPanelState({
isActive: resolvedTab === 'gpu',
ready: gpuReady,
capabilitiesKnown: capabilities !== undefined,
});
const portsPanelState = resolveCapabilityPanelState({
isActive: resolvedTab === 'ports',
ready: portsReady,
capabilitiesKnown: capabilities !== undefined,
});
const servicesPanelState = resolveCapabilityPanelState({
isActive: resolvedTab === 'services',
ready: servicesReady,
capabilitiesKnown: capabilities !== undefined,
});
return (
<SystemPanelShell section="system-manager-panel">
{workspaceHostHeader}
<div
ref={tabBarRef}
className={cn(
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
'system-manager-tab-bar flex min-w-0 w-full items-center px-2 border-b border-border/50',
iconOnlyTabs && SYSTEM_MANAGER_TAB_BAR_ICON_ONLY_CLASS,
)}
role="tablist"
aria-label={t('systemManager.tabs.ariaLabel')}
data-section="system-manager-tabs"
data-icon-only={iconOnlyTabs ? 'true' : undefined}
>
<ToolbarCustomizeContextMenu
items={customizeItems}
placementOf={(id) => tabLayout.layout.placement[id] ?? 'show'}
onSetPlacement={handleSetTabPlacement}
onMove={(id, direction) =>
tabLayout.move(id, direction, availableTabs)
}
onReset={tabLayout.reset}
t={t}
className="flex min-w-0 w-full items-center gap-0.5"
dataSection="system-manager-tab-customize"
>
{shownTabs.map((id) => {
const def = tabDefById.get(id);
if (!def) return null;
const { icon: Icon, label } = def;
const isActive = resolvedTab === id;
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<button
type="button"
role="tab"
aria-selected={isActive}
aria-label={label}
data-system-tab-active={isActive ? 'true' : undefined}
className={cn(
'system-manager-tab h-6 flex items-center gap-1.5 px-2 rounded-md text-[11px] transition-all duration-200',
isActive
? 'bg-primary/15 text-primary font-medium shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
)}
onClick={(event) => {
selectTab(id);
scrollSystemManagerTabIntoView(tabBarEl, event.currentTarget, 'smooth');
}}
>
<Icon size={12} className="shrink-0" />
<span className="system-manager-tab-label">{label}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{label}
</TooltipContent>
</Tooltip>
);
})}
<div className="ml-auto shrink-0" data-section="system-manager-tab-overflow">
<ToolbarOverflowMenu
hasItems={collapsedTabs.length > 0}
label={t('common.more')}
orientation="horizontal"
buttonClassName="h-6 w-6 shrink-0 rounded-md p-0"
contentClassName="min-w-[10rem] p-1"
>
<div className="flex min-w-[10rem] flex-col">
{collapsedTabs.map((id) => {
const def = tabDefById.get(id);
if (!def) return null;
const { icon: Icon, label } = def;
const isActive = resolvedTab === id;
return (
<button
key={id}
type="button"
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-secondary',
isActive && 'bg-secondary font-medium',
)}
onClick={() => selectTab(id)}
>
<Icon size={12} className="shrink-0" />
<span className="truncate">{label}</span>
</button>
);
})}
</div>
</ToolbarOverflowMenu>
</div>
</ToolbarCustomizeContextMenu>
</div>
<div className="flex-1 min-h-0 flex flex-col">
{/* Keep Overview mounted (CSS-hidden) like other system tabs so shared
server-stats cache + sparkline history survive tab switches. */}
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'overview' && 'hidden')}>
<SystemOverviewTab
sessionId={sessionId}
isVisible={isVisible && resolvedTab === 'overview'}
isSupportedOs={isStatsSupportedOs}
refreshIntervalSec={terminalSettings.serverStatsRefreshInterval}
/>
</div>
{availableTabs.includes('processes') ? (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'processes' && 'hidden')}>
<ProcessManagerTab
sessionId={sessionId}
isVisible={isVisible && resolvedTab === 'processes'}
backend={backend}
refreshIntervalSec={terminalSettings.systemManagerProcessRefreshInterval}
/>
</div>
) : null}
{portsPanelState === 'unavailable' ? (
<div className="flex-1 min-h-0">
<SystemPanelEmpty icon={Network} message={t('systemManager.ports.unavailable')} />
</div>
) : portsPanelState === 'checking' ? (
<div className="flex-1 min-h-0">
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
</div>
) : portsPanelState === 'ready' ? (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'ports' && 'hidden')}>
<PortsManagerTab
sessionId={sessionId}
isVisible={isVisible && resolvedTab === 'ports'}
backend={backend}
refreshIntervalSec={terminalSettings.systemManagerProcessRefreshInterval}
allowMutations={allowMutations}
/>
</div>
) : null}
{servicesPanelState === 'unavailable' ? (
<div className="flex-1 min-h-0">
<SystemPanelEmpty icon={Cog} message={t('systemManager.services.unavailable')} />
</div>
) : servicesPanelState === 'checking' ? (
<div className="flex-1 min-h-0">
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
</div>
) : servicesPanelState === 'ready' ? (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'services' && 'hidden')}>
<ServicesManagerTab
sessionId={sessionId}
isVisible={isVisible && resolvedTab === 'services'}
backend={backend}
refreshIntervalSec={terminalSettings.systemManagerProcessRefreshInterval}
allowMutations={allowMutations}
/>
</div>
) : null}
{tmuxPanelState === 'unavailable' ? (
<div className="flex-1 min-h-0">
<SystemPanelEmpty icon={TerminalSquare} message={t('systemManager.tmux.unavailable')} />
</div>
) : tmuxPanelState === 'checking' ? (
<div className="flex-1 min-h-0">
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
</div>
) : tmuxPanelState === 'ready' ? (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'tmux' && 'hidden')}>
<TmuxManagerTab
sessionId={sessionId}
parentSession={session}
isVisible={isVisible && resolvedTab === 'tmux'}
warmupEnabled={isVisible && resolvedTab !== 'tmux'}
backend={backend}
refreshIntervalSec={terminalSettings.systemManagerTmuxRefreshInterval}
snippets={snippets}
onRequestTerminalFocus={onRequestTerminalFocus}
/>
</div>
) : null}
{dockerPanelState === 'unavailable' ? (
<div className="flex-1 min-h-0">
<SystemPanelEmpty icon={Box} message={t('systemManager.docker.unavailable')} />
</div>
) : dockerPanelState === 'checking' ? (
<div className="flex-1 min-h-0">
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
</div>
) : dockerPanelState === 'ready' ? (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'docker' && 'hidden')}>
<DockerManagerTab
sessionId={sessionId}
parentSession={session}
isVisible={isVisible && resolvedTab === 'docker'}
warmupEnabled={isVisible && resolvedTab !== 'docker'}
backend={backend}
listRefreshIntervalSec={terminalSettings.systemManagerDockerListRefreshInterval}
statsRefreshIntervalSec={terminalSettings.systemManagerDockerStatsRefreshInterval}
targetOs={capabilities?.targetOs}
/>
</div>
) : null}
{gpuPanelState === 'unavailable' ? (
<div className="flex-1 min-h-0">
<SystemPanelEmpty icon={CircuitBoard} message={t('systemManager.gpu.unavailable')} />
</div>
) : gpuPanelState === 'checking' ? (
<div className="flex-1 min-h-0">
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
</div>
) : gpuPanelState === 'ready' ? (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'gpu' && 'hidden')}>
<GpuManagerTab
sessionId={sessionId}
isVisible={isVisible && resolvedTab === 'gpu'}
backend={backend}
refreshIntervalSec={terminalSettings.systemManagerProcessRefreshInterval}
/>
</div>
) : null}
</div>
</SystemPanelShell>
);
});

View File

@@ -0,0 +1,642 @@
import {
Activity,
Clock3,
Cpu,
HardDrive,
MemoryStick,
Network,
} from 'lucide-react';
import React, { memo, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { aggregateMountedDiskUsage } from '../../domain/systemDiskUsage';
import { cn } from '../../lib/utils';
import { useServerStats } from '../../application/state/useServerStats';
import { ResourceBar } from './ResourceBar';
import {
SystemPanelEmpty,
SystemPanelError,
SystemPanelInlineError,
SystemPanelLoading,
SystemPanelShell,
} from './SystemPanelUi';
interface SystemOverviewTabProps {
sessionId: string;
isVisible: boolean;
isSupportedOs: boolean;
refreshIntervalSec: number;
}
interface OverviewSample {
at: number;
cpu: number;
memory: number;
disk: number;
network: number;
}
function clampPercent(value: number | null | undefined): number | null {
if (!Number.isFinite(value)) return null;
return Math.max(0, Math.min(100, Number(value)));
}
function ratioPercent(used: number | null | undefined, total: number | null | undefined): number | null {
if (!Number.isFinite(used) || !Number.isFinite(total) || Number(total) <= 0) return null;
return clampPercent((Number(used) / Number(total)) * 100);
}
function formatPercent(value: number | null | undefined, digits = 0): string {
if (!Number.isFinite(value)) return '--';
return `${Number(value).toFixed(digits)}%`;
}
function formatBytes(bytes: number): string {
const value = Number(bytes);
if (!Number.isFinite(value) || value <= 0) return '0 B';
if (value >= 1024 ** 4) return `${(value / 1024 ** 4).toFixed(1)} TB`;
if (value >= 1024 ** 3) return `${(value / 1024 ** 3).toFixed(1)} GB`;
if (value >= 1024 ** 2) return `${(value / 1024 ** 2).toFixed(1)} MB`;
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${Math.round(value)} B`;
}
function formatThroughput(bytesPerSecond: number): string {
return `${formatBytes(bytesPerSecond)}/s`;
}
function formatStorageGb(gb: number | null | undefined): string {
if (!Number.isFinite(gb)) return '--';
const value = Number(gb);
if (value >= 1024) return `${(value / 1024).toFixed(1)} TB`;
return `${value.toFixed(value >= 10 ? 0 : 1)} GB`;
}
function formatMemoryMb(mb: number | null | undefined): string {
if (!Number.isFinite(mb)) return '--';
const value = Number(mb);
if (value >= 1024) return `${(value / 1024).toFixed(1)} GB`;
return `${Math.round(value)} MB`;
}
function formatDuration(seconds: number | null | undefined, t: ReturnType<typeof useI18n>['t']): string {
if (!Number.isFinite(seconds) || Number(seconds) < 0) return '--';
const totalHours = Math.floor(Number(seconds) / 3600);
const days = Math.floor(totalHours / 24);
const hours = totalHours % 24;
const minutes = Math.floor((Number(seconds) % 3600) / 60);
if (days > 0) return t('systemManager.overview.duration.daysHours', { days, hours });
if (hours > 0) return t('systemManager.overview.duration.hoursMinutes', { hours, minutes });
return t('systemManager.overview.duration.minutes', { minutes });
}
function formatLoad(loadAverage: number[] | undefined): string {
if (!loadAverage || loadAverage.length === 0) return '--';
return loadAverage.map((load) => load.toFixed(2)).join(' / ');
}
function MetricTrend({
values,
max,
className,
gradientId,
}: {
values: number[];
max?: number;
className?: string;
/** Unique id for the gradient def (needed if multiple charts on same page) */
gradientId?: string;
}) {
const width = 120;
const height = 34;
const finite = values.filter((value) => Number.isFinite(value));
const computedMax = max ?? Math.max(1, ...finite);
const safeValues = values.length > 1 ? values : [0, values[0] ?? 0];
// Compute point coordinates
const points = safeValues.map((value, index) => {
const x = safeValues.length === 1 ? width : (index / (safeValues.length - 1)) * width;
const clamped = Math.max(0, Math.min(computedMax, Number.isFinite(value) ? value : 0));
const y = height - (clamped / computedMax) * (height - 4) - 2;
return { x, y };
});
// Build smooth bezier curve path
let smoothPath = '';
if (points.length === 1) {
smoothPath = `M 0 ${height} L ${width} ${height}`;
} else if (points.length === 2) {
smoothPath = `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`;
} else {
smoothPath = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length - 1; i++) {
const prev = points[i - 1];
const curr = points[i];
const next = points[i + 1];
const cpx1 = prev.x + (curr.x - prev.x) * 0.6;
const cpy1 = prev.y + (curr.y - prev.y) * 0.2;
const cpx2 = curr.x - (next.x - prev.x) * 0.2;
const cpy2 = curr.y - (next.y - prev.y) * 0.1;
smoothPath += ` C ${cpx1.toFixed(1)} ${cpy1.toFixed(1)}, ${cpx2.toFixed(1)} ${cpy2.toFixed(1)}, ${curr.x.toFixed(1)} ${curr.y.toFixed(1)}`;
}
// Last segment
const last = points[points.length - 1];
const secondLast = points[points.length - 2];
smoothPath += ` S ${last.x.toFixed(1)} ${last.y.toFixed(1)}, ${last.x.toFixed(1)} ${last.y.toFixed(1)}`;
}
// Area path for gradient fill
const areaPath = `${smoothPath} L ${width} ${height} L 0 ${height} Z`;
const gradId = gradientId || `metric-trend-grad-${Math.random().toString(36).slice(2, 8)}`;
return (
<svg className={cn('h-9 w-full overflow-visible', className)} viewBox={`0 0 ${width} ${height}`} role="img">
<defs>
<linearGradient id={gradId} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.35" />
<stop offset="60%" stopColor="currentColor" stopOpacity="0.12" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0.02" />
</linearGradient>
</defs>
<path d={areaPath} fill={`url(#${gradId})`} />
<path
d={smoothPath}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* End point dot */}
{points.length >= 2 && (
<circle
cx={points[points.length - 1].x}
cy={points[points.length - 1].y}
r="2.5"
fill="currentColor"
className="drop-shadow-sm"
/>
)}
</svg>
);
}
function RadialGauge({
value,
className,
gradientId,
}: {
value: number | null;
className?: string;
/** Unique gradient id */
gradientId?: string;
}) {
const clamped = clampPercent(value) ?? 0;
const gradId = gradientId || `radial-gauge-${Math.random().toString(36).slice(2, 8)}`;
// Determine color intensity based on value
const isHigh = clamped > 85;
const isMedium = clamped > 60 && clamped <= 85;
return (
<div className={cn('relative h-16 w-16 shrink-0', className)}>
<svg viewBox="0 0 44 44" className="h-full w-full -rotate-90">
<defs>
<linearGradient id={gradId} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="currentColor" stopOpacity="1" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0.7" />
</linearGradient>
{/* Glow filter */}
<filter id={`${gradId}-glow`} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1.5" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
{/* Track */}
<circle
cx="22"
cy="22"
r="18"
fill="none"
stroke="currentColor"
strokeWidth="5"
className="text-muted/50"
/>
{/* Progress with gradient */}
<circle
cx="22"
cy="22"
r="18"
fill="none"
stroke={`url(#${gradId})`}
strokeWidth="5"
strokeLinecap="round"
pathLength={100}
strokeDasharray={`${clamped} ${100 - clamped}`}
style={{ transition: 'stroke-dasharray 0.4s ease-out' }}
filter={isHigh || isMedium ? `url(#${gradId}-glow)` : undefined}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-[12px] font-bold tabular-nums text-foreground leading-tight">
{formatPercent(value)}
</span>
</div>
</div>
);
}
function MetricCard({
label,
value,
detail,
icon: Icon,
gaugeValue,
trendValues,
trendMax,
tone,
toneBg,
gradientId,
}: {
label: string;
value: string;
detail: string;
icon: React.ComponentType<{ size?: number; className?: string }>;
gaugeValue: number | null;
trendValues: number[];
trendMax?: number;
tone: string;
/** Background glow color class (e.g. "from-sky-500/5") */
toneBg?: string;
gradientId?: string;
}) {
const cardGradId = gradientId || `card-${Math.random().toString(36).slice(2, 8)}`;
return (
<section
className={cn(
'relative overflow-hidden rounded-lg border bg-card p-3',
'transition-all duration-300 hover:shadow-md hover:-translate-y-0.5',
toneBg ? `bg-gradient-to-br ${toneBg}` : '',
)}
style={{ borderColor: 'hsl(var(--border) / 0.7)' }}
>
{/* Subtle top accent line */}
<div
className={cn('absolute top-0 left-0 right-0 h-0.5 opacity-60', tone)}
style={{
background: 'currentColor',
}}
/>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-medium" style={{ color: 'hsl(var(--muted-foreground))' }}>
<Icon size={13} className={tone} />
<span>{label}</span>
</div>
<div
className="truncate text-lg font-bold tabular-nums leading-tight"
style={{ color: 'hsl(var(--foreground))' }}
>
{value}
</div>
<div className="mt-1 truncate text-[10px]" style={{ color: 'hsl(var(--muted-foreground))' }}>
{detail}
</div>
</div>
<RadialGauge value={gaugeValue} className={tone} gradientId={`${cardGradId}-radial`} />
</div>
<MetricTrend values={trendValues} max={trendMax} className={cn('mt-2', tone)} gradientId={`${cardGradId}-trend`} />
</section>
);
}
function InfoPill({
label,
value,
icon: Icon,
tone,
}: {
label: string;
value: string;
icon?: React.ComponentType<{ size?: number; className?: string }>;
tone?: string;
}) {
return (
<div
className="min-w-0 rounded-lg border px-3 py-2.5 transition-all duration-200 hover:shadow-sm"
style={{
borderColor: 'hsl(var(--border) / 0.6)',
background: 'hsl(var(--background))',
}}
>
<div className="flex items-center gap-1.5 mb-0.5">
{Icon && <Icon size={11} className={cn(tone || 'text-muted-foreground')} />}
<div className="text-[10px]" style={{ color: 'hsl(var(--muted-foreground))' }}>{label}</div>
</div>
<div className="truncate text-xs font-semibold" style={{ color: 'hsl(var(--foreground))' }}>
{value || '--'}
</div>
</div>
);
}
export const SystemOverviewTab = memo(function SystemOverviewTab({
sessionId,
isVisible,
isSupportedOs,
refreshIntervalSec,
}: SystemOverviewTabProps) {
const { t } = useI18n();
const [history, setHistory] = useState<OverviewSample[]>([]);
const {
stats,
error,
isLoading: loading,
refresh,
} = useServerStats({
sessionId,
enabled: isVisible,
refreshInterval: refreshIntervalSec,
isSupportedOs,
isConnected: true,
});
const hasStats = Boolean(stats.lastUpdated);
const memoryPercent = ratioPercent(stats?.memUsed, stats?.memTotal);
const mountedDiskUsage = aggregateMountedDiskUsage(stats.disks);
const diskUsed = mountedDiskUsage?.used ?? stats.diskUsed;
const diskTotal = mountedDiskUsage?.total ?? stats.diskTotal;
const diskPercent = mountedDiskUsage?.percent ?? clampPercent(stats.diskPercent);
const networkSpeed = (stats?.netRxSpeed ?? 0) + (stats?.netTxSpeed ?? 0);
const networkGauge = Math.min(100, Math.log10(networkSpeed + 1) * 14);
const loadOne = stats?.loadAverage?.[0] ?? null;
const loadPercent = ratioPercent(loadOne, stats?.cpuCores);
useEffect(() => {
setHistory([]);
}, [sessionId]);
useEffect(() => {
if (!isVisible || !hasStats) return;
setHistory((prev) => {
const next = [
...prev,
{
at: Date.now(),
cpu: clampPercent(stats.cpu) ?? 0,
memory: memoryPercent ?? 0,
disk: diskPercent ?? 0,
network: networkSpeed,
},
];
return next.slice(-24);
});
}, [diskPercent, hasStats, isVisible, memoryPercent, networkSpeed, stats.cpu]);
const trends = useMemo(() => ({
cpu: history.map((sample) => sample.cpu),
memory: history.map((sample) => sample.memory),
disk: history.map((sample) => sample.disk),
network: history.map((sample) => sample.network),
}), [history]);
// Prefer cached stats over empty/loading so tab switches never flash the
// empty placeholder when we already have a successful sample.
const showBlockingError = Boolean(error && !hasStats && !loading);
const showInitialLoading = Boolean(loading && !hasStats);
const showEmpty = Boolean(!hasStats && !loading && !error);
return (
<SystemPanelShell section="system-manager-overview">
{error && hasStats && !loading && (
<SystemPanelInlineError
message={error}
onRetry={() => void refresh()}
retryLabel={t('history.action.retry')}
loading={loading}
/>
)}
{showBlockingError && error ? (
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
) : showInitialLoading ? (
<SystemPanelLoading message={t('systemManager.overview.loading')} />
) : showEmpty ? (
<SystemPanelEmpty icon={Activity} message={t('systemManager.overview.empty')} />
) : hasStats ? (
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 space-y-4">
{/* Main metric cards */}
<div className="grid grid-cols-2 gap-3">
<MetricCard
label="CPU"
value={formatPercent(stats.cpu)}
detail={stats.cpuCores ? t('systemManager.overview.cores', { count: String(stats.cpuCores) }) : '--'}
icon={Cpu}
gaugeValue={stats.cpu}
trendValues={trends.cpu}
trendMax={100}
tone="text-sky-500"
gradientId="cpu-card"
/>
<MetricCard
label={t('systemManager.overview.memory')}
value={formatPercent(memoryPercent)}
detail={`${formatMemoryMb(stats.memUsed)} / ${formatMemoryMb(stats.memTotal)}`}
icon={MemoryStick}
gaugeValue={memoryPercent}
trendValues={trends.memory}
trendMax={100}
tone="text-emerald-500"
gradientId="mem-card"
/>
<MetricCard
label={t('systemManager.overview.disk')}
value={formatPercent(diskPercent)}
detail={`${formatStorageGb(diskUsed)} / ${formatStorageGb(diskTotal)}`}
icon={HardDrive}
gaugeValue={diskPercent}
trendValues={trends.disk}
trendMax={100}
tone="text-amber-500"
gradientId="disk-card"
/>
<MetricCard
label={t('systemManager.overview.network')}
value={formatThroughput(networkSpeed)}
detail={`${t('systemManager.overview.rx')} ${formatThroughput(stats.netRxSpeed)} · ${t('systemManager.overview.tx')} ${formatThroughput(stats.netTxSpeed)}`}
icon={Network}
gaugeValue={networkGauge}
trendValues={trends.network}
tone="text-cyan-500"
gradientId="net-card"
/>
</div>
{/* Info pills grid */}
<div className="grid grid-cols-3 gap-2">
<InfoPill
label={t('systemManager.overview.load')}
value={formatLoad(stats.loadAverage)}
icon={Activity}
tone="text-sky-500"
/>
<InfoPill
label={t('systemManager.overview.uptime')}
value={formatDuration(stats.uptimeSeconds, t)}
icon={Clock3}
tone="text-emerald-500"
/>
<InfoPill
label={t('systemManager.overview.latency')}
value={Number.isFinite(stats.latencyMs) ? `${Math.round(stats.latencyMs ?? 0)} ms` : '--'}
icon={Network}
tone="text-cyan-500"
/>
<InfoPill
label={t('systemManager.overview.system')}
value={stats.osName || '--'}
icon={HardDrive}
tone="text-amber-500"
/>
<InfoPill
label={t('systemManager.overview.kernel')}
value={stats.kernelRelease || '--'}
icon={Cpu}
tone="text-sky-500"
/>
<InfoPill
label={t('systemManager.overview.swap')}
value={`${formatMemoryMb(stats.swapUsed)} / ${formatMemoryMb(stats.swapTotal)}`}
icon={MemoryStick}
tone="text-emerald-500"
/>
</div>
{/* CPU Cores section */}
<section
className="rounded-lg border p-3"
style={{
borderColor: 'hsl(var(--border) / 0.7)',
background: 'hsl(var(--background))',
}}
>
<div className="mb-2.5 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-xs font-semibold" style={{ color: 'hsl(var(--foreground))' }}>
<Cpu size={13} className="text-sky-500" />
{t('systemManager.overview.cpuCores')}
</div>
<span className="text-[10px]" style={{ color: 'hsl(var(--muted-foreground))' }}>
{loadPercent !== null ? `${t('systemManager.overview.load')} ${formatPercent(loadPercent)}` : t('systemManager.overview.noData')}
</span>
</div>
{stats.cpuPerCore.length > 0 ? (
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
{stats.cpuPerCore.slice(0, 12).map((core, index) => (
<ResourceBar key={`core-${index}`} label={`C${index + 1}`} value={core} />
))}
</div>
) : (
<div className="text-[11px]" style={{ color: 'hsl(var(--muted-foreground))' }}>{t('systemManager.overview.noData')}</div>
)}
</section>
{/* Disks section */}
<section
className="rounded-lg border p-3"
style={{
borderColor: 'hsl(var(--border) / 0.7)',
background: 'hsl(var(--background))',
}}
>
<div className="mb-2.5 flex items-center gap-1.5 text-xs font-semibold" style={{ color: 'hsl(var(--foreground))' }}>
<HardDrive size={13} className="text-amber-500" />
{t('systemManager.overview.disks')}
</div>
{stats.disks.length > 0 ? (
<div className="space-y-2.5">
{stats.disks.map((disk) => (
<div key={disk.mountPoint} className="space-y-1.5">
<div className="flex items-center justify-between gap-2 text-[11px]">
<span className="min-w-0 truncate font-medium" style={{ color: 'hsl(var(--foreground))' }}>{disk.mountPoint}</span>
<span className="shrink-0 tabular-nums" style={{ color: 'hsl(var(--muted-foreground))' }}>
{formatStorageGb(disk.used)} / {formatStorageGb(disk.total)}
</span>
</div>
<ResourceBar label="" value={disk.percent} />
</div>
))}
</div>
) : (
<div className="text-[11px]" style={{ color: 'hsl(var(--muted-foreground))' }}>{t('systemManager.overview.noDisks')}</div>
)}
</section>
{/* Network interfaces section */}
<section
className="rounded-lg border p-3"
style={{
borderColor: 'hsl(var(--border) / 0.7)',
background: 'hsl(var(--background))',
}}
>
<div className="mb-2.5 flex items-center gap-1.5 text-xs font-semibold" style={{ color: 'hsl(var(--foreground))' }}>
<Network size={13} className="text-cyan-500" />
{t('systemManager.overview.interfaces')}
</div>
{stats.netInterfaces.length > 0 ? (
<div className="space-y-2.5">
{stats.netInterfaces.slice(0, 5).map((iface) => (
<div key={iface.name} className="space-y-1">
<div className="flex items-center justify-between gap-2 text-[11px]">
<span className="min-w-0 truncate font-medium" style={{ color: 'hsl(var(--foreground))' }}>{iface.name}</span>
<span className="shrink-0 tabular-nums" style={{ color: 'hsl(var(--muted-foreground))' }}>
{formatBytes(iface.rxBytes)} · {formatBytes(iface.txBytes)}
</span>
</div>
<div className="flex items-center justify-between text-[10px] tabular-nums" style={{ color: 'hsl(var(--muted-foreground) / 0.8)' }}>
<span>{t('systemManager.overview.rx')} {formatThroughput(iface.rxSpeed)}</span>
<span>{t('systemManager.overview.tx')} {formatThroughput(iface.txSpeed)}</span>
</div>
</div>
))}
</div>
) : (
<div className="text-[11px]" style={{ color: 'hsl(var(--muted-foreground))' }}>{t('systemManager.overview.noInterfaces')}</div>
)}
</section>
{/* Top processes section */}
<section
className="rounded-lg border p-3"
style={{
borderColor: 'hsl(var(--border) / 0.7)',
background: 'hsl(var(--background))',
}}
>
<div className="mb-2.5 flex items-center gap-1.5 text-xs font-semibold" style={{ color: 'hsl(var(--foreground))' }}>
<Clock3 size={13} className="text-rose-500" />
{t('systemManager.overview.topProcesses')}
</div>
{stats.topProcesses.length > 0 ? (
<div className="space-y-2">
{stats.topProcesses.slice(0, 5).map((proc) => (
<div key={`${proc.pid}-${proc.command}`} className="space-y-1.5">
<div className="flex items-center justify-between gap-2 text-[11px]">
<span className="min-w-0 truncate font-medium" style={{ color: 'hsl(var(--foreground))' }}>{proc.command}</span>
<span className="shrink-0 tabular-nums" style={{ color: 'hsl(var(--muted-foreground))' }}>PID {proc.pid}</span>
</div>
<ResourceBar label="MEM" value={proc.memPercent} />
</div>
))}
</div>
) : (
<div className="text-[11px]" style={{ color: 'hsl(var(--muted-foreground))' }}>{t('systemManager.overview.noTopProcesses')}</div>
)}
</section>
</div>
) : null}
</SystemPanelShell>
);
});

View File

@@ -0,0 +1,69 @@
import React, { memo } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
interface SystemPanelConfirmDialogProps {
open: boolean;
title: string;
message: string;
confirmLabel: string;
busy?: boolean;
destructive?: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}
/**
* In-app confirm dialog. Prefer this over window.confirm() in Electron side panels:
* native confirms can leave focus/modal state broken on Windows, which blocks
* subsequent Radix dialogs (e.g. tmux "new session" right after detach).
*/
export const SystemPanelConfirmDialog = memo(function SystemPanelConfirmDialog({
open,
title,
message,
confirmLabel,
busy = false,
destructive = false,
onOpenChange,
onConfirm,
}: SystemPanelConfirmDialogProps) {
const { t } = useI18n();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[380px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{message}</p>
<DialogFooter>
<button
type="button"
onClick={() => onOpenChange(false)}
disabled={busy}
className="px-3 py-1.5 text-sm rounded-md border border-border hover:bg-muted transition-colors disabled:opacity-50"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={onConfirm}
disabled={busy}
className={cn(
'px-3 py-1.5 text-sm rounded-md transition-colors disabled:opacity-50',
destructive
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-primary text-primary-foreground hover:bg-primary/90',
)}
>
{confirmLabel}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});

View File

@@ -0,0 +1,133 @@
import React, { memo, useEffect, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Input } from '../ui/input';
export interface SystemPanelPromptField {
id: string;
label: string;
placeholder?: string;
initialValue?: string;
mono?: boolean;
/** Defaults to true; optional fields may be submitted empty. */
required?: boolean;
}
interface SystemPanelPromptDialogProps {
open: boolean;
title: string;
fields: SystemPanelPromptField[];
confirmLabel: string;
busy?: boolean;
error?: string | null;
/** Return an error message to block submit, or null to accept. */
validate?: (values: Record<string, string>) => string | null;
onOpenChange: (open: boolean) => void;
onSubmit: (values: Record<string, string>) => void;
}
/**
* Dialog replacement for window.prompt(), which Electron does not support
* (calling it throws, leaving buttons silently dead).
*/
export const SystemPanelPromptDialog = memo(function SystemPanelPromptDialog({
open,
title,
fields,
confirmLabel,
busy = false,
error,
validate,
onOpenChange,
onSubmit,
}: SystemPanelPromptDialogProps) {
const { t } = useI18n();
const [values, setValues] = useState<Record<string, string>>({});
const [localError, setLocalError] = useState<string | null>(null);
useEffect(() => {
if (open) {
const initial: Record<string, string> = {};
for (const field of fields) initial[field.id] = field.initialValue ?? '';
setValues(initial);
setLocalError(null);
}
// Reinitialize only when the dialog (re)opens — `fields` is rebuilt by
// callers on every render, so depending on it would wipe user input.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const hasEmptyField = fields.some(
(field) => (field.required ?? true) && !(values[field.id] ?? '').trim(),
);
const handleSubmit = () => {
const trimmed: Record<string, string> = {};
for (const field of fields) trimmed[field.id] = (values[field.id] ?? '').trim();
const validationError = validate?.(trimmed) ?? null;
if (validationError) {
setLocalError(validationError);
return;
}
setLocalError(null);
onSubmit(trimmed);
};
const displayError = localError || error;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[380px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-1">
{fields.map((field, index) => (
<div key={field.id} className="space-y-2">
<label className="text-sm font-medium" htmlFor={`system-prompt-${field.id}`}>
{field.label}
</label>
<Input
id={`system-prompt-${field.id}`}
value={values[field.id] ?? ''}
onChange={(e) => setValues((prev) => ({ ...prev, [field.id]: e.target.value }))}
placeholder={field.placeholder}
className={cn('h-9 text-sm', field.mono && 'font-mono')}
autoFocus={index === 0}
disabled={busy}
onKeyDown={(e) => {
if (e.key === 'Enter' && !busy && !hasEmptyField) handleSubmit();
}}
/>
</div>
))}
{displayError && (
<p className="text-xs text-destructive">{displayError}</p>
)}
</div>
<DialogFooter>
<button
type="button"
onClick={() => onOpenChange(false)}
disabled={busy}
className="px-3 py-1.5 text-sm rounded-md border border-border hover:bg-muted transition-colors disabled:opacity-50"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={busy || hasEmptyField}
className="px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{confirmLabel}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});

View File

@@ -0,0 +1,625 @@
import { Loader2, RefreshCw, Search, Unplug } from 'lucide-react';
import React, { memo, useEffect, useRef, useState, type ReactNode } from 'react';
import { cn } from '../../lib/utils';
import { Input } from '../ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
function splitPanelMessage(message: string): string[] {
return message.match(/[^。.!?]+[。.!?]?/g)?.map((line) => line.trim()).filter(Boolean) ?? [message];
}
function SystemPanelMessage({
message,
className,
}: {
message: string;
className?: string;
}) {
const lines = splitPanelMessage(message);
if (lines.length <= 1) {
return <span className={className}>{message}</span>;
}
return (
<span className={className}>
{lines.map((line, index) => (
<span key={`${line}-${index}`} className="block">
{line}
</span>
))}
</span>
);
}
export const SystemPanelShell = memo(function SystemPanelShell({
children,
section,
className,
}: {
children: ReactNode;
section?: string;
className?: string;
}) {
return (
<div
className={cn('h-full flex flex-col bg-background overflow-hidden', className)}
data-section={section}
>
{children}
</div>
);
});
export const SystemPanelToolbar = memo(function SystemPanelToolbar({
children,
trailing,
}: {
children?: ReactNode;
trailing?: ReactNode;
}) {
return (
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1.5">
<div className="flex flex-1 min-w-0 items-center gap-1.5">{children}</div>
{trailing && <div className="flex shrink-0 items-center gap-1.5">{trailing}</div>}
</div>
);
});
export const SystemPanelSearch = memo(function SystemPanelSearch({
value,
onChange,
placeholder,
onEnter,
}: {
value: string;
onChange: (value: string) => void;
placeholder: string;
onEnter?: () => void;
}) {
return (
<div className="relative flex-1 min-w-0 group">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none transition-colors group-focus-within:text-primary" />
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="h-7 pl-7 text-xs bg-muted/30 border-none focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:bg-muted/50 transition-all"
onKeyDown={(e) => { if (e.key === 'Enter') onEnter?.(); }}
/>
</div>
);
});
export const SystemPanelIconButton = memo(function SystemPanelIconButton({
title,
onClick,
disabled,
destructive,
children,
}: {
title: string;
onClick?: () => void;
disabled?: boolean;
destructive?: boolean;
children: ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<button
type="button"
aria-label={title}
disabled={disabled}
onClick={onClick}
className={cn(
'shrink-0 h-7 w-7 flex items-center justify-center rounded-md transition-colors disabled:opacity-40 disabled:pointer-events-none',
destructive
? 'text-muted-foreground hover:text-destructive hover:bg-destructive/10'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/60',
)}
>
{children}
</button>
</span>
</TooltipTrigger>
<TooltipContent>{title}</TooltipContent>
</Tooltip>
);
});
export const SystemPanelRefreshButton = memo(function SystemPanelRefreshButton({
title,
loading,
onClick,
}: {
title: string;
loading?: boolean;
onClick: () => void;
}) {
return (
<SystemPanelIconButton title={title} onClick={onClick} disabled={loading}>
<RefreshCw size={14} className={cn(loading && 'animate-spin')} />
</SystemPanelIconButton>
);
});
export const SystemPanelSegmented = memo(function SystemPanelSegmented<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: { id: T; label: string }[];
onChange: (value: T) => void;
}) {
return (
<div className="shrink-0 flex items-center gap-0.5 px-2 py-1 border-b border-border/30 overflow-x-auto">
{options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
className={cn(
'shrink-0 px-2.5 py-0.5 rounded-md text-[10px] transition-all duration-200 whitespace-nowrap',
value === option.id
? 'bg-primary/15 text-primary font-medium shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
)}
>
{option.label}
</button>
))}
</div>
);
});
export const SystemPanelMetaBar = memo(function SystemPanelMetaBar({
children,
trailing,
}: {
children: ReactNode;
trailing?: ReactNode;
}) {
return (
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 text-[11px] text-muted-foreground border-b border-border/30 min-h-[28px]">
<div className="flex-1 min-w-0 truncate">{children}</div>
{trailing}
</div>
);
});
export const SystemPanelEmpty = memo(function SystemPanelEmpty({
icon: Icon,
message,
}: {
icon: React.ComponentType<{ size?: number; className?: string }>;
message: string;
}) {
return (
<div className="flex flex-col items-center justify-center py-10 px-4 text-muted-foreground text-center">
<Icon size={24} className="opacity-40 mb-2" />
<SystemPanelMessage message={message} className="max-w-[260px] text-xs leading-5" />
</div>
);
});
export const SystemPanelLoading = memo(function SystemPanelLoading({
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>
);
});
export const SystemPanelError = memo(function SystemPanelError({
message,
onRetry,
retryLabel,
loading,
}: {
message: string;
onRetry?: () => void;
retryLabel?: string;
loading?: boolean;
}) {
return (
<div className="flex h-full min-h-[180px] flex-col items-center justify-center px-6 py-10 text-center text-muted-foreground">
<Unplug size={24} className="mb-2 opacity-40" />
<SystemPanelMessage message={message} className="max-w-[260px] break-words text-xs leading-5" />
{onRetry && retryLabel && (
<button
type="button"
onClick={onRetry}
disabled={loading}
className="mt-3 inline-flex h-7 items-center gap-1.5 rounded px-2 text-[11px] text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
>
<RefreshCw size={12} className={cn(loading && 'animate-spin')} />
{retryLabel}
</button>
)}
</div>
);
});
export const SystemPanelInlineError = memo(function SystemPanelInlineError({
message,
onRetry,
retryLabel,
loading,
}: {
message: string;
onRetry?: () => void;
retryLabel?: string;
loading?: boolean;
}) {
return (
<div className="shrink-0 flex items-center gap-2 px-3 py-2 text-[11px] text-muted-foreground border-b border-border/30 bg-muted/20">
<Unplug size={12} className="shrink-0 opacity-60" />
<span className="min-w-0 truncate">{message}</span>
{onRetry && retryLabel && (
<button
type="button"
onClick={onRetry}
disabled={loading}
className="ml-auto inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] transition-colors hover:bg-muted/60 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
>
<RefreshCw size={10} className={cn(loading && 'animate-spin')} />
{retryLabel}
</button>
)}
</div>
);
});
export const SystemPanelList = memo(function SystemPanelList({
children,
}: {
children: ReactNode;
}) {
// No divide-y here: the collapsible wrapper stays mounted at zero height
// during its exit animation, and a divider on it would add a moving 1px
// seam. Rows carry their own border-b instead.
return (
<div className="flex-1 min-h-0 overflow-y-auto">
{children}
</div>
);
});
export const SystemPanelRow = memo(function SystemPanelRow({
selected,
onClick,
depth = 0,
leading,
title,
subtitle,
trailing,
actions,
className,
}: {
selected?: boolean;
onClick?: () => void;
depth?: number;
leading?: ReactNode;
title: ReactNode;
subtitle?: ReactNode;
trailing?: ReactNode;
actions?: ReactNode;
className?: string;
}) {
const content = (
<>
{leading}
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">{title}</div>
{subtitle && (
<div className="text-[10px] text-muted-foreground truncate mt-0.5">{subtitle}</div>
)}
</div>
{trailing}
{actions && (
<div
className="flex shrink-0 items-center justify-end gap-0.5"
onClick={(e) => e.stopPropagation()}
>
{actions}
</div>
)}
</>
);
const rowClassName = cn(
'group flex items-center gap-2.5 pr-2.5 py-2.5 min-h-[44px] border-b border-border/30 relative',
'transition-colors duration-150',
selected && 'bg-accent/30',
selected && 'before:absolute before:left-0 before:top-0 before:bottom-0 before:w-0.5 before:bg-primary',
onClick && 'cursor-pointer hover:bg-accent/40',
className,
);
const style = { paddingLeft: 12 + depth * 14 };
if (onClick) {
// Not a <button>: trailing/actions hold real buttons, and interactive
// content may not nest inside a button element.
return (
<div
role="button"
tabIndex={0}
className={cn('w-full text-left', rowClassName)}
style={style}
onClick={onClick}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
>
{content}
</div>
);
}
return (
<div className={rowClassName} style={style}>
{content}
</div>
);
});
export const SystemPanelDetailStrip = memo(function SystemPanelDetailStrip({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={cn('border-b border-border/40 bg-muted/20 px-3 py-2', className)}>
{children}
</div>
);
});
const COLLAPSE_MS = 180;
/**
* Expand/collapse with a height animation (grid-template-rows 0fr→1fr, no
* measuring). Children mount on open and unmount after the exit transition;
* the last rendered children are kept during exit so collapse animates even
* when the parent clears them together with the open flag.
*/
export function SystemPanelCollapsible({
open,
children,
}: {
open: boolean;
children: ReactNode;
}) {
const [mounted, setMounted] = useState(open);
const [expanded, setExpanded] = useState(open);
const lastChildrenRef = useRef<ReactNode>(children);
if (open) lastChildrenRef.current = children;
useEffect(() => {
if (open) {
setMounted(true);
// Two frames so the 0fr state paints before transitioning to 1fr.
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => setExpanded(true));
});
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}
setExpanded(false);
const timer = setTimeout(() => {
setMounted(false);
lastChildrenRef.current = null;
}, COLLAPSE_MS);
return () => clearTimeout(timer);
}, [open]);
if (!mounted) return null;
return (
<div
className={cn(
'grid transition-[grid-template-rows] ease-out motion-reduce:transition-none',
expanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
)}
style={{ transitionDuration: `${COLLAPSE_MS}ms` }}
>
<div className="min-h-0 overflow-hidden">
{open ? children : lastChildrenRef.current}
</div>
</div>
);
}
export const SystemPanelActionChip = memo(function SystemPanelActionChip({
title,
onClick,
destructive,
disabled,
children,
}: {
title: string;
onClick: () => void;
destructive?: boolean;
disabled?: boolean;
children: ReactNode;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
disabled={disabled}
onClick={onClick}
className={cn(
'h-6 px-2 inline-flex items-center gap-1 rounded text-[10px] transition-colors disabled:opacity-40',
destructive
? 'text-muted-foreground hover:text-destructive hover:bg-destructive/10'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/70',
)}
>
{children}
</button>
);
});
export const SystemPanelMiniButton = memo(function SystemPanelMiniButton({
title,
onClick,
disabled,
children,
}: {
title: string;
onClick: () => void;
disabled?: boolean;
children: ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<button
type="button"
aria-label={title}
disabled={disabled}
onClick={onClick}
className="h-6 w-6 shrink-0 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted/70 transition-colors disabled:opacity-40"
>
{children}
</button>
</span>
</TooltipTrigger>
<TooltipContent>{title}</TooltipContent>
</Tooltip>
);
});
/** Solid bright status pill — same palette as the vault entity icons. */
export const SystemPanelStatusBadge = memo(function SystemPanelStatusBadge({
tone,
children,
}: {
tone: 'success' | 'warning' | 'muted';
children: ReactNode;
}) {
return (
<span className={cn(
// h-6 matches SystemPanelRoundButton so pills and round buttons align.
'inline-flex shrink-0 min-w-[52px] h-6 items-center justify-center text-[10px] font-medium px-2 rounded-full tabular-nums relative overflow-hidden',
'shadow-sm',
tone === 'success' && 'bg-emerald-600 text-white dark:bg-emerald-400 dark:text-slate-950',
tone === 'warning' && 'bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950',
tone === 'muted' && 'bg-slate-500 text-white dark:bg-slate-400 dark:text-slate-950',
)}>
{/* Subtle top highlight sheen */}
<span className="absolute inset-x-0 top-0 h-1/2 bg-white/10 rounded-t-full pointer-events-none" />
<span className="relative">{children}</span>
</span>
);
});
/** Always-visible round icon button for list-row quick actions. */
export const SystemPanelRoundButton = memo(function SystemPanelRoundButton({
title,
onClick,
disabled,
destructive,
loading,
children,
}: {
title: string;
onClick: () => void;
disabled?: boolean;
destructive?: boolean;
/** Shows a spinner instead of the icon and disables the button. */
loading?: boolean;
children: ReactNode;
}) {
const isDisabled = disabled || loading;
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<button
type="button"
aria-label={title}
disabled={isDisabled}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className={cn(
'h-6 w-6 shrink-0 rounded-full flex items-center justify-center bg-muted/60 text-muted-foreground transition-colors disabled:opacity-40',
destructive
? 'hover:bg-destructive/20 hover:text-destructive'
: 'hover:bg-muted hover:text-foreground',
)}
>
{loading ? <Loader2 size={12} className="animate-spin" /> : children}
</button>
</span>
</TooltipTrigger>
<TooltipContent>{title}</TooltipContent>
</Tooltip>
);
});
/** Small uppercase section divider inside expanded details. */
export const SystemPanelSectionHeader = memo(function SystemPanelSectionHeader({
children,
trailing,
}: {
children: ReactNode;
trailing?: ReactNode;
}) {
return (
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-border/30 bg-muted/10">
<div className="flex-1 min-w-0 truncate text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{children}
</div>
{trailing}
</div>
);
});
export const SystemPanelInspectBlock = memo(function SystemPanelInspectBlock({
title,
data,
onClose,
closeLabel,
}: {
title: string;
data: Record<string, unknown>;
onClose?: () => void;
closeLabel?: string;
}) {
return (
<SystemPanelDetailStrip>
<div className="flex items-center justify-between gap-2 mb-2">
<span className="text-[11px] font-medium">{title}</span>
{onClose && closeLabel && (
<button type="button" onClick={onClose} className="text-[10px] text-muted-foreground hover:text-foreground">
{closeLabel}
</button>
)}
</div>
<pre className="font-mono text-[10px] text-muted-foreground overflow-auto max-h-40 whitespace-pre-wrap break-all leading-relaxed">
{JSON.stringify(data, null, 2)}
</pre>
</SystemPanelDetailStrip>
);
});

View File

@@ -0,0 +1,255 @@
import { Plus, TerminalSquare } 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 type { Snippet, TerminalSession } from '../../types';
import type { TmuxClientInfo, TmuxSessionInfo, TmuxWindowInfo } from '../../domain/systemManager/types';
import { tmuxSessionInfoEqual } from '../../domain/systemManager/pollEquals';
import {
SystemPanelEmpty,
SystemPanelError,
SystemPanelIconButton,
SystemPanelList,
SystemPanelLoading,
SystemPanelMetaBar,
SystemPanelRefreshButton,
SystemPanelSearch,
SystemPanelShell,
SystemPanelToolbar,
} from './SystemPanelUi';
import { useAsyncRecordCache } from '../../application/state/systemManager/useAsyncRecordCache';
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
import { TmuxNewSessionModal } from './TmuxNewSessionModal';
import { TmuxSessionCard } from './TmuxSessionCard';
import { useStableListOrder, mergePollListByKey } from './listStable';
type Backend = ReturnType<typeof useSystemManagerBackend>;
export interface TmuxSessionDetails {
windows: TmuxWindowInfo[];
clients: TmuxClientInfo[];
}
interface TmuxManagerTabProps {
sessionId: string;
parentSession: TerminalSession;
isVisible: boolean;
warmupEnabled?: boolean;
backend: Backend;
refreshIntervalSec: number;
snippets: Snippet[];
onRequestTerminalFocus?: () => void;
}
export const TmuxManagerTab = memo(function TmuxManagerTab({
sessionId,
parentSession,
isVisible,
warmupEnabled = false,
backend,
refreshIntervalSec,
snippets,
onRequestTerminalFocus,
}: TmuxManagerTabProps) {
const { t } = useI18n();
const stableT = useStableTranslate();
const [query, setQuery] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [modalError, setModalError] = useState<string | null>(null);
const [tmuxVersion, setTmuxVersion] = useState<string | null>(null);
const currentSessionIdRef = useRef(sessionId);
currentSessionIdRef.current = sessionId;
useEffect(() => {
setTmuxVersion(null);
}, [sessionId]);
const fetcher = useCallback(async () => {
const fetchSessionId = sessionId;
const result = await backend.listTmuxSessions(sessionId);
const version = result.tmuxVersion ?? null;
if (currentSessionIdRef.current === fetchSessionId) {
setTmuxVersion((prev) => (prev === version ? prev : version));
}
if (!result.success) {
throw new Error(result.error || stableT('systemManager.errors.loadTmux'));
}
return result.sessions ?? [];
}, [backend, sessionId, stableT]);
const intervalMs = Math.max(2, refreshIntervalSec) * 1000;
const { data: sessions, error, loading, refresh } = usePolling<TmuxSessionInfo[]>(
fetcher,
intervalMs,
isVisible || warmupEnabled,
(prev, next) => mergePollListByKey(prev, next, (s) => s.name, tmuxSessionInfoEqual),
{ poll: isVisible, resetKey: sessionId },
);
const filtered = useMemo<TmuxSessionInfo[]>(() => {
const q = query.trim().toLowerCase();
const list = sessions ?? [];
if (!q) return list;
return list.filter((session) => session.name.toLowerCase().includes(q));
}, [query, sessions]);
const compareSessions = useCallback(
(a: TmuxSessionInfo, b: TmuxSessionInfo) => a.name.localeCompare(b.name),
[],
);
const displaySessions = useStableListOrder<TmuxSessionInfo, string>(
filtered,
(s) => s.name,
query,
compareSessions,
);
const formatTmuxLoadError = useCallback((
message: string,
debug?: { lastOutput?: string; tried?: string[] },
) => {
const parts = [message];
if (debug?.lastOutput) parts.push(debug.lastOutput);
if (debug?.tried?.length) {
parts.push(t('systemManager.tmux.lastCommand', { command: debug.tried[debug.tried.length - 1] ?? '' }));
}
return parts.filter(Boolean).join(' · ');
}, [t]);
const getTmuxDetailsKey = useCallback((session: TmuxSessionInfo) => (
`${sessionId}:${session.name}:${session.created}`
), [sessionId]);
const fetchTmuxDetails = useCallback(async (session: TmuxSessionInfo): Promise<TmuxSessionDetails> => {
const [windowsResult, clientsResult] = await Promise.all([
backend.listTmuxWindows({ sessionId, sessionName: session.name }),
backend.listTmuxClients({ sessionId, sessionName: session.name }),
]);
if (!windowsResult.success) {
throw new Error(formatTmuxLoadError(
windowsResult.error || stableT('systemManager.errors.loadTmuxWindows'),
windowsResult.debug,
));
}
if (!clientsResult.success) {
throw new Error(clientsResult.error || stableT('systemManager.errors.loadTmuxClients'));
}
const freshWindows = windowsResult.windows ?? [];
if (freshWindows.length === 0 && session.windows > 0) {
throw new Error(formatTmuxLoadError(
stableT('systemManager.tmux.windowsMismatch', { count: String(session.windows) }),
windowsResult.debug,
));
}
return {
windows: freshWindows,
clients: clientsResult.clients ?? [],
};
}, [backend, formatTmuxLoadError, sessionId, stableT]);
const {
records: tmuxDetailsByName,
loadRecord: loadTmuxDetails,
refreshRecord: refreshTmuxDetails,
} = useAsyncRecordCache<TmuxSessionInfo, TmuxSessionDetails>({
items: sessions ?? [],
enabled: isVisible && (sessions?.length ?? 0) > 0,
getKey: getTmuxDetailsKey,
fetchRecord: fetchTmuxDetails,
prefetchLimit: 16,
prefetchDelayMs: 40,
staleTimeMs: 20_000,
});
const handleCreate = useCallback(async (name: string, command: string) => {
setCreating(true);
setModalError(null);
try {
const result = await backend.createTmuxSession({
sessionId,
name,
command: command || undefined,
});
if (!result.success) throw new Error(result.error);
setModalOpen(false);
await refresh();
} catch (err) {
setModalError(err instanceof Error ? err.message : t('systemManager.errors.actionFailed'));
} finally {
setCreating(false);
}
}, [backend, refresh, sessionId, t]);
return (
<SystemPanelShell section="system-manager-tmux">
<SystemPanelToolbar
trailing={(
<>
<SystemPanelIconButton
title={t('systemManager.tmux.new')}
onClick={() => {
setModalError(null);
setModalOpen(true);
}}
>
<Plus size={14} />
</SystemPanelIconButton>
<SystemPanelRefreshButton
title={t('history.action.refresh')}
loading={loading}
onClick={() => void refresh()}
/>
</>
)}
>
<SystemPanelSearch
value={query}
onChange={setQuery}
placeholder={t('systemManager.tmux.search')}
/>
</SystemPanelToolbar>
<SystemPanelMetaBar trailing={tmuxVersion ? (
<span className="shrink-0 text-[10px] text-muted-foreground">{tmuxVersion}</span>
) : undefined}>
{t('systemManager.tmux.meta', { count: displaySessions.length })}
</SystemPanelMetaBar>
<SystemPanelList>
{!error && displaySessions.length === 0 && loading && (
<SystemPanelLoading message={t('systemManager.common.loading')} />
)}
{!error && displaySessions.length === 0 && !loading && (
<SystemPanelEmpty icon={TerminalSquare} message={t('systemManager.tmux.empty')} />
)}
{error && (
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
)}
{displaySessions.map((session) => (
<TmuxSessionCard
key={`${session.name}:${session.created}`}
session={session}
sessionId={sessionId}
parentSession={parentSession}
backend={backend}
detailsRecord={tmuxDetailsByName[getTmuxDetailsKey(session)]}
onLoadDetails={loadTmuxDetails}
onRefreshDetails={refreshTmuxDetails}
onSessionsChanged={refresh}
onRequestTerminalFocus={onRequestTerminalFocus}
/>
))}
</SystemPanelList>
<TmuxNewSessionModal
open={modalOpen}
onOpenChange={setModalOpen}
onCreate={handleCreate}
snippets={snippets}
creating={creating}
error={modalError}
/>
</SystemPanelShell>
);
});

View File

@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const modalSource = readFileSync(new URL("./TmuxNewSessionModal.tsx", import.meta.url), "utf8");
const editorSource = readFileSync(new URL("../snippets/SnippetScriptEditor.tsx", import.meta.url), "utf8");
test("tmux new session modal is only modestly wider than the default dialog", () => {
assert.match(modalSource, /w-\[min\(92vw,560px\)\]/);
assert.match(modalSource, /max-w-none/);
assert.doesNotMatch(modalSource, /bg-background\/95/);
assert.doesNotMatch(modalSource, /bg-muted\/10/);
assert.doesNotMatch(modalSource, /border-b border-border\/60/);
assert.doesNotMatch(modalSource, /border-t border-border\/60/);
});
test("tmux command editor does not inherit the global snippet editor height", () => {
assert.match(modalSource, /defaultHeight=\{150\}/);
assert.match(modalSource, /maxHeight=\{260\}/);
assert.match(modalSource, /persistHeight=\{false\}/);
assert.match(editorSource, /persistHeight\?: boolean/);
});

View File

@@ -0,0 +1,196 @@
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { Snippet } from '../../types';
import { SnippetCommandPicker } from '../snippets/SnippetCommandPicker';
import { SnippetScriptEditor } from '../snippets/SnippetScriptEditor';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
type CommandTab = 'custom' | 'snippet';
interface TmuxNewSessionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreate: (name: string, command: string) => Promise<void>;
snippets: Snippet[];
creating?: boolean;
error?: string | null;
}
export const TmuxNewSessionModal = memo(function TmuxNewSessionModal({
open,
onOpenChange,
onCreate,
snippets,
creating = false,
error,
}: TmuxNewSessionModalProps) {
const { t } = useI18n();
const [commandTab, setCommandTab] = useState<CommandTab>('custom');
const [name, setName] = useState('');
const [command, setCommand] = useState('');
const [selectedSnippetId, setSelectedSnippetId] = useState<string | null>(null);
const [localError, setLocalError] = useState<string | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setCommandTab('custom');
setName('');
setCommand('');
setSelectedSnippetId(null);
setLocalError(null);
}
}, [open]);
useEffect(() => {
if (!open) return;
const id = window.setTimeout(() => nameInputRef.current?.focus(), 50);
return () => window.clearTimeout(id);
}, [open]);
const handleSnippetSelect = useCallback((snippet: Snippet) => {
setSelectedSnippetId(snippet.id);
setCommand(snippet.command);
if (!name.trim() && snippet.label.trim()) {
setName(snippet.label.trim().slice(0, 64));
}
}, [name]);
const handleCreate = useCallback(async () => {
const trimmedName = name.trim();
if (!trimmedName) {
setLocalError(t('systemManager.tmux.newSessionRequired'));
return;
}
setLocalError(null);
await onCreate(trimmedName, command);
}, [command, name, onCreate, t]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.defaultPrevented) return;
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !creating && name.trim()) {
e.preventDefault();
void handleCreate();
}
}, [creating, handleCreate, name]);
const handleSubmitShortcut = useCallback(() => {
if (!creating && name.trim()) void handleCreate();
}, [creating, handleCreate, name]);
const handleCommandChange = useCallback((value: string) => {
setCommand(value);
if (selectedSnippetId) {
const linked = snippets.find((snippet) => snippet.id === selectedSnippetId);
if (linked && value !== linked.command) {
setSelectedSnippetId(null);
}
}
}, [selectedSnippetId, snippets]);
const displayError = localError || error;
const selectedSnippet = snippets.find((snippet) => snippet.id === selectedSnippetId) ?? null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex max-h-[min(88vh,680px)] w-[min(92vw,560px)] max-w-none flex-col overflow-hidden"
onKeyDown={handleKeyDown}
>
<DialogHeader className="shrink-0 pr-8">
<DialogTitle>{t('systemManager.tmux.newSessionTitle')}</DialogTitle>
<DialogDescription>{t('systemManager.tmux.newSessionDesc')}</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
<div className="space-y-1.5">
<Label htmlFor="tmux-new-session-name" className="text-xs">
{t('systemManager.tmux.newSessionName')}
</Label>
<Input
id="tmux-new-session-name"
ref={nameInputRef}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('systemManager.tmux.newSessionPlaceholder')}
className="h-9"
spellCheck={false}
disabled={creating}
/>
</div>
<Tabs
value={commandTab}
onValueChange={(value) => setCommandTab(value as CommandTab)}
className="flex min-h-0 flex-col"
>
<TabsList className="grid h-8 w-full grid-cols-2 bg-muted/50 p-0.5">
<TabsTrigger value="custom" className="h-7 text-xs">
{t('systemManager.tmux.newSessionTabCustom')}
</TabsTrigger>
<TabsTrigger value="snippet" className="h-7 text-xs">
{t('systemManager.tmux.newSessionTabSnippet')}
</TabsTrigger>
</TabsList>
<TabsContent value="custom" className="mt-3 space-y-3 focus-visible:outline-none">
<SnippetScriptEditor
id="tmux-new-session-command"
label={t('systemManager.tmux.newSessionCommand')}
value={command}
onChange={handleCommandChange}
onSubmitShortcut={handleSubmitShortcut}
placeholder={t('systemManager.tmux.newSessionCommandPlaceholder')}
defaultHeight={150}
maxHeight={260}
persistHeight={false}
/>
<p className="text-[11px] text-muted-foreground">
{t('systemManager.tmux.newSessionCommandHint')}
</p>
</TabsContent>
<TabsContent value="snippet" className="mt-3 space-y-3 focus-visible:outline-none">
<SnippetCommandPicker
snippets={snippets}
selectedId={selectedSnippetId}
onSelect={handleSnippetSelect}
showTitle={false}
className="h-[240px] min-h-[240px]"
/>
{selectedSnippet && (
<p className="text-[11px] text-muted-foreground">
{t('systemManager.tmux.selectedSnippet', { label: selectedSnippet.label })}
</p>
)}
</TabsContent>
</Tabs>
{displayError && (
<p className="text-xs text-destructive">{displayError}</p>
)}
</div>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={creating}>
{t('common.cancel')}
</Button>
<Button onClick={() => void handleCreate()} disabled={creating || !name.trim()}>
{creating ? t('systemManager.tmux.creating') : t('common.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});

View File

@@ -0,0 +1,412 @@
import {
Loader2, MonitorPlay, Pencil, Plus, Trash2, Unplug,
} from 'lucide-react';
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import { buildTmuxAttachCommand } from '../../domain/systemManager/tmuxShell';
import type {
TmuxManageAction,
TmuxSessionInfo,
} from '../../domain/systemManager/types';
import type { TerminalSession } from '../../types';
import type { AsyncRecordState } from '../../application/state/systemManager/useAsyncRecordCache';
import type { TmuxSessionDetails } from './TmuxManagerTab';
import {
SystemPanelCollapsible,
SystemPanelDetailStrip,
SystemPanelInlineError,
SystemPanelRoundButton,
SystemPanelRow,
SystemPanelSectionHeader,
SystemPanelStatusBadge,
} from './SystemPanelUi';
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
import { openInteractiveTerminal } from './openInteractiveTerminal';
import { showSystemManagerError } from './systemManagerToast';
import { runTmuxSessionAction } from './tmuxActionFocus';
type Backend = ReturnType<typeof useSystemManagerBackend>;
const TMUX_POPUP_ICON = {
kind: 'image',
src: '/system-icons/tmux.svg',
alt: 'tmux',
} as const;
type RenamePromptTarget =
| { kind: 'session' }
| { kind: 'window'; windowIndex: number; currentName: string };
interface PendingTarget {
action: TmuxManageAction['action'];
windowIndex?: number;
}
interface ConfirmTmuxDetachOptions {
sessionName: string;
confirmMessage: string;
confirm: (message: string) => boolean;
runAction: (action: TmuxManageAction) => Promise<void>;
}
export async function runConfirmedTmuxDetachAction({
sessionName,
confirmMessage,
confirm,
runAction,
}: ConfirmTmuxDetachOptions): Promise<boolean> {
if (!confirm(confirmMessage)) return false;
await runAction({ action: 'detachSession', sessionName });
return true;
}
interface TmuxSessionCardProps {
session: TmuxSessionInfo;
sessionId: string;
parentSession: TerminalSession;
backend: Backend;
detailsRecord?: AsyncRecordState<TmuxSessionDetails>;
onLoadDetails: (session: TmuxSessionInfo, options?: { force?: boolean; urgent?: boolean }) => Promise<void>;
onRefreshDetails: (session: TmuxSessionInfo) => Promise<void>;
onSessionsChanged: () => Promise<void>;
onRequestTerminalFocus?: () => void;
}
export const TmuxSessionCard = memo(function TmuxSessionCard({
session,
sessionId,
parentSession,
backend,
detailsRecord,
onLoadDetails,
onRefreshDetails,
onSessionsChanged,
onRequestTerminalFocus,
}: TmuxSessionCardProps) {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
const [renamePrompt, setRenamePrompt] = useState<RenamePromptTarget | null>(null);
const [detachConfirmOpen, setDetachConfirmOpen] = useState(false);
const [killSessionConfirmOpen, setKillSessionConfirmOpen] = useState(false);
const [killWindowConfirm, setKillWindowConfirm] = useState<{
windowIndex: number;
windowName: string;
} | null>(null);
const [newWindowOpen, setNewWindowOpen] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [pending, setPending] = useState<PendingTarget | null>(null);
const windows = detailsRecord?.data?.windows ?? [];
const clients = detailsRecord?.data?.clients ?? [];
const loadingDetails = detailsRecord?.loading ?? false;
const windowsLoadDetail = detailsRecord?.error ?? null;
const summaryKey = useMemo(
() => `${session.name}|${session.created}|${session.windows}|${session.attached}|${session.activity ?? ''}`,
[session.activity, session.attached, session.created, session.name, session.windows],
);
const lastExpandedSummaryKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!expanded) {
lastExpandedSummaryKeyRef.current = null;
return;
}
if (lastExpandedSummaryKeyRef.current === null) {
lastExpandedSummaryKeyRef.current = summaryKey;
return;
}
if (lastExpandedSummaryKeyRef.current === summaryKey) return;
lastExpandedSummaryKeyRef.current = summaryKey;
void onRefreshDetails(session);
}, [expanded, onRefreshDetails, session, summaryKey]);
const runAction = async (action: TmuxManageAction) => {
setBusy(true);
setPending({
action: action.action,
windowIndex: 'windowIndex' in action ? action.windowIndex : undefined,
});
setActionError(null);
try {
const cardWillRemount = action.action === 'killSession' || action.action === 'renameSession';
const result = await runTmuxSessionAction({
sessionId,
action,
tmuxAction: backend.tmuxAction,
onRefreshDetails: !cardWillRemount && expanded ? () => onRefreshDetails(session) : undefined,
onSessionsChanged,
onRequestTerminalFocus,
});
if (!result.success) throw new Error(result.error || t('systemManager.errors.actionFailed'));
} catch (err) {
setActionError(err instanceof Error ? err.message : t('systemManager.errors.actionFailed'));
} finally {
setBusy(false);
setPending(null);
}
};
const isPending = (action: TmuxManageAction['action'], windowIndex?: number) =>
pending !== null
&& pending.action === action
&& pending.windowIndex === windowIndex;
const handleAttach = async (windowIndex?: number) => {
const result = await openInteractiveTerminal(
backend,
parentSession,
windowIndex !== undefined ? `tmux: ${session.name}:${windowIndex}` : `tmux: ${session.name}`,
buildTmuxAttachCommand(session.name, windowIndex),
{ icon: TMUX_POPUP_ICON },
);
if (!result.success) {
const message = result.error || t('systemManager.errors.actionFailed');
setActionError(message);
showSystemManagerError(message, t('common.error'));
}
};
return (
<>
<SystemPanelRow
selected={expanded}
onClick={() => {
const nextExpanded = !expanded;
setExpanded(nextExpanded);
if (nextExpanded) {
void onLoadDetails(session, { force: true, urgent: true });
}
}}
title={session.name}
subtitle={t('systemManager.tmux.windows', { count: String(session.windows) })}
trailing={(
<div className="flex shrink-0 items-center gap-1">
<SystemPanelStatusBadge tone={session.attached ? 'success' : 'muted'}>
{session.attached ? t('systemManager.tmux.attached') : t('systemManager.tmux.detached')}
</SystemPanelStatusBadge>
<SystemPanelRoundButton title={t('systemManager.tmux.attach')} onClick={() => handleAttach()}>
<MonitorPlay size={12} />
</SystemPanelRoundButton>
<SystemPanelRoundButton
title={t('systemManager.tmux.rename')}
disabled={busy}
onClick={() => setRenamePrompt({ kind: 'session' })}
>
<Pencil size={12} />
</SystemPanelRoundButton>
{session.attached && (
<SystemPanelRoundButton
title={t('systemManager.tmux.detach')}
disabled={busy}
loading={isPending('detachSession')}
onClick={() => setDetachConfirmOpen(true)}
>
<Unplug size={12} />
</SystemPanelRoundButton>
)}
<SystemPanelRoundButton
title={t('systemManager.tmux.killSession')}
destructive
disabled={busy}
loading={isPending('killSession')}
onClick={() => setKillSessionConfirmOpen(true)}
>
<Trash2 size={12} />
</SystemPanelRoundButton>
</div>
)}
/>
{actionError && <SystemPanelInlineError message={actionError} />}
<SystemPanelCollapsible open={expanded}>
{loadingDetails && windows.length === 0 && (
<div className="px-3 py-2 text-[10px] text-muted-foreground border-b border-border/30">
{t('systemManager.tmux.loadingDetails')}
</div>
)}
{clients.length > 0 && (
<SystemPanelDetailStrip>
<div className="text-[10px] text-muted-foreground">
{t('systemManager.tmux.clients')}: {clients.map((c) => c.tty || c.name).join(', ')}
</div>
</SystemPanelDetailStrip>
)}
<SystemPanelSectionHeader
trailing={(
<button
type="button"
disabled={busy}
onClick={() => setNewWindowOpen(true)}
className="shrink-0 h-5 px-1.5 rounded text-[10px] text-muted-foreground hover:text-foreground hover:bg-muted/60 inline-flex items-center gap-1 disabled:opacity-40"
>
{isPending('createWindow')
? <Loader2 size={10} className="animate-spin" />
: <Plus size={10} />}
{t('systemManager.tmux.newWindow')}
</button>
)}
>
{t('systemManager.tmux.windowList')}{windows.length > 0 ? ` · ${windows.length}` : ''}
</SystemPanelSectionHeader>
{windows.map((tmuxWindow) => (
<SystemPanelRow
key={tmuxWindow.index}
depth={1}
title={`#${tmuxWindow.index} ${tmuxWindow.name || t('systemManager.tmux.unnamedWindow')}`}
trailing={(
<div className="flex shrink-0 items-center gap-1">
<SystemPanelRoundButton
title={t('systemManager.tmux.attachWindow')}
onClick={() => handleAttach(tmuxWindow.index)}
>
<MonitorPlay size={11} />
</SystemPanelRoundButton>
<SystemPanelRoundButton
title={t('systemManager.tmux.rename')}
disabled={busy}
onClick={() => setRenamePrompt({
kind: 'window',
windowIndex: tmuxWindow.index,
currentName: tmuxWindow.name,
})}
>
<Pencil size={11} />
</SystemPanelRoundButton>
<SystemPanelRoundButton
title={t('systemManager.tmux.killWindow')}
destructive
disabled={busy}
loading={isPending('killWindow', tmuxWindow.index)}
onClick={() => setKillWindowConfirm({
windowIndex: tmuxWindow.index,
windowName: tmuxWindow.name || String(tmuxWindow.index),
})}
>
<Trash2 size={11} />
</SystemPanelRoundButton>
</div>
)}
/>
))}
{!loadingDetails && windows.length === 0 && (
<div className="px-3 py-2 text-[10px] text-muted-foreground border-b border-border/30 break-all">
{windowsLoadDetail || actionError || t('systemManager.tmux.noWindows')}
</div>
)}
</SystemPanelCollapsible>
<SystemPanelConfirmDialog
open={detachConfirmOpen}
title={t('systemManager.tmux.detach')}
message={t('systemManager.tmux.confirmDetachSession', { name: session.name })}
confirmLabel={t('systemManager.tmux.detach')}
destructive
busy={busy}
onOpenChange={setDetachConfirmOpen}
onConfirm={() => {
setDetachConfirmOpen(false);
void runAction({ action: 'detachSession', sessionName: session.name });
}}
/>
<SystemPanelConfirmDialog
open={killSessionConfirmOpen}
title={t('systemManager.tmux.killSession')}
message={t('systemManager.tmux.confirmKillSession', { name: session.name })}
confirmLabel={t('systemManager.tmux.killSession')}
destructive
busy={busy}
onOpenChange={setKillSessionConfirmOpen}
onConfirm={() => {
setKillSessionConfirmOpen(false);
void runAction({ action: 'killSession', sessionName: session.name });
}}
/>
<SystemPanelConfirmDialog
open={killWindowConfirm !== null}
title={t('systemManager.tmux.killWindow')}
message={t('systemManager.tmux.confirmKillWindow', {
name: killWindowConfirm?.windowName ?? '',
})}
confirmLabel={t('systemManager.tmux.killWindow')}
destructive
busy={busy}
onOpenChange={(open) => { if (!open) setKillWindowConfirm(null); }}
onConfirm={() => {
const target = killWindowConfirm;
setKillWindowConfirm(null);
if (!target) return;
void runAction({
action: 'killWindow',
sessionName: session.name,
windowIndex: target.windowIndex,
});
}}
/>
<SystemPanelPromptDialog
open={renamePrompt !== null}
title={renamePrompt?.kind === 'window'
? t('systemManager.tmux.renameWindowPrompt')
: t('systemManager.tmux.renameSessionPrompt')}
fields={[{
id: 'name',
label: renamePrompt?.kind === 'window'
? t('systemManager.tmux.windowName')
: t('systemManager.tmux.newSessionName'),
initialValue: renamePrompt?.kind === 'window' ? renamePrompt.currentName : session.name,
}]}
confirmLabel={t('common.rename')}
busy={busy}
onOpenChange={(open) => { if (!open) setRenamePrompt(null); }}
onSubmit={(values) => {
const target = renamePrompt;
setRenamePrompt(null);
if (!target) return;
if (target.kind === 'session') {
if (values.name !== session.name) {
void runAction({ action: 'renameSession', sessionName: session.name, newName: values.name });
}
} else if (values.name !== target.currentName) {
void runAction({
action: 'renameWindow',
sessionName: session.name,
windowIndex: target.windowIndex,
newName: values.name,
});
}
}}
/>
<SystemPanelPromptDialog
open={newWindowOpen}
title={t('systemManager.tmux.newWindow')}
fields={[{
id: 'name',
label: t('systemManager.tmux.windowName'),
placeholder: t('systemManager.tmux.newWindowPlaceholder'),
required: false,
}]}
confirmLabel={t('common.create')}
busy={busy}
onOpenChange={setNewWindowOpen}
onSubmit={(values) => {
setNewWindowOpen(false);
void runAction({
action: 'createWindow',
sessionName: session.name,
windowName: values.name || undefined,
});
}}
/>
</>
);
});

View File

@@ -0,0 +1,5 @@
/** @deprecated Import from `@/application/state/systemManager/useAsyncRecordCache` instead. */
export {
useAsyncRecordCache,
type AsyncRecordState,
} from "../../../application/state/systemManager/useAsyncRecordCache";

View File

@@ -0,0 +1,7 @@
/** @deprecated Import from `@/application/state/useSystemManager` instead. */
export {
useStableTranslate,
useSessionCapabilities,
useSystemCapabilitiesWarmup,
usePolling,
} from "../../../application/state/useSystemManager";

View File

@@ -0,0 +1,57 @@
import { useMemo, useRef } from 'react';
export {
mergePollListByKey,
nextPollData,
} from '../../domain/systemManager/pollListStable';
/**
* Keep list row order stable across poll refreshes.
* Re-sorts only when sortToken changes (sort / filter / search), or when items are added/removed.
*/
export function useStableListOrder<T, K extends string | number>(
items: T[],
getKey: (item: T) => K,
sortToken: string,
compare: (a: T, b: T) => number,
): T[] {
const orderRef = useRef<K[]>([]);
const lastSortTokenRef = useRef('');
const lastMembershipRef = useRef('');
const outputRef = useRef<T[]>([]);
return useMemo(() => {
const byKey = new Map(items.map((item) => [getKey(item), item]));
const membership = [...items.map(getKey)].sort().join('|');
if (sortToken !== lastSortTokenRef.current || membership !== lastMembershipRef.current) {
lastSortTokenRef.current = sortToken;
lastMembershipRef.current = membership;
orderRef.current = [...items].sort(compare).map(getKey);
} else {
const alive = new Set(items.map(getKey));
orderRef.current = orderRef.current.filter((key) => alive.has(key));
for (const item of items) {
const key = getKey(item);
if (!orderRef.current.includes(key)) {
orderRef.current.push(key);
}
}
}
const nextOutput = orderRef.current
.map((key) => byKey.get(key))
.filter((item): item is T => item !== undefined);
const prevOutput = outputRef.current;
if (
nextOutput.length === prevOutput.length
&& nextOutput.every((item, index) => item === prevOutput[index])
) {
return prevOutput;
}
outputRef.current = nextOutput;
return nextOutput;
}, [items, sortToken, compare, getKey]);
}

View File

@@ -0,0 +1,95 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { TerminalSession } from '../../types';
import { openInteractiveTerminal } from './openInteractiveTerminal';
const parentSession = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
id: 'session-1',
hostId: 'host-1',
hostLabel: 'Prod',
hostname: 'prod.example.com',
username: 'deploy',
status: 'connected',
protocol: 'ssh',
port: 22,
...overrides,
});
test('openInteractiveTerminal opens command popups over SSH even when the source host uses Mosh', async () => {
const payloads: unknown[] = [];
const backend = {
openTerminalPopup: async (payload: unknown) => {
payloads.push(payload);
return { success: true };
},
};
await openInteractiveTerminal(
backend as never,
parentSession({ moshEnabled: true }),
'docker: api',
'docker exec -it abc123 sh',
);
assert.equal(payloads.length, 1);
assert.deepEqual(payloads[0], {
title: 'Prod · docker: api',
icon: undefined,
parentSessionId: 'session-1',
startupCommand: 'docker exec -it abc123 sh',
sourceSession: {
...parentSession({ moshEnabled: true }),
protocol: 'ssh',
moshEnabled: false,
etEnabled: false,
startupCommand: 'docker exec -it abc123 sh',
reuseConnectionFromSessionId: undefined,
},
});
});
test('openInteractiveTerminal opens command popups over SSH even when the source host uses ET', async () => {
const payloads: unknown[] = [];
const backend = {
openTerminalPopup: async (payload: unknown) => {
payloads.push(payload);
return { success: true };
},
};
await openInteractiveTerminal(
backend as never,
parentSession({ protocol: 'et' as TerminalSession['protocol'], etEnabled: true }),
'tmux: api',
'tmux attach-session -t api',
);
const payload = payloads[0] as { sourceSession: TerminalSession };
assert.equal(payload.sourceSession.protocol, 'ssh');
assert.equal(payload.sourceSession.moshEnabled, false);
assert.equal(payload.sourceSession.etEnabled, false);
assert.equal(payload.sourceSession.reuseConnectionFromSessionId, undefined);
});
test('openInteractiveTerminal keeps SSH connection reuse for ordinary connected SSH parents', async () => {
const payloads: unknown[] = [];
const backend = {
openTerminalPopup: async (payload: unknown) => {
payloads.push(payload);
return { success: true };
},
};
await openInteractiveTerminal(
backend as never,
parentSession(),
'logs: api',
'docker logs -f abc123',
);
const payload = payloads[0] as { sourceSession: TerminalSession };
assert.equal(payload.sourceSession.moshEnabled, false);
assert.equal(payload.sourceSession.protocol, 'ssh');
assert.equal(payload.sourceSession.reuseConnectionFromSessionId, 'session-1');
});

View File

@@ -0,0 +1,79 @@
import { canReuseTerminalConnection } from '../../application/state/terminalConnectionReuse';
import { writeSystemManagerDiagnostic } from '../../application/state/systemManagerDiagnostics';
import type { TerminalSession } from '../../types';
import type { TerminalPopupIcon } from '../../domain/systemManager/types';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
type Backend = ReturnType<typeof useSystemManagerBackend>;
function buildPopupTitle(parentSession: TerminalSession, title: string): string {
const hostLabel = parentSession.hostLabel.trim();
const cleanTitle = title.trim();
if (!hostLabel || !cleanTitle) return cleanTitle || hostLabel;
if (cleanTitle === hostLabel || cleanTitle.startsWith(`${hostLabel} · `)) return cleanTitle;
return `${hostLabel} · ${cleanTitle}`;
}
function buildCommandPopupSourceSession(
parentSession: TerminalSession,
startupCommand: string,
canReuseConnection: boolean,
): TerminalSession {
const runtimeProtocol = parentSession.protocol as TerminalSession['protocol'] | 'mosh' | 'et' | undefined;
const shouldUseSshShell =
runtimeProtocol === undefined ||
runtimeProtocol === 'ssh' ||
runtimeProtocol === 'mosh' ||
runtimeProtocol === 'et' ||
parentSession.moshEnabled === true ||
parentSession.etEnabled === true;
return {
...parentSession,
...(shouldUseSshShell
? {
protocol: 'ssh' as const,
moshEnabled: false,
etEnabled: false,
}
: {}),
startupCommand,
reuseConnectionFromSessionId: canReuseConnection
? parentSession.id
: undefined,
};
}
export async function openInteractiveTerminal(
backend: Backend,
parentSession: TerminalSession,
title: string,
startupCommand: string,
options?: { icon?: TerminalPopupIcon },
): Promise<{ success: boolean; error?: string }> {
const canReuseConnection = canReuseTerminalConnection(parentSession);
const popupTitle = buildPopupTitle(parentSession, title);
await writeSystemManagerDiagnostic('openInteractiveTerminal requested', {
title: popupTitle,
parentSessionId: parentSession.id,
parentProtocol: parentSession.protocol,
parentHostLabel: parentSession.hostLabel,
startupCommand,
canReuseConnection,
hasIcon: !!options?.icon,
});
const result = await backend.openTerminalPopup({
title: popupTitle,
icon: options?.icon,
parentSessionId: parentSession.id,
startupCommand,
sourceSession: buildCommandPopupSourceSession(parentSession, startupCommand, canReuseConnection),
});
await writeSystemManagerDiagnostic('openInteractiveTerminal result', {
title: popupTitle,
success: result.success,
error: result.error,
popupId: result.popupId,
});
return result;
}

View File

@@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { SystemProcessInfo } from '../../domain/systemManager/types';
import {
PROCESS_LIST_CACHE_MAX_ROWS,
PROCESS_LIST_CACHE_MAX_SESSIONS,
clearCachedProcessList,
getCachedProcessList,
getProcessListCacheStatsForTests,
resetProcessListCacheForTests,
setCachedProcessList,
} from './processListCache';
const processRow = (pid: number): SystemProcessInfo => ({
pid,
ppid: 1,
user: 'root',
stat: 'S',
command: `process-${pid}`,
cpuPercent: 0,
memPercent: 0,
rssKb: 1,
vszKb: 1,
elapsed: '0:01',
});
test.beforeEach(resetProcessListCacheForTests);
test.afterEach(resetProcessListCacheForTests);
test('process list cache stays bounded across session churn and evicts least-recently-used entries', () => {
const rows = [processRow(1), processRow(2)];
const now = Date.now();
for (let index = 0; index < PROCESS_LIST_CACHE_MAX_SESSIONS; index += 1) {
setCachedProcessList(`session-${index}`, rows, now + index);
}
assert.ok(getCachedProcessList('session-0'));
setCachedProcessList('session-overflow', rows, now + PROCESS_LIST_CACHE_MAX_SESSIONS + 1);
const stats = getProcessListCacheStatsForTests();
assert.equal(stats.sessions, PROCESS_LIST_CACHE_MAX_SESSIONS);
assert.ok(stats.sessionIds.includes('session-0'));
assert.ok(!stats.sessionIds.includes('session-1'));
});
test('process list cache enforces a total row budget and rejects one oversized response', () => {
const halfBudget = Array.from(
{ length: Math.floor(PROCESS_LIST_CACHE_MAX_ROWS / 2) + 1 },
(_, index) => processRow(index),
);
setCachedProcessList('left', halfBudget, 1);
setCachedProcessList('right', halfBudget, 2);
assert.ok(getProcessListCacheStatsForTests().rows <= PROCESS_LIST_CACHE_MAX_ROWS);
setCachedProcessList(
'oversized',
Array.from({ length: PROCESS_LIST_CACHE_MAX_ROWS + 1 }, (_, index) => processRow(index)),
3,
);
assert.equal(getCachedProcessList('oversized'), null);
});
test('process list cache releases a closed or unmounted session immediately', () => {
setCachedProcessList('closed-session', [processRow(1)]);
clearCachedProcessList('closed-session');
assert.equal(getCachedProcessList('closed-session'), null);
assert.equal(getProcessListCacheStatsForTests().sessions, 0);
});

View File

@@ -0,0 +1,76 @@
import type { SystemProcessInfo } from '../../domain/systemManager/types';
export const PROCESS_LIST_CACHE_TTL_MS = 30_000;
export const PROCESS_LIST_CACHE_MAX_SESSIONS = 16;
export const PROCESS_LIST_CACHE_MAX_ROWS = 20_000;
type ProcessListCacheEntry = {
processes: SystemProcessInfo[];
updatedAt: number;
};
const processListCache = new Map<string, ProcessListCacheEntry>();
function pruneExpiredProcessLists(now = Date.now()): void {
for (const [sessionId, entry] of processListCache) {
if (now - entry.updatedAt > PROCESS_LIST_CACHE_TTL_MS) {
processListCache.delete(sessionId);
}
}
}
function enforceProcessListCacheLimits(): void {
let totalRows = 0;
for (const entry of processListCache.values()) totalRows += entry.processes.length;
while (
processListCache.size > PROCESS_LIST_CACHE_MAX_SESSIONS
|| totalRows > PROCESS_LIST_CACHE_MAX_ROWS
) {
const oldestSessionId = processListCache.keys().next().value as string | undefined;
if (!oldestSessionId) break;
const oldest = processListCache.get(oldestSessionId);
processListCache.delete(oldestSessionId);
totalRows -= oldest?.processes.length ?? 0;
}
}
export function getCachedProcessList(sessionId: string): SystemProcessInfo[] | null {
pruneExpiredProcessLists();
const cached = processListCache.get(sessionId);
if (!cached) return null;
processListCache.delete(sessionId);
processListCache.set(sessionId, cached);
return cached.processes;
}
export function setCachedProcessList(
sessionId: string,
processes: SystemProcessInfo[],
now = Date.now(),
): void {
pruneExpiredProcessLists(now);
processListCache.delete(sessionId);
if (processes.length > PROCESS_LIST_CACHE_MAX_ROWS) return;
processListCache.set(sessionId, { processes, updatedAt: now });
enforceProcessListCacheLimits();
}
export function clearCachedProcessList(sessionId: string): void {
processListCache.delete(sessionId);
}
export function resetProcessListCacheForTests(): void {
processListCache.clear();
}
export function getProcessListCacheStatsForTests(): {
sessions: number;
rows: number;
sessionIds: string[];
} {
return {
sessions: processListCache.size,
rows: [...processListCache.values()].reduce((sum, entry) => sum + entry.processes.length, 0),
sessionIds: [...processListCache.keys()],
};
}

View File

@@ -0,0 +1,218 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyHorizontalWheelToScrollContainer,
measureSystemManagerTabBarLabeledFit,
resolveSystemManagerTabBarIconOnly,
scrollSystemManagerTabIntoView,
SYSTEM_MANAGER_TAB_BAR_EXPAND_SLACK_PX,
SYSTEM_MANAGER_TAB_BAR_ICON_ONLY_CLASS,
} from './systemManagerTabBarScroll.ts';
function mockRect(left: number, width: number) {
return {
left,
right: left + width,
width,
top: 0,
bottom: 24,
height: 24,
x: left,
y: 0,
toJSON() {
return this;
},
} as DOMRect;
}
test('scrollSystemManagerTabIntoView is a no-op when content fits', () => {
const calls: Array<{ left: number; behavior?: ScrollBehavior }> = [];
const container = {
scrollWidth: 200,
clientWidth: 200,
scrollLeft: 0,
getBoundingClientRect: () => mockRect(0, 200),
scrollTo(options: { left: number; behavior?: ScrollBehavior }) {
calls.push(options);
},
} as unknown as HTMLElement;
const tab = {
getBoundingClientRect: () => mockRect(20, 60),
} as unknown as HTMLElement;
scrollSystemManagerTabIntoView(container, tab);
assert.equal(calls.length, 0);
});
test('applyHorizontalWheelToScrollContainer maps vertical wheel to horizontal scroll', () => {
const container = {
scrollWidth: 500,
clientWidth: 200,
scrollLeft: 0,
} as unknown as HTMLElement;
assert.equal(
applyHorizontalWheelToScrollContainer(container, { deltaX: 0, deltaY: 40, deltaMode: 0 }),
true,
);
assert.equal(container.scrollLeft, 40);
});
test('measureSystemManagerTabBarLabeledFit sums tab boxes (not scrollWidth===clientWidth trap)', () => {
const label = { style: { display: 'none' } } as unknown as HTMLElement;
const tabA = {
getBoundingClientRect: () => mockRect(0, 80),
} as unknown as HTMLElement;
const tabB = {
getBoundingClientRect: () => mockRect(82, 90),
} as unknown as HTMLElement;
const row = {
// gap-0.5 ≈ 2px
} as unknown as HTMLElement;
Object.defineProperty(tabA, 'parentElement', { get: () => row });
Object.defineProperty(tabB, 'parentElement', { get: () => row });
const classList = {
iconOnly: true,
contains(name: string) {
return name === SYSTEM_MANAGER_TAB_BAR_ICON_ONLY_CLASS && this.iconOnly;
},
remove(name: string) {
if (name === SYSTEM_MANAGER_TAB_BAR_ICON_ONLY_CLASS) this.iconOnly = false;
},
add(name: string) {
if (name === SYSTEM_MANAGER_TAB_BAR_ICON_ONLY_CLASS) this.iconOnly = true;
},
};
// Wide panel: 400px budget, tabs total 80+90+2 = 172 → should fit.
const container = {
clientWidth: 400,
offsetWidth: 400,
classList,
querySelectorAll(selector: string) {
if (selector === '.system-manager-tab-label') {
return [label, label] as unknown as NodeListOf<HTMLElement>;
}
if (selector === '.system-manager-tab') {
return [tabA, tabB] as unknown as NodeListOf<HTMLElement>;
}
return [] as unknown as NodeListOf<HTMLElement>;
},
querySelector() {
return null;
},
} as unknown as HTMLElement;
// jsdom may not have getComputedStyle for gap/padding — stub global if needed.
const originalGcs = globalThis.getComputedStyle;
globalThis.getComputedStyle = ((el: Element) => {
if (el === row) {
return { columnGap: '2px', gap: '2px', paddingLeft: '0', paddingRight: '0' } as CSSStyleDeclaration;
}
if (el === container) {
return { paddingLeft: '8px', paddingRight: '8px' } as CSSStyleDeclaration;
}
return originalGcs(el);
}) as typeof getComputedStyle;
try {
const fit = measureSystemManagerTabBarLabeledFit(container, SYSTEM_MANAGER_TAB_BAR_EXPAND_SLACK_PX);
// content 172, pad 16 → available 384 → fits
assert.equal(fit.overflows, false);
assert.equal(fit.fitsWithSlack, true);
assert.equal(classList.iconOnly, true);
assert.equal(label.style.display, 'none');
} finally {
globalThis.getComputedStyle = originalGcs;
}
});
test('measureSystemManagerTabBarLabeledFit reports overflow when tabs exceed budget', () => {
const label = { style: { display: '' } } as unknown as HTMLElement;
const tabA = {
getBoundingClientRect: () => mockRect(0, 200),
parentElement: null as HTMLElement | null,
} as unknown as HTMLElement;
const tabB = {
getBoundingClientRect: () => mockRect(0, 200),
parentElement: null as HTMLElement | null,
} as unknown as HTMLElement;
const row = {} as HTMLElement;
Object.defineProperty(tabA, 'parentElement', { get: () => row });
Object.defineProperty(tabB, 'parentElement', { get: () => row });
const classList = {
contains: () => false,
remove() {},
add() {},
};
const container = {
clientWidth: 300,
offsetWidth: 300,
classList,
querySelectorAll(selector: string) {
if (selector === '.system-manager-tab-label') {
return [label] as unknown as NodeListOf<HTMLElement>;
}
if (selector === '.system-manager-tab') {
return [tabA, tabB] as unknown as NodeListOf<HTMLElement>;
}
return [] as unknown as NodeListOf<HTMLElement>;
},
querySelector() {
return null;
},
} as unknown as HTMLElement;
const originalGcs = globalThis.getComputedStyle;
globalThis.getComputedStyle = ((el: Element) => {
if (el === row) {
return { columnGap: '4px', gap: '4px', paddingLeft: '0', paddingRight: '0' } as CSSStyleDeclaration;
}
if (el === container) {
return { paddingLeft: '0', paddingRight: '0' } as CSSStyleDeclaration;
}
return originalGcs(el);
}) as typeof getComputedStyle;
try {
const fit = measureSystemManagerTabBarLabeledFit(container);
// 200+200+4 = 404 > 300
assert.equal(fit.overflows, true);
assert.equal(fit.fitsWithSlack, false);
} finally {
globalThis.getComputedStyle = originalGcs;
}
});
test('resolveSystemManagerTabBarIconOnly enters and leaves with hysteresis', () => {
assert.equal(
resolveSystemManagerTabBarIconOnly({ overflows: true, fitsWithSlack: false }, false),
true,
);
assert.equal(
resolveSystemManagerTabBarIconOnly({ overflows: false, fitsWithSlack: false }, true),
true,
);
assert.equal(
resolveSystemManagerTabBarIconOnly({ overflows: false, fitsWithSlack: true }, true),
false,
);
});
test('measureSystemManagerTabBarLabeledFit does not pretend zero-width bar fits labels', () => {
const container = {
clientWidth: 0,
classList: { contains: () => true, remove() {}, add() {} },
querySelectorAll: () => [] as unknown as NodeListOf<HTMLElement>,
} as unknown as HTMLElement;
const fit = measureSystemManagerTabBarLabeledFit(container);
assert.equal(fit.overflows, false);
assert.equal(fit.fitsWithSlack, false);
// Keep icon-only while the host is hidden / not laid out.
assert.equal(resolveSystemManagerTabBarIconOnly(fit, true), true);
});

View File

@@ -0,0 +1,188 @@
/** Edge buffer so an active tab is not stuck flush against the clip edge. */
const TAB_COMFORT_EDGE_RATIO = 0.28;
const TAB_COMFORT_EDGE_MIN = 28;
const TAB_COMFORT_EDGE_MAX = 72;
export const SYSTEM_MANAGER_TAB_BAR_ICON_ONLY_CLASS = 'system-manager-tab-bar--icon-only';
/** Quiet period after resize before re-evaluating icon-only (side-panel drag). */
export const SYSTEM_MANAGER_TAB_BAR_SETTLE_MS = 120;
/**
* Extra room required before leaving icon-only. Keeps a small hysteresis without
* locking the bar into permanent icon-only mode.
*/
export const SYSTEM_MANAGER_TAB_BAR_EXPAND_SLACK_PX = 8;
export type SystemManagerTabBarLabeledFit = {
/** Labeled tabs need more width than the bar's inner budget. */
overflows: boolean;
/** Labeled tabs fit with expand slack — safe to show text again. */
fitsWithSlack: boolean;
};
function readFlexGapPx(el: HTMLElement | null | undefined): number {
if (!el) return 0;
const style = getComputedStyle(el);
const raw = style.columnGap || style.gap || '0';
const value = parseFloat(raw);
return Number.isFinite(value) ? value : 0;
}
/**
* Measure whether the *labeled* tab strip fits in the bar's current width.
*
* Important: do not use scrollWidth vs clientWidth for the "fits" check.
* When content does not overflow, browsers often report scrollWidth === clientWidth,
* so "fitsWithSlack" becomes permanently false and icon-only never exits.
*
* Instead: force labels on, sum real tab (and overflow) box widths, compare to
* the bar's inner budget (clientWidth minus horizontal padding).
*/
export function measureSystemManagerTabBarLabeledFit(
container: HTMLElement | null | undefined,
expandSlackPx: number = SYSTEM_MANAGER_TAB_BAR_EXPAND_SLACK_PX,
): SystemManagerTabBarLabeledFit {
if (!container) {
return { overflows: false, fitsWithSlack: true };
}
const budget = container.clientWidth;
// Hidden / zero-width host: do not pretend labels fit (that would drop
// icon-only while the side panel is display:none or still laying out).
if (budget <= 0) {
return { overflows: false, fitsWithSlack: false };
}
const labelEls = Array.from(
container.querySelectorAll<HTMLElement>('.system-manager-tab-label'),
);
const hadIconOnly = container.classList.contains(SYSTEM_MANAGER_TAB_BAR_ICON_ONLY_CLASS);
const prevDisplay = labelEls.map((el) => el.style.display);
try {
if (hadIconOnly) {
container.classList.remove(SYSTEM_MANAGER_TAB_BAR_ICON_ONLY_CLASS);
}
for (const el of labelEls) {
// Force visible for measurement even if a stylesheet still hides them.
el.style.display = 'inline';
}
void container.offsetWidth;
const tabs = Array.from(container.querySelectorAll<HTMLElement>('.system-manager-tab'));
let contentWidth = 0;
for (const tab of tabs) {
contentWidth += tab.getBoundingClientRect().width;
}
const row = tabs[0]?.parentElement ?? null;
const gap = readFlexGapPx(row);
if (tabs.length > 1) {
contentWidth += gap * (tabs.length - 1);
}
const overflow = container.querySelector<HTMLElement>(
'[data-section="system-manager-tab-overflow"]',
);
if (overflow) {
const overflowWidth = overflow.getBoundingClientRect().width;
if (overflowWidth > 0.5) {
contentWidth += overflowWidth + (tabs.length > 0 ? gap : 0);
}
}
const style = getComputedStyle(container);
const padX = (parseFloat(style.paddingLeft) || 0) + (parseFloat(style.paddingRight) || 0);
// clientWidth includes padding; children lay out in the content box.
const available = Math.max(0, budget - padX);
return {
overflows: contentWidth > available + 1,
fitsWithSlack: contentWidth + expandSlackPx <= available,
};
} finally {
labelEls.forEach((el, i) => {
el.style.display = prevDisplay[i] ?? '';
});
if (hadIconOnly) {
container.classList.add(SYSTEM_MANAGER_TAB_BAR_ICON_ONLY_CLASS);
}
}
}
/**
* Enter icon-only as soon as labels overflow; leave only when they fit with slack.
*/
export function resolveSystemManagerTabBarIconOnly(
fit: SystemManagerTabBarLabeledFit,
currentlyIconOnly: boolean,
): boolean {
if (currentlyIconOnly) {
return !fit.fitsWithSlack;
}
return fit.overflows;
}
/**
* Scroll a system-manager sub-tab into a comfortable position inside its
* horizontal tab bar. Uses container.scrollTo so nested panels are not
* scrolled by element.scrollIntoView.
*/
export function scrollSystemManagerTabIntoView(
container: HTMLElement | null | undefined,
tab: HTMLElement | null | undefined,
behavior: ScrollBehavior = 'smooth',
): void {
if (!container || !tab) return;
if (container.scrollWidth <= container.clientWidth + 1) return;
const containerRect = container.getBoundingClientRect();
const tabRect = tab.getBoundingClientRect();
const edgeBuffer = Math.min(
TAB_COMFORT_EDGE_MAX,
Math.max(TAB_COMFORT_EDGE_MIN, containerRect.width * TAB_COMFORT_EDGE_RATIO),
);
const overflowsLeft = tabRect.left < containerRect.left + edgeBuffer;
const overflowsRight = tabRect.right > containerRect.right - edgeBuffer;
if (!overflowsLeft && !overflowsRight) return;
const tabCenter =
tabRect.left - containerRect.left + container.scrollLeft + tabRect.width / 2;
const maxScrollLeft = container.scrollWidth - container.clientWidth;
const targetLeft = Math.max(
0,
Math.min(maxScrollLeft, tabCenter - container.clientWidth / 2),
);
if (Math.abs(container.scrollLeft - targetLeft) < 1) return;
container.scrollTo({ left: targetLeft, behavior });
}
/**
* Map a wheel event onto horizontal scrollLeft. Vertical mouse wheel becomes
* left/right motion when the bar overflows.
*
* Returns true when the event was consumed (caller should preventDefault).
*/
export function applyHorizontalWheelToScrollContainer(
container: HTMLElement,
event: Pick<WheelEvent, 'deltaX' | 'deltaY' | 'deltaMode'>,
): boolean {
if (container.scrollWidth <= container.clientWidth + 1) return false;
// Prefer non-zero deltaX so trackpads that emit both axes feel natural.
const rawDelta = event.deltaX !== 0 ? event.deltaX : event.deltaY;
if (rawDelta === 0) return false;
// deltaMode: 0 = pixels, 1 = lines, 2 = pages
const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? container.clientWidth : 1;
const delta = rawDelta * scale;
const maxScrollLeft = container.scrollWidth - container.clientWidth;
const next = Math.max(0, Math.min(maxScrollLeft, container.scrollLeft + delta));
if (next === container.scrollLeft) return false;
container.scrollLeft = next;
return true;
}

View File

@@ -0,0 +1,6 @@
import { toast } from '../ui/toast';
/** Surface action failures as a global toast instead of inline panel banners. */
export function showSystemManagerError(message: string, title?: string) {
toast.error(message, title);
}

View File

@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const root = fileURLToPath(new URL("../..", import.meta.url));
function readProjectFile(path: string): string {
return readFileSync(join(root, path), "utf8");
}
const SYSTEM_MANAGER_PANELS = [
"components/systemManager/ProcessManagerTab.tsx",
"components/systemManager/TmuxSessionCard.tsx",
"components/systemManager/DockerContainersPanel.tsx",
"components/systemManager/DockerImagesPanel.tsx",
"components/systemManager/PortsManagerTab.tsx",
"components/systemManager/ServicesManagerTab.tsx",
] as const;
test("system manager destructive actions use in-app confirm dialogs", () => {
for (const path of SYSTEM_MANAGER_PANELS) {
const source = readProjectFile(path);
assert.match(
source,
/import \{ SystemPanelConfirmDialog \} from ['"]\.\/SystemPanelConfirmDialog['"]/,
`${path} should import SystemPanelConfirmDialog`,
);
assert.match(
source,
/<SystemPanelConfirmDialog/,
`${path} should render SystemPanelConfirmDialog`,
);
assert.doesNotMatch(
source,
/window\.confirm|globalThis\.confirm/,
`${path} must not use native confirm dialogs`,
);
}
});
test("process and docker confirm dialogs reset when sessionId changes", () => {
const processSource = readProjectFile("components/systemManager/ProcessManagerTab.tsx");
const containersSource = readProjectFile("components/systemManager/DockerContainersPanel.tsx");
const imagesSource = readProjectFile("components/systemManager/DockerImagesPanel.tsx");
assert.match(processSource, /setPendingSignal\(null\)/);
assert.match(processSource, /}, \[sessionId\]\);/);
assert.match(containersSource, /setConfirmAction\(null\)/);
assert.match(containersSource, /}, \[sessionId\]\);/);
assert.match(imagesSource, /setConfirmTarget\(null\)/);
assert.match(imagesSource, /setActionBusy\(false\)/);
assert.match(imagesSource, /}, \[sessionId\]\);/);
});
test("ports and services confirm dialogs reset when sessionId changes", () => {
const portsSource = readProjectFile("components/systemManager/PortsManagerTab.tsx");
const servicesSource = readProjectFile("components/systemManager/ServicesManagerTab.tsx");
assert.match(portsSource, /setPendingKillPid\(null\)/);
assert.match(portsSource, /setKillBusy\(false\)/);
assert.match(portsSource, /setActionError\(null\)/);
assert.match(portsSource, /}, \[sessionId\]\);/);
assert.match(servicesSource, /setPending\(null\)/);
assert.match(servicesSource, /setActionBusy\(false\)/);
assert.match(servicesSource, /setActionError\(null\)/);
assert.match(servicesSource, /}, \[sessionId\]\);/);
});
test("ports and services surface pending channel results instead of treating them as success", () => {
const portsSource = readProjectFile("components/systemManager/PortsManagerTab.tsx");
const servicesSource = readProjectFile("components/systemManager/ServicesManagerTab.tsx");
assert.match(portsSource, /result\.pending/);
assert.match(portsSource, /systemManager\.errors\.sshChannelUnavailable/);
assert.match(servicesSource, /result\.pending/);
assert.match(servicesSource, /systemManager\.errors\.sshChannelUnavailable/);
});
test("ports and services ignore late action results after session switches", () => {
const portsSource = readProjectFile("components/systemManager/PortsManagerTab.tsx");
const servicesSource = readProjectFile("components/systemManager/ServicesManagerTab.tsx");
assert.match(portsSource, /sessionIdRef\.current !== requestedSessionId/);
assert.match(servicesSource, /sessionIdRef\.current !== requestedSessionId/);
});

View File

@@ -0,0 +1,162 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { TmuxManageAction } from "../../domain/systemManager/types.ts";
import { runConfirmedTmuxDetachAction } from "./TmuxSessionCard.tsx";
import { runTmuxSessionAction, scheduleDeferredTerminalFocus } from "./tmuxActionFocus.ts";
test("tmux detach action requests terminal focus after a successful action", async () => {
const calls: string[] = [];
const action: TmuxManageAction = { action: "detachSession", sessionName: "work" };
const timeouts: Array<{ delay: number; callback: () => void }> = [];
const originalSetTimeout = globalThis.setTimeout;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof globalThis.requestAnimationFrame;
globalThis.setTimeout = ((callback: () => void, delay?: number) => {
timeouts.push({ delay: delay ?? 0, callback });
return timeouts.length;
}) as typeof globalThis.setTimeout;
try {
const result = await runTmuxSessionAction({
sessionId: "session-1",
action,
tmuxAction: async (payload) => {
assert.deepEqual(payload, { sessionId: "session-1", ...action });
calls.push("tmuxAction");
return { success: true };
},
onSessionsChanged: async () => {
calls.push("sessionsChanged");
},
onRequestTerminalFocus: () => {
calls.push("focus");
},
});
assert.deepEqual(result, { success: true });
assert.deepEqual(calls, ["tmuxAction", "sessionsChanged"]);
assert.deepEqual(timeouts.map(({ delay }) => delay), [0, 50, 150]);
for (const { callback } of timeouts) callback();
assert.deepEqual(calls, ["tmuxAction", "sessionsChanged", "focus", "focus", "focus"]);
} finally {
globalThis.setTimeout = originalSetTimeout;
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
}
});
test("scheduleDeferredTerminalFocus runs the callback on deferred timers", () => {
const calls: string[] = [];
const timeouts: Array<{ delay: number; callback: () => void }> = [];
const originalSetTimeout = globalThis.setTimeout;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof globalThis.requestAnimationFrame;
globalThis.setTimeout = ((callback: () => void, delay?: number) => {
timeouts.push({ delay: delay ?? 0, callback });
return timeouts.length;
}) as typeof globalThis.setTimeout;
try {
scheduleDeferredTerminalFocus(() => calls.push("focus"));
assert.deepEqual(timeouts.map(({ delay }) => delay), [0, 50, 150]);
for (const { callback } of timeouts) callback();
assert.deepEqual(calls, ["focus", "focus", "focus"]);
} finally {
globalThis.setTimeout = originalSetTimeout;
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
}
});
test("tmux detach confirmation runs the action path that requests terminal focus", async () => {
const calls: string[] = [];
const timeouts: Array<{ delay: number; callback: () => void }> = [];
const originalSetTimeout = globalThis.setTimeout;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof globalThis.requestAnimationFrame;
globalThis.setTimeout = ((callback: () => void, delay?: number) => {
timeouts.push({ delay: delay ?? 0, callback });
return timeouts.length;
}) as typeof globalThis.setTimeout;
try {
const handled = await runConfirmedTmuxDetachAction({
sessionName: "work",
confirmMessage: "detach work?",
confirm: (message) => {
calls.push(`confirm:${message}`);
return true;
},
runAction: (action) => runTmuxSessionAction({
sessionId: "session-1",
action,
tmuxAction: async () => {
calls.push("tmuxAction");
return { success: true };
},
onSessionsChanged: async () => {
calls.push("sessionsChanged");
},
onRequestTerminalFocus: () => {
calls.push("focus");
},
}).then(() => undefined),
});
assert.equal(handled, true);
assert.deepEqual(calls, ["confirm:detach work?", "tmuxAction", "sessionsChanged"]);
for (const { callback } of timeouts) callback();
assert.deepEqual(calls, ["confirm:detach work?", "tmuxAction", "sessionsChanged", "focus", "focus", "focus"]);
} finally {
globalThis.setTimeout = originalSetTimeout;
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
}
});
test("tmux detach action does not request terminal focus when the action fails", async () => {
const calls: string[] = [];
const result = await runTmuxSessionAction({
sessionId: "session-1",
action: { action: "detachSession", sessionName: "work" },
tmuxAction: async () => ({ success: false, error: "failed" }),
onSessionsChanged: async () => {
calls.push("sessionsChanged");
},
onRequestTerminalFocus: () => {
calls.push("focus");
},
});
assert.deepEqual(result, { success: false, error: "failed" });
assert.deepEqual(calls, []);
});
test("tmux non-detach actions do not request terminal focus after success", async () => {
const calls: string[] = [];
await runTmuxSessionAction({
sessionId: "session-1",
action: { action: "renameSession", sessionName: "work", newName: "renamed" },
tmuxAction: async () => ({ success: true }),
onSessionsChanged: async () => {
calls.push("sessionsChanged");
},
onRequestTerminalFocus: () => {
calls.push("focus");
},
});
assert.deepEqual(calls, ["sessionsChanged"]);
});

View File

@@ -0,0 +1,69 @@
import type { TmuxManageAction } from '../../domain/systemManager/types';
type TmuxActionResult = {
success: boolean;
error?: string;
};
type TmuxActionPayload = { sessionId: string } & TmuxManageAction;
interface RunTmuxSessionActionOptions {
sessionId: string;
action: TmuxManageAction;
tmuxAction: (payload: TmuxActionPayload) => Promise<TmuxActionResult>;
onRefreshDetails?: () => Promise<void>;
onSessionsChanged: () => Promise<void>;
onRequestTerminalFocus?: () => void;
}
const shouldRequestTerminalFocusAfterAction = (action: TmuxManageAction): boolean =>
action.action === 'detachSession';
const DEFERRED_TERMINAL_FOCUS_DELAYS_MS = [0, 50, 150] as const;
export function scheduleDeferredTerminalFocus(onRequestTerminalFocus?: () => void): void {
if (!onRequestTerminalFocus) return;
const run = () => onRequestTerminalFocus();
const schedule = typeof globalThis.setTimeout === 'function'
? globalThis.setTimeout.bind(globalThis)
: (callback: () => void) => {
callback();
return 0;
};
const raf = typeof globalThis.requestAnimationFrame === 'function'
? globalThis.requestAnimationFrame.bind(globalThis)
: (callback: () => void) => {
callback();
return 0;
};
raf(() => {
for (const delayMs of DEFERRED_TERMINAL_FOCUS_DELAYS_MS) {
schedule(run, delayMs);
}
});
}
export async function runTmuxSessionAction({
sessionId,
action,
tmuxAction,
onRefreshDetails,
onSessionsChanged,
onRequestTerminalFocus,
}: RunTmuxSessionActionOptions): Promise<TmuxActionResult> {
const result = await tmuxAction({ sessionId, ...action });
if (!result.success) return result;
try {
await onRefreshDetails?.();
await onSessionsChanged();
} finally {
if (shouldRequestTerminalFocusAfterAction(action)) {
scheduleDeferredTerminalFocus(onRequestTerminalFocus);
}
}
return result;
}