feat(FN-1826): merge fusion/fn-1826
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
335
packages/dashboard/app/components/SettingsSyncConflictModal.tsx
Normal file
335
packages/dashboard/app/components/SettingsSyncConflictModal.tsx
Normal 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">
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
219
packages/dashboard/app/components/SettingsSyncLog.tsx
Normal file
219
packages/dashboard/app/components/SettingsSyncLog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user