mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +00:00
feat(tmux-monitor): add a unified multi-host view (#1520)
This commit is contained in:
1 parent
cbbb80bc82
commit
4c2f54dac0
12 files changed
+1192
-115
No files matched your search
@@ -10,6 +10,29 @@ Browse and control tmux sessions, windows and panes across your hosts.
|
|||||||
- Attach a terminal to any pane.
|
- Attach a terminal to any pane.
|
||||||
- Tag sessions with your own labels.
|
- Tag sessions with your own labels.
|
||||||
|
|
||||||
|
## Unified monitor
|
||||||
|
|
||||||
|
Open Tmux Monitor from a host action or the command palette. **All hosts**
|
||||||
|
shows an expandable tree for each SSH host with the monitor enabled. Filter
|
||||||
|
by host name or address, or choose **Single host** to use one host at a time.
|
||||||
|
Click a session name or pane to switch the live preview. The tree actions
|
||||||
|
continue to target the host containing that session, even when two hosts
|
||||||
|
have identical session names or pane IDs. The toolbar and output search
|
||||||
|
operate on the selected host.
|
||||||
|
|
||||||
|
The monitor remembers its mode, filter, collapsed hosts, expanded sessions,
|
||||||
|
last host and selected pane locally. An explicit host action takes precedence
|
||||||
|
over the saved host and clears the filter. A saved pane is restored only if
|
||||||
|
it still exists in the host's latest overview.
|
||||||
|
|
||||||
|
Expanded background hosts poll overview and resource metrics independently,
|
||||||
|
with at most four background hosts loading at once. Collapsing or filtering
|
||||||
|
out a background host stops its polling; switching away from the monitor
|
||||||
|
stops all overview/metrics polling. The selected host keeps polling while
|
||||||
|
its preview is active, including when its tree is collapsed. These controls
|
||||||
|
use the existing SSH APIs and require only tmux on the remote hosts, not
|
||||||
|
Auto Tmux.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
### Host
|
### Host
|
||||||
|
|||||||
@@ -100,6 +100,14 @@
|
|||||||
"timeJustNow": "just now",
|
"timeJustNow": "just now",
|
||||||
"timeMinutes": "{{count}}m ago",
|
"timeMinutes": "{{count}}m ago",
|
||||||
"timeHours": "{{count}}h ago",
|
"timeHours": "{{count}}h ago",
|
||||||
"timeDays": "{{count}}d ago"
|
"timeDays": "{{count}}d ago",
|
||||||
|
"allHosts": "All hosts",
|
||||||
|
"singleHost": "Single host",
|
||||||
|
"hostScope": "Monitor scope",
|
||||||
|
"filterHosts": "Filter hosts by name or address",
|
||||||
|
"selectHost": "Select host",
|
||||||
|
"toggleHost": "Expand or collapse {{host}}",
|
||||||
|
"noMatchingHosts": "No matching hosts",
|
||||||
|
"toggleSession": "Expand or collapse {{session}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||||
|
import { Button, Skeleton } from "@termix/plugin-sdk/ui";
|
||||||
|
import {
|
||||||
|
getTmuxOverview,
|
||||||
|
getTmuxMetrics,
|
||||||
|
type TmuxOverview,
|
||||||
|
type TmuxPaneMetrics,
|
||||||
|
} from "./api";
|
||||||
|
import { pollBackgroundHost } from "./poll-queue";
|
||||||
|
import { useAdaptivePolling } from "./use-adaptive-polling";
|
||||||
|
|
||||||
|
/** Each background host owns its response, error and adaptive polling cycle. */
|
||||||
|
export function HostOverview({
|
||||||
|
hostId,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
hostId: number;
|
||||||
|
children: (overview: TmuxOverview, metrics: TmuxPaneMetrics[]) => ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [overview, setOverview] = useState<TmuxOverview | null>(null);
|
||||||
|
const [metrics, setMetrics] = useState<TmuxPaneMetrics[]>([]);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const inFlight = useRef(false);
|
||||||
|
const mounted = useRef(true);
|
||||||
|
const signature = useRef("");
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
return () => {
|
||||||
|
mounted.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (inFlight.current) return;
|
||||||
|
inFlight.current = true;
|
||||||
|
try {
|
||||||
|
const snapshot = await pollBackgroundHost(async () => {
|
||||||
|
if (!mounted.current) return null;
|
||||||
|
const next = await getTmuxOverview(hostId);
|
||||||
|
const values =
|
||||||
|
next.available && mounted.current
|
||||||
|
? await getTmuxMetrics(hostId).catch(() => [])
|
||||||
|
: [];
|
||||||
|
return { next, values };
|
||||||
|
});
|
||||||
|
if (!snapshot) return;
|
||||||
|
const { next, values } = snapshot;
|
||||||
|
if (!mounted.current) return;
|
||||||
|
setOverview(next);
|
||||||
|
setError(false);
|
||||||
|
setMetrics(values);
|
||||||
|
const nextSignature = JSON.stringify([next, values]);
|
||||||
|
const changed = signature.current !== nextSignature;
|
||||||
|
signature.current = nextSignature;
|
||||||
|
return changed;
|
||||||
|
} catch (err) {
|
||||||
|
if (mounted.current) {
|
||||||
|
setError(true);
|
||||||
|
setMetrics([]);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
inFlight.current = false;
|
||||||
|
}
|
||||||
|
}, [hostId]);
|
||||||
|
useAdaptivePolling(
|
||||||
|
load,
|
||||||
|
{ minIntervalMs: 10_000, maxIntervalMs: 60_000, stablePollsPerStep: 3 },
|
||||||
|
true,
|
||||||
|
{ runImmediately: true },
|
||||||
|
);
|
||||||
|
if (error)
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-2 text-xs text-destructive" role="status">
|
||||||
|
{t("tmuxMonitor.failedToLoad")}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
void load().catch(() => {});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tmuxMonitor.retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (!overview) return <Skeleton className="m-2 h-8" />;
|
||||||
|
if (!overview.available)
|
||||||
|
return (
|
||||||
|
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
{t("tmuxMonitor.tmuxUnavailable")}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
if (!overview.sessions.length)
|
||||||
|
return (
|
||||||
|
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
{t("tmuxMonitor.noSessions")}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
return children(overview, metrics);
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ interface SessionTreeProps {
|
|||||||
onEditTags: (sessionName: string) => void;
|
onEditTags: (sessionName: string) => void;
|
||||||
/** Open a terminal attached to the session (the ▷ hover action). */
|
/** Open a terminal attached to the session (the ▷ hover action). */
|
||||||
onAttachSession: (sessionName: string) => void;
|
onAttachSession: (sessionName: string) => void;
|
||||||
|
onSelectSession?: (sessionName: string) => void;
|
||||||
/** Create a new window in the session (the + hover action). */
|
/** Create a new window in the session (the + hover action). */
|
||||||
onNewWindow: (sessionName: string) => void;
|
onNewWindow: (sessionName: string) => void;
|
||||||
/** Open the rename dialog for a session (… menu). */
|
/** Open the rename dialog for a session (… menu). */
|
||||||
@@ -87,6 +88,7 @@ export function SessionTree({
|
|||||||
metricsBySession,
|
metricsBySession,
|
||||||
onEditTags,
|
onEditTags,
|
||||||
onAttachSession,
|
onAttachSession,
|
||||||
|
onSelectSession,
|
||||||
onNewWindow,
|
onNewWindow,
|
||||||
onRenameSession,
|
onRenameSession,
|
||||||
onKillSession,
|
onKillSession,
|
||||||
@@ -137,13 +139,28 @@ export function SessionTree({
|
|||||||
<div key={session.name} className="mb-0.5">
|
<div key={session.name} className="mb-0.5">
|
||||||
<div
|
<div
|
||||||
className="group flex cursor-pointer items-center gap-1.5 px-2 py-1 hover:bg-muted/40"
|
className="group flex cursor-pointer items-center gap-1.5 px-2 py-1 hover:bg-muted/40"
|
||||||
onClick={() => onToggleSession(session.name)}
|
onClick={() =>
|
||||||
|
onSelectSession
|
||||||
|
? onSelectSession(session.name)
|
||||||
|
: onToggleSession(session.name)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{expanded ? (
|
<button
|
||||||
<ChevronDown className="size-3.5 shrink-0" />
|
aria-label={t("tmuxMonitor.toggleSession", {
|
||||||
) : (
|
session: session.name,
|
||||||
<ChevronRight className="size-3.5 shrink-0" />
|
})}
|
||||||
)}
|
aria-expanded={expanded}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onToggleSession(session.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="size-3.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="size-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<span
|
<span
|
||||||
className={`size-2 shrink-0 rounded-full ${session.attachedClients > 0 ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
|
className={`size-2 shrink-0 rounded-full ${session.attachedClients > 0 ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
isElectron,
|
isElectron,
|
||||||
} from "@termix/plugin-sdk/ui";
|
} from "@termix/plugin-sdk/ui";
|
||||||
import {
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
ChevronsDownUp,
|
ChevronsDownUp,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
@@ -57,6 +59,24 @@ import { SearchResults } from "./SearchResults";
|
|||||||
import { PanePreview } from "./PanePreview";
|
import { PanePreview } from "./PanePreview";
|
||||||
import type { SelectedPane } from "./types";
|
import type { SelectedPane } from "./types";
|
||||||
|
|
||||||
|
import { HostOverview } from "./HostOverview";
|
||||||
|
import {
|
||||||
|
readMonitorValue,
|
||||||
|
saveMonitorValue,
|
||||||
|
readExpanded,
|
||||||
|
readSelectedPane,
|
||||||
|
} from "./monitor-storage";
|
||||||
|
|
||||||
|
type TreeAction =
|
||||||
|
| { type: "pane"; pane: SelectedPane }
|
||||||
|
| {
|
||||||
|
type: "attach" | "tags" | "rename" | "killSession" | "newWindow";
|
||||||
|
session: string;
|
||||||
|
}
|
||||||
|
| { type: "killPane"; paneId: string }
|
||||||
|
| { type: "split"; paneId: string; direction: "h" | "v" }
|
||||||
|
| { type: "killWindow"; session: string; index: number };
|
||||||
|
|
||||||
const OVERVIEW_POLL_MS = 10_000;
|
const OVERVIEW_POLL_MS = 10_000;
|
||||||
const METRICS_POLL_MS = 10_000;
|
const METRICS_POLL_MS = 10_000;
|
||||||
const TIME_TICK_MS = 30_000;
|
const TIME_TICK_MS = 30_000;
|
||||||
@@ -73,23 +93,13 @@ function expandedStorageKey(hostId: string | number): string {
|
|||||||
return `${LS_PREFIX}expanded-${hostId}`;
|
return `${LS_PREFIX}expanded-${hostId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readStoredExpanded(hostId: string | number): Set<string> | null {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(expandedStorageKey(hostId));
|
|
||||||
if (!raw) return null;
|
|
||||||
const parsed: unknown = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(parsed)) return null;
|
|
||||||
return new Set(parsed.filter((v): v is string => typeof v === "string"));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TmuxMonitor({
|
export function TmuxMonitor({
|
||||||
initialHostId,
|
initialHostId,
|
||||||
|
initialHostRequest,
|
||||||
isVisible = true,
|
isVisible = true,
|
||||||
}: {
|
}: {
|
||||||
initialHostId?: number;
|
initialHostId?: number;
|
||||||
|
initialHostRequest?: number;
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -99,6 +109,7 @@ export function TmuxMonitor({
|
|||||||
() =>
|
() =>
|
||||||
allHosts.filter(
|
allHosts.filter(
|
||||||
(h) =>
|
(h) =>
|
||||||
|
h.enableSsh !== false &&
|
||||||
((h.connectionType as string | undefined) ?? "ssh") === "ssh" &&
|
((h.connectionType as string | undefined) ?? "ssh") === "ssh" &&
|
||||||
tmuxMonitorEnabled(h),
|
tmuxMonitorEnabled(h),
|
||||||
),
|
),
|
||||||
@@ -107,6 +118,31 @@ export function TmuxMonitor({
|
|||||||
const [selectedHostId, setSelectedHostId] = useState<string | number | null>(
|
const [selectedHostId, setSelectedHostId] = useState<string | number | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [scope, setScope] = useState<"all" | "single">(() =>
|
||||||
|
readMonitorValue<string>("scope", "all") === "single" ? "single" : "all",
|
||||||
|
);
|
||||||
|
const [hostFilter, setHostFilter] = useState(() => {
|
||||||
|
const value = readMonitorValue<unknown>("filter", "");
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
});
|
||||||
|
const [collapsedHosts, setCollapsedHosts] = useState<Set<string>>(() => {
|
||||||
|
const value = readMonitorValue<unknown>("collapsed-hosts", []);
|
||||||
|
return new Set(
|
||||||
|
Array.isArray(value)
|
||||||
|
? value.filter((id): id is string => typeof id === "string")
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const [expandedByHost, setExpandedByHost] = useState<
|
||||||
|
Record<string, Set<string>>
|
||||||
|
>({});
|
||||||
|
const [pendingAction, setPendingAction] = useState<{
|
||||||
|
hostId: string | number;
|
||||||
|
action: TreeAction;
|
||||||
|
} | null>(null);
|
||||||
|
const [overviewHostId, setOverviewHostId] = useState<string | number | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [overview, setOverview] = useState<TmuxOverview | null>(null);
|
const [overview, setOverview] = useState<TmuxOverview | null>(null);
|
||||||
const [overviewLoading, setOverviewLoading] = useState(false);
|
const [overviewLoading, setOverviewLoading] = useState(false);
|
||||||
const [overviewError, setOverviewError] = useState<string | null>(null);
|
const [overviewError, setOverviewError] = useState<string | null>(null);
|
||||||
@@ -132,6 +168,7 @@ export function TmuxMonitor({
|
|||||||
// stale (the user switched away while the request was in flight) and must
|
// stale (the user switched away while the request was in flight) and must
|
||||||
// not overwrite the current tree.
|
// not overwrite the current tree.
|
||||||
const activeHostRef = useRef<string | number | null>(null);
|
const activeHostRef = useRef<string | number | null>(null);
|
||||||
|
const hostGeneration = useRef(0);
|
||||||
const overviewSignatureRef = useRef("");
|
const overviewSignatureRef = useRef("");
|
||||||
const metricsSignatureRef = useRef("");
|
const metricsSignatureRef = useRef("");
|
||||||
// True when the expanded-session set for the current host was restored from
|
// True when the expanded-session set for the current host was restored from
|
||||||
@@ -153,7 +190,7 @@ export function TmuxMonitor({
|
|||||||
|
|
||||||
// -- resizable tree panel (same pattern as the AppShell host sidebar) -------
|
// -- resizable tree panel (same pattern as the AppShell host sidebar) -------
|
||||||
const [treeWidth, setTreeWidth] = useState(() => {
|
const [treeWidth, setTreeWidth] = useState(() => {
|
||||||
const saved = Number(localStorage.getItem(LS_TREE_WIDTH_KEY));
|
const saved = Number(readMonitorValue("tree-width", TREE_WIDTH_DEFAULT));
|
||||||
return Number.isFinite(saved) && saved >= TREE_WIDTH_MIN
|
return Number.isFinite(saved) && saved >= TREE_WIDTH_MIN
|
||||||
? Math.min(saved, TREE_WIDTH_MAX)
|
? Math.min(saved, TREE_WIDTH_MAX)
|
||||||
: TREE_WIDTH_DEFAULT;
|
: TREE_WIDTH_DEFAULT;
|
||||||
@@ -203,12 +240,18 @@ export function TmuxMonitor({
|
|||||||
hosts.some((h) => String(h.id) === String(initialHostId))
|
hosts.some((h) => String(h.id) === String(initialHostId))
|
||||||
) {
|
) {
|
||||||
initialHostPicked.current = true;
|
initialHostPicked.current = true;
|
||||||
return initialHostId;
|
return hosts.find((h) => String(h.id) === String(initialHostId))!.id;
|
||||||
}
|
}
|
||||||
const stored = localStorage.getItem(LS_LAST_HOST_KEY);
|
const stored = readMonitorValue<string | number | null>(
|
||||||
if (stored !== null && hosts.some((h) => String(h.id) === stored)) {
|
"last-host",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
stored !== null &&
|
||||||
|
hosts.some((h) => String(h.id) === String(stored))
|
||||||
|
) {
|
||||||
initialHostPicked.current = true;
|
initialHostPicked.current = true;
|
||||||
return stored;
|
return hosts.find((h) => String(h.id) === String(stored))!.id;
|
||||||
}
|
}
|
||||||
initialHostPicked.current = true;
|
initialHostPicked.current = true;
|
||||||
return hosts[0].id;
|
return hosts[0].id;
|
||||||
@@ -219,7 +262,7 @@ export function TmuxMonitor({
|
|||||||
// for it), fall back the same way the initial pick does.
|
// for it), fall back the same way the initial pick does.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hostsLoaded || selectedHostId === null) return;
|
if (!hostsLoaded || selectedHostId === null) return;
|
||||||
if (hosts.some((h) => h.id === selectedHostId)) return;
|
if (hosts.some((h) => String(h.id) === String(selectedHostId))) return;
|
||||||
setSelectedHostId(hosts.length > 0 ? hosts[0].id : null);
|
setSelectedHostId(hosts.length > 0 ? hosts[0].id : null);
|
||||||
}, [hostsLoaded, hosts, selectedHostId]);
|
}, [hostsLoaded, hosts, selectedHostId]);
|
||||||
|
|
||||||
@@ -227,12 +270,20 @@ export function TmuxMonitor({
|
|||||||
// follow it so the requested host gets selected without a remount.
|
// follow it so the requested host gets selected without a remount.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialHostId == null) return;
|
if (initialHostId == null) return;
|
||||||
if (hosts.some((h) => String(h.id) === String(initialHostId)))
|
const host = hosts.find((h) => String(h.id) === String(initialHostId));
|
||||||
setSelectedHostId(initialHostId);
|
if (host) setSelectedHostId(host.id);
|
||||||
|
setHostFilter("");
|
||||||
|
saveMonitorValue("filter", "");
|
||||||
|
setCollapsedHosts((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
next.delete(String(initialHostId));
|
||||||
|
saveMonitorValue("collapsed-hosts", [...next]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
// Intentionally not depending on `hosts`: the mount effect already picks
|
// Intentionally not depending on `hosts`: the mount effect already picks
|
||||||
// the initial host once hosts load.
|
// the initial host once hosts load.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [initialHostId]);
|
}, [initialHostId, initialHostRequest]);
|
||||||
|
|
||||||
// -- relative time refresh --------------------------------------------------
|
// -- relative time refresh --------------------------------------------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -244,24 +295,54 @@ export function TmuxMonitor({
|
|||||||
// -- overview polling -----------------------------------------------------
|
// -- overview polling -----------------------------------------------------
|
||||||
const loadOverview = useCallback(
|
const loadOverview = useCallback(
|
||||||
async (hostId: string | number, silent = false) => {
|
async (hostId: string | number, silent = false) => {
|
||||||
|
const generation = hostGeneration.current;
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setOverviewLoading(true);
|
setOverviewLoading(true);
|
||||||
setOverviewError(null);
|
setOverviewError(null);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await getTmuxOverview(Number(hostId));
|
const data = await getTmuxOverview(Number(hostId));
|
||||||
if (activeHostRef.current !== hostId) return true;
|
if (
|
||||||
|
activeHostRef.current !== hostId ||
|
||||||
|
generation !== hostGeneration.current
|
||||||
|
)
|
||||||
|
return true;
|
||||||
const signature = JSON.stringify(data);
|
const signature = JSON.stringify(data);
|
||||||
const changed = signature !== overviewSignatureRef.current;
|
const changed = signature !== overviewSignatureRef.current;
|
||||||
overviewSignatureRef.current = signature;
|
overviewSignatureRef.current = signature;
|
||||||
|
setOverviewError(null);
|
||||||
|
setOverviewHostId(hostId);
|
||||||
setOverview(data);
|
setOverview(data);
|
||||||
|
setSelectedPane((pane) => {
|
||||||
|
const candidate = pane ?? readSelectedPane(hostId);
|
||||||
|
const exists =
|
||||||
|
candidate &&
|
||||||
|
data.sessions.some(
|
||||||
|
(session) =>
|
||||||
|
session.name === candidate.sessionName &&
|
||||||
|
session.windows.some(
|
||||||
|
(win) =>
|
||||||
|
win.index === candidate.windowIndex &&
|
||||||
|
win.panes.some((p) => p.id === candidate.paneId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!exists) saveMonitorValue(`pane-${hostId}`, null);
|
||||||
|
return exists ? candidate : null;
|
||||||
|
});
|
||||||
setExpandedSessions((prev) => {
|
setExpandedSessions((prev) => {
|
||||||
if (expandedRestoredRef.current || prev.size > 0) return prev;
|
if (expandedRestoredRef.current || prev.size > 0) return prev;
|
||||||
return new Set(data.sessions.map((s) => s.name));
|
return new Set(data.sessions.map((s) => s.name));
|
||||||
});
|
});
|
||||||
return changed;
|
return changed;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (activeHostRef.current !== hostId) return true;
|
if (
|
||||||
|
activeHostRef.current !== hostId ||
|
||||||
|
generation !== hostGeneration.current
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
setPendingAction((pending) =>
|
||||||
|
pending?.hostId === hostId ? null : pending,
|
||||||
|
);
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
const axiosErr = err as {
|
const axiosErr = err as {
|
||||||
code?: string;
|
code?: string;
|
||||||
@@ -294,7 +375,11 @@ export function TmuxMonitor({
|
|||||||
if (silent) throw err;
|
if (silent) throw err;
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent && activeHostRef.current === hostId)
|
if (
|
||||||
|
!silent &&
|
||||||
|
activeHostRef.current === hostId &&
|
||||||
|
generation === hostGeneration.current
|
||||||
|
)
|
||||||
setOverviewLoading(false);
|
setOverviewLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -314,8 +399,9 @@ export function TmuxMonitor({
|
|||||||
}
|
}
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
const ok = await loadOverview(selectedHostId, true);
|
await loadOverview(selectedHostId, true);
|
||||||
if (!ok) toast.error(t("tmuxMonitor.refreshFailed"));
|
} catch {
|
||||||
|
toast.error(t("tmuxMonitor.refreshFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
@@ -323,8 +409,12 @@ export function TmuxMonitor({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeHostRef.current = selectedHostId;
|
activeHostRef.current = selectedHostId;
|
||||||
|
hostGeneration.current++;
|
||||||
setOverview(null);
|
setOverview(null);
|
||||||
|
setOverviewHostId(null);
|
||||||
setSelectedPane(null);
|
setSelectedPane(null);
|
||||||
|
overviewSignatureRef.current = "";
|
||||||
|
metricsSignatureRef.current = "";
|
||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
setMetrics([]);
|
setMetrics([]);
|
||||||
if (selectedHostId === null) return;
|
if (selectedHostId === null) return;
|
||||||
@@ -333,7 +423,7 @@ export function TmuxMonitor({
|
|||||||
} catch {
|
} catch {
|
||||||
// localStorage may be unavailable
|
// localStorage may be unavailable
|
||||||
}
|
}
|
||||||
const storedExpanded = readStoredExpanded(selectedHostId);
|
const storedExpanded = readExpanded(selectedHostId);
|
||||||
expandedRestoredRef.current = storedExpanded !== null;
|
expandedRestoredRef.current = storedExpanded !== null;
|
||||||
setExpandedSessions(storedExpanded ?? new Set());
|
setExpandedSessions(storedExpanded ?? new Set());
|
||||||
loadOverview(selectedHostId);
|
loadOverview(selectedHostId);
|
||||||
@@ -359,7 +449,14 @@ export function TmuxMonitor({
|
|||||||
useAdaptivePolling(
|
useAdaptivePolling(
|
||||||
async () => {
|
async () => {
|
||||||
if (selectedHostId === null) return;
|
if (selectedHostId === null) return;
|
||||||
const next = await getTmuxMetrics(Number(selectedHostId));
|
const hostId = selectedHostId;
|
||||||
|
const generation = hostGeneration.current;
|
||||||
|
const next = await getTmuxMetrics(Number(hostId));
|
||||||
|
if (
|
||||||
|
activeHostRef.current !== hostId ||
|
||||||
|
generation !== hostGeneration.current
|
||||||
|
)
|
||||||
|
return;
|
||||||
const signature = JSON.stringify(next);
|
const signature = JSON.stringify(next);
|
||||||
const changed = signature !== metricsSignatureRef.current;
|
const changed = signature !== metricsSignatureRef.current;
|
||||||
metricsSignatureRef.current = signature;
|
metricsSignatureRef.current = signature;
|
||||||
@@ -371,7 +468,10 @@ export function TmuxMonitor({
|
|||||||
maxIntervalMs: 60_000,
|
maxIntervalMs: 60_000,
|
||||||
stablePollsPerStep: 3,
|
stablePollsPerStep: 3,
|
||||||
},
|
},
|
||||||
selectedHostId !== null && !!overview?.available && isVisible,
|
selectedHostId !== null &&
|
||||||
|
overviewHostId === selectedHostId &&
|
||||||
|
!!overview?.available &&
|
||||||
|
isVisible,
|
||||||
);
|
);
|
||||||
|
|
||||||
// -- keyboard shortcuts -----------------------------------------------------
|
// -- keyboard shortcuts -----------------------------------------------------
|
||||||
@@ -403,6 +503,7 @@ export function TmuxMonitor({
|
|||||||
setSearching(true);
|
setSearching(true);
|
||||||
try {
|
try {
|
||||||
const result = await searchTmux(Number(selectedHostId), query);
|
const result = await searchTmux(Number(selectedHostId), query);
|
||||||
|
if (activeHostRef.current !== selectedHostId) return;
|
||||||
setSearchResults(result.matches);
|
setSearchResults(result.matches);
|
||||||
setSearchLimits(result);
|
setSearchLimits(result);
|
||||||
setSearchedQuery(query);
|
setSearchedQuery(query);
|
||||||
@@ -415,6 +516,10 @@ export function TmuxMonitor({
|
|||||||
|
|
||||||
const persistExpanded = useCallback(
|
const persistExpanded = useCallback(
|
||||||
(hostId: string | number, set: Set<string>) => {
|
(hostId: string | number, set: Set<string>) => {
|
||||||
|
setExpandedByHost((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[String(hostId)]: new Set(set),
|
||||||
|
}));
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
expandedStorageKey(hostId),
|
expandedStorageKey(hostId),
|
||||||
@@ -441,8 +546,7 @@ export function TmuxMonitor({
|
|||||||
const selectPane = useCallback(
|
const selectPane = useCallback(
|
||||||
(pane: SelectedPane) => {
|
(pane: SelectedPane) => {
|
||||||
setSelectedPane(pane);
|
setSelectedPane(pane);
|
||||||
if (selectedHostId !== null)
|
saveMonitorValue(`pane-${selectedHostId}`, pane);
|
||||||
focusTmuxPane(Number(selectedHostId), pane.paneId).catch(() => {});
|
|
||||||
},
|
},
|
||||||
[selectedHostId],
|
[selectedHostId],
|
||||||
);
|
);
|
||||||
@@ -461,6 +565,17 @@ export function TmuxMonitor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
selectedPane &&
|
||||||
|
selectedHostId !== null &&
|
||||||
|
overviewHostId === selectedHostId
|
||||||
|
)
|
||||||
|
void focusTmuxPane(Number(selectedHostId), selectedPane.paneId).catch(
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
}, [selectedHostId, overviewHostId, selectedPane]);
|
||||||
|
|
||||||
// -- create session ---------------------------------------------------------
|
// -- create session ---------------------------------------------------------
|
||||||
const [newSessionName, setNewSessionName] = useState("");
|
const [newSessionName, setNewSessionName] = useState("");
|
||||||
const [newSessionOpen, setNewSessionOpen] = useState(false);
|
const [newSessionOpen, setNewSessionOpen] = useState(false);
|
||||||
@@ -477,6 +592,7 @@ export function TmuxMonitor({
|
|||||||
setCreatingSession(true);
|
setCreatingSession(true);
|
||||||
try {
|
try {
|
||||||
await createTmuxSession(Number(selectedHostId), name);
|
await createTmuxSession(Number(selectedHostId), name);
|
||||||
|
if (activeHostRef.current !== selectedHostId) return;
|
||||||
toast.success(t("tmuxMonitor.sessionCreated", { name }));
|
toast.success(t("tmuxMonitor.sessionCreated", { name }));
|
||||||
setNewSessionOpen(false);
|
setNewSessionOpen(false);
|
||||||
setNewSessionName("");
|
setNewSessionName("");
|
||||||
@@ -484,7 +600,7 @@ export function TmuxMonitor({
|
|||||||
setExpandedSessions(next);
|
setExpandedSessions(next);
|
||||||
expandedRestoredRef.current = true;
|
expandedRestoredRef.current = true;
|
||||||
persistExpanded(selectedHostId, next);
|
persistExpanded(selectedHostId, next);
|
||||||
loadOverview(selectedHostId, true);
|
void loadOverview(selectedHostId, true).catch(() => {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const axiosErr = err as { response?: { data?: { error?: string } } };
|
const axiosErr = err as { response?: { data?: { error?: string } } };
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -504,7 +620,8 @@ export function TmuxMonitor({
|
|||||||
if (selectedHostId === null) return;
|
if (selectedHostId === null) return;
|
||||||
try {
|
try {
|
||||||
await splitTmuxPane(Number(selectedHostId), paneId, direction);
|
await splitTmuxPane(Number(selectedHostId), paneId, direction);
|
||||||
loadOverview(selectedHostId, true);
|
if (activeHostRef.current !== selectedHostId) return;
|
||||||
|
void loadOverview(selectedHostId, true).catch(() => {});
|
||||||
nudgePreviewRedraw();
|
nudgePreviewRedraw();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("tmuxMonitor.splitFailed"));
|
toast.error(t("tmuxMonitor.splitFailed"));
|
||||||
@@ -519,7 +636,8 @@ export function TmuxMonitor({
|
|||||||
if (selectedHostId === null) return;
|
if (selectedHostId === null) return;
|
||||||
try {
|
try {
|
||||||
await createTmuxWindow(Number(selectedHostId), sessionName);
|
await createTmuxWindow(Number(selectedHostId), sessionName);
|
||||||
loadOverview(selectedHostId, true);
|
if (activeHostRef.current !== selectedHostId) return;
|
||||||
|
void loadOverview(selectedHostId, true).catch(() => {});
|
||||||
nudgePreviewRedraw();
|
nudgePreviewRedraw();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("tmuxMonitor.windowCreateFailed"));
|
toast.error(t("tmuxMonitor.windowCreateFailed"));
|
||||||
@@ -560,6 +678,7 @@ export function TmuxMonitor({
|
|||||||
setRenaming(true);
|
setRenaming(true);
|
||||||
try {
|
try {
|
||||||
await renameTmuxSession(Number(selectedHostId), renameTarget, newName);
|
await renameTmuxSession(Number(selectedHostId), renameTarget, newName);
|
||||||
|
if (activeHostRef.current !== selectedHostId) return;
|
||||||
toast.success(t("tmuxMonitor.sessionRenamed", { name: newName }));
|
toast.success(t("tmuxMonitor.sessionRenamed", { name: newName }));
|
||||||
if (expandedSessions.has(renameTarget)) {
|
if (expandedSessions.has(renameTarget)) {
|
||||||
const next = new Set(expandedSessions);
|
const next = new Set(expandedSessions);
|
||||||
@@ -574,7 +693,7 @@ export function TmuxMonitor({
|
|||||||
setSelectedPane({ ...selectedPane, sessionName: newName });
|
setSelectedPane({ ...selectedPane, sessionName: newName });
|
||||||
}
|
}
|
||||||
setRenameTarget(null);
|
setRenameTarget(null);
|
||||||
loadOverview(selectedHostId, true);
|
void loadOverview(selectedHostId, true).catch(() => {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const axiosErr = err as { response?: { data?: { error?: string } } };
|
const axiosErr = err as { response?: { data?: { error?: string } } };
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -590,6 +709,7 @@ export function TmuxMonitor({
|
|||||||
setKilling(true);
|
setKilling(true);
|
||||||
try {
|
try {
|
||||||
await killTmuxSession(Number(selectedHostId), killTarget);
|
await killTmuxSession(Number(selectedHostId), killTarget);
|
||||||
|
if (activeHostRef.current !== selectedHostId) return;
|
||||||
toast.success(t("tmuxMonitor.sessionKilled", { name: killTarget }));
|
toast.success(t("tmuxMonitor.sessionKilled", { name: killTarget }));
|
||||||
if (selectedPane?.sessionName === killTarget) setSelectedPane(null);
|
if (selectedPane?.sessionName === killTarget) setSelectedPane(null);
|
||||||
if (expandedSessions.has(killTarget)) {
|
if (expandedSessions.has(killTarget)) {
|
||||||
@@ -599,7 +719,7 @@ export function TmuxMonitor({
|
|||||||
persistExpanded(selectedHostId, next);
|
persistExpanded(selectedHostId, next);
|
||||||
}
|
}
|
||||||
setKillTarget(null);
|
setKillTarget(null);
|
||||||
loadOverview(selectedHostId, true);
|
void loadOverview(selectedHostId, true).catch(() => {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const axiosErr = err as { response?: { data?: { error?: string } } };
|
const axiosErr = err as { response?: { data?: { error?: string } } };
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -649,11 +769,13 @@ export function TmuxMonitor({
|
|||||||
killWindowTarget.sessionName,
|
killWindowTarget.sessionName,
|
||||||
killWindowTarget.windowIndex,
|
killWindowTarget.windowIndex,
|
||||||
);
|
);
|
||||||
|
if (activeHostRef.current !== selectedHostId) return;
|
||||||
const wasViewing =
|
const wasViewing =
|
||||||
selectedPane?.sessionName === killWindowTarget.sessionName &&
|
selectedPane?.sessionName === killWindowTarget.sessionName &&
|
||||||
selectedPane?.windowIndex === killWindowTarget.windowIndex;
|
selectedPane?.windowIndex === killWindowTarget.windowIndex;
|
||||||
setKillWindowTarget(null);
|
setKillWindowTarget(null);
|
||||||
const data = await getTmuxOverview(Number(selectedHostId));
|
const data = await getTmuxOverview(Number(selectedHostId));
|
||||||
|
if (activeHostRef.current !== selectedHostId) return;
|
||||||
setOverview(data);
|
setOverview(data);
|
||||||
if (wasViewing) selectSurvivor(data, killWindowTarget.sessionName);
|
if (wasViewing) selectSurvivor(data, killWindowTarget.sessionName);
|
||||||
nudgePreviewRedraw();
|
nudgePreviewRedraw();
|
||||||
@@ -677,12 +799,14 @@ export function TmuxMonitor({
|
|||||||
setKillingPane(true);
|
setKillingPane(true);
|
||||||
try {
|
try {
|
||||||
await killTmuxPane(Number(selectedHostId), killPaneTarget);
|
await killTmuxPane(Number(selectedHostId), killPaneTarget);
|
||||||
|
if (activeHostRef.current !== selectedHostId) return;
|
||||||
const viewedSession =
|
const viewedSession =
|
||||||
selectedPane?.paneId === killPaneTarget
|
selectedPane?.paneId === killPaneTarget
|
||||||
? selectedPane.sessionName
|
? selectedPane.sessionName
|
||||||
: null;
|
: null;
|
||||||
setKillPaneTarget(null);
|
setKillPaneTarget(null);
|
||||||
const data = await getTmuxOverview(Number(selectedHostId));
|
const data = await getTmuxOverview(Number(selectedHostId));
|
||||||
|
if (activeHostRef.current !== selectedHostId) return;
|
||||||
setOverview(data);
|
setOverview(data);
|
||||||
if (viewedSession) selectSurvivor(data, viewedSession);
|
if (viewedSession) selectSurvivor(data, viewedSession);
|
||||||
nudgePreviewRedraw();
|
nudgePreviewRedraw();
|
||||||
@@ -716,9 +840,10 @@ export function TmuxMonitor({
|
|||||||
setSavingTags(true);
|
setSavingTags(true);
|
||||||
try {
|
try {
|
||||||
await setTmuxSessionTags(Number(selectedHostId), tagsTarget, tags);
|
await setTmuxSessionTags(Number(selectedHostId), tagsTarget, tags);
|
||||||
|
if (activeHostRef.current !== selectedHostId) return;
|
||||||
toast.success(t("tmuxMonitor.tagsSaved"));
|
toast.success(t("tmuxMonitor.tagsSaved"));
|
||||||
setTagsTarget(null);
|
setTagsTarget(null);
|
||||||
loadOverview(selectedHostId, true);
|
void loadOverview(selectedHostId, true).catch(() => {});
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("tmuxMonitor.tagsSaveFailed"));
|
toast.error(t("tmuxMonitor.tagsSaveFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -789,7 +914,7 @@ export function TmuxMonitor({
|
|||||||
}, [metrics]);
|
}, [metrics]);
|
||||||
|
|
||||||
const selectedHost: PluginHostRecord | undefined = hosts.find(
|
const selectedHost: PluginHostRecord | undefined = hosts.find(
|
||||||
(h) => h.id === selectedHostId,
|
(h) => String(h.id) === String(selectedHostId),
|
||||||
);
|
);
|
||||||
const hostLabel = selectedHost
|
const hostLabel = selectedHost
|
||||||
? selectedHost.name ||
|
? selectedHost.name ||
|
||||||
@@ -799,6 +924,156 @@ export function TmuxMonitor({
|
|||||||
? metricsByPane.get(selectedPane.paneId)
|
? metricsByPane.get(selectedPane.paneId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
function executeTreeAction(action: TreeAction) {
|
||||||
|
if (
|
||||||
|
action.type === "pane" &&
|
||||||
|
!overview?.sessions.some(
|
||||||
|
(session) =>
|
||||||
|
session.name === action.pane.sessionName &&
|
||||||
|
session.windows.some(
|
||||||
|
(win) =>
|
||||||
|
win.index === action.pane.windowIndex &&
|
||||||
|
win.panes.some((pane) => pane.id === action.pane.paneId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
switch (action.type) {
|
||||||
|
case "pane":
|
||||||
|
selectPane(action.pane);
|
||||||
|
break;
|
||||||
|
case "attach":
|
||||||
|
attachInline(action.session);
|
||||||
|
break;
|
||||||
|
case "tags":
|
||||||
|
openTagsEditor(action.session);
|
||||||
|
break;
|
||||||
|
case "rename":
|
||||||
|
setRenameDraft(action.session);
|
||||||
|
setRenameTarget(action.session);
|
||||||
|
break;
|
||||||
|
case "killSession":
|
||||||
|
setKillTarget(action.session);
|
||||||
|
break;
|
||||||
|
case "newWindow":
|
||||||
|
void newWindow(action.session);
|
||||||
|
break;
|
||||||
|
case "killPane":
|
||||||
|
setKillPaneTarget(action.paneId);
|
||||||
|
break;
|
||||||
|
case "split":
|
||||||
|
void splitPane(action.paneId, action.direction);
|
||||||
|
break;
|
||||||
|
case "killWindow":
|
||||||
|
setKillWindowTarget({
|
||||||
|
sessionName: action.session,
|
||||||
|
windowIndex: action.index,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function dispatchTreeAction(hostId: string | number, action: TreeAction) {
|
||||||
|
if (hostId === selectedHostId && overviewHostId === hostId)
|
||||||
|
executeTreeAction(action);
|
||||||
|
else {
|
||||||
|
setPendingAction({ hostId, action });
|
||||||
|
setSelectedHostId(hostId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const executeTreeActionRef = useRef(executeTreeAction);
|
||||||
|
executeTreeActionRef.current = executeTreeAction;
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!pendingAction ||
|
||||||
|
pendingAction.hostId !== selectedHostId ||
|
||||||
|
overviewHostId !== selectedHostId
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
setPendingAction(null);
|
||||||
|
executeTreeActionRef.current(pendingAction.action);
|
||||||
|
}, [pendingAction, selectedHostId, overviewHostId]);
|
||||||
|
useEffect(() => {
|
||||||
|
setPendingAction((pending) =>
|
||||||
|
pending?.hostId === selectedHostId ? pending : null,
|
||||||
|
);
|
||||||
|
setRenameTarget(null);
|
||||||
|
setKillTarget(null);
|
||||||
|
setTagsTarget(null);
|
||||||
|
setKillPaneTarget(null);
|
||||||
|
setKillWindowTarget(null);
|
||||||
|
setNewSessionOpen(false);
|
||||||
|
}, [selectedHostId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedHostId !== null && overviewHostId === selectedHostId)
|
||||||
|
saveMonitorValue(`pane-${selectedHostId}`, selectedPane);
|
||||||
|
}, [selectedHostId, overviewHostId, selectedPane]);
|
||||||
|
|
||||||
|
const filteredHosts =
|
||||||
|
scope === "single"
|
||||||
|
? hosts.filter((h) => h.id === selectedHostId)
|
||||||
|
: hosts.filter((h) =>
|
||||||
|
`${h.name ?? ""} ${h.ip ?? ""}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(hostFilter.toLowerCase().trim()),
|
||||||
|
);
|
||||||
|
|
||||||
|
function backgroundTree(
|
||||||
|
hostId: string | number,
|
||||||
|
data: TmuxOverview,
|
||||||
|
values: TmuxPaneMetrics[],
|
||||||
|
) {
|
||||||
|
const expanded =
|
||||||
|
expandedByHost[String(hostId)] ??
|
||||||
|
readExpanded(hostId) ??
|
||||||
|
new Set(data.sessions.map((session) => session.name));
|
||||||
|
const byPane = new Map(values.map((m) => [m.paneId, m]));
|
||||||
|
const bySession = new Map<string, SessionMetricsAgg>();
|
||||||
|
for (const m of values) {
|
||||||
|
const agg = bySession.get(m.sessionName) ?? {
|
||||||
|
cpu: 0,
|
||||||
|
memKb: 0,
|
||||||
|
gpuMb: 0,
|
||||||
|
};
|
||||||
|
agg.cpu += m.cpuPercent;
|
||||||
|
agg.memKb += m.memRssKb;
|
||||||
|
agg.gpuMb += m.gpuMemMb;
|
||||||
|
bySession.set(m.sessionName, agg);
|
||||||
|
}
|
||||||
|
const run = (action: TreeAction) => dispatchTreeAction(hostId, action);
|
||||||
|
return (
|
||||||
|
<SessionTree
|
||||||
|
sessions={data.sessions}
|
||||||
|
expandedSessions={expanded}
|
||||||
|
onToggleSession={(name) => {
|
||||||
|
const next = new Set(expanded);
|
||||||
|
if (next.has(name)) next.delete(name);
|
||||||
|
else next.add(name);
|
||||||
|
persistExpanded(hostId, next);
|
||||||
|
}}
|
||||||
|
selectedPaneId={null}
|
||||||
|
onSelectPane={(pane) => run({ type: "pane", pane })}
|
||||||
|
metricsByPane={byPane}
|
||||||
|
metricsBySession={bySession}
|
||||||
|
onEditTags={(session) => run({ type: "tags", session })}
|
||||||
|
onAttachSession={(session) => run({ type: "attach", session })}
|
||||||
|
onSelectSession={(session) => run({ type: "attach", session })}
|
||||||
|
onNewWindow={(session) => run({ type: "newWindow", session })}
|
||||||
|
onRenameSession={(session) => run({ type: "rename", session })}
|
||||||
|
onKillSession={(session) => run({ type: "killSession", session })}
|
||||||
|
onKillPane={(paneId) => run({ type: "killPane", paneId })}
|
||||||
|
onSplitPane={(paneId, direction) =>
|
||||||
|
run({ type: "split", paneId, direction })
|
||||||
|
}
|
||||||
|
onKillWindow={(session, index) =>
|
||||||
|
run({ type: "killWindow", session, index })
|
||||||
|
}
|
||||||
|
compact={treeWidth < 280}
|
||||||
|
now={now}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full bg-background text-foreground">
|
<div className="flex h-full w-full bg-background text-foreground">
|
||||||
{/* Left rail: hosts + session tree. Resizable via the right-edge
|
{/* Left rail: hosts + session tree. Resizable via the right-edge
|
||||||
@@ -904,6 +1179,52 @@ export function TmuxMonitor({
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2 border-b border-border p-2">
|
||||||
|
<select
|
||||||
|
className="h-8 w-full border border-border bg-background px-2 text-sm"
|
||||||
|
aria-label={t("tmuxMonitor.hostScope")}
|
||||||
|
value={scope}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value === "single" ? "single" : "all";
|
||||||
|
setScope(value);
|
||||||
|
saveMonitorValue("scope", value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="all">{t("tmuxMonitor.allHosts")}</option>
|
||||||
|
<option value="single">{t("tmuxMonitor.singleHost")}</option>
|
||||||
|
</select>
|
||||||
|
{scope === "all" ? (
|
||||||
|
<Input
|
||||||
|
aria-label={t("tmuxMonitor.filterHosts")}
|
||||||
|
placeholder={t("tmuxMonitor.filterHosts")}
|
||||||
|
value={hostFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setHostFilter(e.target.value);
|
||||||
|
saveMonitorValue("filter", e.target.value);
|
||||||
|
}}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
className="h-8 w-full border border-border bg-background px-2 text-sm"
|
||||||
|
aria-label={t("tmuxMonitor.selectHost")}
|
||||||
|
value={selectedHostId ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPendingAction(null);
|
||||||
|
setSelectedHostId(
|
||||||
|
hosts.find((h) => String(h.id) === e.target.value)?.id ??
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hosts.map((host) => (
|
||||||
|
<option key={host.id} value={host.id}>
|
||||||
|
{host.name || host.ip}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{/* Radix wraps the viewport content in a display:table div sized to
|
{/* Radix wraps the viewport content in a display:table div sized to
|
||||||
the widest row, so one long pane path would stretch every row and
|
the widest row, so one long pane path would stretch every row and
|
||||||
clip the right-aligned actions; force block so rows shrink and
|
clip the right-aligned actions; force block so rows shrink and
|
||||||
@@ -920,75 +1241,152 @@ export function TmuxMonitor({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{overviewLoading && (
|
{filteredHosts.map((host) => {
|
||||||
<div className="space-y-3 px-2 py-2">
|
const active = host.id === selectedHostId;
|
||||||
{[0, 1, 2].map((i) => (
|
const collapsed =
|
||||||
<div key={i} className="space-y-1.5">
|
scope === "all" && collapsedHosts.has(String(host.id));
|
||||||
<Skeleton className="h-5 w-full" />
|
return (
|
||||||
<Skeleton className="ml-6 h-3.5 w-3/4" />
|
<section
|
||||||
<Skeleton className="ml-6 h-3.5 w-2/3" />
|
key={host.id}
|
||||||
</div>
|
aria-label={host.name || String(host.ip)}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{overviewError && (
|
|
||||||
<div className="space-y-2 px-2 py-4">
|
|
||||||
<p className="text-sm text-destructive">{overviewError}</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs"
|
|
||||||
onClick={() =>
|
|
||||||
selectedHostId !== null && loadOverview(selectedHostId)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<RefreshCw className="mr-1 size-3" />
|
<div
|
||||||
{t("tmuxMonitor.retry")}
|
className={`flex items-center gap-1 px-1 py-2 ${active ? "bg-accent-brand/10" : ""}`}
|
||||||
</Button>
|
>
|
||||||
</div>
|
{scope === "all" && (
|
||||||
)}
|
<button
|
||||||
{overview && !overview.available && (
|
aria-label={t("tmuxMonitor.toggleHost", {
|
||||||
<div className="px-2 py-4">
|
host: host.name || host.ip,
|
||||||
<p className="text-sm text-muted-foreground">
|
})}
|
||||||
{t("tmuxMonitor.tmuxUnavailable")}
|
aria-expanded={!collapsed}
|
||||||
</p>
|
onClick={() => {
|
||||||
<p className="mt-1 text-xs text-muted-foreground/70">
|
const next = new Set(collapsedHosts);
|
||||||
{t("tmuxMonitor.tmuxInstallHint")}{" "}
|
if (collapsed) next.delete(String(host.id));
|
||||||
<code className="font-mono">sudo apt install tmux</code>
|
else next.add(String(host.id));
|
||||||
</p>
|
setCollapsedHosts(next);
|
||||||
</div>
|
saveMonitorValue("collapsed-hosts", [...next]);
|
||||||
)}
|
}}
|
||||||
{overview?.available && overview.sessions.length === 0 && (
|
>
|
||||||
<p className="px-2 py-4 text-sm text-muted-foreground">
|
{collapsed ? (
|
||||||
{t("tmuxMonitor.noSessions")}
|
<ChevronRight className="size-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="min-w-0 flex-1 truncate text-left text-sm font-medium"
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => {
|
||||||
|
setPendingAction(null);
|
||||||
|
setSelectedHostId(host.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{host.name || host.ip}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!collapsed &&
|
||||||
|
(active ? (
|
||||||
|
<>
|
||||||
|
{overviewLoading && (
|
||||||
|
<div className="space-y-3 px-2 py-2">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div key={i} className="space-y-1.5">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
<Skeleton className="ml-6 h-3.5 w-3/4" />
|
||||||
|
<Skeleton className="ml-6 h-3.5 w-2/3" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{overviewError && (
|
||||||
|
<div className="space-y-2 px-2 py-4">
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{overviewError}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() =>
|
||||||
|
selectedHostId !== null &&
|
||||||
|
loadOverview(selectedHostId)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-1 size-3" />
|
||||||
|
{t("tmuxMonitor.retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{overview && !overview.available && (
|
||||||
|
<div className="px-2 py-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("tmuxMonitor.tmuxUnavailable")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground/70">
|
||||||
|
{t("tmuxMonitor.tmuxInstallHint")}{" "}
|
||||||
|
<code className="font-mono">
|
||||||
|
sudo apt install tmux
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{overview?.available &&
|
||||||
|
overview.sessions.length === 0 && (
|
||||||
|
<p className="px-2 py-4 text-sm text-muted-foreground">
|
||||||
|
{t("tmuxMonitor.noSessions")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{overview?.available &&
|
||||||
|
overviewHostId === selectedHostId && (
|
||||||
|
<SessionTree
|
||||||
|
sessions={overview.sessions}
|
||||||
|
expandedSessions={expandedSessions}
|
||||||
|
onToggleSession={toggleSession}
|
||||||
|
selectedPaneId={selectedPane?.paneId ?? null}
|
||||||
|
onSelectPane={selectPane}
|
||||||
|
metricsByPane={metricsByPane}
|
||||||
|
metricsBySession={metricsBySession}
|
||||||
|
onEditTags={openTagsEditor}
|
||||||
|
onAttachSession={openTerminal}
|
||||||
|
onSelectSession={attachInline}
|
||||||
|
onNewWindow={newWindow}
|
||||||
|
onRenameSession={(name) => {
|
||||||
|
setRenameDraft(name);
|
||||||
|
setRenameTarget(name);
|
||||||
|
}}
|
||||||
|
onKillSession={setKillTarget}
|
||||||
|
onKillPane={setKillPaneTarget}
|
||||||
|
onSplitPane={splitPane}
|
||||||
|
onKillWindow={(sessionName, windowIndex) =>
|
||||||
|
setKillWindowTarget({
|
||||||
|
sessionName,
|
||||||
|
windowIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
compact={treeWidth < 280}
|
||||||
|
now={now}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
isVisible &&
|
||||||
|
selectedHostId !== null && (
|
||||||
|
<HostOverview hostId={Number(host.id)}>
|
||||||
|
{(data, values) =>
|
||||||
|
backgroundTree(host.id, data, values)
|
||||||
|
}
|
||||||
|
</HostOverview>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{hosts.length > 0 && filteredHosts.length === 0 && (
|
||||||
|
<p className="p-2 text-xs text-muted-foreground">
|
||||||
|
{t("tmuxMonitor.noMatchingHosts")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{overview?.available && (
|
|
||||||
<SessionTree
|
|
||||||
sessions={overview.sessions}
|
|
||||||
expandedSessions={expandedSessions}
|
|
||||||
onToggleSession={toggleSession}
|
|
||||||
selectedPaneId={selectedPane?.paneId ?? null}
|
|
||||||
onSelectPane={selectPane}
|
|
||||||
metricsByPane={metricsByPane}
|
|
||||||
metricsBySession={metricsBySession}
|
|
||||||
onEditTags={openTagsEditor}
|
|
||||||
onAttachSession={openTerminal}
|
|
||||||
onNewWindow={newWindow}
|
|
||||||
onRenameSession={(name) => {
|
|
||||||
setRenameDraft(name);
|
|
||||||
setRenameTarget(name);
|
|
||||||
}}
|
|
||||||
onKillSession={setKillTarget}
|
|
||||||
onKillPane={setKillPaneTarget}
|
|
||||||
onSplitPane={splitPane}
|
|
||||||
onKillWindow={(sessionName, windowIndex) =>
|
|
||||||
setKillWindowTarget({ sessionName, windowIndex })
|
|
||||||
}
|
|
||||||
compact={treeWidth < 280}
|
|
||||||
now={now}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
@@ -1077,7 +1475,7 @@ export function TmuxMonitor({
|
|||||||
|
|
||||||
{/* Pane preview */}
|
{/* Pane preview */}
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
{selectedPane && selectedHost ? (
|
{selectedPane && selectedHost && overviewHostId === selectedHostId ? (
|
||||||
<PanePreview
|
<PanePreview
|
||||||
key={`${selectedHost.id}:${selectedPane.sessionName}`}
|
key={`${selectedHost.id}:${selectedPane.sessionName}`}
|
||||||
host={selectedHost}
|
host={selectedHost}
|
||||||
@@ -1086,7 +1484,10 @@ export function TmuxMonitor({
|
|||||||
terminalRef={previewTermRef}
|
terminalRef={previewTermRef}
|
||||||
onSplit={(direction) => splitPane(selectedPane.paneId, direction)}
|
onSplit={(direction) => splitPane(selectedPane.paneId, direction)}
|
||||||
onKillPane={() => setKillPaneTarget(selectedPane.paneId)}
|
onKillPane={() => setKillPaneTarget(selectedPane.paneId)}
|
||||||
onClose={() => setSelectedPane(null)}
|
onClose={() => {
|
||||||
|
setSelectedPane(null);
|
||||||
|
saveMonitorValue(`pane-${selectedHostId}`, null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : hostsLoaded && hosts.length === 0 ? (
|
) : hostsLoaded && hosts.length === 0 ? (
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ const VIEW_ID = "tmux_monitor";
|
|||||||
|
|
||||||
function TmuxMonitorTab({ tab, isVisible }: TabProps) {
|
function TmuxMonitorTab({ tab, isVisible }: TabProps) {
|
||||||
const hostId = (tab.data?.hostId as number | undefined) ?? undefined;
|
const hostId = (tab.data?.hostId as number | undefined) ?? undefined;
|
||||||
return <TmuxMonitor initialHostId={hostId} isVisible={isVisible} />;
|
return (
|
||||||
|
<TmuxMonitor
|
||||||
|
initialHostId={hostId}
|
||||||
|
initialHostRequest={tab.data?.hostRequest as number | undefined}
|
||||||
|
isVisible={isVisible}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TmuxMonitorStandalone(props: StandaloneViewProps) {
|
function TmuxMonitorStandalone(props: StandaloneViewProps) {
|
||||||
@@ -46,7 +52,9 @@ export function activate(app: TermixApp): void {
|
|||||||
copyUrlView: VIEW_ID,
|
copyUrlView: VIEW_ID,
|
||||||
when: (host) => !!host.enableSsh && tmuxMonitorEnabled(host),
|
when: (host) => !!host.enableSsh && tmuxMonitorEnabled(host),
|
||||||
run: (host, shell) =>
|
run: (host, shell) =>
|
||||||
shell.openSingletonTab(VIEW_ID, { data: { hostId: Number(host.id) } }),
|
shell.openSingletonTab(VIEW_ID, {
|
||||||
|
data: { hostId: Number(host.id), hostRequest: Date.now() },
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
app.registerHostEditorSection({
|
app.registerHostEditorSection({
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { SelectedPane } from "./types";
|
||||||
|
|
||||||
|
const PREFIX = "termix-tmux-monitor-";
|
||||||
|
export function readMonitorValue<T>(key: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(PREFIX + key);
|
||||||
|
return value === null ? fallback : JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function saveMonitorValue(key: string, value: unknown): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PREFIX + key, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
/* unavailable */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function readExpanded(hostId: number | string): Set<string> | null {
|
||||||
|
const value = readMonitorValue<unknown>(`expanded-${hostId}`, null);
|
||||||
|
return Array.isArray(value)
|
||||||
|
? new Set(value.filter((v): v is string => typeof v === "string"))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
export function readSelectedPane(hostId: number | string): SelectedPane | null {
|
||||||
|
const value = readMonitorValue<Partial<SelectedPane> | null>(
|
||||||
|
`pane-${hostId}`,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
return value &&
|
||||||
|
typeof value.paneId === "string" &&
|
||||||
|
typeof value.sessionName === "string" &&
|
||||||
|
Number.isInteger(value.windowIndex)
|
||||||
|
? (value as SelectedPane)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/** Bound background SSH polling when the unified view contains many hosts. */
|
||||||
|
export function createPollQueue(limit: number) {
|
||||||
|
let running = 0;
|
||||||
|
const waiting: Array<() => void> = [];
|
||||||
|
return async function run<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
if (running >= limit)
|
||||||
|
await new Promise<void>((resolve) => waiting.push(resolve));
|
||||||
|
else running++;
|
||||||
|
try {
|
||||||
|
return await task();
|
||||||
|
} finally {
|
||||||
|
const next = waiting.shift();
|
||||||
|
if (next) next();
|
||||||
|
else running--;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pollBackgroundHost = createPollQueue(4);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
readExpanded,
|
||||||
|
readSelectedPane,
|
||||||
|
readMonitorValue,
|
||||||
|
saveMonitorValue,
|
||||||
|
} from "../../src/frontend/monitor-storage";
|
||||||
|
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
describe("per-host monitor preferences", () => {
|
||||||
|
it("keeps identically named sessions and panes separate by host", () => {
|
||||||
|
saveMonitorValue("expanded-1", ["same"]);
|
||||||
|
saveMonitorValue("expanded-2", []);
|
||||||
|
saveMonitorValue("pane-1", {
|
||||||
|
paneId: "%0",
|
||||||
|
sessionName: "same",
|
||||||
|
windowIndex: 0,
|
||||||
|
});
|
||||||
|
expect(readExpanded(1)).toEqual(new Set(["same"]));
|
||||||
|
expect(readExpanded(2)).toEqual(new Set());
|
||||||
|
expect(readSelectedPane(1)?.paneId).toBe("%0");
|
||||||
|
expect(readSelectedPane(2)).toBeNull();
|
||||||
|
});
|
||||||
|
it("tolerates invalid or obsolete stored values", () => {
|
||||||
|
localStorage.setItem("termix-tmux-monitor-filter", "{broken");
|
||||||
|
expect(readMonitorValue("filter", "")).toBe("");
|
||||||
|
saveMonitorValue("pane-1", { paneId: "%0", windowIndex: "0" });
|
||||||
|
expect(readSelectedPane(1)).toBeNull();
|
||||||
|
saveMonitorValue("expanded-1", ["same", null, 3]);
|
||||||
|
expect(readExpanded(1)).toEqual(new Set(["same"]));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import type { PanePreview } from "../../src/frontend/PanePreview";
|
||||||
|
import type { SessionTree } from "../../src/frontend/SessionTree";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
act,
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
within,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { TmuxMonitor } from "../../src/frontend/TmuxMonitor";
|
||||||
|
import * as api from "../../src/frontend/api";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
hosts: [1, 2].map((id) => ({
|
||||||
|
id,
|
||||||
|
name: `Host ${id}`,
|
||||||
|
ip: `host${id}`,
|
||||||
|
enableSsh: true,
|
||||||
|
pluginSettings: { "tmux-monitor": { enableTmuxMonitor: true } },
|
||||||
|
})),
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
t: (key: string) => key,
|
||||||
|
}));
|
||||||
|
vi.mock("@termix/plugin-sdk/frontend", async (original) => ({
|
||||||
|
...(await original<object>()),
|
||||||
|
useHosts: () => ({ hosts: state.hosts, loaded: true }),
|
||||||
|
useToast: () => state.toast,
|
||||||
|
useTranslation: () => ({ t: state.t }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../src/frontend/api", () => ({
|
||||||
|
getTmuxOverview: vi.fn(),
|
||||||
|
getTmuxMetrics: vi.fn(),
|
||||||
|
focusTmuxPane: vi.fn(),
|
||||||
|
searchTmux: vi.fn(),
|
||||||
|
setTmuxSessionTags: vi.fn(),
|
||||||
|
createTmuxSession: vi.fn(),
|
||||||
|
createTmuxWindow: vi.fn(),
|
||||||
|
renameTmuxSession: vi.fn(),
|
||||||
|
killTmuxSession: vi.fn(),
|
||||||
|
killTmuxWindow: vi.fn(),
|
||||||
|
killTmuxPane: vi.fn(),
|
||||||
|
splitTmuxPane: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("../../src/frontend/use-adaptive-polling", async () => {
|
||||||
|
const { useEffect, useRef } = await import("react");
|
||||||
|
return {
|
||||||
|
useAdaptivePolling: (
|
||||||
|
poll: () => Promise<unknown>,
|
||||||
|
_policy: unknown,
|
||||||
|
enabled: boolean,
|
||||||
|
options?: { runImmediately?: boolean },
|
||||||
|
) => {
|
||||||
|
const ref = useRef(poll);
|
||||||
|
ref.current = poll;
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled && options?.runImmediately !== false)
|
||||||
|
void Promise.resolve(ref.current()).catch(() => {});
|
||||||
|
}, [enabled, options?.runImmediately]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("../../src/frontend/PanePreview", () => ({
|
||||||
|
PanePreview: ({
|
||||||
|
host,
|
||||||
|
pane,
|
||||||
|
metrics,
|
||||||
|
}: ComponentProps<typeof PanePreview>) => (
|
||||||
|
<div data-testid="preview">
|
||||||
|
{host.id}:{pane.paneId}:{metrics?.cpuPercent ?? "none"}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("../../src/frontend/SessionTree", () => ({
|
||||||
|
SessionTree: (props: ComponentProps<typeof SessionTree>) => (
|
||||||
|
<div>
|
||||||
|
<span>cpu:{props.metricsByPane.get("%0")?.cpuPercent ?? "none"}</span>
|
||||||
|
<span>
|
||||||
|
{props.expandedSessions.has("same") ? "expanded" : "collapsed"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
props.onSelectPane({
|
||||||
|
paneId: "%0",
|
||||||
|
sessionName: "same",
|
||||||
|
windowIndex: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
pane
|
||||||
|
</button>
|
||||||
|
<button onClick={() => props.onSelectSession?.("same")}>session</button>
|
||||||
|
<button onClick={() => props.onNewWindow("same")}>new window</button>
|
||||||
|
<button onClick={() => props.onKillSession("same")}>kill session</button>
|
||||||
|
<button onClick={() => props.onToggleSession("same")}>
|
||||||
|
toggle session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function overview(): api.TmuxOverview {
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
name: "same",
|
||||||
|
created: 1,
|
||||||
|
lastActivity: 1,
|
||||||
|
attachedClients: 0,
|
||||||
|
tags: [],
|
||||||
|
windows: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
name: "window",
|
||||||
|
active: true,
|
||||||
|
panes: [
|
||||||
|
{
|
||||||
|
id: "%0",
|
||||||
|
index: 0,
|
||||||
|
pid: 1,
|
||||||
|
active: true,
|
||||||
|
width: 80,
|
||||||
|
height: 24,
|
||||||
|
command: "bash",
|
||||||
|
path: "/",
|
||||||
|
title: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function metrics(id: number): api.TmuxPaneMetrics[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
paneId: "%0",
|
||||||
|
sessionName: "same",
|
||||||
|
pid: 1,
|
||||||
|
processCount: 1,
|
||||||
|
cpuPercent: id * 10,
|
||||||
|
memRssKb: id,
|
||||||
|
gpuMemMb: 0,
|
||||||
|
topCommand: "bash",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
const host = (id: number) =>
|
||||||
|
within(screen.getByRole("region", { name: `Host ${id}` }));
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
vi.mocked(api.getTmuxOverview).mockImplementation(async () => overview());
|
||||||
|
vi.mocked(api.getTmuxMetrics).mockImplementation(async (id) => metrics(id));
|
||||||
|
vi.mocked(api.focusTmuxPane).mockResolvedValue();
|
||||||
|
vi.mocked(api.createTmuxWindow).mockResolvedValue();
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("multi-host tmux monitor", () => {
|
||||||
|
it("isolates duplicate session/pane IDs and routes preview and management to their host", async () => {
|
||||||
|
render(<TmuxMonitor initialHostId={1} />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(host(1).getByText("cpu:10")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(host(2).getByText("cpu:20")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
fireEvent.click(host(2).getByText("pane"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("preview")).toHaveTextContent("2:%0:20"),
|
||||||
|
);
|
||||||
|
expect(api.focusTmuxPane).toHaveBeenLastCalledWith(2, "%0");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(host(1).getByText("new window")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
fireEvent.click(host(1).getByText("new window"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.createTmuxWindow).toHaveBeenCalledExactlyOnceWith(1, "same"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the last host, pane, filter and expanded sessions", async () => {
|
||||||
|
const first = render(<TmuxMonitor initialHostId={1} />);
|
||||||
|
await waitFor(() => expect(host(2).getByText("pane")).toBeInTheDocument());
|
||||||
|
fireEvent.click(host(2).getByText("pane"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("preview")).toHaveTextContent("2:%0"),
|
||||||
|
);
|
||||||
|
fireEvent.click(host(2).getByText("toggle session"));
|
||||||
|
fireEvent.change(screen.getByLabelText("tmuxMonitor.filterHosts"), {
|
||||||
|
target: { value: "Host 2" },
|
||||||
|
});
|
||||||
|
first.unmount();
|
||||||
|
render(<TmuxMonitor />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("preview")).toHaveTextContent("2:%0"),
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("tmuxMonitor.filterHosts")).toHaveValue(
|
||||||
|
"Host 2",
|
||||||
|
);
|
||||||
|
expect(host(2).getByText("collapsed")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("region", { name: "Host 1" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores late metrics from a previously selected host", async () => {
|
||||||
|
const old = deferred<api.TmuxPaneMetrics[]>();
|
||||||
|
vi.mocked(api.getTmuxMetrics).mockImplementation((id) =>
|
||||||
|
id === 1 ? old.promise : Promise.resolve(metrics(id)),
|
||||||
|
);
|
||||||
|
render(<TmuxMonitor initialHostId={1} />);
|
||||||
|
await waitFor(() => expect(host(2).getByText("pane")).toBeInTheDocument());
|
||||||
|
fireEvent.click(host(2).getByText("pane"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("preview")).toHaveTextContent("2:%0:20"),
|
||||||
|
);
|
||||||
|
await act(async () => old.resolve(metrics(9)));
|
||||||
|
expect(screen.getByTestId("preview")).toHaveTextContent("2:%0:20");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not execute an abandoned cross-host action after its overview arrives", async () => {
|
||||||
|
const delayed = deferred<api.TmuxOverview>();
|
||||||
|
render(<TmuxMonitor initialHostId={1} />);
|
||||||
|
await waitFor(() => expect(host(2).getByText("pane")).toBeInTheDocument());
|
||||||
|
vi.mocked(api.getTmuxOverview).mockImplementation((id) =>
|
||||||
|
id === 2 ? delayed.promise : Promise.resolve(overview()),
|
||||||
|
);
|
||||||
|
fireEvent.click(host(2).getByText("new window"));
|
||||||
|
fireEvent.click(host(1).getByRole("button", { name: "Host 1" }));
|
||||||
|
await act(async () => delayed.resolve(overview()));
|
||||||
|
expect(api.createTmuxWindow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isolates an unavailable host and stops loading collapsed, filtered and hidden hosts", async () => {
|
||||||
|
localStorage.setItem("termix-tmux-monitor-collapsed-hosts", '["2"]');
|
||||||
|
const app = render(<TmuxMonitor initialHostId={1} />);
|
||||||
|
await waitFor(() => expect(host(1).getByText("pane")).toBeInTheDocument());
|
||||||
|
expect(api.getTmuxOverview).not.toHaveBeenCalledWith(2);
|
||||||
|
vi.mocked(api.getTmuxOverview).mockImplementation(async (id) => {
|
||||||
|
if (id === 2) throw new Error("offline");
|
||||||
|
return overview();
|
||||||
|
});
|
||||||
|
fireEvent.click(host(2).getByLabelText("tmuxMonitor.toggleHost"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(host(2).getByRole("status")).toHaveTextContent(
|
||||||
|
"tmuxMonitor.failedToLoad",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(host(1).getByText("pane")).toBeInTheDocument();
|
||||||
|
app.rerender(<TmuxMonitor initialHostId={1} isVisible={false} />);
|
||||||
|
expect(host(2).queryByRole("status")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirms a destructive action on the host selected from the background tree", async () => {
|
||||||
|
render(<TmuxMonitor initialHostId={1} />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(host(2).getByText("kill session")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
fireEvent.click(host(2).getByText("kill session"));
|
||||||
|
await waitFor(() => expect(screen.getByRole("dialog")).toBeInTheDocument());
|
||||||
|
fireEvent.click(
|
||||||
|
within(screen.getByRole("dialog")).getByRole("button", {
|
||||||
|
name: "tmuxMonitor.kill",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.killTmuxSession).toHaveBeenCalledExactlyOnceWith(2, "same"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not restore a pane removed from the server", async () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"termix-tmux-monitor-pane-1",
|
||||||
|
JSON.stringify({ paneId: "%99", sessionName: "same", windowIndex: 0 }),
|
||||||
|
);
|
||||||
|
render(<TmuxMonitor initialHostId={1} />);
|
||||||
|
await waitFor(() => expect(host(1).getByText("pane")).toBeInTheDocument());
|
||||||
|
expect(screen.queryByTestId("preview")).not.toBeInTheDocument();
|
||||||
|
expect(api.focusTmuxPane).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an old overview after switching away and back to the same host", async () => {
|
||||||
|
const old = deferred<api.TmuxOverview>();
|
||||||
|
let first = true;
|
||||||
|
vi.mocked(api.getTmuxOverview).mockImplementation((id) => {
|
||||||
|
if (id === 1 && first) {
|
||||||
|
first = false;
|
||||||
|
return old.promise;
|
||||||
|
}
|
||||||
|
return Promise.resolve(overview());
|
||||||
|
});
|
||||||
|
render(<TmuxMonitor initialHostId={1} />);
|
||||||
|
await waitFor(() => expect(host(2).getByText("pane")).toBeInTheDocument());
|
||||||
|
fireEvent.click(host(2).getByText("pane"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("preview")).toHaveTextContent("2:%0"),
|
||||||
|
);
|
||||||
|
fireEvent.click(host(1).getByRole("button", { name: "Host 1" }));
|
||||||
|
await waitFor(() => expect(host(1).getByText("pane")).toBeInTheDocument());
|
||||||
|
await act(async () => old.resolve({ available: false, sessions: [] }));
|
||||||
|
expect(host(1).getByText("pane")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors a repeated host action after switching manually", async () => {
|
||||||
|
const app = render(
|
||||||
|
<TmuxMonitor initialHostId={1} initialHostRequest={1} />,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(host(2).getByText("pane")).toBeInTheDocument());
|
||||||
|
fireEvent.click(host(2).getByText("pane"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("preview")).toHaveTextContent("2:%0"),
|
||||||
|
);
|
||||||
|
app.rerender(<TmuxMonitor initialHostId={1} initialHostRequest={2} />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(host(1).getByRole("button", { name: "Host 1" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains single-host mode without loading other hosts", async () => {
|
||||||
|
localStorage.setItem("termix-tmux-monitor-scope", '"single"');
|
||||||
|
render(<TmuxMonitor initialHostId={1} />);
|
||||||
|
await waitFor(() => expect(host(1).getByText("pane")).toBeInTheDocument());
|
||||||
|
expect(api.getTmuxOverview).not.toHaveBeenCalledWith(2);
|
||||||
|
fireEvent.change(screen.getByLabelText("tmuxMonitor.selectHost"), {
|
||||||
|
target: { value: "2" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(host(2).getByText("pane")).toBeInTheDocument());
|
||||||
|
fireEvent.click(host(2).getByText("session"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("preview")).toHaveTextContent("2:%0"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createPollQueue } from "../../src/frontend/poll-queue";
|
||||||
|
|
||||||
|
describe("background host polling queue", () => {
|
||||||
|
it("bounds concurrent hosts and releases a slot after failure", async () => {
|
||||||
|
const run = createPollQueue(2);
|
||||||
|
let release!: () => void;
|
||||||
|
const held = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const first = run(() => held);
|
||||||
|
const second = run(async () => {
|
||||||
|
throw new Error("offline");
|
||||||
|
});
|
||||||
|
const thirdTask = vi.fn(async () => "third");
|
||||||
|
const third = run(thirdTask);
|
||||||
|
expect(thirdTask).not.toHaveBeenCalled();
|
||||||
|
await expect(second).rejects.toThrow("offline");
|
||||||
|
await expect(third).resolves.toBe("third");
|
||||||
|
expect(thirdTask).toHaveBeenCalledOnce();
|
||||||
|
release();
|
||||||
|
await first;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { SessionTree } from "../../src/frontend/SessionTree";
|
||||||
|
vi.mock("@termix/plugin-sdk/frontend", async (original) => ({
|
||||||
|
...(await original<object>()),
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
afterEach(cleanup);
|
||||||
|
it("selects a session by its label while the chevron only changes expansion", () => {
|
||||||
|
const select = vi.fn();
|
||||||
|
const toggle = vi.fn();
|
||||||
|
const noop = () => {};
|
||||||
|
render(
|
||||||
|
<SessionTree
|
||||||
|
sessions={[
|
||||||
|
{
|
||||||
|
name: "same",
|
||||||
|
created: 1,
|
||||||
|
lastActivity: 1,
|
||||||
|
attachedClients: 0,
|
||||||
|
windows: [],
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
expandedSessions={new Set()}
|
||||||
|
onToggleSession={toggle}
|
||||||
|
selectedPaneId={null}
|
||||||
|
onSelectPane={noop}
|
||||||
|
metricsByPane={new Map()}
|
||||||
|
metricsBySession={new Map()}
|
||||||
|
onSelectSession={select}
|
||||||
|
onEditTags={noop}
|
||||||
|
onAttachSession={noop}
|
||||||
|
onNewWindow={noop}
|
||||||
|
onRenameSession={noop}
|
||||||
|
onKillSession={noop}
|
||||||
|
onKillPane={noop}
|
||||||
|
onSplitPane={noop}
|
||||||
|
onKillWindow={noop}
|
||||||
|
compact={false}
|
||||||
|
now={1}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText("same"));
|
||||||
|
expect(select).toHaveBeenCalledExactlyOnceWith("same");
|
||||||
|
expect(toggle).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByLabelText("tmuxMonitor.toggleSession"));
|
||||||
|
expect(toggle).toHaveBeenCalledExactlyOnceWith("same");
|
||||||
|
expect(select).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user