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.