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

This commit is contained in:
gsxdsm
2026-04-16 07:38:07 -07:00
parent 6cbcab20b7
commit 42d528f30d
8 changed files with 1064 additions and 14 deletions

View File

@@ -2,6 +2,8 @@ import { memo, useCallback, useMemo, useState } from "react";
import { Activity, Server, Settings, Trash2 } from "lucide-react";
import type { NodeInfo, ProjectInfo } from "../api";
import { getProjectCountForNode } from "../utils/nodeProjectAssignment";
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
import { formatRelativeTime, getSyncStateColor } from "../hooks/useNodeSettingsSync";
export interface NodeCardProps {
node: NodeInfo;
@@ -10,6 +12,7 @@ export interface NodeCardProps {
onEdit: (node: NodeInfo) => void;
onRemove: (id: string) => void;
isLoading?: boolean;
syncStatus?: ComputedNodeSyncStatus;
}
const STATUS_CONFIG: Record<NodeInfo["status"], { label: string; color: string; className: string }> = {
@@ -37,6 +40,19 @@ function areNodeCardPropsEqual(previous: NodeCardProps, next: NodeCardProps): bo
if (prevNode.updatedAt !== nextNode.updatedAt) return false;
if (previous.isLoading !== next.isLoading) return false;
// Compare sync status
const prevSync = previous.syncStatus;
const nextSync = next.syncStatus;
if (!prevSync && !nextSync) {
// Both undefined - equal
} else if (!prevSync || !nextSync) {
return false; // One defined, one not
} else {
if (prevSync.syncState !== nextSync.syncState) return false;
if (prevSync.lastSyncAt !== nextSync.lastSyncAt) return false;
if (prevSync.diffCount !== nextSync.diffCount) return false;
}
// Compare project counts using the canonical counting function
const previousCount = getProjectCountForNode(previous.projects, prevNode);
const nextCount = getProjectCountForNode(next.projects, nextNode);
@@ -50,6 +66,7 @@ function NodeCardInner({
onEdit,
onRemove,
isLoading = false,
syncStatus,
}: NodeCardProps) {
const [removeArmed, setRemoveArmed] = useState(false);
const statusConfig = STATUS_CONFIG[node.status];
@@ -138,6 +155,24 @@ function NodeCardInner({
<span className="node-card__metric-value">{node.maxConcurrent}</span>
</div>
</div>
{/* Sync status indicator — only for remote nodes with sync data */}
{node.type === "remote" && syncStatus && (
<div
className="node-card__sync"
data-sync-state={syncStatus.syncState}
data-testid="node-card-sync"
>
<span
className="node-card__sync-dot"
style={{ backgroundColor: getSyncStateColor(syncStatus.syncState) }}
aria-hidden
/>
<span className="node-card__sync-time">
{formatRelativeTime(syncStatus.lastSyncAt)}
</span>
</div>
)}
</div>
<footer className="node-card__actions">

View File

@@ -1,8 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Activity, Pencil, Save, X } from "lucide-react";
import { Activity, Download, Key, Pencil, Save, Shield, Upload, X } from "lucide-react";
import type { NodeInfo, NodeUpdateInput, ProjectInfo } from "../api";
import type { ToastType } from "../hooks/useToast";
import { getProjectsForNode } from "../utils/nodeProjectAssignment";
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
import { formatRelativeTime, getSyncStateColor } from "../hooks/useNodeSettingsSync";
interface NodeDetailModalProps {
isOpen: boolean;
@@ -12,6 +14,10 @@ interface NodeDetailModalProps {
onUpdate: (id: string, updates: NodeUpdateInput) => Promise<void>;
onHealthCheck: (id: string) => Promise<void>;
addToast: (message: string, type?: ToastType) => void;
syncStatus?: ComputedNodeSyncStatus;
onPushSettings?: (nodeId: string) => Promise<unknown>;
onPullSettings?: (nodeId: string) => Promise<unknown>;
onSyncAuth?: (nodeId: string) => Promise<unknown>;
}
function formatTimestamp(value?: string): string {
@@ -29,6 +35,10 @@ export function NodeDetailModal({
onUpdate,
onHealthCheck,
addToast,
syncStatus,
onPushSettings,
onPullSettings,
onSyncAuth,
}: NodeDetailModalProps) {
const [editMode, setEditMode] = useState(false);
const [name, setName] = useState("");
@@ -37,6 +47,12 @@ export function NodeDetailModal({
const [maxConcurrent, setMaxConcurrent] = useState(2);
const [isSaving, setIsSaving] = useState(false);
// Sync action states
const [isPushing, setIsPushing] = useState(false);
const [isPulling, setIsPulling] = useState(false);
const [isSyncingAuth, setIsSyncingAuth] = useState(false);
const [syncError, setSyncError] = useState<string | null>(null);
useEffect(() => {
if (!node || !isOpen) {
setEditMode(false);
@@ -81,6 +97,58 @@ export function NodeDetailModal({
}
}, [addToast, node, onHealthCheck]);
const handlePushSettings = useCallback(async () => {
if (!node || !onPushSettings) return;
setSyncError(null);
setIsPushing(true);
try {
await onPushSettings(node.id);
addToast("Settings pushed successfully", "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Push settings failed";
setSyncError(message);
addToast(message, "error");
} finally {
setIsPushing(false);
}
}, [addToast, node, onPushSettings]);
const handlePullSettings = useCallback(async () => {
if (!node || !onPullSettings) return;
setSyncError(null);
setIsPulling(true);
try {
await onPullSettings(node.id);
addToast("Settings pulled successfully", "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Pull settings failed";
setSyncError(message);
addToast(message, "error");
} finally {
setIsPulling(false);
}
}, [addToast, node, onPullSettings]);
const handleSyncAuth = useCallback(async () => {
if (!node || !onSyncAuth) return;
setSyncError(null);
setIsSyncingAuth(true);
try {
await onSyncAuth(node.id);
addToast("Auth credentials synced successfully", "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Auth sync failed";
setSyncError(message);
addToast(message, "error");
} finally {
setIsSyncingAuth(false);
}
}, [addToast, node, onSyncAuth]);
const handleDismissSyncError = useCallback(() => {
setSyncError(null);
}, []);
const handleSave = useCallback(async () => {
if (!node || isSaving) return;
@@ -271,6 +339,78 @@ export function NodeDetailModal({
<span>Last check: <strong>{formatTimestamp(node.updatedAt)}</strong></span>
</div>
</section>
{/* Settings Sync section — only for remote nodes */}
{node.type === "remote" && (
<section className="node-detail-modal__section">
<h4>Settings Sync</h4>
{syncStatus && (
<div className="node-detail-modal__sync-status">
<span
className="node-detail-modal__sync-dot"
style={{ backgroundColor: getSyncStateColor(syncStatus.syncState) }}
aria-hidden
/>
<span>
Last sync:{" "}
<strong>
{syncStatus.lastSyncAt
? formatRelativeTime(syncStatus.lastSyncAt)
: "Never synced"}
</strong>
</span>
{syncStatus.diffCount > 0 && (
<span className="node-detail-modal__sync-diff">
Differences: <strong>{syncStatus.diffCount}</strong>
</span>
)}
</div>
)}
<div className="node-detail-modal__sync-actions">
<button
className="btn btn-sm"
onClick={handlePushSettings}
disabled={isPushing || !onPushSettings}
>
<Upload size={14} />
{isPushing ? "Pushing..." : "Push Settings"}
</button>
<button
className="btn btn-sm"
onClick={handlePullSettings}
disabled={isPulling || !onPullSettings}
>
<Download size={14} />
{isPulling ? "Pulling..." : "Pull Settings"}
</button>
<button
className="btn btn-sm"
onClick={handleSyncAuth}
disabled={isSyncingAuth || !onSyncAuth}
>
<Shield size={14} />
{isSyncingAuth ? "Syncing..." : "Sync Auth"}
</button>
</div>
{syncError && (
<div className="node-detail-modal__sync-error">
<span>{syncError}</span>
<button
className="node-detail-modal__sync-error-dismiss"
onClick={handleDismissSyncError}
aria-label="Dismiss error"
>
<X size={14} />
</button>
</div>
)}
</section>
)}
</div>
<div className="modal-actions node-detail-modal__actions">

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { Plus, Server, Wifi, WifiOff, Globe, RefreshCw, X } from "lucide-react";
import { useNodes } from "../hooks/useNodes";
import { useProjects } from "../hooks/useProjects";
import { useNodeSettingsSync, computeSyncState } from "../hooks/useNodeSettingsSync";
import type { NodeInfo, NodeUpdateInput } from "../api";
import { NodeCard } from "./NodeCard";
import { MeshTopology } from "./MeshTopology";
@@ -17,9 +18,18 @@ 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 [addModalOpen, setAddModalOpen] = useState(false);
const [selectedNode, setSelectedNode] = useState<NodeInfo | null>(null);
// Track remote nodes for sync status polling
useEffect(() => {
const remoteNodes = nodes.filter((node) => node.type === "remote");
for (const node of remoteNodes) {
trackNode(node.id);
}
}, [nodes, trackNode]);
useEffect(() => {
if (!selectedNode) return;
const latest = nodes.find((node) => node.id === selectedNode.id) ?? null;
@@ -31,8 +41,11 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
const online = nodes.filter((node) => node.status === "online").length;
const offline = nodes.filter((node) => node.status === "offline" || node.status === "error").length;
const remote = nodes.filter((node) => node.type === "remote").length;
return { total, online, offline, remote };
}, [nodes]);
const synced = nodes.filter(
(node) => node.type === "remote" && syncStatusMap[node.id] && computeSyncState(syncStatusMap[node.id]).syncState === "synced"
).length;
return { total, online, offline, remote, synced };
}, [nodes, syncStatusMap]);
const handleRegister = useCallback(async (input: AddNodeInput) => {
await register(input);
@@ -120,6 +133,10 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
<span><Globe size={14} /> Remote</span>
<strong>{stats.remote}</strong>
</div>
<div className="nodes-view-stat nodes-view-stat--synced" data-testid="nodes-stat-synced">
<span><RefreshCw size={14} /> Synced</span>
<strong>{stats.synced}</strong>
</div>
</div>
{error && <div className="nodes-view-error">{error}</div>}
@@ -148,17 +165,23 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
</div>
) : (
<div className="nodes-view-grid">
{nodes.map((node) => (
<NodeCard
key={node.id}
node={node}
projects={projects}
onHealthCheck={(id) => { void handleHealthCheck(id); }}
onEdit={(selected) => setSelectedNode(selected)}
onRemove={(id) => { void handleUnregister(id); }}
isLoading={loading}
/>
))}
{nodes.map((node) => {
const nodeSyncStatus = node.type === "remote" && syncStatusMap[node.id]
? computeSyncState(syncStatusMap[node.id])
: undefined;
return (
<NodeCard
key={node.id}
node={node}
projects={projects}
onHealthCheck={(id) => { void handleHealthCheck(id); }}
onEdit={(selected) => setSelectedNode(selected)}
onRemove={(id) => { void handleUnregister(id); }}
isLoading={loading}
syncStatus={nodeSyncStatus}
/>
);
})}
</div>
)}
@@ -177,6 +200,12 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
onUpdate={handleUpdate}
onHealthCheck={handleHealthCheck}
addToast={addToast}
syncStatus={selectedNode?.type === "remote" && selectedNode && syncStatusMap[selectedNode.id]
? computeSyncState(syncStatusMap[selectedNode.id])
: undefined}
onPushSettings={pushSettings}
onPullSettings={pullSettings}
onSyncAuth={syncAuth}
/>
</div>
);

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { NodeCard } from "../NodeCard";
import type { NodeInfo, ProjectInfo } from "../../api";
import type { ComputedNodeSyncStatus } from "../../hooks/useNodeSettingsSync";
vi.mock("lucide-react", () => ({
Activity: () => <span data-testid="activity-icon">activity</span>,
@@ -10,6 +11,23 @@ vi.mock("lucide-react", () => ({
Trash2: () => <span data-testid="trash-icon">trash</span>,
}));
vi.mock("../../hooks/useNodeSettingsSync", () => ({
formatRelativeTime: vi.fn((ts: string | null) => {
if (!ts) return "Never synced";
return "Synced 2m ago";
}),
getSyncStateColor: vi.fn((state: string) => {
switch (state) {
case "synced": return "var(--color-success)";
case "diff": return "var(--warning)";
case "error": return "var(--color-error)";
case "pending": return "var(--warning)";
case "never-synced": return "var(--text-muted)";
default: return "var(--text-muted)";
}
}),
}));
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
return {
id: "node-1",
@@ -36,6 +54,15 @@ function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
};
}
function makeSyncStatus(overrides: Partial<ComputedNodeSyncStatus> = {}): ComputedNodeSyncStatus {
return {
syncState: "synced",
lastSyncAt: new Date(Date.now() - 120000).toISOString(), // 2 minutes ago
diffCount: 0,
...overrides,
};
}
describe("NodeCard", () => {
it("renders node name, type, status, project count, and concurrency", () => {
const node = makeNode({ id: "node-abc", name: "Build Worker", type: "remote", status: "connecting", url: "https://remote.example.com" });
@@ -362,4 +389,156 @@ describe("NodeCard", () => {
}
});
});
describe("sync indicator", () => {
it("renders sync indicator for remote nodes with synced state", () => {
const node = makeNode({
id: "node-synced",
name: "Synced Node",
type: "remote",
url: "https://synced.example.com",
status: "online",
});
const syncStatus = makeSyncStatus({ syncState: "synced" });
render(
<NodeCard
node={node}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
syncStatus={syncStatus}
/>
);
const syncIndicator = screen.getByTestId("node-card-sync");
expect(syncIndicator).toBeInTheDocument();
expect(syncIndicator).toHaveAttribute("data-sync-state", "synced");
expect(syncIndicator.textContent).toMatch(/Synced/);
});
it("does not render sync indicator for local nodes", () => {
const node = makeNode({
id: "node-local",
name: "Local Node",
type: "local",
status: "online",
});
const syncStatus = makeSyncStatus();
render(
<NodeCard
node={node}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
syncStatus={syncStatus}
/>
);
expect(screen.queryByTestId("node-card-sync")).not.toBeInTheDocument();
});
it("does not render sync indicator when syncStatus is undefined", () => {
const node = makeNode({
id: "node-remote",
name: "Remote Node",
type: "remote",
url: "https://remote.example.com",
status: "online",
});
render(
<NodeCard
node={node}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
/>
);
expect(screen.queryByTestId("node-card-sync")).not.toBeInTheDocument();
});
it("renders error state dot for sync error", () => {
const node = makeNode({
id: "node-error-sync",
name: "Error Sync Node",
type: "remote",
status: "online",
});
const syncStatus = makeSyncStatus({ syncState: "error" });
render(
<NodeCard
node={node}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
syncStatus={syncStatus}
/>
);
const syncIndicator = screen.getByTestId("node-card-sync");
expect(syncIndicator).toHaveAttribute("data-sync-state", "error");
});
it("renders 'Never synced' when lastSyncAt is null", () => {
const node = makeNode({
id: "node-never-synced",
name: "Never Synced Node",
type: "remote",
status: "online",
});
const syncStatus = makeSyncStatus({
syncState: "never-synced",
lastSyncAt: null,
});
render(
<NodeCard
node={node}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
syncStatus={syncStatus}
/>
);
const syncIndicator = screen.getByTestId("node-card-sync");
expect(syncIndicator.textContent).toContain("Never synced");
});
it("renders diff state correctly", () => {
const node = makeNode({
id: "node-diff",
name: "Diff Node",
type: "remote",
status: "online",
});
const syncStatus = makeSyncStatus({
syncState: "diff",
diffCount: 5,
});
render(
<NodeCard
node={node}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
syncStatus={syncStatus}
/>
);
const syncIndicator = screen.getByTestId("node-card-sync");
expect(syncIndicator).toHaveAttribute("data-sync-state", "diff");
});
});
});

View File

@@ -0,0 +1,298 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { NodeDetailModal } from "../NodeDetailModal";
import type { NodeInfo, ProjectInfo } from "../../api";
import type { ComputedNodeSyncStatus } from "../../hooks/useNodeSettingsSync";
vi.mock("lucide-react", () => ({
Activity: () => <span data-testid="activity-icon">activity</span>,
Download: () => <span data-testid="download-icon">download</span>,
Pencil: () => <span data-testid="pencil-icon">pencil</span>,
Save: () => <span data-testid="save-icon">save</span>,
Shield: () => <span data-testid="shield-icon">shield</span>,
Upload: () => <span data-testid="upload-icon">upload</span>,
X: () => <span data-testid="x-icon">x</span>,
}));
vi.mock("../../hooks/useNodeSettingsSync", () => ({
formatRelativeTime: vi.fn((ts: string | null) => {
if (!ts) return "Never synced";
return "Synced 2m ago";
}),
getSyncStateColor: vi.fn((state: string) => {
switch (state) {
case "synced": return "var(--color-success)";
case "diff": return "var(--warning)";
case "error": return "var(--color-error)";
case "pending": return "var(--warning)";
case "never-synced": return "var(--text-muted)";
default: return "var(--text-muted)";
}
}),
}));
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
return {
id: "node-1",
name: "Test Node",
type: "remote",
status: "online",
url: "https://test.example.com",
maxConcurrent: 2,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
return {
id: "proj-1",
name: "Project One",
path: "/workspace/project-one",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
function makeSyncStatus(overrides: Partial<ComputedNodeSyncStatus> = {}): ComputedNodeSyncStatus {
return {
syncState: "synced",
lastSyncAt: new Date(Date.now() - 120000).toISOString(),
diffCount: 0,
...overrides,
};
}
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
node: makeNode(),
projects: [],
onUpdate: vi.fn().mockResolvedValue(undefined),
onHealthCheck: vi.fn().mockResolvedValue(undefined),
addToast: vi.fn(),
};
describe("NodeDetailModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("basic rendering", () => {
it("renders modal when isOpen is true", () => {
render(<NodeDetailModal {...defaultProps} />);
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it("does not render modal when isOpen is false", () => {
render(<NodeDetailModal {...defaultProps} isOpen={false} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("renders node name in dialog title", () => {
const node = makeNode({ name: "Custom Node Name" });
render(<NodeDetailModal {...defaultProps} node={node} />);
expect(screen.getByRole("dialog", { name: "Node details for Custom Node Name" })).toBeInTheDocument();
});
it("renders Overview, Projects, Health, and Settings Sync sections for remote nodes", () => {
render(<NodeDetailModal {...defaultProps} />);
expect(screen.getByText("Overview")).toBeInTheDocument();
expect(screen.getByText(/^Assigned Projects \(\d+\)$/)).toBeInTheDocument();
expect(screen.getByText("Health")).toBeInTheDocument();
expect(screen.getByText("Settings Sync")).toBeInTheDocument();
});
it("does not render Settings Sync section for local nodes", () => {
const localNode = makeNode({ type: "local" });
render(<NodeDetailModal {...defaultProps} node={localNode} />);
expect(screen.getByText("Overview")).toBeInTheDocument();
expect(screen.getByText(/^Projects \(\d+\)$/)).toBeInTheDocument();
expect(screen.getByText("Health")).toBeInTheDocument();
expect(screen.queryByText("Settings Sync")).not.toBeInTheDocument();
});
});
describe("Settings Sync section", () => {
it("renders Push Settings, Pull Settings, and Sync Auth buttons for remote nodes", () => {
render(<NodeDetailModal {...defaultProps} />);
expect(screen.getByText("Push Settings")).toBeInTheDocument();
expect(screen.getByText("Pull Settings")).toBeInTheDocument();
expect(screen.getByText("Sync Auth")).toBeInTheDocument();
});
it("displays sync status with last sync time", () => {
const syncStatus = makeSyncStatus({ syncState: "synced" });
render(<NodeDetailModal {...defaultProps} syncStatus={syncStatus} />);
expect(screen.getByText(/Last sync:/)).toBeInTheDocument();
});
it("displays diff count when available", () => {
const syncStatus = makeSyncStatus({ syncState: "diff", diffCount: 3 });
render(<NodeDetailModal {...defaultProps} syncStatus={syncStatus} />);
// The diff count is displayed with the "Differences:" label
const diffElement = document.querySelector(".node-detail-modal__sync-diff");
expect(diffElement).toBeInTheDocument();
expect(diffElement?.textContent).toContain("Differences:");
expect(diffElement?.textContent).toContain("3");
});
it("displays 'Never synced' when lastSyncAt is null", () => {
const syncStatus = makeSyncStatus({ syncState: "never-synced", lastSyncAt: null });
render(<NodeDetailModal {...defaultProps} syncStatus={syncStatus} />);
expect(screen.getByText(/Never synced/)).toBeInTheDocument();
});
it("Push Settings button calls onPushSettings", async () => {
const onPushSettings = vi.fn().mockResolvedValue(undefined);
render(<NodeDetailModal {...defaultProps} onPushSettings={onPushSettings} />);
await act(async () => {
fireEvent.click(screen.getByText("Push Settings"));
});
expect(onPushSettings).toHaveBeenCalledWith(defaultProps.node!.id);
});
it("Pull Settings button calls onPullSettings", async () => {
const onPullSettings = vi.fn().mockResolvedValue(undefined);
render(<NodeDetailModal {...defaultProps} onPullSettings={onPullSettings} />);
await act(async () => {
fireEvent.click(screen.getByText("Pull Settings"));
});
expect(onPullSettings).toHaveBeenCalledWith(defaultProps.node!.id);
});
it("Sync Auth button calls onSyncAuth", async () => {
const onSyncAuth = vi.fn().mockResolvedValue(undefined);
render(<NodeDetailModal {...defaultProps} onSyncAuth={onSyncAuth} />);
await act(async () => {
fireEvent.click(screen.getByText("Sync Auth"));
});
expect(onSyncAuth).toHaveBeenCalledWith(defaultProps.node!.id);
});
it("shows loading state on Push Settings button during operation", async () => {
const onPushSettings = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
render(<NodeDetailModal {...defaultProps} onPushSettings={onPushSettings} />);
await act(async () => {
fireEvent.click(screen.getByText("Push Settings"));
});
expect(screen.getByText("Pushing...")).toBeInTheDocument();
});
it("shows loading state on Pull Settings button during operation", async () => {
const onPullSettings = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
render(<NodeDetailModal {...defaultProps} onPullSettings={onPullSettings} />);
await act(async () => {
fireEvent.click(screen.getByText("Pull Settings"));
});
expect(screen.getByText("Pulling...")).toBeInTheDocument();
});
it("shows loading state on Sync Auth button during operation", async () => {
const onSyncAuth = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
render(<NodeDetailModal {...defaultProps} onSyncAuth={onSyncAuth} />);
await act(async () => {
fireEvent.click(screen.getByText("Sync Auth"));
});
expect(screen.getByText("Syncing...")).toBeInTheDocument();
});
it("displays sync error when push operation fails", async () => {
const onPushSettings = vi.fn().mockRejectedValue(new Error("Push failed: connection refused"));
const addToast = vi.fn();
render(
<NodeDetailModal
{...defaultProps}
onPushSettings={onPushSettings}
addToast={addToast}
/>
);
await act(async () => {
fireEvent.click(screen.getByText("Push Settings"));
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(screen.getByText(/Push failed: connection refused/)).toBeInTheDocument();
});
it("displays sync error when pull operation fails", async () => {
const onPullSettings = vi.fn().mockRejectedValue(new Error("Pull failed: timeout"));
const addToast = vi.fn();
render(
<NodeDetailModal
{...defaultProps}
onPullSettings={onPullSettings}
addToast={addToast}
/>
);
await act(async () => {
fireEvent.click(screen.getByText("Pull Settings"));
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(screen.getByText(/Pull failed: timeout/)).toBeInTheDocument();
});
it("displays sync error when auth sync operation fails", async () => {
const onSyncAuth = vi.fn().mockRejectedValue(new Error("Auth sync failed"));
const addToast = vi.fn();
render(
<NodeDetailModal
{...defaultProps}
onSyncAuth={onSyncAuth}
addToast={addToast}
/>
);
await act(async () => {
fireEvent.click(screen.getByText("Sync Auth"));
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(screen.getByText(/Auth sync failed/)).toBeInTheDocument();
});
it("dismisses sync error when dismiss button is clicked", async () => {
const onPushSettings = vi.fn().mockRejectedValue(new Error("Push failed"));
render(<NodeDetailModal {...defaultProps} onPushSettings={onPushSettings} />);
await act(async () => {
fireEvent.click(screen.getByText("Push Settings"));
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(screen.getByText(/Push failed/)).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByLabelText("Dismiss error"));
});
expect(screen.queryByText(/Push failed/)).not.toBeInTheDocument();
});
it("buttons are disabled when no handlers provided", () => {
render(<NodeDetailModal {...defaultProps} />);
expect(screen.getByText("Push Settings")).toBeDisabled();
expect(screen.getByText("Pull Settings")).toBeDisabled();
expect(screen.getByText("Sync Auth")).toBeDisabled();
});
});
});

View File

@@ -4,6 +4,8 @@ import { NodesView } from "../NodesView";
import type { NodeInfo, ProjectInfo } from "../../api";
import { useNodes } from "../../hooks/useNodes";
import { useProjects } from "../../hooks/useProjects";
import { useNodeSettingsSync } from "../../hooks/useNodeSettingsSync";
import type { NodeSettingsSyncStatus } from "../../api-node";
vi.mock("../../hooks/useNodes", () => ({
useNodes: vi.fn(),
@@ -13,8 +15,33 @@ vi.mock("../../hooks/useProjects", () => ({
useProjects: vi.fn(),
}));
vi.mock("../../hooks/useNodeSettingsSync", () => ({
useNodeSettingsSync: vi.fn(),
computeSyncState: vi.fn((status: NodeSettingsSyncStatus) => {
if (!status) return { syncState: "never-synced" as const, lastSyncAt: null, diffCount: 0 };
if (status.lastSyncAt === null) return { syncState: "never-synced" as const, lastSyncAt: null, diffCount: 0 };
if (!status.remoteReachable) return { syncState: "error" as const, lastSyncAt: status.lastSyncAt, diffCount: 0 };
const diffCount = status.diff.global.length + status.diff.project.length;
if (diffCount > 0) return { syncState: "diff" as const, lastSyncAt: status.lastSyncAt, diffCount };
return { syncState: "synced" as const, lastSyncAt: status.lastSyncAt, diffCount: 0 };
}),
getSyncStateColor: vi.fn((state: string) => {
switch (state) {
case "synced": return "var(--color-success)";
case "diff": return "var(--warning)";
case "error": return "var(--color-error)";
default: return "var(--text-muted)";
}
}),
formatRelativeTime: vi.fn((ts: string | null) => {
if (!ts) return "Never synced";
return "Synced 2m ago";
}),
}));
const mockUseNodes = vi.mocked(useNodes);
const mockUseProjects = vi.mocked(useProjects);
const mockUseNodeSettingsSync = vi.mocked(useNodeSettingsSync);
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
return {
@@ -66,6 +93,19 @@ beforeEach(() => {
update: vi.fn(),
unregister: vi.fn(),
});
mockUseNodeSettingsSync.mockReturnValue({
syncStatusMap: {},
loading: false,
actionLoading: {},
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
trackNode: vi.fn(),
untrackNode: vi.fn(),
pushSettings: vi.fn().mockResolvedValue({ success: true }),
pullSettings: vi.fn().mockResolvedValue({ success: true }),
syncAuth: vi.fn().mockResolvedValue({ success: true, syncedProviders: [] }),
});
});
describe("NodesView", () => {
@@ -269,4 +309,161 @@ describe("NodesView", () => {
expect(screen.getByTestId("nodes-stat-remote").textContent).toContain("0");
});
});
describe("sync status", () => {
it("renders Synced stat in stats row", () => {
const syncedStatus: NodeSettingsSyncStatus = {
lastSyncAt: new Date(Date.now() - 60000).toISOString(),
lastSyncDirection: "push",
localUpdatedAt: new Date().toISOString(),
remoteReachable: true,
diff: { global: [], project: [] },
};
mockUseNodeSettingsSync.mockReturnValue({
syncStatusMap: {
"node-remote": syncedStatus,
},
loading: false,
actionLoading: {},
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
trackNode: vi.fn(),
untrackNode: vi.fn(),
pushSettings: vi.fn().mockResolvedValue({ success: true }),
pullSettings: vi.fn().mockResolvedValue({ success: true }),
syncAuth: vi.fn().mockResolvedValue({ success: true, syncedProviders: [] }),
});
mockUseNodes.mockReturnValue(makeUseNodesResult({
nodes: [
makeNode({ id: "node-remote", name: "Remote Node", type: "remote", status: "online" }),
],
}));
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
expect(screen.getByTestId("nodes-stat-synced")).toBeInTheDocument();
expect(screen.getByTestId("nodes-stat-synced").textContent).toContain("1");
});
it("Synced count is 0 when no remote nodes are synced", () => {
const diffStatus: NodeSettingsSyncStatus = {
lastSyncAt: new Date(Date.now() - 60000).toISOString(),
lastSyncDirection: "push",
localUpdatedAt: new Date().toISOString(),
remoteReachable: true,
diff: { global: ["theme"], project: [] },
};
mockUseNodeSettingsSync.mockReturnValue({
syncStatusMap: {
"node-remote": diffStatus,
},
loading: false,
actionLoading: {},
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
trackNode: vi.fn(),
untrackNode: vi.fn(),
pushSettings: vi.fn().mockResolvedValue({ success: true }),
pullSettings: vi.fn().mockResolvedValue({ success: true }),
syncAuth: vi.fn().mockResolvedValue({ success: true, syncedProviders: [] }),
});
mockUseNodes.mockReturnValue(makeUseNodesResult({
nodes: [
makeNode({ id: "node-remote", name: "Remote Node", type: "remote", status: "online" }),
],
}));
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
expect(screen.getByTestId("nodes-stat-synced").textContent).toContain("0");
});
it("Synced count excludes local nodes", () => {
const syncedStatus: NodeSettingsSyncStatus = {
lastSyncAt: new Date(Date.now() - 60000).toISOString(),
lastSyncDirection: "push",
localUpdatedAt: new Date().toISOString(),
remoteReachable: true,
diff: { global: [], project: [] },
};
mockUseNodeSettingsSync.mockReturnValue({
syncStatusMap: {
"node-local": syncedStatus,
},
loading: false,
actionLoading: {},
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
trackNode: vi.fn(),
untrackNode: vi.fn(),
pushSettings: vi.fn().mockResolvedValue({ success: true }),
pullSettings: vi.fn().mockResolvedValue({ success: true }),
syncAuth: vi.fn().mockResolvedValue({ success: true, syncedProviders: [] }),
});
mockUseNodes.mockReturnValue(makeUseNodesResult({
nodes: [
makeNode({ id: "node-local", name: "Local Node", type: "local", status: "online" }),
],
}));
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
// Local nodes should not be counted in synced
expect(screen.getByTestId("nodes-stat-synced").textContent).toContain("0");
expect(screen.getByTestId("nodes-stat-remote").textContent).toContain("0");
});
it("passes syncStatus to NodeCard components for remote nodes", () => {
const syncedStatus: NodeSettingsSyncStatus = {
lastSyncAt: new Date(Date.now() - 60000).toISOString(),
lastSyncDirection: "push",
localUpdatedAt: new Date().toISOString(),
remoteReachable: true,
diff: { global: [], project: [] },
};
mockUseNodeSettingsSync.mockReturnValue({
syncStatusMap: {
"node-remote": syncedStatus,
},
loading: false,
actionLoading: {},
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
trackNode: vi.fn(),
untrackNode: vi.fn(),
pushSettings: vi.fn().mockResolvedValue({ success: true }),
pullSettings: vi.fn().mockResolvedValue({ success: true }),
syncAuth: vi.fn().mockResolvedValue({ success: true, syncedProviders: [] }),
});
mockUseNodes.mockReturnValue(makeUseNodesResult({
nodes: [
makeNode({ id: "node-local", name: "Local Node", type: "local", status: "online" }),
makeNode({ id: "node-remote", name: "Remote Node", type: "remote", status: "online" }),
],
}));
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
// Remote node card should have sync indicator
const remoteNodeCard = document.querySelector('[data-node-id="node-remote"]');
expect(remoteNodeCard).toBeInTheDocument();
const syncIndicator = remoteNodeCard?.querySelector('[data-testid="node-card-sync"]');
expect(syncIndicator).toBeInTheDocument();
expect(syncIndicator).toHaveAttribute("data-sync-state", "synced");
// Local node card should not have sync indicator
const localNodeCard = document.querySelector('[data-node-id="node-local"]');
expect(localNodeCard).toBeInTheDocument();
const localSyncIndicator = localNodeCard?.querySelector('[data-testid="node-card-sync"]');
expect(localSyncIndicator).not.toBeInTheDocument();
});
});
});

View File

@@ -9,6 +9,94 @@ import {
type NodeAuthSyncResult,
} from "../api-node";
// ── Sync State Utilities ───────────────────────────────────────────────────────
/** Derived sync state computed from raw sync status data */
export type SyncState = "synced" | "pending" | "diff" | "error" | "never-synced";
/** Computed sync status with derived state for UI consumption */
export interface ComputedNodeSyncStatus {
syncState: SyncState;
lastSyncAt: string | null;
diffCount: number;
}
/**
* Compute the derived sync state from raw NodeSettingsSyncStatus data.
* - never-synced: lastSyncAt is null (never been synced)
* - error: remote node is unreachable
* - diff: there are differences between local and remote
* - synced: no differences and lastSyncAt is set
* - pending: lastSyncAt is set, remote is reachable, but we don't have diff data yet
*/
export function computeSyncState(status: NodeSettingsSyncStatus): ComputedNodeSyncStatus {
const { lastSyncAt, remoteReachable, diff } = status;
const diffCount = diff.global.length + diff.project.length;
if (lastSyncAt === null) {
return { syncState: "never-synced", lastSyncAt, diffCount: 0 };
}
if (!remoteReachable) {
return { syncState: "error", lastSyncAt, diffCount };
}
if (diffCount > 0) {
return { syncState: "diff", lastSyncAt, diffCount };
}
return { syncState: "synced", lastSyncAt, diffCount: 0 };
}
/**
* Format a relative time string from an ISO timestamp.
* Returns "Synced Xm ago", "Synced Xh ago", "Synced Xd ago", or "Never synced".
*/
export function formatRelativeTime(isoTimestamp: string | null): string {
if (isoTimestamp === null) {
return "Never synced";
}
const date = new Date(isoTimestamp);
if (Number.isNaN(date.getTime())) {
return "Never synced";
}
const now = Date.now();
const diffMs = now - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffMin < 1) {
return "Synced just now";
}
if (diffMin < 60) {
return `Synced ${diffMin}m ago`;
}
if (diffHr < 24) {
return `Synced ${diffHr}h ago`;
}
return `Synced ${diffDay}d ago`;
}
/** Get the CSS color variable for a sync state */
export function getSyncStateColor(state: SyncState): string {
switch (state) {
case "synced":
return "var(--color-success)";
case "pending":
return "var(--warning)";
case "diff":
return "var(--warning)";
case "error":
return "var(--color-error)";
case "never-synced":
return "var(--text-muted)";
}
}
export interface UseNodeSettingsSyncResult {
/** Per-node sync status keyed by nodeId */
syncStatusMap: Record<string, NodeSettingsSyncStatus>;

View File

@@ -28216,6 +28216,90 @@ html .column.drag-over * {
font-weight: 600;
}
/* === NodeCard Sync Indicator === */
.node-card__sync {
display: flex;
align-items: center;
gap: 6px;
margin-top: var(--space-sm);
font-size: 12px;
color: var(--text-muted);
}
.node-card__sync-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.node-card__sync-time {
color: var(--text-muted);
}
/* === NodeDetailModal Sync Section === */
.node-detail-modal__sync-status {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.node-detail-modal__sync-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.node-detail-modal__sync-diff {
color: var(--warning);
font-size: 12px;
}
.node-detail-modal__sync-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.node-detail-modal__sync-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
margin-top: var(--space-sm);
padding: var(--space-sm);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
border-radius: var(--radius-sm);
color: var(--color-error);
font-size: 12px;
}
.node-detail-modal__sync-error-dismiss {
background: transparent;
border: none;
color: var(--color-error);
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
}
.node-detail-modal__sync-error-dismiss:hover {
background: color-mix(in srgb, var(--color-error) 20%, transparent);
}
/* === NodesView Synced Stat === */
.nodes-view-stat--synced strong {
color: var(--color-success);
}
.node-card__actions {
display: flex;
gap: var(--space-xs);