From 241a509a15b7ced5a98e3a69d4a2f57bb6cee8ca Mon Sep 17 00:00:00 2001 From: root Date: Mon, 29 Sep 2025 19:04:26 +0000 Subject: [PATCH 1/8] Log and Database --- README.md | 2 +- backend/data/.gitignore | 1 + backend/data/stackpulse.db-shm | Bin 0 -> 32768 bytes backend/data/stackpulse.db-wal | 0 backend/db/index.js | 18 +++ backend/db/migrate.js | 19 +++ backend/index.js | 59 +++++++++ backend/package-lock.json | 219 +++++++++++++++++++++++++++++++++ backend/package.json | 18 ++- frontend/package-lock.json | 39 ++++++ frontend/package.json | 1 + frontend/src/App.jsx | 36 +++++- frontend/src/Logs.jsx | 122 ++++++++++++++++++ frontend/src/main.jsx | 5 +- scripts/start-dev.sh | 1 + 15 files changed, 532 insertions(+), 8 deletions(-) create mode 100644 backend/data/.gitignore create mode 100644 backend/data/stackpulse.db-shm create mode 100644 backend/data/stackpulse.db-wal create mode 100644 backend/db/index.js create mode 100644 backend/db/migrate.js create mode 100644 frontend/src/Logs.jsx diff --git a/README.md b/README.md index 6d71400..32339aa 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Ziel: - [ ] Anbindung einer SQLite-Datenbank - [ ] Logging der Redeploy-Aktionen in SQLite speichern - [ ] API-Endpunkte für Log-Abfragen - +2 ### Frontend - [ ] Anzeige der Logs (inkl. Statusfarben) - [ ] UI-Komponenten für Log-Details 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 0000000000000000000000000000000000000000..fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10 GIT binary patch literal 32768 zcmeIuAr62r3 { } }); +// Redeploy-Logs abrufen +app.get('/api/logs', (req, res) => { + const { stackId, status, from, to } = req.query; + const limit = Math.min(parseInt(req.query.limit, 10) || 100, 500); + const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0); + + const filters = []; + const params = { limit, offset }; + + if (stackId) { + filters.push('stack_id = @stackId'); + params.stackId = stackId; + } + + if (status) { + filters.push('status = @status'); + params.status = status; + } + + if (from) { + filters.push('timestamp >= @from'); + params.from = from; + } + + 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; 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..b1d2df8 --- /dev/null +++ b/frontend/src/Logs.jsx @@ -0,0 +1,122 @@ +import React, { useEffect, useState } from "react"; +import axios from "axios"; + +const STATUS_COLORS = { + success: "text-green-400", + warning: "text-yellow-400", + error: "text-red-400" +}; + +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" + }); +}; + +export default function Logs() { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchLogs = async () => { + setLoading(true); + setError(""); + try { + const response = await axios.get("/api/logs", { + params: { limit: 200 } + }); + setLogs(response.data); + } catch (err) { + console.error("❌ Fehler beim Laden der Logs:", err); + setError("Fehler beim Laden der Redeploy-Logs"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchLogs(); + }, []); + + if (loading) { + return

Lade Logs...

; + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+
+

Redeploy-Logs

