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 Analytics (nepcha.com) -->
|
||||||
<!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. -->
|
<!-- 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 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>
|
<script type="module" crossorigin src="/assets/index-1b29e29a.js"></script>
|
||||||
<link rel="stylesheet" href="/assets/index-9562875d.css">
|
<link rel="stylesheet" href="/assets/index-1c9b00a5.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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",
|
sidenavColor: "gray",
|
||||||
sidenavType: "white",
|
sidenavType: "white",
|
||||||
transparentNavbar: true,
|
transparentNavbar: true,
|
||||||
fixedNavbar: false,
|
fixedNavbar: true,
|
||||||
openConfigurator: false,
|
openConfigurator: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Footer,
|
Footer,
|
||||||
} from "@/widgets/layout";
|
} from "@/widgets/layout";
|
||||||
import routes from "@/routes";
|
import routes from "@/routes";
|
||||||
import { useMaterialTailwindController, setOpenConfigurator } from "@/context";
|
import { useMaterialTailwindController, setOpenConfigurator } from "@/components";
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const [controller, dispatch] = useMaterialTailwindController();
|
const [controller, dispatch] = useMaterialTailwindController();
|
||||||
|
|||||||
+14
-11
@@ -14,23 +14,26 @@ import ReactDOM from "react-dom/client";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { ThemeProvider } from "@material-tailwind/react";
|
import { ThemeProvider } from "@material-tailwind/react";
|
||||||
import { MaterialTailwindControllerProvider } from "@/context";
|
import { MaterialTailwindControllerProvider } from "@/components";
|
||||||
import "./tailwind.css";
|
import "./tailwind.css";
|
||||||
import ToastProvider from "@/components/ToastProvider.jsx";
|
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(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<MaintenanceProvider>
|
<MaintenanceProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ThemeProvider>
|
<PageProvider>
|
||||||
<MaterialTailwindControllerProvider>
|
<ThemeProvider>
|
||||||
<App />
|
<MaterialTailwindControllerProvider>
|
||||||
</MaterialTailwindControllerProvider>
|
<App />
|
||||||
</ThemeProvider>
|
</MaterialTailwindControllerProvider>
|
||||||
</ToastProvider>
|
</ThemeProvider>
|
||||||
</MaintenanceProvider>
|
</PageProvider>
|
||||||
</BrowserRouter>
|
</ToastProvider>
|
||||||
</React.StrictMode >
|
</MaintenanceProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
useSelect
|
useSelect
|
||||||
} from "@material-tailwind/react";
|
} from "@material-tailwind/react";
|
||||||
|
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||||
|
|
||||||
const StickyOption = React.forwardRef(({ value, onClick, onKeyDown, ...props }, ref) => {
|
const StickyOption = React.forwardRef(({ value, onClick, onKeyDown, ...props }, ref) => {
|
||||||
const { setOpen } = useSelect();
|
const { setOpen } = useSelect();
|
||||||
@@ -91,16 +92,6 @@ const normalizeDateParam = (value) => {
|
|||||||
const FILTER_STORAGE_KEY = "redeployLogFilters";
|
const FILTER_STORAGE_KEY = "redeployLogFilters";
|
||||||
const ALL_OPTION_VALUE = "__all__";
|
const ALL_OPTION_VALUE = "__all__";
|
||||||
const ALL_OPTION_LABEL = "- Alle -";
|
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 = {
|
const REDEPLOY_TYPE_LABELS = {
|
||||||
Einzeln: "Einzeln",
|
Einzeln: "Einzeln",
|
||||||
Alle: "Alle",
|
Alle: "Alle",
|
||||||
@@ -124,7 +115,6 @@ const hasActiveFilters = (filters) => Boolean(
|
|||||||
export function Logs() {
|
export function Logs() {
|
||||||
|
|
||||||
const [logs, setLogs] = useState([]);
|
const [logs, setLogs] = useState([]);
|
||||||
const [totalLogs, setTotalLogs] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
@@ -147,11 +137,22 @@ export function Logs() {
|
|||||||
const [optionsInitialized, setOptionsInitialized] = useState(false);
|
const [optionsInitialized, setOptionsInitialized] = useState(false);
|
||||||
const [refreshSignal, setRefreshSignal] = useState(0);
|
const [refreshSignal, setRefreshSignal] = useState(0);
|
||||||
|
|
||||||
const [perPage, setPerPage] = useState(PER_PAGE_DEFAULT);
|
const {
|
||||||
const [page, setPage] = useState(1);
|
page,
|
||||||
|
perPage,
|
||||||
|
perPageOptions,
|
||||||
|
setPage,
|
||||||
|
setPerPage: setPerPageValue,
|
||||||
|
setTotals,
|
||||||
|
handlePerPageChange,
|
||||||
|
validPerPageValues,
|
||||||
|
resetPagination
|
||||||
|
} = usePage();
|
||||||
|
|
||||||
const noop = useCallback(() => { }, []);
|
const noop = useCallback(() => { }, []);
|
||||||
|
|
||||||
|
useEffect(() => () => resetPagination(), [resetPagination]);
|
||||||
|
|
||||||
const updateFilterOptions = useCallback((payload) => {
|
const updateFilterOptions = useCallback((payload) => {
|
||||||
const logsPayload = Array.isArray(payload) ? payload : payload?.items ?? [];
|
const logsPayload = Array.isArray(payload) ? payload : payload?.items ?? [];
|
||||||
|
|
||||||
@@ -258,8 +259,8 @@ export function Logs() {
|
|||||||
const rawPerPage = storedPagination.perPage;
|
const rawPerPage = storedPagination.perPage;
|
||||||
if (rawPerPage !== undefined) {
|
if (rawPerPage !== undefined) {
|
||||||
const parsedPerPage = String(rawPerPage);
|
const parsedPerPage = String(rawPerPage);
|
||||||
if (VALID_PER_PAGE_VALUES.has(parsedPerPage)) {
|
if (validPerPageValues.has(parsedPerPage)) {
|
||||||
setPerPage(parsedPerPage);
|
setPerPageValue(parsedPerPage, { resetPage: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +274,7 @@ export function Logs() {
|
|||||||
} finally {
|
} finally {
|
||||||
setFiltersReady(true);
|
setFiltersReady(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setPage, setPerPageValue, validPerPageValues]);
|
||||||
|
|
||||||
const buildFilterParams = useCallback(() => {
|
const buildFilterParams = useCallback(() => {
|
||||||
const params = {};
|
const params = {};
|
||||||
@@ -364,7 +365,7 @@ export function Logs() {
|
|||||||
const total = Array.isArray(data) ? items.length : data.total ?? items.length;
|
const total = Array.isArray(data) ? items.length : data.total ?? items.length;
|
||||||
|
|
||||||
setLogs(items);
|
setLogs(items);
|
||||||
setTotalLogs(total);
|
setTotals(total, items.length);
|
||||||
|
|
||||||
if (perPage === 'all') {
|
if (perPage === 'all') {
|
||||||
if (page !== 1) setPage(1);
|
if (page !== 1) setPage(1);
|
||||||
@@ -389,7 +390,7 @@ export function Logs() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [filtersReady, buildFilterParams, updateFilterOptions, refreshSignal, perPage, page]);
|
}, [filtersReady, buildFilterParams, updateFilterOptions, refreshSignal, perPage, page, setTotals]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filtersReady) return;
|
if (!filtersReady) return;
|
||||||
@@ -473,21 +474,6 @@ export function Logs() {
|
|||||||
setFiltersOpen((prev) => !prev);
|
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 stackSelectOptions = useMemo(() => {
|
||||||
const entries = stackOptions.filter((option) => option.value !== ALL_OPTION_VALUE);
|
const entries = stackOptions.filter((option) => option.value !== ALL_OPTION_VALUE);
|
||||||
return [
|
return [
|
||||||
@@ -538,24 +524,6 @@ export function Logs() {
|
|||||||
return count;
|
return count;
|
||||||
}, [selectedStacks, selectedStatuses, selectedEndpoints, messageQuery, fromDate, toDate]);
|
}, [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) => {
|
const handleDeleteLog = async (id) => {
|
||||||
if (!window.confirm("Diesen Log-Eintrag dauerhaft löschen?")) return;
|
if (!window.confirm("Diesen Log-Eintrag dauerhaft löschen?")) return;
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
@@ -866,8 +834,8 @@ export function Logs() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 mt-5">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mt-8">
|
||||||
<div className="grid gap-4 flex-1">
|
<div className="md:flex-1">
|
||||||
<Input
|
<Input
|
||||||
value={messageQuery}
|
value={messageQuery}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -878,6 +846,24 @@ export function Logs() {
|
|||||||
label="Nachricht (Freitext)"
|
label="Nachricht (Freitext)"
|
||||||
placeholder="Suche" />
|
placeholder="Suche" />
|
||||||
</div>
|
</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>
|
||||||
<div className="flex flex-col md:flex-row flex-wrap gap-4 mt-8">
|
<div className="flex flex-col md:flex-row flex-wrap gap-4 mt-8">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -931,23 +917,6 @@ export function Logs() {
|
|||||||
className="flex items-center justify-between"
|
className="flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<span>Logs</span>
|
<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>
|
</Typography>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody className="overflow-x-scroll px-0 pt-0 pb-2">
|
<CardBody className="overflow-x-scroll px-0 pt-0 pb-2">
|
||||||
@@ -1048,40 +1017,7 @@ export function Logs() {
|
|||||||
</table>
|
</table>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<PaginationControls disabled={actionLoading} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useToast } from "@/components/ToastProvider.jsx";
|
import { useToast } from "@/components/ToastProvider.jsx";
|
||||||
import { useMaintenance } from "@/context/MaintenanceContext.jsx";
|
import { useMaintenance } from "@/components/MaintenanceProvider";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Typography,
|
Typography,
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ 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";
|
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 {
|
import {
|
||||||
Typography,
|
Typography,
|
||||||
Card,
|
Card,
|
||||||
@@ -26,15 +28,6 @@ import {
|
|||||||
|
|
||||||
const SELECTION_PROMPT_STORAGE_KEY = "stackSelectionPreference";
|
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_CACHE_DURATION = 30 * 1000;
|
||||||
const STACKS_REFRESH_INTERVAL = 30 * 1000;
|
const STACKS_REFRESH_INTERVAL = 30 * 1000;
|
||||||
let stacksCache = { data: null, timestamp: 0 };
|
let stacksCache = { data: null, timestamp: 0 };
|
||||||
@@ -48,6 +41,15 @@ const REDEPLOY_PHASES = {
|
|||||||
INFO: 'info'
|
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 isCacheFresh = () => Boolean(stacksCache.data) && (Date.now() - stacksCache.timestamp < STACKS_CACHE_DURATION);
|
||||||
const updateStacksCache = (data) => {
|
const updateStacksCache = (data) => {
|
||||||
stacksCache = { data, timestamp: Date.now() };
|
stacksCache = { data, timestamp: Date.now() };
|
||||||
@@ -57,7 +59,11 @@ const prepareInitialStacks = (data) => {
|
|||||||
if (!Array.isArray(data)) return [];
|
if (!Array.isArray(data)) return [];
|
||||||
return [...data].sort((a, b) => (a?.Name || '').localeCompare(b?.Name || ''));
|
return [...data].sort((a, b) => (a?.Name || '').localeCompare(b?.Name || ''));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function Stacks() {
|
export function Stacks() {
|
||||||
|
|
||||||
const [stacks, setStacks] = useState(() => prepareInitialStacks(stacksCache.data));
|
const [stacks, setStacks] = useState(() => prepareInitialStacks(stacksCache.data));
|
||||||
const [loading, setLoading] = useState(() => !stacksCache.data);
|
const [loading, setLoading] = useState(() => !stacksCache.data);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -67,19 +73,35 @@ export function Stacks() {
|
|||||||
const [selectionPromptVisible, setSelectionPromptVisible] = useState(false);
|
const [selectionPromptVisible, setSelectionPromptVisible] = useState(false);
|
||||||
const [rememberSelectionChoice, setRememberSelectionChoice] = useState(false);
|
const [rememberSelectionChoice, setRememberSelectionChoice] = useState(false);
|
||||||
const [selectionPreferenceStored, setSelectionPreferenceStored] = useState(false);
|
const [selectionPreferenceStored, setSelectionPreferenceStored] = useState(false);
|
||||||
const [perPage, setPerPage] = useState(PER_PAGE_DEFAULT);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
|
|
||||||
const { showToast } = useToast();
|
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 noop = useCallback(() => { }, []);
|
||||||
|
|
||||||
const stacksByIdRef = useRef(new Map());
|
useEffect(() => () => resetPagination(), [resetPagination]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (maintenanceLocked) {
|
if (maintenanceLocked) {
|
||||||
@@ -89,6 +111,9 @@ export function Stacks() {
|
|||||||
}
|
}
|
||||||
}, [maintenanceLocked]);
|
}, [maintenanceLocked]);
|
||||||
|
|
||||||
|
const stacksByIdRef = useRef(new Map());
|
||||||
|
|
||||||
|
|
||||||
const mergeStackState = useCallback((previousStacks, incomingStacks) => {
|
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));
|
||||||
@@ -242,7 +267,7 @@ export function Stacks() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchStacks = useCallback(async ({ force = false, silent = false } = {}) => {
|
const fetchStacks = useCallback(async ({ force = false, silent = false } = {}) => {
|
||||||
if (maintenanceActive) {
|
if ((maintenanceActive || updateRunning)) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -266,6 +291,7 @@ export function Stacks() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (force || !stacksCachePromise) {
|
if (force || !stacksCachePromise) {
|
||||||
stacksCachePromise = axios.get("/api/stacks")
|
stacksCachePromise = axios.get("/api/stacks")
|
||||||
@@ -278,7 +304,10 @@ export function Stacks() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const data = await stacksCachePromise;
|
const data = await stacksCachePromise;
|
||||||
|
|
||||||
|
|
||||||
setStacks(prev => mergeStackState(prev, data));
|
setStacks(prev => mergeStackState(prev, data));
|
||||||
setError("");
|
setError("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -291,14 +320,14 @@ export function Stacks() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [maintenanceActive, mergeStackState]);
|
}, [maintenanceActive, updateRunning, mergeStackState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStacks();
|
fetchStacks();
|
||||||
}, [fetchStacks]);
|
}, [fetchStacks]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof document === 'undefined' || maintenanceActive) return undefined;
|
if (typeof document === 'undefined' || maintenanceActive || updateRunning) return undefined;
|
||||||
|
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
@@ -307,10 +336,10 @@ export function Stacks() {
|
|||||||
}, STACKS_REFRESH_INTERVAL);
|
}, STACKS_REFRESH_INTERVAL);
|
||||||
|
|
||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId);
|
||||||
}, [fetchStacks, maintenanceActive]);
|
}, [fetchStacks, maintenanceActive, updateRunning]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof document === 'undefined' || maintenanceActive) return undefined;
|
if (typeof document === 'undefined' || maintenanceActive || updateRunning) return undefined;
|
||||||
|
|
||||||
const handleVisibility = () => {
|
const handleVisibility = () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
@@ -322,7 +351,7 @@ export function Stacks() {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('visibilitychange', handleVisibility);
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
};
|
};
|
||||||
}, [fetchStacks, maintenanceActive]);
|
}, [fetchStacks, maintenanceActive, updateRunning]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedStackIds(prev => {
|
setSelectedStackIds(prev => {
|
||||||
@@ -389,6 +418,10 @@ export function Stacks() {
|
|||||||
return filteredStacks.slice(start, start + perPageNumber);
|
return filteredStacks.slice(start, start + perPageNumber);
|
||||||
}, [filteredStacks, perPage, page]);
|
}, [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 visiblePageStackIds = useMemo(() => new Set(paginatedStacks.map((stack) => stack.Id)), [paginatedStacks]);
|
||||||
|
|
||||||
const eligiblePageStacks = useMemo(
|
const eligiblePageStacks = useMemo(
|
||||||
@@ -476,7 +509,7 @@ export function Stacks() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [statusFilter, normalizedSearch]);
|
}, [statusFilter, normalizedSearch, setPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (perPage === 'all') {
|
if (perPage === 'all') {
|
||||||
@@ -484,21 +517,15 @@ export function Stacks() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const perPageNumber = Number(perPage);
|
const perPageNumber = Number(perPage) || 1;
|
||||||
const totalPagesCalc = Math.max(1, Math.ceil(filteredStacks.length / perPageNumber));
|
const totalPagesCalc = Math.max(1, Math.ceil(filteredStacks.length / perPageNumber));
|
||||||
if (page > totalPagesCalc) {
|
if (page > totalPagesCalc) {
|
||||||
setPage(totalPagesCalc);
|
setPage(totalPagesCalc);
|
||||||
}
|
}
|
||||||
}, [filteredStacks.length, perPage, page]);
|
}, [filteredStacks.length, perPage, page, setPage]);
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
const toggleStackSelection = (stackId, disabled) => {
|
const toggleStackSelection = (stackId, disabled) => {
|
||||||
if (maintenanceLocked || disabled) return;
|
if (disabled) return;
|
||||||
setSelectedStackIds(prev =>
|
setSelectedStackIds(prev =>
|
||||||
prev.includes(stackId)
|
prev.includes(stackId)
|
||||||
? prev.filter(id => id !== 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 applySelectionPreference = (action) => {
|
||||||
const remember = rememberSelectionChoice;
|
const remember = rememberSelectionChoice;
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -748,7 +756,7 @@ export function Stacks() {
|
|||||||
const hasOutdatedStacks = eligiblePageStacks.length > 0;
|
const hasOutdatedStacks = eligiblePageStacks.length > 0;
|
||||||
const bulkButtonLabel = hasSelection
|
const bulkButtonLabel = hasSelection
|
||||||
? `Redeploy Auswahl (${selectedStackIds.length})`
|
? `Redeploy Auswahl (${selectedStackIds.length})`
|
||||||
: 'Redeploy All';
|
: 'Redeploy Alle';
|
||||||
|
|
||||||
const bulkActionDisabled = maintenanceLocked || (hasSelection
|
const bulkActionDisabled = maintenanceLocked || (hasSelection
|
||||||
? selectionPromptVisible || selectedStackIds.length === 0 || selectedStackIds.every(id => {
|
? selectionPromptVisible || selectedStackIds.length === 0 || selectedStackIds.every(id => {
|
||||||
@@ -780,7 +788,23 @@ export function Stacks() {
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div className="mt-12 mb-8 flex flex-col gap-12">
|
<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>
|
<Card>
|
||||||
<CardHeader variant="gradient" color="gray" className="p-4 pt-2 pb-2">
|
<CardHeader variant="gradient" color="gray" className="p-4 pt-2 pb-2">
|
||||||
{!loading && !error ? (
|
{!loading && !error ? (
|
||||||
@@ -798,70 +822,120 @@ export function Stacks() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{loading ? (
|
{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 ? (
|
) : error ? (
|
||||||
<p className="py-6 text-sm font-medium text-red-400">{error}</p>
|
<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 className="gap-4">
|
||||||
<div>
|
<div className="w-full">
|
||||||
<ButtonGroup
|
<ButtonGroup fullWidth>
|
||||||
|
<Button
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
>Alle</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setStatusFilter('current')}
|
||||||
|
|
||||||
|
>Aktuell</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setStatusFilter('outdated')}
|
||||||
|
|
||||||
|
>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>
|
||||||
|
<div className="md:mt-0 mt-8 md:flex-1">
|
||||||
|
<Select
|
||||||
|
variant="static"
|
||||||
|
label="Einträge pro Seite"
|
||||||
|
onChange={noop}
|
||||||
|
value={perPage}
|
||||||
>
|
>
|
||||||
<Button
|
{perPageOptions.map(({ value, label }) => (
|
||||||
onClick={() => setStatusFilter('all')}
|
<Option
|
||||||
|
key={value}
|
||||||
>Alle</Button>
|
value={value}
|
||||||
<Button
|
onClick={() => handlePerPageChange(value)}
|
||||||
onClick={() => setStatusFilter('current')}
|
>
|
||||||
|
{label}
|
||||||
>Aktuell</Button>
|
</Option>
|
||||||
<Button
|
))}
|
||||||
onClick={() => setStatusFilter('outdated')}
|
</Select>
|
||||||
|
|
||||||
>Veraltet</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
</div>
|
</div>
|
||||||
<Input
|
</div>
|
||||||
value={searchQuery}
|
<div className="flex items-center justify-end gap-3 mt-5">
|
||||||
onChange={(event) => setSearchQuery(event.target.value)}
|
|
||||||
variant="static"
|
|
||||||
placeholder="Name oder ID" />
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
|
|
||||||
<Select
|
|
||||||
variant="static"
|
|
||||||
label="Einträge pro Seite"
|
|
||||||
onChange={noop}
|
|
||||||
value={perPage}
|
|
||||||
>
|
|
||||||
{PER_PAGE_OPTIONS.map(({ value, label }) => (
|
|
||||||
<Option
|
|
||||||
key={value}
|
|
||||||
value={value}
|
|
||||||
onClick={() => handlePerPageChange(value)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-end gap-2">
|
|
||||||
{selectionPreferenceStored && (
|
{selectionPreferenceStored && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={clearStoredSelectionPreference}
|
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
|
Gespeicherte Entscheidung löschen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -875,8 +949,9 @@ export function Stacks() {
|
|||||||
const isSelected = selectedStackIds.includes(stack.Id);
|
const isSelected = selectedStackIds.includes(stack.Id);
|
||||||
const isCurrent = stack.updateStatus === '✅';
|
const isCurrent = stack.updateStatus === '✅';
|
||||||
const isSelfStack = Boolean(stack.redeployDisabled);
|
const isSelfStack = Boolean(stack.redeployDisabled);
|
||||||
const isSelectable = !isBusy && !isCurrent && !isSelfStack && !maintenanceLocked;
|
const isSelectable = !isBusy && !isCurrent && !isSelfStack;
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<Card key={stack.Id}>
|
<Card key={stack.Id}>
|
||||||
<CardBody
|
<CardBody
|
||||||
className={`flex w-full text-stormGrey-500 items-center justify-between gap-4 rounded-xl shadow-lg transition border
|
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
|
<Button
|
||||||
onClick={() => handleRedeploy(stack.Id)}
|
onClick={() => handleRedeploy(stack.Id)}
|
||||||
disabled={isBusy || maintenanceLocked}
|
disabled={isBusy}
|
||||||
className="disabled:opacity-50 disabled:cursor-not-allowed"
|
className="disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Redeploy
|
Redeploy
|
||||||
@@ -939,9 +1014,11 @@ export function Stacks() {
|
|||||||
|
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<PaginationControls disabled={loading || Boolean(error)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
setSidenavColor,
|
setSidenavColor,
|
||||||
setSidenavType,
|
setSidenavType,
|
||||||
setFixedNavbar,
|
setFixedNavbar,
|
||||||
} from "@/context";
|
} from "@/components";
|
||||||
|
|
||||||
function formatNumber(number, decPlaces) {
|
function formatNumber(number, decPlaces) {
|
||||||
decPlaces = Math.pow(10, decPlaces);
|
decPlaces = Math.pow(10, decPlaces);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
useMaterialTailwindController,
|
useMaterialTailwindController,
|
||||||
setOpenConfigurator,
|
setOpenConfigurator,
|
||||||
setOpenSidenav,
|
setOpenSidenav,
|
||||||
} from "@/context";
|
} from "@/components";
|
||||||
|
|
||||||
export function DashboardNavbar() {
|
export function DashboardNavbar() {
|
||||||
const [controller, dispatch] = useMaterialTailwindController();
|
const [controller, dispatch] = useMaterialTailwindController();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@material-tailwind/react";
|
} from "@material-tailwind/react";
|
||||||
import { useMaterialTailwindController, setOpenSidenav } from "@/context";
|
import { useMaterialTailwindController, setOpenSidenav } from "@/components";
|
||||||
|
|
||||||
export function Sidenav({ brandImg, brandName, routes }) {
|
export function Sidenav({ brandImg, brandName, routes }) {
|
||||||
const [controller, dispatch] = useMaterialTailwindController();
|
const [controller, dispatch] = useMaterialTailwindController();
|
||||||
|
|||||||
Reference in New Issue
Block a user