This commit is contained in:
root
2025-10-22 11:33:12 +00:00
parent 23d46e7c40
commit d6ddf12c04
16 changed files with 586 additions and 317 deletions
+40 -104
View File
@@ -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> /&nbsp;<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 -1
View File
@@ -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,
+180 -103
View File
@@ -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,70 +822,120 @@ 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')}
>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
onClick={() => setStatusFilter('all')}
>Alle</Button>
<Button
onClick={() => setStatusFilter('current')}
>Aktuell</Button>
<Button
onClick={() => setStatusFilter('outdated')}
>Veraltet</Button>
</ButtonGroup>
{perPageOptions.map(({ value, label }) => (
<Option
key={value}
value={value}
onClick={() => handlePerPageChange(value)}
>
{label}
</Option>
))}
</Select>
</div>
<Input
value={searchQuery}
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">
</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>
);
}