feat(FN-3108): deliver docker node onboarding and node workflow updates

- Add managed Docker node API client/routes, onboarding hook, and NodesView integration with new onboarding modal UX
- Refine Add Node, mobile nav, agent/node detail, and onboarding-related component styling/interaction flows
- Expand dashboard/core/cli test coverage for onboarding flows, node routes, insights persistence, and mobile regressions
- Add CLI/plugin packaging updates including bundled dependency-graph install and plugin schema initialization support

Fusion-Task-Id: FN-3108
This commit is contained in:
Fusion
2026-05-02 22:33:45 -07:00
committed by gsxdsm
parent 6c5146bb20
commit cdb8539795
15 changed files with 1536 additions and 190 deletions

View File

@@ -70,6 +70,12 @@ import type {
ResearchRunStatus,
TaskPriority,
TaskSourceIssue,
ManagedDockerNodeInput,
DockerHostConfig,
DockerResourceSizing,
DockerVolumeMount,
DockerExtraCli,
DockerNodeStatus,
} from "@fusion/core";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
@@ -5259,6 +5265,29 @@ export interface NodeInfo {
updatedAt: NodeConfig["updatedAt"];
}
/** Managed Docker node information returned by docker node endpoints */
export interface DockerNodeInfo {
id: string;
nodeId: string | null;
name: string;
nodeType: "docker-managed";
imageName: string;
imageTag: string;
containerId: string | null;
status: DockerNodeStatus;
hostConfig: DockerHostConfig;
envVars: Record<string, string>;
volumeMounts: DockerVolumeMount[];
resourceSizing: DockerResourceSizing;
extraClis: DockerExtraCli[];
persistentStorage: boolean;
reachableUrl: string | null;
apiKey: string | null;
errorMessage: string | null;
createdAt: string;
updatedAt: string;
}
/** Node discovered over local network mDNS/DNS-SD */
export interface DiscoveredNodeInfo {
name: string;
@@ -5400,6 +5429,19 @@ export function fetchDiscoveryStatus(): Promise<{ active: boolean; config: Disco
return api<{ active: boolean; config: DiscoveryConfig | null }>("/discovery/status");
}
/** Fetch all managed Docker nodes */
export function listManagedDockerNodes(): Promise<DockerNodeInfo[]> {
return api<DockerNodeInfo[]>("/docker-nodes");
}
/** Create a managed Docker node */
export function createManagedDockerNode(input: ManagedDockerNodeInput): Promise<DockerNodeInfo> {
return api<DockerNodeInfo>("/docker-nodes", {
method: "POST",
body: JSON.stringify(input),
});
}
/** Start local-network discovery service. */
export function startDiscovery(input?: {
broadcast?: boolean;

View File

@@ -65,17 +65,6 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
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);
@@ -86,17 +75,6 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
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);
}, []);
@@ -126,53 +104,14 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
};
}, [closeModal, isOpen, resetForm]);
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 input = useMemo<AddNodeInput>(() => ({
name: name.trim(),
type,
url: type === "remote" ? url.trim() || undefined : undefined,
apiKey: type === "remote" && apiKeyMode === "provide" ? apiKey || undefined : undefined,
maxConcurrent,
apiKeyMode,
}), [apiKey, apiKeyMode, maxConcurrent, name, type, url]);
const handleSubmit = useCallback(async () => {
if (isSubmitting) return;
@@ -211,7 +150,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
</div>
<div className="modal-body add-node-modal__body">
<p className="add-node-modal__description">Provision a managed Docker node with guided defaults, then expand Advanced for host/TLS/env/mount overrides.</p>
<p className="add-node-modal__description">Register an existing Fusion node by providing its connection details and concurrency settings.</p>
<label className="add-node-modal__field">
<span>Name</span>
@@ -312,93 +251,6 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
{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

@@ -0,0 +1,192 @@
.docker-onboarding {
width: min(calc(var(--space-2xl) * 22.5), calc(100vw - (var(--space-lg) * 2)));
}
.docker-onboarding__body {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.docker-onboarding__section {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.docker-onboarding__section-title {
margin: 0;
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: calc(var(--space-md) + var(--space-xs) / 2);
}
.docker-onboarding__field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.docker-onboarding__inline-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-sm);
}
.docker-onboarding__radio-group {
display: flex;
align-items: center;
gap: var(--space-md);
flex-wrap: wrap;
}
.docker-onboarding__type-toggle {
display: flex;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.docker-onboarding__type-btn {
flex: 1;
border: none;
background: var(--surface);
color: var(--text);
padding: var(--space-sm) var(--space-md);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.docker-onboarding__type-btn + .docker-onboarding__type-btn {
border-left: 1px solid var(--border);
}
.docker-onboarding__type-btn.is-active {
background: var(--todo);
color: var(--bg);
}
.docker-onboarding__type-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
position: relative;
z-index: 1;
}
.docker-onboarding__checkbox-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.docker-onboarding__kv-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.docker-onboarding__kv-list h5 {
margin: 0;
font-size: calc(var(--space-sm) + var(--space-xs));
}
.docker-onboarding__kv-row {
display: grid;
gap: var(--space-sm);
align-items: center;
}
.docker-onboarding__kv-row--env {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
}
.docker-onboarding__kv-row--mount {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto auto;
}
.docker-onboarding__kv-add {
align-self: flex-start;
}
.docker-onboarding__advanced-toggle {
border: none;
background: none;
color: var(--text);
padding: var(--space-xs);
margin: calc(var(--space-xs) * -1);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
cursor: pointer;
font-size: calc(var(--space-md) + var(--space-xs) / 2);
font-weight: 600;
}
.docker-onboarding__advanced-toggle:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.docker-onboarding__advanced-toggle svg {
transition: transform var(--transition-fast);
}
.docker-onboarding__advanced-toggle.is-expanded svg {
transform: rotate(180deg);
}
.docker-onboarding__advanced-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows var(--transition-fast);
}
.docker-onboarding__advanced-content > div {
overflow: hidden;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.docker-onboarding__advanced-content.is-expanded {
grid-template-rows: 1fr;
}
.docker-onboarding__tls-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-sm);
}
.docker-onboarding__tls-fields .docker-onboarding__field:first-child {
grid-column: 1 / -1;
}
@media (max-width: 768px) {
.docker-onboarding {
width: calc(100vw - (var(--space-md) * 2));
}
.docker-onboarding__inline-fields,
.docker-onboarding__tls-fields,
.docker-onboarding__kv-row {
grid-template-columns: 1fr;
}
.docker-onboarding .input,
.docker-onboarding .select,
.docker-onboarding .btn {
min-height: calc(var(--space-2xl) + var(--space-md));
}
.docker-onboarding__advanced-content {
transition: none;
}
}

View File

@@ -0,0 +1,631 @@
import { ChevronDown, Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ManagedDockerNodeInput } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import "./DockerNodeOnboardingModal.css";
interface DockerNodeOnboardingModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (input: ManagedDockerNodeInput) => Promise<void>;
addToast: (message: string, type?: ToastType) => void;
}
interface FormErrors {
name?: string;
reachableUrl?: string;
memoryMB?: string;
cpus?: string;
}
interface KeyValueRow {
key: string;
value: string;
}
interface MountRow {
hostPath: string;
containerPath: string;
mode: "ro" | "rw";
}
const DEFAULT_URL = "http://localhost:4040";
export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast: _addToast }: DockerNodeOnboardingModalProps) {
const [name, setName] = useState("");
const [location, setLocation] = useState<"local" | "remote">("local");
const [reachableUrl, setReachableUrl] = useState(DEFAULT_URL);
const [apiKeyMode, setApiKeyMode] = useState<"auto" | "manual">("auto");
const [apiKey, setApiKey] = useState("");
const [includeClaudeCli, setIncludeClaudeCli] = useState(false);
const [includeDroidCli, setIncludeDroidCli] = useState(false);
const [persistentStorage, setPersistentStorage] = useState(true);
const [memoryMB, setMemoryMB] = useState(4096);
const [cpus, setCpus] = useState(2);
const [showAdvanced, setShowAdvanced] = useState(false);
const [imageName, setImageName] = useState("runfusion/fusion");
const [imageTag, setImageTag] = useState("latest");
const [dockerContext, setDockerContext] = useState("");
const [dockerHost, setDockerHost] = useState("");
const [tlsVerify, setTlsVerify] = useState(true);
const [tlsCaPath, setTlsCaPath] = useState("");
const [tlsCertPath, setTlsCertPath] = useState("");
const [tlsKeyPath, setTlsKeyPath] = useState("");
const [envRows, setEnvRows] = useState<KeyValueRow[]>([]);
const [mountRows, setMountRows] = useState<MountRow[]>([]);
const [errors, setErrors] = useState<FormErrors>({});
const [submitting, setSubmitting] = useState(false);
const resetForm = useCallback(() => {
setName("");
setLocation("local");
setReachableUrl(DEFAULT_URL);
setApiKeyMode("auto");
setApiKey("");
setIncludeClaudeCli(false);
setIncludeDroidCli(false);
setPersistentStorage(true);
setMemoryMB(4096);
setCpus(2);
setShowAdvanced(false);
setImageName("runfusion/fusion");
setImageTag("latest");
setDockerContext("");
setDockerHost("");
setTlsVerify(true);
setTlsCaPath("");
setTlsCertPath("");
setTlsKeyPath("");
setEnvRows([]);
setMountRows([]);
setErrors({});
setSubmitting(false);
}, []);
const closeModal = useCallback(() => {
if (submitting) return;
resetForm();
onClose();
}, [onClose, resetForm, submitting]);
useEffect(() => {
if (!isOpen) {
resetForm();
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
closeModal();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [closeModal, isOpen, resetForm]);
const input = useMemo<ManagedDockerNodeInput>(() => ({
nodeId: null,
name: name.trim(),
imageName: imageName.trim() || "runfusion/fusion",
imageTag: imageTag.trim() || "latest",
hostConfig: {
context: dockerContext.trim() || undefined,
host: location === "remote" ? dockerHost.trim() || undefined : undefined,
tlsVerify: location === "remote" ? tlsVerify : undefined,
tlsCaPath: location === "remote" ? tlsCaPath.trim() || undefined : undefined,
tlsCertPath: location === "remote" ? tlsCertPath.trim() || undefined : undefined,
tlsKeyPath: location === "remote" ? tlsKeyPath.trim() || undefined : undefined,
},
envVars: Object.fromEntries(
envRows
.map((row) => [row.key.trim(), row.value] as const)
.filter(([key]) => Boolean(key)),
),
volumeMounts: mountRows
.map((mount) => ({
hostPath: mount.hostPath.trim(),
containerPath: mount.containerPath.trim(),
mode: mount.mode,
}))
.filter((mount) => mount.hostPath && mount.containerPath),
resourceSizing: { memoryMB, cpus },
extraClis: [includeClaudeCli ? "claude-cli" : null, includeDroidCli ? "droid-cli" : null].filter(Boolean) as Array<
"claude-cli" | "droid-cli"
>,
persistentStorage,
reachableUrl: reachableUrl.trim() || null,
apiKey: apiKeyMode === "manual" ? apiKey.trim() || null : null,
}), [
apiKey,
apiKeyMode,
cpus,
dockerContext,
dockerHost,
envRows,
imageName,
imageTag,
includeClaudeCli,
includeDroidCli,
location,
memoryMB,
mountRows,
name,
persistentStorage,
reachableUrl,
tlsCaPath,
tlsCertPath,
tlsKeyPath,
tlsVerify,
]);
const addEnvRow = useCallback(() => {
setEnvRows((current) => [...current, { key: "", value: "" }]);
}, []);
const updateEnvRow = useCallback((index: number, next: KeyValueRow) => {
setEnvRows((current) => current.map((row, rowIndex) => (rowIndex === index ? next : row)));
}, []);
const removeEnvRow = useCallback((index: number) => {
setEnvRows((current) => current.filter((_, rowIndex) => rowIndex !== index));
}, []);
const addMountRow = useCallback(() => {
setMountRows((current) => [...current, { hostPath: "", containerPath: "", mode: "rw" }]);
}, []);
const updateMountRow = useCallback((index: number, next: MountRow) => {
setMountRows((current) => current.map((row, rowIndex) => (rowIndex === index ? next : row)));
}, []);
const removeMountRow = useCallback((index: number) => {
setMountRows((current) => current.filter((_, rowIndex) => rowIndex !== index));
}, []);
const handleSubmit = useCallback(async () => {
if (submitting) return;
const nextErrors: FormErrors = {};
if (!input.name || input.name.length > 64) {
nextErrors.name = "Name is required and must be 64 characters or fewer";
}
if (location === "remote" && !input.reachableUrl) {
nextErrors.reachableUrl = "URL is required for remote Docker";
}
if (memoryMB < 512) {
nextErrors.memoryMB = "Memory must be at least 512 MB";
}
if (cpus < 0.5) {
nextErrors.cpus = "CPUs must be at least 0.5";
}
setErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) {
return;
}
setSubmitting(true);
try {
await onSubmit(input);
closeModal();
} catch {
// Error toast is handled by parent submit handler.
} finally {
setSubmitting(false);
}
}, [closeModal, cpus, input, location, memoryMB, onSubmit, submitting]);
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={closeModal}>
<div
className="modal docker-onboarding"
role="dialog"
aria-modal="true"
aria-label="Docker node onboarding"
onClick={(event) => event.stopPropagation()}
>
<div className="modal-header">
<h3>Provision Docker Node</h3>
<button className="modal-close" onClick={closeModal} disabled={submitting} aria-label="Close onboarding modal">
&times;
</button>
</div>
<div className="modal-body docker-onboarding__body">
<section className="docker-onboarding__section">
<h4 className="docker-onboarding__section-title">Required Settings</h4>
<label className="docker-onboarding__field">
<span>Node Name</span>
<input
className="input"
value={name}
onChange={(event) => setName(event.target.value)}
disabled={submitting}
placeholder="my-docker-node"
autoFocus
/>
</label>
{errors.name && <div className="form-error">{errors.name}</div>}
<div className="docker-onboarding__type-toggle" role="tablist" aria-label="Target location">
<button
type="button"
className={`docker-onboarding__type-btn ${location === "local" ? "is-active" : ""}`}
onClick={() => setLocation("local")}
disabled={submitting}
aria-pressed={location === "local"}
>
Local Docker
</button>
<button
type="button"
className={`docker-onboarding__type-btn ${location === "remote" ? "is-active" : ""}`}
onClick={() => setLocation("remote")}
disabled={submitting}
aria-pressed={location === "remote"}
>
Remote Docker
</button>
</div>
<label className="docker-onboarding__field">
<span>Reachable URL</span>
<input
className="input"
value={reachableUrl}
onChange={(event) => setReachableUrl(event.target.value)}
disabled={submitting}
placeholder={location === "local" ? DEFAULT_URL : "http://192.168.1.50:4040"}
/>
</label>
{errors.reachableUrl && <div className="form-error">{errors.reachableUrl}</div>}
{location === "remote" && (
<label className="docker-onboarding__field">
<span>Docker Host</span>
<input
className="input"
value={dockerHost}
onChange={(event) => setDockerHost(event.target.value)}
disabled={submitting}
placeholder="tcp://host:2376"
/>
</label>
)}
<div className="docker-onboarding__radio-group">
<label className="checkbox-label">
<input
type="radio"
checked={apiKeyMode === "auto"}
onChange={() => setApiKeyMode("auto")}
disabled={submitting}
/>
Auto-generate
</label>
<label className="checkbox-label">
<input
type="radio"
checked={apiKeyMode === "manual"}
onChange={() => setApiKeyMode("manual")}
disabled={submitting}
/>
Provide manually
</label>
</div>
{apiKeyMode === "manual" && (
<label className="docker-onboarding__field">
<span>API Key</span>
<input
className="input"
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
disabled={submitting}
placeholder="Enter API key"
/>
</label>
)}
<div className="docker-onboarding__checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={includeClaudeCli}
onChange={(event) => setIncludeClaudeCli(event.target.checked)}
disabled={submitting}
/>
Claude CLI
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={includeDroidCli}
onChange={(event) => setIncludeDroidCli(event.target.checked)}
disabled={submitting}
/>
Droid CLI
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={persistentStorage}
onChange={(event) => setPersistentStorage(event.target.checked)}
disabled={submitting}
/>
Keep data across container recreations
</label>
</div>
<div className="docker-onboarding__inline-fields">
<label className="docker-onboarding__field">
<span>Memory (MB)</span>
<input
className="input"
type="number"
min={512}
value={memoryMB}
onChange={(event) => setMemoryMB(Number(event.target.value))}
disabled={submitting}
/>
</label>
<label className="docker-onboarding__field">
<span>CPUs</span>
<input
className="input"
type="number"
min={0.5}
step={0.5}
value={cpus}
onChange={(event) => setCpus(Number(event.target.value))}
disabled={submitting}
/>
</label>
</div>
{errors.memoryMB && <div className="form-error">{errors.memoryMB}</div>}
{errors.cpus && <div className="form-error">{errors.cpus}</div>}
</section>
<section className="docker-onboarding__section">
<button
type="button"
className={`docker-onboarding__advanced-toggle ${showAdvanced ? "is-expanded" : ""}`}
onClick={() => setShowAdvanced((value) => !value)}
disabled={submitting}
>
<span>Advanced</span>
<ChevronDown />
</button>
<div className={`docker-onboarding__advanced-content ${showAdvanced ? "is-expanded" : ""}`}>
<div>
<div className="docker-onboarding__inline-fields">
<label className="docker-onboarding__field">
<span>Image</span>
<input
className="input"
value={imageName}
onChange={(event) => setImageName(event.target.value)}
disabled={submitting}
placeholder="runfusion/fusion"
/>
</label>
<label className="docker-onboarding__field">
<span>Tag</span>
<input
className="input"
value={imageTag}
onChange={(event) => setImageTag(event.target.value)}
disabled={submitting}
placeholder="latest"
/>
</label>
</div>
<label className="docker-onboarding__field">
<span>Docker Context</span>
<input
className="input"
value={dockerContext}
onChange={(event) => setDockerContext(event.target.value)}
disabled={submitting}
placeholder="default"
/>
</label>
{location === "local" && (
<label className="docker-onboarding__field">
<span>Docker Host</span>
<input
className="input"
value={dockerHost}
onChange={(event) => setDockerHost(event.target.value)}
disabled={submitting || Boolean(dockerContext.trim())}
placeholder="tcp://host:2376"
/>
</label>
)}
{location === "remote" && (
<div className="docker-onboarding__tls-fields">
<label className="docker-onboarding__field">
<span>CA Cert Path</span>
<input
className="input"
value={tlsCaPath}
onChange={(event) => setTlsCaPath(event.target.value)}
disabled={submitting}
placeholder="/etc/docker/ca.pem"
/>
</label>
<label className="docker-onboarding__field">
<span>Client Cert Path</span>
<input
className="input"
value={tlsCertPath}
onChange={(event) => setTlsCertPath(event.target.value)}
disabled={submitting}
placeholder="/etc/docker/cert.pem"
/>
</label>
<label className="docker-onboarding__field">
<span>Client Key Path</span>
<input
className="input"
value={tlsKeyPath}
onChange={(event) => setTlsKeyPath(event.target.value)}
disabled={submitting}
placeholder="/etc/docker/key.pem"
/>
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={tlsVerify}
onChange={(event) => setTlsVerify(event.target.checked)}
disabled={submitting}
/>
TLS verify
</label>
</div>
)}
<div className="docker-onboarding__kv-list">
<h5>Environment Variables</h5>
{envRows.map((row, index) => (
<div key={`env-${index}`} className="docker-onboarding__kv-row docker-onboarding__kv-row--env">
<input
className="input"
placeholder="KEY"
value={row.key}
disabled={submitting}
onChange={(event) =>
updateEnvRow(index, {
key: event.target.value,
value: row.value,
})
}
/>
<input
className="input"
placeholder="Value"
value={row.value}
disabled={submitting}
onChange={(event) =>
updateEnvRow(index, {
key: row.key,
value: event.target.value,
})
}
/>
<button
type="button"
className="btn btn-icon"
aria-label="Remove environment variable"
onClick={() => removeEnvRow(index)}
disabled={submitting}
>
<Trash2 size={14} />
</button>
</div>
))}
<button
type="button"
className="btn btn-sm docker-onboarding__kv-add"
onClick={addEnvRow}
disabled={submitting}
>
<Plus size={14} />
Add variable
</button>
</div>
<div className="docker-onboarding__kv-list">
<h5>Volume Mounts</h5>
{mountRows.map((row, index) => (
<div key={`mount-${index}`} className="docker-onboarding__kv-row docker-onboarding__kv-row--mount">
<input
className="input"
placeholder="Host path"
value={row.hostPath}
disabled={submitting}
onChange={(event) =>
updateMountRow(index, {
hostPath: event.target.value,
containerPath: row.containerPath,
mode: row.mode,
})
}
/>
<input
className="input"
placeholder="Container path"
value={row.containerPath}
disabled={submitting}
onChange={(event) =>
updateMountRow(index, {
hostPath: row.hostPath,
containerPath: event.target.value,
mode: row.mode,
})
}
/>
<select
className="select"
value={row.mode}
disabled={submitting}
onChange={(event) =>
updateMountRow(index, {
hostPath: row.hostPath,
containerPath: row.containerPath,
mode: event.target.value === "ro" ? "ro" : "rw",
})
}
>
<option value="rw">rw</option>
<option value="ro">ro</option>
</select>
<button
type="button"
className="btn btn-icon"
aria-label="Remove volume mount"
onClick={() => removeMountRow(index)}
disabled={submitting}
>
<Trash2 size={14} />
</button>
</div>
))}
<button
type="button"
className="btn btn-sm docker-onboarding__kv-add"
onClick={addMountRow}
disabled={submitting}
>
<Plus size={14} />
Add mount
</button>
</div>
</div>
</div>
</section>
</div>
<div className="modal-actions">
<button className="btn" onClick={closeModal} disabled={submitting}>
Cancel
</button>
<button className="btn btn-primary" onClick={() => void handleSubmit()} disabled={submitting}>
{submitting ? "Creating..." : "Create Docker Node"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -127,6 +127,8 @@ export function NodeDetailModal({
return getProjectsForNode(projects, node);
}, [node, projects]);
const isManagedDockerNode = useMemo(() => node?.capabilities?.includes("docker-managed") ?? false, [node]);
const handleHealthCheck = useCallback(async () => {
if (!node) return;
@@ -407,35 +409,37 @@ 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>
{isManagedDockerNode && (
<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" && (

View File

@@ -657,4 +657,5 @@
.nodes-view-section-title {
font-size: calc(var(--space-md) + var(--space-xs) / 4);
}
}

View File

@@ -8,7 +8,10 @@ import type { NodeInfo, NodeUpdateInput } from "../api";
import { NodeCard } from "./NodeCard";
import { MeshTopology } from "./MeshTopology";
import { AddNodeModal, type AddNodeInput } from "./AddNodeModal";
import { DockerNodeOnboardingModal } from "./DockerNodeOnboardingModal";
import { NodeDetailModal } from "./NodeDetailModal";
import { useManagedDockerNodes } from "../hooks/useManagedDockerNodes";
import type { ManagedDockerNodeInput } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
interface NodesViewProps {
@@ -20,7 +23,9 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
const { nodes, loading, error, refresh, register, update, unregister, healthCheck } = useNodes();
const { projects } = useProjects();
const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode, getAuthSyncState, getAuthProviders } = useNodeSettingsSync();
const { dockerNodes, create: createDockerNode } = useManagedDockerNodes();
const [addModalOpen, setAddModalOpen] = useState(false);
const [dockerOnboardingOpen, setDockerOnboardingOpen] = useState(false);
const [selectedNode, setSelectedNode] = useState<NodeInfo | null>(null);
// Track remote nodes for sync status polling
@@ -52,6 +57,18 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
await register(input);
}, [register]);
const handleCreateDockerNode = useCallback(async (input: ManagedDockerNodeInput) => {
try {
await createDockerNode(input);
addToast(`Docker node "${input.name}" created`, "success");
setDockerOnboardingOpen(false);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create Docker node";
addToast(message, "error");
throw err;
}
}, [addToast, createDockerNode]);
const handleRefresh = useCallback(async () => {
try {
await refresh();
@@ -110,10 +127,14 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
<RefreshCw size={14} className={loading ? "spin" : ""} />
Refresh
</button>
<button className="btn btn-primary btn-sm" onClick={() => setAddModalOpen(true)}>
<button className="btn btn-sm" onClick={() => setAddModalOpen(true)}>
<Plus size={14} />
Add Node
</button>
<button className="btn btn-primary btn-sm" onClick={() => setDockerOnboardingOpen(true)}>
<Plus size={14} />
Provision Docker Node
</button>
</div>
</div>
@@ -142,6 +163,17 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
{error && <div className="nodes-view-error">{error}</div>}
<section className="nodes-view-topology" aria-label="Docker Nodes Summary">
<h3 className="nodes-view-section-title">Docker Nodes</h3>
<div className="nodes-view-stat">
<span>Managed Docker Nodes</span>
<strong>{dockerNodes.length}</strong>
<button className="btn btn-sm" onClick={() => setDockerOnboardingOpen(true)}>
Provision
</button>
</div>
</section>
{/* Mesh Topology Visualization */}
{!loading && nodes.length > 0 && (
<section className="nodes-view-topology" aria-label="Mesh Topology">
@@ -195,6 +227,13 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
addToast={addToast}
/>
<DockerNodeOnboardingModal
isOpen={dockerOnboardingOpen}
onClose={() => setDockerOnboardingOpen(false)}
onSubmit={handleCreateDockerNode}
addToast={addToast}
/>
<NodeDetailModal
isOpen={selectedNode !== null}
onClose={() => setSelectedNode(null)}

View File

@@ -214,7 +214,7 @@ describe("AddNodeModal", () => {
render(<AddNodeModal {...defaultProps} />);
expect(
screen.getByText("Provision a managed Docker node with guided defaults, then expand Advanced for host/TLS/env/mount overrides.")
screen.getByText("Register an existing Fusion node by providing its connection details and concurrency settings.")
).toBeInTheDocument();
});

View File

@@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { DockerNodeOnboardingModal } from "../DockerNodeOnboardingModal";
describe("DockerNodeOnboardingModal", () => {
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
onSubmit: vi.fn().mockResolvedValue(undefined),
addToast: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it("renders only when open", () => {
const { rerender } = render(<DockerNodeOnboardingModal {...defaultProps} isOpen={false} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
rerender(<DockerNodeOnboardingModal {...defaultProps} isOpen />);
expect(screen.getByRole("dialog", { name: "Docker node onboarding" })).toBeInTheDocument();
});
it("shows required fields by default", () => {
render(<DockerNodeOnboardingModal {...defaultProps} />);
expect(screen.getByPlaceholderText("my-docker-node")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Local Docker" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Remote Docker" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Auto-generate" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Provide manually" })).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "Claude CLI" })).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "Droid CLI" })).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "Keep data across container recreations" })).toBeInTheDocument();
expect(screen.getByRole("spinbutton", { name: "Memory (MB)" })).toBeInTheDocument();
expect(screen.getByRole("spinbutton", { name: "CPUs" })).toBeInTheDocument();
});
it("toggles advanced section", () => {
render(<DockerNodeOnboardingModal {...defaultProps} />);
const advancedToggle = screen.getByRole("button", { name: "Advanced" });
expect(advancedToggle).toHaveClass("docker-onboarding__advanced-toggle");
expect(advancedToggle).not.toHaveClass("is-expanded");
fireEvent.click(advancedToggle);
expect(advancedToggle).toHaveClass("is-expanded");
expect(screen.getByPlaceholderText("runfusion/fusion")).toBeInTheDocument();
expect(screen.getByPlaceholderText("latest")).toBeInTheDocument();
expect(screen.getByPlaceholderText("default")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Remote Docker" }));
expect(screen.getByPlaceholderText("/etc/docker/ca.pem")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add variable" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add mount" })).toBeInTheDocument();
});
it("validates required and minimum fields", async () => {
render(<DockerNodeOnboardingModal {...defaultProps} />);
fireEvent.click(screen.getByRole("button", { name: "Create Docker Node" }));
expect(await screen.findByText("Name is required and must be 64 characters or fewer")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("my-docker-node"), { target: { value: "n1" } });
fireEvent.click(screen.getByRole("button", { name: "Remote Docker" }));
fireEvent.change(screen.getByPlaceholderText("http://192.168.1.50:4040"), { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: "Create Docker Node" }));
expect(await screen.findByText("URL is required for remote Docker")).toBeInTheDocument();
fireEvent.change(screen.getByRole("spinbutton", { name: "Memory (MB)" }), { target: { value: "256" } });
fireEvent.change(screen.getByRole("spinbutton", { name: "CPUs" }), { target: { value: "0.25" } });
fireEvent.click(screen.getByRole("button", { name: "Create Docker Node" }));
expect(await screen.findByText("Memory must be at least 512 MB")).toBeInTheDocument();
expect(await screen.findByText("CPUs must be at least 0.5")).toBeInTheDocument();
});
it("shows remote host and manual api key controls", () => {
render(<DockerNodeOnboardingModal {...defaultProps} />);
fireEvent.click(screen.getByRole("button", { name: "Remote Docker" }));
expect(screen.getAllByPlaceholderText("tcp://host:2376").length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("radio", { name: "Provide manually" }));
expect(screen.getByPlaceholderText("Enter API key")).toBeInTheDocument();
});
it("submits valid input shape", async () => {
render(<DockerNodeOnboardingModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("my-docker-node"), { target: { value: "docker-a" } });
fireEvent.click(screen.getByRole("button", { name: "Remote Docker" }));
fireEvent.change(screen.getByPlaceholderText("http://192.168.1.50:4040"), { target: { value: "http://10.0.0.2:4040" } });
fireEvent.change(screen.getByPlaceholderText("tcp://host:2376"), { target: { value: "tcp://10.0.0.2:2376" } });
fireEvent.click(screen.getByRole("checkbox", { name: "Claude CLI" }));
fireEvent.click(screen.getByRole("button", { name: "Advanced" }));
fireEvent.click(screen.getByRole("button", { name: "Add variable" }));
fireEvent.change(screen.getByPlaceholderText("KEY"), { target: { value: "A" } });
fireEvent.change(screen.getByPlaceholderText("Value"), { target: { value: "1" } });
fireEvent.click(screen.getByRole("button", { name: "Create Docker Node" }));
await waitFor(() => {
expect(defaultProps.onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "docker-a",
reachableUrl: "http://10.0.0.2:4040",
extraClis: ["claude-cli"],
hostConfig: expect.objectContaining({ host: "tcp://10.0.0.2:2376" }),
envVars: { A: "1" },
}),
);
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
it("shows creating state while submitting", async () => {
let resolveSubmit = () => {};
const slowSubmit = vi.fn().mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveSubmit = resolve;
}),
);
render(<DockerNodeOnboardingModal {...defaultProps} onSubmit={slowSubmit} />);
fireEvent.change(screen.getByPlaceholderText("my-docker-node"), { target: { value: "node-1" } });
fireEvent.click(screen.getByRole("button", { name: "Create Docker Node" }));
expect(screen.getByRole("button", { name: "Creating..." })).toBeDisabled();
resolveSubmit();
});
it("closes on escape and cancel", () => {
render(<DockerNodeOnboardingModal {...defaultProps} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(defaultProps.onClose).toHaveBeenCalledTimes(2);
});
it("resets form when closed and reopened", () => {
const { rerender } = render(<DockerNodeOnboardingModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("my-docker-node"), { target: { value: "changed" } });
rerender(<DockerNodeOnboardingModal {...defaultProps} isOpen={false} />);
rerender(<DockerNodeOnboardingModal {...defaultProps} isOpen />);
expect(screen.getByPlaceholderText("my-docker-node")).toHaveValue("");
});
});

