diff --git a/backend/index.js b/backend/index.js index c22e158..ac1612b 100644 --- a/backend/index.js +++ b/backend/index.js @@ -39,6 +39,12 @@ const axiosInstance = axios.create({ baseURL: process.env.PORTAINER_URL, }); +const REDEPLOY_STATUS = { + IDLE: 'idle', + QUEUED: 'queued', + RUNNING: 'running' +}; + const redeployingStacks = {}; const server = http.createServer(app); @@ -51,9 +57,22 @@ io.on("connection", (socket) => { }); const broadcastRedeployStatus = (stackId, status) => { - redeployingStacks[stackId] = status; - io.emit("redeployStatus", { stackId, status }); - console.log(`πŸ”„ [RedeployStatus] Stack ${stackId} ist jetzt ${status ? "im Redeploy" : "fertig"}`); + if (!status || status === REDEPLOY_STATUS.IDLE) { + delete redeployingStacks[stackId]; + } else { + redeployingStacks[stackId] = status; + } + + const payloadStatus = status || REDEPLOY_STATUS.IDLE; + io.emit("redeployStatus", { stackId, status: payloadStatus }); + + let statusText = 'fertig'; + if (payloadStatus === REDEPLOY_STATUS.RUNNING) { + statusText = 'im Redeploy'; + } else if (payloadStatus === REDEPLOY_STATUS.QUEUED) { + statusText = 'in der Warteschlange'; + } + console.log(`πŸ”„ [RedeployStatus] Stack ${stackId} ist jetzt ${statusText}`); }; const REDEPLOY_TYPES = { @@ -127,29 +146,36 @@ app.get('/api/stacks', async (req, res) => { const stacksWithStatus = await Promise.all( uniqueStacks.map(async (stack) => { + const redeployState = redeployingStacks[stack.Id] || REDEPLOY_STATUS.IDLE; + const redeployCommon = { + redeployState, + redeploying: redeployState === REDEPLOY_STATUS.RUNNING, + redeployQueued: redeployState === REDEPLOY_STATUS.QUEUED, + redeployDisabled: SELF_STACK_ID ? String(stack.Id) === SELF_STACK_ID : false + }; + try { const statusRes = await axiosInstance.get( `/api/stacks/${stack.Id}/images_status?refresh=true` ); const statusEmoji = statusRes.data.Status === 'outdated' ? '⚠️' : 'βœ…'; - return { - ...stack, - updateStatus: statusEmoji, - redeploying: redeployingStacks[stack.Id] || false, - redeployDisabled: SELF_STACK_ID ? String(stack.Id) === SELF_STACK_ID : false + return { + ...stack, + updateStatus: statusEmoji, + ...redeployCommon }; } catch (err) { console.error(`❌ Fehler beim Abrufen des Status fΓΌr Stack ${stack.Id}:`, err.message); return { ...stack, updateStatus: '❌', - redeploying: redeployingStacks[stack.Id] || false, - redeployDisabled: SELF_STACK_ID ? String(stack.Id) === SELF_STACK_ID : false + ...redeployCommon }; } }) ); + stacksWithStatus.sort((a, b) => a.Name.localeCompare(b.Name)); console.log(`βœ… GET /api/stacks: Abruf erfolgreich, ${stacksWithStatus.length} Stacks geladen`); res.json(stacksWithStatus); @@ -271,7 +297,7 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => { let stack; try { - broadcastRedeployStatus(id, true); + broadcastRedeployStatus(id, REDEPLOY_STATUS.RUNNING); const stackRes = await axiosInstance.get(`/api/stacks/${id}`); stack = stackRes.data; @@ -318,7 +344,7 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => { ); } - broadcastRedeployStatus(id, false); + broadcastRedeployStatus(id, REDEPLOY_STATUS.IDLE); logRedeployEvent({ stackId: stack.Id || id, stackName: stack.Name, @@ -330,7 +356,7 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => { console.log(`βœ… PUT /api/stacks/${id}/redeploy: Redeploy erfolgreich abgeschlossen`); res.json({ success: true, message: 'Stack redeployed' }); } catch (err) { - broadcastRedeployStatus(id, false); + broadcastRedeployStatus(id, REDEPLOY_STATUS.IDLE); const errorMessage = err.response?.data?.message || err.message; logRedeployEvent({ stackId: stack?.Id || id, @@ -387,9 +413,13 @@ app.put('/api/stacks/redeploy-all', async (req, res) => { return res.json({ success: true, message: 'Keine veralteten Stacks fΓΌr Redeploy ALL' }); } + eligibleStacks.forEach((stack) => { + broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.QUEUED); + }); + for (const stack of eligibleStacks) { try { - broadcastRedeployStatus(stack.Id, true); + broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.RUNNING); logRedeployEvent({ stackId: stack.Id, stackName: stack.Name, @@ -414,7 +444,7 @@ app.put('/api/stacks/redeploy-all', async (req, res) => { } } - broadcastRedeployStatus(stack.Id, false); + broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.IDLE); logRedeployEvent({ stackId: stack.Id, stackName: stack.Name, @@ -425,7 +455,7 @@ app.put('/api/stacks/redeploy-all', async (req, res) => { }); console.log(`βœ… Redeploy abgeschlossen: ${stack.Name}`); } catch (err) { - broadcastRedeployStatus(stack.Id, false); + broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.IDLE); const errorMessage = err.response?.data?.message || err.message; logRedeployEvent({ stackId: stack.Id, @@ -509,9 +539,13 @@ app.put('/api/stacks/redeploy-selection', async (req, res) => { return res.json({ success: true, message: 'Keine veralteten Stacks in der Auswahl' }); } + eligibleStacks.forEach((stack) => { + broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.QUEUED); + }); + for (const stack of eligibleStacks) { try { - broadcastRedeployStatus(stack.Id, true); + broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.RUNNING); logRedeployEvent({ stackId: stack.Id, stackName: stack.Name, @@ -536,7 +570,7 @@ app.put('/api/stacks/redeploy-selection', async (req, res) => { } } - broadcastRedeployStatus(stack.Id, false); + broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.IDLE); logRedeployEvent({ stackId: stack.Id, stackName: stack.Name, @@ -547,7 +581,7 @@ app.put('/api/stacks/redeploy-selection', async (req, res) => { }); console.log(`βœ… Redeploy Auswahl abgeschlossen: ${stack.Name}`); } catch (err) { - broadcastRedeployStatus(stack.Id, false); + broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.IDLE); const errorMessage = err.response?.data?.message || err.message; logRedeployEvent({ stackId: stack.Id, diff --git a/frontend/src/Stacks.jsx b/frontend/src/Stacks.jsx index d96e3fe..ca7d5b9 100644 --- a/frontend/src/Stacks.jsx +++ b/frontend/src/Stacks.jsx @@ -2,6 +2,12 @@ import React, { useEffect, useState } from "react"; import axios from "axios"; import { io } from "socket.io-client"; +const REDEPLOY_STATE = { + IDLE: "idle", + QUEUED: "queued", + RUNNING: "running" +}; + export default function Stacks() { const [stacks, setStacks] = useState([]); const [loading, setLoading] = useState(true); @@ -14,9 +20,15 @@ export default function Stacks() { return sortedIncoming.map((stack) => { const previous = prevMap.get(stack.Id); + const redeployState = stack.redeployState + ?? previous?.redeployState + ?? (previous?.redeploying ? REDEPLOY_STATE.RUNNING : REDEPLOY_STATE.IDLE); + return { ...stack, - redeploying: previous?.redeploying || stack.redeploying || false, + redeployState, + redeploying: redeployState === REDEPLOY_STATE.RUNNING, + redeployQueued: redeployState === REDEPLOY_STATE.QUEUED, redeployDisabled: stack.redeployDisabled ?? previous?.redeployDisabled ?? false }; }); @@ -30,21 +42,26 @@ export default function Stacks() { console.log("πŸ”Œ Socket connected"); socket.on("redeployStatus", async ({ stackId, status }) => { - console.log(`πŸ”„ Stack ${stackId} Redeploy Status: ${status ? "running" : "finished"}`); + const nextState = status || REDEPLOY_STATE.IDLE; + console.log(`πŸ”„ Stack ${stackId} Redeploy Status: ${nextState}`); setStacks(prev => prev.map(stack => { if (stack.Id !== stackId) return stack; - return { + const updated = { ...stack, - redeploying: status, - updateStatus: status ? stack.updateStatus : 'βœ…' + redeployState: nextState, + redeploying: nextState === REDEPLOY_STATE.RUNNING, + redeployQueued: nextState === REDEPLOY_STATE.QUEUED }; + if (nextState === REDEPLOY_STATE.IDLE) { + updated.updateStatus = "βœ…"; + } + return updated; }) ); - if (!status) { - // Status nach Redeploy neu vom Server holen + if (nextState === REDEPLOY_STATE.IDLE) { try { const res = await axios.get("/api/stacks"); setStacks(prev => mergeStackState(prev, res.data)); @@ -78,7 +95,10 @@ export default function Stacks() { setSelectedStackIds(prev => { const filtered = prev.filter(id => { const match = stacks.find(stack => stack.Id === id); - return match && match.updateStatus !== 'βœ…' && !match.redeployDisabled; + return match + && match.updateStatus !== "βœ…" + && !match.redeployDisabled + && (match.redeployState ?? REDEPLOY_STATE.IDLE) === REDEPLOY_STATE.IDLE; }); return filtered.length === prev.length ? prev : filtered; }); @@ -95,12 +115,19 @@ export default function Stacks() { const handleRedeploy = async (stackId) => { const targetStack = stacks.find((stack) => stack.Id === stackId); - if (targetStack?.redeployDisabled) return; + if (!targetStack || targetStack.redeployDisabled || targetStack.redeployState !== REDEPLOY_STATE.IDLE) return; setSelectedStackIds((prev) => prev.filter((id) => id !== stackId)); setStacks((prev) => prev.map((stack) => - stack.Id === stackId ? { ...stack, redeploying: true } : stack + stack.Id === stackId + ? { + ...stack, + redeployState: REDEPLOY_STATE.RUNNING, + redeploying: true, + redeployQueued: false + } + : stack ) ); @@ -111,14 +138,25 @@ export default function Stacks() { console.error("❌ Fehler beim Redeploy:", err); setStacks((prev) => prev.map((stack) => - stack.Id === stackId ? { ...stack, redeploying: false } : stack + stack.Id === stackId + ? { + ...stack, + redeployState: REDEPLOY_STATE.IDLE, + redeploying: false, + redeployQueued: false + } + : stack ) ); } }; const handleRedeployAll = async () => { - const outdatedStacks = stacks.filter((stack) => stack.updateStatus !== 'βœ…' && !stack.redeployDisabled); + const outdatedStacks = stacks.filter((stack) => + stack.updateStatus !== "βœ…" + && !stack.redeployDisabled + && (stack.redeployState ?? REDEPLOY_STATE.IDLE) === REDEPLOY_STATE.IDLE + ); if (!outdatedStacks.length) return; const outdatedIds = new Set(outdatedStacks.map((stack) => stack.Id)); @@ -126,7 +164,12 @@ export default function Stacks() { setStacks(prev => prev.map(stack => outdatedIds.has(stack.Id) - ? { ...stack, redeploying: true } + ? { + ...stack, + redeployState: REDEPLOY_STATE.QUEUED, + redeploying: false, + redeployQueued: true + } : stack ) ); @@ -140,7 +183,12 @@ export default function Stacks() { setStacks(prev => prev.map(stack => outdatedIds.has(stack.Id) - ? { ...stack, redeploying: false } + ? { + ...stack, + redeployState: REDEPLOY_STATE.IDLE, + redeploying: false, + redeployQueued: false + } : stack ) ); @@ -152,7 +200,10 @@ export default function Stacks() { const eligibleIds = selectedStackIds.filter((id) => { const stack = stacks.find((entry) => entry.Id === id); - return stack && stack.updateStatus !== 'βœ…' && !stack.redeployDisabled; + return stack + && stack.updateStatus !== "βœ…" + && !stack.redeployDisabled + && (stack.redeployState ?? REDEPLOY_STATE.IDLE) === REDEPLOY_STATE.IDLE; }); if (!eligibleIds.length) { @@ -165,7 +216,12 @@ export default function Stacks() { setStacks(prev => prev.map(stack => eligibleSet.has(stack.Id) - ? { ...stack, redeploying: true } + ? { + ...stack, + redeployState: REDEPLOY_STATE.QUEUED, + redeploying: false, + redeployQueued: true + } : stack ) ); @@ -179,7 +235,12 @@ export default function Stacks() { setStacks(prev => prev.map(stack => eligibleSet.has(stack.Id) - ? { ...stack, redeploying: false } + ? { + ...stack, + redeployState: REDEPLOY_STATE.IDLE, + redeploying: false, + redeployQueued: false + } : stack ) ); @@ -187,17 +248,24 @@ export default function Stacks() { }; const hasSelection = selectedStackIds.length > 0; - const hasOutdatedStacks = stacks.some((stack) => stack.updateStatus !== 'βœ…' && !stack.redeployDisabled); + const hasRedeployableStacks = stacks.some((stack) => + stack.updateStatus !== "βœ…" + && !stack.redeployDisabled + && (stack.redeployState ?? REDEPLOY_STATE.IDLE) === REDEPLOY_STATE.IDLE + ); const bulkButtonLabel = hasSelection ? `Redeploy Auswahl (${selectedStackIds.length})` - : 'Redeploy All'; + : "Redeploy All"; const bulkActionDisabled = hasSelection ? selectedStackIds.length === 0 || selectedStackIds.every(id => { const targetStack = stacks.find(stack => stack.Id === id); - return !targetStack || targetStack.redeploying || targetStack.updateStatus === 'βœ…' || targetStack.redeployDisabled; + return !targetStack + || targetStack.updateStatus === "βœ…" + || targetStack.redeployDisabled + || (targetStack.redeployState ?? REDEPLOY_STATE.IDLE) !== REDEPLOY_STATE.IDLE; }) - : !hasOutdatedStacks || stacks.every(stack => stack.updateStatus !== 'βœ…' || stack.redeploying || stack.redeployDisabled); + : !hasRedeployableStacks; const handleBulkRedeploy = () => { if (hasSelection) { @@ -224,19 +292,22 @@ export default function Stacks() {
{stacks.map(stack => { - const isRedeploying = stack.redeploying; + const redeployState = stack.redeployState ?? REDEPLOY_STATE.IDLE; + const isRunning = redeployState === REDEPLOY_STATE.RUNNING; + const isQueued = redeployState === REDEPLOY_STATE.QUEUED; + const isBusy = isRunning || isQueued; const isSelected = selectedStackIds.includes(stack.Id); - const isCurrent = stack.updateStatus === 'βœ…'; + const isCurrent = stack.updateStatus === "βœ…"; const isSelfStack = Boolean(stack.redeployDisabled); - const isSelectable = !isRedeploying && !isCurrent && !isSelfStack; + const isSelectable = !isBusy && !isCurrent && !isSelfStack; return (
@@ -258,11 +329,16 @@ export default function Stacks() {
- {isRedeploying ? ( + {isRunning ? ( <> Redeploy lΓ€uft… + ) : isQueued ? ( + <> + Redeploy + in Warteliste… + ) : isSelfStack ? ( <> System @@ -279,7 +355,7 @@ export default function Stacks() { Veraltet