feat(FN-3505): add node onboarding mapping flow with asset-gated bundling
- Add onboarding project-mapping API contracts and dashboard UI wiring in AddNodeModal/NodesView - Persist node-to-project mappings with rollback-safe failure handling and expanded hook/API test coverage - Document node onboarding mapping behavior in architecture and multi-project docs - Add tsup bundle asset gates for openclaw bridge and droid runtime, with changesets and bundle output tests Fusion-Task-Id: FN-3505
This commit is contained in:
@@ -758,6 +758,12 @@ Custom-provider settings routes are registered in `register-custom-provider-rout
|
|||||||
|
|
||||||
This API surface is intentionally separate from `projects.nodeId` (runtime host placement metadata) and from task-level routing defaults (`defaultNodeId` / `Task.nodeId`).
|
This API surface is intentionally separate from `projects.nodeId` (runtime host placement metadata) and from task-level routing defaults (`defaultNodeId` / `Task.nodeId`).
|
||||||
|
|
||||||
|
Dashboard node onboarding (`AddNodeModal` → `useNodes.register`) uses a two-phase flow:
|
||||||
|
1. Register node metadata first via `POST /api/nodes`.
|
||||||
|
2. Persist selected project↔node path mappings with per-project `PUT /api/projects/:id/path-mappings/:nodeId` upserts.
|
||||||
|
|
||||||
|
The client treats mapping persistence as part of onboarding success. If mapping writes fail after node creation, onboarding attempts rollback via `DELETE /api/nodes/:id` and refreshes node state to avoid a silent half-configured node.
|
||||||
|
|
||||||
### Node settings sync and update-check endpoints
|
### Node settings sync and update-check endpoints
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|
|||||||
@@ -115,6 +115,19 @@ Dashboard and node workflows should use dedicated mapping endpoints rather than
|
|||||||
|
|
||||||
These APIs persist/read `projectNodePathMappings` (`projectId` + `nodeId` key). They do **not** assign runtime hosting, and they do **not** change task routing defaults.
|
These APIs persist/read `projectNodePathMappings` (`projectId` + `nodeId` key). They do **not** assign runtime hosting, and they do **not** change task routing defaults.
|
||||||
|
|
||||||
|
### Node onboarding path-capture flow
|
||||||
|
|
||||||
|
When adding a node from the dashboard, onboarding now supports attaching already-registered projects and capturing a node-specific absolute path for each selected project.
|
||||||
|
|
||||||
|
- Step 1: register the node (`POST /api/nodes`)
|
||||||
|
- Step 2: upsert one `projectNodePathMappings` record per selected project (`PUT /api/projects/:id/path-mappings/:nodeId`)
|
||||||
|
|
||||||
|
This onboarding mapping capture is intentionally separate from:
|
||||||
|
- `projects.nodeId` (runtime host-node assignment)
|
||||||
|
- `projects.path` / `ProjectInfo.path` (canonical registered project path)
|
||||||
|
|
||||||
|
So node onboarding records where a given node can access a project on disk, without changing which node hosts the runtime or task-routing defaults.
|
||||||
|
|
||||||
### Runtime placement (`projects.nodeId`)
|
### Runtime placement (`projects.nodeId`)
|
||||||
|
|
||||||
`ProjectManager` uses project registration data plus isolation mode to pick runtime type:
|
`ProjectManager` uses project registration data plus isolation mode to pick runtime type:
|
||||||
|
|||||||
@@ -10,21 +10,25 @@ import {
|
|||||||
fetchNodeSettingsSyncStatus,
|
fetchNodeSettingsSyncStatus,
|
||||||
syncNodeAuth,
|
syncNodeAuth,
|
||||||
fetchNodeProjectPathMappings,
|
fetchNodeProjectPathMappings,
|
||||||
|
persistNodeProjectPathMappings,
|
||||||
} from "../api-node";
|
} from "../api-node";
|
||||||
import * as apiModule from "../api";
|
import * as apiModule from "../api";
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../api", () => ({
|
||||||
proxyApi: vi.fn(),
|
proxyApi: vi.fn(),
|
||||||
api: vi.fn(),
|
api: vi.fn(),
|
||||||
|
upsertProjectPathMapping: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockProxyApi = vi.mocked(apiModule.proxyApi);
|
const mockProxyApi = vi.mocked(apiModule.proxyApi);
|
||||||
const mockApi = vi.mocked(apiModule.api);
|
const mockApi = vi.mocked(apiModule.api);
|
||||||
|
const mockUpsertProjectPathMapping = vi.mocked(apiModule.upsertProjectPathMapping);
|
||||||
|
|
||||||
describe("api-node", () => {
|
describe("api-node", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockProxyApi.mockReset();
|
mockProxyApi.mockReset();
|
||||||
mockApi.mockReset();
|
mockApi.mockReset();
|
||||||
|
mockUpsertProjectPathMapping.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("fetchRemoteNodeHealth", () => {
|
describe("fetchRemoteNodeHealth", () => {
|
||||||
@@ -231,6 +235,45 @@ describe("api-node", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("persistNodeProjectPathMappings", () => {
|
||||||
|
it("upserts one mapping per selected project", async () => {
|
||||||
|
mockUpsertProjectPathMapping
|
||||||
|
.mockResolvedValueOnce({ projectId: "proj-1", nodeId: "node-1", path: "/node/proj-1", createdAt: "t", updatedAt: "t" })
|
||||||
|
.mockResolvedValueOnce({ projectId: "proj-2", nodeId: "node-1", path: "/node/proj-2", createdAt: "t", updatedAt: "t" });
|
||||||
|
|
||||||
|
const result = await persistNodeProjectPathMappings("node-1", [
|
||||||
|
{ projectId: "proj-1", path: "/node/proj-1" },
|
||||||
|
{ projectId: "proj-2", path: "/node/proj-2" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(mockUpsertProjectPathMapping).toHaveBeenNthCalledWith(1, "proj-1", "node-1", "/node/proj-1");
|
||||||
|
expect(mockUpsertProjectPathMapping).toHaveBeenNthCalledWith(2, "proj-2", "node-1", "/node/proj-2");
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves encoded project ids through delegated helper calls", async () => {
|
||||||
|
mockUpsertProjectPathMapping.mockResolvedValueOnce({
|
||||||
|
projectId: "proj/1+2",
|
||||||
|
nodeId: "node/1+2",
|
||||||
|
path: "/path",
|
||||||
|
createdAt: "t",
|
||||||
|
updatedAt: "t",
|
||||||
|
});
|
||||||
|
|
||||||
|
await persistNodeProjectPathMappings("node/1+2", [{ projectId: "proj/1+2", path: "/path" }]);
|
||||||
|
|
||||||
|
expect(mockUpsertProjectPathMapping).toHaveBeenCalledWith("proj/1+2", "node/1+2", "/path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates the first upsert failure", async () => {
|
||||||
|
mockUpsertProjectPathMapping.mockRejectedValueOnce(new Error("mapping failed"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
persistNodeProjectPathMappings("node-1", [{ projectId: "proj-1", path: "/node/proj-1" }]),
|
||||||
|
).rejects.toThrow("mapping failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("error handling", () => {
|
describe("error handling", () => {
|
||||||
it("propagates errors from proxyApi", async () => {
|
it("propagates errors from proxyApi", async () => {
|
||||||
mockProxyApi.mockRejectedValueOnce(new Error("Network error"));
|
mockProxyApi.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
* All functions route through /api/proxy/:nodeId/... when a remote node is targeted.
|
* All functions route through /api/proxy/:nodeId/... when a remote node is targeted.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ProjectInfo } from "./api";
|
import type { NodeProjectMappingInput, ProjectInfo } from "./api";
|
||||||
import type { ProjectHealth, ProjectNodePathMapping, Task } from "@fusion/core";
|
import type { ProjectHealth, ProjectNodePathMapping, Task } from "@fusion/core";
|
||||||
import { api, proxyApi } from "./api";
|
import { api, proxyApi, upsertProjectPathMapping } from "./api";
|
||||||
|
|
||||||
/** Health information for a remote node */
|
/** Health information for a remote node */
|
||||||
export interface RemoteNodeHealth {
|
export interface RemoteNodeHealth {
|
||||||
@@ -125,3 +125,13 @@ export async function syncNodeAuth(nodeId: string): Promise<NodeAuthSyncResult>
|
|||||||
export async function fetchNodeProjectPathMappings(nodeId: string): Promise<ProjectNodePathMapping[]> {
|
export async function fetchNodeProjectPathMappings(nodeId: string): Promise<ProjectNodePathMapping[]> {
|
||||||
return api<ProjectNodePathMapping[]>(`/nodes/${encodeURIComponent(nodeId)}/path-mappings`);
|
return api<ProjectNodePathMapping[]>(`/nodes/${encodeURIComponent(nodeId)}/path-mappings`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persist one mapping per selected project for a newly-created node. */
|
||||||
|
export async function persistNodeProjectPathMappings(
|
||||||
|
nodeId: string,
|
||||||
|
projectMappings: NodeProjectMappingInput[],
|
||||||
|
): Promise<ProjectNodePathMapping[]> {
|
||||||
|
return Promise.all(
|
||||||
|
projectMappings.map(({ projectId, path }) => upsertProjectPathMapping(projectId, nodeId, path)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,3 +5,4 @@
|
|||||||
* while implementation lives under `app/api/*` modules.
|
* while implementation lives under `app/api/*` modules.
|
||||||
*/
|
*/
|
||||||
export * from "./api/legacy";
|
export * from "./api/legacy";
|
||||||
|
export * from "./api-node";
|
||||||
|
|||||||
@@ -5542,6 +5542,22 @@ export interface NodeCreateInput {
|
|||||||
dockerConfig?: DockerNodeConfigInfo;
|
dockerConfig?: DockerNodeConfigInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Input for assigning a project path for a specific node during onboarding. */
|
||||||
|
export interface NodeProjectMappingInput {
|
||||||
|
projectId: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node onboarding payload used by dashboard UI.
|
||||||
|
*
|
||||||
|
* `projectMappings` is intentionally separate from `ProjectInfo.path` and `projects.nodeId`.
|
||||||
|
* It captures node-specific filesystem paths for selected existing projects.
|
||||||
|
*/
|
||||||
|
export interface NodeOnboardingInput extends NodeCreateInput {
|
||||||
|
projectMappings: NodeProjectMappingInput[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Input for updating an existing node */
|
/** Input for updating an existing node */
|
||||||
export type NodeUpdateInput = Partial<Pick<NodeCreateInput, "name" | "type" | "url" | "apiKey" | "maxConcurrent" | "dockerConfig">> & {
|
export type NodeUpdateInput = Partial<Pick<NodeCreateInput, "name" | "type" | "url" | "apiKey" | "maxConcurrent" | "dockerConfig">> & {
|
||||||
status?: NodeStatus;
|
status?: NodeStatus;
|
||||||
|
|||||||
@@ -149,6 +149,34 @@
|
|||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-node-modal__projects {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-node-modal__projects-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-node-modal__project-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-node-modal__project-card {
|
||||||
|
padding: var(--space-sm);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-node-modal__project-toggle {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.add-node-modal {
|
.add-node-modal {
|
||||||
width: calc(100vw - (var(--space-md) * 2));
|
width: calc(100vw - (var(--space-md) * 2));
|
||||||
@@ -171,4 +199,8 @@
|
|||||||
.add-node-modal__row {
|
.add-node-modal__row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-node-modal__project-card {
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { NodeProjectMappingInput, ProjectInfo } from "../api";
|
||||||
|
import { validateProjectPath } from "../utils/projectDetection";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import "./AddNodeModal.css";
|
import "./AddNodeModal.css";
|
||||||
@@ -9,6 +11,7 @@ export interface AddNodeInput {
|
|||||||
url?: string;
|
url?: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
maxConcurrent: number;
|
maxConcurrent: number;
|
||||||
|
projectMappings: NodeProjectMappingInput[];
|
||||||
apiKeyMode?: "auto-generate" | "provide";
|
apiKeyMode?: "auto-generate" | "provide";
|
||||||
extraClis?: Array<"claude-cli" | "droid-cli">;
|
extraClis?: Array<"claude-cli" | "droid-cli">;
|
||||||
persistentStorage?: boolean;
|
persistentStorage?: boolean;
|
||||||
@@ -30,19 +33,21 @@ interface AddNodeModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (input: AddNodeInput) => Promise<void>;
|
onSubmit: (input: AddNodeInput) => Promise<void>;
|
||||||
addToast: (message: string, type?: ToastType) => void;
|
addToast: (message: string, type?: ToastType) => void;
|
||||||
|
projects: ProjectInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormErrors {
|
interface FormErrors {
|
||||||
name?: string;
|
name?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
maxConcurrent?: string;
|
maxConcurrent?: string;
|
||||||
|
projectMappings: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_CONCURRENT_MIN = 1;
|
const MAX_CONCURRENT_MIN = 1;
|
||||||
const MAX_CONCURRENT_MAX = 10;
|
const MAX_CONCURRENT_MAX = 10;
|
||||||
|
|
||||||
function validateInput(input: AddNodeInput): FormErrors {
|
function validateInput(input: AddNodeInput): FormErrors {
|
||||||
const errors: FormErrors = {};
|
const errors: FormErrors = { projectMappings: {} };
|
||||||
|
|
||||||
if (!input.name.trim()) {
|
if (!input.name.trim()) {
|
||||||
errors.name = "Name is required";
|
errors.name = "Name is required";
|
||||||
@@ -56,10 +61,17 @@ function validateInput(input: AddNodeInput): FormErrors {
|
|||||||
errors.maxConcurrent = `Concurrency must be between ${MAX_CONCURRENT_MIN} and ${MAX_CONCURRENT_MAX}`;
|
errors.maxConcurrent = `Concurrency must be between ${MAX_CONCURRENT_MIN} and ${MAX_CONCURRENT_MAX}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const mapping of input.projectMappings) {
|
||||||
|
const validation = validateProjectPath(mapping.path);
|
||||||
|
if (!validation.valid) {
|
||||||
|
errors.projectMappings[mapping.projectId] = validation.error ?? "Path is invalid";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeModalProps) {
|
export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }: AddNodeModalProps) {
|
||||||
useMobileScrollLock(isOpen);
|
useMobileScrollLock(isOpen);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [type, setType] = useState<"local" | "remote">("local");
|
const [type, setType] = useState<"local" | "remote">("local");
|
||||||
@@ -67,7 +79,8 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
|
|||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
||||||
const [apiKeyMode, setApiKeyMode] = useState<"auto-generate" | "provide">("auto-generate");
|
const [apiKeyMode, setApiKeyMode] = useState<"auto-generate" | "provide">("auto-generate");
|
||||||
const [errors, setErrors] = useState<FormErrors>({});
|
const [selectedProjectPaths, setSelectedProjectPaths] = useState<Record<string, string>>({});
|
||||||
|
const [errors, setErrors] = useState<FormErrors>({ projectMappings: {} });
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const resetForm = useCallback(() => {
|
const resetForm = useCallback(() => {
|
||||||
@@ -77,7 +90,8 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
|
|||||||
setApiKey("");
|
setApiKey("");
|
||||||
setMaxConcurrent(2);
|
setMaxConcurrent(2);
|
||||||
setApiKeyMode("auto-generate");
|
setApiKeyMode("auto-generate");
|
||||||
setErrors({});
|
setSelectedProjectPaths({});
|
||||||
|
setErrors({ projectMappings: {} });
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -113,7 +127,8 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
|
|||||||
apiKey: type === "remote" && apiKeyMode === "provide" ? apiKey || undefined : undefined,
|
apiKey: type === "remote" && apiKeyMode === "provide" ? apiKey || undefined : undefined,
|
||||||
maxConcurrent,
|
maxConcurrent,
|
||||||
apiKeyMode,
|
apiKeyMode,
|
||||||
}), [apiKey, apiKeyMode, maxConcurrent, name, type, url]);
|
projectMappings: Object.entries(selectedProjectPaths).map(([projectId, path]) => ({ projectId, path: path.trim() })),
|
||||||
|
}), [apiKey, apiKeyMode, maxConcurrent, name, selectedProjectPaths, type, url]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (isSubmitting) return;
|
if (isSubmitting) return;
|
||||||
@@ -121,7 +136,12 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
|
|||||||
const validationErrors = validateInput(input);
|
const validationErrors = validateInput(input);
|
||||||
setErrors(validationErrors);
|
setErrors(validationErrors);
|
||||||
|
|
||||||
if (Object.keys(validationErrors).length > 0) {
|
if (
|
||||||
|
validationErrors.name
|
||||||
|
|| validationErrors.url
|
||||||
|
|| validationErrors.maxConcurrent
|
||||||
|
|| Object.keys(validationErrors.projectMappings).length > 0
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +159,20 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
|
|||||||
}
|
}
|
||||||
}, [addToast, closeModal, input, isSubmitting, onSubmit]);
|
}, [addToast, closeModal, input, isSubmitting, onSubmit]);
|
||||||
|
|
||||||
|
const toggleProjectSelection = (project: ProjectInfo) => {
|
||||||
|
setSelectedProjectPaths((current) => {
|
||||||
|
if (project.id in current) {
|
||||||
|
const { [project.id]: _removed, ...remaining } = current;
|
||||||
|
return remaining;
|
||||||
|
}
|
||||||
|
return { ...current, [project.id]: project.path };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProjectPath = (projectId: string, path: string) => {
|
||||||
|
setSelectedProjectPaths((current) => ({ ...current, [projectId]: path }));
|
||||||
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -253,6 +287,49 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
|
|||||||
{errors.maxConcurrent && <span className="form-error add-node-modal__error">{errors.maxConcurrent}</span>}
|
{errors.maxConcurrent && <span className="form-error add-node-modal__error">{errors.maxConcurrent}</span>}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<section className="add-node-modal__projects" aria-label="Project path mappings">
|
||||||
|
<h4 className="add-node-modal__projects-title">Attach Existing Projects</h4>
|
||||||
|
<p className="add-node-modal__hint">Select existing projects to run on this node and provide the node-specific absolute path for each one.</p>
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<p className="add-node-modal__hint">No projects are currently registered.</p>
|
||||||
|
) : (
|
||||||
|
<div className="add-node-modal__project-list">
|
||||||
|
{projects.map((project) => {
|
||||||
|
const selected = project.id in selectedProjectPaths;
|
||||||
|
const error = errors.projectMappings[project.id];
|
||||||
|
return (
|
||||||
|
<div key={project.id} className="card add-node-modal__project-card">
|
||||||
|
<label className="checkbox-label add-node-modal__project-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={() => toggleProjectSelection(project)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<span>{project.name}</span>
|
||||||
|
</label>
|
||||||
|
{selected && (
|
||||||
|
<label className="add-node-modal__field">
|
||||||
|
<span>Path on this node</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
value={selectedProjectPaths[project.id] ?? ""}
|
||||||
|
onChange={(event) => updateProjectPath(project.id, event.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
placeholder="/absolute/path/to/project"
|
||||||
|
aria-invalid={Boolean(error)}
|
||||||
|
/>
|
||||||
|
{error && <span className="form-error add-node-modal__error">{error}</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
|||||||
patchDockerConfig,
|
patchDockerConfig,
|
||||||
fetchDockerDiff,
|
fetchDockerDiff,
|
||||||
} = useNodes();
|
} = useNodes();
|
||||||
const { projects } = useProjects();
|
const { projects, refresh: refreshProjects } = useProjects();
|
||||||
const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode, getAuthSyncState, getAuthProviders } = useNodeSettingsSync();
|
const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode, getAuthSyncState, getAuthProviders } = useNodeSettingsSync();
|
||||||
const {
|
const {
|
||||||
dockerNodes,
|
dockerNodes,
|
||||||
@@ -74,7 +74,8 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
|||||||
|
|
||||||
const handleRegister = useCallback(async (input: AddNodeInput) => {
|
const handleRegister = useCallback(async (input: AddNodeInput) => {
|
||||||
await register(input);
|
await register(input);
|
||||||
}, [register]);
|
await refreshProjects();
|
||||||
|
}, [refreshProjects, register]);
|
||||||
|
|
||||||
const handleCreateDockerNode = useCallback(async (input: ManagedDockerNodeInput) => {
|
const handleCreateDockerNode = useCallback(async (input: ManagedDockerNodeInput) => {
|
||||||
try {
|
try {
|
||||||
@@ -249,6 +250,7 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
|||||||
onClose={() => setAddModalOpen(false)}
|
onClose={() => setAddModalOpen(false)}
|
||||||
onSubmit={handleRegister}
|
onSubmit={handleRegister}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
|
projects={projects}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DockerNodeOnboardingModal
|
<DockerNodeOnboardingModal
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { AddNodeModal } from "../AddNodeModal";
|
import { AddNodeModal } from "../AddNodeModal";
|
||||||
import type { NodeInfo } from "../../api";
|
|
||||||
|
|
||||||
describe("AddNodeModal", () => {
|
describe("AddNodeModal", () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
@@ -9,6 +8,24 @@ describe("AddNodeModal", () => {
|
|||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
onSubmit: vi.fn().mockResolvedValue(undefined),
|
onSubmit: vi.fn().mockResolvedValue(undefined),
|
||||||
addToast: vi.fn(),
|
addToast: vi.fn(),
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: "proj-1",
|
||||||
|
name: "Project One",
|
||||||
|
path: "/workspace/project-one",
|
||||||
|
status: "active" as const,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "proj-2",
|
||||||
|
name: "Project Two",
|
||||||
|
path: "/workspace/project-two",
|
||||||
|
status: "active" as const,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -92,6 +109,7 @@ describe("AddNodeModal", () => {
|
|||||||
url: undefined,
|
url: undefined,
|
||||||
apiKey: undefined,
|
apiKey: undefined,
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
|
projectMappings: [],
|
||||||
}));
|
}));
|
||||||
expect(defaultProps.addToast).toHaveBeenCalledWith('Node "Test Node" registered', "success");
|
expect(defaultProps.addToast).toHaveBeenCalledWith('Node "Test Node" registered', "success");
|
||||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||||
@@ -249,7 +267,74 @@ describe("AddNodeModal", () => {
|
|||||||
url: "https://node.example.com",
|
url: "https://node.example.com",
|
||||||
apiKey: "secret-key",
|
apiKey: "secret-key",
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
|
projectMappings: [],
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("validates selected project path is required", async () => {
|
||||||
|
render(<AddNodeModal {...defaultProps} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
|
||||||
|
target: { value: "Node With Project" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: "Project One" }));
|
||||||
|
fireEvent.change(screen.getByDisplayValue("/workspace/project-one"), {
|
||||||
|
target: { value: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Path is required")).toBeInTheDocument();
|
||||||
|
expect(defaultProps.onSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates selected project path is absolute", async () => {
|
||||||
|
render(<AddNodeModal {...defaultProps} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
|
||||||
|
target: { value: "Node With Project" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: "Project One" }));
|
||||||
|
fireEvent.change(screen.getByDisplayValue("/workspace/project-one"), {
|
||||||
|
target: { value: "relative/path" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Path must be absolute")).toBeInTheDocument();
|
||||||
|
expect(defaultProps.onSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits selected project mappings", async () => {
|
||||||
|
render(<AddNodeModal {...defaultProps} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
|
||||||
|
target: { value: "Node With Project" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: "Project One" }));
|
||||||
|
fireEvent.change(screen.getByDisplayValue("/workspace/project-one"), {
|
||||||
|
target: { value: "/mnt/node/project-one" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(defaultProps.onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
name: "Node With Project",
|
||||||
|
projectMappings: [{ projectId: "proj-1", path: "/mnt/node/project-one" }],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes path input when project is deselected", () => {
|
||||||
|
render(<AddNodeModal {...defaultProps} />);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole("checkbox", { name: "Project One" });
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
expect(screen.getByDisplayValue("/workspace/project-one")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
expect(screen.queryByDisplayValue("/workspace/project-one")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { NodesView } from "../NodesView";
|
import { NodesView } from "../NodesView";
|
||||||
import type { NodeInfo, ProjectInfo } from "../../api";
|
import type { NodeInfo, ProjectInfo } from "../../api";
|
||||||
import { useNodes } from "../../hooks/useNodes";
|
import { useNodes } from "../../hooks/useNodes";
|
||||||
@@ -217,6 +217,35 @@ describe("NodesView", () => {
|
|||||||
expect(screen.getByRole("dialog", { name: "Add Node" })).toBeDefined();
|
expect(screen.getByRole("dialog", { name: "Add Node" })).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refreshes projects after node registration succeeds", async () => {
|
||||||
|
const register = vi.fn().mockResolvedValue(makeNode({ id: "node-new", name: "New Node" }));
|
||||||
|
const refreshProjects = vi.fn().mockResolvedValue(undefined);
|
||||||
|
mockUseNodes.mockReturnValue(makeUseNodesResult({ nodes: [], register }));
|
||||||
|
mockUseProjects.mockReturnValue({
|
||||||
|
projects: [makeProject()],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: refreshProjects,
|
||||||
|
register: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
unregister: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Add Node"));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Build Machine"), { target: { value: "New Node" } });
|
||||||
|
fireEvent.click(screen.getByTestId("add-node-submit"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(register).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
name: "New Node",
|
||||||
|
projectMappings: [],
|
||||||
|
}));
|
||||||
|
expect(refreshProjects).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("opens Node Detail modal when a node card is clicked", () => {
|
it("opens Node Detail modal when a node card is clicked", () => {
|
||||||
mockUseProjects.mockReturnValue({
|
mockUseProjects.mockReturnValue({
|
||||||
projects: [makeProject({ nodeId: "node-1" })],
|
projects: [makeProject({ nodeId: "node-1" })],
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|||||||
import { renderHook, act } from "@testing-library/react";
|
import { renderHook, act } from "@testing-library/react";
|
||||||
import { useNodes } from "../useNodes";
|
import { useNodes } from "../useNodes";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
import type { NodeInfo, NodeCreateInput } from "../../api";
|
import * as nodeApi from "../../api-node";
|
||||||
|
import type { NodeInfo, NodeOnboardingInput } from "../../api";
|
||||||
|
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
fetchNodes: vi.fn(),
|
fetchNodes: vi.fn(),
|
||||||
@@ -12,11 +13,16 @@ vi.mock("../../api", () => ({
|
|||||||
checkNodeHealth: vi.fn(),
|
checkNodeHealth: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../api-node", () => ({
|
||||||
|
persistNodeProjectPathMappings: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockFetchNodes = vi.mocked(api.fetchNodes);
|
const mockFetchNodes = vi.mocked(api.fetchNodes);
|
||||||
const mockRegisterNode = vi.mocked(api.registerNode);
|
const mockRegisterNode = vi.mocked(api.registerNode);
|
||||||
const mockUpdateNode = vi.mocked(api.updateNode);
|
const mockUpdateNode = vi.mocked(api.updateNode);
|
||||||
const mockUnregisterNode = vi.mocked(api.unregisterNode);
|
const mockUnregisterNode = vi.mocked(api.unregisterNode);
|
||||||
const mockCheckNodeHealth = vi.mocked(api.checkNodeHealth);
|
const mockCheckNodeHealth = vi.mocked(api.checkNodeHealth);
|
||||||
|
const mockPersistNodeProjectPathMappings = vi.mocked(nodeApi.persistNodeProjectPathMappings);
|
||||||
|
|
||||||
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
||||||
return {
|
return {
|
||||||
@@ -45,6 +51,7 @@ describe("useNodes", () => {
|
|||||||
mockUpdateNode.mockReset();
|
mockUpdateNode.mockReset();
|
||||||
mockUnregisterNode.mockReset();
|
mockUnregisterNode.mockReset();
|
||||||
mockCheckNodeHealth.mockReset();
|
mockCheckNodeHealth.mockReset();
|
||||||
|
mockPersistNodeProjectPathMappings.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -78,9 +85,14 @@ describe("useNodes", () => {
|
|||||||
expect(result.current.error).toBe("boom");
|
expect(result.current.error).toBe("boom");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("register adds node optimistically", async () => {
|
it("register creates node and persists selected path mappings", async () => {
|
||||||
mockFetchNodes.mockResolvedValueOnce([]);
|
mockFetchNodes.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||||
const nodeInput: NodeCreateInput = { name: "Remote Node", type: "remote", url: "https://node.test" };
|
const nodeInput: NodeOnboardingInput = {
|
||||||
|
name: "Remote Node",
|
||||||
|
type: "remote",
|
||||||
|
url: "https://node.test",
|
||||||
|
projectMappings: [{ projectId: "proj-1", path: "/mnt/proj-1" }],
|
||||||
|
};
|
||||||
const createdNode = makeNode({
|
const createdNode = makeNode({
|
||||||
id: "node_remote",
|
id: "node_remote",
|
||||||
name: "Remote Node",
|
name: "Remote Node",
|
||||||
@@ -89,6 +101,7 @@ describe("useNodes", () => {
|
|||||||
status: "connecting",
|
status: "connecting",
|
||||||
});
|
});
|
||||||
mockRegisterNode.mockResolvedValueOnce(createdNode);
|
mockRegisterNode.mockResolvedValueOnce(createdNode);
|
||||||
|
mockPersistNodeProjectPathMappings.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
const { result } = renderHook(() => useNodes());
|
const { result } = renderHook(() => useNodes());
|
||||||
|
|
||||||
@@ -100,9 +113,84 @@ describe("useNodes", () => {
|
|||||||
await result.current.register(nodeInput);
|
await result.current.register(nodeInput);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockRegisterNode).toHaveBeenCalledWith(nodeInput);
|
expect(mockRegisterNode).toHaveBeenCalledWith({
|
||||||
expect(result.current.nodes).toHaveLength(1);
|
name: "Remote Node",
|
||||||
expect(result.current.nodes[0].id).toBe("node_remote");
|
type: "remote",
|
||||||
|
url: "https://node.test",
|
||||||
|
});
|
||||||
|
expect(mockPersistNodeProjectPathMappings).toHaveBeenCalledWith("node_remote", nodeInput.projectMappings);
|
||||||
|
expect(mockFetchNodes).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("register rolls back node when mapping write fails", async () => {
|
||||||
|
mockFetchNodes.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||||
|
const createdNode = makeNode({ id: "node_remote", type: "remote", status: "connecting" });
|
||||||
|
mockRegisterNode.mockResolvedValueOnce(createdNode);
|
||||||
|
mockPersistNodeProjectPathMappings.mockRejectedValueOnce(new Error("mapping failed"));
|
||||||
|
mockUnregisterNode.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useNodes());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(result.current.register({
|
||||||
|
name: "Remote Node",
|
||||||
|
type: "remote",
|
||||||
|
url: "https://node.test",
|
||||||
|
projectMappings: [{ projectId: "proj-1", path: "/mnt/proj-1" }],
|
||||||
|
})).rejects.toThrow("mapping failed");
|
||||||
|
|
||||||
|
expect(mockUnregisterNode).toHaveBeenCalledWith("node_remote");
|
||||||
|
expect(mockFetchNodes).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("register appends cleanup failure message when rollback also fails", async () => {
|
||||||
|
mockFetchNodes.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||||
|
const createdNode = makeNode({ id: "node_remote", type: "remote", status: "connecting" });
|
||||||
|
mockRegisterNode.mockResolvedValueOnce(createdNode);
|
||||||
|
mockPersistNodeProjectPathMappings.mockRejectedValueOnce(new Error("mapping failed"));
|
||||||
|
mockUnregisterNode.mockRejectedValueOnce(new Error("cleanup failed"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useNodes());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(result.current.register({
|
||||||
|
name: "Remote Node",
|
||||||
|
type: "remote",
|
||||||
|
url: "https://node.test",
|
||||||
|
projectMappings: [{ projectId: "proj-1", path: "/mnt/proj-1" }],
|
||||||
|
})).rejects.toThrow("mapping failed. Cleanup also failed: cleanup failed");
|
||||||
|
|
||||||
|
expect(mockUnregisterNode).toHaveBeenCalledWith("node_remote");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not rollback when no mappings are selected even if post-success refresh fails", async () => {
|
||||||
|
mockFetchNodes.mockResolvedValueOnce([]).mockRejectedValueOnce(new Error("refresh failed"));
|
||||||
|
const createdNode = makeNode({ id: "node_remote", type: "remote", status: "connecting" });
|
||||||
|
mockRegisterNode.mockResolvedValueOnce(createdNode);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useNodes());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await expect(result.current.register({
|
||||||
|
name: "Remote Node",
|
||||||
|
type: "remote",
|
||||||
|
url: "https://node.test",
|
||||||
|
projectMappings: [],
|
||||||
|
})).resolves.toEqual(createdNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPersistNodeProjectPathMappings).not.toHaveBeenCalled();
|
||||||
|
expect(mockUnregisterNode).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("update modifies node optimistically", async () => {
|
it("update modifies node optimistically", async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import type { DockerNodeConfigInfo, NodeCreateInput, NodeInfo, NodeUpdateInput } from "../api";
|
import type { DockerNodeConfigInfo, NodeCreateInput, NodeInfo, NodeOnboardingInput, NodeUpdateInput } from "../api";
|
||||||
import {
|
import {
|
||||||
fetchDockerConfigDiff,
|
fetchDockerConfigDiff,
|
||||||
fetchDockerNodeConfig,
|
fetchDockerNodeConfig,
|
||||||
@@ -10,13 +10,14 @@ import {
|
|||||||
unregisterNode,
|
unregisterNode,
|
||||||
checkNodeHealth,
|
checkNodeHealth,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
import { persistNodeProjectPathMappings } from "../api-node";
|
||||||
|
|
||||||
export interface UseNodesResult {
|
export interface UseNodesResult {
|
||||||
nodes: NodeInfo[];
|
nodes: NodeInfo[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
register: (input: NodeCreateInput) => Promise<NodeInfo>;
|
register: (input: NodeOnboardingInput) => Promise<NodeInfo>;
|
||||||
update: (id: string, updates: NodeUpdateInput) => Promise<NodeInfo>;
|
update: (id: string, updates: NodeUpdateInput) => Promise<NodeInfo>;
|
||||||
unregister: (id: string) => Promise<void>;
|
unregister: (id: string) => Promise<void>;
|
||||||
healthCheck: (id: string) => Promise<void>;
|
healthCheck: (id: string) => Promise<void>;
|
||||||
@@ -112,11 +113,36 @@ export function useNodes(): UseNodesResult {
|
|||||||
};
|
};
|
||||||
}, [loading, refresh]);
|
}, [loading, refresh]);
|
||||||
|
|
||||||
const register = useCallback(async (input: NodeCreateInput): Promise<NodeInfo> => {
|
const register = useCallback(async (input: NodeOnboardingInput): Promise<NodeInfo> => {
|
||||||
const node = await registerNode(input);
|
const { projectMappings, ...nodeInput } = input;
|
||||||
setNodes((prev) => [...prev, node]);
|
const node = await registerNode(nodeInput as NodeCreateInput);
|
||||||
|
|
||||||
|
if (projectMappings.length > 0) {
|
||||||
|
try {
|
||||||
|
await persistNodeProjectPathMappings(node.id, projectMappings);
|
||||||
|
} catch (error) {
|
||||||
|
const mappingError = error instanceof Error ? error.message : "Failed to persist project mappings";
|
||||||
|
let cleanupErrorMessage = "";
|
||||||
|
try {
|
||||||
|
await unregisterNode(node.id);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
cleanupErrorMessage = cleanupError instanceof Error
|
||||||
|
? cleanupError.message
|
||||||
|
: "Failed to unregister node after mapping failure";
|
||||||
|
}
|
||||||
|
|
||||||
|
await refresh();
|
||||||
|
|
||||||
|
if (cleanupErrorMessage) {
|
||||||
|
throw new Error(`${mappingError}. Cleanup also failed: ${cleanupErrorMessage}`);
|
||||||
|
}
|
||||||
|
throw new Error(mappingError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await refresh();
|
||||||
return node;
|
return node;
|
||||||
}, []);
|
}, [refresh]);
|
||||||
|
|
||||||
const update = useCallback(async (id: string, updates: NodeUpdateInput): Promise<NodeInfo> => {
|
const update = useCallback(async (id: string, updates: NodeUpdateInput): Promise<NodeInfo> => {
|
||||||
const node = await updateNode(id, updates);
|
const node = await updateNode(id, updates);
|
||||||
|
|||||||
Reference in New Issue
Block a user