Update
This commit is contained in:
@@ -3,6 +3,7 @@ import { NavLink, Route, Routes } from "react-router-dom";
|
||||
import Stacks from "./Stacks.jsx";
|
||||
import Logs from "./Logs.jsx";
|
||||
import logo from "./assets/images/stackpulse.png";
|
||||
import { useMaintenance } from "./context/MaintenanceContext.jsx";
|
||||
|
||||
const navLinkBase =
|
||||
"px-4 py-2 rounded-md font-medium transition-colors duration-150";
|
||||
@@ -11,6 +12,10 @@ const getNavClass = ({ isActive }) =>
|
||||
`${navLinkBase} ${isActive ? "bg-purple-600 text-white" : "text-gray-300 hover:bg-gray-700"}`;
|
||||
|
||||
export default function App() {
|
||||
const { maintenance, update } = useMaintenance();
|
||||
const maintenanceActive = Boolean(maintenance?.active);
|
||||
const maintenanceLabel = maintenance?.message || (update?.running ? "Portainer-Update läuft" : "Wartungsmodus aktiv");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<header className="bg-gray-800 shadow-md">
|
||||
@@ -18,12 +23,27 @@ export default function App() {
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="text-xs text-gray-500">v0.3 WIP</span>
|
||||
{maintenanceActive && (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-300">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
|
||||
{maintenanceLabel}
|
||||
</span>
|
||||
)}
|
||||
<img src={logo} alt="StackPulse" className="h-10 w-auto" />
|
||||
</div>
|
||||
<nav className="flex gap-2 items-end">
|
||||
<NavLink to="/" end className={getNavClass}>
|
||||
Stacks
|
||||
</NavLink>
|
||||
<NavLink to="/maintenance" className={getNavClass}>
|
||||
<span className="flex items-center gap-2">
|
||||
Wartung
|
||||
{maintenanceActive && (
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
|
||||
)}
|
||||
</span>
|
||||
</NavLink>
|
||||
|
||||
<NavLink to="/logs" className={getNavClass}>
|
||||
Logs
|
||||
</NavLink>
|
||||
|
||||
@@ -0,0 +1,798 @@
|
||||
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"
|
||||
};
|
||||
|
||||
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 ?? "-";
|
||||
};
|
||||
|
||||
export default function Maintenance() {
|
||||
const { showToast } = useToast();
|
||||
const {
|
||||
maintenance: maintenanceMeta,
|
||||
update: updateState,
|
||||
script: scriptConfig,
|
||||
loading: maintenanceLoading,
|
||||
error: maintenanceError,
|
||||
triggerUpdate,
|
||||
saveScript,
|
||||
resetScript
|
||||
} = 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("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!scriptConfig) return;
|
||||
const nextValue = scriptConfig.custom ?? scriptConfig.default ?? scriptConfig.effective ?? "";
|
||||
setScriptDraft(nextValue);
|
||||
}, [scriptConfig]);
|
||||
|
||||
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 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 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>
|
||||
|
||||
<div className="rounded-lg border border-amber-500/60 bg-amber-900/40 px-4 py-3 text-sm text-amber-100">
|
||||
Dieses Wartungsfeature ist noch nicht getestet. Bitte nicht in Produktivumgebungen einsetzen.
|
||||
</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>
|
||||
)}
|
||||
|
||||
<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">
|
||||
<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"}>
|
||||
{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>
|
||||
|
||||
<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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
+47
-11
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import axios from "axios";
|
||||
import { io } from "socket.io-client";
|
||||
import { useToast } from "./components/ToastProvider.jsx";
|
||||
import { useMaintenance } from "./context/MaintenanceContext.jsx";
|
||||
|
||||
const SELECTION_PROMPT_STORAGE_KEY = "stackSelectionPreference";
|
||||
|
||||
@@ -45,8 +46,22 @@ export default function Stacks() {
|
||||
|
||||
const { showToast } = useToast();
|
||||
|
||||
const { maintenance, update } = useMaintenance();
|
||||
const maintenanceActive = Boolean(maintenance?.active);
|
||||
const maintenanceMessage = maintenance?.message;
|
||||
const maintenanceLocked = maintenanceActive || Boolean(update?.running);
|
||||
const maintenanceBanner = maintenanceLocked ? (maintenanceMessage || (update?.running ? "Portainer-Update läuft" : "Wartungsmodus aktiv")) : "";
|
||||
|
||||
const stacksByIdRef = useRef(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (maintenanceLocked) {
|
||||
setLoading(false);
|
||||
setStacks([]);
|
||||
setSelectedStackIds([]);
|
||||
}
|
||||
}, [maintenanceLocked]);
|
||||
|
||||
const mergeStackState = useCallback((previousStacks, incomingStacks) => {
|
||||
const prevMap = new Map(previousStacks.map((stack) => [stack.Id, stack]));
|
||||
const sortedIncoming = [...incomingStacks].sort((a, b) => a.Name.localeCompare(b.Name));
|
||||
@@ -158,6 +173,13 @@ export default function Stacks() {
|
||||
}, []);
|
||||
|
||||
const fetchStacks = useCallback(async ({ force = false, silent = false } = {}) => {
|
||||
if (maintenanceActive) {
|
||||
if (!silent) {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const hadCache = Boolean(stacksCache.data);
|
||||
|
||||
if (!force && hadCache) {
|
||||
@@ -200,14 +222,14 @@ export default function Stacks() {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [mergeStackState]);
|
||||
}, [maintenanceActive, mergeStackState]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStacks();
|
||||
}, [fetchStacks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return undefined;
|
||||
if (typeof document === 'undefined' || maintenanceActive) return undefined;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
if (!document.hidden) {
|
||||
@@ -216,10 +238,10 @@ export default function Stacks() {
|
||||
}, STACKS_REFRESH_INTERVAL);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [fetchStacks]);
|
||||
}, [fetchStacks, maintenanceActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return undefined;
|
||||
if (typeof document === 'undefined' || maintenanceActive) return undefined;
|
||||
|
||||
const handleVisibility = () => {
|
||||
if (!document.hidden) {
|
||||
@@ -231,7 +253,7 @@ export default function Stacks() {
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
};
|
||||
}, [fetchStacks]);
|
||||
}, [fetchStacks, maintenanceActive]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedStackIds(prev => {
|
||||
@@ -390,7 +412,7 @@ export default function Stacks() {
|
||||
const pageEnd = perPage === 'all' ? totalItems : Math.min(totalItems, page * perPageNumber);
|
||||
|
||||
const toggleStackSelection = (stackId, disabled) => {
|
||||
if (disabled) return;
|
||||
if (maintenanceLocked || disabled) return;
|
||||
setSelectedStackIds(prev =>
|
||||
prev.includes(stackId)
|
||||
? prev.filter(id => id !== stackId)
|
||||
@@ -599,14 +621,23 @@ export default function Stacks() {
|
||||
? `Redeploy Auswahl (${selectedStackIds.length})`
|
||||
: 'Redeploy All';
|
||||
|
||||
const bulkActionDisabled = hasSelection
|
||||
const bulkActionDisabled = maintenanceLocked || (hasSelection
|
||||
? selectionPromptVisible || selectedStackIds.length === 0 || selectedStackIds.every(id => {
|
||||
const targetStack = stacks.find(stack => stack.Id === id);
|
||||
return !targetStack || targetStack.redeploying || targetStack.updateStatus === '✅' || targetStack.redeployDisabled;
|
||||
})
|
||||
: !hasOutdatedStacks || eligiblePageStacks.every(stack => stack.redeploying);
|
||||
: !hasOutdatedStacks || eligiblePageStacks.every(stack => stack.redeploying));
|
||||
|
||||
const handleBulkRedeploy = () => {
|
||||
if (maintenanceLocked) {
|
||||
showToast({
|
||||
variant: 'warning',
|
||||
title: 'Wartungsmodus aktiv',
|
||||
description: 'Redeploy-Aktionen sind während des Wartungsmodus deaktiviert.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasSelection) {
|
||||
handleRedeploySelection();
|
||||
} else {
|
||||
@@ -619,6 +650,11 @@ export default function Stacks() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl p-6">
|
||||
{maintenanceLocked && (
|
||||
<div className="mb-6 rounded-lg border border-amber-500/60 bg-amber-900/30 px-4 py-3 text-sm text-amber-100">
|
||||
{maintenanceBanner}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:gap-6">
|
||||
<div>
|
||||
@@ -775,7 +811,7 @@ export default function Stacks() {
|
||||
const isSelected = selectedStackIds.includes(stack.Id);
|
||||
const isCurrent = stack.updateStatus === '✅';
|
||||
const isSelfStack = Boolean(stack.redeployDisabled);
|
||||
const isSelectable = !isRedeploying && !isCurrent && !isSelfStack;
|
||||
const isSelectable = !isRedeploying && !isCurrent && !isSelfStack && !maintenanceLocked;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -827,8 +863,8 @@ export default function Stacks() {
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleRedeploy(stack.Id)}
|
||||
disabled={isRedeploying}
|
||||
className="px-5 py-2 rounded-lg font-medium transition bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isRedeploying || maintenanceLocked}
|
||||
className="px-5 py-2 rounded-lg font-medium transition bg-blue-500 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Redeploy
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
const MaintenanceContext = createContext(null);
|
||||
|
||||
const INITIAL_STATE = {
|
||||
maintenance: null,
|
||||
update: null,
|
||||
script: null,
|
||||
loading: true,
|
||||
error: "",
|
||||
lastUpdated: null
|
||||
};
|
||||
|
||||
export default function MaintenanceProvider({ children }) {
|
||||
const [state, setState] = useState(INITIAL_STATE);
|
||||
const pollingRef = useRef(null);
|
||||
const wasRunningRef = useRef(false);
|
||||
|
||||
const applyState = useCallback((partial) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
...partial
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
applyState({ loading: true, error: "" });
|
||||
try {
|
||||
const response = await axios.get("/api/maintenance/config");
|
||||
const data = response.data ?? {};
|
||||
applyState({
|
||||
maintenance: data.maintenance ?? null,
|
||||
update: data.update ?? null,
|
||||
script: data.script ?? null,
|
||||
loading: false,
|
||||
error: "",
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message || "Fehler beim Laden der Wartungsdaten";
|
||||
applyState({ loading: false, error: message });
|
||||
}
|
||||
}, [applyState]);
|
||||
|
||||
const refreshUpdateStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.get("/api/maintenance/update-status");
|
||||
const data = response.data ?? {};
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
maintenance: data.maintenance ?? prev.maintenance,
|
||||
update: data.update ?? prev.update,
|
||||
error: ""
|
||||
}));
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message || "Fehler beim Aktualisieren des Wartungsstatus";
|
||||
setState((prev) => ({ ...prev, error: message }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.update?.running) {
|
||||
refreshUpdateStatus();
|
||||
const intervalId = setInterval(() => {
|
||||
refreshUpdateStatus();
|
||||
}, 5000);
|
||||
pollingRef.current = intervalId;
|
||||
return () => clearInterval(intervalId);
|
||||
}
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
return undefined;
|
||||
}, [state.update?.running, refreshUpdateStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentlyRunning = Boolean(state.update?.running);
|
||||
if (!currentlyRunning && wasRunningRef.current) {
|
||||
fetchConfig();
|
||||
}
|
||||
wasRunningRef.current = currentlyRunning;
|
||||
}, [state.update?.running, fetchConfig]);
|
||||
|
||||
const triggerUpdate = useCallback(async (payload = {}) => {
|
||||
await axios.post("/api/maintenance/portainer-update", payload);
|
||||
await refreshUpdateStatus();
|
||||
}, [refreshUpdateStatus]);
|
||||
|
||||
const saveScript = useCallback(async (script) => {
|
||||
await axios.put("/api/maintenance/update-script", { script });
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const resetScript = useCallback(async () => {
|
||||
await axios.delete("/api/maintenance/update-script");
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
maintenance: state.maintenance,
|
||||
update: state.update,
|
||||
script: state.script,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
lastUpdated: state.lastUpdated,
|
||||
refreshConfig: fetchConfig,
|
||||
refreshUpdateStatus,
|
||||
triggerUpdate,
|
||||
saveScript,
|
||||
resetScript
|
||||
}), [state, fetchConfig, refreshUpdateStatus, triggerUpdate, saveScript, resetScript]);
|
||||
|
||||
return (
|
||||
<MaintenanceContext.Provider value={value}>
|
||||
{children}
|
||||
</MaintenanceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMaintenance() {
|
||||
const context = useContext(MaintenanceContext);
|
||||
if (!context) {
|
||||
throw new Error('useMaintenance must be used within a MaintenanceProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -3,14 +3,17 @@ import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App.jsx";
|
||||
import ToastProvider from "./components/ToastProvider.jsx";
|
||||
import MaintenanceProvider from "./context/MaintenanceContext.jsx";
|
||||
import './index.css'; // Tailwind CSS
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
<MaintenanceProvider>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</MaintenanceProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user