Files
stackpulse/frontend_old/src/Maintenance.jsx
T
2025-10-20 12:29:34 +00:00

1038 lines
44 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useMemo, useState } from "react";
import axios from "axios";
import { useToast } from "./components/ToastProvider.jsx";
import { useMaintenance } from "./context/MaintenanceContext.jsx";
const UPDATE_STATUS_LABELS = {
idle: "Bereit",
running: "Läuft",
success: "Erfolgreich",
error: "Fehlgeschlagen"
};
const UPDATE_STATUS_STYLES = {
idle: "bg-gray-700 text-gray-200",
running: "bg-sky-600 text-white",
success: "bg-green-600 text-white",
error: "bg-red-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-sky-300",
success: "text-green-300",
warning: "text-amber-300",
error: "text-red-300",
stdout: "text-gray-300",
stderr: "text-orange-300",
debug: "text-slate-400"
};
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 default function Maintenance() {
const { showToast } = useToast();
const {
maintenance: maintenanceMeta,
update: updateState,
script: scriptConfig,
ssh: sshConfig,
loading: maintenanceLoading,
error: maintenanceError,
triggerUpdate,
saveScript,
resetScript,
saveSshConfig,
deleteSshConfig,
testSshConnection
} = useMaintenance();
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
const updateRunning = Boolean(updateState?.running);
const [scriptDraft, setScriptDraft] = useState("");
const [scriptSaving, setScriptSaving] = useState(false);
const [updateActionLoading, setUpdateActionLoading] = useState(false);
const [updateActionError, setUpdateActionError] = useState("");
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 [portainerStatus, setPortainerStatus] = useState(null);
const [portainerLoading, setPortainerLoading] = useState(true);
const [portainerError, setPortainerError] = useState("");
const [portainerRefreshing, setPortainerRefreshing] = useState(false);
const [portainerUpdatedAt, setPortainerUpdatedAt] = useState(null);
const fetchPortainerStatus = useCallback(async ({ silent = false } = {}) => {
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);
} finally {
if (silent) {
setPortainerRefreshing(false);
} else {
setPortainerLoading(false);
}
}
}, []);
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 (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) {
setDuplicates([]);
setDuplicatesError("Wartungsmodus aktiv Duplikat-Verwaltung ist vorübergehend deaktiviert.");
} else {
const message = err.response?.data?.error || err.message || "Fehler beim Laden der Wartungsdaten";
setDuplicatesError(message);
}
} finally {
if (silent) {
setDuplicatesRefreshing(false);
} else {
setDuplicatesLoading(false);
}
}
}, [maintenanceActive, updateRunning]);
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 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 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 (
<div className="space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-semibold text-white">Wartung</h1>
<p className="text-sm text-gray-400">
Werkzeuge für wiederkehrende Wartungsaufgaben. Entfernt derzeit doppelte Stack-Einträge und verwaltet Portainer-Updates.
</p>
</div>
{(maintenanceActive || updateRunning) && (
<div className="rounded-lg border border-amber-500/60 bg-amber-900/30 px-4 py-3 text-sm text-amber-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
{updateRunning && (
<span className="text-xs text-amber-200">
Phase: {updateStageLabel}
</span>
)}
</div>
</div>
)}
{maintenanceError && (
<div className="rounded-lg border border-red-500/60 bg-red-900/30 px-4 py-3 text-sm text-red-100">
{maintenanceError}
</div>
)}
{/* Beginn Portainer Update */}
<div className="rounded-xl border border-gray-700 bg-gray-800/60 p-6 shadow-lg">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-lg font-semibold text-white">Portainer Status</h2>
<p className="text-sm text-gray-400">
{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.")}
</p>
{portainerUpdatedAt && !portainerLoading && (
<p className="mt-1 text-xs text-gray-500">
Stand: {portainerUpdatedAt.toLocaleString("de-DE", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
})}
</p>
)}
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => fetchPortainerStatus({ silent: false })}
disabled={portainerLoading || portainerRefreshing}
className="rounded-lg border border-purple-500 px-4 py-2 text-sm font-medium text-purple-200 transition hover:bg-purple-500/20 disabled:opacity-50"
>
Status aktualisieren
</button>
</div>
</div>
{!portainerLoading && !portainerError && portainerStatus && (
<div className="mt-5 space-y-4 text-sm text-gray-200">
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-gray-400">Installierte Version</span>
<span className="font-medium">{portainerStatus.currentVersion ?? "Unbekannt"}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-400">Neueste Version</span>
<span className="font-medium">{portainerStatus.latestVersion ?? "Unbekannt"}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-400">Status</span>
<span
className={`rounded-full px-3 py-0.5 text-xs font-semibold ${
portainerStatus.updateAvailable === true
? UPDATE_STATUS_STYLES.error
: portainerStatus.updateAvailable === false
? UPDATE_STATUS_STYLES.success
: UPDATE_STATUS_STYLES.idle
}`}
>
{portainerStatus.updateAvailable === true
? "Update verfügbar"
: portainerStatus.updateAvailable === false
? "Aktuell"
: "Unbekannt"}
</span>
</div>
{portainerStatus.edition && (
<div className="flex items-center justify-between">
<span className="text-gray-400">Edition</span>
<span className="font-medium">{portainerStatus.edition}</span>
</div>
)}
{portainerStatus.build && (
<div className="flex items-center justify-between">
<span className="text-gray-400">Build</span>
<span className="font-medium">{portainerStatus.build}</span>
</div>
)}
{portainerStatus.errors?.latestVersion && (
<div className="rounded-md border border-amber-500/40 bg-amber-900/40 px-3 py-2 text-xs text-amber-100">
Neueste Version konnte nicht ermittelt werden: {portainerStatus.errors.latestVersion}
</div>
)}
{portainerStatus.errors?.container && (
<div className="rounded-md border border-amber-500/40 bg-amber-900/40 px-3 py-2 text-xs text-amber-100">
Container-Details konnten nicht ermittelt werden: {portainerStatus.errors.container}
</div>
)}
</div>
{/* {portainerStatus.container && (
<div className="rounded-md border border-gray-700 bg-gray-900/40 p-4 text-xs text-gray-200">
<h3 className="text-sm font-semibold text-white">Startparameter</h3>
<div className="mt-3 space-y-2">
{(portainerStatus.container.name || portainerStatus.container.image) && (
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-gray-400">Container</span>
<div className="text-right">
<span className="block font-medium text-white">{portainerStatus.container.name ?? "Unbekannt"}</span>
{portainerStatus.container.image && (
<span className="text-[11px] text-gray-400">{portainerStatus.container.image}</span>
)}
</div>
</div>
)}
{portainerStatus.container.id && (
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-gray-400">Container-ID</span>
<code
title={portainerStatus.container.id}
className="rounded bg-gray-800/80 px-2 py-1 font-mono text-[11px] text-gray-100"
>
{portainerStatus.container.id.slice(0, 12)}
</code>
</div>
)}
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<span className="text-gray-400">Startkommando</span>
<code className="rounded bg-gray-800/60 px-2 py-1 font-mono text-[11px] text-gray-100 sm:max-w-xs sm:text-right">
{[...(portainerStatus.container.entrypoint || []), ...(portainerStatus.container.command || [])].join(" ") || "Unbekannt"}
</code>
</div>
{Array.isArray(portainerStatus.container.args) && portainerStatus.container.args.length > 0 && (
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<span className="text-gray-400">Argumente</span>
<code className="rounded bg-gray-800/60 px-2 py-1 font-mono text-[11px] text-gray-100 sm:max-w-xs sm:text-right">
{portainerStatus.container.args.join(" ")}
</code>
</div>
)}
{Array.isArray(portainerStatus.container.env) && portainerStatus.container.env.length > 0 && (
<div className="space-y-1">
<span className="text-gray-400">Umgebungsvariablen</span>
<div className="grid gap-1">
{portainerStatus.container.env.map((entry, index) => (
<code
key={`${entry || "env"}-${index}`}
className="block rounded bg-gray-800/70 px-2 py-1 font-mono text-[11px] text-gray-100"
>
{entry}
</code>
))}
</div>
</div>
)}
{Array.isArray(portainerStatus.container.binds) && portainerStatus.container.binds.length > 0 && (
<div className="space-y-1">
<span className="text-gray-400">Bind-Mounts</span>
<div className="grid gap-1">
{portainerStatus.container.binds.map((bind, index) => (
<code
key={`bind-${index}`}
className="block rounded bg-gray-800/70 px-2 py-1 font-mono text-[11px] text-gray-100"
>
{bind}
</code>
))}
</div>
</div>
)}
</div>
</div>
)} */}
</div>
)}
<div className="mt-6 grid gap-6 lg:grid-cols-2 xl:grid-cols-3">
<div className="rounded-md border border-gray-700 bg-gray-900/40 p-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white">SSH-Verbindung</h3>
</div>
<div className="mt-3 grid gap-3 text-sm text-gray-200">
<label className="grid gap-1">
<span className="text-xs uppercase tracking-wide text-gray-400">Host</span>
<input
type="text"
value={sshDraft.host}
onChange={(event) => handleSshDraftChange('host', 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 text-gray-100 focus:border-purple-500 focus:outline-none"
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="grid gap-1">
<span className="text-xs uppercase tracking-wide text-gray-400">Port</span>
<input
type="number"
min="1"
value={sshDraft.port}
onChange={(event) => handleSshDraftChange('port', 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 text-gray-100 focus:border-purple-500 focus:outline-none"
/>
</label>
<label className="grid gap-1">
<span className="text-xs uppercase tracking-wide text-gray-400">Benutzer</span>
<input
type="text"
value={sshDraft.username}
onChange={(event) => handleSshDraftChange('username', 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 text-gray-100 focus:border-purple-500 focus:outline-none"
/>
</label>
</div>
<div className="grid gap-1">
<div className="flex items-center justify-between text-xs uppercase tracking-wide text-gray-400">
<label htmlFor="maintenance-ssh-password" className="cursor-pointer">Passwort</label>
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
className="text-[11px] font-medium text-purple-300 transition hover:text-purple-200 disabled:opacity-50"
>
{showPassword ? 'Verbergen' : 'Anzeigen'}
</button>
</div>
<input
id="maintenance-ssh-password"
type={showPassword ? 'text' : 'password'}
value={sshDraft.password}
onChange={(event) => 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 text-gray-100 focus:border-purple-500 focus:outline-none"
placeholder="Passwort für den SSH-Benutzer"
autoComplete="off"
spellCheck={false}
/>
{sshPasswordStored && !sshDraft.password && (
<span className="text-[11px] text-gray-400">
Ein Passwort ist gespeichert. Neuer Inhalt ersetzt es oder lösche die Konfiguration unten.
</span>
)}
</div>
<label className="grid gap-1">
<span className="text-xs uppercase tracking-wide text-gray-400">Weitere SSH-Argumente</span>
<textarea
rows={3}
value={sshDraft.extraSshArgs}
onChange={(event) => handleSshDraftChange('extraSshArgs', 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 font-mono text-[11px] text-gray-100 focus:border-purple-500 focus:outline-none"
placeholder="je Zeile ein Argument (optional)"
/>
</label>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={handleSshSaveConfig}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
className="rounded-lg bg-purple-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-purple-500 disabled:opacity-50"
>
{sshSaving ? 'Speichern…' : 'SSH-Konfiguration speichern'}
</button>
<button
type="button"
onClick={handleSshTestConnection}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
className="rounded-lg border border-gray-600 px-4 py-2 text-sm font-medium text-gray-200 transition hover:bg-gray-700 disabled:opacity-50"
>
{sshTesting ? 'Test läuft…' : 'Verbindung testen'}
</button>
<button
type="button"
onClick={handleSshDeleteConfig}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
className="rounded-lg border border-red-600 px-4 py-2 text-sm font-medium text-red-200 transition hover:bg-red-900/40 disabled:opacity-50"
>
{sshDeleting ? 'Löschen…' : 'SSH-Einstellungen löschen'}
</button>
</div>
{sshTestResult && (
<p className={`mt-3 text-xs ${sshTestResult.success ? 'text-green-300' : 'text-red-300'}`}>
{sshTestResult.success ? 'SSH-Verbindung erfolgreich.' : `SSH-Verbindung fehlgeschlagen: ${sshTestResult.error}`}
</p>
)}
</div>
<div className="rounded-md border border-gray-700 bg-gray-900/40 p-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white">Update-Skript</h3>
<span className="text-xs text-gray-400">Quelle: {scriptSourceLabel}</span>
</div>
<textarea
value={scriptDraft}
onChange={(event) => setScriptDraft(event.target.value)}
rows={10}
disabled={scriptSaving || updateRunning}
className="mt-3 w-full rounded-md border border-gray-700 bg-gray-950/80 px-3 py-2 font-mono text-xs text-gray-100 focus:border-purple-500 focus:outline-none"
/>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={handleScriptSave}
disabled={!scriptIsDirty || scriptSaving || updateRunning}
className="rounded-lg bg-purple-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-purple-500 disabled:opacity-50"
>
Speichern
</button>
<button
type="button"
onClick={handleScriptReset}
disabled={!scriptConfig || scriptConfig.source !== "custom" || scriptSaving || updateRunning}
className="rounded-lg border border-gray-600 px-4 py-2 text-sm font-medium text-gray-200 transition hover:bg-gray-700 disabled:opacity-50"
>
Standard wiederherstellen
</button>
</div>
{scriptConfig?.customUpdatedAt && (
<p className="mt-2 text-xs text-gray-500">
Zuletzt geändert: {formatCreatedAt(scriptConfig.customUpdatedAt)}
</p>
)}
</div>
<div className="rounded-md border border-gray-700 bg-gray-900/40 p-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white">Update-Status</h3>
<span className={`rounded-full px-3 py-0.5 text-xs font-semibold ${updateBadgeClass}`}>
{updateStatusLabel}
</span>
</div>
<div className="mt-3 grid gap-2 text-xs text-gray-300">
<div className="flex items-center justify-between">
<span>Phase</span>
<span className="font-medium text-gray-100">{updateStageLabel}</span>
</div>
<div className="flex items-center justify-between">
<span>Ziel-Version</span>
<span className="font-medium text-gray-100">{updateTargetVersion}</span>
</div>
<div className="flex items-center justify-between">
<span>Installierte Version</span>
<span className="font-medium text-gray-100">{updateResultVersion}</span>
</div>
<div className="flex items-center justify-between">
<span>Gestartet</span>
<span>{updateStartedAt}</span>
</div>
<div className="flex items-center justify-between">
<span>Beendet</span>
<span>{updateFinishedAt}</span>
</div>
{updateStatusMessage && (
<div className="rounded-md border border-gray-700 bg-gray-800/60 px-3 py-2 text-xs text-gray-200">
{updateStatusMessage}
</div>
)}
{updateState?.error && (
<div className="rounded-md border border-red-500/60 bg-red-900/40 px-3 py-2 text-xs text-red-200">
{updateState.error}
</div>
)}
{updateActionError && (
<div className="rounded-md border border-red-500/60 bg-red-900/40 px-3 py-2 text-xs text-red-200">
{updateActionError}
</div>
)}
</div>
<div className="mt-3 h-40 overflow-y-auto rounded-md border border-gray-700 bg-gray-950/70 p-3 font-mono text-[11px] leading-relaxed text-gray-300">
{updateLogs.length === 0 ? (
<p className="text-gray-500">Noch keine Protokolle vorhanden.</p>
) : (
updateLogs.map((entry, index) => (
<div key={`${entry.timestamp}-${index}`} className="mb-2 last:mb-0">
<span className="text-gray-500">[{formatLogTimestamp(entry.timestamp)}]</span>{" "}
<span className={(LOG_LEVEL_STYLES[entry.level] ?? "text-gray-300") + " whitespace-normal break-words"} style={{ overflowWrap: "anywhere", wordBreak: "normal" }}>
{entry.message}
</span>
</div>
))
)}
</div>
<button
type="button"
onClick={handleTriggerUpdate}
disabled={disableUpdateButton || updateActionLoading}
className="mt-3 w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-500 disabled:opacity-50"
>
{updateActionLoading ? "Update wird gestartet…" : "Portainer aktualisieren"}
</button>
</div>
</div>
</div>
{/* Ende Portainer Update */}
<div className="rounded-xl border border-gray-700 bg-gray-800/60 p-6 shadow-lg">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-lg font-semibold text-white">Doppelte Stacks</h2>
<p className="text-sm text-gray-400">
{isDuplicatesDisabled
? "Wartungsmodus aktiv Duplikat-Verwaltung ist vorübergehend deaktiviert."
: duplicatesLoading
? "Analyse läuft…"
: totals.groups === 0
? "Keine Duplikate gefunden"
: `${totals.groups} Stack-Namen mit insgesamt ${totals.duplicateCount} Duplikaten gefunden`}
</p>
{duplicatesUpdatedAt && !duplicatesLoading && !isDuplicatesDisabled && (
<p className="mt-1 text-xs text-gray-500">
Stand: {duplicatesUpdatedAt.toLocaleString("de-DE", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
})}
</p>
)}
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => fetchDuplicates({ silent: false })}
disabled={isDuplicatesDisabled || duplicatesLoading || duplicatesRefreshing || activeCleanupId !== null}
className="rounded-lg border border-purple-500 px-4 py-2 text-sm font-medium text-purple-200 transition hover:bg-purple-500/20 disabled:opacity-50"
>
Aktualisieren
</button>
</div>
</div>
</div>
<div className="rounded-lg border border-amber-500/60 bg-amber-900/40 px-4 py-3 text-sm text-amber-100">
Das Entfernen von deppelten Stacks ist noch nicht getestet. Bitte nicht in Produktivumgebungen einsetzen.
</div>
{duplicatesError && !isDuplicatesDisabled && (
<div className="rounded-lg border border-red-500/60 bg-red-900/40 px-4 py-3 text-sm text-red-100">
{duplicatesError}
</div>
)}
{duplicatesLoading ? (
<div className="rounded-xl border border-gray-800 bg-gray-900/60 p-8 text-center text-sm text-gray-400">
Daten werden geladen
</div>
) : totals.groups === 0 || isDuplicatesDisabled ? (
<div className="rounded-xl border border-green-600/40 bg-green-900/30 p-8 text-center text-sm text-green-100">
{isDuplicatesDisabled
? "Wartungsmodus aktiv Duplikat-Verwaltung ist vorübergehend deaktiviert."
: "Es wurden keine doppelten Stacks gefunden."}
</div>
) : (
<div className="space-y-5">
{duplicates.map((entry) => {
const canonicalId = entry?.canonical?.Id;
const duplicatesForEntry = entry?.duplicates || [];
const isProcessing = activeCleanupId === String(canonicalId);
return (
<div
key={canonicalId || entry.name}
className="rounded-xl border border-gray-700 bg-gray-800/70 p-6 shadow"
>
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-white">{entry.name}</h3>
<span className="rounded-full bg-amber-500/20 px-3 py-0.5 text-xs font-medium text-amber-200">
{duplicatesForEntry.length} Duplikat{duplicatesForEntry.length === 1 ? "" : "e"}
</span>
</div>
<p className="text-sm text-gray-300">
Behaltener Stack: ID {canonicalId} (Endpoint {entry?.canonical?.EndpointId ?? "-"})
</p>
<p className="text-xs text-gray-500">
Typ: {resolveStackType(entry?.canonical?.Type)} Erstellt: {formatCreatedAt(entry?.canonical?.Created)}
</p>
</div>
<button
type="button"
onClick={() => handleCleanup(entry)}
disabled={isDuplicatesDisabled || isProcessing || duplicatesRefreshing || duplicatesLoading}
className="self-start rounded-lg bg-red-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-600 disabled:opacity-50"
>
{isProcessing ? "Bereinigung läuft…" : `Bereinigen (${duplicatesForEntry.length})`}
</button>
</div>
<div className="mt-5 grid gap-3">
{duplicatesForEntry.map((duplicate) => (
<div
key={duplicate.Id}
className="rounded-lg border border-red-500/40 bg-red-900/20 p-4 text-sm text-red-100"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-semibold text-white">ID: {duplicate.Id}</span>
<span>Endpoint: {duplicate.EndpointId ?? "-"}</span>
<span>Typ: {resolveStackType(duplicate.Type)}</span>
<span>Erstellt: {formatCreatedAt(duplicate.Created)}</span>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
}