[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
124
components/systemManager/DockerContainerDetail.tsx
Normal file
124
components/systemManager/DockerContainerDetail.tsx
Normal 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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
513
components/systemManager/DockerContainersPanel.tsx
Normal file
513
components/systemManager/DockerContainersPanel.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
78
components/systemManager/DockerImageIcon.tsx
Normal file
78
components/systemManager/DockerImageIcon.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
415
components/systemManager/DockerImagesPanel.tsx
Normal file
415
components/systemManager/DockerImagesPanel.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
120
components/systemManager/DockerInspectView.tsx
Normal file
120
components/systemManager/DockerInspectView.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
89
components/systemManager/DockerManagerTab.tsx
Normal file
89
components/systemManager/DockerManagerTab.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
61
components/systemManager/GpuManagerTab.test.ts
Normal file
61
components/systemManager/GpuManagerTab.test.ts
Normal 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/);
|
||||
});
|
||||
394
components/systemManager/GpuManagerTab.tsx
Normal file
394
components/systemManager/GpuManagerTab.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
16
components/systemManager/GpuVendorBadge.test.ts
Normal file
16
components/systemManager/GpuVendorBadge.test.ts
Normal 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"/);
|
||||
});
|
||||
77
components/systemManager/GpuVendorBadge.tsx
Normal file
77
components/systemManager/GpuVendorBadge.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
242
components/systemManager/PortsManagerTab.tsx
Normal file
242
components/systemManager/PortsManagerTab.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
609
components/systemManager/ProcessManagerTab.tsx
Normal file
609
components/systemManager/ProcessManagerTab.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
import {
|
||||
Gauge, LayoutList, Loader2, MoreHorizontal, Pause, Play, Skull, XCircle,
|
||||
} from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import {
|
||||
getProcessFlags,
|
||||
getProcessStatusLabelKey,
|
||||
getProcessTone,
|
||||
} from '../../domain/systemManager/processState';
|
||||
import type { SystemProcessInfo } from '../../domain/systemManager/types';
|
||||
import { systemProcessInfoEqual } from '../../domain/systemManager/pollEquals';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { VariableSizeVirtualList } from '../ui/VariableSizeVirtualList';
|
||||
import { ResourceBar } from './ResourceBar';
|
||||
import { useStableListOrder, mergePollListByKey } from './listStable';
|
||||
import {
|
||||
SystemPanelDetailStrip,
|
||||
SystemPanelEmpty,
|
||||
SystemPanelError,
|
||||
SystemPanelInlineError,
|
||||
SystemPanelList,
|
||||
SystemPanelMetaBar,
|
||||
SystemPanelRefreshButton,
|
||||
SystemPanelRoundButton,
|
||||
SystemPanelRow,
|
||||
SystemPanelSearch,
|
||||
SystemPanelSegmented,
|
||||
SystemPanelShell,
|
||||
SystemPanelStatusBadge,
|
||||
SystemPanelToolbar,
|
||||
} from './SystemPanelUi';
|
||||
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
|
||||
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
|
||||
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
|
||||
import {
|
||||
getCachedProcessList,
|
||||
setCachedProcessList,
|
||||
} from './processListCache';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
type SortKey = 'cpuPercent' | 'memPercent' | 'pid' | 'command' | 'user';
|
||||
type ProcessFilter = 'all' | 'running';
|
||||
type ProcessSignal = 'STOP' | 'CONT' | 'TERM' | 'KILL';
|
||||
|
||||
interface PendingProcessSignal {
|
||||
pid: number;
|
||||
signal: ProcessSignal;
|
||||
}
|
||||
|
||||
function processSignalTitleKey(signal: ProcessSignal): string {
|
||||
switch (signal) {
|
||||
case 'STOP': return 'systemManager.processes.stop';
|
||||
case 'CONT': return 'systemManager.processes.cont';
|
||||
case 'TERM': return 'systemManager.processes.term';
|
||||
case 'KILL': return 'systemManager.processes.kill';
|
||||
}
|
||||
}
|
||||
|
||||
const PROCESS_ROW_HEIGHT = 56;
|
||||
const PROCESS_DETAIL_HEIGHT = 112;
|
||||
const PROCESS_OVERSCAN_ROWS = 8;
|
||||
|
||||
const SORT_OPTIONS: Array<{ key: SortKey; labelKey: string }> = [
|
||||
{ key: 'cpuPercent', labelKey: 'systemManager.processes.sort.cpu' },
|
||||
{ key: 'memPercent', labelKey: 'systemManager.processes.sort.mem' },
|
||||
{ key: 'pid', labelKey: 'systemManager.processes.sort.pid' },
|
||||
{ key: 'command', labelKey: 'systemManager.processes.sort.command' },
|
||||
{ key: 'user', labelKey: 'systemManager.processes.sort.user' },
|
||||
];
|
||||
|
||||
function formatKb(kb: number): string {
|
||||
if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)} GB`;
|
||||
if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`;
|
||||
return `${kb} KB`;
|
||||
}
|
||||
|
||||
function isProcessRunning(stat: string): boolean {
|
||||
return /R/i.test(stat);
|
||||
}
|
||||
|
||||
const mergeProcesses = (
|
||||
prev: SystemProcessInfo[] | null,
|
||||
next: SystemProcessInfo[],
|
||||
) => mergePollListByKey(prev, next, (p) => p.pid, systemProcessInfoEqual);
|
||||
|
||||
const ProcessListLoading = memo(function ProcessListLoading({
|
||||
message,
|
||||
}: {
|
||||
message: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[180px] flex-col items-center justify-center px-4 py-10 text-center text-xs text-muted-foreground">
|
||||
<Loader2 size={18} className="mb-2 animate-spin opacity-70" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ProcessRowProps {
|
||||
proc: SystemProcessInfo;
|
||||
selected: boolean;
|
||||
onToggle: (pid: number) => void;
|
||||
onSignal: (pid: number, signal: string) => void;
|
||||
onRenice: (pid: number) => void;
|
||||
}
|
||||
|
||||
const ProcessRow = memo(function ProcessRow({
|
||||
proc,
|
||||
selected,
|
||||
onToggle,
|
||||
onSignal,
|
||||
onRenice,
|
||||
}: ProcessRowProps) {
|
||||
const { t } = useI18n();
|
||||
const { isStopped, isZombie } = getProcessFlags(proc);
|
||||
|
||||
// Extract a clean display name from the command line.
|
||||
// Windows PowerShell returns full paths; Linux often returns just the binary.
|
||||
const rawName = String(proc.command || '');
|
||||
const displayName = rawName
|
||||
? (rawName.includes('/') || rawName.includes('\\')
|
||||
? rawName.split(/[\/\\]/).pop() || rawName
|
||||
: rawName.split(/\s+/)[0] || rawName)
|
||||
: `PID ${proc.pid}`;
|
||||
const isWindowsProc = rawName.toLowerCase().match(/\.exe(?:\s|$)/);
|
||||
|
||||
const mainActions = (
|
||||
<div className="flex w-[64px] shrink-0 items-center justify-end gap-1">
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.processes.term')}
|
||||
onClick={() => onSignal(proc.pid, 'TERM')}
|
||||
>
|
||||
<XCircle size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.processes.kill')}
|
||||
destructive
|
||||
onClick={() => onSignal(proc.pid, 'KILL')}
|
||||
>
|
||||
<Skull size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 h-14 px-3 cursor-pointer transition-colors',
|
||||
selected ? 'bg-primary/5 border-l-2 border-primary' : 'hover:bg-muted/40 border-l-2 border-transparent',
|
||||
)}
|
||||
onClick={() => onToggle(proc.pid)}
|
||||
title={rawName || displayName}
|
||||
>
|
||||
{/* Process name + PID */}
|
||||
<div className="flex min-w-0 flex-col flex-1">
|
||||
<span className="truncate text-[13px] font-medium leading-tight">{displayName}</span>
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{proc.user || '—'} · PID {proc.pid}{proc.ppid ? ` · PPID ${proc.ppid}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* CPU % */}
|
||||
<div className="flex w-[60px] shrink-0 flex-col items-end">
|
||||
<span className="text-[12px] font-mono font-medium tabular-nums">{proc.cpuPercent.toFixed(1)}%</span>
|
||||
<div className="mt-0.5 h-1 w-14 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-[width]"
|
||||
style={{ width: `${Math.min(100, proc.cpuPercent)}%`, backgroundColor: getProcessTone(proc) === 'critical' ? '#ef4444' : '#3b82f6' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MEM % */}
|
||||
<div className="flex w-[60px] shrink-0 flex-col items-end">
|
||||
<span className="text-[12px] font-mono font-medium tabular-nums">{proc.memPercent.toFixed(1)}%</span>
|
||||
<div className="mt-0.5 h-1 w-14 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] bg-emerald-500"
|
||||
style={{ width: `${Math.min(100, proc.memPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="flex w-[72px] shrink-0 items-center justify-end">
|
||||
<SystemPanelStatusBadge tone={getProcessTone(proc)}>
|
||||
{t(getProcessStatusLabelKey(proc))}
|
||||
</SystemPanelStatusBadge>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{mainActions}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<SystemPanelDetailStrip className="overflow-hidden">
|
||||
<div className="grid grid-cols-3 gap-x-3 gap-y-1 text-[11px] text-muted-foreground mb-2">
|
||||
<span>{t('systemManager.processes.ppid')}: {proc.ppid}</span>
|
||||
<span>{t('systemManager.processes.stat')}: {proc.stat}</span>
|
||||
<span>{t('systemManager.processes.elapsed')}: {proc.elapsed || '—'}</span>
|
||||
<span>RSS: {formatKb(proc.rssKb)}</span>
|
||||
<span>VSZ: {formatKb(proc.vszKb)}</span>
|
||||
<span>User: {proc.user || '—'}</span>
|
||||
</div>
|
||||
{rawName && (
|
||||
<div className="text-[10px] text-muted-foreground/80 truncate mb-2" title={rawName}>
|
||||
{rawName}
|
||||
</div>
|
||||
)}
|
||||
{/* Extra actions — hidden on Windows hosts (no POSIX signals) */}
|
||||
{!isWindowsProc && (
|
||||
<div className="flex items-center gap-1 pt-2 border-t border-border/40">
|
||||
{!isStopped && !isZombie && (
|
||||
<SystemPanelRoundButton
|
||||
size="sm"
|
||||
title={t('systemManager.processes.stop')}
|
||||
onClick={() => onSignal(proc.pid, 'STOP')}
|
||||
>
|
||||
<Pause size={11} /> <span className="text-[10px]">暂停</span>
|
||||
</SystemPanelRoundButton>
|
||||
)}
|
||||
{isStopped && !isZombie && (
|
||||
<SystemPanelRoundButton
|
||||
size="sm"
|
||||
title={t('systemManager.processes.cont')}
|
||||
onClick={() => onSignal(proc.pid, 'CONT')}
|
||||
>
|
||||
<Play size={11} /> <span className="text-[10px]">继续</span>
|
||||
</SystemPanelRoundButton>
|
||||
)}
|
||||
<SystemPanelRoundButton
|
||||
size="sm"
|
||||
title={t('systemManager.processes.renice')}
|
||||
onClick={() => onRenice(proc.pid)}
|
||||
>
|
||||
<Gauge size={11} /> <span className="text-[10px]">优先级</span>
|
||||
</SystemPanelRoundButton>
|
||||
</div>
|
||||
)}
|
||||
</SystemPanelDetailStrip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ProcessVirtualListProps {
|
||||
processes: SystemProcessInfo[];
|
||||
selectedPid: number | null;
|
||||
onToggle: (pid: number) => void;
|
||||
onSignal: (pid: number, signal: string) => void;
|
||||
onRenice: (pid: number) => void;
|
||||
}
|
||||
|
||||
const ProcessVirtualList = memo(function ProcessVirtualList({
|
||||
processes,
|
||||
selectedPid,
|
||||
onToggle,
|
||||
onSignal,
|
||||
onRenice,
|
||||
}: ProcessVirtualListProps) {
|
||||
const getItemHeight = useCallback(
|
||||
(proc: SystemProcessInfo) => (
|
||||
proc.pid === selectedPid
|
||||
? PROCESS_ROW_HEIGHT + PROCESS_DETAIL_HEIGHT
|
||||
: PROCESS_ROW_HEIGHT
|
||||
),
|
||||
[selectedPid],
|
||||
);
|
||||
|
||||
const renderItem = useCallback((proc: SystemProcessInfo) => (
|
||||
<ProcessRow
|
||||
proc={proc}
|
||||
selected={selectedPid === proc.pid}
|
||||
onToggle={onToggle}
|
||||
onSignal={onSignal}
|
||||
onRenice={onRenice}
|
||||
/>
|
||||
), [onRenice, onSignal, onToggle, selectedPid]);
|
||||
|
||||
return (
|
||||
<VariableSizeVirtualList<SystemProcessInfo>
|
||||
items={processes}
|
||||
getItemHeight={getItemHeight}
|
||||
className="flex-1 min-h-0"
|
||||
overscan={PROCESS_OVERSCAN_ROWS}
|
||||
getItemKey={(proc) => String(proc.pid)}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
interface ProcessManagerTabProps {
|
||||
sessionId: string;
|
||||
isVisible: boolean;
|
||||
backend: Backend;
|
||||
refreshIntervalSec: number;
|
||||
}
|
||||
|
||||
export const ProcessManagerTab = memo(function ProcessManagerTab({
|
||||
sessionId,
|
||||
isVisible,
|
||||
backend,
|
||||
refreshIntervalSec,
|
||||
}: ProcessManagerTabProps) {
|
||||
const { t } = useI18n();
|
||||
const stableT = useStableTranslate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('cpuPercent');
|
||||
const [sortAsc, setSortAsc] = useState(false);
|
||||
const [filter, setFilter] = useState<ProcessFilter>('all');
|
||||
const [selectedPid, setSelectedPid] = useState<number | null>(null);
|
||||
const [reniceTarget, setReniceTarget] = useState<number | null>(null);
|
||||
const [pendingSignal, setPendingSignal] = useState<PendingProcessSignal | null>(null);
|
||||
const [signalBusy, setSignalBusy] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [cachedProcesses, setCachedProcesses] = useState<SystemProcessInfo[] | null>(() => getCachedProcessList(sessionId));
|
||||
const [cachedProcessesSessionId, setCachedProcessesSessionId] = useState(sessionId);
|
||||
const [processListPending, setProcessListPending] = useState(false);
|
||||
const processFetchGenerationRef = useRef(0);
|
||||
const currentSessionIdRef = useRef(sessionId);
|
||||
|
||||
if (currentSessionIdRef.current !== sessionId) {
|
||||
currentSessionIdRef.current = sessionId;
|
||||
processFetchGenerationRef.current += 1;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
processFetchGenerationRef.current += 1;
|
||||
setCachedProcesses(getCachedProcessList(sessionId));
|
||||
setCachedProcessesSessionId(sessionId);
|
||||
setProcessListPending(false);
|
||||
// Drop in-flight dialogs so a confirm cannot act on a different host/session.
|
||||
setPendingSignal(null);
|
||||
setSignalBusy(false);
|
||||
setReniceTarget(null);
|
||||
setSelectedPid(null);
|
||||
setActionError(null);
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => () => {
|
||||
processFetchGenerationRef.current += 1;
|
||||
}, []);
|
||||
|
||||
const fetcher = useCallback(async () => {
|
||||
const fetchGeneration = processFetchGenerationRef.current;
|
||||
const fetchSessionId = sessionId;
|
||||
const isCurrentFetch = () => (
|
||||
processFetchGenerationRef.current === fetchGeneration
|
||||
&& currentSessionIdRef.current === fetchSessionId
|
||||
);
|
||||
try {
|
||||
const result = await backend.listSystemProcesses(sessionId);
|
||||
if (!isCurrentFetch()) return null;
|
||||
if (result.pending) {
|
||||
setProcessListPending(true);
|
||||
return null;
|
||||
}
|
||||
setProcessListPending(false);
|
||||
if (!result.success || !result.processes) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadProcesses'));
|
||||
}
|
||||
return result.processes;
|
||||
} catch (err) {
|
||||
if (!isCurrentFetch()) return null;
|
||||
setProcessListPending(false);
|
||||
throw err;
|
||||
}
|
||||
}, [backend, sessionId, stableT]);
|
||||
|
||||
const intervalMs = Math.max(2, refreshIntervalSec) * 1000;
|
||||
const { data: processes, error, loading, refresh } = usePolling<SystemProcessInfo[]>(
|
||||
fetcher,
|
||||
intervalMs,
|
||||
isVisible,
|
||||
mergeProcesses,
|
||||
{ resetKey: sessionId },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!processes) return;
|
||||
setCachedProcessList(sessionId, processes);
|
||||
setCachedProcesses(processes);
|
||||
setCachedProcessesSessionId(sessionId);
|
||||
}, [processes, sessionId]);
|
||||
|
||||
const sessionCachedProcesses = cachedProcessesSessionId === sessionId
|
||||
? cachedProcesses
|
||||
: getCachedProcessList(sessionId);
|
||||
const visibleProcesses = processes ?? sessionCachedProcesses;
|
||||
const showingCachedProcesses = processes === null && sessionCachedProcesses !== null;
|
||||
|
||||
const matched = useMemo<SystemProcessInfo[]>(() => {
|
||||
const list = visibleProcesses ?? [];
|
||||
const q = query.trim().toLowerCase();
|
||||
return list.filter((p) => {
|
||||
if (filter === 'running' && !isProcessRunning(p.stat)) return false;
|
||||
if (!q) return true;
|
||||
return String(p.pid).includes(q)
|
||||
|| String(p.ppid).includes(q)
|
||||
|| p.user.toLowerCase().includes(q)
|
||||
|| p.command.toLowerCase().includes(q);
|
||||
});
|
||||
}, [visibleProcesses, query, filter]);
|
||||
|
||||
const compareProcesses = useCallback((a: SystemProcessInfo, b: SystemProcessInfo) => {
|
||||
let cmp = 0;
|
||||
if (sortKey === 'command' || sortKey === 'user') {
|
||||
cmp = a[sortKey].localeCompare(b[sortKey]);
|
||||
} else {
|
||||
const av = a[sortKey];
|
||||
const bv = b[sortKey];
|
||||
cmp = Number(av) < Number(bv) ? -1 : Number(av) > Number(bv) ? 1 : 0;
|
||||
}
|
||||
const primary = sortAsc ? cmp : -cmp;
|
||||
if (primary !== 0) return primary;
|
||||
return a.pid - b.pid;
|
||||
}, [sortAsc, sortKey]);
|
||||
|
||||
const sortToken = `${sortKey}|${sortAsc}|${filter}|${query}`;
|
||||
const displayList = useStableListOrder<SystemProcessInfo, number>(
|
||||
matched,
|
||||
(p) => p.pid,
|
||||
sortToken,
|
||||
compareProcesses,
|
||||
);
|
||||
const isProcessRefreshActive = loading || processListPending;
|
||||
const showInitialLoading = isProcessRefreshActive && displayList.length === 0;
|
||||
const showBlockingError = Boolean(error && !isProcessRefreshActive && displayList.length === 0);
|
||||
const showInlineRefreshError = Boolean(error && !isProcessRefreshActive && displayList.length > 0);
|
||||
|
||||
const cycleSort = (key: SortKey) => {
|
||||
if (sortKey === key) setSortAsc((v) => !v);
|
||||
else {
|
||||
setSortKey(key);
|
||||
setSortAsc(key === 'command' || key === 'user');
|
||||
}
|
||||
};
|
||||
|
||||
const togglePid = useCallback((pid: number) => {
|
||||
setSelectedPid((cur) => (cur === pid ? null : pid));
|
||||
}, []);
|
||||
|
||||
const requestSignal = useCallback((pid: number, signal: string) => {
|
||||
if (signal !== 'STOP' && signal !== 'CONT' && signal !== 'TERM' && signal !== 'KILL') return;
|
||||
setPendingSignal({ pid, signal });
|
||||
}, []);
|
||||
|
||||
const executeSignal = useCallback(async (pid: number, signal: ProcessSignal) => {
|
||||
setSignalBusy(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const result = await backend.signalSystemProcess({ sessionId, pid, signal });
|
||||
if (!result.success) {
|
||||
setActionError(result.error || t('systemManager.errors.actionFailed'));
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
} finally {
|
||||
setSignalBusy(false);
|
||||
}
|
||||
}, [backend, refresh, sessionId, t]);
|
||||
|
||||
const reniceProcess = useCallback(async (pid: number, nice: number) => {
|
||||
setActionError(null);
|
||||
const result = await backend.signalSystemProcess({ sessionId, pid, nice });
|
||||
if (!result.success) {
|
||||
setActionError(result.error || t('systemManager.errors.actionFailed'));
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
}, [backend, refresh, sessionId, t]);
|
||||
|
||||
const openRenicePrompt = useCallback((pid: number) => {
|
||||
setReniceTarget(pid);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SystemPanelShell section="system-manager-processes">
|
||||
<SystemPanelToolbar
|
||||
trailing={(
|
||||
<SystemPanelRefreshButton
|
||||
title={t('history.action.refresh')}
|
||||
loading={isProcessRefreshActive}
|
||||
onClick={() => void refresh()}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<SystemPanelSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t('systemManager.processes.search')}
|
||||
/>
|
||||
</SystemPanelToolbar>
|
||||
|
||||
<SystemPanelSegmented
|
||||
value={filter}
|
||||
options={[
|
||||
{ id: 'all', label: t('systemManager.processes.filter.all') },
|
||||
{ id: 'running', label: t('systemManager.processes.filter.running') },
|
||||
]}
|
||||
onChange={setFilter}
|
||||
/>
|
||||
|
||||
<SystemPanelMetaBar trailing={(
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{SORT_OPTIONS.map(({ key, labelKey }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => cycleSort(key)}
|
||||
className={cn(
|
||||
'shrink-0 px-1.5 py-0.5 rounded text-[10px] transition-colors',
|
||||
sortKey === key
|
||||
? 'text-foreground bg-muted/60'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{t(labelKey)}{sortKey === key ? (sortAsc ? ' ↑' : ' ↓') : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}>
|
||||
<span className={cn(showingCachedProcesses && isProcessRefreshActive && 'inline-flex items-center gap-1.5')}>
|
||||
{showingCachedProcesses && isProcessRefreshActive && <Loader2 size={10} className="animate-spin" />}
|
||||
{t('systemManager.processes.meta', { count: String(displayList.length) })}
|
||||
</span>
|
||||
</SystemPanelMetaBar>
|
||||
|
||||
{actionError && <SystemPanelInlineError message={actionError} />}
|
||||
{showInlineRefreshError && error && <SystemPanelInlineError message={error} />}
|
||||
|
||||
{(showBlockingError || showInitialLoading || (!error && displayList.length === 0 && !loading && !showInitialLoading)) ? (
|
||||
<SystemPanelList>
|
||||
{showBlockingError && error && (
|
||||
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
|
||||
)}
|
||||
{showInitialLoading && (
|
||||
<ProcessListLoading message={t('systemManager.processes.loading')} />
|
||||
)}
|
||||
{!error && displayList.length === 0 && !loading && !showInitialLoading && (
|
||||
<SystemPanelEmpty icon={LayoutList} message={t('systemManager.empty')} />
|
||||
)}
|
||||
</SystemPanelList>
|
||||
) : (
|
||||
<ProcessVirtualList
|
||||
processes={displayList}
|
||||
selectedPid={selectedPid}
|
||||
onToggle={togglePid}
|
||||
onSignal={requestSignal}
|
||||
onRenice={openRenicePrompt}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SystemPanelConfirmDialog
|
||||
open={pendingSignal !== null}
|
||||
title={pendingSignal ? t(processSignalTitleKey(pendingSignal.signal)) : ''}
|
||||
message={pendingSignal
|
||||
? t(
|
||||
pendingSignal.signal === 'KILL'
|
||||
? 'systemManager.processes.confirmKill'
|
||||
: 'systemManager.processes.confirmSignal',
|
||||
{ pid: String(pendingSignal.pid), signal: pendingSignal.signal },
|
||||
)
|
||||
: ''}
|
||||
confirmLabel={pendingSignal ? t(processSignalTitleKey(pendingSignal.signal)) : ''}
|
||||
destructive={pendingSignal?.signal === 'KILL' || pendingSignal?.signal === 'TERM'}
|
||||
busy={signalBusy}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !signalBusy) setPendingSignal(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
const target = pendingSignal;
|
||||
if (!target) return;
|
||||
setPendingSignal(null);
|
||||
void executeSignal(target.pid, target.signal);
|
||||
}}
|
||||
/>
|
||||
|
||||
<SystemPanelPromptDialog
|
||||
open={reniceTarget !== null}
|
||||
title={t('systemManager.processes.renice')}
|
||||
fields={[{
|
||||
id: 'nice',
|
||||
label: t('systemManager.processes.renicePrompt'),
|
||||
initialValue: '0',
|
||||
mono: true,
|
||||
}]}
|
||||
confirmLabel={t('systemManager.processes.renice')}
|
||||
validate={(values) => {
|
||||
const nice = Number(values.nice);
|
||||
if (!Number.isFinite(nice) || nice < -20 || nice > 19) {
|
||||
return t('systemManager.processes.reniceInvalid');
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
onOpenChange={(open) => { if (!open) setReniceTarget(null); }}
|
||||
onSubmit={(values) => {
|
||||
const pid = reniceTarget;
|
||||
setReniceTarget(null);
|
||||
if (pid === null) return;
|
||||
void reniceProcess(pid, Number(values.nice));
|
||||
}}
|
||||
/>
|
||||
</SystemPanelShell>
|
||||
);
|
||||
});
|
||||
90
components/systemManager/ResourceBar.tsx
Normal file
90
components/systemManager/ResourceBar.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
300
components/systemManager/ServicesManagerTab.tsx
Normal file
300
components/systemManager/ServicesManagerTab.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
114
components/systemManager/SystemManagerSidePanel.test.tsx
Normal file
114
components/systemManager/SystemManagerSidePanel.test.tsx
Normal 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/);
|
||||
});
|
||||
615
components/systemManager/SystemManagerSidePanel.tsx
Normal file
615
components/systemManager/SystemManagerSidePanel.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
642
components/systemManager/SystemOverviewTab.tsx
Normal file
642
components/systemManager/SystemOverviewTab.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
69
components/systemManager/SystemPanelConfirmDialog.tsx
Normal file
69
components/systemManager/SystemPanelConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
133
components/systemManager/SystemPanelPromptDialog.tsx
Normal file
133
components/systemManager/SystemPanelPromptDialog.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
625
components/systemManager/SystemPanelUi.tsx
Normal file
625
components/systemManager/SystemPanelUi.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
255
components/systemManager/TmuxManagerTab.tsx
Normal file
255
components/systemManager/TmuxManagerTab.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
22
components/systemManager/TmuxNewSessionModal.test.ts
Normal file
22
components/systemManager/TmuxNewSessionModal.test.ts
Normal 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/);
|
||||
});
|
||||
196
components/systemManager/TmuxNewSessionModal.tsx
Normal file
196
components/systemManager/TmuxNewSessionModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
412
components/systemManager/TmuxSessionCard.tsx
Normal file
412
components/systemManager/TmuxSessionCard.tsx
Normal 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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
5
components/systemManager/hooks/useAsyncRecordCache.ts
Normal file
5
components/systemManager/hooks/useAsyncRecordCache.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @deprecated Import from `@/application/state/systemManager/useAsyncRecordCache` instead. */
|
||||
export {
|
||||
useAsyncRecordCache,
|
||||
type AsyncRecordState,
|
||||
} from "../../../application/state/systemManager/useAsyncRecordCache";
|
||||
7
components/systemManager/hooks/useSystemManager.ts
Normal file
7
components/systemManager/hooks/useSystemManager.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/** @deprecated Import from `@/application/state/useSystemManager` instead. */
|
||||
export {
|
||||
useStableTranslate,
|
||||
useSessionCapabilities,
|
||||
useSystemCapabilitiesWarmup,
|
||||
usePolling,
|
||||
} from "../../../application/state/useSystemManager";
|
||||
57
components/systemManager/listStable.ts
Normal file
57
components/systemManager/listStable.ts
Normal 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]);
|
||||
}
|
||||
95
components/systemManager/openInteractiveTerminal.test.ts
Normal file
95
components/systemManager/openInteractiveTerminal.test.ts
Normal 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');
|
||||
});
|
||||
79
components/systemManager/openInteractiveTerminal.ts
Normal file
79
components/systemManager/openInteractiveTerminal.ts
Normal 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;
|
||||
}
|
||||
67
components/systemManager/processListCache.test.ts
Normal file
67
components/systemManager/processListCache.test.ts
Normal 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);
|
||||
});
|
||||
76
components/systemManager/processListCache.ts
Normal file
76
components/systemManager/processListCache.ts
Normal 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()],
|
||||
};
|
||||
}
|
||||
218
components/systemManager/systemManagerTabBarScroll.test.ts
Normal file
218
components/systemManager/systemManagerTabBarScroll.test.ts
Normal 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);
|
||||
});
|
||||
188
components/systemManager/systemManagerTabBarScroll.ts
Normal file
188
components/systemManager/systemManagerTabBarScroll.ts
Normal 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;
|
||||
}
|
||||
6
components/systemManager/systemManagerToast.ts
Normal file
6
components/systemManager/systemManagerToast.ts
Normal 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);
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
162
components/systemManager/tmuxActionFocus.test.ts
Normal file
162
components/systemManager/tmuxActionFocus.test.ts
Normal 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"]);
|
||||
});
|
||||
69
components/systemManager/tmuxActionFocus.ts
Normal file
69
components/systemManager/tmuxActionFocus.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user