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
616 lines
25 KiB
TypeScript
616 lines
25 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 { 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>
|
|
);
|
|
});
|