diff --git a/.dockerignore b/.dockerignore
index ec0693f..059a443 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -10,4 +10,5 @@ frontend/build
frontend/dist
backend/.env
backend/node_modules
-frontend/node_modules
\ No newline at end of file
+frontend/node_modules
+backend/data/*.db*
diff --git a/Dockerfile b/Dockerfile
index a0bef04..64f613d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -31,10 +31,17 @@ RUN npm ci --only=production
# Backend-Code kopieren
COPY backend/ ./
+COPY backend/docker-entrypoint.sh ./docker-entrypoint.sh
# public leeren
RUN rm -rf ./public/*
+# frische Datenbank vorbereiten
+RUN rm -rf ./data && mkdir -p ./data && chown node:node ./data
+
+# EntryPoint vorbereiten
+RUN chmod +x ./docker-entrypoint.sh && chown node:node ./docker-entrypoint.sh
+
# Inhalt von dist inklusive Unterordner direkt nach public kopieren
COPY --from=frontend-build /app/frontend/dist/. ./public/
@@ -50,4 +57,5 @@ EXPOSE 5173
USER node
# Container startet das Backend (liefert statisches Frontend)
+ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "index.js"]
diff --git a/README.md b/README.md
index 6d71400..8cf8660 100644
--- a/README.md
+++ b/README.md
@@ -28,13 +28,14 @@ Ziel:
đĄ v0.2.0 â In Entwicklung
### Backend
-- [ ] Anbindung einer SQLite-Datenbank
-- [ ] Logging der Redeploy-Aktionen in SQLite speichern
-- [ ] API-Endpunkte fĂŒr Log-Abfragen
-
+- [x] Anbindung einer SQLite-Datenbank
+- [x] Logging der Redeploy-Aktionen in SQLite speichern
+- [x] API-Endpunkte fĂŒr Log-Abfragen
+2
### Frontend
-- [ ] Anzeige der Logs (inkl. Statusfarben)
-- [ ] UI-Komponenten fĂŒr Log-Details
+- [x] Anzeige der Logs (inkl. Statusfarben)
+- [x] UI-Komponenten fĂŒr Log-Details
+- [x] Filterfunktionen fĂŒr die Logs
### Features
- [ ] Selektive Auswahl: einzelne Stacks oder Services neu deployen
diff --git a/backend/data/.gitignore b/backend/data/.gitignore
new file mode 100644
index 0000000..3aef848
--- /dev/null
+++ b/backend/data/.gitignore
@@ -0,0 +1 @@
+stackpulse.db
diff --git a/backend/data/stackpulse.db-shm b/backend/data/stackpulse.db-shm
new file mode 100644
index 0000000..fe9ac28
Binary files /dev/null and b/backend/data/stackpulse.db-shm differ
diff --git a/backend/data/stackpulse.db-wal b/backend/data/stackpulse.db-wal
new file mode 100644
index 0000000..e69de29
diff --git a/backend/db/index.js b/backend/db/index.js
new file mode 100644
index 0000000..0d59277
--- /dev/null
+++ b/backend/db/index.js
@@ -0,0 +1,18 @@
+import Database from 'better-sqlite3';
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const dataDir = path.join(__dirname, '..', 'data');
+const dbFile = path.join(dataDir, 'stackpulse.db');
+
+if (!fs.existsSync(dataDir)) {
+ fs.mkdirSync(dataDir, { recursive: true });
+}
+
+const db = new Database(dbFile);
+db.pragma('journal_mode = WAL');
+export { db, dbFile };
diff --git a/backend/db/migrate.js b/backend/db/migrate.js
new file mode 100644
index 0000000..405120b
--- /dev/null
+++ b/backend/db/migrate.js
@@ -0,0 +1,19 @@
+import { db } from './index.js';
+
+const createRedeployLogsTable = `
+CREATE TABLE IF NOT EXISTS redeploy_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
+ stack_id TEXT NOT NULL,
+ stack_name TEXT NOT NULL,
+ status TEXT NOT NULL,
+ message TEXT,
+ endpoint INTEGER
+);
+`;
+
+db.exec(createRedeployLogsTable);
+
+console.log('â
redeploy_logs table ready');
+
+db.close();
diff --git a/backend/db/redeployLogs.js b/backend/db/redeployLogs.js
new file mode 100644
index 0000000..907b240
--- /dev/null
+++ b/backend/db/redeployLogs.js
@@ -0,0 +1,169 @@
+import { db } from './index.js';
+
+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;
+};
+
+export function buildLogFilter(queryParams = {}) {
+ const filters = [];
+ const params = {};
+
+ const ids = valueToArray(queryParams.ids ?? queryParams.id)
+ .map((entry) => Number(entry))
+ .filter((value) => !Number.isNaN(value));
+ if (ids.length) {
+ const placeholders = ids.map((_, idx) => `@id${idx}`);
+ filters.push(`id IN (${placeholders.join(', ')})`);
+ ids.forEach((value, idx) => {
+ params[`id${idx}`] = value;
+ });
+ }
+
+ const stackIds = valueToArray(queryParams.stackIds ?? queryParams.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;
+ });
+ }
+
+ const statuses = valueToArray(queryParams.statuses ?? queryParams.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(queryParams.endpoints ?? queryParams.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(queryParams.message);
+ if (messageQuery && String(messageQuery).trim()) {
+ filters.push('message LIKE @message');
+ params.message = `%${String(messageQuery).trim()}%`;
+ }
+
+ const from = singleValue(queryParams.from);
+ if (from) {
+ filters.push('timestamp >= @from');
+ params.from = from;
+ }
+
+ const to = singleValue(queryParams.to);
+ if (to) {
+ filters.push('timestamp <= @to');
+ params.to = to;
+ }
+
+ return {
+ whereClause: filters.length ? `WHERE ${filters.join(' AND ')}` : '',
+ params,
+ };
+}
+
+const insertRedeployLogStmt = db.prepare(`
+ INSERT INTO redeploy_logs (stack_id, stack_name, status, message, endpoint)
+ VALUES (@stackId, @stackName, @status, @message, @endpoint)
+`);
+
+export function logRedeployEvent({ stackId, stackName, status, message = null, endpoint = null }) {
+ try {
+ insertRedeployLogStmt.run({
+ stackId: String(stackId),
+ stackName: stackName ?? 'Unknown',
+ status,
+ message,
+ endpoint
+ });
+ } catch (err) {
+ console.error('â Fehler beim Speichern des Redeploy-Logs:', err.message);
+ }
+}
+
+export function deleteLogById(id) {
+ const stmt = db.prepare('DELETE FROM redeploy_logs WHERE id = ?');
+ const info = stmt.run(id);
+ return info.changes || 0;
+}
+
+export function deleteLogsByFilters(queryParams = {}) {
+ const { whereClause, params } = buildLogFilter(queryParams);
+ const stmt = db.prepare(`DELETE FROM redeploy_logs ${whereClause}`);
+ const info = stmt.run(params);
+ return info.changes || 0;
+}
+
+export function exportLogsByFilters(queryParams = {}, format = 'txt') {
+ const { whereClause, params } = buildLogFilter(queryParams);
+ const rows = db.prepare(`
+ SELECT id, timestamp, stack_id AS stackId, stack_name AS stackName, status, message, endpoint
+ FROM redeploy_logs
+ ${whereClause}
+ ORDER BY datetime(timestamp) DESC
+ `).all(params);
+
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+
+ if (format === 'sql') {
+ const statements = rows.map((row) => {
+ const columns = ['id', 'timestamp', 'stack_id', 'stack_name', 'status', 'message', 'endpoint'];
+ const values = [
+ row.id,
+ row.timestamp,
+ row.stackId,
+ row.stackName,
+ row.status,
+ row.message,
+ row.endpoint
+ ].map((value) => {
+ if (value === null || value === undefined) return 'NULL';
+ return `'${String(value).replace(/'/g, "''")}'`;
+ });
+ return `INSERT INTO redeploy_logs (${columns.join(', ')}) VALUES (${values.join(', ')});`;
+ });
+
+ return {
+ filename: `redeploy-logs-${timestamp}.sql`,
+ contentType: 'application/sql; charset=utf-8',
+ content: statements.join('\n')
+ };
+ }
+
+ const lines = rows.map((row) => {
+ const parts = [
+ `[${row.id}]`,
+ row.timestamp,
+ `Stack: ${row.stackName ?? 'Unbekannt'} (ID: ${row.stackId})`,
+ `Status: ${row.status}`,
+ `Endpoint: ${row.endpoint ?? '-'}`,
+ `Nachricht: ${row.message ?? '-'}`
+ ];
+ return parts.join(' | ');
+ });
+
+ return {
+ filename: `redeploy-logs-${timestamp}.txt`,
+ contentType: 'text/plain; charset=utf-8',
+ content: lines.join('\n')
+ };
+}
diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh
new file mode 100644
index 0000000..4585dd3
--- /dev/null
+++ b/backend/docker-entrypoint.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+set -e
+
+echo "đ§ Starte Migration (idempotent)..."
+node db/migrate.js
+
+echo "â
Migration abgeschlossen. Starte Anwendung..."
+exec "$@"
diff --git a/backend/index.js b/backend/index.js
index 81c1fa0..ff6edc7 100644
--- a/backend/index.js
+++ b/backend/index.js
@@ -6,6 +6,14 @@ import http from 'http';
import { Server } from 'socket.io';
import path from 'path';
import { fileURLToPath } from 'url';
+import { db } from './db/index.js';
+import {
+ logRedeployEvent,
+ buildLogFilter,
+ deleteLogById,
+ deleteLogsByFilters,
+ exportLogsByFilters
+} from './db/redeployLogs.js';
dotenv.config();
@@ -91,21 +99,134 @@ app.get('/api/stacks', async (req, res) => {
}
});
+// Redeploy-Logs abrufen
+app.get('/api/logs', (req, res) => {
+ const perPageParam = req.query.perPage ?? req.query.limit;
+ const perPage = perPageParam === 'all' ? 'all' : Math.min(parseInt(perPageParam, 10) || 50, 500);
+ const page = Math.max(parseInt(req.query.page, 10) || 1, 1);
+
+ const limit = perPage === 'all' ? undefined : perPage;
+ const offset = perPage === 'all' ? 0 : (page - 1) * perPage;
+
+ const { whereClause, params } = buildLogFilter(req.query);
+ const baseQuery = `
+ SELECT
+ id,
+ timestamp,
+ stack_id AS stackId,
+ stack_name AS stackName,
+ status,
+ message,
+ endpoint
+ FROM redeploy_logs
+ ${whereClause}
+ ORDER BY datetime(timestamp) DESC
+ `;
+
+ const countQuery = `
+ SELECT COUNT(*) as total
+ FROM redeploy_logs
+ ${whereClause}
+ `;
+
+ const query = limit !== undefined
+ ? `${baseQuery} LIMIT @limit OFFSET @offset`
+ : baseQuery;
+
+ if (limit !== undefined) {
+ params.limit = limit;
+ params.offset = offset;
+ }
+
+ try {
+ const stmt = db.prepare(query);
+ const logs = stmt.all(params);
+ const total = db.prepare(countQuery).get(params)?.total ?? logs.length;
+
+ res.json({
+ total,
+ items: logs,
+ page,
+ perPage: perPage === 'all' ? 'all' : limit
+ });
+ } catch (err) {
+ console.error('â Fehler beim Abrufen der Redeploy-Logs:', err.message);
+ if (err.message.includes('no such table')) {
+ return res.status(500).json({ error: 'redeploy_logs table nicht gefunden. Bitte Migration ausfĂŒhren.' });
+ }
+ res.status(500).json({ error: 'Fehler beim Abrufen der Redeploy-Logs' });
+ }
+});
+
+app.delete('/api/logs/:id', (req, res) => {
+ const id = Number(req.params.id);
+ if (Number.isNaN(id)) {
+ return res.status(400).json({ error: 'UngĂŒltige ID' });
+ }
+
+ try {
+ const changes = deleteLogById(id);
+ if (!changes) {
+ return res.status(404).json({ error: 'Eintrag nicht gefunden' });
+ }
+ res.json({ success: true, deleted: changes });
+ } catch (err) {
+ console.error('â Fehler beim Löschen des Redeploy-Logs:', err.message);
+ res.status(500).json({ error: 'Fehler beim Löschen des Redeploy-Logs' });
+ }
+});
+
+app.delete('/api/logs', (req, res) => {
+ try {
+ const deleted = deleteLogsByFilters(req.query);
+ res.json({ success: true, deleted });
+ } catch (err) {
+ console.error('â Fehler beim Löschen der Redeploy-Logs:', err.message);
+ res.status(500).json({ error: 'Fehler beim Löschen der Redeploy-Logs' });
+ }
+});
+
+app.get('/api/logs/export', (req, res) => {
+ const format = (req.query.format || 'txt').toLowerCase();
+ if (!['txt', 'sql'].includes(format)) {
+ return res.status(400).json({ error: 'UngĂŒltiges Export-Format' });
+ }
+
+ try {
+ const payload = exportLogsByFilters(req.query, format);
+ res.setHeader('Content-Type', payload.contentType);
+ res.setHeader('Content-Disposition', `attachment; filename="${payload.filename}"`);
+ res.send(payload.content);
+ } catch (err) {
+ console.error('â Fehler beim Export der Redeploy-Logs:', err.message);
+ res.status(500).json({ error: 'Fehler beim Export der Redeploy-Logs' });
+ }
+});
+
// Einzel-Redeploy
app.put('/api/stacks/:id/redeploy', async (req, res) => {
const { id } = req.params;
console.log(`đ PUT /api/stacks/${id}/redeploy: Redeploy gestartet`);
+ let stack;
try {
broadcastRedeployStatus(id, true);
const stackRes = await axiosInstance.get(`/api/stacks/${id}`);
- const stack = stackRes.data;
+ stack = stackRes.data;
if (stack.EndpointId !== ENDPOINT_ID) {
throw new Error(`Stack gehört nicht zum Endpoint ${ENDPOINT_ID}`);
}
+ logRedeployEvent({
+ stackId: stack.Id || id,
+ stackName: stack.Name,
+ status: 'started',
+ message: 'Redeploy gestartet',
+ endpoint: stack.EndpointId
+ });
+
if (stack.Type === 1) {
console.log(`đ [Redeploy] Git Stack "${stack.Name}" (${id}) wird redeployed`);
await axiosInstance.put(`/api/stacks/${id}/git/redeploy?endpointId=${stack.EndpointId}`);
@@ -136,12 +257,27 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => {
}
broadcastRedeployStatus(id, false);
+ logRedeployEvent({
+ stackId: stack.Id || id,
+ stackName: stack.Name,
+ status: 'success',
+ message: 'Redeploy erfolgreich abgeschlossen',
+ endpoint: stack.EndpointId
+ });
console.log(`â
PUT /api/stacks/${id}/redeploy: Redeploy erfolgreich abgeschlossen`);
res.json({ success: true, message: 'Stack redeployed' });
} catch (err) {
broadcastRedeployStatus(id, false);
- console.error(`â Fehler beim Redeploy von Stack ${id}:`, err.message);
- res.status(500).json({ error: err.message });
+ const errorMessage = err.response?.data?.message || err.message;
+ logRedeployEvent({
+ stackId: stack?.Id || id,
+ stackName: stack?.Name || `Stack ${id}`,
+ status: 'error',
+ message: errorMessage,
+ endpoint: stack?.EndpointId || ENDPOINT_ID
+ });
+ console.error(`â Fehler beim Redeploy von Stack ${id}:`, errorMessage);
+ res.status(500).json({ error: errorMessage });
}
});
@@ -159,6 +295,13 @@ app.put('/api/stacks/redeploy-all', async (req, res) => {
filteredStacks.forEach(async (stack) => {
try {
broadcastRedeployStatus(stack.Id, true);
+ logRedeployEvent({
+ stackId: stack.Id,
+ stackName: stack.Name,
+ status: 'started',
+ message: 'Redeploy ĂŒber Redeploy ALL gestartet',
+ endpoint: stack.EndpointId
+ });
if (stack.Type === 1) {
console.log(`đ [Redeploy] Git Stack "${stack.Name}" (${stack.Id})`);
@@ -176,10 +319,25 @@ app.put('/api/stacks/redeploy-all', async (req, res) => {
}
broadcastRedeployStatus(stack.Id, false);
+ logRedeployEvent({
+ stackId: stack.Id,
+ stackName: stack.Name,
+ status: 'success',
+ message: 'Redeploy ĂŒber Redeploy ALL abgeschlossen',
+ endpoint: stack.EndpointId
+ });
console.log(`â
Redeploy abgeschlossen: ${stack.Name}`);
} catch (err) {
broadcastRedeployStatus(stack.Id, false);
- console.error(`â Fehler beim Redeploy von Stack ${stack.Name}:`, err.message);
+ const errorMessage = err.response?.data?.message || err.message;
+ logRedeployEvent({
+ stackId: stack.Id,
+ stackName: stack.Name,
+ status: 'error',
+ message: errorMessage,
+ endpoint: stack.EndpointId
+ });
+ console.error(`â Fehler beim Redeploy von Stack ${stack.Name}:`, errorMessage);
}
});
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 6008df4..8d0c8a9 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"axios": "^1.7.0",
+ "better-sqlite3": "^9.6.0",
"dockerode": "^4.0.7",
"dotenv": "^16.0.0",
"express": "^4.18.0",
@@ -230,6 +231,24 @@
"tweetnacl": "^0.14.3"
}
},
+ "node_modules/better-sqlite3": {
+ "version": "9.6.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
+ "integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==",
+ "hasInstallScript": true,
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -441,6 +460,28 @@
"ms": "2.0.0"
}
},
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -466,6 +507,14 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/detect-libc": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz",
+ "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/docker-modem": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz",
@@ -686,6 +735,14 @@
"node": ">= 0.6"
}
},
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
@@ -731,6 +788,11 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
+ },
"node_modules/finalhandler": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
@@ -854,6 +916,11 @@
"node": ">= 0.4"
}
},
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -951,6 +1018,11 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
+ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1039,6 +1111,25 @@
"node": ">= 0.6"
}
},
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@@ -1055,6 +1146,11 @@
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
"optional": true
},
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="
+ },
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1063,6 +1159,17 @@
"node": ">= 0.6"
}
},
+ "node_modules/node-abi": {
+ "version": "3.77.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.77.0.tgz",
+ "integrity": "sha512-DSmt0OEcLoK4i3NuscSbGjOf3bqiDEutejqENSplMSFA/gmB8mkED9G4pKWnPl7MDU4rSHebKPHeitpDfyH0cQ==",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -1114,6 +1221,31 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
},
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
@@ -1199,6 +1331,20 @@
"node": ">= 0.8"
}
},
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -1244,6 +1390,17 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
+ "node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/send": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
@@ -1367,6 +1524,49 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
"node_modules/socket.io": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
@@ -1530,6 +1730,14 @@
"node": ">=8"
}
},
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/tar-fs": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
@@ -1564,6 +1772,17 @@
"node": ">=0.6"
}
},
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
diff --git a/backend/package.json b/backend/package.json
index 959770e..290f8ff 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1 +1,17 @@
-{"name":"backend","version":"1.0.0","type":"module","scripts":{"start":"node index.js"},"dependencies":{"axios":"^1.7.0","dockerode":"^4.0.7","dotenv":"^16.0.0","express":"^4.18.0","socket.io":"^4.8.1"}}
\ No newline at end of file
+{
+ "name": "backend",
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "start": "node index.js",
+ "migrate": "node db/migrate.js"
+ },
+ "dependencies": {
+ "axios": "^1.7.0",
+ "better-sqlite3": "^9.6.0",
+ "dockerode": "^4.0.7",
+ "dotenv": "^16.0.0",
+ "express": "^4.18.0",
+ "socket.io": "^4.8.1"
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 0110094..1202a14 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,8 +6,13 @@ services:
dockerfile: Dockerfile
ports:
- "4001:4001"
+ volumes:
+ - stackpulse_data:/app/backend/data
environment:
PORTAINER_URL: "Your_Portainer_Server_Address"
PORTAINER_API_KEY: "Your_Portainer_API_Key"
PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_ID"
restart: unless-stopped
+
+volumes:
+ stackpulse_data:
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index c8f96c0..3555d9e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,6 +11,7 @@
"axios": "^1.7.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "react-router-dom": "^6.26.2",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
@@ -758,6 +759,14 @@
"node": ">=14"
}
},
+ "node_modules/@remix-run/router": {
+ "version": "1.23.0",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
+ "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2188,6 +2197,36 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-router": {
+ "version": "6.30.1",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
+ "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
+ "dependencies": {
+ "@remix-run/router": "1.23.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.30.1",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
+ "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
+ "dependencies": {
+ "@remix-run/router": "1.23.0",
+ "react-router": "6.30.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 75934ec..ece7f7b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,6 +10,7 @@
"axios": "^1.7.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "react-router-dom": "^6.26.2",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 3dd713a..c6db119 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,15 +1,41 @@
import React from "react";
+import { NavLink, Route, Routes } from "react-router-dom";
import Stacks from "./Stacks.jsx";
+import Logs from "./Logs.jsx";
+
+const navLinkBase =
+ "px-4 py-2 rounded-md font-medium transition-colors duration-150";
+
+const getNavClass = ({ isActive }) =>
+ `${navLinkBase} ${isActive ? "bg-purple-600 text-white" : "text-gray-300 hover:bg-gray-700"}`;
export default function App() {
return (
);
diff --git a/frontend/src/Logs.jsx b/frontend/src/Logs.jsx
new file mode 100644
index 0000000..24e389a
--- /dev/null
+++ b/frontend/src/Logs.jsx
@@ -0,0 +1,810 @@
+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",
+ started: "text-blue-300"
+};
+
+const formatTimestamp = (value) => {
+ if (!value) return "-";
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return value;
+ return date.toLocaleString("de-DE", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit"
+ });
+};
+
+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 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 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 [totalLogs, setTotalLogs] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [actionLoading, setActionLoading] = useState(false);
+
+ const [stackOptions, setStackOptions] = useState([]);
+ const [statusOptions, setStatusOptions] = useState([]);
+ const [endpointOptions, setEndpointOptions] = useState([]);
+
+ 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 [perPage, setPerPage] = useState(PER_PAGE_DEFAULT);
+ const [page, setPage] = useState(1);
+
+ 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 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 }));
+ });
+ }, []);
+
+ const stackLabelMap = useMemo(() => {
+ const map = new Map();
+ stackOptions.forEach((option) => {
+ map.set(option.value, option.label);
+ });
+ return map;
+ }, [stackOptions]);
+
+ useEffect(() => {
+ if (typeof window === "undefined") {
+ setFiltersReady(true);
+ return;
+ }
+
+ 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 ?? {};
+ const storedPagination = parsed?.pagination ?? {};
+
+ 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);
+ }
+
+ const rawPerPage = storedPagination.perPage;
+ if (rawPerPage !== undefined) {
+ const parsedPerPage = String(rawPerPage);
+ if (VALID_PER_PAGE_VALUES.has(parsedPerPage)) {
+ setPerPage(parsedPerPage);
+ }
+ }
+
+ const rawPage = storedPagination.page;
+ if (typeof rawPage === 'number' && rawPage > 0) {
+ setPage(rawPage);
+ }
+ }
+ } catch (storageError) {
+ console.error("â Fehler beim Laden der gespeicherten Log-Filter:", storageError);
+ } finally {
+ setFiltersReady(true);
+ }
+ }, []);
+
+ const buildFilterParams = useCallback(() => {
+ const params = {};
+
+ if (selectedStacks.length) {
+ params.stackIds = selectedStacks.join(",");
+ }
+
+ if (selectedStatuses.length) {
+ params.statuses = selectedStatuses.join(",");
+ }
+
+ if (selectedEndpoints.length) {
+ params.endpoints = selectedEndpoints.join(",");
+ }
+
+ if (messageQuery.trim()) {
+ params.message = messageQuery.trim();
+ }
+
+ const fromParam = normalizeDateParam(fromDate);
+ if (fromParam) {
+ params.from = fromParam;
+ }
+
+ const toParam = normalizeDateParam(toDate);
+ if (toParam) {
+ params.to = toParam;
+ }
+
+ return params;
+ }, [selectedStacks, selectedStatuses, selectedEndpoints, messageQuery, fromDate, toDate]);
+
+ useEffect(() => {
+ if (!filtersReady) return;
+
+ let cancelled = false;
+ axios.get("/api/logs", { params: { ...buildFilterParams(), perPage: 'all', page: 1 } })
+ .then((response) => {
+ if (cancelled) return;
+ updateFilterOptions(response.data);
+ })
+ .catch((err) => {
+ if (cancelled) return;
+ console.error("â ïž Konnte Filteroptionen nicht aktualisieren:", err);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [filtersReady, buildFilterParams, updateFilterOptions, refreshSignal]);
+
+ const currentFilters = useMemo(() => ({
+ stacks: selectedStacks,
+ statuses: selectedStatuses,
+ endpoints: selectedEndpoints,
+ message: messageQuery,
+ from: fromDate,
+ to: toDate
+ }), [selectedStacks, selectedStatuses, selectedEndpoints, messageQuery, fromDate, toDate]);
+
+ useEffect(() => {
+ if (!filtersReady) return;
+
+ const params = { ...buildFilterParams() };
+ if (perPage === 'all') {
+ params.perPage = 'all';
+ params.page = 1;
+ } else {
+ params.perPage = perPage;
+ params.page = page;
+ }
+
+ let cancelled = false;
+ setLoading(true);
+ setError("");
+
+ axios.get("/api/logs", { params })
+ .then((response) => {
+ if (cancelled) return;
+ const data = response.data || {};
+ const items = Array.isArray(data) ? data : data.items ?? [];
+ const total = Array.isArray(data) ? items.length : data.total ?? items.length;
+
+ setLogs(items);
+ setTotalLogs(total);
+ updateFilterOptions(response.data);
+
+ if (perPage === 'all') {
+ if (page !== 1) setPage(1);
+ } else {
+ const totalPages = Math.max(1, Math.ceil((total || 0) / Number(perPage)));
+ const nextPage = Math.min(Math.max(page, 1), totalPages);
+ if (nextPage !== page) {
+ setPage(nextPage);
+ }
+ }
+ })
+ .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, buildFilterParams, updateFilterOptions, refreshSignal, perPage, page]);
+
+ 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,
+ },
+ pagination: {
+ perPage,
+ page
+ }
+ })
+ );
+ } catch (storageError) {
+ console.error("â ïž Konnte Filter nicht speichern:", storageError);
+ }
+ }, [filtersReady, currentFilters, stackOptions, statusOptions, endpointOptions, perPage, page]);
+
+ const handleMultiSelectChange = (setter) => (event) => {
+ const values = Array.from(event.target.selectedOptions).map((option) => option.value);
+ if (values.includes(ALL_OPTION_VALUE)) {
+ setter([]);
+ } else {
+ setter(values);
+ }
+ setPage(1);
+ };
+
+ const handleResetFilters = () => {
+ setSelectedStacks([]);
+ setSelectedStatuses([]);
+ setSelectedEndpoints([]);
+ setMessageQuery("");
+ setFromDate("");
+ setToDate("");
+ setFiltersOpen(false);
+ setPage(1);
+ };
+
+ const handleToggleFilters = () => {
+ setFiltersOpen((prev) => !prev);
+ };
+
+ const handleRefresh = () => {
+ setRefreshSignal((prev) => prev + 1);
+ };
+
+ const handlePerPageChange = (event) => {
+ const value = event.target.value;
+ if (!VALID_PER_PAGE_VALUES.has(value)) return;
+ setPerPage(value);
+ setPage(1);
+ };
+
+ const handlePageChange = (nextPage) => {
+ if (perPage === 'all') return;
+ if (nextPage < 1) return;
+ const totalPages = Math.max(1, Math.ceil((totalLogs || 0) / Number(perPage)));
+ if (nextPage > totalPages) return;
+ setPage(nextPage);
+ };
+
+ 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]);
+
+ 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]);
+
+ const totalPages = useMemo(() => {
+ if (perPage === 'all') return 1;
+ const numeric = Number(perPage) || Number(PER_PAGE_DEFAULT);
+ return Math.max(1, Math.ceil((totalLogs || 0) / numeric));
+ }, [perPage, totalLogs]);
+
+ const rangeStart = useMemo(() => {
+ if (totalLogs === 0) return 0;
+ if (perPage === 'all') return 1;
+ return (page - 1) * Number(perPage) + 1;
+ }, [totalLogs, perPage, page]);
+
+ const rangeEnd = useMemo(() => {
+ if (totalLogs === 0) return 0;
+ if (perPage === 'all') return totalLogs;
+ return Math.min(totalLogs, (page - 1) * Number(perPage) + logs.length);
+ }, [totalLogs, perPage, page, logs.length]);
+
+ const handleDeleteLog = async (id) => {
+ if (!window.confirm("Diesen Log-Eintrag dauerhaft löschen?")) return;
+ setActionLoading(true);
+ setError("");
+ try {
+ await axios.delete(`/api/logs/${id}`);
+ setRefreshSignal((prev) => prev + 1);
+ } catch (err) {
+ console.error("â Fehler beim Löschen des Logs:", err);
+ setError("Fehler beim Löschen des Redeploy-Logs");
+ } finally {
+ setActionLoading(false);
+ }
+ };
+
+ const handleDeleteFiltered = async () => {
+ if (!logs.length) return;
+ if (!window.confirm("Alle angezeigten Logs (entsprechend Filter) löschen?")) return;
+ setActionLoading(true);
+ setError("");
+ try {
+ const params = {
+ ...buildFilterParams(),
+ ids: logs.map((log) => log.id).join(',')
+ };
+ await axios.delete("/api/logs", { params });
+ setRefreshSignal((prev) => prev + 1);
+ } catch (err) {
+ console.error("â Fehler beim Löschen der gefilterten Logs:", err);
+ setError("Fehler beim Löschen der gefilterten Logs");
+ } finally {
+ setActionLoading(false);
+ }
+ };
+
+ const handleExport = async (format) => {
+ setActionLoading(true);
+ setError("");
+ try {
+ const response = await axios.get("/api/logs/export", {
+ params: { ...buildFilterParams(), format },
+ responseType: "blob"
+ });
+
+ const contentType = response.headers["content-type"] || (format === "sql" ? "application/sql" : "text/plain");
+ const disposition = response.headers["content-disposition"] || "";
+ const match = disposition.match(/filename="?([^";]+)"?/i);
+ const filename = match?.[1] || `redeploy-logs.${format}`;
+
+ const blob = new Blob([response.data], { type: contentType });
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ window.URL.revokeObjectURL(url);
+ } catch (err) {
+ console.error("â Fehler beim Export der Logs:", err);
+ setError("Fehler beim Export der Redeploy-Logs");
+ } finally {
+ setActionLoading(false);
+ }
+ };
+
+ return (
+
+
+
Redeploy-Logs
+
+
+ EintrÀge pro Seite
+
+
+
+
+
+ {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);
+ setPage(1);
+ }}
+ 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);
+ setPage(1);
+ }}
+ 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);
+ setPage(1);
+ }}
+ 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"
+ />
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ | Zeitpunkt |
+ Stack |
+ Status |
+ Nachricht |
+ Endpoint |
+ Aktionen |
+
+
+
+ {logs.length === 0 && !loading && (
+
+ |
+ Keine Logs vorhanden.
+ |
+
+ )}
+ {logs.map((log) => {
+ const statusClass = STATUS_COLORS[log.status] || "text-blue-300";
+ return (
+
+ |
+ {formatTimestamp(log.timestamp)}
+ |
+
+
+ {log.stackName || "Unbekannt"}
+ ID: {log.stackId}
+
+ |
+
+ {log.status}
+ |
+
+ {log.message || "-"}
+ |
+
+ {log.endpoint ?? "-"}
+ |
+
+
+ |
+
+ );
+ })}
+
+
+
+
+
+
+ {totalLogs === 0
+ ? "Keine EintrÀge"
+ : perPage === 'all'
+ ? `Zeige alle ${totalLogs} EintrÀge`
+ : `Zeige ${rangeStart.toLocaleString()} â ${rangeEnd.toLocaleString()} von ${totalLogs.toLocaleString()} EintrĂ€gen`}
+
+ {perPage !== 'all' && (
+
+
+
+ Seite {page} / {totalPages}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index b194025..032e87b 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -1,10 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
import App from "./App.jsx";
import './index.css'; // Tailwind CSS
ReactDOM.createRoot(document.getElementById("root")).render(
-
+
+
+
);
diff --git a/scripts/start-dev.sh b/scripts/start-dev.sh
index 8caf75d..85a6b7d 100755
--- a/scripts/start-dev.sh
+++ b/scripts/start-dev.sh
@@ -6,6 +6,7 @@ echo "đ Starte StackPulse Dev-Umgebung..."
# --- Backend ---
cd backend
npm install
+npm run migrate
npm start &
BACK_PID=$!
cd ..