import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import axios from "axios"; import { useToast } from "@/components/ToastProvider.jsx"; import { useMaintenance } from "@/context/MaintenanceContext.jsx"; import { Typography, Card, CardHeader, CardBody, Button, Switch } from "@material-tailwind/react"; const UPDATE_STATUS_LABELS = { idle: "Bereit", running: "Läuft", success: "Erfolgreich", error: "Fehlgeschlagen" }; const UPDATE_STATUS_STYLES = { idle: "bg-stormGrey-700 text-gray-200", running: "bg-arcticBlue-600 text-white", success: "bg-mossGreen-600 text-white", error: "bg-sunsetCoral-600 text-white" }; const UPDATE_STAGE_LABELS = { initializing: "Vorbereitung", "activating-maintenance": "Wartungsmodus aktivieren", "executing-script": "Skript wird ausgeführt", waiting: "Warte auf Portainer", completed: "Abgeschlossen", failed: "Fehlgeschlagen" }; const LOG_LEVEL_STYLES = { info: "text-arcticBlue-700", success: "text-mossGreen-700", warning: "text-warmAmberGlow-700", error: "text-sunsetCoral-700", stdout: "text-stormGrey-700", stderr: "text-warmAmberGlow-700", debug: "text-lavenderSmoke-700" }; const formatLogTimestamp = (value) => { if (!value) return "-"; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" }); }; const formatCreatedAt = (value) => { if (!value && value !== 0) return "-"; const normalizeToDate = (input) => { if (input instanceof Date) return input; if (typeof input === "number") { const epoch = input > 1e12 ? input : input * 1000; return new Date(epoch); } if (typeof input === "string") { const numeric = Number(input); if (!Number.isNaN(numeric)) { const epoch = numeric > 1e12 ? numeric : numeric * 1000; return new Date(epoch); } return new Date(input); } return null; }; const date = normalizeToDate(value); if (!date || Number.isNaN(date.getTime())) { return String(value); } return date.toLocaleString("de-DE", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); }; const resolveStackType = (type) => { if (type === 1) return "Git"; if (type === 2) return "Compose"; return type ?? "-"; }; const createEmptySshDraft = () => ({ host: '', port: '22', username: '', password: '', extraSshArgs: '' }); export function Maintenance() { const { showToast } = useToast(); const { maintenance: maintenanceMeta, update: updateState, script: scriptConfig, ssh: sshConfig, loading: maintenanceLoading, error: maintenanceError, setMaintenanceMode, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection } = useMaintenance(); const maintenanceActive = Boolean(maintenanceMeta?.active); const maintenanceMessage = maintenanceMeta?.message; const maintenanceExtraType = maintenanceMeta?.extra?.type; const updateRunning = Boolean(updateState?.running); const [scriptDraft, setScriptDraft] = useState(""); const [scriptSaving, setScriptSaving] = useState(false); const [updateActionLoading, setUpdateActionLoading] = useState(false); const [updateActionError, setUpdateActionError] = useState(""); const [maintenanceToggleLoading, setMaintenanceToggleLoading] = useState(false); const [sshDraft, setSshDraft] = useState(() => createEmptySshDraft()); const [sshPasswordStored, setSshPasswordStored] = useState(false); const [showPassword, setShowPassword] = useState(false); const [sshSaving, setSshSaving] = useState(false); const [sshTesting, setSshTesting] = useState(false); const [sshDeleting, setSshDeleting] = useState(false); const [sshTestResult, setSshTestResult] = useState(null); useEffect(() => { if (!scriptConfig) return; const nextValue = scriptConfig.custom ?? scriptConfig.default ?? scriptConfig.effective ?? ""; setScriptDraft(nextValue); }, [scriptConfig]); useEffect(() => { if (!sshConfig) { setSshDraft(createEmptySshDraft()); setSshPasswordStored(false); setShowPassword(false); setSshTestResult(null); return; } setSshDraft({ host: sshConfig.host ?? '', port: String(sshConfig.port ?? '22'), username: sshConfig.username ?? '', password: '', extraSshArgs: Array.isArray(sshConfig.extraSshArgs) ? sshConfig.extraSshArgs.join('\n') : '' }); setSshPasswordStored(Boolean(sshConfig.passwordStored)); setShowPassword(false); setSshTestResult(null); }, [sshConfig]); const scriptBaseline = useMemo(() => { if (!scriptConfig) return ""; if (scriptConfig.source === "custom" && typeof scriptConfig.custom === "string") { return scriptConfig.custom; } return scriptConfig.default ?? scriptConfig.effective ?? ""; }, [scriptConfig]); const scriptIsDirty = scriptConfig ? scriptDraft !== scriptBaseline : false; const scriptSourceLabel = scriptConfig?.source === "custom" ? "Benutzerdefiniert" : "Standard"; const [duplicates, setDuplicates] = useState([]); const [duplicatesLoading, setDuplicatesLoading] = useState(true); const [duplicatesError, setDuplicatesError] = useState(""); const [duplicatesRefreshing, setDuplicatesRefreshing] = useState(false); const [activeCleanupId, setActiveCleanupId] = useState(null); const [duplicatesUpdatedAt, setDuplicatesUpdatedAt] = useState(null); const duplicatesRequestRef = useRef(null); const [portainerStatus, setPortainerStatus] = useState(null); const [portainerLoading, setPortainerLoading] = useState(true); const [portainerError, setPortainerError] = useState(""); const [portainerRefreshing, setPortainerRefreshing] = useState(false); const [portainerUpdatedAt, setPortainerUpdatedAt] = useState(null); const portainerRequestRef = useRef(null); const fetchPortainerStatus = useCallback(async ({ silent = false } = {}) => { if (portainerRequestRef.current) { return portainerRequestRef.current; } const requestPromise = (async () => { if (silent) { setPortainerRefreshing(true); } else { setPortainerLoading(true); } setPortainerError(""); try { const response = await axios.get("/api/maintenance/portainer-status"); const payload = response.data ?? {}; setPortainerStatus(payload); setPortainerUpdatedAt(new Date()); } catch (err) { const message = err.response?.data?.error || err.message || "Fehler beim Prüfen des Portainer-Status"; setPortainerError(message); showToast({ variant: "error", title: "Statusaktualisierung fehlgeschlagen", description: message }); } finally { if (silent) { setPortainerRefreshing(false); } else { setPortainerLoading(false); } portainerRequestRef.current = null; } })(); portainerRequestRef.current = requestPromise; return requestPromise; }, [showToast]); const fetchDuplicates = useCallback(async ({ silent = false } = {}) => { if (maintenanceActive || updateRunning) { setDuplicates([]); setDuplicatesError("Wartungsmodus aktiv – Duplikat-Verwaltung ist vorübergehend deaktiviert."); setDuplicatesLoading(false); setDuplicatesRefreshing(false); return; } if (duplicatesRequestRef.current) { return duplicatesRequestRef.current; } const requestPromise = (async () => { if (silent) { setDuplicatesRefreshing(true); } else { setDuplicatesLoading(true); } setDuplicatesError(""); try { const response = await axios.get("/api/maintenance/duplicates"); const payload = response.data; const items = Array.isArray(payload) ? payload : payload?.items ?? []; setDuplicates(items); setDuplicatesUpdatedAt(new Date()); } catch (err) { if (err.response?.status === 423) { const message = "Wartungsmodus aktiv – Duplikat-Verwaltung ist vorübergehend deaktiviert."; setDuplicates([]); setDuplicatesError(message); showToast({ variant: "warning", title: "Duplikat-Verwaltung gesperrt", description: message }); } else { const message = err.response?.data?.error || err.message || "Fehler beim Laden der Wartungsdaten"; setDuplicatesError(message); showToast({ variant: "error", title: "Duplikate konnten nicht geladen werden", description: message }); } } finally { if (silent) { setDuplicatesRefreshing(false); } else { setDuplicatesLoading(false); } duplicatesRequestRef.current = null; } })(); duplicatesRequestRef.current = requestPromise; return requestPromise; }, [maintenanceActive, updateRunning, showToast]); useEffect(() => { fetchPortainerStatus(); }, [fetchPortainerStatus]); useEffect(() => { fetchDuplicates(); }, [fetchDuplicates]); const totals = useMemo(() => { const groups = Array.isArray(duplicates) ? duplicates.length : 0; const duplicateCount = Array.isArray(duplicates) ? duplicates.reduce((sum, entry) => sum + ((entry?.duplicates?.length) || 0), 0) : 0; return { groups, duplicateCount }; }, [duplicates]); const handleSshDraftChange = useCallback((field, value) => { setSshDraft((prev) => ({ ...prev, [field]: value })); if (field === 'password') { setSshPasswordStored(false); } }, []); const normalizedSshDraft = useMemo(() => { const normalized = { host: sshDraft.host.trim(), port: Number.parseInt(sshDraft.port, 10) || 22, username: sshDraft.username.trim(), extraSshArgs: (sshDraft.extraSshArgs || '') .split(/\r?\n/) .map((entry) => entry.trim()) .filter(Boolean) }; const rawPassword = sshDraft.password ?? ''; if (rawPassword) { normalized.password = rawPassword; } else if (!sshPasswordStored) { normalized.password = ''; } return normalized; }, [sshDraft, sshPasswordStored]); const handleScriptSave = useCallback(async () => { if (!scriptConfig) return; try { setScriptSaving(true); await saveScript(scriptDraft); showToast({ variant: "success", title: "Skript gespeichert", description: "Das benutzerdefinierte Portainer-Update-Skript wurde aktualisiert." }); } catch (err) { const message = err.response?.data?.error || err.message || "Skript konnte nicht gespeichert werden"; showToast({ variant: "error", title: "Speichern fehlgeschlagen", description: message }); } finally { setScriptSaving(false); } }, [saveScript, scriptDraft, scriptConfig, showToast]); const handleScriptReset = useCallback(async () => { try { setScriptSaving(true); await resetScript(); showToast({ variant: "info", title: "Standardskript wiederhergestellt", description: "Es wird wieder das Standard-Update-Skript verwendet." }); } catch (err) { const message = err.response?.data?.error || err.message || "Standardskript konnte nicht wiederhergestellt werden"; showToast({ variant: "error", title: "Zurücksetzen fehlgeschlagen", description: message }); } finally { setScriptSaving(false); } }, [resetScript, showToast]); const handleSshSaveConfig = useCallback(async () => { try { setSshSaving(true); await saveSshConfig(normalizedSshDraft); setSshTestResult(null); setShowPassword(false); showToast({ variant: "success", title: "SSH-Konfiguration gespeichert", description: "Verbindungseinstellungen wurden aktualisiert." }); } catch (err) { const message = err.response?.data?.error || err.message || "SSH-Konfiguration konnte nicht gespeichert werden"; showToast({ variant: "error", title: "Speichern fehlgeschlagen", description: message }); } finally { setSshSaving(false); } }, [normalizedSshDraft, saveSshConfig, showToast]); const handleSshTestConnection = useCallback(async () => { try { setSshTesting(true); const result = await testSshConnection(normalizedSshDraft); setSshTestResult({ success: true, timestamp: new Date(), details: result?.result }); showToast({ variant: "success", title: "SSH-Verbindung erfolgreich", description: "Verbindung zum Portainer-Host wurde hergestellt." }); } catch (err) { const message = err.response?.data?.error || err.message || "SSH-Verbindung fehlgeschlagen"; setSshTestResult({ success: false, timestamp: new Date(), error: message }); showToast({ variant: "error", title: "SSH-Test fehlgeschlagen", description: message }); } finally { setSshTesting(false); } }, [normalizedSshDraft, testSshConnection, showToast]); const handleSshDeleteConfig = useCallback(async () => { try { setSshDeleting(true); await deleteSshConfig(); setSshDraft(createEmptySshDraft()); setSshPasswordStored(false); setShowPassword(false); setSshTestResult(null); showToast({ variant: "info", title: "SSH-Konfiguration gelöscht", description: "Die Verbindungseinstellungen wurden zurückgesetzt." }); } catch (err) { const message = err.response?.data?.error || err.message || "SSH-Konfiguration konnte nicht gelöscht werden"; showToast({ variant: "error", title: "Löschen fehlgeschlagen", description: message }); } finally { setSshDeleting(false); } }, [deleteSshConfig, showToast]); const handleMaintenanceToggle = useCallback(async (nextActive) => { if (maintenanceLoading || maintenanceToggleLoading) return; if (nextActive === maintenanceActive) return; if (updateRunning) { showToast({ variant: "warning", title: "Aktion nicht möglich", description: "Während eines laufenden Updates kann der Wartungsmodus nicht geändert werden." }); return; } setMaintenanceToggleLoading(true); try { const shouldPreserveMessage = nextActive && Boolean(maintenanceExtraType); const payload = { active: nextActive }; if (shouldPreserveMessage && maintenanceMessage) { payload.message = maintenanceMessage; } else { payload.message = null; } await setMaintenanceMode(payload); showToast({ variant: nextActive ? "info" : "success", title: nextActive ? "Wartungsmodus aktiviert" : "Wartungsmodus deaktiviert", description: nextActive ? "Benutzer sehen jetzt die Wartungsseite." : "StackPulse steht wieder zur Verfügung." }); } catch (err) { const message = err.response?.data?.error || err.message || "Wartungsmodus konnte nicht geändert werden"; showToast({ variant: "error", title: "Änderung fehlgeschlagen", description: message }); } finally { setMaintenanceToggleLoading(false); } }, [maintenanceLoading, maintenanceToggleLoading, maintenanceActive, updateRunning, setMaintenanceMode, maintenanceMessage, maintenanceExtraType, showToast]); const handleTriggerUpdate = useCallback(async () => { if (maintenanceActive || updateRunning) { showToast({ variant: "warning", title: "Update nicht möglich", description: "Während eines aktiven Wartungsmodus kann kein weiteres Update gestartet werden." }); return; } const targetVersion = portainerStatus?.latestVersion ?? portainerStatus?.currentVersion ?? "unbekannt"; if (typeof window !== "undefined") { const confirmed = window.confirm( `Portainer-Update starten?\nZielversion: ${targetVersion}.\nWährend des Updates befindet sich StackPulse im Wartungsmodus.\nFortfahren?` ); if (!confirmed) { return; } } try { setUpdateActionError(""); setUpdateActionLoading(true); await triggerUpdate(); showToast({ variant: "info", title: "Update gestartet", description: "Das Portainer-Update wurde gestartet." }); } catch (err) { const message = err.response?.data?.error || err.message || "Portainer-Update konnte nicht gestartet werden"; setUpdateActionError(message); showToast({ variant: "error", title: "Update fehlgeschlagen", description: message }); } finally { setUpdateActionLoading(false); } }, [maintenanceActive, updateRunning, portainerStatus, triggerUpdate, showToast]); const handleCleanup = useCallback(async (entry) => { if (!entry) return; if (maintenanceActive || updateRunning) { showToast({ variant: "warning", title: "Aktion nicht möglich", description: "Während des Wartungsmodus sind Bereinigungen deaktiviert." }); return; } const canonicalId = entry.canonical?.Id; if (!canonicalId) return; const duplicateIds = (entry.duplicates || []).map((dup) => dup.Id).filter(Boolean); if (!duplicateIds.length) return; const canonicalName = entry.canonical?.Name || entry.name || `Stack ${canonicalId}`; if (typeof window !== "undefined") { const confirmation = window.confirm( `Bereinigung für "${canonicalName}" starten?\n` + `Es werden ${duplicateIds.length} Duplikate entfernt: ${duplicateIds.join(", ")}` ); if (!confirmation) { return; } } setActiveCleanupId(String(canonicalId)); try { const response = await axios.post("/api/maintenance/duplicates/cleanup", { canonicalId, duplicateIds }); const payload = response.data ?? {}; if (payload.success === false) { throw new Error(payload.error || "Bereinigung fehlgeschlagen"); } const removedIds = Array.isArray(payload.results) ? payload.results.filter((result) => result.status === "deleted").map((result) => result.id) : duplicateIds; showToast({ variant: "success", title: "Bereinigung abgeschlossen", description: `${canonicalName} – entfernte IDs: ${removedIds.join(", ")}` }); await fetchDuplicates({ silent: true }); } catch (err) { const message = err.response?.data?.error || err.message || "Bereinigung fehlgeschlagen"; showToast({ variant: "error", title: "Bereinigung fehlgeschlagen", description: message }); } finally { setActiveCleanupId(null); } }, [fetchDuplicates, maintenanceActive, showToast, updateRunning]); const maintenanceActivatedAt = maintenanceMeta?.activatedAt ? formatCreatedAt(maintenanceMeta.activatedAt) : null; const maintenanceUpdatedAt = maintenanceMeta?.updatedAt ? formatCreatedAt(maintenanceMeta.updatedAt) : null; const isDuplicatesDisabled = maintenanceActive || updateRunning; const updateStatusKey = updateState?.status ?? (updateRunning ? "running" : "idle"); const updateStatusLabel = UPDATE_STATUS_LABELS[updateStatusKey] ?? updateStatusKey; const updateBadgeClass = UPDATE_STATUS_STYLES[updateStatusKey] ?? UPDATE_STATUS_STYLES.idle; const updateStageLabel = updateState?.stage ? UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage : "–"; const updateLogs = updateState?.logs ?? []; const updateTargetVersion = updateState?.targetVersion ?? "-"; const updateResultVersion = updateState?.resultVersion ?? "-"; const updateStartedAt = updateState?.startedAt ? formatCreatedAt(updateState.startedAt) : "-"; const updateFinishedAt = updateState?.finishedAt ? formatCreatedAt(updateState.finishedAt) : "-"; const updateStatusMessage = updateState?.message ?? (updateRunning ? "Update läuft…" : ""); const disableUpdateButton = updateRunning || scriptSaving || maintenanceLoading; return (
{(maintenanceActive || updateRunning) && (
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}. {updateRunning && ( Phase: {updateStageLabel} )}
)} Wartungsmodus

