Files
fusion/packages/dashboard/app/components/NodeCard.tsx
Fusion cc5c8c668b feat(FN-3112): tokenize NodesView border-radius and motion styles
Fixes FN-3112: Updates NodesView CSS to use tokenized border-radius and transition values from the design system instead of hardcoded pixel values, ensuring consistency with the dashboard's established token variables.

Fusion-Task-Id: FN-3112
2026-05-04 06:12:12 -07:00

359 lines
13 KiB
TypeScript

import { memo, useCallback, useMemo, useState } from "react";
import { Activity, Box, Play, RotateCw, Server, Settings, Shield, Square, Trash2 } from "lucide-react";
import type { ManagedDockerNodeInfo, 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;
projects: ProjectInfo[];
onHealthCheck: (id: string) => void;
onEdit: (node: NodeInfo) => void;
onRemove: (id: string) => void;
isLoading?: boolean;
syncStatus?: ComputedNodeSyncStatus;
/** Auth credential sync state for this node. Only meaningful for remote nodes. */
authSyncState?: "match" | "differs" | "not-synced";
/** Per-provider auth match details for tooltip. Map of provider name (e.g. "anthropic") to its match status. */
authSyncProviders?: Record<string, "match" | "differs">;
managedDockerNode?: ManagedDockerNodeInfo;
}
const STATUS_CONFIG: Record<string, { label: string; color: string; className: string }> = {
online: { label: "Online", color: "var(--color-success)", className: "node-card__status--online" },
offline: { label: "Offline", color: "var(--color-error)", className: "node-card__status--offline" },
connecting: { label: "Connecting", color: "var(--color-warning)", className: "node-card__status--connecting" },
error: { label: "Error", color: "var(--color-error)", className: "node-card__status--error" },
creating: { label: "Creating", color: "var(--color-warning)", className: "node-card__status--creating" },
recreating: { label: "Recreating", color: "var(--color-warning)", className: "node-card__status--recreating" },
deleting: { label: "Deleting", color: "var(--color-error)", className: "node-card__status--deleting" },
running: { label: "Running", color: "var(--color-success)", className: "node-card__status--online" },
stopped: { label: "Stopped", color: "var(--color-error)", className: "node-card__status--offline" },
exited: { label: "Exited", color: "var(--color-error)", className: "node-card__status--offline" },
};
const AUTH_SYNC_COLORS: Record<string, string> = {
match: "var(--color-success)",
differs: "var(--color-warning)",
"not-synced": "var(--text-muted)",
};
function buildAuthTooltip(
state: "match" | "differs" | "not-synced",
providers?: Record<string, "match" | "differs">,
): string {
if (state === "match") return "Auth credentials match";
if (state === "not-synced") return "Auth not synced";
// state === "differs"
if (providers && Object.keys(providers).length > 0) {
const differing = Object.entries(providers)
.filter(([, status]) => status === "differs")
.map(([name]) => name);
if (differing.length > 0) {
return `Auth credentials differ: ${differing.join(", ")}`;
}
}
return "Auth credentials differ";
}
function truncateUrl(url: string, maxLength: number = 42): string {
if (url.length <= maxLength) return url;
return `${url.slice(0, maxLength - 3)}...`;
}
function areNodeCardPropsEqual(previous: NodeCardProps, next: NodeCardProps): boolean {
const prevNode = previous.node;
const nextNode = next.node;
if (prevNode.id !== nextNode.id) return false;
if (prevNode.name !== nextNode.name) return false;
if (prevNode.type !== nextNode.type) return false;
if (prevNode.url !== nextNode.url) return false;
if (prevNode.status !== nextNode.status) return false;
if (prevNode.maxConcurrent !== nextNode.maxConcurrent) return false;
if (prevNode.updatedAt !== nextNode.updatedAt) return false;
if (previous.isLoading !== next.isLoading) return false;
const prevDocker = previous.managedDockerNode;
const nextDocker = next.managedDockerNode;
if (!!prevDocker !== !!nextDocker) return false;
if (prevDocker && nextDocker) {
if (prevDocker.id !== nextDocker.id) return false;
if (prevDocker.status !== nextDocker.status) return false;
if (prevDocker.imageTag !== nextDocker.imageTag) return false;
if (prevDocker.updatedAt !== nextDocker.updatedAt) 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 auth sync state
if (previous.authSyncState !== next.authSyncState) return false;
// Shallow compare authSyncProviders
const prevProviders = previous.authSyncProviders;
const nextProviders = next.authSyncProviders;
if (prevProviders === nextProviders) {
// same ref or both undefined - equal
} else if (!prevProviders || !nextProviders) {
return false; // one defined, one not
} else {
const prevKeys = Object.keys(prevProviders);
const nextKeys = Object.keys(nextProviders);
if (prevKeys.length !== nextKeys.length) return false;
if (prevKeys.some((k) => prevProviders[k] !== nextProviders[k])) return false;
}
// Compare project counts using the canonical counting function
const previousCount = getProjectCountForNode(previous.projects, prevNode);
const nextCount = getProjectCountForNode(next.projects, nextNode);
return previousCount === nextCount;
}
function NodeCardInner({
node,
projects,
onHealthCheck,
onEdit,
onRemove,
isLoading = false,
syncStatus,
authSyncState,
authSyncProviders,
managedDockerNode,
}: NodeCardProps) {
const [removeArmed, setRemoveArmed] = useState(false);
const statusConfig = STATUS_CONFIG[node.status] ?? STATUS_CONFIG.offline;
const dockerStatusConfig = managedDockerNode ? (STATUS_CONFIG[managedDockerNode.status] ?? STATUS_CONFIG.error) : null;
const dockerHost = managedDockerNode?.hostConfig.type === "remote"
? `Remote: ${managedDockerNode.hostConfig.host ?? "unknown"}`
: "Local Docker";
const assignedProjectCount = useMemo(() => {
return getProjectCountForNode(projects, node);
}, [projects, node]);
const handleOpenDetails = useCallback(() => {
onEdit(node);
}, [onEdit, node]);
const handleHealthCheck = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
onHealthCheck(node.id);
}, [onHealthCheck, node.id]);
const handleEdit = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
onEdit(node);
}, [onEdit, node]);
const handleRemove = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
if (!removeArmed) {
setRemoveArmed(true);
return;
}
onRemove(node.id);
setRemoveArmed(false);
}, [removeArmed, onRemove, node.id]);
const handleCardKeyDown = useCallback((event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onEdit(node);
}
}, [onEdit, node]);
return (
<article
className={`node-card ${isLoading ? "node-card--loading" : ""}`}
data-node-id={node.id}
role="button"
tabIndex={0}
onClick={handleOpenDetails}
onKeyDown={handleCardKeyDown}
>
<header className="node-card__header">
<div className="node-card__title-wrap">
<div className="node-card__icon">
<Server size={18} />
</div>
<div>
<h3 className="node-card__name" title={node.name}>{node.name}</h3>
<div className="node-card__meta-row">
<span className="node-card__type-badge">{node.type === "local" ? "Local" : "Remote"}</span>
{managedDockerNode && (
<span className="node-card__docker-badge" title="Managed Docker node">
<Box size={12} aria-hidden />
Docker
</span>
)}
<span
className={`node-card__status ${statusConfig.className}`}
style={{ color: statusConfig.color }}
data-status={node.status}
>
<span className="node-card__status-indicator" style={{ backgroundColor: statusConfig.color }} aria-hidden />
{statusConfig.label}
</span>
{managedDockerNode && dockerStatusConfig && (
<span
className={`node-card__status ${dockerStatusConfig.className}`}
style={{ color: dockerStatusConfig.color }}
data-status={managedDockerNode.status}
>
<span className="node-card__status-indicator" style={{ backgroundColor: dockerStatusConfig.color }} aria-hidden />
{dockerStatusConfig.label}
</span>
)}
{node.type === "remote" && authSyncState && (
<span
className={`node-card__auth-indicator node-card__auth-indicator--${authSyncState}`}
title={buildAuthTooltip(authSyncState, authSyncProviders)}
aria-label={`Auth sync: ${authSyncState === "match" ? "credentials match" : authSyncState === "differs" ? "credentials differ" : "not synced"}`}
style={{ color: AUTH_SYNC_COLORS[authSyncState] }}
>
<Shield size={14} />
</span>
)}
</div>
</div>
</div>
</header>
<div className="node-card__body">
{node.type === "remote" && node.url && (
<div className="node-card__url" title={node.url}>
{truncateUrl(node.url)}
</div>
)}
{managedDockerNode && (
<div className="node-card__docker-meta">
<span title={`${managedDockerNode.imageName}:${managedDockerNode.imageTag}`}>
{managedDockerNode.imageName}:{managedDockerNode.imageTag}
</span>
<span title={dockerHost}>{dockerHost}</span>
</div>
)}
<div className="node-card__metrics">
<div className="node-card__metric">
<span className="node-card__metric-label">Projects</span>
<span className="node-card__metric-value">{assignedProjectCount}</span>
</div>
<div className="node-card__metric">
<span className="node-card__metric-label">Concurrency</span>
<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">
<button
className="btn btn-sm node-card__action"
type="button"
onClick={handleHealthCheck}
disabled={isLoading}
aria-label="Run node health check"
title="Health Check"
>
<Activity size={14} />
<span>Health</span>
</button>
<button
className="btn btn-sm node-card__action"
type="button"
onClick={handleEdit}
disabled={isLoading}
aria-label="Edit node"
title="Edit"
>
<Settings size={14} />
<span>Edit</span>
</button>
{managedDockerNode && (
<>
<button
className="btn btn-sm node-card__action"
type="button"
disabled
aria-label="Start node container"
title="Available after FN-3113"
>
<Play size={14} />
<span>Start</span>
</button>
<button
className="btn btn-sm node-card__action"
type="button"
disabled
aria-label="Stop node container"
title="Available after FN-3113"
>
<Square size={14} />
<span>Stop</span>
</button>
<button
className="btn btn-sm node-card__action"
type="button"
disabled
aria-label="Restart node container"
title="Available after FN-3113"
>
<RotateCw size={14} />
<span>Restart</span>
</button>
</>
)}
<button
className={`btn btn-sm node-card__action node-card__action--remove ${removeArmed ? "btn-danger is-armed" : ""}`}
type="button"
onClick={handleRemove}
disabled={isLoading}
aria-label={removeArmed ? "Confirm remove node" : "Remove node"}
title={removeArmed ? "Confirm remove" : "Remove"}
>
<Trash2 size={14} />
<span>{removeArmed ? "Confirm" : "Remove"}</span>
</button>
</footer>
</article>
);
}
export const NodeCard = memo(NodeCardInner, areNodeCardPropsEqual);