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

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

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

View File

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

View File

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

View File

@@ -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;

View File

@@ -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")

View File

@@ -406,6 +406,14 @@ export type {
NodeStatus,
NodeVersionInfo,
NodeVersionInfoInput,
DockerNodeStatus,
DockerHostConfig,
DockerResourceSizing,
DockerVolumeMount,
DockerExtraCli,
ManagedDockerNode,
ManagedDockerNodeInput,
ManagedDockerNodeUpdate,
NodeDiscoveryEvent,
DiscoveryConfig,
DiscoveredNode,

View File

@@ -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) */

View 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;
}
}

View File

@@ -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 (110)</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">

View File

@@ -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"

View 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;
}
}

View File

@@ -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>

View File

@@ -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));

View File

@@ -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,
});
}));
});
});
});

View File

@@ -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>,
}));

View File

@@ -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();
});