Files
stackpulse/frontend_old/src/Stacks.jsx
T
2025-10-15 14:45:04 +00:00

1039 lines
37 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
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 };
let stacksCachePromise = null;
const REDEPLOY_PHASES = {
QUEUED: 'queued',
STARTED: 'started',
SUCCESS: 'success',
ERROR: 'error',
INFO: 'info'
};
const isCacheFresh = () => Boolean(stacksCache.data) && (Date.now() - stacksCache.timestamp < STACKS_CACHE_DURATION);
const updateStacksCache = (data) => {
stacksCache = { data, timestamp: Date.now() };
};
const prepareInitialStacks = (data) => {
if (!Array.isArray(data)) return [];
return [...data].sort((a, b) => (a?.Name || '').localeCompare(b?.Name || ''));
};
export default function Stacks() {
const [stacks, setStacks] = useState(() => prepareInitialStacks(stacksCache.data));
const [loading, setLoading] = useState(() => !stacksCache.data);
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 [perPage, setPerPage] = useState(PER_PAGE_DEFAULT);
const [page, setPage] = useState(1);
const { showToast } = useToast();
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 stacksByIdRef = useRef(new Map());
useEffect(() => {
if (maintenanceLocked) {
setLoading(false);
setStacks([]);
setSelectedStackIds([]);
}
}, [maintenanceLocked]);
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));
return sortedIncoming.map((stack) => {
const previous = prevMap.get(stack.Id);
if (previous && previous.updateStatus === '✅' && stack.updateStatus === '⚠️') {
const stackLabel = stack.Name ? `${stack.Name} (ID: ${stack.Id})` : `Stack ${stack.Id}`;
showToast({
variant: 'warning',
title: 'Aktualisierung gefunden',
description: stackLabel
});
}
let effectivePhase = stack.redeployPhase ?? previous?.redeployPhase ?? null;
if (!effectivePhase && (stack.redeploying || previous?.redeploying)) {
effectivePhase = REDEPLOY_PHASES.STARTED;
}
const isQueued = effectivePhase === REDEPLOY_PHASES.QUEUED;
const isRunning = effectivePhase === REDEPLOY_PHASES.STARTED;
const isBusy = isQueued || isRunning;
return {
...stack,
redeployPhase: effectivePhase,
redeployQueued: isQueued,
redeploying: isBusy,
redeployDisabled: stack.redeployDisabled ?? previous?.redeployDisabled ?? false,
duplicateName: stack.duplicateName ?? previous?.duplicateName ?? false
};
});
}, [showToast]);
useEffect(() => {
const socket = io("/", {
path: "/socket.io",
transports: ["websocket"]
});
console.log("🔌 Socket connected");
socket.on("redeployStatus", async (payload = {}) => {
const { stackId } = payload;
if (!stackId) return;
const resolvedPhaseRaw = payload.redeployPhase ?? payload.phase;
let resolvedPhase = resolvedPhaseRaw;
if (!resolvedPhase) {
if (payload.isRedeploying === true) {
resolvedPhase = REDEPLOY_PHASES.STARTED;
} else if (payload.isRedeploying === false) {
resolvedPhase = REDEPLOY_PHASES.SUCCESS;
}
} else if (resolvedPhaseRaw === 'started' || resolvedPhaseRaw === 'running') {
resolvedPhase = REDEPLOY_PHASES.STARTED;
} else if (resolvedPhaseRaw === 'queued') {
resolvedPhase = REDEPLOY_PHASES.QUEUED;
} else if (resolvedPhaseRaw === 'success') {
resolvedPhase = REDEPLOY_PHASES.SUCCESS;
} else if (resolvedPhaseRaw === 'error') {
resolvedPhase = REDEPLOY_PHASES.ERROR;
} else if (resolvedPhaseRaw === 'info') {
resolvedPhase = REDEPLOY_PHASES.INFO;
}
const stackSnapshot = stacksByIdRef.current.get(stackId);
const stackName = payload.stackName ?? stackSnapshot?.Name;
const stackLabel = stackName ? `${stackName} (ID: ${stackId})` : `Stack ${stackId}`;
console.log(`🔄 Stack ${stackId} Redeploy Update: ${resolvedPhase ?? 'unbekannt'}`);
if (resolvedPhase === REDEPLOY_PHASES.STARTED) {
showToast({
variant: 'info',
title: 'Redeploy gestartet',
description: stackLabel
});
} else if (resolvedPhase === REDEPLOY_PHASES.SUCCESS) {
showToast({
variant: 'success',
title: 'Redeploy abgeschlossen',
description: stackLabel
});
} else if (resolvedPhase === REDEPLOY_PHASES.ERROR) {
const detail = payload.message ? ` ${payload.message}` : '';
showToast({
variant: 'error',
title: 'Redeploy fehlgeschlagen',
description: `${stackLabel}${detail}`
});
}
setStacks(prev =>
prev.map(stack => {
if (stack.Id !== stackId) return stack;
const nextPhase = resolvedPhase ?? stack.redeployPhase ?? null;
const isQueued = nextPhase === REDEPLOY_PHASES.QUEUED;
const isRunning = nextPhase === REDEPLOY_PHASES.STARTED;
const isSuccess = nextPhase === REDEPLOY_PHASES.SUCCESS;
const isError = nextPhase === REDEPLOY_PHASES.ERROR;
const isBusy = isQueued || isRunning;
const updated = {
...stack,
redeployPhase: nextPhase,
redeployQueued: isQueued,
redeploying: isBusy
};
if (isSuccess) {
updated.updateStatus = '✅';
updated.redeploying = false;
updated.redeployQueued = false;
}
if (isError) {
updated.redeploying = false;
updated.redeployQueued = false;
}
if (!isBusy && !isSuccess && !isError && resolvedPhase === undefined) {
updated.redeploying = false;
updated.redeployQueued = false;
updated.redeployPhase = stack.redeployPhase ?? null;
}
return updated;
})
);
const shouldRefresh =
resolvedPhase === REDEPLOY_PHASES.SUCCESS ||
resolvedPhase === REDEPLOY_PHASES.ERROR ||
(payload.isRedeploying === false && !resolvedPhase);
if (shouldRefresh) {
try {
const res = await axios.get('/api/stacks');
updateStacksCache(res.data);
setStacks(prev => mergeStackState(prev, res.data));
} catch (err) {
console.error('Fehler beim Aktualisieren nach Redeploy:', err);
}
}
});
return () => socket.disconnect();
}, []);
const fetchStacks = useCallback(async ({ force = false, silent = false } = {}) => {
if (maintenanceActive) {
if (!silent) {
setLoading(false);
}
return;
}
const hadCache = Boolean(stacksCache.data);
if (!force && hadCache) {
setStacks(prev => mergeStackState(prev, stacksCache.data));
if (!silent) {
setLoading(false);
if (isCacheFresh()) {
return;
}
}
}
const shouldShowSpinner = !silent && (!hadCache || force);
if (shouldShowSpinner) {
setLoading(true);
}
try {
if (force || !stacksCachePromise) {
stacksCachePromise = axios.get("/api/stacks")
.then((res) => {
updateStacksCache(res.data);
return res.data;
})
.finally(() => {
stacksCachePromise = null;
});
}
const data = await stacksCachePromise;
setStacks(prev => mergeStackState(prev, data));
setError("");
} catch (err) {
console.error("❌ Fehler beim Abrufen der Stacks:", err);
if (!hadCache && !silent) {
setError("Fehler beim Laden der Stacks");
}
} finally {
if (shouldShowSpinner) {
setLoading(false);
}
}
}, [maintenanceActive, mergeStackState]);
useEffect(() => {
fetchStacks();
}, [fetchStacks]);
useEffect(() => {
if (typeof document === 'undefined' || maintenanceActive) return undefined;
const intervalId = setInterval(() => {
if (!document.hidden) {
fetchStacks({ force: true, silent: true });
}
}, STACKS_REFRESH_INTERVAL);
return () => clearInterval(intervalId);
}, [fetchStacks, maintenanceActive]);
useEffect(() => {
if (typeof document === 'undefined' || maintenanceActive) return undefined;
const handleVisibility = () => {
if (!document.hidden) {
fetchStacks({ force: true, silent: true });
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => {
document.removeEventListener('visibilitychange', handleVisibility);
};
}, [fetchStacks, maintenanceActive]);
useEffect(() => {
setSelectedStackIds(prev => {
const filtered = prev.filter(id => {
const match = stacks.find(stack => stack.Id === id);
if (!match) return false;
if (match.updateStatus === '✅') return false;
if (match.redeployDisabled) return false;
const phase = match.redeployPhase;
if (phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED) return false;
return true;
});
return filtered.length === prev.length ? prev : filtered;
});
}, [stacks]);
useEffect(() => {
stacksByIdRef.current = new Map(stacks.map((stack) => [stack.Id, stack]));
}, [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 (normalizedSearch) {
const identifier = `${stack.Name ?? ""} ${stack.Id ?? ""}`.toLowerCase();
if (!identifier.includes(normalizedSearch)) return false;
}
return true;
});
}, [stacks, statusFilter, normalizedSearch]);
const eligibleFilteredStacks = useMemo(
() => filteredStacks.filter((stack) => {
if (stack.updateStatus === '✅') return false;
if (stack.redeployDisabled) return false;
const phase = stack.redeployPhase;
if (phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED) return false;
return true;
}),
[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) => {
if (stack.updateStatus === '✅') return false;
if (stack.redeployDisabled) return false;
const phase = stack.redeployPhase;
if (phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED) return false;
return true;
}),
[paginatedStacks]
);
const selectionPreferenceRef = useRef({ action: 'keep', remember: false });
const previousFiltersRef = useRef({ status: statusFilter, search: normalizedSearch });
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 };
return;
}
const prev = previousFiltersRef.current;
const filtersChanged = prev.status !== statusFilter || prev.search !== normalized;
if (!filtersChanged) {
if (statusFilter === 'all' && normalized.length === 0 && selectionPromptVisible) {
setSelectionPromptVisible(false);
}
return;
}
previousFiltersRef.current = { status: statusFilter, search: normalized };
if (selectedStackIds.length === 0) {
setSelectionPromptVisible(false);
return;
}
const hasFilters = statusFilter !== 'all' || normalized.length > 0;
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;
useEffect(() => {
setPage(1);
}, [statusFilter, normalizedSearch]);
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 (maintenanceLocked || disabled) return;
setSelectedStackIds(prev =>
prev.includes(stackId)
? prev.filter(id => id !== stackId)
: [...prev, stackId]
);
};
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) => {
const snapshot = stacksByIdRef.current.get(stackId);
const phase = snapshot?.redeployPhase;
if (phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED) return;
setSelectedStackIds((prev) => prev.filter((id) => id !== stackId));
setStacks((prev) =>
prev.map((stack) =>
stack.Id === stackId
? {
...stack,
redeployPhase: REDEPLOY_PHASES.STARTED,
redeploying: true,
redeployQueued: false
}
: stack
)
);
try {
await axios.put(`/api/stacks/${stackId}/redeploy`);
// Statusupdates kommen über Socket.IO
} catch (err) {
console.error("❌ Fehler beim Redeploy:", err);
setStacks((prev) =>
prev.map((stack) =>
stack.Id === stackId
? {
...stack,
redeployPhase: null,
redeploying: false,
redeployQueued: false
}
: stack
)
);
if (!err.response) {
const current = stacksByIdRef.current.get(stackId);
const stackLabel = current?.Name ? `${current.Name} (ID: ${stackId})` : `Stack ${stackId}`;
const errorText = err.message || 'Unbekannter Fehler';
showToast({
variant: 'error',
title: 'Redeploy fehlgeschlagen',
description: `${stackLabel} ${errorText}`
});
}
}
};
const handleRedeployAll = async () => {
if (!eligiblePageStacks.length) return;
const targetIds = new Set(eligiblePageStacks.map((stack) => stack.Id));
setStacks(prev =>
prev.map(stack =>
targetIds.has(stack.Id)
? {
...stack,
redeployPhase: REDEPLOY_PHASES.QUEUED,
redeploying: true,
redeployQueued: true
}
: stack
)
);
try {
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 =>
targetIds.has(stack.Id)
? {
...stack,
redeployPhase: null,
redeploying: false,
redeployQueued: false
}
: stack
)
);
const errorText = err.response?.data?.error || err.message || 'Unbekannter Fehler';
showToast({
variant: 'error',
title: 'Redeploy ALL fehlgeschlagen',
description: errorText
});
}
};
const handleRedeploySelection = async () => {
if (!selectedStackIds.length) return;
const eligibleIds = selectedStackIds.filter((id) => {
const stack = stacks.find((entry) => entry.Id === id);
if (!stack) return false;
if (stack.updateStatus === '✅') return false;
if (stack.redeployDisabled) return false;
const phase = stack.redeployPhase;
if (phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED) return false;
return true;
});
if (!eligibleIds.length) {
setSelectedStackIds([]);
return;
}
const eligibleSet = new Set(eligibleIds);
setStacks(prev =>
prev.map(stack =>
eligibleSet.has(stack.Id)
? {
...stack,
redeployPhase: REDEPLOY_PHASES.QUEUED,
redeploying: true,
redeployQueued: true
}
: stack
)
);
try {
await axios.put("/api/stacks/redeploy-selection", { stackIds: eligibleIds });
setSelectedStackIds((prev) => prev.filter((id) => !eligibleSet.has(id)));
// Statusupdates kommen über Socket.IO
} catch (err) {
console.error("❌ Fehler beim Redeploy Auswahl:", err);
setStacks(prev =>
prev.map(stack =>
eligibleSet.has(stack.Id)
? {
...stack,
redeployPhase: null,
redeploying: false,
redeployQueued: false
}
: stack
)
);
const errorText = err.response?.data?.error || err.message || 'Unbekannter Fehler';
showToast({
variant: 'error',
title: 'Redeploy Auswahl fehlgeschlagen',
description: errorText
});
}
};
const hasSelection = selectedStackIds.length > 0;
const hasOutdatedStacks = eligiblePageStacks.length > 0;
const bulkButtonLabel = hasSelection
? `Redeploy Auswahl (${selectedStackIds.length})`
: 'Redeploy All';
const bulkActionDisabled = maintenanceLocked || (hasSelection
? selectionPromptVisible || selectedStackIds.length === 0 || selectedStackIds.every(id => {
const targetStack = stacks.find(stack => stack.Id === id);
if (!targetStack) return true;
if (targetStack.updateStatus === '✅') return true;
if (targetStack.redeployDisabled) return true;
const phase = targetStack.redeployPhase;
return phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED;
})
: !hasOutdatedStacks);
const handleBulkRedeploy = () => {
if (maintenanceLocked) {
showToast({
variant: 'warning',
title: 'Wartungsmodus aktiv',
description: 'Redeploy-Aktionen sind während des Wartungsmodus deaktiviert.'
});
return;
}
if (hasSelection) {
handleRedeploySelection();
} else {
handleRedeployAll();
}
};
if (loading) return <p className="text-gray-400">Lade Stacks...</p>;
if (error) return <p className="text-red-400">{error}</p>;
return (
<div className="mx-auto max-w-6xl p-6">
{maintenanceLocked && (
<div className="mb-6 rounded-lg border border-amber-500/60 bg-amber-900/30 px-4 py-3 text-sm text-amber-100">
{maintenanceBanner}
</div>
)}
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:gap-6">
<div>
<span className="mb-2 block text-sm font-medium text-gray-300">Status</span>
<div className="inline-flex overflow-hidden rounded-lg border border-gray-700 bg-gray-900">
<button
type="button"
onClick={() => setStatusFilter('all')}
className={`px-4 py-2 text-sm font-medium transition ${statusFilter === 'all' ? 'bg-purple-600 text-white' : 'text-gray-300 hover:text-white'} border-r border-gray-700 last:border-r-0`}
>
Alle
</button>
<button
type="button"
onClick={() => setStatusFilter('current')}
className={`px-4 py-2 text-sm font-medium transition ${statusFilter === 'current' ? 'bg-purple-600 text-white' : 'text-gray-300 hover:text-white'} border-r border-gray-700 last:border-r-0`}
>
Aktuell
</button>
<button
type="button"
onClick={() => setStatusFilter('outdated')}
className={`px-4 py-2 text-sm font-medium transition ${statusFilter === 'outdated' ? 'bg-purple-600 text-white' : 'text-gray-300 hover:text-white'}`}
>
Veraltet
</button>
</div>
</div>
<div className="md:w-64">
<label htmlFor="stack-search" className="mb-2 block text-sm font-medium text-gray-300">Suche</label>
<input
id="stack-search"
type="text"
value={searchQuery}
onChange={(event) => 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"
/>
</div>
</div>
<div className="flex flex-col items-end gap-2">
{selectionPreferenceStored && (
<button
type="button"
onClick={clearStoredSelectionPreference}
className="text-xs font-medium text-amber-300 underline underline-offset-2 transition hover:text-amber-100"
>
Gespeicherte Entscheidung löschen
</button>
)}
<button
onClick={handleBulkRedeploy}
disabled={bulkActionDisabled}
className={`px-5 py-2 rounded-lg font-medium transition ${bulkActionDisabled ? 'bg-purple-900 cursor-not-allowed text-gray-400' : 'bg-purple-500 hover:bg-purple-600'}`}
>
{bulkButtonLabel}
</button>
<label className="flex items-center gap-2 text-sm text-gray-300">
<span>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-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500"
>
{PER_PAGE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
</div>
{selectedStackIds.length > 0 && (
<div className="mb-4 flex flex-col gap-2 text-sm text-gray-300 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-gray-200">Auswahl:</span>
{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 (
<button
key={id}
type="button"
onClick={() => handleChipRemove(id)}
className="inline-flex items-center gap-2 rounded-full border border-purple-500/60 bg-purple-500/10 px-3 py-1 text-purple-100 transition hover:border-purple-400 hover:bg-purple-500/20"
>
<span>{name}</span>
{!isFiltered && (
<span className="text-xs uppercase tracking-wide text-amber-300">Ausgefiltert</span>
)}
{isFiltered && !isVisibleOnPage && (
<span className="text-xs uppercase tracking-wide text-blue-300">Andere Seite</span>
)}
<span className="text-xs font-semibold text-purple-200">x</span>
</button>
);
})}
</div>
<button
type="button"
onClick={clearSelection}
className="self-start text-xs font-medium text-gray-400 underline underline-offset-2 transition hover:text-gray-200 md:self-auto"
>
Auswahl aufheben
</button>
</div>
)}
{selectionPromptVisible && (
<div className="mb-6 flex flex-col gap-3 rounded-lg border border-amber-500/50 bg-amber-900/40 px-4 py-3 text-amber-100 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
<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 text-sm">
<input
type="checkbox"
checked={rememberSelectionChoice}
onChange={(event) => setRememberSelectionChoice(event.target.checked)}
className="h-4 w-4 rounded border border-amber-400 bg-amber-950 text-amber-400 focus:ring-amber-300"
/>
<span>Einstellung merken (bis Browser geschlossen wird)</span>
</label>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => applySelectionPreference('clear')}
className="rounded-md bg-amber-500 px-4 py-2 text-sm font-medium text-amber-950 transition hover:bg-amber-400"
>
Auswahl entfernen
</button>
<button
type="button"
onClick={() => applySelectionPreference('keep')}
className="rounded-md border border-amber-400 px-4 py-2 text-sm font-medium transition hover:bg-amber-400/20"
>
Auswahl behalten
</button>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{paginatedStacks.map(stack => {
const phase = stack.redeployPhase;
const isQueued = phase === REDEPLOY_PHASES.QUEUED;
const isRunning = phase === REDEPLOY_PHASES.STARTED;
const isBusy = isQueued || isRunning;
const isSelected = selectedStackIds.includes(stack.Id);
const isCurrent = stack.updateStatus === '✅';
const isSelfStack = Boolean(stack.redeployDisabled);
const isSelectable = !isBusy && !isCurrent && !isSelfStack && !maintenanceLocked;
return (
<div
key={stack.Id}
className={`flex justify-between items-center p-5 rounded-xl shadow-lg transition border
${isSelected ? 'border-purple-500 ring-1 ring-purple-500/40' : 'border-transparent'}
${isBusy ? 'bg-gray-700 cursor-wait' : 'bg-gray-800 hover:bg-gray-700'}
${!isSelectable && !isBusy ? 'opacity-75' : ''}`}
>
<div className="flex items-center space-x-4">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleStackSelection(stack.Id, !isSelectable)}
className={`h-5 w-5 text-purple-500 focus:ring-purple-400 border-gray-600 bg-gray-900 rounded ${!isSelectable ? 'opacity-40 cursor-not-allowed' : ''}`}
disabled={!isSelectable}
/>
<div className={`w-12 h-12 flex items-center justify-center rounded-full
${stack.updateStatus === '✅' ? 'bg-green-500' :
stack.updateStatus === '⚠️' ? 'bg-yellow-500' :
'bg-red-500'}`}
/>
<div>
<p className="text-lg font-semibold text-white">{stack.Name}</p>
<p className="text-sm text-gray-400">ID: {stack.Id}</p>
{stack.duplicateName && (
<p className="text-xs text-amber-300"> Doppelter Name erkannt</p>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1 text-sm">
{isRunning ? (
<>
<span className="text-xs uppercase tracking-wide text-orange-300">Redeploy</span>
<span className="text-orange-200">läuft</span>
</>
) : isQueued ? (
<>
<span className="text-xs uppercase tracking-wide text-orange-300">Redeploy</span>
<span className="text-orange-200">in Warteliste</span>
</>
) : isSelfStack ? (
<>
<span className="text-xs uppercase tracking-wide text-gray-400">System</span>
<span className="text-gray-300">Redeploy deaktiviert</span>
</>
) : isCurrent ? (
<>
<span className="text-xs uppercase tracking-wide text-gray-400">Status</span>
<span className="text-green-300">Aktuell</span>
</>
) : (
<button
onClick={() => handleRedeploy(stack.Id)}
disabled={isBusy || maintenanceLocked}
className="px-5 py-2 rounded-lg font-medium transition bg-blue-500 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
Redeploy
</button>
)}
</div>
</div>
);
})}
{filteredStacks.length === 0 && (
<p className="text-gray-400">
{hasActiveFilters ? 'Keine Stacks für die gesetzten Filter.' : 'Keine Stacks gefunden.'}
</p>
)}
</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 text-sm text-gray-300">
<span>
{totalItems === 0
? 'Keine Stacks verfügbar'
: perPage === 'all'
? `Zeige alle ${totalItems} Stacks`
: `Zeige ${pageStart}-${pageEnd} von ${totalItems} Stacks`}
</span>
{perPage !== 'all' && totalItems > 0 && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={handlePrevPage}
disabled={page <= 1}
className="rounded-md border border-gray-600 px-3 py-1 text-sm text-gray-200 transition hover:bg-gray-700 disabled:opacity-60"
>
Zurück
</button>
<span className="text-gray-400">
Seite {Math.min(page, totalPages)} / {totalPages}
</span>
<button
type="button"
onClick={handleNextPage}
disabled={page >= totalPages}
className="rounded-md border border-gray-600 px-3 py-1 text-sm text-gray-200 transition hover:bg-gray-700 disabled:opacity-60"
>
Weiter
</button>
</div>
)}
</div>
</div>
);
}