Compare commits
37 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 |
@@ -1,4 +1,6 @@
|
|||||||
# 📦 StackPulse
|

|
||||||
|
|
||||||
|
# 📦 StackPulse 
|
||||||
|
|
||||||
**StackPulse** ist eine kleine Web-App, die über die Portainer-API deine Docker-Stacks verwaltet und aktualisiert.
|
**StackPulse** ist eine kleine Web-App, die über die Portainer-API deine Docker-Stacks verwaltet und aktualisiert.
|
||||||
Sie besteht aus einem **Backend (Node.js/Express)** und einem **Frontend (React/Tailwind)**.
|
Sie besteht aus einem **Backend (Node.js/Express)** und einem **Frontend (React/Tailwind)**.
|
||||||
@@ -13,7 +15,7 @@ Ziel:
|
|||||||
## 🚀 Features & Roadmap
|
## 🚀 Features & Roadmap
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>✅ v0.1.0 – Initial Release</summary>
|
<summary>✅ v0.1 – Initial Release</summary>
|
||||||
|
|
||||||
- Projektstruktur mit Frontend & Backend
|
- Projektstruktur mit Frontend & Backend
|
||||||
- Lokales Startskript (`scripts/start-dev.sh`)
|
- Lokales Startskript (`scripts/start-dev.sh`)
|
||||||
@@ -25,7 +27,7 @@ Ziel:
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details open>
|
<details open>
|
||||||
<summary>🟡 v0.2.0 – In Entwicklung</summary>
|
<summary>✅ v0.2 – Release</summary>
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
- [x] Anbindung einer SQLite-Datenbank
|
- [x] Anbindung einer SQLite-Datenbank
|
||||||
@@ -44,16 +46,41 @@ Ziel:
|
|||||||
|
|
||||||
</details>
|
</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>
|
<details>
|
||||||
<summary>🔮 Geplante Features (v0.3+)</summary>
|
<summary>🔮 Geplante Features (v0.4+)</summary>
|
||||||
|
|
||||||
- Notifications (z. B. via Webhooks oder Mail)
|
- Notifications (z. B. via Webhooks oder Mail)
|
||||||
- Authentifizierung & Benutzerverwaltung
|
- Authentifizierung & Benutzerverwaltung
|
||||||
- Monitoring (Status, CPU/RAM)
|
- Monitoring (Status, CPU/RAM)
|
||||||
- Verbesserte UI/UX
|
- Verbesserte UI/UX
|
||||||
|
- Export/Import von Scheduler-Jobs (z. B. JSON)
|
||||||
|
- Health-Checks nach Redeploy (automatisierte Tests, Statusprüfung)
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -62,6 +89,8 @@ Ziel:
|
|||||||
```bash
|
```bash
|
||||||
stackpulse/
|
stackpulse/
|
||||||
├── backend/ # Node.js Backend mit Express
|
├── backend/ # Node.js Backend mit Express
|
||||||
|
│ └── data # SQlite Datenbank
|
||||||
|
│ └── db # Datenbank-Integration
|
||||||
├── frontend/ # React Frontend mit Tailwind
|
├── frontend/ # React Frontend mit Tailwind
|
||||||
├── scripts/ # Lokale Hilfsskripte (nicht Teil des Images)
|
├── scripts/ # Lokale Hilfsskripte (nicht Teil des Images)
|
||||||
│ └── start-dev.sh #Skript für den lokalen Start
|
│ └── start-dev.sh #Skript für den lokalen Start
|
||||||
@@ -95,12 +124,32 @@ services:
|
|||||||
image: ghcr.io/mboehmlaender/stackpulse
|
image: ghcr.io/mboehmlaender/stackpulse
|
||||||
ports:
|
ports:
|
||||||
- '4001:4001'
|
- '4001:4001'
|
||||||
|
volumes:
|
||||||
|
- stackpulse_data:/app/backend/data
|
||||||
environment:
|
environment:
|
||||||
- PORTAINER_URL=Deine_Portainer_Adresse/
|
PORTAINER_URL: "Your_Portainer_Server_Address"
|
||||||
- PORTAINER_API_KEY=Dein_Portainer_API_Key
|
PORTAINER_API_KEY: "Your_Portainer_API_Key"
|
||||||
- PORTAINER_ENDPOINT_ID=Deine_Portainer_Endpoint_ID
|
PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_ID"
|
||||||
|
SELF_STACK_ID: "Stackpulse ID"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
stackpulse_data:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Die PORTAINER_ENDPOINT_ID erhältst du, wenn du die die URL im Browser ansiehst, wenn du das Dashboard in Portainer öffnest:
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
Diese ID kann erst nach dem Deploy von Stackpulse ausgelesen werden. Vergiss daher nicht, nach dem Hinterlegen der ID in den Variablen das Stack noch einmal zu redeployen!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Voraussetzungen
|
## 📋 Voraussetzungen
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
+49
-15
@@ -39,6 +39,12 @@ const axiosInstance = axios.create({
|
|||||||
baseURL: process.env.PORTAINER_URL,
|
baseURL: process.env.PORTAINER_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const REDEPLOY_STATUS = {
|
||||||
|
IDLE: 'idle',
|
||||||
|
QUEUED: 'queued',
|
||||||
|
RUNNING: 'running'
|
||||||
|
};
|
||||||
|
|
||||||
const redeployingStacks = {};
|
const redeployingStacks = {};
|
||||||
|
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
@@ -51,9 +57,22 @@ io.on("connection", (socket) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const broadcastRedeployStatus = (stackId, status) => {
|
const broadcastRedeployStatus = (stackId, status) => {
|
||||||
|
if (!status || status === REDEPLOY_STATUS.IDLE) {
|
||||||
|
delete redeployingStacks[stackId];
|
||||||
|
} else {
|
||||||
redeployingStacks[stackId] = status;
|
redeployingStacks[stackId] = status;
|
||||||
io.emit("redeployStatus", { stackId, status });
|
}
|
||||||
console.log(`🔄 [RedeployStatus] Stack ${stackId} ist jetzt ${status ? "im Redeploy" : "fertig"}`);
|
|
||||||
|
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 = {
|
const REDEPLOY_TYPES = {
|
||||||
@@ -127,6 +146,14 @@ app.get('/api/stacks', async (req, res) => {
|
|||||||
|
|
||||||
const stacksWithStatus = await Promise.all(
|
const stacksWithStatus = await Promise.all(
|
||||||
uniqueStacks.map(async (stack) => {
|
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 {
|
try {
|
||||||
const statusRes = await axiosInstance.get(
|
const statusRes = await axiosInstance.get(
|
||||||
`/api/stacks/${stack.Id}/images_status?refresh=true`
|
`/api/stacks/${stack.Id}/images_status?refresh=true`
|
||||||
@@ -135,21 +162,20 @@ app.get('/api/stacks', async (req, res) => {
|
|||||||
return {
|
return {
|
||||||
...stack,
|
...stack,
|
||||||
updateStatus: statusEmoji,
|
updateStatus: statusEmoji,
|
||||||
redeploying: redeployingStacks[stack.Id] || false,
|
...redeployCommon
|
||||||
redeployDisabled: SELF_STACK_ID ? String(stack.Id) === SELF_STACK_ID : false
|
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`❌ Fehler beim Abrufen des Status für Stack ${stack.Id}:`, err.message);
|
console.error(`❌ Fehler beim Abrufen des Status für Stack ${stack.Id}:`, err.message);
|
||||||
return {
|
return {
|
||||||
...stack,
|
...stack,
|
||||||
updateStatus: '❌',
|
updateStatus: '❌',
|
||||||
redeploying: redeployingStacks[stack.Id] || false,
|
...redeployCommon
|
||||||
redeployDisabled: SELF_STACK_ID ? String(stack.Id) === SELF_STACK_ID : false
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
stacksWithStatus.sort((a, b) => a.Name.localeCompare(b.Name));
|
stacksWithStatus.sort((a, b) => a.Name.localeCompare(b.Name));
|
||||||
console.log(`✅ GET /api/stacks: Abruf erfolgreich, ${stacksWithStatus.length} Stacks geladen`);
|
console.log(`✅ GET /api/stacks: Abruf erfolgreich, ${stacksWithStatus.length} Stacks geladen`);
|
||||||
res.json(stacksWithStatus);
|
res.json(stacksWithStatus);
|
||||||
@@ -271,7 +297,7 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => {
|
|||||||
|
|
||||||
let stack;
|
let stack;
|
||||||
try {
|
try {
|
||||||
broadcastRedeployStatus(id, true);
|
broadcastRedeployStatus(id, REDEPLOY_STATUS.RUNNING);
|
||||||
|
|
||||||
const stackRes = await axiosInstance.get(`/api/stacks/${id}`);
|
const stackRes = await axiosInstance.get(`/api/stacks/${id}`);
|
||||||
stack = stackRes.data;
|
stack = stackRes.data;
|
||||||
@@ -318,7 +344,7 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcastRedeployStatus(id, false);
|
broadcastRedeployStatus(id, REDEPLOY_STATUS.IDLE);
|
||||||
logRedeployEvent({
|
logRedeployEvent({
|
||||||
stackId: stack.Id || id,
|
stackId: stack.Id || id,
|
||||||
stackName: stack.Name,
|
stackName: stack.Name,
|
||||||
@@ -330,7 +356,7 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => {
|
|||||||
console.log(`✅ PUT /api/stacks/${id}/redeploy: Redeploy erfolgreich abgeschlossen`);
|
console.log(`✅ PUT /api/stacks/${id}/redeploy: Redeploy erfolgreich abgeschlossen`);
|
||||||
res.json({ success: true, message: 'Stack redeployed' });
|
res.json({ success: true, message: 'Stack redeployed' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
broadcastRedeployStatus(id, false);
|
broadcastRedeployStatus(id, REDEPLOY_STATUS.IDLE);
|
||||||
const errorMessage = err.response?.data?.message || err.message;
|
const errorMessage = err.response?.data?.message || err.message;
|
||||||
logRedeployEvent({
|
logRedeployEvent({
|
||||||
stackId: stack?.Id || id,
|
stackId: stack?.Id || id,
|
||||||
@@ -387,9 +413,13 @@ app.put('/api/stacks/redeploy-all', async (req, res) => {
|
|||||||
return res.json({ success: true, message: 'Keine veralteten Stacks für Redeploy 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) {
|
for (const stack of eligibleStacks) {
|
||||||
try {
|
try {
|
||||||
broadcastRedeployStatus(stack.Id, true);
|
broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.RUNNING);
|
||||||
logRedeployEvent({
|
logRedeployEvent({
|
||||||
stackId: stack.Id,
|
stackId: stack.Id,
|
||||||
stackName: stack.Name,
|
stackName: stack.Name,
|
||||||
@@ -414,7 +444,7 @@ app.put('/api/stacks/redeploy-all', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcastRedeployStatus(stack.Id, false);
|
broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.IDLE);
|
||||||
logRedeployEvent({
|
logRedeployEvent({
|
||||||
stackId: stack.Id,
|
stackId: stack.Id,
|
||||||
stackName: stack.Name,
|
stackName: stack.Name,
|
||||||
@@ -425,7 +455,7 @@ app.put('/api/stacks/redeploy-all', async (req, res) => {
|
|||||||
});
|
});
|
||||||
console.log(`✅ Redeploy abgeschlossen: ${stack.Name}`);
|
console.log(`✅ Redeploy abgeschlossen: ${stack.Name}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
broadcastRedeployStatus(stack.Id, false);
|
broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.IDLE);
|
||||||
const errorMessage = err.response?.data?.message || err.message;
|
const errorMessage = err.response?.data?.message || err.message;
|
||||||
logRedeployEvent({
|
logRedeployEvent({
|
||||||
stackId: stack.Id,
|
stackId: stack.Id,
|
||||||
@@ -509,9 +539,13 @@ app.put('/api/stacks/redeploy-selection', async (req, res) => {
|
|||||||
return res.json({ success: true, message: 'Keine veralteten Stacks in der Auswahl' });
|
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) {
|
for (const stack of eligibleStacks) {
|
||||||
try {
|
try {
|
||||||
broadcastRedeployStatus(stack.Id, true);
|
broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.RUNNING);
|
||||||
logRedeployEvent({
|
logRedeployEvent({
|
||||||
stackId: stack.Id,
|
stackId: stack.Id,
|
||||||
stackName: stack.Name,
|
stackName: stack.Name,
|
||||||
@@ -536,7 +570,7 @@ app.put('/api/stacks/redeploy-selection', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcastRedeployStatus(stack.Id, false);
|
broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.IDLE);
|
||||||
logRedeployEvent({
|
logRedeployEvent({
|
||||||
stackId: stack.Id,
|
stackId: stack.Id,
|
||||||
stackName: stack.Name,
|
stackName: stack.Name,
|
||||||
@@ -547,7 +581,7 @@ app.put('/api/stacks/redeploy-selection', async (req, res) => {
|
|||||||
});
|
});
|
||||||
console.log(`✅ Redeploy Auswahl abgeschlossen: ${stack.Name}`);
|
console.log(`✅ Redeploy Auswahl abgeschlossen: ${stack.Name}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
broadcastRedeployStatus(stack.Id, false);
|
broadcastRedeployStatus(stack.Id, REDEPLOY_STATUS.IDLE);
|
||||||
const errorMessage = err.response?.data?.message || err.message;
|
const errorMessage = err.response?.data?.message || err.message;
|
||||||
logRedeployEvent({
|
logRedeployEvent({
|
||||||
stackId: stack.Id,
|
stackId: stack.Id,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function App() {
|
|||||||
<div className="max-w-6xl mx-auto px-6 py-6">
|
<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 gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-end gap-1">
|
||||||
<span className="text-xs text-gray-500">v0.2</span>
|
<span className="text-xs text-gray-500">v0.2.1</span>
|
||||||
<img src={logo} alt="StackPulse" className="h-10 w-auto" />
|
<img src={logo} alt="StackPulse" className="h-10 w-auto" />
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex gap-2 items-end">
|
<nav className="flex gap-2 items-end">
|
||||||
|
|||||||
+109
-32
@@ -2,6 +2,12 @@ import React, { useEffect, useState } from "react";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { io } from "socket.io-client";
|
import { io } from "socket.io-client";
|
||||||
|
|
||||||
|
const REDEPLOY_STATE = {
|
||||||
|
IDLE: "idle",
|
||||||
|
QUEUED: "queued",
|
||||||
|
RUNNING: "running"
|
||||||
|
};
|
||||||
|
|
||||||
export default function Stacks() {
|
export default function Stacks() {
|
||||||
const [stacks, setStacks] = useState([]);
|
const [stacks, setStacks] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -14,9 +20,15 @@ export default function Stacks() {
|
|||||||
|
|
||||||
return sortedIncoming.map((stack) => {
|
return sortedIncoming.map((stack) => {
|
||||||
const previous = prevMap.get(stack.Id);
|
const previous = prevMap.get(stack.Id);
|
||||||
|
const redeployState = stack.redeployState
|
||||||
|
?? previous?.redeployState
|
||||||
|
?? (previous?.redeploying ? REDEPLOY_STATE.RUNNING : REDEPLOY_STATE.IDLE);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...stack,
|
...stack,
|
||||||
redeploying: previous?.redeploying || stack.redeploying || false,
|
redeployState,
|
||||||
|
redeploying: redeployState === REDEPLOY_STATE.RUNNING,
|
||||||
|
redeployQueued: redeployState === REDEPLOY_STATE.QUEUED,
|
||||||
redeployDisabled: stack.redeployDisabled ?? previous?.redeployDisabled ?? false
|
redeployDisabled: stack.redeployDisabled ?? previous?.redeployDisabled ?? false
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -30,21 +42,26 @@ export default function Stacks() {
|
|||||||
console.log("🔌 Socket connected");
|
console.log("🔌 Socket connected");
|
||||||
|
|
||||||
socket.on("redeployStatus", async ({ stackId, status }) => {
|
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}`);
|
||||||
|
|
||||||
setStacks(prev =>
|
setStacks(prev =>
|
||||||
prev.map(stack => {
|
prev.map(stack => {
|
||||||
if (stack.Id !== stackId) return stack;
|
if (stack.Id !== stackId) return stack;
|
||||||
return {
|
const updated = {
|
||||||
...stack,
|
...stack,
|
||||||
redeploying: status,
|
redeployState: nextState,
|
||||||
updateStatus: status ? stack.updateStatus : '✅'
|
redeploying: nextState === REDEPLOY_STATE.RUNNING,
|
||||||
|
redeployQueued: nextState === REDEPLOY_STATE.QUEUED
|
||||||
};
|
};
|
||||||
|
if (nextState === REDEPLOY_STATE.IDLE) {
|
||||||
|
updated.updateStatus = "✅";
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!status) {
|
if (nextState === REDEPLOY_STATE.IDLE) {
|
||||||
// Status nach Redeploy neu vom Server holen
|
|
||||||
try {
|
try {
|
||||||
const res = await axios.get("/api/stacks");
|
const res = await axios.get("/api/stacks");
|
||||||
setStacks(prev => mergeStackState(prev, res.data));
|
setStacks(prev => mergeStackState(prev, res.data));
|
||||||
@@ -78,7 +95,10 @@ export default function Stacks() {
|
|||||||
setSelectedStackIds(prev => {
|
setSelectedStackIds(prev => {
|
||||||
const filtered = prev.filter(id => {
|
const filtered = prev.filter(id => {
|
||||||
const match = stacks.find(stack => stack.Id === id);
|
const match = stacks.find(stack => stack.Id === id);
|
||||||
return match && match.updateStatus !== '✅' && !match.redeployDisabled;
|
return match
|
||||||
|
&& match.updateStatus !== "✅"
|
||||||
|
&& !match.redeployDisabled
|
||||||
|
&& (match.redeployState ?? REDEPLOY_STATE.IDLE) === REDEPLOY_STATE.IDLE;
|
||||||
});
|
});
|
||||||
return filtered.length === prev.length ? prev : filtered;
|
return filtered.length === prev.length ? prev : filtered;
|
||||||
});
|
});
|
||||||
@@ -95,12 +115,19 @@ export default function Stacks() {
|
|||||||
|
|
||||||
const handleRedeploy = async (stackId) => {
|
const handleRedeploy = async (stackId) => {
|
||||||
const targetStack = stacks.find((stack) => stack.Id === stackId);
|
const targetStack = stacks.find((stack) => stack.Id === stackId);
|
||||||
if (targetStack?.redeployDisabled) return;
|
if (!targetStack || targetStack.redeployDisabled || targetStack.redeployState !== REDEPLOY_STATE.IDLE) return;
|
||||||
|
|
||||||
setSelectedStackIds((prev) => prev.filter((id) => id !== stackId));
|
setSelectedStackIds((prev) => prev.filter((id) => id !== stackId));
|
||||||
setStacks((prev) =>
|
setStacks((prev) =>
|
||||||
prev.map((stack) =>
|
prev.map((stack) =>
|
||||||
stack.Id === stackId ? { ...stack, redeploying: true } : stack
|
stack.Id === stackId
|
||||||
|
? {
|
||||||
|
...stack,
|
||||||
|
redeployState: REDEPLOY_STATE.RUNNING,
|
||||||
|
redeploying: true,
|
||||||
|
redeployQueued: false
|
||||||
|
}
|
||||||
|
: stack
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -111,14 +138,25 @@ export default function Stacks() {
|
|||||||
console.error("❌ Fehler beim Redeploy:", err);
|
console.error("❌ Fehler beim Redeploy:", err);
|
||||||
setStacks((prev) =>
|
setStacks((prev) =>
|
||||||
prev.map((stack) =>
|
prev.map((stack) =>
|
||||||
stack.Id === stackId ? { ...stack, redeploying: false } : stack
|
stack.Id === stackId
|
||||||
|
? {
|
||||||
|
...stack,
|
||||||
|
redeployState: REDEPLOY_STATE.IDLE,
|
||||||
|
redeploying: false,
|
||||||
|
redeployQueued: false
|
||||||
|
}
|
||||||
|
: stack
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRedeployAll = async () => {
|
const handleRedeployAll = async () => {
|
||||||
const outdatedStacks = stacks.filter((stack) => stack.updateStatus !== '✅' && !stack.redeployDisabled);
|
const outdatedStacks = stacks.filter((stack) =>
|
||||||
|
stack.updateStatus !== "✅"
|
||||||
|
&& !stack.redeployDisabled
|
||||||
|
&& (stack.redeployState ?? REDEPLOY_STATE.IDLE) === REDEPLOY_STATE.IDLE
|
||||||
|
);
|
||||||
if (!outdatedStacks.length) return;
|
if (!outdatedStacks.length) return;
|
||||||
|
|
||||||
const outdatedIds = new Set(outdatedStacks.map((stack) => stack.Id));
|
const outdatedIds = new Set(outdatedStacks.map((stack) => stack.Id));
|
||||||
@@ -126,7 +164,12 @@ export default function Stacks() {
|
|||||||
setStacks(prev =>
|
setStacks(prev =>
|
||||||
prev.map(stack =>
|
prev.map(stack =>
|
||||||
outdatedIds.has(stack.Id)
|
outdatedIds.has(stack.Id)
|
||||||
? { ...stack, redeploying: true }
|
? {
|
||||||
|
...stack,
|
||||||
|
redeployState: REDEPLOY_STATE.QUEUED,
|
||||||
|
redeploying: false,
|
||||||
|
redeployQueued: true
|
||||||
|
}
|
||||||
: stack
|
: stack
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -140,7 +183,12 @@ export default function Stacks() {
|
|||||||
setStacks(prev =>
|
setStacks(prev =>
|
||||||
prev.map(stack =>
|
prev.map(stack =>
|
||||||
outdatedIds.has(stack.Id)
|
outdatedIds.has(stack.Id)
|
||||||
? { ...stack, redeploying: false }
|
? {
|
||||||
|
...stack,
|
||||||
|
redeployState: REDEPLOY_STATE.IDLE,
|
||||||
|
redeploying: false,
|
||||||
|
redeployQueued: false
|
||||||
|
}
|
||||||
: stack
|
: stack
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -152,7 +200,10 @@ export default function Stacks() {
|
|||||||
|
|
||||||
const eligibleIds = selectedStackIds.filter((id) => {
|
const eligibleIds = selectedStackIds.filter((id) => {
|
||||||
const stack = stacks.find((entry) => entry.Id === id);
|
const stack = stacks.find((entry) => entry.Id === id);
|
||||||
return stack && stack.updateStatus !== '✅' && !stack.redeployDisabled;
|
return stack
|
||||||
|
&& stack.updateStatus !== "✅"
|
||||||
|
&& !stack.redeployDisabled
|
||||||
|
&& (stack.redeployState ?? REDEPLOY_STATE.IDLE) === REDEPLOY_STATE.IDLE;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!eligibleIds.length) {
|
if (!eligibleIds.length) {
|
||||||
@@ -165,7 +216,12 @@ export default function Stacks() {
|
|||||||
setStacks(prev =>
|
setStacks(prev =>
|
||||||
prev.map(stack =>
|
prev.map(stack =>
|
||||||
eligibleSet.has(stack.Id)
|
eligibleSet.has(stack.Id)
|
||||||
? { ...stack, redeploying: true }
|
? {
|
||||||
|
...stack,
|
||||||
|
redeployState: REDEPLOY_STATE.QUEUED,
|
||||||
|
redeploying: false,
|
||||||
|
redeployQueued: true
|
||||||
|
}
|
||||||
: stack
|
: stack
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -179,7 +235,12 @@ export default function Stacks() {
|
|||||||
setStacks(prev =>
|
setStacks(prev =>
|
||||||
prev.map(stack =>
|
prev.map(stack =>
|
||||||
eligibleSet.has(stack.Id)
|
eligibleSet.has(stack.Id)
|
||||||
? { ...stack, redeploying: false }
|
? {
|
||||||
|
...stack,
|
||||||
|
redeployState: REDEPLOY_STATE.IDLE,
|
||||||
|
redeploying: false,
|
||||||
|
redeployQueued: false
|
||||||
|
}
|
||||||
: stack
|
: stack
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -187,17 +248,24 @@ export default function Stacks() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasSelection = selectedStackIds.length > 0;
|
const hasSelection = selectedStackIds.length > 0;
|
||||||
const hasOutdatedStacks = stacks.some((stack) => stack.updateStatus !== '✅' && !stack.redeployDisabled);
|
const hasRedeployableStacks = stacks.some((stack) =>
|
||||||
|
stack.updateStatus !== "✅"
|
||||||
|
&& !stack.redeployDisabled
|
||||||
|
&& (stack.redeployState ?? REDEPLOY_STATE.IDLE) === REDEPLOY_STATE.IDLE
|
||||||
|
);
|
||||||
const bulkButtonLabel = hasSelection
|
const bulkButtonLabel = hasSelection
|
||||||
? `Redeploy Auswahl (${selectedStackIds.length})`
|
? `Redeploy Auswahl (${selectedStackIds.length})`
|
||||||
: 'Redeploy All';
|
: "Redeploy All";
|
||||||
|
|
||||||
const bulkActionDisabled = hasSelection
|
const bulkActionDisabled = hasSelection
|
||||||
? selectedStackIds.length === 0 || selectedStackIds.every(id => {
|
? selectedStackIds.length === 0 || selectedStackIds.every(id => {
|
||||||
const targetStack = stacks.find(stack => stack.Id === id);
|
const targetStack = stacks.find(stack => stack.Id === id);
|
||||||
return !targetStack || targetStack.redeploying || targetStack.updateStatus === '✅' || targetStack.redeployDisabled;
|
return !targetStack
|
||||||
|
|| targetStack.updateStatus === "✅"
|
||||||
|
|| targetStack.redeployDisabled
|
||||||
|
|| (targetStack.redeployState ?? REDEPLOY_STATE.IDLE) !== REDEPLOY_STATE.IDLE;
|
||||||
})
|
})
|
||||||
: !hasOutdatedStacks || stacks.every(stack => stack.updateStatus !== '✅' || stack.redeploying || stack.redeployDisabled);
|
: !hasRedeployableStacks;
|
||||||
|
|
||||||
const handleBulkRedeploy = () => {
|
const handleBulkRedeploy = () => {
|
||||||
if (hasSelection) {
|
if (hasSelection) {
|
||||||
@@ -224,19 +292,22 @@ export default function Stacks() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{stacks.map(stack => {
|
{stacks.map(stack => {
|
||||||
const isRedeploying = stack.redeploying;
|
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 isSelected = selectedStackIds.includes(stack.Id);
|
||||||
const isCurrent = stack.updateStatus === '✅';
|
const isCurrent = stack.updateStatus === "✅";
|
||||||
const isSelfStack = Boolean(stack.redeployDisabled);
|
const isSelfStack = Boolean(stack.redeployDisabled);
|
||||||
const isSelectable = !isRedeploying && !isCurrent && !isSelfStack;
|
const isSelectable = !isBusy && !isCurrent && !isSelfStack;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={stack.Id}
|
key={stack.Id}
|
||||||
className={`flex justify-between items-center p-5 rounded-xl shadow-lg transition border
|
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'}
|
${isSelected ? 'border-purple-500 ring-1 ring-purple-500/40' : 'border-transparent'}
|
||||||
${isRedeploying ? 'bg-gray-700 cursor-wait' : 'bg-gray-800 hover:bg-gray-700'}
|
${isBusy ? 'bg-gray-700 cursor-wait' : 'bg-gray-800 hover:bg-gray-700'}
|
||||||
${!isSelectable && !isRedeploying ? 'opacity-75' : ''}`}
|
${!isSelectable && !isBusy ? 'opacity-75' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<input
|
<input
|
||||||
@@ -247,22 +318,30 @@ export default function Stacks() {
|
|||||||
disabled={!isSelectable}
|
disabled={!isSelectable}
|
||||||
/>
|
/>
|
||||||
<div className={`w-12 h-12 flex items-center justify-center rounded-full
|
<div className={`w-12 h-12 flex items-center justify-center rounded-full
|
||||||
${stack.updateStatus === '✅' ? 'bg-green-500' :
|
${stack.updateStatus === "✅" ? 'bg-green-500' :
|
||||||
stack.updateStatus === '⚠️' ? 'bg-yellow-500' :
|
stack.updateStatus === "⚠️" ? 'bg-yellow-500' :
|
||||||
'bg-red-500'}`}
|
'bg-red-500'}`}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-semibold text-white">{stack.Name}</p>
|
<p className="text-lg font-semibold text-white">{stack.Name}</p>
|
||||||
<p className="text-sm text-gray-400">ID: {stack.Id}</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>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-end gap-1 text-sm">
|
<div className="flex flex-col items-end gap-1 text-sm">
|
||||||
{isRedeploying ? (
|
{isRunning ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-xs uppercase tracking-wide text-orange-300">Redeploy</span>
|
<span className="text-xs uppercase tracking-wide text-orange-300">Redeploy</span>
|
||||||
<span className="text-orange-200">läuft…</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 ? (
|
) : isSelfStack ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-xs uppercase tracking-wide text-gray-400">System</span>
|
<span className="text-xs uppercase tracking-wide text-gray-400">System</span>
|
||||||
@@ -275,11 +354,9 @@ export default function Stacks() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="text-xs uppercase tracking-wide text-gray-400">Status</span>
|
|
||||||
<span className="text-amber-300">Veraltet</span>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRedeploy(stack.Id)}
|
onClick={() => handleRedeploy(stack.Id)}
|
||||||
disabled={isRedeploying}
|
disabled={isBusy}
|
||||||
className="px-5 py-2 rounded-lg font-medium transition bg-blue-500 hover:bg-blue-600"
|
className="px-5 py-2 rounded-lg font-medium transition bg-blue-500 hover:bg-blue-600"
|
||||||
>
|
>
|
||||||
Redeploy
|
Redeploy
|
||||||
|
|||||||
Reference in New Issue
Block a user