mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-15 03:21:15 +00:00
* feat(system-manager): add container runtime switcher and kubectl pods Keep the existing Docker tab and select docker, podman, nerdctl, or Kubernetes from runtimes detected on the host. Kubernetes replaces the switcher with context and namespace selectors in the same panel. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): address container runtime review findings Move the runtime adapter into the domain layer, keep a context namespace when namespace listing is forbidden, and emit PowerShell or cmd startup commands for Kubernetes shell, logs, and describe. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): cover Windows container popups and stale namespaces Run container shell and logs through the host shell's inspect wrapper on PowerShell and cmd, and reset the namespace when a remembered Kubernetes context is no longer present. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): parse kubectl JSON after banners and scope inspect cache Skip bracketed banners before kubectl JSON, and include the container runtime in image and container inspect cache keys. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): parse Podman image lists emitted as JSON arrays Podman applies {{json .}} to the whole image slice. Accept that array, including Names and byte sizes, and keep Docker's JSON lines. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): parse Podman stats arrays Accept Podman's stats JSON array and its id/cpu_percent fields, and keep Docker's one-stat-per-line output. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): list every Podman image tag Expand each name in a Podman image's Names array into its own row so extra tags stay visible and searchable. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): quote kubectl list and delete for the session shell Namespace, pod, and delete commands follow PowerShell or cmd quoting when the session shell is not POSIX, so apostrophes in a context name survive local PowerShell. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): drop renamed runtime launch commands from shell history Container popups now probe with _nc_rt_err and also cover podman, nerdctl, and kubectl. History sanitizers still required _nc_docker_err, so those launches stayed in persisted history. Confirm-dialog resets also follow the selected runtime. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): sum Podman interface stats and quote kubectl for the executor Podman {{json .}} stats keep per-interface counters on Network, so the details panel now sums RxBytes and TxBytes. Background kubectl runs in powershell.exe on local Windows even when the terminal is cmd, so those commands are quoted for that executor instead of the interactive shell. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): remove a selected image tag instead of the shared id Podman rows that share one image id were all deleted with rmi <id>, which either fails while other tags remain or drops every name. Tagged rows now run rmi repository:tag; dangling images still use the id. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): reject cmd kubectl arguments that contain percent signs Interactive cmd.exe expands %VAR% inside double quotes, so a context such as prod-%USERNAME% would be rewritten before kubectl sees it. Cmd popups now refuse that argument. PowerShell quoting still preserves the percent. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * refactor(system-manager): move runtime selection into an application hook Persisted source, context, and namespace state plus the kubectl discovery effects now live in useContainerRuntimeSelection. DockerManagerTab only renders the switcher and panels. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): offer kubectl shell only for running containers Succeeded and failed pods have no running container, and a completed init container cannot accept exec. Shell targets now come from container statuses that are running; logs still include init containers. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): keep Windows popup commands out of shell history PowerShell and cmd container popups inspect then run, and kubectl popups are typed directly. History filtering only matched the POSIX sh -c wrapper, so those launches were stored as user commands. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): mark Windows popups instead of matching kubectl syntax Filtering kubectl --context logs and describe also dropped commands the user typed. Windows popups now carry __NC_POPUP__, and ordinary kubectl lines stay in history. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): reject cmd arguments that contain delayed-expansion marks cmd.exe with /V:ON expands !VAR! inside double quotes. Contexts such as prod!USER! are refused for cmd popups, same as percent signs. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): hide raw nerdctl and unreachable cluster errors Only offer nerdctl after a short info probe succeeds, or when the failure is a socket permission error that sudo can still recover. Map dead containerd and unreachable kube API errors to short localized messages and keep the CLI text in the log. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): show terminating pods and ephemeral containers A deletion timestamp is Terminating even while the phase is still Running. Ephemeral containers are included in the logs list, and a running one is a shell target. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): keep the runtime select on Kubernetes and avoid covering it Kubernetes uses the same compact source menu as Docker, with context and namespace beside it. Shell popups open in the free part of the window so they no longer sit on top of the pod badges. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): restore the saved engine when Kubernetes disappears A host that was on Podman before Kubernetes keeps Podman if kubectl is gone, instead of falling back to Docker and overwriting that choice. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): avoid the visible system panel when others are parked A hidden parking-host panel can be the first system-manager match and has a zero-size rect. Scan every match and pass the first panel that actually has size so the shell popup still sits beside the open panel. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): keep a runtime choice across probe gaps and show podman ports A failed nerdctl or kubectl probe was saved as the fallback engine, so the previous choice never returned. Reconciliation now restores the stored source when it is detected again, and automatic fallbacks are not written back. Podman ps port mappings and exposed ports are formatted into the same display string the container detail strip already shows. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): show a pod's Evicted reason Pod status.reason is the eviction signal, while the phase stays Failed and the container often reports Error. Use that reason after a container waiting state and before the generic phase so the badge stays Evicted. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): keep an avoided shell popup inside the work area When the source window sits partly off-screen, clamping a slot back onto the display could cover the system panel, and the fallback then used the raw slot origin. Place the popup in the on-screen part of the slot instead. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(system-manager): left-align the runtime selector row The Kubernetes context, namespace, and runtime selects sat on the right of the header, so they did not line up with the search field. Keep the same padding, let the context select fill the remaining width, and leave the runtime and namespace selects at their natural width. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
660 lines
26 KiB
TypeScript
660 lines
26 KiB
TypeScript
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 { useTerminalBackend } from '../../application/state/useTerminalBackend';
|
|
import { remoteSoftwareRequiresSingleChannel } from '../../domain/singleChannelSshBanner.shared.mjs';
|
|
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,
|
|
hasContainerSource,
|
|
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 terminalBackend = useTerminalBackend();
|
|
const sessionId = session?.id ?? null;
|
|
const isConnected = session?.status === 'connected';
|
|
const [peerSingleChannel, setPeerSingleChannel] = useState<boolean | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!sessionId || !isConnected) {
|
|
setPeerSingleChannel(false);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setPeerSingleChannel(null);
|
|
void terminalBackend.getSessionRemoteInfo(sessionId).then((info) => {
|
|
if (cancelled) return;
|
|
setPeerSingleChannel(remoteSoftwareRequiresSingleChannel(info?.remoteSshVersion));
|
|
}).catch(() => {
|
|
if (!cancelled) setPeerSingleChannel(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [isConnected, sessionId, terminalBackend]);
|
|
|
|
const systemManagerSupported = peerSingleChannel !== true;
|
|
const capabilitiesEnabled = systemManagerSupported && peerSingleChannel !== null;
|
|
|
|
const capabilitiesTtlMs = terminalSettings.systemManagerProcessRefreshInterval * 1000;
|
|
|
|
const { capabilities, refreshCapabilities } = useSessionCapabilities(
|
|
sessionId,
|
|
isConnected,
|
|
backend,
|
|
isVisible && capabilitiesEnabled,
|
|
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 containerSourceReady = hasContainerSource(capabilities);
|
|
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' && !containerSourceReady) {
|
|
if (!probingRef.current) {
|
|
probingRef.current = true;
|
|
refreshCapabilities().finally(() => { probingRef.current = false; });
|
|
}
|
|
} else if (resolvedTab === 'tmux' && capabilities?.hasTmux !== true) {
|
|
void refreshCapabilities();
|
|
}
|
|
}, [resolvedTab, capabilities, containerSourceReady, 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' || containerSourceReady) 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, containerSourceReady, 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>
|
|
);
|
|
}
|
|
|
|
if (!systemManagerSupported) {
|
|
return (
|
|
<SystemPanelShell section="system-manager-panel">
|
|
{workspaceHostHeader}
|
|
<SystemPanelEmpty icon={Activity} message={t('systemManager.unsupportedRemote')} />
|
|
</SystemPanelShell>
|
|
);
|
|
}
|
|
|
|
const tmuxReady = capabilities?.hasTmux === true;
|
|
const dockerReady = containerSourceReady;
|
|
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 text-[11px] transition-colors',
|
|
isActive
|
|
? 'bg-muted text-foreground font-medium'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
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 && peerSingleChannel !== true}
|
|
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
|
|
key={session.hostId || sessionId}
|
|
sessionId={sessionId}
|
|
parentSession={session}
|
|
capabilities={capabilities}
|
|
isVisible={isVisible && resolvedTab === 'docker'}
|
|
warmupEnabled={isVisible && resolvedTab !== 'docker'}
|
|
backend={backend}
|
|
listRefreshIntervalSec={terminalSettings.systemManagerDockerListRefreshInterval}
|
|
statsRefreshIntervalSec={terminalSettings.systemManagerDockerStatsRefreshInterval}
|
|
/>
|
|
</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>
|
|
);
|
|
});
|