Update
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -32,8 +32,8 @@
|
||||
<!-- Nepcha Analytics (nepcha.com) -->
|
||||
<!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. -->
|
||||
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-b23f4bf0.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-9562875d.css">
|
||||
<script type="module" crossorigin src="/assets/index-1b29e29a.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-1c9b00a5.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
|
||||
const DEFAULT_PER_PAGE_OPTIONS = [
|
||||
{ value: "10", label: "10" },
|
||||
{ value: "25", label: "25" },
|
||||
{ value: "50", label: "50" },
|
||||
{ value: "100", label: "100" },
|
||||
{ value: "all", label: "Alle" }
|
||||
];
|
||||
|
||||
const DEFAULT_PER_PAGE = "50";
|
||||
|
||||
const PageContext = createContext(null);
|
||||
|
||||
const normalizeNumber = (value, fallback) => {
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
return numeric;
|
||||
}
|
||||
const fallbackNumeric = Number(fallback);
|
||||
if (Number.isFinite(fallbackNumeric) && fallbackNumeric > 0) {
|
||||
return fallbackNumeric;
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
export function PageProvider({
|
||||
children,
|
||||
initialPage = 1,
|
||||
initialPerPage = DEFAULT_PER_PAGE,
|
||||
perPageOptions = DEFAULT_PER_PAGE_OPTIONS
|
||||
}) {
|
||||
const [page, setPage] = useState(initialPage);
|
||||
const [perPage, setPerPage] = useState(initialPerPage);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [visibleItems, setVisibleItems] = useState(0);
|
||||
|
||||
const initialPageRef = useRef(initialPage);
|
||||
const initialPerPageRef = useRef(initialPerPage);
|
||||
|
||||
const validPerPageValues = useMemo(() => new Set(
|
||||
perPageOptions.map((option) => String(option.value))
|
||||
), [perPageOptions]);
|
||||
|
||||
const perPageIsAll = perPage === "all";
|
||||
|
||||
const resolvePerPageNumber = useCallback(() => {
|
||||
if (perPageIsAll) {
|
||||
return null;
|
||||
}
|
||||
return normalizeNumber(perPage, initialPerPage);
|
||||
}, [perPage, perPageIsAll, initialPerPage]);
|
||||
|
||||
const updateTotals = useCallback((total, visible) => {
|
||||
setTotalItems(Math.max(0, Number(total) || 0));
|
||||
setVisibleItems(Math.max(0, Number(visible) || 0));
|
||||
}, []);
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPage(initialPageRef.current ?? 1);
|
||||
setPerPage(initialPerPageRef.current ?? DEFAULT_PER_PAGE);
|
||||
setTotalItems(0);
|
||||
setVisibleItems(0);
|
||||
}, []);
|
||||
|
||||
const setPerPageValue = useCallback((value, { resetPage = true } = {}) => {
|
||||
if (value === undefined || value === null) return;
|
||||
const nextValue = String(value);
|
||||
if (!validPerPageValues.has(nextValue)) {
|
||||
return;
|
||||
}
|
||||
setPerPage(nextValue);
|
||||
if (resetPage) {
|
||||
setPage(1);
|
||||
}
|
||||
}, [validPerPageValues]);
|
||||
|
||||
const handlePerPageChange = useCallback((eventOrValue) => {
|
||||
const nextValue = typeof eventOrValue === "string"
|
||||
? eventOrValue
|
||||
: eventOrValue?.target?.value;
|
||||
setPerPageValue(nextValue, { resetPage: true });
|
||||
}, [setPerPageValue]);
|
||||
|
||||
const handlePageChange = useCallback((nextPage) => {
|
||||
if (perPageIsAll) return;
|
||||
|
||||
const numericPage = Number(nextPage);
|
||||
if (!Number.isFinite(numericPage) || numericPage < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const perPageNumber = resolvePerPageNumber();
|
||||
if (!perPageNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPages = Math.max(1, Math.ceil((totalItems || 0) / perPageNumber));
|
||||
if (numericPage > maxPages) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPage(numericPage);
|
||||
}, [perPageIsAll, resolvePerPageNumber, totalItems]);
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
if (perPageIsAll) {
|
||||
return 1;
|
||||
}
|
||||
const perPageNumber = resolvePerPageNumber();
|
||||
if (!perPageNumber) {
|
||||
return 1;
|
||||
}
|
||||
return Math.max(1, Math.ceil((totalItems || 0) / perPageNumber));
|
||||
}, [perPageIsAll, resolvePerPageNumber, totalItems]);
|
||||
|
||||
const rangeStart = useMemo(() => {
|
||||
if (!totalItems) {
|
||||
return 0;
|
||||
}
|
||||
if (perPageIsAll) {
|
||||
return 1;
|
||||
}
|
||||
const perPageNumber = resolvePerPageNumber();
|
||||
return (page - 1) * perPageNumber + 1;
|
||||
}, [totalItems, perPageIsAll, resolvePerPageNumber, page]);
|
||||
|
||||
const rangeEnd = useMemo(() => {
|
||||
if (!totalItems) {
|
||||
return 0;
|
||||
}
|
||||
if (perPageIsAll) {
|
||||
return totalItems;
|
||||
}
|
||||
const perPageNumber = resolvePerPageNumber();
|
||||
const estimatedEnd = (page - 1) * perPageNumber + (visibleItems || perPageNumber);
|
||||
return Math.min(totalItems, estimatedEnd);
|
||||
}, [totalItems, perPageIsAll, resolvePerPageNumber, page, visibleItems]);
|
||||
|
||||
const summaryLabel = useMemo(() => {
|
||||
if (!totalItems) {
|
||||
return "Keine Einträge";
|
||||
}
|
||||
if (perPageIsAll) {
|
||||
return `Zeige alle ${totalItems.toLocaleString()} Einträge`;
|
||||
}
|
||||
return `Zeige ${rangeStart.toLocaleString()} – ${rangeEnd.toLocaleString()} von ${totalItems.toLocaleString()} Einträgen`;
|
||||
}, [totalItems, perPageIsAll, rangeStart, rangeEnd]);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
page,
|
||||
perPage,
|
||||
perPageOptions,
|
||||
perPageIsAll,
|
||||
totalItems,
|
||||
visibleItems,
|
||||
totalPages,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
summaryLabel,
|
||||
setPage,
|
||||
setPerPage: setPerPageValue,
|
||||
setTotals: updateTotals,
|
||||
setTotalItems,
|
||||
setVisibleItems,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
validPerPageValues,
|
||||
resetPagination
|
||||
}), [
|
||||
page,
|
||||
perPage,
|
||||
perPageOptions,
|
||||
perPageIsAll,
|
||||
totalItems,
|
||||
visibleItems,
|
||||
totalPages,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
summaryLabel,
|
||||
updateTotals,
|
||||
setPerPageValue,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
validPerPageValues,
|
||||
resetPagination
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageContext.Provider value={value}>
|
||||
{children}
|
||||
</PageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePage() {
|
||||
const context = useContext(PageContext);
|
||||
if (!context) {
|
||||
throw new Error("usePage must be used within a PageProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function PaginationControls({ disabled = false }) {
|
||||
const {
|
||||
summaryLabel,
|
||||
perPageIsAll,
|
||||
page,
|
||||
totalPages,
|
||||
handlePageChange
|
||||
} = usePage();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span>{summaryLabel}</span>
|
||||
{!perPageIsAll && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={disabled || page <= 1}
|
||||
className="rounded-md border border-slate-300 p-2.5 text-center text-sm transition-all shadow-sm hover:shadow-lg text-slate-600 hover:text-white hover:bg-slate-800 hover:border-slate-800 focus:text-white focus:bg-slate-800 focus:border-slate-800 active:border-slate-800 active:text-white active:bg-slate-800 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
|
||||
type="button"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className="h-4 w-4">
|
||||
<path d="M11.03 3.97a.75.75 0 0 1 0 1.06l-6.22 6.22H21a.75.75 0 0 1 0 1.5H4.81l6.22 6.22a.75.75 0 1 1-1.06 1.06l-7.5-7.5a.75.75 0 0 1 0-1.06l7.5-7.5a.75.75 0 0 1 1.06 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<p className="text-slate-600">
|
||||
Seite <strong className="text-slate-800">{page}</strong> /
|
||||
<strong className="text-slate-800">{totalPages}</strong>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={disabled || page >= totalPages}
|
||||
className="rounded-md border border-slate-300 p-2.5 text-center text-sm transition-all shadow-sm hover:shadow-lg text-slate-600 hover:text-white hover:bg-slate-800 hover:border-slate-800 focus:text-white focus:bg-slate-800 focus:border-slate-800 active:border-slate-800 active:text-white active:bg-slate-800 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
|
||||
type="button"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className="h-4 w-4">
|
||||
<path d="M12.97 3.97a.75.75 0 0 1 1.06 0l7.5 7.5a.75.75 0 0 1 0 1.06l-7.5 7.5a.75.75 0 1 1-1.06-1.06l6.22-6.22H3a.75.75 0 0 1 0-1.5h16.19l-6.22-6.22a.75.75 0 0 1 0-1.06Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PAGE_DEFAULTS = {
|
||||
perPage: DEFAULT_PER_PAGE,
|
||||
options: DEFAULT_PER_PAGE_OPTIONS
|
||||
};
|
||||
|
||||
export default PageProvider;
|
||||
@@ -36,7 +36,7 @@ export function MaterialTailwindControllerProvider({ children }) {
|
||||
sidenavColor: "gray",
|
||||
sidenavType: "white",
|
||||
transparentNavbar: true,
|
||||
fixedNavbar: false,
|
||||
fixedNavbar: true,
|
||||
openConfigurator: false,
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Footer,
|
||||
} from "@/widgets/layout";
|
||||
import routes from "@/routes";
|
||||
import { useMaterialTailwindController, setOpenConfigurator } from "@/context";
|
||||
import { useMaterialTailwindController, setOpenConfigurator } from "@/components";
|
||||
|
||||
export function Dashboard() {
|
||||
const [controller, dispatch] = useMaterialTailwindController();
|
||||
|
||||
@@ -14,23 +14,26 @@ import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { ThemeProvider } from "@material-tailwind/react";
|
||||
import { MaterialTailwindControllerProvider } from "@/context";
|
||||
import { MaterialTailwindControllerProvider } from "@/components";
|
||||
import "./tailwind.css";
|
||||
import ToastProvider from "@/components/ToastProvider.jsx";
|
||||
import MaintenanceProvider from "@/context/MaintenanceContext.jsx";
|
||||
import MaintenanceProvider from "@/components/MaintenanceProvider.jsx";
|
||||
import PageProvider from "@/components/PageProvider.jsx";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<MaintenanceProvider>
|
||||
<ToastProvider>
|
||||
<PageProvider>
|
||||
<ThemeProvider>
|
||||
<MaterialTailwindControllerProvider>
|
||||
<App />
|
||||
</MaterialTailwindControllerProvider>
|
||||
</ThemeProvider>
|
||||
</PageProvider>
|
||||
</ToastProvider>
|
||||
</MaintenanceProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode >
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Input,
|
||||
useSelect
|
||||
} from "@material-tailwind/react";
|
||||
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||
|
||||
const StickyOption = React.forwardRef(({ value, onClick, onKeyDown, ...props }, ref) => {
|
||||
const { setOpen } = useSelect();
|
||||
@@ -91,16 +92,6 @@ const normalizeDateParam = (value) => {
|
||||
const FILTER_STORAGE_KEY = "redeployLogFilters";
|
||||
const ALL_OPTION_VALUE = "__all__";
|
||||
const ALL_OPTION_LABEL = "- Alle -";
|
||||
const PER_PAGE_DEFAULT = "50";
|
||||
const PER_PAGE_OPTIONS = [
|
||||
{ value: "10", label: "10" },
|
||||
{ value: "25", label: "25" },
|
||||
{ value: "50", label: "50" },
|
||||
{ value: "100", label: "100" },
|
||||
{ value: "all", label: "Alle" }
|
||||
];
|
||||
const VALID_PER_PAGE_VALUES = new Set(PER_PAGE_OPTIONS.map((option) => option.value));
|
||||
|
||||
const REDEPLOY_TYPE_LABELS = {
|
||||
Einzeln: "Einzeln",
|
||||
Alle: "Alle",
|
||||
@@ -124,7 +115,6 @@ const hasActiveFilters = (filters) => Boolean(
|
||||
export function Logs() {
|
||||
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [totalLogs, setTotalLogs] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
@@ -147,11 +137,22 @@ export function Logs() {
|
||||
const [optionsInitialized, setOptionsInitialized] = useState(false);
|
||||
const [refreshSignal, setRefreshSignal] = useState(0);
|
||||
|
||||
const [perPage, setPerPage] = useState(PER_PAGE_DEFAULT);
|
||||
const [page, setPage] = useState(1);
|
||||
const {
|
||||
page,
|
||||
perPage,
|
||||
perPageOptions,
|
||||
setPage,
|
||||
setPerPage: setPerPageValue,
|
||||
setTotals,
|
||||
handlePerPageChange,
|
||||
validPerPageValues,
|
||||
resetPagination
|
||||
} = usePage();
|
||||
|
||||
const noop = useCallback(() => { }, []);
|
||||
|
||||
useEffect(() => () => resetPagination(), [resetPagination]);
|
||||
|
||||
const updateFilterOptions = useCallback((payload) => {
|
||||
const logsPayload = Array.isArray(payload) ? payload : payload?.items ?? [];
|
||||
|
||||
@@ -258,8 +259,8 @@ export function Logs() {
|
||||
const rawPerPage = storedPagination.perPage;
|
||||
if (rawPerPage !== undefined) {
|
||||
const parsedPerPage = String(rawPerPage);
|
||||
if (VALID_PER_PAGE_VALUES.has(parsedPerPage)) {
|
||||
setPerPage(parsedPerPage);
|
||||
if (validPerPageValues.has(parsedPerPage)) {
|
||||
setPerPageValue(parsedPerPage, { resetPage: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +274,7 @@ export function Logs() {
|
||||
} finally {
|
||||
setFiltersReady(true);
|
||||
}
|
||||
}, []);
|
||||
}, [setPage, setPerPageValue, validPerPageValues]);
|
||||
|
||||
const buildFilterParams = useCallback(() => {
|
||||
const params = {};
|
||||
@@ -364,7 +365,7 @@ export function Logs() {
|
||||
const total = Array.isArray(data) ? items.length : data.total ?? items.length;
|
||||
|
||||
setLogs(items);
|
||||
setTotalLogs(total);
|
||||
setTotals(total, items.length);
|
||||
|
||||
if (perPage === 'all') {
|
||||
if (page !== 1) setPage(1);
|
||||
@@ -389,7 +390,7 @@ export function Logs() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filtersReady, buildFilterParams, updateFilterOptions, refreshSignal, perPage, page]);
|
||||
}, [filtersReady, buildFilterParams, updateFilterOptions, refreshSignal, perPage, page, setTotals]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filtersReady) return;
|
||||
@@ -473,21 +474,6 @@ export function Logs() {
|
||||
setFiltersOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const handlePerPageChange = (event) => {
|
||||
const value = event.target.value;
|
||||
if (!VALID_PER_PAGE_VALUES.has(value)) return;
|
||||
setPerPage(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePageChange = (nextPage) => {
|
||||
if (perPage === 'all') return;
|
||||
if (nextPage < 1) return;
|
||||
const totalPages = Math.max(1, Math.ceil((totalLogs || 0) / Number(perPage)));
|
||||
if (nextPage > totalPages) return;
|
||||
setPage(nextPage);
|
||||
};
|
||||
|
||||
const stackSelectOptions = useMemo(() => {
|
||||
const entries = stackOptions.filter((option) => option.value !== ALL_OPTION_VALUE);
|
||||
return [
|
||||
@@ -538,24 +524,6 @@ export function Logs() {
|
||||
return count;
|
||||
}, [selectedStacks, selectedStatuses, selectedEndpoints, messageQuery, fromDate, toDate]);
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
if (perPage === 'all') return 1;
|
||||
const numeric = Number(perPage) || Number(PER_PAGE_DEFAULT);
|
||||
return Math.max(1, Math.ceil((totalLogs || 0) / numeric));
|
||||
}, [perPage, totalLogs]);
|
||||
|
||||
const rangeStart = useMemo(() => {
|
||||
if (totalLogs === 0) return 0;
|
||||
if (perPage === 'all') return 1;
|
||||
return (page - 1) * Number(perPage) + 1;
|
||||
}, [totalLogs, perPage, page]);
|
||||
|
||||
const rangeEnd = useMemo(() => {
|
||||
if (totalLogs === 0) return 0;
|
||||
if (perPage === 'all') return totalLogs;
|
||||
return Math.min(totalLogs, (page - 1) * Number(perPage) + logs.length);
|
||||
}, [totalLogs, perPage, page, logs.length]);
|
||||
|
||||
const handleDeleteLog = async (id) => {
|
||||
if (!window.confirm("Diesen Log-Eintrag dauerhaft löschen?")) return;
|
||||
setActionLoading(true);
|
||||
@@ -866,8 +834,8 @@ export function Logs() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-5">
|
||||
<div className="grid gap-4 flex-1">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mt-8">
|
||||
<div className="md:flex-1">
|
||||
<Input
|
||||
value={messageQuery}
|
||||
onChange={(event) => {
|
||||
@@ -878,6 +846,24 @@ export function Logs() {
|
||||
label="Nachricht (Freitext)"
|
||||
placeholder="Suche" />
|
||||
</div>
|
||||
<div className="md:mt-0 mt-8 md:flex-1">
|
||||
<Select
|
||||
variant="static"
|
||||
label="Einträge pro Seite"
|
||||
onChange={noop}
|
||||
value={perPage}
|
||||
>
|
||||
{perPageOptions.map(({ value, label }) => (
|
||||
<Option
|
||||
key={value}
|
||||
value={value}
|
||||
onClick={() => handlePerPageChange(value)}
|
||||
>
|
||||
{label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row flex-wrap gap-4 mt-8">
|
||||
<div className="flex-1">
|
||||
@@ -931,23 +917,6 @@ export function Logs() {
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>Logs</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs uppercase tracking-wide text-gray-400">
|
||||
Einträge pro Seite:
|
||||
</span>
|
||||
<select
|
||||
value={perPage}
|
||||
onChange={handlePerPageChange}
|
||||
className="rounded-md border border-gray-700 bg-gray-900 px-2 py-1 text-sm text-gray-100 focus:border-lavenderSmoke-500 focus:outline-none focus:ring-1 focus:ring-lavenderSmoke-500"
|
||||
>
|
||||
{PER_PAGE_OPTIONS.map(({ value, label }) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody className="overflow-x-scroll px-0 pt-0 pb-2">
|
||||
@@ -1048,40 +1017,7 @@ export function Logs() {
|
||||
</table>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span>
|
||||
{totalLogs === 0
|
||||
? "Keine Einträge"
|
||||
: perPage === 'all'
|
||||
? `Zeige alle ${totalLogs} Einträge`
|
||||
: `Zeige ${rangeStart.toLocaleString()} – ${rangeEnd.toLocaleString()} von ${totalLogs.toLocaleString()} Einträgen`}
|
||||
</span>
|
||||
{perPage !== 'all' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={page <= 1 || actionLoading}
|
||||
className="rounded-md border border-slate-300 p-2.5 text-center text-sm transition-all shadow-sm hover:shadow-lg text-slate-600 hover:text-white hover:bg-slate-800 hover:border-slate-800 focus:text-white focus:bg-slate-800 focus:border-slate-800 active:border-slate-800 active:text-white active:bg-slate-800 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none">
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className="w-4 h-4">
|
||||
<path d="M11.03 3.97a.75.75 0 0 1 0 1.06l-6.22 6.22H21a.75.75 0 0 1 0 1.5H4.81l6.22 6.22a.75.75 0 1 1-1.06 1.06l-7.5-7.5a.75.75 0 0 1 0-1.06l7.5-7.5a.75.75 0 0 1 1.06 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<p className="text-slate-600">
|
||||
Seite <strong className="text-slate-800">{page}</strong> / <strong className="text-slate-800">{totalPages}</strong>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={page >= totalPages || actionLoading}
|
||||
className="rounded-md border border-slate-300 p-2.5 text-center text-sm transition-all shadow-sm hover:shadow-lg text-slate-600 hover:text-white hover:bg-slate-800 hover:border-slate-800 focus:text-white focus:bg-slate-800 focus:border-slate-800 active:border-slate-800 active:text-white active:bg-slate-800 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className="w-4 h-4">
|
||||
<path d="M12.97 3.97a.75.75 0 0 1 1.06 0l7.5 7.5a.75.75 0 0 1 0 1.06l-7.5 7.5a.75.75 0 1 1-1.06-1.06l6.22-6.22H3a.75.75 0 0 1 0-1.5h16.19l-6.22-6.22a.75.75 0 0 1 0-1.06Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PaginationControls disabled={actionLoading} />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { useToast } from "@/components/ToastProvider.jsx";
|
||||
import { useMaintenance } from "@/context/MaintenanceContext.jsx";
|
||||
import { useMaintenance } from "@/components/MaintenanceProvider";
|
||||
|
||||
import {
|
||||
Typography,
|
||||
|
||||
@@ -2,7 +2,9 @@ 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";
|
||||
import { useMaintenance } from "@/context/MaintenanceContext.jsx";
|
||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
@@ -26,15 +28,6 @@ import {
|
||||
|
||||
const SELECTION_PROMPT_STORAGE_KEY = "stackSelectionPreference";
|
||||
|
||||
const PER_PAGE_DEFAULT = "50";
|
||||
const PER_PAGE_OPTIONS = [
|
||||
{ value: "10", label: "10" },
|
||||
{ value: "25", label: "25" },
|
||||
{ value: "50", label: "50" },
|
||||
{ value: "100", label: "100" },
|
||||
{ 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 };
|
||||
@@ -48,6 +41,15 @@ const REDEPLOY_PHASES = {
|
||||
INFO: 'info'
|
||||
};
|
||||
|
||||
const UPDATE_STAGE_LABELS = {
|
||||
initializing: "Vorbereitung",
|
||||
"activating-maintenance": "Wartungsmodus aktivieren",
|
||||
"executing-script": "Skript wird ausgeführt",
|
||||
waiting: "Warte auf Portainer",
|
||||
completed: "Abgeschlossen",
|
||||
failed: "Fehlgeschlagen"
|
||||
};
|
||||
|
||||
const isCacheFresh = () => Boolean(stacksCache.data) && (Date.now() - stacksCache.timestamp < STACKS_CACHE_DURATION);
|
||||
const updateStacksCache = (data) => {
|
||||
stacksCache = { data, timestamp: Date.now() };
|
||||
@@ -57,7 +59,11 @@ const prepareInitialStacks = (data) => {
|
||||
if (!Array.isArray(data)) return [];
|
||||
return [...data].sort((a, b) => (a?.Name || '').localeCompare(b?.Name || ''));
|
||||
};
|
||||
|
||||
|
||||
|
||||
export function Stacks() {
|
||||
|
||||
const [stacks, setStacks] = useState(() => prepareInitialStacks(stacksCache.data));
|
||||
const [loading, setLoading] = useState(() => !stacksCache.data);
|
||||
const [error, setError] = useState("");
|
||||
@@ -67,19 +73,35 @@ export function Stacks() {
|
||||
const [selectionPromptVisible, setSelectionPromptVisible] = useState(false);
|
||||
const [rememberSelectionChoice, setRememberSelectionChoice] = useState(false);
|
||||
const [selectionPreferenceStored, setSelectionPreferenceStored] = useState(false);
|
||||
const [perPage, setPerPage] = useState(PER_PAGE_DEFAULT);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { showToast } = useToast();
|
||||
const {
|
||||
maintenance: maintenanceMeta,
|
||||
update: updateState,
|
||||
script: scriptConfig,
|
||||
ssh: sshConfig,
|
||||
} = useMaintenance();
|
||||
|
||||
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||
const maintenanceMessage = maintenanceMeta?.message;
|
||||
const updateRunning = Boolean(updateState?.running);
|
||||
const maintenanceLocked = maintenanceActive || updateRunning;
|
||||
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–";
|
||||
|
||||
const {
|
||||
page,
|
||||
perPage,
|
||||
perPageOptions,
|
||||
setPage,
|
||||
setTotals,
|
||||
handlePerPageChange,
|
||||
resetPagination
|
||||
} = usePage();
|
||||
|
||||
|
||||
const { maintenance, update } = useMaintenance();
|
||||
const maintenanceActive = Boolean(maintenance?.active);
|
||||
const maintenanceMessage = maintenance?.message;
|
||||
const maintenanceLocked = maintenanceActive || Boolean(update?.running);
|
||||
const maintenanceBanner = maintenanceLocked ? (maintenanceMessage || (update?.running ? "Portainer-Update läuft" : "Wartungsmodus aktiv")) : "";
|
||||
const noop = useCallback(() => { }, []);
|
||||
|
||||
const stacksByIdRef = useRef(new Map());
|
||||
useEffect(() => () => resetPagination(), [resetPagination]);
|
||||
|
||||
useEffect(() => {
|
||||
if (maintenanceLocked) {
|
||||
@@ -89,6 +111,9 @@ export function Stacks() {
|
||||
}
|
||||
}, [maintenanceLocked]);
|
||||
|
||||
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));
|
||||
@@ -242,7 +267,7 @@ export function Stacks() {
|
||||
}, []);
|
||||
|
||||
const fetchStacks = useCallback(async ({ force = false, silent = false } = {}) => {
|
||||
if (maintenanceActive) {
|
||||
if ((maintenanceActive || updateRunning)) {
|
||||
if (!silent) {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -266,6 +291,7 @@ export function Stacks() {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
if (force || !stacksCachePromise) {
|
||||
stacksCachePromise = axios.get("/api/stacks")
|
||||
@@ -278,7 +304,10 @@ export function Stacks() {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const data = await stacksCachePromise;
|
||||
|
||||
|
||||
setStacks(prev => mergeStackState(prev, data));
|
||||
setError("");
|
||||
} catch (err) {
|
||||
@@ -291,14 +320,14 @@ export function Stacks() {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [maintenanceActive, mergeStackState]);
|
||||
}, [maintenanceActive, updateRunning, mergeStackState]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStacks();
|
||||
}, [fetchStacks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined' || maintenanceActive) return undefined;
|
||||
if (typeof document === 'undefined' || maintenanceActive || updateRunning) return undefined;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
if (!document.hidden) {
|
||||
@@ -307,10 +336,10 @@ export function Stacks() {
|
||||
}, STACKS_REFRESH_INTERVAL);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [fetchStacks, maintenanceActive]);
|
||||
}, [fetchStacks, maintenanceActive, updateRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined' || maintenanceActive) return undefined;
|
||||
if (typeof document === 'undefined' || maintenanceActive || updateRunning) return undefined;
|
||||
|
||||
const handleVisibility = () => {
|
||||
if (!document.hidden) {
|
||||
@@ -322,7 +351,7 @@ export function Stacks() {
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
};
|
||||
}, [fetchStacks, maintenanceActive]);
|
||||
}, [fetchStacks, maintenanceActive, updateRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedStackIds(prev => {
|
||||
@@ -389,6 +418,10 @@ export function Stacks() {
|
||||
return filteredStacks.slice(start, start + perPageNumber);
|
||||
}, [filteredStacks, perPage, page]);
|
||||
|
||||
useEffect(() => {
|
||||
setTotals(filteredStacks.length, paginatedStacks.length);
|
||||
}, [filteredStacks.length, paginatedStacks.length, setTotals]);
|
||||
|
||||
const visiblePageStackIds = useMemo(() => new Set(paginatedStacks.map((stack) => stack.Id)), [paginatedStacks]);
|
||||
|
||||
const eligiblePageStacks = useMemo(
|
||||
@@ -476,7 +509,7 @@ export function Stacks() {
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [statusFilter, normalizedSearch]);
|
||||
}, [statusFilter, normalizedSearch, setPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (perPage === 'all') {
|
||||
@@ -484,21 +517,15 @@ export function Stacks() {
|
||||
return;
|
||||
}
|
||||
|
||||
const perPageNumber = Number(perPage);
|
||||
const perPageNumber = Number(perPage) || 1;
|
||||
const totalPagesCalc = Math.max(1, Math.ceil(filteredStacks.length / perPageNumber));
|
||||
if (page > totalPagesCalc) {
|
||||
setPage(totalPagesCalc);
|
||||
}
|
||||
}, [filteredStacks.length, perPage, page]);
|
||||
|
||||
const totalItems = filteredStacks.length;
|
||||
const perPageNumber = perPage === 'all' ? (totalItems || 1) : Number(perPage);
|
||||
const totalPages = perPage === 'all' ? 1 : Math.max(1, Math.ceil(totalItems / perPageNumber));
|
||||
const pageStart = totalItems === 0 ? 0 : (perPage === 'all' ? 1 : ((page - 1) * perPageNumber + 1));
|
||||
const pageEnd = perPage === 'all' ? totalItems : Math.min(totalItems, page * perPageNumber);
|
||||
}, [filteredStacks.length, perPage, page, setPage]);
|
||||
|
||||
const toggleStackSelection = (stackId, disabled) => {
|
||||
if (maintenanceLocked || disabled) return;
|
||||
if (disabled) return;
|
||||
setSelectedStackIds(prev =>
|
||||
prev.includes(stackId)
|
||||
? prev.filter(id => id !== stackId)
|
||||
@@ -507,25 +534,6 @@ export function Stacks() {
|
||||
};
|
||||
|
||||
|
||||
const handlePerPageChange = (value) => {
|
||||
if (!VALID_PER_PAGE_VALUES.has(value)) return;
|
||||
setPerPage(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
setPage((prev) => Math.max(1, prev - 1));
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (perPage === 'all') return;
|
||||
setPage((prev) => {
|
||||
const perPageNumberLocal = Number(perPage);
|
||||
const totalPagesCalc = Math.max(1, Math.ceil(filteredStacks.length / perPageNumberLocal));
|
||||
return Math.min(totalPagesCalc, prev + 1);
|
||||
});
|
||||
};
|
||||
|
||||
const applySelectionPreference = (action) => {
|
||||
const remember = rememberSelectionChoice;
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -748,7 +756,7 @@ export function Stacks() {
|
||||
const hasOutdatedStacks = eligiblePageStacks.length > 0;
|
||||
const bulkButtonLabel = hasSelection
|
||||
? `Redeploy Auswahl (${selectedStackIds.length})`
|
||||
: 'Redeploy All';
|
||||
: 'Redeploy Alle';
|
||||
|
||||
const bulkActionDisabled = maintenanceLocked || (hasSelection
|
||||
? selectionPromptVisible || selectedStackIds.length === 0 || selectedStackIds.every(id => {
|
||||
@@ -780,7 +788,23 @@ export function Stacks() {
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<div className="mt-12 mb-8 flex flex-col gap-12">
|
||||
|
||||
{(maintenanceActive || updateRunning) && (<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>
|
||||
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}.
|
||||
</span>
|
||||
{updateRunning && (
|
||||
<span className="text-xs text-indigo-900">
|
||||
Phase: {updateStageLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader variant="gradient" color="gray" className="p-4 pt-2 pb-2">
|
||||
{!loading && !error ? (
|
||||
@@ -798,17 +822,17 @@ export function Stacks() {
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{loading ? (
|
||||
<p className="flex py-6 text-center text-sm font-medium text-gray-400"> <Spinner className="mr-2 h-4 w-4 flex" />Lade Stacks...</p>
|
||||
<p className="flex py-6 text-center text-sm font-medium text-gray-400">
|
||||
<Spinner className="mr-2 h-4 w-4 flex" />Lade Stacks...
|
||||
</p>
|
||||
) : error ? (
|
||||
<p className="py-6 text-sm font-medium text-red-400">{error}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<ButtonGroup
|
||||
>
|
||||
<div className="gap-4">
|
||||
<div className="w-full">
|
||||
<ButtonGroup fullWidth>
|
||||
<Button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
|
||||
>Alle</Button>
|
||||
<Button
|
||||
onClick={() => setStatusFilter('current')}
|
||||
@@ -820,20 +844,23 @@ export function Stacks() {
|
||||
>Veraltet</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mt-8">
|
||||
<div className="md:flex-1">
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
variant="static"
|
||||
placeholder="Name oder ID" />
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
placeholder="Name oder ID"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:mt-0 mt-8 md:flex-1">
|
||||
<Select
|
||||
variant="static"
|
||||
label="Einträge pro Seite"
|
||||
onChange={noop}
|
||||
value={perPage}
|
||||
>
|
||||
{PER_PAGE_OPTIONS.map(({ value, label }) => (
|
||||
{perPageOptions.map(({ value, label }) => (
|
||||
<Option
|
||||
key={value}
|
||||
value={value}
|
||||
@@ -844,24 +871,71 @@ export function Stacks() {
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 mt-5">
|
||||
{selectionPreferenceStored && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearStoredSelectionPreference}
|
||||
className="text-xs font-medium text-amber-300 underline underline-offset-2 transition hover:text-amber-100"
|
||||
className="block antialiased font-sans text-sm leading-normal text-inherit text-xs font-medium text-warmAmberGlow-500 underline underline-offset-2 transition hover:text-warmAmberGlow-600"
|
||||
|
||||
>
|
||||
Gespeicherte Entscheidung löschen
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectionPromptVisible && (
|
||||
<div className="flex flex-col mt-5">
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-arcticBlue-800 bg-arcticBlue-900/90 px-4 py-3 text-arcticBlue-100 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-2 block antialiased font-sans text-sm font-light leading-normal text-inherit">
|
||||
<p>
|
||||
{selectedStackIds.length === 1
|
||||
? '1 Stack ist weiterhin ausgewählt.'
|
||||
: `${selectedStackIds.length} Stacks sind weiterhin ausgewählt.`}
|
||||
{' '}Soll die Auswahl entfernt werden?
|
||||
</p>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberSelectionChoice}
|
||||
onChange={(event) => setRememberSelectionChoice(event.target.checked)}
|
||||
className="h-4 w-4 rounded border border-arcticBlue-400 bg-arcticBlue-900 text-arcticBlue-400 focus:ring-arcticBlue-300"
|
||||
/>
|
||||
<span className="text-xs">Einstellung merken (bis Browser geschlossen wird)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => applySelectionPreference('keep')}
|
||||
className="rounded-md border border-arcticBlue-600 bg-arcticBlue-700 px-4 py-2 font-sans font-bold text-center uppercase transition-all hover:bg-arcticBlue-800"
|
||||
>
|
||||
Auswahl behalten
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => applySelectionPreference('clear')}
|
||||
className="rounded-md border border-sunsetCoral-500 bg-sunsetCoral-600 px-4 py-2 font-sans font-bold text-center uppercase transition-all hover:bg-sunsetCoral-600"
|
||||
>
|
||||
Auswahl entfernen
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-6 md:justify-end md:items-center">
|
||||
|
||||
<Button
|
||||
className="w-full bg-arcticBlue-500 hover:bg-arcticBlue-600"
|
||||
onClick={handleBulkRedeploy}
|
||||
disabled={bulkActionDisabled}
|
||||
>
|
||||
{bulkButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -875,8 +949,9 @@ export function Stacks() {
|
||||
const isSelected = selectedStackIds.includes(stack.Id);
|
||||
const isCurrent = stack.updateStatus === '✅';
|
||||
const isSelfStack = Boolean(stack.redeployDisabled);
|
||||
const isSelectable = !isBusy && !isCurrent && !isSelfStack && !maintenanceLocked;
|
||||
const isSelectable = !isBusy && !isCurrent && !isSelfStack;
|
||||
return (
|
||||
|
||||
<Card key={stack.Id}>
|
||||
<CardBody
|
||||
className={`flex w-full text-stormGrey-500 items-center justify-between gap-4 rounded-xl shadow-lg transition border
|
||||
@@ -929,7 +1004,7 @@ export function Stacks() {
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleRedeploy(stack.Id)}
|
||||
disabled={isBusy || maintenanceLocked}
|
||||
disabled={isBusy}
|
||||
className="disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Redeploy
|
||||
@@ -939,9 +1014,11 @@ export function Stacks() {
|
||||
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<PaginationControls disabled={loading || Boolean(error)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
setSidenavColor,
|
||||
setSidenavType,
|
||||
setFixedNavbar,
|
||||
} from "@/context";
|
||||
} from "@/components";
|
||||
|
||||
function formatNumber(number, decPlaces) {
|
||||
decPlaces = Math.pow(10, decPlaces);
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
useMaterialTailwindController,
|
||||
setOpenConfigurator,
|
||||
setOpenSidenav,
|
||||
} from "@/context";
|
||||
} from "@/components";
|
||||
|
||||
export function DashboardNavbar() {
|
||||
const [controller, dispatch] = useMaterialTailwindController();
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
IconButton,
|
||||
Typography,
|
||||
} from "@material-tailwind/react";
|
||||
import { useMaterialTailwindController, setOpenSidenav } from "@/context";
|
||||
import { useMaterialTailwindController, setOpenSidenav } from "@/components";
|
||||
|
||||
export function Sidenav({ brandImg, brandName, routes }) {
|
||||
const [controller, dispatch] = useMaterialTailwindController();
|
||||
|
||||
Reference in New Issue
Block a user