From 4e63ae529e56e2ca920196ea6f8d1f795135f014 Mon Sep 17 00:00:00 2001
From: root
Date: Thu, 9 Oct 2025 18:45:44 +0000
Subject: [PATCH 1/2] Maintenance Page and Doube Stack Find
---
backend/index.js | 425 ++++++++++++++++++++++++++++++++---
frontend/src/App.jsx | 5 +
frontend/src/Logs.jsx | 2 +
frontend/src/Maintenance.jsx | 271 ++++++++++++++++++++++
4 files changed, 670 insertions(+), 33 deletions(-)
create mode 100644 frontend/src/Maintenance.jsx
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)}
+
+
+ ))}
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
From e8aeecec43ddd861fdcac0f21a30be0c89c1fe9a Mon Sep 17 00:00:00 2001
From: root
Date: Thu, 9 Oct 2025 18:47:54 +0000
Subject: [PATCH 2/2] Maintenance Page / Double Stack Remove
---
frontend/src/Maintenance.jsx | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/frontend/src/Maintenance.jsx b/frontend/src/Maintenance.jsx
index 106bc4e..5e5803e 100644
--- a/frontend/src/Maintenance.jsx
+++ b/frontend/src/Maintenance.jsx
@@ -160,6 +160,10 @@ export default function Maintenance() {
+
+ Dieses Wartungsfeature ist noch nicht getestet. Bitte nicht in Produktivumgebungen einsetzen.
+
+