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", () => {
|
||||
|
||||
@@ -52,6 +52,11 @@ import type {
|
||||
DiscoveredNode,
|
||||
NodeVersionInfo,
|
||||
NodeVersionInfoInput,
|
||||
DockerNodeStatus,
|
||||
DockerHostConfig,
|
||||
ManagedDockerNode,
|
||||
ManagedDockerNodeInput,
|
||||
ManagedDockerNodeUpdate,
|
||||
PluginSyncResult,
|
||||
VersionCompatibilityResult,
|
||||
SettingsSyncPayload,
|
||||
@@ -826,6 +831,212 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a managed Docker node record.
|
||||
*/
|
||||
async createManagedDockerNode(input: ManagedDockerNodeInput): Promise<ManagedDockerNode> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const name = input.name.trim();
|
||||
if (!name || name.length > 64) {
|
||||
throw new Error("Managed Docker node name must be between 1 and 64 characters");
|
||||
}
|
||||
|
||||
const existingByName = await this.getManagedDockerNodeByName(name);
|
||||
if (existingByName) {
|
||||
throw new Error(`Managed Docker node already exists with name: ${name}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const node: ManagedDockerNode = {
|
||||
id: `dn_${randomUUID().replace(/-/g, "").slice(0, 16)}`,
|
||||
nodeId: input.nodeId ?? null,
|
||||
name,
|
||||
imageName: input.imageName,
|
||||
imageTag: input.imageTag,
|
||||
containerId: null,
|
||||
status: "creating",
|
||||
hostConfig: input.hostConfig,
|
||||
envVars: input.envVars,
|
||||
volumeMounts: input.volumeMounts,
|
||||
resourceSizing: input.resourceSizing,
|
||||
extraClis: input.extraClis,
|
||||
persistentStorage: input.persistentStorage,
|
||||
reachableUrl: input.reachableUrl ?? null,
|
||||
apiKey: input.apiKey ?? null,
|
||||
errorMessage: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db!.prepare(
|
||||
`INSERT INTO managedDockerNodes (
|
||||
id, nodeId, name, imageName, imageTag, containerId, status,
|
||||
hostConfig, envVars, volumeMounts, resourceSizing, extraClis,
|
||||
persistentStorage, reachableUrl, apiKey, errorMessage, createdAt, updatedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
node.id,
|
||||
node.nodeId,
|
||||
node.name,
|
||||
node.imageName,
|
||||
node.imageTag,
|
||||
node.containerId,
|
||||
node.status,
|
||||
toJson(node.hostConfig),
|
||||
toJson(node.envVars),
|
||||
toJson(node.volumeMounts),
|
||||
toJson(node.resourceSizing),
|
||||
toJson(node.extraClis),
|
||||
node.persistentStorage ? 1 : 0,
|
||||
node.reachableUrl,
|
||||
node.apiKey,
|
||||
node.errorMessage,
|
||||
node.createdAt,
|
||||
node.updatedAt,
|
||||
);
|
||||
|
||||
this.db!.bumpLastModified();
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a managed Docker node by ID.
|
||||
*/
|
||||
async getManagedDockerNode(id: string): Promise<ManagedDockerNode | undefined> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const row = this.db!.prepare("SELECT * FROM managedDockerNodes WHERE id = ?").get(id) as
|
||||
| Parameters<CentralCore["rowToManagedDockerNode"]>[0]
|
||||
| undefined;
|
||||
|
||||
return row ? this.rowToManagedDockerNode(row) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a managed Docker node by unique name.
|
||||
*/
|
||||
async getManagedDockerNodeByName(name: string): Promise<ManagedDockerNode | undefined> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const row = this.db!.prepare("SELECT * FROM managedDockerNodes WHERE name = ?").get(name) as
|
||||
| Parameters<CentralCore["rowToManagedDockerNode"]>[0]
|
||||
| undefined;
|
||||
|
||||
return row ? this.rowToManagedDockerNode(row) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* List managed Docker nodes ordered by name.
|
||||
*/
|
||||
async listManagedDockerNodes(): Promise<ManagedDockerNode[]> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const rows = this.db!.prepare("SELECT * FROM managedDockerNodes ORDER BY name").all() as Array<
|
||||
Parameters<CentralCore["rowToManagedDockerNode"]>[0]
|
||||
>;
|
||||
|
||||
return rows.map((row) => this.rowToManagedDockerNode(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a managed Docker node.
|
||||
*/
|
||||
async updateManagedDockerNode(id: string, updates: ManagedDockerNodeUpdate): Promise<ManagedDockerNode> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const existing = await this.getManagedDockerNode(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Managed Docker node not found: ${id}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updated: ManagedDockerNode = {
|
||||
...existing,
|
||||
...updates,
|
||||
id: existing.id,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: now,
|
||||
name: updates.name ? updates.name.trim() : existing.name,
|
||||
};
|
||||
|
||||
if (!updated.name || updated.name.length > 64) {
|
||||
throw new Error("Managed Docker node name must be between 1 and 64 characters");
|
||||
}
|
||||
|
||||
if (updated.name !== existing.name) {
|
||||
const existingByName = await this.getManagedDockerNodeByName(updated.name);
|
||||
if (existingByName && existingByName.id !== id) {
|
||||
throw new Error(`Managed Docker node already exists with name: ${updated.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.db!.prepare(
|
||||
`UPDATE managedDockerNodes SET
|
||||
nodeId = ?,
|
||||
name = ?,
|
||||
imageName = ?,
|
||||
imageTag = ?,
|
||||
containerId = ?,
|
||||
status = ?,
|
||||
hostConfig = ?,
|
||||
envVars = ?,
|
||||
volumeMounts = ?,
|
||||
resourceSizing = ?,
|
||||
extraClis = ?,
|
||||
persistentStorage = ?,
|
||||
reachableUrl = ?,
|
||||
apiKey = ?,
|
||||
errorMessage = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
updated.nodeId,
|
||||
updated.name,
|
||||
updated.imageName,
|
||||
updated.imageTag,
|
||||
updated.containerId,
|
||||
updated.status,
|
||||
toJson(updated.hostConfig),
|
||||
toJson(updated.envVars),
|
||||
toJson(updated.volumeMounts),
|
||||
toJson(updated.resourceSizing),
|
||||
toJson(updated.extraClis),
|
||||
updated.persistentStorage ? 1 : 0,
|
||||
updated.reachableUrl,
|
||||
updated.apiKey,
|
||||
updated.errorMessage,
|
||||
updated.updatedAt,
|
||||
id,
|
||||
);
|
||||
|
||||
this.db!.bumpLastModified();
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a managed Docker node record by ID.
|
||||
*/
|
||||
async deleteManagedDockerNode(id: string): Promise<void> {
|
||||
this.ensureInitialized();
|
||||
this.db!.prepare("DELETE FROM managedDockerNodes WHERE id = ?").run(id);
|
||||
this.db!.bumpLastModified();
|
||||
}
|
||||
|
||||
/**
|
||||
* Link an existing managed Docker node record to a registered mesh node.
|
||||
*/
|
||||
async linkManagedDockerNodeToNode(managedDockerNodeId: string, nodeId: string): Promise<ManagedDockerNode> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const node = await this.getNode(nodeId);
|
||||
if (!node) {
|
||||
throw new Error(`Node not found: ${nodeId}`);
|
||||
}
|
||||
|
||||
return this.updateManagedDockerNode(managedDockerNodeId, { nodeId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check node health and update stored status.
|
||||
*/
|
||||
@@ -2023,6 +2234,48 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
};
|
||||
}
|
||||
|
||||
private rowToManagedDockerNode(row: {
|
||||
id: string;
|
||||
nodeId: string | null;
|
||||
name: string;
|
||||
imageName: string;
|
||||
imageTag: string;
|
||||
containerId: string | null;
|
||||
status: string;
|
||||
hostConfig: string;
|
||||
envVars: string;
|
||||
volumeMounts: string;
|
||||
resourceSizing: string;
|
||||
extraClis: string;
|
||||
persistentStorage: number;
|
||||
reachableUrl: string | null;
|
||||
apiKey: string | null;
|
||||
errorMessage: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}): ManagedDockerNode {
|
||||
return {
|
||||
id: row.id,
|
||||
nodeId: row.nodeId,
|
||||
name: row.name,
|
||||
imageName: row.imageName,
|
||||
imageTag: row.imageTag,
|
||||
containerId: row.containerId,
|
||||
status: row.status as DockerNodeStatus,
|
||||
hostConfig: fromJson<DockerHostConfig>(row.hostConfig) ?? {},
|
||||
envVars: fromJson<Record<string, string>>(row.envVars) ?? {},
|
||||
volumeMounts: fromJson<ManagedDockerNode["volumeMounts"]>(row.volumeMounts) ?? [],
|
||||
resourceSizing: fromJson<ManagedDockerNode["resourceSizing"]>(row.resourceSizing) ?? {},
|
||||
extraClis: fromJson<ManagedDockerNode["extraClis"]>(row.extraClis) ?? [],
|
||||
persistentStorage: row.persistentStorage === 1,
|
||||
reachableUrl: row.reachableUrl,
|
||||
apiKey: row.apiKey,
|
||||
errorMessage: row.errorMessage,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private rowToPeerNode(row: {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
|
||||
@@ -23,7 +23,7 @@ export { toJson, toJsonNullable, fromJson };
|
||||
|
||||
// ── Schema Definition ───────────────────────────────────────────────────
|
||||
|
||||
const CENTRAL_SCHEMA_VERSION = 5;
|
||||
const CENTRAL_SCHEMA_VERSION = 6;
|
||||
|
||||
const CENTRAL_SCHEMA_SQL = `
|
||||
-- Projects table (project registry)
|
||||
@@ -137,6 +137,31 @@ CREATE TABLE IF NOT EXISTS settingsSyncState (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idxSettingsSyncNode ON settingsSyncState(nodeId);
|
||||
|
||||
-- Managed Docker nodes table (Docker-provisioned mesh nodes)
|
||||
CREATE TABLE IF NOT EXISTS managedDockerNodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
nodeId TEXT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
imageName TEXT NOT NULL,
|
||||
imageTag TEXT NOT NULL,
|
||||
containerId TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'creating',
|
||||
hostConfig TEXT NOT NULL DEFAULT '{}',
|
||||
envVars TEXT NOT NULL DEFAULT '{}',
|
||||
volumeMounts TEXT NOT NULL DEFAULT '[]',
|
||||
resourceSizing TEXT NOT NULL DEFAULT '{}',
|
||||
extraClis TEXT NOT NULL DEFAULT '[]',
|
||||
persistentStorage INTEGER NOT NULL DEFAULT 1,
|
||||
reachableUrl TEXT,
|
||||
apiKey TEXT,
|
||||
errorMessage TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
FOREIGN KEY (nodeId) REFERENCES nodes(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idxManagedDockerNodesStatus ON managedDockerNodes(status);
|
||||
CREATE INDEX IF NOT EXISTS idxManagedDockerNodesNodeId ON managedDockerNodes(nodeId);
|
||||
|
||||
-- Schema version tracking
|
||||
CREATE TABLE IF NOT EXISTS __meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -202,6 +227,32 @@ CREATE TABLE IF NOT EXISTS settingsSyncState (
|
||||
CREATE INDEX IF NOT EXISTS idxSettingsSyncNode ON settingsSyncState(nodeId);
|
||||
`;
|
||||
|
||||
const CENTRAL_SCHEMA_V6_MIGRATION_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS managedDockerNodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
nodeId TEXT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
imageName TEXT NOT NULL,
|
||||
imageTag TEXT NOT NULL,
|
||||
containerId TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'creating',
|
||||
hostConfig TEXT NOT NULL DEFAULT '{}',
|
||||
envVars TEXT NOT NULL DEFAULT '{}',
|
||||
volumeMounts TEXT NOT NULL DEFAULT '[]',
|
||||
resourceSizing TEXT NOT NULL DEFAULT '{}',
|
||||
extraClis TEXT NOT NULL DEFAULT '[]',
|
||||
persistentStorage INTEGER NOT NULL DEFAULT 1,
|
||||
reachableUrl TEXT,
|
||||
apiKey TEXT,
|
||||
errorMessage TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
FOREIGN KEY (nodeId) REFERENCES nodes(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idxManagedDockerNodesStatus ON managedDockerNodes(status);
|
||||
CREATE INDEX IF NOT EXISTS idxManagedDockerNodesNodeId ON managedDockerNodes(nodeId);
|
||||
`;
|
||||
|
||||
// ── Central Database Class ────────────────────────────────────────────────
|
||||
|
||||
export class CentralDatabase {
|
||||
@@ -279,6 +330,11 @@ export class CentralDatabase {
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (currentVersion < 6) {
|
||||
this.db.exec(CENTRAL_SCHEMA_V6_MIGRATION_SQL);
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (migrated) {
|
||||
this.db
|
||||
.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
||||
|
||||
@@ -406,6 +406,14 @@ export type {
|
||||
NodeStatus,
|
||||
NodeVersionInfo,
|
||||
NodeVersionInfoInput,
|
||||
DockerNodeStatus,
|
||||
DockerHostConfig,
|
||||
DockerResourceSizing,
|
||||
DockerVolumeMount,
|
||||
DockerExtraCli,
|
||||
ManagedDockerNode,
|
||||
ManagedDockerNodeInput,
|
||||
ManagedDockerNodeUpdate,
|
||||
NodeDiscoveryEvent,
|
||||
DiscoveryConfig,
|
||||
DiscoveredNode,
|
||||
|
||||
@@ -2467,6 +2467,99 @@ export type NodeVersionInfoInput = Omit<NodeVersionInfo, "appVersion"> & {
|
||||
appVersion?: string;
|
||||
};
|
||||
|
||||
/** Lifecycle status of a managed Docker node. */
|
||||
export type DockerNodeStatus = "creating" | "running" | "stopped" | "error" | "recreating" | "deleting";
|
||||
|
||||
/** Docker daemon connection settings for provisioning a managed node container. */
|
||||
export interface DockerHostConfig {
|
||||
/** Docker host URI (for example: tcp://192.168.1.50:2376 or unix:///var/run/docker.sock). */
|
||||
host?: string;
|
||||
/** Named Docker context to target. */
|
||||
context?: string;
|
||||
/** Whether to verify Docker daemon TLS certificates. */
|
||||
tlsVerify?: boolean;
|
||||
/** Path to Docker daemon CA certificate. */
|
||||
tlsCaPath?: string;
|
||||
/** Path to Docker client certificate. */
|
||||
tlsCertPath?: string;
|
||||
/** Path to Docker client private key. */
|
||||
tlsKeyPath?: string;
|
||||
}
|
||||
|
||||
/** Container CPU and memory limit settings for managed Docker nodes. */
|
||||
export interface DockerResourceSizing {
|
||||
/** Memory limit in MB (for example: 4096). */
|
||||
memoryMB?: number;
|
||||
/** CPU limit (for example: 2.0). */
|
||||
cpus?: number;
|
||||
/** Swap limit in MB (0 = unlimited swap, Docker default behavior). */
|
||||
memorySwapMB?: number;
|
||||
}
|
||||
|
||||
/** A single bind mount definition for a managed Docker node container. */
|
||||
export interface DockerVolumeMount {
|
||||
/** Absolute path on the host machine. */
|
||||
hostPath: string;
|
||||
/** Path inside the container. */
|
||||
containerPath: string;
|
||||
/** Mount mode. Defaults to read/write when omitted. */
|
||||
mode?: "ro" | "rw";
|
||||
}
|
||||
|
||||
/** Optional additional CLI tools installed in the managed Docker node image. */
|
||||
export type DockerExtraCli = "claude-cli" | "droid-cli";
|
||||
|
||||
/** Persisted definition and lifecycle metadata for a managed Docker node. */
|
||||
export interface ManagedDockerNode {
|
||||
/** Unique managed Docker node ID (for example: dn_abc123). */
|
||||
id: string;
|
||||
/** Linked mesh node ID after registration, or null while provisioning. */
|
||||
nodeId: string | null;
|
||||
/** Display name (unique across managed Docker nodes). */
|
||||
name: string;
|
||||
/** Docker image repository/name (for example: runfusion/fusion). */
|
||||
imageName: string;
|
||||
/** Docker image tag (for example: latest or 0.2.0). */
|
||||
imageTag: string;
|
||||
/** Provisioned container ID, or null before container creation. */
|
||||
containerId: string | null;
|
||||
/** Current managed Docker lifecycle status. */
|
||||
status: DockerNodeStatus;
|
||||
/** Docker daemon host/context configuration used for operations. */
|
||||
hostConfig: DockerHostConfig;
|
||||
/** Environment variables injected into the container. */
|
||||
envVars: Record<string, string>;
|
||||
/** Bind mounts configured for this container. */
|
||||
volumeMounts: DockerVolumeMount[];
|
||||
/** Resource limits for this container. */
|
||||
resourceSizing: DockerResourceSizing;
|
||||
/** Optional extra CLI tools included in provisioning. */
|
||||
extraClis: DockerExtraCli[];
|
||||
/** Whether storage volumes persist across container recreation. */
|
||||
persistentStorage: boolean;
|
||||
/** Reachable URL for mesh/node registration once running. */
|
||||
reachableUrl: string | null;
|
||||
/** API key for the managed node, auto-generated or user-provided. */
|
||||
apiKey: string | null;
|
||||
/** Last provisioning/runtime error message when status is error. */
|
||||
errorMessage: string | null;
|
||||
/** ISO-8601 creation timestamp. */
|
||||
createdAt: string;
|
||||
/** ISO-8601 last update timestamp. */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Input for creating a managed Docker node record. */
|
||||
export type ManagedDockerNodeInput = Omit<
|
||||
ManagedDockerNode,
|
||||
"id" | "containerId" | "status" | "createdAt" | "updatedAt" | "errorMessage"
|
||||
>;
|
||||
|
||||
/** Partial update payload for managed Docker nodes. */
|
||||
export type ManagedDockerNodeUpdate = Partial<
|
||||
Omit<ManagedDockerNode, "id" | "createdAt">
|
||||
>;
|
||||
|
||||
/** A single plugin's version information for sync comparison */
|
||||
export interface PluginVersionEntry {
|
||||
/** Plugin ID (matches PluginManifest.id) */
|
||||
|
||||
Reference in New Issue
Block a user