feat(FN-3107): merge fusion/fn-3107

This merge lands the managed Docker nodes feature (FN-3107) end-to-end: core schema migration for nodes with types, CRUD operations in CentralCore, and dashboard UI (AddNodeModal, NodeDetailModal) with accessibility fixes and tokenized CSS. Also included are manual PR linking (FN-3202), plugin schem

Fusion-Task-Id: FN-3107
This commit is contained in:
Fusion
2026-05-02 11:17:18 -07:00
committed by gsxdsm
parent ffb71b7912
commit d9835970e1
15 changed files with 1411 additions and 357 deletions

View File

@@ -0,0 +1,156 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CentralCore } from "../central-core.js";
describe("CentralCore managed Docker nodes", () => {
let tempDir: string;
let central: CentralCore;
beforeEach(async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-01T10:00:00.000Z"));
tempDir = mkdtempSync(join(tmpdir(), "kb-central-docker-node-test-"));
central = new CentralCore(tempDir);
await central.init();
});
afterEach(async () => {
await central.close();
vi.useRealTimers();
vi.restoreAllMocks();
rmSync(tempDir, { recursive: true, force: true });
});
const buildInput = (name: string) => ({
nodeId: null,
name,
imageName: "runfusion/fusion",
imageTag: "latest",
hostConfig: { context: "default", tlsVerify: false },
envVars: { FUSION_NODE_NAME: name, FUSION_MODE: "managed" },
volumeMounts: [{ hostPath: "/var/lib/fusion", containerPath: "/data", mode: "rw" as const }],
resourceSizing: { memoryMB: 4096, cpus: 2, memorySwapMB: 0 },
extraClis: ["droid-cli" as const],
persistentStorage: true,
reachableUrl: "http://127.0.0.1:4041",
apiKey: "secret-key",
});
it("createManagedDockerNode creates full record with dn_ id and creating status", async () => {
const created = await central.createManagedDockerNode(buildInput("docker-a"));
expect(created.id.startsWith("dn_")).toBe(true);
expect(created.name).toBe("docker-a");
expect(created.status).toBe("creating");
expect(created.containerId).toBeNull();
expect(created.errorMessage).toBeNull();
});
it("createManagedDockerNode enforces unique names", async () => {
await central.createManagedDockerNode(buildInput("docker-unique"));
await expect(central.createManagedDockerNode(buildInput("docker-unique"))).rejects.toThrow(
"already exists with name",
);
});
it("createManagedDockerNode validates required name", async () => {
await expect(central.createManagedDockerNode(buildInput(" "))).rejects.toThrow(
"between 1 and 64 characters",
);
});
it("getManagedDockerNode returns found and undefined for missing", async () => {
const created = await central.createManagedDockerNode(buildInput("docker-get"));
await expect(central.getManagedDockerNode(created.id)).resolves.toBeDefined();
await expect(central.getManagedDockerNode("dn_missing")).resolves.toBeUndefined();
});
it("getManagedDockerNodeByName returns found and undefined for missing", async () => {
const created = await central.createManagedDockerNode(buildInput("docker-by-name"));
const found = await central.getManagedDockerNodeByName("docker-by-name");
expect(found?.id).toBe(created.id);
await expect(central.getManagedDockerNodeByName("missing-name")).resolves.toBeUndefined();
});
it("listManagedDockerNodes returns all ordered by name", async () => {
await central.createManagedDockerNode(buildInput("zeta"));
await central.createManagedDockerNode(buildInput("alpha"));
const list = await central.listManagedDockerNodes();
expect(list.map((item) => item.name)).toEqual(["alpha", "zeta"]);
});
it("updateManagedDockerNode applies partial changes and updates updatedAt", async () => {
const created = await central.createManagedDockerNode(buildInput("docker-update"));
vi.setSystemTime(new Date("2026-05-01T10:05:00.000Z"));
const updated = await central.updateManagedDockerNode(created.id, {
status: "running",
envVars: { ...created.envVars, EXTRA: "1" },
volumeMounts: [
...created.volumeMounts,
{ hostPath: "/var/log/fusion", containerPath: "/logs", mode: "ro" },
],
});
expect(updated.status).toBe("running");
expect(updated.envVars.EXTRA).toBe("1");
expect(updated.volumeMounts).toHaveLength(2);
expect(updated.updatedAt).not.toBe(created.updatedAt);
});
it("updateManagedDockerNode throws for unknown id", async () => {
await expect(central.updateManagedDockerNode("dn_missing", { status: "error" })).rejects.toThrow(
"not found",
);
});
it("deleteManagedDockerNode removes record", async () => {
const created = await central.createManagedDockerNode(buildInput("docker-delete"));
await central.deleteManagedDockerNode(created.id);
await expect(central.getManagedDockerNode(created.id)).resolves.toBeUndefined();
});
it("linkManagedDockerNodeToNode sets nodeId", async () => {
const managed = await central.createManagedDockerNode(buildInput("docker-link"));
const node = await central.registerNode({
name: "remote-link-target",
type: "remote",
url: "http://127.0.0.1:5050",
apiKey: "remote-key",
});
const linked = await central.linkManagedDockerNodeToNode(managed.id, node.id);
expect(linked.nodeId).toBe(node.id);
});
it("JSON fields round-trip through storage", async () => {
const created = await central.createManagedDockerNode({
...buildInput("docker-json"),
hostConfig: {
host: "tcp://192.168.1.50:2376",
context: "prod",
tlsVerify: true,
tlsCaPath: "/certs/ca.pem",
tlsCertPath: "/certs/cert.pem",
tlsKeyPath: "/certs/key.pem",
},
extraClis: ["claude-cli", "droid-cli"],
});
const fetched = await central.getManagedDockerNode(created.id);
expect(fetched?.hostConfig).toEqual(created.hostConfig);
expect(fetched?.envVars).toEqual(created.envVars);
expect(fetched?.volumeMounts).toEqual(created.volumeMounts);
expect(fetched?.resourceSizing).toEqual(created.resourceSizing);
expect(fetched?.extraClis).toEqual(created.extraClis);
});
});

