feat(FN-3506): add discovery contract and integrate remote node discovery i
Merges node discovery onboarding (FN-3506) with two steps: a pre-registration discovery contract wired into the AddNodeModal UI, and full remote discovery integration with documentation. Also adds a local embedded runtime manager to the desktop app (FN-3404) and wires permanent-agent approval contex Fusion-Task-Id: FN-3506
This commit is contained in:
@@ -135,3 +135,4 @@ export async function persistNodeProjectPathMappings(
|
||||
projectMappings.map(({ projectId, path }) => upsertProjectPathMapping(projectId, nodeId, path)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5548,6 +5548,18 @@ export interface NodeProjectMappingInput {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface RemoteNodeDiscoveredProject {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
status: "active" | "paused" | "errored" | "initializing";
|
||||
isolationMode: "in-process" | "child-process";
|
||||
}
|
||||
|
||||
export interface RemoteNodeProjectDiscoveryResult {
|
||||
projects: RemoteNodeDiscoveredProject[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Node onboarding payload used by dashboard UI.
|
||||
*
|
||||
@@ -5746,6 +5758,14 @@ export function registerNode(input: NodeCreateInput): Promise<NodeInfo> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Discover projects from a remote node before registering it. */
|
||||
export function discoverRemoteNodeProjects(input: { url: string; apiKey?: string }): Promise<RemoteNodeProjectDiscoveryResult> {
|
||||
return api<RemoteNodeProjectDiscoveryResult>("/nodes/discover-projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a single node by ID */
|
||||
export function fetchNode(id: string): Promise<NodeInfo> {
|
||||
return api<NodeInfo>(`/nodes/${encodeURIComponent(id)}`);
|
||||
|
||||
@@ -149,6 +149,37 @@
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.add-node-modal__discovery-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.add-node-modal__discovery-state {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.375);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.add-node-modal__discovered-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.add-node-modal__discovered-card {
|
||||
padding: var(--space-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.add-node-modal__discovered-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.add-node-modal__projects {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -203,4 +234,9 @@
|
||||
.add-node-modal__project-card {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.add-node-modal__discovered-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { NodeProjectMappingInput, ProjectInfo } from "../api";
|
||||
import type { NodeProjectMappingInput, ProjectInfo, RemoteNodeDiscoveredProject, RemoteNodeProjectDiscoveryResult } from "../api";
|
||||
import { validateProjectPath } from "../utils/projectDetection";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
@@ -32,6 +32,7 @@ interface AddNodeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (input: AddNodeInput) => Promise<void>;
|
||||
onDiscoverRemoteProjects: (input: { url: string; apiKey?: string }) => Promise<RemoteNodeProjectDiscoveryResult>;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
projects: ProjectInfo[];
|
||||
}
|
||||
@@ -71,7 +72,9 @@ function validateInput(input: AddNodeInput): FormErrors {
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }: AddNodeModalProps) {
|
||||
type DiscoveryState = "idle" | "loading" | "success" | "error";
|
||||
|
||||
export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjects, addToast, projects }: AddNodeModalProps) {
|
||||
useMobileScrollLock(isOpen);
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState<"local" | "remote">("local");
|
||||
@@ -82,6 +85,9 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }:
|
||||
const [selectedProjectPaths, setSelectedProjectPaths] = useState<Record<string, string>>({});
|
||||
const [errors, setErrors] = useState<FormErrors>({ projectMappings: {} });
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [discoveryState, setDiscoveryState] = useState<DiscoveryState>("idle");
|
||||
const [discoveryError, setDiscoveryError] = useState<string | null>(null);
|
||||
const [discoveredProjects, setDiscoveredProjects] = useState<RemoteNodeDiscoveredProject[]>([]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setName("");
|
||||
@@ -93,8 +99,10 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }:
|
||||
setSelectedProjectPaths({});
|
||||
setErrors({ projectMappings: {} });
|
||||
setIsSubmitting(false);
|
||||
setDiscoveryState("idle");
|
||||
setDiscoveryError(null);
|
||||
setDiscoveredProjects([]);
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
if (isSubmitting) return;
|
||||
resetForm();
|
||||
@@ -130,6 +138,60 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }:
|
||||
projectMappings: Object.entries(selectedProjectPaths).map(([projectId, path]) => ({ projectId, path: path.trim() })),
|
||||
}), [apiKey, apiKeyMode, maxConcurrent, name, selectedProjectPaths, type, url]);
|
||||
|
||||
const handleDiscoverProjects = useCallback(async () => {
|
||||
if (isSubmitting || discoveryState === "loading") return;
|
||||
|
||||
const trimmedUrl = url.trim();
|
||||
if (!trimmedUrl) {
|
||||
setErrors((current) => ({ ...current, url: "URL is required for remote nodes" }));
|
||||
return;
|
||||
}
|
||||
|
||||
setDiscoveryState("loading");
|
||||
setDiscoveryError(null);
|
||||
|
||||
try {
|
||||
const response = await onDiscoverRemoteProjects({
|
||||
url: trimmedUrl,
|
||||
apiKey: apiKeyMode === "provide" && apiKey.trim().length > 0 ? apiKey : undefined,
|
||||
});
|
||||
setDiscoveredProjects(response.projects);
|
||||
setDiscoveryState("success");
|
||||
|
||||
setSelectedProjectPaths((current) => {
|
||||
if (Object.keys(current).length === 0) {
|
||||
return current;
|
||||
}
|
||||
const next = { ...current };
|
||||
for (const project of projects) {
|
||||
if (!(project.id in next)) continue;
|
||||
const matches = response.projects.filter((remoteProject) => remoteProject.name === project.name);
|
||||
if (matches.length === 1) {
|
||||
next[project.id] = matches[0].path;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
setDiscoveryState("error");
|
||||
setDiscoveredProjects([]);
|
||||
setDiscoveryError(error instanceof Error ? error.message : "Failed to discover remote projects");
|
||||
}
|
||||
}, [apiKey, apiKeyMode, discoveryState, isSubmitting, onDiscoverRemoteProjects, projects, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type !== "remote") {
|
||||
setDiscoveryState("idle");
|
||||
setDiscoveryError(null);
|
||||
setDiscoveredProjects([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setDiscoveryState("idle");
|
||||
setDiscoveryError(null);
|
||||
setDiscoveredProjects([]);
|
||||
}, [apiKey, apiKeyMode, type, url]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
@@ -145,6 +207,11 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }:
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.type === "remote" && discoveryState !== "success") {
|
||||
setDiscoveryError("Discover remote projects before adding this node.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
@@ -157,7 +224,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }:
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [addToast, closeModal, input, isSubmitting, onSubmit]);
|
||||
}, [addToast, closeModal, discoveryState, input, isSubmitting, onSubmit]);
|
||||
|
||||
const toggleProjectSelection = (project: ProjectInfo) => {
|
||||
setSelectedProjectPaths((current) => {
|
||||
@@ -165,6 +232,15 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }:
|
||||
const { [project.id]: _removed, ...remaining } = current;
|
||||
return remaining;
|
||||
}
|
||||
|
||||
if (type === "remote" && discoveryState === "success") {
|
||||
const matches = discoveredProjects.filter((remoteProject) => remoteProject.name === project.name);
|
||||
if (matches.length === 1) {
|
||||
return { ...current, [project.id]: matches[0].path };
|
||||
}
|
||||
return { ...current, [project.id]: "" };
|
||||
}
|
||||
|
||||
return { ...current, [project.id]: project.path };
|
||||
});
|
||||
};
|
||||
@@ -268,6 +344,42 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }:
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="add-node-modal__discovery-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => void handleDiscoverProjects()}
|
||||
disabled={isSubmitting || discoveryState === "loading"}
|
||||
>
|
||||
{discoveryState === "loading" ? "Discovering..." : "Discover Remote Projects"}
|
||||
</button>
|
||||
{discoveryState === "success" && (
|
||||
<span className="add-node-modal__discovery-state" data-state="success">
|
||||
{discoveredProjects.length > 0 ? `Discovered ${discoveredProjects.length} remote project${discoveredProjects.length === 1 ? "" : "s"}.` : "No projects discovered on remote node."}
|
||||
</span>
|
||||
)}
|
||||
{discoveryState === "error" && discoveryError && (
|
||||
<span className="form-error add-node-modal__error">{discoveryError}</span>
|
||||
)}
|
||||
{discoveryState === "idle" && (
|
||||
<span className="add-node-modal__hint">Discover remote projects before adding this node.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{discoveryState === "success" && discoveredProjects.length > 0 && (
|
||||
<div className="add-node-modal__discovered-list" aria-label="Discovered remote projects">
|
||||
{discoveredProjects.map((project) => (
|
||||
<div key={`${project.id}-${project.path}`} className="card add-node-modal__discovered-card">
|
||||
<div className="add-node-modal__discovered-row">
|
||||
<strong>{project.name}</strong>
|
||||
<span className="card-status-badge card-status-badge--in-review">{project.status}</span>
|
||||
</div>
|
||||
<div className="add-node-modal__hint">{project.path}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -311,6 +423,20 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }:
|
||||
{selected && (
|
||||
<label className="add-node-modal__field">
|
||||
<span>Path on this node</span>
|
||||
{type === "remote" && discoveryState === "success" && (
|
||||
<span className="add-node-modal__hint">
|
||||
{(() => {
|
||||
const matches = discoveredProjects.filter((remoteProject) => remoteProject.name === project.name);
|
||||
if (matches.length === 1) {
|
||||
return `Remote-authoritative path discovered: ${matches[0].path}`;
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return "Multiple remote projects matched this name. Enter the correct path manually.";
|
||||
}
|
||||
return "No exact remote name match. Enter this path manually.";
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
|
||||
@@ -31,6 +31,7 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
||||
healthCheck,
|
||||
patchDockerConfig,
|
||||
fetchDockerDiff,
|
||||
discoverRemoteProjects,
|
||||
} = useNodes();
|
||||
const { projects, refresh: refreshProjects } = useProjects();
|
||||
const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode, getAuthSyncState, getAuthProviders } = useNodeSettingsSync();
|
||||
@@ -249,6 +250,7 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
||||
isOpen={addModalOpen}
|
||||
onClose={() => setAddModalOpen(false)}
|
||||
onSubmit={handleRegister}
|
||||
onDiscoverRemoteProjects={discoverRemoteProjects}
|
||||
addToast={addToast}
|
||||
projects={projects}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ describe("AddNodeModal", () => {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
onSubmit: vi.fn().mockResolvedValue(undefined),
|
||||
onDiscoverRemoteProjects: vi.fn().mockResolvedValue({ projects: [] }),
|
||||
addToast: vi.fn(),
|
||||
projects: [
|
||||
{
|
||||
@@ -236,18 +237,22 @@ describe("AddNodeModal", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits remote node with URL and API key", async () => {
|
||||
render(<AddNodeModal {...defaultProps} />);
|
||||
it("submits remote node with URL and API key after discovery", async () => {
|
||||
const onDiscoverRemoteProjects = vi.fn().mockResolvedValue({
|
||||
projects: [{
|
||||
id: "remote-1",
|
||||
name: "Project One",
|
||||
path: "/srv/project-one",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
}],
|
||||
});
|
||||
render(<AddNodeModal {...defaultProps} onDiscoverRemoteProjects={onDiscoverRemoteProjects} />);
|
||||
|
||||
// Fill name
|
||||
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
|
||||
target: { value: "Remote Node" },
|
||||
});
|
||||
|
||||
// Switch to remote
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
|
||||
|
||||
// Fill URL and API key
|
||||
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
|
||||
target: { value: "https://node.example.com" },
|
||||
});
|
||||
@@ -258,6 +263,15 @@ describe("AddNodeModal", () => {
|
||||
target: { value: "secret-key" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDiscoverRemoteProjects).toHaveBeenCalledWith({
|
||||
url: "https://node.example.com",
|
||||
apiKey: "secret-key",
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -272,6 +286,22 @@ describe("AddNodeModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks remote submit until discovery succeeds", async () => {
|
||||
render(<AddNodeModal {...defaultProps} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
|
||||
target: { value: "Remote Node" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
|
||||
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
|
||||
target: { value: "https://node.example.com" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
|
||||
|
||||
expect(await screen.findByText("Discover remote projects before adding this node.")).toBeInTheDocument();
|
||||
expect(defaultProps.onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates selected project path is required", async () => {
|
||||
render(<AddNodeModal {...defaultProps} />);
|
||||
|
||||
@@ -306,6 +336,96 @@ describe("AddNodeModal", () => {
|
||||
expect(defaultProps.onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefills path from exact-name discovery match", async () => {
|
||||
const onDiscoverRemoteProjects = vi.fn().mockResolvedValue({
|
||||
projects: [{
|
||||
id: "remote-1",
|
||||
name: "Project One",
|
||||
path: "/remote/project-one",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
}],
|
||||
});
|
||||
render(<AddNodeModal {...defaultProps} onDiscoverRemoteProjects={onDiscoverRemoteProjects} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
|
||||
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
|
||||
target: { value: "https://node.example.com" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" }));
|
||||
await waitFor(() => {
|
||||
expect(onDiscoverRemoteProjects).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Project One" }));
|
||||
expect(screen.getByDisplayValue("/remote/project-one")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("leaves ambiguous name matches unresolved", async () => {
|
||||
const onDiscoverRemoteProjects = vi.fn().mockResolvedValue({
|
||||
projects: [
|
||||
{
|
||||
id: "remote-1",
|
||||
name: "Project One",
|
||||
path: "/remote/project-one-a",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
},
|
||||
{
|
||||
id: "remote-2",
|
||||
name: "Project One",
|
||||
path: "/remote/project-one-b",
|
||||
status: "paused",
|
||||
isolationMode: "child-process",
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<AddNodeModal {...defaultProps} onDiscoverRemoteProjects={onDiscoverRemoteProjects} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
|
||||
target: { value: "Remote Node" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
|
||||
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
|
||||
target: { value: "https://node.example.com" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" }));
|
||||
await waitFor(() => {
|
||||
expect(onDiscoverRemoteProjects).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Project One" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
|
||||
|
||||
expect(await screen.findByText("Path is required")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows discovery error inline", async () => {
|
||||
const onDiscoverRemoteProjects = vi.fn().mockRejectedValue(new Error("upstream unavailable"));
|
||||
render(<AddNodeModal {...defaultProps} onDiscoverRemoteProjects={onDiscoverRemoteProjects} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
|
||||
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
|
||||
target: { value: "https://node.example.com" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" }));
|
||||
|
||||
expect(await screen.findByText("upstream unavailable")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows explicit zero-project discovery state", async () => {
|
||||
render(<AddNodeModal {...defaultProps} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
|
||||
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
|
||||
target: { value: "https://node.example.com" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" }));
|
||||
|
||||
expect(await screen.findByText("No projects discovered on remote node.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits selected project mappings", async () => {
|
||||
render(<AddNodeModal {...defaultProps} />);
|
||||
|
||||
|
||||
@@ -85,6 +85,10 @@ function makeUseNodesResult(overrides: Partial<ReturnType<typeof useNodes>> = {}
|
||||
update: vi.fn().mockResolvedValue(makeNode()),
|
||||
unregister: vi.fn().mockResolvedValue(undefined),
|
||||
healthCheck: vi.fn().mockResolvedValue(undefined),
|
||||
fetchDockerConfig: vi.fn().mockResolvedValue(null),
|
||||
patchDockerConfig: vi.fn().mockResolvedValue({}),
|
||||
fetchDockerDiff: vi.fn().mockResolvedValue({ persistedVersion: 0, deployedVersion: null, needsRecreate: false }),
|
||||
discoverRemoteProjects: vi.fn().mockResolvedValue({ projects: [] }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { DockerNodeConfigInfo, NodeCreateInput, NodeInfo, NodeOnboardingInput, NodeUpdateInput } from "../api";
|
||||
import type { DockerNodeConfigInfo, NodeCreateInput, NodeInfo, NodeOnboardingInput, NodeUpdateInput, RemoteNodeProjectDiscoveryResult } from "../api";
|
||||
import {
|
||||
fetchDockerConfigDiff,
|
||||
fetchDockerNodeConfig,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
updateNode,
|
||||
unregisterNode,
|
||||
checkNodeHealth,
|
||||
discoverRemoteNodeProjects,
|
||||
} from "../api";
|
||||
import { persistNodeProjectPathMappings } from "../api-node";
|
||||
|
||||
@@ -24,6 +25,7 @@ export interface UseNodesResult {
|
||||
fetchDockerConfig: (nodeId: string) => Promise<DockerNodeConfigInfo | null>;
|
||||
patchDockerConfig: (nodeId: string, config: Partial<DockerNodeConfigInfo>) => Promise<DockerNodeConfigInfo>;
|
||||
fetchDockerDiff: (nodeId: string) => Promise<{ persistedVersion: number; deployedVersion: number | null; needsRecreate: boolean }>;
|
||||
discoverRemoteProjects: (input: { url: string; apiKey?: string }) => Promise<RemoteNodeProjectDiscoveryResult>;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 10000; // 10 seconds
|
||||
@@ -188,6 +190,10 @@ export function useNodes(): UseNodesResult {
|
||||
return { persistedVersion: 0, deployedVersion: null, needsRecreate: false };
|
||||
}, []);
|
||||
|
||||
const discoverRemoteProjects = useCallback(async (input: { url: string; apiKey?: string }) => {
|
||||
return discoverRemoteNodeProjects(input);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
loading,
|
||||
@@ -200,5 +206,6 @@ export function useNodes(): UseNodesResult {
|
||||
fetchDockerConfig,
|
||||
patchDockerConfig,
|
||||
fetchDockerDiff,
|
||||
discoverRemoteProjects,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { request } from "../test-request.js";
|
||||
@@ -100,6 +100,10 @@ function makeNode(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
describe("Node routes", () => {
|
||||
const app = createServer(new MockStore() as any);
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListNodes.mockResolvedValue([]);
|
||||
@@ -163,6 +167,130 @@ describe("Node routes", () => {
|
||||
mockListProjectNodePathMappingsForNode.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("POST /api/nodes/discover-projects", () => {
|
||||
it("returns normalized remote project discovery payload on success", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ([
|
||||
{
|
||||
id: "proj_1",
|
||||
name: "Project One",
|
||||
path: "/srv/project-one",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
},
|
||||
]),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes/discover-projects",
|
||||
JSON.stringify({ url: "https://node.example.com", apiKey: "secret" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
projects: [
|
||||
{
|
||||
id: "proj_1",
|
||||
name: "Project One",
|
||||
path: "/srv/project-one",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://node.example.com/api/projects",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer secret" },
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns upstream HTTP failure status and message", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
statusText: "Service Unavailable",
|
||||
json: async () => ({ error: "upstream down" }),
|
||||
}));
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes/discover-projects",
|
||||
JSON.stringify({ url: "https://node.example.com" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body).toEqual({ error: "upstream down" });
|
||||
});
|
||||
|
||||
it("returns 401 when upstream rejects auth", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
json: async () => ({ error: "Invalid API key" }),
|
||||
}));
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes/discover-projects",
|
||||
JSON.stringify({ url: "https://node.example.com", apiKey: "wrong" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: "Invalid API key" });
|
||||
});
|
||||
|
||||
it("rejects malformed upstream payload", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ projects: [] }),
|
||||
}));
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes/discover-projects",
|
||||
JSON.stringify({ url: "https://node.example.com" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body).toEqual({ error: "Remote node returned malformed project discovery payload" });
|
||||
});
|
||||
|
||||
it("returns 504 on timeout/unreachable abort", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(Object.assign(new Error("aborted"), { name: "AbortError" })));
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes/discover-projects",
|
||||
JSON.stringify({ url: "https://node.example.com" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(504);
|
||||
expect(res.body).toEqual({ error: "Remote node discovery request timed out" });
|
||||
});
|
||||
});
|
||||
|
||||
it("GET /api/nodes/:id/path-mappings returns node mappings", async () => {
|
||||
mockListProjectNodePathMappingsForNode.mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -1,6 +1,48 @@
|
||||
import { ApiError, badRequest, notFound } from "../api-error.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
|
||||
const REMOTE_DISCOVERY_TIMEOUT_MS = 5000;
|
||||
|
||||
type DiscoveredRemoteProject = {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
status: "active" | "paused" | "errored" | "initializing";
|
||||
isolationMode: "in-process" | "child-process";
|
||||
};
|
||||
|
||||
function normalizeNodeUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw badRequest("url is required and must be a non-empty string");
|
||||
}
|
||||
|
||||
const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(withProtocol);
|
||||
} catch {
|
||||
throw badRequest("url must be a valid HTTP(S) URL");
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw badRequest("url must use http or https");
|
||||
}
|
||||
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function isDiscoveredRemoteProject(value: unknown): value is DiscoveredRemoteProject {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const project = value as Record<string, unknown>;
|
||||
|
||||
return typeof project.id === "string"
|
||||
&& typeof project.name === "string"
|
||||
&& typeof project.path === "string"
|
||||
&& ["active", "paused", "errored", "initializing"].includes(String(project.status))
|
||||
&& ["in-process", "child-process"].includes(String(project.isolationMode));
|
||||
}
|
||||
|
||||
export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const { router, rethrowAsApiError } = ctx;
|
||||
|
||||
@@ -93,6 +135,70 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/nodes/discover-projects
|
||||
* Discover projects from a remote node before registration.
|
||||
* Body: { url: string; apiKey?: string }
|
||||
*/
|
||||
router.post("/nodes/discover-projects", async (req, res) => {
|
||||
try {
|
||||
const { url, apiKey } = req.body as { url?: unknown; apiKey?: unknown };
|
||||
if (typeof url !== "string") {
|
||||
throw badRequest("url is required and must be a non-empty string");
|
||||
}
|
||||
if (apiKey !== undefined && typeof apiKey !== "string") {
|
||||
throw badRequest("apiKey must be a string when provided");
|
||||
}
|
||||
|
||||
const normalizedUrl = normalizeNodeUrl(url);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), REMOTE_DISCOVERY_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${normalizedUrl}/api/projects`, {
|
||||
method: "GET",
|
||||
headers: apiKey && apiKey.trim().length > 0 ? { Authorization: `Bearer ${apiKey}` } : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let upstreamMessage = `Remote node returned ${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const payload = await response.json() as { error?: unknown };
|
||||
if (typeof payload?.error === "string" && payload.error.trim()) {
|
||||
upstreamMessage = payload.error.trim();
|
||||
}
|
||||
} catch {
|
||||
// keep generic upstream message
|
||||
}
|
||||
throw new ApiError(response.status, upstreamMessage);
|
||||
}
|
||||
|
||||
const rawBody = await response.json() as unknown;
|
||||
if (!Array.isArray(rawBody) || !rawBody.every(isDiscoveredRemoteProject)) {
|
||||
throw new ApiError(502, "Remote node returned malformed project discovery payload");
|
||||
}
|
||||
|
||||
res.json({ projects: rawBody });
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new ApiError(504, "Remote node discovery request timed out");
|
||||
}
|
||||
throw new ApiError(502, `Unable to reach remote node: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/nodes/:id/path-mappings
|
||||
* List all project path mappings for a node.
|
||||
|
||||
Reference in New Issue
Block a user