Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ede88b4f2 | |||
| 8db6a51ebb | |||
| 4cd55a8f15 | |||
| 827c50f674 | |||
| f9e8c36433 | |||
| 95336e4350 | |||
| 8a2b4a2ac5 | |||
| 6d4e340185 | |||
| cf0b51eb7d | |||
| 3875d275e0 | |||
| a81fde24f6 | |||
| 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/node_modules
|
||||
frontend/node_modules
|
||||
|
||||
backend/data/*.db*
|
||||
|
||||
+4
-1
@@ -11,4 +11,7 @@ backend/node_modules
|
||||
backend/.env
|
||||
frontend/node_modules
|
||||
docker-compose.dev.yml
|
||||
scripts/docker-release.sh
|
||||
devscripts
|
||||
backend/data/stackpulse.db
|
||||
backend/data/stackpulse.db-shm
|
||||
backend/data/stackpulse.db-wal
|
||||
|
||||
@@ -31,10 +31,17 @@ RUN npm ci --only=production
|
||||
|
||||
# Backend-Code kopieren
|
||||
COPY backend/ ./
|
||||
COPY backend/docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
|
||||
# public leeren
|
||||
RUN rm -rf ./public/*
|
||||
|
||||
# frische Datenbank vorbereiten
|
||||
RUN rm -rf ./data && mkdir -p ./data && chown node:node ./data
|
||||
|
||||
# EntryPoint vorbereiten
|
||||
RUN chmod +x ./docker-entrypoint.sh && chown node:node ./docker-entrypoint.sh
|
||||
|
||||
# Inhalt von dist inklusive Unterordner direkt nach public kopieren
|
||||
COPY --from=frontend-build /app/frontend/dist/. ./public/
|
||||
|
||||
@@ -50,4 +57,5 @@ EXPOSE 5173
|
||||
USER node
|
||||
|
||||
# Container startet das Backend (liefert statisches Frontend)
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# 📦 StackPulse
|
||||

|
||||
|
||||
# 📦 StackPulse 
|
||||
|
||||
**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)**.
|
||||
@@ -10,14 +12,75 @@ Ziel:
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Features (0.1 Roadmap)
|
||||
## 🚀 Features & Roadmap
|
||||
|
||||
- [x] Projektstruktur mit Frontend & Backend
|
||||
- [x] Lokales Startskript (`scripts/start-dev.sh`)
|
||||
- [x] Frontend zeigt Stacks an (über Backend)
|
||||
- [x] API-Verbindung zu Portainer
|
||||
- [x] Stack Redeploy
|
||||
- [x] Docker Image im ghcr zur Verfügung stellen
|
||||
<details>
|
||||
<summary>✅ v0.1 – Initial Release</summary>
|
||||
|
||||
- Projektstruktur mit Frontend & Backend
|
||||
- Lokales Startskript (`scripts/start-dev.sh`)
|
||||
- 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
|
||||
stackpulse/
|
||||
├── backend/ # Node.js Backend mit Express
|
||||
│ └── data # SQlite Datenbank
|
||||
│ └── db # Datenbank-Integration
|
||||
├── frontend/ # React Frontend mit Tailwind
|
||||
├── 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
|
||||
│ └── switch-branch.sh #nicht relevant
|
||||
│ └── start-dev.sh #Skript für den lokalen Start
|
||||
├── Dockerfile # Multi-Stage Build für Frontend + Backend
|
||||
├── docker-compose.yml
|
||||
└── README.md
|
||||
@@ -63,12 +124,32 @@ services:
|
||||
image: ghcr.io/mboehmlaender/stackpulse
|
||||
ports:
|
||||
- '4001:4001'
|
||||
volumes:
|
||||
- stackpulse_data:/app/backend/data
|
||||
environment:
|
||||
- PORTAINER_URL=Deine_Portainer_Adresse/
|
||||
- PORTAINER_API_KEY=Dein_Portainer_API_Key
|
||||
- PORTAINER_ENDPOINT_ID=Deine_Portainer_Endpoint_ID
|
||||
PORTAINER_URL: "Your_Portainer_Server_Address"
|
||||
PORTAINER_API_KEY: "Your_Portainer_API_Key"
|
||||
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
|
||||
|
||||
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,8 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "🔧 Starte Migration (idempotent)..."
|
||||
node db/migrate.js
|
||||
|
||||
echo "✅ Migration abgeschlossen. Starte Anwendung..."
|
||||
exec "$@"
|
||||
+485
-16
@@ -6,6 +6,14 @@ import http from 'http';
|
||||
import { Server } from 'socket.io';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { db } from './db/index.js';
|
||||
import {
|
||||
logRedeployEvent,
|
||||
buildLogFilter,
|
||||
deleteLogById,
|
||||
deleteLogsByFilters,
|
||||
exportLogsByFilters
|
||||
} from './db/redeployLogs.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -14,7 +22,6 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
app.get('*', (req, res, next) => {
|
||||
@@ -32,37 +39,121 @@ const axiosInstance = axios.create({
|
||||
baseURL: process.env.PORTAINER_URL,
|
||||
});
|
||||
|
||||
const REDEPLOY_STATUS = {
|
||||
IDLE: 'idle',
|
||||
QUEUED: 'queued',
|
||||
RUNNING: 'running'
|
||||
};
|
||||
|
||||
const redeployingStacks = {};
|
||||
|
||||
const server = http.createServer(app);
|
||||
const io = new Server(server, { cors: { origin: "*" } });
|
||||
|
||||
const io = new Server(server, {
|
||||
path: "/socket.io",
|
||||
cors: { origin: "*" }
|
||||
});
|
||||
io.on("connection", (socket) => {
|
||||
console.log(`🔌 [Socket] Client verbunden: ${socket.id}`);
|
||||
});
|
||||
|
||||
const broadcastRedeployStatus = (stackId, status) => {
|
||||
redeployingStacks[stackId] = status;
|
||||
io.emit("redeployStatus", { stackId, status });
|
||||
console.log(`🔄 [RedeployStatus] Stack ${stackId} ist jetzt ${status ? "im Redeploy" : "fertig"}`);
|
||||
if (!status || status === REDEPLOY_STATUS.IDLE) {
|
||||
delete redeployingStacks[stackId];
|
||||
} else {
|
||||
redeployingStacks[stackId] = status;
|
||||
}
|
||||
|
||||
const payloadStatus = status || REDEPLOY_STATUS.IDLE;
|
||||
io.emit("redeployStatus", { stackId, status: payloadStatus });
|
||||
|
||||
let statusText = 'fertig';
|
||||
if (payloadStatus === REDEPLOY_STATUS.RUNNING) {
|
||||
statusText = 'im Redeploy';
|
||||
} else if (payloadStatus === REDEPLOY_STATUS.QUEUED) {
|
||||
statusText = 'in der Warteschlange';
|
||||
}
|
||||
console.log(`🔄 [RedeployStatus] Stack ${stackId} ist jetzt ${statusText}`);
|
||||
};
|
||||
|
||||
const REDEPLOY_TYPES = {
|
||||
SINGLE: 'Einzeln',
|
||||
ALL: 'Alle',
|
||||
SELECTION: 'Auswahl'
|
||||
};
|
||||
|
||||
const SELF_STACK_ID = process.env.SELF_STACK_ID ? String(process.env.SELF_STACK_ID) : null;
|
||||
|
||||
const isStackOutdated = async (stackId) => {
|
||||
try {
|
||||
const statusRes = await axiosInstance.get(`/api/stacks/${stackId}/images_status?refresh=true`);
|
||||
return statusRes.data?.Status === 'outdated';
|
||||
} catch (err) {
|
||||
console.error(`⚠️ Konnte Update-Status für Stack ${stackId} nicht ermitteln:`, err.message);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const filterOutdatedStacks = async (stacks = []) => {
|
||||
const results = await Promise.all(
|
||||
stacks.map(async (stack) => ({
|
||||
stack,
|
||||
outdated: SELF_STACK_ID && String(stack.Id) === SELF_STACK_ID
|
||||
? false
|
||||
: await isStackOutdated(stack.Id)
|
||||
}))
|
||||
);
|
||||
|
||||
return {
|
||||
eligibleStacks: results.filter((entry) => entry.outdated).map((entry) => entry.stack),
|
||||
skippedStacks: results.filter((entry) => !entry.outdated).map((entry) => entry.stack),
|
||||
};
|
||||
};
|
||||
|
||||
// --- API Endpoints ---
|
||||
|
||||
// Stacks abrufen
|
||||
app.get('/api/stacks', async (req, res) => {
|
||||
console.log("ℹ️ [API] GET /api/stacks: Abruf gestartet");
|
||||
try {
|
||||
const stacksRes = await axiosInstance.get('/api/stacks');
|
||||
const filteredStacks = stacksRes.data.filter(stack => stack.EndpointId === ENDPOINT_ID);
|
||||
|
||||
const uniqueStacksMap = {};
|
||||
const stacksByName = new Map();
|
||||
const duplicateNames = new Set();
|
||||
|
||||
filteredStacks.forEach(stack => {
|
||||
if (!uniqueStacksMap[stack.Name]) uniqueStacksMap[stack.Name] = stack;
|
||||
const name = stack.Name;
|
||||
const isSelf = SELF_STACK_ID && String(stack.Id) === SELF_STACK_ID;
|
||||
const existingEntry = stacksByName.get(name);
|
||||
|
||||
if (!existingEntry) {
|
||||
stacksByName.set(name, { stack, isSelf });
|
||||
return;
|
||||
}
|
||||
|
||||
duplicateNames.add(name);
|
||||
|
||||
if (!existingEntry.isSelf && isSelf) {
|
||||
stacksByName.set(name, { stack, isSelf });
|
||||
}
|
||||
});
|
||||
const uniqueStacks = Object.values(uniqueStacksMap);
|
||||
|
||||
const uniqueStacks = Array.from(stacksByName.values()).map(entry => entry.stack);
|
||||
|
||||
if (duplicateNames.size) {
|
||||
console.warn(`⚠️ [API] GET /api/stacks: Doppelte Stack-Namen erkannt: ${Array.from(duplicateNames).join(', ')}`);
|
||||
}
|
||||
|
||||
const stacksWithStatus = await Promise.all(
|
||||
uniqueStacks.map(async (stack) => {
|
||||
const redeployState = redeployingStacks[stack.Id] || REDEPLOY_STATUS.IDLE;
|
||||
const redeployCommon = {
|
||||
redeployState,
|
||||
redeploying: redeployState === REDEPLOY_STATUS.RUNNING,
|
||||
redeployQueued: redeployState === REDEPLOY_STATUS.QUEUED,
|
||||
redeployDisabled: SELF_STACK_ID ? String(stack.Id) === SELF_STACK_ID : false
|
||||
};
|
||||
|
||||
try {
|
||||
const statusRes = await axiosInstance.get(
|
||||
`/api/stacks/${stack.Id}/images_status?refresh=true`
|
||||
@@ -71,15 +162,20 @@ app.get('/api/stacks', async (req, res) => {
|
||||
return {
|
||||
...stack,
|
||||
updateStatus: statusEmoji,
|
||||
redeploying: redeployingStacks[stack.Id] || false
|
||||
...redeployCommon
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`❌ Fehler beim Abrufen des Status für Stack ${stack.Id}:`, err.message);
|
||||
return { ...stack, updateStatus: '❌', redeploying: redeployingStacks[stack.Id] || false };
|
||||
return {
|
||||
...stack,
|
||||
updateStatus: '❌',
|
||||
...redeployCommon
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
stacksWithStatus.sort((a, b) => a.Name.localeCompare(b.Name));
|
||||
console.log(`✅ GET /api/stacks: Abruf erfolgreich, ${stacksWithStatus.length} Stacks geladen`);
|
||||
res.json(stacksWithStatus);
|
||||
@@ -89,20 +185,136 @@ app.get('/api/stacks', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Redeploy-Logs abrufen
|
||||
app.get('/api/logs', (req, res) => {
|
||||
const perPageParam = req.query.perPage ?? req.query.limit;
|
||||
const perPage = perPageParam === 'all' ? 'all' : Math.min(parseInt(perPageParam, 10) || 50, 500);
|
||||
const page = Math.max(parseInt(req.query.page, 10) || 1, 1);
|
||||
|
||||
const limit = perPage === 'all' ? undefined : perPage;
|
||||
const offset = perPage === 'all' ? 0 : (page - 1) * perPage;
|
||||
|
||||
const { whereClause, params } = buildLogFilter(req.query);
|
||||
const baseQuery = `
|
||||
SELECT
|
||||
id,
|
||||
timestamp,
|
||||
stack_id AS stackId,
|
||||
stack_name AS stackName,
|
||||
status,
|
||||
message,
|
||||
endpoint,
|
||||
redeploy_type AS redeployType
|
||||
FROM redeploy_logs
|
||||
${whereClause}
|
||||
ORDER BY datetime(timestamp) DESC
|
||||
`;
|
||||
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM redeploy_logs
|
||||
${whereClause}
|
||||
`;
|
||||
|
||||
const query = limit !== undefined
|
||||
? `${baseQuery} LIMIT @limit OFFSET @offset`
|
||||
: baseQuery;
|
||||
|
||||
if (limit !== undefined) {
|
||||
params.limit = limit;
|
||||
params.offset = offset;
|
||||
}
|
||||
|
||||
try {
|
||||
const stmt = db.prepare(query);
|
||||
const logs = stmt.all(params);
|
||||
const total = db.prepare(countQuery).get(params)?.total ?? logs.length;
|
||||
|
||||
res.json({
|
||||
total,
|
||||
items: logs,
|
||||
page,
|
||||
perPage: perPage === 'all' ? 'all' : limit
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('❌ Fehler beim Abrufen der Redeploy-Logs:', err.message);
|
||||
if (err.message.includes('no such table')) {
|
||||
return res.status(500).json({ error: 'redeploy_logs table nicht gefunden. Bitte Migration ausführen.' });
|
||||
}
|
||||
res.status(500).json({ error: 'Fehler beim Abrufen der Redeploy-Logs' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/logs/:id', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (Number.isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Ungültige ID' });
|
||||
}
|
||||
|
||||
try {
|
||||
const changes = deleteLogById(id);
|
||||
if (!changes) {
|
||||
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
|
||||
}
|
||||
res.json({ success: true, deleted: changes });
|
||||
} catch (err) {
|
||||
console.error('❌ Fehler beim Löschen des Redeploy-Logs:', err.message);
|
||||
res.status(500).json({ error: 'Fehler beim Löschen des Redeploy-Logs' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/logs', (req, res) => {
|
||||
try {
|
||||
const deleted = deleteLogsByFilters(req.query);
|
||||
res.json({ success: true, deleted });
|
||||
} catch (err) {
|
||||
console.error('❌ Fehler beim Löschen der Redeploy-Logs:', err.message);
|
||||
res.status(500).json({ error: 'Fehler beim Löschen der Redeploy-Logs' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/logs/export', (req, res) => {
|
||||
const format = (req.query.format || 'txt').toLowerCase();
|
||||
if (!['txt', 'sql'].includes(format)) {
|
||||
return res.status(400).json({ error: 'Ungültiges Export-Format' });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = exportLogsByFilters(req.query, format);
|
||||
res.setHeader('Content-Type', payload.contentType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${payload.filename}"`);
|
||||
res.send(payload.content);
|
||||
} catch (err) {
|
||||
console.error('❌ Fehler beim Export der Redeploy-Logs:', err.message);
|
||||
res.status(500).json({ error: 'Fehler beim Export der Redeploy-Logs' });
|
||||
}
|
||||
});
|
||||
|
||||
// Einzel-Redeploy
|
||||
app.put('/api/stacks/:id/redeploy', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
console.log(`🔄 PUT /api/stacks/${id}/redeploy: Redeploy gestartet`);
|
||||
|
||||
let stack;
|
||||
try {
|
||||
broadcastRedeployStatus(id, true);
|
||||
broadcastRedeployStatus(id, REDEPLOY_STATUS.RUNNING);
|
||||
|
||||
const stackRes = await axiosInstance.get(`/api/stacks/${id}`);
|
||||
const stack = stackRes.data;
|
||||
stack = stackRes.data;
|
||||
|
||||
if (stack.EndpointId !== ENDPOINT_ID) {
|
||||
throw new Error(`Stack gehört nicht zum Endpoint ${ENDPOINT_ID}`);
|
||||
}
|
||||
|
||||
logRedeployEvent({
|
||||
stackId: stack.Id || id,
|
||||
stackName: stack.Name,
|
||||
status: 'started',
|
||||
message: 'Redeploy gestartet',
|
||||
endpoint: stack.EndpointId,
|
||||
redeployType: REDEPLOY_TYPES.SINGLE
|
||||
});
|
||||
|
||||
if (stack.Type === 1) {
|
||||
console.log(`🔄 [Redeploy] Git Stack "${stack.Name}" (${id}) wird redeployed`);
|
||||
await axiosInstance.put(`/api/stacks/${id}/git/redeploy?endpointId=${stack.EndpointId}`);
|
||||
@@ -132,16 +344,273 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => {
|
||||
);
|
||||
}
|
||||
|
||||
broadcastRedeployStatus(id, false);
|
||||
broadcastRedeployStatus(id, REDEPLOY_STATUS.IDLE);
|
||||
logRedeployEvent({
|
||||
stackId: stack.Id || id,
|
||||
stackName: stack.Name,
|
||||
status: 'success',
|
||||
message: 'Redeploy erfolgreich abgeschlossen',
|
||||
endpoint: stack.EndpointId,
|
||||
redeployType: REDEPLOY_TYPES.SINGLE
|
||||
});
|
||||
console.log(`✅ PUT /api/stacks/${id}/redeploy: Redeploy erfolgreich abgeschlossen`);
|
||||
res.json({ success: true, message: 'Stack redeployed' });
|
||||
} catch (err) {
|
||||
broadcastRedeployStatus(id, false);
|
||||
console.error(`❌ Fehler beim Redeploy von Stack ${id}:`, err.message);
|
||||
broadcastRedeployStatus(id, REDEPLOY_STATUS.IDLE);
|
||||
const errorMessage = err.response?.data?.message || err.message;
|
||||
logRedeployEvent({
|
||||
stackId: stack?.Id || id,
|
||||
stackName: stack?.Name || `Stack ${id}`,
|
||||
status: 'error',
|
||||
message: errorMessage,
|
||||
endpoint: stack?.EndpointId || ENDPOINT_ID,
|
||||
redeployType: REDEPLOY_TYPES.SINGLE
|
||||
});
|
||||
console.error(`❌ Fehler beim Redeploy von Stack ${id}:`, errorMessage);
|
||||
res.status(500).json({ error: errorMessage });
|
||||
}
|
||||
});
|
||||
|
||||
// Redeploy ALL
|
||||
app.put('/api/stacks/redeploy-all', async (req, res) => {
|
||||
console.log(`🚀 PUT /api/stacks/redeploy-all: Redeploy ALL gestartet`);
|
||||
|
||||
try {
|
||||
const stacksRes = await axiosInstance.get('/api/stacks');
|
||||
const filteredStacks = stacksRes.data.filter(stack => stack.EndpointId === ENDPOINT_ID);
|
||||
|
||||
console.log("📦 Redeploy ALL für folgende Stacks:");
|
||||
filteredStacks.forEach(s => console.log(` - ${s.Name}`));
|
||||
|
||||
const { eligibleStacks, skippedStacks } = await filterOutdatedStacks(filteredStacks);
|
||||
|
||||
if (skippedStacks.length) {
|
||||
skippedStacks.forEach((stack) => {
|
||||
console.log(`⏭️ Übersprungen (aktuell): ${stack.Name} (${stack.Id})`);
|
||||
});
|
||||
}
|
||||
|
||||
const stackSummaryList = eligibleStacks.map((stack) => `${stack.Name} (${stack.Id})`);
|
||||
const stackSummary = stackSummaryList.length ? stackSummaryList.join(', ') : 'keine Stacks';
|
||||
logRedeployEvent({
|
||||
stackId: '---',
|
||||
stackName: '---',
|
||||
status: 'started',
|
||||
message: `Redeploy ALL gestartet für: ${stackSummary}`,
|
||||
endpoint: ENDPOINT_ID,
|
||||
redeployType: REDEPLOY_TYPES.ALL
|
||||
});
|
||||
|
||||
if (!eligibleStacks.length) {
|
||||
logRedeployEvent({
|
||||
stackId: '---',
|
||||
stackName: '---',
|
||||
status: 'success',
|
||||
message: 'Redeploy ALL übersprungen: keine veralteten Stacks',
|
||||
endpoint: ENDPOINT_ID,
|
||||
redeployType: REDEPLOY_TYPES.ALL
|
||||
});
|
||||
return res.json({ success: true, message: 'Keine veralteten Stacks für Redeploy ALL' });
|
||||
}
|
||||
|
||||
eligibleStacks.forEach((stack) => {
|
||||
broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.QUEUED);
|
||||
});
|
||||
|
||||
for (const stack of eligibleStacks) {
|
||||
try {
|
||||
broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.RUNNING);
|
||||
logRedeployEvent({
|
||||
stackId: stack.Id,
|
||||
stackName: stack.Name,
|
||||
status: 'started',
|
||||
message: 'Redeploy ALL gestartet',
|
||||
endpoint: stack.EndpointId,
|
||||
redeployType: REDEPLOY_TYPES.ALL
|
||||
});
|
||||
|
||||
if (stack.Type === 1) {
|
||||
console.log(`🔄 [Redeploy] Git Stack "${stack.Name}" (${stack.Id})`);
|
||||
await axiosInstance.put(`/api/stacks/${stack.Id}/git/redeploy?endpointId=${stack.EndpointId}`);
|
||||
} else if (stack.Type === 2) {
|
||||
console.log(`🔄 [Redeploy] Compose Stack "${stack.Name}" (${stack.Id})`);
|
||||
const fileRes = await axiosInstance.get(`/api/stacks/${stack.Id}/file`);
|
||||
const stackFileContent = fileRes.data?.StackFileContent;
|
||||
if (stackFileContent) {
|
||||
await axiosInstance.put(`/api/stacks/${stack.Id}`,
|
||||
{ StackFileContent: stackFileContent, Prune: false, PullImage: true },
|
||||
{ params: { endpointId: stack.EndpointId } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.IDLE);
|
||||
logRedeployEvent({
|
||||
stackId: stack.Id,
|
||||
stackName: stack.Name,
|
||||
status: 'success',
|
||||
message: 'Redeploy ALL abgeschlossen',
|
||||
endpoint: stack.EndpointId,
|
||||
redeployType: REDEPLOY_TYPES.ALL
|
||||
});
|
||||
console.log(`✅ Redeploy abgeschlossen: ${stack.Name}`);
|
||||
} catch (err) {
|
||||
broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.IDLE);
|
||||
const errorMessage = err.response?.data?.message || err.message;
|
||||
logRedeployEvent({
|
||||
stackId: stack.Id,
|
||||
stackName: stack.Name,
|
||||
status: 'error',
|
||||
message: errorMessage,
|
||||
endpoint: stack.EndpointId,
|
||||
redeployType: REDEPLOY_TYPES.ALL
|
||||
});
|
||||
console.error(`❌ Fehler beim Redeploy von Stack ${stack.Name}:`, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Redeploy ALL gestartet' });
|
||||
} catch (err) {
|
||||
console.error(`❌ Fehler beim Redeploy ALL:`, err.message);
|
||||
logRedeployEvent({
|
||||
stackId: '---',
|
||||
stackName: '---',
|
||||
status: 'error',
|
||||
message: err.message,
|
||||
endpoint: ENDPOINT_ID,
|
||||
redeployType: REDEPLOY_TYPES.ALL
|
||||
});
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/stacks/redeploy-selection', async (req, res) => {
|
||||
const { stackIds } = req.body || {};
|
||||
console.log(`🚀 PUT /api/stacks/redeploy-selection: Redeploy Auswahl gestartet (${Array.isArray(stackIds) ? stackIds.length : 0} Stacks)`);
|
||||
|
||||
if (!Array.isArray(stackIds) || !stackIds.length) {
|
||||
return res.status(400).json({ error: 'stackIds (array) erforderlich' });
|
||||
}
|
||||
|
||||
const normalizedIds = stackIds.map((id) => String(id));
|
||||
|
||||
try {
|
||||
const stacksRes = await axiosInstance.get('/api/stacks');
|
||||
const endpointStacks = stacksRes.data.filter(stack => stack.EndpointId === ENDPOINT_ID);
|
||||
const selectedStacks = endpointStacks.filter((stack) => normalizedIds.includes(String(stack.Id)));
|
||||
|
||||
if (!selectedStacks.length) {
|
||||
return res.status(400).json({ error: 'Keine gültigen Stacks für Redeploy Auswahl gefunden' });
|
||||
}
|
||||
|
||||
const missingIds = normalizedIds.filter((id) => !selectedStacks.some((stack) => String(stack.Id) === id));
|
||||
if (missingIds.length) {
|
||||
return res.status(400).json({ error: `Ungültige Stack-IDs: ${missingIds.join(', ')}` });
|
||||
}
|
||||
|
||||
const { eligibleStacks, skippedStacks } = await filterOutdatedStacks(selectedStacks);
|
||||
|
||||
if (skippedStacks.length) {
|
||||
skippedStacks.forEach((stack) => {
|
||||
console.log(`⏭️ Übersprungen (aktuell): ${stack.Name} (${stack.Id})`);
|
||||
});
|
||||
}
|
||||
|
||||
const stackSummaryList = eligibleStacks.map((stack) => `${stack.Name} (${stack.Id})`);
|
||||
const stackSummary = stackSummaryList.length ? stackSummaryList.join(', ') : 'keine Stacks';
|
||||
logRedeployEvent({
|
||||
stackId: '---',
|
||||
stackName: '---',
|
||||
status: 'started',
|
||||
message: `Redeploy Auswahl gestartet für: ${stackSummary}`,
|
||||
endpoint: ENDPOINT_ID,
|
||||
redeployType: REDEPLOY_TYPES.SELECTION
|
||||
});
|
||||
|
||||
if (!eligibleStacks.length) {
|
||||
logRedeployEvent({
|
||||
stackId: '---',
|
||||
stackName: '---',
|
||||
status: 'success',
|
||||
message: 'Redeploy Auswahl übersprungen: keine veralteten Stacks',
|
||||
endpoint: ENDPOINT_ID,
|
||||
redeployType: REDEPLOY_TYPES.SELECTION
|
||||
});
|
||||
return res.json({ success: true, message: 'Keine veralteten Stacks in der Auswahl' });
|
||||
}
|
||||
|
||||
eligibleStacks.forEach((stack) => {
|
||||
broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.QUEUED);
|
||||
});
|
||||
|
||||
for (const stack of eligibleStacks) {
|
||||
try {
|
||||
broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.RUNNING);
|
||||
logRedeployEvent({
|
||||
stackId: stack.Id,
|
||||
stackName: stack.Name,
|
||||
status: 'started',
|
||||
message: 'Redeploy Auswahl gestartet',
|
||||
endpoint: stack.EndpointId,
|
||||
redeployType: REDEPLOY_TYPES.SELECTION
|
||||
});
|
||||
|
||||
if (stack.Type === 1) {
|
||||
console.log(`🔄 [Redeploy Auswahl] Git Stack "${stack.Name}" (${stack.Id})`);
|
||||
await axiosInstance.put(`/api/stacks/${stack.Id}/git/redeploy?endpointId=${stack.EndpointId}`);
|
||||
} else if (stack.Type === 2) {
|
||||
console.log(`🔄 [Redeploy Auswahl] Compose Stack "${stack.Name}" (${stack.Id})`);
|
||||
const fileRes = await axiosInstance.get(`/api/stacks/${stack.Id}/file`);
|
||||
const stackFileContent = fileRes.data?.StackFileContent;
|
||||
if (stackFileContent) {
|
||||
await axiosInstance.put(`/api/stacks/${stack.Id}`,
|
||||
{ StackFileContent: stackFileContent, Prune: false, PullImage: true },
|
||||
{ params: { endpointId: stack.EndpointId } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.IDLE);
|
||||
logRedeployEvent({
|
||||
stackId: stack.Id,
|
||||
stackName: stack.Name,
|
||||
status: 'success',
|
||||
message: 'Redeploy Auswahl erfolgreich abgeschlossen',
|
||||
endpoint: stack.EndpointId,
|
||||
redeployType: REDEPLOY_TYPES.SELECTION
|
||||
});
|
||||
console.log(`✅ Redeploy Auswahl abgeschlossen: ${stack.Name}`);
|
||||
} catch (err) {
|
||||
broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.IDLE);
|
||||
const errorMessage = err.response?.data?.message || err.message;
|
||||
logRedeployEvent({
|
||||
stackId: stack.Id,
|
||||
stackName: stack.Name,
|
||||
status: 'error',
|
||||
message: errorMessage,
|
||||
endpoint: stack.EndpointId,
|
||||
redeployType: REDEPLOY_TYPES.SELECTION
|
||||
});
|
||||
console.error(`❌ Fehler beim Redeploy Auswahl für Stack ${stack.Name}:`, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Redeploy Auswahl gestartet' });
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.message || err.message;
|
||||
console.error(`❌ Fehler beim Redeploy Auswahl:`, errorMessage);
|
||||
logRedeployEvent({
|
||||
stackId: '---',
|
||||
stackName: '---',
|
||||
status: 'error',
|
||||
message: errorMessage,
|
||||
endpoint: ENDPOINT_ID,
|
||||
redeployType: REDEPLOY_TYPES.SELECTION
|
||||
});
|
||||
res.status(500).json({ error: errorMessage });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🚀 Backend läuft auf Port ${PORT}`);
|
||||
});
|
||||
|
||||
Generated
+219
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"better-sqlite3": "^9.6.0",
|
||||
"dockerode": "^4.0.7",
|
||||
"dotenv": "^16.0.0",
|
||||
"express": "^4.18.0",
|
||||
@@ -230,6 +231,24 @@
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "9.6.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
|
||||
"integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
@@ -441,6 +460,28 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -466,6 +507,14 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz",
|
||||
"integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/docker-modem": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz",
|
||||
@@ -686,6 +735,14 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
@@ -731,6 +788,11 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||
@@ -854,6 +916,11 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -951,6 +1018,11 @@
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -1039,6 +1111,25 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
@@ -1055,6 +1146,11 @@
|
||||
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -1063,6 +1159,17 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.77.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.77.0.tgz",
|
||||
"integrity": "sha512-DSmt0OEcLoK4i3NuscSbGjOf3bqiDEutejqENSplMSFA/gmB8mkED9G4pKWnPl7MDU4rSHebKPHeitpDfyH0cQ==",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -1114,6 +1221,31 @@
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
||||
@@ -1199,6 +1331,20 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
@@ -1244,6 +1390,17 @@
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||
@@ -1367,6 +1524,49 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
|
||||
@@ -1530,6 +1730,14 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||
@@ -1564,6 +1772,17 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
|
||||
+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
|
||||
ports:
|
||||
- "4001:4001"
|
||||
volumes:
|
||||
- stackpulse_data:/app/backend/data
|
||||
environment:
|
||||
PORTAINER_URL: "Your_Portainer_Server_Address"
|
||||
PORTAINER_API_KEY: "Your_Portainer_API_Key"
|
||||
PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_ID"
|
||||
SELF_STACK_ID: "Stackpulse ID"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
stackpulse_data:
|
||||
|
||||
Generated
+39
@@ -11,6 +11,7 @@
|
||||
"axios": "^1.7.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"socket.io-client": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -758,6 +759,14 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
||||
"integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -2188,6 +2197,36 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
|
||||
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
|
||||
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.0",
|
||||
"react-router": "6.30.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"axios": "^1.7.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"socket.io-client": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+32
-5
@@ -1,15 +1,42 @@
|
||||
import React from "react";
|
||||
import { NavLink, Route, Routes } from "react-router-dom";
|
||||
import Stacks from "./Stacks.jsx";
|
||||
import Logs from "./Logs.jsx";
|
||||
import logo from "./assets/images/stackpulse.png";
|
||||
|
||||
const navLinkBase =
|
||||
"px-4 py-2 rounded-md font-medium transition-colors duration-150";
|
||||
|
||||
const getNavClass = ({ isActive }) =>
|
||||
`${navLinkBase} ${isActive ? "bg-purple-600 text-white" : "text-gray-300 hover:bg-gray-700"}`;
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<header className="p-6 bg-gray-800 shadow-md">
|
||||
<h1 className="text-2xl font-bold text-white">StackPulse</h1>
|
||||
<p className="text-gray-400 mt-1">Verwalte deine Docker Stacks</p>
|
||||
<header className="bg-gray-800 shadow-md">
|
||||
<div className="max-w-6xl mx-auto px-6 py-6">
|
||||
<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.2.1</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="/logs" className={getNavClass}>
|
||||
Logs
|
||||
</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="p-6">
|
||||
<Stacks />
|
||||
<main className="max-w-6xl mx-auto px-6 py-6">
|
||||
<Routes>
|
||||
<Route path="/" element={<Stacks />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="*" element={<Stacks />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,934 @@
|
||||
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="space-y-4">
|
||||
<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">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<span>Einträge pro Seite</span>
|
||||
<select
|
||||
value={perPage}
|
||||
onChange={handlePerPageChange}
|
||||
className="rounded-md border border-gray-700 bg-gray-900/70 px-3 py-1.5 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
{PER_PAGE_OPTIONS.map(({ value, label }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<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="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>
|
||||
);
|
||||
}
|
||||
+312
-50
@@ -2,33 +2,72 @@ import React, { useEffect, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { io } from "socket.io-client";
|
||||
|
||||
const REDEPLOY_STATE = {
|
||||
IDLE: "idle",
|
||||
QUEUED: "queued",
|
||||
RUNNING: "running"
|
||||
};
|
||||
|
||||
export default function Stacks() {
|
||||
const [stacks, setStacks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [selectedStackIds, setSelectedStackIds] = useState([]);
|
||||
|
||||
const mergeStackState = (previousStacks, incomingStacks) => {
|
||||
const prevMap = new Map(previousStacks.map((stack) => [stack.Id, stack]));
|
||||
const sortedIncoming = [...incomingStacks].sort((a, b) => a.Name.localeCompare(b.Name));
|
||||
|
||||
return sortedIncoming.map((stack) => {
|
||||
const previous = prevMap.get(stack.Id);
|
||||
const redeployState = stack.redeployState
|
||||
?? previous?.redeployState
|
||||
?? (previous?.redeploying ? REDEPLOY_STATE.RUNNING : REDEPLOY_STATE.IDLE);
|
||||
|
||||
return {
|
||||
...stack,
|
||||
redeployState,
|
||||
redeploying: redeployState === REDEPLOY_STATE.RUNNING,
|
||||
redeployQueued: redeployState === REDEPLOY_STATE.QUEUED,
|
||||
redeployDisabled: stack.redeployDisabled ?? previous?.redeployDisabled ?? false
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const socket = io("/", { transports: ["websocket"] });
|
||||
const socket = io("/", {
|
||||
path: "/socket.io",
|
||||
transports: ["websocket"]
|
||||
});
|
||||
console.log("🔌 Socket connected");
|
||||
|
||||
socket.on("redeployStatus", async ({ stackId, status }) => {
|
||||
console.log(`🔄 Stack ${stackId} Redeploy Status: ${status ? "running" : "finished"}`);
|
||||
const nextState = status || REDEPLOY_STATE.IDLE;
|
||||
console.log(`🔄 Stack ${stackId} Redeploy Status: ${nextState}`);
|
||||
|
||||
if (!status) {
|
||||
// Status nach Redeploy neu vom Server holen
|
||||
setStacks(prev =>
|
||||
prev.map(stack => {
|
||||
if (stack.Id !== stackId) return stack;
|
||||
const updated = {
|
||||
...stack,
|
||||
redeployState: nextState,
|
||||
redeploying: nextState === REDEPLOY_STATE.RUNNING,
|
||||
redeployQueued: nextState === REDEPLOY_STATE.QUEUED
|
||||
};
|
||||
if (nextState === REDEPLOY_STATE.IDLE) {
|
||||
updated.updateStatus = "✅";
|
||||
}
|
||||
return updated;
|
||||
})
|
||||
);
|
||||
|
||||
if (nextState === REDEPLOY_STATE.IDLE) {
|
||||
try {
|
||||
const res = await axios.get("/api/stacks");
|
||||
setStacks(res.data.sort((a, b) => a.Name.localeCompare(b.Name)));
|
||||
setStacks(prev => mergeStackState(prev, res.data));
|
||||
} catch (err) {
|
||||
console.error("Fehler beim Aktualisieren nach Redeploy:", err);
|
||||
}
|
||||
} else {
|
||||
// UI direkt auf redeploying setzen
|
||||
setStacks(prev =>
|
||||
prev.map(stack =>
|
||||
stack.Id === stackId ? { ...stack, redeploying: true } : stack
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -39,7 +78,7 @@ export default function Stacks() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get("/api/stacks");
|
||||
setStacks(res.data.map(stack => ({ ...stack, redeploying: stack.redeploying || false })));
|
||||
setStacks(prev => mergeStackState(prev, res.data));
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Abrufen der Stacks:", err);
|
||||
setError("Fehler beim Laden der Stacks");
|
||||
@@ -52,61 +91,284 @@ export default function Stacks() {
|
||||
fetchStacks();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedStackIds(prev => {
|
||||
const filtered = prev.filter(id => {
|
||||
const match = stacks.find(stack => stack.Id === id);
|
||||
return match
|
||||
&& match.updateStatus !== "✅"
|
||||
&& !match.redeployDisabled
|
||||
&& (match.redeployState ?? REDEPLOY_STATE.IDLE) === REDEPLOY_STATE.IDLE;
|
||||
});
|
||||
return filtered.length === prev.length ? prev : filtered;
|
||||
});
|
||||
}, [stacks]);
|
||||
|
||||
const toggleStackSelection = (stackId, disabled) => {
|
||||
if (disabled) return;
|
||||
setSelectedStackIds(prev =>
|
||||
prev.includes(stackId)
|
||||
? prev.filter(id => id !== stackId)
|
||||
: [...prev, stackId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleRedeploy = async (stackId) => {
|
||||
setStacks(prev =>
|
||||
prev.map(stack => stack.Id === stackId ? { ...stack, redeploying: true } : stack)
|
||||
const targetStack = stacks.find((stack) => stack.Id === stackId);
|
||||
if (!targetStack || targetStack.redeployDisabled || targetStack.redeployState !== REDEPLOY_STATE.IDLE) return;
|
||||
|
||||
setSelectedStackIds((prev) => prev.filter((id) => id !== stackId));
|
||||
setStacks((prev) =>
|
||||
prev.map((stack) =>
|
||||
stack.Id === stackId
|
||||
? {
|
||||
...stack,
|
||||
redeployState: REDEPLOY_STATE.RUNNING,
|
||||
redeploying: true,
|
||||
redeployQueued: false
|
||||
}
|
||||
: stack
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await axios.put(`/api/stacks/${stackId}/redeploy`);
|
||||
// Socket.IO Event aktualisiert Status automatisch
|
||||
// Statusupdates kommen über Socket.IO
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Redeploy:", err);
|
||||
setStacks(prev =>
|
||||
prev.map(stack => stack.Id === stackId ? { ...stack, redeploying: false } : stack)
|
||||
setStacks((prev) =>
|
||||
prev.map((stack) =>
|
||||
stack.Id === stackId
|
||||
? {
|
||||
...stack,
|
||||
redeployState: REDEPLOY_STATE.IDLE,
|
||||
redeploying: false,
|
||||
redeployQueued: false
|
||||
}
|
||||
: stack
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRedeployAll = async () => {
|
||||
const outdatedStacks = stacks.filter((stack) =>
|
||||
stack.updateStatus !== "✅"
|
||||
&& !stack.redeployDisabled
|
||||
&& (stack.redeployState ?? REDEPLOY_STATE.IDLE) === REDEPLOY_STATE.IDLE
|
||||
);
|
||||
if (!outdatedStacks.length) return;
|
||||
|
||||
const outdatedIds = new Set(outdatedStacks.map((stack) => stack.Id));
|
||||
|
||||
setStacks(prev =>
|
||||
prev.map(stack =>
|
||||
outdatedIds.has(stack.Id)
|
||||
? {
|
||||
...stack,
|
||||
redeployState: REDEPLOY_STATE.QUEUED,
|
||||
redeploying: false,
|
||||
redeployQueued: true
|
||||
}
|
||||
: stack
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await axios.put("/api/stacks/redeploy-all");
|
||||
setSelectedStackIds((prev) => prev.filter((id) => !outdatedIds.has(id)));
|
||||
// Statusupdates kommen über Socket.IO
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Redeploy ALL:", err);
|
||||
setStacks(prev =>
|
||||
prev.map(stack =>
|
||||
outdatedIds.has(stack.Id)
|
||||
? {
|
||||
...stack,
|
||||
redeployState: REDEPLOY_STATE.IDLE,
|
||||
redeploying: false,
|
||||
redeployQueued: false
|
||||
}
|
||||
: stack
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRedeploySelection = async () => {
|
||||
if (!selectedStackIds.length) return;
|
||||
|
||||
const eligibleIds = selectedStackIds.filter((id) => {
|
||||
const stack = stacks.find((entry) => entry.Id === id);
|
||||
return stack
|
||||
&& stack.updateStatus !== "✅"
|
||||
&& !stack.redeployDisabled
|
||||
&& (stack.redeployState ?? REDEPLOY_STATE.IDLE) === REDEPLOY_STATE.IDLE;
|
||||
});
|
||||
|
||||
if (!eligibleIds.length) {
|
||||
setSelectedStackIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const eligibleSet = new Set(eligibleIds);
|
||||
|
||||
setStacks(prev =>
|
||||
prev.map(stack =>
|
||||
eligibleSet.has(stack.Id)
|
||||
? {
|
||||
...stack,
|
||||
redeployState: REDEPLOY_STATE.QUEUED,
|
||||
redeploying: false,
|
||||
redeployQueued: true
|
||||
}
|
||||
: stack
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await axios.put("/api/stacks/redeploy-selection", { stackIds: eligibleIds });
|
||||
setSelectedStackIds((prev) => prev.filter((id) => !eligibleSet.has(id)));
|
||||
// Statusupdates kommen über Socket.IO
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Redeploy Auswahl:", err);
|
||||
setStacks(prev =>
|
||||
prev.map(stack =>
|
||||
eligibleSet.has(stack.Id)
|
||||
? {
|
||||
...stack,
|
||||
redeployState: REDEPLOY_STATE.IDLE,
|
||||
redeploying: false,
|
||||
redeployQueued: false
|
||||
}
|
||||
: stack
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hasSelection = selectedStackIds.length > 0;
|
||||
const hasRedeployableStacks = stacks.some((stack) =>
|
||||
stack.updateStatus !== "✅"
|
||||
&& !stack.redeployDisabled
|
||||
&& (stack.redeployState ?? REDEPLOY_STATE.IDLE) === REDEPLOY_STATE.IDLE
|
||||
);
|
||||
const bulkButtonLabel = hasSelection
|
||||
? `Redeploy Auswahl (${selectedStackIds.length})`
|
||||
: "Redeploy All";
|
||||
|
||||
const bulkActionDisabled = hasSelection
|
||||
? selectedStackIds.length === 0 || selectedStackIds.every(id => {
|
||||
const targetStack = stacks.find(stack => stack.Id === id);
|
||||
return !targetStack
|
||||
|| targetStack.updateStatus === "✅"
|
||||
|| targetStack.redeployDisabled
|
||||
|| (targetStack.redeployState ?? REDEPLOY_STATE.IDLE) !== REDEPLOY_STATE.IDLE;
|
||||
})
|
||||
: !hasRedeployableStacks;
|
||||
|
||||
const handleBulkRedeploy = () => {
|
||||
if (hasSelection) {
|
||||
handleRedeploySelection();
|
||||
} else {
|
||||
handleRedeployAll();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p className="text-gray-400">Lade Stacks...</p>;
|
||||
if (error) return <p className="text-red-400">{error}</p>;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 p-6">
|
||||
{stacks.map(stack => {
|
||||
const isRedeploying = stack.redeploying;
|
||||
<div className="p-6">
|
||||
<div className="flex justify-end mb-4">
|
||||
<button
|
||||
onClick={handleBulkRedeploy}
|
||||
disabled={bulkActionDisabled}
|
||||
className={`px-5 py-2 rounded-lg font-medium transition ${bulkActionDisabled ? 'bg-purple-900 cursor-not-allowed text-gray-400' : 'bg-purple-500 hover:bg-purple-600'}`}
|
||||
>
|
||||
{bulkButtonLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stack.Id}
|
||||
className={`flex justify-between items-center p-5 rounded-xl shadow-lg transition
|
||||
${isRedeploying ? "bg-gray-700 cursor-not-allowed" : "bg-gray-800 hover:bg-gray-700"}`}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className={`w-12 h-12 flex items-center justify-center rounded-full
|
||||
${stack.updateStatus === "✅" ? "bg-green-500" :
|
||||
stack.updateStatus === "⚠️" ? "bg-yellow-500" :
|
||||
"bg-red-500"}`}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-white">{stack.Name}</p>
|
||||
<p className="text-sm text-gray-400">ID: {stack.Id}</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{stacks.map(stack => {
|
||||
const redeployState = stack.redeployState ?? REDEPLOY_STATE.IDLE;
|
||||
const isRunning = redeployState === REDEPLOY_STATE.RUNNING;
|
||||
const isQueued = redeployState === REDEPLOY_STATE.QUEUED;
|
||||
const isBusy = isRunning || isQueued;
|
||||
const isSelected = selectedStackIds.includes(stack.Id);
|
||||
const isCurrent = stack.updateStatus === "✅";
|
||||
const isSelfStack = Boolean(stack.redeployDisabled);
|
||||
const isSelectable = !isBusy && !isCurrent && !isSelfStack;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stack.Id}
|
||||
className={`flex justify-between items-center p-5 rounded-xl shadow-lg transition border
|
||||
${isSelected ? 'border-purple-500 ring-1 ring-purple-500/40' : 'border-transparent'}
|
||||
${isBusy ? 'bg-gray-700 cursor-wait' : 'bg-gray-800 hover:bg-gray-700'}
|
||||
${!isSelectable && !isBusy ? 'opacity-75' : ''}`}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleStackSelection(stack.Id, !isSelectable)}
|
||||
className={`h-5 w-5 text-purple-500 focus:ring-purple-400 border-gray-600 bg-gray-900 rounded ${!isSelectable ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
disabled={!isSelectable}
|
||||
/>
|
||||
<div className={`w-12 h-12 flex items-center justify-center rounded-full
|
||||
${stack.updateStatus === "✅" ? 'bg-green-500' :
|
||||
stack.updateStatus === "⚠️" ? 'bg-yellow-500' :
|
||||
'bg-red-500'}`}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-white">{stack.Name}</p>
|
||||
<p className="text-sm text-gray-400">ID: {stack.Id}</p>
|
||||
{stack.duplicateName && (
|
||||
<p className="text-xs text-amber-300">⚠️ Doppelter Name erkannt</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1 text-sm">
|
||||
{isRunning ? (
|
||||
<>
|
||||
<span className="text-xs uppercase tracking-wide text-orange-300">Redeploy</span>
|
||||
<span className="text-orange-200">läuft…</span>
|
||||
</>
|
||||
) : isQueued ? (
|
||||
<>
|
||||
<span className="text-xs uppercase tracking-wide text-orange-300">Redeploy</span>
|
||||
<span className="text-orange-200">in Warteliste…</span>
|
||||
</>
|
||||
) : isSelfStack ? (
|
||||
<>
|
||||
<span className="text-xs uppercase tracking-wide text-gray-400">System</span>
|
||||
<span className="text-gray-300">Redeploy deaktiviert</span>
|
||||
</>
|
||||
) : isCurrent ? (
|
||||
<>
|
||||
<span className="text-xs uppercase tracking-wide text-gray-400">Status</span>
|
||||
<span className="text-green-300">Aktuell</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleRedeploy(stack.Id)}
|
||||
disabled={isBusy}
|
||||
className="px-5 py-2 rounded-lg font-medium transition bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
Redeploy
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleRedeploy(stack.Id)}
|
||||
disabled={isRedeploying}
|
||||
className={`px-5 py-2 rounded-lg font-medium transition
|
||||
${isRedeploying ? "bg-orange-500 cursor-not-allowed" :
|
||||
"bg-blue-500 hover:bg-blue-600"}`}
|
||||
>
|
||||
{isRedeploying ? "Redeploying" : "Redeploy"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{stacks.length === 0 && <p className="text-gray-400">Keine Stacks gefunden.</p>}
|
||||
);
|
||||
})}
|
||||
{stacks.length === 0 && <p className="text-gray-400">Keine Stacks gefunden.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -1,10 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App.jsx";
|
||||
import './index.css'; // Tailwind CSS
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</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 ---
|
||||
cd backend
|
||||
npm install
|
||||
npm run migrate
|
||||
npm start &
|
||||
BACK_PID=$!
|
||||
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