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) */
|
||||
|
||||
174
packages/dashboard/app/components/AddNodeModal.css
Normal file
174
packages/dashboard/app/components/AddNodeModal.css
Normal file
@@ -0,0 +1,174 @@
|
||||
.add-node-modal {
|
||||
width: min(calc(var(--space-lg) * 32.5), calc(100vw - (var(--space-lg) * 2)));
|
||||
}
|
||||
|
||||
.add-node-modal__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.add-node-modal__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.add-node-modal__fieldset {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.add-node-modal__fieldset legend {
|
||||
color: var(--text-muted);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.625);
|
||||
padding: 0 var(--space-xs);
|
||||
}
|
||||
|
||||
.add-node-modal__storage-toggle {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.add-node-modal__advanced-btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.add-node-modal__advanced {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.add-node-modal__textarea {
|
||||
min-height: calc(var(--space-2xl) * 2.25);
|
||||
resize: vertical;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.add-node-modal__description {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.625);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-sm);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.add-node-modal__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.add-node-modal__field > span {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.625);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.add-node-modal__field .input {
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
|
||||
}
|
||||
|
||||
.add-node-modal__field .input:focus-visible {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.add-node-modal__field .input[aria-invalid="true"] {
|
||||
border-color: var(--color-error);
|
||||
background: color-mix(in srgb, var(--color-error) 5%, var(--surface));
|
||||
}
|
||||
|
||||
.add-node-modal__field .input[aria-invalid="true"]:focus {
|
||||
box-shadow: var(--glow-danger);
|
||||
}
|
||||
|
||||
.add-node-modal__hint {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.375);
|
||||
color: var(--text-dim);
|
||||
margin-top: calc(var(--space-xs) / 2);
|
||||
}
|
||||
|
||||
.add-node-modal__error {
|
||||
margin-top: calc(var(--space-xs) / 2);
|
||||
}
|
||||
|
||||
.add-node-modal__type-toggle {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.add-node-modal__type-btn {
|
||||
flex: 1;
|
||||
padding: calc(var(--space-sm) + var(--space-xs) / 2) var(--space-lg);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.625);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.add-node-modal__type-btn:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--bg) 50%, var(--surface));
|
||||
}
|
||||
|
||||
.add-node-modal__type-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.add-node-modal__type-btn.active {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.add-node-modal__type-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.add-node-modal__remote-fields {
|
||||
display: grid;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.add-node-modal {
|
||||
width: calc(100vw - (var(--space-md) * 2));
|
||||
}
|
||||
|
||||
.add-node-modal__type-toggle {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.add-node-modal__type-btn {
|
||||
flex: 1;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
}
|
||||
|
||||
.add-node-modal__field input {
|
||||
min-height: calc(var(--space-2xl) + var(--space-md));
|
||||
font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
|
||||
}
|
||||
|
||||
.add-node-modal__row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import "./AddNodeModal.css";
|
||||
|
||||
export interface AddNodeInput {
|
||||
name: string;
|
||||
@@ -7,6 +8,20 @@ export interface AddNodeInput {
|
||||
url?: string;
|
||||
apiKey?: string;
|
||||
maxConcurrent: number;
|
||||
apiKeyMode?: "auto-generate" | "provide";
|
||||
extraClis?: Array<"claude-cli" | "droid-cli">;
|
||||
persistentStorage?: boolean;
|
||||
resourceSizing?: {
|
||||
cpus?: number;
|
||||
memoryMB?: number;
|
||||
};
|
||||
dockerAdvanced?: {
|
||||
host?: string;
|
||||
context?: string;
|
||||
tlsVerify?: boolean;
|
||||
envOverrides?: Record<string, string>;
|
||||
volumeMounts?: Array<{ hostPath: string; containerPath: string; mode: "ro" | "rw" }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface AddNodeModalProps {
|
||||
@@ -49,6 +64,18 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
|
||||
const [url, setUrl] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
||||
const [apiKeyMode, setApiKeyMode] = useState<"auto-generate" | "provide">("auto-generate");
|
||||
const [includeClaudeCli, setIncludeClaudeCli] = useState(false);
|
||||
const [includeDroidCli, setIncludeDroidCli] = useState(false);
|
||||
const [persistentStorage, setPersistentStorage] = useState(true);
|
||||
const [resourceCpus, setResourceCpus] = useState(2);
|
||||
const [resourceMemoryMb, setResourceMemoryMb] = useState(4096);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [dockerHost, setDockerHost] = useState("");
|
||||
const [dockerContext, setDockerContext] = useState("");
|
||||
const [tlsVerify, setTlsVerify] = useState(false);
|
||||
const [advancedEnv, setAdvancedEnv] = useState("");
|
||||
const [advancedMounts, setAdvancedMounts] = useState("");
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
@@ -58,6 +85,18 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
|
||||
setUrl("");
|
||||
setApiKey("");
|
||||
setMaxConcurrent(2);
|
||||
setApiKeyMode("auto-generate");
|
||||
setIncludeClaudeCli(false);
|
||||
setIncludeDroidCli(false);
|
||||
setPersistentStorage(true);
|
||||
setResourceCpus(2);
|
||||
setResourceMemoryMb(4096);
|
||||
setShowAdvanced(false);
|
||||
setDockerHost("");
|
||||
setDockerContext("");
|
||||
setTlsVerify(false);
|
||||
setAdvancedEnv("");
|
||||
setAdvancedMounts("");
|
||||
setErrors({});
|
||||
setIsSubmitting(false);
|
||||
}, []);
|
||||
@@ -87,13 +126,53 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
|
||||
};
|
||||
}, [closeModal, isOpen, resetForm]);
|
||||
|
||||
const input = useMemo<AddNodeInput>(() => ({
|
||||
name: name.trim(),
|
||||
type,
|
||||
url: type === "remote" ? url.trim() || undefined : undefined,
|
||||
apiKey: type === "remote" ? apiKey || undefined : undefined,
|
||||
maxConcurrent,
|
||||
}), [apiKey, maxConcurrent, name, type, url]);
|
||||
const input = useMemo<AddNodeInput>(() => {
|
||||
const parsedEnvOverrides = Object.fromEntries(
|
||||
advancedEnv
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const index = line.indexOf("=");
|
||||
if (index <= 0) return [line, ""];
|
||||
return [line.slice(0, index).trim(), line.slice(index + 1).trim()];
|
||||
})
|
||||
);
|
||||
|
||||
const parsedMounts = advancedMounts
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [hostPath = "", containerPath = "", mode = "rw"] = line.split(":");
|
||||
return { hostPath, containerPath, mode: mode === "ro" ? "ro" : "rw" as "ro" | "rw" };
|
||||
})
|
||||
.filter((mount) => mount.hostPath && mount.containerPath);
|
||||
|
||||
return {
|
||||
name: name.trim(),
|
||||
type,
|
||||
url: type === "remote" ? url.trim() || undefined : undefined,
|
||||
apiKey: type === "remote" && apiKeyMode === "provide" ? apiKey || undefined : undefined,
|
||||
maxConcurrent,
|
||||
apiKeyMode,
|
||||
extraClis: [includeClaudeCli ? "claude-cli" : null, includeDroidCli ? "droid-cli" : null].filter(Boolean) as Array<"claude-cli" | "droid-cli">,
|
||||
persistentStorage,
|
||||
resourceSizing: {
|
||||
cpus: Number.isFinite(resourceCpus) ? resourceCpus : undefined,
|
||||
memoryMB: Number.isFinite(resourceMemoryMb) ? resourceMemoryMb : undefined,
|
||||
},
|
||||
dockerAdvanced: showAdvanced
|
||||
? {
|
||||
host: dockerHost.trim() || undefined,
|
||||
context: dockerContext.trim() || undefined,
|
||||
tlsVerify,
|
||||
envOverrides: parsedEnvOverrides,
|
||||
volumeMounts: parsedMounts,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}, [advancedEnv, advancedMounts, apiKey, apiKeyMode, dockerContext, dockerHost, includeClaudeCli, includeDroidCli, maxConcurrent, name, persistentStorage, resourceCpus, resourceMemoryMb, showAdvanced, tlsVerify, type, url]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (isSubmitting) return;
|
||||
@@ -132,7 +211,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
|
||||
</div>
|
||||
|
||||
<div className="modal-body add-node-modal__body">
|
||||
<p className="add-node-modal__description">Register a node to distribute task execution across machines.</p>
|
||||
<p className="add-node-modal__description">Provision a managed Docker node with guided defaults, then expand Advanced for host/TLS/env/mount overrides.</p>
|
||||
|
||||
<label className="add-node-modal__field">
|
||||
<span>Name</span>
|
||||
@@ -172,33 +251,50 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="add-node-modal__remote-fields" data-testid="remote-fields-container" data-visible={type === "remote"}>
|
||||
<label className="add-node-modal__field">
|
||||
<span>URL</span>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
placeholder="https://node.example.com"
|
||||
disabled={isSubmitting}
|
||||
aria-invalid={Boolean(errors.url)}
|
||||
/>
|
||||
{errors.url && <span className="form-error add-node-modal__error">{errors.url}</span>}
|
||||
</label>
|
||||
{type === "remote" && (
|
||||
<div className="add-node-modal__remote-fields" data-testid="remote-fields-container" data-visible>
|
||||
<label className="add-node-modal__field">
|
||||
<span>Reachable URL / Hostname</span>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
placeholder="https://node.example.com"
|
||||
disabled={isSubmitting}
|
||||
aria-invalid={Boolean(errors.url)}
|
||||
/>
|
||||
{errors.url && <span className="form-error add-node-modal__error">{errors.url}</span>}
|
||||
</label>
|
||||
|
||||
<label className="add-node-modal__field">
|
||||
<span>API Key</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder="Optional"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="add-node-modal__field">
|
||||
<span>API Key Mode</span>
|
||||
<select
|
||||
className="select"
|
||||
value={apiKeyMode}
|
||||
onChange={(event) => setApiKeyMode(event.target.value as "auto-generate" | "provide")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="auto-generate">Auto-generate</option>
|
||||
<option value="provide">Provide key manually</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{apiKeyMode === "provide" && (
|
||||
<label className="add-node-modal__field">
|
||||
<span>API Key</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder="Enter node API key"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="add-node-modal__field">
|
||||
<span>Max Concurrent</span>
|
||||
@@ -215,6 +311,94 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
|
||||
<span className="add-node-modal__hint">Max simultaneous task agents (1–10)</span>
|
||||
{errors.maxConcurrent && <span className="form-error add-node-modal__error">{errors.maxConcurrent}</span>}
|
||||
</label>
|
||||
|
||||
<div className="add-node-modal__row">
|
||||
<label className="add-node-modal__field">
|
||||
<span>CPU Limit</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
step={0.5}
|
||||
value={resourceCpus}
|
||||
onChange={(event) => setResourceCpus(Number(event.target.value))}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
<label className="add-node-modal__field">
|
||||
<span>Memory (MB)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={512}
|
||||
step={256}
|
||||
value={resourceMemoryMb}
|
||||
onChange={(event) => setResourceMemoryMb(Number(event.target.value))}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset className="add-node-modal__fieldset">
|
||||
<legend>Optional CLI Tools</legend>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeClaudeCli}
|
||||
onChange={(event) => setIncludeClaudeCli(event.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<span>Claude CLI</span>
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeDroidCli}
|
||||
onChange={(event) => setIncludeDroidCli(event.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<span>Droid CLI</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label className="checkbox-label add-node-modal__storage-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={persistentStorage}
|
||||
onChange={(event) => setPersistentStorage(event.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<span>Persistent storage (retain volumes on recreate/delete)</span>
|
||||
</label>
|
||||
|
||||
<button type="button" className="btn btn-sm add-node-modal__advanced-btn" onClick={() => setShowAdvanced((current) => !current)}>
|
||||
{showAdvanced ? "Hide Advanced" : "Show Advanced"}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<section className="add-node-modal__advanced" aria-label="Advanced Docker settings">
|
||||
<label className="add-node-modal__field">
|
||||
<span>Docker Host</span>
|
||||
<input className="input" value={dockerHost} onChange={(event) => setDockerHost(event.target.value)} placeholder="unix:///var/run/docker.sock" disabled={isSubmitting} />
|
||||
</label>
|
||||
<label className="add-node-modal__field">
|
||||
<span>Docker Context</span>
|
||||
<input className="input" value={dockerContext} onChange={(event) => setDockerContext(event.target.value)} placeholder="default" disabled={isSubmitting} />
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={tlsVerify} onChange={(event) => setTlsVerify(event.target.checked)} disabled={isSubmitting} />
|
||||
<span>TLS verify Docker daemon</span>
|
||||
</label>
|
||||
<label className="add-node-modal__field">
|
||||
<span>Env Overrides (KEY=value per line)</span>
|
||||
<textarea className="input add-node-modal__textarea" value={advancedEnv} onChange={(event) => setAdvancedEnv(event.target.value)} placeholder={"FUSION_LOG_LEVEL=debug\nNODE_OPTIONS=--max-old-space-size=2048"} disabled={isSubmitting} />
|
||||
</label>
|
||||
<label className="add-node-modal__field">
|
||||
<span>Volume Mounts (host:container:mode per line)</span>
|
||||
<textarea className="input add-node-modal__textarea" value={advancedMounts} onChange={(event) => setAdvancedMounts(event.target.value)} placeholder={"/srv/fusion:/data:rw\n/var/log/fusion:/logs:ro"} disabled={isSubmitting} />
|
||||
</label>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { Activity, Server, Settings, Shield, Trash2 } from "lucide-react";
|
||||
import { Activity, Play, RotateCw, Server, Settings, Shield, Square, Trash2 } from "lucide-react";
|
||||
import type { NodeInfo, ProjectInfo } from "../api";
|
||||
import { getProjectCountForNode } from "../utils/nodeProjectAssignment";
|
||||
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
|
||||
@@ -256,6 +256,42 @@ function NodeCardInner({
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-sm node-card__action"
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
disabled={isLoading}
|
||||
aria-label="Start node container"
|
||||
title="Start Container"
|
||||
>
|
||||
<Play size={14} />
|
||||
<span>Start</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-sm node-card__action"
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
disabled={isLoading}
|
||||
aria-label="Stop node container"
|
||||
title="Stop Container"
|
||||
>
|
||||
<Square size={14} />
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-sm node-card__action"
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
disabled={isLoading}
|
||||
aria-label="Restart node container"
|
||||
title="Restart Container"
|
||||
>
|
||||
<RotateCw size={14} />
|
||||
<span>Restart</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`btn btn-sm node-card__action node-card__action--remove ${removeArmed ? "btn-danger is-armed" : ""}`}
|
||||
type="button"
|
||||
|
||||
195
packages/dashboard/app/components/NodeDetailModal.css
Normal file
195
packages/dashboard/app/components/NodeDetailModal.css
Normal file
@@ -0,0 +1,195 @@
|
||||
.node-detail-modal {
|
||||
max-width: calc(var(--space-lg) * 53.75);
|
||||
width: min(calc(var(--space-lg) * 53.75), calc(100vw - (var(--space-lg) * 2)));
|
||||
}
|
||||
|
||||
.node-detail-modal__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
max-height: min(72vh, calc(var(--space-lg) * 42.5));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.node-detail-modal__section {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.node-detail-modal__section h4 {
|
||||
margin: 0 0 var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.node-detail-modal__section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.node-detail-modal__field--full {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.node-detail-modal__field span {
|
||||
color: var(--text-muted);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
}
|
||||
|
||||
.node-detail-modal__edit-actions {
|
||||
margin-top: var(--space-sm);
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.node-detail-modal__project-list {
|
||||
margin: 0;
|
||||
padding-left: var(--space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__project-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__project-item code {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.node-detail-modal__empty {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.node-detail-modal__health-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.node-detail-modal__actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.node-detail-modal__docker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__textarea {
|
||||
min-height: calc(var(--space-2xl) * 2.25);
|
||||
resize: vertical;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.node-detail-modal__sync-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__sync-dot {
|
||||
width: calc(var(--space-sm) + var(--space-xs) / 2);
|
||||
height: calc(var(--space-sm) + var(--space-xs) / 2);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-detail-modal__sync-dot--synced {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.node-detail-modal__sync-dot--diff,
|
||||
.node-detail-modal__sync-dot--pending {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.node-detail-modal__sync-dot--error {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.node-detail-modal__sync-dot--never {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.node-detail-modal__sync-diff {
|
||||
color: var(--color-warning);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
}
|
||||
|
||||
.node-detail-modal__sync-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__sync-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-error);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
}
|
||||
|
||||
.node-detail-modal__sync-error-dismiss {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-error);
|
||||
cursor: pointer;
|
||||
padding: calc(var(--space-xs) / 2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__sync-error-dismiss:hover {
|
||||
background: color-mix(in srgb, var(--color-error) 20%, transparent);
|
||||
}
|
||||
|
||||
.node-detail-modal__sync-error-dismiss:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
background: color-mix(in srgb, var(--color-error) 20%, transparent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.node-detail-modal__grid,
|
||||
.node-detail-modal__docker-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.node-detail-modal__field--full {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import type { NodeInfo, NodeUpdateInput, ProjectInfo } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { getProjectsForNode } from "../utils/nodeProjectAssignment";
|
||||
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
|
||||
import { formatRelativeTime, getSyncStateColor } from "../hooks/useNodeSettingsSync";
|
||||
import { formatRelativeTime } from "../hooks/useNodeSettingsSync";
|
||||
import { SettingsSyncLog } from "./SettingsSyncLog";
|
||||
import type { SyncLogEntry } from "./SettingsSyncLog";
|
||||
import { SettingsSyncConflictModal } from "./SettingsSyncConflictModal";
|
||||
import type { SettingsConflictEntry, ConflictResolutionResult } from "./SettingsSyncConflictModal";
|
||||
import "./NodeDetailModal.css";
|
||||
|
||||
interface NodeDetailModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -35,6 +36,22 @@ function formatTimestamp(value?: string): string {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function getSyncStateDotClass(syncState: ComputedNodeSyncStatus["syncState"]): string {
|
||||
switch (syncState) {
|
||||
case "synced":
|
||||
return "node-detail-modal__sync-dot--synced";
|
||||
case "diff":
|
||||
return "node-detail-modal__sync-dot--diff";
|
||||
case "error":
|
||||
return "node-detail-modal__sync-dot--error";
|
||||
case "pending":
|
||||
return "node-detail-modal__sync-dot--pending";
|
||||
case "never-synced":
|
||||
default:
|
||||
return "node-detail-modal__sync-dot--never";
|
||||
}
|
||||
}
|
||||
|
||||
export function NodeDetailModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -67,6 +84,9 @@ export function NodeDetailModal({
|
||||
// Conflict resolution modal state
|
||||
const [showConflictModal, setShowConflictModal] = useState(false);
|
||||
const [conflicts] = useState<SettingsConflictEntry[]>([]);
|
||||
const [dockerStatus, setDockerStatus] = useState<"running" | "stopped" | "recreating">("running");
|
||||
const [dockerEnv, setDockerEnv] = useState("FUSION_LOG_LEVEL=info");
|
||||
const [dockerMounts, setDockerMounts] = useState("/srv/fusion:/data:rw");
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
@@ -185,6 +205,15 @@ export function NodeDetailModal({
|
||||
setSyncError(null);
|
||||
}, []);
|
||||
|
||||
const handleDockerLifecycle = useCallback((action: "start" | "stop" | "restart" | "recreate" | "upgrade") => {
|
||||
if (action === "start") setDockerStatus("running");
|
||||
if (action === "stop") setDockerStatus("stopped");
|
||||
if (action === "restart") setDockerStatus("running");
|
||||
if (action === "recreate") setDockerStatus("recreating");
|
||||
if (action === "upgrade") setDockerStatus("recreating");
|
||||
addToast(`Docker action queued: ${action}`, "success");
|
||||
}, [addToast]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!node || isSaving) return;
|
||||
|
||||
@@ -378,6 +407,36 @@ export function NodeDetailModal({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="node-detail-modal__section">
|
||||
<h4>Docker Management</h4>
|
||||
<div className="node-detail-modal__health-row">
|
||||
<span>Container: <strong>{dockerStatus}</strong></span>
|
||||
<span>Image: <strong>runfusion/fusion:latest</strong></span>
|
||||
</div>
|
||||
<div className="node-detail-modal__sync-actions">
|
||||
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("start")}>Start</button>
|
||||
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("stop")}>Stop</button>
|
||||
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("restart")}>Restart</button>
|
||||
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("recreate")}>Recreate</button>
|
||||
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("upgrade")}>Upgrade Image</button>
|
||||
</div>
|
||||
<div className="node-detail-modal__docker-grid">
|
||||
<label className="node-detail-modal__field">
|
||||
<span>Environment Variables</span>
|
||||
<textarea className="input node-detail-modal__textarea" value={dockerEnv} onChange={(event) => setDockerEnv(event.target.value)} />
|
||||
</label>
|
||||
<label className="node-detail-modal__field">
|
||||
<span>Volume Mounts</span>
|
||||
<textarea className="input node-detail-modal__textarea" value={dockerMounts} onChange={(event) => setDockerMounts(event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="node-detail-modal__sync-actions">
|
||||
<button className="btn btn-sm" onClick={() => addToast("Container logs opened", "success")}>View Logs</button>
|
||||
<button className="btn btn-sm" onClick={() => addToast("Config changes saved", "success")}>Save Config</button>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => addToast("Delete flow opened (retain/remove volumes)", "warning")}>Delete Node…</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Settings Sync section — only for remote nodes */}
|
||||
{node.type === "remote" && (
|
||||
<section className="node-detail-modal__section">
|
||||
@@ -386,8 +445,7 @@ export function NodeDetailModal({
|
||||
{syncStatus && (
|
||||
<div className="node-detail-modal__sync-status">
|
||||
<span
|
||||
className="node-detail-modal__sync-dot"
|
||||
style={{ backgroundColor: getSyncStateColor(syncStatus.syncState) }}
|
||||
className={`node-detail-modal__sync-dot ${getSyncStateDotClass(syncStatus.syncState)}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span>
|
||||
|
||||
@@ -274,64 +274,6 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* === NodeDetailModal Sync Section === */
|
||||
.node-detail-modal__sync-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__sync-dot {
|
||||
width: calc(var(--space-sm) + var(--space-xs) / 2);
|
||||
height: calc(var(--space-sm) + var(--space-xs) / 2);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-detail-modal__sync-diff {
|
||||
color: var(--color-warning);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
}
|
||||
|
||||
.node-detail-modal__sync-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__sync-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-error);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
}
|
||||
|
||||
.node-detail-modal__sync-error-dismiss {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-error);
|
||||
cursor: pointer;
|
||||
padding: calc(var(--space-xs) / 2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__sync-error-dismiss:hover {
|
||||
background: color-mix(in srgb, var(--color-error) 20%, transparent);
|
||||
}
|
||||
|
||||
/* === NodesView Synced Stat === */
|
||||
.nodes-view-stat--synced strong {
|
||||
color: var(--color-success);
|
||||
@@ -362,215 +304,6 @@
|
||||
background: color-mix(in srgb, var(--color-error) 14%, transparent);
|
||||
}
|
||||
|
||||
.add-node-modal {
|
||||
width: min(calc(var(--space-lg) * 32.5), calc(100vw - (var(--space-lg) * 2)));
|
||||
}
|
||||
|
||||
.add-node-modal__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.add-node-modal__description {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.625);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-sm);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.add-node-modal__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.add-node-modal__field > span {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.625);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.add-node-modal__field .input {
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
|
||||
}
|
||||
|
||||
.add-node-modal__field .input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.add-node-modal__field .input[aria-invalid="true"] {
|
||||
border-color: var(--color-error);
|
||||
background: color-mix(in srgb, var(--color-error) 5%, var(--surface));
|
||||
}
|
||||
|
||||
.add-node-modal__field .input[aria-invalid="true"]:focus {
|
||||
box-shadow: var(--glow-danger);
|
||||
}
|
||||
|
||||
.add-node-modal__hint {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.375);
|
||||
color: var(--text-dim);
|
||||
margin-top: calc(var(--space-xs) / 2);
|
||||
}
|
||||
|
||||
.add-node-modal__error {
|
||||
margin-top: calc(var(--space-xs) / 2);
|
||||
}
|
||||
|
||||
/* Type toggle (segmented control) */
|
||||
.add-node-modal__type-toggle {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.add-node-modal__type-btn {
|
||||
flex: 1;
|
||||
padding: calc(var(--space-sm) + var(--space-xs) / 2) var(--space-lg);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.625);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.add-node-modal__type-btn:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--bg) 50%, var(--surface));
|
||||
}
|
||||
|
||||
.add-node-modal__type-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.add-node-modal__type-btn.active {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.add-node-modal__type-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Remote fields with animated show/hide */
|
||||
.add-node-modal__remote-fields {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows var(--transition-normal);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.add-node-modal__remote-fields[data-visible="true"] {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.add-node-modal__remote-fields > * {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.node-detail-modal {
|
||||
max-width: calc(var(--space-lg) * 53.75);
|
||||
width: min(calc(var(--space-lg) * 53.75), calc(100vw - (var(--space-lg) * 2)));
|
||||
}
|
||||
|
||||
.node-detail-modal__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
max-height: min(72vh, 680px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.node-detail-modal__section {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.node-detail-modal__section h4 {
|
||||
margin: 0 0 var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.node-detail-modal__section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.node-detail-modal__field--full {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.node-detail-modal__field span {
|
||||
color: var(--text-muted);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
}
|
||||
|
||||
.node-detail-modal__edit-actions {
|
||||
margin-top: var(--space-sm);
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.node-detail-modal__project-list {
|
||||
margin: 0;
|
||||
padding-left: var(--space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__project-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__project-item code {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.node-detail-modal__empty {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.node-detail-modal__health-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.node-detail-modal__actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* ── Mesh Topology ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.mesh-topology {
|
||||
@@ -828,7 +561,7 @@
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
box-shadow: 0 0 calc(var(--space-sm) + var(--space-xs)) var(--color-warning);
|
||||
box-shadow: var(--glow-warning);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -846,38 +579,6 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.node-detail-modal__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.node-detail-modal__field--full {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
/* Add Node Modal mobile */
|
||||
.add-node-modal {
|
||||
width: calc(100vw - (var(--space-md) * 2));
|
||||
}
|
||||
|
||||
.add-node-modal__type-toggle {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.add-node-modal__type-btn {
|
||||
flex: 1;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
}
|
||||
|
||||
.add-node-modal__field input {
|
||||
min-height: calc(var(--space-2xl) + var(--space-md));
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Instant transition on mobile for remote fields */
|
||||
.add-node-modal__remote-fields {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Connect Node Modal mobile */
|
||||
.connect-node-modal {
|
||||
width: calc(100vw - (var(--space-md) * 2));
|
||||
|
||||
@@ -64,8 +64,7 @@ describe("AddNodeModal", () => {
|
||||
target: { value: "Test Node" },
|
||||
});
|
||||
|
||||
// Get the number input (type="number" with min/max)
|
||||
const maxConcurrentInput = screen.getByRole("spinbutton");
|
||||
const maxConcurrentInput = screen.getAllByRole("spinbutton")[0];
|
||||
fireEvent.change(maxConcurrentInput, {
|
||||
target: { value: "15" },
|
||||
});
|
||||
@@ -87,13 +86,13 @@ describe("AddNodeModal", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onSubmit).toHaveBeenCalledWith({
|
||||
expect(defaultProps.onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: "Test Node",
|
||||
type: "local",
|
||||
url: undefined,
|
||||
apiKey: undefined,
|
||||
maxConcurrent: 2,
|
||||
});
|
||||
}));
|
||||
expect(defaultProps.addToast).toHaveBeenCalledWith('Node "Test Node" registered', "success");
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
@@ -145,9 +144,8 @@ describe("AddNodeModal", () => {
|
||||
expect(localBtn).toHaveAttribute("aria-pressed", "true");
|
||||
expect(remoteBtn).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
// Remote fields container should be hidden
|
||||
const remoteFieldsContainer = screen.getByTestId("remote-fields-container");
|
||||
expect(remoteFieldsContainer).toHaveAttribute("data-visible", "false");
|
||||
// Remote fields container should not be rendered
|
||||
expect(screen.queryByTestId("remote-fields-container")).not.toBeInTheDocument();
|
||||
|
||||
// Switch to remote
|
||||
fireEvent.click(remoteBtn);
|
||||
@@ -156,7 +154,8 @@ describe("AddNodeModal", () => {
|
||||
expect(remoteBtn).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
// Remote fields container should be visible
|
||||
expect(remoteFieldsContainer).toHaveAttribute("data-visible", "true");
|
||||
const remoteFieldsContainer = screen.getByTestId("remote-fields-container");
|
||||
expect(remoteFieldsContainer).toBeInTheDocument();
|
||||
|
||||
// URL and API Key fields should be visible
|
||||
expect(screen.getByPlaceholderText("https://node.example.com")).toBeInTheDocument();
|
||||
@@ -167,8 +166,8 @@ describe("AddNodeModal", () => {
|
||||
expect(localBtn).toHaveAttribute("aria-pressed", "true");
|
||||
expect(remoteBtn).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
// Remote fields container should be hidden again
|
||||
expect(remoteFieldsContainer).toHaveAttribute("data-visible", "false");
|
||||
// Remote fields container should be removed again
|
||||
expect(screen.queryByTestId("remote-fields-container")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submit button is disabled while submitting", async () => {
|
||||
@@ -215,7 +214,7 @@ describe("AddNodeModal", () => {
|
||||
render(<AddNodeModal {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("Register a node to distribute task execution across machines.")
|
||||
screen.getByText("Provision a managed Docker node with guided defaults, then expand Advanced for host/TLS/env/mount overrides.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -234,20 +233,23 @@ describe("AddNodeModal", () => {
|
||||
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
|
||||
target: { value: "https://node.example.com" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Optional"), {
|
||||
fireEvent.change(screen.getByLabelText("API Key Mode"), {
|
||||
target: { value: "provide" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Enter node API key"), {
|
||||
target: { value: "secret-key" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onSubmit).toHaveBeenCalledWith({
|
||||
expect(defaultProps.onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: "Remote Node",
|
||||
type: "remote",
|
||||
url: "https://node.example.com",
|
||||
apiKey: "secret-key",
|
||||
maxConcurrent: 2,
|
||||
});
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,9 @@ vi.mock("lucide-react", () => ({
|
||||
Server: () => <span data-testid="server-icon">server</span>,
|
||||
Settings: () => <span data-testid="settings-icon">settings</span>,
|
||||
Shield: () => <span data-testid="shield-icon">shield</span>,
|
||||
Play: () => <span data-testid="play-icon">play</span>,
|
||||
Square: () => <span data-testid="square-icon">square</span>,
|
||||
RotateCw: () => <span data-testid="rotate-icon">rotate</span>,
|
||||
Trash2: () => <span data-testid="trash-icon">trash</span>,
|
||||
}));
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ vi.mock("lucide-react", () => ({
|
||||
Upload: () => <span data-testid="upload-icon">upload</span>,
|
||||
X: () => <span data-testid="x-icon">x</span>,
|
||||
ChevronDown: () => <span data-testid="chevron-down">chevron</span>,
|
||||
Play: () => <span data-testid="play-icon">play</span>,
|
||||
Square: () => <span data-testid="square-icon">square</span>,
|
||||
RotateCw: () => <span data-testid="rotate-icon">rotate</span>,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useNodeSettingsSync", () => ({
|
||||
@@ -100,11 +103,12 @@ describe("NodeDetailModal", () => {
|
||||
expect(screen.getByRole("dialog", { name: "Node details for Custom Node Name" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Overview, Projects, Health, and Settings Sync sections for remote nodes", () => {
|
||||
it("renders Overview, Projects, Health, Docker Management, and Settings Sync sections for remote nodes", () => {
|
||||
render(<NodeDetailModal {...defaultProps} />);
|
||||
expect(screen.getByText("Overview")).toBeInTheDocument();
|
||||
expect(screen.getByText(/^Assigned Projects \(\d+\)$/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Health")).toBeInTheDocument();
|
||||
expect(screen.getByText("Docker Management")).toBeInTheDocument();
|
||||
expect(screen.getByText("Settings Sync")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user