diff --git a/backend/index.js b/backend/index.js index c22e158..49d6718 100644 --- a/backend/index.js +++ b/backend/index.js @@ -50,10 +50,24 @@ io.on("connection", (socket) => { console.log(`π [Socket] Client verbunden: ${socket.id}`); }); -const broadcastRedeployStatus = (stackId, status) => { - redeployingStacks[stackId] = status; - io.emit("redeployStatus", { stackId, status }); - console.log(`π [RedeployStatus] Stack ${stackId} ist jetzt ${status ? "im Redeploy" : "fertig"}`); +const broadcastRedeployStatus = ({ stackId, stackName, phase, message }) => { + const normalizedPhase = phase || (message ? 'info' : undefined); + const isRedeploying = normalizedPhase === 'started'; + redeployingStacks[stackId] = Boolean(isRedeploying); + + const payload = { + stackId, + stackName, + phase: normalizedPhase, + message, + isRedeploying + }; + + io.emit("redeployStatus", payload); + + const label = stackName ? `${stackName} (${stackId})` : `Stack ${stackId}`; + const phaseLabel = normalizedPhase ?? (isRedeploying ? 'started' : 'success'); + console.log(`π [RedeployStatus] ${label} -> ${phaseLabel}${message ? `: ${message}` : ""}`); }; const REDEPLOY_TYPES = { @@ -271,8 +285,6 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => { let stack; try { - broadcastRedeployStatus(id, true); - const stackRes = await axiosInstance.get(`/api/stacks/${id}`); stack = stackRes.data; @@ -280,6 +292,12 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => { 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, @@ -318,7 +336,11 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => { ); } - broadcastRedeployStatus(id, false); + broadcastRedeployStatus({ + stackId: stack.Id || id, + stackName: stack.Name, + phase: 'success' + }); logRedeployEvent({ stackId: stack.Id || id, stackName: stack.Name, @@ -330,8 +352,13 @@ 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); 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}`, @@ -389,7 +416,11 @@ app.put('/api/stacks/redeploy-all', async (req, res) => { for (const stack of eligibleStacks) { try { - broadcastRedeployStatus(stack.Id, true); + broadcastRedeployStatus({ + stackId: stack.Id, + stackName: stack.Name, + phase: 'started' + }); logRedeployEvent({ stackId: stack.Id, stackName: stack.Name, @@ -414,7 +445,11 @@ app.put('/api/stacks/redeploy-all', async (req, res) => { } } - broadcastRedeployStatus(stack.Id, false); + broadcastRedeployStatus({ + stackId: stack.Id, + stackName: stack.Name, + phase: 'success' + }); logRedeployEvent({ stackId: stack.Id, stackName: stack.Name, @@ -425,8 +460,13 @@ app.put('/api/stacks/redeploy-all', async (req, res) => { }); console.log(`β Redeploy abgeschlossen: ${stack.Name}`); } catch (err) { - broadcastRedeployStatus(stack.Id, false); const errorMessage = err.response?.data?.message || err.message; + broadcastRedeployStatus({ + stackId: stack.Id, + stackName: stack.Name, + phase: 'error', + message: errorMessage + }); logRedeployEvent({ stackId: stack.Id, stackName: stack.Name, @@ -511,7 +551,11 @@ app.put('/api/stacks/redeploy-selection', async (req, res) => { for (const stack of eligibleStacks) { try { - broadcastRedeployStatus(stack.Id, true); + broadcastRedeployStatus({ + stackId: stack.Id, + stackName: stack.Name, + phase: 'started' + }); logRedeployEvent({ stackId: stack.Id, stackName: stack.Name, @@ -536,7 +580,11 @@ app.put('/api/stacks/redeploy-selection', async (req, res) => { } } - broadcastRedeployStatus(stack.Id, false); + broadcastRedeployStatus({ + stackId: stack.Id, + stackName: stack.Name, + phase: 'success' + }); logRedeployEvent({ stackId: stack.Id, stackName: stack.Name, @@ -547,8 +595,13 @@ app.put('/api/stacks/redeploy-selection', async (req, res) => { }); console.log(`β Redeploy Auswahl abgeschlossen: ${stack.Name}`); } catch (err) { - broadcastRedeployStatus(stack.Id, false); const errorMessage = err.response?.data?.message || err.message; + broadcastRedeployStatus({ + stackId: stack.Id, + stackName: stack.Name, + phase: 'error', + message: errorMessage + }); logRedeployEvent({ stackId: stack.Id, stackName: stack.Name, diff --git a/frontend/src/Stacks.jsx b/frontend/src/Stacks.jsx index 09ea1a6..504df96 100644 --- a/frontend/src/Stacks.jsx +++ b/frontend/src/Stacks.jsx @@ -1,6 +1,7 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import axios from "axios"; import { io } from "socket.io-client"; +import { useToast } from "./components/ToastProvider.jsx"; const SELECTION_PROMPT_STORAGE_KEY = "stackSelectionPreference"; @@ -13,10 +14,25 @@ const PER_PAGE_OPTIONS = [ { value: "all", label: "Alle" } ]; const VALID_PER_PAGE_VALUES = new Set(PER_PAGE_OPTIONS.map((option) => option.value)); +const STACKS_CACHE_DURATION = 30 * 1000; +const STACKS_REFRESH_INTERVAL = 30 * 1000; +let stacksCache = { data: null, timestamp: 0 }; +let stacksCachePromise = null; + +const isCacheFresh = () => Boolean(stacksCache.data) && (Date.now() - stacksCache.timestamp < STACKS_CACHE_DURATION); +const updateStacksCache = (data) => { + stacksCache = { data, timestamp: Date.now() }; +}; + +const prepareInitialStacks = (data) => { + if (!Array.isArray(data)) return []; + return [...data].sort((a, b) => (a?.Name || '').localeCompare(b?.Name || '')); +}; + export default function Stacks() { - const [stacks, setStacks] = useState([]); - const [loading, setLoading] = useState(true); + const [stacks, setStacks] = useState(() => prepareInitialStacks(stacksCache.data)); + const [loading, setLoading] = useState(() => !stacksCache.data); const [error, setError] = useState(""); const [selectedStackIds, setSelectedStackIds] = useState([]); const [statusFilter, setStatusFilter] = useState("all"); @@ -27,12 +43,26 @@ export default function Stacks() { const [perPage, setPerPage] = useState(PER_PAGE_DEFAULT); const [page, setPage] = useState(1); - const mergeStackState = (previousStacks, incomingStacks) => { + const { showToast } = useToast(); + + const stacksByIdRef = useRef(new Map()); + + const mergeStackState = useCallback((previousStacks, incomingStacks) => { const prevMap = new Map(previousStacks.map((stack) => [stack.Id, stack])); const sortedIncoming = [...incomingStacks].sort((a, b) => a.Name.localeCompare(b.Name)); return sortedIncoming.map((stack) => { const previous = prevMap.get(stack.Id); + + if (previous && previous.updateStatus === 'β ' && stack.updateStatus === 'β οΈ') { + const stackLabel = stack.Name ? `${stack.Name} (ID: ${stack.Id})` : `Stack ${stack.Id}`; + showToast({ + variant: 'warning', + title: 'Aktualisierung gefunden', + description: stackLabel + }); + } + return { ...stack, redeploying: previous?.redeploying || stack.redeploying || false, @@ -40,7 +70,7 @@ export default function Stacks() { duplicateName: stack.duplicateName ?? previous?.duplicateName ?? false }; }); - }; + }, [showToast]); useEffect(() => { const socket = io("/", { @@ -49,27 +79,77 @@ export default function Stacks() { }); console.log("π Socket connected"); - socket.on("redeployStatus", async ({ stackId, status }) => { - console.log(`π Stack ${stackId} Redeploy Status: ${status ? "running" : "finished"}`); + socket.on("redeployStatus", async (payload = {}) => { + 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 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')}`); + + if (resolvedPhase === 'started') { + showToast({ + variant: 'info', + title: 'Redeploy gestartet', + description: stackLabel + }); + } else if (resolvedPhase === 'success') { + showToast({ + variant: 'success', + title: 'Redeploy abgeschlossen', + description: stackLabel + }); + } else if (resolvedPhase === 'error') { + const detail = payload.message ? ` β ${payload.message}` : ''; + showToast({ + variant: 'error', + title: 'Redeploy fehlgeschlagen', + description: `${stackLabel}${detail}` + }); + } setStacks(prev => prev.map(stack => { if (stack.Id !== stackId) return stack; - return { - ...stack, - redeploying: status, - updateStatus: status ? stack.updateStatus : 'β ' - }; + + if (resolvedPhase === 'started' || isRedeploying === true) { + return { ...stack, redeploying: true }; + } + + if (resolvedPhase === 'success') { + return { ...stack, redeploying: false, updateStatus: 'β ' }; + } + + if (resolvedPhase === 'error' || isRedeploying === false) { + return { ...stack, redeploying: false }; + } + + return stack; }) ); - if (!status) { - // Status nach Redeploy neu vom Server holen + const shouldRefresh = + resolvedPhase === 'success' || + resolvedPhase === 'error' || + (hasBooleanStatus && payload.status === false); + + if (shouldRefresh) { try { - const res = await axios.get("/api/stacks"); + const res = await axios.get('/api/stacks'); + updateStacksCache(res.data); setStacks(prev => mergeStackState(prev, res.data)); } catch (err) { - console.error("Fehler beim Aktualisieren nach Redeploy:", err); + console.error('Fehler beim Aktualisieren nach Redeploy:', err); } } }); @@ -77,22 +157,81 @@ export default function Stacks() { return () => socket.disconnect(); }, []); - const fetchStacks = async () => { - setLoading(true); + const fetchStacks = useCallback(async ({ force = false, silent = false } = {}) => { + const hadCache = Boolean(stacksCache.data); + + if (!force && hadCache) { + setStacks(prev => mergeStackState(prev, stacksCache.data)); + if (!silent) { + setLoading(false); + if (isCacheFresh()) { + return; + } + } + } + + const shouldShowSpinner = !silent && (!hadCache || force); + if (shouldShowSpinner) { + setLoading(true); + } + try { - const res = await axios.get("/api/stacks"); - setStacks(prev => mergeStackState(prev, res.data)); + if (force || !stacksCachePromise) { + stacksCachePromise = axios.get("/api/stacks") + .then((res) => { + updateStacksCache(res.data); + return res.data; + }) + .finally(() => { + stacksCachePromise = null; + }); + } + + const data = await stacksCachePromise; + setStacks(prev => mergeStackState(prev, data)); + setError(""); } catch (err) { console.error("β Fehler beim Abrufen der Stacks:", err); - setError("Fehler beim Laden der Stacks"); + if (!hadCache && !silent) { + setError("Fehler beim Laden der Stacks"); + } } finally { - setLoading(false); + if (shouldShowSpinner) { + setLoading(false); + } } - }; + }, [mergeStackState]); useEffect(() => { fetchStacks(); - }, []); + }, [fetchStacks]); + + useEffect(() => { + if (typeof document === 'undefined') return undefined; + + const intervalId = setInterval(() => { + if (!document.hidden) { + fetchStacks({ force: true, silent: true }); + } + }, STACKS_REFRESH_INTERVAL); + + return () => clearInterval(intervalId); + }, [fetchStacks]); + + useEffect(() => { + if (typeof document === 'undefined') return undefined; + + const handleVisibility = () => { + if (!document.hidden) { + fetchStacks({ force: true, silent: true }); + } + }; + + document.addEventListener('visibilitychange', handleVisibility); + return () => { + document.removeEventListener('visibilitychange', handleVisibility); + }; + }, [fetchStacks]); useEffect(() => { setSelectedStackIds(prev => { @@ -104,6 +243,10 @@ export default function Stacks() { }); }, [stacks]); + useEffect(() => { + stacksByIdRef.current = new Map(stacks.map((stack) => [stack.Id, stack])); + }, [stacks]); + const normalizedSearch = searchQuery.trim().toLowerCase(); const filteredStacks = useMemo(() => { @@ -350,6 +493,17 @@ export default function Stacks() { stack.Id === stackId ? { ...stack, redeploying: false } : stack ) ); + + if (!err.response) { + const snapshot = stacksByIdRef.current.get(stackId); + const stackLabel = snapshot?.Name ? `${snapshot.Name} (ID: ${stackId})` : `Stack ${stackId}`; + const errorText = err.message || 'Unbekannter Fehler'; + showToast({ + variant: 'error', + title: 'Redeploy fehlgeschlagen', + description: `${stackLabel} β ${errorText}` + }); + } } }; @@ -383,6 +537,13 @@ export default function Stacks() { : stack ) ); + + const errorText = err.response?.data?.error || err.message || 'Unbekannter Fehler'; + showToast({ + variant: 'error', + title: 'Redeploy ALL fehlgeschlagen', + description: errorText + }); } }; @@ -422,6 +583,13 @@ export default function Stacks() { : stack ) ); + + const errorText = err.response?.data?.error || err.message || 'Unbekannter Fehler'; + showToast({ + variant: 'error', + title: 'Redeploy Auswahl fehlgeschlagen', + description: errorText + }); } }; diff --git a/frontend/src/components/ToastProvider.jsx b/frontend/src/components/ToastProvider.jsx new file mode 100644 index 0000000..f6ceeab --- /dev/null +++ b/frontend/src/components/ToastProvider.jsx @@ -0,0 +1,121 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; + +const ToastContext = createContext(null); + +const VARIANT_STYLES = { + success: { + container: "border-green-500/80 bg-green-950/90 text-green-100", + accent: "bg-green-400" + }, + warning: { + container: "border-yellow-500/80 bg-yellow-950/90 text-yellow-100", + accent: "bg-yellow-400" + }, + error: { + container: "border-red-500/80 bg-red-950/90 text-red-100", + accent: "bg-red-400" + }, + info: { + container: "border-sky-500/60 bg-sky-950/90 text-sky-100", + accent: "bg-sky-400" + } +}; + +const DEFAULT_DURATION = 6000; + +export default function ToastProvider({ children }) { + const [toasts, setToasts] = useState([]); + const timeoutsRef = useRef(new Map()); + + const dismissToast = useCallback((id) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + + const handle = timeoutsRef.current.get(id); + if (handle && typeof window !== "undefined") { + window.clearTimeout(handle); + } + timeoutsRef.current.delete(id); + }, []); + + const showToast = useCallback(({ id, title, description, variant = "info", duration = DEFAULT_DURATION } = {}) => { + const toastId = id ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const normalizedVariant = VARIANT_STYLES[variant] ? variant : "info"; + + setToasts((prev) => { + const withoutExisting = prev.filter((toast) => toast.id !== toastId); + return [ + ...withoutExisting, + { + id: toastId, + title, + description, + variant: normalizedVariant + } + ]; + }); + + if (duration !== null && duration !== Infinity && typeof window !== "undefined") { + const timeoutHandle = window.setTimeout(() => { + dismissToast(toastId); + }, duration); + + const existingHandle = timeoutsRef.current.get(toastId); + if (existingHandle) { + window.clearTimeout(existingHandle); + } + timeoutsRef.current.set(toastId, timeoutHandle); + } + + return toastId; + }, [dismissToast]); + + useEffect(() => { + return () => { + if (typeof window === "undefined") return; + timeoutsRef.current.forEach((handle) => window.clearTimeout(handle)); + timeoutsRef.current.clear(); + }; + }, []); + + const contextValue = useMemo(() => ({ showToast, dismissToast }), [showToast, dismissToast]); + + return ( + + {children} + + {toasts.map((toast) => { + const styles = VARIANT_STYLES[toast.variant] ?? VARIANT_STYLES.info; + return ( + + + + + {toast.title && {toast.title}} + {toast.description && {toast.description}} + + dismissToast(toast.id)} + className="ml-2 rounded-md p-1 text-xs uppercase tracking-wide text-white/60 transition hover:text-white/90" + > + SchlieΓen + + + + ); + })} + + + ); +} + +export function useToast() { + const context = useContext(ToastContext); + if (!context) { + throw new Error('useToast must be used within a ToastProvider'); + } + return context; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 032e87b..25b58b5 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -2,12 +2,15 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import App from "./App.jsx"; +import ToastProvider from "./components/ToastProvider.jsx"; import './index.css'; // Tailwind CSS ReactDOM.createRoot(document.getElementById("root")).render( - + + + );
{toast.title}
{toast.description}