View File

@@ -39,7 +39,7 @@ describe("CentralDatabase", () => {
it("should initialize schema version", () => {
db.init();
expect(db.getSchemaVersion()).toBe(5);
expect(db.getSchemaVersion()).toBe(6);
});
it("should seed lastModified on init", () => {
@@ -213,7 +213,7 @@ describe("CentralDatabase", () => {
db.init();
expect(db.getSchemaVersion()).toBe(5);
expect(db.getSchemaVersion()).toBe(6);
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
const nodeColumnNames = nodeColumns.map((column) => column.name);
@@ -278,7 +278,7 @@ describe("CentralDatabase", () => {
db.init();
expect(db.getSchemaVersion()).toBe(5);
expect(db.getSchemaVersion()).toBe(6);
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
const nodeColumnNames = nodeColumns.map((column) => column.name);
@@ -294,6 +294,137 @@ describe("CentralDatabase", () => {
expect(row?.versionInfo).toBeNull();
expect(row?.pluginVersions).toBeNull();
});
it("should migrate from v5 to v6 with managed Docker node schema", () => {
const now = new Date().toISOString();
db.exec(`
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active',
isolationMode TEXT NOT NULL DEFAULT 'in-process',
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
lastActivityAt TEXT,
nodeId TEXT,
settings TEXT
);
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL CHECK (type IN ('local', 'remote')),
url TEXT,
apiKey TEXT,
status TEXT NOT NULL DEFAULT 'offline',
capabilities TEXT,
systemMetrics TEXT,
knownPeers TEXT,
versionInfo TEXT,
pluginVersions TEXT,
maxConcurrent INTEGER NOT NULL DEFAULT 2,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS peerNodes (
id TEXT PRIMARY KEY,
nodeId TEXT NOT NULL,
peerNodeId TEXT NOT NULL,
name TEXT NOT NULL,
url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'unknown',
lastSeen TEXT NOT NULL,
connectedAt TEXT NOT NULL,
UNIQUE(nodeId, peerNodeId),
FOREIGN KEY (nodeId) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settingsSyncState (
nodeId TEXT NOT NULL,
remoteNodeId TEXT NOT NULL,
lastSyncedAt TEXT,
localChecksum TEXT,
remoteChecksum TEXT,
syncCount INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (nodeId, remoteNodeId),
FOREIGN KEY (nodeId) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS __meta (
key TEXT PRIMARY KEY,
value TEXT
);
`);
db.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '5')").run();
db.prepare("INSERT INTO __meta (key, value) VALUES ('lastModified', ?)").run(String(Date.now()));
db.init();
expect(db.getSchemaVersion()).toBe(6);
const columns = db.prepare("PRAGMA table_info(managedDockerNodes)").all() as Array<{ name: string }>;
const columnNames = columns.map((column) => column.name);
expect(columnNames).toEqual(
expect.arrayContaining([
"id",
"nodeId",
"name",
"imageName",
"imageTag",
"containerId",
"status",
"hostConfig",
"envVars",
"volumeMounts",
"resourceSizing",
"extraClis",
"persistentStorage",
"reachableUrl",
"apiKey",
"errorMessage",
"createdAt",
"updatedAt",
]),
);
const indexes = db
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='managedDockerNodes'")
.all() as Array<{ name: string }>;
const indexNames = indexes.map((index) => index.name);
expect(indexNames).toContain("idxManagedDockerNodesStatus");
expect(indexNames).toContain("idxManagedDockerNodesNodeId");
db.prepare(
"INSERT INTO managedDockerNodes (id, name, imageName, imageTag, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)",
).run("dn_test_defaults", "docker-defaults", "runfusion/fusion", "latest", now, now);
const row = db.prepare(
"SELECT status, hostConfig, envVars, volumeMounts, resourceSizing, extraClis FROM managedDockerNodes WHERE id = ?",
).get("dn_test_defaults") as
| {
status: string;
hostConfig: string;
envVars: string;
volumeMounts: string;
resourceSizing: string;
extraClis: string;
}
| undefined;
expect(row).toBeDefined();
expect(row?.status).toBe("creating");
expect(fromJson(row?.hostConfig, {})).toEqual({});
expect(fromJson(row?.envVars, {})).toEqual({});
expect(fromJson(row?.volumeMounts, [])).toEqual([]);
expect(fromJson(row?.resourceSizing, {})).toEqual({});
expect(fromJson(row?.extraClis, [])).toEqual([]);
});
});
describe("transactions", () => {