feat(update): add dynamic environment & docker network options to the app installer modal and change the style of the installed app modal.
This commit is contained in:
1 parent
54bba5c19a
commit
f7f25d88cd
4 files changed
+633
-170
No files matched your search
+334
-161
@@ -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<Record<string, boolean>>({});
|
||||
const contentViewportRef = useRef<HTMLDivElement>(null);
|
||||
const actionsMenuRef = useRef<HTMLDivElement | null>(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({
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={handleOpenContainers}
|
||||
>
|
||||
<Boxes size={12} /> Containers
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={handleOpenTerminal}
|
||||
>
|
||||
<Terminal size={12} /> Terminal
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
@@ -521,6 +542,7 @@ function InstallOperationsModal({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="installed-app-ops-toolbar"
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
@@ -528,35 +550,82 @@ function InstallOperationsModal({
|
||||
borderBottom: "1px solid var(--border)",
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
border:
|
||||
activeTab === tab.id
|
||||
? "1px solid rgba(59,130,246,0.35)"
|
||||
: "1px solid transparent",
|
||||
background:
|
||||
activeTab === tab.id ? "rgba(59,130,246,0.12)" : undefined,
|
||||
color: activeTab === tab.id ? "#3b82f6" : undefined,
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
.installed-app-ops-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.installed-app-ops-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.installed-app-ops-menu {
|
||||
right: 0;
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.installed-app-ops-toolbar {
|
||||
padding: 12px 14px !important;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
|
||||
.installed-app-ops-tabs,
|
||||
.installed-app-ops-actions {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.installed-app-ops-actions {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.installed-app-ops-tabs > button,
|
||||
.installed-app-ops-actions button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.installed-app-ops-menu {
|
||||
left: 0;
|
||||
right: auto;
|
||||
width: 100%;
|
||||
min-width: 190px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<div className="installed-app-ops-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
border:
|
||||
activeTab === tab.id
|
||||
? "1px solid rgba(59,130,246,0.35)"
|
||||
: "1px solid transparent",
|
||||
background:
|
||||
activeTab === tab.id ? "rgba(59,130,246,0.12)" : undefined,
|
||||
color: activeTab === tab.id ? "#3b82f6" : undefined,
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="installed-app-ops-actions">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12 }}
|
||||
@@ -568,76 +637,157 @@ function InstallOperationsModal({
|
||||
) : (
|
||||
<RefreshCw size={12} />
|
||||
)}
|
||||
Refresh
|
||||
Sync
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={requestRebuildConfirm}
|
||||
disabled={acting.rebuild}
|
||||
<div
|
||||
ref={actionsMenuRef}
|
||||
style={{ position: "relative", display: "inline-flex" }}
|
||||
>
|
||||
{acting.rebuild ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<Wrench size={12} />
|
||||
)}
|
||||
Rebuild
|
||||
</button>
|
||||
{detail?.container ? (
|
||||
<>
|
||||
{detail.container.status === "STOPPED" ||
|
||||
detail.container.status === "ERROR" ? (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, color: "#10b981" }}
|
||||
onClick={() => void handleLifecycle("start")}
|
||||
disabled={acting.start}
|
||||
>
|
||||
{acting.start ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={12} />
|
||||
)}
|
||||
Start
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => void handleLifecycle("stop")}
|
||||
disabled={acting.stop}
|
||||
>
|
||||
{acting.stop ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<Square size={12} />
|
||||
)}
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => void handleLifecycle("restart")}
|
||||
disabled={acting.restart}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => setActionsMenuOpen((open) => !open)}
|
||||
aria-expanded={actionsMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Cpu size={12} />
|
||||
Manage
|
||||
<ChevronDown size={13} />
|
||||
</button>
|
||||
{actionsMenuOpen ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="card installed-app-ops-menu"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "calc(100% + 8px)",
|
||||
zIndex: 2300,
|
||||
padding: 6,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
boxShadow: "0 18px 40px rgba(2, 6, 23, 0.22)",
|
||||
}}
|
||||
>
|
||||
{acting.restart ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<RotateCcw size={12} />
|
||||
)}
|
||||
Restart
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => void onRemove(install)}
|
||||
disabled={acting.remove}
|
||||
>
|
||||
<Trash2 size={12} /> Remove App
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="btn btn-ghost"
|
||||
onClick={handleOpenEnvironment}
|
||||
style={{
|
||||
justifyContent: "flex-start",
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<Container
|
||||
size={14}
|
||||
style={{ color: "var(--accent-blue)" }}
|
||||
/>
|
||||
Environment
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="btn btn-ghost"
|
||||
onClick={requestRebuildConfirm}
|
||||
disabled={acting.rebuild}
|
||||
style={{
|
||||
justifyContent: "flex-start",
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{acting.rebuild ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Wrench size={14} style={{ color: "#a78bfa" }} />
|
||||
)}
|
||||
Rebuild
|
||||
</button>
|
||||
{detail?.container ? (
|
||||
<>
|
||||
{detail.container.status === "STOPPED" ||
|
||||
detail.container.status === "ERROR" ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => handleMenuLifecycle("start")}
|
||||
disabled={acting.start}
|
||||
style={{
|
||||
justifyContent: "flex-start",
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{acting.start ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={14} style={{ color: "#10b981" }} />
|
||||
)}
|
||||
Start
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => handleMenuLifecycle("stop")}
|
||||
disabled={acting.stop}
|
||||
style={{
|
||||
justifyContent: "flex-start",
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{acting.stop ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Square size={14} style={{ color: "#94a3b8" }} />
|
||||
)}
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => handleMenuLifecycle("restart")}
|
||||
disabled={acting.restart}
|
||||
style={{
|
||||
justifyContent: "flex-start",
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{acting.restart ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<RotateCcw size={14} style={{ color: "#60a5fa" }} />
|
||||
)}
|
||||
Restart
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="btn btn-ghost"
|
||||
onClick={handleMenuRemove}
|
||||
disabled={acting.remove}
|
||||
style={{
|
||||
justifyContent: "flex-start",
|
||||
fontSize: 12,
|
||||
color: "#ef4444",
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} /> Remove App
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ minWidth: 0, flex: "1 1 auto" }}>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
marginBottom: 4,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={install.appName}
|
||||
>
|
||||
{install.appName}
|
||||
</p>
|
||||
@@ -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}
|
||||
</p>
|
||||
@@ -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() {
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -1716,47 +1886,50 @@ export default function InstalledAppsPage() {
|
||||
<ServerIcon size={12} />
|
||||
{install.server?.name ?? "Unknown server"}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: "6px 10px" }}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`http://${install.server?.ip}:${install.port?.split(":")[0]}`,
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Boxes size={12} /> Open WebUI
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: "6px 10px" }}
|
||||
onClick={() => openInstallModal(install, "logs")}
|
||||
>
|
||||
<ScrollText size={12} /> Logs
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: "6px 10px" }}
|
||||
type="button"
|
||||
className="btn btn-success"
|
||||
style={{
|
||||
flex: "1 1 120px",
|
||||
fontSize: 11,
|
||||
padding: "5px",
|
||||
}}
|
||||
onClick={() => openInstallModal(install, "overview")}
|
||||
>
|
||||
<Cpu size={12} /> Ops
|
||||
<Wrench size={11} />
|
||||
Detail/Ops
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ fontSize: 12, padding: "6px 10px" }}
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{
|
||||
flex: "1 1 120px",
|
||||
fontSize: 11,
|
||||
padding: "5px",
|
||||
}}
|
||||
onClick={() => openInstallEnvironment(install)}
|
||||
>
|
||||
<Container size={11} />
|
||||
Open Env
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
flex: "1 1 120px",
|
||||
fontSize: 11,
|
||||
padding: "5px",
|
||||
}}
|
||||
onClick={() => void handleRemove(install)}
|
||||
disabled={removingId === install.id}
|
||||
>
|
||||
{removingId === install.id ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : install.status === "FAILED" ? (
|
||||
<XCircle size={12} />
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={12} />
|
||||
<Trash2 size={11} />
|
||||
)}
|
||||
Remove
|
||||
{removingId === install.id ? "Removing..." : "Remove"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+198
-8
@@ -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<InstallMode>(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<PortMapping[]>([]);
|
||||
const [envVars, setEnvVars] = useState<EnvVarRow[]>([]);
|
||||
const [volumes, setVolumes] = useState<VolumeMapping[]>([]);
|
||||
const [projectOptions, setProjectOptions] = useState<ProjectRecord[]>([]);
|
||||
const [networkOptions, setNetworkOptions] = useState<NetworkRecord[]>([]);
|
||||
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<DockerRuntimeStatus | null>(
|
||||
@@ -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({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel>Project Environment *</InputLabel>
|
||||
<SearchableSelect
|
||||
value={environmentId}
|
||||
options={environmentSelectOptions}
|
||||
onChange={setEnvironmentId}
|
||||
placeholder={
|
||||
projectsLoading
|
||||
? "Loading environments..."
|
||||
: availableEnvironments.length > 0
|
||||
? "No environment selected"
|
||||
: "No environment mapped to this server"
|
||||
}
|
||||
searchPlaceholder="Search environment..."
|
||||
emptyText="No environment found"
|
||||
disabled={!serverId || projectsLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<InputLabel>App Name *</InputLabel>
|
||||
<input
|
||||
@@ -1137,14 +1307,34 @@ function InstallModal({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel>Network</InputLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={network}
|
||||
onChange={(e) => setNetwork(e.target.value)}
|
||||
placeholder="bridge"
|
||||
style={{ width: "100%" }}
|
||||
<InputLabel>Docker Network</InputLabel>
|
||||
<SearchableSelect
|
||||
value={networkId}
|
||||
options={networkSelectOptions}
|
||||
onChange={setNetworkId}
|
||||
placeholder={
|
||||
networksLoading
|
||||
? "Loading networks..."
|
||||
: network.trim()
|
||||
? `Template/default network (${network.trim()})`
|
||||
: "Bridge default / no explicit network"
|
||||
}
|
||||
searchPlaceholder="Search network..."
|
||||
emptyText="No network found"
|
||||
disabled={!serverId || networksLoading}
|
||||
/>
|
||||
{networksError ? (
|
||||
<p
|
||||
style={{
|
||||
marginTop: 6,
|
||||
fontSize: 11,
|
||||
color: "#fca5a5",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{networksError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel>Docker Image *</InputLabel>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user