feat: move the terminal fully into the ssh-terminal plugin (B9)

This commit is contained in:
LukeGus committed 2026-09-23 21:41:22 -05:00
1 parent 535126fd1b
commit 79d77678c9
309 files changed
+41789 -14958

No files matched your search

+3
View File
@@ -239,5 +239,8 @@
"showCurrentValue": "Show Current Value",
"chartMetric": "Metric",
"metricRange": "Range"
},
"terminalStatus": {
"unavailable": "Live stats unavailable"
}
}
@@ -0,0 +1,174 @@
import { useEffect, useState } from "react";
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
import {
cn,
getPollingEnvironmentMultiplier,
runAdaptivePolling,
} from "@termix/plugin-sdk/ui";
/** What the ssh-terminal plugin hands a "terminal.toolbarStatus" component. */
interface TerminalToolbarStatusProps {
host: { id: number | string };
isConnected: boolean;
/** Visible on a desktop viewport and connected: poll only while true. */
active: boolean;
}
interface Sample {
cpu: number | null;
memory: number | null;
disk: number | null;
}
function StatBar({
label,
percent,
}: {
label: string;
percent: number | null;
}) {
const value = percent == null ? null : Math.max(0, Math.min(100, percent));
return (
<div className="flex items-center gap-1.5 px-1.5">
<span className="w-12 shrink-0 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
<div className="h-1.5 w-12 shrink-0 overflow-hidden rounded-full bg-muted">
{value != null && (
<div
className={cn(
"h-full rounded-full transition-all",
value >= 90
? "bg-red-500"
: value >= 70
? "bg-yellow-500"
: "bg-accent-brand",
)}
style={{ width: `${value}%` }}
/>
)}
</div>
<span className="w-7 shrink-0 text-right text-[10px] tabular-nums text-muted-foreground">
{value == null ? "--" : `${Math.round(value)}%`}
</span>
</div>
);
}
/** CPU, memory and disk bars in the terminal toolbar's expanded view. */
export function TerminalMetricsStatus(props: Record<string, unknown>) {
const { host, active } = props as unknown as TerminalToolbarStatusProps;
const { t } = useTranslation();
const api = usePluginApi();
const [metrics, setMetrics] = useState<Sample | null>(null);
const [available, setAvailable] = useState(true);
const hostId = host?.id ? Number(host.id) : null;
useEffect(() => {
if (!active || !hostId) {
setMetrics(null);
return;
}
let cancelled = false;
let stopPolling: (() => void) | undefined;
let viewerSessionId: string | undefined;
let previous: Sample | null = null;
setAvailable(true);
const poll = async () => {
try {
const response = await api.get<{
cpu?: { percent?: number };
memory?: { percent?: number };
disk?: { percent?: number };
} | null>(`/metrics/${hostId}`, {
validateStatus: (status: number) => status === 200 || status === 404,
});
const data = response.data;
if (cancelled || !data) return;
const next: Sample = {
cpu: data.cpu?.percent ?? null,
memory: data.memory?.percent ?? null,
disk: data.disk?.percent ?? null,
};
const changed =
!previous ||
(["cpu", "memory", "disk"] as const).some((key) => {
const before = previous?.[key];
const now = next[key];
return before == null || now == null || Math.abs(now - before) >= 2;
});
previous = next;
setMetrics(next);
return changed;
} catch {
// Keep the previous sample after a transient polling error.
return false;
}
};
const start = async () => {
try {
const { data } = await api.post<{
requires_totp?: boolean;
viewerSessionId?: string;
}>(`/metrics/start/${hostId}`);
if (cancelled) {
if (data.viewerSessionId) {
void api
.post(`/metrics/stop/${hostId}`, {
viewerSessionId: data.viewerSessionId,
})
.catch(() => {});
}
return;
}
if (data.requires_totp) {
setAvailable(false);
return;
}
viewerSessionId = data.viewerSessionId;
stopPolling = runAdaptivePolling(
poll,
{
minIntervalMs: 5_000,
maxIntervalMs: 20_000,
stablePollsPerStep: 2,
maxRequestDutyCycle: 0.2,
},
{ intervalMultiplier: getPollingEnvironmentMultiplier },
);
} catch {
if (!cancelled) setAvailable(false);
}
};
void start();
return () => {
cancelled = true;
stopPolling?.();
void api
.post(`/metrics/stop/${hostId}`, { viewerSessionId })
.catch(() => {});
};
}, [active, hostId, api]);
if (!available) {
return (
<span role="status" className="px-2 py-1 text-xs text-muted-foreground">
{t("terminalStatus.unavailable")}
</span>
);
}
return (
<>
<StatBar label={t("hostMetrics.cpu")} percent={metrics?.cpu ?? null} />
<StatBar
label={t("hostMetrics.memory")}
percent={metrics?.memory ?? null}
/>
<StatBar label={t("hostMetrics.disk")} percent={metrics?.disk ?? null} />
</>
);
}
@@ -13,6 +13,7 @@ import HostMetricsApp from "./HostMetricsApp";
import { HostStatsTab } from "./HostEditorStatsTab";
import { metricsChartWidget } from "./MetricsChartWidget";
import { MetricsRetentionSetting } from "./MetricsRetentionSetting";
import { TerminalMetricsStatus } from "./TerminalMetricsStatus";
type SectionSetField = Parameters<typeof HostStatsTab>[0]["setField"];
@@ -90,6 +91,17 @@ export function activate(app: TermixApp): void {
app.registerSettingsComponent("retention", MetricsRetentionSetting);
// Live CPU, memory and disk bars in the terminal toolbar's expanded view.
app.registerSlotContribution("terminal.toolbarStatus", {
actionId: "host-metrics.terminalStatus",
titleKey: "nav.hostMetrics",
kind: "component",
component: TerminalMetricsStatus,
when: (context) =>
(context.host as { statsConfig?: { metricsEnabled?: boolean } })
?.statsConfig?.metricsEnabled !== false,
});
app.registerSlotContribution("onboarding.features", {
actionId: "host-metrics.feature",
titleKey: "onboarding.feature_metrics",
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
const api = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
}));
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
usePluginApi: () => api,
useTranslation: () => ({ t: (key: string) => key, language: "en" }),
}));
vi.mock("@termix/plugin-sdk/ui", () => ({
cn: (...classes: unknown[]) => classes.filter(Boolean).join(" "),
getPollingEnvironmentMultiplier: () => 1,
runAdaptivePolling: (poll: () => Promise<unknown>) => {
void poll();
return () => {};
},
}));
import { TerminalMetricsStatus } from "../../src/frontend/TerminalMetricsStatus";
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("TerminalMetricsStatus", () => {
it("polls the host's metrics while active and shows the bars", async () => {
api.post.mockResolvedValue({ data: { viewerSessionId: "viewer-1" } });
api.get.mockResolvedValue({
data: {
cpu: { percent: 12 },
memory: { percent: 40 },
disk: { percent: 91 },
},
});
render(<TerminalMetricsStatus host={{ id: 7 }} isConnected active />);
await waitFor(() => expect(screen.getByText("91%")).toBeInTheDocument());
expect(api.post).toHaveBeenCalledWith("/metrics/start/7");
expect(screen.getByText("12%")).toBeInTheDocument();
expect(screen.getByText("hostMetrics.cpu")).toBeInTheDocument();
});
it("does not poll while inactive", () => {
render(
<TerminalMetricsStatus host={{ id: 7 }} isConnected active={false} />,
);
expect(api.post).not.toHaveBeenCalled();
});
it("says stats are unavailable when the host needs a second factor", async () => {
api.post.mockResolvedValue({ data: { requires_totp: true } });
render(<TerminalMetricsStatus host={{ id: 7 }} isConnected active />);
await waitFor(() =>
expect(
screen.getByText("terminalStatus.unavailable"),
).toBeInTheDocument(),
);
});
it("stops its viewer on unmount", async () => {
api.post.mockResolvedValue({ data: { viewerSessionId: "viewer-2" } });
api.get.mockResolvedValue({ data: null });
const view = render(
<TerminalMetricsStatus host={{ id: 7 }} isConnected active />,
);
await waitFor(() => expect(api.get).toHaveBeenCalled());
view.unmount();
expect(api.post).toHaveBeenLastCalledWith("/metrics/stop/7", {
viewerSessionId: "viewer-2",
});
});
});