mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-10 05:42:49 +00:00
feat: ship docker as a first-party in-process plugin
Moves docker backend and frontend into plugins/docker/, alongside ssh-terminal. Adds a host editor tab seam, a dashboard card registry, and a build step to compile plugin backends into dist.
This commit is contained in:
1 parent
c94a453f1e
commit
a2479c19b0
34 files changed
+729
-143
No files matched your search
@@ -13,6 +13,14 @@ dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# tsc -p tsconfig.plugins.json emits plugin backend .js next to its .ts
|
||||
# source. It also stray-emits sibling .js next to every core .ts the plugin
|
||||
# imports for type-checking (src/types, src/backend/utils, repositories...);
|
||||
# scripts/copy-bundled-plugins.cjs removes those after every compile, this
|
||||
# is a safety net for running tsc -p tsconfig.plugins.json directly.
|
||||
/plugins/*/backend/*.js
|
||||
/src/**/*.js
|
||||
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
.idea
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
// Core imports below point at TypeScript source so tsc can type-check them.
|
||||
// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled
|
||||
// output path after tsc -p tsconfig.plugins.json runs -- see that script.
|
||||
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
|
||||
import { StringDecoder } from "string_decoder";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
||||
import { SSH_ALGORITHMS } from "../../../src/backend/utils/ssh-algorithms.js";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
|
||||
import { systemLogger } from "../../utils/logger.js";
|
||||
import type { SSHHost } from "../../../types/index.js";
|
||||
import { applyAgentAuth } from "../terminal-auth-helpers.js";
|
||||
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
||||
import { createCurrentHostResolutionRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||
import { systemLogger } from "../../../src/backend/utils/logger.js";
|
||||
import type { SSHHost } from "../../../src/types/index.js";
|
||||
import { applyAgentAuth } from "../../../src/backend/hosts/terminal-auth-helpers.js";
|
||||
import {
|
||||
containerCommand,
|
||||
getContainerRuntimeConfig,
|
||||
type ContainerRuntime,
|
||||
} from "./container-runtime.js";
|
||||
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
|
||||
import { resolveSshConnectConfigHost } from "../../../src/backend/hosts/ssh-dns.js";
|
||||
import {
|
||||
hostAddressMismatch,
|
||||
HOST_ADDRESS_MISMATCH_MESSAGE,
|
||||
HOST_NOT_ON_THIS_SERVER_MESSAGE,
|
||||
} from "../host-identity.js";
|
||||
import { extractWebSocketToken } from "../../utils/ws-auth.js";
|
||||
} from "../../../src/backend/hosts/host-identity.js";
|
||||
import { extractWebSocketToken } from "../../../src/backend/utils/ws-auth.js";
|
||||
import {
|
||||
asObject,
|
||||
asString,
|
||||
MAX_WS_MESSAGE_BYTES,
|
||||
parseWsMessage,
|
||||
toTerminalDimension,
|
||||
} from "../../utils/ws-message.js";
|
||||
} from "../../../src/backend/utils/ws-message.js";
|
||||
|
||||
const sshLogger = systemLogger;
|
||||
|
||||
@@ -404,7 +407,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
try {
|
||||
// Resolve host with credentials server-side
|
||||
const { resolveHostById, resolveHostBySyncId } =
|
||||
await import("../host-resolver.js");
|
||||
await import("../../../src/backend/hosts/host-resolver.js");
|
||||
// syncId names the host on both sides of a sync pair; the numeric
|
||||
// id only names it in the database the client is displaying.
|
||||
const hostSyncId = hostConfig?.syncId;
|
||||
@@ -823,7 +826,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
});
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
/**
|
||||
* Closes every live console session and the WebSocket server. Called by the
|
||||
* plugin's deactivate() as well as SIGTERM, since disabling the docker
|
||||
* plugin has to free port 30009 the same way process shutdown does.
|
||||
*/
|
||||
export function closeConsoleServer(): Promise<void> {
|
||||
activeSessions.forEach((session) => {
|
||||
if (session.stream) {
|
||||
session.stream.end();
|
||||
@@ -833,7 +841,9 @@ process.on("SIGTERM", () => {
|
||||
|
||||
activeSessions.clear();
|
||||
|
||||
wss.close(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
return new Promise((resolve) => wss.close(() => resolve()));
|
||||
}
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
void closeConsoleServer().then(() => process.exit(0));
|
||||
});
|
||||
+5
-2
@@ -1,6 +1,9 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
// Core imports below point at TypeScript source so tsc can type-check them.
|
||||
// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled
|
||||
// output path after tsc -p tsconfig.plugins.json runs -- see that script.
|
||||
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
|
||||
import type express from "express";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
import { logger } from "../../../src/backend/utils/logger.js";
|
||||
import {
|
||||
containerCommand,
|
||||
type ContainerRuntime,
|
||||
File renamed without changes.
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Docker - first-party, in-process plugin.
|
||||
*
|
||||
* This plugin runs on the main thread rather than in a worker for the same
|
||||
* reason ssh-terminal does (see src/backend/plugins/first-party.ts): its
|
||||
* container-console WebSocket server holds long-lived ssh2.Client + PTY
|
||||
* streams, its session-manager reuses a live ssh2.Client across many REST
|
||||
* calls, and its SSH connect flow implements TOTP/Warpgate as a genuinely
|
||||
* stateful multi-request handshake. None of that can cross the
|
||||
* structured-clone postMessage boundary a worker plugin talks over.
|
||||
*
|
||||
* Unlike ssh-terminal, Docker's backend has no consumers outside itself, so
|
||||
* the whole implementation moved here rather than staying in core with only
|
||||
* a lifecycle wrapper. This file is what used to be
|
||||
* src/backend/hosts/docker/index.ts, rewritten from a module-scope
|
||||
* SIGINT/SIGTERM + listenOnServicePort() call into activate()/deactivate().
|
||||
*
|
||||
* The moved files (routes.ts, container-routes.ts, container-runtime.ts,
|
||||
* session-manager.ts, console.ts) are real TypeScript, compiled by
|
||||
* scripts/copy-bundled-plugins.cjs (see that script for why plugins/ needs
|
||||
* its own compile step) rather than hand-written like ssh-terminal's .mjs
|
||||
* wrapper. This file itself stays plain JS since it is a thin entry point
|
||||
* with no types worth checking.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
/**
|
||||
* 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 the moved docker files (routes.js, session-manager.js, ...) sits
|
||||
* right next to this .mjs, and core modules resolve via
|
||||
* ../../../backend/backend/... from there (dist/plugins/docker/backend ->
|
||||
* dist/backend/backend). Under vitest/dev nothing imports this plugin
|
||||
* directly (src/backend/plugins/loader.ts 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 httpServer = null;
|
||||
let consoleModule = null;
|
||||
let authManagerInstance = null;
|
||||
|
||||
export async function activate(ctx) {
|
||||
const [
|
||||
express,
|
||||
cookieParser,
|
||||
{ createCorsMiddleware },
|
||||
{ createCompressionMiddleware },
|
||||
{ logger },
|
||||
{ AuthManager },
|
||||
{ registerDockerContainerRoutes },
|
||||
sessionManager,
|
||||
{ DOCKER_TIMESTAMP_RE, getRequestUserId, registerDockerSshRoutes },
|
||||
] = await Promise.all([
|
||||
import("express").then((m) => m.default),
|
||||
import("cookie-parser").then((m) => m.default),
|
||||
loadRelative("../../../backend/backend/utils/cors-config"),
|
||||
loadRelative("../../../backend/backend/utils/compression-config"),
|
||||
loadRelative("../../../backend/backend/utils/logger"),
|
||||
loadRelative("../../../backend/backend/utils/auth-manager"),
|
||||
loadRelative("container-routes"),
|
||||
loadRelative("session-manager"),
|
||||
loadRelative("routes"),
|
||||
]);
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", "loopback");
|
||||
|
||||
app.use(createCompressionMiddleware());
|
||||
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
||||
|
||||
app.use(cookieParser());
|
||||
authManagerInstance = AuthManager.getInstance();
|
||||
app.use(authManagerInstance.createAuthMiddleware());
|
||||
app.use(express.json({ limit: "100mb" }));
|
||||
app.use(express.urlencoded({ limit: "100mb", extended: true }));
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
|
||||
registerDockerSshRoutes(app);
|
||||
registerDockerContainerRoutes(app, {
|
||||
sshSessions: sessionManager.sshSessions,
|
||||
pendingTOTPSessions: sessionManager.pendingTOTPSessions,
|
||||
getRequestUserId,
|
||||
executeDockerCommand: sessionManager.executeDockerCommand,
|
||||
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
|
||||
});
|
||||
|
||||
const port = 30007;
|
||||
const server = createServer(app);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
httpServer = server;
|
||||
|
||||
try {
|
||||
await authManagerInstance.initialize();
|
||||
} catch (err) {
|
||||
logger.error("Failed to initialize Docker backend", err, {
|
||||
operation: "startup",
|
||||
});
|
||||
}
|
||||
|
||||
// Importing console.js binds its WebSocket server on port 30009 as a
|
||||
// module-scope side effect -- unchanged from before the move, see that
|
||||
// file's own header.
|
||||
consoleModule = await loadRelative("console");
|
||||
|
||||
ctx.log.info(`Docker plugin listening on ${port} (console on 30009)`);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
if (consoleModule?.closeConsoleServer) {
|
||||
await consoleModule.closeConsoleServer();
|
||||
}
|
||||
consoleModule = null;
|
||||
|
||||
if (httpServer) {
|
||||
const server = httpServer;
|
||||
httpServer = null;
|
||||
await new Promise((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
|
||||
authManagerInstance = null;
|
||||
}
|
||||
@@ -1,42 +1,45 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import { usesIssuedCertificate } from "../issued-certificate-auth.js";
|
||||
// Core imports below point at TypeScript source so tsc can type-check them.
|
||||
// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled
|
||||
// output path after tsc -p tsconfig.plugins.json runs -- see that script.
|
||||
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
|
||||
import { usesIssuedCertificate } from "../../../src/backend/hosts/issued-certificate-auth.js";
|
||||
import express from "express";
|
||||
import axios from "axios";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
import { logger } from "../../../src/backend/utils/logger.js";
|
||||
import {
|
||||
logAudit,
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
} from "../../utils/audit-logger.js";
|
||||
import { createCurrentHostRepository } from "../../database/repositories/factory.js";
|
||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||
import { resolveHostById } from "../host-resolver.js";
|
||||
import { createConnectionLog } from "../connection-log.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import {
|
||||
type AuthenticatedRequest,
|
||||
type ProxyNode,
|
||||
type SSHHost,
|
||||
} from "../../../types/index.js";
|
||||
} from "../../../src/backend/utils/audit-logger.js";
|
||||
import { createCurrentHostRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||
import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js";
|
||||
import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js";
|
||||
import { createConnectionLog } from "../../../src/backend/hosts/connection-log.js";
|
||||
import { DataCrypto } from "../../../src/backend/utils/data-crypto.js";
|
||||
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
||||
import type {
|
||||
AuthenticatedRequest,
|
||||
ProxyNode,
|
||||
SSHHost,
|
||||
} from "../../../src/types/index.js";
|
||||
import {
|
||||
createSocks5Connection,
|
||||
type SOCKS5Config,
|
||||
} from "../../utils/socks5-helper.js";
|
||||
} from "../../../src/backend/utils/socks5-helper.js";
|
||||
import type {
|
||||
LogEntry,
|
||||
ConnectionStage,
|
||||
} from "../../../types/connection-log.js";
|
||||
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
|
||||
import { applyAgentAuth } from "../terminal-auth-helpers.js";
|
||||
} from "../../../src/types/connection-log.js";
|
||||
import { SSHHostKeyVerifier } from "../../../src/backend/hosts/host-key-verifier.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../../../src/backend/utils/ssh-key-utils.js";
|
||||
import { applyAgentAuth } from "../../../src/backend/hosts/terminal-auth-helpers.js";
|
||||
import {
|
||||
containerCommand,
|
||||
getContainerRuntimeConfig,
|
||||
getRuntimeLabel,
|
||||
} from "./container-runtime.js";
|
||||
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
|
||||
import { resolveSshConnectConfigHost } from "../../../src/backend/hosts/ssh-dns.js";
|
||||
import {
|
||||
type SSHSession,
|
||||
sshSessions,
|
||||
@@ -303,7 +306,8 @@ export function registerDockerSshRoutes(app: express.Express): void {
|
||||
}
|
||||
} else if (usesIssuedCertificate(resolvedCredentials.authType)) {
|
||||
try {
|
||||
const { getOPKSSHToken } = await import("../opkssh-auth.js");
|
||||
const { getOPKSSHToken } =
|
||||
await import("../../../src/backend/hosts/opkssh-auth.js");
|
||||
const token = await getOPKSSHToken(userId, hostId);
|
||||
|
||||
if (!token) {
|
||||
@@ -323,7 +327,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
|
||||
}
|
||||
|
||||
const { setupOPKSSHCertAuth } =
|
||||
await import("../opkssh-cert-auth.js");
|
||||
await import("../../../src/backend/hosts/opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(
|
||||
config as import("ssh2").ConnectConfig,
|
||||
client,
|
||||
+4
-1
@@ -1,5 +1,8 @@
|
||||
// Core imports below point at TypeScript source so tsc can type-check them.
|
||||
// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled
|
||||
// output path after tsc -p tsconfig.plugins.json runs -- see that script.
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
import { logger } from "../../../src/backend/utils/logger.js";
|
||||
import type { ContainerRuntime } from "./container-runtime.js";
|
||||
|
||||
const sshLogger = logger;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DockerManager } from "@/features/docker/DockerManager.tsx";
|
||||
import { DockerManager } from "./DockerManager.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
|
||||
File renamed without changes.
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { getErrorMessage } from "../../../lib/error-message.js";
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import React from "react";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { getErrorMessage } from "../../../lib/error-message.js";
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import React from "react";
|
||||
import { Card } from "@/components/card.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
File renamed without changes.
File renamed without changes.
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { getErrorMessage } from "../../../lib/error-message.js";
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import React from "react";
|
||||
import {
|
||||
Activity,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { getErrorMessage } from "../../../lib/error-message.js";
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import React from "react";
|
||||
import { Box, List, Play, RefreshCw, Square, Terminal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { getErrorMessage } from "../../../lib/error-message.js";
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Frontend half of the docker plugin.
|
||||
*
|
||||
* Registers the container-management tab through the existing extension
|
||||
* seams (registerRailItem / registerTabComponent) the same way
|
||||
* ssh-terminal's frontend does, and the host-editor Docker tab through the
|
||||
* new registerHostEditorTab seam built for this plugin.
|
||||
*
|
||||
* HostDockerTab itself stays in src/ui/sidebar/HostEditorFeatureTabs.tsx
|
||||
* rather than moving into this plugin: it takes the same {form, setField}
|
||||
* pair every other host editor tab does, and those types (HostEditorForm,
|
||||
* the setField signature) are core types HostEditor.tsx owns, the same
|
||||
* reason ssh-terminal leaves session-manager.ts in core rather than copying
|
||||
* it here.
|
||||
*
|
||||
* register() is called when the plugin is enabled, unregister() when it is
|
||||
* disabled. As of this change neither is actually invoked anywhere yet --
|
||||
* 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" for the frontend half. This file follows
|
||||
* the same shape so it is ready the moment that loader exists.
|
||||
*/
|
||||
|
||||
export const id = "docker";
|
||||
export const tabId = "docker";
|
||||
export const hostEditorTabId = "docker";
|
||||
|
||||
export async function register({
|
||||
registerRailItem,
|
||||
registerTabComponent,
|
||||
registerHostEditorTab,
|
||||
icons,
|
||||
}) {
|
||||
registerTabComponent(tabId, () =>
|
||||
import("./DockerManager.tsx").then((m) => ({
|
||||
default: m.DockerManager,
|
||||
})),
|
||||
);
|
||||
|
||||
registerRailItem({
|
||||
id: tabId,
|
||||
icon: icons.Box,
|
||||
labelKey: "nav.docker",
|
||||
kind: "tab",
|
||||
});
|
||||
|
||||
if (registerHostEditorTab) {
|
||||
const { HostDockerTab } = await import(
|
||||
"../../../src/ui/sidebar/HostEditorFeatureTabs.tsx"
|
||||
);
|
||||
registerHostEditorTab({
|
||||
id: hostEditorTabId,
|
||||
labelKey: "hosts.tabDocker",
|
||||
icon: icons.Box,
|
||||
component: HostDockerTab,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregister({
|
||||
unregisterRailItem,
|
||||
unregisterTabComponent,
|
||||
unregisterHostEditorTab,
|
||||
}) {
|
||||
unregisterRailItem(tabId);
|
||||
unregisterTabComponent(tabId);
|
||||
unregisterHostEditorTab?.(hostEditorTabId);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"id": "docker",
|
||||
"name": "Docker",
|
||||
"version": "1.0.0",
|
||||
"description": "Container management over SSH: list, inspect, start, stop and view logs for Docker or Podman containers, plus an interactive container console.",
|
||||
"author": { "name": "Termix" },
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Infrastructure",
|
||||
"icon": "Box",
|
||||
"engine": { "termix": ">=2.9.0", "api": "1" },
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": true,
|
||||
"electron": true,
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
},
|
||||
"permissions": ["process:transport-owner"],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
{
|
||||
"id": "docker",
|
||||
"titleKey": "nav.docker",
|
||||
"icon": "Box",
|
||||
"openFrom": ["host-context-menu", "palette"]
|
||||
}
|
||||
],
|
||||
"hostCapability": {
|
||||
"key": "enableDocker",
|
||||
"labelKey": "hosts.dockerIntegration",
|
||||
"editorTab": "docker"
|
||||
}
|
||||
},
|
||||
"sidecars": []
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -1,10 +1,24 @@
|
||||
/**
|
||||
* Copies plugins/ into dist/plugins so bundled first-party plugins ship with a
|
||||
* built server.
|
||||
* Compiles plugin backend TypeScript, then copies plugins/ into dist/plugins
|
||||
* so bundled first-party plugins ship with a built server.
|
||||
*
|
||||
* tsc only emits .ts, and a plugin's backend entry is a hand-written .mjs plus
|
||||
* a manifest.json, so without this step the plugins directory simply would not
|
||||
* exist in dist and the loader would find nothing.
|
||||
* Most plugin backend entries are hand-written .mjs plus a manifest.json --
|
||||
* tsc only emits .ts, so without a copy step the plugins directory simply
|
||||
* would not exist in dist and the loader would find nothing.
|
||||
*
|
||||
* The docker plugin is the exception: its backend is real TypeScript,
|
||||
* physically relocated from src/backend/hosts/docker/ rather than kept as
|
||||
* hand-written JS, because it is ~4000 lines of typed SSH/session logic that
|
||||
* is not worth hand-transpiling. tsconfig.plugins.json compiles the plugin
|
||||
* backend TypeScript to a sibling .js next to its source. Its imports
|
||||
* into core (e.g. "../../../src/backend/utils/logger.js") are written
|
||||
* relative to the TypeScript SOURCE tree so tsc can type-check them, since
|
||||
* plugins/ is not under tsconfig.node.json's rootDir and cannot be added to
|
||||
* it without changing every existing dist/backend/backend/... path in the
|
||||
* codebase. That means the emitted .js still points at src/backend/, which
|
||||
* does not exist in a built server -- only its compiled counterpart at
|
||||
* dist/backend/backend/ does. rewriteCoreImports() below corrects that one
|
||||
* prefix after compilation, before the directory is copied into dist/.
|
||||
*
|
||||
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
|
||||
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
|
||||
@@ -12,6 +26,7 @@
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const source = path.join(root, "plugins");
|
||||
@@ -22,6 +37,84 @@ if (!fs.existsSync(source)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const SRC_BACKEND_PREFIX = "../../../src/backend/";
|
||||
const COMPILED_BACKEND_PREFIX = "../../../backend/backend/";
|
||||
|
||||
function compilePluginBackends() {
|
||||
execSync("npx tsc -p tsconfig.plugins.json", {
|
||||
cwd: root,
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the compiled plugin JS's imports into core from the source-tree
|
||||
* path tsc needed to resolve them, to the compiled-output path they need to
|
||||
* resolve at runtime. See the module comment above for why this exists.
|
||||
*/
|
||||
function rewriteCoreImports(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
rewriteCoreImports(entryPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.endsWith(".js")) continue;
|
||||
|
||||
const original = fs.readFileSync(entryPath, "utf8");
|
||||
if (!original.includes(SRC_BACKEND_PREFIX)) continue;
|
||||
|
||||
const rewritten = original.split(SRC_BACKEND_PREFIX).join(COMPILED_BACKEND_PREFIX);
|
||||
fs.writeFileSync(entryPath, rewritten);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* tsconfig.plugins.json has rootDir/outDir "." so the docker plugin's .js
|
||||
* lands beside its own .ts, but tsc also pulls every file the plugin
|
||||
* imports into the same program for type-checking -- core utils, repository
|
||||
* factories, src/types -- and stray-emits a sibling .js next to each of
|
||||
* those .ts files too, even though none of that code is actually compiled
|
||||
* by this pass (only referenced for types/signatures). Those stray files
|
||||
* are not part of the normal build and must not leak into dist/ or the
|
||||
* working tree, so anything under src/ that is a .js with no git history
|
||||
* and a same-named .ts sibling gets removed after every compile.
|
||||
*/
|
||||
function isGitTracked(filePath) {
|
||||
try {
|
||||
execSync(`git ls-files --error-unmatch "${filePath}"`, {
|
||||
cwd: root,
|
||||
stdio: "ignore",
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeStrayEmits(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
removeStrayEmits(entryPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.endsWith(".js")) continue;
|
||||
const tsSibling = entryPath.slice(0, -".js".length) + ".ts";
|
||||
// Never touch a tracked file, even if some future .ts/.js pair
|
||||
// legitimately coexists -- only remove what is both untracked and has a
|
||||
// .ts sibling, which is what a stray tsc emit always looks like.
|
||||
if (fs.existsSync(tsSibling) && !isGitTracked(entryPath)) {
|
||||
fs.rmSync(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compilePluginBackends();
|
||||
rewriteCoreImports(path.join(source, "docker", "backend"));
|
||||
removeStrayEmits(path.join(root, "src"));
|
||||
|
||||
fs.rmSync(destination, { recursive: true, force: true });
|
||||
fs.cpSync(source, destination, { recursive: true });
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { createCorsMiddleware } from "../../utils/cors-config.js";
|
||||
import { createCompressionMiddleware } from "../../utils/compression-config.js";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { registerDockerContainerRoutes } from "./container-routes.js";
|
||||
import {
|
||||
sshSessions,
|
||||
pendingTOTPSessions,
|
||||
cleanupSession,
|
||||
executeDockerCommand,
|
||||
} from "./session-manager.js";
|
||||
import {
|
||||
DOCKER_TIMESTAMP_RE,
|
||||
getRequestUserId,
|
||||
registerDockerSshRoutes,
|
||||
} from "./routes.js";
|
||||
import { listenOnServicePort } from "../../utils/service-listen.js";
|
||||
|
||||
const sshLogger = logger;
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", "loopback");
|
||||
|
||||
app.use(createCompressionMiddleware());
|
||||
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
||||
|
||||
app.use(cookieParser());
|
||||
const authManager = AuthManager.getInstance();
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
app.use(express.json({ limit: "100mb" }));
|
||||
app.use(express.urlencoded({ limit: "100mb", extended: true }));
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
|
||||
registerDockerSshRoutes(app);
|
||||
|
||||
registerDockerContainerRoutes(app, {
|
||||
sshSessions,
|
||||
pendingTOTPSessions,
|
||||
getRequestUserId,
|
||||
executeDockerCommand,
|
||||
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
|
||||
});
|
||||
|
||||
const PORT = 30007;
|
||||
|
||||
listenOnServicePort({
|
||||
app,
|
||||
port: PORT,
|
||||
logger: sshLogger,
|
||||
serviceName: "docker",
|
||||
onListening: async () => {
|
||||
try {
|
||||
await authManager.initialize();
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to initialize Docker backend", err, {
|
||||
operation: "startup",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
Object.keys(sshSessions).forEach((sessionId) => {
|
||||
cleanupSession(sessionId);
|
||||
});
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
Object.keys(sshSessions).forEach((sessionId) => {
|
||||
cleanupSession(sessionId);
|
||||
});
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -32,9 +32,16 @@
|
||||
* ssh-terminal is here because it IS the SSH transport rather than a consumer
|
||||
* of it: it owns a WebSocket server and long-lived ssh2 clients, neither of
|
||||
* which can cross a postMessage boundary.
|
||||
*
|
||||
* docker is here for the same reason: its container console owns a
|
||||
* WebSocket server with long-lived ssh2 clients and PTY streams, its
|
||||
* session manager reuses a live ssh2.Client across many REST calls, and its
|
||||
* SSH connect flow implements TOTP/Warpgate as a stateful multi-request
|
||||
* handshake the worker ctx.ssh API has no hook for.
|
||||
*/
|
||||
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||
"ssh-terminal",
|
||||
"docker",
|
||||
]);
|
||||
|
||||
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
|
||||
|
||||
@@ -295,13 +295,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
|
||||
const { serverReady } = await import("./database/database.js");
|
||||
await serverReady;
|
||||
// Terminal is deliberately absent: the ssh-terminal plugin starts it, so
|
||||
// disabling that plugin stops the WS server. See plugins/ssh-terminal.
|
||||
// Terminal and docker are deliberately absent: the ssh-terminal and
|
||||
// docker plugins start their own servers, so disabling either plugin
|
||||
// stops its WS/HTTP server. See plugins/ssh-terminal and plugins/docker.
|
||||
await import("./hosts/tunnel/index.js");
|
||||
await import("./hosts/file-manager/index.js");
|
||||
await import("./hosts/metrics/index.js");
|
||||
await import("./hosts/docker/index.js");
|
||||
await import("./hosts/docker/console.js");
|
||||
await import("./hosts/tmux/index.js");
|
||||
await import("./hosts/serial.js");
|
||||
await import("./services/dashboard.js");
|
||||
|
||||
@@ -82,3 +82,57 @@ describe("bundled ssh-terminal plugin", () => {
|
||||
expect(source).toMatch(/export async function deactivate/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundled docker plugin", () => {
|
||||
it("ships a directory with a manifest and both entry points", () => {
|
||||
const dir = path.join(getBundledPluginsDir(), "docker");
|
||||
|
||||
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("docker"));
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(manifest?.id).toBe("docker");
|
||||
expect(manifest?.category).toBe("Infrastructure");
|
||||
});
|
||||
|
||||
it("declares the transport-owner capability and qualifies for the tier", () => {
|
||||
const { manifest } = parseManifest(readBundledManifest("docker"));
|
||||
|
||||
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
|
||||
expect(isFirstParty("docker")).toBe(true);
|
||||
expect(runsInProcess("docker", manifest!.permissions)).toBe(true);
|
||||
});
|
||||
|
||||
it("contributes the docker tab the shell registers", () => {
|
||||
const { manifest } = parseManifest(readBundledManifest("docker"));
|
||||
const tab = manifest?.contributes?.tabs?.[0];
|
||||
|
||||
// The shell keys tab content off this id, so it has to stay "docker".
|
||||
expect(tab?.id).toBe("docker");
|
||||
expect(tab?.openFrom).toContain("host-context-menu");
|
||||
});
|
||||
|
||||
it("declares the enableDocker host capability", () => {
|
||||
const { manifest } = parseManifest(readBundledManifest("docker"));
|
||||
|
||||
expect(manifest?.contributes?.hostCapability?.key).toBe("enableDocker");
|
||||
});
|
||||
|
||||
it("exports activate and deactivate from its backend entry", () => {
|
||||
const entry = path.join(
|
||||
getBundledPluginsDir(),
|
||||
"docker",
|
||||
"backend",
|
||||
"index.mjs",
|
||||
);
|
||||
const source = fs.readFileSync(entry, "utf8");
|
||||
|
||||
expect(source).toMatch(/export async function activate/);
|
||||
expect(source).toMatch(/export async function deactivate/);
|
||||
});
|
||||
});
|
||||
@@ -39,12 +39,16 @@ function manifest(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe("first-party allowlist", () => {
|
||||
it("contains only ssh-terminal", () => {
|
||||
expect([...FIRST_PARTY_PLUGIN_IDS]).toEqual(["ssh-terminal"]);
|
||||
it("contains only ssh-terminal and docker", () => {
|
||||
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
|
||||
"docker",
|
||||
"ssh-terminal",
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognises ssh-terminal and nothing else", () => {
|
||||
it("recognises ssh-terminal and docker and nothing else", () => {
|
||||
expect(isFirstParty("ssh-terminal")).toBe(true);
|
||||
expect(isFirstParty("docker")).toBe(true);
|
||||
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
|
||||
expect(isFirstParty("community-plugin")).toBe(false);
|
||||
expect(isFirstParty("")).toBe(false);
|
||||
@@ -56,10 +60,12 @@ describe("runsInProcess", () => {
|
||||
expect(runsInProcess("ssh-terminal", [TRANSPORT_OWNER_CAPABILITY])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(runsInProcess("docker", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
|
||||
|
||||
// On the list but not asking for it: stays in a worker, so the manifest
|
||||
// remains an honest description of what the plugin does.
|
||||
expect(runsInProcess("ssh-terminal", ["hosts.read"])).toBe(false);
|
||||
expect(runsInProcess("docker", ["hosts.read"])).toBe(false);
|
||||
|
||||
// Asking for it but not on the list.
|
||||
expect(
|
||||
@@ -103,6 +109,18 @@ describe("manifest gate on the reserved capability", () => {
|
||||
expect(parsed?.id).toBe("ssh-terminal");
|
||||
});
|
||||
|
||||
it("allows it for docker", () => {
|
||||
const { manifest: parsed, errors } = parseManifest(
|
||||
manifest({
|
||||
id: "docker",
|
||||
permissions: [TRANSPORT_OWNER_CAPABILITY],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(parsed?.id).toBe("docker");
|
||||
});
|
||||
|
||||
it("still accepts an ordinary manifest that never mentions it", () => {
|
||||
const { manifest: parsed, errors } = parseManifest(
|
||||
manifest({ permissions: ["hosts.read"] }),
|
||||
|
||||
+3
-1
@@ -54,7 +54,9 @@ const ProxmoxStatsApp = lazy(() =>
|
||||
})),
|
||||
);
|
||||
const DockerApp = lazy(() =>
|
||||
import("@/features/docker/DockerApp").then((m) => ({ default: m.default })),
|
||||
import("../plugins/docker/frontend/DockerApp").then((m) => ({
|
||||
default: m.default,
|
||||
})),
|
||||
);
|
||||
const GuacamoleApp = lazy(() =>
|
||||
import("@/features/guacamole/GuacamoleApp").then((m) => ({
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Runtime registry for plugin-contributed dashboard cards.
|
||||
*
|
||||
* Unlike registerRailItem/registerTabComponent (rail-items.ts, tabUtils.tsx),
|
||||
* nothing in DashboardTab.tsx consumes this yet: its card system
|
||||
* (DASHBOARD_CARDS in @/lib/theme, DashboardCardId in @/types/ui-types) is a
|
||||
* closed, typed set with its own drag/drop layout and per-user persisted
|
||||
* slot preferences, and no plugin today contributes an actual dashboard
|
||||
* card. This registry exists so a plugin CAN register one without another
|
||||
* round of seam-building, but adding it to the DashboardTab render/layout
|
||||
* system is future work, not part of this change.
|
||||
*/
|
||||
export interface RegisteredDashboardCard {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
render: () => ReactNode;
|
||||
}
|
||||
|
||||
const registeredDashboardCards = new Map<string, RegisteredDashboardCard>();
|
||||
|
||||
export function registerDashboardCard(def: RegisteredDashboardCard): void {
|
||||
registeredDashboardCards.set(def.id, def);
|
||||
}
|
||||
|
||||
export function unregisterDashboardCard(id: string): void {
|
||||
registeredDashboardCards.delete(id);
|
||||
}
|
||||
|
||||
export function registeredDashboardCardList(): RegisteredDashboardCard[] {
|
||||
return [...registeredDashboardCards.values()];
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { DockerManager } from "@/features/docker/DockerManager";
|
||||
import { DockerManager } from "../../../../../plugins/docker/frontend/DockerManager";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ const loadFileManager = () =>
|
||||
}));
|
||||
const FileManager = lazy(loadFileManager);
|
||||
const loadDockerManager = () =>
|
||||
import("@/features/docker/DockerManager").then((m) => ({
|
||||
import("../../../plugins/docker/frontend/DockerManager").then((m) => ({
|
||||
default: m.DockerManager,
|
||||
}));
|
||||
const DockerManager = lazy(loadDockerManager);
|
||||
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
} from "./HostEditorGuacamoleTabs";
|
||||
import { HostStatsTab } from "./HostEditorStatsTab";
|
||||
import { VaultProfileManager } from "./VaultProfileManager";
|
||||
import { getRegisteredHostEditorTab } from "./HostManagerTabs";
|
||||
import {
|
||||
SecretReferenceHint,
|
||||
SecretSourceManager,
|
||||
@@ -2661,6 +2662,13 @@ export function HostEditor({
|
||||
credentials={availableCredentials}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const registeredTab = getRegisteredHostEditorTab(activeTab);
|
||||
if (!registeredTab) return null;
|
||||
const RegisteredTabComponent = registeredTab.component;
|
||||
return <RegisteredTabComponent form={form} setField={setField} />;
|
||||
})()}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useEffect, useRef, type ComponentType, type ReactNode } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Box,
|
||||
@@ -52,6 +52,50 @@ export const SSH_GROUP_TABS = new Set<HostTabId>([
|
||||
"host-metrics",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A plugin-contributed host editor tab. `component` receives the same
|
||||
* {form, setField} pair every built-in tab body gets (see HostDockerTab in
|
||||
* HostEditorFeatureTabs.tsx for the shape a plugin's own tab component
|
||||
* should match).
|
||||
*/
|
||||
export type HostEditorTabComponent = ComponentType<{
|
||||
form: unknown;
|
||||
setField: (key: string, value: unknown) => void;
|
||||
}>;
|
||||
|
||||
interface RegisteredHostEditorTab {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
icon: ReactNode;
|
||||
component: HostEditorTabComponent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime registry for host editor tabs that don't exist in the built-in
|
||||
* HostTabId union. A plugin's frontend registers into this from its own
|
||||
* register() (see plugins/*\/frontend/index.mjs), the same pattern
|
||||
* rail-items.ts and tabUtils.tsx already use for their seams.
|
||||
*/
|
||||
const registeredHostEditorTabs = new Map<string, RegisteredHostEditorTab>();
|
||||
|
||||
export function registerHostEditorTab(def: RegisteredHostEditorTab): void {
|
||||
registeredHostEditorTabs.set(def.id, def);
|
||||
}
|
||||
|
||||
export function unregisterHostEditorTab(id: string): void {
|
||||
registeredHostEditorTabs.delete(id);
|
||||
}
|
||||
|
||||
export function getRegisteredHostEditorTab(
|
||||
id: string,
|
||||
): RegisteredHostEditorTab | undefined {
|
||||
return registeredHostEditorTabs.get(id);
|
||||
}
|
||||
|
||||
export function registeredHostEditorTabList(): RegisteredHostEditorTab[] {
|
||||
return [...registeredHostEditorTabs.values()];
|
||||
}
|
||||
|
||||
export function makeHostTabs(t: (key: string) => string): HostTab[] {
|
||||
return [
|
||||
{
|
||||
@@ -124,6 +168,11 @@ export function makeHostSshSubTabs(t: (key: string) => string): HostTab[] {
|
||||
label: t("hosts.tabHostMetrics"),
|
||||
icon: <Activity className="size-3" />,
|
||||
},
|
||||
...registeredHostEditorTabList().map((tab) => ({
|
||||
id: tab.id as HostTabId,
|
||||
label: t(tab.labelKey),
|
||||
icon: tab.icon,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
registerDashboardCard,
|
||||
registeredDashboardCardList,
|
||||
unregisterDashboardCard,
|
||||
} from "@/dashboard/dashboard-cards-registry";
|
||||
|
||||
const FAKE_CARD_ID = "__test_plugin_dashboard_card__";
|
||||
|
||||
describe("registerDashboardCard seam", () => {
|
||||
afterEach(() => {
|
||||
unregisterDashboardCard(FAKE_CARD_ID);
|
||||
});
|
||||
|
||||
it("is not registered by default", () => {
|
||||
expect(registeredDashboardCardList()).toEqual([]);
|
||||
});
|
||||
|
||||
it("registers and unregisters a plugin dashboard card", () => {
|
||||
registerDashboardCard({
|
||||
id: FAKE_CARD_ID,
|
||||
titleKey: "nav.fakePluginItem",
|
||||
render: () => null,
|
||||
});
|
||||
|
||||
expect(registeredDashboardCardList().map((c) => c.id)).toContain(
|
||||
FAKE_CARD_ID,
|
||||
);
|
||||
|
||||
unregisterDashboardCard(FAKE_CARD_ID);
|
||||
|
||||
expect(registeredDashboardCardList().map((c) => c.id)).not.toContain(
|
||||
FAKE_CARD_ID,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Puzzle } from "lucide-react";
|
||||
import {
|
||||
getRegisteredHostEditorTab,
|
||||
makeHostSshSubTabs,
|
||||
registerHostEditorTab,
|
||||
registeredHostEditorTabList,
|
||||
unregisterHostEditorTab,
|
||||
} from "@/sidebar/HostManagerTabs";
|
||||
|
||||
const FAKE_TAB_ID = "__test_plugin_host_editor_tab__";
|
||||
|
||||
function FakeTabComponent() {
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("registerHostEditorTab seam", () => {
|
||||
afterEach(() => {
|
||||
unregisterHostEditorTab(FAKE_TAB_ID);
|
||||
});
|
||||
|
||||
it("is not registered by default", () => {
|
||||
expect(getRegisteredHostEditorTab(FAKE_TAB_ID)).toBeUndefined();
|
||||
expect(registeredHostEditorTabList()).toEqual([]);
|
||||
});
|
||||
|
||||
it("registers and unregisters a plugin host editor tab", () => {
|
||||
registerHostEditorTab({
|
||||
id: FAKE_TAB_ID,
|
||||
labelKey: "nav.fakePluginItem",
|
||||
icon: <Puzzle />,
|
||||
component: FakeTabComponent,
|
||||
});
|
||||
|
||||
expect(getRegisteredHostEditorTab(FAKE_TAB_ID)?.component).toBe(
|
||||
FakeTabComponent,
|
||||
);
|
||||
expect(registeredHostEditorTabList().map((t) => t.id)).toContain(
|
||||
FAKE_TAB_ID,
|
||||
);
|
||||
|
||||
unregisterHostEditorTab(FAKE_TAB_ID);
|
||||
|
||||
expect(getRegisteredHostEditorTab(FAKE_TAB_ID)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("appends a registered tab after the built-in SSH sub-tabs", () => {
|
||||
registerHostEditorTab({
|
||||
id: FAKE_TAB_ID,
|
||||
labelKey: "nav.fakePluginItem",
|
||||
icon: <Puzzle />,
|
||||
component: FakeTabComponent,
|
||||
});
|
||||
|
||||
const ids: string[] = makeHostSshSubTabs((key) => key).map((tab) => tab.id);
|
||||
expect(ids).toContain(FAKE_TAB_ID);
|
||||
expect(ids.indexOf(FAKE_TAB_ID)).toBe(ids.length - 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.plugins.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "nodenext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "nodenext",
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"esModuleInterop": true,
|
||||
"noEmit": false,
|
||||
"rootDir": ".",
|
||||
"outDir": ".",
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitReturns": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"noUncheckedSideEffectImports": false,
|
||||
"noImplicitAny": false,
|
||||
"noImplicitThis": false,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"allowUnusedLabels": true,
|
||||
"allowUnreachableCode": true
|
||||
},
|
||||
"include": ["plugins/docker/backend/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user