feat: add WebServerManagerModal and DockerManagerModal components for server management

- Implemented WebServerManagerModal for managing web server configurations, including service actions and live status updates.
- Introduced DockerManagerModal for Docker management, allowing installation, repair, and cleanup of Docker artifacts.
- Both components include user-friendly interfaces with action confirmation dialogs and error handling.
This commit is contained in:
KodekaTeam committed 2026-08-11 01:23:00 +07:00
1 parent 92737dd892
commit 310b13f329
12 files changed
+1319 -896

No files matched your search

+247 -4
View File
@@ -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 {
@@ -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<ServerConfigSnapshot | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="modal-overlay">
{confirmConfig && pendingAction ? (
<ConfirmActionDialog
open
title={confirmConfig.title}
description={confirmConfig.description}
confirmLabel={confirmConfig.confirmLabel}
tone={confirmConfig.tone}
note={confirmConfig.note}
onClose={() => setPendingAction(null)}
onConfirm={() => void runAction(pendingAction)}
/>
) : null}
<div className="modal-shell" style={{ maxWidth: 760 }}>
<button
type="button"
onClick={onClose}
className="modal-close"
aria-label="Close Docker manager"
>
<X size={22} />
</button>
<div
className="modal animate-slide-in"
style={{
maxWidth: 760,
maxHeight: "90vh",
overflow: "auto",
padding: 24,
display: "grid",
gap: 16,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: 12,
flexWrap: "wrap",
}}
>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<h2 style={{ color: "var(--text-primary)", fontSize: 18 }}>
Manage Docker
</h2>
</div>
<p className="server-config-component-description">
Live Docker status and maintenance controls for {server.name}.
</p>
</div>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => void refresh()}
disabled={loading}
>
{loading ? (
<Loader2 size={13} className="animate-spin" />
) : (
<RefreshCw size={13} />
)}
Refresh
</button>
</div>
{error ? (
<div
style={{
display: "flex",
gap: 8,
padding: 12,
borderRadius: 8,
color: "var(--text-danger)",
background: "rgba(248,113,113,0.12)",
border: "1px solid rgba(248,113,113,0.25)",
fontSize: 12,
}}
>
<AlertTriangle size={15} /> {error}
</div>
) : null}
<div
className="card"
style={{ padding: 18, display: "grid", gap: 12 }}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: 12,
flexWrap: "wrap",
}}
>
<div>
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
Docker Runtime
</strong>
<p className="server-config-component-description">
{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."}
</p>
</div>
<UserBadge
label={
loading
? "Checking"
: docker?.available
? "Running"
: docker?.installed
? "Needs Repair"
: "Not Installed"
}
tone={
docker?.available
? "success"
: loading
? "neutral"
: "warning"
}
/>
</div>
<p style={{ margin: 0, color: "var(--text-muted)", fontSize: 12 }}>
Version: {docker?.version ?? "Not detected"} / Package manager:{" "}
{docker?.platform.packageManager ?? "Not detected"}
</p>
{docker?.reason ? (
<p style={{ margin: 0, color: "#b45309", fontSize: 12 }}>
{docker.reason}
</p>
) : null}
<div
style={{
display: "flex",
gap: 8,
flexWrap: "wrap",
paddingTop: 12,
borderTop: "1px solid var(--border)",
}}
>
{!docker?.installed ? (
<button
type="button"
className="btn btn-primary btn-sm"
disabled={runningAction !== null || loading}
onClick={() => setPendingAction("install")}
>
{runningAction === "install" ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Boxes size={13} />
)}{" "}
Install Docker
</button>
) : !docker.available ? (
<button
type="button"
className="btn btn-primary btn-sm"
disabled={runningAction !== null || loading}
onClick={() => setPendingAction("repair")}
>
{runningAction === "repair" ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Boxes size={13} />
)}{" "}
Repair Docker
</button>
) : null}
{docker?.installed ? (
<>
<button
type="button"
className="btn btn-ghost btn-sm"
disabled={runningAction !== null || loading}
onClick={() => setPendingAction("reinstall")}
>
{runningAction === "reinstall" ? (
<Loader2 size={13} className="animate-spin" />
) : (
<RefreshCw size={13} />
)}{" "}
Reinstall Docker
</button>
<button
type="button"
className="btn btn-ghost btn-sm"
disabled={runningAction !== null || loading}
onClick={() => setPendingAction("uninstall")}
style={{ color: "#ef4444" }}
>
{runningAction === "uninstall" ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Trash2 size={13} />
)}{" "}
Remove Docker
</button>
</>
) : null}
</div>
</div>
{docker?.installed ? (
<div
className="card"
style={{ padding: 18, display: "grid", gap: 12 }}
>
<div>
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
Prune Docker Garbage
</strong>
<p className="server-config-component-description">
Select unused artifacts to remove, then confirm the cleanup.
</p>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
gap: 8,
}}
>
{cleanupOptions.map((option) => (
<label
key={option.key}
style={{
display: "grid",
gridTemplateColumns: "auto minmax(0, 1fr)",
alignItems: "start",
gap: 8,
padding: "10px 12px",
border: "1px solid var(--border)",
borderRadius: 7,
color: "var(--text-primary)",
fontSize: 12,
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={selectedCleanup[option.key]}
onChange={() =>
setSelectedCleanup((current) => ({
...current,
[option.key]: !current[option.key],
}))
}
/>
<span style={{ display: "grid", gap: 3 }}>
<strong>{option.label}</strong>
<span
style={{ color: "var(--text-muted)", fontSize: 11 }}
>
{option.description}
</span>
</span>
</label>
))}
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
flexWrap: "wrap",
paddingTop: 12,
borderTop: "1px solid var(--border)",
}}
>
<span style={{ color: "var(--text-muted)", fontSize: 12 }}>
{selectedCleanupCount} cleanup option
{selectedCleanupCount === 1 ? "" : "s"} selected
</span>
<button
type="button"
className="btn btn-ghost btn-sm"
disabled={
runningAction !== null ||
selectedCleanupCount === 0 ||
!docker?.available
}
onClick={() => setPendingAction("prune")}
>
{runningAction === "prune" ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Check size={13} />
)}{" "}
Run Docker Prune
</button>
</div>
</div>
) : null}
</div>
</div>
</div>
);
}
+18 -403
View File
@@ -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<ServerConfigTab>("overview");
const [snapshot, setSnapshot] = useState<ServerConfigSnapshot | null>(null);
const [loading, setLoading] = useState(true);
@@ -80,18 +63,12 @@ export default function ServerConfigModal({
useState<ServerPendingConfirm | null>(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(() => {
const refreshTimer = window.setTimeout(() => {
void loadSnapshot();
}, [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 (
<ServerConfigWebServerPanel
snapshot={snapshot}
isActionRunning={isActionRunning}
getWebStackActionKey={getWebStackActionKey}
onRequestWebStackActionConfirm={requestWebStackActionConfirm}
/>
);
case "mounts":
return (
<ServerConfigMountsPanel
@@ -1295,9 +1052,7 @@ export default function ServerConfigModal({
deleteConfirmed={deleteConfirmed}
isActionRunning={isActionRunning}
getServerActionKey={getServerActionKey}
getDockerActionKey={getDockerActionKey}
onRequestServerActionConfirm={requestServerActionConfirm}
onRequestDockerActionConfirm={requestDockerActionConfirm}
onReset={handleReset}
setError={setError}
/>
@@ -1309,7 +1064,10 @@ export default function ServerConfigModal({
<button
type="button"
key={id}
onClick={() => setActiveTab(id)}
onClick={() => {
setActiveTab(id);
setNoticeExpanded(false);
}}
className="btn btn-ghost"
style={{
height: 28,
@@ -1396,119 +1154,6 @@ export default function ServerConfigModal({
{pendingConfirm.description}
</p>
</div>
{pendingConfirm.kind === "server" &&
pendingConfirm.action === "prune-docker" ? (
<div
style={{
display: "grid",
gap: 10,
border: "1px solid var(--border)",
borderRadius: 12,
padding: 14,
background: "rgba(59,130,246,0.08)",
}}
>
<strong
style={{ color: "var(--text-primary)", fontSize: 13 }}
>
Select unused Docker artifacts to clean up
</strong>
{[
{
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 (
<label
key={option.key}
style={{
display: "flex",
gap: 10,
alignItems: "flex-start",
cursor:
activeActionKey !== null
? "not-allowed"
: "pointer",
opacity: activeActionKey !== null ? 0.6 : 1,
}}
>
<input
type="checkbox"
checked={checked}
disabled={activeActionKey !== null}
onChange={(event) => {
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 }}
/>
<span style={{ display: "grid", gap: 2 }}>
<span
style={{
color: "var(--text-primary)",
fontSize: 13,
fontWeight: 600,
}}
>
{option.label}
</span>
<span
style={{
color: "var(--text-muted)",
fontSize: 12,
lineHeight: 1.5,
}}
>
{option.description}
</span>
</span>
</label>
);
})}
</div>
) : null}
<div
style={{
borderRadius: 10,
@@ -1557,27 +1202,12 @@ export default function ServerConfigModal({
}
if (currentConfirm.kind === "server") {
if (
currentConfirm.action === "prune-docker" &&
!hasSelectedDockerPruneOption
) {
setPendingConfirm(null);
void handleServerAction(currentConfirm.action);
return;
}
setPendingConfirm(null);
void handleServerAction(
currentConfirm.action,
currentConfirm.pruneOptions,
);
return;
}
setPendingConfirm(null);
if (currentConfirm.kind === "docker") {
void handleDockerAction(currentConfirm.action);
return;
}
if (currentConfirm.kind === "service") {
void handleServiceRestart(currentConfirm.serviceName);
@@ -1679,17 +1309,8 @@ export default function ServerConfigModal({
return;
}
void handleWebStackAction(
currentConfirm.component,
currentConfirm.action,
);
}}
disabled={
activeActionKey !== null ||
(pendingConfirm.kind === "server" &&
pendingConfirm.action === "prune-docker" &&
!hasSelectedDockerPruneOption)
}
disabled={activeActionKey !== null}
style={{
background:
pendingConfirm.tone === "danger"
@@ -1701,12 +1322,6 @@ export default function ServerConfigModal({
pendingConfirm.tone === "danger"
? "1px solid rgba(239,68,68,0.22)"
: "1px solid rgba(245,158,11,0.22)",
opacity:
pendingConfirm.kind === "server" &&
pendingConfirm.action === "prune-docker" &&
!hasSelectedDockerPruneOption
? 0.6
: 1,
}}
>
{activeActionKey !== null ? (
@@ -1747,7 +1362,7 @@ export default function ServerConfigModal({
fontSize: 16,
}}
>
Server Config
{title}
</h3>
<UserBadge
label={server.status}
+29 -30
View File
@@ -37,7 +37,6 @@ interface ServersTableProps {
dockerErrors: Record<string, string | null>;
dockerLoading: Record<string, boolean>;
refreshing: Record<string, boolean>;
installingDocker: Record<string, boolean>;
deleting: string | null;
openMenuId: string | null;
expandedMetricsId: string | null;
@@ -48,9 +47,10 @@ interface ServersTableProps {
onRefreshMetrics: (serverId: string) => void | Promise<void>;
onTestConnection: (serverId: string) => void | Promise<void>;
onOpenTerminal: (server: ServerType) => void;
onInstallDocker: (server: ServerType) => void | Promise<void>;
onToggleMenu: (serverId: string) => void;
onOpenConfig: (server: ServerType) => void;
onOpenWebServerManager: (server: ServerType) => void;
onOpenDockerManager: (server: ServerType) => void;
onToggleMetrics: (server: ServerType) => void | Promise<void>;
onEdit: (server: ServerType) => void;
onDelete: (serverId: string) => void | Promise<void>;
@@ -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({
<tbody>
{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({
>
<Terminal size={12} />
</button>}
{canInstallDocker ? (
<button
title="Install Docker"
onClick={() => void onInstallDocker(server)}
title={
isMetricsOpen
? "Hide resource monitor"
: "Open resource monitor"
}
onClick={() => void onToggleMetrics(server)}
className="btn btn-ghost"
style={{
padding: "4px 8px",
fontSize: 11,
color: "#10b981",
color: isMetricsOpen
? "var(--accent-blue)"
: "var(--text-secondary)",
}}
disabled={installingDocker[server.id]}
>
{installingDocker[server.id] ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Boxes size={12} />
)}
<Activity size={12} />
</button>
) : null}
<button
title="More actions"
onClick={(event) =>
@@ -469,20 +462,26 @@ export default function ServersTable({
<Settings2 size={12} /> Server Config
</button>
<button
onClick={() => 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",
}}
>
<Activity size={12} />
{isMetricsOpen
? "Hide Monitor"
: "Resource Monitor"}
<Settings2 size={12} /> Manage Web Server
</button>
<button
onClick={() => onOpenDockerManager(server)}
className="btn btn-ghost"
style={{
fontSize: 12,
justifyContent: "flex-start",
color: "#10b981",
}}
>
<Boxes size={12} /> Manage Docker
</button>
<button
onClick={() => onEdit(server)}
@@ -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<ServerConfigSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedGroup, setSelectedGroup] = useState<
(typeof serviceGroups)[number]["id"]
>("infrastructure");
const [runningAction, setRunningAction] = useState<string | null>(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 (
<div className="modal-overlay">
{pendingAction ? (
<ConfirmActionDialog
open
title={`${getWebStackActionLabel(pendingAction.action)} ${pendingLabel}`}
description={getWebStackActionDescription(pendingLabel, pendingAction.action)}
confirmLabel={`${getWebStackActionLabel(pendingAction.action)} ${pendingLabel}`}
tone={getWebStackActionTone(pendingAction.action)}
note="Service actions are applied directly to this server and may briefly interrupt matching workloads."
onClose={() => setPendingAction(null)}
onConfirm={() => void runAction()}
/>
) : null}
<div className="modal-shell" style={{ maxWidth: 920 }}>
<button type="button" onClick={onClose} className="modal-close" aria-label="Close web server manager">
<X size={22} />
</button>
<div className="modal animate-slide-in" style={{ maxWidth: 920, maxHeight: "90vh", padding: 24, display: "grid", gap: 18, overflow: "hidden" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<h3 style={{ color: "var(--text-primary)", fontWeight: 700, fontSize: 16 }}>Manage Web Server</h3>
<UserBadge label={server.status} tone={server.status === "ONLINE" ? "success" : "neutral"} />
</div>
<p style={{ color: "var(--text-muted)", fontSize: 13, marginTop: 6 }}>{server.name} • {server.ip}:{server.sshPort}</p>
<p style={{ color: "var(--text-muted)", fontSize: 12, marginTop: 6 }}>Live service status refreshes when this modal opens or when you click Refresh.</p>
</div>
<button className="btn btn-ghost btn-sm" onClick={() => void refresh()} disabled={loading}>
{loading ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />} Refresh
</button>
</div>
<nav className="ui-tab-scroll no-scrollbar" style={{ width: "100%", borderRadius: 6, background: "var(--bg-card)", minWidth: 0, minHeight: 38, alignItems: "center", overflowY: "hidden", flex: "0 0 auto" }} aria-label="Web server service groups">
{serviceGroups.map((group) => (
<button type="button" key={group.id} onClick={() => 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}
</button>
))}
</nav>
<div style={{ display: "grid", flex: "1 1 auto", gap: 16, minHeight: 0, overflowY: "auto", paddingRight: 2 }}>
<div style={{ padding: "12px 14px", borderLeft: "3px solid #3b82f6", background: "rgba(59,130,246,0.08)", display: "grid", gap: 4 }}>
<strong style={{ color: "var(--text-primary)", fontSize: 13 }}>{selectedGroupInfo.label}</strong>
<span style={{ color: "var(--text-secondary)", fontSize: 12 }}>{selectedGroupInfo.description}</span>
<span style={{ color: "var(--text-muted)", fontSize: 12 }}>Services: {selectedGroupInfo.services}</span>
{"optionalNote" in selectedGroupInfo && selectedGroupInfo.optionalNote ? <span style={{ color: "var(--text-muted)", fontSize: 12 }}>{selectedGroupInfo.optionalNote}</span> : null}
</div>
{error ? <div style={{ display: "flex", gap: 8, padding: 12, borderRadius: 8, color: "var(--text-danger)", background: "rgba(248,113,113,0.12)", border: "1px solid rgba(248,113,113,0.25)", fontSize: 12 }}><AlertTriangle size={15} />{error}</div> : null}
{loading && !snapshot ? <div style={{ padding: 44, textAlign: "center", color: "var(--text-muted)", fontSize: 13 }}><Loader2 size={22} className="animate-spin" style={{ margin: "0 auto 10px" }} />Loading web server status...</div> : null}
{snapshot ? <ServerConfigWebServerPanel snapshot={snapshot} serviceGroup={selectedGroup} hideComponentGroups isActionRunning={(key) => runningAction === key} getWebStackActionKey={getActionKey} onRequestWebStackActionConfirm={(component, action) => setPendingAction({ component, action })} /> : null}
</div>
</div>
</div>
</div>
);
}
@@ -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"] {
@@ -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<SetStateAction<boolean>>;
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<void>;
setError: Dispatch<SetStateAction<string>>;
}
@@ -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 (
<div
@@ -87,7 +73,7 @@ export default function ServerConfigActionsPanel({
>
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.
</p>
<p style={{ color: "#fbbf24", fontSize: 12 }}>
{snapshotLoadError}
@@ -149,123 +135,6 @@ export default function ServerConfigActionsPanel({
</div>
</div>
<div className="card" style={{ padding: 18, display: "grid", gap: 14 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Boxes size={15} style={{ color: "#3b82f6" }} />
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
Docker Maintenance
</strong>
</div>
<p
style={{
color: "var(--text-muted)",
fontSize: 13,
lineHeight: 1.6,
}}
>
Install Docker when it is missing, or remove and reinstall it when
the runtime needs a clean recovery.
</p>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
{showInstallDocker ? (
<button
className="btn btn-sm"
onClick={() => 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")) ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Boxes size={14} />
)}
Install Docker
</button>
) : null}
{showRuntimeMaintenance ? (
<>
<button
className="btn btn-sm"
onClick={() => 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")) ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RefreshCw size={14} />
)}
Reinstall
</button>
<button
className="btn btn-sm"
onClick={() => 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")) ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
Remove
</button>
<button
className="btn btn-sm"
onClick={() => 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")) ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
Prune Garbage
</button>
</>
) : null}
</div>
{!dockerStatusUnavailable &&
!snapshot.docker.canInstall &&
!snapshot.docker.installed ? (
<div
style={{
borderRadius: 10,
border: "1px solid rgba(245,158,11,0.25)",
background: "rgba(245,158,11,0.08)",
padding: "12px 14px",
color: "#b45309",
fontSize: 12,
}}
>
Docker install requires root access or non-interactive sudo on
this server.
</div>
) : null}
</div>
<div className="card" style={{ padding: 18, display: "grid", gap: 14 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Power size={15} style={{ color: "#ef4444" }} />
@@ -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) => (
<div key={component.key} className="card server-config-component-card">
<div className="server-config-component-header">
@@ -84,27 +102,18 @@ export default function ServerConfigWebServerPanel({
</div>
<hr className="server-config-component-divider" />
<div className="server-config-badge-list">
<UserBadge label={component.category} tone="neutral" />
{component.version ? (
<UserBadge label={component.version} tone="neutral" />
{component.installed ? (
<p className="server-config-component-metadata">
{[
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(" / ")}
</p>
) : null}
{component.serviceName ? (
<UserBadge label={component.serviceName} tone="neutral" />
) : null}
{component.active ? (
<ServiceStatusBadge state={component.active} />
) : null}
{component.enabled ? (
<ServiceStatusBadge state={component.enabled} />
) : null}
</div>
<div className="server-config-badge-list">
{component.recommendedFor.map((target) => (
<UserBadge key={target} label={target} tone="neutral" />
))}
</div>
{component.notes.length > 0 ? (
<button
@@ -219,12 +228,15 @@ export default function ServerConfigWebServerPanel({
)
: null}
<div className="card" style={{ padding: 18, display: "grid", gap: 14 }}>
{/* <div
className="card"
style={{ padding: "14px 16px", display: "grid", gap: 10 }}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 12,
flexWrap: "wrap",
}}
@@ -233,144 +245,120 @@ export default function ServerConfigWebServerPanel({
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
Web Server Readiness
</strong>
<p
style={{
marginTop: 6,
color: "var(--text-muted)",
fontSize: 13,
lineHeight: 1.6,
}}
>
<p className="server-config-component-description">
{snapshot.webServer.summary}
</p>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<UserBadge
label={snapshot.webServer.ready ? "Ready" : "Needs Setup"}
tone={snapshot.webServer.ready ? "success" : "warning"}
/>
<UserBadge
label={`Pkg: ${snapshot.webServer.packageManager ?? "none"}`}
tone="neutral"
/>
{snapshot.webServer.primaryWebServer ? (
<UserBadge
label={snapshot.webServer.primaryWebServer}
tone="info"
/>
) : null}
</div>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<UserBadge
label={
snapshot.webServer.support.staticSites
? "Static Sites Ready"
: "Static Sites Missing"
}
tone={
snapshot.webServer.support.staticSites ? "success" : "warning"
}
/>
<UserBadge
label={
snapshot.webServer.support.phpApps
? "PHP Apps Ready"
: "PHP Apps Missing"
}
tone={snapshot.webServer.support.phpApps ? "success" : "warning"}
/>
<UserBadge
label={
snapshot.webServer.support.javascriptApps
? "JavaScript Apps Ready"
: "JavaScript Apps Missing"
}
tone={
snapshot.webServer.support.javascriptApps ? "success" : "warning"
}
/>
<UserBadge
label={
snapshot.webServer.support.sslAutomation
? "HTTPS Automation Ready"
: "HTTPS Automation Missing"
}
tone={
snapshot.webServer.support.sslAutomation ? "success" : "warning"
}
/>
<UserBadge
label={
snapshot.webServer.support.processManager
? "Process Manager Ready"
: "Process Manager Missing"
}
tone={
snapshot.webServer.support.processManager ? "success" : "warning"
}
/>
<UserBadge
label={
snapshot.webServer.support.relationalDatabase
? "SQL Ready"
: "SQL Missing"
}
tone={
snapshot.webServer.support.relationalDatabase
? "success"
: "warning"
}
/>
<UserBadge
label={
snapshot.webServer.support.cache ? "Cache Ready" : "Cache Missing"
}
tone={snapshot.webServer.support.cache ? "success" : "warning"}
/>
</div>
<p
style={{
margin: 0,
color: "var(--text-muted)",
fontSize: 11,
lineHeight: 1.5,
}}
>
{snapshot.webServer.primaryWebServer ?? "No primary web server"}
{" / "}
{snapshot.webServer.packageManager ?? "No package manager"}
{" / "}
{readyCapabilityCount} of {totalCapabilityCount} capabilities ready
</p>
{snapshot.webServer.notes.length > 0 ? (
<div className="server-config-readiness-notices">
{snapshot.webServer.notes.map((note) => (
<div key={note} className="server-config-readiness-notice">
<div
style={{
display: "grid",
gap: 8,
paddingTop: 8,
borderTop: "1px solid var(--border)",
}}
>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => setShowReadinessNotes((current) => !current)}
aria-expanded={showReadinessNotes}
style={{
justifySelf: "start",
color: "#b45309",
padding: "4px 0",
border: 0,
background: "transparent",
}}
>
<AlertTriangle size={13} />
{showReadinessNotes ? "Hide" : "Show"}{" "}
{snapshot.webServer.notes.length} setup recommendation
{snapshot.webServer.notes.length === 1 ? "" : "s"}
</button>
{showReadinessNotes ? (
<div style={{ display: "grid", gap: 8 }}>
{snapshot.webServer.notes.map((note) => (
<div
key={note}
style={{
padding: "9px 11px",
borderRadius: 7,
border: "1px solid rgba(245, 158, 11, 0.24)",
background: "rgba(245, 158, 11, 0.08)",
color: "#b45309",
fontSize: 12,
lineHeight: 1.5,
}}
>
<span>{note}</span>
</div>
))}
</div>
) : null}
</div>
) : null}
</div> */}
<div className="card" style={{ padding: 18, display: "grid", gap: 14 }}>
{!hideComponentGroups ? (
<div
className="card"
style={{
padding: "12px 14px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
alignItems: "flex-start",
gap: 12,
flexWrap: "wrap",
}}
>
<div>
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
Component Groups
<div style={{ minWidth: 0, display: "grid", gap: 3 }}>
<strong style={{ color: "var(--text-primary)", fontSize: 13 }}>
Components
</strong>
<p
<span
style={{
marginTop: 6,
color: "var(--text-muted)",
fontSize: 13,
lineHeight: 1.6,
fontSize: 11,
lineHeight: 1.45,
}}
>
Switch between core host infrastructure and runtime packages that
support native deployments on the server.
</p>
{activeGroup === "infrastructure"
? "Reverse proxy, site delivery, and HTTPS."
: "Runtimes, databases, cache, and supporting tools."}
</span>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
flexWrap: "wrap",
}}
>
<button
className="btn btn-ghost btn-sm"
onClick={() => setActiveGroup("infrastructure")}
aria-pressed={activeGroup === "infrastructure"}
style={{
borderColor:
activeGroup === "infrastructure"
@@ -386,11 +374,12 @@ export default function ServerConfigWebServerPanel({
: "var(--text-secondary)",
}}
>
Infrastructure
Infrastructure ({infrastructureComponents.length})
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setActiveGroup("runtime-tools")}
aria-pressed={activeGroup === "runtime-tools"}
style={{
borderColor:
activeGroup === "runtime-tools"
@@ -406,52 +395,11 @@ export default function ServerConfigWebServerPanel({
: "var(--text-secondary)",
}}
>
Runtime Tools
Runtime Tools ({runtimeToolComponents.length})
</button>
</div>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
flexWrap: "wrap",
}}
>
<div>
<strong style={{ color: "var(--text-primary)", fontSize: 13 }}>
{activeGroup === "infrastructure"
? "Infrastructure"
: "Runtime Tools"}
</strong>
<p
style={{
marginTop: 4,
color: "var(--text-muted)",
fontSize: 12,
lineHeight: 1.6,
}}
>
{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."}
</p>
</div>
<hr style={{ borderColor: "var(--border)", width: "100%" }} />
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<UserBadge
label={`${visibleComponents.length} components`}
tone="neutral"
/>
{activeGroup === "runtime-tools" ? (
<UserBadge label="Native Host Deployment" tone="info" />
) : null}
</div>
</div>
</div>
<div className="server-config-component-grid">
{visibleComponents.map((component) => renderComponentCard(component))}
@@ -459,5 +407,3 @@ export default function ServerConfigWebServerPanel({
</div>
);
}
+49 -49
View File
@@ -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<ServerType[]>([]);
const [loading, setLoading] = useState(true);
@@ -51,13 +55,14 @@ export default function ServersPage() {
const [deleteTarget, setDeleteTarget] = useState<ServerType | null>(null);
const [deleteConfirmation, setDeleteConfirmation] = useState("");
const [confirmDeleteStep, setConfirmDeleteStep] = useState(false);
const [installingDocker, setInstallingDocker] = useState<
Record<string, boolean>
>({});
const [confirmDialog, setConfirmDialog] =
useState<PendingConfirmAction | null>(null);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [configServer, setConfigServer] = useState<ServerType | null>(null);
const [webServerManagerServer, setWebServerManagerServer] =
useState<ServerType | null>(null);
const [dockerManagerServer, setDockerManagerServer] =
useState<ServerType | null>(null);
const [expandedMetricsId, setExpandedMetricsId] = useState<string | null>(
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 ? (
<WebServerManagerModal
server={webServerManagerServer}
onClose={() => setWebServerManagerServer(null)}
onActionComplete={(message, tone = "success") => {
pushToast({
tone,
title: tone === "error" ? "Web Server" : "Web Server Update",
message,
showProgress: true,
});
void load();
}}
/>
) : null}
{dockerManagerServer ? (
<DockerManagerModal
server={dockerManagerServer}
onClose={() => setDockerManagerServer(null)}
onActionComplete={(message, tone = "success") => {
pushToast({
tone,
title: tone === "error" ? "Docker Manager" : "Docker Update",
message,
showProgress: true,
});
void load();
}}
/>
) : null}
<div
className="animate-slide-in"
style={{ display: "flex", flexDirection: "column", gap: 16 }}
@@ -706,7 +702,6 @@ export default function ServersPage() {
dockerErrors={dockerErrors}
dockerLoading={dockerLoading}
refreshing={refreshing}
installingDocker={installingDocker}
deleting={deleting}
openMenuId={openMenuId}
expandedMetricsId={expandedMetricsId}
@@ -718,10 +713,9 @@ export default function ServersPage() {
onTestConnection={handleTestConnection}
onOpenTerminal={(server) =>
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() {
</DashboardLayout>
);
}
+7 -1
View File
@@ -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`,
+63 -2
View File
@@ -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 },
@@ -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<DockerRuntimeStatus> {
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<DockerRuntimeStatus> {