diff --git a/backend/index.js b/backend/index.js index 49d6718..cb0731b 100644 --- a/backend/index.js +++ b/backend/index.js @@ -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,47 +182,27 @@ 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` ); const statusEmoji = statusRes.data.Status === 'outdated' ? '⚠️' : '✅'; - return { - ...stack, - updateStatus: statusEmoji, + return { + ...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)`); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 917cf47..d39b6a3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { Stacks + + Wartung + Logs @@ -34,6 +38,7 @@ export default function App() {
} /> + } /> } /> } /> diff --git a/frontend/src/Logs.jsx b/frontend/src/Logs.jsx index b86bd83..1488dee 100644 --- a/frontend/src/Logs.jsx +++ b/frontend/src/Logs.jsx @@ -44,6 +44,8 @@ const REDEPLOY_TYPE_LABELS = { Einzeln: "Einzeln", Alle: "Alle", Auswahl: "Auswahl", + Wartung: "Wartung", + maintenance: "Wartung", single: "Einzeln", all: "Alle", selection: "Auswahl" diff --git a/frontend/src/Maintenance.jsx b/frontend/src/Maintenance.jsx new file mode 100644 index 0000000..106bc4e --- /dev/null +++ b/frontend/src/Maintenance.jsx @@ -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 ( +
+
+

Wartung

+

+ Werkzeuge für wiederkehrende Wartungsaufgaben. Entfernt derzeit doppelte Stack-Einträge. +

+
+ +
+
+
+

Doppelte Stacks

+

+ {loading + ? "Analyse läuft…" + : totals.groups === 0 + ? "Keine Duplikate gefunden" + : `${totals.groups} Stack-Namen mit insgesamt ${totals.duplicateCount} Duplikaten gefunden`} +

+ {lastUpdated && ( +

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

+ )} +
+
+ +
+
+
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+ Daten werden geladen… +
+ ) : isEmpty ? ( +
+ Es wurden keine doppelten Stacks gefunden. +
+ ) : ( +
+ {duplicates.map((entry) => { + const canonicalId = entry?.canonical?.Id; + const duplicatesForEntry = entry?.duplicates || []; + const isProcessing = activeCleanupId === String(canonicalId); + + return ( +
+
+
+
+

{entry.name}

+ + {duplicatesForEntry.length} Duplikat{duplicatesForEntry.length === 1 ? "" : "e"} + +
+

+ Behaltener Stack: ID {canonicalId} (Endpoint {entry?.canonical?.EndpointId ?? "-"}) +

+

+ Typ: {resolveStackType(entry?.canonical?.Type)} • Erstellt: {formatCreatedAt(entry?.canonical?.Created)} +

+
+ + +
+ +
+ {duplicatesForEntry.map((duplicate) => ( +
+
+ ID: {duplicate.Id} + Endpoint: {duplicate.EndpointId ?? "-"} + Typ: {resolveStackType(duplicate.Type)} + Erstellt: {formatCreatedAt(duplicate.Created)} +
+
+ ))} +
+
+ ); + })} +
+ )} +
+ ); +}