View File

@@ -103,15 +103,21 @@ describe("NodeDetailModal", () => {
expect(screen.getByRole("dialog", { name: "Node details for Custom Node Name" })).toBeInTheDocument();
});
it("renders Overview, Projects, Health, Docker Management, and Settings Sync sections for remote nodes", () => {
it("renders Overview, Projects, Health, 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.queryByText("Docker Management")).not.toBeInTheDocument();
expect(screen.getByText("Settings Sync")).toBeInTheDocument();
});
it("renders Docker Management section for managed docker nodes", () => {
const dockerManagedNode = makeNode({ capabilities: ["docker-managed"] });
render(<NodeDetailModal {...defaultProps} node={dockerManagedNode} />);
expect(screen.getByText("Docker Management")).toBeInTheDocument();
});
it("does not render Settings Sync section for local nodes", () => {
const localNode = makeNode({ type: "local" });
render(<NodeDetailModal {...defaultProps} node={localNode} />);

View File

@@ -5,6 +5,7 @@ import type { NodeInfo, ProjectInfo } from "../../api";
import { useNodes } from "../../hooks/useNodes";
import { useProjects } from "../../hooks/useProjects";
import { useNodeSettingsSync } from "../../hooks/useNodeSettingsSync";
import { useManagedDockerNodes } from "../../hooks/useManagedDockerNodes";
import type { NodeSettingsSyncStatus } from "../../api-node";
vi.mock("../../hooks/useNodes", () => ({
@@ -39,9 +40,14 @@ vi.mock("../../hooks/useNodeSettingsSync", () => ({
}),
}));
vi.mock("../../hooks/useManagedDockerNodes", () => ({
useManagedDockerNodes: vi.fn(),
}));
const mockUseNodes = vi.mocked(useNodes);
const mockUseProjects = vi.mocked(useProjects);
const mockUseNodeSettingsSync = vi.mocked(useNodeSettingsSync);
const mockUseManagedDockerNodes = vi.mocked(useManagedDockerNodes);
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
return {
@@ -108,6 +114,14 @@ beforeEach(() => {
getAuthSyncState: vi.fn().mockReturnValue(undefined),
getAuthProviders: vi.fn().mockReturnValue(undefined),
});
mockUseManagedDockerNodes.mockReturnValue({
dockerNodes: [],
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue(undefined),
});
});
describe("NodesView", () => {

View File

@@ -0,0 +1,64 @@
import { useCallback, useEffect, useState } from "react";
import type { ManagedDockerNodeInput } from "@fusion/core";
import type { DockerNodeInfo } from "../api";
import { createManagedDockerNode, listManagedDockerNodes } from "../api";
export interface UseManagedDockerNodesResult {
dockerNodes: DockerNodeInfo[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
create: (input: ManagedDockerNodeInput) => Promise<DockerNodeInfo>;
}
export function useManagedDockerNodes(): UseManagedDockerNodesResult {
const [dockerNodes, setDockerNodes] = useState<DockerNodeInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
setError(null);
const data = await listManagedDockerNodes();
setDockerNodes(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch managed Docker nodes");
}
}, []);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
try {
const data = await listManagedDockerNodes();
if (!cancelled) {
setDockerNodes(data);
setError(null);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to fetch managed Docker nodes");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, []);
const create = useCallback(async (input: ManagedDockerNodeInput): Promise<DockerNodeInfo> => {
const node = await createManagedDockerNode(input);
setDockerNodes((previous) => [...previous, node]);
return node;
}, []);
return { dockerNodes, loading, error, refresh, create };
}

View File

@@ -98,6 +98,7 @@ import { registerFilesTerminalWorkspaceRoutes } from "./routes/register-files-te
import { registerAgentsProjectsNodesRoutes } from "./routes/register-agents-projects-nodes.js";
import { registerProjectRoutes } from "./routes/register-project-routes.js";
import { registerNodeRoutes } from "./routes/register-node-routes.js";
import { registerDockerNodeRoutes } from "./routes/register-docker-node-routes.js";
import { registerSettingsSyncRoutes } from "./routes/register-settings-sync-routes.js";
import { registerMeshRoutes } from "./routes/register-mesh-routes.js";
import { registerDiscoveryRoutes } from "./routes/register-discovery-routes.js";
@@ -3851,6 +3852,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// ── Node Management Routes (Multi-Node Support) ───────────────────────────
registerNodeRoutes(routeContext);
registerDockerNodeRoutes(routeContext);
// ── Remote Node Settings Sync Routes ──────────────────────────────────────

View File

@@ -0,0 +1,195 @@
// @vitest-environment node
import express from "express";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ManagedDockerNode, TaskStore } from "@fusion/core";
import { createApiRoutes } from "../../routes.js";
import { request as performRequest } from "../../test-request.js";
const mockListManagedDockerNodes = vi.fn<() => Promise<ManagedDockerNode[]>>();
const mockCreateManagedDockerNode = vi.fn<(input: any) => Promise<ManagedDockerNode>>();
const mockGetManagedDockerNode = vi.fn<(id: string) => Promise<ManagedDockerNode | undefined>>();
const mockCentralInit = vi.fn().mockResolvedValue(undefined);
const mockCentralClose = vi.fn().mockResolvedValue(undefined);
const mockCentralListProjects = vi.fn().mockResolvedValue([]);
const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined);
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
isGhAvailable: vi.fn(),
isGhAuthenticated: vi.fn(),
isQmdAvailable: vi.fn().mockResolvedValue(false),
CentralCore: vi.fn().mockImplementation(() => ({
init: mockCentralInit,
close: mockCentralClose,
listProjects: mockCentralListProjects,
reconcileProjectStatuses: mockCentralReconcileProjectStatuses,
listManagedDockerNodes: mockListManagedDockerNodes,
createManagedDockerNode: mockCreateManagedDockerNode,
getManagedDockerNode: mockGetManagedDockerNode,
})),
};
});
vi.mock("@fusion/engine", () => ({
createFnAgent: vi.fn(async () => ({ session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() } })),
promptWithFallback: vi.fn(),
}));
function createMockStore(): TaskStore {
return {
getTask: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
searchTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
getSettingsFast: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getGlobalSettingsStore: vi.fn(),
logEntry: vi.fn(),
getAgentLogs: vi.fn().mockResolvedValue([]),
getAgentLogCount: vi.fn().mockResolvedValue(0),
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
addTaskComment: vi.fn(),
updateTaskComment: vi.fn(),
deleteTaskComment: vi.fn(),
getTaskDocuments: vi.fn().mockResolvedValue([]),
getTaskDocument: vi.fn().mockResolvedValue(null),
getTaskDocumentRevisions: vi.fn().mockResolvedValue([]),
getAllDocuments: vi.fn().mockResolvedValue([]),
upsertTaskDocument: vi.fn(),
deleteTaskDocument: vi.fn(),
updatePrInfo: vi.fn(),
updateIssueInfo: vi.fn(),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
listWorkflowSteps: vi.fn().mockResolvedValue([]),
createWorkflowStep: vi.fn(),
getWorkflowStep: vi.fn(),
updateWorkflowStep: vi.fn(),
deleteWorkflowStep: vi.fn(),
getMissionStore: vi.fn(),
} as unknown as TaskStore;
}
function setupApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(createMockStore()));
return app;
}
async function req(app: express.Express, method: string, path: string, body?: unknown) {
return performRequest(
app,
method,
path,
body === undefined ? undefined : JSON.stringify(body),
body === undefined ? undefined : { "Content-Type": "application/json" },
);
}
const sampleNode: ManagedDockerNode = {
id: "dn_1",
nodeId: null,
name: "Docker Node",
imageName: "runfusion/fusion",
imageTag: "latest",
containerId: null,
status: "creating",
hostConfig: {},
envVars: {},
volumeMounts: [],
resourceSizing: { memoryMB: 4096, cpus: 2 },
extraClis: [],
persistentStorage: true,
reachableUrl: null,
apiKey: null,
errorMessage: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
describe("docker node routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockListManagedDockerNodes.mockResolvedValue([sampleNode]);
mockCreateManagedDockerNode.mockResolvedValue(sampleNode);
mockGetManagedDockerNode.mockResolvedValue(sampleNode);
});
it("POST /api/docker-nodes with valid input returns 201", async () => {
const app = setupApp();
const res = await req(app, "POST", "/api/docker-nodes", {
nodeId: null,
name: "Docker Node",
imageName: "runfusion/fusion",
imageTag: "latest",
hostConfig: {},
envVars: {},
volumeMounts: [],
resourceSizing: { memoryMB: 4096, cpus: 2 },
extraClis: [],
persistentStorage: true,
reachableUrl: null,
apiKey: null,
});
expect(res.status).toBe(201);
expect(mockCreateManagedDockerNode).toHaveBeenCalled();
});
it("POST /api/docker-nodes without name returns 400", async () => {
const app = setupApp();
const res = await req(app, "POST", "/api/docker-nodes", { imageName: "runfusion/fusion" });
expect(res.status).toBe(400);
});
it("POST /api/docker-nodes with empty name returns 400", async () => {
const app = setupApp();
const res = await req(app, "POST", "/api/docker-nodes", { name: " ", imageName: "runfusion/fusion" });
expect(res.status).toBe(400);
});
it("POST /api/docker-nodes with invalid extraClis returns 400", async () => {
const app = setupApp();
const res = await req(app, "POST", "/api/docker-nodes", {
name: "Docker Node",
imageName: "runfusion/fusion",
extraClis: ["bad-cli"],
});
expect(res.status).toBe(400);
});
it("GET /api/docker-nodes returns list", async () => {
const app = setupApp();
const res = await req(app, "GET", "/api/docker-nodes");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
});
it("GET /api/docker-nodes/:id returns single node", async () => {
const app = setupApp();
const res = await req(app, "GET", "/api/docker-nodes/dn_1");
expect(res.status).toBe(200);
expect(res.body.id).toBe("dn_1");
});
it("GET /api/docker-nodes/:id with non-existent ID returns 404", async () => {
mockGetManagedDockerNode.mockResolvedValue(undefined);
const app = setupApp();
const res = await req(app, "GET", "/api/docker-nodes/missing");
expect(res.status).toBe(404);
});
});

