From e2cc5865841a488f4bdf7f91de01ac154643cce1 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 10 Oct 2025 09:36:07 +0000 Subject: [PATCH] =?UTF-8?q?Meine=20=C3=84nderungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/db/settings.js | 71 ++ backend/index.js | 703 +++++++++++++++++++- backend/maintenance/state.js | 70 ++ frontend/src/App.jsx | 18 +- frontend/src/Maintenance.jsx | 591 +++++++++++++++- frontend/src/Stacks.jsx | 50 +- frontend/src/context/MaintenanceContext.jsx | 132 ++++ frontend/src/main.jsx | 9 +- 8 files changed, 1589 insertions(+), 55 deletions(-) create mode 100644 backend/db/settings.js create mode 100644 backend/maintenance/state.js create mode 100644 frontend/src/context/MaintenanceContext.jsx diff --git a/backend/db/settings.js b/backend/db/settings.js new file mode 100644 index 0000000..dfd9d99 --- /dev/null +++ b/backend/db/settings.js @@ -0,0 +1,71 @@ +import { db } from './index.js'; + +const CREATE_TABLE_SQL = ` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ) +`; + +db.prepare(CREATE_TABLE_SQL).run(); + +const getSettingStmt = db.prepare('SELECT key, value, updated_at FROM settings WHERE key = ?'); +const setSettingStmt = db.prepare(` + INSERT INTO settings (key, value, updated_at) + VALUES (@key, @value, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = CURRENT_TIMESTAMP +`); +const deleteSettingStmt = db.prepare('DELETE FROM settings WHERE key = ?'); + +export function getSetting(key) { + try { + return getSettingStmt.get(key) || null; + } catch (err) { + console.error(`❌ [Settings] Fehler beim Lesen des Settings "${key}":`, err.message); + return null; + } +} + +export function setSetting(key, value) { + try { + setSettingStmt.run({ key, value }); + return true; + } catch (err) { + console.error(`❌ [Settings] Fehler beim Speichern des Settings "${key}":`, err.message); + return false; + } +} + +export function deleteSetting(key) { + try { + deleteSettingStmt.run(key); + return true; + } catch (err) { + console.error(`❌ [Settings] Fehler beim Löschen des Settings "${key}":`, err.message); + return false; + } +} + +export function getJsonSetting(key, defaultValue = null) { + const row = getSetting(key); + if (!row || row.value === null || row.value === undefined) return defaultValue; + try { + return JSON.parse(row.value); + } catch (err) { + console.warn(`⚠️ [Settings] Konnte JSON für Setting "${key}" nicht parsen:`, err.message); + return defaultValue; + } +} + +export function setJsonSetting(key, value) { + try { + const payload = value === undefined ? null : JSON.stringify(value); + return setSetting(key, payload); + } catch (err) { + console.error(`❌ [Settings] Fehler beim Serialisieren von Setting "${key}":`, err.message); + return false; + } +} diff --git a/backend/index.js b/backend/index.js index cb0731b..8030095 100644 --- a/backend/index.js +++ b/backend/index.js @@ -7,6 +7,9 @@ import { Server } from 'socket.io'; import path from 'path'; import { fileURLToPath } from 'url'; import { db } from './db/index.js'; +import { getSetting, setSetting, deleteSetting } from './db/settings.js'; +import { activateMaintenanceMode, deactivateMaintenanceMode, getMaintenanceState, isMaintenanceModeActive } from './maintenance/state.js'; +import { spawn } from 'child_process'; import { logRedeployEvent, buildLogFilter, @@ -79,6 +82,432 @@ const REDEPLOY_TYPES = { const SELF_STACK_ID = process.env.SELF_STACK_ID ? String(process.env.SELF_STACK_ID) : null; +const PORTAINER_SCRIPT_SETTING_KEY = 'portainer_update_script'; + +const DEFAULT_PORTAINER_UPDATE_SCRIPT = [ + 'docker stop portainer', + 'docker rm portainer', + 'docker pull portainer/portainer-ee:lts', + 'docker run -d -p 8000:8000 -p 9443:9443 --name=portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock' +].join('\n'); + +let portainerUpdateState = { + running: false, + status: 'idle', + stage: 'idle', + startedAt: null, + finishedAt: null, + targetVersion: null, + resultVersion: null, + scriptSource: null, + message: null, + error: null, + logs: [] +}; + +const addUpdateLog = (message, level = 'info') => { + const entry = { + timestamp: new Date().toISOString(), + level, + message + }; + portainerUpdateState = { + ...portainerUpdateState, + logs: [...(portainerUpdateState.logs || []).slice(-50), entry] + }; + console.log(`🛠️ [PortainerUpdate:${level}] ${message}`); +}; + +const updatePortainerState = (partial = {}) => { + portainerUpdateState = { + ...portainerUpdateState, + ...partial + }; + return portainerUpdateState; +}; + +const getPortainerUpdateStatus = () => ({ + ...portainerUpdateState +}); + +const getCustomPortainerScript = () => { + const row = getSetting(PORTAINER_SCRIPT_SETTING_KEY); + if (!row) return null; + const value = typeof row.value === 'string' ? row.value.trim() : ''; + if (!value) return null; + return { + script: row.value, + updatedAt: row.updated_at || null + }; +}; + +const saveCustomPortainerScript = (script) => { + const normalized = String(script ?? '').replace(/\r?\n/g, '\n').trim(); + if (!normalized) { + deleteSetting(PORTAINER_SCRIPT_SETTING_KEY); + return null; + } + setSetting(PORTAINER_SCRIPT_SETTING_KEY, normalized); + return normalized; +}; + +const getEffectivePortainerScript = () => { + const custom = getCustomPortainerScript(); + if (custom) { + return { + script: custom.script, + source: 'custom', + updatedAt: custom.updatedAt + }; + } + return { + script: DEFAULT_PORTAINER_UPDATE_SCRIPT, + source: 'default', + updatedAt: null + }; +}; + +const detectPortainerContainer = async () => { + try { + const containersRes = await axiosInstance.get(`/api/endpoints/${ENDPOINT_ID}/docker/containers/json`, { + params: { all: true } + }); + const containers = Array.isArray(containersRes.data) ? containersRes.data : []; + + const normalizeName = (value) => (typeof value === 'string' ? value.replace(/^\//, '').toLowerCase() : ''); + const isPortainerContainer = (container = {}) => { + const names = Array.isArray(container.Names) + ? container.Names.map(normalizeName) + : []; + const image = String(container.Image ?? '').toLowerCase(); + const labels = container.Labels || {}; + + if (labels['io.portainer.container']) return true; + if (labels['io.portainer.role'] === 'instance') return true; + if (names.includes('portainer') || names.includes('portainer_ce')) return true; + if (image.includes('portainer/portainer')) return true; + if (image.includes('portainer-ce')) return true; + return false; + }; + + const matchedContainer = containers.find((entry) => isPortainerContainer(entry)); + if (!matchedContainer) { + return { summary: null, error: 'Portainer-Container nicht gefunden' }; + } + + const inspectRes = await axiosInstance.get(`/api/endpoints/${ENDPOINT_ID}/docker/containers/${matchedContainer.Id}/json`); + const inspect = inspectRes.data ?? {}; + + const trimName = (value) => (typeof value === 'string' ? value.replace(/^\//, '') : value); + const toArray = (value) => { + if (!value) return []; + if (Array.isArray(value)) return value.map((item) => String(item)); + return [String(value)]; + }; + + const summary = { + id: inspect.Id ?? matchedContainer.Id ?? null, + name: trimName(inspect.Name) ?? (matchedContainer.Names?.[0] ? trimName(matchedContainer.Names[0]) : null), + image: inspect.Config?.Image ?? matchedContainer.Image ?? null, + created: inspect.Created ?? null, + entrypoint: toArray(inspect.Config?.Entrypoint), + command: toArray(inspect.Config?.Cmd), + args: toArray(inspect.Args), + env: Array.isArray(inspect.Config?.Env) ? inspect.Config.Env : [], + binds: Array.isArray(inspect.HostConfig?.Binds) ? inspect.HostConfig.Binds : [], + mounts: Array.isArray(inspect.Mounts) + ? inspect.Mounts.map((mount) => ({ + type: mount.Type ?? null, + source: mount.Source ?? null, + destination: mount.Destination ?? null, + mode: mount.Mode ?? null, + rw: typeof mount.RW === 'boolean' ? mount.RW : null + })) + : [], + labels: inspect.Config?.Labels ?? matchedContainer.Labels ?? {}, + ports: inspect.HostConfig?.PortBindings ?? null, + networks: inspect.NetworkSettings?.Networks ?? null, + restartPolicy: inspect.HostConfig?.RestartPolicy ?? null + }; + + return { summary, error: null }; + } catch (err) { + const message = err.response?.data?.message || err.message; + return { summary: null, error: message }; + } +}; + +const waitForPortainerAvailability = async ({ timeoutMs = 5 * 60 * 1000, intervalMs = 5000 } = {}) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + await axiosInstance.get('/api/status'); + return true; + } catch (err) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + } + return false; +}; + +const fetchPortainerStatusSummary = async () => { + const statusRes = await axiosInstance.get('/api/status'); + const statusData = statusRes.data ?? {}; + + const currentVersion = statusData.Version + ?? statusData.ServerVersion + ?? statusData.Server?.Version + ?? statusData.ServerInfo?.Version + ?? statusData.ServerVersionNumber + ?? null; + const edition = statusData.Edition ?? statusData.Server?.Edition ?? null; + const build = statusData.BuildNumber ?? statusData.Server?.Build ?? null; + + const errors = {}; + let latestVersion = null; + + const { summary: containerSummary, error: containerError } = await detectPortainerContainer(); + if (containerError) { + errors.container = containerError; + } + + try { + const githubRes = await axios.get('https://api.github.com/repos/portainer/portainer/releases/latest', { + headers: { + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'StackPulse-Maintenance' + }, + timeout: 5000 + }); + latestVersion = githubRes.data?.tag_name ?? githubRes.data?.tagName ?? null; + } catch (err) { + const message = err.response?.data?.message || err.message; + console.warn(`⚠️ [Maintenance] Konnte Portainer-Latest-Version nicht ermitteln: ${message}`); + errors.latestVersion = message; + } + + if (!currentVersion) { + errors.currentVersion = 'Portainer-Version konnte nicht ermittelt werden'; + } + + const normalizedCurrent = normalizeVersion(currentVersion); + const normalizedLatest = normalizeVersion(latestVersion); + const portainerFlag = typeof statusData.UpdateAvailable === 'boolean' + ? statusData.UpdateAvailable + : null; + + let updateAvailable = null; + if (normalizedCurrent && normalizedLatest) { + updateAvailable = compareSemver(normalizedCurrent, normalizedLatest) < 0; + } else if (portainerFlag !== null) { + updateAvailable = portainerFlag; + } + + const responsePayload = { + currentVersion, + latestVersion, + normalized: { + current: normalizedCurrent, + latest: normalizedLatest + }, + updateAvailable, + portainerFlag, + edition: edition ?? null, + build: build ?? null, + fetchedAt: new Date().toISOString(), + container: containerSummary + }; + + if (Object.keys(errors).length) { + responsePayload.errors = errors; + } + + return responsePayload; +}; + +const maintenanceGuard = (req, res, next) => { + if (!isMaintenanceModeActive()) { + return next(); + } + + return res.status(423).json({ + error: 'Wartungsmodus aktiv', + maintenance: getMaintenanceState(), + update: getPortainerUpdateStatus() + }); +}; + +const logScriptOutput = (data, level) => { + if (!data) return; + const text = data.toString(); + text.split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .forEach((line) => addUpdateLog(line, level)); +}; + +const executePortainerUpdateScript = async (script) => { + const normalized = String(script ?? '').replace(/\r?\n/g, '\n').trim(); + if (!normalized) { + addUpdateLog('Kein Update-Skript definiert. Vorgang wird übersprungen.', 'warning'); + return; + } + + return new Promise((resolve, reject) => { + const child = spawn('bash', ['-lc', `set -e +${normalized}`], { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + child.stdout.on('data', (chunk) => logScriptOutput(chunk, 'stdout')); + child.stderr.on('data', (chunk) => logScriptOutput(chunk, 'stderr')); + child.on('error', (err) => reject(err)); + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Update-Skript beendet mit Exit-Code ${code}`)); + } + }); + }); +}; + +let currentPortainerUpdatePromise = null; + +const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) => { + let maintenanceActivated = false; + + try { + addUpdateLog(`Portainer-Update gestartet (Quelle: ${scriptSource})`, 'info'); + updatePortainerState({ + status: 'running', + stage: 'activating-maintenance', + message: 'Wartungsmodus wird aktiviert' + }); + + const maintenanceDetails = { + type: 'portainer-update', + targetVersion: targetVersion ?? null, + scriptSource + }; + + const maintenanceState = activateMaintenanceMode({ + message: 'Portainer Update läuft', + extra: maintenanceDetails + }); + maintenanceActivated = true; + + logRedeployEvent({ + stackId: 'portainer', + stackName: 'Portainer', + status: 'started', + message: `Portainer Update gestartet (Ziel: ${targetVersion ?? 'unbekannt'})`, + endpoint: ENDPOINT_ID, + redeployType: REDEPLOY_TYPES.MAINTENANCE + }); + + logRedeployEvent({ + stackId: 'maintenance', + stackName: 'StackPulse Wartung', + status: 'started', + message: 'Wartungsmodus aktiviert (Portainer Update)', + endpoint: ENDPOINT_ID, + redeployType: REDEPLOY_TYPES.MAINTENANCE + }); + + updatePortainerState({ + stage: 'executing-script', + message: 'Update-Skript wird ausgeführt' + }); + await executePortainerUpdateScript(script); + + addUpdateLog('Update-Skript erfolgreich abgeschlossen', 'info'); + updatePortainerState({ + stage: 'waiting', + message: 'Warte auf Portainer-Verfügbarkeit' + }); + + const available = await waitForPortainerAvailability({ timeoutMs: 5 * 60 * 1000, intervalMs: 5000 }); + if (!available) { + throw new Error('Portainer blieb nach dem Update unerreichbar'); + } + + addUpdateLog('Portainer antwortet wieder. Ermittle Version…', 'info'); + const statusAfter = await fetchPortainerStatusSummary().catch(() => null); + const finalVersion = statusAfter?.currentVersion ?? null; + + logRedeployEvent({ + stackId: 'portainer', + stackName: 'Portainer', + status: 'success', + message: `Portainer Update abgeschlossen (Version: ${finalVersion ?? 'unbekannt'})`, + endpoint: ENDPOINT_ID, + redeployType: REDEPLOY_TYPES.MAINTENANCE + }); + + updatePortainerState({ + running: false, + status: 'success', + stage: 'completed', + finishedAt: new Date().toISOString(), + message: 'Portainer Update abgeschlossen', + error: null, + resultVersion: finalVersion + }); + + addUpdateLog('Portainer Update erfolgreich abgeschlossen', 'success'); + + if (maintenanceActivated || maintenanceState?.active) { + deactivateMaintenanceMode({ message: 'Portainer Update abgeschlossen' }); + maintenanceActivated = false; + logRedeployEvent({ + stackId: 'maintenance', + stackName: 'StackPulse Wartung', + status: 'success', + message: 'Wartungsmodus deaktiviert', + endpoint: ENDPOINT_ID, + redeployType: REDEPLOY_TYPES.MAINTENANCE + }); + } + } catch (err) { + const message = err?.message || 'Portainer Update fehlgeschlagen'; + logRedeployEvent({ + stackId: 'portainer', + stackName: 'Portainer', + status: 'error', + message, + endpoint: ENDPOINT_ID, + redeployType: REDEPLOY_TYPES.MAINTENANCE + }); + + updatePortainerState({ + running: false, + status: 'error', + stage: 'failed', + finishedAt: new Date().toISOString(), + message, + error: message + }); + + addUpdateLog(message, 'error'); + + if (maintenanceActivated || isMaintenanceModeActive()) { + deactivateMaintenanceMode({ message: 'Portainer Update fehlgeschlagen' }); + logRedeployEvent({ + stackId: 'maintenance', + stackName: 'StackPulse Wartung', + status: 'error', + message: 'Wartungsmodus deaktiviert (Fehler)', + endpoint: ENDPOINT_ID, + redeployType: REDEPLOY_TYPES.MAINTENANCE + }); + } + } finally { + currentPortainerUpdatePromise = null; + } +}; + const fetchPortainerStacks = async () => { const stacksRes = await axiosInstance.get('/api/stacks'); return stacksRes.data.filter((stack) => stack.EndpointId === ENDPOINT_ID); @@ -150,6 +579,34 @@ const loadStackCollections = async () => { }; }; +const normalizeVersion = (value) => { + if (!value) return null; + return String(value).trim().replace(/^v/i, ''); +}; + +const semverParts = (value) => { + const normalized = normalizeVersion(value); + if (!normalized) return null; + return normalized.split(/[.-]/).map((segment) => { + const numericPart = segment.replace(/[^0-9].*$/, ''); + const parsed = Number.parseInt(numericPart, 10); + return Number.isNaN(parsed) ? 0 : parsed; + }); +}; + +const compareSemver = (a, b) => { + const partsA = semverParts(a) || []; + const partsB = semverParts(b) || []; + const length = Math.max(partsA.length, partsB.length); + for (let index = 0; index < length; index += 1) { + const valueA = partsA[index] ?? 0; + const valueB = partsB[index] ?? 0; + if (valueA > valueB) return 1; + if (valueA < valueB) return -1; + } + return 0; +}; + const isStackOutdated = async (stackId) => { try { const statusRes = await axiosInstance.get(`/api/stacks/${stackId}/images_status?refresh=true`); @@ -179,7 +636,7 @@ const filterOutdatedStacks = async (stacks = []) => { // --- API Endpoints --- // Stacks abrufen -app.get('/api/stacks', async (req, res) => { +app.get('/api/stacks', maintenanceGuard, async (req, res) => { console.log("ℹ️ [API] GET /api/stacks: Abruf gestartet"); try { const { canonicalStacks, duplicates } = await loadStackCollections(); @@ -226,7 +683,165 @@ app.get('/api/stacks', async (req, res) => { } }); -app.get('/api/maintenance/duplicates', async (req, res) => { +app.get('/api/maintenance/portainer-status', async (req, res) => { + console.log("🧭 [Maintenance] GET /api/maintenance/portainer-status: Prüfung gestartet"); + try { + const payload = await fetchPortainerStatusSummary(); + res.json(payload); + } catch (err) { + const message = err.response?.data?.message || err.message || 'Unbekannter Fehler'; + console.error(`❌ [Maintenance] Fehler beim Prüfen des Portainer-Status: ${message}`); + res.status(500).json({ error: message }); + } +}); + +app.get('/api/maintenance/config', (req, res) => { + const custom = getCustomPortainerScript(); + const effective = getEffectivePortainerScript(); + + res.json({ + maintenance: getMaintenanceState(), + update: getPortainerUpdateStatus(), + script: { + default: DEFAULT_PORTAINER_UPDATE_SCRIPT, + custom: custom?.script ?? null, + customUpdatedAt: custom?.updatedAt ?? null, + effective: effective.script, + source: effective.source, + updatedAt: effective.updatedAt + } + }); +}); + +app.put('/api/maintenance/update-script', (req, res) => { + if (portainerUpdateState.running) { + return res.status(409).json({ + error: 'Aktualisierung läuft. Skript kann derzeit nicht geändert werden.', + update: getPortainerUpdateStatus() + }); + } + + const incoming = req.body?.script; + if (typeof incoming !== 'string') { + return res.status(400).json({ error: 'Feld "script" (string) wird benötigt.' }); + } + + saveCustomPortainerScript(incoming); + + const custom = getCustomPortainerScript(); + const effective = getEffectivePortainerScript(); + + res.json({ + success: true, + script: { + default: DEFAULT_PORTAINER_UPDATE_SCRIPT, + custom: custom?.script ?? null, + customUpdatedAt: custom?.updatedAt ?? null, + effective: effective.script, + source: effective.source, + updatedAt: effective.updatedAt + } + }); +}); + +app.delete('/api/maintenance/update-script', (req, res) => { + if (portainerUpdateState.running) { + return res.status(409).json({ + error: 'Aktualisierung läuft. Skript kann derzeit nicht geändert werden.', + update: getPortainerUpdateStatus() + }); + } + + saveCustomPortainerScript(''); + const effective = getEffectivePortainerScript(); + + res.json({ + success: true, + script: { + default: DEFAULT_PORTAINER_UPDATE_SCRIPT, + custom: null, + customUpdatedAt: null, + effective: effective.script, + source: effective.source, + updatedAt: effective.updatedAt + } + }); +}); + +app.get('/api/maintenance/update-status', (req, res) => { + res.json({ + maintenance: getMaintenanceState(), + update: getPortainerUpdateStatus() + }); +}); + +app.post('/api/maintenance/portainer-update', async (req, res) => { + if (portainerUpdateState.running) { + return res.status(409).json({ + error: 'Ein Portainer-Update läuft bereits.', + update: getPortainerUpdateStatus() + }); + } + + if (isMaintenanceModeActive()) { + return res.status(423).json({ + error: 'Wartungsmodus ist bereits aktiv.', + maintenance: getMaintenanceState() + }); + } + + const overrideScript = typeof req.body?.script === 'string' ? req.body.script : null; + let scriptSource = 'default'; + let scriptToRun = DEFAULT_PORTAINER_UPDATE_SCRIPT; + + if (overrideScript && overrideScript.trim().length) { + scriptSource = 'override'; + scriptToRun = overrideScript.replace(/\r?\n/g, '\n'); + } else { + const effective = getEffectivePortainerScript(); + scriptSource = effective.source; + scriptToRun = effective.script; + } + + let targetVersion = null; + try { + const statusBefore = await fetchPortainerStatusSummary(); + targetVersion = statusBefore?.latestVersion ?? statusBefore?.currentVersion ?? null; + } catch (err) { + console.warn('⚠️ [Maintenance] Konnte Portainer-Status vor Update nicht ermitteln:', err.message); + } + + updatePortainerState({ + running: true, + status: 'running', + stage: 'initializing', + startedAt: new Date().toISOString(), + finishedAt: null, + targetVersion, + scriptSource, + message: 'Portainer Update wird vorbereitet', + error: null, + logs: [] + }); + + addUpdateLog('Vorbereitung abgeschlossen, Update wird gestartet', 'info'); + + res.json({ + success: true, + maintenance: getMaintenanceState(), + update: getPortainerUpdateStatus() + }); + + currentPortainerUpdatePromise = performPortainerUpdate({ + script: scriptToRun, + scriptSource, + targetVersion + }).catch((err) => { + console.error('❌ [Maintenance] Portainer Update Fehler:', err.message); + }); +}); + +app.get('/api/maintenance/duplicates', maintenanceGuard, async (req, res) => { console.log("🧹 [Maintenance] GET /api/maintenance/duplicates: Abruf gestartet"); try { const { duplicates } = await loadStackCollections(); @@ -261,7 +876,7 @@ app.get('/api/maintenance/duplicates', async (req, res) => { } }); -app.post('/api/maintenance/duplicates/cleanup', async (req, res) => { +app.post('/api/maintenance/duplicates/cleanup', maintenanceGuard, async (req, res) => { const canonicalId = req.body?.canonicalId; const duplicateIdsInput = Array.isArray(req.body?.duplicateIds) ? req.body.duplicateIds : []; const duplicateIds = duplicateIdsInput @@ -485,7 +1100,7 @@ app.get('/api/logs/export', (req, res) => { }); // Einzel-Redeploy -app.put('/api/stacks/:id/redeploy', async (req, res) => { +app.put('/api/stacks/:id/redeploy', maintenanceGuard, async (req, res) => { const { id } = req.params; console.log(`🔄 PUT /api/stacks/${id}/redeploy: Redeploy gestartet`); @@ -579,7 +1194,7 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => { }); // Redeploy ALL -app.put('/api/stacks/redeploy-all', async (req, res) => { +app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => { console.log(`🚀 PUT /api/stacks/redeploy-all: Redeploy ALL gestartet`); try { @@ -700,6 +1315,82 @@ app.put('/api/stacks/redeploy-all', async (req, res) => { } }); +app.get('/api/maintenance/portainer-status', async (req, res) => { + console.log("🧭 [Maintenance] GET /api/maintenance/portainer-status: Prüfung gestartet"); + try { + const statusRes = await axiosInstance.get('/api/status'); + const statusData = statusRes.data ?? {}; + + const currentVersion = statusData.Version + ?? statusData.ServerVersion + ?? statusData.Server?.Version + ?? statusData.ServerInfo?.Version + ?? statusData.ServerVersionNumber + ?? null; + const edition = statusData.Edition ?? statusData.Server?.Edition ?? null; + const build = statusData.BuildNumber ?? statusData.Server?.Build ?? null; + + const errors = {}; + let latestVersion = null; + + try { + const githubRes = await axios.get('https://api.github.com/repos/portainer/portainer/releases/latest', { + headers: { + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'StackPulse-Maintenance' + }, + timeout: 5000 + }); + latestVersion = githubRes.data?.tag_name ?? githubRes.data?.tagName ?? null; + } catch (err) { + const message = err.response?.data?.message || err.message; + console.warn(`⚠️ [Maintenance] Konnte Portainer-Latest-Version nicht ermitteln: ${message}`); + errors.latestVersion = message; + } + + if (!currentVersion) { + errors.currentVersion = 'Portainer-Version konnte nicht ermittelt werden'; + } + + const normalizedCurrent = normalizeVersion(currentVersion); + const normalizedLatest = normalizeVersion(latestVersion); + const portainerFlag = typeof statusData.UpdateAvailable === 'boolean' + ? statusData.UpdateAvailable + : null; + + let updateAvailable = null; + if (normalizedCurrent && normalizedLatest) { + updateAvailable = compareSemver(normalizedCurrent, normalizedLatest) < 0; + } else if (portainerFlag !== null) { + updateAvailable = portainerFlag; + } + + const responsePayload = { + currentVersion, + latestVersion, + normalized: { + current: normalizedCurrent, + latest: normalizedLatest + }, + updateAvailable, + portainerFlag, + edition: edition ?? null, + build: build ?? null, + fetchedAt: new Date().toISOString() + }; + + if (Object.keys(errors).length) { + responsePayload.errors = errors; + } + + res.json(responsePayload); + } catch (err) { + const message = err.response?.data?.message || err.message || 'Unbekannter Fehler'; + console.error(`❌ [Maintenance] Fehler beim Prüfen des Portainer-Status: ${message}`); + res.status(500).json({ error: message }); + } +}); + app.get('/api/maintenance/duplicates', async (req, res) => { console.log("🧹 [Maintenance] GET /api/maintenance/duplicates: Abruf gestartet"); try { @@ -853,7 +1544,7 @@ app.post('/api/maintenance/duplicates/cleanup', async (req, res) => { } }); -app.put('/api/stacks/redeploy-selection', async (req, res) => { +app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) => { const { stackIds } = req.body || {}; console.log(`🚀 PUT /api/stacks/redeploy-selection: Redeploy Auswahl gestartet (${Array.isArray(stackIds) ? stackIds.length : 0} Stacks)`); diff --git a/backend/maintenance/state.js b/backend/maintenance/state.js new file mode 100644 index 0000000..049d92e --- /dev/null +++ b/backend/maintenance/state.js @@ -0,0 +1,70 @@ +import { getJsonSetting, setJsonSetting } from '../db/settings.js'; + +const MAINTENANCE_KEY = 'maintenance_mode'; + +const DEFAULT_STATE = { + active: false, + message: null, + activatedAt: null, + updatedAt: null, + extra: null +}; + +let maintenanceState = loadState(); + +function loadState() { + const saved = getJsonSetting(MAINTENANCE_KEY, null); + if (!saved) { + const initial = { ...DEFAULT_STATE, updatedAt: new Date().toISOString() }; + setJsonSetting(MAINTENANCE_KEY, initial); + return initial; + } + return { + ...DEFAULT_STATE, + ...saved + }; +} + +function persistState(state) { + maintenanceState = { + ...DEFAULT_STATE, + ...state, + updatedAt: new Date().toISOString() + }; + setJsonSetting(MAINTENANCE_KEY, maintenanceState); + return maintenanceState; +} + +export function getMaintenanceState() { + return { ...maintenanceState }; +} + +export function isMaintenanceModeActive() { + return Boolean(maintenanceState?.active); +} + +export function activateMaintenanceMode({ message = null, extra = null } = {}) { + const now = new Date().toISOString(); + return persistState({ + active: true, + message, + extra, + activatedAt: now + }); +} + +export function updateMaintenanceState(partial = {}) { + return persistState({ + ...maintenanceState, + ...partial + }); +} + +export function deactivateMaintenanceMode({ message = null } = {}) { + return persistState({ + active: false, + message, + extra: null, + activatedAt: null + }); +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d39b6a3..2d124c3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,6 +4,7 @@ import Stacks from "./Stacks.jsx"; import Logs from "./Logs.jsx"; import Maintenance from "./Maintenance.jsx"; import logo from "./assets/images/stackpulse.png"; +import { useMaintenance } from "./context/MaintenanceContext.jsx"; const navLinkBase = "px-4 py-2 rounded-md font-medium transition-colors duration-150"; @@ -12,6 +13,10 @@ const getNavClass = ({ isActive }) => `${navLinkBase} ${isActive ? "bg-purple-600 text-white" : "text-gray-300 hover:bg-gray-700"}`; export default function App() { + const { maintenance, update } = useMaintenance(); + const maintenanceActive = Boolean(maintenance?.active); + const maintenanceLabel = maintenance?.message || (update?.running ? "Portainer-Update läuft" : "Wartungsmodus aktiv"); + return (
@@ -19,6 +24,12 @@ export default function App() {
v0.3 WIP + {maintenanceActive && ( + + + {maintenanceLabel} + + )} StackPulse