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:
1 parent
92737dd892
commit
310b13f329
12 files changed
+1413
-990
No files matched your search
+247
-4
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(() => {
|
||||
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 (
|
||||
<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,28 +1202,13 @@ export default function ServerConfigModal({
|
||||
}
|
||||
|
||||
if (currentConfirm.kind === "server") {
|
||||
if (
|
||||
currentConfirm.action === "prune-docker" &&
|
||||
!hasSelectedDockerPruneOption
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingConfirm(null);
|
||||
void handleServerAction(
|
||||
currentConfirm.action,
|
||||
currentConfirm.pruneOptions,
|
||||
);
|
||||
void handleServerAction(currentConfirm.action);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingConfirm(null);
|
||||
|
||||
if (currentConfirm.kind === "docker") {
|
||||
void handleDockerAction(currentConfirm.action);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentConfirm.kind === "service") {
|
||||
void handleServiceRestart(currentConfirm.serviceName);
|
||||
return;
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
fontSize: 11,
|
||||
color: "#10b981",
|
||||
}}
|
||||
disabled={installingDocker[server.id]}
|
||||
>
|
||||
{installingDocker[server.id] ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<Boxes size={12} />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
title={
|
||||
isMetricsOpen
|
||||
? "Hide resource monitor"
|
||||
: "Open resource monitor"
|
||||
}
|
||||
onClick={() => void onToggleMetrics(server)}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
fontSize: 11,
|
||||
color: isMetricsOpen
|
||||
? "var(--accent-blue)"
|
||||
: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<Activity size={12} />
|
||||
</button>
|
||||
<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" />
|
||||
) : 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.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.notes.length > 0 ? (
|
||||
<button
|
||||
@@ -177,41 +186,41 @@ export default function ServerConfigWebServerPanel({
|
||||
<div className="modal-overlay server-config-issue-overlay">
|
||||
<div className="modal-shell" style={{ maxWidth: 520 }}>
|
||||
<div className="modal server-config-issue-dialog">
|
||||
<div className="server-config-issue-dialog-header">
|
||||
<div>
|
||||
<strong
|
||||
style={{ color: "var(--text-primary)", fontSize: 15 }}
|
||||
>
|
||||
{activeIssueDetail.label} Issues
|
||||
</strong>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 6,
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Service information returned by the current server
|
||||
snapshot.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveIssueDetail(null)}
|
||||
aria-label="Close issue details"
|
||||
className="server-config-issue-close"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="server-config-issue-list">
|
||||
{activeIssueDetail.notes.map((note) => (
|
||||
<div key={note} className="server-config-issue-note">
|
||||
{note}
|
||||
<div className="server-config-issue-dialog-header">
|
||||
<div>
|
||||
<strong
|
||||
style={{ color: "var(--text-primary)", fontSize: 15 }}
|
||||
>
|
||||
{activeIssueDetail.label} Issues
|
||||
</strong>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 6,
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Service information returned by the current server
|
||||
snapshot.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveIssueDetail(null)}
|
||||
aria-label="Close issue details"
|
||||
className="server-config-issue-close"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="server-config-issue-list">
|
||||
{activeIssueDetail.notes.map((note) => (
|
||||
<div key={note} className="server-config-issue-note">
|
||||
{note}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -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,225 +245,161 @@ 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"}
|
||||
label={snapshot.webServer.ready ? "Ready" : "Needs Setup"}
|
||||
tone={snapshot.webServer.ready ? "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">
|
||||
<AlertTriangle size={13} />
|
||||
<span>{note}</span>
|
||||
<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> */}
|
||||
|
||||
<div className="card" style={{ padding: 18, display: "grid", gap: 14 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
|
||||
Component Groups
|
||||
</strong>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 6,
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
Switch between core host infrastructure and runtime packages that
|
||||
support native deployments on the server.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setActiveGroup("infrastructure")}
|
||||
style={{
|
||||
borderColor:
|
||||
activeGroup === "infrastructure"
|
||||
? "rgba(59,130,246,0.35)"
|
||||
: "var(--border)",
|
||||
background:
|
||||
activeGroup === "infrastructure"
|
||||
? "rgba(59,130,246,0.12)"
|
||||
: "var(--bg-input)",
|
||||
color:
|
||||
activeGroup === "infrastructure"
|
||||
? "#3b82f6"
|
||||
: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
Infrastructure
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setActiveGroup("runtime-tools")}
|
||||
style={{
|
||||
borderColor:
|
||||
activeGroup === "runtime-tools"
|
||||
? "rgba(59,130,246,0.35)"
|
||||
: "var(--border)",
|
||||
background:
|
||||
activeGroup === "runtime-tools"
|
||||
? "rgba(59,130,246,0.12)"
|
||||
: "var(--bg-input)",
|
||||
color:
|
||||
activeGroup === "runtime-tools"
|
||||
? "#3b82f6"
|
||||
: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
Runtime Tools
|
||||
</button>
|
||||
</div>
|
||||
{!hideComponentGroups ? (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: "12px 14px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, display: "grid", gap: 3 }}>
|
||||
<strong style={{ color: "var(--text-primary)", fontSize: 13 }}>
|
||||
Components
|
||||
</strong>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
{activeGroup === "infrastructure"
|
||||
? "Reverse proxy, site delivery, and HTTPS."
|
||||
: "Runtimes, databases, cache, and supporting tools."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
gap: 6,
|
||||
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>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setActiveGroup("infrastructure")}
|
||||
aria-pressed={activeGroup === "infrastructure"}
|
||||
style={{
|
||||
borderColor:
|
||||
activeGroup === "infrastructure"
|
||||
? "rgba(59,130,246,0.35)"
|
||||
: "var(--border)",
|
||||
background:
|
||||
activeGroup === "infrastructure"
|
||||
? "rgba(59,130,246,0.12)"
|
||||
: "var(--bg-input)",
|
||||
color:
|
||||
activeGroup === "infrastructure"
|
||||
? "#3b82f6"
|
||||
: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
Infrastructure ({infrastructureComponents.length})
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setActiveGroup("runtime-tools")}
|
||||
aria-pressed={activeGroup === "runtime-tools"}
|
||||
style={{
|
||||
borderColor:
|
||||
activeGroup === "runtime-tools"
|
||||
? "rgba(59,130,246,0.35)"
|
||||
: "var(--border)",
|
||||
background:
|
||||
activeGroup === "runtime-tools"
|
||||
? "rgba(59,130,246,0.12)"
|
||||
: "var(--bg-input)",
|
||||
color:
|
||||
activeGroup === "runtime-tools"
|
||||
? "#3b82f6"
|
||||
: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
Runtime Tools ({runtimeToolComponents.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="server-config-component-grid">
|
||||
{visibleComponents.map((component) => renderComponentCard(component))}
|
||||
@@ -459,5 +407,3 @@ export default function ServerConfigWebServerPanel({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+49
-49
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user