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:
156
packages/core/src/__tests__/central-core-docker-node.test.ts
Normal file
156
packages/core/src/__tests__/central-core-docker-node.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user