mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
feat: move fleets into a first-party plugin
This commit is contained in:
1 parent
76b2e5739a
commit
a6f6a0439c
17 files changed
+281
-38
No files matched your search
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Fleets - first-party, in-process plugin.
|
||||||
|
*
|
||||||
|
* This plugin runs on the main thread rather than in a worker for the same
|
||||||
|
* reason proxmox and ai do (see src/backend/plugins/first-party.ts): its
|
||||||
|
* execute/transfer routes resolve and connect to many hosts concurrently over
|
||||||
|
* the shared SSH pool via resolveHostById and withConnection, read/write
|
||||||
|
* files over SFTP, and stream a zip archive back for pull requests. None of
|
||||||
|
* that fits the worker ctx -- ctx.hosts is a 13-field read-only view with no
|
||||||
|
* fleets surface at all, and ctx.http.route replies with a single postMessage
|
||||||
|
* value rather than a stream.
|
||||||
|
*
|
||||||
|
* Like proxmox, this plugin does not own a live transport (no WebSocket
|
||||||
|
* server, no dedicated port), so it registers its Express router with the
|
||||||
|
* always-mounted /fleets dispatcher in
|
||||||
|
* src/backend/database/routes/fleet-dispatch.ts instead of getting its own
|
||||||
|
* port.
|
||||||
|
*
|
||||||
|
* routes.ts is what used to be src/backend/database/routes/fleet-routes.ts,
|
||||||
|
* moved here and rewritten from `export default router` into
|
||||||
|
* startFleetsService()/stopFleetsService(), called from
|
||||||
|
* activate()/deactivate() below. The fleets/fleetMembers/fleetInventory
|
||||||
|
* tables and their repositories stay in core (src/backend/database/db/schema.ts,
|
||||||
|
* src/backend/database/repositories/fleet-repository.ts,
|
||||||
|
* fleet-inventory-repository.ts) -- this plugin system has no owned-table
|
||||||
|
* migration mechanism of its own, and these tables carry FKs into core's
|
||||||
|
* hosts/users tables.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a module by its path relative to this file's own directory.
|
||||||
|
*
|
||||||
|
* The plugin ships beside dist/backend, so in a built server the compiled
|
||||||
|
* .js for routes.js sits right next to this .mjs, and core modules resolve
|
||||||
|
* via ../../../backend/backend/... from there (dist/plugins/fleets/backend
|
||||||
|
* -> dist/backend/backend). Under vitest/dev nothing imports this plugin
|
||||||
|
* directly (the loader loads in-process plugins with a plain dynamic import,
|
||||||
|
* no tsx loader involved), so the only layout that needs to resolve here is
|
||||||
|
* the built one.
|
||||||
|
*/
|
||||||
|
async function loadRelative(relativePath) {
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const target = path.join(here, `${relativePath}.js`);
|
||||||
|
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
|
||||||
|
}
|
||||||
|
|
||||||
|
let routesModule = null;
|
||||||
|
|
||||||
|
export async function activate(ctx) {
|
||||||
|
routesModule = await loadRelative("routes");
|
||||||
|
routesModule.startFleetsService();
|
||||||
|
ctx.log.info("Fleets router registered at /fleets");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deactivate() {
|
||||||
|
if (routesModule) {
|
||||||
|
routesModule.stopFleetsService();
|
||||||
|
routesModule = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,44 +1,51 @@
|
|||||||
import { getErrorMessage } from "../../utils/error-message.js";
|
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
|
||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../src/types/index.js";
|
||||||
import express, { type Request, type Response } from "express";
|
import express, { type Request, type Response } from "express";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import type { Client, SFTPWrapper } from "ssh2";
|
import type { Client, SFTPWrapper } from "ssh2";
|
||||||
import { authLogger, databaseLogger } from "../../utils/logger.js";
|
import {
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
authLogger,
|
||||||
|
databaseLogger,
|
||||||
|
} from "../../../src/backend/utils/logger.js";
|
||||||
|
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
||||||
import {
|
import {
|
||||||
PermissionManager,
|
PermissionManager,
|
||||||
type HostAction,
|
type HostAction,
|
||||||
} from "../../utils/permission-manager.js";
|
} from "../../../src/backend/utils/permission-manager.js";
|
||||||
import {
|
import {
|
||||||
isSharePermissionLevel,
|
isSharePermissionLevel,
|
||||||
expiryFromDuration,
|
expiryFromDuration,
|
||||||
parseShareTargets,
|
parseShareTargets,
|
||||||
} from "./rbac.js";
|
} from "../../../src/backend/database/routes/rbac.js";
|
||||||
import {
|
import {
|
||||||
createCurrentFleetRepository,
|
createCurrentFleetRepository,
|
||||||
createCurrentFleetInventoryRepository,
|
createCurrentFleetInventoryRepository,
|
||||||
createCurrentRbacAccessRepository,
|
createCurrentRbacAccessRepository,
|
||||||
createCurrentRoleRepository,
|
createCurrentRoleRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../../../src/backend/database/repositories/factory.js";
|
||||||
import { resolveHostById } from "../../hosts/host-resolver.js";
|
import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js";
|
||||||
import {
|
import {
|
||||||
getFleetPoolKey,
|
getFleetPoolKey,
|
||||||
createFleetSshFactory,
|
createFleetSshFactory,
|
||||||
} from "../../hosts/ssh-client-factory.js";
|
} from "../../../src/backend/hosts/ssh-client-factory.js";
|
||||||
import { withConnection } from "../../hosts/ssh-connection-pool.js";
|
import { withConnection } from "../../../src/backend/hosts/ssh-connection-pool.js";
|
||||||
import { execCommand } from "../../hosts/metrics-shared/common-utils.js";
|
import { execCommand } from "../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||||
import { detectPlatform } from "../../hosts/metrics-shared/platform.js";
|
import { detectPlatform } from "../../../src/backend/hosts/metrics-shared/platform.js";
|
||||||
import {
|
import {
|
||||||
execElevated,
|
execElevated,
|
||||||
ElevationError,
|
ElevationError,
|
||||||
} from "../../hosts/metrics-shared/exec-elevated.js";
|
} from "../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||||
import { buildPackageActionCommand } from "../../hosts/metrics-shared/package-commands.js";
|
import { buildPackageActionCommand } from "../../../src/backend/hosts/metrics-shared/package-commands.js";
|
||||||
import { isValidPackageName } from "../../hosts/metrics-shared/validation.js";
|
import { isValidPackageName } from "../../../src/backend/hosts/metrics-shared/validation.js";
|
||||||
import { resolveSnippetCommand } from "./snippets-execution.js";
|
import { resolveSnippetCommand } from "../../../src/backend/database/routes/snippets-execution.js";
|
||||||
|
import {
|
||||||
|
registerFleetsRouter,
|
||||||
|
unregisterFleetsRouter,
|
||||||
|
} from "../../../src/backend/database/routes/fleet-dispatch.js";
|
||||||
|
|
||||||
const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const permissionManager = PermissionManager.getInstance();
|
const permissionManager = PermissionManager.getInstance();
|
||||||
@@ -622,7 +629,7 @@ router.post(
|
|||||||
const expiresAt = expiryFromDuration(durationHours);
|
const expiresAt = expiryFromDuration(durationHours);
|
||||||
const rbacAccessRepository = createCurrentRbacAccessRepository();
|
const rbacAccessRepository = createCurrentRbacAccessRepository();
|
||||||
const { SharedHostSecretsManager } =
|
const { SharedHostSecretsManager } =
|
||||||
await import("../../utils/shared-host-secrets-manager.js");
|
await import("../../../src/backend/utils/shared-host-secrets-manager.js");
|
||||||
const secretsManager = SharedHostSecretsManager.getInstance();
|
const secretsManager = SharedHostSecretsManager.getInstance();
|
||||||
|
|
||||||
const hostResults: Array<{
|
const hostResults: Array<{
|
||||||
@@ -1496,4 +1503,13 @@ export function buildRemoveCommand(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default router;
|
/** Called from activate(). Mounts this router at /fleets via the shared
|
||||||
|
* dispatcher. */
|
||||||
|
export function startFleetsService(): void {
|
||||||
|
registerFleetsRouter(router);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called from deactivate(). /fleets/* falls back to 404 until reactivated. */
|
||||||
|
export function stopFleetsService(): void {
|
||||||
|
unregisterFleetsRouter();
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { getErrorMessage } from "../lib/error-message.js";
|
import { getErrorMessage } from "@/lib/error-message.js";
|
||||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getErrorMessage } from "../lib/error-message.js";
|
import { getErrorMessage } from "@/lib/error-message.js";
|
||||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Frontend half of the fleets plugin.
|
||||||
|
*
|
||||||
|
* FleetsPanel is a left-sidebar railView panel (opened from the rail, not a
|
||||||
|
* Tab), the same shape as HostsPanel/CredentialsPanel/AiPanel. None of those
|
||||||
|
* go through registerRailItem/registerTabComponent either -- per
|
||||||
|
* src/ui/shell/pluginLoader.ts's own comment, a rail-view panel like this is
|
||||||
|
* rendered directly by AppShell.tsx rather than registered at runtime, the
|
||||||
|
* same way ai's AiPanel is. FleetsPanel is reached today through a direct
|
||||||
|
* relative import in AppShell.tsx (../../plugins/fleets/frontend/FleetsPanel),
|
||||||
|
* not through register() below.
|
||||||
|
*
|
||||||
|
* register()/unregister() still follow the same shape every other plugin's
|
||||||
|
* frontend entry does, so this is ready the moment a real frontend plugin
|
||||||
|
* loader exists -- see plugins/ssh-terminal/frontend/index.mjs and
|
||||||
|
* src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright
|
||||||
|
* "there is no plugin loader yet".
|
||||||
|
*
|
||||||
|
* The fleet-inventory tab (opened from within FleetsPanel) is wired
|
||||||
|
* separately in src/ui/shell/tabUtils.tsx, the same way docker/host-metrics
|
||||||
|
* tabs are -- a plugin tab id that already exists in the built-in TabType
|
||||||
|
* union goes through the ordinary tabSurfaceLoaders/switch wiring there, not
|
||||||
|
* through registerTabComponent (which is for tab ids outside that union).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const id = "fleets";
|
||||||
|
export const railViewId = "fleets";
|
||||||
|
|
||||||
|
export async function register({ registerRailItem, icons }) {
|
||||||
|
registerRailItem?.({
|
||||||
|
id: railViewId,
|
||||||
|
icon: icons.Boxes,
|
||||||
|
labelKey: "nav.fleets",
|
||||||
|
separatorAfter: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unregister({ unregisterRailItem }) {
|
||||||
|
unregisterRailItem?.(railViewId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"id": "fleets",
|
||||||
|
"name": "Fleets",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Group hosts into fleets by static membership or tag rules, and run commands, package actions, and file transfers across every member at once.",
|
||||||
|
"author": { "name": "Termix" },
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": "https://github.com/Termix-SSH/Termix",
|
||||||
|
"category": "Infrastructure",
|
||||||
|
"icon": "Boxes",
|
||||||
|
"engine": { "termix": ">=2.9.0", "api": "1" },
|
||||||
|
"capabilities": {
|
||||||
|
"backend": true,
|
||||||
|
"frontend": true,
|
||||||
|
"electron": true,
|
||||||
|
"platforms": ["linux", "win32", "darwin"]
|
||||||
|
},
|
||||||
|
"permissions": ["process:transport-owner"],
|
||||||
|
"sidecars": []
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import hostRoutes from "./routes/host.js";
|
|||||||
import alertRoutes from "./routes/alerts.js";
|
import alertRoutes from "./routes/alerts.js";
|
||||||
import credentialsRoutes from "./routes/credentials.js";
|
import credentialsRoutes from "./routes/credentials.js";
|
||||||
import snippetsRoutes from "./routes/snippets.js";
|
import snippetsRoutes from "./routes/snippets.js";
|
||||||
import fleetRoutes from "./routes/fleet-routes.js";
|
import fleetsDispatch from "./routes/fleet-dispatch.js";
|
||||||
import workspaceRoutes from "./routes/workspaces.js";
|
import workspaceRoutes from "./routes/workspaces.js";
|
||||||
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
||||||
import terminalRoutes from "./routes/terminal.js";
|
import terminalRoutes from "./routes/terminal.js";
|
||||||
@@ -1753,7 +1753,7 @@ app.use("/host", hostRoutes);
|
|||||||
app.use("/alerts", alertRoutes);
|
app.use("/alerts", alertRoutes);
|
||||||
app.use("/credentials", credentialsRoutes);
|
app.use("/credentials", credentialsRoutes);
|
||||||
app.use("/snippets", snippetsRoutes);
|
app.use("/snippets", snippetsRoutes);
|
||||||
app.use("/fleets", fleetRoutes);
|
app.use("/fleets", fleetsDispatch);
|
||||||
app.use("/workspaces", workspaceRoutes);
|
app.use("/workspaces", workspaceRoutes);
|
||||||
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
||||||
app.use("/terminal", terminalRoutes);
|
app.use("/terminal", terminalRoutes);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Mounted at /fleets. Fleets ships as a first-party in-process plugin
|
||||||
|
// (plugins/fleets) rather than a route file imported directly here, so this
|
||||||
|
// module exists to keep /fleets/* working at its original path without
|
||||||
|
// handing the plugin a reference to the shared app instance. The plugin
|
||||||
|
// runtime registers its router via registerFleetsRouter when it activates,
|
||||||
|
// and clears it on deactivate, so a disabled plugin falls back to the 404
|
||||||
|
// below.
|
||||||
|
//
|
||||||
|
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
||||||
|
// to exactly one first-party router (the Fleets plugin's own), and every
|
||||||
|
// route in that router already applies authenticateJWT/requireDataAccess
|
||||||
|
// itself, the same as every other router mounted alongside it here (/users,
|
||||||
|
// /host, /snippets, ...). /plugin-api gates at the mount point because it
|
||||||
|
// forwards to arbitrary third-party plugin code that cannot be assumed to
|
||||||
|
// gate itself; that reasoning does not apply to a single first-party router
|
||||||
|
// that already does.
|
||||||
|
|
||||||
|
import type { Request, Response, NextFunction, Router } from "express";
|
||||||
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
|
|
||||||
|
let activeRouter: Router | null = null;
|
||||||
|
|
||||||
|
export function registerFleetsRouter(router: Router): void {
|
||||||
|
activeRouter = router;
|
||||||
|
databaseLogger.info("Registered Fleets plugin router", {
|
||||||
|
operation: "fleets_dispatch_register",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterFleetsRouter(): void {
|
||||||
|
if (!activeRouter) return;
|
||||||
|
activeRouter = null;
|
||||||
|
databaseLogger.info("Unregistered Fleets plugin router", {
|
||||||
|
operation: "fleets_dispatch_register",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function fleetsDispatch(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
): void {
|
||||||
|
if (!activeRouter) {
|
||||||
|
res.status(404).json({ error: "Fleets is not available" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeRouter(req, res, next);
|
||||||
|
}
|
||||||
@@ -68,6 +68,13 @@
|
|||||||
* /connect-host/:hostId route does multi-step credential/tunnel resolution
|
* /connect-host/:hostId route does multi-step credential/tunnel resolution
|
||||||
* (SSH jump-host tunnels, a macOS VNC compatibility proxy) using raw
|
* (SSH jump-host tunnels, a macOS VNC compatibility proxy) using raw
|
||||||
* net.Socket plumbing that cannot cross a structured-clone boundary.
|
* net.Socket plumbing that cannot cross a structured-clone boundary.
|
||||||
|
*
|
||||||
|
* fleets is here for the same reason as ai and proxmox: it does not own a
|
||||||
|
* transport, but its execute/transfer routes resolve and connect to many
|
||||||
|
* hosts concurrently via resolveHostById and the shared SSH pool, stream SFTP
|
||||||
|
* reads/writes and a zip archive for its push/pull routes, and buffer
|
||||||
|
* multipart file uploads. None of that fits the worker's single-exec
|
||||||
|
* ctx.ssh or single-postMessage-reply ctx.http.route.
|
||||||
*/
|
*/
|
||||||
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||||
"ssh-terminal",
|
"ssh-terminal",
|
||||||
@@ -76,6 +83,7 @@ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
|||||||
"ai",
|
"ai",
|
||||||
"proxmox",
|
"proxmox",
|
||||||
"remote-desktop",
|
"remote-desktop",
|
||||||
|
"fleets",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
|
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
|
||||||
|
|||||||
@@ -299,11 +299,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
|||||||
// ssh-terminal, docker and host-metrics plugins start their own servers,
|
// ssh-terminal, docker and host-metrics plugins start their own servers,
|
||||||
// so disabling any of them stops its WS/HTTP server. See
|
// so disabling any of them stops its WS/HTTP server. See
|
||||||
// plugins/ssh-terminal, plugins/docker and plugins/host-metrics.
|
// plugins/ssh-terminal, plugins/docker and plugins/host-metrics.
|
||||||
// AI, Proxmox and Remote Desktop are also absent for the same reason:
|
// AI, Proxmox, Remote Desktop and Fleets are also absent for the same
|
||||||
// each plugin registers its router with its own dispatcher in
|
// reason: each plugin registers its router with its own dispatcher in
|
||||||
// database.ts on activate (/ai, /proxmox, /guacamole), so disabling any
|
// database.ts on activate (/ai, /proxmox, /guacamole, /fleets), so
|
||||||
// of them makes its routes 404 instead of leaving a dead import here.
|
// disabling any of them makes its routes 404 instead of leaving a dead
|
||||||
// See plugins/ai, plugins/proxmox and plugins/remote-desktop.
|
// import here. See plugins/ai, plugins/proxmox, plugins/remote-desktop
|
||||||
|
// and plugins/fleets.
|
||||||
await import("./hosts/tunnel/index.js");
|
await import("./hosts/tunnel/index.js");
|
||||||
await import("./hosts/file-manager/index.js");
|
await import("./hosts/file-manager/index.js");
|
||||||
await import("./hosts/tmux/index.js");
|
await import("./hosts/tmux/index.js");
|
||||||
|
|||||||
@@ -244,3 +244,42 @@ describe("bundled remote-desktop plugin", () => {
|
|||||||
expect(source).toMatch(/export async function deactivate/);
|
expect(source).toMatch(/export async function deactivate/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("bundled fleets plugin", () => {
|
||||||
|
it("ships a directory with a manifest and both entry points", () => {
|
||||||
|
const dir = path.join(getBundledPluginsDir(), "fleets");
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has a manifest that passes the real validator", () => {
|
||||||
|
const { manifest, errors } = parseManifest(readBundledManifest("fleets"));
|
||||||
|
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
expect(manifest?.id).toBe("fleets");
|
||||||
|
expect(manifest?.category).toBe("Infrastructure");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares the transport-owner capability and qualifies for the tier", () => {
|
||||||
|
const { manifest } = parseManifest(readBundledManifest("fleets"));
|
||||||
|
|
||||||
|
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
|
||||||
|
expect(isFirstParty("fleets")).toBe(true);
|
||||||
|
expect(runsInProcess("fleets", manifest!.permissions)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports activate and deactivate from its backend entry", () => {
|
||||||
|
const entry = path.join(
|
||||||
|
getBundledPluginsDir(),
|
||||||
|
"fleets",
|
||||||
|
"backend",
|
||||||
|
"index.mjs",
|
||||||
|
);
|
||||||
|
const source = fs.readFileSync(entry, "utf8");
|
||||||
|
|
||||||
|
expect(source).toMatch(/export async function activate/);
|
||||||
|
expect(source).toMatch(/export async function deactivate/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,10 +39,11 @@ function manifest(overrides: Record<string, unknown> = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("first-party allowlist", () => {
|
describe("first-party allowlist", () => {
|
||||||
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox and remote-desktop", () => {
|
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop and fleets", () => {
|
||||||
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
|
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
|
||||||
"ai",
|
"ai",
|
||||||
"docker",
|
"docker",
|
||||||
|
"fleets",
|
||||||
"host-metrics",
|
"host-metrics",
|
||||||
"proxmox",
|
"proxmox",
|
||||||
"remote-desktop",
|
"remote-desktop",
|
||||||
@@ -50,13 +51,14 @@ describe("first-party allowlist", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox and remote-desktop and nothing else", () => {
|
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop and fleets and nothing else", () => {
|
||||||
expect(isFirstParty("ssh-terminal")).toBe(true);
|
expect(isFirstParty("ssh-terminal")).toBe(true);
|
||||||
expect(isFirstParty("docker")).toBe(true);
|
expect(isFirstParty("docker")).toBe(true);
|
||||||
expect(isFirstParty("host-metrics")).toBe(true);
|
expect(isFirstParty("host-metrics")).toBe(true);
|
||||||
expect(isFirstParty("ai")).toBe(true);
|
expect(isFirstParty("ai")).toBe(true);
|
||||||
expect(isFirstParty("proxmox")).toBe(true);
|
expect(isFirstParty("proxmox")).toBe(true);
|
||||||
expect(isFirstParty("remote-desktop")).toBe(true);
|
expect(isFirstParty("remote-desktop")).toBe(true);
|
||||||
|
expect(isFirstParty("fleets")).toBe(true);
|
||||||
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
|
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
|
||||||
expect(isFirstParty("community-plugin")).toBe(false);
|
expect(isFirstParty("community-plugin")).toBe(false);
|
||||||
expect(isFirstParty("")).toBe(false);
|
expect(isFirstParty("")).toBe(false);
|
||||||
|
|||||||
+7
-5
@@ -135,11 +135,13 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
|||||||
createCurrentUserRepository: () => ({}),
|
createCurrentUserRepository: () => ({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
vi.mock("../../../database/routes/fleet-dispatch.js", () => ({
|
||||||
default: router,
|
registerFleetsRouter: vi.fn(),
|
||||||
parseInventoryProbe,
|
unregisterFleetsRouter: vi.fn(),
|
||||||
buildRemoveCommand,
|
}));
|
||||||
} = await import("../../../database/routes/fleet-routes.js");
|
|
||||||
|
const { router, parseInventoryProbe, buildRemoveCommand } =
|
||||||
|
await import("../../../../../plugins/fleets/backend/routes.js");
|
||||||
|
|
||||||
function findLayer(method: string, path: string) {
|
function findLayer(method: string, path: string) {
|
||||||
const stack = (router as unknown as Router).stack as Array<{
|
const stack = (router as unknown as Router).stack as Array<{
|
||||||
+3
-1
@@ -92,7 +92,9 @@ const MacrosPanel = lazy(() =>
|
|||||||
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
|
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
|
||||||
);
|
);
|
||||||
const FleetsPanel = lazy(() =>
|
const FleetsPanel = lazy(() =>
|
||||||
import("@/sidebar/FleetsPanel").then((m) => ({ default: m.FleetsPanel })),
|
import("../../plugins/fleets/frontend/FleetsPanel").then((m) => ({
|
||||||
|
default: m.FleetsPanel,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
const WorkspacesPanel = lazy(() =>
|
const WorkspacesPanel = lazy(() =>
|
||||||
import("@/sidebar/WorkspacesPanel").then((m) => ({
|
import("@/sidebar/WorkspacesPanel").then((m) => ({
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const BUILT_IN_TABS_BY_PLUGIN: Record<string, string[]> = {
|
|||||||
"ssh-terminal": ["terminal"],
|
"ssh-terminal": ["terminal"],
|
||||||
"remote-desktop": ["rdp", "vnc", "telnet"],
|
"remote-desktop": ["rdp", "vnc", "telnet"],
|
||||||
ai: ["ai"],
|
ai: ["ai"],
|
||||||
|
fleets: ["fleet-inventory"],
|
||||||
};
|
};
|
||||||
|
|
||||||
let pluginEnabled = new Map<string, boolean>();
|
let pluginEnabled = new Map<string, boolean>();
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ const Serial = lazy(() =>
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const FleetInventoryTab = lazy(() =>
|
const FleetInventoryTab = lazy(() =>
|
||||||
import("@/sidebar/FleetInventoryTab").then((m) => ({
|
import("../../../plugins/fleets/frontend/FleetInventoryTab").then((m) => ({
|
||||||
default: m.FleetInventoryTab,
|
default: m.FleetInventoryTab,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,7 +31,8 @@
|
|||||||
"plugins/host-metrics/backend/**/*.ts",
|
"plugins/host-metrics/backend/**/*.ts",
|
||||||
"plugins/ai/backend/**/*.ts",
|
"plugins/ai/backend/**/*.ts",
|
||||||
"plugins/proxmox/backend/**/*.ts",
|
"plugins/proxmox/backend/**/*.ts",
|
||||||
"plugins/remote-desktop/backend/**/*.ts"
|
"plugins/remote-desktop/backend/**/*.ts",
|
||||||
|
"plugins/fleets/backend/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["src/**/*.test.ts"]
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user