mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
fix: finish 2.9.0 plugin moves and get every check green
This commit is contained in:
1 parent
8c1e02af89
commit
6489b62ece
335 files changed
+29878
-17749
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>;
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -116,7 +116,10 @@
|
||||
"descriptionKey": "permissions.run.description",
|
||||
"defaultRoles": ["user"]
|
||||
}
|
||||
]
|
||||
],
|
||||
"http": {
|
||||
"legacyPaths": ["/automations/webhook"]
|
||||
}
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"],
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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] =
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
}
|
||||
@@ -419,7 +419,8 @@
|
||||
"type": "json",
|
||||
"labelKey": "settings.host.guacamoleConfig.label",
|
||||
"default": {},
|
||||
"hidden": true
|
||||
"hidden": true,
|
||||
"shareRead": "edit"
|
||||
},
|
||||
{
|
||||
"key": "enableToolbar",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"db:own",
|
||||
"files:own",
|
||||
"network:serve",
|
||||
"ui:surface"
|
||||
"ui:surface",
|
||||
"events:core"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"network:serve",
|
||||
"ssh:connect",
|
||||
"credentials:use",
|
||||
"ui:surface"
|
||||
"ui:surface",
|
||||
"events:core"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
Reference in new issue
Block a user