diff --git a/backend/index.js b/backend/index.js index 46a7bd1..b2e0f91 100644 --- a/backend/index.js +++ b/backend/index.js @@ -45,7 +45,23 @@ const axiosInstance = axios.create({ baseURL: process.env.PORTAINER_URL, }); -const redeployingStacks = {}; +const REDEPLOY_PHASES = { + QUEUED: 'queued', + STARTED: 'started', + SUCCESS: 'success', + ERROR: 'error', + INFO: 'info' +}; + +const redeployingStacks = new Map(); + +const isActiveRedeployPhase = (phase) => phase === REDEPLOY_PHASES.QUEUED || phase === REDEPLOY_PHASES.STARTED; + +const resolveRedeployPhase = (phase, message) => { + if (phase) return phase; + if (message) return REDEPLOY_PHASES.INFO; + return REDEPLOY_PHASES.SUCCESS; +}; const server = http.createServer(app); const io = new Server(server, { @@ -57,23 +73,35 @@ io.on("connection", (socket) => { }); const broadcastRedeployStatus = ({ stackId, stackName, phase, message }) => { - const normalizedPhase = phase || (message ? 'info' : undefined); - const isRedeploying = normalizedPhase === 'started'; - redeployingStacks[stackId] = Boolean(isRedeploying); + if (!stackId) return; + + const resolvedPhase = resolveRedeployPhase(phase, message); + const isRedeploying = isActiveRedeployPhase(resolvedPhase); + + if (isRedeploying) { + redeployingStacks.set(String(stackId), { + phase: resolvedPhase, + stackName: stackName || null, + message: message || null, + updatedAt: Date.now() + }); + } else if (resolvedPhase === REDEPLOY_PHASES.SUCCESS || resolvedPhase === REDEPLOY_PHASES.ERROR || resolvedPhase === 'idle') { + redeployingStacks.delete(String(stackId)); + } const payload = { stackId, stackName, - phase: normalizedPhase, + phase: resolvedPhase, message, - isRedeploying + isRedeploying, + redeployPhase: resolvedPhase }; io.emit("redeployStatus", payload); const label = stackName ? `${stackName} (${stackId})` : `Stack ${stackId}`; - const phaseLabel = normalizedPhase ?? (isRedeploying ? 'started' : 'success'); - console.log(`🔄 [RedeployStatus] ${label} -> ${phaseLabel}${message ? `: ${message}` : ""}`); + console.log(`🔄 [RedeployStatus] ${label} -> ${resolvedPhase}${message ? `: ${message}` : ""}`); }; const REDEPLOY_TYPES = { @@ -962,6 +990,162 @@ const filterOutdatedStacks = async (stacks = []) => { }; }; +const getRedeployMessages = (type) => { + switch (type) { + case REDEPLOY_TYPES.ALL: + return { started: 'Redeploy ALL gestartet', success: 'Redeploy ALL abgeschlossen' }; + case REDEPLOY_TYPES.SELECTION: + return { started: 'Redeploy Auswahl gestartet', success: 'Redeploy Auswahl abgeschlossen' }; + case REDEPLOY_TYPES.MAINTENANCE: + return { started: 'Redeploy (Wartung) gestartet', success: 'Redeploy (Wartung) abgeschlossen' }; + default: + return { started: 'Redeploy gestartet', success: 'Redeploy erfolgreich abgeschlossen' }; + } +}; + +const shouldFallbackToStackFile = (message) => { + if (!message) return false; + const normalized = String(message).toLowerCase(); + return normalized.includes('not created from git') || normalized.includes('no git configuration'); +}; + +const redeployStackById = async (stackId, redeployType) => { + let stack; + const messages = getRedeployMessages(redeployType); + + try { + const stackRes = await axiosInstance.get(`/api/stacks/${stackId}`); + stack = stackRes.data; + + if (stack.EndpointId !== ENDPOINT_ID) { + throw new Error(`Stack gehört nicht zum Endpoint ${ENDPOINT_ID}`); + } + + const targetId = stack.Id || stackId; + const targetName = stack.Name || `Stack ${stackId}`; + + broadcastRedeployStatus({ + stackId: targetId, + stackName: targetName, + phase: REDEPLOY_PHASES.STARTED + }); + + logRedeployEvent({ + stackId: targetId, + stackName: targetName, + status: 'started', + message: messages.started, + endpoint: stack.EndpointId, + redeployType + }); + + const redeployViaStackFile = async () => { + const fileRes = await axiosInstance.get(`/api/stacks/${stack.Id}/file`); + const stackFileContent = fileRes.data?.StackFileContent; + if (!stackFileContent) { + throw new Error('Stack file konnte nicht geladen werden'); + } + + if (stack.Type === 2) { + const services = fileRes.data?.Config?.services || {}; + for (const serviceName in services) { + const imageName = services[serviceName].image; + if (!imageName) continue; + try { + console.log(`🖼️ Pulling image "${imageName}" für Service "${serviceName}"`); + await axiosInstance.post( + `/api/endpoints/${stack.EndpointId}/docker/images/create?fromImage=${encodeURIComponent(imageName)}` + ); + } catch (err) { + console.error(`❌ Fehler beim Pulling von Image "${imageName}":`, err.message); + } + } + } + + const updatePayload = { + StackFileContent: stackFileContent, + Prune: false, + PullImage: true, + Env: stack.Env || [] + }; + + const swarmId = stack.SwarmId || stack.SwarmID || fileRes.data?.SwarmID; + if (swarmId) { + updatePayload.SwarmID = swarmId; + } + + await axiosInstance.put( + `/api/stacks/${stack.Id}`, + updatePayload, + { params: { endpointId: stack.EndpointId } } + ); + }; + + const isGitStack = Boolean(stack.GitConfig?.RepositoryURL); + let gitRedeploySucceeded = false; + + if (isGitStack) { + try { + console.log(`🔄 [Redeploy] Git Stack "${stack.Name}" (${stack.Id}) wird redeployed`); + await axiosInstance.put(`/api/stacks/${stack.Id}/git/redeploy?endpointId=${stack.EndpointId}`); + gitRedeploySucceeded = true; + } catch (err) { + const gitErrorMessage = err.response?.data?.message || err.message; + if (shouldFallbackToStackFile(gitErrorMessage)) { + console.warn(`⚠️ Git Redeploy nicht möglich für Stack "${stack.Name}" (${stack.Id}): ${gitErrorMessage}. Fallback auf Stack-Datei.`); + } else { + throw err; + } + } + } + + if (!gitRedeploySucceeded) { + console.log(`🔄 [Redeploy] Stack "${stack.Name}" (${stack.Id}) wird über Stack-Datei redeployed`); + await redeployViaStackFile(); + } + + broadcastRedeployStatus({ + stackId: targetId, + stackName: targetName, + phase: REDEPLOY_PHASES.SUCCESS + }); + + logRedeployEvent({ + stackId: targetId, + stackName: targetName, + status: 'success', + message: messages.success, + endpoint: stack.EndpointId, + redeployType + }); + + return stack; + } catch (err) { + const errorMessage = err.response?.data?.message || err.message; + const fallbackId = stack?.Id || stackId; + const fallbackName = stack?.Name || `Stack ${stackId}`; + + broadcastRedeployStatus({ + stackId: fallbackId, + stackName: fallbackName, + phase: REDEPLOY_PHASES.ERROR, + message: errorMessage + }); + + logRedeployEvent({ + stackId: fallbackId, + stackName: fallbackName, + status: 'error', + message: errorMessage, + endpoint: stack?.EndpointId || ENDPOINT_ID, + redeployType + }); + + console.error(`❌ Fehler beim Redeploy von Stack ${fallbackName}:`, errorMessage); + throw new Error(errorMessage); + } +}; + // --- API Endpoints --- // Stacks abrufen @@ -983,19 +1167,27 @@ app.get('/api/stacks', maintenanceGuard, async (req, res) => { `/api/stacks/${stack.Id}/images_status?refresh=true` ); const statusEmoji = statusRes.data.Status === 'outdated' ? '⚠️' : '✅'; + const redeployMeta = redeployingStacks.get(String(stack.Id)); + const redeployPhase = redeployMeta?.phase || null; return { ...stack, updateStatus: statusEmoji, - redeploying: redeployingStacks[stack.Id] || false, + redeploying: redeployPhase === REDEPLOY_PHASES.STARTED || redeployPhase === REDEPLOY_PHASES.QUEUED, + redeployPhase, + redeployQueued: redeployPhase === REDEPLOY_PHASES.QUEUED, 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); + const redeployMeta = redeployingStacks.get(String(stack.Id)); + const redeployPhase = redeployMeta?.phase || null; return { ...stack, updateStatus: '❌', - redeploying: redeployingStacks[stack.Id] || false, + redeploying: redeployPhase === REDEPLOY_PHASES.STARTED || redeployPhase === REDEPLOY_PHASES.QUEUED, + redeployPhase, + redeployQueued: redeployPhase === REDEPLOY_PHASES.QUEUED, redeployDisabled: SELF_STACK_ID ? String(stack.Id) === SELF_STACK_ID : false, duplicateName: duplicateNameSet.has(stack.Name) }; @@ -1484,94 +1676,16 @@ app.get('/api/logs/export', (req, res) => { }); // Einzel-Redeploy -app.put('/api/stacks/:id/redeploy', maintenanceGuard, async (req, res) => { +app.put('/api/stacks/:id/redeploy', async (req, res) => { const { id } = req.params; console.log(`🔄 PUT /api/stacks/${id}/redeploy: Redeploy gestartet`); - let stack; try { - const stackRes = await axiosInstance.get(`/api/stacks/${id}`); - stack = stackRes.data; - - if (stack.EndpointId !== ENDPOINT_ID) { - throw new Error(`Stack gehört nicht zum Endpoint ${ENDPOINT_ID}`); - } - - broadcastRedeployStatus({ - stackId: stack.Id || id, - stackName: stack.Name, - phase: 'started' - }); - - logRedeployEvent({ - stackId: stack.Id || id, - stackName: stack.Name, - status: 'started', - message: 'Redeploy gestartet', - endpoint: stack.EndpointId, - redeployType: REDEPLOY_TYPES.SINGLE - }); - - if (stack.Type === 1) { - console.log(`🔄 [Redeploy] Git Stack "${stack.Name}" (${id}) wird redeployed`); - await axiosInstance.put(`/api/stacks/${id}/git/redeploy?endpointId=${stack.EndpointId}`); - } else if (stack.Type === 2) { - console.log(`🔄 [Redeploy] Compose Stack "${stack.Name}" (${id}) wird redeployed`); - const fileRes = await axiosInstance.get(`/api/stacks/${id}/file`); - const stackFileContent = fileRes.data?.StackFileContent; - if (!stackFileContent) throw new Error("Stack file konnte nicht geladen werden"); - - const services = fileRes.data?.Config?.services || {}; - for (const serviceName in services) { - const imageName = services[serviceName].image; - if (!imageName) continue; - try { - console.log(`🖼️ Pulling image "${imageName}" für Service "${serviceName}"`); - await axiosInstance.post( - `/api/endpoints/${stack.EndpointId}/docker/images/create?fromImage=${encodeURIComponent(imageName)}` - ); - } catch (err) { - console.error(`❌ Fehler beim Pulling von Image "${imageName}":`, err.message); - } - } - - await axiosInstance.put(`/api/stacks/${id}`, - { StackFileContent: stackFileContent, Prune: false, PullImage: true }, - { params: { endpointId: stack.EndpointId } } - ); - } - - broadcastRedeployStatus({ - stackId: stack.Id || id, - stackName: stack.Name, - phase: 'success' - }); - logRedeployEvent({ - stackId: stack.Id || id, - stackName: stack.Name, - status: 'success', - message: 'Redeploy erfolgreich abgeschlossen', - endpoint: stack.EndpointId, - redeployType: REDEPLOY_TYPES.SINGLE - }); + await redeployStackById(id, REDEPLOY_TYPES.SINGLE); console.log(`✅ PUT /api/stacks/${id}/redeploy: Redeploy erfolgreich abgeschlossen`); res.json({ success: true, message: 'Stack redeployed' }); } catch (err) { - const errorMessage = err.response?.data?.message || err.message; - broadcastRedeployStatus({ - stackId: stack?.Id || id, - stackName: stack?.Name, - phase: 'error', - message: errorMessage - }); - logRedeployEvent({ - stackId: stack?.Id || id, - stackName: stack?.Name || `Stack ${id}`, - status: 'error', - message: errorMessage, - endpoint: stack?.EndpointId || ENDPOINT_ID, - redeployType: REDEPLOY_TYPES.SINGLE - }); + const errorMessage = err.message || 'Unbekannter Fehler beim Redeploy'; console.error(`❌ Fehler beim Redeploy von Stack ${id}:`, errorMessage); res.status(500).json({ error: errorMessage }); } @@ -1612,9 +1726,17 @@ app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => { return res.json({ success: true, message: 'Keine veralteten Stacks gefunden' }); } + eligibleStacks.forEach((stack) => { + broadcastRedeployStatus({ + stackId: stack.Id, + stackName: stack.Name, + phase: REDEPLOY_PHASES.QUEUED + }); + }); + for (const stack of eligibleStacks) { try { - await axiosInstance.put(`/api/stacks/${stack.Id}/git/redeploy?endpointId=${stack.EndpointId}`); + await redeployStackById(stack.Id, REDEPLOY_TYPES.ALL); console.log(`✅ Redeploy ALL -> Stack ${stack.Name} (${stack.Id}) erfolgreich`); } catch (err) { console.error(`❌ Redeploy ALL -> Stack ${stack.Name} (${stack.Id}) fehlgeschlagen:`, err.message); @@ -1649,15 +1771,75 @@ app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => { // Redeploy selection app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) => { const { stackIds } = req.body || {}; - console.log(`🚀 PUT /api/stacks/redeploy-selection: Redeploy Auswahl gestartet (${Array.isArray(stackIds) ? stackIds.length : 0} Stacks)`); + const totalCount = Array.isArray(stackIds) ? stackIds.length : 0; + console.log(`🚀 PUT /api/stacks/redeploy-selection: Redeploy Auswahl gestartet (${totalCount} Stacks)`); if (!Array.isArray(stackIds) || stackIds.length === 0) { return res.status(400).json({ error: 'stackIds muss eine nicht leere Array sein' }); } try { - for (const id of stackIds) { - await axiosInstance.put(`/api/stacks/${id}/git/redeploy?endpointId=${ENDPOINT_ID}`); + const normalizedIds = stackIds.map((id) => String(id)); + const { filteredStacks } = await loadStackCollections(); + const stacksById = new Map(filteredStacks.map((stack) => [String(stack.Id), stack])); + + const missingIds = normalizedIds.filter((id) => !stacksById.has(id)); + if (missingIds.length) { + return res.status(400).json({ error: `Ungültige Stack-IDs: ${missingIds.join(', ')}` }); + } + + const selectedStacks = normalizedIds.map((id) => stacksById.get(id)).filter(Boolean); + if (!selectedStacks.length) { + return res.status(400).json({ error: 'Keine gültigen Stacks für Redeploy Auswahl gefunden' }); + } + + const { eligibleStacks, skippedStacks } = await filterOutdatedStacks(selectedStacks); + + if (skippedStacks.length) { + skippedStacks.forEach((stack) => { + console.log(`⏭️ Redeploy Auswahl übersprungen (aktuell): ${stack.Name} (${stack.Id})`); + }); + } + + const summaryList = eligibleStacks.map((stack) => `${stack.Name} (${stack.Id})`); + const summaryText = summaryList.length ? summaryList.join(', ') : 'keine Stacks'; + + logRedeployEvent({ + stackId: stackIds.join(','), + stackName: `Auswahl (${stackIds.length})`, + status: 'started', + message: `Redeploy Auswahl gestartet für: ${summaryText}`, + endpoint: ENDPOINT_ID, + redeployType: REDEPLOY_TYPES.SELECTION + }); + + if (!eligibleStacks.length) { + logRedeployEvent({ + stackId: stackIds.join(','), + stackName: `Auswahl (${stackIds.length})`, + status: 'success', + message: 'Redeploy Auswahl übersprungen: keine veralteten Stacks', + endpoint: ENDPOINT_ID, + redeployType: REDEPLOY_TYPES.SELECTION + }); + return res.json({ success: true, message: 'Keine veralteten Stacks in der Auswahl' }); + } + + eligibleStacks.forEach((stack) => { + broadcastRedeployStatus({ + stackId: stack.Id, + stackName: stack.Name, + phase: REDEPLOY_PHASES.QUEUED + }); + }); + + for (const stack of eligibleStacks) { + try { + await redeployStackById(stack.Id, REDEPLOY_TYPES.SELECTION); + console.log(`✅ Redeploy Auswahl -> Stack ${stack.Name} (${stack.Id}) erfolgreich`); + } catch (err) { + console.error(`❌ Redeploy Auswahl -> Stack ${stack.Name} (${stack.Id}) fehlgeschlagen:`, err.message); + } } logRedeployEvent({ @@ -1673,8 +1855,8 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) => } catch (err) { const message = err.response?.data?.message || err.message; logRedeployEvent({ - stackId: stackIds.join(','), - stackName: `Auswahl (${stackIds.length})`, + stackId: Array.isArray(stackIds) ? stackIds.join(',') : String(stackIds ?? ''), + stackName: `Auswahl (${Array.isArray(stackIds) ? stackIds.length : 0})`, status: 'error', message, endpoint: ENDPOINT_ID, diff --git a/frontend/src/Stacks.jsx b/frontend/src/Stacks.jsx index b5b52fc..6368be5 100644 --- a/frontend/src/Stacks.jsx +++ b/frontend/src/Stacks.jsx @@ -20,6 +20,14 @@ const STACKS_REFRESH_INTERVAL = 30 * 1000; let stacksCache = { data: null, timestamp: 0 }; let stacksCachePromise = null; +const REDEPLOY_PHASES = { + QUEUED: 'queued', + STARTED: 'started', + SUCCESS: 'success', + ERROR: 'error', + INFO: 'info' +}; + const isCacheFresh = () => Boolean(stacksCache.data) && (Date.now() - stacksCache.timestamp < STACKS_CACHE_DURATION); const updateStacksCache = (data) => { stacksCache = { data, timestamp: Date.now() }; @@ -78,9 +86,20 @@ export default function Stacks() { }); } + let effectivePhase = stack.redeployPhase ?? previous?.redeployPhase ?? null; + if (!effectivePhase && (stack.redeploying || previous?.redeploying)) { + effectivePhase = REDEPLOY_PHASES.STARTED; + } + + const isQueued = effectivePhase === REDEPLOY_PHASES.QUEUED; + const isRunning = effectivePhase === REDEPLOY_PHASES.STARTED; + const isBusy = isQueued || isRunning; + return { ...stack, - redeploying: previous?.redeploying || stack.redeploying || false, + redeployPhase: effectivePhase, + redeployQueued: isQueued, + redeploying: isBusy, redeployDisabled: stack.redeployDisabled ?? previous?.redeployDisabled ?? false, duplicateName: stack.duplicateName ?? previous?.duplicateName ?? false }; @@ -98,33 +117,45 @@ export default function Stacks() { const { stackId } = payload; if (!stackId) return; - const hasBooleanStatus = typeof payload.status === 'boolean'; - const isRedeploying = typeof payload.isRedeploying === 'boolean' - ? payload.isRedeploying - : (hasBooleanStatus ? payload.status : undefined); - const resolvedPhase = payload.phase ?? (hasBooleanStatus - ? (payload.status ? 'started' : 'success') - : undefined); + const resolvedPhaseRaw = payload.redeployPhase ?? payload.phase; + let resolvedPhase = resolvedPhaseRaw; + if (!resolvedPhase) { + if (payload.isRedeploying === true) { + resolvedPhase = REDEPLOY_PHASES.STARTED; + } else if (payload.isRedeploying === false) { + resolvedPhase = REDEPLOY_PHASES.SUCCESS; + } + } else if (resolvedPhaseRaw === 'started' || resolvedPhaseRaw === 'running') { + resolvedPhase = REDEPLOY_PHASES.STARTED; + } else if (resolvedPhaseRaw === 'queued') { + resolvedPhase = REDEPLOY_PHASES.QUEUED; + } else if (resolvedPhaseRaw === 'success') { + resolvedPhase = REDEPLOY_PHASES.SUCCESS; + } else if (resolvedPhaseRaw === 'error') { + resolvedPhase = REDEPLOY_PHASES.ERROR; + } else if (resolvedPhaseRaw === 'info') { + resolvedPhase = REDEPLOY_PHASES.INFO; + } const stackSnapshot = stacksByIdRef.current.get(stackId); const stackName = payload.stackName ?? stackSnapshot?.Name; const stackLabel = stackName ? `${stackName} (ID: ${stackId})` : `Stack ${stackId}`; - console.log(`🔄 Stack ${stackId} Redeploy Update: ${resolvedPhase ?? (isRedeploying ? 'running' : 'finished')}`); + console.log(`🔄 Stack ${stackId} Redeploy Update: ${resolvedPhase ?? 'unbekannt'}`); - if (resolvedPhase === 'started') { + if (resolvedPhase === REDEPLOY_PHASES.STARTED) { showToast({ variant: 'info', title: 'Redeploy gestartet', description: stackLabel }); - } else if (resolvedPhase === 'success') { + } else if (resolvedPhase === REDEPLOY_PHASES.SUCCESS) { showToast({ variant: 'success', title: 'Redeploy abgeschlossen', description: stackLabel }); - } else if (resolvedPhase === 'error') { + } else if (resolvedPhase === REDEPLOY_PHASES.ERROR) { const detail = payload.message ? ` – ${payload.message}` : ''; showToast({ variant: 'error', @@ -137,26 +168,45 @@ export default function Stacks() { prev.map(stack => { if (stack.Id !== stackId) return stack; - if (resolvedPhase === 'started' || isRedeploying === true) { - return { ...stack, redeploying: true }; + const nextPhase = resolvedPhase ?? stack.redeployPhase ?? null; + const isQueued = nextPhase === REDEPLOY_PHASES.QUEUED; + const isRunning = nextPhase === REDEPLOY_PHASES.STARTED; + const isSuccess = nextPhase === REDEPLOY_PHASES.SUCCESS; + const isError = nextPhase === REDEPLOY_PHASES.ERROR; + const isBusy = isQueued || isRunning; + + const updated = { + ...stack, + redeployPhase: nextPhase, + redeployQueued: isQueued, + redeploying: isBusy + }; + + if (isSuccess) { + updated.updateStatus = '✅'; + updated.redeploying = false; + updated.redeployQueued = false; } - if (resolvedPhase === 'success') { - return { ...stack, redeploying: false, updateStatus: '✅' }; + if (isError) { + updated.redeploying = false; + updated.redeployQueued = false; } - if (resolvedPhase === 'error' || isRedeploying === false) { - return { ...stack, redeploying: false }; + if (!isBusy && !isSuccess && !isError && resolvedPhase === undefined) { + updated.redeploying = false; + updated.redeployQueued = false; + updated.redeployPhase = stack.redeployPhase ?? null; } - return stack; + return updated; }) ); const shouldRefresh = - resolvedPhase === 'success' || - resolvedPhase === 'error' || - (hasBooleanStatus && payload.status === false); + resolvedPhase === REDEPLOY_PHASES.SUCCESS || + resolvedPhase === REDEPLOY_PHASES.ERROR || + (payload.isRedeploying === false && !resolvedPhase); if (shouldRefresh) { try { @@ -259,7 +309,12 @@ 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; + if (!match) return false; + if (match.updateStatus === '✅') return false; + if (match.redeployDisabled) return false; + const phase = match.redeployPhase; + if (phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED) return false; + return true; }); return filtered.length === prev.length ? prev : filtered; }); @@ -286,7 +341,13 @@ export default function Stacks() { }, [stacks, statusFilter, normalizedSearch]); const eligibleFilteredStacks = useMemo( - () => filteredStacks.filter((stack) => stack.updateStatus !== '✅' && !stack.redeployDisabled), + () => filteredStacks.filter((stack) => { + if (stack.updateStatus === '✅') return false; + if (stack.redeployDisabled) return false; + const phase = stack.redeployPhase; + if (phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED) return false; + return true; + }), [filteredStacks] ); @@ -312,7 +373,13 @@ export default function Stacks() { const visiblePageStackIds = useMemo(() => new Set(paginatedStacks.map((stack) => stack.Id)), [paginatedStacks]); const eligiblePageStacks = useMemo( - () => paginatedStacks.filter((stack) => stack.updateStatus !== '✅' && !stack.redeployDisabled), + () => paginatedStacks.filter((stack) => { + if (stack.updateStatus === '✅') return false; + if (stack.redeployDisabled) return false; + const phase = stack.redeployPhase; + if (phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED) return false; + return true; + }), [paginatedStacks] ); @@ -498,10 +565,21 @@ export default function Stacks() { }; const handleRedeploy = async (stackId) => { + const snapshot = stacksByIdRef.current.get(stackId); + const phase = snapshot?.redeployPhase; + if (phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED) return; + setSelectedStackIds((prev) => prev.filter((id) => id !== stackId)); setStacks((prev) => prev.map((stack) => - stack.Id === stackId ? { ...stack, redeploying: true } : stack + stack.Id === stackId + ? { + ...stack, + redeployPhase: REDEPLOY_PHASES.STARTED, + redeploying: true, + redeployQueued: false + } + : stack ) ); @@ -512,13 +590,20 @@ 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, + redeployPhase: null, + redeploying: false, + redeployQueued: false + } + : stack ) ); if (!err.response) { - const snapshot = stacksByIdRef.current.get(stackId); - const stackLabel = snapshot?.Name ? `${snapshot.Name} (ID: ${stackId})` : `Stack ${stackId}`; + const current = stacksByIdRef.current.get(stackId); + const stackLabel = current?.Name ? `${current.Name} (ID: ${stackId})` : `Stack ${stackId}`; const errorText = err.message || 'Unbekannter Fehler'; showToast({ variant: 'error', @@ -537,7 +622,12 @@ export default function Stacks() { setStacks(prev => prev.map(stack => targetIds.has(stack.Id) - ? { ...stack, redeploying: true } + ? { + ...stack, + redeployPhase: REDEPLOY_PHASES.QUEUED, + redeploying: true, + redeployQueued: true + } : stack ) ); @@ -555,7 +645,12 @@ export default function Stacks() { setStacks(prev => prev.map(stack => targetIds.has(stack.Id) - ? { ...stack, redeploying: false } + ? { + ...stack, + redeployPhase: null, + redeploying: false, + redeployQueued: false + } : stack ) ); @@ -574,7 +669,12 @@ export default function Stacks() { const eligibleIds = selectedStackIds.filter((id) => { const stack = stacks.find((entry) => entry.Id === id); - return stack && stack.updateStatus !== '✅' && !stack.redeployDisabled; + if (!stack) return false; + if (stack.updateStatus === '✅') return false; + if (stack.redeployDisabled) return false; + const phase = stack.redeployPhase; + if (phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED) return false; + return true; }); if (!eligibleIds.length) { @@ -587,7 +687,12 @@ export default function Stacks() { setStacks(prev => prev.map(stack => eligibleSet.has(stack.Id) - ? { ...stack, redeploying: true } + ? { + ...stack, + redeployPhase: REDEPLOY_PHASES.QUEUED, + redeploying: true, + redeployQueued: true + } : stack ) ); @@ -601,7 +706,12 @@ export default function Stacks() { setStacks(prev => prev.map(stack => eligibleSet.has(stack.Id) - ? { ...stack, redeploying: false } + ? { + ...stack, + redeployPhase: null, + redeploying: false, + redeployQueued: false + } : stack ) ); @@ -624,9 +734,13 @@ export default function Stacks() { const bulkActionDisabled = maintenanceLocked || (hasSelection ? selectionPromptVisible || selectedStackIds.length === 0 || selectedStackIds.every(id => { const targetStack = stacks.find(stack => stack.Id === id); - return !targetStack || targetStack.redeploying || targetStack.updateStatus === '✅' || targetStack.redeployDisabled; + if (!targetStack) return true; + if (targetStack.updateStatus === '✅') return true; + if (targetStack.redeployDisabled) return true; + const phase = targetStack.redeployPhase; + return phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED; }) - : !hasOutdatedStacks || eligiblePageStacks.every(stack => stack.redeploying)); + : !hasOutdatedStacks); const handleBulkRedeploy = () => { if (maintenanceLocked) { @@ -807,19 +921,22 @@ export default function Stacks() {