feat(FN-1825): merge fusion/fn-1825
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user