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..31efa33
--- /dev/null
+++ b/backend/db/redeployLogs.js
@@ -0,0 +1,20 @@
+import { db } from './index.js';
+
+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);
+ }
+}
diff --git a/backend/index.js b/backend/index.js
index 81c1fa0..ef4d3b1 100644
--- a/backend/index.js
+++ b/backend/index.js
@@ -6,6 +6,8 @@ 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 } from './db/redeployLogs.js';
dotenv.config();
@@ -91,21 +93,127 @@ app.get('/api/stacks', async (req, res) => {
}
});
+// Redeploy-Logs abrufen
+app.get('/api/logs', (req, res) => {
+ 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 };
+
+ 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;
+ });
+ }
+
+ 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;
+ }
+
+ const whereClause = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
+ const query = `
+ SELECT
+ id,
+ timestamp,
+ stack_id AS stackId,
+ stack_name AS stackName,
+ status,
+ message,
+ endpoint
+ FROM redeploy_logs
+ ${whereClause}
+ ORDER BY datetime(timestamp) DESC
+ LIMIT @limit OFFSET @offset
+ `;
+
+ try {
+ const stmt = db.prepare(query);
+ const logs = stmt.all(params);
+ res.json(logs);
+ } 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' });
+ }
+});
+
// 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 +244,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 +282,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 +306,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/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..5cdd203
--- /dev/null
+++ b/frontend/src/Logs.jsx
@@ -0,0 +1,563 @@
+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 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 [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 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 }));
+ });
+ }, []);
+
+ 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 ?? {};
+
+ 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
+
+ {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"
+ />
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ | Zeitpunkt |
+ Stack |
+ Status |
+ Nachricht |
+ Endpoint |
+
+
+
+ {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 ?? "-"}
+ |
+
+ );
+ })}
+
+
+
+
+ );
+}
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 ..