diff --git a/backend/index.js b/backend/index.js
index 7d2f30d..ef4d3b1 100644
--- a/backend/index.js
+++ b/backend/index.js
@@ -95,28 +95,67 @@ app.get('/api/stacks', async (req, res) => {
// Redeploy-Logs abrufen
app.get('/api/logs', (req, res) => {
- const { stackId, status, from, to } = req.query;
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 500);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
+ const valueToArray = (value) => {
+ if (!value && value !== 0) return [];
+ const base = Array.isArray(value) ? value : [value];
+ return base
+ .flatMap((entry) => String(entry).split(','))
+ .map((entry) => entry.trim())
+ .filter(Boolean);
+ };
+
+ const singleValue = (value) => {
+ if (value === undefined || value === null) return undefined;
+ return Array.isArray(value) ? value[0] : value;
+ };
+
const filters = [];
const params = { limit, offset };
- if (stackId) {
- filters.push('stack_id = @stackId');
- params.stackId = stackId;
+ const stackIds = valueToArray(req.query.stackIds ?? req.query.stackId);
+ if (stackIds.length) {
+ const placeholders = stackIds.map((_, idx) => `@stackId${idx}`);
+ filters.push(`stack_id IN (${placeholders.join(', ')})`);
+ stackIds.forEach((stack, idx) => {
+ params[`stackId${idx}`] = stack;
+ });
}
- if (status) {
- filters.push('status = @status');
- params.status = status;
+ const statuses = valueToArray(req.query.statuses ?? req.query.status);
+ if (statuses.length) {
+ const placeholders = statuses.map((_, idx) => `@status${idx}`);
+ filters.push(`status IN (${placeholders.join(', ')})`);
+ statuses.forEach((entry, idx) => {
+ params[`status${idx}`] = entry;
+ });
}
+ const endpoints = valueToArray(req.query.endpoints ?? req.query.endpoint);
+ if (endpoints.length) {
+ const placeholders = endpoints.map((_, idx) => `@endpoint${idx}`);
+ filters.push(`endpoint IN (${placeholders.join(', ')})`);
+ endpoints.forEach((entry, idx) => {
+ const numeric = Number(entry);
+ params[`endpoint${idx}`] = Number.isNaN(numeric) ? entry : numeric;
+ });
+ }
+
+ const messageQuery = singleValue(req.query.message);
+ if (messageQuery && String(messageQuery).trim()) {
+ filters.push('message LIKE @message');
+ params.message = `%${String(messageQuery).trim()}%`;
+ }
+
+ const from = singleValue(req.query.from);
if (from) {
filters.push('timestamp >= @from');
params.from = from;
}
+ const to = singleValue(req.query.to);
if (to) {
filters.push('timestamp <= @to');
params.to = to;
diff --git a/frontend/src/Logs.jsx b/frontend/src/Logs.jsx
index b1d2df8..5cdd203 100644
--- a/frontend/src/Logs.jsx
+++ b/frontend/src/Logs.jsx
@@ -1,10 +1,11 @@
-import React, { useEffect, useState } from "react";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
import axios from "axios";
const STATUS_COLORS = {
success: "text-green-400",
warning: "text-yellow-400",
- error: "text-red-400"
+ error: "text-red-400",
+ started: "text-blue-300"
};
const formatTimestamp = (value) => {
@@ -21,53 +22,493 @@ const formatTimestamp = (value) => {
});
};
+const normalizeDateParam = (value) => {
+ if (!value) return undefined;
+ return value.replace("T", " ");
+};
+
+const FILTER_STORAGE_KEY = "redeployLogFilters";
+const ALL_OPTION_VALUE = "__all__";
+const ALL_OPTION_LABEL = "- Alle -";
+
+const hasActiveFilters = (filters) => Boolean(
+ (filters.stacks && filters.stacks.length) ||
+ (filters.statuses && filters.statuses.length) ||
+ (filters.endpoints && filters.endpoints.length) ||
+ (filters.message && filters.message.trim()) ||
+ (filters.from && filters.from.trim()) ||
+ (filters.to && filters.to.trim())
+);
+
export default function Logs() {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
- const fetchLogs = async () => {
- setLoading(true);
- setError("");
- try {
- const response = await axios.get("/api/logs", {
- params: { limit: 200 }
- });
- setLogs(response.data);
- } catch (err) {
- console.error("❌ Fehler beim Laden der Logs:", err);
- setError("Fehler beim Laden der Redeploy-Logs");
- } finally {
- setLoading(false);
- }
- };
+ const [stackOptions, setStackOptions] = useState([]);
+ const [statusOptions, setStatusOptions] = useState([]);
+ const [endpointOptions, setEndpointOptions] = useState([]);
- useEffect(() => {
- fetchLogs();
+ const [selectedStacks, setSelectedStacks] = useState([]);
+ const [selectedStatuses, setSelectedStatuses] = useState([]);
+ const [selectedEndpoints, setSelectedEndpoints] = useState([]);
+ const [messageQuery, setMessageQuery] = useState("");
+ const [fromDate, setFromDate] = useState("");
+ const [toDate, setToDate] = useState("");
+
+ const [filtersOpen, setFiltersOpen] = useState(false);
+ const [filtersReady, setFiltersReady] = useState(false);
+ const [refreshSignal, setRefreshSignal] = useState(0);
+
+ const updateFilterOptions = useCallback((logsPayload) => {
+ setStackOptions((prev) => {
+ const map = new Map(prev.map((entry) => [entry.value, entry.label]));
+ logsPayload.forEach((log) => {
+ if (!log.stackId) return;
+ 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));
+ });
+
+ setStatusOptions((prev) => {
+ const next = new Set(prev);
+ logsPayload.forEach((log) => {
+ if (log.status) next.add(log.status);
+ });
+ return Array.from(next).sort();
+ });
+
+ 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 }));
+ });
}, []);
- if (loading) {
- return
Lade Logs...
;
- }
+ useEffect(() => {
+ if (typeof window === "undefined") {
+ setFiltersReady(true);
+ return;
+ }
- if (error) {
- return (
-
- {error}
-
- );
- }
+ try {
+ const storedValue = window.localStorage.getItem(FILTER_STORAGE_KEY);
+ if (storedValue) {
+ const parsed = JSON.parse(storedValue);
+ const storedFilters = parsed?.filters ?? parsed ?? {};
+ const storedOptions = parsed?.options ?? {};
+
+ setSelectedStacks(storedFilters.stacks || []);
+ setSelectedStatuses(storedFilters.statuses || []);
+ setSelectedEndpoints(storedFilters.endpoints || []);
+ 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);
+ }
+ }
+ } catch (storageError) {
+ console.error("❌ Fehler beim Laden der gespeicherten Log-Filter:", storageError);
+ } finally {
+ setFiltersReady(true);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!filtersReady) return;
+
+ let cancelled = false;
+ axios.get("/api/logs", { params: { limit: 500 } })
+ .then((response) => {
+ if (cancelled) return;
+ updateFilterOptions(response.data);
+ })
+ .catch((err) => {
+ if (cancelled) return;
+ console.error("⚠️ Konnte Filteroptionen nicht aktualisieren:", err);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [filtersReady, updateFilterOptions, refreshSignal]);
+
+ const currentFilters = useMemo(() => ({
+ stacks: selectedStacks,
+ statuses: selectedStatuses,
+ endpoints: selectedEndpoints,
+ message: messageQuery,
+ from: fromDate,
+ to: toDate
+ }), [selectedStacks, selectedStatuses, selectedEndpoints, messageQuery, fromDate, toDate]);
+
+ const stackLabelMap = useMemo(() => {
+ const map = new Map();
+ stackOptions.forEach((option) => {
+ map.set(option.value, option.label);
+ });
+ return map;
+ }, [stackOptions]);
+
+ const stackSelectOptions = useMemo(() => {
+ const entries = stackOptions.filter((option) => option.value !== ALL_OPTION_VALUE);
+ return [
+ { value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
+ ...entries
+ ];
+ }, [stackOptions]);
+
+ const statusSelectOptions = useMemo(() => {
+ const entries = statusOptions
+ .filter((status) => status !== ALL_OPTION_VALUE)
+ .map((status) => ({ value: status, label: status }));
+ return [
+ { value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
+ ...entries
+ ];
+ }, [statusOptions]);
+
+ const endpointSelectOptions = useMemo(() => {
+ const entries = endpointOptions
+ .filter((endpoint) => endpoint !== ALL_OPTION_VALUE)
+ .map((endpoint) => ({ value: endpoint, label: endpoint }));
+ return [
+ { value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
+ ...entries
+ ];
+ }, [endpointOptions]);
+
+ useEffect(() => {
+ if (!filtersReady) return;
+
+ const params = { limit: 200 };
+
+ if (currentFilters.stacks.length) {
+ params.stackIds = currentFilters.stacks.join(",");
+ }
+
+ if (currentFilters.statuses.length) {
+ params.statuses = currentFilters.statuses.join(",");
+ }
+
+ if (currentFilters.endpoints.length) {
+ params.endpoints = currentFilters.endpoints.join(",");
+ }
+
+ if (currentFilters.message.trim()) {
+ params.message = currentFilters.message.trim();
+ }
+
+ const fromParam = normalizeDateParam(currentFilters.from);
+ if (fromParam) {
+ params.from = fromParam;
+ }
+
+ const toParam = normalizeDateParam(currentFilters.to);
+ if (toParam) {
+ params.to = toParam;
+ }
+
+ let cancelled = false;
+ setLoading(true);
+ setError("");
+
+ axios.get("/api/logs", { params })
+ .then((response) => {
+ if (cancelled) return;
+ setLogs(response.data);
+ updateFilterOptions(response.data);
+ })
+ .catch((err) => {
+ if (cancelled) return;
+ console.error("❌ Fehler beim Laden der Logs:", err);
+ setError("Fehler beim Laden der Redeploy-Logs");
+ })
+ .finally(() => {
+ if (cancelled) return;
+ setLoading(false);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [filtersReady, currentFilters, updateFilterOptions, refreshSignal]);
+
+ useEffect(() => {
+ if (!filtersReady) return;
+ if (typeof window === "undefined") return;
+
+ try {
+ window.localStorage.setItem(
+ FILTER_STORAGE_KEY,
+ JSON.stringify({
+ filters: currentFilters,
+ options: {
+ stacks: stackOptions,
+ statuses: statusOptions,
+ endpoints: endpointOptions,
+ }
+ })
+ );
+ } catch (storageError) {
+ console.error("⚠️ Konnte Filter nicht speichern:", storageError);
+ }
+ }, [filtersReady, currentFilters, stackOptions, statusOptions, endpointOptions]);
+
+ const handleMultiSelectChange = (setter) => (event) => {
+ const values = Array.from(event.target.selectedOptions).map((option) => option.value);
+ if (values.includes(ALL_OPTION_VALUE)) {
+ setter([]);
+ return;
+ }
+ setter(values);
+ };
+
+ const handleResetFilters = () => {
+ setSelectedStacks([]);
+ setSelectedStatuses([]);
+ setSelectedEndpoints([]);
+ setMessageQuery("");
+ setFromDate("");
+ setToDate("");
+ setFiltersOpen(false);
+ };
+
+ const handleToggleFilters = () => {
+ setFiltersOpen((prev) => !prev);
+ };
+
+ const handleRefresh = () => {
+ setRefreshSignal((prev) => prev + 1);
+ };
+
+ 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 (messageQuery.trim()) count += 1;
+ if (fromDate) count += 1;
+ if (toDate) count += 1;
+ return count;
+ }, [selectedStacks, selectedStatuses, selectedEndpoints, messageQuery, fromDate, toDate]);
return (
-
-
Redeploy-Logs
+
+
Redeploy-Logs
+
+ {loading && Aktualisiere…}
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ {filtersOpen && (
+
+
+
+
+
+
+ {selectedStacks.length === 0 ? (
+
+ Alle Stacks
+
+ ) : (
+
+ {selectedStacks.map((stackId) => (
+
+ {stackLabelMap.get(stackId) ?? `Stack ${stackId}`}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ {selectedStatuses.length === 0 ? (
+
+ Alle Status
+
+ ) : (
+
+ {selectedStatuses.map((status) => (
+
+ {status}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ {selectedEndpoints.length === 0 ? (
+
+ Alle Endpoints
+
+ ) : (
+
+ {selectedEndpoints.map((endpoint) => (
+
+ {endpoint}
+
+ ))}
+
+ )}
+
+
+
+
+
+ setMessageQuery(event.target.value)}
+ placeholder="Textsuche in Log-Nachrichten..."
+ className="w-full 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"
+ />
+
+
+
+
+ setFromDate(event.target.value)}
+ className="w-full 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"
+ />
+
+
+
+
+ setToDate(event.target.value)}
+ className="w-full 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"
+ />
+
+
+
+
+
+
+
+ )}
@@ -82,7 +523,7 @@ export default function Logs() {
- {logs.length === 0 && (
+ {logs.length === 0 && !loading && (
|
Keine Logs vorhanden.
|