diff --git a/src/app/projects/[projectId]/environments/[environmentId]/components/import/ImportFromSyncModal.tsx b/src/app/projects/[projectId]/environments/[environmentId]/components/import/ImportFromSyncModal.tsx new file mode 100644 index 0000000..ddb599c --- /dev/null +++ b/src/app/projects/[projectId]/environments/[environmentId]/components/import/ImportFromSyncModal.tsx @@ -0,0 +1,463 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + CheckSquare, + CloudSyncIcon, + Info, + Loader2, + RefreshCw, + Square, + X, +} from "lucide-react"; +import { containers as containersApi, type Container } from "@/lib/api"; + +interface ImportFromSyncModalProps { + environmentId: string; + serverId: string; + serverName: string; + environmentName: string; + onClose: () => void; + onAssign: (containerIds: string[]) => Promise; +} + +function formatStatus(value: string) { + return value.charAt(0) + value.slice(1).toLowerCase(); +} + +function formatPorts(ports: string[]) { + if (ports.length === 0) return "internal only"; + return ports.join(", "); +} + +export default function ImportFromSyncModal({ + environmentId, + serverId, + serverName, + environmentName, + onClose, + onAssign, +}: ImportFromSyncModalProps) { + const [serverContainers, setServerContainers] = useState([]); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [assigningContainerId, setAssigningContainerId] = useState< + string | null + >(null); + const [error, setError] = useState(""); + const [syncNotice, setSyncNotice] = useState(""); + + const assignedContainerIds = useMemo( + () => + serverContainers + .filter((container) => container.environmentId === environmentId) + .map((container) => container.id), + [environmentId, serverContainers], + ); + const unassignedCount = serverContainers.filter( + (container) => !container.environmentId, + ).length; + + const loadContainers = useCallback( + async (syncFirst = false) => { + if (syncFirst) { + setSyncing(true); + } else { + setLoading(true); + } + setError(""); + setSyncNotice(""); + + try { + const response = syncFirst + ? await containersApi.sync({ serverId }) + : await containersApi.list({ serverId }); + setServerContainers(response.data ?? []); + } catch (loadError) { + if (!syncFirst) { + setServerContainers([]); + setError( + loadError instanceof Error + ? loadError.message + : "Container sync data could not be loaded.", + ); + return; + } + + try { + const fallbackResponse = await containersApi.list({ serverId }); + setServerContainers(fallbackResponse.data ?? []); + setSyncNotice( + loadError instanceof Error + ? loadError.message + : "Live Docker sync failed. Showing saved server inventory.", + ); + } catch (fallbackError) { + setServerContainers([]); + setError( + fallbackError instanceof Error + ? fallbackError.message + : "Container sync data could not be loaded.", + ); + } + } finally { + setLoading(false); + setSyncing(false); + } + }, + [serverId], + ); + + useEffect(() => { + const frame = window.requestAnimationFrame(() => { + void loadContainers(true); + }); + + return () => window.cancelAnimationFrame(frame); + }, [loadContainers]); + + const handleAssign = async (container: Container) => { + setAssigningContainerId(container.id); + setError(""); + + try { + await onAssign( + Array.from(new Set([...assignedContainerIds, container.id])), + ); + setServerContainers((current) => + current.map((item) => + item.id === container.id ? { ...item, environmentId } : item, + ), + ); + } catch (assignError) { + setError( + assignError instanceof Error + ? assignError.message + : "Container could not be assigned to this environment.", + ); + } finally { + setAssigningContainerId(null); + } + }; + + return ( +
+
event.stopPropagation()} + style={{ width: "100%", maxWidth: 760, padding: 24 }} + > +
+
+

+ Import from Sync +

+

+ Sync inventory from {serverName}, then assign containers into{" "} + {environmentName}. +

+
+ +
+ +
+ {[ + { label: "Synced Containers", value: serverContainers.length }, + { label: "Unassigned", value: unassignedCount }, + { label: "Assigned Here", value: assignedContainerIds.length }, + ].map((item) => ( +
+

+ {item.label} +

+

+ {item.value} +

+
+ ))} +
+ +
+
+ + Services / Containers +
+ +
+ + {syncNotice ? ( +
+ {syncNotice} +
+ ) : null} + +
+ {loading ? ( +
+ + Loading synced containers +
+ ) : null} + + {!loading && serverContainers.length > 0 + ? serverContainers.map((container) => { + const assignedHere = container.environmentId === environmentId; + const assignedElsewhere = + Boolean(container.environmentId) && !assignedHere; + const assigning = assigningContainerId === container.id; + + return ( +
+ {assignedHere ? ( + + ) : ( + + )} + + + {container.name} + + + {container.image} + + + {formatStatus(container.status)} -{" "} + {formatPorts(container.ports)} + + + {assignedHere ? ( + + {formatStatus(container.status)} + + ) : ( + + )} +
+ ); + }) + : null} + + {!loading && serverContainers.length === 0 ? ( +
+ + No synced containers found on this server. +
+ ) : null} +
+ + {error ? ( +
+ {error} +
+ ) : null} +
+
+ ); +} diff --git a/src/app/projects/[projectId]/environments/[environmentId]/components/toolbar/EnvironmentContainersToolbar.tsx b/src/app/projects/[projectId]/environments/[environmentId]/components/toolbar/EnvironmentContainersToolbar.tsx index afecba4..1377991 100644 --- a/src/app/projects/[projectId]/environments/[environmentId]/components/toolbar/EnvironmentContainersToolbar.tsx +++ b/src/app/projects/[projectId]/environments/[environmentId]/components/toolbar/EnvironmentContainersToolbar.tsx @@ -19,6 +19,7 @@ interface EnvironmentContainersToolbarProps { onSync: () => void | Promise; onDeployContainer: () => void; onDeployDatabase: () => void; + onImportFromSync: () => void; } export default function EnvironmentContainersToolbar({ @@ -30,6 +31,7 @@ export default function EnvironmentContainersToolbar({ onSync, onDeployContainer, onDeployDatabase, + onImportFromSync, }: EnvironmentContainersToolbarProps) { const [deployMenuOpen, setDeployMenuOpen] = useState(false); const deployMenuRef = useRef(null); @@ -60,6 +62,11 @@ export default function EnvironmentContainersToolbar({ onDeployDatabase(); }; + const handleImportFromSync = () => { + setDeployMenuOpen(false); + onImportFromSync(); + }; + return (
- Import form Sync + Import from Sync ) : null} diff --git a/src/app/projects/[projectId]/environments/[environmentId]/page.tsx b/src/app/projects/[projectId]/environments/[environmentId]/page.tsx index 42a5351..411cd39 100644 --- a/src/app/projects/[projectId]/environments/[environmentId]/page.tsx +++ b/src/app/projects/[projectId]/environments/[environmentId]/page.tsx @@ -22,6 +22,7 @@ import DeployContainerModal from "@/app/containers/components/modals/DeployConta import AddDatabaseModal from "@/app/databases/components/AddDatabaseModal"; import EnvironmentContainersTable from "./components/containers/EnvironmentContainersTable"; import EnvironmentHeader from "./components/header/EnvironmentHeader"; +import ImportFromSyncModal from "./components/import/ImportFromSyncModal"; import EnvironmentContainersSummary from "./components/summary/EnvironmentContainersSummary"; import EnvironmentContainersToolbar from "./components/toolbar/EnvironmentContainersToolbar"; import type { @@ -97,6 +98,7 @@ export default function EnvironmentContainersPage() { const [syncing, setSyncing] = useState(false); const [showDeploy, setShowDeploy] = useState(false); const [showDeployDatabase, setShowDeployDatabase] = useState(false); + const [showImportFromSync, setShowImportFromSync] = useState(false); const [error, setError] = useState(""); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); @@ -184,6 +186,25 @@ export default function EnvironmentContainersPage() { } }, [activeEnvironment, load, pushToast]); + const handleImportAssign = useCallback( + async (containerIds: string[]) => { + if (!activeEnvironment) { + return; + } + + await projectsApi.updateEnvironmentContainers(activeEnvironment.id, { + containerIds, + }); + await load(); + pushToast({ + tone: "success", + title: "Container assigned", + message: "Synced container has been assigned to this environment.", + }); + }, + [activeEnvironment, load, pushToast], + ); + useEffect(() => { const frame = window.requestAnimationFrame(() => { void load(); @@ -249,6 +270,16 @@ export default function EnvironmentContainersPage() { }} /> ) : null} + {showImportFromSync && activeEnvironment ? ( + setShowImportFromSync(false)} + onAssign={handleImportAssign} + /> + ) : null} setShowDeploy(true)} onDeployDatabase={() => setShowDeployDatabase(true)} + onImportFromSync={() => setShowImportFromSync(true)} /> { setUpdatingEnvironment(true); try { @@ -289,12 +288,7 @@ export default function ProjectDetailPage() { environmentId, payload, ); - const assignmentResponse = - await projectsApi.updateEnvironmentContainers(environmentId, { - containerIds, - }); - const updatedEnvironment = - assignmentResponse.data ?? updateResponse.data; + const updatedEnvironment = updateResponse.data; if (updatedEnvironment) { setProject((current) => { diff --git a/src/app/projects/components/EnvironmentOpsModal.tsx b/src/app/projects/components/EnvironmentOpsModal.tsx index d64a6f6..b9e3805 100644 --- a/src/app/projects/components/EnvironmentOpsModal.tsx +++ b/src/app/projects/components/EnvironmentOpsModal.tsx @@ -1,18 +1,8 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; +import { Loader2, Save, X } from "lucide-react"; import { - CheckSquare, - Info, - Loader2, - Save, - Server as ServerIcon, - Square, - X, -} from "lucide-react"; -import { - type Container, - containers, type ProjectEnvironmentKind, type ProjectEnvironmentRecord, type ProjectEnvironmentUpdateBody, @@ -27,7 +17,6 @@ interface EnvironmentOpsModalProps { onSubmit: ( environmentId: string, payload: ProjectEnvironmentUpdateBody, - containerIds: string[], ) => Promise; } @@ -39,10 +28,6 @@ const kinds: Array<{ value: ProjectEnvironmentKind; label: string }> = [ { value: "CUSTOM", label: "Custom" }, ]; -function formatStatus(value: string) { - return value.charAt(0) + value.slice(1).toLowerCase(); -} - export default function EnvironmentOpsModal({ environment, serverList, @@ -54,85 +39,27 @@ export default function EnvironmentOpsModal({ const [kind, setKind] = useState(environment.kind); const [serverId, setServerId] = useState(environment.serverId); const [description, setDescription] = useState(environment.description ?? ""); - const [serverContainers, setServerContainers] = useState([]); - const [selectedContainerIds, setSelectedContainerIds] = useState( - [], - ); - const [loadingContainers, setLoadingContainers] = useState(false); const [error, setError] = useState(""); - useEffect(() => { - let mounted = true; - - const loadContainers = async () => { - setLoadingContainers(true); - setError(""); - - try { - const response = await containers.list({ serverId }); - if (!mounted) { - return; - } - - const nextContainers = response.data ?? []; - setServerContainers(nextContainers); - setSelectedContainerIds( - nextContainers - .filter((container) => container.environmentId === environment.id) - .map((container) => container.id), - ); - } catch (loadError) { - if (!mounted) { - return; - } - - setServerContainers([]); - setSelectedContainerIds([]); - setError( - loadError instanceof Error - ? loadError.message - : "Container tidak bisa dimuat.", - ); - } finally { - if (mounted) { - setLoadingContainers(false); - } - } - }; - - void loadContainers(); - - return () => { - mounted = false; - }; - }, [environment.id, serverId]); - const hasServerChanged = serverId !== environment.serverId; + const hasAssignedContainers = environment.containersCount > 0; const canSubmit = useMemo( () => name.trim().length >= 2 && serverId.trim().length > 0 && - (!hasServerChanged || selectedContainerIds.length === 0), - [hasServerChanged, name, selectedContainerIds.length, serverId], + (!hasServerChanged || !hasAssignedContainers), + [hasAssignedContainers, hasServerChanged, name, serverId], ); const selectedServer = serverList.find((server) => server.id === serverId); - const toggleContainer = (containerId: string) => { - setSelectedContainerIds((current) => - current.includes(containerId) - ? current.filter((id) => id !== containerId) - : [...current, containerId], - ); - }; - const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!canSubmit) { setError( - hasServerChanged - ? "Lepas semua container sebelum memindahkan environment ke server lain." + hasServerChanged && hasAssignedContainers + ? "Move or unassign containers before moving this environment to another server." : "Lengkapi nama environment dan server tujuan.", ); return; @@ -147,7 +74,6 @@ export default function EnvironmentOpsModal({ serverId, description: description.trim(), }, - selectedContainerIds, ); }; @@ -181,7 +107,7 @@ export default function EnvironmentOpsModal({

- Informasi, edit metadata, dan pointing services/container. + Informasi dan edit metadata environment.

- ); - }) - ) : ( -
- - No containers are assigned to this server. -
- )} - -
- - {hasServerChanged ? ( + {hasServerChanged && hasAssignedContainers ? (

Environment can only be moved to another server if it does not have - any container assigned. Please reassign or remove all containers - before changing the server. + any container assigned. Use Import from Sync on the environment + page to manage container assignments.

) : null} @@ -532,7 +320,7 @@ export default function EnvironmentOpsModal({