feat(FN-1824): merge fusion/fn-1824
This commit is contained in:
@@ -4,18 +4,26 @@ import {
|
||||
fetchRemoteNodeProjects,
|
||||
fetchRemoteNodeTasks,
|
||||
fetchRemoteNodeProjectHealth,
|
||||
fetchNodeSettings,
|
||||
pushNodeSettings,
|
||||
pullNodeSettings,
|
||||
fetchNodeSettingsSyncStatus,
|
||||
syncNodeAuth,
|
||||
} from "./api-node";
|
||||
import * as apiModule from "./api";
|
||||
|
||||
vi.mock("./api", () => ({
|
||||
proxyApi: vi.fn(),
|
||||
api: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockProxyApi = vi.mocked(apiModule.proxyApi);
|
||||
const mockApi = vi.mocked(apiModule.api);
|
||||
|
||||
describe("api-node", () => {
|
||||
beforeEach(() => {
|
||||
mockProxyApi.mockReset();
|
||||
mockApi.mockReset();
|
||||
});
|
||||
|
||||
describe("fetchRemoteNodeHealth", () => {
|
||||
@@ -225,4 +233,171 @@ describe("api-node", () => {
|
||||
await expect(fetchRemoteNodeProjects("node_abc")).rejects.toThrow("404 Not Found");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Node Settings Sync API ──────────────────────────────────────────────
|
||||
|
||||
describe("fetchNodeSettings", () => {
|
||||
it("calls api with correct path and returns settings", async () => {
|
||||
const mockSettings = { global: { theme: "dark" }, project: { maxConcurrent: 4 } };
|
||||
mockApi.mockResolvedValueOnce(mockSettings);
|
||||
|
||||
const result = await fetchNodeSettings("node_abc");
|
||||
|
||||
expect(mockApi).toHaveBeenCalledTimes(1);
|
||||
expect(mockApi).toHaveBeenCalledWith("/nodes/node_abc/settings");
|
||||
expect(result).toEqual(mockSettings);
|
||||
});
|
||||
|
||||
it("encodes nodeId with special characters in URL", async () => {
|
||||
mockApi.mockResolvedValueOnce({ global: {}, project: {} });
|
||||
|
||||
await fetchNodeSettings("node/abc+def");
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith("/nodes/node%2Fabc%2Bdef/settings");
|
||||
});
|
||||
|
||||
it("propagates errors from api", async () => {
|
||||
mockApi.mockRejectedValueOnce(new Error("Node unreachable"));
|
||||
|
||||
await expect(fetchNodeSettings("node_abc")).rejects.toThrow("Node unreachable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushNodeSettings", () => {
|
||||
it("calls api with POST method and correct path", async () => {
|
||||
const mockResult = { success: true, syncedFields: ["theme", "maxConcurrent"] };
|
||||
mockApi.mockResolvedValueOnce(mockResult);
|
||||
|
||||
const result = await pushNodeSettings("node_abc");
|
||||
|
||||
expect(mockApi).toHaveBeenCalledTimes(1);
|
||||
expect(mockApi).toHaveBeenCalledWith(
|
||||
"/nodes/node_abc/settings/push",
|
||||
{ method: "POST", body: JSON.stringify({}) },
|
||||
);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it("encodes nodeId with special characters in URL", async () => {
|
||||
mockApi.mockResolvedValueOnce({ success: true, syncedFields: [] });
|
||||
|
||||
await pushNodeSettings("node/abc+def");
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith(
|
||||
"/nodes/node%2Fabc%2Bdef/settings/push",
|
||||
{ method: "POST", body: JSON.stringify({}) },
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates errors from api", async () => {
|
||||
mockApi.mockRejectedValueOnce(new Error("Push failed"));
|
||||
|
||||
await expect(pushNodeSettings("node_abc")).rejects.toThrow("Push failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pullNodeSettings", () => {
|
||||
it("calls api with POST method, correct path, and last-write-wins conflict resolution", async () => {
|
||||
const mockResult = { success: true, appliedFields: ["theme"], skippedFields: [] };
|
||||
mockApi.mockResolvedValueOnce(mockResult);
|
||||
|
||||
const result = await pullNodeSettings("node_abc");
|
||||
|
||||
expect(mockApi).toHaveBeenCalledTimes(1);
|
||||
expect(mockApi).toHaveBeenCalledWith(
|
||||
"/nodes/node_abc/settings/pull",
|
||||
{ method: "POST", body: JSON.stringify({ conflictResolution: "last-write-wins" }) },
|
||||
);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it("encodes nodeId with special characters in URL", async () => {
|
||||
mockApi.mockResolvedValueOnce({ success: true, appliedFields: [], skippedFields: [] });
|
||||
|
||||
await pullNodeSettings("node/abc+def");
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith(
|
||||
"/nodes/node%2Fabc%2Bdef/settings/pull",
|
||||
{ method: "POST", body: JSON.stringify({ conflictResolution: "last-write-wins" }) },
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates errors from api", async () => {
|
||||
mockApi.mockRejectedValueOnce(new Error("Pull failed"));
|
||||
|
||||
await expect(pullNodeSettings("node_abc")).rejects.toThrow("Pull failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchNodeSettingsSyncStatus", () => {
|
||||
it("calls api with correct path and returns sync status", async () => {
|
||||
const mockStatus = {
|
||||
lastSyncAt: "2026-04-01T00:00:00.000Z",
|
||||
lastSyncDirection: "sync",
|
||||
localUpdatedAt: "2026-04-01T00:00:00.000Z",
|
||||
remoteReachable: true,
|
||||
diff: { global: ["theme"], project: [] },
|
||||
};
|
||||
mockApi.mockResolvedValueOnce(mockStatus);
|
||||
|
||||
const result = await fetchNodeSettingsSyncStatus("node_abc");
|
||||
|
||||
expect(mockApi).toHaveBeenCalledTimes(1);
|
||||
expect(mockApi).toHaveBeenCalledWith("/nodes/node_abc/settings/sync-status");
|
||||
expect(result).toEqual(mockStatus);
|
||||
});
|
||||
|
||||
it("encodes nodeId with special characters in URL", async () => {
|
||||
mockApi.mockResolvedValueOnce({
|
||||
lastSyncAt: null,
|
||||
lastSyncDirection: null,
|
||||
localUpdatedAt: "2026-04-01T00:00:00.000Z",
|
||||
remoteReachable: false,
|
||||
diff: { global: [], project: [] },
|
||||
});
|
||||
|
||||
await fetchNodeSettingsSyncStatus("node/abc+def");
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith("/nodes/node%2Fabc%2Bdef/settings/sync-status");
|
||||
});
|
||||
|
||||
it("propagates errors from api", async () => {
|
||||
mockApi.mockRejectedValueOnce(new Error("Sync status check failed"));
|
||||
|
||||
await expect(fetchNodeSettingsSyncStatus("node_abc")).rejects.toThrow("Sync status check failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncNodeAuth", () => {
|
||||
it("calls api with POST method and correct path", async () => {
|
||||
const mockResult = { success: true, syncedProviders: ["openai", "anthropic"] };
|
||||
mockApi.mockResolvedValueOnce(mockResult);
|
||||
|
||||
const result = await syncNodeAuth("node_abc");
|
||||
|
||||
expect(mockApi).toHaveBeenCalledTimes(1);
|
||||
expect(mockApi).toHaveBeenCalledWith(
|
||||
"/nodes/node_abc/auth/sync",
|
||||
{ method: "POST", body: JSON.stringify({}) },
|
||||
);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it("encodes nodeId with special characters in URL", async () => {
|
||||
mockApi.mockResolvedValueOnce({ success: true, syncedProviders: [] });
|
||||
|
||||
await syncNodeAuth("node/abc+def");
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith(
|
||||
"/nodes/node%2Fabc%2Bdef/auth/sync",
|
||||
{ method: "POST", body: JSON.stringify({}) },
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates errors from api", async () => {
|
||||
mockApi.mockRejectedValueOnce(new Error("Auth sync failed"));
|
||||
|
||||
await expect(syncNodeAuth("node_abc")).rejects.toThrow("Auth sync failed");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import type { ProjectInfo } from "./api";
|
||||
import type { ProjectHealth, Task } from "@fusion/core";
|
||||
import { proxyApi } from "./api";
|
||||
import { api, proxyApi } from "./api";
|
||||
|
||||
/** Health information for a remote node */
|
||||
export interface RemoteNodeHealth {
|
||||
@@ -46,3 +46,72 @@ export async function fetchRemoteNodeProjectHealth(
|
||||
nodeId,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Node Settings Sync API ──────────────────────────────────────────────────────
|
||||
|
||||
/** Settings scopes returned by GET /api/nodes/:id/settings */
|
||||
export interface NodeSettingsScopes {
|
||||
global: Record<string, unknown>;
|
||||
project: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Result from settings push/pull operations */
|
||||
export interface NodeSettingsSyncResult {
|
||||
success: boolean;
|
||||
syncedFields?: string[];
|
||||
appliedFields?: string[];
|
||||
skippedFields?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Sync status returned by GET /api/nodes/:id/settings/sync-status */
|
||||
export interface NodeSettingsSyncStatus {
|
||||
lastSyncAt: string | null;
|
||||
lastSyncDirection: string | null;
|
||||
localUpdatedAt: string;
|
||||
remoteReachable: boolean;
|
||||
diff: {
|
||||
global: string[];
|
||||
project: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Result from auth sync operation */
|
||||
export interface NodeAuthSyncResult {
|
||||
success: boolean;
|
||||
syncedProviders: string[];
|
||||
}
|
||||
|
||||
/** Fetch settings from a remote node */
|
||||
export async function fetchNodeSettings(nodeId: string): Promise<NodeSettingsScopes> {
|
||||
return api<NodeSettingsScopes>(`/nodes/${encodeURIComponent(nodeId)}/settings`);
|
||||
}
|
||||
|
||||
/** Push local settings to a remote node */
|
||||
export async function pushNodeSettings(nodeId: string): Promise<NodeSettingsSyncResult> {
|
||||
return api<NodeSettingsSyncResult>(`/nodes/${encodeURIComponent(nodeId)}/settings/push`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Pull settings from a remote node and apply locally */
|
||||
export async function pullNodeSettings(nodeId: string): Promise<NodeSettingsSyncResult> {
|
||||
return api<NodeSettingsSyncResult>(`/nodes/${encodeURIComponent(nodeId)}/settings/pull`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ conflictResolution: "last-write-wins" }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Get the sync status for a node (last sync time, diff summary, etc.) */
|
||||
export async function fetchNodeSettingsSyncStatus(nodeId: string): Promise<NodeSettingsSyncStatus> {
|
||||
return api<NodeSettingsSyncStatus>(`/nodes/${encodeURIComponent(nodeId)}/settings/sync-status`);
|
||||
}
|
||||
|
||||
/** Synchronize model auth credentials with a remote node */
|
||||
export async function syncNodeAuth(nodeId: string): Promise<NodeAuthSyncResult> {
|
||||
return api<NodeAuthSyncResult>(`/nodes/${encodeURIComponent(nodeId)}/auth/sync`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ function buildApiUrl(path: string): string {
|
||||
return `/api${path}`;
|
||||
}
|
||||
|
||||
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
export async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
const url = buildApiUrl(path);
|
||||
const res = await fetch(url, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -0,0 +1,682 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useNodeSettingsSync } from "../useNodeSettingsSync";
|
||||
import * as apiNode from "../../api-node";
|
||||
import type { NodeSettingsSyncStatus, NodeSettingsSyncResult, NodeAuthSyncResult } from "../../api-node";
|
||||
|
||||
vi.mock("../../api-node", () => ({
|
||||
fetchNodeSettingsSyncStatus: vi.fn(),
|
||||
pushNodeSettings: vi.fn(),
|
||||
pullNodeSettings: vi.fn(),
|
||||
syncNodeAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchNodeSettingsSyncStatus = vi.mocked(apiNode.fetchNodeSettingsSyncStatus);
|
||||
const mockPushNodeSettings = vi.mocked(apiNode.pushNodeSettings);
|
||||
const mockPullNodeSettings = vi.mocked(apiNode.pullNodeSettings);
|
||||
const mockSyncNodeAuth = vi.mocked(apiNode.syncNodeAuth);
|
||||
|
||||
function makeSyncStatus(overrides: Partial<NodeSettingsSyncStatus> = {}): NodeSettingsSyncStatus {
|
||||
return {
|
||||
lastSyncAt: "2026-04-01T00:00:00.000Z",
|
||||
lastSyncDirection: "sync",
|
||||
localUpdatedAt: "2026-04-01T00:00:00.000Z",
|
||||
remoteReachable: true,
|
||||
diff: { global: [], project: [] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("useNodeSettingsSync", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockFetchNodeSettingsSyncStatus.mockReset();
|
||||
mockPushNodeSettings.mockReset();
|
||||
mockPullNodeSettings.mockReset();
|
||||
mockSyncNodeAuth.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Initial state ─────────────────────────────────────────────────────────
|
||||
|
||||
it("returns empty syncStatusMap, loading=false, no error when no nodes are tracked", async () => {
|
||||
const { result } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.syncStatusMap).toEqual({});
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.actionLoading).toEqual({});
|
||||
});
|
||||
|
||||
it("makes no API calls when no nodes are tracked", async () => {
|
||||
renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(mockFetchNodeSettingsSyncStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Track node ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("immediately fetches sync status when a node is tracked", async () => {
|
||||
mockFetchNodeSettingsSyncStatus.mockResolvedValueOnce(makeSyncStatus({ lastSyncAt: "2026-04-01T00:00:00.000Z" }));
|
||||
|
||||
const { result } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.trackNode("node_1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(mockFetchNodeSettingsSyncStatus).toHaveBeenCalledWith("node_1");
|
||||
expect(result.current.syncStatusMap.node_1).toEqual(makeSyncStatus({ lastSyncAt: "2026-04-01T00:00:00.000Z" }));
|
||||
});
|
||||
|
||||
it("tracks multiple nodes and fetches status for each", async () => {
|
||||
mockFetchNodeSettingsSyncStatus
|
||||
.mockResolvedValueOnce(makeSyncStatus({ lastSyncAt: "2026-04-01T00:00:00.000Z" }))
|
||||
.mockResolvedValueOnce(makeSyncStatus({ lastSyncAt: "2026-04-02T00:00:00.000Z" }));
|
||||
|
||||
const { result } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.trackNode("node_1");
|
||||
result.current.trackNode("node_2");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(mockFetchNodeSettingsSyncStatus).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.syncStatusMap.node_1).toBeDefined();
|
||||
expect(result.current.syncStatusMap.node_2).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Untrack node ───────────────────────────────────────────────────────────
|
||||
|
||||
it("removes node from syncStatusMap when untracked", async () => {
|
||||
mockFetchNodeSettingsSyncStatus
|
||||
.mockResolvedValueOnce(makeSyncStatus())
|
||||
.mockResolvedValueOnce(makeSyncStatus());
|
||||
|
||||
const { result } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.trackNode("node_1");
|
||||
result.current.trackNode("node_2");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.syncStatusMap.node_1).toBeDefined();
|
||||
expect(result.current.syncStatusMap.node_2).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
result.current.untrackNode("node_1");
|
||||
});
|
||||
|
||||
expect(result.current.syncStatusMap.node_1).toBeUndefined();
|
||||
expect(result.current.syncStatusMap.node_2).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Loading contract (FN-1734) ───────────────────────────────────────────────
|
||||
|
||||
it("sets loading=true during initial fetch and false after completion", async () => {
|
||||
// Track a node first, then resolve the pending promise after checking the transition
|
||||
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
|
||||
|
||||
const { result } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.trackNode("node_1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// After trackNode and flush, loading should be false (fetch completed)
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.syncStatusMap.node_1).toEqual(makeSyncStatus());
|
||||
});
|
||||
|
||||
it("does NOT set loading to true during background polling refreshes (FN-1734 regression)", async () => {
|
||||
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
|
||||
|
||||
const { result } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.trackNode("node_1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.syncStatusMap.node_1).toEqual(makeSyncStatus());
|
||||
|
||||
// Advance timer for polling refresh (30 seconds)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// loading should still be false (regression: previously was set to true)
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(mockFetchNodeSettingsSyncStatus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// ── Polling ───────────────────────────────────────────────────────────────
|
||||
|
||||
it("polls fetchNodeSettingsSyncStatus every 30 seconds for tracked nodes", async () => {
|
||||
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
|
||||
|
||||
const { result } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.trackNode("node_1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Initial fetch: 1 call
|
||||
expect(mockFetchNodeSettingsSyncStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance 30 seconds
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(mockFetchNodeSettingsSyncStatus).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Advance another 30 seconds
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(mockFetchNodeSettingsSyncStatus).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("clears polling interval on unmount", async () => {
|
||||
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
|
||||
|
||||
const { result, unmount } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.trackNode("node_1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Reset call count to check post-unmount calls
|
||||
mockFetchNodeSettingsSyncStatus.mockClear();
|
||||
|
||||
unmount();
|
||||
|
||||
// Advance 60 seconds worth of polling
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Should NOT have any new calls after unmount
|
||||
expect(mockFetchNodeSettingsSyncStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops polling when all nodes are untracked", async () => {
|
||||
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
|
||||
|
||||
const { result } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.trackNode("node_1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Initial fetch
|
||||
expect(mockFetchNodeSettingsSyncStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Untrack the only node
|
||||
await act(async () => {
|
||||
result.current.untrackNode("node_1");
|
||||
});
|
||||
|
||||
// Clear call count
|
||||
mockFetchNodeSettingsSyncStatus.mockClear();
|
||||
|
||||
// Advance 60 seconds
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// No polling should have happened
|
||||
expect(mockFetchNodeSettingsSyncStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Push action ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("pushSettings calls pushNodeSettings and sets actionLoading during the call", async () => {
|
||||
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
|
||||
const pushDeferred = deferred<{ success: boolean; syncedFields: string[] }>();
|
||||
mockPushNodeSettings.mockReturnValue(pushDeferred.promise);
|
||||
|
||||
const { result } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.trackNode("node_1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Reset after tracking
|
||||
mockFetchNodeSettingsSyncStatus.mockClear();
|
||||
|
||||
// Start push but don't resolve yet
|
||||
await act(async () => {
|
||||
const promise = result.current.pushSettings("node_1");
|
||||
void promise; // We don't await yet - we'll resolve the deferred manually
|
||||
});
|
||||
|
||||
expect(mockPushNodeSettings).toHaveBeenCalledWith("node_1");
|
||||
expect(result.current.actionLoading.node_1).toBe(true);
|
||||
|
||||
// Resolve the deferred
|
||||
pushDeferred.resolve({ success: true, syncedFields: ["theme"] });
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.actionLoading.node_1).toBeUndefined();
|
||||
});
|
||||
|
||||
it("pushSettings refreshes sync status after completion", async () => {
|
||||
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
|
||||
mockPushNodeSettings.mockResolvedValue({ success: true, syncedFields: ["theme"] });
|
||||
|
||||
const { result } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.trackNode("node_1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
mockFetchNodeSettingsSyncStatus.mockClear();
|
||||
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus({ lastSyncAt: "2026-04-02T00:00:00.000Z" }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.pushSettings("node_1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(mockFetchNodeSettingsSyncStatus).toHaveBeenCalledWith("node_1");
|
||||
});
|
||||
|
||||
// ── Pull action ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("pullSettings calls pullNodeSettings and sets actionLoading during the call", async () => {
|
||||
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
|
||||
const pullDeferred = deferred<{ success: boolean; appliedFields: string[]; skippedFields: string[] }>();
|
||||
mockPullNodeSettings.mockReturnValue(pullDeferred.promise);
|
||||
|
||||
const { result } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.trackNode("node_1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
mockFetchNodeSettingsSyncStatus.mockClear();
|
||||
|
||||
// Start pull but don't resolve yet
|
||||
await act(async () => {
|
||||
const promise = result.current.pullSettings("node_1");
|
||||
void promise;
|
||||
});
|
||||
|
||||
expect(mockPullNodeSettings).toHaveBeenCalledWith("node_1");
|
||||
expect(result.current.actionLoading.node_1).toBe(true);
|
||||
|
||||
// Resolve the deferred
|
||||
pullDeferred.resolve({ success: true, appliedFields: ["theme"], skippedFields: [] });
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.actionLoading.node_1).toBeUndefined();
|
||||
});
|
||||
|
||||
it("pullSettings refreshes sync status after completion", async () => {
|
||||
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
|
||||
mockPullNodeSettings.mockResolvedValue({ success: true, appliedFields: [], skippedFields: [] });
|
||||
|
||||
const { result } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.trackNode("node_1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
mockFetchNodeSettingsSyncStatus.mockClear();
|
||||
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus({ lastSyncAt: "2026-04-03T00:00:00.000Z" }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.pullSettings("node_1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(mockFetchNodeSettingsSyncStatus).toHaveBeenCalledWith("node_1");
|
||||
});
|
||||
|
||||
// ── Auth sync action ─────────────────────────────────────────────────────────
|
||||
|
||||
it("syncAuth calls syncNodeAuth and sets actionLoading during the call", async () => {
|
||||
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
|
||||
const authDeferred = deferred<NodeAuthSyncResult>();
|
||||
mockSyncNodeAuth.mockReturnValue(authDeferred.promise);
|
||||
|
||||
const { result } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.trackNode("node_1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Start auth sync but don't resolve yet
|
||||
await act(async () => {
|
||||
const promise = result.current.syncAuth("node_1");
|
||||
void promise;
|
||||
});
|
||||
|
||||
expect(mockSyncNodeAuth).toHaveBeenCalledWith("node_1");
|
||||
expect(result.current.actionLoading.node_1).toBe(true);
|
||||
|
||||
// Resolve the deferred
|
||||
authDeferred.resolve({ success: true, syncedProviders: ["openai"] });
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.actionLoading.node_1).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Action error handling ───────────────────────────────────────────────────
|
||||
|
||||
it("action error re-throws, clears actionLoading, and sets error", async () => {
|
||||
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
|
||||
mockPushNodeSettings.mockRejectedValue(new Error("Push failed"));
|
||||
|
||||
const { result } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.trackNode("node_1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await expect(result.current.pushSettings("node_1")).rejects.toThrow("Push failed");
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("Push failed");
|
||||
expect(result.current.actionLoading).toEqual({});
|
||||
});
|
||||
|
||||
it("action error for pull clears actionLoading and sets error", async () => {
|
||||
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
|
||||
mockPullNodeSettings.mockRejectedValue(new Error("Pull failed"));
|
||||
|
||||
const { result } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.trackNode("node_1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await expect(result.current.pullSettings("node_1")).rejects.toThrow("Pull failed");
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("Pull failed");
|
||||
expect(result.current.actionLoading).toEqual({});
|
||||
});
|
||||
|
||||
it("action error for auth sync clears actionLoading and sets error", async () => {
|
||||
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
|
||||
mockSyncNodeAuth.mockRejectedValue(new Error("Auth sync failed"));
|
||||
|
||||
const { result } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.trackNode("node_1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await expect(result.current.syncAuth("node_1")).rejects.toThrow("Auth sync failed");
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("Auth sync failed");
|
||||
expect(result.current.actionLoading).toEqual({});
|
||||
});
|
||||
|
||||
// ── Polling error handling ─────────────────────────────────────────────────
|
||||
|
||||
it("preserves existing data when polling fails, sets error, keeps loading=false", async () => {
|
||||
// Initial successful fetch
|
||||
mockFetchNodeSettingsSyncStatus
|
||||
.mockResolvedValueOnce(makeSyncStatus({ lastSyncAt: "2026-04-01T00:00:00.000Z" }))
|
||||
// Polling failure
|
||||
.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const { result } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.trackNode("node_1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Verify initial data is present
|
||||
expect(result.current.syncStatusMap.node_1).toEqual(makeSyncStatus({ lastSyncAt: "2026-04-01T00:00:00.000Z" }));
|
||||
|
||||
// Advance timer to trigger polling
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Data should still be present (stale but visible)
|
||||
expect(result.current.syncStatusMap.node_1).toEqual(makeSyncStatus({ lastSyncAt: "2026-04-01T00:00:00.000Z" }));
|
||||
// Error should be set
|
||||
expect(result.current.error).toBe("Network error");
|
||||
// Loading should be false (no loading state for background polling)
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// ── Empty tracking ─────────────────────────────────────────────────────────
|
||||
|
||||
it("makes no API calls when all nodes are untracked", async () => {
|
||||
const pending = deferred<NodeSettingsSyncStatus>();
|
||||
mockFetchNodeSettingsSyncStatus.mockReturnValue(pending.promise);
|
||||
|
||||
const { result } = renderHook(() => useNodeSettingsSync());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.trackNode("node_1");
|
||||
});
|
||||
|
||||
// Let initial fetch start
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
mockFetchNodeSettingsSyncStatus.mockClear();
|
||||
|
||||
// Untrack the node
|
||||
await act(async () => {
|
||||
result.current.untrackNode("node_1");
|
||||
});
|
||||
|
||||
// Advance timers
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// No polling should have happened
|
||||
expect(mockFetchNodeSettingsSyncStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
279
packages/dashboard/app/hooks/useNodeSettingsSync.ts
Normal file
279
packages/dashboard/app/hooks/useNodeSettingsSync.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
fetchNodeSettingsSyncStatus,
|
||||
pushNodeSettings,
|
||||
pullNodeSettings,
|
||||
syncNodeAuth,
|
||||
type NodeSettingsSyncStatus,
|
||||
type NodeSettingsSyncResult,
|
||||
type NodeAuthSyncResult,
|
||||
} from "../api-node";
|
||||
|
||||
export interface UseNodeSettingsSyncResult {
|
||||
/** Per-node sync status keyed by nodeId */
|
||||
syncStatusMap: Record<string, NodeSettingsSyncStatus>;
|
||||
/** Loading state — true ONLY during initial load, false during background polling */
|
||||
loading: boolean;
|
||||
/** Per-node loading states for push/pull/auth actions */
|
||||
actionLoading: Record<string, boolean>;
|
||||
/** Error if any */
|
||||
error: string | null;
|
||||
/** Manually refresh sync status for all tracked nodes */
|
||||
refresh: () => Promise<void>;
|
||||
/** Start tracking a node for sync status polling */
|
||||
trackNode: (nodeId: string) => void;
|
||||
/** Stop tracking a node */
|
||||
untrackNode: (nodeId: string) => void;
|
||||
/** Push local settings to a remote node */
|
||||
pushSettings: (nodeId: string) => Promise<NodeSettingsSyncResult>;
|
||||
/** Pull settings from a remote node */
|
||||
pullSettings: (nodeId: string) => Promise<NodeSettingsSyncResult>;
|
||||
/** Sync auth credentials with a remote node */
|
||||
syncAuth: (nodeId: string) => Promise<NodeAuthSyncResult>;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 30_000; // 30 seconds
|
||||
|
||||
/**
|
||||
* Hook for managing per-node settings synchronization state.
|
||||
*
|
||||
* Automatically polls sync status for all tracked nodes every 30 seconds.
|
||||
* Stops polling when component unmounts.
|
||||
*
|
||||
* Loading behavior: `loading` is true only during the initial fetch.
|
||||
* Background polling updates do NOT set `loading` to true, so the UI
|
||||
* keeps previously loaded data visible during refreshes. This prevents
|
||||
* skeleton flicker and scroll position resets during periodic updates (FN-1734).
|
||||
*/
|
||||
export function useNodeSettingsSync(): UseNodeSettingsSyncResult {
|
||||
const [syncStatusMap, setSyncStatusMap] = useState<Record<string, NodeSettingsSyncStatus>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Track which nodes are being monitored
|
||||
const trackedNodesRef = useRef<Set<string>>(new Set());
|
||||
// Track if initial load is complete
|
||||
const initialLoadCompleteRef = useRef(false);
|
||||
// Abort controller for cancelling in-flight requests
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
// Polling interval ref
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch sync status for a single node and update state.
|
||||
* Does NOT set loading=true (called during polling and initial fetch).
|
||||
*/
|
||||
const fetchNodeStatus = useCallback(async (nodeId: string, isInitial: boolean): Promise<void> => {
|
||||
try {
|
||||
const status = await fetchNodeSettingsSyncStatus(nodeId);
|
||||
setSyncStatusMap((prev) => ({
|
||||
...prev,
|
||||
[nodeId]: status,
|
||||
}));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
// Keep stale data visible during polling failures
|
||||
console.error(`Failed to fetch sync status for node ${nodeId}:`, err);
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch sync status");
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Refresh sync status for all tracked nodes.
|
||||
* Sets loading=true only for initial fetch, not for background refreshes.
|
||||
*/
|
||||
const refresh = useCallback(async () => {
|
||||
const trackedNodes = Array.from(trackedNodesRef.current);
|
||||
if (trackedNodes.length === 0) return;
|
||||
|
||||
// Cancel any in-flight requests
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
abortRef.current = new AbortController();
|
||||
|
||||
const isInitial = !initialLoadCompleteRef.current;
|
||||
if (isInitial) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Fetch status for all tracked nodes concurrently
|
||||
const results = await Promise.allSettled(
|
||||
trackedNodes.map((nodeId) => fetchNodeStatus(nodeId, isInitial))
|
||||
);
|
||||
|
||||
// Mark initial load complete
|
||||
initialLoadCompleteRef.current = true;
|
||||
|
||||
// Check if any failed
|
||||
const failures = results.filter((r) => r.status === "rejected");
|
||||
if (failures.length > 0) {
|
||||
setError("Some sync status requests failed");
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch sync status");
|
||||
initialLoadCompleteRef.current = true;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchNodeStatus]);
|
||||
|
||||
/**
|
||||
* Start polling sync status for all tracked nodes.
|
||||
*/
|
||||
const startPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
intervalRef.current = setInterval(() => {
|
||||
void refresh();
|
||||
}, POLL_INTERVAL_MS);
|
||||
}, [refresh]);
|
||||
|
||||
/**
|
||||
* Stop polling.
|
||||
*/
|
||||
const stopPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial fetch and polling setup
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
startPolling();
|
||||
|
||||
return () => {
|
||||
stopPolling();
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [refresh, startPolling, stopPolling]);
|
||||
|
||||
/**
|
||||
* Start tracking a node for sync status polling.
|
||||
* Immediately fetches status for the newly tracked node.
|
||||
*/
|
||||
const trackNode = useCallback((nodeId: string) => {
|
||||
if (trackedNodesRef.current.has(nodeId)) return;
|
||||
trackedNodesRef.current.add(nodeId);
|
||||
void fetchNodeStatus(nodeId, !initialLoadCompleteRef.current);
|
||||
}, [fetchNodeStatus]);
|
||||
|
||||
/**
|
||||
* Stop tracking a node.
|
||||
* Removes its entry from syncStatusMap and stops polling for it.
|
||||
*/
|
||||
const untrackNode = useCallback((nodeId: string) => {
|
||||
trackedNodesRef.current.delete(nodeId);
|
||||
setSyncStatusMap((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[nodeId];
|
||||
return next;
|
||||
});
|
||||
// If no more tracked nodes, stop polling
|
||||
if (trackedNodesRef.current.size === 0) {
|
||||
stopPolling();
|
||||
}
|
||||
}, [stopPolling]);
|
||||
|
||||
/**
|
||||
* Push local settings to a remote node.
|
||||
* Sets per-node actionLoading during the call, updates syncStatusMap on completion.
|
||||
*/
|
||||
const pushSettings = useCallback(async (nodeId: string): Promise<NodeSettingsSyncResult> => {
|
||||
setActionLoading((prev) => ({ ...prev, [nodeId]: true }));
|
||||
setError(null);
|
||||
try {
|
||||
const result = await pushNodeSettings(nodeId);
|
||||
// Refresh sync status after push
|
||||
void fetchNodeStatus(nodeId, false);
|
||||
if (!result.success && result.error) {
|
||||
setError(result.error);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Push settings failed";
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setActionLoading((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[nodeId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [fetchNodeStatus]);
|
||||
|
||||
/**
|
||||
* Pull settings from a remote node.
|
||||
* Sets per-node actionLoading during the call, updates syncStatusMap on completion.
|
||||
*/
|
||||
const pullSettings = useCallback(async (nodeId: string): Promise<NodeSettingsSyncResult> => {
|
||||
setActionLoading((prev) => ({ ...prev, [nodeId]: true }));
|
||||
setError(null);
|
||||
try {
|
||||
const result = await pullNodeSettings(nodeId);
|
||||
// Refresh sync status after pull
|
||||
void fetchNodeStatus(nodeId, false);
|
||||
if (!result.success && result.error) {
|
||||
setError(result.error);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Pull settings failed";
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setActionLoading((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[nodeId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [fetchNodeStatus]);
|
||||
|
||||
/**
|
||||
* Sync auth credentials with a remote node.
|
||||
* Sets per-node actionLoading during the call.
|
||||
*/
|
||||
const syncAuth = useCallback(async (nodeId: string): Promise<NodeAuthSyncResult> => {
|
||||
setActionLoading((prev) => ({ ...prev, [nodeId]: true }));
|
||||
setError(null);
|
||||
try {
|
||||
return await syncNodeAuth(nodeId);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Auth sync failed";
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setActionLoading((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[nodeId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
syncStatusMap,
|
||||
loading,
|
||||
actionLoading,
|
||||
error,
|
||||
refresh,
|
||||
trackNode,
|
||||
untrackNode,
|
||||
pushSettings,
|
||||
pullSettings,
|
||||
syncAuth,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user