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}. +

+
+ +
+ + {error ? ( +
+ {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 ? ( + + ) : !docker.available ? ( + + ) : null} + {docker?.installed ? ( + <> + + + + ) : null} +
+
+ + {docker?.installed ? ( +
+
+ + Prune Docker Garbage + +

+ Select unused artifacts to remove, then confirm the cleanup. +

+
+
+ {cleanupOptions.map((option) => ( + + ))} +
+
+ + {selectedCleanupCount} cleanup option + {selectedCleanupCount === 1 ? "" : "s"} selected + + +
+
+ ) : 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({ } - {canInstallDocker ? ( - - ) : null} + + +
+
+
+
+

Manage Web Server

+ +
+

{server.name} • {server.ip}:{server.sshPort}

+

Live service status refreshes when this modal opens or when you click Refresh.

+
+ +
+ + + +
+
+ {selectedGroupInfo.label} + {selectedGroupInfo.description} + Services: {selectedGroupInfo.services} + {"optionalNote" in selectedGroupInfo && selectedGroupInfo.optionalNote ? {selectedGroupInfo.optionalNote} : null} +
+ + {error ?
{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 ? ( - - ) : null} - - {showRuntimeMaintenance ? ( - <> - - - - - ) : 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.notes.map((note) => ( -
- {note} +
+
+ + {activeIssueDetail.label} Issues + +

+ Service information returned by the current server + snapshot. +

- ))} -
+ +
+
+ {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} +
+ + {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. -

-
-
- - -
+ {!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} -
+ +
+ ) : 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 {