feat: add environment import from synced containers

This commit is contained in:
KodekaTeam committed 2026-06-14 03:05:03 +07:00
1 parent cb77dab219
commit eed757d60c
5 files changed
+518 -234

No files matched your search

@@ -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<void>;
}
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<Container[]>([]);
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 (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal animate-slide-in"
onClick={(event) => event.stopPropagation()}
style={{ width: "100%", maxWidth: 760, padding: 24 }}
>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 12,
marginBottom: 16,
}}
>
<div style={{ minWidth: 0 }}>
<h3
style={{
color: "var(--text-primary)",
fontWeight: 700,
fontSize: 16,
margin: 0,
}}
>
Import from Sync
</h3>
<p
style={{
color: "var(--text-muted)",
fontSize: 12,
margin: "3px 0 0",
}}
>
Sync inventory from {serverName}, then assign containers into{" "}
{environmentName}.
</p>
</div>
<button
type="button"
onClick={onClose}
style={{
background: "none",
border: "none",
color: "var(--text-muted)",
cursor: "pointer",
}}
>
<X size={18} />
</button>
</div>
<section
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
gap: 8,
marginBottom: 14,
}}
>
{[
{ label: "Synced Containers", value: serverContainers.length },
{ label: "Unassigned", value: unassignedCount },
{ label: "Assigned Here", value: assignedContainerIds.length },
].map((item) => (
<div
key={item.label}
style={{
background: "var(--bg-input)",
borderRadius: 7,
padding: "8px 10px",
border: "1px solid var(--border)",
minWidth: 0,
}}
>
<p
style={{
color: "var(--text-muted)",
fontSize: 10,
marginBottom: 2,
}}
>
{item.label}
</p>
<p
style={{
color: "var(--text-primary)",
fontSize: 12,
fontWeight: 600,
fontFamily: "JetBrains Mono, monospace",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.value}
</p>
</div>
))}
</section>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
marginBottom: 8,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<CloudSyncIcon size={14} style={{ color: "var(--text-muted)" }} />
<strong style={{ fontSize: 13 }}>Services / Containers</strong>
</div>
<button
type="button"
className="btn btn-ghost"
onClick={() => void loadContainers(true)}
disabled={syncing || loading || assigningContainerId !== null}
style={{ fontSize: 12 }}
>
{syncing ? (
<Loader2 size={12} className="animate-spin" />
) : (
<RefreshCw size={12} />
)}
Sync from Server
</button>
</div>
{syncNotice ? (
<div
style={{
marginBottom: 10,
borderRadius: 8,
padding: "9px 12px",
color: "#f59e0b",
background: "rgba(245,158,11,0.1)",
border: "1px solid rgba(245,158,11,0.3)",
fontSize: 12,
}}
>
{syncNotice}
</div>
) : null}
<div
style={{
border: "1px solid var(--border)",
borderRadius: 7,
overflow: "hidden",
maxHeight: "52vh",
overflowY: "auto",
}}
>
{loading ? (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: 32,
background: "var(--bg-input)",
color: "var(--text-muted)",
fontSize: 12,
}}
>
<Loader2 size={14} className="animate-spin" />
Loading synced containers
</div>
) : 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 (
<div
key={container.id}
style={{
width: "100%",
display: "grid",
gridTemplateColumns: "20px minmax(0, 1fr) auto",
alignItems: "center",
gap: 10,
padding: "10px 12px",
background: assignedHere
? "rgba(16,185,129,0.08)"
: "var(--bg-input)",
borderBottom: "1px solid var(--border)",
color: "var(--text-primary)",
}}
>
{assignedHere ? (
<CheckSquare size={14} style={{ color: "#10b981" }} />
) : (
<Square
size={14}
style={{ color: "var(--text-muted)" }}
/>
)}
<span style={{ minWidth: 0 }}>
<strong
style={{
display: "block",
fontSize: 12,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{container.name}
</strong>
<span
style={{
display: "block",
color: "var(--text-muted)",
fontSize: 11,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{container.image}
</span>
<span
style={{
display: "block",
color: "var(--text-muted)",
fontSize: 10,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
marginTop: 2,
}}
>
{formatStatus(container.status)} -{" "}
{formatPorts(container.ports)}
</span>
</span>
{assignedHere ? (
<span
style={{
color: "#94a3b8",
border: "1px solid rgba(100,116,139,0.3)",
borderRadius: 6,
padding: "3px 8px",
fontSize: 11,
}}
>
{formatStatus(container.status)}
</span>
) : (
<button
type="button"
className="btn btn-ghost"
onClick={() => void handleAssign(container)}
disabled={
assigningContainerId !== null || syncing || loading
}
style={{
color: assignedElsewhere ? "#f59e0b" : "#93c5fd",
borderColor: assignedElsewhere
? "rgba(245,158,11,0.3)"
: "rgba(59,130,246,0.3)",
fontSize: 11,
padding: "4px 8px",
}}
>
{assigning ? (
<Loader2 size={12} className="animate-spin" />
) : null}
{assignedElsewhere ? "Reassign" : "Assign"}
</button>
)}
</div>
);
})
: null}
{!loading && serverContainers.length === 0 ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: 12,
background: "var(--bg-input)",
color: "var(--text-muted)",
fontSize: 12,
}}
>
<Info size={14} />
No synced containers found on this server.
</div>
) : null}
</div>
{error ? (
<div
style={{
marginTop: 14,
borderRadius: 8,
padding: "10px 14px",
color: "#ef4444",
background: "rgba(239,68,68,0.1)",
border: "1px solid rgba(239,68,68,0.3)",
fontSize: 13,
}}
>
{error}
</div>
) : null}
</div>
</div>
);
}
@@ -19,6 +19,7 @@ interface EnvironmentContainersToolbarProps {
onSync: () => void | Promise<void>;
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<HTMLDivElement | null>(null);
@@ -60,6 +62,11 @@ export default function EnvironmentContainersToolbar({
onDeployDatabase();
};
const handleImportFromSync = () => {
setDeployMenuOpen(false);
onImportFromSync();
};
return (
<section
className="card ui-responsive-toolbar"
@@ -154,7 +161,7 @@ export default function EnvironmentContainersToolbar({
type="button"
role="menuitem"
className="btn btn-ghost"
onClick={handleDeployDatabase}
onClick={handleImportFromSync}
style={{
justifyContent: "flex-start",
fontSize: 12,
@@ -179,7 +186,7 @@ export default function EnvironmentContainersToolbar({
size={14}
style={{ color: "var(--accent-red)" }}
/>
Import form Sync
Import from Sync
</button>
</div>
) : null}
@@ -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 ? (
<ImportFromSyncModal
environmentId={activeEnvironment.id}
serverId={activeEnvironment.serverId}
serverName={activeEnvironment.server.name}
environmentName={activeEnvironment.name}
onClose={() => setShowImportFromSync(false)}
onAssign={handleImportAssign}
/>
) : null}
<ProcessLogsModal
open={isProcessLogsOpen}
onClose={closeProcessLogs}
@@ -292,6 +323,7 @@ export default function EnvironmentContainersPage() {
onSync={handleSync}
onDeployContainer={() => setShowDeploy(true)}
onDeployDatabase={() => setShowDeployDatabase(true)}
onImportFromSync={() => setShowImportFromSync(true)}
/>
<EnvironmentContainersTable
projectId={params.projectId}
+1 -7
View File
@@ -281,7 +281,6 @@ export default function ProjectDetailPage() {
async (
environmentId: string,
payload: ProjectEnvironmentUpdateBody,
containerIds: string[],
) => {
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) => {
@@ -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<void>;
}
@@ -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<ProjectEnvironmentKind>(environment.kind);
const [serverId, setServerId] = useState(environment.serverId);
const [description, setDescription] = useState(environment.description ?? "");
const [serverContainers, setServerContainers] = useState<Container[]>([]);
const [selectedContainerIds, setSelectedContainerIds] = useState<string[]>(
[],
);
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({
<p
style={{ color: "var(--text-muted)", fontSize: 12, marginTop: 2 }}
>
Informasi, edit metadata, dan pointing services/container.
Informasi dan edit metadata environment.
</p>
</div>
<button
@@ -217,7 +143,7 @@ export default function EnvironmentOpsModal({
label: "Server IP",
value: selectedServer?.ip ?? environment.server.ip,
},
{ label: "Containers", value: selectedContainerIds.length },
{ label: "Containers", value: environment.containersCount },
].map((item) => (
<div
key={item.label}
@@ -351,149 +277,11 @@ export default function EnvironmentOpsModal({
</div>
</div>
<section style={{ marginTop: 18 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
marginBottom: 8,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<ServerIcon size={14} style={{ color: "var(--text-muted)" }} />
<strong style={{ fontSize: 13 }}>Services / Containers</strong>
</div>
{loadingContainers ? (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
color: "var(--text-muted)",
fontSize: 12,
}}
>
<Loader2 size={12} className="animate-spin" />
Loading
</span>
) : null}
</div>
<div
style={{
border: "1px solid var(--border)",
borderRadius: 7,
overflow: "hidden",
}}
>
{serverContainers.length > 0 ? (
serverContainers.map((container) => {
const checked = selectedContainerIds.includes(container.id);
const ownedByOtherEnvironment =
container.environmentId &&
container.environmentId !== environment.id;
return (
<button
type="button"
key={container.id}
onClick={() => toggleContainer(container.id)}
style={{
width: "100%",
display: "grid",
gridTemplateColumns: "20px minmax(0, 1fr) auto",
alignItems: "center",
gap: 10,
padding: "10px 12px",
background: checked
? "rgba(16,185,129,0.08)"
: "var(--bg-input)",
border: 0,
borderBottom: "1px solid var(--border)",
color: "var(--text-primary)",
cursor: "pointer",
textAlign: "left",
}}
>
{checked ? (
<CheckSquare size={14} style={{ color: "#10b981" }} />
) : (
<Square
size={14}
style={{ color: "var(--text-muted)" }}
/>
)}
<span style={{ minWidth: 0 }}>
<strong
style={{
display: "block",
fontSize: 12,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{container.name}
</strong>
<span
style={{
display: "block",
color: "var(--text-muted)",
fontSize: 11,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{container.image}
</span>
</span>
<span
style={{
color: ownedByOtherEnvironment ? "#f59e0b" : "#94a3b8",
border: `1px solid ${
ownedByOtherEnvironment
? "rgba(245,158,11,0.3)"
: "rgba(100,116,139,0.3)"
}`,
borderRadius: 6,
padding: "3px 8px",
fontSize: 11,
}}
>
{ownedByOtherEnvironment
? "Reassign"
: formatStatus(container.status)}
</span>
</button>
);
})
) : (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: 12,
background: "var(--bg-input)",
color: "var(--text-muted)",
fontSize: 12,
}}
>
<Info size={14} />
No containers are assigned to this server.
</div>
)}
</div>
</section>
{hasServerChanged ? (
{hasServerChanged && hasAssignedContainers ? (
<p style={{ color: "#f59e0b", fontSize: 12, marginTop: 10 }}>
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.
</p>
) : null}
@@ -532,7 +320,7 @@ export default function EnvironmentOpsModal({
<button
type="submit"
className="btn btn-primary"
disabled={!canSubmit || submitting || loadingContainers}
disabled={!canSubmit || submitting}
>
{submitting ? (
<Loader2 size={14} className="animate-spin" />