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:
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
192
packages/dashboard/app/components/DockerNodeOnboardingModal.css
Normal file
192
packages/dashboard/app/components/DockerNodeOnboardingModal.css
Normal 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;
|
||||
}
|
||||
}
|
||||
631
packages/dashboard/app/components/DockerNodeOnboardingModal.tsx
Normal file
631
packages/dashboard/app/components/DockerNodeOnboardingModal.tsx
Normal 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">
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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" && (
|
||||
|
||||
@@ -657,4 +657,5 @@
|
||||
.nodes-view-section-title {
|
||||
font-size: calc(var(--space-md) + var(--space-xs) / 4);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
64
packages/dashboard/app/hooks/useManagedDockerNodes.ts
Normal file
64
packages/dashboard/app/hooks/useManagedDockerNodes.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 ──────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
155
packages/dashboard/src/routes/register-docker-node-routes.ts
Normal file
155
packages/dashboard/src/routes/register-docker-node-routes.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user