import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Activity, Download, FileText, Pencil, Play, RotateCcw, Save, Shield, Square, Upload, X, } from "lucide-react"; import type { ContainerStatusInfo, 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"; 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; } 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, }: 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); 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); 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); }, [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 ?? "—" : "Local Docker"; }, [managedDockerNode]); const dockerResourceSizing = useMemo(() => { if (!managedDockerNode?.resourceSizing?.cpuLimit && !managedDockerNode?.resourceSizing?.memoryLimit) { return "Default"; } return `${managedDockerNode.resourceSizing?.cpuLimit ?? "Default CPU"} / ${managedDockerNode.resourceSizing?.memoryLimit ?? "Default memory"}`; }, [managedDockerNode]); 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 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 : "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 : "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("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; const effectiveDockerStatus = liveContainerStatus?.status ?? managedDockerNode?.status; const dockerStatusTone = getDockerStatusTone(effectiveDockerStatus); 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)}
{managedDockerNode && (

Docker Management

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

Fetching logs...

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

Settings Sync

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

Sync History

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