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

This commit is contained in:
gsxdsm
2026-04-16 08:38:08 -07:00
parent 6293984839
commit 19a06a6a2c
7 changed files with 1236 additions and 0 deletions

View File

@@ -5,6 +5,10 @@ import type { ToastType } from "../hooks/useToast";
import { getProjectsForNode } from "../utils/nodeProjectAssignment";
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
import { formatRelativeTime, getSyncStateColor } from "../hooks/useNodeSettingsSync";
import { SettingsSyncLog } from "./SettingsSyncLog";
import type { SyncLogEntry } from "./SettingsSyncLog";
import { SettingsSyncConflictModal } from "./SettingsSyncConflictModal";
import type { SettingsConflictEntry, ConflictResolutionResult } from "./SettingsSyncConflictModal";
interface NodeDetailModalProps {
isOpen: boolean;
@@ -18,6 +22,10 @@ interface NodeDetailModalProps {
onPushSettings?: (nodeId: string) => Promise<unknown>;
onPullSettings?: (nodeId: string) => Promise<unknown>;
onSyncAuth?: (nodeId: string) => Promise<unknown>;
/** Sync history entries for this node */
syncHistory?: SyncLogEntry[];
/** Called when sync conflicts need resolution */
onResolveConflicts?: (resolutions: ConflictResolutionResult[]) => Promise<void>;
}
function formatTimestamp(value?: string): string {
@@ -39,6 +47,8 @@ export function NodeDetailModal({
onPushSettings,
onPullSettings,
onSyncAuth,
syncHistory = [],
onResolveConflicts,
}: NodeDetailModalProps) {
const [editMode, setEditMode] = useState(false);
const [name, setName] = useState("");
@@ -53,6 +63,10 @@ export function NodeDetailModal({
const [isSyncingAuth, setIsSyncingAuth] = useState(false);
const [syncError, setSyncError] = useState<string | null>(null);
// Conflict resolution modal state
const [showConflictModal, setShowConflictModal] = useState(false);
const [conflicts, setConflicts] = useState<SettingsConflictEntry[]>([]);
useEffect(() => {
if (!node || !isOpen) {
setEditMode(false);
@@ -411,6 +425,18 @@ export function NodeDetailModal({
)}
</section>
)}
{/* Sync History section — only for remote nodes */}
{node.type === "remote" && (
<section className="node-detail-modal__section">
<h4>Sync History</h4>
<SettingsSyncLog
nodeId={node.id}
entries={syncHistory}
singleNode={true}
/>
</section>
)}
</div>
<div className="modal-actions node-detail-modal__actions">
@@ -421,6 +447,19 @@ export function NodeDetailModal({
<button className="btn btn-sm" onClick={onClose}>Close</button>
</div>
</div>
{/* Conflict resolution modal — rendered outside main modal container */}
{node.type === "remote" && (
<SettingsSyncConflictModal
isOpen={showConflictModal}
onClose={() => setShowConflictModal(false)}
onResolve={onResolveConflicts ?? (async () => {})}
conflicts={conflicts}
localNodeName="Local"
remoteNodeName={node.name}
addToast={addToast}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,335 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
// ── Types ─────────────────────────────────────────────────────────────────────
/** A single conflicting setting between local and remote */
export interface SettingsConflictEntry {
key: string;
localValue: unknown;
remoteValue: unknown;
}
/** Resolution choice for a single setting */
export type ConflictResolution = "local" | "remote" | "manual";
/** A resolved setting to send back to the sync API */
export interface ConflictResolutionResult {
key: string;
value: unknown;
}
export interface SettingsSyncConflictModalProps {
isOpen: boolean;
onClose: () => void;
onResolve: (resolutions: ConflictResolutionResult[]) => Promise<void>;
conflicts: SettingsConflictEntry[];
localNodeName: string;
remoteNodeName: string;
addToast: (message: string, type?: "success" | "error" | "info") => void;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* Generate a unified diff string from two values suitable for display.
* Handles both string and non-string values (JSON stringified).
*/
function generateSettingsDiff(local: unknown, remote: unknown): string {
const localStr = typeof local === "string" ? local : JSON.stringify(local, null, 2);
const remoteStr = typeof remote === "string" ? remote : JSON.stringify(remote, null, 2);
if (localStr === remoteStr) {
return localStr;
}
const localLines = localStr.split("\n");
const remoteLines = remoteStr.split("\n");
const lines: string[] = [];
const maxLen = Math.max(localLines.length, remoteLines.length);
for (let i = 0; i < maxLen; i++) {
const lLine = localLines[i];
const rLine = remoteLines[i];
if (lLine !== undefined && lLine !== rLine) {
lines.push(`- ${lLine}`);
}
if (rLine !== undefined && rLine !== lLine) {
lines.push(`+ ${rLine}`);
}
if (lLine !== undefined && lLine === rLine) {
lines.push(` ${lLine}`);
}
}
return lines.join("\n");
}
interface ResolutionState {
resolution: ConflictResolution;
manualValue?: string;
}
// ── Component ────────────────────────────────────────────────────────────────
/**
* Modal dialog for resolving settings conflicts between local and remote nodes.
* Displays side-by-side diffs with per-key resolution options.
*/
export function SettingsSyncConflictModal({
isOpen,
onClose,
onResolve,
conflicts,
localNodeName,
remoteNodeName,
addToast,
}: SettingsSyncConflictModalProps) {
const [resolutionMap, setResolutionMap] = useState<Record<string, ResolutionState>>({});
const [isResolving, setIsResolving] = useState(false);
// Initialize resolution map when conflicts change
useEffect(() => {
const initial: Record<string, ResolutionState> = {};
for (const conflict of conflicts) {
if (!resolutionMap[conflict.key]) {
initial[conflict.key] = { resolution: "local" };
}
}
if (Object.keys(initial).length > 0) {
setResolutionMap((prev) => ({ ...prev, ...initial }));
}
}, [conflicts, resolutionMap]);
// Escape key handler
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
// Handle resolution change for a specific key
const handleResolutionChange = useCallback(
(key: string, resolution: ConflictResolution) => {
setResolutionMap((prev) => {
const current = prev[key] ?? { resolution: "local" };
if (resolution === "manual") {
return {
...prev,
[key]: {
resolution: "manual",
manualValue: current.manualValue ?? JSON.stringify(conflicts.find((c) => c.key === key)?.localValue ?? null, null, 2),
},
};
}
return {
...prev,
[key]: { resolution },
};
});
},
[conflicts]
);
// Handle manual value change
const handleManualValueChange = useCallback((key: string, value: string) => {
setResolutionMap((prev) => ({
...prev,
[key]: {
...prev[key],
resolution: "manual",
manualValue: value,
},
}));
}, []);
// Bulk resolution actions
const handleBulkResolution = useCallback(
(resolution: ConflictResolution) => {
const updates: Record<string, ResolutionState> = {};
for (const conflict of conflicts) {
updates[conflict.key] = { resolution };
}
setResolutionMap(updates);
},
[conflicts]
);
// Build resolution payload and submit
const handleConfirm = useCallback(async () => {
setIsResolving(true);
try {
const results: ConflictResolutionResult[] = conflicts.map((conflict) => {
const state = resolutionMap[conflict.key] ?? { resolution: "local" as ConflictResolution };
let value: unknown;
switch (state.resolution) {
case "remote":
value = conflict.remoteValue;
break;
case "manual":
try {
value = JSON.parse(state.manualValue ?? "null");
} catch {
value = state.manualValue ?? null;
}
break;
case "local":
default:
value = conflict.localValue;
break;
}
return { key: conflict.key, value };
});
await onResolve(results);
addToast("Settings conflicts resolved successfully", "success");
onClose();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to resolve conflicts";
addToast(message, "error");
} finally {
setIsResolving(false);
}
}, [addToast, conflicts, onClose, onResolve, resolutionMap]);
// Memoize diff output per conflict
const diffs = useMemo(() => {
const map: Record<string, string> = {};
for (const conflict of conflicts) {
map[conflict.key] = generateSettingsDiff(conflict.localValue, conflict.remoteValue);
}
return map;
}, [conflicts]);
if (!isOpen || conflicts.length === 0) {
return null;
}
return (
<div className="modal-overlay open" onClick={onClose}>
<div
className="modal modal-lg settings-sync-conflict-modal"
onClick={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Resolve Settings Conflicts"
>
<div className="modal-header">
<h3>Resolve Settings Conflicts</h3>
<button className="modal-close" onClick={onClose} aria-label="Close conflict modal">
&times;
</button>
</div>
<div className="modal-body">
<div className="settings-sync-conflict-modal__conflict-list">
{conflicts.map((conflict) => {
const state = resolutionMap[conflict.key] ?? { resolution: "local" };
const diffOutput = diffs[conflict.key];
return (
<div key={conflict.key} className="settings-sync-conflict-modal__conflict-item">
<div className="settings-sync-conflict-modal__key">{conflict.key}</div>
<div className="settings-sync-conflict-modal__diff-panel">
<div className="settings-sync-conflict-modal__diff-side">
<div className="settings-sync-conflict-modal__diff-label">
{localNodeName}
</div>
<div className="settings-sync-conflict-modal__diff-content">
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>{diffOutput}</pre>
</div>
</div>
<div className="settings-sync-conflict-modal__diff-side">
<div className="settings-sync-conflict-modal__diff-label">
{remoteNodeName}
</div>
<div className="settings-sync-conflict-modal__diff-content">
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>{diffOutput}</pre>
</div>
</div>
</div>
<div className="settings-sync-conflict-modal__resolution">
<label>
<input
type="radio"
name={`resolution-${conflict.key}`}
checked={state.resolution === "local"}
onChange={() => handleResolutionChange(conflict.key, "local")}
/>
Keep Local
</label>
<label>
<input
type="radio"
name={`resolution-${conflict.key}`}
checked={state.resolution === "remote"}
onChange={() => handleResolutionChange(conflict.key, "remote")}
/>
Keep Remote
</label>
<label>
<input
type="radio"
name={`resolution-${conflict.key}`}
checked={state.resolution === "manual"}
onChange={() => handleResolutionChange(conflict.key, "manual")}
/>
Merge Manually
</label>
</div>
{state.resolution === "manual" && (
<textarea
className="settings-sync-conflict-modal__manual-input"
value={state.manualValue ?? ""}
onChange={(e) => handleManualValueChange(conflict.key, e.target.value)}
placeholder="Enter JSON value..."
/>
)}
</div>
);
})}
</div>
<div className="settings-sync-conflict-modal__bulk-actions">
<button
className="btn btn-sm"
onClick={() => handleBulkResolution("local")}
type="button"
>
Resolve All: Keep Local
</button>
<button
className="btn btn-sm"
onClick={() => handleBulkResolution("remote")}
type="button"
>
Resolve All: Keep Remote
</button>
</div>
</div>
<div className="modal-actions settings-sync-conflict-modal__footer">
<button className="btn btn-sm" onClick={onClose}>
Cancel
</button>
<button
className="btn btn-primary btn-sm"
onClick={handleConfirm}
disabled={isResolving}
>
{isResolving ? "Resolving..." : "Confirm"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,219 @@
import React, { useCallback, useMemo, useState } from "react";
import { ChevronDown, Download, Upload } from "lucide-react";
// ── Types ─────────────────────────────────────────────────────────────────────
export interface SyncLogEntry {
id: string;
timestamp: string;
direction: "push" | "pull";
result: "success" | "conflict" | "error";
nodeId: string;
nodeName: string;
details?: string;
}
interface SettingsSyncLogProps {
/** The node ID this log is for */
nodeId: string;
/** Sync history entries to display — provided by parent */
entries: SyncLogEntry[];
/** Show loading state */
loading?: boolean;
/** When true, hides the node name filter (used when showing log for a single node) */
singleNode?: boolean;
}
// ── Component ────────────────────────────────────────────────────────────────
/**
* Component that displays recent settings sync operations as a chronological list
* with filtering by direction and node name.
*/
export function SettingsSyncLog({
nodeId,
entries,
loading = false,
singleNode = false,
}: SettingsSyncLogProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [directionFilter, setDirectionFilter] = useState<"all" | "push" | "pull">("all");
const [nodeFilter, setNodeFilter] = useState<string>("all");
// Toggle expanded state
const handleToggle = useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
// Extract unique node names for filter dropdown
const uniqueNodes = useMemo(() => {
const nodes = new Set<string>();
for (const entry of entries) {
nodes.add(entry.nodeName);
}
return Array.from(nodes).sort();
}, [entries]);
// Filter entries based on current filters
const filteredEntries = useMemo(() => {
let result = [...entries];
// Filter by direction
if (directionFilter !== "all") {
result = result.filter((entry) => entry.direction === directionFilter);
}
// Filter by node name
if (!singleNode && nodeFilter !== "all") {
result = result.filter((entry) => entry.nodeName === nodeFilter);
}
// Sort by timestamp descending (newest first)
result.sort((a, b) => {
const timeA = new Date(a.timestamp).getTime();
const timeB = new Date(b.timestamp).getTime();
return timeB - timeA;
});
return result;
}, [entries, directionFilter, nodeFilter, singleNode]);
// Format timestamp for display
const formatTimestamp = useCallback((isoTimestamp: string): string => {
const date = new Date(isoTimestamp);
return date.toLocaleString();
}, []);
// Get result badge class
const getResultBadgeClass = useCallback((result: SyncLogEntry["result"]): string => {
switch (result) {
case "success":
return "settings-sync-log__badge--success";
case "conflict":
return "settings-sync-log__badge--conflict";
case "error":
return "settings-sync-log__badge--error";
default:
return "";
}
}, []);
// Get result display text
const getResultText = useCallback((result: SyncLogEntry["result"]): string => {
switch (result) {
case "success":
return "Success";
case "conflict":
return "Conflict";
case "error":
return "Error";
default:
return result;
}
}, []);
return (
<div className="settings-sync-log">
<div
className="settings-sync-log__header"
onClick={handleToggle}
role="button"
tabIndex={0}
aria-expanded={isExpanded}
data-testid="settings-sync-log-header"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleToggle();
}
}}
>
<ChevronDown
size={16}
style={{
transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.15s ease",
}}
/>
<span>
{entries.length} {entries.length === 1 ? "entry" : "entries"}
</span>
</div>
{isExpanded && (
<>
<div className="settings-sync-log__filters">
<label>
Direction:
<select
value={directionFilter}
onChange={(e) => setDirectionFilter(e.target.value as "all" | "push" | "pull")}
>
<option value="all">All</option>
<option value="push">Push</option>
<option value="pull">Pull</option>
</select>
</label>
{!singleNode && (
<label>
Node:
<select
value={nodeFilter}
onChange={(e) => setNodeFilter(e.target.value)}
>
<option value="all">All Nodes</option>
{uniqueNodes.map((nodeName) => (
<option key={nodeName} value={nodeName}>
{nodeName}
</option>
))}
</select>
</label>
)}
</div>
{loading && entries.length === 0 ? (
<div className="settings-sync-log__empty">Loading...</div>
) : filteredEntries.length === 0 ? (
<div className="settings-sync-log__empty">No sync history available</div>
) : (
<div className="settings-sync-log__list">
{filteredEntries.map((entry) => (
<div key={entry.id} className="settings-sync-log__entry">
<span className="settings-sync-log__entry-timestamp">
{formatTimestamp(entry.timestamp)}
</span>
<span className="settings-sync-log__entry-direction">
{entry.direction === "push" ? (
<Upload size={14} data-testid="upload-icon" />
) : (
<Download size={14} data-testid="download-icon" />
)}
</span>
<span
className={`settings-sync-log__entry-result ${getResultBadgeClass(entry.result)}`}
>
{getResultText(entry.result)}
</span>
{!singleNode && (
<span className="settings-sync-log__entry-node">{entry.nodeName}</span>
)}
{entry.details && (
<span className="settings-sync-log__entry-details" title={entry.details}>
{entry.details}
</span>
)}
</div>
))}
</div>
)}
</>
)}
</div>
);
}

View File

@@ -12,6 +12,7 @@ vi.mock("lucide-react", () => ({
Shield: () => <span data-testid="shield-icon">shield</span>,
Upload: () => <span data-testid="upload-icon">upload</span>,
X: () => <span data-testid="x-icon">x</span>,
ChevronDown: () => <span data-testid="chevron-down">chevron</span>,
}));
vi.mock("../../hooks/useNodeSettingsSync", () => ({

View File

@@ -0,0 +1,181 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { SettingsSyncConflictModal } from "../SettingsSyncConflictModal";
// Define the props type inline to avoid import issues
interface TestConflictEntry {
key: string;
localValue: unknown;
remoteValue: unknown;
}
interface TestProps {
isOpen: boolean;
onClose: () => void;
onResolve: (resolutions: Array<{ key: string; value: unknown }>) => Promise<void>;
conflicts: TestConflictEntry[];
localNodeName: string;
remoteNodeName: string;
addToast: (message: string, type?: "success" | "error" | "info") => void;
}
function makeProps(overrides: Partial<TestProps> = {}): TestProps {
return {
isOpen: true,
onClose: vi.fn(),
onResolve: vi.fn().mockResolvedValue(undefined),
conflicts: [
{ key: "maxConcurrent", localValue: 2, remoteValue: 4 },
{ key: "defaultModelId", localValue: "claude-sonnet", remoteValue: "gpt-4o" },
],
localNodeName: "Local Node",
remoteNodeName: "Remote Node",
addToast: vi.fn(),
...overrides,
};
}
describe("SettingsSyncConflictModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("basic rendering", () => {
it("renders nothing when isOpen is false", () => {
render(<SettingsSyncConflictModal {...makeProps({ isOpen: false })} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("renders nothing when conflicts array is empty", () => {
render(<SettingsSyncConflictModal {...makeProps({ conflicts: [] })} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("renders all conflicts with key names", () => {
const conflicts = [
{ key: "setting1", localValue: 1, remoteValue: 2 },
{ key: "setting2", localValue: "a", remoteValue: "b" },
{ key: "setting3", localValue: true, remoteValue: false },
];
render(<SettingsSyncConflictModal {...makeProps({ conflicts })} />);
expect(screen.getByText("setting1")).toBeInTheDocument();
expect(screen.getByText("setting2")).toBeInTheDocument();
expect(screen.getByText("setting3")).toBeInTheDocument();
});
it("shows side-by-side diff panels", () => {
render(<SettingsSyncConflictModal {...makeProps()} />);
// There are multiple "Local Node" and "Remote Node" labels (one per conflict)
const localLabels = document.querySelectorAll(".settings-sync-conflict-modal__diff-label");
expect(localLabels.length).toBeGreaterThan(0);
});
it("default resolution is Keep Local", () => {
render(<SettingsSyncConflictModal {...makeProps()} />);
const keepLocalRadios = screen.getAllByRole("radio", { name: "Keep Local" });
expect(keepLocalRadios.length).toBeGreaterThan(0);
expect(keepLocalRadios[0]).toBeChecked();
});
});
describe("resolution interactions", () => {
it("selecting Keep Remote updates resolution", async () => {
render(<SettingsSyncConflictModal {...makeProps()} />);
const keepRemoteRadios = screen.getAllByRole("radio", { name: "Keep Remote" });
fireEvent.click(keepRemoteRadios[0]);
expect(keepRemoteRadios[0]).toBeChecked();
});
it("Merge Manually shows textarea", async () => {
render(<SettingsSyncConflictModal {...makeProps()} />);
const mergeManuallyRadios = screen.getAllByRole("radio", { name: "Merge Manually" });
fireEvent.click(mergeManuallyRadios[0]);
const textareas = screen.getAllByRole("textbox");
expect(textareas.length).toBeGreaterThan(0);
});
it("Resolve All: Keep Local sets all to local", () => {
render(<SettingsSyncConflictModal {...makeProps()} />);
fireEvent.click(screen.getByText("Resolve All: Keep Local"));
const keepLocalRadios = screen.getAllByRole("radio", { name: "Keep Local" });
for (const radio of keepLocalRadios) {
expect(radio).toBeChecked();
}
});
it("Resolve All: Keep Remote sets all to remote", () => {
render(<SettingsSyncConflictModal {...makeProps()} />);
fireEvent.click(screen.getByText("Resolve All: Keep Remote"));
const keepRemoteRadios = screen.getAllByRole("radio", { name: "Keep Remote" });
for (const radio of keepRemoteRadios) {
expect(radio).toBeChecked();
}
});
});
describe("confirm/cancel actions", () => {
it("Confirm calls onResolve with correct payload", async () => {
const onResolve = vi.fn().mockResolvedValue(undefined);
render(<SettingsSyncConflictModal {...makeProps({ onResolve })} />);
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
await waitFor(() => {
expect(onResolve).toHaveBeenCalledTimes(1);
const resolutions = onResolve.mock.calls[0][0];
expect(resolutions).toHaveLength(2);
expect(resolutions[0]).toEqual({ key: "maxConcurrent", value: 2 });
expect(resolutions[1]).toEqual({ key: "defaultModelId", value: "claude-sonnet" });
});
});
it("Cancel calls onClose", () => {
const onClose = vi.fn();
render(<SettingsSyncConflictModal {...makeProps({ onClose })} />);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("Confirm shows loading state", async () => {
let resolvePromise: () => void;
const onResolve = vi.fn().mockImplementation(
() => new Promise<void>((resolve) => { resolvePromise = resolve; })
);
render(<SettingsSyncConflictModal {...makeProps({ onResolve })} />);
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
expect(screen.getByText("Resolving...")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Resolving..." })).toBeDisabled();
resolvePromise!();
});
it("Error during resolution shows error toast", async () => {
const addToast = vi.fn();
const onResolve = vi.fn().mockRejectedValue(new Error("Sync failed"));
render(<SettingsSyncConflictModal {...makeProps({ addToast, onResolve })} />);
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Sync failed", "error");
});
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
});
describe("diff rendering", () => {
it("diff content rendered in pre tags", () => {
render(<SettingsSyncConflictModal {...makeProps()} />);
const diffContents = document.querySelectorAll(".settings-sync-conflict-modal__diff-content pre");
expect(diffContents.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,227 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { SettingsSyncLog } from "../SettingsSyncLog";
import type { SyncLogEntry } from "../SettingsSyncLog";
vi.mock("lucide-react", () => ({
Upload: () => <span data-testid="upload-icon">upload</span>,
Download: () => <span data-testid="download-icon">download</span>,
ChevronDown: () => <span data-testid="chevron-down">chevron</span>,
}));
function makeEntry(overrides: Partial<SyncLogEntry> = {}): SyncLogEntry {
return {
id: "sync-1",
timestamp: "2026-04-14T10:00:00.000Z",
direction: "push",
result: "success",
nodeId: "node-1",
nodeName: "Build Server",
...overrides,
};
}
describe("SettingsSyncLog", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("basic rendering", () => {
it("renders entries count in header", () => {
const entries = [
makeEntry({ id: "1" }),
makeEntry({ id: "2" }),
makeEntry({ id: "3" }),
];
render(<SettingsSyncLog nodeId="node-1" entries={entries} />);
expect(screen.getByText("3 entries")).toBeInTheDocument();
});
it("renders single entry correctly", () => {
const entries = [makeEntry({ id: "1" })];
render(<SettingsSyncLog nodeId="node-1" entries={entries} />);
expect(screen.getByText("1 entry")).toBeInTheDocument();
});
it("renders entries in chronological order (newest first)", () => {
const entries = [
makeEntry({ id: "1", timestamp: "2026-04-14T10:00:00.000Z" }),
makeEntry({ id: "2", timestamp: "2026-04-14T11:00:00.000Z" }),
makeEntry({ id: "3", timestamp: "2026-04-14T12:00:00.000Z" }),
];
render(<SettingsSyncLog nodeId="node-1" entries={entries} />);
// Expand the list
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
// Count entries - should be 3
const entryNodes = document.querySelectorAll(".settings-sync-log__entry");
expect(entryNodes.length).toBe(3);
});
it("shows correct direction icons", () => {
const entries = [
makeEntry({ id: "1", direction: "push" }),
makeEntry({ id: "2", direction: "pull" }),
];
render(<SettingsSyncLog nodeId="node-1" entries={entries} />);
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
expect(screen.getByTestId("upload-icon")).toBeInTheDocument();
expect(screen.getByTestId("download-icon")).toBeInTheDocument();
});
it("shows correct result badges", () => {
const entries = [
makeEntry({ id: "1", result: "success" }),
makeEntry({ id: "2", result: "conflict" }),
makeEntry({ id: "3", result: "error" }),
];
render(<SettingsSyncLog nodeId="node-1" entries={entries} />);
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
expect(screen.getByText("Success")).toBeInTheDocument();
expect(screen.getByText("Conflict")).toBeInTheDocument();
expect(screen.getByText("Error")).toBeInTheDocument();
const successBadge = document.querySelector(".settings-sync-log__badge--success");
const conflictBadge = document.querySelector(".settings-sync-log__badge--conflict");
const errorBadge = document.querySelector(".settings-sync-log__badge--error");
expect(successBadge).toBeInTheDocument();
expect(conflictBadge).toBeInTheDocument();
expect(errorBadge).toBeInTheDocument();
});
it("shows node names", () => {
const entries = [
makeEntry({ id: "1", nodeName: "Server Alpha" }),
makeEntry({ id: "2", nodeName: "Server Beta" }),
];
render(<SettingsSyncLog nodeId="node-1" entries={entries} singleNode={false} />);
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
// Use queryAllByText with exact match for the entry node spans
const nodeSpans = document.querySelectorAll(".settings-sync-log__entry-node");
expect(nodeSpans[0].textContent).toBe("Server Alpha");
expect(nodeSpans[1].textContent).toBe("Server Beta");
});
it("shows details when present", () => {
const entries = [
makeEntry({ id: "1", details: "3 settings changed" }),
];
render(<SettingsSyncLog nodeId="node-1" entries={entries} />);
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
expect(screen.getByText("3 settings changed")).toBeInTheDocument();
});
});
describe("filtering", () => {
it("direction filter works", () => {
const entries = [
makeEntry({ id: "1", direction: "push" }),
makeEntry({ id: "2", direction: "pull" }),
makeEntry({ id: "3", direction: "push" }),
];
render(<SettingsSyncLog nodeId="node-1" entries={entries} />);
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
const directionSelect = screen.getByRole("combobox", { name: "Direction:" });
fireEvent.change(directionSelect, { target: { value: "push" } });
// When filter is set, entry count in header should still show 3
// but actual list entries should be filtered
fireEvent.change(directionSelect, { target: { value: "pull" } });
fireEvent.change(directionSelect, { target: { value: "all" } });
});
it("node filter works when singleNode is not set", () => {
const entries = [
makeEntry({ id: "1", nodeName: "Build Server" }),
makeEntry({ id: "2", nodeName: "GPU Cluster" }),
];
render(<SettingsSyncLog nodeId="node-1" entries={entries} singleNode={false} />);
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
expect(screen.getByRole("combobox", { name: "Node:" })).toBeInTheDocument();
});
it("hides node filter when singleNode is true", () => {
const entries = [makeEntry({ id: "1" })];
render(<SettingsSyncLog nodeId="node-1" entries={entries} singleNode={true} />);
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
expect(screen.queryByRole("combobox", { name: "Node:" })).not.toBeInTheDocument();
});
});
describe("states", () => {
it("empty state when no entries", () => {
render(<SettingsSyncLog nodeId="node-1" entries={[]} />);
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
expect(screen.getByText("No sync history available")).toBeInTheDocument();
});
it("loading state", () => {
render(<SettingsSyncLog nodeId="node-1" entries={[]} loading={true} />);
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
});
describe("collapsible toggle", () => {
it("default state is collapsed", () => {
render(<SettingsSyncLog nodeId="node-1" entries={[makeEntry()]} />);
// Entry list should not be visible (use entry count text as proxy)
expect(screen.getByText("1 entry")).toBeInTheDocument();
expect(document.querySelector(".settings-sync-log__list")).not.toBeInTheDocument();
});
it("expands on click", () => {
render(<SettingsSyncLog nodeId="node-1" entries={[makeEntry()]} />);
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
expect(document.querySelector(".settings-sync-log__list")).toBeInTheDocument();
});
it("collapses on second click", () => {
render(<SettingsSyncLog nodeId="node-1" entries={[makeEntry()]} />);
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
expect(document.querySelector(".settings-sync-log__list")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
expect(document.querySelector(".settings-sync-log__list")).not.toBeInTheDocument();
});
});
describe("timestamp formatting", () => {
it("formats timestamps for display", () => {
const entries = [makeEntry({ timestamp: "2026-04-14T10:30:00.000Z" })];
render(<SettingsSyncLog nodeId="node-1" entries={entries} />);
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
// Check that timestamps are present
const timestampSpans = document.querySelectorAll(".settings-sync-log__entry-timestamp");
expect(timestampSpans.length).toBe(1);
});
});
});

View File

@@ -28550,6 +28550,236 @@ html .column.drag-over * {
justify-content: space-between;
}
/* === SettingsSyncConflictModal === */
.settings-sync-conflict-modal {
max-width: 860px;
width: min(860px, calc(100vw - 32px));
}
.settings-sync-conflict-modal__conflict-list {
display: flex;
flex-direction: column;
gap: var(--space-md);
max-height: min(50vh, 400px);
overflow-y: auto;
}
.settings-sync-conflict-modal__conflict-item {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-md);
}
.settings-sync-conflict-modal__key {
font-weight: 600;
font-family: monospace;
margin-bottom: var(--space-sm);
}
.settings-sync-conflict-modal__diff-panel {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.settings-sync-conflict-modal__diff-side {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.settings-sync-conflict-modal__diff-label {
font-size: 11px;
color: var(--text-muted);
padding: 4px 8px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.settings-sync-conflict-modal__diff-content {
padding: var(--space-xs) var(--space-sm);
white-space: pre;
font-family: monospace;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
}
.settings-sync-conflict-modal__resolution {
display: flex;
gap: var(--space-md);
align-items: center;
}
.settings-sync-conflict-modal__resolution label {
display: flex;
align-items: center;
gap: var(--space-xs);
cursor: pointer;
}
.settings-sync-conflict-modal__resolution input[type="radio"] {
cursor: pointer;
}
.settings-sync-conflict-modal__manual-input {
width: 100%;
font-family: monospace;
font-size: 12px;
min-height: 80px;
margin-top: var(--space-xs);
padding: var(--space-sm);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
resize: vertical;
}
.settings-sync-conflict-modal__manual-input:focus {
outline: none;
border-color: var(--todo);
box-shadow: var(--focus-ring);
}
.settings-sync-conflict-modal__bulk-actions {
display: flex;
gap: var(--space-xs);
padding-top: var(--space-sm);
border-top: 1px solid var(--border);
}
.settings-sync-conflict-modal__footer {
display: flex;
justify-content: flex-end;
gap: var(--space-xs);
}
/* === SettingsSyncLog === */
.settings-sync-log {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.settings-sync-log__header {
display: flex;
align-items: center;
gap: var(--space-xs);
cursor: pointer;
padding: var(--space-xs) 0;
border-radius: var(--radius-sm);
transition: background-color var(--transition-fast);
}
.settings-sync-log__header:hover {
background: var(--surface-hover, rgba(0,0,0,0.03));
}
.settings-sync-log__filters {
display: flex;
gap: var(--space-sm);
align-items: center;
flex-wrap: wrap;
}
.settings-sync-log__filters label {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: 12px;
color: var(--text-muted);
}
.settings-sync-log__filters select {
font-size: 12px;
padding: 4px 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
}
.settings-sync-log__filters select:focus {
outline: none;
border-color: var(--todo);
box-shadow: var(--focus-ring);
}
.settings-sync-log__list {
display: flex;
flex-direction: column;
}
.settings-sync-log__entry {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) 0;
border-bottom: 1px solid var(--border);
}
.settings-sync-log__entry:last-child {
border-bottom: none;
}
.settings-sync-log__entry-timestamp {
font-size: 12px;
color: var(--text-muted);
min-width: 140px;
}
.settings-sync-log__entry-direction {
display: flex;
align-items: center;
color: var(--text-muted);
}
.settings-sync-log__entry-result {
display: inline-flex;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
}
.settings-sync-log__badge--success {
background: color-mix(in srgb, var(--color-success, #2da44e) 14%, transparent);
color: var(--color-success, #2da44e);
}
.settings-sync-log__badge--conflict {
background: color-mix(in srgb, #d29922 14%, transparent);
color: #d29922;
}
.settings-sync-log__badge--error {
background: color-mix(in srgb, var(--color-error, #cf222e) 14%, transparent);
color: var(--color-error, #cf222e);
}
.settings-sync-log__entry-node {
font-weight: 500;
font-size: 12px;
}
.settings-sync-log__entry-details {
font-size: 12px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.settings-sync-log__empty {
text-align: center;
color: var(--text-muted);
padding: var(--space-md);
font-size: 13px;
}
.project-node-selector {
display: flex;
flex-direction: column;
@@ -28600,6 +28830,10 @@ html .column.drag-over * {
grid-template-columns: 1fr;
}
.settings-sync-conflict-modal__diff-panel {
grid-template-columns: 1fr;
}
.node-detail-modal__field--full {
grid-column: span 1;
}