View File

@@ -0,0 +1,155 @@
import type { DockerExtraCli, DockerHostConfig, DockerVolumeMount, ManagedDockerNodeInput } from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js";
import type { ApiRouteRegistrar } from "./types.js";
const VALID_EXTRA_CLIS: DockerExtraCli[] = ["claude-cli", "droid-cli"];
function sanitizeHostConfig(input: unknown): DockerHostConfig {
const host = (input ?? {}) as Partial<DockerHostConfig>;
return {
host: typeof host.host === "string" ? host.host.trim() : undefined,
context: typeof host.context === "string" ? host.context.trim() : undefined,
tlsVerify: host.tlsVerify === undefined ? undefined : Boolean(host.tlsVerify),
tlsCaPath: typeof host.tlsCaPath === "string" ? host.tlsCaPath.trim() : undefined,
tlsCertPath: typeof host.tlsCertPath === "string" ? host.tlsCertPath.trim() : undefined,
tlsKeyPath: typeof host.tlsKeyPath === "string" ? host.tlsKeyPath.trim() : undefined,
};
}
function sanitizeVolumeMounts(input: unknown): DockerVolumeMount[] {
if (!Array.isArray(input)) {
throw badRequest("volumeMounts must be an array");
}
return input.map((mount, index) => {
if (!mount || typeof mount !== "object") {
throw badRequest(`volumeMounts[${index}] must be an object`);
}
const normalized = mount as Partial<DockerVolumeMount>;
const hostPath = typeof normalized.hostPath === "string" ? normalized.hostPath.trim() : "";
const containerPath = typeof normalized.containerPath === "string" ? normalized.containerPath.trim() : "";
const mode = normalized.mode === "ro" ? "ro" : "rw";
if (!hostPath || !containerPath) {
throw badRequest(`volumeMounts[${index}] requires hostPath and containerPath`);
}
return { hostPath, containerPath, mode };
});
}
function sanitizeExtraClis(input: unknown): DockerExtraCli[] {
if (!Array.isArray(input)) {
throw badRequest("extraClis must be an array");
}
for (const cli of input) {
if (typeof cli !== "string" || !VALID_EXTRA_CLIS.includes(cli as DockerExtraCli)) {
throw badRequest("extraClis contains unsupported value");
}
}
return input as DockerExtraCli[];
}
export const registerDockerNodeRoutes: ApiRouteRegistrar = (ctx) => {
const { router, rethrowAsApiError } = ctx;
router.get("/docker-nodes", async (_req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const nodes = await central.listManagedDockerNodes();
await central.close();
nodes.sort((a, b) => a.name.localeCompare(b.name));
res.json(nodes);
} catch (error: unknown) {
if (error instanceof ApiError) {
throw error;
}
rethrowAsApiError(error);
}
});
router.post("/docker-nodes", async (req, res) => {
try {
const payload = (req.body ?? {}) as Partial<ManagedDockerNodeInput>;
const name = typeof payload.name === "string" ? payload.name.trim() : "";
const imageName = typeof payload.imageName === "string" ? payload.imageName.trim() : "";
if (!name || name.length > 64) {
throw badRequest("name is required and must be 1-64 characters");
}
if (!imageName) {
throw badRequest("imageName is required");
}
const input: ManagedDockerNodeInput = {
nodeId: typeof payload.nodeId === "string" ? payload.nodeId.trim() : null,
name,
imageName,
imageTag: typeof payload.imageTag === "string" && payload.imageTag.trim() ? payload.imageTag.trim() : "latest",
hostConfig: sanitizeHostConfig(payload.hostConfig),
envVars:
payload.envVars && typeof payload.envVars === "object" && !Array.isArray(payload.envVars)
? Object.fromEntries(
Object.entries(payload.envVars).map(([key, value]) => [key.trim(), String(value)]),
)
: {},
volumeMounts: sanitizeVolumeMounts(payload.volumeMounts ?? []),
resourceSizing: {
memoryMB:
typeof payload.resourceSizing?.memoryMB === "number" && Number.isFinite(payload.resourceSizing.memoryMB)
? payload.resourceSizing.memoryMB
: 4096,
cpus:
typeof payload.resourceSizing?.cpus === "number" && Number.isFinite(payload.resourceSizing.cpus)
? payload.resourceSizing.cpus
: 2,
},
extraClis: sanitizeExtraClis(payload.extraClis ?? []),
persistentStorage: payload.persistentStorage !== undefined ? Boolean(payload.persistentStorage) : true,
reachableUrl:
typeof payload.reachableUrl === "string" && payload.reachableUrl.trim() ? payload.reachableUrl.trim() : null,
apiKey: typeof payload.apiKey === "string" && payload.apiKey.trim() ? payload.apiKey.trim() : null,
};
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const created = await central.createManagedDockerNode(input);
await central.close();
res.status(201).json(created);
} catch (error: unknown) {
if (error instanceof ApiError) {
throw error;
}
rethrowAsApiError(error);
}
});
router.get("/docker-nodes/:id", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const node = await central.getManagedDockerNode(req.params.id);
await central.close();
if (!node) {
throw notFound("Managed Docker node not found");
}
res.json(node);
} catch (error: unknown) {
if (error instanceof ApiError) {
throw error;
}
rethrowAsApiError(error);
}
});
};