Toast Notifications

This commit is contained in:
root
2025-10-09 10:30:26 +00:00
parent db46b366ab
commit b6579f511d
4 changed files with 384 additions and 39 deletions
+67 -14
View File
@@ -50,10 +50,24 @@ io.on("connection", (socket) => {
console.log(`🔌 [Socket] Client verbunden: ${socket.id}`); console.log(`🔌 [Socket] Client verbunden: ${socket.id}`);
}); });
const broadcastRedeployStatus = (stackId, status) => { const broadcastRedeployStatus = ({ stackId, stackName, phase, message }) => {
redeployingStacks[stackId] = status; const normalizedPhase = phase || (message ? 'info' : undefined);
io.emit("redeployStatus", { stackId, status }); const isRedeploying = normalizedPhase === 'started';
console.log(`🔄 [RedeployStatus] Stack ${stackId} ist jetzt ${status ? "im Redeploy" : "fertig"}`); 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 = { const REDEPLOY_TYPES = {
@@ -271,8 +285,6 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => {
let stack; let stack;
try { try {
broadcastRedeployStatus(id, true);
const stackRes = await axiosInstance.get(`/api/stacks/${id}`); const stackRes = await axiosInstance.get(`/api/stacks/${id}`);
stack = stackRes.data; 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}`); throw new Error(`Stack gehört nicht zum Endpoint ${ENDPOINT_ID}`);
} }
broadcastRedeployStatus({
stackId: stack.Id || id,
stackName: stack.Name,
phase: 'started'
});
logRedeployEvent({ logRedeployEvent({
stackId: stack.Id || id, stackId: stack.Id || id,
stackName: stack.Name, 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({ logRedeployEvent({
stackId: stack.Id || id, stackId: stack.Id || id,
stackName: stack.Name, 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`); console.log(`✅ PUT /api/stacks/${id}/redeploy: Redeploy erfolgreich abgeschlossen`);
res.json({ success: true, message: 'Stack redeployed' }); res.json({ success: true, message: 'Stack redeployed' });
} catch (err) { } catch (err) {
broadcastRedeployStatus(id, false);
const errorMessage = err.response?.data?.message || err.message; const errorMessage = err.response?.data?.message || err.message;
broadcastRedeployStatus({
stackId: stack?.Id || id,
stackName: stack?.Name,
phase: 'error',
message: errorMessage
});
logRedeployEvent({ logRedeployEvent({
stackId: stack?.Id || id, stackId: stack?.Id || id,
stackName: stack?.Name || `Stack ${id}`, stackName: stack?.Name || `Stack ${id}`,
@@ -389,7 +416,11 @@ app.put('/api/stacks/redeploy-all', async (req, res) => {
for (const stack of eligibleStacks) { for (const stack of eligibleStacks) {
try { try {
broadcastRedeployStatus(stack.Id, true); broadcastRedeployStatus({
stackId: stack.Id,
stackName: stack.Name,
phase: 'started'
});
logRedeployEvent({ logRedeployEvent({
stackId: stack.Id, stackId: stack.Id,
stackName: stack.Name, 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({ logRedeployEvent({
stackId: stack.Id, stackId: stack.Id,
stackName: stack.Name, stackName: stack.Name,
@@ -425,8 +460,13 @@ app.put('/api/stacks/redeploy-all', async (req, res) => {
}); });
console.log(`✅ Redeploy abgeschlossen: ${stack.Name}`); console.log(`✅ Redeploy abgeschlossen: ${stack.Name}`);
} catch (err) { } catch (err) {
broadcastRedeployStatus(stack.Id, false);
const errorMessage = err.response?.data?.message || err.message; const errorMessage = err.response?.data?.message || err.message;
broadcastRedeployStatus({
stackId: stack.Id,
stackName: stack.Name,
phase: 'error',
message: errorMessage
});
logRedeployEvent({ logRedeployEvent({
stackId: stack.Id, stackId: stack.Id,
stackName: stack.Name, stackName: stack.Name,
@@ -511,7 +551,11 @@ app.put('/api/stacks/redeploy-selection', async (req, res) => {
for (const stack of eligibleStacks) { for (const stack of eligibleStacks) {
try { try {
broadcastRedeployStatus(stack.Id, true); broadcastRedeployStatus({
stackId: stack.Id,
stackName: stack.Name,
phase: 'started'
});
logRedeployEvent({ logRedeployEvent({
stackId: stack.Id, stackId: stack.Id,
stackName: stack.Name, 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({ logRedeployEvent({
stackId: stack.Id, stackId: stack.Id,
stackName: stack.Name, stackName: stack.Name,
@@ -547,8 +595,13 @@ app.put('/api/stacks/redeploy-selection', async (req, res) => {
}); });
console.log(`✅ Redeploy Auswahl abgeschlossen: ${stack.Name}`); console.log(`✅ Redeploy Auswahl abgeschlossen: ${stack.Name}`);
} catch (err) { } catch (err) {
broadcastRedeployStatus(stack.Id, false);
const errorMessage = err.response?.data?.message || err.message; const errorMessage = err.response?.data?.message || err.message;
broadcastRedeployStatus({
stackId: stack.Id,
stackName: stack.Name,
phase: 'error',
message: errorMessage
});
logRedeployEvent({ logRedeployEvent({
stackId: stack.Id, stackId: stack.Id,
stackName: stack.Name, stackName: stack.Name,
+192 -24
View File
@@ -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 axios from "axios";
import { io } from "socket.io-client"; import { io } from "socket.io-client";
import { useToast } from "./components/ToastProvider.jsx";
const SELECTION_PROMPT_STORAGE_KEY = "stackSelectionPreference"; const SELECTION_PROMPT_STORAGE_KEY = "stackSelectionPreference";
@@ -13,10 +14,25 @@ const PER_PAGE_OPTIONS = [
{ value: "all", label: "Alle" } { value: "all", label: "Alle" }
]; ];
const VALID_PER_PAGE_VALUES = new Set(PER_PAGE_OPTIONS.map((option) => option.value)); 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() { export default function Stacks() {
const [stacks, setStacks] = useState([]); const [stacks, setStacks] = useState(() => prepareInitialStacks(stacksCache.data));
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(() => !stacksCache.data);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [selectedStackIds, setSelectedStackIds] = useState([]); const [selectedStackIds, setSelectedStackIds] = useState([]);
const [statusFilter, setStatusFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all");
@@ -27,12 +43,26 @@ export default function Stacks() {
const [perPage, setPerPage] = useState(PER_PAGE_DEFAULT); const [perPage, setPerPage] = useState(PER_PAGE_DEFAULT);
const [page, setPage] = useState(1); 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 prevMap = new Map(previousStacks.map((stack) => [stack.Id, stack]));
const sortedIncoming = [...incomingStacks].sort((a, b) => a.Name.localeCompare(b.Name)); const sortedIncoming = [...incomingStacks].sort((a, b) => a.Name.localeCompare(b.Name));
return sortedIncoming.map((stack) => { return sortedIncoming.map((stack) => {
const previous = prevMap.get(stack.Id); 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 { return {
...stack, ...stack,
redeploying: previous?.redeploying || stack.redeploying || false, redeploying: previous?.redeploying || stack.redeploying || false,
@@ -40,7 +70,7 @@ export default function Stacks() {
duplicateName: stack.duplicateName ?? previous?.duplicateName ?? false duplicateName: stack.duplicateName ?? previous?.duplicateName ?? false
}; };
}); });
}; }, [showToast]);
useEffect(() => { useEffect(() => {
const socket = io("/", { const socket = io("/", {
@@ -49,27 +79,77 @@ export default function Stacks() {
}); });
console.log("🔌 Socket connected"); console.log("🔌 Socket connected");
socket.on("redeployStatus", async ({ stackId, status }) => { socket.on("redeployStatus", async (payload = {}) => {
console.log(`🔄 Stack ${stackId} Redeploy Status: ${status ? "running" : "finished"}`); 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 => setStacks(prev =>
prev.map(stack => { prev.map(stack => {
if (stack.Id !== stackId) return stack; if (stack.Id !== stackId) return stack;
return {
...stack, if (resolvedPhase === 'started' || isRedeploying === true) {
redeploying: status, return { ...stack, redeploying: true };
updateStatus: status ? stack.updateStatus : '✅' }
};
if (resolvedPhase === 'success') {
return { ...stack, redeploying: false, updateStatus: '✅' };
}
if (resolvedPhase === 'error' || isRedeploying === false) {
return { ...stack, redeploying: false };
}
return stack;
}) })
); );
if (!status) { const shouldRefresh =
// Status nach Redeploy neu vom Server holen resolvedPhase === 'success' ||
resolvedPhase === 'error' ||
(hasBooleanStatus && payload.status === false);
if (shouldRefresh) {
try { try {
const res = await axios.get("/api/stacks"); const res = await axios.get('/api/stacks');
updateStacksCache(res.data);
setStacks(prev => mergeStackState(prev, res.data)); setStacks(prev => mergeStackState(prev, res.data));
} catch (err) { } 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(); return () => socket.disconnect();
}, []); }, []);
const fetchStacks = async () => { const fetchStacks = useCallback(async ({ force = false, silent = false } = {}) => {
setLoading(true); 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 { try {
const res = await axios.get("/api/stacks"); if (force || !stacksCachePromise) {
setStacks(prev => mergeStackState(prev, res.data)); 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) { } catch (err) {
console.error("❌ Fehler beim Abrufen der Stacks:", 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 { } finally {
setLoading(false); if (shouldShowSpinner) {
setLoading(false);
}
} }
}; }, [mergeStackState]);
useEffect(() => { useEffect(() => {
fetchStacks(); 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(() => { useEffect(() => {
setSelectedStackIds(prev => { setSelectedStackIds(prev => {
@@ -104,6 +243,10 @@ export default function Stacks() {
}); });
}, [stacks]); }, [stacks]);
useEffect(() => {
stacksByIdRef.current = new Map(stacks.map((stack) => [stack.Id, stack]));
}, [stacks]);
const normalizedSearch = searchQuery.trim().toLowerCase(); const normalizedSearch = searchQuery.trim().toLowerCase();
const filteredStacks = useMemo(() => { const filteredStacks = useMemo(() => {
@@ -350,6 +493,17 @@ export default function Stacks() {
stack.Id === stackId ? { ...stack, redeploying: false } : stack 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 : 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 : stack
) )
); );
const errorText = err.response?.data?.error || err.message || 'Unbekannter Fehler';
showToast({
variant: 'error',
title: 'Redeploy Auswahl fehlgeschlagen',
description: errorText
});
} }
}; };
+121
View File
@@ -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 (
<ToastContext.Provider value={contextValue}>
{children}
<div className="pointer-events-none fixed right-4 top-4 z-50 flex w-80 max-w-full flex-col gap-3">
{toasts.map((toast) => {
const styles = VARIANT_STYLES[toast.variant] ?? VARIANT_STYLES.info;
return (
<div
key={toast.id}
className={`pointer-events-auto overflow-hidden rounded-lg border px-4 py-3 shadow-xl backdrop-blur ${styles.container}`}
>
<div className="flex items-start gap-3">
<span className={`mt-1 h-2 flex-shrink-0 w-2 rounded-full ${styles.accent}`}></span>
<div className="flex-1">
{toast.title && <p className="text-sm font-semibold">{toast.title}</p>}
{toast.description && <p className="mt-1 text-sm leading-snug text-white/80">{toast.description}</p>}
</div>
<button
type="button"
onClick={() => 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
</button>
</div>
</div>
);
})}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}
+4 -1
View File
@@ -2,12 +2,15 @@ import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import App from "./App.jsx"; import App from "./App.jsx";
import ToastProvider from "./components/ToastProvider.jsx";
import './index.css'; // Tailwind CSS import './index.css'; // Tailwind CSS
ReactDOM.createRoot(document.getElementById("root")).render( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<App /> <ToastProvider>
<App />
</ToastProvider>
</BrowserRouter> </BrowserRouter>
</React.StrictMode> </React.StrictMode>
); );