Schaltet StackPulse für Benutzer in den Wartungsmodus.

handleMaintenanceToggle(event.target.checked)} ripple={false} color="amber" />
{maintenanceToggleLoading && (

Wartungsmodus wird aktualisiert…

)}
Portainer

Portainer Status

{portainerLoading ? "Status wird geprüft…" : portainerError ? portainerError : updateRunning ? "Portainer wird aktuell aktualisiert." : (portainerStatus?.updateAvailable === true ? "Es ist ein Update für Portainer verfügbar." : "Portainer ist auf dem aktuellen Stand.")}

{portainerUpdatedAt && !portainerLoading && (

Stand: {portainerUpdatedAt.toLocaleString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}

)}
{!portainerLoading && !portainerError && portainerStatus && (
Installierte Version {portainerStatus.currentVersion ?? "Unbekannt"}
Neueste Version {portainerStatus.latestVersion ?? "Unbekannt"}
Status {portainerStatus.updateAvailable === true ? "Update verfügbar" : portainerStatus.updateAvailable === false ? "Aktuell" : "Unbekannt"}
{portainerStatus.edition && (
Edition {portainerStatus.edition}
)} {portainerStatus.build && (
Build {portainerStatus.build}
)} {portainerStatus.errors?.latestVersion && (
Neueste Version konnte nicht ermittelt werden: {portainerStatus.errors.latestVersion}
)} {portainerStatus.errors?.container && (
Container-Details konnten nicht ermittelt werden: {portainerStatus.errors.container}
)}
/* {portainerStatus.container && (

Startparameter

{(portainerStatus.container.name || portainerStatus.container.image) && (
Container
{portainerStatus.container.name ?? "Unbekannt"} {portainerStatus.container.image && ( {portainerStatus.container.image} )}
)} {portainerStatus.container.id && (
Container-ID {portainerStatus.container.id.slice(0, 12)}
)}
Startkommando {[...(portainerStatus.container.entrypoint || []), ...(portainerStatus.container.command || [])].join(" ") || "Unbekannt"}
{Array.isArray(portainerStatus.container.args) && portainerStatus.container.args.length > 0 && (
Argumente {portainerStatus.container.args.join(" ")}
)} {Array.isArray(portainerStatus.container.env) && portainerStatus.container.env.length > 0 && (
Umgebungsvariablen
{portainerStatus.container.env.map((entry, index) => ( {entry} ))}
)} {Array.isArray(portainerStatus.container.binds) && portainerStatus.container.binds.length > 0 && (
Bind-Mounts
{portainerStatus.container.binds.map((bind, index) => ( {bind} ))}
)}
)} */ )}

SSH-Verbindung

handleSshDraftChange('password', event.target.value)} disabled={sshSaving || sshTesting || sshDeleting || updateRunning} className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm focus:border-purple-500 focus:outline-none" placeholder="Passwort für den SSH-Benutzer" autoComplete="off" spellCheck={false} /> {sshPasswordStored && !sshDraft.password && ( Ein Passwort ist gespeichert. Neuer Inhalt ersetzt es oder lösche die Konfiguration unten. )}