Logging fixes, redeploy selection, Logo

This commit is contained in:
root
2025-09-30 20:58:11 +00:00
parent f14c3aa6dd
commit d99ffc1488
10 changed files with 428 additions and 97 deletions
+6 -5
View File
@@ -2,6 +2,7 @@ import React from "react";
import { NavLink, Route, Routes } from "react-router-dom";
import Stacks from "./Stacks.jsx";
import Logs from "./Logs.jsx";
import logo from "./assets/images/stackpulse.png";
const navLinkBase =
"px-4 py-2 rounded-md font-medium transition-colors duration-150";
@@ -14,12 +15,12 @@ export default function App() {
<div className="min-h-screen bg-gray-900 text-white">
<header className="bg-gray-800 shadow-md">
<div className="max-w-6xl mx-auto px-6 py-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white">StackPulse</h1>
<p className="text-gray-400 mt-1">Verwalte deine Docker Stacks</p>
<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">
<span className="text-xs text-gray-500">v0.2</span>
<img src={logo} alt="StackPulse" className="h-10 w-auto" />
</div>
<nav className="flex gap-2">
<nav className="flex gap-2 items-end">
<NavLink to="/" end className={getNavClass}>
Stacks
</NavLink>
+165 -65
View File
@@ -40,10 +40,20 @@ const PER_PAGE_OPTIONS = [
];
const VALID_PER_PAGE_VALUES = new Set(PER_PAGE_OPTIONS.map((option) => option.value));
const REDEPLOY_TYPE_LABELS = {
Einzeln: "Einzeln",
Alle: "Alle",
Auswahl: "Auswahl",
single: "Einzeln",
all: "Alle",
selection: "Auswahl"
};
const hasActiveFilters = (filters) => Boolean(
(filters.stacks && filters.stacks.length) ||
(filters.statuses && filters.statuses.length) ||
(filters.endpoints && filters.endpoints.length) ||
(filters.redeployTypes && filters.redeployTypes.length) ||
(filters.message && filters.message.trim()) ||
(filters.from && filters.from.trim()) ||
(filters.to && filters.to.trim())
@@ -59,16 +69,19 @@ export default function Logs() {
const [stackOptions, setStackOptions] = useState([]);
const [statusOptions, setStatusOptions] = useState([]);
const [endpointOptions, setEndpointOptions] = useState([]);
const [redeployTypeOptions, setRedeployTypeOptions] = useState([]);
const [selectedStacks, setSelectedStacks] = useState([]);
const [selectedStatuses, setSelectedStatuses] = useState([]);
const [selectedEndpoints, setSelectedEndpoints] = useState([]);
const [selectedRedeployTypes, setSelectedRedeployTypes] = useState([]);
const [messageQuery, setMessageQuery] = useState("");
const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState("");
const [filtersOpen, setFiltersOpen] = useState(false);
const [filtersReady, setFiltersReady] = useState(false);
const [optionsInitialized, setOptionsInitialized] = useState(false);
const [refreshSignal, setRefreshSignal] = useState(0);
const [perPage, setPerPage] = useState(PER_PAGE_DEFAULT);
@@ -77,36 +90,42 @@ export default function Logs() {
const updateFilterOptions = useCallback((payload) => {
const logsPayload = Array.isArray(payload) ? payload : payload?.items ?? [];
setStackOptions((prev) => {
const map = new Map(prev.map((entry) => [entry.value, entry.label]));
logsPayload.forEach((log) => {
if (!log.stackId) return;
const stackMap = new Map();
const statusSet = new Set();
const endpointSet = new Set();
const redeployTypeSet = new Set();
logsPayload.forEach((log) => {
if (log.stackId) {
const value = String(log.stackId);
if (!map.has(value)) {
map.set(value, log.stackName || `Stack ${value}`);
}
});
return Array.from(map.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label));
const label = log.stackName || `Stack ${value}`;
stackMap.set(value, label);
}
if (log.status) {
statusSet.add(log.status);
}
if (log.endpoint !== null && log.endpoint !== undefined && log.endpoint !== "") {
endpointSet.add(String(log.endpoint));
}
if (log.redeployType) {
redeployTypeSet.add(log.redeployType);
}
});
setStatusOptions((prev) => {
const next = new Set(prev);
logsPayload.forEach((log) => {
if (log.status) next.add(log.status);
});
return Array.from(next).sort();
});
setStackOptions(Array.from(stackMap.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label)));
setEndpointOptions((prev) => {
const next = new Set(prev);
logsPayload.forEach((log) => {
if (log.endpoint === null || log.endpoint === undefined || log.endpoint === "") return;
next.add(String(log.endpoint));
});
return Array.from(next).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
});
setStatusOptions(Array.from(statusSet).sort());
setEndpointOptions(Array.from(endpointSet)
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })));
setRedeployTypeOptions(Array.from(redeployTypeSet).sort());
setOptionsInitialized(true);
}, []);
const stackLabelMap = useMemo(() => {
@@ -117,6 +136,38 @@ export default function Logs() {
return map;
}, [stackOptions]);
useEffect(() => {
if (!optionsInitialized) return;
setSelectedStacks((prev) => {
const valid = prev.filter((value) => stackOptions.some((option) => option.value === value));
return valid.length === prev.length ? prev : valid;
});
}, [optionsInitialized, stackOptions]);
useEffect(() => {
if (!optionsInitialized) return;
setSelectedStatuses((prev) => {
const valid = prev.filter((value) => statusOptions.includes(value));
return valid.length === prev.length ? prev : valid;
});
}, [optionsInitialized, statusOptions]);
useEffect(() => {
if (!optionsInitialized) return;
setSelectedEndpoints((prev) => {
const valid = prev.filter((value) => endpointOptions.includes(value));
return valid.length === prev.length ? prev : valid;
});
}, [optionsInitialized, endpointOptions]);
useEffect(() => {
if (!optionsInitialized) return;
setSelectedRedeployTypes((prev) => {
const valid = prev.filter((value) => redeployTypeOptions.includes(value));
return valid.length === prev.length ? prev : valid;
});
}, [optionsInitialized, redeployTypeOptions]);
useEffect(() => {
if (typeof window === "undefined") {
setFiltersReady(true);
@@ -128,29 +179,17 @@ export default function Logs() {
if (storedValue) {
const parsed = JSON.parse(storedValue);
const storedFilters = parsed?.filters ?? parsed ?? {};
const storedOptions = parsed?.options ?? {};
const storedPagination = parsed?.pagination ?? {};
setSelectedStacks(storedFilters.stacks || []);
setSelectedStatuses(storedFilters.statuses || []);
setSelectedEndpoints(storedFilters.endpoints || []);
setSelectedRedeployTypes(storedFilters.redeployTypes || []);
setMessageQuery(storedFilters.message || "");
setFromDate(storedFilters.from || "");
setToDate(storedFilters.to || "");
setFiltersOpen(hasActiveFilters(storedFilters));
if (Array.isArray(storedOptions.stacks) && storedOptions.stacks.length) {
setStackOptions(storedOptions.stacks);
}
if (Array.isArray(storedOptions.statuses) && storedOptions.statuses.length) {
setStatusOptions(storedOptions.statuses);
}
if (Array.isArray(storedOptions.endpoints) && storedOptions.endpoints.length) {
setEndpointOptions(storedOptions.endpoints);
}
const rawPerPage = storedPagination.perPage;
if (rawPerPage !== undefined) {
const parsedPerPage = String(rawPerPage);
@@ -186,6 +225,10 @@ export default function Logs() {
params.endpoints = selectedEndpoints.join(",");
}
if (selectedRedeployTypes.length) {
params.redeployTypes = selectedRedeployTypes.join(",");
}
if (messageQuery.trim()) {
params.message = messageQuery.trim();
}
@@ -201,7 +244,7 @@ export default function Logs() {
}
return params;
}, [selectedStacks, selectedStatuses, selectedEndpoints, messageQuery, fromDate, toDate]);
}, [selectedStacks, selectedStatuses, selectedEndpoints, selectedRedeployTypes, messageQuery, fromDate, toDate]);
useEffect(() => {
if (!filtersReady) return;
@@ -226,10 +269,11 @@ export default function Logs() {
stacks: selectedStacks,
statuses: selectedStatuses,
endpoints: selectedEndpoints,
redeployTypes: selectedRedeployTypes,
message: messageQuery,
from: fromDate,
to: toDate
}), [selectedStacks, selectedStatuses, selectedEndpoints, messageQuery, fromDate, toDate]);
}), [selectedStacks, selectedStatuses, selectedEndpoints, selectedRedeployTypes, messageQuery, fromDate, toDate]);
useEffect(() => {
if (!filtersReady) return;
@@ -288,25 +332,20 @@ export default function Logs() {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
FILTER_STORAGE_KEY,
JSON.stringify({
filters: currentFilters,
options: {
stacks: stackOptions,
statuses: statusOptions,
endpoints: endpointOptions,
},
pagination: {
perPage,
page
}
})
);
} catch (storageError) {
console.error("⚠️ Konnte Filter nicht speichern:", storageError);
}
}, [filtersReady, currentFilters, stackOptions, statusOptions, endpointOptions, perPage, page]);
window.localStorage.setItem(
FILTER_STORAGE_KEY,
JSON.stringify({
filters: currentFilters,
pagination: {
perPage,
page
}
})
);
} catch (storageError) {
console.error("⚠️ Konnte Filter nicht speichern:", storageError);
}
}, [filtersReady, currentFilters, perPage, page]);
const handleMultiSelectChange = (setter) => (event) => {
const values = Array.from(event.target.selectedOptions).map((option) => option.value);
@@ -322,6 +361,7 @@ export default function Logs() {
setSelectedStacks([]);
setSelectedStatuses([]);
setSelectedEndpoints([]);
setSelectedRedeployTypes([]);
setMessageQuery("");
setFromDate("");
setToDate("");
@@ -380,11 +420,22 @@ export default function Logs() {
];
}, [endpointOptions]);
const redeployTypeSelectOptions = useMemo(() => {
const entries = redeployTypeOptions
.filter((type) => type !== ALL_OPTION_VALUE)
.map((type) => ({ value: type, label: REDEPLOY_TYPE_LABELS[type] ?? type }));
return [
{ value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
...entries
];
}, [redeployTypeOptions]);
const activeFilterCount = useMemo(() => {
let count = 0;
if (selectedStacks.length) count += selectedStacks.length;
if (selectedStatuses.length) count += selectedStatuses.length;
if (selectedEndpoints.length) count += selectedEndpoints.length;
if (selectedRedeployTypes.length) count += selectedRedeployTypes.length;
if (messageQuery.trim()) count += 1;
if (fromDate) count += 1;
if (toDate) count += 1;
@@ -549,7 +600,7 @@ export default function Logs() {
{filtersOpen && (
<div className="space-y-4 border-t border-gray-700 px-4 py-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Stack</label>
<select
@@ -664,7 +715,45 @@ export default function Logs() {
</div>
</div>
<div className="md:col-span-2 lg:col-span-3">
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Redeploy-Typ</label>
<select
multiple
value={selectedRedeployTypes}
onChange={handleMultiSelectChange(setSelectedRedeployTypes)}
className="w-full min-h-[8rem] rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
>
{redeployTypeSelectOptions.map(({ value, label }) => (
<option
key={value}
value={value}
className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
>
{label}
</option>
))}
</select>
<div className="mt-2 min-h-[1.5rem] text-xs text-gray-400">
{selectedRedeployTypes.length === 0 ? (
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
Alle Typen
</span>
) : (
<div className="flex flex-wrap gap-2">
{selectedRedeployTypes.map((type) => (
<span
key={type}
className="rounded-full bg-teal-500/20 px-2 py-0.5 text-teal-200"
>
{REDEPLOY_TYPE_LABELS[type] ?? type}
</span>
))}
</div>
)}
</div>
</div>
<div className="md:col-span-2 lg:col-span-4">
<label className="mb-2 block text-sm font-medium text-gray-300">Nachricht (Freitext)</label>
<input
type="text"
@@ -723,6 +812,7 @@ export default function Logs() {
<tr className="text-left text-sm uppercase tracking-wide text-gray-400">
<th className="px-4 py-3">Zeitpunkt</th>
<th className="px-4 py-3">Stack</th>
<th className="px-4 py-3">Art</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Nachricht</th>
<th className="px-4 py-3">Endpoint</th>
@@ -732,13 +822,18 @@ export default function Logs() {
<tbody className="divide-y divide-gray-700 text-sm">
{logs.length === 0 && !loading && (
<tr>
<td colSpan="6" className="px-4 py-6 text-center text-gray-400">
<td colSpan="7" className="px-4 py-6 text-center text-gray-400">
Keine Logs vorhanden.
</td>
</tr>
)}
{logs.map((log) => {
const statusClass = STATUS_COLORS[log.status] || "text-blue-300";
const stackDisplayName = log.stackName || "Unbekannt";
const showStackId = stackDisplayName !== '---' && log.stackId !== undefined && log.stackId !== null;
const redeployTypeLabel = log.redeployType
? (REDEPLOY_TYPE_LABELS[log.redeployType] ?? log.redeployType)
: '---';
return (
<tr key={log.id} className="hover:bg-gray-700/40">
<td className="px-4 py-3 whitespace-nowrap text-gray-300">
@@ -746,10 +841,15 @@ export default function Logs() {
</td>
<td className="px-4 py-3 text-gray-200">
<div className="flex flex-col">
<span className="font-medium">{log.stackName || "Unbekannt"}</span>
<span className="text-xs text-gray-400">ID: {log.stackId}</span>
<span className="font-medium">{stackDisplayName}</span>
{showStackId && (
<span className="text-xs text-gray-400">ID: {log.stackId}</span>
)}
</div>
</td>
<td className="px-4 py-3 text-gray-300">
{redeployTypeLabel}
</td>
<td className={`px-4 py-3 font-semibold ${statusClass}`}>
{log.status}
</td>
+86 -9
View File
@@ -6,12 +6,13 @@ export default function Stacks() {
const [stacks, setStacks] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [selectedStackIds, setSelectedStackIds] = useState([]);
useEffect(() => {
const socket = io("/", {
path: "/socket.io",
transports: ["websocket"]
});
const socket = io("/", {
path: "/socket.io",
transports: ["websocket"]
});
console.log("🔌 Socket connected");
socket.on("redeployStatus", async ({ stackId, status }) => {
@@ -42,7 +43,8 @@ const socket = io("/", {
setLoading(true);
try {
const res = await axios.get("/api/stacks");
setStacks(res.data.map(stack => ({ ...stack, redeploying: stack.redeploying || false })));
const sortedStacks = [...res.data].sort((a, b) => a.Name.localeCompare(b.Name));
setStacks(sortedStacks.map(stack => ({ ...stack, redeploying: stack.redeploying || false })));
} catch (err) {
console.error("❌ Fehler beim Abrufen der Stacks:", err);
setError("Fehler beim Laden der Stacks");
@@ -55,7 +57,24 @@ const socket = io("/", {
fetchStacks();
}, []);
useEffect(() => {
setSelectedStackIds(prev => {
const filtered = prev.filter(id => stacks.some(stack => stack.Id === id));
return filtered.length === prev.length ? prev : filtered;
});
}, [stacks]);
const toggleStackSelection = (stackId, disabled) => {
if (disabled) return;
setSelectedStackIds(prev =>
prev.includes(stackId)
? prev.filter(id => id !== stackId)
: [...prev, stackId]
);
};
const handleRedeploy = async (stackId) => {
setSelectedStackIds(prev => prev.filter(id => id !== stackId));
setStacks(prev =>
prev.map(stack => stack.Id === stackId ? { ...stack, redeploying: true } : stack)
);
@@ -76,6 +95,7 @@ const socket = io("/", {
try {
await axios.put("/api/stacks/redeploy-all");
setSelectedStackIds([]);
// Statusupdates kommen über Socket.IO
} catch (err) {
console.error("❌ Fehler beim Redeploy ALL:", err);
@@ -83,6 +103,53 @@ const socket = io("/", {
}
};
const handleRedeploySelection = async () => {
if (!selectedStackIds.length) return;
setStacks(prev =>
prev.map(stack =>
selectedStackIds.includes(stack.Id)
? { ...stack, redeploying: true }
: stack
)
);
try {
await axios.put("/api/stacks/redeploy-selection", { stackIds: selectedStackIds });
setSelectedStackIds([]);
// Statusupdates kommen über Socket.IO
} catch (err) {
console.error("❌ Fehler beim Redeploy Auswahl:", err);
setStacks(prev =>
prev.map(stack =>
selectedStackIds.includes(stack.Id)
? { ...stack, redeploying: false }
: stack
)
);
}
};
const hasSelection = selectedStackIds.length > 0;
const bulkButtonLabel = hasSelection
? `Redeploy Auswahl (${selectedStackIds.length})`
: 'Redeploy All';
const bulkActionDisabled = hasSelection
? selectedStackIds.every(id => {
const targetStack = stacks.find(stack => stack.Id === id);
return targetStack?.redeploying;
})
: stacks.every(stack => stack.redeploying);
const handleBulkRedeploy = () => {
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>;
@@ -90,24 +157,34 @@ const socket = io("/", {
<div className="p-6">
<div className="flex justify-end mb-4">
<button
onClick={handleRedeployAll}
className="px-5 py-2 rounded-lg font-medium transition bg-purple-500 hover:bg-purple-600"
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'}`}
>
Redeploy All
{bulkButtonLabel}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{stacks.map(stack => {
const isRedeploying = stack.redeploying;
const isSelected = selectedStackIds.includes(stack.Id);
return (
<div
key={stack.Id}
className={`flex justify-between items-center p-5 rounded-xl shadow-lg transition
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'}
${isRedeploying ? "bg-gray-700 cursor-not-allowed" : "bg-gray-800 hover:bg-gray-700"}`}
>
<div className="flex items-center space-x-4">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleStackSelection(stack.Id, isRedeploying)}
className="h-5 w-5 text-purple-500 focus:ring-purple-400 border-gray-600 bg-gray-900 rounded"
disabled={isRedeploying}
/>
<div className={`w-12 h-12 flex items-center justify-center rounded-full
${stack.updateStatus === "✅" ? "bg-green-500" :
stack.updateStatus === "⚠️" ? "bg-yellow-500" :
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB