import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Activity, Download, 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 } from "../hooks/useNodeSettingsSync"; import { SettingsSyncLog } from "./SettingsSyncLog"; import type { SyncLogEntry } from "./SettingsSyncLog"; import { SettingsSyncConflictModal } from "./SettingsSyncConflictModal"; import type { SettingsConflictEntry, ConflictResolutionResult } from "./SettingsSyncConflictModal"; import "./NodeDetailModal.css"; interface NodeDetailModalProps { isOpen: boolean; onClose: () => void; node: NodeInfo | null; projects: ProjectInfo[]; onUpdate: (id: string, updates: NodeUpdateInput) => Promise; onHealthCheck: (id: string) => Promise; addToast: (message: string, type?: ToastType) => void; syncStatus?: ComputedNodeSyncStatus; onPushSettings?: (nodeId: string) => Promise; onPullSettings?: (nodeId: string) => Promise; onSyncAuth?: (nodeId: string) => Promise; /** Sync history entries for this node */ syncHistory?: SyncLogEntry[]; /** Called when sync conflicts need resolution */ onResolveConflicts?: (resolutions: ConflictResolutionResult[]) => Promise; } function formatTimestamp(value?: string): string { if (!value) return "—"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "—"; return date.toLocaleString(); } function getSyncStateDotClass(syncState: ComputedNodeSyncStatus["syncState"]): string { switch (syncState) { case "synced": return "node-detail-modal__sync-dot--synced"; case "diff": return "node-detail-modal__sync-dot--diff"; case "error": return "node-detail-modal__sync-dot--error"; case "pending": return "node-detail-modal__sync-dot--pending"; case "never-synced": default: return "node-detail-modal__sync-dot--never"; } } export function NodeDetailModal({ isOpen, onClose, node, projects, onUpdate, onHealthCheck, addToast, syncStatus, onPushSettings, onPullSettings, onSyncAuth, syncHistory = [], onResolveConflicts, }: NodeDetailModalProps) { const isMountedRef = useRef(true); const [editMode, setEditMode] = useState(false); const [name, setName] = useState(""); const [url, setUrl] = useState(""); const [apiKey, setApiKey] = useState(""); 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(null); // Conflict resolution modal state const [showConflictModal, setShowConflictModal] = useState(false); const [conflicts] = useState([]); const [dockerStatus, setDockerStatus] = useState<"running" | "stopped" | "recreating">("running"); const [dockerEnv, setDockerEnv] = useState("FUSION_LOG_LEVEL=info"); const [dockerMounts, setDockerMounts] = useState("/srv/fusion:/data:rw"); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); useEffect(() => { if (!node || !isOpen) { setEditMode(false); return; } setName(node.name); setUrl(node.url ?? ""); setApiKey(node.apiKey ?? ""); setMaxConcurrent(node.maxConcurrent); setEditMode(false); }, [isOpen, node]); useEffect(() => { if (!isOpen) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); onClose(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [isOpen, onClose]); const assignedProjects = useMemo(() => { if (!node) return []; return getProjectsForNode(projects, node); }, [node, projects]); const handleHealthCheck = useCallback(async () => { if (!node) return; try { await onHealthCheck(node.id); if (!isMountedRef.current) return; addToast(`Health check completed for ${node.name}`, "success"); } catch (error) { if (!isMountedRef.current) return; const message = error instanceof Error ? error.message : "Health check failed"; addToast(message, "error"); } }, [addToast, node, onHealthCheck]); const handlePushSettings = useCallback(async () => { if (!node || !onPushSettings) return; setSyncError(null); setIsPushing(true); try { await onPushSettings(node.id); if (!isMountedRef.current) return; addToast("Settings pushed successfully", "success"); } catch (error) { if (!isMountedRef.current) return; const message = error instanceof Error ? error.message : "Push settings failed"; setSyncError(message); addToast(message, "error"); } finally { if (isMountedRef.current) { setIsPushing(false); } } }, [addToast, node, onPushSettings]); const handlePullSettings = useCallback(async () => { if (!node || !onPullSettings) return; setSyncError(null); setIsPulling(true); try { await onPullSettings(node.id); if (!isMountedRef.current) return; addToast("Settings pulled successfully", "success"); } catch (error) { if (!isMountedRef.current) return; const message = error instanceof Error ? error.message : "Pull settings failed"; setSyncError(message); addToast(message, "error"); } finally { if (isMountedRef.current) { setIsPulling(false); } } }, [addToast, node, onPullSettings]); const handleSyncAuth = useCallback(async () => { if (!node || !onSyncAuth) return; setSyncError(null); setIsSyncingAuth(true); try { await onSyncAuth(node.id); if (!isMountedRef.current) return; addToast("Auth credentials synced successfully", "success"); } catch (error) { if (!isMountedRef.current) return; const message = error instanceof Error ? error.message : "Auth sync failed"; setSyncError(message); addToast(message, "error"); } finally { if (isMountedRef.current) { setIsSyncingAuth(false); } } }, [addToast, node, onSyncAuth]); const handleDismissSyncError = useCallback(() => { setSyncError(null); }, []); const handleDockerLifecycle = useCallback((action: "start" | "stop" | "restart" | "recreate" | "upgrade") => { if (action === "start") setDockerStatus("running"); if (action === "stop") setDockerStatus("stopped"); if (action === "restart") setDockerStatus("running"); if (action === "recreate") setDockerStatus("recreating"); if (action === "upgrade") setDockerStatus("recreating"); addToast(`Docker action queued: ${action}`, "success"); }, [addToast]); const handleSave = useCallback(async () => { if (!node || isSaving) return; const trimmedName = name.trim(); if (!trimmedName) { addToast("Name is required", "error"); return; } if (node.type === "remote" && !url.trim()) { addToast("URL is required for remote nodes", "error"); return; } if (!Number.isFinite(maxConcurrent) || maxConcurrent < 1) { addToast("Concurrency must be at least 1", "error"); return; } setIsSaving(true); try { await onUpdate(node.id, { name: trimmedName, url: node.type === "remote" ? url.trim() || undefined : undefined, apiKey: node.type === "remote" ? apiKey || undefined : undefined, maxConcurrent, }); addToast(`Updated ${trimmedName}`, "success"); setEditMode(false); } catch (error) { const message = error instanceof Error ? error.message : "Failed to update node"; addToast(message, "error"); } finally { setIsSaving(false); } }, [addToast, apiKey, isSaving, maxConcurrent, name, node, onUpdate, url]); const handleCancelEdit = useCallback(() => { if (!node) return; setName(node.name); setUrl(node.url ?? ""); setApiKey(node.apiKey ?? ""); setMaxConcurrent(node.maxConcurrent); setEditMode(false); }, [node]); if (!isOpen || !node) return null; return (
event.stopPropagation()} role="dialog" aria-modal="true" aria-label={`Node details for ${node.name}`} >

Node Details

Overview

{!editMode && ( )}
Type {node.type === "local" ? "Local" : "Remote"}
Status {node.status}
{node.type === "remote" && ( <> )}
Created {formatTimestamp(node.createdAt)}
Updated {formatTimestamp(node.updatedAt)}
{editMode && (
)}

{node.type === "local" ? "Projects" : "Assigned Projects"} ({assignedProjects.length})

{assignedProjects.length === 0 ? (

{node.type === "local" ? "No projects are running on this node." : "No projects are assigned to this node."}

) : (
    {assignedProjects.map((project) => (
  • {project.name} {project.id}
  • ))}
)}

Health

Status: {node.status} Last check: {formatTimestamp(node.updatedAt)}

Docker Management

Container: {dockerStatus} Image: runfusion/fusion:latest