Merge feature/v03-double_stack_remove into dev
This commit is contained in:
+389
-30
@@ -73,11 +73,83 @@ const broadcastRedeployStatus = ({ stackId, stackName, phase, message }) => {
|
|||||||
const REDEPLOY_TYPES = {
|
const REDEPLOY_TYPES = {
|
||||||
SINGLE: 'Einzeln',
|
SINGLE: 'Einzeln',
|
||||||
ALL: 'Alle',
|
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 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) => {
|
const isStackOutdated = async (stackId) => {
|
||||||
try {
|
try {
|
||||||
const statusRes = await axiosInstance.get(`/api/stacks/${stackId}/images_status?refresh=true`);
|
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) => {
|
app.get('/api/stacks', async (req, res) => {
|
||||||
console.log("ℹ️ [API] GET /api/stacks: Abruf gestartet");
|
console.log("ℹ️ [API] GET /api/stacks: Abruf gestartet");
|
||||||
try {
|
try {
|
||||||
const stacksRes = await axiosInstance.get('/api/stacks');
|
const { canonicalStacks, duplicates } = await loadStackCollections();
|
||||||
const filteredStacks = stacksRes.data.filter(stack => stack.EndpointId === ENDPOINT_ID);
|
const duplicateNames = duplicates.map((entry) => entry.name);
|
||||||
|
const duplicateNameSet = new Set(duplicateNames);
|
||||||
|
|
||||||
const stacksByName = new Map();
|
if (duplicateNames.length) {
|
||||||
const duplicateNames = new Set();
|
console.warn(`⚠️ [API] GET /api/stacks: Doppelte Stack-Namen erkannt: ${duplicateNames.join(', ')}`);
|
||||||
|
|
||||||
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(', ')}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stacksWithStatus = await Promise.all(
|
const stacksWithStatus = await Promise.all(
|
||||||
uniqueStacks.map(async (stack) => {
|
canonicalStacks.map(async (stack) => {
|
||||||
try {
|
try {
|
||||||
const statusRes = await axiosInstance.get(
|
const statusRes = await axiosInstance.get(
|
||||||
`/api/stacks/${stack.Id}/images_status?refresh=true`
|
`/api/stacks/${stack.Id}/images_status?refresh=true`
|
||||||
@@ -150,7 +201,8 @@ app.get('/api/stacks', async (req, res) => {
|
|||||||
...stack,
|
...stack,
|
||||||
updateStatus: statusEmoji,
|
updateStatus: statusEmoji,
|
||||||
redeploying: redeployingStacks[stack.Id] || false,
|
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) {
|
} catch (err) {
|
||||||
console.error(`❌ Fehler beim Abrufen des Status für Stack ${stack.Id}:`, err.message);
|
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,
|
...stack,
|
||||||
updateStatus: '❌',
|
updateStatus: '❌',
|
||||||
redeploying: redeployingStacks[stack.Id] || false,
|
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
|
// Redeploy-Logs abrufen
|
||||||
app.get('/api/logs', (req, res) => {
|
app.get('/api/logs', (req, res) => {
|
||||||
const perPageParam = req.query.perPage ?? req.query.limit;
|
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) => {
|
app.put('/api/stacks/redeploy-selection', async (req, res) => {
|
||||||
const { stackIds } = req.body || {};
|
const { stackIds } = req.body || {};
|
||||||
console.log(`🚀 PUT /api/stacks/redeploy-selection: Redeploy Auswahl gestartet (${Array.isArray(stackIds) ? stackIds.length : 0} Stacks)`);
|
console.log(`🚀 PUT /api/stacks/redeploy-selection: Redeploy Auswahl gestartet (${Array.isArray(stackIds) ? stackIds.length : 0} Stacks)`);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from "react";
|
|||||||
import { NavLink, Route, Routes } from "react-router-dom";
|
import { NavLink, Route, Routes } from "react-router-dom";
|
||||||
import Stacks from "./Stacks.jsx";
|
import Stacks from "./Stacks.jsx";
|
||||||
import Logs from "./Logs.jsx";
|
import Logs from "./Logs.jsx";
|
||||||
|
import Maintenance from "./Maintenance.jsx";
|
||||||
import logo from "./assets/images/stackpulse.png";
|
import logo from "./assets/images/stackpulse.png";
|
||||||
|
|
||||||
const navLinkBase =
|
const navLinkBase =
|
||||||
@@ -24,6 +25,9 @@ export default function App() {
|
|||||||
<NavLink to="/" end className={getNavClass}>
|
<NavLink to="/" end className={getNavClass}>
|
||||||
Stacks
|
Stacks
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink to="/maintenance" className={getNavClass}>
|
||||||
|
Wartung
|
||||||
|
</NavLink>
|
||||||
<NavLink to="/logs" className={getNavClass}>
|
<NavLink to="/logs" className={getNavClass}>
|
||||||
Logs
|
Logs
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -34,6 +38,7 @@ export default function App() {
|
|||||||
<main className="max-w-6xl mx-auto px-6 py-6">
|
<main className="max-w-6xl mx-auto px-6 py-6">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Stacks />} />
|
<Route path="/" element={<Stacks />} />
|
||||||
|
<Route path="/maintenance" element={<Maintenance />} />
|
||||||
<Route path="/logs" element={<Logs />} />
|
<Route path="/logs" element={<Logs />} />
|
||||||
<Route path="*" element={<Stacks />} />
|
<Route path="*" element={<Stacks />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ const REDEPLOY_TYPE_LABELS = {
|
|||||||
Einzeln: "Einzeln",
|
Einzeln: "Einzeln",
|
||||||
Alle: "Alle",
|
Alle: "Alle",
|
||||||
Auswahl: "Auswahl",
|
Auswahl: "Auswahl",
|
||||||
|
Wartung: "Wartung",
|
||||||
|
maintenance: "Wartung",
|
||||||
single: "Einzeln",
|
single: "Einzeln",
|
||||||
all: "Alle",
|
all: "Alle",
|
||||||
selection: "Auswahl"
|
selection: "Auswahl"
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
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-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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user