Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3d802eeef | |||
| ca59231480 | |||
| 1d0ece6940 | |||
| 5fcc7fdbf1 | |||
| 3b12557a89 | |||
| 666e4e67ad | |||
| 11f4759e78 | |||
| de4a05a6e6 | |||
| b682f3378c | |||
| 56891182d5 | |||
| d25d34b8cd | |||
| 016cb6671a | |||
| 9313c01637 | |||
| 96b1446292 | |||
| 14b1459767 | |||
| e2cc586584 | |||
| c71025d717 | |||
| e8aeecec43 | |||
| 4e63ae529e | |||
| a8c01b9cb2 | |||
| b6579f511d | |||
| db46b366ab | |||
| efa732f347 | |||
| a09a68ed39 | |||
| 81f7660768 | |||
| a53a91caeb | |||
| 841a1713d2 | |||
| 737495d186 | |||
| 6ed9b94d3a | |||
| 95336e4350 | |||
| 8a2b4a2ac5 | |||
| cf0b51eb7d | |||
| 3875d275e0 | |||
| 3f68c6e39a | |||
| 53f1b55157 | |||
| dc9b92bdff | |||
| 6e7ec194af | |||
| 3003084112 | |||
| 4592daff36 | |||
| 3c16c4d8e4 | |||
| 959af0cdd4 | |||
| 777ea4d74f | |||
| a9228a2557 | |||
| 2b745e3d78 | |||
| eada55b8d7 | |||
| b1f5ac03f6 | |||
| 2c78ab966d | |||
| cd003a12f8 | |||
| 752f8170ba | |||
| be1890582c | |||
| b74d49a836 | |||
| cba236b95f | |||
| 040cd4d3cb | |||
| 3649d2ee04 | |||
| 0a6d17c64e | |||
| 7a69dd0821 | |||
| 4053db4a3e | |||
| 35d64d6765 | |||
| 6000641e50 | |||
| d349d31d02 | |||
| 4241bb3114 | |||
| 08c37245e1 | |||
| ef33a0d669 | |||
| ec2d998bc5 | |||
| 7cb1201b51 | |||
| 8fa5ffc49c | |||
| e10d0dec1c | |||
| 5483ee9092 | |||
| 02fe83d65c | |||
| d99ffc1488 | |||
| 2b051d5085 | |||
| f46ba1c820 | |||
| f14c3aa6dd | |||
| 044164a440 | |||
| c37b046e39 | |||
| cc7d80e569 | |||
| deb05814e4 | |||
| 2b284195fb | |||
| 24c98aff3e | |||
| b33e9e587b | |||
| b1fdd074ec | |||
| 3a961270d7 | |||
| 0a65f3e60f | |||
| 38b96da4e7 | |||
| 241a509a15 | |||
| 520e36fe85 | |||
| 1f248e3920 | |||
| ba0905e2b9 | |||
| 876acfa763 | |||
| ccd7fffe5c | |||
| ee7ccd0a39 | |||
| 6687a86802 | |||
| c7d63a28c4 | |||
| d91655aee9 | |||
| 8001a2be37 | |||
| 7f241d352d | |||
| 32af9d2584 | |||
| 55a83267a6 | |||
| dc56f0cbeb | |||
| 66d975ae75 | |||
| 571b185b9b | |||
| 8c59fc5445 | |||
| 6bf4f7aab4 | |||
| cd7933ebc7 | |||
| 3b0bd3c632 | |||
| 281aeb40a6 | |||
| 429721d9da | |||
| 47f632f8fc | |||
| 2e7ebfe06d | |||
| 03b3766a22 | |||
| d1adcb1188 | |||
| e38269e23e | |||
| 81f0e7eaf0 | |||
| 7980837eaf | |||
| 31b506ceb8 | |||
| 44e3754fcb | |||
| 73d225cc79 | |||
| a1e20f188e | |||
| 421e6e47dd | |||
| 79e420d3e3 | |||
| ee3b907cd6 | |||
| 960832af6b | |||
| 3489ea55a2 | |||
| ca148247cb | |||
| bff6521658 | |||
| ef8920cb26 | |||
| 279ad3ffd1 |
+1
-1
@@ -11,4 +11,4 @@ frontend/dist
|
|||||||
backend/.env
|
backend/.env
|
||||||
backend/node_modules
|
backend/node_modules
|
||||||
frontend/node_modules
|
frontend/node_modules
|
||||||
|
backend/data/*.db*
|
||||||
|
|||||||
+4
-1
@@ -11,4 +11,7 @@ backend/node_modules
|
|||||||
backend/.env
|
backend/.env
|
||||||
frontend/node_modules
|
frontend/node_modules
|
||||||
docker-compose.dev.yml
|
docker-compose.dev.yml
|
||||||
scripts/docker-release.sh
|
devscripts
|
||||||
|
backend/data/stackpulse.db
|
||||||
|
backend/data/stackpulse.db-shm
|
||||||
|
backend/data/stackpulse.db-wal
|
||||||
|
|||||||
+11
@@ -22,6 +22,9 @@ RUN npm run build
|
|||||||
# ===============================
|
# ===============================
|
||||||
FROM node:20-alpine AS runtime
|
FROM node:20-alpine AS runtime
|
||||||
|
|
||||||
|
#Install SSH-Client
|
||||||
|
RUN apk add --no-cache openssh-client
|
||||||
|
|
||||||
# Arbeitsverzeichnis
|
# Arbeitsverzeichnis
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
|
|
||||||
@@ -31,10 +34,17 @@ RUN npm ci --only=production
|
|||||||
|
|
||||||
# Backend-Code kopieren
|
# Backend-Code kopieren
|
||||||
COPY backend/ ./
|
COPY backend/ ./
|
||||||
|
COPY backend/docker-entrypoint.sh ./docker-entrypoint.sh
|
||||||
|
|
||||||
# public leeren
|
# public leeren
|
||||||
RUN rm -rf ./public/*
|
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
|
# Inhalt von dist inklusive Unterordner direkt nach public kopieren
|
||||||
COPY --from=frontend-build /app/frontend/dist/. ./public/
|
COPY --from=frontend-build /app/frontend/dist/. ./public/
|
||||||
|
|
||||||
@@ -50,4 +60,5 @@ EXPOSE 5173
|
|||||||
USER node
|
USER node
|
||||||
|
|
||||||
# Container startet das Backend (liefert statisches Frontend)
|
# Container startet das Backend (liefert statisches Frontend)
|
||||||
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.js"]
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
# 📦 StackPulse
|

|
||||||
|
|
||||||
|
# 📦 StackPulse 
|
||||||
|
|
||||||
**StackPulse** ist eine kleine Web-App, die über die Portainer-API deine Docker-Stacks verwaltet und aktualisiert.
|
**StackPulse** ist eine kleine Web-App, die über die Portainer-API deine Docker-Stacks verwaltet und aktualisiert.
|
||||||
Sie besteht aus einem **Backend (Node.js/Express)** und einem **Frontend (React/Tailwind)**.
|
Sie besteht aus einem **Backend (Node.js/Express)** und einem **Frontend (React/Tailwind)**.
|
||||||
@@ -10,14 +12,75 @@ Ziel:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Features (0.1 Roadmap)
|
## 🚀 Features & Roadmap
|
||||||
|
|
||||||
- [x] Projektstruktur mit Frontend & Backend
|
<details>
|
||||||
- [x] Lokales Startskript (`scripts/start-dev.sh`)
|
<summary>✅ v0.1 – Initial Release</summary>
|
||||||
- [x] Frontend zeigt Stacks an (über Backend)
|
|
||||||
- [x] API-Verbindung zu Portainer
|
- Projektstruktur mit Frontend & Backend
|
||||||
- [x] Stack Redeploy
|
- Lokales Startskript (`scripts/start-dev.sh`)
|
||||||
- [x] Docker Image im ghcr zur Verfügung stellen
|
- Frontend zeigt Stacks an (über Backend)
|
||||||
|
- API-Verbindung zu Portainer
|
||||||
|
- Stack Redeploy
|
||||||
|
- Bereitstellung eines Docker Images über GHCR
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary>✅ v0.2 – Release</summary>
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- [x] Anbindung einer SQLite-Datenbank
|
||||||
|
- [x] Logging der Redeploy-Aktionen in SQLite speichern
|
||||||
|
- [x] API-Endpunkte für Log-Abfragen
|
||||||
|
- [x] Funktionen für Pagination, Löschen und Export
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- [x] Anzeige der Logs (inkl. Statusfarben)
|
||||||
|
- [x] UI-Komponenten für Log-Details
|
||||||
|
- [x] Filterfunktionen für die Logs
|
||||||
|
- [x] Pagination, Lösch- und Exportanzeigen
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- [x] Selektive Auswahl: einzelne Stacks oder Services neu deployen
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary>🟡 v0.3 – In Entwicklung</summary>
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- [ ] Automatische Datenbereinigung: Duplikate bei Stack-IDs erkennen & entfernen
|
||||||
|
- [ ] Erweiterung der Logs
|
||||||
|
- [x] API für Filter & Suche (Stacks nach Name/Status abrufen)
|
||||||
|
- [ ] API für Sortierung der Stacks
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- [x] Filter: Stacks nach Name oder Status durchsuchen
|
||||||
|
- [ ] Sortierung: Stacks im Frontend sortieren
|
||||||
|
- [ ] Benachrichtigungen im UI: erfolgreicher/fehlgeschlagener Redeploy (Toast + Notification-Center)
|
||||||
|
- [ ] Visualisierung der Datenbereinigung (Konflikt/Auto-Fix Meldungen)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- [ ] Frontend-Filter und Sortierungen für schnellere Navigation bei vielen Stacks
|
||||||
|
- [ ] Echtzeit-Feedback im UI (Notifications)
|
||||||
|
- [ ] Datenkonsistenz sichern: keine doppelten Stack-IDs mehr
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>🔮 Geplante Features (v0.4+)</summary>
|
||||||
|
|
||||||
|
- Notifications (z. B. via Webhooks oder Mail)
|
||||||
|
- Authentifizierung & Benutzerverwaltung
|
||||||
|
- Monitoring (Status, CPU/RAM)
|
||||||
|
- Verbesserte UI/UX
|
||||||
|
- Export/Import von Scheduler-Jobs (z. B. JSON)
|
||||||
|
- Health-Checks nach Redeploy (automatisierte Tests, Statusprüfung)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,13 +89,11 @@ Ziel:
|
|||||||
```bash
|
```bash
|
||||||
stackpulse/
|
stackpulse/
|
||||||
├── backend/ # Node.js Backend mit Express
|
├── backend/ # Node.js Backend mit Express
|
||||||
|
│ └── data # SQlite Datenbank
|
||||||
|
│ └── db # Datenbank-Integration
|
||||||
├── frontend/ # React Frontend mit Tailwind
|
├── frontend/ # React Frontend mit Tailwind
|
||||||
├── scripts/ # Lokale Hilfsskripte (nicht Teil des Images)
|
├── scripts/ # Lokale Hilfsskripte (nicht Teil des Images)
|
||||||
│ ├── git-push.sh #nicht relevant
|
|
||||||
│ └── merge-dev-to-master.sh #nicht relevant
|
|
||||||
│ └── merge-feature-to-dev.sh #nicht relevant
|
|
||||||
│ └── start-dev.sh #Skript für den lokalen Start
|
│ └── start-dev.sh #Skript für den lokalen Start
|
||||||
│ └── switch-branch.sh #nicht relevant
|
|
||||||
├── Dockerfile # Multi-Stage Build für Frontend + Backend
|
├── Dockerfile # Multi-Stage Build für Frontend + Backend
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
└── README.md
|
└── README.md
|
||||||
@@ -63,12 +124,32 @@ services:
|
|||||||
image: ghcr.io/mboehmlaender/stackpulse
|
image: ghcr.io/mboehmlaender/stackpulse
|
||||||
ports:
|
ports:
|
||||||
- '4001:4001'
|
- '4001:4001'
|
||||||
|
volumes:
|
||||||
|
- stackpulse_data:/app/backend/data
|
||||||
environment:
|
environment:
|
||||||
- PORTAINER_URL=Deine_Portainer_Adresse/
|
PORTAINER_URL: "Your_Portainer_Server_Address"
|
||||||
- PORTAINER_API_KEY=Dein_Portainer_API_Key
|
PORTAINER_API_KEY: "Your_Portainer_API_Key"
|
||||||
- PORTAINER_ENDPOINT_ID=Deine_Portainer_Endpoint_ID
|
PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_ID"
|
||||||
|
SELF_STACK_ID: "Stackpulse ID"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
stackpulse_data:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Die PORTAINER_ENDPOINT_ID erhältst du, wenn du die die URL im Browser ansiehst, wenn du das Dashboard in Portainer öffnest:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Die 3 wäre in diesem Fall Endpoint-ID.
|
||||||
|
|
||||||
|
Die STACK_SELF_ID findest du, wenn du das Frontend von StackPulse öffnest:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Diese ID kann erst nach dem Deploy von Stackpulse ausgelesen werden. Vergiss daher nicht, nach dem Hinterlegen der ID in den Variablen das Stack noch einmal zu redeployen!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Voraussetzungen
|
## 📋 Voraussetzungen
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1 @@
|
|||||||
|
stackpulse.db
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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,
|
||||||
|
redeploy_type TEXT
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.exec(createRedeployLogsTable);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const columns = db.prepare('PRAGMA table_info(redeploy_logs)').all();
|
||||||
|
const hasRedeployType = columns.some((column) => column.name === 'redeploy_type');
|
||||||
|
if (!hasRedeployType) {
|
||||||
|
db.exec('ALTER TABLE redeploy_logs ADD COLUMN redeploy_type TEXT');
|
||||||
|
console.log('ℹ️ redeploy_type column hinzugefügt');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('⚠️ Konnte redeploy_type Spalte nicht prüfen/erstellen:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ redeploy_logs table ready');
|
||||||
|
|
||||||
|
db.close();
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { db } from './index.js';
|
||||||
|
|
||||||
|
const valueToArray = (value) => {
|
||||||
|
if (!value && value !== 0) return [];
|
||||||
|
const base = Array.isArray(value) ? value : [value];
|
||||||
|
return base
|
||||||
|
.flatMap((entry) => String(entry).split(','))
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const singleValue = (value) => {
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
return Array.isArray(value) ? value[0] : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildLogFilter(queryParams = {}) {
|
||||||
|
const filters = [];
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
const ids = valueToArray(queryParams.ids ?? queryParams.id)
|
||||||
|
.map((entry) => Number(entry))
|
||||||
|
.filter((value) => !Number.isNaN(value));
|
||||||
|
if (ids.length) {
|
||||||
|
const placeholders = ids.map((_, idx) => `@id${idx}`);
|
||||||
|
filters.push(`id IN (${placeholders.join(', ')})`);
|
||||||
|
ids.forEach((value, idx) => {
|
||||||
|
params[`id${idx}`] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stackIds = valueToArray(queryParams.stackIds ?? queryParams.stackId);
|
||||||
|
if (stackIds.length) {
|
||||||
|
const placeholders = stackIds.map((_, idx) => `@stackId${idx}`);
|
||||||
|
filters.push(`stack_id IN (${placeholders.join(', ')})`);
|
||||||
|
stackIds.forEach((stack, idx) => {
|
||||||
|
params[`stackId${idx}`] = stack;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const statuses = valueToArray(queryParams.statuses ?? queryParams.status);
|
||||||
|
if (statuses.length) {
|
||||||
|
const placeholders = statuses.map((_, idx) => `@status${idx}`);
|
||||||
|
filters.push(`status IN (${placeholders.join(', ')})`);
|
||||||
|
statuses.forEach((entry, idx) => {
|
||||||
|
params[`status${idx}`] = entry;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoints = valueToArray(queryParams.endpoints ?? queryParams.endpoint);
|
||||||
|
if (endpoints.length) {
|
||||||
|
const placeholders = endpoints.map((_, idx) => `@endpoint${idx}`);
|
||||||
|
filters.push(`endpoint IN (${placeholders.join(', ')})`);
|
||||||
|
endpoints.forEach((entry, idx) => {
|
||||||
|
const numeric = Number(entry);
|
||||||
|
params[`endpoint${idx}`] = Number.isNaN(numeric) ? entry : numeric;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const redeployTypes = valueToArray(queryParams.redeployTypes ?? queryParams.redeployType);
|
||||||
|
if (redeployTypes.length) {
|
||||||
|
const placeholders = redeployTypes.map((_, idx) => `@redeployType${idx}`);
|
||||||
|
filters.push(`redeploy_type IN (${placeholders.join(', ')})`);
|
||||||
|
redeployTypes.forEach((entry, idx) => {
|
||||||
|
params[`redeployType${idx}`] = entry;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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, redeploy_type)
|
||||||
|
VALUES (@stackId, @stackName, @status, @message, @endpoint, @redeployType)
|
||||||
|
`);
|
||||||
|
|
||||||
|
export function logRedeployEvent({ stackId, stackName, status, message = null, endpoint = null, redeployType = null }) {
|
||||||
|
try {
|
||||||
|
insertRedeployLogStmt.run({
|
||||||
|
stackId: String(stackId),
|
||||||
|
stackName: stackName ?? 'Unknown',
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
endpoint,
|
||||||
|
redeployType: redeployType ?? null
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Fehler beim Speichern des Redeploy-Logs:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteLogById(id) {
|
||||||
|
const stmt = db.prepare('DELETE FROM redeploy_logs WHERE id = ?');
|
||||||
|
const info = stmt.run(id);
|
||||||
|
return info.changes || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteLogsByFilters(queryParams = {}) {
|
||||||
|
const { whereClause, params } = buildLogFilter(queryParams);
|
||||||
|
const stmt = db.prepare(`DELETE FROM redeploy_logs ${whereClause}`);
|
||||||
|
const info = stmt.run(params);
|
||||||
|
return info.changes || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportLogsByFilters(queryParams = {}, format = 'txt') {
|
||||||
|
const { whereClause, params } = buildLogFilter(queryParams);
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT id, timestamp, stack_id AS stackId, stack_name AS stackName, status, message, endpoint, redeploy_type AS redeployType
|
||||||
|
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', 'redeploy_type'];
|
||||||
|
const values = [
|
||||||
|
row.id,
|
||||||
|
row.timestamp,
|
||||||
|
row.stackId,
|
||||||
|
row.stackName,
|
||||||
|
row.status,
|
||||||
|
row.message,
|
||||||
|
row.endpoint,
|
||||||
|
row.redeployType
|
||||||
|
].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 ?? '-'}`,
|
||||||
|
`Redeploy: ${row.redeployType ?? '-'}`
|
||||||
|
];
|
||||||
|
return parts.join(' | ');
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename: `redeploy-logs-${timestamp}.txt`,
|
||||||
|
contentType: 'text/plain; charset=utf-8',
|
||||||
|
content: lines.join('\n')
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { db } from './index.js';
|
||||||
|
|
||||||
|
const CREATE_TABLE_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.prepare(CREATE_TABLE_SQL).run();
|
||||||
|
|
||||||
|
const getSettingStmt = db.prepare('SELECT key, value, updated_at FROM settings WHERE key = ?');
|
||||||
|
const setSettingStmt = db.prepare(`
|
||||||
|
INSERT INTO settings (key, value, updated_at)
|
||||||
|
VALUES (@key, @value, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`);
|
||||||
|
const deleteSettingStmt = db.prepare('DELETE FROM settings WHERE key = ?');
|
||||||
|
|
||||||
|
export function getSetting(key) {
|
||||||
|
try {
|
||||||
|
return getSettingStmt.get(key) || null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ [Settings] Fehler beim Lesen des Settings "${key}":`, err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSetting(key, value) {
|
||||||
|
try {
|
||||||
|
setSettingStmt.run({ key, value });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ [Settings] Fehler beim Speichern des Settings "${key}":`, err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSetting(key) {
|
||||||
|
try {
|
||||||
|
deleteSettingStmt.run(key);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ [Settings] Fehler beim Löschen des Settings "${key}":`, err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJsonSetting(key, defaultValue = null) {
|
||||||
|
const row = getSetting(key);
|
||||||
|
if (!row || row.value === null || row.value === undefined) return defaultValue;
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.value);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`⚠️ [Settings] Konnte JSON für Setting "${key}" nicht parsen:`, err.message);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setJsonSetting(key, value) {
|
||||||
|
try {
|
||||||
|
const payload = value === undefined ? null : JSON.stringify(value);
|
||||||
|
return setSetting(key, payload);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ [Settings] Fehler beim Serialisieren von Setting "${key}":`, err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔧 Starte Migration (idempotent)..."
|
||||||
|
node db/migrate.js
|
||||||
|
|
||||||
|
echo "✅ Migration abgeschlossen. Starte Anwendung..."
|
||||||
|
exec "$@"
|
||||||
+1759
-69
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
|||||||
|
import { getJsonSetting, setJsonSetting } from '../db/settings.js';
|
||||||
|
|
||||||
|
const MAINTENANCE_KEY = 'maintenance_mode';
|
||||||
|
|
||||||
|
const DEFAULT_STATE = {
|
||||||
|
active: false,
|
||||||
|
message: null,
|
||||||
|
activatedAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
extra: null
|
||||||
|
};
|
||||||
|
|
||||||
|
let maintenanceState = loadState();
|
||||||
|
|
||||||
|
function loadState() {
|
||||||
|
const saved = getJsonSetting(MAINTENANCE_KEY, null);
|
||||||
|
if (!saved) {
|
||||||
|
const initial = { ...DEFAULT_STATE, updatedAt: new Date().toISOString() };
|
||||||
|
setJsonSetting(MAINTENANCE_KEY, initial);
|
||||||
|
return initial;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
...saved
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistState(state) {
|
||||||
|
maintenanceState = {
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
...state,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
setJsonSetting(MAINTENANCE_KEY, maintenanceState);
|
||||||
|
return maintenanceState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMaintenanceState() {
|
||||||
|
return { ...maintenanceState };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMaintenanceModeActive() {
|
||||||
|
return Boolean(maintenanceState?.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activateMaintenanceMode({ message = null, extra = null } = {}) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return persistState({
|
||||||
|
active: true,
|
||||||
|
message,
|
||||||
|
extra,
|
||||||
|
activatedAt: now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deactivateMaintenanceMode({ message = null } = {}) {
|
||||||
|
return persistState({
|
||||||
|
active: false,
|
||||||
|
message,
|
||||||
|
extra: null,
|
||||||
|
activatedAt: null
|
||||||
|
});
|
||||||
|
}
|
||||||
Generated
+219
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
|
"better-sqlite3": "^9.6.0",
|
||||||
"dockerode": "^4.0.7",
|
"dockerode": "^4.0.7",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"express": "^4.18.0",
|
"express": "^4.18.0",
|
||||||
@@ -230,6 +231,24 @@
|
|||||||
"tweetnacl": "^0.14.3"
|
"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": {
|
"node_modules/bl": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||||
@@ -441,6 +460,28 @@
|
|||||||
"ms": "2.0.0"
|
"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": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
@@ -466,6 +507,14 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"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": {
|
"node_modules/docker-modem": {
|
||||||
"version": "5.0.6",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz",
|
||||||
@@ -686,6 +735,14 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/express": {
|
||||||
"version": "4.21.2",
|
"version": "4.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||||
@@ -731,6 +788,11 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/finalhandler": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||||
@@ -854,6 +916,11 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -1039,6 +1111,25 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/mkdirp-classic": {
|
||||||
"version": "0.5.3",
|
"version": "0.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||||
@@ -1055,6 +1146,11 @@
|
|||||||
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
|
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
|
||||||
"optional": true
|
"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": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
@@ -1063,6 +1159,17 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
|
"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": {
|
"node_modules/protobufjs": {
|
||||||
"version": "7.5.4",
|
"version": "7.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
||||||
@@ -1199,6 +1331,20 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/readable-stream": {
|
||||||
"version": "3.6.2",
|
"version": "3.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
"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": {
|
"node_modules/send": {
|
||||||
"version": "0.19.0",
|
"version": "0.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||||
@@ -1367,6 +1524,49 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/socket.io": {
|
||||||
"version": "4.8.1",
|
"version": "4.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
|
||||||
@@ -1530,6 +1730,14 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/tar-fs": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||||
@@ -1564,6 +1772,17 @@
|
|||||||
"node": ">=0.6"
|
"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": {
|
"node_modules/tweetnacl": {
|
||||||
"version": "0.14.5",
|
"version": "0.14.5",
|
||||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||||
|
|||||||
+17
-1
@@ -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"}}
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -6,8 +6,14 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "4001:4001"
|
- "4001:4001"
|
||||||
|
volumes:
|
||||||
|
- stackpulse_data:/app/backend/data
|
||||||
environment:
|
environment:
|
||||||
PORTAINER_URL: "Your_Portainer_Server_Address"
|
PORTAINER_URL: "Your_Portainer_Server_Address"
|
||||||
PORTAINER_API_KEY: "Your_Portainer_API_Key"
|
PORTAINER_API_KEY: "Your_Portainer_API_Key"
|
||||||
PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_ID"
|
PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_ID"
|
||||||
|
SELF_STACK_ID: "Stackpulse ID"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
stackpulse_data:
|
||||||
|
|||||||
Generated
+39
@@ -11,6 +11,7 @@
|
|||||||
"axios": "^1.7.5",
|
"axios": "^1.7.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.26.2",
|
||||||
"socket.io-client": "^4.8.1"
|
"socket.io-client": "^4.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -758,6 +759,14 @@
|
|||||||
"node": ">=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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -2188,6 +2197,36 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"axios": "^1.7.5",
|
"axios": "^1.7.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.26.2",
|
||||||
"socket.io-client": "^4.8.1"
|
"socket.io-client": "^4.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+54
-5
@@ -1,15 +1,64 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { NavLink, Route, Routes } from "react-router-dom";
|
||||||
import Stacks from "./Stacks.jsx";
|
import Stacks from "./Stacks.jsx";
|
||||||
|
import Logs from "./Logs.jsx";
|
||||||
|
import Maintenance from "./Maintenance.jsx";
|
||||||
|
import logo from "./assets/images/stackpulse.png";
|
||||||
|
import { useMaintenance } from "./context/MaintenanceContext.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() {
|
export default function App() {
|
||||||
|
const { maintenance, update } = useMaintenance();
|
||||||
|
const maintenanceActive = Boolean(maintenance?.active);
|
||||||
|
const maintenanceLabel = maintenance?.message || (update?.running ? "Portainer-Update läuft" : "Wartungsmodus aktiv");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white">
|
<div className="min-h-screen bg-gray-900 text-white">
|
||||||
<header className="p-6 bg-gray-800 shadow-md">
|
<header className="bg-gray-800 shadow-md">
|
||||||
<h1 className="text-2xl font-bold text-white">StackPulse</h1>
|
<div className="max-w-6xl mx-auto px-6 py-6">
|
||||||
<p className="text-gray-400 mt-1">Verwalte deine Docker Stacks</p>
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<span className="text-xs text-gray-500">v0.3</span>
|
||||||
|
{maintenanceActive && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-amber-300">
|
||||||
|
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
|
||||||
|
{maintenanceLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<img src={logo} alt="StackPulse" className="h-10 w-auto" />
|
||||||
|
</div>
|
||||||
|
<nav className="flex gap-2 items-end">
|
||||||
|
<NavLink to="/" end className={getNavClass}>
|
||||||
|
Stacks
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/maintenance" className={getNavClass}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
Wartung
|
||||||
|
{maintenanceActive && (
|
||||||
|
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink to="/logs" className={getNavClass}>
|
||||||
|
Logs
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="p-6">
|
<main className="max-w-6xl mx-auto px-6 py-6">
|
||||||
<Stacks />
|
<Routes>
|
||||||
|
<Route path="/" element={<Stacks />} />
|
||||||
|
<Route path="/maintenance" element={<Maintenance />} />
|
||||||
|
<Route path="/logs" element={<Logs />} />
|
||||||
|
<Route path="*" element={<Stacks />} />
|
||||||
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,935 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
success: "text-green-400",
|
||||||
|
warning: "text-yellow-400",
|
||||||
|
error: "text-red-400",
|
||||||
|
started: "text-blue-300"
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (value) => {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("de-DE", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDateParam = (value) => {
|
||||||
|
if (!value) return undefined;
|
||||||
|
return value.replace("T", " ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILTER_STORAGE_KEY = "redeployLogFilters";
|
||||||
|
const ALL_OPTION_VALUE = "__all__";
|
||||||
|
const ALL_OPTION_LABEL = "- Alle -";
|
||||||
|
const PER_PAGE_DEFAULT = "50";
|
||||||
|
const PER_PAGE_OPTIONS = [
|
||||||
|
{ value: "10", label: "10" },
|
||||||
|
{ value: "25", label: "25" },
|
||||||
|
{ value: "50", label: "50" },
|
||||||
|
{ value: "100", label: "100" },
|
||||||
|
{ value: "all", label: "Alle" }
|
||||||
|
];
|
||||||
|
const VALID_PER_PAGE_VALUES = new Set(PER_PAGE_OPTIONS.map((option) => option.value));
|
||||||
|
|
||||||
|
const REDEPLOY_TYPE_LABELS = {
|
||||||
|
Einzeln: "Einzeln",
|
||||||
|
Alle: "Alle",
|
||||||
|
Auswahl: "Auswahl",
|
||||||
|
single: "Einzeln",
|
||||||
|
all: "Alle",
|
||||||
|
selection: "Auswahl"
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = (filters) => Boolean(
|
||||||
|
(filters.stacks && filters.stacks.length) ||
|
||||||
|
(filters.statuses && filters.statuses.length) ||
|
||||||
|
(filters.endpoints && filters.endpoints.length) ||
|
||||||
|
(filters.redeployTypes && filters.redeployTypes.length) ||
|
||||||
|
(filters.message && filters.message.trim()) ||
|
||||||
|
(filters.from && filters.from.trim()) ||
|
||||||
|
(filters.to && filters.to.trim())
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Logs() {
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
const [totalLogs, setTotalLogs] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
|
||||||
|
const [stackOptions, setStackOptions] = useState([]);
|
||||||
|
const [statusOptions, setStatusOptions] = useState([]);
|
||||||
|
const [endpointOptions, setEndpointOptions] = useState([]);
|
||||||
|
const [redeployTypeOptions, setRedeployTypeOptions] = useState([]);
|
||||||
|
|
||||||
|
const [selectedStacks, setSelectedStacks] = useState([]);
|
||||||
|
const [selectedStatuses, setSelectedStatuses] = useState([]);
|
||||||
|
const [selectedEndpoints, setSelectedEndpoints] = useState([]);
|
||||||
|
const [selectedRedeployTypes, setSelectedRedeployTypes] = useState([]);
|
||||||
|
const [messageQuery, setMessageQuery] = useState("");
|
||||||
|
const [fromDate, setFromDate] = useState("");
|
||||||
|
const [toDate, setToDate] = useState("");
|
||||||
|
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
|
const [filtersReady, setFiltersReady] = useState(false);
|
||||||
|
const [optionsInitialized, setOptionsInitialized] = useState(false);
|
||||||
|
const [refreshSignal, setRefreshSignal] = useState(0);
|
||||||
|
|
||||||
|
const [perPage, setPerPage] = useState(PER_PAGE_DEFAULT);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const updateFilterOptions = useCallback((payload) => {
|
||||||
|
const logsPayload = Array.isArray(payload) ? payload : payload?.items ?? [];
|
||||||
|
|
||||||
|
const stackMap = new Map();
|
||||||
|
const statusSet = new Set();
|
||||||
|
const endpointSet = new Set();
|
||||||
|
const redeployTypeSet = new Set();
|
||||||
|
|
||||||
|
logsPayload.forEach((log) => {
|
||||||
|
if (log.stackId) {
|
||||||
|
const value = String(log.stackId);
|
||||||
|
const label = log.stackName || `Stack ${value}`;
|
||||||
|
stackMap.set(value, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log.status) {
|
||||||
|
statusSet.add(log.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log.endpoint !== null && log.endpoint !== undefined && log.endpoint !== "") {
|
||||||
|
endpointSet.add(String(log.endpoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log.redeployType) {
|
||||||
|
redeployTypeSet.add(log.redeployType);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setStackOptions(Array.from(stackMap.entries())
|
||||||
|
.map(([value, label]) => ({ value, label }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label)));
|
||||||
|
|
||||||
|
setStatusOptions(Array.from(statusSet).sort());
|
||||||
|
|
||||||
|
setEndpointOptions(Array.from(endpointSet)
|
||||||
|
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })));
|
||||||
|
|
||||||
|
setRedeployTypeOptions(Array.from(redeployTypeSet).sort());
|
||||||
|
setOptionsInitialized(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stackLabelMap = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
stackOptions.forEach((option) => {
|
||||||
|
map.set(option.value, option.label);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [stackOptions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!optionsInitialized) return;
|
||||||
|
setSelectedStacks((prev) => {
|
||||||
|
const valid = prev.filter((value) => stackOptions.some((option) => option.value === value));
|
||||||
|
return valid.length === prev.length ? prev : valid;
|
||||||
|
});
|
||||||
|
}, [optionsInitialized, stackOptions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!optionsInitialized) return;
|
||||||
|
setSelectedStatuses((prev) => {
|
||||||
|
const valid = prev.filter((value) => statusOptions.includes(value));
|
||||||
|
return valid.length === prev.length ? prev : valid;
|
||||||
|
});
|
||||||
|
}, [optionsInitialized, statusOptions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!optionsInitialized) return;
|
||||||
|
setSelectedEndpoints((prev) => {
|
||||||
|
const valid = prev.filter((value) => endpointOptions.includes(value));
|
||||||
|
return valid.length === prev.length ? prev : valid;
|
||||||
|
});
|
||||||
|
}, [optionsInitialized, endpointOptions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!optionsInitialized) return;
|
||||||
|
setSelectedRedeployTypes((prev) => {
|
||||||
|
const valid = prev.filter((value) => redeployTypeOptions.includes(value));
|
||||||
|
return valid.length === prev.length ? prev : valid;
|
||||||
|
});
|
||||||
|
}, [optionsInitialized, redeployTypeOptions]);
|
||||||
|
|
||||||
|
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 storedPagination = parsed?.pagination ?? {};
|
||||||
|
|
||||||
|
setSelectedStacks(storedFilters.stacks || []);
|
||||||
|
setSelectedStatuses(storedFilters.statuses || []);
|
||||||
|
setSelectedEndpoints(storedFilters.endpoints || []);
|
||||||
|
setSelectedRedeployTypes(storedFilters.redeployTypes || []);
|
||||||
|
setMessageQuery(storedFilters.message || "");
|
||||||
|
setFromDate(storedFilters.from || "");
|
||||||
|
setToDate(storedFilters.to || "");
|
||||||
|
setFiltersOpen(hasActiveFilters(storedFilters));
|
||||||
|
|
||||||
|
const rawPerPage = storedPagination.perPage;
|
||||||
|
if (rawPerPage !== undefined) {
|
||||||
|
const parsedPerPage = String(rawPerPage);
|
||||||
|
if (VALID_PER_PAGE_VALUES.has(parsedPerPage)) {
|
||||||
|
setPerPage(parsedPerPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPage = storedPagination.page;
|
||||||
|
if (typeof rawPage === 'number' && rawPage > 0) {
|
||||||
|
setPage(rawPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (storageError) {
|
||||||
|
console.error("❌ Fehler beim Laden der gespeicherten Log-Filter:", storageError);
|
||||||
|
} finally {
|
||||||
|
setFiltersReady(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buildFilterParams = useCallback(() => {
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
if (selectedStacks.length) {
|
||||||
|
params.stackIds = selectedStacks.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedStatuses.length) {
|
||||||
|
params.statuses = selectedStatuses.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedEndpoints.length) {
|
||||||
|
params.endpoints = selectedEndpoints.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedRedeployTypes.length) {
|
||||||
|
params.redeployTypes = selectedRedeployTypes.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, selectedRedeployTypes, messageQuery, fromDate, toDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filtersReady) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
axios.get("/api/logs", { params: { perPage: 'all', page: 1 } })
|
||||||
|
.then((response) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
updateFilterOptions(response.data);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.error("⚠️ Konnte Filteroptionen nicht aktualisieren:", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [filtersReady, updateFilterOptions, refreshSignal]);
|
||||||
|
|
||||||
|
const currentFilters = useMemo(() => ({
|
||||||
|
stacks: selectedStacks,
|
||||||
|
statuses: selectedStatuses,
|
||||||
|
endpoints: selectedEndpoints,
|
||||||
|
redeployTypes: selectedRedeployTypes,
|
||||||
|
message: messageQuery,
|
||||||
|
from: fromDate,
|
||||||
|
to: toDate
|
||||||
|
}), [selectedStacks, selectedStatuses, selectedEndpoints, selectedRedeployTypes, messageQuery, fromDate, toDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filtersReady) return;
|
||||||
|
|
||||||
|
const params = { ...buildFilterParams() };
|
||||||
|
if (perPage === 'all') {
|
||||||
|
params.perPage = 'all';
|
||||||
|
params.page = 1;
|
||||||
|
} else {
|
||||||
|
params.perPage = perPage;
|
||||||
|
params.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
axios.get("/api/logs", { params })
|
||||||
|
.then((response) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const data = response.data || {};
|
||||||
|
const items = Array.isArray(data) ? data : data.items ?? [];
|
||||||
|
const total = Array.isArray(data) ? items.length : data.total ?? items.length;
|
||||||
|
|
||||||
|
setLogs(items);
|
||||||
|
setTotalLogs(total);
|
||||||
|
|
||||||
|
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,
|
||||||
|
pagination: {
|
||||||
|
perPage,
|
||||||
|
page
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (storageError) {
|
||||||
|
console.error("⚠️ Konnte Filter nicht speichern:", storageError);
|
||||||
|
}
|
||||||
|
}, [filtersReady, currentFilters, 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 handleOptionMouseDown = (event, currentValues, setter) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const { value } = event.target;
|
||||||
|
if (value === ALL_OPTION_VALUE) {
|
||||||
|
if (currentValues.length) {
|
||||||
|
setter([]);
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextValues = currentValues.includes(value)
|
||||||
|
? currentValues.filter((entry) => entry !== value)
|
||||||
|
: [...currentValues, value];
|
||||||
|
|
||||||
|
setter(nextValues);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetFilters = () => {
|
||||||
|
setSelectedStacks([]);
|
||||||
|
setSelectedStatuses([]);
|
||||||
|
setSelectedEndpoints([]);
|
||||||
|
setSelectedRedeployTypes([]);
|
||||||
|
setMessageQuery("");
|
||||||
|
setFromDate("");
|
||||||
|
setToDate("");
|
||||||
|
setFiltersOpen(false);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleFilters = () => {
|
||||||
|
setFiltersOpen((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
setRefreshSignal((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePerPageChange = (event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
if (!VALID_PER_PAGE_VALUES.has(value)) return;
|
||||||
|
setPerPage(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (nextPage) => {
|
||||||
|
if (perPage === 'all') return;
|
||||||
|
if (nextPage < 1) return;
|
||||||
|
const totalPages = Math.max(1, Math.ceil((totalLogs || 0) / Number(perPage)));
|
||||||
|
if (nextPage > totalPages) return;
|
||||||
|
setPage(nextPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stackSelectOptions = useMemo(() => {
|
||||||
|
const entries = stackOptions.filter((option) => option.value !== ALL_OPTION_VALUE);
|
||||||
|
return [
|
||||||
|
{ value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
|
||||||
|
...entries
|
||||||
|
];
|
||||||
|
}, [stackOptions]);
|
||||||
|
|
||||||
|
const statusSelectOptions = useMemo(() => {
|
||||||
|
const entries = statusOptions
|
||||||
|
.filter((status) => status !== ALL_OPTION_VALUE)
|
||||||
|
.map((status) => ({ value: status, label: status }));
|
||||||
|
return [
|
||||||
|
{ value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
|
||||||
|
...entries
|
||||||
|
];
|
||||||
|
}, [statusOptions]);
|
||||||
|
|
||||||
|
const endpointSelectOptions = useMemo(() => {
|
||||||
|
const entries = endpointOptions
|
||||||
|
.filter((endpoint) => endpoint !== ALL_OPTION_VALUE)
|
||||||
|
.map((endpoint) => ({ value: endpoint, label: endpoint }));
|
||||||
|
return [
|
||||||
|
{ value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
|
||||||
|
...entries
|
||||||
|
];
|
||||||
|
}, [endpointOptions]);
|
||||||
|
|
||||||
|
const redeployTypeSelectOptions = useMemo(() => {
|
||||||
|
const entries = redeployTypeOptions
|
||||||
|
.filter((type) => type !== ALL_OPTION_VALUE)
|
||||||
|
.map((type) => ({ value: type, label: REDEPLOY_TYPE_LABELS[type] ?? type }));
|
||||||
|
return [
|
||||||
|
{ value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
|
||||||
|
...entries
|
||||||
|
];
|
||||||
|
}, [redeployTypeOptions]);
|
||||||
|
|
||||||
|
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 (selectedRedeployTypes.length) count += selectedRedeployTypes.length;
|
||||||
|
if (messageQuery.trim()) count += 1;
|
||||||
|
if (fromDate) count += 1;
|
||||||
|
if (toDate) count += 1;
|
||||||
|
return count;
|
||||||
|
}, [selectedStacks, selectedStatuses, selectedEndpoints, messageQuery, fromDate, toDate]);
|
||||||
|
|
||||||
|
const totalPages = useMemo(() => {
|
||||||
|
if (perPage === 'all') return 1;
|
||||||
|
const numeric = Number(perPage) || Number(PER_PAGE_DEFAULT);
|
||||||
|
return Math.max(1, Math.ceil((totalLogs || 0) / numeric));
|
||||||
|
}, [perPage, totalLogs]);
|
||||||
|
|
||||||
|
const rangeStart = useMemo(() => {
|
||||||
|
if (totalLogs === 0) return 0;
|
||||||
|
if (perPage === 'all') return 1;
|
||||||
|
return (page - 1) * Number(perPage) + 1;
|
||||||
|
}, [totalLogs, perPage, page]);
|
||||||
|
|
||||||
|
const rangeEnd = useMemo(() => {
|
||||||
|
if (totalLogs === 0) return 0;
|
||||||
|
if (perPage === 'all') return totalLogs;
|
||||||
|
return Math.min(totalLogs, (page - 1) * Number(perPage) + logs.length);
|
||||||
|
}, [totalLogs, perPage, page, logs.length]);
|
||||||
|
|
||||||
|
const handleDeleteLog = async (id) => {
|
||||||
|
if (!window.confirm("Diesen Log-Eintrag dauerhaft löschen?")) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/logs/${id}`);
|
||||||
|
setRefreshSignal((prev) => prev + 1);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Fehler beim Löschen des Logs:", err);
|
||||||
|
setError("Fehler beim Löschen des Redeploy-Logs");
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFiltered = async () => {
|
||||||
|
if (!logs.length) return;
|
||||||
|
if (!window.confirm("Alle angezeigten Logs (entsprechend Filter) löschen?")) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
...buildFilterParams(),
|
||||||
|
ids: logs.map((log) => log.id).join(',')
|
||||||
|
};
|
||||||
|
await axios.delete("/api/logs", { params });
|
||||||
|
setRefreshSignal((prev) => prev + 1);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Fehler beim Löschen der gefilterten Logs:", err);
|
||||||
|
setError("Fehler beim Löschen der gefilterten Logs");
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async (format) => {
|
||||||
|
setActionLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await axios.get("/api/logs/export", {
|
||||||
|
params: { ...buildFilterParams(), format },
|
||||||
|
responseType: "blob"
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = response.headers["content-type"] || (format === "sql" ? "application/sql" : "text/plain");
|
||||||
|
const disposition = response.headers["content-disposition"] || "";
|
||||||
|
const match = disposition.match(/filename="?([^";]+)"?/i);
|
||||||
|
const filename = match?.[1] || `redeploy-logs.${format}`;
|
||||||
|
|
||||||
|
const blob = new Blob([response.data], { type: contentType });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Fehler beim Export der Logs:", err);
|
||||||
|
setError("Fehler beim Export der Redeploy-Logs");
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl space-y-4 p-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-100">Redeploy-Logs</h2>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport('txt')}
|
||||||
|
disabled={actionLoading || loading}
|
||||||
|
className="rounded-md border border-gray-600 px-3 py-1.5 text-sm text-gray-200 transition hover:bg-gray-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Export TXT
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport('sql')}
|
||||||
|
disabled={actionLoading || loading}
|
||||||
|
className="rounded-md border border-gray-600 px-3 py-1.5 text-sm text-gray-200 transition hover:bg-gray-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Export SQL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteFiltered}
|
||||||
|
disabled={actionLoading || loading || logs.length === 0}
|
||||||
|
className="rounded-md border border-red-600 px-3 py-1.5 text-sm text-red-300 transition hover:bg-red-600/20 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Angezeigte löschen
|
||||||
|
</button>
|
||||||
|
{loading && <span className="text-sm text-gray-400">Aktualisiere…</span>}
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="px-4 py-2 rounded-md font-medium transition bg-purple-500 hover:bg-purple-600 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-900/20 border border-red-500/40 text-red-300 px-4 py-3 rounded-lg">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-gray-700 bg-gray-800/60">
|
||||||
|
<button
|
||||||
|
onClick={handleToggleFilters}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium text-gray-200 hover:bg-gray-700/40"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
Filter {activeFilterCount > 0 && (
|
||||||
|
<span className="ml-2 rounded-full bg-purple-500/20 px-2 py-0.5 text-xs text-purple-300">
|
||||||
|
{activeFilterCount} aktiv
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs uppercase tracking-wide text-gray-400">
|
||||||
|
{filtersOpen ? "Ausblenden" : "Anzeigen"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{filtersOpen && (
|
||||||
|
<div className="space-y-4 border-t border-gray-700 px-4 py-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-300">Stack</label>
|
||||||
|
<select
|
||||||
|
multiple
|
||||||
|
value={selectedStacks}
|
||||||
|
onChange={handleMultiSelectChange(setSelectedStacks)}
|
||||||
|
className="w-full min-h-[8rem] 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"
|
||||||
|
>
|
||||||
|
{stackSelectOptions.map(({ value, label }) => (
|
||||||
|
<option
|
||||||
|
key={value}
|
||||||
|
value={value}
|
||||||
|
onMouseDown={(event) => handleOptionMouseDown(event, selectedStacks, setSelectedStacks)}
|
||||||
|
className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="mt-2 min-h-[1.5rem] text-xs text-gray-400">
|
||||||
|
{selectedStacks.length === 0 ? (
|
||||||
|
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
|
||||||
|
Alle Stacks
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedStacks.map((stackId) => (
|
||||||
|
<span
|
||||||
|
key={stackId}
|
||||||
|
className="rounded-full bg-purple-500/20 px-2 py-0.5 text-purple-200"
|
||||||
|
>
|
||||||
|
{stackLabelMap.get(stackId) ?? `Stack ${stackId}`}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-300">Status</label>
|
||||||
|
<select
|
||||||
|
multiple
|
||||||
|
value={selectedStatuses}
|
||||||
|
onChange={handleMultiSelectChange(setSelectedStatuses)}
|
||||||
|
className="w-full min-h-[8rem] 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"
|
||||||
|
>
|
||||||
|
{statusSelectOptions.map(({ value, label }) => (
|
||||||
|
<option
|
||||||
|
key={value}
|
||||||
|
value={value}
|
||||||
|
onMouseDown={(event) => handleOptionMouseDown(event, selectedStatuses, setSelectedStatuses)}
|
||||||
|
className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="mt-2 min-h-[1.5rem] text-xs text-gray-400">
|
||||||
|
{selectedStatuses.length === 0 ? (
|
||||||
|
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
|
||||||
|
Alle Status
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedStatuses.map((status) => (
|
||||||
|
<span
|
||||||
|
key={status}
|
||||||
|
className="rounded-full bg-amber-500/20 px-2 py-0.5 text-amber-200"
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-300">Endpoint</label>
|
||||||
|
<select
|
||||||
|
multiple
|
||||||
|
value={selectedEndpoints}
|
||||||
|
onChange={handleMultiSelectChange(setSelectedEndpoints)}
|
||||||
|
className="w-full min-h-[8rem] 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"
|
||||||
|
>
|
||||||
|
{endpointSelectOptions.map(({ value, label }) => (
|
||||||
|
<option
|
||||||
|
key={value}
|
||||||
|
value={value}
|
||||||
|
onMouseDown={(event) => handleOptionMouseDown(event, selectedEndpoints, setSelectedEndpoints)}
|
||||||
|
className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="mt-2 min-h-[1.5rem] text-xs text-gray-400">
|
||||||
|
{selectedEndpoints.length === 0 ? (
|
||||||
|
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
|
||||||
|
Alle Endpoints
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedEndpoints.map((endpoint) => (
|
||||||
|
<span
|
||||||
|
key={endpoint}
|
||||||
|
className="rounded-full bg-blue-500/20 px-2 py-0.5 text-blue-200"
|
||||||
|
>
|
||||||
|
{endpoint}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-300">Redeploy-Typ</label>
|
||||||
|
<select
|
||||||
|
multiple
|
||||||
|
value={selectedRedeployTypes}
|
||||||
|
onChange={handleMultiSelectChange(setSelectedRedeployTypes)}
|
||||||
|
className="w-full min-h-[8rem] 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"
|
||||||
|
>
|
||||||
|
{redeployTypeSelectOptions.map(({ value, label }) => (
|
||||||
|
<option
|
||||||
|
key={value}
|
||||||
|
value={value}
|
||||||
|
onMouseDown={(event) => handleOptionMouseDown(event, selectedRedeployTypes, setSelectedRedeployTypes)}
|
||||||
|
className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="mt-2 min-h-[1.5rem] text-xs text-gray-400">
|
||||||
|
{selectedRedeployTypes.length === 0 ? (
|
||||||
|
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
|
||||||
|
Alle Typen
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedRedeployTypes.map((type) => (
|
||||||
|
<span
|
||||||
|
key={type}
|
||||||
|
className="rounded-full bg-teal-500/20 px-2 py-0.5 text-teal-200"
|
||||||
|
>
|
||||||
|
{REDEPLOY_TYPE_LABELS[type] ?? type}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2 lg:col-span-4">
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-300">Nachricht (Freitext)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={messageQuery}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-300">Von</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={fromDate}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-300">Bis</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={toDate}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleResetFilters}
|
||||||
|
className="rounded-md border border-gray-600 px-4 py-2 text-gray-200 transition hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Zurücksetzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2 text-sm text-gray-300">
|
||||||
|
<span>Einträge pro Seite</span>
|
||||||
|
<select
|
||||||
|
value={perPage}
|
||||||
|
onChange={handlePerPageChange}
|
||||||
|
className="rounded-md border border-gray-700 bg-gray-900 px-2 py-1 text-sm text-gray-100 focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500"
|
||||||
|
>
|
||||||
|
{PER_PAGE_OPTIONS.map(({ value, label }) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto bg-gray-800/60 rounded-xl border border-gray-700">
|
||||||
|
<table className="min-w-full divide-y divide-gray-700">
|
||||||
|
<thead className="bg-gray-800">
|
||||||
|
<tr className="text-left text-sm uppercase tracking-wide text-gray-400">
|
||||||
|
<th className="px-4 py-3">Zeitpunkt</th>
|
||||||
|
<th className="px-4 py-3">Stack</th>
|
||||||
|
<th className="px-4 py-3">Art</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
<th className="px-4 py-3">Nachricht</th>
|
||||||
|
<th className="px-4 py-3">Endpoint</th>
|
||||||
|
<th className="px-4 py-3 text-right">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700 text-sm">
|
||||||
|
{logs.length === 0 && !loading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="7" className="px-4 py-6 text-center text-gray-400">
|
||||||
|
Keine Logs vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{logs.map((log) => {
|
||||||
|
const statusClass = STATUS_COLORS[log.status] || "text-blue-300";
|
||||||
|
const stackDisplayName = log.stackName || "Unbekannt";
|
||||||
|
const showStackId = stackDisplayName !== '---' && log.stackId !== undefined && log.stackId !== null;
|
||||||
|
const redeployTypeLabel = log.redeployType
|
||||||
|
? (REDEPLOY_TYPE_LABELS[log.redeployType] ?? log.redeployType)
|
||||||
|
: '---';
|
||||||
|
return (
|
||||||
|
<tr key={log.id} className="hover:bg-gray-700/40">
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-gray-300">
|
||||||
|
{formatTimestamp(log.timestamp)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-200">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{stackDisplayName}</span>
|
||||||
|
{showStackId && (
|
||||||
|
<span className="text-xs text-gray-400">ID: {log.stackId}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-300">
|
||||||
|
{redeployTypeLabel}
|
||||||
|
</td>
|
||||||
|
<td className={`px-4 py-3 font-semibold ${statusClass}`}>
|
||||||
|
{log.status}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-300">
|
||||||
|
{log.message || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400">
|
||||||
|
{log.endpoint ?? "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteLog(log.id)}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="rounded-md border border-red-600 px-3 py-1 text-xs text-red-300 transition hover:bg-red-600/20 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-300">
|
||||||
|
<span>
|
||||||
|
{totalLogs === 0
|
||||||
|
? "Keine Einträge"
|
||||||
|
: perPage === 'all'
|
||||||
|
? `Zeige alle ${totalLogs} Einträge`
|
||||||
|
: `Zeige ${rangeStart.toLocaleString()} – ${rangeEnd.toLocaleString()} von ${totalLogs.toLocaleString()} Einträgen`}
|
||||||
|
</span>
|
||||||
|
{perPage !== 'all' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(page - 1)}
|
||||||
|
disabled={page <= 1 || actionLoading}
|
||||||
|
className="rounded-md border border-gray-600 px-3 py-1 text-gray-200 transition hover:bg-gray-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-400">
|
||||||
|
Seite {page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages || actionLoading}
|
||||||
|
className="rounded-md border border-gray-600 px-3 py-1 text-gray-200 transition hover:bg-gray-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+973
-47
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1,121 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const ToastContext = createContext(null);
|
||||||
|
|
||||||
|
const VARIANT_STYLES = {
|
||||||
|
success: {
|
||||||
|
container: "border-green-500/80 bg-green-950/90 text-green-100",
|
||||||
|
accent: "bg-green-400"
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
container: "border-yellow-500/80 bg-yellow-950/90 text-yellow-100",
|
||||||
|
accent: "bg-yellow-400"
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
container: "border-red-500/80 bg-red-950/90 text-red-100",
|
||||||
|
accent: "bg-red-400"
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
container: "border-sky-500/60 bg-sky-950/90 text-sky-100",
|
||||||
|
accent: "bg-sky-400"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_DURATION = 6000;
|
||||||
|
|
||||||
|
export default function ToastProvider({ children }) {
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
const timeoutsRef = useRef(new Map());
|
||||||
|
|
||||||
|
const dismissToast = useCallback((id) => {
|
||||||
|
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||||
|
|
||||||
|
const handle = timeoutsRef.current.get(id);
|
||||||
|
if (handle && typeof window !== "undefined") {
|
||||||
|
window.clearTimeout(handle);
|
||||||
|
}
|
||||||
|
timeoutsRef.current.delete(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showToast = useCallback(({ id, title, description, variant = "info", duration = DEFAULT_DURATION } = {}) => {
|
||||||
|
const toastId = id ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
const normalizedVariant = VARIANT_STYLES[variant] ? variant : "info";
|
||||||
|
|
||||||
|
setToasts((prev) => {
|
||||||
|
const withoutExisting = prev.filter((toast) => toast.id !== toastId);
|
||||||
|
return [
|
||||||
|
...withoutExisting,
|
||||||
|
{
|
||||||
|
id: toastId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
variant: normalizedVariant
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duration !== null && duration !== Infinity && typeof window !== "undefined") {
|
||||||
|
const timeoutHandle = window.setTimeout(() => {
|
||||||
|
dismissToast(toastId);
|
||||||
|
}, duration);
|
||||||
|
|
||||||
|
const existingHandle = timeoutsRef.current.get(toastId);
|
||||||
|
if (existingHandle) {
|
||||||
|
window.clearTimeout(existingHandle);
|
||||||
|
}
|
||||||
|
timeoutsRef.current.set(toastId, timeoutHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
return toastId;
|
||||||
|
}, [dismissToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
timeoutsRef.current.forEach((handle) => window.clearTimeout(handle));
|
||||||
|
timeoutsRef.current.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const contextValue = useMemo(() => ({ showToast, dismissToast }), [showToast, dismissToast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
<div className="pointer-events-none fixed right-4 top-4 z-50 flex w-80 max-w-full flex-col gap-3">
|
||||||
|
{toasts.map((toast) => {
|
||||||
|
const styles = VARIANT_STYLES[toast.variant] ?? VARIANT_STYLES.info;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={`pointer-events-auto overflow-hidden rounded-lg border px-4 py-3 shadow-xl backdrop-blur ${styles.container}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className={`mt-1 h-2 flex-shrink-0 w-2 rounded-full ${styles.accent}`}></span>
|
||||||
|
<div className="flex-1">
|
||||||
|
{toast.title && <p className="text-sm font-semibold">{toast.title}</p>}
|
||||||
|
{toast.description && <p className="mt-1 text-sm leading-snug text-white/80">{toast.description}</p>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismissToast(toast.id)}
|
||||||
|
className="ml-2 rounded-md p-1 text-xs uppercase tracking-wide text-white/60 transition hover:text-white/90"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useToast must be used within a ToastProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const MaintenanceContext = createContext(null);
|
||||||
|
|
||||||
|
const INITIAL_STATE = {
|
||||||
|
maintenance: null,
|
||||||
|
update: null,
|
||||||
|
script: null,
|
||||||
|
ssh: null,
|
||||||
|
loading: true,
|
||||||
|
error: "",
|
||||||
|
lastUpdated: null
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MaintenanceProvider({ children }) {
|
||||||
|
const [state, setState] = useState(INITIAL_STATE);
|
||||||
|
const pollingRef = useRef(null);
|
||||||
|
const wasRunningRef = useRef(false);
|
||||||
|
|
||||||
|
const applyState = useCallback((partial) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
...partial
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchConfig = useCallback(async () => {
|
||||||
|
applyState({ loading: true, error: "" });
|
||||||
|
try {
|
||||||
|
const response = await axios.get("/api/maintenance/config");
|
||||||
|
const data = response.data ?? {};
|
||||||
|
applyState({
|
||||||
|
maintenance: data.maintenance ?? null,
|
||||||
|
update: data.update ?? null,
|
||||||
|
script: data.script ?? null,
|
||||||
|
ssh: data.ssh ?? null,
|
||||||
|
loading: false,
|
||||||
|
error: "",
|
||||||
|
lastUpdated: new Date()
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.error || err.message || "Fehler beim Laden der Wartungsdaten";
|
||||||
|
applyState({ loading: false, error: message });
|
||||||
|
}
|
||||||
|
}, [applyState]);
|
||||||
|
|
||||||
|
const refreshUpdateStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get("/api/maintenance/update-status");
|
||||||
|
const data = response.data ?? {};
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
maintenance: data.maintenance ?? prev.maintenance,
|
||||||
|
update: data.update ?? prev.update,
|
||||||
|
error: ""
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.error || err.message || "Fehler beim Aktualisieren des Wartungsstatus";
|
||||||
|
setState((prev) => ({ ...prev, error: message }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.update?.running) {
|
||||||
|
refreshUpdateStatus();
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
refreshUpdateStatus();
|
||||||
|
}, 5000);
|
||||||
|
pollingRef.current = intervalId;
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}
|
||||||
|
if (pollingRef.current) {
|
||||||
|
clearInterval(pollingRef.current);
|
||||||
|
pollingRef.current = null;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [state.update?.running, refreshUpdateStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentlyRunning = Boolean(state.update?.running);
|
||||||
|
if (!currentlyRunning && wasRunningRef.current) {
|
||||||
|
fetchConfig();
|
||||||
|
}
|
||||||
|
wasRunningRef.current = currentlyRunning;
|
||||||
|
}, [state.update?.running, fetchConfig]);
|
||||||
|
|
||||||
|
const triggerUpdate = useCallback(async (payload = {}) => {
|
||||||
|
await axios.post("/api/maintenance/portainer-update", payload);
|
||||||
|
await refreshUpdateStatus();
|
||||||
|
}, [refreshUpdateStatus]);
|
||||||
|
|
||||||
|
const saveScript = useCallback(async (script) => {
|
||||||
|
await axios.put("/api/maintenance/update-script", { script });
|
||||||
|
await fetchConfig();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
const resetScript = useCallback(async () => {
|
||||||
|
await axios.delete("/api/maintenance/update-script");
|
||||||
|
await fetchConfig();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
const saveSshConfig = useCallback(async (config) => {
|
||||||
|
await axios.put("/api/maintenance/ssh-config", config);
|
||||||
|
await fetchConfig();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
const deleteSshConfig = useCallback(async () => {
|
||||||
|
await axios.delete("/api/maintenance/ssh-config");
|
||||||
|
await fetchConfig();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
const testSshConnection = useCallback(async (config) => {
|
||||||
|
const response = await axios.post("/api/maintenance/test-ssh", config ?? {});
|
||||||
|
return response.data;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({
|
||||||
|
maintenance: state.maintenance,
|
||||||
|
update: state.update,
|
||||||
|
script: state.script,
|
||||||
|
ssh: state.ssh,
|
||||||
|
loading: state.loading,
|
||||||
|
error: state.error,
|
||||||
|
lastUpdated: state.lastUpdated,
|
||||||
|
refreshConfig: fetchConfig,
|
||||||
|
refreshUpdateStatus,
|
||||||
|
triggerUpdate,
|
||||||
|
saveScript,
|
||||||
|
resetScript,
|
||||||
|
saveSshConfig,
|
||||||
|
deleteSshConfig,
|
||||||
|
testSshConnection
|
||||||
|
}), [state, fetchConfig, refreshUpdateStatus, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MaintenanceContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</MaintenanceContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMaintenance() {
|
||||||
|
const context = useContext(MaintenanceContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useMaintenance must be used within a MaintenanceProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import App from "./App.jsx";
|
import App from "./App.jsx";
|
||||||
|
import ToastProvider from "./components/ToastProvider.jsx";
|
||||||
|
import MaintenanceProvider from "./context/MaintenanceContext.jsx";
|
||||||
import './index.css'; // Tailwind CSS
|
import './index.css'; // Tailwind CSS
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<MaintenanceProvider>
|
||||||
|
<ToastProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
|
</MaintenanceProvider>
|
||||||
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Aktuellen Branch herausfinden
|
|
||||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
|
||||||
|
|
||||||
# Branch bestätigen
|
|
||||||
read -p "Aktueller Branch: $BRANCH. Ist das korrekt? (y/n): " CONFIRM
|
|
||||||
if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then
|
|
||||||
echo "Abgebrochen."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Commit-Nachricht
|
|
||||||
read -p "Bitte Commit-Nachricht eingeben (default: 'Update'): " COMMIT_MSG
|
|
||||||
COMMIT_MSG=${COMMIT_MSG:-Update}
|
|
||||||
|
|
||||||
# Änderungen stagen und committen
|
|
||||||
echo "Änderungen werden gestaged..."
|
|
||||||
git add .
|
|
||||||
echo "Commit wird erstellt..."
|
|
||||||
git commit -m "$COMMIT_MSG" || echo "Nichts zu committen"
|
|
||||||
|
|
||||||
# Prüfen, ob Branch master ist
|
|
||||||
if [[ "$BRANCH" == "master" ]]; then
|
|
||||||
# Versionsnummer verpflichtend abfragen
|
|
||||||
while true; do
|
|
||||||
read -p "Bitte Versionsnummer für Master-Release Tag eingeben (z.B. v0.2.0): " VERSION_TAG
|
|
||||||
if [[ -n "$VERSION_TAG" ]]; then
|
|
||||||
break
|
|
||||||
else
|
|
||||||
echo "Versionsnummer darf nicht leer sein. Bitte eingeben."
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Push Branch
|
|
||||||
echo "Push nach origin/$BRANCH..."
|
|
||||||
git push origin "$BRANCH"
|
|
||||||
|
|
||||||
# Tag setzen und pushen, nur bei master
|
|
||||||
if [[ "$BRANCH" == "master" ]]; then
|
|
||||||
git tag -a "$VERSION_TAG" -m "Release $VERSION_TAG"
|
|
||||||
git push origin "$VERSION_TAG"
|
|
||||||
echo "Tag $VERSION_TAG gesetzt und gepusht."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Push abgeschlossen."
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Skript: Dev in Master mergen
|
|
||||||
|
|
||||||
DEV_BRANCH="dev"
|
|
||||||
MASTER_BRANCH="master"
|
|
||||||
|
|
||||||
# Auf master wechseln und aktuell holen
|
|
||||||
git checkout $MASTER_BRANCH
|
|
||||||
git pull origin $MASTER_BRANCH
|
|
||||||
|
|
||||||
# Dev aktuell holen
|
|
||||||
git checkout $DEV_BRANCH
|
|
||||||
git pull origin $DEV_BRANCH
|
|
||||||
|
|
||||||
# Merge Dev in Master
|
|
||||||
git checkout $MASTER_BRANCH
|
|
||||||
git merge --no-ff $DEV_BRANCH -m "Merge $DEV_BRANCH into $MASTER_BRANCH"
|
|
||||||
|
|
||||||
# Push Master auf Remote
|
|
||||||
git push origin $MASTER_BRANCH
|
|
||||||
|
|
||||||
echo "Branch $DEV_BRANCH wurde in $MASTER_BRANCH gemerged."
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Skript: Feature in Dev mergen mit Auswahl der Feature-Branches
|
|
||||||
|
|
||||||
DEV_BRANCH="dev"
|
|
||||||
|
|
||||||
# Auflisten aller Feature-Branches (lokal und remote)
|
|
||||||
echo "Verfügbare Feature-Branches:"
|
|
||||||
FEATURE_BRANCHES=$(git branch -r | grep 'origin/feature/' | sed 's|origin/||')
|
|
||||||
PS3="Bitte wähle einen Feature-Branch zum Mergen: "
|
|
||||||
|
|
||||||
# in ein Array konvertieren
|
|
||||||
BRANCH_ARRAY=($FEATURE_BRANCHES)
|
|
||||||
|
|
||||||
# Auswahlmenü anzeigen
|
|
||||||
select CHOSEN_BRANCH in "${BRANCH_ARRAY[@]}"; do
|
|
||||||
if [[ -n "$CHOSEN_BRANCH" ]]; then
|
|
||||||
FEATURE_BRANCH=$CHOSEN_BRANCH
|
|
||||||
break
|
|
||||||
else
|
|
||||||
echo "Ungültige Auswahl. Bitte erneut versuchen."
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Ausgewählter Feature-Branch: $FEATURE_BRANCH"
|
|
||||||
|
|
||||||
# Dev aktuell holen
|
|
||||||
git checkout $DEV_BRANCH
|
|
||||||
git pull origin $DEV_BRANCH
|
|
||||||
|
|
||||||
# Feature-Branch aktuell holen
|
|
||||||
git checkout $FEATURE_BRANCH
|
|
||||||
git pull origin $FEATURE_BRANCH
|
|
||||||
|
|
||||||
# Merge Feature in Dev
|
|
||||||
git checkout $DEV_BRANCH
|
|
||||||
git merge --no-ff $FEATURE_BRANCH -m "Merge $FEATURE_BRANCH into $DEV_BRANCH"
|
|
||||||
|
|
||||||
# Push Dev auf Remote
|
|
||||||
git push origin $DEV_BRANCH
|
|
||||||
|
|
||||||
echo "Feature $FEATURE_BRANCH wurde erfolgreich in $DEV_BRANCH gemerged."
|
|
||||||
@@ -6,6 +6,7 @@ echo "🚀 Starte StackPulse Dev-Umgebung..."
|
|||||||
# --- Backend ---
|
# --- Backend ---
|
||||||
cd backend
|
cd backend
|
||||||
npm install
|
npm install
|
||||||
|
npm run migrate
|
||||||
npm start &
|
npm start &
|
||||||
BACK_PID=$!
|
BACK_PID=$!
|
||||||
cd ..
|
cd ..
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ===============================
|
|
||||||
# Dateien sichern, die nicht gepusht werden
|
|
||||||
# ===============================
|
|
||||||
UNVERSIONED_FILES=("scripts/docker-release.sh")
|
|
||||||
for f in "${UNVERSIONED_FILES[@]}"; do
|
|
||||||
if [[ -f $f ]]; then
|
|
||||||
mkdir -p /tmp/git_safe_backup
|
|
||||||
cp "$f" "/tmp/git_safe_backup/$(basename "$f")"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# ===============================
|
|
||||||
# Alle Branches sammeln
|
|
||||||
# ===============================
|
|
||||||
# Alle lokalen Branches holen, führende Sternchen und Leerzeichen entfernen
|
|
||||||
LOCAL_BRANCHES=$(git branch | sed 's/* //' | sed 's/^[[:space:]]*//')
|
|
||||||
|
|
||||||
# Alle Remote-Branches holen, 'origin/' entfernen, HEAD ignorieren, führende Leerzeichen entfernen
|
|
||||||
REMOTE_BRANCHES=$(git branch -r | grep -v 'HEAD' | sed 's|origin/||' | sed 's/^[[:space:]]*//')
|
|
||||||
|
|
||||||
# Beide Listen zusammenführen und Duplikate entfernen
|
|
||||||
ALL_BRANCHES=$(echo -e "$LOCAL_BRANCHES\n$REMOTE_BRANCHES" | sort -u)
|
|
||||||
|
|
||||||
# Master und dev extrahieren, restliche feature-Branches alphabetisch
|
|
||||||
MASTER_BRANCH=$(echo "$ALL_BRANCHES" | grep -x 'master')
|
|
||||||
DEV_BRANCH=$(echo "$ALL_BRANCHES" | grep -x 'dev')
|
|
||||||
FEATURE_BRANCHES=$(echo "$ALL_BRANCHES" | grep '^feature/' | sort)
|
|
||||||
|
|
||||||
# Zusammenführen
|
|
||||||
SORTED_BRANCHES=()
|
|
||||||
[[ -n "$MASTER_BRANCH" ]] && SORTED_BRANCHES+=("$MASTER_BRANCH")
|
|
||||||
[[ -n "$DEV_BRANCH" ]] && SORTED_BRANCHES+=("$DEV_BRANCH")
|
|
||||||
for branch in $FEATURE_BRANCHES; do
|
|
||||||
SORTED_BRANCHES+=("$branch")
|
|
||||||
done
|
|
||||||
|
|
||||||
# Branches nummerieren
|
|
||||||
echo "Verfügbare Branches:"
|
|
||||||
i=1
|
|
||||||
declare -A BRANCH_MAP
|
|
||||||
for branch in "${SORTED_BRANCHES[@]}"; do
|
|
||||||
echo "$i) $branch"
|
|
||||||
BRANCH_MAP[$i]=$branch
|
|
||||||
((i++))
|
|
||||||
done
|
|
||||||
|
|
||||||
# Auswahl abfragen
|
|
||||||
read -p "Wähle einen Branch (Nummer): " choice
|
|
||||||
|
|
||||||
# Validierung
|
|
||||||
if [[ -z "${BRANCH_MAP[$choice]}" ]]; then
|
|
||||||
echo "Ungültige Auswahl!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
SELECTED_BRANCH=${BRANCH_MAP[$choice]}
|
|
||||||
echo "Wechsle zu Branch: $SELECTED_BRANCH"
|
|
||||||
|
|
||||||
# Remote-Stand holen
|
|
||||||
git fetch origin
|
|
||||||
|
|
||||||
# Branch wechseln (lokal erstellen, falls nur remote vorhanden)
|
|
||||||
if git show-ref --verify --quiet refs/heads/$SELECTED_BRANCH; then
|
|
||||||
git checkout $SELECTED_BRANCH
|
|
||||||
else
|
|
||||||
git checkout -b $SELECTED_BRANCH origin/$SELECTED_BRANCH
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Arbeitsverzeichnis exakt auf Remote-Branch zurücksetzen
|
|
||||||
git reset --hard origin/$SELECTED_BRANCH
|
|
||||||
git clean -fd
|
|
||||||
|
|
||||||
# ===============================
|
|
||||||
# Gesicherte unversionierte Dateien zurückkopieren
|
|
||||||
# ===============================
|
|
||||||
for f in "${UNVERSIONED_FILES[@]}"; do
|
|
||||||
if [[ -f "/tmp/git_safe_backup/$(basename "$f")" ]]; then
|
|
||||||
mkdir -p "$(dirname "$f")"
|
|
||||||
mv "/tmp/git_safe_backup/$(basename "$f")" "$f"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
rm -rf /tmp/git_safe_backup
|
|
||||||
|
|
||||||
echo "Branch '$SELECTED_BRANCH' ist nun aktiv. Arbeitsverzeichnis entspricht exakt dem Remote-Stand."
|
|
||||||
echo "Unversionierte Dateien wurden wiederhergestellt."
|
|
||||||
Reference in New Issue
Block a user