Merge dev into master

This commit is contained in:
root
2025-10-01 11:39:43 +00:00
13 changed files with 681 additions and 138 deletions
+3
View File
@@ -12,3 +12,6 @@ backend/.env
frontend/node_modules frontend/node_modules
docker-compose.dev.yml docker-compose.dev.yml
devscripts devscripts
backend/data/stackpulse.db
backend/data/stackpulse.db-shm
backend/data/stackpulse.db-wal
+4 -2
View File
@@ -31,14 +31,16 @@ Ziel:
- [x] Anbindung einer SQLite-Datenbank - [x] Anbindung einer SQLite-Datenbank
- [x] Logging der Redeploy-Aktionen in SQLite speichern - [x] Logging der Redeploy-Aktionen in SQLite speichern
- [x] API-Endpunkte für Log-Abfragen - [x] API-Endpunkte für Log-Abfragen
2 - [x] Funktionen für Pagination, Löschen und Export
### Frontend ### Frontend
- [x] Anzeige der Logs (inkl. Statusfarben) - [x] Anzeige der Logs (inkl. Statusfarben)
- [x] UI-Komponenten für Log-Details - [x] UI-Komponenten für Log-Details
- [x] Filterfunktionen für die Logs - [x] Filterfunktionen für die Logs
- [x] Pagination, Lösch- und Exportanzeigen
### Features ### Features
- [ ] Selektive Auswahl: einzelne Stacks oder Services neu deployen - [x] Selektive Auswahl: einzelne Stacks oder Services neu deployen
</details> </details>
Binary file not shown.
View File
+13 -1
View File
@@ -8,12 +8,24 @@ CREATE TABLE IF NOT EXISTS redeploy_logs (
stack_name TEXT NOT NULL, stack_name TEXT NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
message TEXT, message TEXT,
endpoint INTEGER endpoint INTEGER,
redeploy_type TEXT
); );
`; `;
db.exec(createRedeployLogsTable); 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'); console.log('✅ redeploy_logs table ready');
db.close(); db.close();
+20 -8
View File
@@ -57,6 +57,15 @@ export function buildLogFilter(queryParams = {}) {
}); });
} }
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); const messageQuery = singleValue(queryParams.message);
if (messageQuery && String(messageQuery).trim()) { if (messageQuery && String(messageQuery).trim()) {
filters.push('message LIKE @message'); filters.push('message LIKE @message');
@@ -82,18 +91,19 @@ export function buildLogFilter(queryParams = {}) {
} }
const insertRedeployLogStmt = db.prepare(` const insertRedeployLogStmt = db.prepare(`
INSERT INTO redeploy_logs (stack_id, stack_name, status, message, endpoint) INSERT INTO redeploy_logs (stack_id, stack_name, status, message, endpoint, redeploy_type)
VALUES (@stackId, @stackName, @status, @message, @endpoint) VALUES (@stackId, @stackName, @status, @message, @endpoint, @redeployType)
`); `);
export function logRedeployEvent({ stackId, stackName, status, message = null, endpoint = null }) { export function logRedeployEvent({ stackId, stackName, status, message = null, endpoint = null, redeployType = null }) {
try { try {
insertRedeployLogStmt.run({ insertRedeployLogStmt.run({
stackId: String(stackId), stackId: String(stackId),
stackName: stackName ?? 'Unknown', stackName: stackName ?? 'Unknown',
status, status,
message, message,
endpoint endpoint,
redeployType: redeployType ?? null
}); });
} catch (err) { } catch (err) {
console.error('❌ Fehler beim Speichern des Redeploy-Logs:', err.message); console.error('❌ Fehler beim Speichern des Redeploy-Logs:', err.message);
@@ -116,7 +126,7 @@ export function deleteLogsByFilters(queryParams = {}) {
export function exportLogsByFilters(queryParams = {}, format = 'txt') { export function exportLogsByFilters(queryParams = {}, format = 'txt') {
const { whereClause, params } = buildLogFilter(queryParams); const { whereClause, params } = buildLogFilter(queryParams);
const rows = db.prepare(` const rows = db.prepare(`
SELECT id, timestamp, stack_id AS stackId, stack_name AS stackName, status, message, endpoint SELECT id, timestamp, stack_id AS stackId, stack_name AS stackName, status, message, endpoint, redeploy_type AS redeployType
FROM redeploy_logs FROM redeploy_logs
${whereClause} ${whereClause}
ORDER BY datetime(timestamp) DESC ORDER BY datetime(timestamp) DESC
@@ -126,7 +136,7 @@ export function exportLogsByFilters(queryParams = {}, format = 'txt') {
if (format === 'sql') { if (format === 'sql') {
const statements = rows.map((row) => { const statements = rows.map((row) => {
const columns = ['id', 'timestamp', 'stack_id', 'stack_name', 'status', 'message', 'endpoint']; const columns = ['id', 'timestamp', 'stack_id', 'stack_name', 'status', 'message', 'endpoint', 'redeploy_type'];
const values = [ const values = [
row.id, row.id,
row.timestamp, row.timestamp,
@@ -134,7 +144,8 @@ export function exportLogsByFilters(queryParams = {}, format = 'txt') {
row.stackName, row.stackName,
row.status, row.status,
row.message, row.message,
row.endpoint row.endpoint,
row.redeployType
].map((value) => { ].map((value) => {
if (value === null || value === undefined) return 'NULL'; if (value === null || value === undefined) return 'NULL';
return `'${String(value).replace(/'/g, "''")}'`; return `'${String(value).replace(/'/g, "''")}'`;
@@ -156,7 +167,8 @@ export function exportLogsByFilters(queryParams = {}, format = 'txt') {
`Stack: ${row.stackName ?? 'Unbekannt'} (ID: ${row.stackId})`, `Stack: ${row.stackName ?? 'Unbekannt'} (ID: ${row.stackId})`,
`Status: ${row.status}`, `Status: ${row.status}`,
`Endpoint: ${row.endpoint ?? '-'}`, `Endpoint: ${row.endpoint ?? '-'}`,
`Nachricht: ${row.message ?? '-'}` `Nachricht: ${row.message ?? '-'}`,
`Redeploy: ${row.redeployType ?? '-'}`
]; ];
return parts.join(' | '); return parts.join(' | ');
}); });
+245 -16
View File
@@ -56,6 +56,40 @@ const broadcastRedeployStatus = (stackId, status) => {
console.log(`🔄 [RedeployStatus] Stack ${stackId} ist jetzt ${status ? "im Redeploy" : "fertig"}`); console.log(`🔄 [RedeployStatus] Stack ${stackId} ist jetzt ${status ? "im Redeploy" : "fertig"}`);
}; };
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 --- // --- API Endpoints ---
// Stacks abrufen // Stacks abrufen
@@ -65,11 +99,31 @@ app.get('/api/stacks', async (req, res) => {
const stacksRes = await axiosInstance.get('/api/stacks'); const stacksRes = await axiosInstance.get('/api/stacks');
const filteredStacks = stacksRes.data.filter(stack => stack.EndpointId === ENDPOINT_ID); const filteredStacks = stacksRes.data.filter(stack => stack.EndpointId === ENDPOINT_ID);
const uniqueStacksMap = {}; const stacksByName = new Map();
const duplicateNames = new Set();
filteredStacks.forEach(stack => { 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( const stacksWithStatus = await Promise.all(
uniqueStacks.map(async (stack) => { uniqueStacks.map(async (stack) => {
@@ -81,11 +135,17 @@ app.get('/api/stacks', async (req, res) => {
return { return {
...stack, ...stack,
updateStatus: statusEmoji, updateStatus: statusEmoji,
redeploying: redeployingStacks[stack.Id] || false redeploying: redeployingStacks[stack.Id] || false,
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 { ...stack, updateStatus: '❌', redeploying: redeployingStacks[stack.Id] || false }; return {
...stack,
updateStatus: '❌',
redeploying: redeployingStacks[stack.Id] || false,
redeployDisabled: SELF_STACK_ID ? String(stack.Id) === SELF_STACK_ID : false
};
} }
}) })
); );
@@ -117,7 +177,8 @@ app.get('/api/logs', (req, res) => {
stack_name AS stackName, stack_name AS stackName,
status, status,
message, message,
endpoint endpoint,
redeploy_type AS redeployType
FROM redeploy_logs FROM redeploy_logs
${whereClause} ${whereClause}
ORDER BY datetime(timestamp) DESC ORDER BY datetime(timestamp) DESC
@@ -224,7 +285,8 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => {
stackName: stack.Name, stackName: stack.Name,
status: 'started', status: 'started',
message: 'Redeploy gestartet', message: 'Redeploy gestartet',
endpoint: stack.EndpointId endpoint: stack.EndpointId,
redeployType: REDEPLOY_TYPES.SINGLE
}); });
if (stack.Type === 1) { if (stack.Type === 1) {
@@ -262,7 +324,8 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => {
stackName: stack.Name, stackName: stack.Name,
status: 'success', status: 'success',
message: 'Redeploy erfolgreich abgeschlossen', message: 'Redeploy erfolgreich abgeschlossen',
endpoint: stack.EndpointId endpoint: stack.EndpointId,
redeployType: REDEPLOY_TYPES.SINGLE
}); });
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' });
@@ -274,7 +337,8 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => {
stackName: stack?.Name || `Stack ${id}`, stackName: stack?.Name || `Stack ${id}`,
status: 'error', status: 'error',
message: errorMessage, message: errorMessage,
endpoint: stack?.EndpointId || ENDPOINT_ID endpoint: stack?.EndpointId || ENDPOINT_ID,
redeployType: REDEPLOY_TYPES.SINGLE
}); });
console.error(`❌ Fehler beim Redeploy von Stack ${id}:`, errorMessage); console.error(`❌ Fehler beim Redeploy von Stack ${id}:`, errorMessage);
res.status(500).json({ error: errorMessage }); res.status(500).json({ error: errorMessage });
@@ -292,15 +356,47 @@ app.put('/api/stacks/redeploy-all', async (req, res) => {
console.log("📦 Redeploy ALL für folgende Stacks:"); console.log("📦 Redeploy ALL für folgende Stacks:");
filteredStacks.forEach(s => console.log(` - ${s.Name}`)); filteredStacks.forEach(s => console.log(` - ${s.Name}`));
filteredStacks.forEach(async (stack) => { 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' });
}
for (const stack of eligibleStacks) {
try { try {
broadcastRedeployStatus(stack.Id, true); broadcastRedeployStatus(stack.Id, true);
logRedeployEvent({ logRedeployEvent({
stackId: stack.Id, stackId: stack.Id,
stackName: stack.Name, stackName: stack.Name,
status: 'started', status: 'started',
message: 'Redeploy über Redeploy ALL gestartet', message: 'Redeploy ALL gestartet',
endpoint: stack.EndpointId endpoint: stack.EndpointId,
redeployType: REDEPLOY_TYPES.ALL
}); });
if (stack.Type === 1) { if (stack.Type === 1) {
@@ -323,8 +419,9 @@ app.put('/api/stacks/redeploy-all', async (req, res) => {
stackId: stack.Id, stackId: stack.Id,
stackName: stack.Name, stackName: stack.Name,
status: 'success', status: 'success',
message: 'Redeploy über Redeploy ALL abgeschlossen', message: 'Redeploy ALL abgeschlossen',
endpoint: stack.EndpointId endpoint: stack.EndpointId,
redeployType: REDEPLOY_TYPES.ALL
}); });
console.log(`✅ Redeploy abgeschlossen: ${stack.Name}`); console.log(`✅ Redeploy abgeschlossen: ${stack.Name}`);
} catch (err) { } catch (err) {
@@ -335,19 +432,151 @@ app.put('/api/stacks/redeploy-all', async (req, res) => {
stackName: stack.Name, stackName: stack.Name,
status: 'error', status: 'error',
message: errorMessage, message: errorMessage,
endpoint: stack.EndpointId endpoint: stack.EndpointId,
redeployType: REDEPLOY_TYPES.ALL
}); });
console.error(`❌ Fehler beim Redeploy von Stack ${stack.Name}:`, errorMessage); console.error(`❌ Fehler beim Redeploy von Stack ${stack.Name}:`, errorMessage);
} }
}); }
res.json({ success: true, message: 'Redeploy ALL gestartet' }); res.json({ success: true, message: 'Redeploy ALL gestartet' });
} catch (err) { } catch (err) {
console.error(`❌ Fehler beim Redeploy ALL:`, err.message); 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 }); 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' });
}
for (const stack of eligibleStacks) {
try {
broadcastRedeployStatus(stack.Id, true);
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, false);
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, false);
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', () => { server.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 Backend läuft auf Port ${PORT}`); console.log(`🚀 Backend läuft auf Port ${PORT}`);
}); });
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+1
View File
@@ -12,6 +12,7 @@ services:
PORTAINER_URL: "Your_Portainer_Server_Address" PORTAINER_URL: "Your_Portainer_Server_Address"
PORTAINER_API_KEY: "Your_Portainer_API_Key" PORTAINER_API_KEY: "Your_Portainer_API_Key"
PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_ID" PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_ID"
SELF_STACK_ID: "Stackpulse ID"
restart: unless-stopped restart: unless-stopped
volumes: volumes:
+6 -5
View File
@@ -2,6 +2,7 @@ import React from "react";
import { NavLink, Route, Routes } from "react-router-dom"; import { NavLink, Route, Routes } from "react-router-dom";
import Stacks from "./Stacks.jsx"; import Stacks from "./Stacks.jsx";
import Logs from "./Logs.jsx"; import Logs from "./Logs.jsx";
import logo from "./assets/images/stackpulse.png";
const navLinkBase = const navLinkBase =
"px-4 py-2 rounded-md font-medium transition-colors duration-150"; "px-4 py-2 rounded-md font-medium transition-colors duration-150";
@@ -14,12 +15,12 @@ export default function App() {
<div className="min-h-screen bg-gray-900 text-white"> <div className="min-h-screen bg-gray-900 text-white">
<header className="bg-gray-800 shadow-md"> <header className="bg-gray-800 shadow-md">
<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-center sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div> <div className="flex flex-col items-end gap-1">
<h1 className="text-2xl font-bold text-white">StackPulse</h1> <span className="text-xs text-gray-500">v0.2</span>
<p className="text-gray-400 mt-1">Verwalte deine Docker Stacks</p> <img src={logo} alt="StackPulse" className="h-10 w-auto" />
</div> </div>
<nav className="flex gap-2"> <nav className="flex gap-2 items-end">
<NavLink to="/" end className={getNavClass}> <NavLink to="/" end className={getNavClass}>
Stacks Stacks
</NavLink> </NavLink>
+175 -51
View File
@@ -40,10 +40,20 @@ const PER_PAGE_OPTIONS = [
]; ];
const VALID_PER_PAGE_VALUES = new Set(PER_PAGE_OPTIONS.map((option) => option.value)); 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( const hasActiveFilters = (filters) => Boolean(
(filters.stacks && filters.stacks.length) || (filters.stacks && filters.stacks.length) ||
(filters.statuses && filters.statuses.length) || (filters.statuses && filters.statuses.length) ||
(filters.endpoints && filters.endpoints.length) || (filters.endpoints && filters.endpoints.length) ||
(filters.redeployTypes && filters.redeployTypes.length) ||
(filters.message && filters.message.trim()) || (filters.message && filters.message.trim()) ||
(filters.from && filters.from.trim()) || (filters.from && filters.from.trim()) ||
(filters.to && filters.to.trim()) (filters.to && filters.to.trim())
@@ -59,16 +69,19 @@ export default function Logs() {
const [stackOptions, setStackOptions] = useState([]); const [stackOptions, setStackOptions] = useState([]);
const [statusOptions, setStatusOptions] = useState([]); const [statusOptions, setStatusOptions] = useState([]);
const [endpointOptions, setEndpointOptions] = useState([]); const [endpointOptions, setEndpointOptions] = useState([]);
const [redeployTypeOptions, setRedeployTypeOptions] = useState([]);
const [selectedStacks, setSelectedStacks] = useState([]); const [selectedStacks, setSelectedStacks] = useState([]);
const [selectedStatuses, setSelectedStatuses] = useState([]); const [selectedStatuses, setSelectedStatuses] = useState([]);
const [selectedEndpoints, setSelectedEndpoints] = useState([]); const [selectedEndpoints, setSelectedEndpoints] = useState([]);
const [selectedRedeployTypes, setSelectedRedeployTypes] = useState([]);
const [messageQuery, setMessageQuery] = useState(""); const [messageQuery, setMessageQuery] = useState("");
const [fromDate, setFromDate] = useState(""); const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState(""); const [toDate, setToDate] = useState("");
const [filtersOpen, setFiltersOpen] = useState(false); const [filtersOpen, setFiltersOpen] = useState(false);
const [filtersReady, setFiltersReady] = useState(false); const [filtersReady, setFiltersReady] = useState(false);
const [optionsInitialized, setOptionsInitialized] = useState(false);
const [refreshSignal, setRefreshSignal] = useState(0); const [refreshSignal, setRefreshSignal] = useState(0);
const [perPage, setPerPage] = useState(PER_PAGE_DEFAULT); const [perPage, setPerPage] = useState(PER_PAGE_DEFAULT);
@@ -77,36 +90,42 @@ export default function Logs() {
const updateFilterOptions = useCallback((payload) => { const updateFilterOptions = useCallback((payload) => {
const logsPayload = Array.isArray(payload) ? payload : payload?.items ?? []; const logsPayload = Array.isArray(payload) ? payload : payload?.items ?? [];
setStackOptions((prev) => { const stackMap = new Map();
const map = new Map(prev.map((entry) => [entry.value, entry.label])); const statusSet = new Set();
const endpointSet = new Set();
const redeployTypeSet = new Set();
logsPayload.forEach((log) => { logsPayload.forEach((log) => {
if (!log.stackId) return; if (log.stackId) {
const value = String(log.stackId); const value = String(log.stackId);
if (!map.has(value)) { const label = log.stackName || `Stack ${value}`;
map.set(value, 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);
} }
}); });
return Array.from(map.entries())
setStackOptions(Array.from(stackMap.entries())
.map(([value, label]) => ({ value, label })) .map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label)); .sort((a, b) => a.label.localeCompare(b.label)));
});
setStatusOptions((prev) => { setStatusOptions(Array.from(statusSet).sort());
const next = new Set(prev);
logsPayload.forEach((log) => {
if (log.status) next.add(log.status);
});
return Array.from(next).sort();
});
setEndpointOptions((prev) => { setEndpointOptions(Array.from(endpointSet)
const next = new Set(prev); .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })));
logsPayload.forEach((log) => {
if (log.endpoint === null || log.endpoint === undefined || log.endpoint === "") return; setRedeployTypeOptions(Array.from(redeployTypeSet).sort());
next.add(String(log.endpoint)); setOptionsInitialized(true);
});
return Array.from(next).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
});
}, []); }, []);
const stackLabelMap = useMemo(() => { const stackLabelMap = useMemo(() => {
@@ -117,6 +136,38 @@ export default function Logs() {
return map; return map;
}, [stackOptions]); }, [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(() => { useEffect(() => {
if (typeof window === "undefined") { if (typeof window === "undefined") {
setFiltersReady(true); setFiltersReady(true);
@@ -128,29 +179,17 @@ export default function Logs() {
if (storedValue) { if (storedValue) {
const parsed = JSON.parse(storedValue); const parsed = JSON.parse(storedValue);
const storedFilters = parsed?.filters ?? parsed ?? {}; const storedFilters = parsed?.filters ?? parsed ?? {};
const storedOptions = parsed?.options ?? {};
const storedPagination = parsed?.pagination ?? {}; const storedPagination = parsed?.pagination ?? {};
setSelectedStacks(storedFilters.stacks || []); setSelectedStacks(storedFilters.stacks || []);
setSelectedStatuses(storedFilters.statuses || []); setSelectedStatuses(storedFilters.statuses || []);
setSelectedEndpoints(storedFilters.endpoints || []); setSelectedEndpoints(storedFilters.endpoints || []);
setSelectedRedeployTypes(storedFilters.redeployTypes || []);
setMessageQuery(storedFilters.message || ""); setMessageQuery(storedFilters.message || "");
setFromDate(storedFilters.from || ""); setFromDate(storedFilters.from || "");
setToDate(storedFilters.to || ""); setToDate(storedFilters.to || "");
setFiltersOpen(hasActiveFilters(storedFilters)); setFiltersOpen(hasActiveFilters(storedFilters));
if (Array.isArray(storedOptions.stacks) && storedOptions.stacks.length) {
setStackOptions(storedOptions.stacks);
}
if (Array.isArray(storedOptions.statuses) && storedOptions.statuses.length) {
setStatusOptions(storedOptions.statuses);
}
if (Array.isArray(storedOptions.endpoints) && storedOptions.endpoints.length) {
setEndpointOptions(storedOptions.endpoints);
}
const rawPerPage = storedPagination.perPage; const rawPerPage = storedPagination.perPage;
if (rawPerPage !== undefined) { if (rawPerPage !== undefined) {
const parsedPerPage = String(rawPerPage); const parsedPerPage = String(rawPerPage);
@@ -186,6 +225,10 @@ export default function Logs() {
params.endpoints = selectedEndpoints.join(","); params.endpoints = selectedEndpoints.join(",");
} }
if (selectedRedeployTypes.length) {
params.redeployTypes = selectedRedeployTypes.join(",");
}
if (messageQuery.trim()) { if (messageQuery.trim()) {
params.message = messageQuery.trim(); params.message = messageQuery.trim();
} }
@@ -201,13 +244,13 @@ export default function Logs() {
} }
return params; return params;
}, [selectedStacks, selectedStatuses, selectedEndpoints, messageQuery, fromDate, toDate]); }, [selectedStacks, selectedStatuses, selectedEndpoints, selectedRedeployTypes, messageQuery, fromDate, toDate]);
useEffect(() => { useEffect(() => {
if (!filtersReady) return; if (!filtersReady) return;
let cancelled = false; let cancelled = false;
axios.get("/api/logs", { params: { ...buildFilterParams(), perPage: 'all', page: 1 } }) axios.get("/api/logs", { params: { perPage: 'all', page: 1 } })
.then((response) => { .then((response) => {
if (cancelled) return; if (cancelled) return;
updateFilterOptions(response.data); updateFilterOptions(response.data);
@@ -220,16 +263,17 @@ export default function Logs() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [filtersReady, buildFilterParams, updateFilterOptions, refreshSignal]); }, [filtersReady, updateFilterOptions, refreshSignal]);
const currentFilters = useMemo(() => ({ const currentFilters = useMemo(() => ({
stacks: selectedStacks, stacks: selectedStacks,
statuses: selectedStatuses, statuses: selectedStatuses,
endpoints: selectedEndpoints, endpoints: selectedEndpoints,
redeployTypes: selectedRedeployTypes,
message: messageQuery, message: messageQuery,
from: fromDate, from: fromDate,
to: toDate to: toDate
}), [selectedStacks, selectedStatuses, selectedEndpoints, messageQuery, fromDate, toDate]); }), [selectedStacks, selectedStatuses, selectedEndpoints, selectedRedeployTypes, messageQuery, fromDate, toDate]);
useEffect(() => { useEffect(() => {
if (!filtersReady) return; if (!filtersReady) return;
@@ -256,7 +300,6 @@ export default function Logs() {
setLogs(items); setLogs(items);
setTotalLogs(total); setTotalLogs(total);
updateFilterOptions(response.data);
if (perPage === 'all') { if (perPage === 'all') {
if (page !== 1) setPage(1); if (page !== 1) setPage(1);
@@ -292,11 +335,6 @@ export default function Logs() {
FILTER_STORAGE_KEY, FILTER_STORAGE_KEY,
JSON.stringify({ JSON.stringify({
filters: currentFilters, filters: currentFilters,
options: {
stacks: stackOptions,
statuses: statusOptions,
endpoints: endpointOptions,
},
pagination: { pagination: {
perPage, perPage,
page page
@@ -306,7 +344,7 @@ export default function Logs() {
} catch (storageError) { } catch (storageError) {
console.error("⚠️ Konnte Filter nicht speichern:", storageError); console.error("⚠️ Konnte Filter nicht speichern:", storageError);
} }
}, [filtersReady, currentFilters, stackOptions, statusOptions, endpointOptions, perPage, page]); }, [filtersReady, currentFilters, perPage, page]);
const handleMultiSelectChange = (setter) => (event) => { const handleMultiSelectChange = (setter) => (event) => {
const values = Array.from(event.target.selectedOptions).map((option) => option.value); const values = Array.from(event.target.selectedOptions).map((option) => option.value);
@@ -318,10 +356,32 @@ export default function Logs() {
setPage(1); 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 = () => { const handleResetFilters = () => {
setSelectedStacks([]); setSelectedStacks([]);
setSelectedStatuses([]); setSelectedStatuses([]);
setSelectedEndpoints([]); setSelectedEndpoints([]);
setSelectedRedeployTypes([]);
setMessageQuery(""); setMessageQuery("");
setFromDate(""); setFromDate("");
setToDate(""); setToDate("");
@@ -380,11 +440,22 @@ export default function Logs() {
]; ];
}, [endpointOptions]); }, [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(() => { const activeFilterCount = useMemo(() => {
let count = 0; let count = 0;
if (selectedStacks.length) count += selectedStacks.length; if (selectedStacks.length) count += selectedStacks.length;
if (selectedStatuses.length) count += selectedStatuses.length; if (selectedStatuses.length) count += selectedStatuses.length;
if (selectedEndpoints.length) count += selectedEndpoints.length; if (selectedEndpoints.length) count += selectedEndpoints.length;
if (selectedRedeployTypes.length) count += selectedRedeployTypes.length;
if (messageQuery.trim()) count += 1; if (messageQuery.trim()) count += 1;
if (fromDate) count += 1; if (fromDate) count += 1;
if (toDate) count += 1; if (toDate) count += 1;
@@ -549,7 +620,7 @@ export default function Logs() {
{filtersOpen && ( {filtersOpen && (
<div className="space-y-4 border-t border-gray-700 px-4 py-4"> <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-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300">Stack</label> <label className="mb-2 block text-sm font-medium text-gray-300">Stack</label>
<select <select
@@ -562,6 +633,7 @@ export default function Logs() {
<option <option
key={value} key={value}
value={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' : ''}`} className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
> >
{label} {label}
@@ -600,6 +672,7 @@ export default function Logs() {
<option <option
key={value} key={value}
value={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' : ''}`} className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
> >
{label} {label}
@@ -638,6 +711,7 @@ export default function Logs() {
<option <option
key={value} key={value}
value={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' : ''}`} className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
> >
{label} {label}
@@ -664,7 +738,46 @@ export default function Logs() {
</div> </div>
</div> </div>
<div className="md:col-span-2 lg:col-span-3"> <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> <label className="mb-2 block text-sm font-medium text-gray-300">Nachricht (Freitext)</label>
<input <input
type="text" type="text"
@@ -723,6 +836,7 @@ export default function Logs() {
<tr className="text-left text-sm uppercase tracking-wide text-gray-400"> <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">Zeitpunkt</th>
<th className="px-4 py-3">Stack</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">Status</th>
<th className="px-4 py-3">Nachricht</th> <th className="px-4 py-3">Nachricht</th>
<th className="px-4 py-3">Endpoint</th> <th className="px-4 py-3">Endpoint</th>
@@ -732,13 +846,18 @@ export default function Logs() {
<tbody className="divide-y divide-gray-700 text-sm"> <tbody className="divide-y divide-gray-700 text-sm">
{logs.length === 0 && !loading && ( {logs.length === 0 && !loading && (
<tr> <tr>
<td colSpan="6" className="px-4 py-6 text-center text-gray-400"> <td colSpan="7" className="px-4 py-6 text-center text-gray-400">
Keine Logs vorhanden. Keine Logs vorhanden.
</td> </td>
</tr> </tr>
)} )}
{logs.map((log) => { {logs.map((log) => {
const statusClass = STATUS_COLORS[log.status] || "text-blue-300"; 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 ( return (
<tr key={log.id} className="hover:bg-gray-700/40"> <tr key={log.id} className="hover:bg-gray-700/40">
<td className="px-4 py-3 whitespace-nowrap text-gray-300"> <td className="px-4 py-3 whitespace-nowrap text-gray-300">
@@ -746,10 +865,15 @@ export default function Logs() {
</td> </td>
<td className="px-4 py-3 text-gray-200"> <td className="px-4 py-3 text-gray-200">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium">{log.stackName || "Unbekannt"}</span> <span className="font-medium">{stackDisplayName}</span>
{showStackId && (
<span className="text-xs text-gray-400">ID: {log.stackId}</span> <span className="text-xs text-gray-400">ID: {log.stackId}</span>
)}
</div> </div>
</td> </td>
<td className="px-4 py-3 text-gray-300">
{redeployTypeLabel}
</td>
<td className={`px-4 py-3 font-semibold ${statusClass}`}> <td className={`px-4 py-3 font-semibold ${statusClass}`}>
{log.status} {log.status}
</td> </td>
+189 -30
View File
@@ -6,32 +6,51 @@ export default function Stacks() {
const [stacks, setStacks] = useState([]); const [stacks, setStacks] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); 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);
return {
...stack,
redeploying: previous?.redeploying || stack.redeploying || false,
redeployDisabled: stack.redeployDisabled ?? previous?.redeployDisabled ?? false
};
});
};
useEffect(() => { useEffect(() => {
const socket = io("/", { const socket = io("/", {
path: "/socket.io", path: "/socket.io",
transports: ["websocket"] transports: ["websocket"]
}); });
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"}`); console.log(`🔄 Stack ${stackId} Redeploy Status: ${status ? "running" : "finished"}`);
setStacks(prev =>
prev.map(stack => {
if (stack.Id !== stackId) return stack;
return {
...stack,
redeploying: status,
updateStatus: status ? stack.updateStatus : '✅'
};
})
);
if (!status) { if (!status) {
// Status nach Redeploy neu vom Server holen // Status nach Redeploy neu vom Server holen
try { try {
const res = await axios.get("/api/stacks"); 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) { } catch (err) {
console.error("Fehler beim Aktualisieren nach Redeploy:", 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
)
);
} }
}); });
@@ -42,7 +61,7 @@ const socket = io("/", {
setLoading(true); setLoading(true);
try { try {
const res = await axios.get("/api/stacks"); 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) { } catch (err) {
console.error("❌ Fehler beim Abrufen der Stacks:", err); console.error("❌ Fehler beim Abrufen der Stacks:", err);
setError("Fehler beim Laden der Stacks"); setError("Fehler beim Laden der Stacks");
@@ -55,31 +74,136 @@ const socket = io("/", {
fetchStacks(); fetchStacks();
}, []); }, []);
useEffect(() => {
setSelectedStackIds(prev => {
const filtered = prev.filter(id => {
const match = stacks.find(stack => stack.Id === id);
return match && match.updateStatus !== '✅' && !match.redeployDisabled;
});
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) => { const handleRedeploy = async (stackId) => {
setStacks(prev => const targetStack = stacks.find((stack) => stack.Id === stackId);
prev.map(stack => stack.Id === stackId ? { ...stack, redeploying: true } : stack) if (targetStack?.redeployDisabled) return;
setSelectedStackIds((prev) => prev.filter((id) => id !== stackId));
setStacks((prev) =>
prev.map((stack) =>
stack.Id === stackId ? { ...stack, redeploying: true } : stack
)
); );
try { try {
await axios.put(`/api/stacks/${stackId}/redeploy`); await axios.put(`/api/stacks/${stackId}/redeploy`);
// Socket.IO Event aktualisiert Status automatisch // Statusupdates kommen über Socket.IO
} catch (err) { } catch (err) {
console.error("❌ Fehler beim Redeploy:", err); console.error("❌ Fehler beim Redeploy:", err);
setStacks(prev => setStacks((prev) =>
prev.map(stack => stack.Id === stackId ? { ...stack, redeploying: false } : stack) prev.map((stack) =>
stack.Id === stackId ? { ...stack, redeploying: false } : stack
)
); );
} }
}; };
const handleRedeployAll = async () => { const handleRedeployAll = async () => {
setStacks(prev => prev.map(stack => ({ ...stack, redeploying: true }))); const outdatedStacks = stacks.filter((stack) => stack.updateStatus !== '✅' && !stack.redeployDisabled);
if (!outdatedStacks.length) return;
const outdatedIds = new Set(outdatedStacks.map((stack) => stack.Id));
setStacks(prev =>
prev.map(stack =>
outdatedIds.has(stack.Id)
? { ...stack, redeploying: true }
: stack
)
);
try { try {
await axios.put("/api/stacks/redeploy-all"); await axios.put("/api/stacks/redeploy-all");
setSelectedStackIds((prev) => prev.filter((id) => !outdatedIds.has(id)));
// Statusupdates kommen über Socket.IO // Statusupdates kommen über Socket.IO
} catch (err) { } catch (err) {
console.error("❌ Fehler beim Redeploy ALL:", err); console.error("❌ Fehler beim Redeploy ALL:", err);
setStacks(prev => prev.map(stack => ({ ...stack, redeploying: false }))); setStacks(prev =>
prev.map(stack =>
outdatedIds.has(stack.Id)
? { ...stack, redeploying: 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;
});
if (!eligibleIds.length) {
setSelectedStackIds([]);
return;
}
const eligibleSet = new Set(eligibleIds);
setStacks(prev =>
prev.map(stack =>
eligibleSet.has(stack.Id)
? { ...stack, redeploying: 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, redeploying: false }
: stack
)
);
}
};
const hasSelection = selectedStackIds.length > 0;
const hasOutdatedStacks = stacks.some((stack) => stack.updateStatus !== '✅' && !stack.redeployDisabled);
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.redeploying || targetStack.updateStatus === '✅' || targetStack.redeployDisabled;
})
: !hasOutdatedStacks || stacks.every(stack => stack.updateStatus !== '✅' || stack.redeploying || stack.redeployDisabled);
const handleBulkRedeploy = () => {
if (hasSelection) {
handleRedeploySelection();
} else {
handleRedeployAll();
} }
}; };
@@ -90,28 +214,42 @@ const socket = io("/", {
<div className="p-6"> <div className="p-6">
<div className="flex justify-end mb-4"> <div className="flex justify-end mb-4">
<button <button
onClick={handleRedeployAll} onClick={handleBulkRedeploy}
className="px-5 py-2 rounded-lg font-medium transition bg-purple-500 hover:bg-purple-600" 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'}`}
> >
Redeploy All {bulkButtonLabel}
</button> </button>
</div> </div>
<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 isRedeploying = stack.redeploying;
const isSelected = selectedStackIds.includes(stack.Id);
const isCurrent = stack.updateStatus === '✅';
const isSelfStack = Boolean(stack.redeployDisabled);
const isSelectable = !isRedeploying && !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 className={`flex justify-between items-center p-5 rounded-xl shadow-lg transition border
${isRedeploying ? "bg-gray-700 cursor-not-allowed" : "bg-gray-800 hover:bg-gray-700"}`} ${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'}
${!isSelectable && !isRedeploying ? 'opacity-75' : ''}`}
> >
<div className="flex items-center space-x-4"> <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 <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>
@@ -119,15 +257,36 @@ const socket = io("/", {
</div> </div>
</div> </div>
<div className="flex flex-col items-end gap-1 text-sm">
{isRedeploying ? (
<>
<span className="text-xs uppercase tracking-wide text-orange-300">Redeploy</span>
<span className="text-orange-200">läuft</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>
</>
) : (
<>
<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={isRedeploying}
className={`px-5 py-2 rounded-lg font-medium transition className="px-5 py-2 rounded-lg font-medium transition bg-blue-500 hover:bg-blue-600"
${isRedeploying ? "bg-orange-500 cursor-not-allowed" :
"bg-blue-500 hover:bg-blue-600"}`}
> >
{isRedeploying ? "Redeploying" : "Redeploy"} Redeploy
</button> </button>
</>
)}
</div>
</div> </div>
); );
})} })}
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB