1038 lines
44 KiB
React
1038 lines
44 KiB
React
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>
|
||
);
|
||
}
|