+ +
+ +
+ + + + + + + + + + + + {logs.length === 0 && ( + + + + )} + {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 .. From 38b96da4e7f1aefadf99349111d5bf5c6c81efa8 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 29 Sep 2025 19:18:12 +0000 Subject: [PATCH 2/8] db write --- backend/db/redeployLogs.js | 20 ++++++++++++++ backend/index.js | 55 +++++++++++++++++++++++++++++++++++--- 2 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 backend/db/redeployLogs.js 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 50e9a2c..7d2f30d 100644 --- a/backend/index.js +++ b/backend/index.js @@ -7,6 +7,7 @@ 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(); @@ -155,16 +156,25 @@ 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}`); @@ -195,12 +205,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 }); } }); @@ -218,6 +243,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})`); @@ -235,10 +267,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); } }); From 0a65f3e60f9a8cd92515a8694b35fc17cec3e28f Mon Sep 17 00:00:00 2001 From: root Date: Tue, 30 Sep 2025 07:02:59 +0000 Subject: [PATCH 3/8] Logging Filter --- backend/index.js | 53 ++++- frontend/src/Logs.jsx | 511 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 522 insertions(+), 42 deletions(-) diff --git a/backend/index.js b/backend/index.js index 7d2f30d..ef4d3b1 100644 --- a/backend/index.js +++ b/backend/index.js @@ -95,28 +95,67 @@ app.get('/api/stacks', async (req, res) => { // Redeploy-Logs abrufen app.get('/api/logs', (req, res) => { - const { stackId, status, from, to } = req.query; const limit = Math.min(parseInt(req.query.limit, 10) || 100, 500); const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0); + const valueToArray = (value) => { + if (!value && value !== 0) return []; + const base = Array.isArray(value) ? value : [value]; + return base + .flatMap((entry) => String(entry).split(',')) + .map((entry) => entry.trim()) + .filter(Boolean); + }; + + const singleValue = (value) => { + if (value === undefined || value === null) return undefined; + return Array.isArray(value) ? value[0] : value; + }; + const filters = []; const params = { limit, offset }; - if (stackId) { - filters.push('stack_id = @stackId'); - params.stackId = stackId; + const stackIds = valueToArray(req.query.stackIds ?? req.query.stackId); + if (stackIds.length) { + const placeholders = stackIds.map((_, idx) => `@stackId${idx}`); + filters.push(`stack_id IN (${placeholders.join(', ')})`); + stackIds.forEach((stack, idx) => { + params[`stackId${idx}`] = stack; + }); } - if (status) { - filters.push('status = @status'); - params.status = status; + const statuses = valueToArray(req.query.statuses ?? req.query.status); + if (statuses.length) { + const placeholders = statuses.map((_, idx) => `@status${idx}`); + filters.push(`status IN (${placeholders.join(', ')})`); + statuses.forEach((entry, idx) => { + params[`status${idx}`] = entry; + }); } + const endpoints = valueToArray(req.query.endpoints ?? req.query.endpoint); + if (endpoints.length) { + const placeholders = endpoints.map((_, idx) => `@endpoint${idx}`); + filters.push(`endpoint IN (${placeholders.join(', ')})`); + endpoints.forEach((entry, idx) => { + const numeric = Number(entry); + params[`endpoint${idx}`] = Number.isNaN(numeric) ? entry : numeric; + }); + } + + const messageQuery = singleValue(req.query.message); + if (messageQuery && String(messageQuery).trim()) { + filters.push('message LIKE @message'); + params.message = `%${String(messageQuery).trim()}%`; + } + + const from = singleValue(req.query.from); if (from) { filters.push('timestamp >= @from'); params.from = from; } + const to = singleValue(req.query.to); if (to) { filters.push('timestamp <= @to'); params.to = to; diff --git a/frontend/src/Logs.jsx b/frontend/src/Logs.jsx index b1d2df8..5cdd203 100644 --- a/frontend/src/Logs.jsx +++ b/frontend/src/Logs.jsx @@ -1,10 +1,11 @@ -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import axios from "axios"; const STATUS_COLORS = { success: "text-green-400", warning: "text-yellow-400", - error: "text-red-400" + error: "text-red-400", + started: "text-blue-300" }; const formatTimestamp = (value) => { @@ -21,53 +22,493 @@ const formatTimestamp = (value) => { }); }; +const normalizeDateParam = (value) => { + if (!value) return undefined; + return value.replace("T", " "); +}; + +const FILTER_STORAGE_KEY = "redeployLogFilters"; +const ALL_OPTION_VALUE = "__all__"; +const ALL_OPTION_LABEL = "- Alle -"; + +const hasActiveFilters = (filters) => Boolean( + (filters.stacks && filters.stacks.length) || + (filters.statuses && filters.statuses.length) || + (filters.endpoints && filters.endpoints.length) || + (filters.message && filters.message.trim()) || + (filters.from && filters.from.trim()) || + (filters.to && filters.to.trim()) +); + export default function Logs() { const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); - const fetchLogs = async () => { - setLoading(true); - setError(""); - try { - const response = await axios.get("/api/logs", { - params: { limit: 200 } - }); - setLogs(response.data); - } catch (err) { - console.error("❌ Fehler beim Laden der Logs:", err); - setError("Fehler beim Laden der Redeploy-Logs"); - } finally { - setLoading(false); - } - }; + const [stackOptions, setStackOptions] = useState([]); + const [statusOptions, setStatusOptions] = useState([]); + const [endpointOptions, setEndpointOptions] = useState([]); - useEffect(() => { - fetchLogs(); + const [selectedStacks, setSelectedStacks] = useState([]); + const [selectedStatuses, setSelectedStatuses] = useState([]); + const [selectedEndpoints, setSelectedEndpoints] = useState([]); + const [messageQuery, setMessageQuery] = useState(""); + const [fromDate, setFromDate] = useState(""); + const [toDate, setToDate] = useState(""); + + const [filtersOpen, setFiltersOpen] = useState(false); + const [filtersReady, setFiltersReady] = useState(false); + const [refreshSignal, setRefreshSignal] = useState(0); + + const updateFilterOptions = useCallback((logsPayload) => { + setStackOptions((prev) => { + const map = new Map(prev.map((entry) => [entry.value, entry.label])); + logsPayload.forEach((log) => { + if (!log.stackId) return; + const value = String(log.stackId); + if (!map.has(value)) { + map.set(value, log.stackName || `Stack ${value}`); + } + }); + return Array.from(map.entries()) + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label)); + }); + + setStatusOptions((prev) => { + const next = new Set(prev); + logsPayload.forEach((log) => { + if (log.status) next.add(log.status); + }); + return Array.from(next).sort(); + }); + + setEndpointOptions((prev) => { + const next = new Set(prev); + logsPayload.forEach((log) => { + if (log.endpoint === null || log.endpoint === undefined || log.endpoint === "") return; + next.add(String(log.endpoint)); + }); + return Array.from(next).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + }); }, []); - if (loading) { - return

Lade Logs...

; - } + useEffect(() => { + if (typeof window === "undefined") { + setFiltersReady(true); + return; + } - if (error) { - return ( -
- {error} -
- ); - } + try { + const storedValue = window.localStorage.getItem(FILTER_STORAGE_KEY); + if (storedValue) { + const parsed = JSON.parse(storedValue); + const storedFilters = parsed?.filters ?? parsed ?? {}; + const storedOptions = parsed?.options ?? {}; + + setSelectedStacks(storedFilters.stacks || []); + setSelectedStatuses(storedFilters.statuses || []); + setSelectedEndpoints(storedFilters.endpoints || []); + setMessageQuery(storedFilters.message || ""); + setFromDate(storedFilters.from || ""); + setToDate(storedFilters.to || ""); + setFiltersOpen(hasActiveFilters(storedFilters)); + + if (Array.isArray(storedOptions.stacks) && storedOptions.stacks.length) { + setStackOptions(storedOptions.stacks); + } + + if (Array.isArray(storedOptions.statuses) && storedOptions.statuses.length) { + setStatusOptions(storedOptions.statuses); + } + + if (Array.isArray(storedOptions.endpoints) && storedOptions.endpoints.length) { + setEndpointOptions(storedOptions.endpoints); + } + } + } catch (storageError) { + console.error("❌ Fehler beim Laden der gespeicherten Log-Filter:", storageError); + } finally { + setFiltersReady(true); + } + }, []); + + useEffect(() => { + if (!filtersReady) return; + + let cancelled = false; + axios.get("/api/logs", { params: { limit: 500 } }) + .then((response) => { + if (cancelled) return; + updateFilterOptions(response.data); + }) + .catch((err) => { + if (cancelled) return; + console.error("⚠️ Konnte Filteroptionen nicht aktualisieren:", err); + }); + + return () => { + cancelled = true; + }; + }, [filtersReady, updateFilterOptions, refreshSignal]); + + const currentFilters = useMemo(() => ({ + stacks: selectedStacks, + statuses: selectedStatuses, + endpoints: selectedEndpoints, + message: messageQuery, + from: fromDate, + to: toDate + }), [selectedStacks, selectedStatuses, selectedEndpoints, messageQuery, fromDate, toDate]); + + const stackLabelMap = useMemo(() => { + const map = new Map(); + stackOptions.forEach((option) => { + map.set(option.value, option.label); + }); + return map; + }, [stackOptions]); + + const stackSelectOptions = useMemo(() => { + const entries = stackOptions.filter((option) => option.value !== ALL_OPTION_VALUE); + return [ + { value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL }, + ...entries + ]; + }, [stackOptions]); + + const statusSelectOptions = useMemo(() => { + const entries = statusOptions + .filter((status) => status !== ALL_OPTION_VALUE) + .map((status) => ({ value: status, label: status })); + return [ + { value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL }, + ...entries + ]; + }, [statusOptions]); + + const endpointSelectOptions = useMemo(() => { + const entries = endpointOptions + .filter((endpoint) => endpoint !== ALL_OPTION_VALUE) + .map((endpoint) => ({ value: endpoint, label: endpoint })); + return [ + { value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL }, + ...entries + ]; + }, [endpointOptions]); + + useEffect(() => { + if (!filtersReady) return; + + const params = { limit: 200 }; + + if (currentFilters.stacks.length) { + params.stackIds = currentFilters.stacks.join(","); + } + + if (currentFilters.statuses.length) { + params.statuses = currentFilters.statuses.join(","); + } + + if (currentFilters.endpoints.length) { + params.endpoints = currentFilters.endpoints.join(","); + } + + if (currentFilters.message.trim()) { + params.message = currentFilters.message.trim(); + } + + const fromParam = normalizeDateParam(currentFilters.from); + if (fromParam) { + params.from = fromParam; + } + + const toParam = normalizeDateParam(currentFilters.to); + if (toParam) { + params.to = toParam; + } + + let cancelled = false; + setLoading(true); + setError(""); + + axios.get("/api/logs", { params }) + .then((response) => { + if (cancelled) return; + setLogs(response.data); + updateFilterOptions(response.data); + }) + .catch((err) => { + if (cancelled) return; + console.error("❌ Fehler beim Laden der Logs:", err); + setError("Fehler beim Laden der Redeploy-Logs"); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [filtersReady, currentFilters, updateFilterOptions, refreshSignal]); + + useEffect(() => { + if (!filtersReady) return; + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem( + FILTER_STORAGE_KEY, + JSON.stringify({ + filters: currentFilters, + options: { + stacks: stackOptions, + statuses: statusOptions, + endpoints: endpointOptions, + } + }) + ); + } catch (storageError) { + console.error("⚠️ Konnte Filter nicht speichern:", storageError); + } + }, [filtersReady, currentFilters, stackOptions, statusOptions, endpointOptions]); + + const handleMultiSelectChange = (setter) => (event) => { + const values = Array.from(event.target.selectedOptions).map((option) => option.value); + if (values.includes(ALL_OPTION_VALUE)) { + setter([]); + return; + } + setter(values); + }; + + const handleResetFilters = () => { + setSelectedStacks([]); + setSelectedStatuses([]); + setSelectedEndpoints([]); + setMessageQuery(""); + setFromDate(""); + setToDate(""); + setFiltersOpen(false); + }; + + const handleToggleFilters = () => { + setFiltersOpen((prev) => !prev); + }; + + const handleRefresh = () => { + setRefreshSignal((prev) => prev + 1); + }; + + const activeFilterCount = useMemo(() => { + let count = 0; + if (selectedStacks.length) count += selectedStacks.length; + if (selectedStatuses.length) count += selectedStatuses.length; + if (selectedEndpoints.length) count += selectedEndpoints.length; + if (messageQuery.trim()) count += 1; + if (fromDate) count += 1; + if (toDate) count += 1; + return count; + }, [selectedStacks, selectedStatuses, selectedEndpoints, messageQuery, fromDate, toDate]); return (
-
-

Redeploy-Logs

+
+

Redeploy-Logs

+
+ {loading && Aktualisiere…} + +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + {filtersOpen && ( +
+
+
+ + +
+ {selectedStacks.length === 0 ? ( + + Alle Stacks + + ) : ( +
+ {selectedStacks.map((stackId) => ( + + {stackLabelMap.get(stackId) ?? `Stack ${stackId}`} + + ))} +
+ )} +
+
+ +
+ + +
+ {selectedStatuses.length === 0 ? ( + + Alle Status + + ) : ( +
+ {selectedStatuses.map((status) => ( + + {status} + + ))} +
+ )} +
+
+ +
+ + +
+ {selectedEndpoints.length === 0 ? ( + + Alle Endpoints + + ) : ( +
+ {selectedEndpoints.map((endpoint) => ( + + {endpoint} + + ))} +
+ )} +
+
+ +
+ + setMessageQuery(event.target.value)} + placeholder="Textsuche in Log-Nachrichten..." + className="w-full rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500" + /> +
+ +
+ + setFromDate(event.target.value)} + className="w-full rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500" + /> +
+ +
+ + setToDate(event.target.value)} + className="w-full rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500" + /> +
+
+ +
+ +
+
+ )}
@@ -82,7 +523,7 @@ export default function Logs() { - {logs.length === 0 && ( + {logs.length === 0 && !loading && ( Keine Logs vorhanden. From 3a961270d78e6d7626a685ed971ac575fc0ec746 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 30 Sep 2025 07:09:00 +0000 Subject: [PATCH 4/8] Update Reame --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 32339aa..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 From b33e9e587b69981b303cbf34ceb5483b2de85bad Mon Sep 17 00:00:00 2001 From: root Date: Tue, 30 Sep 2025 08:38:05 +0000 Subject: [PATCH 5/8] Update Dockerbuild --- .dockerignore | 3 ++- Dockerfile | 3 +++ docker-compose.yml | 5 +++++ 3 files changed, 10 insertions(+), 1 deletion(-) 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..73df5de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,9 @@ COPY backend/ ./ # public leeren RUN rm -rf ./public/* +# frische Datenbank vorbereiten +RUN rm -rf ./data && mkdir -p ./data && chown node:node ./data + # Inhalt von dist inklusive Unterordner direkt nach public kopieren COPY --from=frontend-build /app/frontend/dist/. ./public/ 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: From 2b284195fb549c0c2507ed8ca7e13195ae25be35 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 30 Sep 2025 08:42:05 +0000 Subject: [PATCH 6/8] Update --- Dockerfile | 5 +++++ backend/docker-entrypoint.sh | 8 ++++++++ 2 files changed, 13 insertions(+) create mode 100644 backend/docker-entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 73df5de..64f613d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,7 @@ RUN npm ci --only=production # Backend-Code kopieren COPY backend/ ./ +COPY backend/docker-entrypoint.sh ./docker-entrypoint.sh # public leeren RUN rm -rf ./public/* @@ -38,6 +39,9 @@ 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/ @@ -53,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/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 "$@" From cc7d80e569731610dd50d03bf566eadd9a20a8f6 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 30 Sep 2025 09:08:36 +0000 Subject: [PATCH 7/8] Delete/Export/Pageination --- backend/data/stackpulse.db-shm | Bin 32768 -> 32768 bytes backend/data/stackpulse.db-wal | Bin 0 -> 8272 bytes backend/db/redeployLogs.js | 149 ++++++++++ backend/index.js | 151 +++++----- frontend/src/Logs.jsx | 487 +++++++++++++++++++++++++-------- 5 files changed, 598 insertions(+), 189 deletions(-) diff --git a/backend/data/stackpulse.db-shm b/backend/data/stackpulse.db-shm index fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10..50e066a7fea23e8d411259b916f9392eb600e852 100644 GIT binary patch delta 155 zcmZo@U}|V!s+V}A%K!pQK+MR%AOND7fq3?f5|=W$y1?aggZ6I}lw#RYa!GzN=VD$` h)dP(J1CY7@kpNV9Vm&7-NQ!A=W4GR1Pit^n&UkRTHfGsDEiFJ+ufR+KZ~<=@A^ z$e+W&p93UD!DtAKhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kgR#UT*D#KB-J zC@&qzXk=hys%v1WYh { + 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) @@ -18,3 +99,71 @@ export function logRedeployEvent({ stackId, stackName, status, message = null, e 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/index.js b/backend/index.js index ef4d3b1..ff6edc7 100644 --- a/backend/index.js +++ b/backend/index.js @@ -7,7 +7,13 @@ 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'; +import { + logRedeployEvent, + buildLogFilter, + deleteLogById, + deleteLogsByFilters, + exportLogsByFilters +} from './db/redeployLogs.js'; dotenv.config(); @@ -95,74 +101,15 @@ 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 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 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 limit = perPage === 'all' ? undefined : perPage; + const offset = perPage === 'all' ? 0 : (page - 1) * perPage; - 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 = ` + const { whereClause, params } = buildLogFilter(req.query); + const baseQuery = ` SELECT id, timestamp, @@ -174,13 +121,34 @@ app.get('/api/logs', (req, res) => { FROM redeploy_logs ${whereClause} ORDER BY datetime(timestamp) DESC - LIMIT @limit OFFSET @offset `; + 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); - res.json(logs); + 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')) { @@ -190,6 +158,51 @@ app.get('/api/logs', (req, res) => { } }); +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; diff --git a/frontend/src/Logs.jsx b/frontend/src/Logs.jsx index 5cdd203..24e389a 100644 --- a/frontend/src/Logs.jsx +++ b/frontend/src/Logs.jsx @@ -30,6 +30,15 @@ const normalizeDateParam = (value) => { 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) || @@ -42,8 +51,10 @@ const hasActiveFilters = (filters) => Boolean( 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([]); @@ -60,7 +71,12 @@ export default function Logs() { const [filtersReady, setFiltersReady] = useState(false); const [refreshSignal, setRefreshSignal] = useState(0); - const updateFilterOptions = useCallback((logsPayload) => { + 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) => { @@ -93,6 +109,14 @@ export default function Logs() { }); }, []); + 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); @@ -105,6 +129,7 @@ export default function Logs() { const parsed = JSON.parse(storedValue); const storedFilters = parsed?.filters ?? parsed ?? {}; const storedOptions = parsed?.options ?? {}; + const storedPagination = parsed?.pagination ?? {}; setSelectedStacks(storedFilters.stacks || []); setSelectedStatuses(storedFilters.statuses || []); @@ -125,6 +150,19 @@ export default function Logs() { 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); @@ -133,11 +171,43 @@ export default function Logs() { } }, []); + 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: { limit: 500 } }) + axios.get("/api/logs", { params: { ...buildFilterParams(), perPage: 'all', page: 1 } }) .then((response) => { if (cancelled) return; updateFilterOptions(response.data); @@ -150,7 +220,7 @@ export default function Logs() { return () => { cancelled = true; }; - }, [filtersReady, updateFilterOptions, refreshSignal]); + }, [filtersReady, buildFilterParams, updateFilterOptions, refreshSignal]); const currentFilters = useMemo(() => ({ stacks: selectedStacks, @@ -161,13 +231,126 @@ export default function Logs() { 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]); + 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); @@ -197,110 +380,6 @@ export default function Logs() { ]; }, [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; @@ -312,15 +391,133 @@ export default function Logs() { 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…} @@ -472,7 +669,10 @@ export default function Logs() { setMessageQuery(event.target.value)} + onChange={(event) => { + 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" /> @@ -483,7 +683,10 @@ export default function Logs() { setFromDate(event.target.value)} + onChange={(event) => { + 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" />
@@ -493,7 +696,10 @@ export default function Logs() { setToDate(event.target.value)} + onChange={(event) => { + 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" />
@@ -520,12 +726,13 @@ export default function Logs() { Status Nachricht Endpoint + Aktionen {logs.length === 0 && !loading && ( - + Keine Logs vorhanden. @@ -552,12 +759,52 @@ export default function Logs() { {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} + + +
+ )} +
); } From 044164a440b6367d4f5a85deee4bca52facfce2b Mon Sep 17 00:00:00 2001 From: root Date: Tue, 30 Sep 2025 09:15:18 +0000 Subject: [PATCH 8/8] Database/LoggingExtended --- backend/data/stackpulse.db-shm | Bin 32768 -> 32768 bytes backend/data/stackpulse.db-wal | Bin 8272 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/backend/data/stackpulse.db-shm b/backend/data/stackpulse.db-shm index 50e066a7fea23e8d411259b916f9392eb600e852..fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10 100644 GIT binary patch delta 80 zcmZo@U}|V!;+1%$%K!t66DLZGTChv7nNGgVi7p`mlYpuI4+Il)WHv5{aoxB;#tHzK C2@}Nt delta 155 zcmZo@U}|V!s+V}A%K!pQK+MR%AOND7fq3?f5|=W$y1?aggZ6I}lw#RYa!GzN=VD$` h)dP(J1CY7@kpNV9Vm&7-NQ!A=W4GR1Pit^n&UkRTHfGsDEiFJ+ufR+KZ~<=@A^ z$e+W&p93UD!DtAKhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kgR#UT*D#KB-J zC@&qzXk=hys%v1WYh