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 (
-
-

StackPulse

-

Verwalte deine Docker Stacks

+
+
+
+
+

StackPulse

+

Verwalte deine Docker Stacks

+
+ +
+
-
- +
+ + } /> + } /> + } /> +
); 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" + /> +
+
+ +
+ +
+
+ )} +
+ +
+ + + + + + + + + + + + {logs.length === 0 && !loading && ( + + + + )} + {logs.map((log) => { + const statusClass = STATUS_COLORS[log.status] || "text-blue-300"; + return ( + + + + + + + + ); + })} + +
ZeitpunktStackStatusNachrichtEndpoint
+ Keine Logs vorhanden. +
+ {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 ..