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
+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 { 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
});
}
};
+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 { 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(
<React.StrictMode>
<BrowserRouter>
<App />
<ToastProvider>
<App />
</ToastProvider>
</BrowserRouter>
</React.StrictMode>
);