feat(FN-1827): merge fusion/fn-1827

This commit is contained in:
gsxdsm
2026-04-16 09:08:06 -07:00
parent 563f169f3d
commit 30a178bb3f
11 changed files with 438 additions and 3 deletions

View File

@@ -1,5 +1,5 @@
import { memo, useCallback, useMemo, useState } from "react";
import { Activity, Server, Settings, Trash2 } from "lucide-react";
import { Activity, Server, Settings, Shield, Trash2 } from "lucide-react";
import type { NodeInfo, ProjectInfo } from "../api";
import { getProjectCountForNode } from "../utils/nodeProjectAssignment";
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
@@ -13,6 +13,10 @@ export interface NodeCardProps {
onRemove: (id: string) => void;
isLoading?: boolean;
syncStatus?: ComputedNodeSyncStatus;
/** Auth credential sync state for this node. Only meaningful for remote nodes. */
authSyncState?: "match" | "differs" | "not-synced";
/** Per-provider auth match details for tooltip. Map of provider name (e.g. "anthropic") to its match status. */
authSyncProviders?: Record<string, "match" | "differs">;
}
const STATUS_CONFIG: Record<NodeInfo["status"], { label: string; color: string; className: string }> = {
@@ -22,6 +26,30 @@ const STATUS_CONFIG: Record<NodeInfo["status"], { label: string; color: string;
error: { label: "Error", color: "var(--color-error)", className: "node-card__status--error" },
};
const AUTH_SYNC_COLORS: Record<string, string> = {
match: "var(--success)",
differs: "var(--warning)",
"not-synced": "var(--text-muted)",
};
function buildAuthTooltip(
state: "match" | "differs" | "not-synced",
providers?: Record<string, "match" | "differs">,
): string {
if (state === "match") return "Auth credentials match";
if (state === "not-synced") return "Auth not synced";
// state === "differs"
if (providers && Object.keys(providers).length > 0) {
const differing = Object.entries(providers)
.filter(([, status]) => status === "differs")
.map(([name]) => name);
if (differing.length > 0) {
return `Auth credentials differ: ${differing.join(", ")}`;
}
}
return "Auth credentials differ";
}
function truncateUrl(url: string, maxLength: number = 42): string {
if (url.length <= maxLength) return url;
return `${url.slice(0, maxLength - 3)}...`;
@@ -53,6 +81,22 @@ function areNodeCardPropsEqual(previous: NodeCardProps, next: NodeCardProps): bo
if (prevSync.diffCount !== nextSync.diffCount) return false;
}
// Compare auth sync state
if (previous.authSyncState !== next.authSyncState) return false;
// Shallow compare authSyncProviders
const prevProviders = previous.authSyncProviders;
const nextProviders = next.authSyncProviders;
if (prevProviders === nextProviders) {
// same ref or both undefined - equal
} else if (!prevProviders || !nextProviders) {
return false; // one defined, one not
} else {
const prevKeys = Object.keys(prevProviders);
const nextKeys = Object.keys(nextProviders);
if (prevKeys.length !== nextKeys.length) return false;
if (prevKeys.some((k) => prevProviders[k] !== nextProviders[k])) return false;
}
// Compare project counts using the canonical counting function
const previousCount = getProjectCountForNode(previous.projects, prevNode);
const nextCount = getProjectCountForNode(next.projects, nextNode);
@@ -67,6 +111,8 @@ function NodeCardInner({
onRemove,
isLoading = false,
syncStatus,
authSyncState,
authSyncProviders,
}: NodeCardProps) {
const [removeArmed, setRemoveArmed] = useState(false);
const statusConfig = STATUS_CONFIG[node.status];
@@ -133,6 +179,16 @@ function NodeCardInner({
<span className="node-card__status-indicator" style={{ backgroundColor: statusConfig.color }} aria-hidden />
{statusConfig.label}
</span>
{node.type === "remote" && authSyncState && (
<span
className={`node-card__auth-indicator node-card__auth-indicator--${authSyncState}`}
title={buildAuthTooltip(authSyncState, authSyncProviders)}
aria-label={`Auth sync: ${authSyncState === "match" ? "credentials match" : authSyncState === "differs" ? "credentials differ" : "not synced"}`}
style={{ color: AUTH_SYNC_COLORS[authSyncState] }}
>
<Shield size={14} />
</span>
)}
</div>
</div>
</div>

View File

@@ -18,7 +18,7 @@ interface NodesViewProps {
export function NodesView({ addToast, onClose }: NodesViewProps) {
const { nodes, loading, error, refresh, register, update, unregister, healthCheck } = useNodes();
const { projects } = useProjects();
const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode } = useNodeSettingsSync();
const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode, getAuthSyncState, getAuthProviders } = useNodeSettingsSync();
const [addModalOpen, setAddModalOpen] = useState(false);
const [selectedNode, setSelectedNode] = useState<NodeInfo | null>(null);
@@ -179,6 +179,8 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
onRemove={(id) => { void handleUnregister(id); }}
isLoading={loading}
syncStatus={nodeSyncStatus}
authSyncState={node.type === "remote" ? getAuthSyncState(node.id) : undefined}
authSyncProviders={node.type === "remote" ? getAuthProviders(node.id) : undefined}
/>
);
})}

