diff --git a/src/app/globals.css b/src/app/globals.css
index 6a23578..11d807c 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -545,6 +545,14 @@ body {
line-height: 1.6;
}
+.server-config-component-metadata {
+ margin: 0;
+ color: var(--text-muted);
+ font-size: 12px;
+ line-height: 1.55;
+ overflow-wrap: anywhere;
+}
+
.server-config-component-divider {
margin: 0;
border: 0;
@@ -559,6 +567,84 @@ body {
min-width: 0;
}
+.server-config-readiness-compact {
+ padding: 14px 16px;
+ display: grid;
+ gap: 8px;
+}
+
+.server-config-readiness-compact-main {
+ min-width: 0;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.server-config-readiness-meta {
+ margin: 0;
+ color: var(--text-muted);
+ font-size: 11px;
+ line-height: 1.5;
+}
+
+.server-config-readiness-details {
+ padding-top: 8px;
+ border-top: 1px solid var(--border);
+}
+
+.server-config-readiness-details summary {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ color: #b45309;
+ font-size: 12px;
+ cursor: pointer;
+ list-style: none;
+}
+
+.server-config-readiness-details summary::-webkit-details-marker {
+ display: none;
+}
+
+.server-config-readiness-details .server-config-readiness-notices {
+ margin-top: 10px;
+}
+
+.server-config-group-switcher {
+ padding: 12px 14px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.server-config-group-switcher-copy {
+ min-width: 0;
+ display: grid;
+ gap: 3px;
+}
+
+.server-config-group-switcher-copy span {
+ color: var(--text-muted);
+ font-size: 11px;
+ line-height: 1.45;
+}
+
+.server-config-group-switcher-actions {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.server-config-group-switcher-actions .btn[aria-pressed="true"] {
+ border-color: rgba(59, 130, 246, 0.35);
+ background: rgba(59, 130, 246, 0.12);
+ color: #3b82f6;
+}
+
.server-config-readiness-notices {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
@@ -709,7 +795,162 @@ body {
white-space: nowrap;
}
+.server-config-setup-profile-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(210px, 100%), 1fr));
+ gap: 12px;
+}
+
+.server-config-setup-intro {
+ display: grid;
+ gap: 5px;
+ padding: 12px 14px;
+ border-left: 3px solid #3b82f6;
+ background: rgba(59, 130, 246, 0.08);
+ color: var(--text-secondary);
+ font-size: 12px;
+ line-height: 1.55;
+}
+
+.server-config-setup-intro strong {
+ color: var(--text-primary);
+ font-size: 13px;
+}
+
+.server-config-setup-profile {
+ min-width: 0;
+ padding: 16px;
+ transition: border-color 150ms ease, background 150ms ease;
+}
+
+.server-config-setup-profile:hover {
+ border-color: rgba(59, 130, 246, 0.4);
+}
+
+.server-config-setup-profile-select {
+ width: 100%;
+ min-width: 0;
+ display: grid;
+ gap: 9px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
+.server-config-setup-profile-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+}
+
+.server-config-setup-steps {
+ display: grid;
+ gap: 10px;
+}
+
+.server-config-setup-step {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: start;
+ gap: 10px;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg-input);
+}
+
+.server-config-setup-step-number {
+ width: 22px;
+ height: 22px;
+ display: grid;
+ place-items: center;
+ border-radius: 999px;
+ background: rgba(59, 130, 246, 0.12);
+ color: #3b82f6;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.server-config-setup-preview-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ padding-top: 14px;
+ border-top: 1px solid var(--border);
+ color: var(--text-muted);
+ font-size: 12px;
+}
+
+.server-config-setup-review-list {
+ display: grid;
+ gap: 8px;
+}
+
+.server-config-setup-review-item {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ padding: 11px 12px;
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ background: var(--bg-input);
+ color: var(--text-primary);
+ font-size: 13px;
+}
+
+.server-config-setup-review-item input {
+ width: 15px;
+ height: 15px;
+ accent-color: #3b82f6;
+}
+
+.server-config-setup-review-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.server-config-setup-review-actions .btn {
+ min-width: auto;
+}
+
+.server-config-setup-warning {
+ display: flex;
+ align-items: flex-start;
+ gap: 9px;
+ padding: 12px 14px;
+ border: 1px solid rgba(245, 158, 11, 0.28);
+ border-radius: 8px;
+ background: rgba(245, 158, 11, 0.08);
+ color: var(--text-secondary);
+ font-size: 12px;
+ line-height: 1.55;
+}
+
+.server-config-setup-warning svg {
+ flex: 0 0 auto;
+ color: #f59e0b;
+}
+
@media (max-width: 520px) {
+ .server-config-readiness-compact-main {
+ flex-direction: column;
+ }
+
+ .server-config-group-switcher-actions,
+ .server-config-group-switcher-actions .btn {
+ width: 100%;
+ }
+
.server-config-component-card {
padding: 14px;
}
@@ -1049,8 +1290,9 @@ body {
--modal-viewport-gutter-right: max(16px, env(safe-area-inset-right));
--modal-viewport-gutter-bottom: max(16px, env(safe-area-inset-bottom));
--modal-viewport-gutter-left: max(16px, env(safe-area-inset-left));
- padding: var(--modal-viewport-gutter-top) var(--modal-viewport-gutter-right)
- var(--modal-viewport-gutter-bottom) var(--modal-viewport-gutter-left);
+ padding: var(--modal-viewport-gutter-top)
+ var(--modal-viewport-gutter-right) var(--modal-viewport-gutter-bottom)
+ var(--modal-viewport-gutter-left);
}
.modal-shell {
@@ -1070,8 +1312,9 @@ body {
}
.modal-overlay-wide {
- padding: var(--modal-viewport-gutter-top) var(--modal-viewport-gutter-right)
- var(--modal-viewport-gutter-bottom) var(--modal-viewport-gutter-left);
+ padding: var(--modal-viewport-gutter-top)
+ var(--modal-viewport-gutter-right) var(--modal-viewport-gutter-bottom)
+ var(--modal-viewport-gutter-left);
}
.modal-shell-wide {
diff --git a/src/app/servers/components/DockerManagerModal.tsx b/src/app/servers/components/DockerManagerModal.tsx
new file mode 100644
index 0000000..1765a4e
--- /dev/null
+++ b/src/app/servers/components/DockerManagerModal.tsx
@@ -0,0 +1,538 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import {
+ AlertTriangle,
+ Boxes,
+ Check,
+ Loader2,
+ RefreshCw,
+ Trash2,
+ X,
+} from "lucide-react";
+import ConfirmActionDialog from "@/components/ConfirmActionDialog";
+import {
+ servers as serversApi,
+ type DockerPruneOptions,
+ type Server as ServerType,
+ type ServerConfigSnapshot,
+} from "@/lib/api";
+import { UserBadge } from "@/app/servers/components/server-config/ServerConfigPrimitives";
+
+const cleanupOptions = [
+ {
+ key: "images",
+ label: "Unused images",
+ description: "Equivalent to docker image prune -a.",
+ },
+ {
+ key: "containers",
+ label: "Stopped containers",
+ description: "Equivalent to docker container prune.",
+ },
+ {
+ key: "networks",
+ label: "Unused networks",
+ description: "Equivalent to docker network prune.",
+ },
+ {
+ key: "volumes",
+ label: "Unused volumes",
+ description: "Equivalent to docker volume prune.",
+ },
+ {
+ key: "buildCache",
+ label: "Build cache",
+ description: "Equivalent to docker builder prune.",
+ },
+] as const;
+
+function formatDockerManagerError(error: unknown): string {
+ const message = error instanceof Error ? error.message : String(error);
+
+ if (message.includes("All configured authentication methods failed")) {
+ return "SSH authentication failed. Check the server SSH username and password or private key in Server Config, then try again.";
+ }
+
+ if (message.includes("Connection lost before handshake")) {
+ return "SSH connection was lost before authentication. Check the server address, SSH port, firewall, and network connection, then try again.";
+ }
+
+ return message || "Docker operation failed.";
+}
+
+export default function DockerManagerModal({
+ server,
+ onClose,
+ onActionComplete,
+}: {
+ server: ServerType;
+ onClose: () => void;
+ onActionComplete: (message: string, tone?: "success" | "error") => void;
+}) {
+ const [snapshot, setSnapshot] = useState(null);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [selectedCleanup, setSelectedCleanup] = useState({
+ images: false,
+ containers: false,
+ networks: false,
+ volumes: false,
+ buildCache: false,
+ });
+ const [runningAction, setRunningAction] = useState<
+ "install" | "repair" | "reinstall" | "uninstall" | "prune" | null
+ >(null);
+ const [pendingAction, setPendingAction] = useState<
+ "install" | "repair" | "reinstall" | "uninstall" | "prune" | null
+ >(null);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await serversApi.getConfig(server.id);
+ setSnapshot(response.data);
+ } catch (caught: unknown) {
+ setError(formatDockerManagerError(caught));
+ } finally {
+ setLoading(false);
+ }
+ }, [server.id]);
+
+ useEffect(() => {
+ let isCurrent = true;
+
+ serversApi
+ .getConfig(server.id)
+ .then((response) => {
+ if (isCurrent) {
+ setSnapshot(response.data);
+ setError(null);
+ }
+ })
+ .catch((caught: unknown) => {
+ if (isCurrent) {
+ setError(formatDockerManagerError(caught));
+ }
+ })
+ .finally(() => {
+ if (isCurrent) {
+ setLoading(false);
+ }
+ });
+
+ return () => {
+ isCurrent = false;
+ };
+ }, [server.id]);
+
+ const docker = snapshot?.docker;
+ const selectedCleanupCount =
+ Object.values(selectedCleanup).filter(Boolean).length;
+
+ const runAction = async (
+ action: "install" | "repair" | "reinstall" | "uninstall" | "prune",
+ ) => {
+ setRunningAction(action);
+ setPendingAction(null);
+ setError(null);
+
+ try {
+ const response =
+ action === "install"
+ ? await serversApi.installDocker(server.id)
+ : action === "repair"
+ ? await serversApi.repairDocker(server.id)
+ : action === "reinstall"
+ ? await serversApi.reinstallDocker(server.id)
+ : action === "uninstall"
+ ? await serversApi.uninstallDocker(server.id)
+ : await serversApi.pruneDocker(
+ server.id,
+ selectedCleanup as DockerPruneOptions,
+ );
+
+ setSnapshot((current) =>
+ current
+ ? {
+ ...current,
+ docker: response.data,
+ }
+ : current,
+ );
+ const responseMessage =
+ "message" in response && typeof response.message === "string"
+ ? response.message
+ : null;
+ const message = responseMessage
+ ? responseMessage
+ : action === "install"
+ ? `Docker installation completed for ${server.name}.`
+ : action === "repair"
+ ? `Docker repair completed for ${server.name}.`
+ : action === "reinstall"
+ ? `Docker reinstall completed for ${server.name}.`
+ : action === "uninstall"
+ ? `Docker removal completed for ${server.name}.`
+ : `Docker cleanup completed for ${server.name}.`;
+ onActionComplete(message);
+ await refresh();
+ } catch (caught: unknown) {
+ const message = formatDockerManagerError(caught);
+ setError(message);
+ onActionComplete(message, "error");
+ } finally {
+ setRunningAction(null);
+ }
+ };
+
+ const confirmConfig = pendingAction
+ ? {
+ install: {
+ title: "Install Docker",
+ description: `Install Docker on ${server.name}? This requires non-interactive sudo access when the SSH user is not root.`,
+ confirmLabel: "Install Docker",
+ tone: "warning" as const,
+ note: "This can take several minutes on a fresh VPS. Keep this window open; the server will refresh after installation completes.",
+ },
+ repair: {
+ title: "Repair Docker",
+ description: `Start and enable the existing Docker daemon on ${server.name}? Docker packages, containers, images, volumes, networks, and configuration will not be removed or replaced.`,
+ confirmLabel: "Repair Docker",
+ tone: "warning" as const,
+ note: "This requires non-interactive sudo access when the SSH user is not root.",
+ },
+ reinstall: {
+ title: "Reinstall Docker",
+ description: `Reinstall Docker on ${server.name}? Running containers may be affected while Docker restarts.`,
+ confirmLabel: "Reinstall Docker",
+ tone: "warning" as const,
+ note: "Review active workloads before continuing.",
+ },
+ uninstall: {
+ title: "Remove Docker",
+ description: `Remove Docker from ${server.name}? This can interrupt and remove access to container workloads on the host.`,
+ confirmLabel: "Remove Docker",
+ tone: "danger" as const,
+ note: "This action does not ask for a typed confirmation, so use it only when you intend to remove Docker.",
+ },
+ prune: {
+ title: "Run Docker Cleanup",
+ description: `Remove ${selectedCleanupCount} selected ${selectedCleanupCount === 1 ? "category" : "categories"} of unused Docker artifacts on ${server.name}?`,
+ confirmLabel: "Run Cleanup",
+ tone: "warning" as const,
+ note: "Only the selected unused artifacts will be removed.",
+ },
+ }[pendingAction]
+ : null;
+
+ return (
+
+ {confirmConfig && pendingAction ? (
+
setPendingAction(null)}
+ onConfirm={() => void runAction(pendingAction)}
+ />
+ ) : null}
+
+
+
+
+
+
+
+
+
+ Manage Docker
+
+
+
+ Live Docker status and maintenance controls for {server.name}.
+
+
+
void refresh()}
+ disabled={loading}
+ >
+ {loading ? (
+
+ ) : (
+
+ )}
+ Refresh
+
+
+
+ {error ? (
+
+ ) : null}
+
+
+
+
+
+ Docker Runtime
+
+
+ {loading
+ ? "Checking Docker on this server..."
+ : docker?.available
+ ? "Docker is installed and the daemon is reachable."
+ : docker?.installed
+ ? "Docker is installed, but the daemon is not reachable."
+ : "Docker is not installed on this server."}
+
+
+
+
+
+ Version: {docker?.version ?? "Not detected"} / Package manager:{" "}
+ {docker?.platform.packageManager ?? "Not detected"}
+
+ {docker?.reason ? (
+
+ {docker.reason}
+
+ ) : null}
+
+ {!docker?.installed ? (
+ setPendingAction("install")}
+ >
+ {runningAction === "install" ? (
+
+ ) : (
+
+ )}{" "}
+ Install Docker
+
+ ) : !docker.available ? (
+ setPendingAction("repair")}
+ >
+ {runningAction === "repair" ? (
+
+ ) : (
+
+ )}{" "}
+ Repair Docker
+
+ ) : null}
+ {docker?.installed ? (
+ <>
+ setPendingAction("reinstall")}
+ >
+ {runningAction === "reinstall" ? (
+
+ ) : (
+
+ )}{" "}
+ Reinstall Docker
+
+ setPendingAction("uninstall")}
+ style={{ color: "#ef4444" }}
+ >
+ {runningAction === "uninstall" ? (
+
+ ) : (
+
+ )}{" "}
+ Remove Docker
+
+ >
+ ) : null}
+
+
+
+ {docker?.installed ? (
+
+
+
+ Prune Docker Garbage
+
+
+ Select unused artifacts to remove, then confirm the cleanup.
+
+
+
+ {cleanupOptions.map((option) => (
+
+
+ setSelectedCleanup((current) => ({
+ ...current,
+ [option.key]: !current[option.key],
+ }))
+ }
+ />
+
+ {option.label}
+
+ {option.description}
+
+
+
+ ))}
+
+
+
+ {selectedCleanupCount} cleanup option
+ {selectedCleanupCount === 1 ? "" : "s"} selected
+
+ setPendingAction("prune")}
+ >
+ {runningAction === "prune" ? (
+
+ ) : (
+
+ )}{" "}
+ Run Docker Prune
+
+
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/app/servers/components/ServerConfigModal.tsx b/src/app/servers/components/ServerConfigModal.tsx
index 8a5096a..b615485 100644
--- a/src/app/servers/components/ServerConfigModal.tsx
+++ b/src/app/servers/components/ServerConfigModal.tsx
@@ -11,7 +11,6 @@ import {
X,
} from "lucide-react";
import {
- type DockerPruneOptions,
getUser,
type Server as ServerType,
type ServerConfigSnapshot,
@@ -19,21 +18,11 @@ import {
type ServerSystemUser,
type ServerSystemUserCreateBody,
servers as serversApi,
- type WebStackAction,
- type WebStackComponentKey,
} from "@/lib/api";
import {
createUnavailableServerConfigSnapshot,
- getDockerPruneSummary,
- getDockerActionDescription,
- getDockerActionLabel,
- getDockerActionTone,
getServiceRestartDescription,
getServiceRestartTone,
- getWebStackActionDescription,
- getWebStackActionLabel,
- getWebStackActionTone,
- getWebStackComponentLabel,
type ServerConfigNotice,
type ServerConfigTab,
type ServerPendingConfirm,
@@ -45,27 +34,21 @@ import ServerConfigOverviewPanel from "@/app/servers/components/server-config/Se
import ServerConfigSshAccessPanel from "@/app/servers/components/server-config/ServerConfigSshAccessPanel";
import ServerConfigServicesPanel from "@/app/servers/components/server-config/ServerConfigServicesPanel";
import ServerConfigUsersPanel from "@/app/servers/components/server-config/ServerConfigUsersPanel";
-import ServerConfigWebServerPanel from "@/app/servers/components/server-config/ServerConfigWebServerPanel";
import IssueDetailsSummary from "@/components/IssueDetailsSummary";
interface ServerConfigModalProps {
server: ServerType;
onClose: () => void;
onActionComplete: (message: string, tone?: "success" | "error") => void;
+ title?: string;
}
export default function ServerConfigModal({
server,
onClose,
onActionComplete,
+ title = "Server Config",
}: ServerConfigModalProps) {
- const defaultDockerPruneOptions = {
- images: false,
- containers: false,
- networks: false,
- volumes: false,
- buildCache: false,
- };
const [activeTab, setActiveTab] = useState("overview");
const [snapshot, setSnapshot] = useState(null);
const [loading, setLoading] = useState(true);
@@ -80,18 +63,12 @@ export default function ServerConfigModal({
useState(null);
const [resetConfirmation, setResetConfirmation] = useState("");
const [confirmResetStep, setConfirmResetStep] = useState(false);
- const [dockerPruneOptions, setDockerPruneOptions] = useState(
- defaultDockerPruneOptions,
- );
const deleteConfirmed = resetConfirmation.trim() === "DELETE";
- const hasSelectedDockerPruneOption =
- Object.values(dockerPruneOptions).some(Boolean);
const tabs: Array<{ id: ServerConfigTab; label: string }> = [
{ id: "overview", label: "Overview" },
{ id: "users", label: "Users" },
{ id: "ssh-access", label: "SSH Access" },
{ id: "services", label: "Services" },
- { id: "web-server", label: "Web Server" },
{ id: "mounts", label: "Disk Mounts" },
{ id: "actions", label: "Actions" },
];
@@ -107,12 +84,7 @@ export default function ServerConfigModal({
);
const getServerActionKey = useCallback(
- (action: "reboot" | "restart-nginx" | "prune-docker") => `server:${action}`,
- [],
- );
-
- const getDockerActionKey = useCallback(
- (action: "install" | "uninstall" | "reinstall") => `docker:${action}`,
+ (action: "reboot" | "restart-nginx") => `server:${action}`,
[],
);
@@ -121,11 +93,6 @@ export default function ServerConfigModal({
[],
);
- const getWebStackActionKey = useCallback(
- (component: WebStackComponentKey, action: WebStackAction) =>
- `web-stack:${component}:${action}`,
- [],
- );
const canManageSystemAccounts = ["OPERATOR", "SUPER_ADMIN"].includes(
getUser()?.role ?? "VIEWER",
);
@@ -163,12 +130,12 @@ export default function ServerConfigModal({
}, [server]);
useEffect(() => {
- void loadSnapshot();
- }, [loadSnapshot]);
+ const refreshTimer = window.setTimeout(() => {
+ void loadSnapshot();
+ }, 0);
- useEffect(() => {
- setNoticeExpanded(false);
- }, [activeTab]);
+ return () => window.clearTimeout(refreshTimer);
+ }, [loadSnapshot]);
const handleReset = async () => {
if (!deleteConfirmed) {
@@ -214,10 +181,7 @@ export default function ServerConfigModal({
}
};
- const handleServerAction = async (
- action: "reboot" | "restart-nginx" | "prune-docker",
- options?: DockerPruneOptions,
- ) => {
+ const handleServerAction = async (action: "reboot" | "restart-nginx") => {
const actionKey = getServerActionKey(action);
setActiveActionKey(actionKey);
setError("");
@@ -254,33 +218,6 @@ export default function ServerConfigModal({
onActionComplete(
res.message || `Web server restarted on ${server.name}`,
);
- } else {
- const res = await serversApi.pruneDocker(server.id, options);
- setSnapshot((current) =>
- current
- ? {
- ...current,
- docker: res.data,
- }
- : current,
- );
- publishNotice({
- tab: "actions",
- tone: "success",
- title: "Docker Cleanup Completed",
- summary: res.message || `Docker cleanup completed on ${server.name}.`,
- details:
- res.details && res.details.length > 0
- ? res.details
- : [
- "Selected unused Docker artifacts were removed from the host.",
- ],
- detailText: res.rawOutput,
- });
- onActionComplete(
- res.message || `Docker cleanup completed on ${server.name}`,
- );
- await loadSnapshot();
}
} catch (err: unknown) {
const message =
@@ -291,9 +228,7 @@ export default function ServerConfigModal({
title:
action === "reboot"
? "Reboot Failed"
- : action === "restart-nginx"
- ? "Web Server Restart Failed"
- : "Docker Cleanup Failed",
+ : "Web Server Restart Failed",
summary: message,
details: [`Server: ${server.name} (${server.ip})`],
});
@@ -303,9 +238,7 @@ export default function ServerConfigModal({
}
};
- const requestServerActionConfirm = (
- action: "reboot" | "restart-nginx" | "prune-docker",
- ) => {
+ const requestServerActionConfirm = (action: "reboot" | "restart-nginx") => {
if (action === "restart-nginx") {
setPendingConfirm({
kind: "server",
@@ -332,91 +265,6 @@ export default function ServerConfigModal({
return;
}
- setDockerPruneOptions(defaultDockerPruneOptions);
- setPendingConfirm({
- kind: "server",
- action,
- title: "Prune Docker Garbage",
- description: getDockerPruneSummary(defaultDockerPruneOptions),
- confirmLabel: "Run Docker Prune",
- tone: "warning",
- pruneOptions: defaultDockerPruneOptions,
- });
- };
-
- const handleDockerAction = async (
- action: "install" | "uninstall" | "reinstall",
- ) => {
- const actionKey = getDockerActionKey(action);
- setActiveActionKey(actionKey);
- setError("");
- setNotice(null);
- setNoticeExpanded(false);
-
- try {
- const res =
- action === "install"
- ? await serversApi.installDocker(server.id)
- : action === "uninstall"
- ? await serversApi.uninstallDocker(server.id)
- : await serversApi.reinstallDocker(server.id);
-
- setSnapshot((current) =>
- current
- ? {
- ...current,
- docker: res.data,
- }
- : current,
- );
- publishNotice({
- tab: "actions",
- tone: "success",
- title:
- action === "install"
- ? "Docker Installation Started"
- : action === "uninstall"
- ? "Docker Removed"
- : "Docker Reinstalled",
- summary:
- action === "install"
- ? `Docker installation started successfully for ${server.name}.`
- : action === "uninstall"
- ? `Docker removal completed for ${server.name}.`
- : `Docker reinstall completed for ${server.name}.`,
- details: [
- `Server: ${server.name} (${server.ip})`,
- res.data.version ? `Runtime version: ${res.data.version}` : null,
- res.data.reason ? `Runtime note: ${res.data.reason}` : null,
- ].filter((value): value is string => Boolean(value)),
- });
- onActionComplete(
- action === "install"
- ? `Docker installation completed for ${server.name}`
- : action === "uninstall"
- ? `Docker removal completed for ${server.name}`
- : `Docker reinstall completed for ${server.name}`,
- );
- await loadSnapshot();
- } catch (err: unknown) {
- const message =
- err instanceof Error ? err.message : `Failed to ${action} Docker`;
- publishNotice({
- tab: "actions",
- tone: "error",
- title:
- action === "install"
- ? "Docker Installation Failed"
- : action === "uninstall"
- ? "Docker Removal Failed"
- : "Docker Reinstall Failed",
- summary: message,
- details: [`Server: ${server.name} (${server.ip})`],
- });
- onActionComplete(message, "error");
- } finally {
- setActiveActionKey(null);
- }
};
const handleServiceRestart = async (serviceName: string) => {
@@ -464,88 +312,6 @@ export default function ServerConfigModal({
}
};
- const handleWebStackAction = async (
- component: WebStackComponentKey,
- action: WebStackAction,
- ) => {
- const actionKey = getWebStackActionKey(component, action);
- setActiveActionKey(actionKey);
- setError("");
- setNotice(null);
- setNoticeExpanded(false);
-
- try {
- const res = await serversApi.manageWebStack(server.id, component, action);
- setSnapshot((current) =>
- current
- ? {
- ...current,
- webServer: res.data,
- }
- : current,
- );
- publishNotice({
- tab: "web-server",
- tone: "success",
- title: res.meta?.componentLabel
- ? `${res.meta.componentLabel} ${getWebStackActionLabel(action)}`
- : "Web Stack Updated",
- summary:
- res.message ||
- `${getWebStackActionLabel(action)} completed for ${component}.`,
- details: [...(res.details ?? []), ...res.data.notes.slice(0, 3)],
- });
- onActionComplete(
- res.message ||
- `${getWebStackActionLabel(action)} completed for ${component}.`,
- );
- await loadSnapshot();
- } catch (err: unknown) {
- const message =
- err instanceof Error ? err.message : `Failed to ${action} ${component}`;
- publishNotice({
- tab: "web-server",
- tone: "error",
- title: "Web Stack Action Failed",
- summary: message,
- details: [`Component: ${component}`, `Action: ${action}`],
- });
- onActionComplete(message, "error");
- } finally {
- setActiveActionKey(null);
- }
- };
-
- const requestWebStackActionConfirm = (
- component: WebStackComponentKey,
- action: WebStackAction,
- ) => {
- const componentLabel = getWebStackComponentLabel(component);
- setPendingConfirm({
- kind: "web-stack",
- component,
- action,
- title: `${getWebStackActionLabel(action)} ${componentLabel}`,
- description: getWebStackActionDescription(componentLabel, action),
- confirmLabel: `${getWebStackActionLabel(action)} ${componentLabel}`,
- tone: getWebStackActionTone(action),
- });
- };
-
- const requestDockerActionConfirm = (
- action: "install" | "uninstall" | "reinstall",
- ) => {
- const label = getDockerActionLabel(action);
- setPendingConfirm({
- kind: "docker",
- action,
- title: label,
- description: getDockerActionDescription(server.name, action),
- confirmLabel: label,
- tone: getDockerActionTone(action),
- });
- };
-
const requestServiceRestartConfirm = (serviceName: string) => {
setPendingConfirm({
kind: "service",
@@ -1266,15 +1032,6 @@ export default function ServerConfigModal({
onRequestServiceRestartConfirm={requestServiceRestartConfirm}
/>
);
- case "web-server":
- return (
-
- );
case "mounts":
return (
@@ -1309,7 +1064,10 @@ export default function ServerConfigModal({
setActiveTab(id)}
+ onClick={() => {
+ setActiveTab(id);
+ setNoticeExpanded(false);
+ }}
className="btn btn-ghost"
style={{
height: 28,
@@ -1396,119 +1154,6 @@ export default function ServerConfigModal({
{pendingConfirm.description}
- {pendingConfirm.kind === "server" &&
- pendingConfirm.action === "prune-docker" ? (
-
-
- Select unused Docker artifacts to clean up
-
- {[
- {
- key: "images",
- label: "Unused images",
- description: "Equivalent to docker image prune -a.",
- },
- {
- key: "containers",
- label: "Stopped containers",
- description: "Equivalent to docker container prune.",
- },
- {
- key: "networks",
- label: "Unused networks",
- description: "Equivalent to docker network prune.",
- },
- {
- key: "volumes",
- label: "Unused volumes",
- description: "Equivalent to docker volume prune.",
- },
- {
- key: "buildCache",
- label: "Build cache",
- description: "Equivalent to docker builder prune.",
- },
- ].map((option) => {
- const checked =
- dockerPruneOptions[
- option.key as keyof typeof dockerPruneOptions
- ];
-
- return (
-
- {
- const nextOptions = {
- ...dockerPruneOptions,
- [option.key]: event.target.checked,
- };
- setDockerPruneOptions(nextOptions);
- setPendingConfirm((current) =>
- current &&
- current.kind === "server" &&
- current.action === "prune-docker"
- ? {
- ...current,
- description:
- getDockerPruneSummary(nextOptions),
- pruneOptions: nextOptions,
- }
- : current,
- );
- }}
- style={{ marginTop: 2 }}
- />
-
-
- {option.label}
-
-
- {option.description}
-
-
-
- );
- })}
-
- ) : null}
{activeActionKey !== null ? (
@@ -1747,7 +1362,7 @@ export default function ServerConfigModal({
fontSize: 16,
}}
>
- Server Config
+ {title}
;
dockerLoading: Record;
refreshing: Record;
- installingDocker: Record;
deleting: string | null;
openMenuId: string | null;
expandedMetricsId: string | null;
@@ -48,9 +47,10 @@ interface ServersTableProps {
onRefreshMetrics: (serverId: string) => void | Promise;
onTestConnection: (serverId: string) => void | Promise;
onOpenTerminal: (server: ServerType) => void;
- onInstallDocker: (server: ServerType) => void | Promise;
onToggleMenu: (serverId: string) => void;
onOpenConfig: (server: ServerType) => void;
+ onOpenWebServerManager: (server: ServerType) => void;
+ onOpenDockerManager: (server: ServerType) => void;
onToggleMetrics: (server: ServerType) => void | Promise;
onEdit: (server: ServerType) => void;
onDelete: (serverId: string) => void | Promise;
@@ -68,7 +68,6 @@ export default function ServersTable({
dockerErrors,
dockerLoading,
refreshing,
- installingDocker,
deleting,
openMenuId,
expandedMetricsId,
@@ -79,9 +78,10 @@ export default function ServersTable({
onRefreshMetrics,
onTestConnection,
onOpenTerminal,
- onInstallDocker,
onToggleMenu,
onOpenConfig,
+ onOpenWebServerManager,
+ onOpenDockerManager,
onToggleMetrics,
onEdit,
onDelete,
@@ -98,7 +98,7 @@ export default function ServersTable({
const rect = event.currentTarget.getBoundingClientRect();
const menuWidth = 176;
- const menuHeight = 174;
+ const menuHeight = 212;
const nextLeft = Math.min(
window.innerWidth - menuWidth - 12,
Math.max(12, rect.right - menuWidth + rect.width),
@@ -163,12 +163,6 @@ export default function ServersTable({
{items.map((server) => {
const metrics = server.metrics;
- const docker = dockerStates[server.id];
- const canInstallDocker =
- !dockerLoading[server.id] &&
- !!docker &&
- !docker.available &&
- docker.canInstall;
const isMetricsOpen = expandedMetricsId === server.id;
return (
@@ -412,25 +406,24 @@ export default function ServersTable({
>
}
- {canInstallDocker ? (
- void onInstallDocker(server)}
- className="btn btn-ghost"
- style={{
- padding: "4px 8px",
- fontSize: 11,
- color: "#10b981",
- }}
- disabled={installingDocker[server.id]}
- >
- {installingDocker[server.id] ? (
-
- ) : (
-
- )}
-
- ) : null}
+ void onToggleMetrics(server)}
+ className="btn btn-ghost"
+ style={{
+ padding: "4px 8px",
+ fontSize: 11,
+ color: isMetricsOpen
+ ? "var(--accent-blue)"
+ : "var(--text-secondary)",
+ }}
+ >
+
+
@@ -469,20 +462,26 @@ export default function ServersTable({
Server Config
void onToggleMetrics(server)}
+ onClick={() => onOpenWebServerManager(server)}
className="btn btn-ghost"
style={{
fontSize: 12,
justifyContent: "flex-start",
- color: isMetricsOpen
- ? "var(--text-primary)"
- : "var(--text-secondary)",
+ color: "#3b82f6",
}}
>
-
- {isMetricsOpen
- ? "Hide Monitor"
- : "Resource Monitor"}
+ Manage Web Server
+
+ onOpenDockerManager(server)}
+ className="btn btn-ghost"
+ style={{
+ fontSize: 12,
+ justifyContent: "flex-start",
+ color: "#10b981",
+ }}
+ >
+ Manage Docker
onEdit(server)}
diff --git a/src/app/servers/components/WebServerManagerModal.tsx b/src/app/servers/components/WebServerManagerModal.tsx
new file mode 100644
index 0000000..a6f84a8
--- /dev/null
+++ b/src/app/servers/components/WebServerManagerModal.tsx
@@ -0,0 +1,201 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { AlertTriangle, Loader2, RefreshCw, X } from "lucide-react";
+import ConfirmActionDialog from "@/components/ConfirmActionDialog";
+import {
+ servers as serversApi,
+ type Server as ServerType,
+ type ServerConfigSnapshot,
+ type WebStackAction,
+ type WebStackComponentKey,
+} from "@/lib/api";
+import {
+ getWebStackActionDescription,
+ getWebStackActionLabel,
+ getWebStackActionTone,
+ getWebStackComponentLabel,
+} from "@/app/servers/components/server-config-utils";
+import { UserBadge } from "@/app/servers/components/server-config/ServerConfigPrimitives";
+import ServerConfigWebServerPanel from "@/app/servers/components/server-config/ServerConfigWebServerPanel";
+
+const serviceGroups = [
+ {
+ id: "infrastructure",
+ label: "Infrastructure",
+ description: "Reverse proxy, website delivery, and HTTPS automation.",
+ services: "Nginx, Apache, Caddy, and Certbot",
+ },
+ {
+ id: "php-runtime",
+ label: "PHP Runtime",
+ description: "Runtime and dependency tooling for native PHP applications.",
+ services: "PHP and Composer",
+ optionalNote:
+ "Optional — install only when PHP applications run directly on this server.",
+ },
+ {
+ id: "node-runtime",
+ label: "Node.js Runtime",
+ description: "Runtime and process management for Node.js applications.",
+ services: "Node.js and PM2",
+ optionalNote:
+ "Optional — needed only when Node.js applications run directly on this server, not in Docker.",
+ },
+ {
+ id: "data-services",
+ label: "Data Services",
+ description: "Database and cache services used by web applications.",
+ services: "MySQL, PostgreSQL, and Redis",
+ optionalNote:
+ "Optional — install only when this VPS will host the application together with its database or cache. For larger production workloads, prefer a dedicated or managed data service.",
+ },
+] as const;
+
+export default function WebServerManagerModal({
+ server,
+ onClose,
+ onActionComplete,
+}: {
+ server: ServerType;
+ onClose: () => void;
+ onActionComplete: (message: string, tone?: "success" | "error") => void;
+}) {
+ const [snapshot, setSnapshot] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [selectedGroup, setSelectedGroup] = useState<
+ (typeof serviceGroups)[number]["id"]
+ >("infrastructure");
+ const [runningAction, setRunningAction] = useState(null);
+ const [pendingAction, setPendingAction] = useState<{
+ component: WebStackComponentKey;
+ action: WebStackAction;
+ } | null>(null);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await serversApi.getConfig(server.id);
+ setSnapshot(response.data);
+ } catch (caught: unknown) {
+ setError(caught instanceof Error ? caught.message : "Could not load live web server data.");
+ } finally {
+ setLoading(false);
+ }
+ }, [server.id]);
+
+ useEffect(() => {
+ const refreshTimer = window.setTimeout(() => {
+ void refresh();
+ }, 0);
+
+ return () => window.clearTimeout(refreshTimer);
+ }, [refresh]);
+
+ const getActionKey = useCallback(
+ (component: WebStackComponentKey, action: WebStackAction) =>
+ `web-stack:${component}:${action}`,
+ [],
+ );
+ const selectedGroupInfo = serviceGroups.find(
+ (group) => group.id === selectedGroup,
+ )!;
+
+ const runAction = async () => {
+ if (!pendingAction) return;
+
+ const current = pendingAction;
+ const actionKey = getActionKey(current.component, current.action);
+ setPendingAction(null);
+ setRunningAction(actionKey);
+ setError(null);
+ try {
+ const response = await serversApi.manageWebStack(
+ server.id,
+ current.component,
+ current.action,
+ );
+ setSnapshot((previous) =>
+ previous ? { ...previous, webServer: response.data } : previous,
+ );
+ const message =
+ response.message ||
+ `${getWebStackActionLabel(current.action)} completed for ${getWebStackComponentLabel(current.component)}.`;
+ onActionComplete(message);
+ await refresh();
+ } catch (caught: unknown) {
+ const message =
+ caught instanceof Error
+ ? caught.message
+ : `Failed to ${current.action} ${current.component}.`;
+ setError(message);
+ onActionComplete(message, "error");
+ } finally {
+ setRunningAction(null);
+ }
+ };
+
+ const pendingLabel = pendingAction
+ ? getWebStackComponentLabel(pendingAction.component)
+ : "";
+
+ return (
+
+ {pendingAction ? (
+
setPendingAction(null)}
+ onConfirm={() => void runAction()}
+ />
+ ) : null}
+
+
+
+
+
+
+
+
+
Manage Web Server
+
+
+
{server.name} • {server.ip}:{server.sshPort}
+
Live service status refreshes when this modal opens or when you click Refresh.
+
+
void refresh()} disabled={loading}>
+ {loading ? : } Refresh
+
+
+
+
+ {serviceGroups.map((group) => (
+ setSelectedGroup(group.id)} className="btn btn-ghost" style={{ height: 28, minHeight: 28, padding: "5px 12px", borderRadius: 4, boxSizing: "border-box", borderColor: selectedGroup === group.id ? "rgba(59,130,246,0.5)" : "transparent", background: selectedGroup === group.id ? "rgba(59,130,246,0.16)" : "transparent", color: selectedGroup === group.id ? "var(--accent-blue)" : "var(--text-secondary)", flex: "0 0 auto", fontSize: 12 }}>
+ {group.label}
+
+ ))}
+
+
+
+
+ {selectedGroupInfo.label}
+ {selectedGroupInfo.description}
+ Services: {selectedGroupInfo.services}
+ {"optionalNote" in selectedGroupInfo && selectedGroupInfo.optionalNote ? {selectedGroupInfo.optionalNote} : null}
+
+
+ {error ?
: null}
+ {loading && !snapshot ?
Loading web server status...
: null}
+ {snapshot ?
runningAction === key} getWebStackActionKey={getActionKey} onRequestWebStackActionConfirm={(component, action) => setPendingAction({ component, action })} /> : null}
+
+
+
+
+ );
+}
diff --git a/src/app/servers/components/server-config-utils.ts b/src/app/servers/components/server-config-utils.ts
index b16b3a2..3d33746 100644
--- a/src/app/servers/components/server-config-utils.ts
+++ b/src/app/servers/components/server-config-utils.ts
@@ -13,7 +13,6 @@ export type ServerConfigTab =
| "users"
| "ssh-access"
| "services"
- | "web-server"
| "mounts"
| "actions";
@@ -36,18 +35,11 @@ export type ServerConfigNotice = {
export type ServerPendingConfirm =
| {
kind: "server";
- action: "restart-nginx" | "reboot" | "prune-docker";
+ action: "restart-nginx" | "reboot";
title: string;
description: string;
confirmLabel: string;
tone: "danger" | "warning" | "info";
- pruneOptions?: {
- images: boolean;
- containers: boolean;
- networks: boolean;
- volumes: boolean;
- buildCache: boolean;
- };
}
| {
kind: "system-user";
@@ -150,14 +142,6 @@ export type ServerPendingConfirm =
confirmLabel: string;
tone: "danger" | "warning" | "info";
}
- | {
- kind: "docker";
- action: "install" | "uninstall" | "reinstall";
- title: string;
- description: string;
- confirmLabel: string;
- tone: "danger" | "warning" | "info";
- }
| {
kind: "service";
serviceName: string;
@@ -166,15 +150,6 @@ export type ServerPendingConfirm =
confirmLabel: string;
tone: "danger" | "warning" | "info";
}
- | {
- kind: "web-stack";
- component: WebStackComponentKey;
- action: WebStackAction;
- title: string;
- description: string;
- confirmLabel: string;
- tone: "danger" | "warning" | "info";
- };
export function canRestartService(serviceName: string): boolean {
return [
@@ -203,19 +178,6 @@ export function getWebStackActionLabel(action: WebStackAction): string {
}
}
-export function getDockerActionLabel(
- action: "install" | "uninstall" | "reinstall",
-): string {
- switch (action) {
- case "install":
- return "Install Docker";
- case "uninstall":
- return "Remove Docker";
- case "reinstall":
- return "Reinstall Docker";
- }
-}
-
export function getWebStackActionStyle(action: WebStackAction) {
if (action === "remove") {
return {
@@ -283,20 +245,6 @@ export function getWebStackActionTone(
return "info";
}
-export function getDockerActionTone(
- action: "install" | "uninstall" | "reinstall",
-): "danger" | "warning" | "info" {
- if (action === "uninstall") {
- return "danger";
- }
-
- if (action === "reinstall") {
- return "warning";
- }
-
- return "info";
-}
-
export function getServiceRestartTone(
serviceName: string,
): "danger" | "warning" | "info" {
@@ -325,20 +273,6 @@ export function getWebStackActionDescription(
}
}
-export function getDockerActionDescription(
- serverName: string,
- action: "install" | "uninstall" | "reinstall",
-): string {
- switch (action) {
- case "install":
- return `This will install Docker on ${serverName} and may add packages, services, and system groups on the host.`;
- case "uninstall":
- return `This will remove the Docker runtime from ${serverName}. Existing containers, images, and dependent workloads may stop working immediately.`;
- case "reinstall":
- return `This will remove and reinstall Docker on ${serverName} to repair the runtime. Containers and related workloads may restart or become unavailable during the process.`;
- }
-}
-
export function getServiceRestartDescription(serviceName: string): string {
const normalizedName = serviceName.toLowerCase();
@@ -353,33 +287,6 @@ export function getServiceRestartDescription(serviceName: string): string {
return `This will restart the ${serviceName} service on the host. Active requests or background work that depend on it may reconnect briefly.`;
}
-export function getDockerPruneSummary(options: {
- images: boolean;
- containers: boolean;
- networks: boolean;
- volumes: boolean;
- buildCache: boolean;
-}): string {
- const labels = [
- options.images ? "images" : null,
- options.containers ? "stopped containers" : null,
- options.networks ? "unused networks" : null,
- options.volumes ? "unused volumes" : null,
- options.buildCache ? "build cache" : null,
- ].filter((value): value is string => Boolean(value));
-
- if (labels.length === 0) {
- return "Select at least one Docker artifact to prune.";
- }
-
- if (labels.length === 1) {
- return `This will remove unused ${labels[0]} only. Active containers will remain untouched.`;
- }
-
- const lastLabel = labels[labels.length - 1];
- return `This will remove unused ${labels.slice(0, -1).join(", ")} and ${lastLabel}. Active containers will remain untouched.`;
-}
-
function getFallbackWebStackComponentCategory(
component: WebStackComponentKey,
): WebStackComponentStatus["category"] {
diff --git a/src/app/servers/components/server-config/ServerConfigActionsPanel.tsx b/src/app/servers/components/server-config/ServerConfigActionsPanel.tsx
index b3e4c4d..7b69d31 100644
--- a/src/app/servers/components/server-config/ServerConfigActionsPanel.tsx
+++ b/src/app/servers/components/server-config/ServerConfigActionsPanel.tsx
@@ -1,7 +1,7 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
-import { Boxes, Loader2, Power, RefreshCw, Trash2 } from "lucide-react";
+import { Loader2, Power, RefreshCw } from "lucide-react";
import type { Server as ServerType, ServerConfigSnapshot } from "@/lib/api";
import { ConfigInfoRow } from "@/app/servers/components/server-config/ServerConfigPrimitives";
@@ -15,16 +15,8 @@ interface ServerConfigActionsPanelProps {
setConfirmResetStep: Dispatch>;
deleteConfirmed: boolean;
isActionRunning: (actionKey: string) => boolean;
- getServerActionKey: (
- action: "reboot" | "restart-nginx" | "prune-docker",
- ) => string;
- getDockerActionKey: (action: "install" | "uninstall" | "reinstall") => string;
- onRequestServerActionConfirm: (
- action: "reboot" | "restart-nginx" | "prune-docker",
- ) => void;
- onRequestDockerActionConfirm: (
- action: "install" | "uninstall" | "reinstall",
- ) => void;
+ getServerActionKey: (action: "reboot" | "restart-nginx") => string;
+ onRequestServerActionConfirm: (action: "reboot" | "restart-nginx") => void;
onReset: () => Promise;
setError: Dispatch>;
}
@@ -40,19 +32,13 @@ export default function ServerConfigActionsPanel({
deleteConfirmed,
isActionRunning,
getServerActionKey,
- getDockerActionKey,
onRequestServerActionConfirm,
- onRequestDockerActionConfirm,
onReset,
setError,
}: ServerConfigActionsPanelProps) {
const dockerStatusUnavailable = Boolean(
snapshotLoadError || snapshot.docker.probeFailed,
);
- const showInstallDocker =
- dockerStatusUnavailable || !snapshot.docker.installed;
- const showRuntimeMaintenance =
- dockerStatusUnavailable || snapshot.docker.installed;
return (
Live runtime status could not be loaded, but host-level actions
are still available so you can try rebooting the server,
- restarting the web server, or repairing Docker.
+ restarting the web server.
{snapshotLoadError}
@@ -149,123 +135,6 @@ export default function ServerConfigActionsPanel({
-
-
-
-
- Docker Maintenance
-
-
-
- Install Docker when it is missing, or remove and reinstall it when
- the runtime needs a clean recovery.
-
-
- {showInstallDocker ? (
- onRequestDockerActionConfirm("install")}
- disabled={
- (!dockerStatusUnavailable &&
- isActionRunning(getDockerActionKey("install"))) ||
- (!dockerStatusUnavailable && !snapshot.docker.canInstall)
- }
- style={{
- background: "rgba(59,130,246,0.12)",
- color: "#3b82f6",
- border: "1px solid rgba(59,130,246,0.22)",
- }}
- >
- {isActionRunning(getDockerActionKey("install")) ? (
-
- ) : (
-
- )}
- Install Docker
-
- ) : null}
-
- {showRuntimeMaintenance ? (
- <>
- onRequestDockerActionConfirm("reinstall")}
- disabled={isActionRunning(getDockerActionKey("reinstall"))}
- style={{
- background: "rgba(59,130,246,0.12)",
- color: "#3b82f6",
- border: "1px solid rgba(59,130,246,0.22)",
- }}
- >
- {isActionRunning(getDockerActionKey("reinstall")) ? (
-
- ) : (
-
- )}
- Reinstall
-
- onRequestDockerActionConfirm("uninstall")}
- disabled={isActionRunning(getDockerActionKey("uninstall"))}
- style={{
- background: "rgba(245,158,11,0.12)",
- color: "#f59e0b",
- border: "1px solid rgba(245,158,11,0.22)",
- }}
- >
- {isActionRunning(getDockerActionKey("uninstall")) ? (
-
- ) : (
-
- )}
- Remove
-
- onRequestServerActionConfirm("prune-docker")}
- disabled={isActionRunning(getServerActionKey("prune-docker"))}
- style={{
- background: "rgba(239,68,68,0.12)",
- color: "#ef4444",
- border: "1px solid rgba(239,68,68,0.22)",
- }}
- >
- {isActionRunning(getServerActionKey("prune-docker")) ? (
-
- ) : (
-
- )}
- Prune Garbage
-
- >
- ) : null}
-
- {!dockerStatusUnavailable &&
- !snapshot.docker.canInstall &&
- !snapshot.docker.installed ? (
-
- Docker install requires root access or non-interactive sudo on
- this server.
-
- ) : null}
-
-
diff --git a/src/app/servers/components/server-config/ServerConfigWebServerPanel.tsx b/src/app/servers/components/server-config/ServerConfigWebServerPanel.tsx
index 27dab8e..fbd9d0a 100644
--- a/src/app/servers/components/server-config/ServerConfigWebServerPanel.tsx
+++ b/src/app/servers/components/server-config/ServerConfigWebServerPanel.tsx
@@ -16,17 +16,22 @@ import type {
WebStackAction,
WebStackComponentKey,
} from "@/lib/api";
-import {
- ServiceStatusBadge,
- UserBadge,
-} from "@/app/servers/components/server-config/ServerConfigPrimitives";
+import { UserBadge } from "@/app/servers/components/server-config/ServerConfigPrimitives";
import {
getWebStackActionLabel,
getWebStackActionStyle,
} from "@/app/servers/components/server-config-utils";
+type WebServerServiceGroup =
+ | "infrastructure"
+ | "php-runtime"
+ | "node-runtime"
+ | "data-services";
+
interface ServerConfigWebServerPanelProps {
snapshot: ServerConfigSnapshot;
+ serviceGroup?: WebServerServiceGroup;
+ hideComponentGroups?: boolean;
isActionRunning: (actionKey: string) => boolean;
getWebStackActionKey: (
component: WebStackComponentKey,
@@ -40,6 +45,8 @@ interface ServerConfigWebServerPanelProps {
export default function ServerConfigWebServerPanel({
snapshot,
+ serviceGroup,
+ hideComponentGroups = false,
isActionRunning,
getWebStackActionKey,
onRequestWebStackActionConfirm,
@@ -60,12 +67,23 @@ export default function ServerConfigWebServerPanel({
(component) =>
component.category !== "web-server" && component.key !== "certbot",
);
+ const groupComponentKeys: Record<
+ WebServerServiceGroup,
+ readonly WebStackComponentKey[]
+ > = {
+ infrastructure: ["nginx", "apache", "caddy", "certbot"],
+ "php-runtime": ["php", "composer"],
+ "node-runtime": ["nodejs", "pm2"],
+ "data-services": ["mysql", "postgresql", "redis"],
+ };
- const visibleComponents =
- activeGroup === "infrastructure"
+ const visibleComponents = serviceGroup
+ ? snapshot.webServer.components.filter((component) =>
+ groupComponentKeys[serviceGroup].includes(component.key),
+ )
+ : activeGroup === "infrastructure"
? infrastructureComponents
: runtimeToolComponents;
-
const renderComponentCard = (component: WebStackComponentStatus) => (
@@ -84,27 +102,18 @@ export default function ServerConfigWebServerPanel({
-
-
- {component.version ? (
-
- ) : null}
- {component.serviceName ? (
-
- ) : null}
- {component.active ? (
-
- ) : null}
- {component.enabled ? (
-
- ) : null}
-
-
-
- {component.recommendedFor.map((target) => (
-
- ))}
-
+ {component.installed ? (
+
+ {[
+ component.version ? `Version ${component.version}` : null,
+ component.serviceName ? `Service ${component.serviceName}` : null,
+ component.active ? `Status ${component.active}` : null,
+ component.enabled ? `Startup ${component.enabled}` : null,
+ ]
+ .filter(Boolean)
+ .join(" / ")}
+
+ ) : null}
{component.notes.length > 0 ? (
-
-
-
- {activeIssueDetail.label} Issues
-
-
- Service information returned by the current server
- snapshot.
-
-
-
setActiveIssueDetail(null)}
- aria-label="Close issue details"
- className="server-config-issue-close"
- >
-
-
-
-
- {activeIssueDetail.notes.map((note) => (
-
- {note}
+
+
+
+ {activeIssueDetail.label} Issues
+
+
+ Service information returned by the current server
+ snapshot.
+
- ))}
-
+
setActiveIssueDetail(null)}
+ aria-label="Close issue details"
+ className="server-config-issue-close"
+ >
+
+
+
+
+ {activeIssueDetail.notes.map((note) => (
+
+ {note}
+
+ ))}
+
,
@@ -219,12 +228,15 @@ export default function ServerConfigWebServerPanel({
)
: null}
-
+ {/*
Web Server Readiness
-
+
{snapshot.webServer.summary}
-
-
-
- {snapshot.webServer.primaryWebServer ? (
-
- ) : null}
-
-
-
-
-
-
-
-
-
+
+ {snapshot.webServer.primaryWebServer ?? "No primary web server"}
+ {" / "}
+ {snapshot.webServer.packageManager ?? "No package manager"}
+ {" / "}
+ {readyCapabilityCount} of {totalCapabilityCount} capabilities ready
+
{snapshot.webServer.notes.length > 0 ? (
-
- {snapshot.webServer.notes.map((note) => (
-
-
-
{note}
+
+
setShowReadinessNotes((current) => !current)}
+ aria-expanded={showReadinessNotes}
+ style={{
+ justifySelf: "start",
+ color: "#b45309",
+ padding: "4px 0",
+ border: 0,
+ background: "transparent",
+ }}
+ >
+
+ {showReadinessNotes ? "Hide" : "Show"}{" "}
+ {snapshot.webServer.notes.length} setup recommendation
+ {snapshot.webServer.notes.length === 1 ? "" : "s"}
+
+ {showReadinessNotes ? (
+
+ {snapshot.webServer.notes.map((note) => (
+
+ {note}
+
+ ))}
- ))}
+ ) : null}
) : null}
-
+
*/}
-
-
-
-
- Component Groups
-
-
- Switch between core host infrastructure and runtime packages that
- support native deployments on the server.
-
-
-
- setActiveGroup("infrastructure")}
- style={{
- borderColor:
- activeGroup === "infrastructure"
- ? "rgba(59,130,246,0.35)"
- : "var(--border)",
- background:
- activeGroup === "infrastructure"
- ? "rgba(59,130,246,0.12)"
- : "var(--bg-input)",
- color:
- activeGroup === "infrastructure"
- ? "#3b82f6"
- : "var(--text-secondary)",
- }}
- >
- Infrastructure
-
- setActiveGroup("runtime-tools")}
- style={{
- borderColor:
- activeGroup === "runtime-tools"
- ? "rgba(59,130,246,0.35)"
- : "var(--border)",
- background:
- activeGroup === "runtime-tools"
- ? "rgba(59,130,246,0.12)"
- : "var(--bg-input)",
- color:
- activeGroup === "runtime-tools"
- ? "#3b82f6"
- : "var(--text-secondary)",
- }}
- >
- Runtime Tools
-
-
+ {!hideComponentGroups ? (
+
+
+
+ Components
+
+
+ {activeGroup === "infrastructure"
+ ? "Reverse proxy, site delivery, and HTTPS."
+ : "Runtimes, databases, cache, and supporting tools."}
+
-
-
-
- {activeGroup === "infrastructure"
- ? "Infrastructure"
- : "Runtime Tools"}
-
-
- {activeGroup === "infrastructure"
- ? "Core host services for reverse proxy, site delivery, and HTTPS automation."
- : "Optional native host deployment packages for app runtimes, databases, queues, and supporting tools."}
-
-
-
-
-
-
- {activeGroup === "runtime-tools" ? (
-
- ) : null}
-
+
setActiveGroup("infrastructure")}
+ aria-pressed={activeGroup === "infrastructure"}
+ style={{
+ borderColor:
+ activeGroup === "infrastructure"
+ ? "rgba(59,130,246,0.35)"
+ : "var(--border)",
+ background:
+ activeGroup === "infrastructure"
+ ? "rgba(59,130,246,0.12)"
+ : "var(--bg-input)",
+ color:
+ activeGroup === "infrastructure"
+ ? "#3b82f6"
+ : "var(--text-secondary)",
+ }}
+ >
+ Infrastructure ({infrastructureComponents.length})
+
+
setActiveGroup("runtime-tools")}
+ aria-pressed={activeGroup === "runtime-tools"}
+ style={{
+ borderColor:
+ activeGroup === "runtime-tools"
+ ? "rgba(59,130,246,0.35)"
+ : "var(--border)",
+ background:
+ activeGroup === "runtime-tools"
+ ? "rgba(59,130,246,0.12)"
+ : "var(--bg-input)",
+ color:
+ activeGroup === "runtime-tools"
+ ? "#3b82f6"
+ : "var(--text-secondary)",
+ }}
+ >
+ Runtime Tools ({runtimeToolComponents.length})
+
+ ) : null}
{visibleComponents.map((component) => renderComponentCard(component))}
@@ -459,5 +407,3 @@ export default function ServerConfigWebServerPanel({
);
}
-
-
diff --git a/src/app/servers/page.tsx b/src/app/servers/page.tsx
index 468490a..bd4355c 100644
--- a/src/app/servers/page.tsx
+++ b/src/app/servers/page.tsx
@@ -1,4 +1,4 @@
-"use client";
+"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
@@ -7,6 +7,8 @@ import ConfirmActionDialog from "@/components/ConfirmActionDialog";
import DashboardLayout from "@/components/DashboardLayout";
import ToastViewport from "@/components/ToastViewport";
import ServerConfigModal from "@/app/servers/components/ServerConfigModal";
+import WebServerManagerModal from "@/app/servers/components/WebServerManagerModal";
+import DockerManagerModal from "@/app/servers/components/DockerManagerModal";
import ServerFormModal from "@/app/servers/components/ServerFormModal";
import ServersSummary from "@/app/servers/components/ServersSummary";
import ServersTable from "@/app/servers/components/ServersTable";
@@ -16,6 +18,7 @@ import {
servers as serversApi,
type ServerMetric,
type Server as ServerType,
+ canViewServerConnection,
} from "@/lib/api";
import { useTablePagination } from "@/lib/use-table-pagination";
import { useToastManager } from "@/lib/use-toast-manager";
@@ -30,6 +33,7 @@ type PendingConfirmAction = {
};
export default function ServersPage() {
+ const showConnectionDetails = canViewServerConnection();
const router = useRouter();
const [data, setData] = useState
([]);
const [loading, setLoading] = useState(true);
@@ -51,13 +55,14 @@ export default function ServersPage() {
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteConfirmation, setDeleteConfirmation] = useState("");
const [confirmDeleteStep, setConfirmDeleteStep] = useState(false);
- const [installingDocker, setInstallingDocker] = useState<
- Record
- >({});
const [confirmDialog, setConfirmDialog] =
useState(null);
const [openMenuId, setOpenMenuId] = useState(null);
const [configServer, setConfigServer] = useState(null);
+ const [webServerManagerServer, setWebServerManagerServer] =
+ useState(null);
+ const [dockerManagerServer, setDockerManagerServer] =
+ useState(null);
const [expandedMetricsId, setExpandedMetricsId] = useState(
null,
);
@@ -316,45 +321,6 @@ export default function ServersPage() {
}
};
- const installDocker = async (server: ServerType) => {
- setInstallingDocker((prev) => ({ ...prev, [server.id]: true }));
- try {
- const res = await serversApi.installDocker(server.id);
- await fetchDockerStatuses([server.id]);
- pushToast({
- tone: res.data.available ? "success" : "error",
- title: "Docker Install",
- message: res.data.available
- ? `Docker was installed on ${server.name}`
- : res.data.reason || `Docker is still not ready on ${server.name}`,
- showProgress: true,
- });
- } catch (err: unknown) {
- pushToast({
- tone: "error",
- title: "Docker Install",
- message:
- err instanceof Error ? err.message : "Failed to install Docker",
- showProgress: true,
- });
- } finally {
- setInstallingDocker((prev) => ({ ...prev, [server.id]: false }));
- }
- };
-
- const handleInstallDocker = async (server: ServerType) => {
- setConfirmDialog({
- title: "Install Docker",
- description: `Install Docker on "${server.name}" now? This requires non-interactive sudo if the SSH user is not root.`,
- confirmLabel: "Install Docker",
- tone: "warning",
- note: "The host must allow the SSH user to run the installer without interactive sudo prompts.",
- onConfirm: () => {
- void installDocker(server);
- },
- });
- };
-
const handleToggleMetrics = useCallback(
async (server: ServerType) => {
setOpenMenuId(null);
@@ -403,7 +369,7 @@ export default function ServersPage() {
const matchesSearch =
!query ||
server.name.toLowerCase().includes(query) ||
- server.ip.toLowerCase().includes(query) ||
+ (showConnectionDetails && server.ip.toLowerCase().includes(query)) ||
(server.location ?? "").toLowerCase().includes(query) ||
server.status.toLowerCase().includes(query);
@@ -680,6 +646,36 @@ export default function ServersPage() {
}}
/>
) : null}
+ {webServerManagerServer ? (
+ setWebServerManagerServer(null)}
+ onActionComplete={(message, tone = "success") => {
+ pushToast({
+ tone,
+ title: tone === "error" ? "Web Server" : "Web Server Update",
+ message,
+ showProgress: true,
+ });
+ void load();
+ }}
+ />
+ ) : null}
+ {dockerManagerServer ? (
+ setDockerManagerServer(null)}
+ onActionComplete={(message, tone = "success") => {
+ pushToast({
+ tone,
+ title: tone === "error" ? "Docker Manager" : "Docker Update",
+ message,
+ showProgress: true,
+ });
+ void load();
+ }}
+ />
+ ) : null}
router.push(
- `/terminal?serverId=${server.id}&name=${encodeURIComponent(server.name)}&ip=${server.ip}`,
+ `/terminal?serverId=${server.id}&name=${encodeURIComponent(server.name)}`,
)
}
- onInstallDocker={handleInstallDocker}
onToggleMenu={(serverId) =>
setOpenMenuId((current) => (current === serverId ? null : serverId))
}
@@ -729,6 +723,14 @@ export default function ServersPage() {
setConfigServer(server);
setOpenMenuId(null);
}}
+ onOpenWebServerManager={(server) => {
+ setWebServerManagerServer(server);
+ setOpenMenuId(null);
+ }}
+ onOpenDockerManager={(server) => {
+ setDockerManagerServer(server);
+ setOpenMenuId(null);
+ }}
onToggleMetrics={handleToggleMetrics}
onEdit={(server) => {
setEditingServer(server);
@@ -745,5 +747,3 @@ export default function ServersPage() {
);
}
-
-
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 729be7e..46ee0bf 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -1846,7 +1846,7 @@ export const servers = {
post<{ success: boolean; data: DockerRuntimeStatus }>(
`/servers/${id}/docker/install`,
{},
- { timeoutMs: 45000 },
+ { timeoutMs: 10 * 60 * 1000 },
),
pruneDocker: (id: string, options?: DockerPruneOptions) =>
post<{
@@ -1862,6 +1862,12 @@ export const servers = {
{},
{ timeoutMs: 45000 },
),
+ repairDocker: (id: string) =>
+ post<{ success: boolean; data: DockerRuntimeStatus }>(
+ `/servers/${id}/docker/repair`,
+ {},
+ { timeoutMs: 60000 },
+ ),
reinstallDocker: (id: string) =>
post<{ success: boolean; data: DockerRuntimeStatus }>(
`/servers/${id}/docker/reinstall`,
diff --git a/src/server/routes/servers.ts b/src/server/routes/servers.ts
index 8ca4b61..b3ee8f6 100644
--- a/src/server/routes/servers.ts
+++ b/src/server/routes/servers.ts
@@ -720,7 +720,11 @@ export async function serverRoutes(app: FastifyInstance) {
{ preHandler: serverReadAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
- const server = await getAccessibleServer(req.userId, id, req.organizationId);
+ const server = await getAccessibleServer(
+ req.userId,
+ id,
+ req.organizationId,
+ );
if (!server) {
return reply.status(403).send({ success: false, error: "Forbidden" });
}
@@ -731,7 +735,10 @@ export async function serverRoutes(app: FastifyInstance) {
} catch (err: unknown) {
return reply.status(500).send({
success: false,
- error: err instanceof Error ? err.message : "Failed to inspect domain proxy capability",
+ error:
+ err instanceof Error
+ ? err.message
+ : "Failed to inspect domain proxy capability",
});
}
},
@@ -1905,6 +1912,60 @@ export async function serverRoutes(app: FastifyInstance) {
},
);
+ app.post(
+ "/:id/docker/repair",
+ { preHandler: serverWriteAccess },
+ async (req, reply) => {
+ const { id } = req.params as { id: string };
+ const server = await getAccessibleServer(
+ req.userId,
+ id,
+ req.organizationId,
+ );
+ if (!server) {
+ return reply.status(403).send({
+ success: false,
+ error: "Forbidden — you do not have access to this server",
+ });
+ }
+
+ try {
+ const docker = await ssh.repairDockerEngine(server);
+
+ await auditLog({
+ userId: req.userId,
+ serverId: id,
+ action: "DOCKER_REPAIR",
+ category: "SERVER",
+ level: docker.available ? "SUCCESS" : "WARNING",
+ message: `Docker repair executed on \"${server.name}\"`,
+ meta: {
+ available: docker.available,
+ version: docker.version,
+ reason: docker.reason,
+ },
+ });
+
+ return reply.send({ success: true, data: docker });
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : "Unknown error";
+ await auditLog({
+ userId: req.userId,
+ serverId: id,
+ action: "DOCKER_REPAIR_FAILED",
+ category: "SERVER",
+ level: "ERROR",
+ message: `Docker repair failed on \"${server.name}\": ${message}`,
+ });
+
+ return reply.status(400).send({
+ success: false,
+ error: message || "Failed to repair Docker",
+ });
+ }
+ },
+ );
+
app.post(
"/:id/docker/reinstall",
{ preHandler: serverWriteAccess },
diff --git a/src/server/services/ssh-services/docker-engine.ts b/src/server/services/ssh-services/docker-engine.ts
index 015ac0e..6f8402f 100644
--- a/src/server/services/ssh-services/docker-engine.ts
+++ b/src/server/services/ssh-services/docker-engine.ts
@@ -284,6 +284,44 @@ export async function installDockerEngine(
return getDockerRuntimeStatus(server);
}
+/**
+ * Restore an existing Docker daemon without replacing packages or touching
+ * containers, images, volumes, networks, or Docker configuration.
+ */
+export async function repairDockerEngine(
+ server: Server,
+): Promise {
+ const docker = await getDockerRuntimeStatus(server);
+
+ if (!docker.installed) {
+ throw new Error("Docker is not installed on this server. Install Docker instead.");
+ }
+
+ if (!docker.platform.sudoNonInteractive && server.username !== "root") {
+ throw new Error(
+ `Docker repair requires non-interactive sudo access on server ${server.name}`,
+ );
+ }
+
+ await execStrict(
+ server,
+ privilegedCommand(
+ server,
+ "systemctl reset-failed docker || true; systemctl daemon-reload; systemctl enable --now docker",
+ ),
+ );
+
+ const repaired = await getDockerRuntimeStatus(server);
+ if (!repaired.available) {
+ throw new Error(
+ repaired.reason ||
+ "Docker is still unavailable after starting the daemon. Check the Docker service logs.",
+ );
+ }
+
+ return repaired;
+}
+
export async function uninstallDockerEngine(
server: Server,
): Promise {