Maintenance Page and Doube Stack Find

This commit is contained in:
root
2025-10-09 18:45:44 +00:00
parent a8c01b9cb2
commit 4e63ae529e
4 changed files with 670 additions and 33 deletions
+389 -30
View File
@@ -73,11 +73,83 @@ const broadcastRedeployStatus = ({ stackId, stackName, phase, message }) => {
const REDEPLOY_TYPES = {
SINGLE: 'Einzeln',
ALL: 'Alle',
SELECTION: 'Auswahl'
SELECTION: 'Auswahl',
MAINTENANCE: 'Wartung'
};
const SELF_STACK_ID = process.env.SELF_STACK_ID ? String(process.env.SELF_STACK_ID) : null;
const fetchPortainerStacks = async () => {
const stacksRes = await axiosInstance.get('/api/stacks');
return stacksRes.data.filter((stack) => stack.EndpointId === ENDPOINT_ID);
};
const buildStackCollections = (stacks = []) => {
const collections = new Map();
stacks.forEach((stack) => {
const name = stack.Name || 'Unbenannt';
const isSelf = SELF_STACK_ID && String(stack.Id) === SELF_STACK_ID;
const entry = collections.get(name);
if (!entry) {
collections.set(name, {
canonical: stack,
isSelf,
members: [stack]
});
return;
}
entry.members.push(stack);
if (!entry.isSelf && isSelf) {
entry.canonical = stack;
entry.isSelf = true;
}
});
const canonicalStacks = [];
const duplicates = [];
collections.forEach((entry, name) => {
canonicalStacks.push(entry.canonical);
if (entry.members.length > 1) {
const seenIds = new Set();
const duplicateEntries = entry.members.filter((member) => {
const id = String(member.Id);
if (id === String(entry.canonical.Id)) {
return false;
}
if (seenIds.has(id)) {
return false;
}
seenIds.add(id);
return true;
});
if (duplicateEntries.length > 0) {
duplicates.push({
name,
canonical: entry.canonical,
members: duplicateEntries
});
}
}
});
return { canonicalStacks, duplicates };
};
const loadStackCollections = async () => {
const filteredStacks = await fetchPortainerStacks();
return {
filteredStacks,
...buildStackCollections(filteredStacks)
};
};
const isStackOutdated = async (stackId) => {
try {
const statusRes = await axiosInstance.get(`/api/stacks/${stackId}/images_status?refresh=true`);
@@ -110,37 +182,16 @@ const filterOutdatedStacks = async (stacks = []) => {
app.get('/api/stacks', async (req, res) => {
console.log("️ [API] GET /api/stacks: Abruf gestartet");
try {
const stacksRes = await axiosInstance.get('/api/stacks');
const filteredStacks = stacksRes.data.filter(stack => stack.EndpointId === ENDPOINT_ID);
const { canonicalStacks, duplicates } = await loadStackCollections();
const duplicateNames = duplicates.map((entry) => entry.name);
const duplicateNameSet = new Set(duplicateNames);
const stacksByName = new Map();
const duplicateNames = new Set();
filteredStacks.forEach(stack => {
const name = stack.Name;
const isSelf = SELF_STACK_ID && String(stack.Id) === SELF_STACK_ID;
const existingEntry = stacksByName.get(name);
if (!existingEntry) {
stacksByName.set(name, { stack, isSelf });
return;
}
duplicateNames.add(name);
if (!existingEntry.isSelf && isSelf) {
stacksByName.set(name, { stack, isSelf });
}
});
const uniqueStacks = Array.from(stacksByName.values()).map(entry => entry.stack);
if (duplicateNames.size) {
console.warn(`⚠️ [API] GET /api/stacks: Doppelte Stack-Namen erkannt: ${Array.from(duplicateNames).join(', ')}`);
if (duplicateNames.length) {
console.warn(`⚠️ [API] GET /api/stacks: Doppelte Stack-Namen erkannt: ${duplicateNames.join(', ')}`);
}
const stacksWithStatus = await Promise.all(
uniqueStacks.map(async (stack) => {
canonicalStacks.map(async (stack) => {
try {
const statusRes = await axiosInstance.get(
`/api/stacks/${stack.Id}/images_status?refresh=true`
@@ -150,7 +201,8 @@ app.get('/api/stacks', async (req, res) => {
...stack,
updateStatus: statusEmoji,
redeploying: redeployingStacks[stack.Id] || false,
redeployDisabled: SELF_STACK_ID ? String(stack.Id) === SELF_STACK_ID : false
redeployDisabled: SELF_STACK_ID ? String(stack.Id) === SELF_STACK_ID : false,
duplicateName: duplicateNameSet.has(stack.Name)
};
} catch (err) {
console.error(`❌ Fehler beim Abrufen des Status für Stack ${stack.Id}:`, err.message);
@@ -158,7 +210,8 @@ app.get('/api/stacks', async (req, res) => {
...stack,
updateStatus: '❌',
redeploying: redeployingStacks[stack.Id] || false,
redeployDisabled: SELF_STACK_ID ? String(stack.Id) === SELF_STACK_ID : false
redeployDisabled: SELF_STACK_ID ? String(stack.Id) === SELF_STACK_ID : false,
duplicateName: duplicateNameSet.has(stack.Name)
};
}
})
@@ -173,6 +226,159 @@ app.get('/api/stacks', async (req, res) => {
}
});
app.get('/api/maintenance/duplicates', async (req, res) => {
console.log("🧹 [Maintenance] GET /api/maintenance/duplicates: Abruf gestartet");
try {
const { duplicates } = await loadStackCollections();
const payload = duplicates
.sort((a, b) => a.name.localeCompare(b.name))
.map((entry) => ({
name: entry.name,
canonical: {
Id: entry.canonical.Id,
Name: entry.canonical.Name,
EndpointId: entry.canonical.EndpointId,
Type: entry.canonical.Type,
Created: entry.canonical.Created
},
duplicates: entry.members.map((stack) => ({
Id: stack.Id,
Name: stack.Name,
EndpointId: stack.EndpointId,
Type: stack.Type,
Created: stack.Created
}))
}));
res.json({
total: payload.length,
items: payload
});
} catch (err) {
console.error(`❌ [Maintenance] Fehler beim Abrufen der Duplikate:`, err.message);
res.status(500).json({ error: 'Fehler beim Abrufen der doppelten Stacks' });
}
});
app.post('/api/maintenance/duplicates/cleanup', async (req, res) => {
const canonicalId = req.body?.canonicalId;
const duplicateIdsInput = Array.isArray(req.body?.duplicateIds) ? req.body.duplicateIds : [];
const duplicateIds = duplicateIdsInput
.map((value) => String(value).trim())
.filter((value) => value.length > 0);
if (!canonicalId) {
return res.status(400).json({ error: 'canonicalId ist erforderlich' });
}
if (!duplicateIds.length) {
return res.status(400).json({ error: 'duplicateIds ist erforderlich' });
}
const canonicalIdStr = String(canonicalId);
console.log(`🧹 [Maintenance] Bereinigung angefordert für Stack ${canonicalIdStr}. Ziel-IDs: ${duplicateIds.join(', ')}`);
try {
const { duplicates } = await loadStackCollections();
const target = duplicates.find((entry) => String(entry.canonical.Id) === canonicalIdStr);
if (!target) {
return res.status(404).json({ error: 'Kein doppelter Stack für diese ID gefunden' });
}
const duplicatesToDelete = target.members.filter((stack) => duplicateIds.includes(String(stack.Id)));
if (!duplicatesToDelete.length) {
return res.status(400).json({ error: 'Keine passenden Duplikate gefunden' });
}
logRedeployEvent({
stackId: target.canonical.Id,
stackName: target.canonical.Name,
status: 'started',
message: `Bereinigung doppelter Stacks gestartet (${duplicatesToDelete.length} Einträge)`,
endpoint: target.canonical.EndpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE
});
const results = [];
const errors = [];
for (const stack of duplicatesToDelete) {
try {
await axiosInstance.delete(`/api/stacks/${stack.Id}`, {
params: { endpointId: stack.EndpointId }
});
console.log(`🧹 [Maintenance] Stack entfernt: ${stack.Name} (${stack.Id})`);
results.push({
id: stack.Id,
name: stack.Name,
endpointId: stack.EndpointId,
status: 'deleted'
});
} catch (err) {
const message = err.response?.data?.message || err.message;
console.error(`❌ [Maintenance] Fehler beim Entfernen von Stack ${stack.Id}:`, message);
errors.push({ id: stack.Id, message });
results.push({
id: stack.Id,
name: stack.Name,
endpointId: stack.EndpointId,
status: 'error',
message
});
}
}
if (errors.length) {
const failedIds = errors.map((entry) => entry.id).join(', ');
logRedeployEvent({
stackId: target.canonical.Id,
stackName: target.canonical.Name,
status: 'error',
message: `Bereinigung fehlgeschlagen für IDs: ${failedIds}`,
endpoint: target.canonical.EndpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE
});
return res.status(500).json({
success: false,
canonical: {
id: target.canonical.Id,
name: target.canonical.Name,
endpointId: target.canonical.EndpointId
},
results
});
}
logRedeployEvent({
stackId: target.canonical.Id,
stackName: target.canonical.Name,
status: 'success',
message: `Bereinigung abgeschlossen. Entfernte IDs: ${results.map((entry) => entry.id).join(', ')}`,
endpoint: target.canonical.EndpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE
});
res.json({
success: true,
canonical: {
id: target.canonical.Id,
name: target.canonical.Name,
endpointId: target.canonical.EndpointId
},
removed: results.length,
results
});
} catch (err) {
const message = err.response?.data?.message || err.message;
console.error(`❌ [Maintenance] Fehler bei der Bereinigung:`, message);
res.status(500).json({ error: message || 'Fehler bei der Bereinigung' });
}
});
// Redeploy-Logs abrufen
app.get('/api/logs', (req, res) => {
const perPageParam = req.query.perPage ?? req.query.limit;
@@ -494,6 +700,159 @@ app.put('/api/stacks/redeploy-all', async (req, res) => {
}
});
app.get('/api/maintenance/duplicates', async (req, res) => {
console.log("🧹 [Maintenance] GET /api/maintenance/duplicates: Abruf gestartet");
try {
const { duplicates } = await loadStackCollections();
const payload = duplicates
.sort((a, b) => a.name.localeCompare(b.name))
.map((entry) => ({
name: entry.name,
canonical: {
Id: entry.canonical.Id,
Name: entry.canonical.Name,
EndpointId: entry.canonical.EndpointId,
Type: entry.canonical.Type,
Created: entry.canonical.Created
},
duplicates: entry.members.map((stack) => ({
Id: stack.Id,
Name: stack.Name,
EndpointId: stack.EndpointId,
Type: stack.Type,
Created: stack.Created
}))
}));
res.json({
total: payload.length,
items: payload
});
} catch (err) {
console.error(`❌ [Maintenance] Fehler beim Abrufen der Duplikate:`, err.message);
res.status(500).json({ error: 'Fehler beim Abrufen der doppelten Stacks' });
}
});
app.post('/api/maintenance/duplicates/cleanup', async (req, res) => {
const canonicalId = req.body?.canonicalId;
const duplicateIdsInput = Array.isArray(req.body?.duplicateIds) ? req.body.duplicateIds : [];
const duplicateIds = duplicateIdsInput
.map((value) => String(value).trim())
.filter((value) => value.length > 0);
if (!canonicalId) {
return res.status(400).json({ error: 'canonicalId ist erforderlich' });
}
if (!duplicateIds.length) {
return res.status(400).json({ error: 'duplicateIds ist erforderlich' });
}
const canonicalIdStr = String(canonicalId);
console.log(`🧹 [Maintenance] Bereinigung angefordert für Stack ${canonicalIdStr}. Ziel-IDs: ${duplicateIds.join(', ')}`);
try {
const { duplicates } = await loadStackCollections();
const target = duplicates.find((entry) => String(entry.canonical.Id) === canonicalIdStr);
if (!target) {
return res.status(404).json({ error: 'Kein doppelter Stack für diese ID gefunden' });
}
const duplicatesToDelete = target.members.filter((stack) => duplicateIds.includes(String(stack.Id)));
if (!duplicatesToDelete.length) {
return res.status(400).json({ error: 'Keine passenden Duplikate gefunden' });
}
logRedeployEvent({
stackId: target.canonical.Id,
stackName: target.canonical.Name,
status: 'started',
message: `Bereinigung doppelter Stacks gestartet (${duplicatesToDelete.length} Einträge)`,
endpoint: target.canonical.EndpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE
});
const results = [];
const errors = [];
for (const stack of duplicatesToDelete) {
try {
await axiosInstance.delete(`/api/stacks/${stack.Id}`, {
params: { endpointId: stack.EndpointId }
});
console.log(`🧹 [Maintenance] Stack entfernt: ${stack.Name} (${stack.Id})`);
results.push({
id: stack.Id,
name: stack.Name,
endpointId: stack.EndpointId,
status: 'deleted'
});
} catch (err) {
const message = err.response?.data?.message || err.message;
console.error(`❌ [Maintenance] Fehler beim Entfernen von Stack ${stack.Id}:`, message);
errors.push({ id: stack.Id, message });
results.push({
id: stack.Id,
name: stack.Name,
endpointId: stack.EndpointId,
status: 'error',
message
});
}
}
if (errors.length) {
const failedIds = errors.map((entry) => entry.id).join(', ');
logRedeployEvent({
stackId: target.canonical.Id,
stackName: target.canonical.Name,
status: 'error',
message: `Bereinigung fehlgeschlagen für IDs: ${failedIds}`,
endpoint: target.canonical.EndpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE
});
return res.status(500).json({
success: false,
canonical: {
id: target.canonical.Id,
name: target.canonical.Name,
endpointId: target.canonical.EndpointId
},
results
});
}
logRedeployEvent({
stackId: target.canonical.Id,
stackName: target.canonical.Name,
status: 'success',
message: `Bereinigung abgeschlossen. Entfernte IDs: ${results.map((entry) => entry.id).join(', ')}`,
endpoint: target.canonical.EndpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE
});
res.json({
success: true,
canonical: {
id: target.canonical.Id,
name: target.canonical.Name,
endpointId: target.canonical.EndpointId
},
removed: results.length,
results
});
} catch (err) {
const message = err.response?.data?.message || err.message;
console.error(`❌ [Maintenance] Fehler bei der Bereinigung:`, message);
res.status(500).json({ error: message || 'Fehler bei der Bereinigung' });
}
});
app.put('/api/stacks/redeploy-selection', async (req, res) => {
const { stackIds } = req.body || {};
console.log(`🚀 PUT /api/stacks/redeploy-selection: Redeploy Auswahl gestartet (${Array.isArray(stackIds) ? stackIds.length : 0} Stacks)`);
+5
View File
@@ -2,6 +2,7 @@ import React from "react";
import { NavLink, Route, Routes } from "react-router-dom";
import Stacks from "./Stacks.jsx";
import Logs from "./Logs.jsx";
import Maintenance from "./Maintenance.jsx";
import logo from "./assets/images/stackpulse.png";
const navLinkBase =
@@ -24,6 +25,9 @@ export default function App() {
<NavLink to="/" end className={getNavClass}>
Stacks
</NavLink>
<NavLink to="/maintenance" className={getNavClass}>
Wartung
</NavLink>
<NavLink to="/logs" className={getNavClass}>
Logs
</NavLink>
@@ -34,6 +38,7 @@ export default function App() {
<main className="max-w-6xl mx-auto px-6 py-6">
<Routes>
<Route path="/" element={<Stacks />} />
<Route path="/maintenance" element={<Maintenance />} />
<Route path="/logs" element={<Logs />} />
<Route path="*" element={<Stacks />} />
</Routes>
+2
View File
@@ -44,6 +44,8 @@ const REDEPLOY_TYPE_LABELS = {
Einzeln: "Einzeln",
Alle: "Alle",
Auswahl: "Auswahl",
Wartung: "Wartung",
maintenance: "Wartung",
single: "Einzeln",
all: "Alle",
selection: "Auswahl"
+271
View File
@@ -0,0 +1,271 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import axios from "axios";
import { useToast } from "./components/ToastProvider.jsx";
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 [duplicates, setDuplicates] = useState([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState("");
const [activeCleanupId, setActiveCleanupId] = useState(null);
const [lastUpdated, setLastUpdated] = useState(null);
const fetchDuplicates = useCallback(async ({ silent = false } = {}) => {
if (silent) {
setRefreshing(true);
} else {
setLoading(true);
}
setError("");
try {
const response = await axios.get("/api/maintenance/duplicates");
const payload = response.data;
const items = Array.isArray(payload) ? payload : payload?.items ?? [];
setDuplicates(items);
setLastUpdated(new Date());
} catch (err) {
console.error("❌ Fehler beim Laden der Wartungsdaten:", err);
setError("Fehler beim Laden der Wartungsdaten");
} finally {
if (silent) {
setRefreshing(false);
} else {
setLoading(false);
}
}
}, []);
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 handleCleanup = useCallback(async (entry) => {
if (!entry) 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";
console.error("❌ Fehler bei der Duplikat-Bereinigung:", err);
showToast({
variant: "error",
title: "Bereinigung fehlgeschlagen",
description: message
});
} finally {
setActiveCleanupId(null);
}
}, [fetchDuplicates, showToast]);
const isEmpty = !loading && totals.groups === 0;
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.
</p>
</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">
{loading
? "Analyse läuft…"
: totals.groups === 0
? "Keine Duplikate gefunden"
: `${totals.groups} Stack-Namen mit insgesamt ${totals.duplicateCount} Duplikaten gefunden`}
</p>
{lastUpdated && (
<p className="mt-1 text-xs text-gray-500">
Stand: {lastUpdated.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={loading || refreshing || 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>
{error && (
<div className="rounded-lg border border-red-500/60 bg-red-900/40 px-4 py-3 text-sm text-red-100">
{error}
</div>
)}
{loading ? (
<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>
) : isEmpty ? (
<div className="rounded-xl border border-green-600/40 bg-green-900/30 p-8 text-center text-sm text-green-100">
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={isProcessing || refreshing || loading}
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>
);
}