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

@@ -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",