fix: finish 2.9.0 plugin moves and get every check green

This commit is contained in:
LukeGus committed 2026-09-24 23:35:18 -05:00
1 parent 8c1e02af89
commit 6489b62ece
335 files changed
+29878 -17749

No files matched your search

+12 -2
View File
@@ -98,7 +98,12 @@ export async function collectMetrics(
onAuthenticated?.();
const platform = await detectHostPlatform(client);
const result: CollectedMetrics = {
cpu: await collectCpuMetrics(client, platform, host.id),
cpu: await collectCpuMetrics(
client,
platform,
host.id,
state.rateSamples.cpu,
),
memory: await collectMemoryMetrics(client, platform),
disk: await collectDiskMetrics(
client,
@@ -106,7 +111,12 @@ export async function collectMetrics(
mounts.monitoredMounts,
platform,
),
network: await collectNetworkMetrics(client, platform, host.id),
network: await collectNetworkMetrics(
client,
platform,
host.id,
state.rateSamples.network,
),
uptime: await collectUptimeMetrics(client, platform),
processes: await collectProcessesMetrics(client),
system: await collectSystemMetrics(client, platform),
@@ -30,3 +30,32 @@ export function hostImportNormalizer(
});
return { ...settings };
}
/**
* Registered as "host-metrics.hostPayloadLegacy": the 2.8 statsConfig shape
* Termix-Mobile still reads. Remove once the mobile app reads
* pluginSettings["host-metrics"] and GET /host/status.
*/
export function hostPayloadLegacy(
values: Record<string, unknown>,
host: Record<string, unknown>,
): Record<string, unknown> {
const settings = readHostMetricsSettings(values);
const statusInterval =
typeof host.statusCheckInterval === "number"
? host.statusCheckInterval
: null;
return {
statsConfig: {
enabledWidgets: settings.enabledWidgets,
statusCheckEnabled: host.statusCheckEnabled !== false,
statusCheckInterval: statusInterval ?? 30,
useGlobalStatusInterval: statusInterval === null,
metricsEnabled: settings.metricsEnabled,
metricsInterval: settings.metricsInterval ?? 30,
useGlobalMetricsInterval: settings.metricsInterval == null,
excludedMounts: settings.excludedMounts,
monitoredMounts: settings.monitoredMounts,
},
};
}
+2 -1
View File
@@ -7,7 +7,7 @@ import { MetricsSessions } from "./sessions.js";
import { CollectorRegistry } from "./collectors.js";
import { MetricsPoller, TOPIC_HEALTH_CHECK } from "./poller.js";
import { registerRoutes } from "./routes.js";
import { hostImportNormalizer } from "./host-import.js";
import { hostImportNormalizer, hostPayloadLegacy } from "./host-import.js";
import { newSessionId, supportsMetrics } from "./helpers.js";
export type { MetricsCollectorV1 } from "./collectors.js";
@@ -101,6 +101,7 @@ export async function activate(ctx: PluginContext) {
"host-metrics.hostImportNormalizer",
hostImportNormalizer,
);
ctx.registry.provide("host-metrics.hostPayloadLegacy", hostPayloadLegacy);
ctx.log.info("Host Metrics mounted at /plugin-api/host-metrics");
}
+2 -6
View File
@@ -18,8 +18,6 @@ import {
metricsConcurrencyFor,
type MetricsState,
} from "./state.js";
import { clearCpuSampleCache } from "./widgets/cpu-collector.js";
import { clearNetworkSampleCache } from "./widgets/network-collector.js";
export const DEFAULT_METRICS_INTERVAL = 30;
export const DEFAULT_RETENTION_DAYS = 7;
@@ -201,8 +199,7 @@ export class MetricsPoller {
for (const hostId of [...this.polled.keys()]) this.stop(hostId, true);
this.viewers.clear();
this.viewerDetails.clear();
clearCpuSampleCache();
clearNetworkSampleCache();
this.state.rateSamples.clear();
}
/** The host as `userId` may reach it, cached for a few minutes. */
@@ -274,8 +271,7 @@ export class MetricsPoller {
if (clearData) {
this.metrics.delete(hostId);
this.state.hostCache.invalidate(hostId);
clearCpuSampleCache(hostId);
clearNetworkSampleCache(hostId);
this.state.rateSamples.clear(hostId);
}
}
+25
View File
@@ -1,3 +1,6 @@
import type { CpuSamples } from "./widgets/cpu-collector.js";
import { createNetworkSamples } from "./widgets/network-collector.js";
export type HostStatus = "online" | "reachable" | "offline";
export class RequestQueue {
@@ -422,8 +425,30 @@ export function canStartInitialMetrics(
}
/** Everything one activation of the plugin polls with. */
/** The last CPU and network sample per host, for rates between polls. */
export function createRateSamples() {
const cpu: CpuSamples = new Map();
const network = createNetworkSamples();
return {
cpu,
network,
clear(hostId?: number) {
if (hostId === undefined) {
cpu.clear();
network.counters.clear();
network.windows.clear();
return;
}
cpu.delete(hostId);
network.counters.delete(hostId);
network.windows.delete(hostId);
},
};
}
export function createMetricsState() {
return {
rateSamples: createRateSamples(),
/** SSH metrics execs are expensive; resized with the fleet. */
metricsLimiter: new ConcurrentLimiter(5),
/** Viewer registration is bursty; admit only two first samples at a time. */
@@ -9,20 +9,13 @@ import type { Client } from "ssh2";
/**
* CPU percent is derived from the delta against the previous poll's
* /proc/stat sample rather than a same-poll double-read, so a poll no longer
* has to block for a fixed settle window. Keyed by host.
* has to block for a fixed settle window. Keyed by host, and owned by the
* poller so it goes away with the plugin.
*/
const previousStat = new Map<
export type CpuSamples = Map<
number,
{ total: number; idle: number; timestamp: number }
>();
export function clearCpuSampleCache(hostId?: number): void {
if (hostId === undefined) {
previousStat.clear();
return;
}
previousStat.delete(hostId);
}
>;
export function parseCpuLine(
cpuLine: string,
@@ -149,6 +142,7 @@ export async function collectCpuMetrics(
client: Client,
platform?: HostPlatform,
hostId?: number,
previousStat: CpuSamples = new Map(),
): Promise<{
percent: number | null;
cores: number | null;
@@ -49,24 +49,16 @@ export function counterRate(
* double-sample, so a poll no longer has to block for a fixed settle window.
* Keyed by host, since counters from one host are meaningless against another.
*/
const previousSamples = new Map<
number,
{ counters: Map<string, NetworkCounters>; timestamp: number }
>();
export interface NetworkSamples {
counters: Map<
number,
{ counters: Map<string, NetworkCounters>; timestamp: number }
>;
windows: Map<number, { rows: WindowsAdapterRow[]; timestamp: number }>;
}
const previousWindowsSamples = new Map<
number,
{ rows: WindowsAdapterRow[]; timestamp: number }
>();
export function clearNetworkSampleCache(hostId?: number): void {
if (hostId === undefined) {
previousSamples.clear();
previousWindowsSamples.clear();
return;
}
previousSamples.delete(hostId);
previousWindowsSamples.delete(hostId);
export function createNetworkSamples(): NetworkSamples {
return { counters: new Map(), windows: new Map() };
}
export function parseDarwinIfconfig(
@@ -112,7 +104,8 @@ export function parseDarwinNetstat(
async function collectDarwinNetworkMetrics(
client: Client,
hostId?: number,
hostId: number | undefined,
samples: NetworkSamples,
): Promise<{
interfaces: Array<{
name: string;
@@ -143,7 +136,7 @@ async function collectDarwinNetworkMetrics(
const netstatOut = await execCommand(client, "netstat -ib 2>/dev/null");
const current = parseDarwinNetstat(netstatOut.stdout);
const previous =
hostId !== undefined ? previousSamples.get(hostId) : undefined;
hostId !== undefined ? samples.counters.get(hostId) : undefined;
const elapsedSeconds = previous
? (readAt - previous.timestamp) / 1000
: 0;
@@ -166,7 +159,7 @@ async function collectDarwinNetworkMetrics(
});
}
if (hostId !== undefined) {
previousSamples.set(hostId, { counters: current, timestamp: readAt });
samples.counters.set(hostId, { counters: current, timestamp: readAt });
}
} catch {
for (const [name, data] of ifMap.entries()) {
@@ -225,7 +218,8 @@ export function parseWindowsAdapterJson(output: string): WindowsAdapterRow[] {
async function collectWindowsNetworkMetrics(
client: Client,
hostId?: number,
hostId: number | undefined,
samples: NetworkSamples,
): Promise<{
interfaces: Array<{
name: string;
@@ -252,7 +246,7 @@ async function collectWindowsNetworkMetrics(
const result = await execPowerShell(client, WINDOWS_ADAPTER_SCRIPT);
const currentRows = parseWindowsAdapterJson(result.stdout);
const previous =
hostId !== undefined ? previousWindowsSamples.get(hostId) : undefined;
hostId !== undefined ? samples.windows.get(hostId) : undefined;
const elapsedSeconds = previous ? (readAt - previous.timestamp) / 1000 : 0;
const previousMap = new Map(
(previous?.rows ?? []).map((row) => [row.name, row]),
@@ -276,7 +270,7 @@ async function collectWindowsNetworkMetrics(
}
if (hostId !== undefined) {
previousWindowsSamples.set(hostId, {
samples.windows.set(hostId, {
rows: currentRows,
timestamp: readAt,
});
@@ -292,6 +286,7 @@ export async function collectNetworkMetrics(
client: Client,
platform?: HostPlatform,
hostId?: number,
samples: NetworkSamples = createNetworkSamples(),
): Promise<{
interfaces: Array<{
name: string;
@@ -304,10 +299,10 @@ export async function collectNetworkMetrics(
}>;
}> {
if (platform === "darwin") {
return collectDarwinNetworkMetrics(client, hostId);
return collectDarwinNetworkMetrics(client, hostId, samples);
}
if (platform === "windows") {
return collectWindowsNetworkMetrics(client, hostId);
return collectWindowsNetworkMetrics(client, hostId, samples);
}
const interfaces: Array<{
@@ -368,7 +363,7 @@ export async function collectNetworkMetrics(
const procNet = await execCommand(client, "cat /proc/net/dev");
const rxTxMap = parseNetworkCounters(procNet.stdout);
const previous =
hostId !== undefined ? previousSamples.get(hostId) : undefined;
hostId !== undefined ? samples.counters.get(hostId) : undefined;
const elapsedSeconds = previous
? (readAt - previous.timestamp) / 1000
: 0;
@@ -395,7 +390,7 @@ export async function collectNetworkMetrics(
});
}
if (hostId !== undefined) {
previousSamples.set(hostId, {
samples.counters.set(hostId, {
counters: rxTxMap,
timestamp: readAt,
});
@@ -1,7 +1,6 @@
import {
Separator,
Button,
useAreaPreferences,
useConfirmation,
TOTPDialog,
useTabsSafe,
@@ -12,7 +11,6 @@ import {
CardGridCanvas,
ColumnCountStepper,
type GridCardCatalogEntry,
logActivity,
SnippetVariablesDialog,
} from "@termix/plugin-sdk/ui";
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
@@ -52,6 +50,8 @@ import {
useHost,
useHostStatus,
invokeAction,
logActivity,
usePluginUiPreferences,
} from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { RefreshCw, Server, LayoutDashboard } from "lucide-react";
@@ -161,7 +161,9 @@ function HostMetricsInner({
const hostId = currentHostConfig?.id ?? null;
const { layout, setLayout } = useHostMetricsPreferences(hostId);
const metricsPrefs = useAreaPreferences("hostMetrics");
const { values: metricsPrefs } = usePluginUiPreferences<{
columns: number;
}>();
const effectiveLayout: HostMetricsLayout = React.useMemo(() => {
// A saved layout is user-authored, so the preset never rewrites it -- it
@@ -1,14 +1,24 @@
import { useEffect, useState } from "react";
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
import {
usePluginApi,
usePluginApiFor,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import {
cn,
getPollingEnvironmentMultiplier,
resolveConnectionOrigin,
resolveRemoteHostId,
runAdaptivePolling,
} from "@termix/plugin-sdk/ui";
/** What the ssh-terminal plugin hands a "terminal.toolbarStatus" component. */
interface TerminalToolbarStatusProps {
host: { id: number | string };
host: {
id: number | string;
connectionOrigin?: "local" | "remote" | null;
syncId?: string | null;
};
isConnected: boolean;
/** Visible on a desktop viewport and connected: poll only while true. */
active: boolean;
@@ -59,7 +69,8 @@ function StatBar({
export function TerminalMetricsStatus(props: Record<string, unknown>) {
const { host, active } = props as unknown as TerminalToolbarStatusProps;
const { t } = useTranslation();
const api = usePluginApi();
const localApi = usePluginApi();
const remoteApi = usePluginApiFor("remote");
const [metrics, setMetrics] = useState<Sample | null>(null);
const [available, setAvailable] = useState(true);
const hostId = host?.id ? Number(host.id) : null;
@@ -73,6 +84,9 @@ export function TerminalMetricsStatus(props: Record<string, unknown>) {
let stopPolling: (() => void) | undefined;
let viewerSessionId: string | undefined;
let previous: Sample | null = null;
// A host on the desktop app's remote server has its own id there.
let api = localApi;
let targetId: number = hostId;
setAvailable(true);
const poll = async () => {
@@ -81,7 +95,7 @@ export function TerminalMetricsStatus(props: Record<string, unknown>) {
cpu?: { percent?: number };
memory?: { percent?: number };
disk?: { percent?: number };
} | null>(`/metrics/${hostId}`, {
} | null>(`/metrics/${targetId}`, {
validateStatus: (status: number) => status === 200 || status === 404,
});
const data = response.data;
@@ -109,14 +123,24 @@ export function TerminalMetricsStatus(props: Record<string, unknown>) {
const start = async () => {
try {
if ((await resolveConnectionOrigin(host)) === "remote") {
const remoteId = await resolveRemoteHostId(host.syncId);
if (cancelled) return;
if (remoteId === null) {
setAvailable(false);
return;
}
api = remoteApi;
targetId = remoteId;
}
const { data } = await api.post<{
requires_totp?: boolean;
viewerSessionId?: string;
}>(`/metrics/start/${hostId}`);
}>(`/metrics/start/${targetId}`);
if (cancelled) {
if (data.viewerSessionId) {
void api
.post(`/metrics/stop/${hostId}`, {
.post(`/metrics/stop/${targetId}`, {
viewerSessionId: data.viewerSessionId,
})
.catch(() => {});
@@ -148,10 +172,10 @@ export function TerminalMetricsStatus(props: Record<string, unknown>) {
cancelled = true;
stopPolling?.();
void api
.post(`/metrics/stop/${hostId}`, { viewerSessionId })
.post(`/metrics/stop/${targetId}`, { viewerSessionId })
.catch(() => {});
};
}, [active, hostId, api]);
}, [active, hostId, localApi, remoteApi]); // eslint-disable-line react-hooks/exhaustive-deps
if (!available) {
return (
@@ -5,8 +5,8 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
import { ManagerSearch } from "@termix/plugin-sdk/ui";
interface CronEntry {
raw: string;
@@ -6,8 +6,8 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
import { ManagerSearch } from "@termix/plugin-sdk/ui";
interface FirewallData {
type: "iptables" | "nftables" | "none";
@@ -13,7 +13,7 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
interface HealthCheck {
id: string;
@@ -4,7 +4,7 @@ import { ScrollText } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useHostMetricsApi } from "../../host-metrics-api";
import { extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
interface LogFiles {
common: string[];
@@ -1,74 +0,0 @@
import { Button, MetricCard } from "@termix/plugin-sdk/ui";
import type { ReactNode } from "react";
import { RefreshCw, AlertTriangle } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ManagerError } from "./useManagerData";
export function ManagerCardShell({
title,
icon,
loading,
error,
onRefresh,
empty,
emptyMessage,
children,
headerExtra,
}: {
title: string;
icon: ReactNode;
loading: boolean;
error: ManagerError | null;
onRefresh: () => void;
empty?: boolean;
emptyMessage?: string;
children: ReactNode;
headerExtra?: ReactNode;
}) {
const { t } = useTranslation();
return (
<MetricCard
title={title}
icon={icon}
scroll
action={
<div className="flex items-center gap-1">
{headerExtra}
<button
onClick={onRefresh}
className="flex size-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title={t("hostMetrics.refresh")}
>
<RefreshCw className={`size-3 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
}
>
{error ? (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<AlertTriangle className="size-6 text-yellow-500" />
<span className="text-xs text-muted-foreground">{error.message}</span>
{error.code === "SUDO_REQUIRED" && (
<span className="text-[10px] text-muted-foreground">
{t("hostMetrics.managers.sudoHint")}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={onRefresh}
className="mt-1"
>
{t("hostMetrics.retry")}
</Button>
</div>
) : empty ? (
<div className="flex items-center justify-center py-8 text-xs text-muted-foreground/50">
{emptyMessage ?? t("hostMetrics.managers.noData")}
</div>
) : (
children
)}
</MetricCard>
);
}
@@ -1,42 +0,0 @@
import type { ReactNode } from "react";
import { Search } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
/**
* Compact search row shared by manager cards. Optional `extra` renders to the
* right (filters, action buttons) and `count` shows a result tally.
*/
export function ManagerSearch({
value,
onChange,
placeholder,
count,
extra,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
count?: number;
extra?: ReactNode;
}) {
const { t } = useTranslation();
return (
<div className="mb-2 flex items-center gap-1.5">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground/60" />
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? t("hostMetrics.managers.filter")}
className="h-7 w-full border border-border bg-background pl-7 pr-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
</div>
{count != null && (
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{count}
</span>
)}
{extra}
</div>
);
}
@@ -5,8 +5,8 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
import { ManagerSearch } from "@termix/plugin-sdk/ui";
interface UpgradablePackage {
name: string;
@@ -5,8 +5,8 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
import { ManagerSearch } from "@termix/plugin-sdk/ui";
interface ProcessRow {
pid: number;
@@ -4,8 +4,8 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
import { ManagerSearch } from "@termix/plugin-sdk/ui";
interface SystemdService {
unit: string;
@@ -3,8 +3,8 @@ import { useMemo, useState } from "react";
import { MemoryStick, Timer, HardDrive } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useManagerData } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
import { ManagerSearch } from "@termix/plugin-sdk/ui";
interface MemProc {
pid: number;
@@ -8,7 +8,7 @@ import {
useManagerAction,
extractError,
} from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
interface CertInfo {
client: string;
@@ -5,8 +5,8 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
import { ManagerSearch } from "@termix/plugin-sdk/ui";
interface SystemUser {
name: string;
@@ -8,7 +8,7 @@ import {
} from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useManagerData, useManagerAction } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
interface WireGuardPeer {
publicKey: string;