diff --git a/frontend/src/Stacks.jsx b/frontend/src/Stacks.jsx index ff7f9f6..7b635b6 100644 --- a/frontend/src/Stacks.jsx +++ b/frontend/src/Stacks.jsx @@ -1,12 +1,32 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import axios from "axios"; import { io } from "socket.io-client"; +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)); + export default function Stacks() { const [stacks, setStacks] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [selectedStackIds, setSelectedStackIds] = useState([]); + const [statusFilter, setStatusFilter] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); + const [selectionPromptVisible, setSelectionPromptVisible] = useState(false); + const [rememberSelectionChoice, setRememberSelectionChoice] = useState(false); + const [selectionPreferenceStored, setSelectionPreferenceStored] = useState(false); + const [showRedeployingOnly, setShowRedeployingOnly] = useState(false); + const [perPage, setPerPage] = useState(PER_PAGE_DEFAULT); + const [page, setPage] = useState(1); const mergeStackState = (previousStacks, incomingStacks) => { const prevMap = new Map(previousStacks.map((stack) => [stack.Id, stack])); @@ -85,6 +105,149 @@ export default function Stacks() { }); }, [stacks]); + const normalizedSearch = searchQuery.trim().toLowerCase(); + + const filteredStacks = useMemo(() => { + return stacks.filter((stack) => { + if (statusFilter === "current" && stack.updateStatus !== '✅') return false; + if (statusFilter === "outdated" && stack.updateStatus === '✅') return false; + if (showRedeployingOnly && !stack.redeploying) return false; + + if (normalizedSearch) { + const identifier = `${stack.Name ?? ""} ${stack.Id ?? ""}`.toLowerCase(); + if (!identifier.includes(normalizedSearch)) return false; + } + + return true; + }); + }, [stacks, statusFilter, normalizedSearch, showRedeployingOnly]); + + const eligibleFilteredStacks = useMemo( + () => filteredStacks.filter((stack) => stack.updateStatus !== '✅' && !stack.redeployDisabled), + [filteredStacks] + ); + + const stacksById = useMemo(() => { + const map = new Map(); + stacks.forEach((stack) => { + map.set(stack.Id, stack); + }); + return map; + }, [stacks]); + + const filteredStackIdSet = useMemo(() => new Set(filteredStacks.map((stack) => stack.Id)), [filteredStacks]); + + const paginatedStacks = useMemo(() => { + if (perPage === 'all') { + return filteredStacks; + } + const perPageNumber = Number(perPage); + const start = (page - 1) * perPageNumber; + return filteredStacks.slice(start, start + perPageNumber); + }, [filteredStacks, perPage, page]); + + const visiblePageStackIds = useMemo(() => new Set(paginatedStacks.map((stack) => stack.Id)), [paginatedStacks]); + + const eligiblePageStacks = useMemo( + () => paginatedStacks.filter((stack) => stack.updateStatus !== '✅' && !stack.redeployDisabled), + [paginatedStacks] + ); + + const selectionPreferenceRef = useRef({ action: 'keep', remember: false }); + const previousFiltersRef = useRef({ status: statusFilter, search: normalizedSearch, redeploying: showRedeployingOnly }); + const didMountRef = useRef(false); + + useEffect(() => { + if (typeof window === 'undefined') return; + try { + const storedValue = window.sessionStorage.getItem(SELECTION_PROMPT_STORAGE_KEY); + if (!storedValue) return; + const parsed = JSON.parse(storedValue); + if (parsed && (parsed.action === 'keep' || parsed.action === 'clear')) { + selectionPreferenceRef.current = { action: parsed.action, remember: true }; + setSelectionPreferenceStored(true); + } + } catch (err) { + console.error('⚠️ Konnte gespeicherte Auswahl-Einstellung nicht laden:', err); + selectionPreferenceRef.current = { action: 'keep', remember: false }; + } + }, []); + + useEffect(() => { + const normalized = normalizedSearch; + if (!didMountRef.current) { + didMountRef.current = true; + previousFiltersRef.current = { status: statusFilter, search: normalized, redeploying: showRedeployingOnly }; + return; + } + + const prev = previousFiltersRef.current; + const filtersChanged = prev.status !== statusFilter || prev.search !== normalized || prev.redeploying !== showRedeployingOnly; + + if (!filtersChanged) { + if (statusFilter === 'all' && normalized.length === 0 && !showRedeployingOnly && selectionPromptVisible) { + setSelectionPromptVisible(false); + } + return; + } + + previousFiltersRef.current = { status: statusFilter, search: normalized, redeploying: showRedeployingOnly }; + + if (selectedStackIds.length === 0) { + setSelectionPromptVisible(false); + return; + } + + const hasFilters = statusFilter !== 'all' || normalized.length > 0 || showRedeployingOnly; + if (!hasFilters) { + setSelectionPromptVisible(false); + return; + } + + const storedPreference = selectionPreferenceRef.current; + if (storedPreference?.remember) { + if (storedPreference.action === 'clear') { + setSelectedStackIds([]); + } + setSelectionPromptVisible(false); + return; + } + + setRememberSelectionChoice(Boolean(storedPreference?.remember)); + setSelectionPromptVisible(true); + }, [statusFilter, normalizedSearch, selectedStackIds.length, selectionPromptVisible]); + + useEffect(() => { + if (selectedStackIds.length === 0 && selectionPromptVisible) { + setSelectionPromptVisible(false); + } + }, [selectedStackIds.length, selectionPromptVisible]); + + const hasActiveFilters = statusFilter !== "all" || normalizedSearch.length > 0 || showRedeployingOnly; + + useEffect(() => { + setPage(1); + }, [statusFilter, normalizedSearch, showRedeployingOnly]); + + useEffect(() => { + if (perPage === 'all') { + if (page !== 1) setPage(1); + return; + } + + const perPageNumber = Number(perPage); + 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); + const toggleStackSelection = (stackId, disabled) => { if (disabled) return; setSelectedStackIds(prev => @@ -94,6 +257,83 @@ export default function Stacks() { ); }; + const handlePerPageChange = (event) => { + const value = event.target.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') { + try { + if (remember) { + const payload = { action, remember: true }; + window.sessionStorage.setItem(SELECTION_PROMPT_STORAGE_KEY, JSON.stringify(payload)); + selectionPreferenceRef.current = payload; + } else { + window.sessionStorage.removeItem(SELECTION_PROMPT_STORAGE_KEY); + selectionPreferenceRef.current = { action, remember: false }; + } + } catch (err) { + console.error('⚠️ Konnte Auswahl-Einstellung nicht speichern:', err); + selectionPreferenceRef.current = { action, remember: false }; + } + } else { + selectionPreferenceRef.current = { action, remember: false }; + } + + setSelectionPreferenceStored(remember); + setRememberSelectionChoice(remember); + + if (action === 'clear') { + setSelectedStackIds([]); + } + + setSelectionPromptVisible(false); + }; + + const clearStoredSelectionPreference = () => { + if (typeof window !== 'undefined') { + try { + window.sessionStorage.removeItem(SELECTION_PROMPT_STORAGE_KEY); + } catch (err) { + console.error('⚠️ Konnte gespeicherte Auswahl-Einstellung nicht löschen:', err); + } + } + + selectionPreferenceRef.current = { action: 'keep', remember: false }; + setSelectionPreferenceStored(false); + setRememberSelectionChoice(false); + + if (hasActiveFilters && selectedStackIds.length > 0) { + setSelectionPromptVisible(true); + } + }; + + const clearSelection = () => { + setSelectedStackIds([]); + setSelectionPromptVisible(false); + }; + + const handleChipRemove = (stackId) => { + setSelectedStackIds((prev) => prev.filter((id) => id !== stackId)); + }; + const handleRedeploy = async (stackId) => { setSelectedStackIds((prev) => prev.filter((id) => id !== stackId)); setStacks((prev) => @@ -116,28 +356,31 @@ export default function Stacks() { }; const handleRedeployAll = async () => { - const outdatedStacks = stacks.filter((stack) => stack.updateStatus !== '✅' && !stack.redeployDisabled); - if (!outdatedStacks.length) return; + if (!eligiblePageStacks.length) return; - const outdatedIds = new Set(outdatedStacks.map((stack) => stack.Id)); + const targetIds = new Set(eligiblePageStacks.map((stack) => stack.Id)); setStacks(prev => prev.map(stack => - outdatedIds.has(stack.Id) + targetIds.has(stack.Id) ? { ...stack, redeploying: true } : stack ) ); try { - await axios.put("/api/stacks/redeploy-all"); - setSelectedStackIds((prev) => prev.filter((id) => !outdatedIds.has(id))); + if (targetIds.size === eligibleFilteredStacks.length) { + await axios.put("/api/stacks/redeploy-all"); + } else { + await axios.put("/api/stacks/redeploy-selection", { stackIds: Array.from(targetIds) }); + } + setSelectedStackIds((prev) => prev.filter((id) => !targetIds.has(id))); // Statusupdates kommen über Socket.IO } catch (err) { console.error("❌ Fehler beim Redeploy ALL:", err); setStacks(prev => prev.map(stack => - outdatedIds.has(stack.Id) + targetIds.has(stack.Id) ? { ...stack, redeploying: false } : stack ) @@ -185,17 +428,17 @@ export default function Stacks() { }; const hasSelection = selectedStackIds.length > 0; - const hasOutdatedStacks = stacks.some((stack) => stack.updateStatus !== '✅' && !stack.redeployDisabled); + const hasOutdatedStacks = eligiblePageStacks.length > 0; const bulkButtonLabel = hasSelection ? `Redeploy Auswahl (${selectedStackIds.length})` : 'Redeploy All'; const bulkActionDisabled = hasSelection - ? selectedStackIds.length === 0 || selectedStackIds.every(id => { + ? selectionPromptVisible || selectedStackIds.length === 0 || selectedStackIds.every(id => { const targetStack = stacks.find(stack => stack.Id === id); return !targetStack || targetStack.redeploying || targetStack.updateStatus === '✅' || targetStack.redeployDisabled; }) - : !hasOutdatedStacks || stacks.every(stack => stack.updateStatus !== '✅' || stack.redeploying || stack.redeployDisabled); + : !hasOutdatedStacks || eligiblePageStacks.every(stack => stack.redeploying); const handleBulkRedeploy = () => { if (hasSelection) { @@ -210,18 +453,196 @@ export default function Stacks() { return (
-
- +
+
+
+ Status +
+ + + +
+
+
+ + setSearchQuery(event.target.value)} + placeholder="Name oder ID" + className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500" + /> + +
+
+
+ {selectionPreferenceStored && ( + + )} + +
-
- {stacks.map(stack => { +
+
+ {totalItems === 0 + ? 'Keine Stacks verfügbar' + : `Zeige ${pageStart}-${pageEnd} von ${totalItems} Stacks`} +
+
+ +
+ + {`Seite ${Math.min(page, totalPages)} / ${totalPages}`} + +
+
+
+ + {selectedStackIds.length > 0 && ( +
+
+ Auswahl: + {selectedStackIds.map((id) => { + const stack = stacksById.get(id); + const name = stack?.Name || `Stack ${id}`; + const isFiltered = filteredStackIdSet.has(id); + const isVisibleOnPage = visiblePageStackIds.has(id); + return ( + + ); + })} +
+ +
+ )} + + {selectionPromptVisible && ( +
+
+

+ {selectedStackIds.length === 1 + ? '1 Stack ist weiterhin ausgewählt.' + : `${selectedStackIds.length} Stacks sind weiterhin ausgewählt.`} + {' '}Soll die Auswahl entfernt werden? +

+ +
+
+ + +
+
+ )} + +
+ {paginatedStacks.map(stack => { const isRedeploying = stack.redeploying; const isSelected = selectedStackIds.includes(stack.Id); const isCurrent = stack.updateStatus === '✅'; @@ -289,7 +710,11 @@ export default function Stacks() {
); })} - {stacks.length === 0 &&

Keine Stacks gefunden.

} + {filteredStacks.length === 0 && ( +

+ {hasActiveFilters ? 'Keine Stacks für die gesetzten Filter.' : 'Keine Stacks gefunden.'} +

+ )}
);