feat: add environment import from synced containers
This commit is contained in:
1 parent
cb77dab219
commit
eed757d60c
5 files changed
+518
-234
No files matched your search
+463
@@ -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>
|
||||
);
|
||||
}
|
||||
+9
-2
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in new issue
Block a user