View File

@@ -32,6 +32,7 @@ import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPr
* - authentication: OAuth provider status, login/logout (independent)
* - appearance: Theme and color settings (global)
* - notifications: ntfy.sh notification settings (global)
* - node-sync: Settings sync between nodes (global)
* - global-models: Default/fallback models and thinking level (global)
* - project-models: Planning & validator models, model presets, and AI summarization (project)
* - general: Task prefix configuration (project)
@@ -59,6 +60,7 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
{ id: "authentication", label: "Authentication", scope: undefined, icon: Globe },
{ id: "appearance", label: "Appearance", scope: "global" },
{ id: "notifications", label: "Notifications", scope: "global" },
{ id: "node-sync", label: "Node Sync", scope: "global" },
{ id: "global-models", label: "Models", scope: "global" },
{ id: "project-models", label: "Project Models", scope: "project" },
{ id: "__global_header", label: "Global", scope: undefined, isGroupHeader: true },
@@ -2463,6 +2465,77 @@ export function SettingsModal({
)}
</>
);
case "node-sync":
return (
<>
{renderScopeBanner()}
<h4 className="settings-section-heading">Node Sync</h4>
<div className="form-group">
<label htmlFor="settingsSyncEnabled" className="checkbox-label">
<input
id="settingsSyncEnabled"
type="checkbox"
checked={form.settingsSyncEnabled || false}
onChange={(e) =>
setForm((f) => ({ ...f, settingsSyncEnabled: e.target.checked }))
}
/>
Enable automatic settings sync
</label>
<small>Automatically synchronize settings between this node and connected remote nodes</small>
</div>
{form.settingsSyncEnabled && (
<>
<div className="form-group">
<label htmlFor="settingsSyncAuth" className="checkbox-label">
<input
id="settingsSyncAuth"
type="checkbox"
checked={form.settingsSyncAuth || false}
onChange={(e) =>
setForm((f) => ({ ...f, settingsSyncAuth: e.target.checked }))
}
/>
Sync model auth credentials
</label>
<small>Include API keys and OAuth tokens in sync operations</small>
</div>
<div className="form-group">
<label htmlFor="settingsSyncInterval">Sync interval</label>
<select
id="settingsSyncInterval"
className="select"
value={form.settingsSyncInterval || 900000}
onChange={(e) =>
setForm((f) => ({ ...f, settingsSyncInterval: parseInt(e.target.value, 10) }))
}
>
<option value={300000}>Every 5 minutes</option>
<option value={900000}>Every 15 minutes</option>
<option value={1800000}>Every 30 minutes</option>
<option value={3600000}>Every 1 hour</option>
</select>
</div>
<div className="form-group">
<label htmlFor="settingsSyncConflictResolution">Conflict resolution</label>
<select
id="settingsSyncConflictResolution"
className="select"
value={form.settingsSyncConflictResolution || "last-write-wins"}
onChange={(e) =>
setForm((f) => ({ ...f, settingsSyncConflictResolution: e.target.value as "last-write-wins" | "always-ask" | "keep-local" | "keep-remote" }))
}
>
<option value="last-write-wins">Last write wins</option>
<option value="always-ask">Always ask</option>
<option value="keep-local">Keep local</option>
<option value="keep-remote">Keep remote</option>
</select>
</div>
</>
)}
</>
);
case "prompts":
return (
<>

View File

@@ -8,6 +8,7 @@ vi.mock("lucide-react", () => ({
Activity: () => <span data-testid="activity-icon">activity</span>,
Server: () => <span data-testid="server-icon">server</span>,
Settings: () => <span data-testid="settings-icon">settings</span>,
Shield: () => <span data-testid="shield-icon">shield</span>,
Trash2: () => <span data-testid="trash-icon">trash</span>,
}));
@@ -541,4 +542,125 @@ describe("NodeCard", () => {
expect(syncIndicator).toHaveAttribute("data-sync-state", "diff");
});
});
describe("auth sync indicator", () => {
it("renders auth indicator for remote node with match state", () => {
const node = makeNode({
id: "node-auth-match",
name: "Auth Match Node",
type: "remote",
status: "online",
});
render(
<NodeCard
node={node}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
authSyncState="match"
/>
);
expect(screen.getByTestId("shield-icon")).toBeInTheDocument();
const indicator = document.querySelector(".node-card__auth-indicator--match");
expect(indicator).toBeInTheDocument();
expect(indicator?.getAttribute("aria-label")).toContain("credentials match");
});
it("renders auth indicator with differs state and provider details in tooltip", () => {
const node = makeNode({
id: "node-auth-differs",
name: "Auth Differs Node",
type: "remote",
status: "online",
});
render(
<NodeCard
node={node}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
authSyncState="differs"
authSyncProviders={{ anthropic: "differs", openai: "match" }}
/>
);
const indicator = document.querySelector(".node-card__auth-indicator--differs");
expect(indicator).toBeInTheDocument();
expect(indicator?.getAttribute("aria-label")).toContain("credentials differ");
expect(indicator?.getAttribute("title")).toContain("anthropic");
});
it("renders auth indicator with not-synced state", () => {
const node = makeNode({
id: "node-auth-not-synced",
name: "Auth Not Synced Node",
type: "remote",
status: "online",
});
render(
<NodeCard
node={node}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
authSyncState="not-synced"
/>
);
const indicator = document.querySelector(".node-card__auth-indicator--not-synced");
expect(indicator).toBeInTheDocument();
expect(indicator?.getAttribute("aria-label")).toContain("not synced");
expect(indicator?.getAttribute("title")).toBe("Auth not synced");
});
it("does not render auth indicator when authSyncState is undefined", () => {
const node = makeNode({
id: "node-no-auth",
name: "No Auth State Node",
type: "remote",
status: "online",
});
render(
<NodeCard
node={node}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
/>
);
expect(screen.queryByTestId("shield-icon")).not.toBeInTheDocument();
});
it("does not render auth indicator for local nodes even with authSyncState", () => {
const node = makeNode({
id: "node-local-auth",
name: "Local Node",
type: "local",
status: "online",
});
render(
<NodeCard
node={node}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
authSyncState="match"
/>
);
expect(screen.queryByTestId("shield-icon")).not.toBeInTheDocument();
});
});
});

