import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Activity, ChevronDown, Download, Eye, EyeOff, FileText, Pencil, Play, RotateCcw, Save, Shield, Square, Upload, X, } from "lucide-react"; import type { ContainerStatusInfo, DockerNodeConfigInfo, ManagedDockerNodeInfo, 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"; const DOCKER_MOUNT_LABELS = { readWrite: "rw", readOnly: "ro", volume: "volume", bind: "bind", } as const; 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; syncHistory?: SyncLogEntry[]; onResolveConflicts?: (resolutions: ConflictResolutionResult[]) => Promise; managedDockerNode?: ManagedDockerNodeInfo; containerStatus?: ContainerStatusInfo; onFetchContainerStatus?: (managedId: string) => Promise; onFetchLogs?: (managedId: string) => Promise; onUpdateDockerConfig?: (nodeId: string, config: Partial) => Promise; onFetchDockerConfigDiff?: (nodeId: string) => Promise<{ persistedVersion: number; deployedVersion: number | null; needsRecreate: boolean }>; } const SENSITIVE_ENV_KEY_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD)/i; function formatTimestamp(value?: string): string { if (!value) return "—"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "—"; return date.toLocaleString(); } function formatDockerUptime(startedAt?: string): string { if (!startedAt) return "—"; const started = new Date(startedAt); const now = Date.now(); if (Number.isNaN(started.getTime()) || started.getTime() > now) return "—"; const seconds = Math.floor((now - started.getTime()) / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ${minutes % 60}m`; const days = Math.floor(hours / 24); return `${days}d ${hours % 24}h`; } 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"; } } function getDockerStatusTone(status?: string): "success" | "warning" | "error" { if (status === "running") return "success"; if (status === "creating" || status === "recreating" || status === "restarting") return "warning"; return "error"; } function getDockerStatusLabel(status?: string): string { if (!status) return "Unknown"; return `${status.charAt(0).toUpperCase()}${status.slice(1)}`; } function parsePortFromReachableUrl(url?: string): string { if (!url) return "—"; try { const parsed = new URL(url); if (parsed.port) return parsed.port; return parsed.protocol === "https:" ? "443" : parsed.protocol === "http:" ? "80" : "—"; } catch { return "—"; } } function maskEnvValue(key: string, value: string): string { return SENSITIVE_ENV_KEY_PATTERN.test(key) ? "••••••••" : value; } export function NodeDetailModal({ isOpen, onClose, node, projects, onUpdate, onHealthCheck, addToast, syncStatus, onPushSettings, onPullSettings, onSyncAuth, syncHistory = [], onResolveConflicts, managedDockerNode, containerStatus, onFetchContainerStatus, onFetchLogs, onUpdateDockerConfig, onFetchDockerConfigDiff, }: NodeDetailModalProps) { const { t } = useTranslation("app"); 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); const [isPushing, setIsPushing] = useState(false); const [isPulling, setIsPulling] = useState(false); const [isSyncingAuth, setIsSyncingAuth] = useState(false); const [syncError, setSyncError] = useState(null); const [showConflictModal, setShowConflictModal] = useState(false); const [conflicts] = useState([]); const [liveContainerStatus, setLiveContainerStatus] = useState(containerStatus); const [isRefreshingContainerStatus, setIsRefreshingContainerStatus] = useState(false); const [logsOpen, setLogsOpen] = useState(false); const [logs, setLogs] = useState(""); const [logsLoading, setLogsLoading] = useState(false); const [dockerConfigExpanded, setDockerConfigExpanded] = useState(false); const [dockerConfigDraft, setDockerConfigDraft] = useState(node?.dockerConfig ?? null); const [dockerEnvReveal, setDockerEnvReveal] = useState>({}); const [dockerConfigSaving, setDockerConfigSaving] = useState(false); const [dockerConfigNeedsRecreate, setDockerConfigNeedsRecreate] = useState(false); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); useEffect(() => { setLiveContainerStatus(containerStatus); }, [containerStatus]); useEffect(() => { if (!node || !isOpen) { setEditMode(false); setLogsOpen(false); setLogs(""); return; } setName(node.name); setUrl(node.url ?? ""); setApiKey(node.apiKey ?? ""); setMaxConcurrent(node.maxConcurrent); setEditMode(false); setDockerConfigDraft(node.dockerConfig ?? null); setDockerConfigExpanded(false); setDockerEnvReveal({}); }, [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 dockerHost = useMemo(() => { if (!managedDockerNode) return "—"; return managedDockerNode.hostConfig.type === "remote" ? managedDockerNode.hostConfig.host ?? "—" : t("nodes.localDocker", "Local Docker"); }, [managedDockerNode, t]); const dockerResourceSizing = useMemo(() => { if (!managedDockerNode?.resourceSizing?.cpuLimit && !managedDockerNode?.resourceSizing?.memoryLimit) { return t("nodes.dockerResourceDefault", "Default"); } return `${managedDockerNode.resourceSizing?.cpuLimit ?? t("nodes.dockerDefaultCpu", "Default CPU")} / ${managedDockerNode.resourceSizing?.memoryLimit ?? t("nodes.dockerDefaultMemory", "Default memory")}`; }, [managedDockerNode, t]); const handleHealthCheck = useCallback(async () => { if (!node) return; try { await onHealthCheck(node.id); if (!isMountedRef.current) return; addToast(t("nodes.healthCheckSuccess", "Health check completed for {{name}}", { name: node.name }), "success"); } catch (error) { if (!isMountedRef.current) return; const message = error instanceof Error ? error.message : t("nodes.healthCheckFailed", "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(t("nodes.pushSettingsSuccess", "Settings pushed successfully"), "success"); } catch (error) { if (!isMountedRef.current) return; const message = error instanceof Error ? error.message : t("nodes.pushSettingsFailed", "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(t("nodes.pullSettingsSuccess", "Settings pulled successfully"), "success"); } catch (error) { if (!isMountedRef.current) return; const message = error instanceof Error ? error.message : t("nodes.pullSettingsFailed", "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(t("nodes.syncAuthSuccess", "Auth credentials synced successfully"), "success"); } catch (error) { if (!isMountedRef.current) return; const message = error instanceof Error ? error.message : t("nodes.syncAuthFailed", "Auth sync failed"); setSyncError(message); addToast(message, "error"); } finally { if (isMountedRef.current) { setIsSyncingAuth(false); } } }, [addToast, node, onSyncAuth]); const handleDismissSyncError = useCallback(() => { setSyncError(null); }, []); const handleRefreshContainerStatus = useCallback(async () => { if (!managedDockerNode || !onFetchContainerStatus) return; setIsRefreshingContainerStatus(true); try { const result = await onFetchContainerStatus(managedDockerNode.id); if (!isMountedRef.current) return; setLiveContainerStatus(result); } catch (error) { const message = error instanceof Error ? error.message : t("nodes.fetchContainerStatusFailed", "Failed to fetch container status"); addToast(message, "error"); } finally { if (isMountedRef.current) { setIsRefreshingContainerStatus(false); } } }, [addToast, managedDockerNode, onFetchContainerStatus]); const handleFetchLogs = useCallback(async () => { if (!managedDockerNode || !onFetchLogs) return; setLogsOpen(true); setLogsLoading(true); try { const result = await onFetchLogs(managedDockerNode.id); if (!isMountedRef.current) return; setLogs(result); } catch (error) { if (!isMountedRef.current) return; setLogs(""); const message = error instanceof Error ? error.message : t("nodes.fetchContainerLogsFailed", "Failed to fetch container logs"); addToast(message, "error"); } finally { if (isMountedRef.current) { setLogsLoading(false); } } }, [addToast, managedDockerNode, onFetchLogs]); const handleSave = useCallback(async () => { if (!node || isSaving) return; const trimmedName = name.trim(); if (!trimmedName) { addToast(t("nodes.nameRequired", "Name is required"), "error"); return; } if (node.type === "remote" && !url.trim()) { addToast(t("nodes.urlRequired", "URL is required for remote nodes"), "error"); return; } if (!Number.isFinite(maxConcurrent) || maxConcurrent < 1) { addToast(t("nodes.concurrencyMin", "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(t("nodes.updateSuccess", "Updated {{name}}", { name: trimmedName }), "success"); setEditMode(false); } catch (error) { const message = error instanceof Error ? error.message : t("nodes.updateFailed", "Failed to update node"); addToast(message, "error"); } finally { setIsSaving(false); } }, [addToast, apiKey, isSaving, maxConcurrent, name, node, onUpdate, url]); useEffect(() => { if (!node?.dockerConfig || !onFetchDockerConfigDiff || !isOpen) return; void onFetchDockerConfigDiff(node.id) .then((diff) => { if (!isMountedRef.current) return; setDockerConfigNeedsRecreate(diff.needsRecreate); }) .catch(() => { if (!isMountedRef.current) return; setDockerConfigNeedsRecreate(false); }); }, [isOpen, node, onFetchDockerConfigDiff]); const handleDockerConfigSave = useCallback(async () => { if (!node || !dockerConfigDraft || !onUpdateDockerConfig || dockerConfigSaving) return; setDockerConfigSaving(true); try { const result = await onUpdateDockerConfig(node.id, { image: dockerConfigDraft.image, volumeMounts: dockerConfigDraft.volumeMounts, environment: dockerConfigDraft.environment, resources: dockerConfigDraft.resources, host: dockerConfigDraft.host, extraClis: dockerConfigDraft.extraClis, persistence: dockerConfigDraft.persistence, containerName: dockerConfigDraft.containerName, }); if (!isMountedRef.current) return; setDockerConfigDraft(result); addToast(t("nodes.dockerConfigSaveSuccess", "Docker config saved"), "success"); } catch (error) { if (!isMountedRef.current) return; const message = error instanceof Error ? error.message : t("nodes.dockerConfigSaveFailed", "Failed to save Docker config"); addToast(message, "error"); } finally { if (isMountedRef.current) setDockerConfigSaving(false); } }, [addToast, dockerConfigDraft, dockerConfigSaving, node, onUpdateDockerConfig]); 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; const effectiveDockerStatus = liveContainerStatus?.status ?? managedDockerNode?.status; const dockerStatusTone = getDockerStatusTone(effectiveDockerStatus); return (
event.stopPropagation()} role="dialog" aria-modal="true" aria-label={t("nodes.modalAriaLabel", "Node details for {{name}}", { name: node.name })} >

{t("nodes.modalTitle", "Node Details")}

{t("nodes.sectionOverview", "Overview")}

{!editMode && ( )}
{t("nodes.fieldType", "Type")} {node.type === "local" ? t("nodes.typeLocal", "Local") : t("nodes.typeRemote", "Remote")}
{t("nodes.fieldStatus", "Status")} {node.status}
{node.type === "remote" && ( <> )}
{t("nodes.fieldCreated", "Created")} {formatTimestamp(node.createdAt)}
{t("nodes.fieldUpdated", "Updated")} {formatTimestamp(node.updatedAt)}
{editMode && (
)}

{node.type === "local" ? t("nodes.sectionProjects", "Projects") : t("nodes.sectionAssignedProjects", "Assigned Projects")} ({assignedProjects.length})

{assignedProjects.length === 0 ? (

{node.type === "local" ? t("nodes.noProjectsRunning", "No projects are running on this node.") : t("nodes.noProjectsAssigned", "No projects are assigned to this node.")}

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

{t("nodes.sectionHealth", "Health")}

{t("nodes.healthStatus", "Status:")} {node.status} {t("nodes.healthLastCheck", "Last check:")} {formatTimestamp(node.updatedAt)}
{dockerConfigDraft && (
{dockerConfigExpanded && (
{t("nodes.dockerVolumeMounts", "Volume Mounts")}
{dockerConfigDraft.volumeMounts.map((mount: DockerNodeConfigInfo["volumeMounts"][number], index: number) => (
{ const next = [...dockerConfigDraft.volumeMounts]; next[index] = { ...next[index], hostPath: event.target.value }; setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: next }); }} /> { const next = [...dockerConfigDraft.volumeMounts]; next[index] = { ...next[index], containerPath: event.target.value }; setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: next }); }} />
))}
{t("nodes.dockerEnvVars", "Environment Variables")}
{Object.entries(dockerConfigDraft.environment as Record).map(([key, value]: [string, string]) => { const masked = SENSITIVE_ENV_KEY_PATTERN.test(key) && !dockerEnvReveal[key]; return (
{ const next = { ...dockerConfigDraft.environment }; delete next[key]; next[event.target.value] = value; setDockerConfigDraft({ ...dockerConfigDraft, environment: next }); }} /> setDockerConfigDraft({ ...dockerConfigDraft, environment: { ...dockerConfigDraft.environment, [key]: event.target.value } })} />
); })}
{t("nodes.dockerResources", "Resources")}
setDockerConfigDraft({ ...dockerConfigDraft, resources: { ...dockerConfigDraft.resources, memoryBytes: event.target.value ? Number(event.target.value) : undefined } })} /> setDockerConfigDraft({ ...dockerConfigDraft, resources: { ...dockerConfigDraft.resources, cpuCount: event.target.value ? Number(event.target.value) : undefined } })} /> setDockerConfigDraft({ ...dockerConfigDraft, resources: { ...dockerConfigDraft.resources, pidsLimit: event.target.value ? Number(event.target.value) : undefined } })} />
{t("nodes.dockerHostConfig", "Host Config")}
setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, contextName: event.target.value } })} /> setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, dockerHost: event.target.value } })} /> setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, tlsCaCert: event.target.value } })} /> setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, tlsCert: event.target.value } })} /> setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, tlsKey: event.target.value } })} />
{t("nodes.dockerExtraClis", "Extra CLIs")}
{(dockerConfigDraft.extraClis ?? []).map((cli: string, index: number) => (
{ const next = [...(dockerConfigDraft.extraClis ?? [])]; next[index] = event.target.value; setDockerConfigDraft({ ...dockerConfigDraft, extraClis: next }); }} />
))}
{t("nodes.dockerPersistence", "Persistence")}
setDockerConfigDraft({ ...dockerConfigDraft, persistence: { ...dockerConfigDraft.persistence, volumeName: event.target.value } })} />
{t("nodes.dockerConfigMeta", "Config v{{version}} • Updated {{updated}}", { version: dockerConfigDraft.configVersion, updated: formatRelativeTime(dockerConfigDraft.lastUpdated ?? node.updatedAt) })} {dockerConfigNeedsRecreate && {t("nodes.dockerNeedsRecreate", "Needs Recreate")}}
)}
)} {managedDockerNode && (

{t("nodes.sectionDockerManagement", "Docker Management")}

{effectiveDockerStatus ? getDockerStatusLabel(effectiveDockerStatus) : t("nodes.dockerStatusUnknown", "Unknown")} {(effectiveDockerStatus === "creating" || effectiveDockerStatus === "recreating" || effectiveDockerStatus === "restarting") && ( )}
{effectiveDockerStatus === "running" && {t("nodes.dockerUptime", "Uptime:")} {formatDockerUptime(liveContainerStatus?.startedAt)}} {effectiveDockerStatus !== "running" && liveContainerStatus?.exitCode !== undefined && ( {t("nodes.dockerExitCode", "Exit code:")} {liveContainerStatus.exitCode} )} {(liveContainerStatus?.error || managedDockerNode.errorMessage) && ( {liveContainerStatus?.error ?? managedDockerNode.errorMessage} )}
{t("nodes.dockerFieldImage", "Image")}{managedDockerNode.imageName}:{managedDockerNode.imageTag}
{t("nodes.dockerContainerId", "Container ID")}{managedDockerNode.containerId ? managedDockerNode.containerId.slice(0, 12) : "—"}
{t("nodes.dockerHost", "Host")}{dockerHost}
{t("nodes.dockerPersistentStorage", "Persistent Storage")}{managedDockerNode.persistentStorage ? t("nodes.yes", "Yes") : t("nodes.no", "No")}
{t("nodes.dockerPort", "Port")}{parsePortFromReachableUrl(managedDockerNode.reachableUrl)}
{t("nodes.dockerResourceSizing", "Resource Sizing")}{dockerResourceSizing}
{logsOpen && (
{t("nodes.containerLogs", "Container Logs")}
{logsLoading ? (

{t("nodes.fetchingLogs", "Fetching logs...")}

) : (
{logs.trim() || t("nodes.noLogsAvailable", "No logs available")}
)}
)}
{t("nodes.dockerEnvVars", "Environment Variables")}
{Object.entries(managedDockerNode.envVars).map(([key, value]) => (
{key}
{maskEnvValue(key, value)}
))}
{t("nodes.dockerVolumeMounts", "Volume Mounts")}
    {managedDockerNode.volumeMounts.map((mount) => (
  • {mount.hostPath} → {mount.containerPath} {mount.readOnly && {t("nodes.readOnly", "Read-only")}}
  • ))}
)} {node.type === "remote" && (

{t("nodes.sectionSettingsSync", "Settings Sync")}

{syncStatus && (
{t("nodes.syncLastSync", "Last sync:")} {syncStatus.lastSyncAt ? formatRelativeTime(syncStatus.lastSyncAt) : t("nodes.syncNeverSynced", "Never synced")} {syncStatus.diffCount > 0 && ( {t("nodes.syncDifferences", "Differences:")} {syncStatus.diffCount} )}
)}
{syncError && (
{syncError}
)}
)} {node.type === "remote" && (

{t("nodes.sectionSyncHistory", "Sync History")}

)}
{node.type === "remote" && ( setShowConflictModal(false)} onResolve={onResolveConflicts ?? (async () => {})} conflicts={conflicts} localNodeName={t("nodes.localNodeName", "Local")} remoteNodeName={node.name} addToast={addToast} /> )}
); }