From f7f25d88cd865f94a95df0361e2c7bd4e6c351a7 Mon Sep 17 00:00:00 2001 From: KodekaTeam Date: Mon, 8 Jun 2026 00:55:43 +0700 Subject: [PATCH] feat(update): add dynamic environment & docker network options to the app installer modal and change the style of the installed app modal. --- src/app/apps/installed/page.tsx | 495 +++++++++++++++++++++----------- src/app/apps/page.tsx | 206 ++++++++++++- src/lib/api.ts | 12 + src/server/routes/apps.ts | 90 +++++- 4 files changed, 633 insertions(+), 170 deletions(-) diff --git a/src/app/apps/installed/page.tsx b/src/app/apps/installed/page.tsx index 2d7f290..709b1e6 100644 --- a/src/app/apps/installed/page.tsx +++ b/src/app/apps/installed/page.tsx @@ -6,7 +6,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AlertTriangle, Boxes, + ChevronDown, CheckCircle, + Container, Cpu, HardDrive, Loader2, @@ -16,11 +18,9 @@ import { ScrollText, Server as ServerIcon, Square, - Terminal, Trash2, Wrench, X, - XCircle, } from "lucide-react"; import DashboardLayout from "@/components/DashboardLayout"; import ConfirmActionDialog from "@/components/ConfirmActionDialog"; @@ -269,6 +269,8 @@ function InstallOperationsModal({ const [error, setError] = useState(""); const [acting, setActing] = useState>({}); const contentViewportRef = useRef(null); + const actionsMenuRef = useRef(null); + const [actionsMenuOpen, setActionsMenuOpen] = useState(false); useEffect(() => { setActiveTab(initialTab); @@ -295,12 +297,29 @@ function InstallOperationsModal({ void loadRuntime(); }, [loadRuntime]); + useEffect(() => { + if (!actionsMenuOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + if ( + actionsMenuRef.current && + !actionsMenuRef.current.contains(event.target as Node) + ) { + setActionsMenuOpen(false); + } + }; + + document.addEventListener("pointerdown", handlePointerDown); + return () => document.removeEventListener("pointerdown", handlePointerDown); + }, [actionsMenuOpen]); + const handleLifecycle = async (action: "start" | "stop" | "restart") => { if (!install.containerName) { onToast({ tone: "error", title: "Installed Apps", - message: "Container runtime belum ditemukan untuk app ini", + message: + "Container name is not available for this app install. Lifecycle actions cannot be performed.", showProgress: true, }); return; @@ -314,7 +333,7 @@ function InstallOperationsModal({ onToast({ tone: "success", title: "Installed Apps", - message: `${install.appName} ${action} berhasil dijalankan`, + message: `${install.appName} ${action} successfully executed`, showProgress: true, }); } catch (err: unknown) { @@ -338,7 +357,7 @@ function InstallOperationsModal({ onToast({ tone: "success", title: "Installed Apps", - message: `${install.appName} berhasil direbuild`, + message: `${install.appName} successfully rebuilt`, showProgress: true, }); } catch (err: unknown) { @@ -354,31 +373,47 @@ function InstallOperationsModal({ }; const requestRebuildConfirm = () => { + setActionsMenuOpen(false); onRequestConfirm({ title: "Rebuild Installed App", description: `Rebuild app "${install.appName}" sekarang?`, confirmLabel: "Rebuild App", tone: "warning", - note: "Container app ini akan dihentikan, dihapus, lalu dijalankan ulang. Doktainer akan mencoba menarik image terbaru terlebih dahulu jika tersedia di registry.", + note: "Rebuilding will stop the app if it's currently running, and apply any changes from the original app template or the last successful build. This is useful if the app is not working correctly or if you have made changes to the app's source that you want to apply.", onConfirm: () => { void handleRebuild(); }, }); }; - const handleOpenContainers = () => { - storeServerSelection("containers", install.serverId); - router.push("/containers"); + const handleOpenEnvironment = () => { + setActionsMenuOpen(false); + + if (!install.environment) { + onToast({ + tone: "error", + title: "Installed Apps", + message: + "This app install does not have an associated environment. It might have been installed before this feature was implemented, or there was an issue linking the environment during installation.", + showProgress: true, + }); + return; + } + + router.push( + `/projects/${install.environment.projectId}/environments/${install.environment.id}/containers/${install.environment.containerId}`, + ); onClose(); }; - const handleOpenTerminal = () => { - const encodedName = encodeURIComponent(install.server?.name ?? "Server"); - const encodedIp = encodeURIComponent(install.server?.ip ?? ""); - router.push( - `/terminal?serverId=${install.serverId}&name=${encodedName}&ip=${encodedIp}`, - ); - onClose(); + const handleMenuLifecycle = (action: "start" | "stop" | "restart") => { + setActionsMenuOpen(false); + void handleLifecycle(action); + }; + + const handleMenuRemove = () => { + setActionsMenuOpen(false); + void onRemove(install); }; const currentStatus = detail?.container.status ?? install.status; @@ -492,20 +527,6 @@ function InstallOperationsModal({

- - - ))} -
+ +
+ {tabs.map((tab) => ( + + ))} +
+
- - {detail?.container ? ( - <> - {detail.container.status === "STOPPED" || - detail.container.status === "ERROR" ? ( - - ) : ( - - )} - + {actionsMenuOpen ? ( +
- {acting.restart ? ( - - ) : ( - - )} - Restart - - - ) : null} - + + + {detail?.container ? ( + <> + {detail.container.status === "STOPPED" || + detail.container.status === "ERROR" ? ( + + ) : ( + + )} + + + ) : null} + +
+ ) : null} +
@@ -974,7 +1124,7 @@ function InstallOperationsModal({ title="Docker inspect failed" message={ diagnostics.inspect.error || - "Payload docker inspect tidak bisa dibaca untuk app ini." + "Docker inspect payload is not available for this app." } /> ) : null} @@ -1230,9 +1380,21 @@ export default function InstalledAppsPage() { setModalTab(tab); }; - const openContainersPage = (install: AppInstall) => { - storeServerSelection("containers", install.serverId); - router.push("/containers"); + const openInstallEnvironment = (install: AppInstall) => { + if (!install.environment) { + pushToast({ + tone: "error", + title: "Installed Apps", + message: + "This app is not yet connected to a Project Environment. Please select an environment during reinstallation or check the related container in Projects.", + showProgress: true, + }); + return; + } + + router.push( + `/projects/${install.environment.projectId}/environments/${install.environment.id}/containers/${install.environment.containerId}`, + ); }; const runningCount = installs.filter( @@ -1584,14 +1746,18 @@ export default function InstalledAppsPage() { gap: 12, }} > -
+

{install.appName}

@@ -1600,7 +1766,11 @@ export default function InstalledAppsPage() { fontSize: 11, color: "var(--text-muted)", fontFamily: "JetBrains Mono, monospace", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", }} + title={install.appId} > {install.appId}

@@ -1614,6 +1784,7 @@ export default function InstalledAppsPage() { borderRadius: 999, fontSize: 11, fontWeight: 700, + flexShrink: 0, }} > {meta.label} @@ -1699,9 +1870,8 @@ export default function InstalledAppsPage() {
{install.server?.name ?? "Unknown server"}
-
+
- - +
diff --git a/src/app/apps/page.tsx b/src/app/apps/page.tsx index c8d5bc9..e3492c1 100644 --- a/src/app/apps/page.tsx +++ b/src/app/apps/page.tsx @@ -5,12 +5,14 @@ import { type FormEvent, useCallback, useEffect, + useMemo, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import DashboardLayout from "@/components/DashboardLayout"; import SearchField from "@/components/SearchField"; +import SearchableSelect from "@/components/SearchableSelect"; import { Boxes, Package, @@ -34,7 +36,11 @@ import { AppTemplate, DockerRuntimeStatus, apps as appsApi, + networks as networksApi, + projectsApi, servers as serversApi, + NetworkRecord, + ProjectRecord, Server, } from "@/lib/api"; import { @@ -555,17 +561,24 @@ function InstallModal({ const [mounted, setMounted] = useState(false); const [mode, setMode] = useState(initialMode); const [serverId, setServerId] = useState(""); + const [environmentId, setEnvironmentId] = useState(""); const [presetId, setPresetId] = useState(template?.presets?.[0]?.id ?? ""); const [appName, setAppName] = useState(""); const [imageName, setImageName] = useState(""); const [imageTag, setImageTag] = useState(""); const [containerName, setContainerName] = useState(""); const [network, setNetwork] = useState("bridge"); + const [networkId, setNetworkId] = useState(""); const [restartPolicy, setRestartPolicy] = useState("unless-stopped"); const [command, setCommand] = useState(""); const [ports, setPorts] = useState([]); const [envVars, setEnvVars] = useState([]); const [volumes, setVolumes] = useState([]); + const [projectOptions, setProjectOptions] = useState([]); + const [networkOptions, setNetworkOptions] = useState([]); + const [projectsLoading, setProjectsLoading] = useState(true); + const [networksLoading, setNetworksLoading] = useState(false); + const [networksError, setNetworksError] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [dockerStatus, setDockerStatus] = useState( @@ -581,6 +594,59 @@ function InstallModal({ const visual = getTemplateVisual(template); const hasTemplate = Boolean(template); + const availableEnvironments = useMemo( + () => + projectOptions.flatMap((project) => + project.environments + .filter((environment) => environment.serverId === serverId) + .map((environment) => ({ + id: environment.id, + label: `${project.name} / ${environment.name}`, + description: `${environment.kind.toLowerCase()} on ${environment.server.name}`, + })), + ), + [projectOptions, serverId], + ); + const environmentSelectOptions = useMemo( + () => [ + { + value: "", + label: projectsLoading + ? "Loading environments..." + : availableEnvironments.length > 0 + ? "No environment selected" + : "No environment mapped to this server", + }, + ...availableEnvironments.map((environment) => ({ + value: environment.id, + label: environment.label, + description: environment.description, + keywords: `${environment.label} ${environment.description}`, + })), + ], + [availableEnvironments, projectsLoading], + ); + const networkSelectOptions = useMemo( + () => [ + { + value: "", + label: networksLoading + ? "Loading networks..." + : network.trim() + ? `Template/default network (${network.trim()})` + : "Bridge default / no explicit network", + }, + ...networkOptions.map((networkOption) => ({ + value: networkOption.id, + label: networkOption.name, + description: networkOption.driver, + keywords: `${networkOption.name} ${networkOption.driver} ${ + networkOption.scope + } ${networkOption.subnet ?? ""} ${networkOption.gateway ?? ""}`, + })), + ], + [network, networkOptions, networksLoading], + ); useEffect(() => { setMounted(true); @@ -603,6 +669,7 @@ function InstallModal({ setImageTag(defaults.tag); setContainerName(defaults.containerName); setNetwork(defaults.network); + setNetworkId(""); setRestartPolicy(defaults.restartPolicy); setCommand(defaults.command); setPorts(defaults.ports); @@ -610,6 +677,78 @@ function InstallModal({ setVolumes(defaults.volumes); }, [presetId, template]); + useEffect(() => { + let isActive = true; + + projectsApi + .list() + .then((response) => { + if (!isActive) return; + setProjectOptions(response.data ?? []); + }) + .catch(() => { + if (!isActive) return; + setProjectOptions([]); + }) + .finally(() => { + if (!isActive) return; + setProjectsLoading(false); + }); + + return () => { + isActive = false; + }; + }, []); + + useEffect(() => { + if (!serverId) { + queueMicrotask(() => { + setNetworkOptions([]); + setNetworksError(""); + setNetworkId(""); + }); + return; + } + + let isActive = true; + + queueMicrotask(() => { + if (!isActive) return; + setNetworksLoading(true); + setNetworksError(""); + }); + networksApi + .list(serverId) + .then((response) => { + if (!isActive) return; + const networks = response.data ?? []; + setNetworkOptions(networks); + setNetworkId((current) => + current && networks.some((item) => item.id === current) + ? current + : "", + ); + }) + .catch((err: unknown) => { + if (!isActive) return; + setNetworkOptions([]); + setNetworkId(""); + setNetworksError( + err instanceof Error + ? err.message + : "Failed to load available networks", + ); + }) + .finally(() => { + if (!isActive) return; + setNetworksLoading(false); + }); + + return () => { + isActive = false; + }; + }, [serverId]); + const validateDockerStatus = useCallback(async (targetServerId: string) => { setDockerStatusLoading(true); setDockerNotice(""); @@ -797,6 +936,7 @@ function InstallModal({ templateSnapshot: mode === "template" && template ? template : undefined, serverId, + environmentId: environmentId || undefined, appName: appName.trim(), image: imageName.trim(), tag: imageTag.trim() || undefined, @@ -804,7 +944,8 @@ function InstallModal({ ports: serializePorts(ports), env: serializeEnv(envVars), volumes: serializeVolumes(volumes), - network: network.trim() || undefined, + networkId: networkId || undefined, + network: networkId ? undefined : network.trim() || undefined, restartPolicy, command: command.trim() || undefined, }); @@ -1103,6 +1244,8 @@ function InstallModal({ value={serverId} onChange={(e) => { setServerId(e.target.value); + setEnvironmentId(""); + setNetworkId(""); setDockerNotice(""); }} required @@ -1116,6 +1259,33 @@ function InstallModal({ ))}
+
+ Project Environment * + 0 + ? "No environment selected" + : "No environment mapped to this server" + } + searchPlaceholder="Search environment..." + emptyText="No environment found" + disabled={!serverId || projectsLoading} + /> +
+
+ +
App Name *
- Network - setNetwork(e.target.value)} - placeholder="bridge" - style={{ width: "100%" }} + Docker Network + + {networksError ? ( +

+ {networksError} +

+ ) : null}
Docker Image * diff --git a/src/lib/api.ts b/src/lib/api.ts index 326c458..26c49ed 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2412,6 +2412,17 @@ export interface AppInstall { error: string | null; installedAt: string; server?: { name: string; ip: string }; + environment?: { + containerId: string; + id: string; + projectId: string; + name: string; + project?: { + id: string; + name: string; + slug: string; + }; + } | null; } export interface AppInstallRuntimeDetails { @@ -2483,6 +2494,7 @@ export const apps = { templateSnapshot?: AppTemplate; serverId: string; environmentId?: string; + networkId?: string; appName?: string; image?: string; tag?: string; diff --git a/src/server/routes/apps.ts b/src/server/routes/apps.ts index 10ca6f0..e158df7 100644 --- a/src/server/routes/apps.ts +++ b/src/server/routes/apps.ts @@ -520,6 +520,7 @@ const installSchema = z templateSnapshot: templateSnapshotSchema.optional(), serverId: z.string(), environmentId: z.string().trim().max(64).optional().or(z.literal("")), + networkId: z.string().trim().max(64).optional().or(z.literal("")), appName: z.string().min(1).max(120).optional(), image: z.string().min(1).max(255).optional(), tag: z.string().max(120).optional(), @@ -660,7 +661,74 @@ export async function appsRoutes(app: FastifyInstance) { ? await overlayInstallRuntimeStatuses(dedupedInstalls) : dedupedInstalls; - return reply.send({ success: true, data: installsWithRuntime }); + const installContainerFilters = dedupedInstalls + .map((install) => ({ + serverId: install.serverId, + containerName: install.containerName?.trim(), + })) + .filter( + (item): item is { serverId: string; containerName: string } => + Boolean(item.containerName), + ); + const containers = + installContainerFilters.length > 0 + ? await prisma.container.findMany({ + where: { + server: { organizationId: req.organizationId! }, + OR: installContainerFilters.map((item) => ({ + serverId: item.serverId, + name: { equals: item.containerName, mode: "insensitive" }, + })), + }, + select: { + id: true, + serverId: true, + name: true, + environment: { + select: { + id: true, + projectId: true, + name: true, + project: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + }, + }, + }) + : []; + const environmentByContainer = new Map( + containers + .filter((container) => container.environment) + .map((container) => [ + `${container.serverId}::${container.name.trim().toLowerCase()}`, + container.environment + ? { + containerId: container.id, + ...container.environment, + } + : null, + ]), + ); + const installsWithEnvironment = installsWithRuntime.map((install) => { + const containerName = install.containerName?.trim(); + const environment = containerName + ? environmentByContainer.get( + `${install.serverId}::${containerName.toLowerCase()}`, + ) + : null; + + return { + ...install, + environment: environment ?? null, + }; + }); + + return reply.send({ success: true, data: installsWithEnvironment }); }); app.post("/install", { preHandler: appWriteAccess }, async (req, reply) => { @@ -701,6 +769,25 @@ export async function appsRoutes(app: FastifyInstance) { } } + const selectedNetworkId = body.data.networkId?.trim() || null; + const selectedNetwork = selectedNetworkId + ? await prisma.network.findFirst({ + where: { + id: selectedNetworkId, + serverId: server.id, + server: { organizationId: req.organizationId! }, + }, + select: { id: true, name: true }, + }) + : null; + + if (selectedNetworkId && !selectedNetwork) { + return reply.status(400).send({ + success: false, + error: "Selected Docker network was not found for this server", + }); + } + const dockerStatus = await ssh.getDockerRuntimeStatus(server); if (!dockerStatus.available) { return reply.status(400).send({ @@ -761,6 +848,7 @@ export async function appsRoutes(app: FastifyInstance) { "", ); const finalNetwork = firstDefined( + selectedNetwork?.name, body.data.network, preset?.defaultNetwork, template?.defaultNetwork,