Merge feature/v03-notifications into dev
This commit is contained in:
+67
-14
@@ -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,
|
||||||
|
|||||||
+189
-21
@@ -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 } = {}) => {
|
||||||
|
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);
|
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);
|
||||||
|
if (!hadCache && !silent) {
|
||||||
setError("Fehler beim Laden der Stacks");
|
setError("Fehler beim Laden der Stacks");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (shouldShowSpinner) {
|
||||||
setLoading(false);
|
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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
<ToastProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user