import { memo, useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; 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; managedDockerNode?: ManagedDockerNodeInfo; } function getStatusConfig(t: ReturnType["t"]): Record { return { online: { label: t("nodes.status.online", "Online"), color: "var(--color-success)", className: "node-card__status--online" }, offline: { label: t("nodes.status.offline", "Offline"), color: "var(--color-error)", className: "node-card__status--offline" }, connecting: { label: t("nodes.status.connecting", "Connecting"), color: "var(--color-warning)", className: "node-card__status--connecting" }, error: { label: t("nodes.status.error", "Error"), color: "var(--color-error)", className: "node-card__status--error" }, creating: { label: t("nodes.status.creating", "Creating"), color: "var(--color-warning)", className: "node-card__status--creating" }, recreating: { label: t("nodes.status.recreating", "Recreating"), color: "var(--color-warning)", className: "node-card__status--recreating" }, deleting: { label: t("nodes.status.deleting", "Deleting"), color: "var(--color-error)", className: "node-card__status--deleting" }, running: { label: t("nodes.status.running", "Running"), color: "var(--color-success)", className: "node-card__status--online" }, stopped: { label: t("nodes.status.stopped", "Stopped"), color: "var(--color-error)", className: "node-card__status--offline" }, exited: { label: t("nodes.status.exited", "Exited"), color: "var(--color-error)", className: "node-card__status--offline" }, }; } const AUTH_SYNC_COLORS: Record = { match: "var(--color-success)", differs: "var(--color-warning)", "not-synced": "var(--text-muted)", }; function buildAuthTooltip( state: "match" | "differs" | "not-synced", providers?: Record, t?: ReturnType["t"], ): string { if (state === "match") return t ? t("nodes.auth.match", "Auth credentials match") : "Auth credentials match"; if (state === "not-synced") return t ? t("nodes.auth.notSynced", "Auth not synced") : "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 t ? t("nodes.auth.differProviders", "Auth credentials differ: {{providers}}", { providers: differing.join(", ") }) : `Auth credentials differ: ${differing.join(", ")}`; } } return t ? t("nodes.auth.differ", "Auth credentials differ") : "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 { t } = useTranslation("app"); const [removeArmed, setRemoveArmed] = useState(false); const statusConfigMap = getStatusConfig(t); const statusConfig = statusConfigMap[node.status] ?? statusConfigMap.offline; const dockerStatusConfig = managedDockerNode ? (statusConfigMap[managedDockerNode.status] ?? statusConfigMap.error) : null; const dockerHost = managedDockerNode?.hostConfig.type === "remote" ? t("nodes.dockerHost.remote", "Remote: {{host}}", { host: managedDockerNode.hostConfig.host ?? "unknown" }) : t("nodes.dockerHost.local", "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 (

{node.name}

{node.type === "local" ? t("nodes.type.local", "Local") : t("nodes.type.remote", "Remote")} {managedDockerNode && ( {t("nodes.dockerLabel", "Docker")} )} {statusConfig.label} {managedDockerNode && dockerStatusConfig && ( {dockerStatusConfig.label} )} {node.type === "remote" && authSyncState && ( )}
{node.type === "remote" && node.url && (
{truncateUrl(node.url)}
)} {managedDockerNode && (
{managedDockerNode.imageName}:{managedDockerNode.imageTag} {dockerHost}
)}
{t("nodes.metrics.projects", "Projects")} {assignedProjectCount}
{t("nodes.metrics.concurrency", "Concurrency")} {node.maxConcurrent}
{/* Sync status indicator — only for remote nodes with sync data */} {node.type === "remote" && syncStatus && (
{formatRelativeTime(syncStatus.lastSyncAt)}
)}
); } export const NodeCard = memo(NodeCardInner, areNodeCardPropsEqual);