Update
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
const MaintenanceContext = createContext(null);
|
||||
|
||||
const INITIAL_STATE = {
|
||||
maintenance: null,
|
||||
update: null,
|
||||
script: null,
|
||||
ssh: null,
|
||||
loading: true,
|
||||
error: "",
|
||||
lastUpdated: null
|
||||
};
|
||||
|
||||
export default function MaintenanceProvider({ children }) {
|
||||
const [state, setState] = useState(INITIAL_STATE);
|
||||
const pollingRef = useRef(null);
|
||||
const wasRunningRef = useRef(false);
|
||||
|
||||
const applyState = useCallback((partial) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
...partial
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
applyState({ loading: true, error: "" });
|
||||
try {
|
||||
const response = await axios.get("/api/maintenance/config");
|
||||
const data = response.data ?? {};
|
||||
applyState({
|
||||
maintenance: data.maintenance ?? null,
|
||||
update: data.update ?? null,
|
||||
script: data.script ?? null,
|
||||
ssh: data.ssh ?? null,
|
||||
loading: false,
|
||||
error: "",
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message || "Fehler beim Laden der Wartungsdaten";
|
||||
applyState({ loading: false, error: message });
|
||||
}
|
||||
}, [applyState]);
|
||||
|
||||
const refreshUpdateStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.get("/api/maintenance/update-status");
|
||||
const data = response.data ?? {};
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
maintenance: data.maintenance ?? prev.maintenance,
|
||||
update: data.update ?? prev.update,
|
||||
error: ""
|
||||
}));
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message || "Fehler beim Aktualisieren des Wartungsstatus";
|
||||
setState((prev) => ({ ...prev, error: message }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.update?.running) {
|
||||
refreshUpdateStatus();
|
||||
const intervalId = setInterval(() => {
|
||||
refreshUpdateStatus();
|
||||
}, 5000);
|
||||
pollingRef.current = intervalId;
|
||||
return () => clearInterval(intervalId);
|
||||
}
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
return undefined;
|
||||
}, [state.update?.running, refreshUpdateStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentlyRunning = Boolean(state.update?.running);
|
||||
if (!currentlyRunning && wasRunningRef.current) {
|
||||
fetchConfig();
|
||||
}
|
||||
wasRunningRef.current = currentlyRunning;
|
||||
}, [state.update?.running, fetchConfig]);
|
||||
|
||||
const triggerUpdate = useCallback(async (payload = {}) => {
|
||||
await axios.post("/api/maintenance/portainer-update", payload);
|
||||
await refreshUpdateStatus();
|
||||
}, [refreshUpdateStatus]);
|
||||
|
||||
const saveScript = useCallback(async (script) => {
|
||||
await axios.put("/api/maintenance/update-script", { script });
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const resetScript = useCallback(async () => {
|
||||
await axios.delete("/api/maintenance/update-script");
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const saveSshConfig = useCallback(async (config) => {
|
||||
await axios.put("/api/maintenance/ssh-config", config);
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const deleteSshConfig = useCallback(async () => {
|
||||
await axios.delete("/api/maintenance/ssh-config");
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const setMaintenanceMode = useCallback(async ({ active, message } = {}) => {
|
||||
const payload = { active: Boolean(active) };
|
||||
if (message !== undefined) {
|
||||
payload.message = message;
|
||||
}
|
||||
|
||||
const response = await axios.post("/api/maintenance/mode", payload);
|
||||
const nextMaintenance = response.data?.maintenance ?? null;
|
||||
applyState({ maintenance: nextMaintenance, error: "" });
|
||||
return nextMaintenance;
|
||||
}, [applyState]);
|
||||
|
||||
const testSshConnection = useCallback(async (config) => {
|
||||
const response = await axios.post("/api/maintenance/test-ssh", config ?? {});
|
||||
return response.data;
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
maintenance: state.maintenance,
|
||||
update: state.update,
|
||||
script: state.script,
|
||||
ssh: state.ssh,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
lastUpdated: state.lastUpdated,
|
||||
refreshConfig: fetchConfig,
|
||||
refreshUpdateStatus,
|
||||
setMaintenanceMode,
|
||||
triggerUpdate,
|
||||
saveScript,
|
||||
resetScript,
|
||||
saveSshConfig,
|
||||
deleteSshConfig,
|
||||
testSshConnection
|
||||
}), [state, fetchConfig, refreshUpdateStatus, setMaintenanceMode, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection]);
|
||||
|
||||
return (
|
||||
<MaintenanceContext.Provider value={value}>
|
||||
{children}
|
||||
</MaintenanceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMaintenance() {
|
||||
const context = useContext(MaintenanceContext);
|
||||
if (!context) {
|
||||
throw new Error('useMaintenance must be used within a MaintenanceProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
+14
-8
@@ -15,16 +15,22 @@ import App from "./App";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { ThemeProvider } from "@material-tailwind/react";
|
||||
import { MaterialTailwindControllerProvider } from "@/context";
|
||||
import "../dist/css/tailwind.css";
|
||||
import "./tailwind.css";
|
||||
import ToastProvider from "@/components/ToastProvider.jsx";
|
||||
import MaintenanceProvider from "@/context/MaintenanceContext.jsx";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<MaterialTailwindControllerProvider>
|
||||
<App />
|
||||
</MaterialTailwindControllerProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
<MaintenanceProvider>
|
||||
<ToastProvider>
|
||||
<ThemeProvider>
|
||||
<MaterialTailwindControllerProvider>
|
||||
<App />
|
||||
</MaterialTailwindControllerProvider>
|
||||
</ThemeProvider>
|
||||
</ToastProvider>
|
||||
</MaintenanceProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode >
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Button,
|
||||
IconButton,
|
||||
Breadcrumbs,
|
||||
Input,
|
||||
Menu,
|
||||
MenuHandler,
|
||||
MenuList,
|
||||
@@ -72,9 +71,7 @@ export function DashboardNavbar() {
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="mr-auto md:mr-4 md:w-56">
|
||||
<Input label="Search" />
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
variant="text"
|
||||
color="blue-gray"
|
||||
|
||||
Reference in New Issue
Block a user