feat(FN-3109): add Docker connectivity service and target selector UI
This merge adds Docker connectivity management to Fusion (FN-3109), introducing a Docker client service in `@fusion/core` with typed interfaces for daemon connection configuration (host, socket path, TLS). The dashboard gains a `DockerTargetSelector` component for choosing between local socket and T Fusion-Task-Id: FN-3109
This commit is contained in:
5
.changeset/fn-3109-docker-client.md
Normal file
5
.changeset/fn-3109-docker-client.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add Docker target connectivity support for local daemon, Docker contexts, and direct host/TLS configuration with dashboard API and UI selectors.
|
||||||
@@ -40,7 +40,13 @@ export default defineConfig({
|
|||||||
// Native module: leave node-pty (aliased to @homebridge fork) out of the
|
// Native module: leave node-pty (aliased to @homebridge fork) out of the
|
||||||
// bundle. esbuild can't statically resolve its conditional native require()s
|
// bundle. esbuild can't statically resolve its conditional native require()s
|
||||||
// (build/Release/pty.node, build/Debug/conpty.node, ...).
|
// (build/Release/pty.node, build/Debug/conpty.node, ...).
|
||||||
external: ["node-pty", "@homebridge/node-pty-prebuilt-multiarch"],
|
external: [
|
||||||
|
"node-pty",
|
||||||
|
"@homebridge/node-pty-prebuilt-multiarch",
|
||||||
|
"dockerode",
|
||||||
|
"ssh2",
|
||||||
|
"cpu-features",
|
||||||
|
],
|
||||||
splitting: false,
|
splitting: false,
|
||||||
clean: true,
|
clean: true,
|
||||||
removeNodeProtocol: false,
|
removeNodeProtocol: false,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"test": "vitest run --silent=passed-only --reporter=dot"
|
"test": "vitest run --silent=passed-only --reporter=dot"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/dockerode": "^3.3.41",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@vitest/coverage-v8": "^3.1.0",
|
"@vitest/coverage-v8": "^3.1.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
"bonjour-service": "^1.3.0",
|
"bonjour-service": "^1.3.0",
|
||||||
"check-disk-space": "^3.4.0",
|
"check-disk-space": "^3.4.0",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
|
"dockerode": "^4.0.2",
|
||||||
"extract-zip": "^2.0.1",
|
"extract-zip": "^2.0.1",
|
||||||
"tar": "^7.5.13",
|
"tar": "^7.5.13",
|
||||||
"yaml": "^2.8.3"
|
"yaml": "^2.8.3"
|
||||||
|
|||||||
134
packages/core/src/__tests__/docker-client.test.ts
Normal file
134
packages/core/src/__tests__/docker-client.test.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const { execMock, readFileMock, pingMock, versionMock, inspectMock, dockerCtor } = vi.hoisted(() => {
|
||||||
|
const execMock = vi.fn();
|
||||||
|
const readFileMock = vi.fn();
|
||||||
|
const pingMock = vi.fn();
|
||||||
|
const versionMock = vi.fn();
|
||||||
|
const inspectMock = vi.fn();
|
||||||
|
const dockerCtor = vi.fn().mockImplementation(() => ({
|
||||||
|
ping: pingMock,
|
||||||
|
version: versionMock,
|
||||||
|
getContainer: vi.fn(() => ({ inspect: inspectMock })),
|
||||||
|
}));
|
||||||
|
return { execMock, readFileMock, pingMock, versionMock, inspectMock, dockerCtor };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("dockerode", () => ({ default: dockerCtor }));
|
||||||
|
vi.mock("node:child_process", () => ({ exec: execMock }));
|
||||||
|
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||||
|
|
||||||
|
import { DockerClientService } from "../docker-client";
|
||||||
|
|
||||||
|
describe("DockerClientService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => cb(null, { stdout: "", stderr: "" }));
|
||||||
|
pingMock.mockResolvedValue(undefined);
|
||||||
|
versionMock.mockResolvedValue({ Version: "24.0.0", ApiVersion: "1.43", Os: "linux" });
|
||||||
|
readFileMock.mockResolvedValue(Buffer.from("x"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[undefined, undefined],
|
||||||
|
[{ host: "tcp://1.2.3.4:2376" }, { host: "tcp://1.2.3.4:2376" }],
|
||||||
|
])("creates docker instance for mode", async (hostConfig, expected) => {
|
||||||
|
const service = new DockerClientService();
|
||||||
|
await service.testConnection(hostConfig as never);
|
||||||
|
if (expected) expect(dockerCtor).toHaveBeenCalledWith(expected);
|
||||||
|
else expect(dockerCtor).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns success connection result", async () => {
|
||||||
|
const service = new DockerClientService();
|
||||||
|
const result = await service.testConnection();
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.dockerVersion).toBe("24.0.0");
|
||||||
|
expect(result.apiVersion).toBe("1.43");
|
||||||
|
expect(result.operatingSystem).toBe("linux");
|
||||||
|
expect(result.isLocalDaemon).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks remote host as non-local daemon", async () => {
|
||||||
|
const service = new DockerClientService();
|
||||||
|
const result = await service.testConnection({ host: "tcp://1.2.3.4:2376" });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.isLocalDaemon).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ mode: "context", hostConfig: { context: "my-remote" } },
|
||||||
|
{
|
||||||
|
mode: "host+tls",
|
||||||
|
hostConfig: {
|
||||||
|
host: "tcp://1.2.3.4:2376",
|
||||||
|
tlsVerify: true,
|
||||||
|
tlsCaPath: "/ca.pem",
|
||||||
|
tlsCertPath: "/cert.pem",
|
||||||
|
tlsKeyPath: "/key.pem",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])("covers additional mode $mode", async ({ hostConfig }) => {
|
||||||
|
if ((hostConfig as any).context) {
|
||||||
|
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => {
|
||||||
|
if (cmd.includes("context inspect")) cb(null, { stdout: '[{"Endpoints":{"docker":{"Host":"tcp://ctx:2376"}}}]', stderr: "" });
|
||||||
|
else cb(null, { stdout: "", stderr: "" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const service = new DockerClientService();
|
||||||
|
await service.testConnection(hostConfig as any);
|
||||||
|
expect(dockerCtor).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses docker context", async () => {
|
||||||
|
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => {
|
||||||
|
if (cmd.includes("context inspect")) cb(null, { stdout: '[{"Endpoints":{"docker":{"Host":"tcp://ctx:2376"}}}]', stderr: "" });
|
||||||
|
else cb(null, { stdout: "", stderr: "" });
|
||||||
|
});
|
||||||
|
const service = new DockerClientService();
|
||||||
|
await service.testConnection({ context: "my-remote" });
|
||||||
|
expect(execMock.mock.calls[0][0]).toContain("docker context inspect");
|
||||||
|
expect(dockerCtor).toHaveBeenCalledWith({ host: "tcp://ctx:2376" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports host with tls", async () => {
|
||||||
|
const service = new DockerClientService();
|
||||||
|
await service.testConnection({ host: "tcp://1.2.3.4:2376", tlsVerify: true, tlsCaPath: "/ca.pem", tlsCertPath: "/cert.pem", tlsKeyPath: "/key.pem" });
|
||||||
|
expect(readFileMock).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns failure when ping fails", async () => {
|
||||||
|
pingMock.mockRejectedValue(new Error("connect ECONNREFUSED"));
|
||||||
|
const service = new DockerClientService();
|
||||||
|
const result = await service.testConnection();
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain("ECONNREFUSED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists contexts and ENOENT fallback", async () => {
|
||||||
|
execMock.mockImplementationOnce((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => cb(null, { stdout: '{"Name":"default","Current":true}\n{"Name":"remote","DockerHost":"tcp://1.2.3.4:2376","Current":false}\n', stderr: "" }));
|
||||||
|
const service = new DockerClientService();
|
||||||
|
const contexts = await service.listContexts();
|
||||||
|
expect(contexts).toHaveLength(2);
|
||||||
|
|
||||||
|
execMock.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (err: unknown) => void) => cb(new Error("ENOENT")));
|
||||||
|
const fallback = await service.listContexts();
|
||||||
|
expect(fallback[0].name).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gets container info and not found", async () => {
|
||||||
|
inspectMock.mockResolvedValue({ Id: "abc", Name: "/container", Created: "2020-01-01T00:00:00Z", Config: { Image: "img:latest" }, State: { Status: "running", Running: true, Paused: false, Restarting: false, Dead: false } });
|
||||||
|
const service = new DockerClientService();
|
||||||
|
const container = await service.getContainerInfo("abc");
|
||||||
|
expect(container?.name).toBe("container");
|
||||||
|
|
||||||
|
inspectMock.mockRejectedValue(new Error("404 no such container"));
|
||||||
|
const missing = await service.getContainerInfo("missing");
|
||||||
|
expect(missing).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not use execSync", async () => {
|
||||||
|
const source = await import("node:fs/promises").then((m) => m.readFile(new URL("../docker-client.ts", import.meta.url), "utf8"));
|
||||||
|
expect(source.includes("execSync")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
192
packages/core/src/docker-client.ts
Normal file
192
packages/core/src/docker-client.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import Docker from "dockerode";
|
||||||
|
import { exec } from "node:child_process";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import type {
|
||||||
|
DockerConnectivityResult,
|
||||||
|
DockerContainerInspectResult,
|
||||||
|
DockerContextInfo,
|
||||||
|
DockerHostConfig,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
const EXEC_OPTIONS = {
|
||||||
|
timeout: 15_000,
|
||||||
|
maxBuffer: 5 * 1024 * 1024,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function isLocalDaemonHost(host?: string): boolean {
|
||||||
|
return !host || host.trim() === "" || host === "unix:///var/run/docker.sock";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error && error.message) return error.message;
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DockerContextCliEntry {
|
||||||
|
Name?: string;
|
||||||
|
Description?: string;
|
||||||
|
DockerEndpoint?: string;
|
||||||
|
DockerHost?: string;
|
||||||
|
Current?: boolean;
|
||||||
|
Error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DockerClientService {
|
||||||
|
private dockerInstance: Docker | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly defaultHostConfig?: DockerHostConfig) {}
|
||||||
|
|
||||||
|
private async createDockerInstance(hostConfig?: DockerHostConfig): Promise<Docker> {
|
||||||
|
if (hostConfig?.context) {
|
||||||
|
const contextName = hostConfig.context.trim();
|
||||||
|
if (!contextName) throw new Error("Docker context name cannot be empty");
|
||||||
|
|
||||||
|
let stdout: string;
|
||||||
|
try {
|
||||||
|
({ stdout } = await promisify(exec)(`docker context inspect ${JSON.stringify(contextName)}`, EXEC_OPTIONS));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to inspect Docker context "${contextName}": ${toErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(stdout) as Array<{ Endpoints?: { docker?: { Host?: string } } }>;
|
||||||
|
const dockerHost = parsed[0]?.Endpoints?.docker?.Host;
|
||||||
|
if (!dockerHost) throw new Error(`Docker context "${contextName}" does not define a Docker endpoint host`);
|
||||||
|
return new Docker({ host: dockerHost });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostConfig?.host) {
|
||||||
|
const options: {
|
||||||
|
host: string;
|
||||||
|
ca?: Buffer;
|
||||||
|
cert?: Buffer;
|
||||||
|
key?: Buffer;
|
||||||
|
rejectUnauthorized?: boolean;
|
||||||
|
} = {
|
||||||
|
host: hostConfig.host,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hostConfig.tlsCaPath) options.ca = await readFile(hostConfig.tlsCaPath);
|
||||||
|
if (hostConfig.tlsCertPath) options.cert = await readFile(hostConfig.tlsCertPath);
|
||||||
|
if (hostConfig.tlsKeyPath) options.key = await readFile(hostConfig.tlsKeyPath);
|
||||||
|
if (hostConfig.tlsVerify === false) options.rejectUnauthorized = false;
|
||||||
|
|
||||||
|
return new Docker(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Docker();
|
||||||
|
}
|
||||||
|
|
||||||
|
async testConnection(hostConfig?: DockerHostConfig): Promise<DockerConnectivityResult> {
|
||||||
|
const isLocalDaemon = isLocalDaemonHost(hostConfig?.host) && !hostConfig?.context;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const docker = await this.createDockerInstance(hostConfig);
|
||||||
|
await docker.ping();
|
||||||
|
const version = await docker.version();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
dockerVersion: version.Version,
|
||||||
|
apiVersion: version.ApiVersion,
|
||||||
|
operatingSystem: version.Os,
|
||||||
|
isLocalDaemon,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: toErrorMessage(error),
|
||||||
|
isLocalDaemon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listContexts(): Promise<DockerContextInfo[]> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await promisify(exec)("docker context ls --format json", EXEC_OPTIONS);
|
||||||
|
const lines = stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return [{ name: "default", isCurrentContext: true, description: "Current Docker context" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return lines.map((line) => {
|
||||||
|
const entry = JSON.parse(line) as DockerContextCliEntry;
|
||||||
|
return {
|
||||||
|
name: entry.Name ?? "default",
|
||||||
|
description: entry.Description,
|
||||||
|
dockerHost: entry.DockerEndpoint ?? entry.DockerHost,
|
||||||
|
isCurrentContext: Boolean(entry.Current),
|
||||||
|
isError: Boolean(entry.Error),
|
||||||
|
errorMessage: entry.Error,
|
||||||
|
} satisfies DockerContextInfo;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
const tableLines = lines.slice(1);
|
||||||
|
const contexts: DockerContextInfo[] = [];
|
||||||
|
for (const line of tableLines) {
|
||||||
|
const parts = line.split(/\s{2,}/).map((part) => part.trim()).filter(Boolean);
|
||||||
|
if (parts.length === 0) continue;
|
||||||
|
const rawName = parts[0] ?? "";
|
||||||
|
const isCurrentContext = rawName.startsWith("*");
|
||||||
|
const name = rawName.replace(/^\*\s*/, "") || "default";
|
||||||
|
contexts.push({
|
||||||
|
name,
|
||||||
|
description: parts[2] || undefined,
|
||||||
|
dockerHost: parts[1] || undefined,
|
||||||
|
isCurrentContext,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return contexts;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = toErrorMessage(error);
|
||||||
|
if (message.includes("ENOENT")) {
|
||||||
|
return [{ name: "default", isCurrentContext: true, description: "Current Docker context" }];
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContainerInfo(containerId: string): Promise<DockerContainerInspectResult | null> {
|
||||||
|
try {
|
||||||
|
const docker = await this.getInstance();
|
||||||
|
const inspect = await docker.getContainer(containerId).inspect();
|
||||||
|
return {
|
||||||
|
id: inspect.Id,
|
||||||
|
name: (inspect.Name ?? "").replace(/^\//, ""),
|
||||||
|
status: inspect.State?.Status ?? "unknown",
|
||||||
|
image: inspect.Config?.Image ?? "",
|
||||||
|
created: inspect.Created ? Date.parse(inspect.Created) : 0,
|
||||||
|
state: {
|
||||||
|
running: Boolean(inspect.State?.Running),
|
||||||
|
paused: Boolean(inspect.State?.Paused),
|
||||||
|
restarting: Boolean(inspect.State?.Restarting),
|
||||||
|
dead: Boolean(inspect.State?.Dead),
|
||||||
|
error: inspect.State?.Error || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = toErrorMessage(error);
|
||||||
|
if (message.includes("404") || message.toLowerCase().includes("no such container")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getInstance(): Promise<Docker> {
|
||||||
|
if (!this.dockerInstance) {
|
||||||
|
this.dockerInstance = await this.createDockerInstance(this.defaultHostConfig);
|
||||||
|
}
|
||||||
|
return this.dockerInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.dockerInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -391,6 +391,7 @@ export { NodeConnection } from "./node-connection.js";
|
|||||||
export { NodeDiscovery } from "./node-discovery.js";
|
export { NodeDiscovery } from "./node-discovery.js";
|
||||||
export { collectSystemMetrics } from "./system-metrics.js";
|
export { collectSystemMetrics } from "./system-metrics.js";
|
||||||
export { getAppVersion, parseSemver } from "./app-version.js";
|
export { getAppVersion, parseSemver } from "./app-version.js";
|
||||||
|
export { DockerClientService } from "./docker-client.js";
|
||||||
export type {
|
export type {
|
||||||
ConnectionErrorType,
|
ConnectionErrorType,
|
||||||
ConnectionOptions,
|
ConnectionOptions,
|
||||||
@@ -414,6 +415,9 @@ export type {
|
|||||||
DockerResourceSizing,
|
DockerResourceSizing,
|
||||||
DockerVolumeMount,
|
DockerVolumeMount,
|
||||||
DockerExtraCli,
|
DockerExtraCli,
|
||||||
|
DockerContextInfo,
|
||||||
|
DockerConnectivityResult,
|
||||||
|
DockerContainerInspectResult,
|
||||||
ManagedDockerNode,
|
ManagedDockerNode,
|
||||||
ManagedDockerNodeInput,
|
ManagedDockerNodeInput,
|
||||||
ManagedDockerNodeUpdate,
|
ManagedDockerNodeUpdate,
|
||||||
|
|||||||
@@ -2581,6 +2581,60 @@ export type ManagedDockerNodeUpdate = Partial<
|
|||||||
Omit<ManagedDockerNode, "id" | "createdAt">
|
Omit<ManagedDockerNode, "id" | "createdAt">
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
/** Information about a discovered Docker context */
|
||||||
|
export interface DockerContextInfo {
|
||||||
|
/** Context name (e.g., "default", "my-remote") */
|
||||||
|
name: string;
|
||||||
|
/** Human-readable description */
|
||||||
|
description?: string;
|
||||||
|
/** Docker host URI for this context (e.g., "tcp://192.168.1.50:2376") */
|
||||||
|
dockerHost?: string;
|
||||||
|
/** Whether this is the currently active context */
|
||||||
|
isCurrentContext: boolean;
|
||||||
|
/** Whether this context has a connection error */
|
||||||
|
isError?: boolean;
|
||||||
|
/** Error message if the context is unreachable */
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of testing Docker daemon connectivity */
|
||||||
|
export interface DockerConnectivityResult {
|
||||||
|
/** Whether the connection succeeded */
|
||||||
|
success: boolean;
|
||||||
|
/** Docker Engine version string */
|
||||||
|
dockerVersion?: string;
|
||||||
|
/** Docker API version string */
|
||||||
|
apiVersion?: string;
|
||||||
|
/** Docker Engine OS/arch info */
|
||||||
|
operatingSystem?: string;
|
||||||
|
/** Error message if connection failed */
|
||||||
|
error?: string;
|
||||||
|
/** Whether the target is the local Docker daemon */
|
||||||
|
isLocalDaemon: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal container inspection result from Docker */
|
||||||
|
export interface DockerContainerInspectResult {
|
||||||
|
/** Container ID */
|
||||||
|
id: string;
|
||||||
|
/** Container name (with leading / stripped) */
|
||||||
|
name: string;
|
||||||
|
/** Container status string (e.g., "running", "exited") */
|
||||||
|
status: string;
|
||||||
|
/** Image name/tag */
|
||||||
|
image: string;
|
||||||
|
/** Creation timestamp (Unix epoch) */
|
||||||
|
created: number;
|
||||||
|
/** Detailed container state */
|
||||||
|
state: {
|
||||||
|
running: boolean;
|
||||||
|
paused: boolean;
|
||||||
|
restarting: boolean;
|
||||||
|
dead: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** A single plugin's version information for sync comparison */
|
/** A single plugin's version information for sync comparison */
|
||||||
export interface PluginVersionEntry {
|
export interface PluginVersionEntry {
|
||||||
/** Plugin ID (matches PluginManifest.id) */
|
/** Plugin ID (matches PluginManifest.id) */
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ChevronDown, Plus, Trash2 } from "lucide-react";
|
import { ChevronDown, Plus, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import type { ManagedDockerNodeInput } from "@fusion/core";
|
import type { DockerHostConfig, ManagedDockerNodeInput } from "@fusion/core";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
import { DockerTargetSelector } from "./DockerTargetSelector";
|
||||||
import "./DockerNodeOnboardingModal.css";
|
import "./DockerNodeOnboardingModal.css";
|
||||||
|
|
||||||
interface DockerNodeOnboardingModalProps {
|
interface DockerNodeOnboardingModalProps {
|
||||||
@@ -33,7 +34,7 @@ const DEFAULT_URL = "http://localhost:4040";
|
|||||||
|
|
||||||
export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast: _addToast }: DockerNodeOnboardingModalProps) {
|
export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast: _addToast }: DockerNodeOnboardingModalProps) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [location, setLocation] = useState<"local" | "remote">("local");
|
const [hostConfig, setHostConfig] = useState<DockerHostConfig>({});
|
||||||
const [reachableUrl, setReachableUrl] = useState(DEFAULT_URL);
|
const [reachableUrl, setReachableUrl] = useState(DEFAULT_URL);
|
||||||
const [apiKeyMode, setApiKeyMode] = useState<"auto" | "manual">("auto");
|
const [apiKeyMode, setApiKeyMode] = useState<"auto" | "manual">("auto");
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
@@ -45,12 +46,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
|||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
const [imageName, setImageName] = useState("runfusion/fusion");
|
const [imageName, setImageName] = useState("runfusion/fusion");
|
||||||
const [imageTag, setImageTag] = useState("latest");
|
const [imageTag, setImageTag] = useState("latest");
|
||||||
const [dockerContext, setDockerContext] = useState("");
|
|
||||||
const [dockerHost, setDockerHost] = useState("");
|
|
||||||
const [tlsVerify, setTlsVerify] = useState(true);
|
|
||||||
const [tlsCaPath, setTlsCaPath] = useState("");
|
|
||||||
const [tlsCertPath, setTlsCertPath] = useState("");
|
|
||||||
const [tlsKeyPath, setTlsKeyPath] = useState("");
|
|
||||||
const [envRows, setEnvRows] = useState<KeyValueRow[]>([]);
|
const [envRows, setEnvRows] = useState<KeyValueRow[]>([]);
|
||||||
const [mountRows, setMountRows] = useState<MountRow[]>([]);
|
const [mountRows, setMountRows] = useState<MountRow[]>([]);
|
||||||
const [errors, setErrors] = useState<FormErrors>({});
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
@@ -58,7 +54,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
|||||||
|
|
||||||
const resetForm = useCallback(() => {
|
const resetForm = useCallback(() => {
|
||||||
setName("");
|
setName("");
|
||||||
setLocation("local");
|
setHostConfig({});
|
||||||
setReachableUrl(DEFAULT_URL);
|
setReachableUrl(DEFAULT_URL);
|
||||||
setApiKeyMode("auto");
|
setApiKeyMode("auto");
|
||||||
setApiKey("");
|
setApiKey("");
|
||||||
@@ -70,12 +66,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
|||||||
setShowAdvanced(false);
|
setShowAdvanced(false);
|
||||||
setImageName("runfusion/fusion");
|
setImageName("runfusion/fusion");
|
||||||
setImageTag("latest");
|
setImageTag("latest");
|
||||||
setDockerContext("");
|
|
||||||
setDockerHost("");
|
|
||||||
setTlsVerify(true);
|
|
||||||
setTlsCaPath("");
|
|
||||||
setTlsCertPath("");
|
|
||||||
setTlsKeyPath("");
|
|
||||||
setEnvRows([]);
|
setEnvRows([]);
|
||||||
setMountRows([]);
|
setMountRows([]);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
@@ -111,12 +102,12 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
|||||||
imageName: imageName.trim() || "runfusion/fusion",
|
imageName: imageName.trim() || "runfusion/fusion",
|
||||||
imageTag: imageTag.trim() || "latest",
|
imageTag: imageTag.trim() || "latest",
|
||||||
hostConfig: {
|
hostConfig: {
|
||||||
context: dockerContext.trim() || undefined,
|
context: hostConfig.context?.trim() || undefined,
|
||||||
host: location === "remote" ? dockerHost.trim() || undefined : undefined,
|
host: hostConfig.host?.trim() || undefined,
|
||||||
tlsVerify: location === "remote" ? tlsVerify : undefined,
|
tlsVerify: hostConfig.tlsVerify,
|
||||||
tlsCaPath: location === "remote" ? tlsCaPath.trim() || undefined : undefined,
|
tlsCaPath: hostConfig.tlsCaPath?.trim() || undefined,
|
||||||
tlsCertPath: location === "remote" ? tlsCertPath.trim() || undefined : undefined,
|
tlsCertPath: hostConfig.tlsCertPath?.trim() || undefined,
|
||||||
tlsKeyPath: location === "remote" ? tlsKeyPath.trim() || undefined : undefined,
|
tlsKeyPath: hostConfig.tlsKeyPath?.trim() || undefined,
|
||||||
},
|
},
|
||||||
envVars: Object.fromEntries(
|
envVars: Object.fromEntries(
|
||||||
envRows
|
envRows
|
||||||
@@ -141,23 +132,17 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
|||||||
apiKey,
|
apiKey,
|
||||||
apiKeyMode,
|
apiKeyMode,
|
||||||
cpus,
|
cpus,
|
||||||
dockerContext,
|
hostConfig,
|
||||||
dockerHost,
|
|
||||||
envRows,
|
envRows,
|
||||||
imageName,
|
imageName,
|
||||||
imageTag,
|
imageTag,
|
||||||
includeClaudeCli,
|
includeClaudeCli,
|
||||||
includeDroidCli,
|
includeDroidCli,
|
||||||
location,
|
|
||||||
memoryMB,
|
memoryMB,
|
||||||
mountRows,
|
mountRows,
|
||||||
name,
|
name,
|
||||||
persistentStorage,
|
persistentStorage,
|
||||||
reachableUrl,
|
reachableUrl,
|
||||||
tlsCaPath,
|
|
||||||
tlsCertPath,
|
|
||||||
tlsKeyPath,
|
|
||||||
tlsVerify,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const addEnvRow = useCallback(() => {
|
const addEnvRow = useCallback(() => {
|
||||||
@@ -191,8 +176,8 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
|||||||
if (!input.name || input.name.length > 64) {
|
if (!input.name || input.name.length > 64) {
|
||||||
nextErrors.name = "Name is required and must be 64 characters or fewer";
|
nextErrors.name = "Name is required and must be 64 characters or fewer";
|
||||||
}
|
}
|
||||||
if (location === "remote" && !input.reachableUrl) {
|
if (!input.reachableUrl) {
|
||||||
nextErrors.reachableUrl = "URL is required for remote Docker";
|
nextErrors.reachableUrl = "URL is required";
|
||||||
}
|
}
|
||||||
if (memoryMB < 512) {
|
if (memoryMB < 512) {
|
||||||
nextErrors.memoryMB = "Memory must be at least 512 MB";
|
nextErrors.memoryMB = "Memory must be at least 512 MB";
|
||||||
@@ -215,7 +200,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
|||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [closeModal, cpus, input, location, memoryMB, onSubmit, submitting]);
|
}, [closeModal, cpus, input, memoryMB, onSubmit, submitting]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
@@ -252,26 +237,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
|||||||
</label>
|
</label>
|
||||||
{errors.name && <div className="form-error">{errors.name}</div>}
|
{errors.name && <div className="form-error">{errors.name}</div>}
|
||||||
|
|
||||||
<div className="docker-onboarding__type-toggle" role="tablist" aria-label="Target location">
|
<DockerTargetSelector value={hostConfig} onChange={setHostConfig} />
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`docker-onboarding__type-btn ${location === "local" ? "is-active" : ""}`}
|
|
||||||
onClick={() => setLocation("local")}
|
|
||||||
disabled={submitting}
|
|
||||||
aria-pressed={location === "local"}
|
|
||||||
>
|
|
||||||
Local Docker
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`docker-onboarding__type-btn ${location === "remote" ? "is-active" : ""}`}
|
|
||||||
onClick={() => setLocation("remote")}
|
|
||||||
disabled={submitting}
|
|
||||||
aria-pressed={location === "remote"}
|
|
||||||
>
|
|
||||||
Remote Docker
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="docker-onboarding__field">
|
<label className="docker-onboarding__field">
|
||||||
<span>Reachable URL</span>
|
<span>Reachable URL</span>
|
||||||
@@ -280,23 +246,11 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
|||||||
value={reachableUrl}
|
value={reachableUrl}
|
||||||
onChange={(event) => setReachableUrl(event.target.value)}
|
onChange={(event) => setReachableUrl(event.target.value)}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
placeholder={location === "local" ? DEFAULT_URL : "http://192.168.1.50:4040"}
|
placeholder={DEFAULT_URL}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{errors.reachableUrl && <div className="form-error">{errors.reachableUrl}</div>}
|
{errors.reachableUrl && <div className="form-error">{errors.reachableUrl}</div>}
|
||||||
|
|
||||||
{location === "remote" && (
|
|
||||||
<label className="docker-onboarding__field">
|
|
||||||
<span>Docker Host</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={dockerHost}
|
|
||||||
onChange={(event) => setDockerHost(event.target.value)}
|
|
||||||
disabled={submitting}
|
|
||||||
placeholder="tcp://host:2376"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="docker-onboarding__radio-group">
|
<div className="docker-onboarding__radio-group">
|
||||||
<label className="checkbox-label">
|
<label className="checkbox-label">
|
||||||
@@ -428,73 +382,6 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="docker-onboarding__field">
|
|
||||||
<span>Docker Context</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={dockerContext}
|
|
||||||
onChange={(event) => setDockerContext(event.target.value)}
|
|
||||||
disabled={submitting}
|
|
||||||
placeholder="default"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{location === "local" && (
|
|
||||||
<label className="docker-onboarding__field">
|
|
||||||
<span>Docker Host</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={dockerHost}
|
|
||||||
onChange={(event) => setDockerHost(event.target.value)}
|
|
||||||
disabled={submitting || Boolean(dockerContext.trim())}
|
|
||||||
placeholder="tcp://host:2376"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{location === "remote" && (
|
|
||||||
<div className="docker-onboarding__tls-fields">
|
|
||||||
<label className="docker-onboarding__field">
|
|
||||||
<span>CA Cert Path</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={tlsCaPath}
|
|
||||||
onChange={(event) => setTlsCaPath(event.target.value)}
|
|
||||||
disabled={submitting}
|
|
||||||
placeholder="/etc/docker/ca.pem"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="docker-onboarding__field">
|
|
||||||
<span>Client Cert Path</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={tlsCertPath}
|
|
||||||
onChange={(event) => setTlsCertPath(event.target.value)}
|
|
||||||
disabled={submitting}
|
|
||||||
placeholder="/etc/docker/cert.pem"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="docker-onboarding__field">
|
|
||||||
<span>Client Key Path</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={tlsKeyPath}
|
|
||||||
onChange={(event) => setTlsKeyPath(event.target.value)}
|
|
||||||
disabled={submitting}
|
|
||||||
placeholder="/etc/docker/key.pem"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={tlsVerify}
|
|
||||||
onChange={(event) => setTlsVerify(event.target.checked)}
|
|
||||||
disabled={submitting}
|
|
||||||
/>
|
|
||||||
TLS verify
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="docker-onboarding__kv-list">
|
<div className="docker-onboarding__kv-list">
|
||||||
<h5>Environment Variables</h5>
|
<h5>Environment Variables</h5>
|
||||||
|
|||||||
56
packages/dashboard/app/components/DockerTargetSelector.css
Normal file
56
packages/dashboard/app/components/DockerTargetSelector.css
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
.docker-target-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-target-selector__modes {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-target-selector__mode-active {
|
||||||
|
border-color: var(--todo);
|
||||||
|
box-shadow: var(--focus-ring-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-target-selector__panel {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-target-selector__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-target-selector__field label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-target-selector__context-row {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-target-selector__status {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-target-selector__success {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-target-selector__error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.docker-target-selector__modes .btn {
|
||||||
|
min-height: calc(var(--mobile-nav-height) - var(--space-sm));
|
||||||
|
}
|
||||||
|
}
|
||||||
128
packages/dashboard/app/components/DockerTargetSelector.tsx
Normal file
128
packages/dashboard/app/components/DockerTargetSelector.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { DockerHostConfig } from "@fusion/core";
|
||||||
|
import { useDockerTargets } from "../hooks/useDockerTargets";
|
||||||
|
import { DockerTlsConfig } from "./DockerTlsConfig";
|
||||||
|
import "./DockerTargetSelector.css";
|
||||||
|
|
||||||
|
type TargetMode = "local" | "context" | "host";
|
||||||
|
|
||||||
|
interface DockerTargetSelectorProps {
|
||||||
|
value?: DockerHostConfig;
|
||||||
|
onChange: (config: DockerHostConfig) => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DockerTargetSelector({ value, onChange, onError }: DockerTargetSelectorProps) {
|
||||||
|
const initialMode: TargetMode = value?.context ? "context" : value?.host ? "host" : "local";
|
||||||
|
const [mode, setMode] = useState<TargetMode>(initialMode);
|
||||||
|
const [selectedContext, setSelectedContext] = useState(value?.context ?? "");
|
||||||
|
const [host, setHost] = useState(value?.host ?? "");
|
||||||
|
const [localStatus, setLocalStatus] = useState<string | null>(null);
|
||||||
|
const { contexts, isLoadingContexts, contextsError, loadContexts, testConnection, isTestingConnection, lastTestResult, checkLocalDocker, isCheckingLocal } = useDockerTargets();
|
||||||
|
|
||||||
|
const tlsValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
tlsVerify: value?.tlsVerify,
|
||||||
|
tlsCaPath: value?.tlsCaPath,
|
||||||
|
tlsCertPath: value?.tlsCertPath,
|
||||||
|
tlsKeyPath: value?.tlsKeyPath,
|
||||||
|
}),
|
||||||
|
[value],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "local") {
|
||||||
|
onChange({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "context") {
|
||||||
|
void loadContexts().catch((error) => onError?.(error instanceof Error ? error.message : String(error)));
|
||||||
|
onChange(selectedContext ? { context: selectedContext } : {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({ host, ...tlsValue });
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
const updateTls = useCallback(
|
||||||
|
(tls: Pick<DockerHostConfig, "tlsVerify" | "tlsCaPath" | "tlsCertPath" | "tlsKeyPath">) => {
|
||||||
|
if (mode !== "host") return;
|
||||||
|
onChange({ host, ...tls });
|
||||||
|
},
|
||||||
|
[host, mode, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="docker-target-selector">
|
||||||
|
<div className="docker-target-selector__modes" role="group" aria-label="Docker target mode">
|
||||||
|
<button type="button" className={`btn btn-sm ${mode === "local" ? "docker-target-selector__mode-active" : ""}`} onClick={() => {
|
||||||
|
setMode("local");
|
||||||
|
void checkLocalDocker()
|
||||||
|
.then((result) => setLocalStatus(result.available ? `Docker is available${result.version ? ` (${result.version})` : ""}` : `Docker not found${result.error ? `: ${result.error}` : ""}`))
|
||||||
|
.catch((error) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setLocalStatus(`Docker not found: ${message}`);
|
||||||
|
onError?.(message);
|
||||||
|
});
|
||||||
|
}}>Local Docker</button>
|
||||||
|
<button type="button" className={`btn btn-sm ${mode === "context" ? "docker-target-selector__mode-active" : ""}`} onClick={() => setMode("context")}>Docker Context</button>
|
||||||
|
<button type="button" className={`btn btn-sm ${mode === "host" ? "docker-target-selector__mode-active" : ""}`} onClick={() => setMode("host")}>Remote Host</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "local" && localStatus && <div className="docker-target-selector__status">{localStatus}</div>}
|
||||||
|
|
||||||
|
{mode === "context" && (
|
||||||
|
<div className="docker-target-selector__panel">
|
||||||
|
<div className="docker-target-selector__context-row">
|
||||||
|
<select className="select" value={selectedContext} onChange={(event) => {
|
||||||
|
const next = event.target.value;
|
||||||
|
setSelectedContext(next);
|
||||||
|
onChange(next ? { context: next } : {});
|
||||||
|
}}>
|
||||||
|
<option value="">Select context</option>
|
||||||
|
{contexts.map((context) => (
|
||||||
|
<option key={context.name} value={context.name}>{context.name}{context.isCurrentContext ? " (current)" : ""}{context.dockerHost ? ` — ${context.dockerHost}` : ""}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="button" className="btn btn-sm btn-icon" onClick={() => void loadContexts()} disabled={isLoadingContexts} aria-label="Refresh contexts"><RefreshCw size={14} /></button>
|
||||||
|
</div>
|
||||||
|
{contextsError && <div className="docker-target-selector__error">{contextsError}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "host" && (
|
||||||
|
<div className="docker-target-selector__panel">
|
||||||
|
<div className="docker-target-selector__field">
|
||||||
|
<label htmlFor="docker-target-selector-host">Docker Host</label>
|
||||||
|
<input
|
||||||
|
id="docker-target-selector-host"
|
||||||
|
className="input"
|
||||||
|
placeholder="tcp://host:2376"
|
||||||
|
value={host}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.value;
|
||||||
|
setHost(next);
|
||||||
|
onChange({ host: next, ...tlsValue });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DockerTlsConfig value={tlsValue} onChange={updateTls} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => void testConnection(mode === "local" ? undefined : mode === "context" ? { context: selectedContext } : { host, ...tlsValue })} disabled={isTestingConnection || isCheckingLocal}>
|
||||||
|
{isTestingConnection ? "Testing..." : "Test Connection"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{lastTestResult && (
|
||||||
|
<div className={lastTestResult.success ? "docker-target-selector__success" : "docker-target-selector__error"}>
|
||||||
|
{lastTestResult.success
|
||||||
|
? `Connected${lastTestResult.dockerVersion ? ` (Docker ${lastTestResult.dockerVersion})` : ""}`
|
||||||
|
: lastTestResult.error ?? "Connection failed"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
packages/dashboard/app/components/DockerTlsConfig.css
Normal file
26
packages/dashboard/app/components/DockerTlsConfig.css
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
.docker-tls-config {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-tls-config__fields {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-tls-config__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-tls-config__field label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.docker-tls-config .input {
|
||||||
|
min-height: calc(var(--mobile-nav-height) - var(--space-sm));
|
||||||
|
}
|
||||||
|
}
|
||||||
69
packages/dashboard/app/components/DockerTlsConfig.tsx
Normal file
69
packages/dashboard/app/components/DockerTlsConfig.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { DockerHostConfig } from "@fusion/core";
|
||||||
|
import "./DockerTlsConfig.css";
|
||||||
|
|
||||||
|
type DockerTlsValue = Pick<DockerHostConfig, "tlsVerify" | "tlsCaPath" | "tlsCertPath" | "tlsKeyPath">;
|
||||||
|
|
||||||
|
interface DockerTlsConfigProps {
|
||||||
|
value?: DockerTlsValue;
|
||||||
|
onChange: (tls: DockerTlsValue) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DockerTlsConfig({ value, onChange }: DockerTlsConfigProps) {
|
||||||
|
const [enabled, setEnabled] = useState(Boolean(value?.tlsCaPath || value?.tlsCertPath || value?.tlsKeyPath || value?.tlsVerify));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
onChange({ tlsVerify: undefined, tlsCaPath: undefined, tlsCertPath: undefined, tlsKeyPath: undefined });
|
||||||
|
}
|
||||||
|
}, [enabled, onChange]);
|
||||||
|
|
||||||
|
const tls = useMemo(() => ({ tlsVerify: value?.tlsVerify ?? true, tlsCaPath: value?.tlsCaPath ?? "", tlsCertPath: value?.tlsCertPath ?? "", tlsKeyPath: value?.tlsKeyPath ?? "" }), [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="docker-tls-config">
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
|
||||||
|
Use TLS
|
||||||
|
</label>
|
||||||
|
{enabled && (
|
||||||
|
<div className="docker-tls-config__fields">
|
||||||
|
<div className="docker-tls-config__field">
|
||||||
|
<label htmlFor="docker-tls-ca-path">CA Certificate Path</label>
|
||||||
|
<input
|
||||||
|
id="docker-tls-ca-path"
|
||||||
|
className="input"
|
||||||
|
value={tls.tlsCaPath}
|
||||||
|
onChange={(event) => onChange({ ...tls, tlsCaPath: event.target.value })}
|
||||||
|
placeholder="/etc/docker/ca.pem"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="docker-tls-config__field">
|
||||||
|
<label htmlFor="docker-tls-cert-path">Client Certificate Path</label>
|
||||||
|
<input
|
||||||
|
id="docker-tls-cert-path"
|
||||||
|
className="input"
|
||||||
|
value={tls.tlsCertPath}
|
||||||
|
onChange={(event) => onChange({ ...tls, tlsCertPath: event.target.value })}
|
||||||
|
placeholder="/etc/docker/cert.pem"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="docker-tls-config__field">
|
||||||
|
<label htmlFor="docker-tls-key-path">Client Key Path</label>
|
||||||
|
<input
|
||||||
|
id="docker-tls-key-path"
|
||||||
|
className="input"
|
||||||
|
value={tls.tlsKeyPath}
|
||||||
|
onChange={(event) => onChange({ ...tls, tlsKeyPath: event.target.value })}
|
||||||
|
placeholder="/etc/docker/key.pem"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input type="checkbox" checked={tls.tlsVerify} onChange={(event) => onChange({ ...tls, tlsVerify: event.target.checked })} />
|
||||||
|
Verify TLS Certificate
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ describe("DockerNodeOnboardingModal", () => {
|
|||||||
render(<DockerNodeOnboardingModal {...defaultProps} />);
|
render(<DockerNodeOnboardingModal {...defaultProps} />);
|
||||||
expect(screen.getByPlaceholderText("my-docker-node")).toBeInTheDocument();
|
expect(screen.getByPlaceholderText("my-docker-node")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Local Docker" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Local Docker" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Remote Docker" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Remote Host" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("radio", { name: "Auto-generate" })).toBeInTheDocument();
|
expect(screen.getByRole("radio", { name: "Auto-generate" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("radio", { name: "Provide manually" })).toBeInTheDocument();
|
expect(screen.getByRole("radio", { name: "Provide manually" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("checkbox", { name: "Claude CLI" })).toBeInTheDocument();
|
expect(screen.getByRole("checkbox", { name: "Claude CLI" })).toBeInTheDocument();
|
||||||
@@ -46,9 +46,8 @@ describe("DockerNodeOnboardingModal", () => {
|
|||||||
expect(advancedToggle).toHaveClass("is-expanded");
|
expect(advancedToggle).toHaveClass("is-expanded");
|
||||||
expect(screen.getByPlaceholderText("runfusion/fusion")).toBeInTheDocument();
|
expect(screen.getByPlaceholderText("runfusion/fusion")).toBeInTheDocument();
|
||||||
expect(screen.getByPlaceholderText("latest")).toBeInTheDocument();
|
expect(screen.getByPlaceholderText("latest")).toBeInTheDocument();
|
||||||
expect(screen.getByPlaceholderText("default")).toBeInTheDocument();
|
fireEvent.click(screen.getByRole("button", { name: "Remote Host" }));
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: "Use TLS" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Remote Docker" }));
|
|
||||||
expect(screen.getByPlaceholderText("/etc/docker/ca.pem")).toBeInTheDocument();
|
expect(screen.getByPlaceholderText("/etc/docker/ca.pem")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Add variable" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Add variable" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Add mount" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Add mount" })).toBeInTheDocument();
|
||||||
@@ -60,10 +59,9 @@ describe("DockerNodeOnboardingModal", () => {
|
|||||||
expect(await screen.findByText("Name is required and must be 64 characters or fewer")).toBeInTheDocument();
|
expect(await screen.findByText("Name is required and must be 64 characters or fewer")).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("my-docker-node"), { target: { value: "n1" } });
|
fireEvent.change(screen.getByPlaceholderText("my-docker-node"), { target: { value: "n1" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Remote Docker" }));
|
fireEvent.change(screen.getByPlaceholderText("http://localhost:4040"), { target: { value: "" } });
|
||||||
fireEvent.change(screen.getByPlaceholderText("http://192.168.1.50:4040"), { target: { value: "" } });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Create Docker Node" }));
|
fireEvent.click(screen.getByRole("button", { name: "Create Docker Node" }));
|
||||||
expect(await screen.findByText("URL is required for remote Docker")).toBeInTheDocument();
|
expect(await screen.findByText("URL is required")).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole("spinbutton", { name: "Memory (MB)" }), { target: { value: "256" } });
|
fireEvent.change(screen.getByRole("spinbutton", { name: "Memory (MB)" }), { target: { value: "256" } });
|
||||||
fireEvent.change(screen.getByRole("spinbutton", { name: "CPUs" }), { target: { value: "0.25" } });
|
fireEvent.change(screen.getByRole("spinbutton", { name: "CPUs" }), { target: { value: "0.25" } });
|
||||||
@@ -75,8 +73,8 @@ describe("DockerNodeOnboardingModal", () => {
|
|||||||
it("shows remote host and manual api key controls", () => {
|
it("shows remote host and manual api key controls", () => {
|
||||||
render(<DockerNodeOnboardingModal {...defaultProps} />);
|
render(<DockerNodeOnboardingModal {...defaultProps} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Remote Docker" }));
|
fireEvent.click(screen.getByRole("button", { name: "Remote Host" }));
|
||||||
expect(screen.getAllByPlaceholderText("tcp://host:2376").length).toBeGreaterThan(0);
|
expect(screen.getByPlaceholderText("tcp://host:2376")).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("radio", { name: "Provide manually" }));
|
fireEvent.click(screen.getByRole("radio", { name: "Provide manually" }));
|
||||||
expect(screen.getByPlaceholderText("Enter API key")).toBeInTheDocument();
|
expect(screen.getByPlaceholderText("Enter API key")).toBeInTheDocument();
|
||||||
@@ -86,8 +84,8 @@ describe("DockerNodeOnboardingModal", () => {
|
|||||||
render(<DockerNodeOnboardingModal {...defaultProps} />);
|
render(<DockerNodeOnboardingModal {...defaultProps} />);
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("my-docker-node"), { target: { value: "docker-a" } });
|
fireEvent.change(screen.getByPlaceholderText("my-docker-node"), { target: { value: "docker-a" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Remote Docker" }));
|
fireEvent.click(screen.getByRole("button", { name: "Remote Host" }));
|
||||||
fireEvent.change(screen.getByPlaceholderText("http://192.168.1.50:4040"), { target: { value: "http://10.0.0.2:4040" } });
|
fireEvent.change(screen.getByPlaceholderText("http://localhost:4040"), { target: { value: "http://10.0.0.2:4040" } });
|
||||||
fireEvent.change(screen.getByPlaceholderText("tcp://host:2376"), { target: { value: "tcp://10.0.0.2:2376" } });
|
fireEvent.change(screen.getByPlaceholderText("tcp://host:2376"), { target: { value: "tcp://10.0.0.2:2376" } });
|
||||||
fireEvent.click(screen.getByRole("checkbox", { name: "Claude CLI" }));
|
fireEvent.click(screen.getByRole("checkbox", { name: "Claude CLI" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Advanced" }));
|
fireEvent.click(screen.getByRole("button", { name: "Advanced" }));
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { DockerTargetSelector } from "../DockerTargetSelector";
|
||||||
|
|
||||||
|
vi.mock("lucide-react", () => ({ RefreshCw: () => null }));
|
||||||
|
|
||||||
|
const hookState = {
|
||||||
|
contexts: [{ name: "default", isCurrentContext: true }],
|
||||||
|
isLoadingContexts: false,
|
||||||
|
contextsError: null,
|
||||||
|
loadContexts: vi.fn().mockResolvedValue(undefined),
|
||||||
|
testConnection: vi.fn().mockResolvedValue({ success: true, dockerVersion: "24.0" }),
|
||||||
|
isTestingConnection: false,
|
||||||
|
lastTestResult: null,
|
||||||
|
checkLocalDocker: vi.fn().mockResolvedValue({ available: true, version: "24.0" }),
|
||||||
|
isCheckingLocal: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useDockerTargets", () => ({ useDockerTargets: () => hookState }));
|
||||||
|
|
||||||
|
describe("DockerTargetSelector", () => {
|
||||||
|
it("renders modes and emits config", async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<DockerTargetSelector onChange={onChange} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Local Docker")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Docker Context")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Remote Host")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Docker Context"));
|
||||||
|
await waitFor(() => expect(hookState.loadContexts).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Remote Host"));
|
||||||
|
expect(screen.getByPlaceholderText("tcp://host:2376")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Test Connection"));
|
||||||
|
await waitFor(() => expect(hookState.testConnection).toHaveBeenCalled());
|
||||||
|
expect(onChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useDockerTargets } from "../useDockerTargets";
|
||||||
|
|
||||||
|
describe("useDockerTargets", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
global.fetch = vi.fn() as unknown as typeof fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads contexts", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue({ ok: true, json: async () => [{ name: "default", isCurrentContext: true }] } as Response);
|
||||||
|
const { result } = renderHook(() => useDockerTargets());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.loadContexts();
|
||||||
|
});
|
||||||
|
expect(result.current.contexts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tests connection", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue({ ok: true, json: async () => ({ success: true, isLocalDaemon: true }) } as Response);
|
||||||
|
const { result } = renderHook(() => useDockerTargets());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.testConnection({ host: "tcp://1.2.3.4:2376" });
|
||||||
|
});
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/docker/test-connection",
|
||||||
|
expect.objectContaining({ method: "POST" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks local docker", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue({ ok: true, json: async () => ({ available: true, version: "24.0" }) } as Response);
|
||||||
|
const { result } = renderHook(() => useDockerTargets());
|
||||||
|
await act(async () => {
|
||||||
|
const response = await result.current.checkLocalDocker();
|
||||||
|
expect(response.available).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
81
packages/dashboard/app/hooks/useDockerTargets.ts
Normal file
81
packages/dashboard/app/hooks/useDockerTargets.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import type { DockerConnectivityResult, DockerContextInfo, DockerHostConfig } from "@fusion/core";
|
||||||
|
|
||||||
|
interface LocalDockerAvailability {
|
||||||
|
available: boolean;
|
||||||
|
version?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDockerTargets() {
|
||||||
|
const [contexts, setContexts] = useState<DockerContextInfo[]>([]);
|
||||||
|
const [isLoadingContexts, setIsLoadingContexts] = useState(false);
|
||||||
|
const [contextsError, setContextsError] = useState<string | null>(null);
|
||||||
|
const [isTestingConnection, setIsTestingConnection] = useState(false);
|
||||||
|
const [lastTestResult, setLastTestResult] = useState<DockerConnectivityResult | null>(null);
|
||||||
|
const [isCheckingLocal, setIsCheckingLocal] = useState(false);
|
||||||
|
|
||||||
|
const loadContexts = useCallback(async () => {
|
||||||
|
setIsLoadingContexts(true);
|
||||||
|
setContextsError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/docker/contexts");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load Docker contexts (${response.status})`);
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as DockerContextInfo[];
|
||||||
|
setContexts(payload);
|
||||||
|
return payload;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setContextsError(message);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsLoadingContexts(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const testConnection = useCallback(async (hostConfig?: DockerHostConfig) => {
|
||||||
|
setIsTestingConnection(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/docker/test-connection", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ hostConfig }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to test Docker connection (${response.status})`);
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as DockerConnectivityResult;
|
||||||
|
setLastTestResult(payload);
|
||||||
|
return payload;
|
||||||
|
} finally {
|
||||||
|
setIsTestingConnection(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkLocalDocker = useCallback(async () => {
|
||||||
|
setIsCheckingLocal(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/docker/local-available");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to check local Docker availability (${response.status})`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as LocalDockerAvailability;
|
||||||
|
} finally {
|
||||||
|
setIsCheckingLocal(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contexts,
|
||||||
|
isLoadingContexts,
|
||||||
|
contextsError,
|
||||||
|
loadContexts,
|
||||||
|
isTestingConnection,
|
||||||
|
lastTestResult,
|
||||||
|
testConnection,
|
||||||
|
isCheckingLocal,
|
||||||
|
checkLocalDocker,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createApiRoutes } from "../../routes.js";
|
||||||
|
import { request } from "../../test-request.js";
|
||||||
|
|
||||||
|
const service = {
|
||||||
|
listContexts: vi.fn(),
|
||||||
|
testConnection: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("@fusion/core", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||||
|
return { ...actual, DockerClientService: vi.fn().mockImplementation(() => service) };
|
||||||
|
});
|
||||||
|
|
||||||
|
function createStore() {
|
||||||
|
return {
|
||||||
|
getTask: vi.fn(),
|
||||||
|
listTasks: vi.fn().mockResolvedValue([]),
|
||||||
|
getSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
getSettingsFast: vi.fn().mockResolvedValue({}),
|
||||||
|
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
|
||||||
|
getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: {}, project: {} }),
|
||||||
|
getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })),
|
||||||
|
getRootDir: vi.fn().mockReturnValue("/tmp"),
|
||||||
|
getFusionDir: vi.fn().mockReturnValue("/tmp/.fusion"),
|
||||||
|
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||||
|
getMissionStore: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function app() {
|
||||||
|
const server = express();
|
||||||
|
server.use(express.json());
|
||||||
|
server.use("/api", createApiRoutes(createStore()));
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("registerDockerNodeRoutes", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("GET /api/docker/contexts returns contexts", async () => {
|
||||||
|
service.listContexts.mockResolvedValue([{ name: "default", isCurrentContext: true }]);
|
||||||
|
const res = await request(app(), "GET", "/api/docker/contexts");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual([{ name: "default", isCurrentContext: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/docker/test-connection validates host protocol", async () => {
|
||||||
|
const res = await request(app(), "POST", "/api/docker/test-connection", JSON.stringify({ hostConfig: { host: "http://bad" } }), { "Content-Type": "application/json" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/docker/test-connection passes hostConfig", async () => {
|
||||||
|
service.testConnection.mockResolvedValue({ success: true, isLocalDaemon: false });
|
||||||
|
const hostConfig = { host: "tcp://1.2.3.4:2376", tlsVerify: true };
|
||||||
|
const res = await request(app(), "POST", "/api/docker/test-connection", JSON.stringify({ hostConfig }), { "Content-Type": "application/json" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(service.testConnection).toHaveBeenCalledWith(hostConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/docker/local-available maps connection", async () => {
|
||||||
|
service.testConnection.mockResolvedValue({ success: true, isLocalDaemon: true, dockerVersion: "24.0" });
|
||||||
|
const res = await request(app(), "GET", "/api/docker/local-available");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ available: true, version: "24.0" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/docker/local-available handles errors", async () => {
|
||||||
|
service.testConnection.mockRejectedValue(new Error("boom"));
|
||||||
|
const res = await request(app(), "GET", "/api/docker/local-available");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((res.body as any).available).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -57,6 +57,50 @@ function sanitizeExtraClis(input: unknown): DockerExtraCli[] {
|
|||||||
export const registerDockerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
export const registerDockerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||||
const { router, rethrowAsApiError } = ctx;
|
const { router, rethrowAsApiError } = ctx;
|
||||||
|
|
||||||
|
router.get("/docker/contexts", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const { DockerClientService } = await import("@fusion/core");
|
||||||
|
const service = new DockerClientService();
|
||||||
|
const contexts = await service.listContexts();
|
||||||
|
res.json(contexts);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof ApiError) throw error;
|
||||||
|
rethrowAsApiError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/docker/test-connection", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const hostConfig = ((req.body ?? {}) as { hostConfig?: DockerHostConfig }).hostConfig;
|
||||||
|
if (hostConfig?.host && !/^(tcp|unix|npipe):\/\//.test(hostConfig.host)) {
|
||||||
|
throw badRequest("hostConfig.host must start with tcp://, unix://, or npipe://");
|
||||||
|
}
|
||||||
|
if (hostConfig?.context !== undefined && typeof hostConfig.context === "string" && hostConfig.context.trim() === "") {
|
||||||
|
throw badRequest("hostConfig.context must be a non-empty string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { DockerClientService } = await import("@fusion/core");
|
||||||
|
const service = new DockerClientService();
|
||||||
|
const result = await service.testConnection(hostConfig);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof ApiError) throw error;
|
||||||
|
rethrowAsApiError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/docker/local-available", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const { DockerClientService } = await import("@fusion/core");
|
||||||
|
const service = new DockerClientService();
|
||||||
|
const result = await service.testConnection();
|
||||||
|
res.json({ available: result.success, version: result.dockerVersion, error: result.error });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
res.json({ available: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/docker-nodes", async (_req, res) => {
|
router.get("/docker-nodes", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const { CentralCore } = await import("@fusion/core");
|
||||||
|
|||||||
185
pnpm-lock.yaml
generated
185
pnpm-lock.yaml
generated
@@ -123,6 +123,9 @@ importers:
|
|||||||
cron-parser:
|
cron-parser:
|
||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
version: 5.5.0
|
version: 5.5.0
|
||||||
|
dockerode:
|
||||||
|
specifier: ^4.0.2
|
||||||
|
version: 4.0.12
|
||||||
extract-zip:
|
extract-zip:
|
||||||
specifier: ^2.0.1
|
specifier: ^2.0.1
|
||||||
version: 2.0.1
|
version: 2.0.1
|
||||||
@@ -133,6 +136,9 @@ importers:
|
|||||||
specifier: ^2.8.3
|
specifier: ^2.8.3
|
||||||
version: 2.8.3
|
version: 2.8.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/dockerode':
|
||||||
|
specifier: ^3.3.41
|
||||||
|
version: 3.3.47
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.5.0
|
specifier: ^25.5.0
|
||||||
version: 25.5.0
|
version: 25.5.0
|
||||||
@@ -1021,6 +1027,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@balena/dockerignore@1.0.2':
|
||||||
|
resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==}
|
||||||
|
|
||||||
'@bcoe/v8-coverage@1.0.2':
|
'@bcoe/v8-coverage@1.0.2':
|
||||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1629,6 +1638,20 @@ packages:
|
|||||||
'@modelcontextprotocol/sdk':
|
'@modelcontextprotocol/sdk':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@grpc/grpc-js@1.14.3':
|
||||||
|
resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==}
|
||||||
|
engines: {node: '>=12.10.0'}
|
||||||
|
|
||||||
|
'@grpc/proto-loader@0.7.15':
|
||||||
|
resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
'@grpc/proto-loader@0.8.0':
|
||||||
|
resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@homebridge/node-pty-prebuilt-multiarch@0.13.1':
|
'@homebridge/node-pty-prebuilt-multiarch@0.13.1':
|
||||||
resolution: {integrity: sha512-ccQ60nMcbEGrQh0U9E6x0ajW9qJNeazpcM/9CH6J8leyNtJgb+gu24WTBAfBUVeO486ZhscnaxLEITI2HXwhow==}
|
resolution: {integrity: sha512-ccQ60nMcbEGrQh0U9E6x0ajW9qJNeazpcM/9CH6J8leyNtJgb+gu24WTBAfBUVeO486ZhscnaxLEITI2HXwhow==}
|
||||||
engines: {node: '>=18.0.0 <25.0.0'}
|
engines: {node: '>=18.0.0 <25.0.0'}
|
||||||
@@ -1844,6 +1867,9 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@js-sdsl/ordered-map@4.4.2':
|
||||||
|
resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==}
|
||||||
|
|
||||||
'@leichtgewicht/ip-codec@2.0.5':
|
'@leichtgewicht/ip-codec@2.0.5':
|
||||||
resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==}
|
resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==}
|
||||||
|
|
||||||
@@ -2658,6 +2684,12 @@ packages:
|
|||||||
'@types/deep-eql@4.0.2':
|
'@types/deep-eql@4.0.2':
|
||||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
|
'@types/docker-modem@3.0.6':
|
||||||
|
resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==}
|
||||||
|
|
||||||
|
'@types/dockerode@3.3.47':
|
||||||
|
resolution: {integrity: sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw==}
|
||||||
|
|
||||||
'@types/estree-jsx@1.0.5':
|
'@types/estree-jsx@1.0.5':
|
||||||
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
|
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
|
||||||
|
|
||||||
@@ -2706,6 +2738,9 @@ packages:
|
|||||||
'@types/node@12.20.55':
|
'@types/node@12.20.55':
|
||||||
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
|
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
|
||||||
|
|
||||||
|
'@types/node@18.19.130':
|
||||||
|
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
||||||
|
|
||||||
'@types/node@22.19.15':
|
'@types/node@22.19.15':
|
||||||
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
|
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
|
||||||
|
|
||||||
@@ -2753,6 +2788,9 @@ packages:
|
|||||||
'@types/slice-ansi@4.0.0':
|
'@types/slice-ansi@4.0.0':
|
||||||
resolution: {integrity: sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==}
|
resolution: {integrity: sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==}
|
||||||
|
|
||||||
|
'@types/ssh2@1.15.5':
|
||||||
|
resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
|
||||||
|
|
||||||
'@types/unist@2.0.11':
|
'@types/unist@2.0.11':
|
||||||
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
||||||
|
|
||||||
@@ -3015,6 +3053,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
|
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
asn1@0.2.6:
|
||||||
|
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
|
||||||
|
|
||||||
assert-plus@1.0.0:
|
assert-plus@1.0.0:
|
||||||
resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==}
|
resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
@@ -3123,6 +3164,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==}
|
resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
|
bcrypt-pbkdf@1.0.2:
|
||||||
|
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
|
||||||
|
|
||||||
better-path-resolve@1.0.0:
|
better-path-resolve@1.0.0:
|
||||||
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
|
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -3196,6 +3240,10 @@ packages:
|
|||||||
buffer@6.0.3:
|
buffer@6.0.3:
|
||||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||||
|
|
||||||
|
buildcheck@0.0.7:
|
||||||
|
resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
builder-util-runtime@9.5.1:
|
builder-util-runtime@9.5.1:
|
||||||
resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==}
|
resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -3458,6 +3506,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
|
cpu-features@0.0.10:
|
||||||
|
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
crc-32@1.2.2:
|
crc-32@1.2.2:
|
||||||
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
@@ -3625,6 +3677,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==}
|
resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
docker-modem@5.0.7:
|
||||||
|
resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==}
|
||||||
|
engines: {node: '>= 8.0'}
|
||||||
|
|
||||||
|
dockerode@4.0.12:
|
||||||
|
resolution: {integrity: sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==}
|
||||||
|
engines: {node: '>= 8.0'}
|
||||||
|
|
||||||
dom-accessibility-api@0.5.16:
|
dom-accessibility-api@0.5.16:
|
||||||
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
||||||
|
|
||||||
@@ -4572,6 +4632,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
lodash.camelcase@4.3.0:
|
||||||
|
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
||||||
|
|
||||||
lodash.defaults@4.2.0:
|
lodash.defaults@4.2.0:
|
||||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||||
|
|
||||||
@@ -4939,6 +5002,9 @@ packages:
|
|||||||
mz@2.7.0:
|
mz@2.7.0:
|
||||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||||
|
|
||||||
|
nan@2.26.2:
|
||||||
|
resolution: {integrity: sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==}
|
||||||
|
|
||||||
nanoid@3.3.11:
|
nanoid@3.3.11:
|
||||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
@@ -5681,6 +5747,9 @@ packages:
|
|||||||
spawndamnit@3.0.1:
|
spawndamnit@3.0.1:
|
||||||
resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==}
|
resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==}
|
||||||
|
|
||||||
|
split-ca@1.0.1:
|
||||||
|
resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==}
|
||||||
|
|
||||||
split2@4.2.0:
|
split2@4.2.0:
|
||||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
engines: {node: '>= 10.x'}
|
engines: {node: '>= 10.x'}
|
||||||
@@ -5691,6 +5760,10 @@ packages:
|
|||||||
sprintf-js@1.1.3:
|
sprintf-js@1.1.3:
|
||||||
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
|
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
|
||||||
|
|
||||||
|
ssh2@1.17.0:
|
||||||
|
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
|
||||||
|
engines: {node: '>=10.16.0'}
|
||||||
|
|
||||||
ssri@12.0.0:
|
ssri@12.0.0:
|
||||||
resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==}
|
resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==}
|
||||||
engines: {node: ^18.17.0 || >=20.5.0}
|
engines: {node: ^18.17.0 || >=20.5.0}
|
||||||
@@ -5983,6 +6056,9 @@ packages:
|
|||||||
tunnel-agent@0.6.0:
|
tunnel-agent@0.6.0:
|
||||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||||
|
|
||||||
|
tweetnacl@0.14.5:
|
||||||
|
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -6032,6 +6108,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
|
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
undici-types@5.26.5:
|
||||||
|
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||||
|
|
||||||
undici-types@6.21.0:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
@@ -6099,6 +6178,11 @@ packages:
|
|||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
|
uuid@10.0.0:
|
||||||
|
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
||||||
|
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
uuid@14.0.0:
|
uuid@14.0.0:
|
||||||
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
|
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -7312,6 +7396,8 @@ snapshots:
|
|||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
|
'@balena/dockerignore@1.0.2': {}
|
||||||
|
|
||||||
'@bcoe/v8-coverage@1.0.2': {}
|
'@bcoe/v8-coverage@1.0.2': {}
|
||||||
|
|
||||||
'@borewit/text-codec@0.2.2': {}
|
'@borewit/text-codec@0.2.2': {}
|
||||||
@@ -7998,6 +8084,25 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
|
|
||||||
|
'@grpc/grpc-js@1.14.3':
|
||||||
|
dependencies:
|
||||||
|
'@grpc/proto-loader': 0.8.0
|
||||||
|
'@js-sdsl/ordered-map': 4.4.2
|
||||||
|
|
||||||
|
'@grpc/proto-loader@0.7.15':
|
||||||
|
dependencies:
|
||||||
|
lodash.camelcase: 4.3.0
|
||||||
|
long: 5.3.2
|
||||||
|
protobufjs: 7.5.4
|
||||||
|
yargs: 17.7.2
|
||||||
|
|
||||||
|
'@grpc/proto-loader@0.8.0':
|
||||||
|
dependencies:
|
||||||
|
lodash.camelcase: 4.3.0
|
||||||
|
long: 5.3.2
|
||||||
|
protobufjs: 7.5.4
|
||||||
|
yargs: 17.7.2
|
||||||
|
|
||||||
'@homebridge/node-pty-prebuilt-multiarch@0.13.1':
|
'@homebridge/node-pty-prebuilt-multiarch@0.13.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
node-addon-api: 7.1.1
|
node-addon-api: 7.1.1
|
||||||
@@ -8212,6 +8317,8 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@js-sdsl/ordered-map@4.4.2': {}
|
||||||
|
|
||||||
'@leichtgewicht/ip-codec@2.0.5': {}
|
'@leichtgewicht/ip-codec@2.0.5': {}
|
||||||
|
|
||||||
'@lezer/common@0.16.1': {}
|
'@lezer/common@0.16.1': {}
|
||||||
@@ -9489,6 +9596,17 @@ snapshots:
|
|||||||
|
|
||||||
'@types/deep-eql@4.0.2': {}
|
'@types/deep-eql@4.0.2': {}
|
||||||
|
|
||||||
|
'@types/docker-modem@3.0.6':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 25.5.2
|
||||||
|
'@types/ssh2': 1.15.5
|
||||||
|
|
||||||
|
'@types/dockerode@3.3.47':
|
||||||
|
dependencies:
|
||||||
|
'@types/docker-modem': 3.0.6
|
||||||
|
'@types/node': 25.5.2
|
||||||
|
'@types/ssh2': 1.15.5
|
||||||
|
|
||||||
'@types/estree-jsx@1.0.5':
|
'@types/estree-jsx@1.0.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
@@ -9544,6 +9662,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/node@12.20.55': {}
|
'@types/node@12.20.55': {}
|
||||||
|
|
||||||
|
'@types/node@18.19.130':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 5.26.5
|
||||||
|
|
||||||
'@types/node@22.19.15':
|
'@types/node@22.19.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
@@ -9599,6 +9721,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/slice-ansi@4.0.0': {}
|
'@types/slice-ansi@4.0.0': {}
|
||||||
|
|
||||||
|
'@types/ssh2@1.15.5':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 18.19.130
|
||||||
|
|
||||||
'@types/unist@2.0.11': {}
|
'@types/unist@2.0.11': {}
|
||||||
|
|
||||||
'@types/unist@3.0.3': {}
|
'@types/unist@3.0.3': {}
|
||||||
@@ -9982,6 +10108,10 @@ snapshots:
|
|||||||
|
|
||||||
array-union@2.1.0: {}
|
array-union@2.1.0: {}
|
||||||
|
|
||||||
|
asn1@0.2.6:
|
||||||
|
dependencies:
|
||||||
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
assert-plus@1.0.0:
|
assert-plus@1.0.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -10055,6 +10185,10 @@ snapshots:
|
|||||||
|
|
||||||
basic-ftp@5.2.0: {}
|
basic-ftp@5.2.0: {}
|
||||||
|
|
||||||
|
bcrypt-pbkdf@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
tweetnacl: 0.14.5
|
||||||
|
|
||||||
better-path-resolve@1.0.0:
|
better-path-resolve@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-windows: 1.0.2
|
is-windows: 1.0.2
|
||||||
@@ -10144,6 +10278,9 @@ snapshots:
|
|||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
ieee754: 1.2.1
|
ieee754: 1.2.1
|
||||||
|
|
||||||
|
buildcheck@0.0.7:
|
||||||
|
optional: true
|
||||||
|
|
||||||
builder-util-runtime@9.5.1:
|
builder-util-runtime@9.5.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@@ -10411,6 +10548,12 @@ snapshots:
|
|||||||
object-assign: 4.1.1
|
object-assign: 4.1.1
|
||||||
vary: 1.1.2
|
vary: 1.1.2
|
||||||
|
|
||||||
|
cpu-features@0.0.10:
|
||||||
|
dependencies:
|
||||||
|
buildcheck: 0.0.7
|
||||||
|
nan: 2.26.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
crc-32@1.2.2: {}
|
crc-32@1.2.2: {}
|
||||||
|
|
||||||
crc32-stream@6.0.0:
|
crc32-stream@6.0.0:
|
||||||
@@ -10573,6 +10716,27 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@leichtgewicht/ip-codec': 2.0.5
|
'@leichtgewicht/ip-codec': 2.0.5
|
||||||
|
|
||||||
|
docker-modem@5.0.7:
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
readable-stream: 3.6.2
|
||||||
|
split-ca: 1.0.1
|
||||||
|
ssh2: 1.17.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
dockerode@4.0.12:
|
||||||
|
dependencies:
|
||||||
|
'@balena/dockerignore': 1.0.2
|
||||||
|
'@grpc/grpc-js': 1.14.3
|
||||||
|
'@grpc/proto-loader': 0.7.15
|
||||||
|
docker-modem: 5.0.7
|
||||||
|
protobufjs: 7.5.4
|
||||||
|
tar-fs: 2.1.4
|
||||||
|
uuid: 10.0.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
dom-accessibility-api@0.5.16: {}
|
dom-accessibility-api@0.5.16: {}
|
||||||
|
|
||||||
dom-accessibility-api@0.6.3: {}
|
dom-accessibility-api@0.6.3: {}
|
||||||
@@ -11721,6 +11885,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
|
|
||||||
|
lodash.camelcase@4.3.0: {}
|
||||||
|
|
||||||
lodash.defaults@4.2.0: {}
|
lodash.defaults@4.2.0: {}
|
||||||
|
|
||||||
lodash.escaperegexp@4.1.2: {}
|
lodash.escaperegexp@4.1.2: {}
|
||||||
@@ -12279,6 +12445,9 @@ snapshots:
|
|||||||
object-assign: 4.1.1
|
object-assign: 4.1.1
|
||||||
thenify-all: 1.6.0
|
thenify-all: 1.6.0
|
||||||
|
|
||||||
|
nan@2.26.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
napi-build-utils@2.0.0: {}
|
napi-build-utils@2.0.0: {}
|
||||||
@@ -13139,6 +13308,8 @@ snapshots:
|
|||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
signal-exit: 4.1.0
|
signal-exit: 4.1.0
|
||||||
|
|
||||||
|
split-ca@1.0.1: {}
|
||||||
|
|
||||||
split2@4.2.0: {}
|
split2@4.2.0: {}
|
||||||
|
|
||||||
sprintf-js@1.0.3: {}
|
sprintf-js@1.0.3: {}
|
||||||
@@ -13146,6 +13317,14 @@ snapshots:
|
|||||||
sprintf-js@1.1.3:
|
sprintf-js@1.1.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
ssh2@1.17.0:
|
||||||
|
dependencies:
|
||||||
|
asn1: 0.2.6
|
||||||
|
bcrypt-pbkdf: 1.0.2
|
||||||
|
optionalDependencies:
|
||||||
|
cpu-features: 0.0.10
|
||||||
|
nan: 2.26.2
|
||||||
|
|
||||||
ssri@12.0.0:
|
ssri@12.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
minipass: 7.1.3
|
minipass: 7.1.3
|
||||||
@@ -13468,6 +13647,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
tweetnacl@0.14.5: {}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@@ -13513,6 +13694,8 @@ snapshots:
|
|||||||
|
|
||||||
uint8array-extras@1.5.0: {}
|
uint8array-extras@1.5.0: {}
|
||||||
|
|
||||||
|
undici-types@5.26.5: {}
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
undici-types@7.18.2: {}
|
undici-types@7.18.2: {}
|
||||||
@@ -13582,6 +13765,8 @@ snapshots:
|
|||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
|
uuid@10.0.0: {}
|
||||||
|
|
||||||
uuid@14.0.0: {}
|
uuid@14.0.0: {}
|
||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user