View File

@@ -105,6 +105,8 @@ beforeEach(() => {
pushSettings: vi.fn().mockResolvedValue({ success: true }),
pullSettings: vi.fn().mockResolvedValue({ success: true }),
syncAuth: vi.fn().mockResolvedValue({ success: true, syncedProviders: [] }),
getAuthSyncState: vi.fn().mockReturnValue(undefined),
getAuthProviders: vi.fn().mockReturnValue(undefined),
});
});
@@ -333,6 +335,8 @@ describe("NodesView", () => {
pushSettings: vi.fn().mockResolvedValue({ success: true }),
pullSettings: vi.fn().mockResolvedValue({ success: true }),
syncAuth: vi.fn().mockResolvedValue({ success: true, syncedProviders: [] }),
getAuthSyncState: vi.fn().mockReturnValue(undefined),
getAuthProviders: vi.fn().mockReturnValue(undefined),
});
mockUseNodes.mockReturnValue(makeUseNodesResult({
@@ -369,6 +373,8 @@ describe("NodesView", () => {
pushSettings: vi.fn().mockResolvedValue({ success: true }),
pullSettings: vi.fn().mockResolvedValue({ success: true }),
syncAuth: vi.fn().mockResolvedValue({ success: true, syncedProviders: [] }),
getAuthSyncState: vi.fn().mockReturnValue(undefined),
getAuthProviders: vi.fn().mockReturnValue(undefined),
});
mockUseNodes.mockReturnValue(makeUseNodesResult({
@@ -404,6 +410,8 @@ describe("NodesView", () => {
pushSettings: vi.fn().mockResolvedValue({ success: true }),
pullSettings: vi.fn().mockResolvedValue({ success: true }),
syncAuth: vi.fn().mockResolvedValue({ success: true, syncedProviders: [] }),
getAuthSyncState: vi.fn().mockReturnValue(undefined),
getAuthProviders: vi.fn().mockReturnValue(undefined),
});
mockUseNodes.mockReturnValue(makeUseNodesResult({
@@ -441,6 +449,8 @@ describe("NodesView", () => {
pushSettings: vi.fn().mockResolvedValue({ success: true }),
pullSettings: vi.fn().mockResolvedValue({ success: true }),
syncAuth: vi.fn().mockResolvedValue({ success: true, syncedProviders: [] }),
getAuthSyncState: vi.fn().mockReturnValue(undefined),
getAuthProviders: vi.fn().mockReturnValue(undefined),
});
mockUseNodes.mockReturnValue(makeUseNodesResult({

View File

@@ -32,6 +32,10 @@ const defaultSettings: Settings = {
runStepsInNewSessions: false,
maxParallelSteps: 2,
showQuickChatFAB: false,
settingsSyncEnabled: false,
settingsSyncAuth: false,
settingsSyncInterval: 900000,
settingsSyncConflictResolution: "last-write-wins",
};
vi.mock("../../api", () => ({
@@ -1976,7 +1980,7 @@ describe("SettingsModal", () => {
expect(sidebar).toBeTruthy();
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
// 15 nav items (group headers are not nav items)
expect(navItems.length).toBe(15);
expect(navItems.length).toBe(16);
// Labels include scope icons (Globe for global, Folder for project)
const labels = Array.from(navItems).map((el) => el.textContent?.trim());
@@ -1984,6 +1988,7 @@ describe("SettingsModal", () => {
"Authentication",
"Appearance",
"Notifications",
"Node Sync",
"Models",
"Project Models",
"General",
@@ -2402,6 +2407,111 @@ describe("SettingsModal", () => {
expect((screen.getByLabelText("Plan needs approval") as HTMLInputElement).checked).toBe(false);
});
// Node Sync section tests
describe("Node Sync section", () => {
it("Node Sync section renders with heading and toggle", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Node Sync"));
expect(screen.getByText("Node Sync", { selector: "h4" })).toBeTruthy();
expect(screen.getByLabelText("Enable automatic settings sync")).toBeTruthy();
});
it("sub-fields are hidden when sync is disabled", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
settingsSyncEnabled: false,
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Node Sync"));
expect(screen.queryByLabelText("Sync interval")).toBeNull();
expect(screen.queryByLabelText("Conflict resolution")).toBeNull();
});
it("sub-fields are visible when sync is enabled", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
settingsSyncEnabled: true,
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Node Sync"));
expect(screen.getByLabelText("Enable automatic settings sync")).toBeTruthy();
expect(screen.getByLabelText("Sync model auth credentials")).toBeTruthy();
expect(screen.getByLabelText("Sync interval")).toBeTruthy();
expect(screen.getByLabelText("Conflict resolution")).toBeTruthy();
});
it("toggle enables sync and updates form state", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Node Sync"));
const checkbox = screen.getByLabelText("Enable automatic settings sync") as HTMLInputElement;
expect(checkbox.checked).toBe(false);
fireEvent.click(checkbox);
expect((screen.getByLabelText("Enable automatic settings sync") as HTMLInputElement).checked).toBe(true);
});
it("interval dropdown changes form value", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
settingsSyncEnabled: true,
settingsSyncInterval: 900000,
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Node Sync"));
const select = screen.getByLabelText("Sync interval") as HTMLSelectElement;
expect(select.value).toBe("900000");
fireEvent.change(select, { target: { value: "3600000" } });
expect(select.value).toBe("3600000");
});
it("conflict resolution dropdown changes form value", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
settingsSyncEnabled: true,
settingsSyncConflictResolution: "last-write-wins",
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Node Sync"));
const select = screen.getByLabelText("Conflict resolution") as HTMLSelectElement;
expect(select.value).toBe("last-write-wins");
fireEvent.change(select, { target: { value: "keep-local" } });
expect(select.value).toBe("keep-local");
});
it("save persists global settings with sync enabled", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Node Sync"));
const checkbox = screen.getByLabelText("Enable automatic settings sync");
fireEvent.click(checkbox);
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.settingsSyncEnabled).toBe(true);
});
});
// Model filter tests with CustomModelDropdown
it("renders filter input in Models section dropdown", async () => {
const user = userEvent.setup();