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

+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"dependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+4 -2
View File
@@ -56,6 +56,7 @@ export function registerAiRoutes(
notify: ctx.notify,
rbac: ctx.rbac,
ssh: ctx.ssh,
audit: ctx.audit,
};
const logError = (message: string, error: unknown) =>
@@ -728,8 +729,9 @@ export function registerAiRoutes(
).aiAccess ?? { allowReadOnlyCommands: false };
const hosts = await ctx.hosts.list();
const config = await providerConfig(provider);
const tools = availableTools((service) =>
serviceAvailable(ctx.services, service),
const tools = availableTools(
(service) => serviceAvailable(ctx.services, service),
{ allowReadOnlyCommands: access.allowReadOnlyCommands },
);
// no-transform keeps core's compression from buffering the stream,
+7 -1
View File
@@ -64,8 +64,14 @@ export const FORBIDDEN_DOMAINS = [
*/
export function availableTools(
isAvailable: (service: string) => boolean,
options: { allowReadOnlyCommands?: boolean } = {},
): AiTool[] {
return AI_TOOLS.filter((tool) => !tool.service || isAvailable(tool.service));
return AI_TOOLS.filter(
(tool) =>
(!tool.service || isAvailable(tool.service)) &&
(!tool.requiresReadOnlyCommands ||
options.allowReadOnlyCommands === true),
);
}
/** Tool definitions in the shape the provider adapters expect. */
+1 -1
View File
@@ -247,7 +247,7 @@ export async function applyProposal(
* Runs one approved command over this plugin's SSH pool, mirroring how an
* automation run_command step executes.
*/
async function runCommandOnHost(
export async function runCommandOnHost(
deps: ToolDeps,
hostId: number,
command: string,
+47 -1
View File
@@ -1,6 +1,11 @@
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
import { num, objectSchema, type AiTool } from "./types.js";
import { num, objectSchema, str, type AiTool } from "./types.js";
import { readService, SERVICE } from "../services.js";
import { isReadOnlyCommand } from "./command-allowlist.js";
import { runCommandOnHost } from "./executor.js";
/** Output handed back to the model is capped; a log can be huge. */
const MAX_COMMAND_OUTPUT = 8000;
/**
* Read tools project explicit fields rather than spreading rows. Redaction runs
@@ -30,6 +35,47 @@ function toHostSummary(host: PluginHostSummary) {
}
export const readTools: AiTool[] = [
{
name: "run_readonly_command",
description:
"Run a read-only diagnostic command on a host and get its output, for example df -h, uptime, free -m or systemctl status nginx. Only commands on a fixed read-only allowlist run; anything else must be proposed with propose_run_command.",
category: "read",
requiresReadOnlyCommands: true,
parameters: objectSchema(
{
hostId: num("The host id, as returned by list_hosts"),
command: str("The command to run"),
},
["hostId", "command"],
),
handler: async (args, context) => {
if (!context.allowReadOnlyCommands) {
return { error: "The user has not allowed read-only commands" };
}
const hostId = Number(args.hostId);
const command = typeof args.command === "string" ? args.command : "";
const check = isReadOnlyCommand(command);
if (!check.allowed) {
return { error: check.reason ?? "Command is not read-only" };
}
const access = await context.deps.hosts.checkAccess(hostId, "connect");
if (!access.hasAccess) return { error: "Host not found" };
const result = await runCommandOnHost(context.deps, hostId, command);
await context.deps.audit
.record({
action: "ai_readonly_command",
resourceType: "host",
resourceId: String(hostId),
details: JSON.stringify({ command }),
success: !result.error,
errorMessage: result.error,
})
.catch(() => undefined);
if (result.error) return { error: result.error };
return { output: (result.output ?? "").slice(0, MAX_COMMAND_OUTPUT) };
},
},
{
name: "list_hosts",
description:
+3 -1
View File
@@ -5,7 +5,7 @@ export type ToolCategory = "read" | "propose";
/** What tools and the proposal executor reach core and other plugins through. */
export type ToolDeps = Pick<
PluginContext,
"hosts" | "services" | "notify" | "rbac" | "ssh"
"hosts" | "services" | "notify" | "rbac" | "ssh" | "audit"
>;
export interface ToolContext {
@@ -26,6 +26,8 @@ export interface AiTool {
* not offered to the model at all.
*/
service?: string;
/** Offered only to a user who turned on read-only commands. */
requiresReadOnlyCommands?: boolean;
/** JSON Schema for the arguments, sent to the provider verbatim. */
parameters: Record<string, unknown>;
/**
+2 -9
View File
@@ -184,18 +184,11 @@ export function activate(app: TermixApp): void {
applyStatus();
refresh();
// The admin switch and the user's own choice are settings, saved from
// the settings screen, so re-read when plugin state or focus changes too.
let lastFocusRefresh = Date.now();
const onFocus = () => {
if (Date.now() - lastFocusRefresh < 30_000) return;
lastFocusRefresh = Date.now();
refresh();
};
// the settings screen.
app.onSettingsChanged(() => refresh());
const events = [AI_STATUS_CHANGED_EVENT, "termix:plugins-changed"];
for (const event of events) window.addEventListener(event, refresh);
window.addEventListener("focus", onFocus);
app.onDispose(() => {
for (const event of events) window.removeEventListener(event, refresh);
window.removeEventListener("focus", onFocus);
});
}
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest";
import { availableTools, getTool } from "../../../src/backend/tools/catalog.js";
import type { ToolContext } from "../../../src/backend/tools/types.js";
const tool = getTool("run_readonly_command")!;
function contextWith(overrides: Partial<ToolContext> = {}) {
const withConnection = vi.fn(async () => ({
stdout: "Filesystem Size",
stderr: "",
code: 0,
}));
const record = vi.fn(async () => {});
const context = {
userId: "user-1",
conversationId: 1,
allowReadOnlyCommands: true,
deps: {
hosts: {
checkAccess: vi.fn(async () => ({ hasAccess: true })),
},
ssh: { withConnection },
audit: { record },
},
...overrides,
} as unknown as ToolContext;
return { context, withConnection, record };
}
describe("run_readonly_command", () => {
it("is only offered to a user who opted in", () => {
const names = (allow: boolean) =>
availableTools(() => true, { allowReadOnlyCommands: allow }).map(
(entry) => entry.name,
);
expect(names(false)).not.toContain("run_readonly_command");
expect(names(true)).toContain("run_readonly_command");
});
it("runs an allowlisted command and audits it", async () => {
const { context, withConnection, record } = contextWith();
const result = await tool.handler({ hostId: 4, command: "df -h" }, context);
expect(result).toEqual({ output: "Filesystem Size" });
expect(withConnection).toHaveBeenCalledTimes(1);
expect(record).toHaveBeenCalledWith(
expect.objectContaining({ action: "ai_readonly_command", success: true }),
);
});
it("refuses a command that is not read-only", async () => {
const { context, withConnection } = contextWith();
const result = (await tool.handler(
{ hostId: 4, command: "rm -rf /tmp/x" },
context,
)) as { error?: string };
expect(result.error).toBeTruthy();
expect(withConnection).not.toHaveBeenCalled();
});
it("refuses when the user has not opted in, or cannot reach the host", async () => {
const off = contextWith({ allowReadOnlyCommands: false });
expect(
await tool.handler({ hostId: 4, command: "uptime" }, off.context),
).toEqual({ error: "The user has not allowed read-only commands" });
const denied = contextWith();
(
denied.context.deps.hosts.checkAccess as ReturnType<typeof vi.fn>
).mockResolvedValue({ hasAccess: false });
expect(
await tool.handler({ hostId: 4, command: "uptime" }, denied.context),
).toEqual({ error: "Host not found" });
expect(denied.withConnection).not.toHaveBeenCalled();
});
});
+4 -1
View File
@@ -116,7 +116,10 @@
"descriptionKey": "permissions.run.description",
"defaultRoles": ["user"]
}
]
],
"http": {
"legacyPaths": ["/automations/webhook"]
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"],
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+16 -1
View File
@@ -19,7 +19,8 @@
"credentials:use",
"ssh:connect",
"network:serve",
"ui:surface"
"ui:surface",
"events:core"
],
"provides": [
{
@@ -75,6 +76,20 @@
}
]
}
},
"uiPresets": {
"simple": {
"viewMode": "list",
"containerLayout": "card"
},
"balanced": {
"viewMode": "list",
"containerLayout": "card"
},
"advanced": {
"viewMode": "detail",
"containerLayout": "table"
}
}
},
"locales": "locales",
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
@@ -13,6 +13,8 @@ import {
useConnectionRetry,
useHost,
useTranslation,
logActivity,
usePluginUiPreferences,
} from "@termix/plugin-sdk/frontend";
import {
Alert,
@@ -27,12 +29,9 @@ import {
Separator,
TOTPDialog,
BrowserSignInDialog,
logActivity,
useAdaptivePolling,
useAreaPreferences,
useConnectionLog,
useTabsSafe,
useUiPreferencesContext,
} from "@termix/plugin-sdk/ui";
import { DockerApiError, useDockerApi, type ConnectResult } from "./docker-api";
import {
@@ -82,8 +81,10 @@ function DockerManagerInner({
const docker = useDockerApi();
const { addLog, setLogs, clearLogs } = useConnectionLog();
const { currentTab, removeTab } = useTabsSafe();
const dockerPrefs = useAreaPreferences("docker");
const uiPrefsCtx = useUiPreferencesContext();
const { values: dockerPrefs, set: setDockerPref } = usePluginUiPreferences<{
viewMode: "list" | "detail";
containerLayout: "card" | "table";
}>();
// The shell's host list keeps the host current after an edit; a
// standalone window has no shell, so it keeps the host it was given.
@@ -113,9 +114,9 @@ function DockerManagerInner({
const handleSetContainerLayout = React.useCallback(
(layout: "card" | "table") => {
setContainerLayout(layout);
uiPrefsCtx?.setOverride("docker", "containerLayout", layout);
setDockerPref("containerLayout", layout);
},
[uiPrefsCtx],
[setDockerPref],
);
const [isConnecting, setIsConnecting] = React.useState(false);
const [dockerValidation, setDockerValidation] =
-7
View File
@@ -401,13 +401,6 @@
},
"hosts": {
"fileManager": "File Manager",
"tabFiles": "Files",
"enableFileManagerMonitor": "Enable File Manager",
"enableFileManagerMonitorDesc": "Browse and manage files on this host over SFTP",
"scpLegacyLabel": "SCP Legacy Mode",
"scpLegacyDesc": "Use legacy file transfer for servers that do not support the SFTP subsystem (e.g. embedded or minimal SSH servers).",
"defaultPathLabel": "Default Path",
"fileManagerPathHint": "The directory to open when the file manager launches for this host.",
"fileManagerUrlCopied": "File Manager URL copied",
"copyFileManagerUrlAction": "Copy File Manager URL"
}
+5 -2
View File
@@ -20,7 +20,9 @@
"ssh:connect",
"db:own",
"network:serve",
"ui:surface"
"ui:surface",
"events:core",
"kv:own"
],
"provides": [
{
@@ -70,6 +72,7 @@
"host": {
"enableKey": "enableFileManager",
"enableLabelKey": "settings.host.enable.label",
"enableDefault": true,
"fields": [
{
"key": "defaultPath",
@@ -89,7 +92,7 @@
"hostCapability": {
"key": "enableFileManager",
"labelKey": "hostCapability.label",
"editorTab": "files"
"editorTab": "plugins"
}
},
"locales": "locales",
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+19 -1
View File
@@ -9,6 +9,12 @@ import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend";
import { getErrorMessage } from "./error-message.js";
import { setPluginSsh, pluginSsh } from "./ssh.js";
import { setPluginCtx } from "./plugin-ctx.js";
import {
flushTransferProfiles,
setTransferProfileStore,
} from "./transfer-tuning.js";
const TRANSFER_PROFILES_KEY = "transfer-profiles";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import {
@@ -113,6 +119,14 @@ export async function activate(ctx: PluginContext) {
setPluginCtx(ctx);
ctx.disposables.add(() => setPluginSsh(null));
ctx.disposables.add(() => setPluginCtx(null));
setTransferProfileStore({
read: () => ctx.kv.get(TRANSFER_PROFILES_KEY),
write: (value) => ctx.kv.set(TRANSFER_PROFILES_KEY, value),
});
ctx.disposables.add(async () => {
await flushTransferProfiles();
setTransferProfileStore(null);
});
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db.define hands back an untyped table object; typed at this module's
@@ -605,7 +619,11 @@ export async function activate(ctx: PluginContext) {
};
resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as
Record<string, unknown> | undefined;
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
resolvedScpLegacy =
(await ctx.settings.getHost<boolean>(
Number(resolvedHost.id),
"scpLegacy",
)) === true;
if (resolvedHost.useSocks5) {
resolvedUseSocks5 = resolvedHost.useSocks5;
resolvedSocks5Host = resolvedHost.socks5Host;
@@ -1,7 +1,4 @@
// TODO(file-manager): move this to ctx.kv, see FINISH-LIST.md
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
export interface TransferPerformanceProfile {
throughputBps: number;
@@ -32,15 +29,32 @@ const MB = 1024 * 1024;
const PROFILE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_PROFILES = 128;
const STORE_VERSION = 1;
const STORE_FILENAME = "adaptive-transfer-profiles.json";
const profiles = new Map<string, TransferPerformanceProfile>();
const directRoutes = new Map<string, DirectRouteProfile>();
let loadedPath: string | undefined;
/** Where the learned profiles live: ctx.kv, set on activate. */
export interface TransferProfileStore {
read: () => Promise<unknown>;
write: (value: PersistedProfiles) => Promise<void>;
}
let store: TransferProfileStore | null = null;
let loadedStore: TransferProfileStore | null = null;
let loadPromise: Promise<void> | undefined;
export function setTransferProfileStore(
next: TransferProfileStore | null,
): void {
store = next;
loadedStore = null;
loadPromise = undefined;
profiles.clear();
directRoutes.clear();
}
let persistTimer: ReturnType<typeof setTimeout> | undefined;
let persistPromise = Promise.resolve();
interface PersistedProfiles {
export interface PersistedProfiles {
version: typeof STORE_VERSION;
profiles: Record<string, TransferPerformanceProfile>;
directRoutes: Record<string, DirectRouteProfile>;
@@ -68,12 +82,6 @@ function validDirectRoute(value: unknown): value is DirectRouteProfile {
);
}
function storePath(): string {
const dataDir =
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
return path.join(dataDir, STORE_FILENAME);
}
function profileId(key: string): string {
return createHash("sha256").update(key).digest("hex");
}
@@ -117,22 +125,15 @@ function trimProfiles(now = Date.now()): void {
async function persistProfiles(): Promise<void> {
trimProfiles();
const target = storePath();
const temporary = `${target}.${process.pid}.tmp`;
const payload: PersistedProfiles = {
version: STORE_VERSION,
profiles: Object.fromEntries(profiles),
directRoutes: Object.fromEntries(directRoutes),
};
if (!store) return;
try {
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(temporary, JSON.stringify(payload), {
encoding: "utf8",
mode: 0o600,
await store.write({
version: STORE_VERSION,
profiles: Object.fromEntries(profiles),
directRoutes: Object.fromEntries(directRoutes),
});
await fs.rename(temporary, target);
} catch {
await fs.rm(temporary, { force: true }).catch(() => {});
// Learning data is a nicety; a failed write must not affect transfers.
}
}
@@ -146,16 +147,16 @@ function queuePersist(): void {
}
export async function initializeTransferProfiles(): Promise<void> {
const target = storePath();
if (loadedPath === target) return loadPromise;
loadedPath = target;
if (loadPromise && loadedStore === store) return loadPromise;
loadedStore = store;
const current = store;
loadPromise = (async () => {
profiles.clear();
directRoutes.clear();
if (!current) return;
try {
const parsed = JSON.parse(
await fs.readFile(target, "utf8"),
) as Partial<PersistedProfiles>;
const parsed = ((await current.read()) ??
{}) as Partial<PersistedProfiles>;
if (parsed.version !== STORE_VERSION || !parsed.profiles) return;
for (const [key, profile] of Object.entries(parsed.profiles)) {
if (/^[a-f0-9]{64}$/.test(key) && validProfile(profile)) {
@@ -381,7 +382,7 @@ export function getRecentDirectRouteDecision(
export function clearTransferProfiles(): void {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = undefined;
loadedPath = undefined;
loadedStore = null;
loadPromise = undefined;
profiles.clear();
directRoutes.clear();
@@ -1,3 +1,4 @@
import { fileManagerHostSetting } from "./host-settings";
import { getErrorMessage } from "@/lib/error-message.js";
/* eslint-disable react-hooks/exhaustive-deps */
import React, {
@@ -44,7 +45,11 @@ import {
ConnectionScreen,
} from "@termix/plugin-sdk/ui";
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
import { invokeAction, useConnectionRetry } from "@termix/plugin-sdk/frontend";
import {
invokeAction,
useConnectionRetry,
logActivity,
} from "@termix/plugin-sdk/frontend";
import { copyToClipboard } from "@/lib/clipboard.ts";
import {
listSSHFiles,
@@ -77,7 +82,6 @@ import {
getPinnedFiles,
} from "./api/file-manager-data-api";
import {
logActivity,
transferToHost,
addTransferRecent,
type TransferMethodPreference,
@@ -145,15 +149,21 @@ function FileManagerContent({
const [currentHost] = useState<SSHHost | null>(initialHost || null);
const [currentPath, setCurrentPath] = useState(
initialPath || initialHost?.defaultPath || "/",
initialPath ||
fileManagerHostSetting(initialHost, "defaultPath", "") ||
"/",
);
const currentPathRef = useRef(currentPath);
currentPathRef.current = currentPath;
const lastSuccessfulPathRef = useRef(
initialPath || initialHost?.defaultPath || "/",
initialPath ||
fileManagerHostSetting(initialHost, "defaultPath", "") ||
"/",
);
const [navHistory, setNavHistory] = useState<string[]>([
initialPath || initialHost?.defaultPath || "/",
initialPath ||
fileManagerHostSetting(initialHost, "defaultPath", "") ||
"/",
]);
const [navIndex, setNavIndex] = useState(0);
const [files, setFiles] = useState<FileItem[]>([]);
@@ -1,65 +0,0 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Input,
SectionCard,
SettingRow,
FakeSwitch,
} from "@termix/plugin-sdk/ui";
import { FolderSearch } from "lucide-react";
import type { HostEditorForm } from "@/sidebar/HostEditorData";
type SetHostField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
export function HostFilesTab({
form,
setField,
}: {
form: HostEditorForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
return (
<SectionCard
title={t("hosts.fileManager")}
icon={<FolderSearch className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableFileManagerMonitor")}
description={t("hosts.enableFileManagerMonitorDesc")}
>
<FakeSwitch
checked={form.enableFileManager}
onChange={(v) => setField("enableFileManager", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.scpLegacyLabel")}
description={t("hosts.scpLegacyDesc")}
>
<FakeSwitch
checked={form.scpLegacy}
onChange={(v) => setField("scpLegacy", v)}
/>
</SettingRow>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.defaultPathLabel")}
</label>
<Input
placeholder="/"
value={form.defaultPath}
onChange={(e) => setField("defaultPath", e.target.value)}
/>
<span className="text-[10px] text-muted-foreground">
{t("hosts.fileManagerPathHint")}
</span>
</div>
</div>
</SectionCard>
);
}
@@ -1,3 +1,4 @@
import { fileManagerHostSetting } from "./host-settings";
import {
useCallback,
useEffect,
@@ -372,11 +373,11 @@ function RemotePane({
...current,
connectionState: "ready",
sessionId: result.sessionId || String(host.id),
path: host.defaultPath || "/",
path: fileManagerHostSetting(host, "defaultPath", "") || "/",
}));
await loadRemotePath(
result.sessionId || String(host.id),
host.defaultPath || "/",
fileManagerHostSetting(host, "defaultPath", "") || "/",
);
},
[loadRemotePath, setPane],
@@ -585,7 +586,7 @@ export function SftpTransferTab() {
setHosts(
data.filter(
(host) =>
host.enableFileManager !== false &&
fileManagerHostSetting(host, "enableFileManager", true) &&
host.connectionType !== "rdp" &&
host.connectionType !== "vnc",
),
@@ -1,14 +1,11 @@
import React, { useState } from "react";
import { Document, Page } from "react-pdf";
import { Document, Page, pdfjs } from "react-pdf";
import { AlertCircle, Download } from "lucide-react";
import { Button } from "@termix/plugin-sdk/ui";
import { useTranslation } from "react-i18next";
import pdfjsWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
// No workerSrc: the termix-plugin CLI's esbuild step has no asset-copy
// pipeline for a worker file the way core's Vite build does with `?url`, so
// pdfjs falls back to parsing on the main thread instead. Fine for the
// config/log-sized files this preview usually opens; a real worker needs the
// CLI to gain static-asset output first (see FINISH-LIST.md).
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorkerUrl;
interface PdfPreviewProps {
content: string;
@@ -1,3 +1,4 @@
import { fileManagerHostSetting } from "../host-settings";
import React, {
useCallback,
useEffect,
@@ -236,7 +237,7 @@ export function TransferToHostDialog({
const candidates = hosts.filter(
(h) =>
h.id !== sourceHost.id &&
h.enableFileManager !== false &&
fileManagerHostSetting(h, "enableFileManager", true) &&
h.connectionType !== "rdp" &&
h.connectionType !== "vnc",
);
@@ -1,3 +1,4 @@
import { fileManagerHostSetting } from "../host-settings";
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
import { Select2 } from "@termix/plugin-sdk/ui";
import type {
@@ -12,7 +13,9 @@ export function FileManagerWidgetEditForm({
const { t } = useTranslation();
const { hosts } = useHosts();
const options = hosts.filter(
(host) => host.enableSsh !== false && host.enableFileManager !== false,
(host) =>
host.enableSsh !== false &&
fileManagerHostSetting(host, "enableFileManager", true),
);
return (
@@ -0,0 +1,14 @@
/** One of this plugin's host settings, from the host payload. */
export function fileManagerHostSetting<T>(
host: object | null | undefined,
key: "enableFileManager" | "defaultPath" | "scpLegacy",
fallback: T,
): T {
const settings = (
host as { pluginSettings?: Record<string, Record<string, unknown>> } | null
)?.pluginSettings;
const value = settings?.["file-manager"]?.[key];
return value === undefined || value === null || value === ""
? fallback
: (value as T);
}
+4 -11
View File
@@ -1,3 +1,4 @@
import { fileManagerHostSetting } from "./host-settings";
import type { ComponentType } from "react";
import { FolderSearch, ArrowLeftRight } from "lucide-react";
import {
@@ -11,7 +12,6 @@ import { GRID_SIZE } from "./homepage/homepage.js";
import { FileManager } from "./FileManager.tsx";
import FileManagerApp from "./FileManagerApp.tsx";
import { SftpTransferTab } from "./SftpTransferTab.tsx";
import { HostFilesTab } from "./HostFilesTab.tsx";
import { FileManagerWidget } from "./homepage/FileManagerWidget.tsx";
import { FileManagerWidgetEditForm } from "./homepage/FileManagerWidgetEditForm.tsx";
import { startTransferMonitor } from "./TransferMonitor.tsx";
@@ -104,16 +104,9 @@ export function activate(app: TermixApp): void {
order: 20,
tabType: "files",
copyUrlView: "file-manager",
when: (host) => !!host.enableSsh && host.enableFileManager !== false,
});
app.registerHostEditorSection({
id: "files",
group: "ssh",
titleKey: "hosts.tabFiles",
icon: FolderSearch,
order: 60,
component: HostFilesTab as never,
when: (host) =>
!!host.enableSsh &&
fileManagerHostSetting(host, "enableFileManager", true),
});
app.registerHomepageWidget({
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from "vitest";
import {
clearTransferProfiles,
flushTransferProfiles,
getTransferProfile,
initializeTransferProfiles,
recordTransferProfile,
setTransferProfileStore,
type PersistedProfiles,
} from "../../src/backend/transfer-tuning.js";
afterEach(() => {
setTransferProfileStore(null);
clearTransferProfiles();
});
function memoryStore() {
let saved: PersistedProfiles | undefined;
return {
store: {
read: async () => saved,
write: async (value: PersistedProfiles) => {
saved = JSON.parse(JSON.stringify(value));
},
},
saved: () => saved,
};
}
describe("transfer profile store", () => {
it("saves learned profiles and reads them back after a restart", async () => {
const memory = memoryStore();
setTransferProfileStore(memory.store);
await initializeTransferProfiles();
recordTransferProfile("host-a:host-b", {
bytes: 50 * 1024 * 1024,
durationMs: 5000,
lanes: 4,
pipelineConcurrency: 8,
failed: false,
now: Date.now(),
});
await flushTransferProfiles();
expect(Object.keys(memory.saved()?.profiles ?? {})).toHaveLength(1);
// A fresh activation starts from what the store holds.
setTransferProfileStore(memory.store);
await initializeTransferProfiles();
expect(getTransferProfile("host-a:host-b")).toBeDefined();
});
it("works without a store", async () => {
await initializeTransferProfiles();
expect(getTransferProfile("nothing")).toBeUndefined();
});
});
@@ -59,17 +59,19 @@ beforeEach(() => {
id: 1,
name: "prod",
ip: "10.0.0.1",
enableFileManager: true,
connectionType: "ssh",
defaultPath: "/srv",
pluginSettings: {
"file-manager": { enableFileManager: true, defaultPath: "/srv" },
},
},
{
id: 2,
name: "backup",
ip: "10.0.0.2",
enableFileManager: true,
connectionType: "ssh",
defaultPath: "/srv",
pluginSettings: {
"file-manager": { enableFileManager: true, defaultPath: "/srv" },
},
},
]);
api.ensureSSHSessionForHost.mockImplementation(async (host) => ({
@@ -52,9 +52,9 @@ describe(`${manifest.id} activate`, () => {
);
});
it("registers the files host editor section", async () => {
it("leaves its host fields to the schema-driven Plugins tab", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.hostEditorSections()).toContain("files");
expect(rendered.registered.hostEditorSections()).toEqual([]);
});
it("registers files.openHost and files.openEditor actions", async () => {
@@ -8,7 +8,7 @@ vi.mock("sonner", () => ({
}));
vi.mock("@/main-axios.ts", () => ({}));
vi.mock("../../../features/file-manager/transferNotificationStore.ts", () => ({
vi.mock("../../src/frontend/transferNotificationStore.ts", () => ({
markTransferNotified: vi.fn(),
}));
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
@@ -12,7 +12,7 @@ export function SshTerminalEditForm({
<SingleHostEditForm
hostId={config.hostId}
onChange={(hostId) => onChange({ ...config, hostId })}
filter={(h) => !!(h.enableSsh && h.enableTerminal)}
filter={(h) => !!h.enableSsh}
/>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
+14 -2
View File
@@ -20,7 +20,8 @@
"ssh:connect",
"db:own",
"network:serve",
"ui:surface"
"ui:surface",
"events:core"
],
"provides": [
{
@@ -136,7 +137,18 @@
"id": "host-metrics.managers",
"accepts": ["component"]
}
]
],
"uiPresets": {
"simple": {
"columns": 1
},
"balanced": {
"columns": 3
},
"advanced": {
"columns": 4
}
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+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;
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { hostPayloadLegacy } from "../../src/backend/host-import.js";
describe("hostPayloadLegacy", () => {
it("rebuilds the 2.8 statsConfig Termix-Mobile reads", () => {
const { statsConfig } = hostPayloadLegacy(
{ metricsEnabled: false, metricsInterval: 45, enabledWidgets: ["cpu"] },
{ statusCheckEnabled: true, statusCheckInterval: null },
) as { statsConfig: Record<string, unknown> };
expect(statsConfig).toMatchObject({
enabledWidgets: ["cpu"],
statusCheckEnabled: true,
useGlobalStatusInterval: true,
metricsEnabled: false,
metricsInterval: 45,
useGlobalMetricsInterval: false,
});
});
it("carries a host's own status interval", () => {
const { statsConfig } = hostPayloadLegacy(
{},
{ statusCheckEnabled: false, statusCheckInterval: 120 },
) as { statsConfig: Record<string, unknown> };
expect(statsConfig).toMatchObject({
statusCheckEnabled: false,
statusCheckInterval: 120,
useGlobalStatusInterval: false,
});
});
});
@@ -54,7 +54,6 @@ vi.mock("../../src/frontend/host-metrics-api", async (importOriginal) => {
vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({
...(await importOriginal<object>()),
useAreaPreferences: () => ({ columns: 3 }),
useConfirmation: () => ({ confirmWithToast: vi.fn() }),
logActivity: vi.fn(async () => undefined),
}));
@@ -6,16 +6,24 @@ const api = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
}));
const remote = vi.hoisted(() => ({
api: { get: vi.fn(), post: vi.fn() },
origin: "local" as "local" | "remote",
remoteId: 70 as number | null,
}));
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
usePluginApi: () => api,
usePluginApiFor: () => remote.api,
useTranslation: () => ({ t: (key: string) => key, language: "en" }),
}));
vi.mock("@termix/plugin-sdk/ui", () => ({
cn: (...classes: unknown[]) => classes.filter(Boolean).join(" "),
getPollingEnvironmentMultiplier: () => 1,
resolveConnectionOrigin: async () => remote.origin,
resolveRemoteHostId: async () => remote.remoteId,
runAdaptivePolling: (poll: () => Promise<unknown>) => {
void poll();
return () => {};
@@ -27,6 +35,8 @@ import { TerminalMetricsStatus } from "../../src/frontend/TerminalMetricsStatus"
afterEach(() => {
cleanup();
vi.clearAllMocks();
remote.origin = "local";
remote.remoteId = 70;
});
describe("TerminalMetricsStatus", () => {
@@ -77,4 +87,22 @@ describe("TerminalMetricsStatus", () => {
viewerSessionId: "viewer-2",
});
});
it("polls a remote server's host by its id there", async () => {
remote.origin = "remote";
remote.api.post.mockResolvedValue({ data: { viewerSessionId: "v" } });
remote.api.get.mockResolvedValue({ data: { cpu: { percent: 33 } } });
render(
<TerminalMetricsStatus
host={{ id: 7, connectionOrigin: "remote", syncId: "s-1" }}
isConnected
active
/>,
);
await waitFor(() => expect(screen.getByText("33%")).toBeInTheDocument());
expect(remote.api.post).toHaveBeenCalledWith("/metrics/start/70");
expect(api.post).not.toHaveBeenCalled();
});
});
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"dependencies": {
-5
View File
@@ -40,12 +40,7 @@
"sourceDifferentFromTarget": "Source and target must be different",
"exportJSON": "Export JSON",
"importJSON": "Import JSON",
"terminal": "Terminal",
"fileManager": "File Manager",
"tunnel": "Tunnel",
"docker": "Docker",
"serverStats": "Host Metrics",
"hostMetrics": "Host Metrics",
"noNodes": "No nodes yet"
},
"nav": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"dependencies": {
@@ -31,6 +31,7 @@ import {
useTranslation,
usePluginApi,
useHosts,
useHostActions,
type PluginHostRecord,
} from "@termix/plugin-sdk/frontend";
import {
@@ -48,13 +49,8 @@ import {
Edit,
FolderInput,
FolderMinus,
Terminal,
ArrowUp,
Network,
FolderOpen,
Container,
Server,
ArrowDownUp,
Loader2,
} from "lucide-react";
import {
@@ -79,11 +75,6 @@ type HostStatus = "online" | "reachable" | "offline" | "unknown";
interface HostWithStatus extends PluginHostRecord {
status?: HostStatus;
enableTerminal?: boolean;
enableFileManager?: boolean;
enableTunnel?: boolean;
enableDocker?: boolean;
tunnelConnections?: unknown;
}
interface HostMap {
@@ -232,6 +223,7 @@ export function NetworkGraphCard({
isVisible = true,
}: NetworkGraphCardProps): React.ReactElement {
const { t } = useTranslation();
const hostActions = useHostActions();
const { addTab } = useTabsSafe();
const pluginApi = usePluginApi();
const api = useMemo(() => createNetworkTopologyApi(pluginApi), [pluginApi]);
@@ -638,18 +630,6 @@ export function NetworkGraphCard({
fireOpen(contextMenu.targetId, appType);
};
const hasTunnelConnections = (h: HostWithStatus | undefined) => {
if (!h?.tunnelConnections) return false;
try {
const arr = Array.isArray(h.tunnelConnections)
? h.tunnelConnections
: JSON.parse(h.tunnelConnections as string);
return Array.isArray(arr) && arr.length > 0;
} catch {
return false;
}
};
const handleConfirmAddNode = async () => {
if (!cyRef.current || !selectedHostForAddNode) return;
try {
@@ -813,50 +793,25 @@ export function NetworkGraphCard({
>
{contextMenu.type === "node" && (
<>
{hostMap[contextMenu.targetId]?.enableTerminal && (
<button
onClick={() => handleConnectAction("terminal")}
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-muted transition-colors"
>
<Terminal className="size-3 shrink-0" />
{t("networkGraph.terminal")}
</button>
)}
{hostMap[contextMenu.targetId]?.enableFileManager && (
<button
onClick={() => handleConnectAction("files")}
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-muted transition-colors"
>
<FolderOpen className="size-3 shrink-0" />
{t("networkGraph.fileManager")}
</button>
)}
{hostMap[contextMenu.targetId]?.enableTunnel &&
hasTunnelConnections(hostMap[contextMenu.targetId]) && (
<button
onClick={() => handleConnectAction("tunnel")}
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-muted transition-colors"
>
<ArrowDownUp className="size-3 shrink-0" />
{t("networkGraph.tunnel")}
</button>
)}
{hostMap[contextMenu.targetId]?.enableDocker && (
<button
onClick={() => handleConnectAction("docker")}
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-muted transition-colors"
>
<Container className="size-3 shrink-0" />
{t("networkGraph.docker")}
</button>
)}
<button
onClick={() => handleConnectAction("host-metrics")}
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-muted transition-colors"
>
<Server className="size-3 shrink-0" />
{t("networkGraph.hostMetrics")}
</button>
{hostActions
.filter((action) => !!action.tabType && !action.items)
.filter((action) => {
const host = hostMap[contextMenu.targetId];
return !!host && action.when(host);
})
.map((action) => {
const Icon = action.icon;
return (
<button
key={action.id}
onClick={() => handleConnectAction(action.tabType!)}
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-muted transition-colors"
>
<Icon className="size-3 shrink-0" />
{t(action.titleKey)}
</button>
);
})}
{!embedded && (
<>
<div className="h-px bg-border mx-2 my-0.5" />
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"dependencies": {
+8 -2
View File
@@ -43,7 +43,8 @@ export function binaryName(
* What ctx.process.ensureBinary needs for OPKSSH.
*
* The pinned version is trusted by its built-in checksums. OPKSSH_VERSION
* picks another release, and then OPKSSH_SHA256 must give its checksum.
* picks another release, and then OPKSSH_SHA256 (or OPKSSH_SHA256_AMD64 /
* OPKSSH_SHA256_ARM64, which the Docker image passes on) gives its checksum.
*
* The Docker image bakes the binary into /app/opkssh-bundled (the
* opkssh-downloader stage), which is checked first so an offline install
@@ -60,7 +61,12 @@ export function binarySpec(
const sha256 =
version === DEFAULT_VERSION
? DEFAULT_CHECKSUMS[name]
: env.OPKSSH_SHA256?.trim().toLowerCase();
: (
env.OPKSSH_SHA256 ||
env[`OPKSSH_SHA256_${arch === "arm64" ? "ARM64" : "AMD64"}`]
)
?.trim()
.toLowerCase();
if (!sha256 || !/^[0-9a-f]{64}$/.test(sha256)) {
throw new Error(
`OPKSSH ${version} has no trusted SHA-256 checksum for ${name}`,
@@ -107,4 +107,16 @@ describe("binary", () => {
binarySpec({ OPKSSH_VERSION: "v9", OPKSSH_SHA256: sha }, "linux", "x64"),
).toMatchObject({ version: "v9", sha256: sha });
});
it("reads the per-architecture checksum the Docker image passes on", () => {
const amd = "b".repeat(64);
const arm = "c".repeat(64);
const env = {
OPKSSH_VERSION: "v9",
OPKSSH_SHA256_AMD64: amd,
OPKSSH_SHA256_ARM64: arm,
};
expect(binarySpec(env, "linux", "x64").sha256).toBe(amd);
expect(binarySpec(env, "linux", "arm64").sha256).toBe(arm);
});
});
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
@@ -130,9 +130,8 @@ export class ProxmoxPollingManager<
return intervalMs + jitter;
}
// Host Metrics' own retention setting is not on the core settings
// allowlist, so this plugin uses a fixed default rather than reaching for
// it. See FINISH-LIST.md.
// Host Metrics' retention setting belongs to that plugin, so node history
// keeps a fixed week.
private readonly retentionDays = 7;
private async pollHostStats(
+14 -37
View File
@@ -1,4 +1,3 @@
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import express, { type Router } from "express";
import type { Client as SSHClient } from "ssh2";
import { execElevated } from "@termix/plugin-sdk/host-commands";
@@ -16,6 +15,11 @@ import {
} from "./proxmox-jump-hosts.js";
import { isSafeNodeName } from "./proxmox-shared.js";
/** A thrown value's message, or the fallback when it is not an Error. */
function getErrorMessage(error: unknown, fallback = "Unknown error"): string {
return error instanceof Error ? error.message : fallback;
}
const router = express.Router();
const runningSyncs = new Set<string>();
@@ -630,9 +634,6 @@ async function syncProxmoxHost(
}
Object.assign(update, {
enableTerminal: connectionType !== "rdp",
enableFileManager: connectionType !== "rdp",
enableTunnel: connectionType !== "rdp",
enableDocker: guest.enableDocker,
enableSsh: connectionType === "ssh",
enableRdp: connectionType === "rdp",
@@ -657,8 +658,6 @@ async function syncProxmoxHost(
vncPassword: null,
telnetUser: null,
telnetPassword: null,
defaultPath: "/",
tunnelConnections: "[]",
jumpHosts: serializeProxmoxJumpHosts(discovery.jumpHosts),
quickActions: null,
statsConfig: null,
@@ -672,11 +671,6 @@ async function syncProxmoxHost(
socks5Password: null,
socks5ProxyChain: null,
portKnockSequence: null,
showTerminalInSidebar: 0,
showFileManagerInSidebar: 0,
showTunnelInSidebar: 0,
showDockerInSidebar: 0,
showServerStatsInSidebar: 0,
} as unknown as PluginHostCreateInput);
result.created++;
}
@@ -793,34 +787,17 @@ router.post("/sync", async (req, res) => {
async function runDueProxmoxAutoSyncs(): Promise<void> {
const ctx = pluginCtx();
try {
const {
createCurrentPluginSettingsRepository,
createCurrentHostRepository,
} =
await import("../../../../src/backend/database/repositories/factory.js");
const enabledRows = await createCurrentPluginSettingsRepository().listByKey(
"proxmox",
"host",
"enableProxmox",
);
const hostRepository = createCurrentHostRepository();
const enabled = await ctx.settings.listHostValues<boolean>("enableProxmox");
const now = Date.now();
for (const row of enabledRows) {
if (row.value !== "true") continue;
const hostId = Number(row.scopeId);
if (!Number.isInteger(hostId)) continue;
for (const { hostId, userId, value } of enabled) {
if (value !== true) continue;
const host = await hostRepository.findById(hostId);
if (!host) continue;
const configRow = await createCurrentPluginSettingsRepository().get(
"proxmox",
"host",
String(hostId),
"proxmoxConfig",
);
const configRaw = configRow?.value ? JSON.parse(configRow.value) : {};
const configRaw =
(await ctx.settings.getHost<Record<string, unknown>>(
hostId,
"proxmoxConfig",
)) ?? {};
const config = parseProxmoxConfig(configRaw);
if (!config.autoSyncEnabled) continue;
@@ -832,7 +809,7 @@ async function runDueProxmoxAutoSyncs(): Promise<void> {
if (lastSyncAt && now - lastSyncAt < intervalMs) continue;
ctx
.asUser(host.userId, () => syncProxmoxHost(host.userId, hostId))
.asUser(userId, () => syncProxmoxHost(userId, hostId))
.catch((error) => {
ctx.log.error(
`Scheduled Proxmox sync failed for host ${hostId}`,
@@ -4,19 +4,21 @@ import { Server } from "lucide-react";
import { Input } from "@/components/input";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import { getCredentials } from "@/main-axios";
import type { HostEditorForm } from "@/sidebar/HostEditorData";
import { Select2 } from "@/components/select2";
import type { ProxmoxHostConfig } from "./types";
type SetHostField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
interface HostProxmoxForm {
enableProxmox: boolean;
proxmoxConfig: ProxmoxHostConfig | null;
}
type SetHostField = (key: string, value: unknown) => void;
export function HostProxmoxTab({
form,
setField,
}: {
form: HostEditorForm;
form: HostProxmoxForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
@@ -158,9 +158,6 @@ export function ProxmoxDiscoverDialog({
// imported guests are reachable the same way; user can override.
jumpHosts: discoveredJumpHosts ?? undefined,
...importAuth,
enableTerminal: g.connectionType !== "rdp",
enableFileManager: g.connectionType !== "rdp",
enableTunnel: g.connectionType !== "rdp",
enableSsh: g.connectionType !== "rdp",
enableRdp: g.connectionType === "rdp",
enableDocker: g.enableDocker,
@@ -172,16 +169,21 @@ export function ProxmoxDiscoverDialog({
g.type === "lxc" ? `ct-${g.vmid}` : `vm-${g.vmid}`,
...(g.enableDocker ? ["docker"] : []),
],
proxmoxConfig: {
source: {
source: "proxmox",
sourceHostId: Number(effectiveHostId),
node: g.node,
vmid: g.vmid,
type: g.type,
lastSeenAt: new Date().toISOString(),
lastStatus: g.status,
missingSince: null,
// This plugin's host settings travel with the import like an export's.
pluginSettings: {
proxmox: {
proxmoxConfig: {
source: {
source: "proxmox",
sourceHostId: Number(effectiveHostId),
node: g.node,
vmid: g.vmid,
type: g.type,
lastSeenAt: new Date().toISOString(),
lastStatus: g.status,
missingSince: null,
},
},
},
},
}));
+39 -5
View File
@@ -25,19 +25,53 @@ interface DiscoverRequest {
type Opener = (request: DiscoverRequest) => void;
function ProxmoxHostSection({ form, setField }: HostEditorSectionProps) {
// The Proxmox fields are this plugin's host settings, saved with the host
// through form.pluginSettings.proxmox.
function ProxmoxHostSection({ form, updateForm }: HostEditorSectionProps) {
const values = (form.pluginSettings?.proxmox ?? {}) as Record<
string,
unknown
>;
const setField = (key: string, value: unknown) =>
updateForm((current) => {
const all = (current.pluginSettings ?? {}) as Record<
string,
Record<string, unknown>
>;
return {
...current,
pluginSettings: {
...all,
proxmox: { ...(all.proxmox ?? {}), [key]: value },
},
};
});
const sectionForm = {
enableProxmox: values.enableProxmox === true,
proxmoxConfig: values.proxmoxConfig ?? null,
enableProxmoxStats: values.enableProxmoxStats === true,
proxmoxStatsConfig: values.proxmoxStatsConfig ?? {
pollInterval: 60,
nodeName: null,
},
};
return (
<>
<HostProxmoxTab
form={form}
form={sectionForm as Parameters<typeof HostProxmoxTab>[0]["form"]}
setField={setField as Parameters<typeof HostProxmoxTab>[0]["setField"]}
/>
<HostProxmoxStatsTab
form={form}
setField={setField as Parameters<typeof HostProxmoxTab>[0]["setField"]}
form={sectionForm as Parameters<typeof HostProxmoxStatsTab>[0]["form"]}
setField={
setField as Parameters<typeof HostProxmoxStatsTab>[0]["setField"]
}
/>
{/* Other plugins add Proxmox-related settings here. */}
<ComponentSlot slotId="proxmox.hostEditor" props={{ form, setField }} />
<ComponentSlot
slotId="proxmox.hostEditor"
props={{ form: sectionForm, setField }}
/>
</>
);
}
@@ -3,7 +3,7 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Box, Search, Server as ServerIcon } from "lucide-react";
import { Input } from "@termix/plugin-sdk/ui";
import type { ProxmoxGuestSummary } from "../types";
import { cn } from "./utils";
import { cn } from "@termix/plugin-sdk/ui";
type StatusFilter = "all" | "running" | "stopped";
type TypeFilter = "all" | "qemu" | "lxc";
@@ -1,7 +1,7 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Cpu, MemoryStick, HardDrive, Clock, Server } from "lucide-react";
import type { ProxmoxNodeStats } from "../types";
import { cn } from "./utils";
import { cn } from "@termix/plugin-sdk/ui";
function Tile({
icon,
@@ -20,7 +20,7 @@ import { NodeNetworkCard } from "./cards/NodeNetworkCard";
import { StoragePoolsCard } from "./cards/StoragePoolsCard";
import { ClusterHealthCard } from "./cards/ClusterHealthCard";
import { useConnectionRetry } from "@termix/plugin-sdk/frontend";
import { runAdaptivePolling } from "./adaptive-polling";
import { runAdaptivePolling } from "@termix/plugin-sdk/ui";
const HISTORY_LEN = 30;
@@ -1,132 +0,0 @@
export interface AdaptivePollingPolicy {
minIntervalMs: number;
maxIntervalMs: number;
stablePollsPerStep?: number;
jitterRatio?: number;
/** Prevent expensive requests from consuming more than this share of time. */
maxRequestDutyCycle?: number;
}
export interface AdaptivePollingState {
stablePolls: number;
consecutiveFailures: number;
lastPollDurationMs?: number;
}
export type AdaptivePollResult = boolean | void;
export function computeAdaptivePollDelay(
policy: AdaptivePollingPolicy,
state: AdaptivePollingState,
random = Math.random,
): number {
const min = Math.max(250, policy.minIntervalMs);
const max = Math.max(min, policy.maxIntervalMs);
const stableStep = Math.floor(
state.stablePolls / Math.max(1, policy.stablePollsPerStep ?? 3),
);
const exponent = state.consecutiveFailures || stableStep;
let base = Math.min(max, min * 2 ** Math.min(exponent, 10));
const dutyCycle = policy.maxRequestDutyCycle;
if (dutyCycle && dutyCycle > 0 && dutyCycle < 1) {
const duration = Math.max(0, state.lastPollDurationMs ?? 0);
base = Math.max(base, duration * ((1 - dutyCycle) / dutyCycle));
}
base = Math.min(max, base);
const jitterRatio = Math.max(0, Math.min(0.5, policy.jitterRatio ?? 0.1));
const jitter = base * jitterRatio * (random() * 2 - 1);
return Math.max(min, Math.min(max, Math.round(base + jitter)));
}
// Copy of src/ui/lib/adaptive-polling.ts. Duplicated here rather than exposed
// through the SDK because only this plugin's Proxmox stats tab uses it
// today; promote to @termix/plugin-sdk/frontend once a second plugin needs it.
export function runAdaptivePolling(
poll: () => AdaptivePollResult | Promise<AdaptivePollResult>,
policy: AdaptivePollingPolicy,
options: {
enabled?: () => boolean;
visible?: () => boolean;
runImmediately?: boolean;
random?: () => number;
intervalMultiplier?: () => number;
onError?: (error: unknown) => void;
} = {},
): () => void {
const enabled = options.enabled ?? (() => true);
const visible =
options.visible ??
(() =>
typeof document === "undefined" || document.visibilityState !== "hidden");
const state: AdaptivePollingState = {
stablePolls: 0,
consecutiveFailures: 0,
};
let timer: ReturnType<typeof setTimeout> | null = null;
let stopped = false;
let inFlight = false;
const clearTimer = () => {
if (!timer) return;
clearTimeout(timer);
timer = null;
};
const schedule = () => {
clearTimer();
if (stopped || !enabled() || !visible()) return;
timer = setTimeout(
() => void tick(),
Math.min(
policy.maxIntervalMs,
Math.round(
computeAdaptivePollDelay(policy, state, options.random) *
Math.max(1, options.intervalMultiplier?.() ?? 1),
),
),
);
};
const tick = async () => {
if (stopped || inFlight || !enabled() || !visible()) return;
inFlight = true;
const startedAt = Date.now();
try {
const changed = await poll();
state.consecutiveFailures = 0;
state.stablePolls = changed === false ? state.stablePolls + 1 : 0;
} catch (error) {
state.stablePolls = 0;
state.consecutiveFailures += 1;
options.onError?.(error);
} finally {
state.lastPollDurationMs = Date.now() - startedAt;
inFlight = false;
schedule();
}
};
const onVisibility = () => {
if (!visible()) {
clearTimer();
return;
}
state.stablePolls = 0;
state.consecutiveFailures = 0;
void tick();
};
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibility);
}
if (options.runImmediately ?? true) void tick();
else schedule();
return () => {
stopped = true;
clearTimer();
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility);
}
};
}
@@ -2,7 +2,7 @@ import { Network } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { MetricCard } from "@termix/plugin-sdk/ui";
import type { ProxmoxStatsSnapshot } from "../../types";
import { cn } from "../utils";
import { cn } from "@termix/plugin-sdk/ui";
export function ClusterHealthCard({
snapshot,
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+32
View File
@@ -101,3 +101,35 @@ export interface ProxmoxStatsConfig {
pollInterval?: number;
enabledCards?: string[];
}
/** The proxmoxConfig host setting. */
export interface ProxmoxHostConfig {
source?: {
source: "proxmox";
sourceHostId: number;
node: string;
vmid: number;
type: "qemu" | "lxc";
lastSeenAt?: string;
lastStatus?: string;
missingSince?: string | null;
};
defaultCredentialId: number | null;
defaultAuthType?: string;
windowsPatterns: string;
dockerPatterns: string;
preferredPrefixes: string;
autoSyncEnabled?: boolean;
syncIntervalMinutes?: number;
markMissingGuests?: boolean;
lastSyncAt?: string;
lastSyncStatus?: "success" | "error";
lastSyncError?: string | null;
lastSyncResult?: {
created: number;
updated: number;
markedMissing: number;
skipped: number;
errors: string[];
};
}
+2 -1
View File
@@ -419,7 +419,8 @@
"type": "json",
"labelKey": "settings.host.guacamoleConfig.label",
"default": {},
"hidden": true
"hidden": true,
"shareRead": "edit"
},
{
"key": "enableToolbar",
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
@@ -20,6 +20,7 @@ import {
useHost,
useTranslation,
useConnectionRetry,
logActivity,
} from "@termix/plugin-sdk/frontend";
import {
Button,
@@ -33,7 +34,6 @@ import {
Input,
PasswordInput,
isElectron,
logActivity,
resolveConnectionOrigin,
useConnectionLog,
type ConnectionOrigin,
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+20 -5
View File
@@ -34,6 +34,21 @@ export function registerSecretSourceRoutes(
tokenStore: TokenStore,
): void {
const isAdmin = () => ctx.rbac.has("admin.plugins.manage");
// Secret sources are credentials, so they follow core's credential
// permissions. ctx.rbac.require only takes this plugin's own names.
const requireCredentials =
(action: "view" | "create" | "edit" | "delete") =>
(req: Request, res: Response, next: () => void) => {
void ctx.rbac.has(`credentials.${action}`).then((allowed) => {
if (allowed) next();
else {
res.status(403).json({
error: "Insufficient permissions",
required: `credentials.${action}`,
});
}
});
};
const allowedPrivateHosts = async () =>
parseAllowlist(await ctx.settings.get<string>("privateEndpoints"));
@@ -47,7 +62,7 @@ export function registerSecretSourceRoutes(
*/
router.get(
"/",
ctx.rbac.require("credentials.view") as never,
requireCredentials("view") as never,
async (req: Request, res: Response) => {
const userId = ctx.currentActor()!;
try {
@@ -76,7 +91,7 @@ export function registerSecretSourceRoutes(
*/
router.post(
"/",
ctx.rbac.require("credentials.create") as never,
requireCredentials("create") as never,
async (req: Request, res: Response) => {
const userId = ctx.currentActor()!;
const {
@@ -138,7 +153,7 @@ export function registerSecretSourceRoutes(
*/
router.put(
"/:id",
ctx.rbac.require("credentials.edit") as never,
requireCredentials("edit") as never,
async (req: Request, res: Response) => {
const userId = ctx.currentActor()!;
const { name, baseUrl, token, shared } = req.body ?? {};
@@ -187,7 +202,7 @@ export function registerSecretSourceRoutes(
*/
router.delete(
"/:id",
ctx.rbac.require("credentials.delete") as never,
requireCredentials("delete") as never,
async (req: Request, res: Response) => {
const userId = ctx.currentActor()!;
try {
@@ -227,7 +242,7 @@ export function registerSecretSourceRoutes(
*/
router.post(
"/:id/test",
ctx.rbac.require("credentials.view") as never,
requireCredentials("view") as never,
async (req: Request, res: Response) => {
const userId = ctx.currentActor()!;
try {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"dependencies": {
+17 -17
View File
@@ -9,23 +9,16 @@ import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { TriangleAlert } from "lucide-react";
import { useTranslation, useTheme } from "@termix/plugin-sdk/frontend";
import {
DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS,
ensureTerminalFontsLoaded,
resolveTermixThemeColors,
} from "@termix/plugin-sdk/ui";
import type { SerialConfig, SerialHandle } from "./types.js";
import { isElectron } from "./electron.js";
import { resolveSerialWsUrl } from "./transport.js";
const XTERM_THEME: Record<"light" | "dark", Record<string, string>> = {
dark: {
background: "#0b0e14",
foreground: "#d4d7de",
cursor: "#d4d7de",
},
light: {
background: "#ffffff",
foreground: "#1a1a1a",
cursor: "#1a1a1a",
},
};
type WebSerialPort = {
open(options: {
baudRate: number;
@@ -70,10 +63,17 @@ export const Serial = forwardRef<SerialHandle, SerialProps>(function Serial(
useEffect(() => {
if (!terminal) return;
terminal.options.theme = XTERM_THEME[appTheme];
terminal.options.fontFamily =
'"SF Mono", Consolas, "Liberation Mono", monospace';
terminal.options.fontSize = 14;
// The same look as every other terminal in the app.
const font =
TERMINAL_FONTS.find(
(candidate) => candidate.value === DEFAULT_TERMINAL_CONFIG.fontFamily,
) ?? TERMINAL_FONTS[0];
ensureTerminalFontsLoaded(font.value);
terminal.options.theme = {
...resolveTermixThemeColors(DEFAULT_TERMINAL_CONFIG.theme, appTheme),
};
terminal.options.fontFamily = font.fallback;
terminal.options.fontSize = DEFAULT_TERMINAL_CONFIG.fontSize;
}, [terminal, appTheme]);
// ── WebSocket (Electron) path ──────────────────────────────────────────
@@ -20,7 +20,7 @@ describe("serial activate", () => {
await activate(mock.ctx);
expect(mock.wsRoutes).toEqual([{ path: "/console", raw: false }]);
expect(mock.wsRoutes).toMatchObject([{ path: "/console", raw: false }]);
});
it("refuses to mount without network:serve", async () => {
+2 -1
View File
@@ -19,7 +19,8 @@
"db:own",
"files:own",
"network:serve",
"ui:surface"
"ui:surface",
"events:core"
],
"provides": [
{
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"dependencies": {
@@ -201,11 +201,13 @@ export function registerRoomRoutes(
const requireUse = ctx.rbac.require("use") as never;
const audit = (
req: Request,
action: string,
room: { id: string; name: string },
details?: Record<string, unknown>,
) =>
ctx.audit.record({
request: req,
action,
resourceType: "collab_room",
resourceId: room.id,
@@ -270,7 +272,7 @@ export function registerRoomRoutes(
roomRole: "host",
addedBy: userId,
});
await audit("collab_room_create", room);
await audit(req, "collab_room_create", room);
res.json({ room: publicRoom(room) });
} catch (error) {
fail(res, "collab_room_create_error", "Failed to create room")(error);
@@ -449,7 +451,7 @@ export function registerRoomRoutes(
});
}
await audit("collab_room_invite", access.room, {
await audit(req, "collab_room_invite", access.room, {
memberCount: expanded.size,
});
hub.broadcast(roomId, { type: "collab_members_changed", roomId });
@@ -545,6 +547,7 @@ export function registerRoomRoutes(
}
await audit(
req,
targetId === userId
? "collab_room_leave"
: "collab_room_remove_member",
@@ -681,7 +684,7 @@ export function registerRoomRoutes(
shareId: share.id,
};
hub.broadcast(roomId, { type: "collab_stage_changed", roomId, stage });
await audit("collab_room_present", access.room, {
await audit(req, "collab_room_present", access.room, {
protocol,
hostId: numericHostId,
});
@@ -741,7 +744,7 @@ export function registerRoomRoutes(
roomId,
stage: null,
});
await audit("collab_room_stop_presenting", access.room);
await audit(req, "collab_room_stop_presenting", access.room);
res.json({ success: true });
} catch (error) {
fail(res, "collab_room_stop_error", "Failed to stop presenting")(error);
@@ -893,6 +896,7 @@ export function registerRoomRoutes(
});
}
await audit(
req,
targetId ? "collab_control_grant" : "collab_control_revoke",
access.room,
{ targetUserId: targetId },
@@ -969,7 +973,7 @@ export function registerRoomRoutes(
roomId,
...request,
});
await audit("collab_control_request", access.room);
await audit(req, "collab_control_request", access.room);
res.json({ request });
} catch (error) {
fail(
@@ -1076,6 +1080,7 @@ export function registerRoomRoutes(
roomId,
});
await audit(
req,
targetId === userId
? "collab_control_request_cancel"
: "collab_control_request_dismiss",
@@ -1171,6 +1176,7 @@ export function registerRoomRoutes(
}
await audit(
req,
enabled ? "collab_guest_link_enable" : "collab_guest_link_disable",
access.room,
);
@@ -1308,7 +1314,7 @@ export function registerRoomRoutes(
await rooms.endRoom(roomId);
hub.broadcast(roomId, { type: "collab_room_ended", roomId });
}
await audit("collab_room_end", access.room);
await audit(req, "collab_room_end", access.room);
res.json({ success: true });
} catch (error) {
fail(res, "collab_room_end_error", "Failed to end room")(error);
@@ -1358,7 +1364,7 @@ export function registerRoomRoutes(
await store.clearRequests(roomId);
hub.broadcast(roomId, { type: "collab_room_ended", roomId });
await rooms.deleteRoom(roomId);
await audit("collab_room_delete", access.room);
await audit(req, "collab_room_delete", access.room);
res.json({ success: true });
} catch (error) {
fail(res, "collab_room_delete_error", "Failed to delete room")(error);
@@ -207,6 +207,21 @@ export function activate(app: TermixApp): void {
});
}
// The tab bar's right-click menu, for a terminal whose toolbar is hidden.
app.registerSlotContribution("tab.menu", {
actionId: SHARE_ACTION,
titleKey: "sessionSharing.shareButton",
icon: Share2,
kind: "button",
when: (context) => {
const handle = context.handle as
{ getShareTarget?: () => unknown } | null | undefined;
return typeof handle?.getShareTarget === "function"
? handle.getShareTarget() !== null
: false;
},
});
app.registerSlotContribution("shell.overlay", {
actionId: "session-sharing.shareDialog",
titleKey: "sessionSharing.shareButton",
+2 -1
View File
@@ -19,7 +19,8 @@
"network:serve",
"ssh:connect",
"credentials:use",
"ui:surface"
"ui:surface",
"events:core"
],
"provides": [
{
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+4
View File
@@ -157,6 +157,10 @@
"label": "Command History",
"description": "Allow command history recording. When disabled, history is not saved regardless of per-host settings."
},
"commandHistoryForNewHosts": {
"label": "Command history for new hosts",
"description": "Whether new hosts start with command history on."
},
"imageStorageMode": {
"label": "Storage Mode",
"description": "Where pasted and uploaded terminal images are written. Auto picks by connection, Local and Remote (SFTP) are fixed.",
+12 -10
View File
@@ -135,6 +135,13 @@
"group": "settings.admin.group.sessions",
"default": true
},
{
"key": "commandHistoryForNewHosts",
"type": "boolean",
"labelKey": "settings.admin.commandHistoryForNewHosts.label",
"descriptionKey": "settings.admin.commandHistoryForNewHosts.description",
"default": true
},
{
"key": "touchInput",
"type": "custom",
@@ -208,20 +215,15 @@
}
],
"host": {
"enableKey": "enableTerminal",
"enableLabelKey": "settings.host.enableTerminal.label",
"enableDefault": true,
"fields": [
{
"key": "enableTerminal",
"type": "boolean",
"labelKey": "settings.host.enableTerminal.label",
"descriptionKey": "settings.host.enableTerminal.description",
"default": true
},
{
"key": "enableTerminalToolbar",
"type": "boolean",
"labelKey": "settings.host.enableTerminalToolbar.label",
"descriptionKey": "settings.host.enableTerminalToolbar.description",
"requires": "enableTerminal",
"default": true
},
{
@@ -229,8 +231,8 @@
"type": "boolean",
"labelKey": "settings.host.enableCommandHistory.label",
"descriptionKey": "settings.host.enableCommandHistory.description",
"requires": "enableTerminal",
"default": true
"default": true,
"defaultFrom": "commandHistoryForNewHosts"
}
]
}
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
@@ -22,6 +22,7 @@ import {
import { ADMIN_KEYS } from "./settings.js";
import { commandHistory } from "./tables.js";
import { createTerminalSocket } from "./terminal-socket.js";
import { validateAdminSettings } from "./settings-validation.js";
function toInfo(session: TerminalSession): LiveSessionInfo {
return {
@@ -55,6 +56,7 @@ function optionalService<T extends object>(
export async function activate(ctx: PluginContext) {
const log = createTerminalLogger(ctx.log);
ctx.settings.onValidate("admin", validateAdminSettings);
const table = await ctx.db.define(commandHistory);
const history = createHistoryRepository(ctx.db, table);
Loaded 100 of 335 files, more files were not shown because too many files have changed in this diff. Show more