Merge feature/v03-stack_filters into dev
This commit is contained in:
@@ -17,7 +17,7 @@ export default function App() {
|
|||||||
<div className="max-w-6xl mx-auto px-6 py-6">
|
<div className="max-w-6xl mx-auto px-6 py-6">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-end gap-1">
|
||||||
<span className="text-xs text-gray-500">v0.2</span>
|
<span className="text-xs text-gray-500">v0.3 WIP</span>
|
||||||
<img src={logo} alt="StackPulse" className="h-10 w-auto" />
|
<img src={logo} alt="StackPulse" className="h-10 w-auto" />
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex gap-2 items-end">
|
<nav className="flex gap-2 items-end">
|
||||||
|
|||||||
+14
-13
@@ -547,22 +547,10 @@ export default function Logs() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="mx-auto max-w-6xl space-y-4 p-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h2 className="text-xl font-semibold text-gray-100">Redeploy-Logs</h2>
|
<h2 className="text-xl font-semibold text-gray-100">Redeploy-Logs</h2>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
|
||||||
<span>Einträge pro Seite</span>
|
|
||||||
<select
|
|
||||||
value={perPage}
|
|
||||||
onChange={handlePerPageChange}
|
|
||||||
className="rounded-md border border-gray-700 bg-gray-900/70 px-3 py-1.5 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
|
||||||
>
|
|
||||||
{PER_PAGE_OPTIONS.map(({ value, label }) => (
|
|
||||||
<option key={value} value={value}>{label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleExport('txt')}
|
onClick={() => handleExport('txt')}
|
||||||
disabled={actionLoading || loading}
|
disabled={actionLoading || loading}
|
||||||
@@ -830,6 +818,19 @@ export default function Logs() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-end 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(({ value, label }) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-gray-800/60 rounded-xl border border-gray-700">
|
<div className="overflow-x-auto bg-gray-800/60 rounded-xl border border-gray-700">
|
||||||
<table className="min-w-full divide-y divide-gray-700">
|
<table className="min-w-full divide-y divide-gray-700">
|
||||||
<thead className="bg-gray-800">
|
<thead className="bg-gray-800">
|
||||||
|
|||||||
+433
-15
@@ -1,12 +1,31 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { 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";
|
||||||
|
|
||||||
|
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() {
|
export default function Stacks() {
|
||||||
const [stacks, setStacks] = useState([]);
|
const [stacks, setStacks] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [selectedStackIds, setSelectedStackIds] = 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 mergeStackState = (previousStacks, incomingStacks) => {
|
const mergeStackState = (previousStacks, incomingStacks) => {
|
||||||
const prevMap = new Map(previousStacks.map((stack) => [stack.Id, stack]));
|
const prevMap = new Map(previousStacks.map((stack) => [stack.Id, stack]));
|
||||||
@@ -85,6 +104,148 @@ export default function Stacks() {
|
|||||||
});
|
});
|
||||||
}, [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 (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) => 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 });
|
||||||
|
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) => {
|
const toggleStackSelection = (stackId, disabled) => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
setSelectedStackIds(prev =>
|
setSelectedStackIds(prev =>
|
||||||
@@ -94,6 +255,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) => {
|
const handleRedeploy = async (stackId) => {
|
||||||
setSelectedStackIds((prev) => prev.filter((id) => id !== stackId));
|
setSelectedStackIds((prev) => prev.filter((id) => id !== stackId));
|
||||||
setStacks((prev) =>
|
setStacks((prev) =>
|
||||||
@@ -116,28 +354,31 @@ export default function Stacks() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRedeployAll = async () => {
|
const handleRedeployAll = async () => {
|
||||||
const outdatedStacks = stacks.filter((stack) => stack.updateStatus !== '✅' && !stack.redeployDisabled);
|
if (!eligiblePageStacks.length) return;
|
||||||
if (!outdatedStacks.length) return;
|
|
||||||
|
|
||||||
const outdatedIds = new Set(outdatedStacks.map((stack) => stack.Id));
|
const targetIds = new Set(eligiblePageStacks.map((stack) => stack.Id));
|
||||||
|
|
||||||
setStacks(prev =>
|
setStacks(prev =>
|
||||||
prev.map(stack =>
|
prev.map(stack =>
|
||||||
outdatedIds.has(stack.Id)
|
targetIds.has(stack.Id)
|
||||||
? { ...stack, redeploying: true }
|
? { ...stack, redeploying: true }
|
||||||
: stack
|
: stack
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (targetIds.size === eligibleFilteredStacks.length) {
|
||||||
await axios.put("/api/stacks/redeploy-all");
|
await axios.put("/api/stacks/redeploy-all");
|
||||||
setSelectedStackIds((prev) => prev.filter((id) => !outdatedIds.has(id)));
|
} 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
|
// Statusupdates kommen über Socket.IO
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("❌ Fehler beim Redeploy ALL:", err);
|
console.error("❌ Fehler beim Redeploy ALL:", err);
|
||||||
setStacks(prev =>
|
setStacks(prev =>
|
||||||
prev.map(stack =>
|
prev.map(stack =>
|
||||||
outdatedIds.has(stack.Id)
|
targetIds.has(stack.Id)
|
||||||
? { ...stack, redeploying: false }
|
? { ...stack, redeploying: false }
|
||||||
: stack
|
: stack
|
||||||
)
|
)
|
||||||
@@ -185,17 +426,17 @@ export default function Stacks() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasSelection = selectedStackIds.length > 0;
|
const hasSelection = selectedStackIds.length > 0;
|
||||||
const hasOutdatedStacks = stacks.some((stack) => stack.updateStatus !== '✅' && !stack.redeployDisabled);
|
const hasOutdatedStacks = eligiblePageStacks.length > 0;
|
||||||
const bulkButtonLabel = hasSelection
|
const bulkButtonLabel = hasSelection
|
||||||
? `Redeploy Auswahl (${selectedStackIds.length})`
|
? `Redeploy Auswahl (${selectedStackIds.length})`
|
||||||
: 'Redeploy All';
|
: 'Redeploy All';
|
||||||
|
|
||||||
const bulkActionDisabled = hasSelection
|
const bulkActionDisabled = hasSelection
|
||||||
? selectedStackIds.length === 0 || selectedStackIds.every(id => {
|
? selectionPromptVisible || selectedStackIds.length === 0 || selectedStackIds.every(id => {
|
||||||
const targetStack = stacks.find(stack => stack.Id === id);
|
const targetStack = stacks.find(stack => stack.Id === id);
|
||||||
return !targetStack || targetStack.redeploying || targetStack.updateStatus === '✅' || targetStack.redeployDisabled;
|
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 = () => {
|
const handleBulkRedeploy = () => {
|
||||||
if (hasSelection) {
|
if (hasSelection) {
|
||||||
@@ -209,8 +450,57 @@ export default function Stacks() {
|
|||||||
if (error) return <p className="text-red-400">{error}</p>;
|
if (error) return <p className="text-red-400">{error}</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="mx-auto max-w-6xl p-6">
|
||||||
<div className="flex justify-end mb-4">
|
<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
|
<button
|
||||||
onClick={handleBulkRedeploy}
|
onClick={handleBulkRedeploy}
|
||||||
disabled={bulkActionDisabled}
|
disabled={bulkActionDisabled}
|
||||||
@@ -218,10 +508,101 @@ export default function Stacks() {
|
|||||||
>
|
>
|
||||||
{bulkButtonLabel}
|
{bulkButtonLabel}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
{selectedStackIds.length > 0 && (
|
||||||
{stacks.map(stack => {
|
<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 isRedeploying = stack.redeploying;
|
const isRedeploying = stack.redeploying;
|
||||||
const isSelected = selectedStackIds.includes(stack.Id);
|
const isSelected = selectedStackIds.includes(stack.Id);
|
||||||
const isCurrent = stack.updateStatus === '✅';
|
const isCurrent = stack.updateStatus === '✅';
|
||||||
@@ -289,7 +670,44 @@ export default function Stacks() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{stacks.length === 0 && <p className="text-gray-400">Keine Stacks gefunden.</p>}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user