Meine Änderungen
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+697
-6
@@ -7,6 +7,9 @@ import { Server } from 'socket.io';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { db } from './db/index.js';
|
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 {
|
import {
|
||||||
logRedeployEvent,
|
logRedeployEvent,
|
||||||
buildLogFilter,
|
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 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 fetchPortainerStacks = async () => {
|
||||||
const stacksRes = await axiosInstance.get('/api/stacks');
|
const stacksRes = await axiosInstance.get('/api/stacks');
|
||||||
return stacksRes.data.filter((stack) => stack.EndpointId === ENDPOINT_ID);
|
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) => {
|
const isStackOutdated = async (stackId) => {
|
||||||
try {
|
try {
|
||||||
const statusRes = await axiosInstance.get(`/api/stacks/${stackId}/images_status?refresh=true`);
|
const statusRes = await axiosInstance.get(`/api/stacks/${stackId}/images_status?refresh=true`);
|
||||||
@@ -179,7 +636,7 @@ const filterOutdatedStacks = async (stacks = []) => {
|
|||||||
// --- API Endpoints ---
|
// --- API Endpoints ---
|
||||||
|
|
||||||
// Stacks abrufen
|
// 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");
|
console.log("ℹ️ [API] GET /api/stacks: Abruf gestartet");
|
||||||
try {
|
try {
|
||||||
const { canonicalStacks, duplicates } = await loadStackCollections();
|
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");
|
console.log("🧹 [Maintenance] GET /api/maintenance/duplicates: Abruf gestartet");
|
||||||
try {
|
try {
|
||||||
const { duplicates } = await loadStackCollections();
|
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 canonicalId = req.body?.canonicalId;
|
||||||
const duplicateIdsInput = Array.isArray(req.body?.duplicateIds) ? req.body.duplicateIds : [];
|
const duplicateIdsInput = Array.isArray(req.body?.duplicateIds) ? req.body.duplicateIds : [];
|
||||||
const duplicateIds = duplicateIdsInput
|
const duplicateIds = duplicateIdsInput
|
||||||
@@ -485,7 +1100,7 @@ app.get('/api/logs/export', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Einzel-Redeploy
|
// 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;
|
const { id } = req.params;
|
||||||
console.log(`🔄 PUT /api/stacks/${id}/redeploy: Redeploy gestartet`);
|
console.log(`🔄 PUT /api/stacks/${id}/redeploy: Redeploy gestartet`);
|
||||||
|
|
||||||
@@ -579,7 +1194,7 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Redeploy ALL
|
// 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`);
|
console.log(`🚀 PUT /api/stacks/redeploy-all: Redeploy ALL gestartet`);
|
||||||
|
|
||||||
try {
|
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) => {
|
app.get('/api/maintenance/duplicates', async (req, res) => {
|
||||||
console.log("🧹 [Maintenance] GET /api/maintenance/duplicates: Abruf gestartet");
|
console.log("🧹 [Maintenance] GET /api/maintenance/duplicates: Abruf gestartet");
|
||||||
try {
|
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 || {};
|
const { stackIds } = req.body || {};
|
||||||
console.log(`🚀 PUT /api/stacks/redeploy-selection: Redeploy Auswahl gestartet (${Array.isArray(stackIds) ? stackIds.length : 0} Stacks)`);
|
console.log(`🚀 PUT /api/stacks/redeploy-selection: Redeploy Auswahl gestartet (${Array.isArray(stackIds) ? stackIds.length : 0} Stacks)`);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import Stacks from "./Stacks.jsx";
|
|||||||
import Logs from "./Logs.jsx";
|
import Logs from "./Logs.jsx";
|
||||||
import Maintenance from "./Maintenance.jsx";
|
import Maintenance from "./Maintenance.jsx";
|
||||||
import logo from "./assets/images/stackpulse.png";
|
import logo from "./assets/images/stackpulse.png";
|
||||||
|
import { useMaintenance } from "./context/MaintenanceContext.jsx";
|
||||||
|
|
||||||
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";
|
||||||
@@ -12,6 +13,10 @@ const getNavClass = ({ isActive }) =>
|
|||||||
`${navLinkBase} ${isActive ? "bg-purple-600 text-white" : "text-gray-300 hover:bg-gray-700"}`;
|
`${navLinkBase} ${isActive ? "bg-purple-600 text-white" : "text-gray-300 hover:bg-gray-700"}`;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const { maintenance, update } = useMaintenance();
|
||||||
|
const maintenanceActive = Boolean(maintenance?.active);
|
||||||
|
const maintenanceLabel = maintenance?.message || (update?.running ? "Portainer-Update läuft" : "Wartungsmodus aktiv");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white">
|
<div className="min-h-screen bg-gray-900 text-white">
|
||||||
<header className="bg-gray-800 shadow-md">
|
<header className="bg-gray-800 shadow-md">
|
||||||
@@ -19,6 +24,12 @@ export default function App() {
|
|||||||
<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.3 WIP</span>
|
<span className="text-xs text-gray-500">v0.3 WIP</span>
|
||||||
|
{maintenanceActive && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-amber-300">
|
||||||
|
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
|
||||||
|
{maintenanceLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<img src={logo} alt="StackPulse" className="h-10 w-auto" />
|
<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">
|
||||||
@@ -26,7 +37,12 @@ export default function App() {
|
|||||||
Stacks
|
Stacks
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/maintenance" className={getNavClass}>
|
<NavLink to="/maintenance" className={getNavClass}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
Wartung
|
Wartung
|
||||||
|
{maintenanceActive && (
|
||||||
|
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/logs" className={getNavClass}>
|
<NavLink to="/logs" className={getNavClass}>
|
||||||
Logs
|
Logs
|
||||||
|
|||||||
+553
-30
@@ -1,6 +1,50 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useToast } from "./components/ToastProvider.jsx";
|
import { useToast } from "./components/ToastProvider.jsx";
|
||||||
|
import { useMaintenance } from "./context/MaintenanceContext.jsx";
|
||||||
|
|
||||||
|
const UPDATE_STATUS_LABELS = {
|
||||||
|
idle: "Bereit",
|
||||||
|
running: "Läuft",
|
||||||
|
success: "Erfolgreich",
|
||||||
|
error: "Fehlgeschlagen"
|
||||||
|
};
|
||||||
|
|
||||||
|
const UPDATE_STATUS_STYLES = {
|
||||||
|
idle: "bg-gray-700 text-gray-200",
|
||||||
|
running: "bg-sky-600 text-white",
|
||||||
|
success: "bg-green-600 text-white",
|
||||||
|
error: "bg-red-600 text-white"
|
||||||
|
};
|
||||||
|
|
||||||
|
const UPDATE_STAGE_LABELS = {
|
||||||
|
initializing: "Vorbereitung",
|
||||||
|
"activating-maintenance": "Wartungsmodus aktivieren",
|
||||||
|
"executing-script": "Skript wird ausgeführt",
|
||||||
|
waiting: "Warte auf Portainer",
|
||||||
|
completed: "Abgeschlossen",
|
||||||
|
failed: "Fehlgeschlagen"
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOG_LEVEL_STYLES = {
|
||||||
|
info: "text-sky-300",
|
||||||
|
success: "text-green-300",
|
||||||
|
warning: "text-amber-300",
|
||||||
|
error: "text-red-300",
|
||||||
|
stdout: "text-gray-300",
|
||||||
|
stderr: "text-orange-300"
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLogTimestamp = (value) => {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("de-DE", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const formatCreatedAt = (value) => {
|
const formatCreatedAt = (value) => {
|
||||||
if (!value && value !== 0) return "-";
|
if (!value && value !== 0) return "-";
|
||||||
@@ -48,38 +92,123 @@ const resolveStackType = (type) => {
|
|||||||
|
|
||||||
export default function Maintenance() {
|
export default function Maintenance() {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
const {
|
||||||
|
maintenance: maintenanceMeta,
|
||||||
|
update: updateState,
|
||||||
|
script: scriptConfig,
|
||||||
|
loading: maintenanceLoading,
|
||||||
|
error: maintenanceError,
|
||||||
|
triggerUpdate,
|
||||||
|
saveScript,
|
||||||
|
resetScript
|
||||||
|
} = useMaintenance();
|
||||||
|
|
||||||
|
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||||
|
const maintenanceMessage = maintenanceMeta?.message;
|
||||||
|
const updateRunning = Boolean(updateState?.running);
|
||||||
|
|
||||||
|
const [scriptDraft, setScriptDraft] = useState("");
|
||||||
|
const [scriptSaving, setScriptSaving] = useState(false);
|
||||||
|
const [updateActionLoading, setUpdateActionLoading] = useState(false);
|
||||||
|
const [updateActionError, setUpdateActionError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scriptConfig) return;
|
||||||
|
const nextValue = scriptConfig.custom ?? scriptConfig.default ?? scriptConfig.effective ?? "";
|
||||||
|
setScriptDraft(nextValue);
|
||||||
|
}, [scriptConfig]);
|
||||||
|
|
||||||
|
const scriptBaseline = useMemo(() => {
|
||||||
|
if (!scriptConfig) return "";
|
||||||
|
if (scriptConfig.source === "custom" && typeof scriptConfig.custom === "string") {
|
||||||
|
return scriptConfig.custom;
|
||||||
|
}
|
||||||
|
return scriptConfig.default ?? scriptConfig.effective ?? "";
|
||||||
|
}, [scriptConfig]);
|
||||||
|
|
||||||
|
const scriptIsDirty = scriptConfig ? scriptDraft !== scriptBaseline : false;
|
||||||
|
const scriptSourceLabel = scriptConfig?.source === "custom" ? "Benutzerdefiniert" : "Standard";
|
||||||
|
|
||||||
const [duplicates, setDuplicates] = useState([]);
|
const [duplicates, setDuplicates] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [duplicatesLoading, setDuplicatesLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [duplicatesError, setDuplicatesError] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [duplicatesRefreshing, setDuplicatesRefreshing] = useState(false);
|
||||||
const [activeCleanupId, setActiveCleanupId] = useState(null);
|
const [activeCleanupId, setActiveCleanupId] = useState(null);
|
||||||
const [lastUpdated, setLastUpdated] = useState(null);
|
const [duplicatesUpdatedAt, setDuplicatesUpdatedAt] = useState(null);
|
||||||
|
|
||||||
|
const [portainerStatus, setPortainerStatus] = useState(null);
|
||||||
|
const [portainerLoading, setPortainerLoading] = useState(true);
|
||||||
|
const [portainerError, setPortainerError] = useState("");
|
||||||
|
const [portainerRefreshing, setPortainerRefreshing] = useState(false);
|
||||||
|
const [portainerUpdatedAt, setPortainerUpdatedAt] = useState(null);
|
||||||
|
|
||||||
|
const fetchPortainerStatus = useCallback(async ({ silent = false } = {}) => {
|
||||||
|
if (silent) {
|
||||||
|
setPortainerRefreshing(true);
|
||||||
|
} else {
|
||||||
|
setPortainerLoading(true);
|
||||||
|
}
|
||||||
|
setPortainerError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get("/api/maintenance/portainer-status");
|
||||||
|
const payload = response.data ?? {};
|
||||||
|
setPortainerStatus(payload);
|
||||||
|
setPortainerUpdatedAt(new Date());
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.error || err.message || "Fehler beim Prüfen des Portainer-Status";
|
||||||
|
setPortainerError(message);
|
||||||
|
} finally {
|
||||||
|
if (silent) {
|
||||||
|
setPortainerRefreshing(false);
|
||||||
|
} else {
|
||||||
|
setPortainerLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchDuplicates = useCallback(async ({ silent = false } = {}) => {
|
const fetchDuplicates = useCallback(async ({ silent = false } = {}) => {
|
||||||
if (silent) {
|
if (maintenanceActive || updateRunning) {
|
||||||
setRefreshing(true);
|
setDuplicates([]);
|
||||||
} else {
|
setDuplicatesError("Wartungsmodus aktiv – Duplikat-Verwaltung ist vorübergehend deaktiviert.");
|
||||||
setLoading(true);
|
setDuplicatesLoading(false);
|
||||||
|
setDuplicatesRefreshing(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setError("");
|
|
||||||
|
if (silent) {
|
||||||
|
setDuplicatesRefreshing(true);
|
||||||
|
} else {
|
||||||
|
setDuplicatesLoading(true);
|
||||||
|
}
|
||||||
|
setDuplicatesError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get("/api/maintenance/duplicates");
|
const response = await axios.get("/api/maintenance/duplicates");
|
||||||
const payload = response.data;
|
const payload = response.data;
|
||||||
const items = Array.isArray(payload) ? payload : payload?.items ?? [];
|
const items = Array.isArray(payload) ? payload : payload?.items ?? [];
|
||||||
setDuplicates(items);
|
setDuplicates(items);
|
||||||
setLastUpdated(new Date());
|
setDuplicatesUpdatedAt(new Date());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("❌ Fehler beim Laden der Wartungsdaten:", err);
|
if (err.response?.status === 423) {
|
||||||
setError("Fehler beim Laden der Wartungsdaten");
|
setDuplicates([]);
|
||||||
|
setDuplicatesError("Wartungsmodus aktiv – Duplikat-Verwaltung ist vorübergehend deaktiviert.");
|
||||||
|
} else {
|
||||||
|
const message = err.response?.data?.error || err.message || "Fehler beim Laden der Wartungsdaten";
|
||||||
|
setDuplicatesError(message);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (silent) {
|
if (silent) {
|
||||||
setRefreshing(false);
|
setDuplicatesRefreshing(false);
|
||||||
} else {
|
} else {
|
||||||
setLoading(false);
|
setDuplicatesLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, [maintenanceActive, updateRunning]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPortainerStatus();
|
||||||
|
}, [fetchPortainerStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDuplicates();
|
fetchDuplicates();
|
||||||
@@ -93,8 +222,90 @@ export default function Maintenance() {
|
|||||||
return { groups, duplicateCount };
|
return { groups, duplicateCount };
|
||||||
}, [duplicates]);
|
}, [duplicates]);
|
||||||
|
|
||||||
|
const handleScriptSave = useCallback(async () => {
|
||||||
|
if (!scriptConfig) return;
|
||||||
|
try {
|
||||||
|
setScriptSaving(true);
|
||||||
|
await saveScript(scriptDraft);
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "Skript gespeichert",
|
||||||
|
description: "Das benutzerdefinierte Portainer-Update-Skript wurde aktualisiert."
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.error || err.message || "Skript konnte nicht gespeichert werden";
|
||||||
|
showToast({ variant: "error", title: "Speichern fehlgeschlagen", description: message });
|
||||||
|
} finally {
|
||||||
|
setScriptSaving(false);
|
||||||
|
}
|
||||||
|
}, [saveScript, scriptDraft, scriptConfig, showToast]);
|
||||||
|
|
||||||
|
const handleScriptReset = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setScriptSaving(true);
|
||||||
|
await resetScript();
|
||||||
|
showToast({
|
||||||
|
variant: "info",
|
||||||
|
title: "Standardskript wiederhergestellt",
|
||||||
|
description: "Es wird wieder das Standard-Update-Skript verwendet."
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.error || err.message || "Standardskript konnte nicht wiederhergestellt werden";
|
||||||
|
showToast({ variant: "error", title: "Zurücksetzen fehlgeschlagen", description: message });
|
||||||
|
} finally {
|
||||||
|
setScriptSaving(false);
|
||||||
|
}
|
||||||
|
}, [resetScript, showToast]);
|
||||||
|
|
||||||
|
const handleTriggerUpdate = useCallback(async () => {
|
||||||
|
if (maintenanceActive || updateRunning) {
|
||||||
|
showToast({
|
||||||
|
variant: "warning",
|
||||||
|
title: "Update nicht möglich",
|
||||||
|
description: "Während eines aktiven Wartungsmodus kann kein weiteres Update gestartet werden."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetVersion = portainerStatus?.latestVersion ?? portainerStatus?.currentVersion ?? "unbekannt";
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Portainer-Update starten?\nZielversion: ${targetVersion}.\nWährend des Updates befindet sich StackPulse im Wartungsmodus.\nFortfahren?`
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUpdateActionError("");
|
||||||
|
setUpdateActionLoading(true);
|
||||||
|
await triggerUpdate();
|
||||||
|
showToast({
|
||||||
|
variant: "info",
|
||||||
|
title: "Update gestartet",
|
||||||
|
description: "Das Portainer-Update wurde gestartet."
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.error || err.message || "Portainer-Update konnte nicht gestartet werden";
|
||||||
|
setUpdateActionError(message);
|
||||||
|
showToast({ variant: "error", title: "Update fehlgeschlagen", description: message });
|
||||||
|
} finally {
|
||||||
|
setUpdateActionLoading(false);
|
||||||
|
}
|
||||||
|
}, [maintenanceActive, updateRunning, portainerStatus, triggerUpdate, showToast]);
|
||||||
|
|
||||||
const handleCleanup = useCallback(async (entry) => {
|
const handleCleanup = useCallback(async (entry) => {
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
if (maintenanceActive || updateRunning) {
|
||||||
|
showToast({
|
||||||
|
variant: "warning",
|
||||||
|
title: "Aktion nicht möglich",
|
||||||
|
description: "Während des Wartungsmodus sind Bereinigungen deaktiviert."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const canonicalId = entry.canonical?.Id;
|
const canonicalId = entry.canonical?.Id;
|
||||||
if (!canonicalId) return;
|
if (!canonicalId) return;
|
||||||
|
|
||||||
@@ -105,7 +316,7 @@ export default function Maintenance() {
|
|||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const confirmation = window.confirm(
|
const confirmation = window.confirm(
|
||||||
`Bereinigung für \"${canonicalName}\" starten?\n` +
|
`Bereinigung für "${canonicalName}" starten?\n` +
|
||||||
`Es werden ${duplicateIds.length} Duplikate entfernt: ${duplicateIds.join(", ")}`
|
`Es werden ${duplicateIds.length} Duplikate entfernt: ${duplicateIds.join(", ")}`
|
||||||
);
|
);
|
||||||
if (!confirmation) {
|
if (!confirmation) {
|
||||||
@@ -138,7 +349,6 @@ export default function Maintenance() {
|
|||||||
await fetchDuplicates({ silent: true });
|
await fetchDuplicates({ silent: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err.response?.data?.error || err.message || "Bereinigung fehlgeschlagen";
|
const message = err.response?.data?.error || err.message || "Bereinigung fehlgeschlagen";
|
||||||
console.error("❌ Fehler bei der Duplikat-Bereinigung:", err);
|
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: "Bereinigung fehlgeschlagen",
|
title: "Bereinigung fehlgeschlagen",
|
||||||
@@ -147,16 +357,30 @@ export default function Maintenance() {
|
|||||||
} finally {
|
} finally {
|
||||||
setActiveCleanupId(null);
|
setActiveCleanupId(null);
|
||||||
}
|
}
|
||||||
}, [fetchDuplicates, showToast]);
|
}, [fetchDuplicates, maintenanceActive, showToast, updateRunning]);
|
||||||
|
|
||||||
const isEmpty = !loading && totals.groups === 0;
|
const isDuplicatesDisabled = maintenanceActive || updateRunning;
|
||||||
|
const updateStatusKey = updateState?.status ?? (updateRunning ? "running" : "idle");
|
||||||
|
const updateStatusLabel = UPDATE_STATUS_LABELS[updateStatusKey] ?? updateStatusKey;
|
||||||
|
const updateBadgeClass = UPDATE_STATUS_STYLES[updateStatusKey] ?? UPDATE_STATUS_STYLES.idle;
|
||||||
|
const updateStageLabel = updateState?.stage
|
||||||
|
? UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage
|
||||||
|
: "–";
|
||||||
|
const updateLogs = updateState?.logs ?? [];
|
||||||
|
const updateTargetVersion = updateState?.targetVersion ?? "-";
|
||||||
|
const updateResultVersion = updateState?.resultVersion ?? "-";
|
||||||
|
const updateStartedAt = updateState?.startedAt ? formatCreatedAt(updateState.startedAt) : "-";
|
||||||
|
const updateFinishedAt = updateState?.finishedAt ? formatCreatedAt(updateState.finishedAt) : "-";
|
||||||
|
const updateStatusMessage = updateState?.message ?? (updateRunning ? "Update läuft…" : "");
|
||||||
|
|
||||||
|
const disableUpdateButton = updateRunning || scriptSaving || maintenanceLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h1 className="text-2xl font-semibold text-white">Wartung</h1>
|
<h1 className="text-2xl font-semibold text-white">Wartung</h1>
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
Werkzeuge für wiederkehrende Wartungsaufgaben. Entfernt derzeit doppelte Stack-Einträge.
|
Werkzeuge für wiederkehrende Wartungsaufgaben. Entfernt derzeit doppelte Stack-Einträge und verwaltet Portainer-Updates.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,20 +388,317 @@ export default function Maintenance() {
|
|||||||
Dieses Wartungsfeature ist noch nicht getestet. Bitte nicht in Produktivumgebungen einsetzen.
|
Dieses Wartungsfeature ist noch nicht getestet. Bitte nicht in Produktivumgebungen einsetzen.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(maintenanceActive || updateRunning) && (
|
||||||
|
<div className="rounded-lg border border-amber-500/60 bg-amber-900/30 px-4 py-3 text-sm text-amber-100">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span>
|
||||||
|
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}.
|
||||||
|
</span>
|
||||||
|
{updateRunning && (
|
||||||
|
<span className="text-xs text-amber-200">
|
||||||
|
Phase: {updateStageLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{maintenanceError && (
|
||||||
|
<div className="rounded-lg border border-red-500/60 bg-red-900/30 px-4 py-3 text-sm text-red-100">
|
||||||
|
{maintenanceError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-gray-700 bg-gray-800/60 p-6 shadow-lg">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Portainer Status</h2>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{portainerLoading
|
||||||
|
? "Status wird geprüft…"
|
||||||
|
: portainerError
|
||||||
|
? portainerError
|
||||||
|
: updateRunning
|
||||||
|
? "Portainer wird aktuell aktualisiert."
|
||||||
|
: (portainerStatus?.updateAvailable === true
|
||||||
|
? "Es ist ein Update für Portainer verfügbar."
|
||||||
|
: "Portainer ist auf dem aktuellen Stand.")}
|
||||||
|
</p>
|
||||||
|
{portainerUpdatedAt && !portainerLoading && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Stand: {portainerUpdatedAt.toLocaleString("de-DE", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fetchPortainerStatus({ silent: false })}
|
||||||
|
disabled={portainerLoading || portainerRefreshing}
|
||||||
|
className="rounded-lg border border-purple-500 px-4 py-2 text-sm font-medium text-purple-200 transition hover:bg-purple-500/20 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Status aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!portainerLoading && !portainerError && portainerStatus && (
|
||||||
|
<div className="mt-5 space-y-4 text-sm text-gray-200">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-400">Installierte Version</span>
|
||||||
|
<span className="font-medium">{portainerStatus.currentVersion ?? "Unbekannt"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-400">Neueste Version</span>
|
||||||
|
<span className="font-medium">{portainerStatus.latestVersion ?? "Unbekannt"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-400">Status</span>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-3 py-0.5 text-xs font-semibold ${
|
||||||
|
portainerStatus.updateAvailable === true
|
||||||
|
? UPDATE_STATUS_STYLES.error
|
||||||
|
: portainerStatus.updateAvailable === false
|
||||||
|
? UPDATE_STATUS_STYLES.success
|
||||||
|
: UPDATE_STATUS_STYLES.idle
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{portainerStatus.updateAvailable === true
|
||||||
|
? "Update verfügbar"
|
||||||
|
: portainerStatus.updateAvailable === false
|
||||||
|
? "Aktuell"
|
||||||
|
: "Unbekannt"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{portainerStatus.edition && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-400">Edition</span>
|
||||||
|
<span className="font-medium">{portainerStatus.edition}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{portainerStatus.build && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-400">Build</span>
|
||||||
|
<span className="font-medium">{portainerStatus.build}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{portainerStatus.errors?.latestVersion && (
|
||||||
|
<div className="rounded-md border border-amber-500/40 bg-amber-900/40 px-3 py-2 text-xs text-amber-100">
|
||||||
|
Neueste Version konnte nicht ermittelt werden: {portainerStatus.errors.latestVersion}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{portainerStatus.errors?.container && (
|
||||||
|
<div className="rounded-md border border-amber-500/40 bg-amber-900/40 px-3 py-2 text-xs text-amber-100">
|
||||||
|
Container-Details konnten nicht ermittelt werden: {portainerStatus.errors.container}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{portainerStatus.container && (
|
||||||
|
<div className="rounded-md border border-gray-700 bg-gray-900/40 p-4 text-xs text-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-white">Startparameter</h3>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{(portainerStatus.container.name || portainerStatus.container.image) && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="text-gray-400">Container</span>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="block font-medium text-white">{portainerStatus.container.name ?? "Unbekannt"}</span>
|
||||||
|
{portainerStatus.container.image && (
|
||||||
|
<span className="text-[11px] text-gray-400">{portainerStatus.container.image}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{portainerStatus.container.id && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="text-gray-400">Container-ID</span>
|
||||||
|
<code
|
||||||
|
title={portainerStatus.container.id}
|
||||||
|
className="rounded bg-gray-800/80 px-2 py-1 font-mono text-[11px] text-gray-100"
|
||||||
|
>
|
||||||
|
{portainerStatus.container.id.slice(0, 12)}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<span className="text-gray-400">Startkommando</span>
|
||||||
|
<code className="rounded bg-gray-800/60 px-2 py-1 font-mono text-[11px] text-gray-100 sm:max-w-xs sm:text-right">
|
||||||
|
{[...(portainerStatus.container.entrypoint || []), ...(portainerStatus.container.command || [])].join(" ") || "Unbekannt"}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
{Array.isArray(portainerStatus.container.args) && portainerStatus.container.args.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<span className="text-gray-400">Argumente</span>
|
||||||
|
<code className="rounded bg-gray-800/60 px-2 py-1 font-mono text-[11px] text-gray-100 sm:max-w-xs sm:text-right">
|
||||||
|
{portainerStatus.container.args.join(" ")}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Array.isArray(portainerStatus.container.env) && portainerStatus.container.env.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-gray-400">Umgebungsvariablen</span>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{portainerStatus.container.env.map((entry, index) => (
|
||||||
|
<code
|
||||||
|
key={`${entry || "env"}-${index}`}
|
||||||
|
className="block rounded bg-gray-800/70 px-2 py-1 font-mono text-[11px] text-gray-100"
|
||||||
|
>
|
||||||
|
{entry}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Array.isArray(portainerStatus.container.binds) && portainerStatus.container.binds.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-gray-400">Bind-Mounts</span>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{portainerStatus.container.binds.map((bind, index) => (
|
||||||
|
<code
|
||||||
|
key={`bind-${index}`}
|
||||||
|
className="block rounded bg-gray-800/70 px-2 py-1 font-mono text-[11px] text-gray-100"
|
||||||
|
>
|
||||||
|
{bind}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-6 lg:grid-cols-2">
|
||||||
|
<div className="rounded-md border border-gray-700 bg-gray-900/40 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-white">Update-Skript</h3>
|
||||||
|
<span className="text-xs text-gray-400">Quelle: {scriptSourceLabel}</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={scriptDraft}
|
||||||
|
onChange={(event) => setScriptDraft(event.target.value)}
|
||||||
|
rows={10}
|
||||||
|
disabled={scriptSaving || updateRunning}
|
||||||
|
className="mt-3 w-full rounded-md border border-gray-700 bg-gray-950/80 px-3 py-2 font-mono text-xs text-gray-100 focus:border-purple-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleScriptSave}
|
||||||
|
disabled={!scriptIsDirty || scriptSaving || updateRunning}
|
||||||
|
className="rounded-lg bg-purple-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-purple-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleScriptReset}
|
||||||
|
disabled={!scriptConfig || scriptConfig.source !== "custom" || scriptSaving || updateRunning}
|
||||||
|
className="rounded-lg border border-gray-600 px-4 py-2 text-sm font-medium text-gray-200 transition hover:bg-gray-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Standard wiederherstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{scriptConfig?.customUpdatedAt && (
|
||||||
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
|
Zuletzt geändert: {formatCreatedAt(scriptConfig.customUpdatedAt)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-gray-700 bg-gray-900/40 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-white">Update-Status</h3>
|
||||||
|
<span className={`rounded-full px-3 py-0.5 text-xs font-semibold ${updateBadgeClass}`}>
|
||||||
|
{updateStatusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-2 text-xs text-gray-300">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Phase</span>
|
||||||
|
<span className="font-medium text-gray-100">{updateStageLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Ziel-Version</span>
|
||||||
|
<span className="font-medium text-gray-100">{updateTargetVersion}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Installierte Version</span>
|
||||||
|
<span className="font-medium text-gray-100">{updateResultVersion}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Gestartet</span>
|
||||||
|
<span>{updateStartedAt}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Beendet</span>
|
||||||
|
<span>{updateFinishedAt}</span>
|
||||||
|
</div>
|
||||||
|
{updateStatusMessage && (
|
||||||
|
<div className="rounded-md border border-gray-700 bg-gray-800/60 px-3 py-2 text-xs text-gray-200">
|
||||||
|
{updateStatusMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{updateState?.error && (
|
||||||
|
<div className="rounded-md border border-red-500/60 bg-red-900/40 px-3 py-2 text-xs text-red-200">
|
||||||
|
{updateState.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{updateActionError && (
|
||||||
|
<div className="rounded-md border border-red-500/60 bg-red-900/40 px-3 py-2 text-xs text-red-200">
|
||||||
|
{updateActionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-40 overflow-y-auto rounded-md border border-gray-700 bg-gray-950/70 p-3 font-mono text-[11px] leading-relaxed text-gray-300">
|
||||||
|
{updateLogs.length === 0 ? (
|
||||||
|
<p className="text-gray-500">Noch keine Protokolle vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
updateLogs.map((entry, index) => (
|
||||||
|
<div key={`${entry.timestamp}-${index}`} className="mb-2 last:mb-0">
|
||||||
|
<span className="text-gray-500">[{formatLogTimestamp(entry.timestamp)}]</span>{" "}
|
||||||
|
<span className={LOG_LEVEL_STYLES[entry.level] ?? "text-gray-300"}>
|
||||||
|
{entry.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTriggerUpdate}
|
||||||
|
disabled={disableUpdateButton || updateActionLoading}
|
||||||
|
className="mt-3 w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateActionLoading ? "Update wird gestartet…" : "Portainer aktualisieren"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-gray-700 bg-gray-800/60 p-6 shadow-lg">
|
<div className="rounded-xl border border-gray-700 bg-gray-800/60 p-6 shadow-lg">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white">Doppelte Stacks</h2>
|
<h2 className="text-lg font-semibold text-white">Doppelte Stacks</h2>
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
{loading
|
{isDuplicatesDisabled
|
||||||
|
? "Wartungsmodus aktiv – Duplikat-Verwaltung ist vorübergehend deaktiviert."
|
||||||
|
: duplicatesLoading
|
||||||
? "Analyse läuft…"
|
? "Analyse läuft…"
|
||||||
: totals.groups === 0
|
: totals.groups === 0
|
||||||
? "Keine Duplikate gefunden"
|
? "Keine Duplikate gefunden"
|
||||||
: `${totals.groups} Stack-Namen mit insgesamt ${totals.duplicateCount} Duplikaten gefunden`}
|
: `${totals.groups} Stack-Namen mit insgesamt ${totals.duplicateCount} Duplikaten gefunden`}
|
||||||
</p>
|
</p>
|
||||||
{lastUpdated && (
|
{duplicatesUpdatedAt && !duplicatesLoading && !isDuplicatesDisabled && (
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
Stand: {lastUpdated.toLocaleString("de-DE", {
|
Stand: {duplicatesUpdatedAt.toLocaleString("de-DE", {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit"
|
second: "2-digit"
|
||||||
@@ -189,7 +710,7 @@ export default function Maintenance() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fetchDuplicates({ silent: false })}
|
onClick={() => fetchDuplicates({ silent: false })}
|
||||||
disabled={loading || refreshing || activeCleanupId !== null}
|
disabled={isDuplicatesDisabled || duplicatesLoading || duplicatesRefreshing || activeCleanupId !== null}
|
||||||
className="rounded-lg border border-purple-500 px-4 py-2 text-sm font-medium text-purple-200 transition hover:bg-purple-500/20 disabled:opacity-50"
|
className="rounded-lg border border-purple-500 px-4 py-2 text-sm font-medium text-purple-200 transition hover:bg-purple-500/20 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Aktualisieren
|
Aktualisieren
|
||||||
@@ -198,19 +719,21 @@ export default function Maintenance() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{duplicatesError && !isDuplicatesDisabled && (
|
||||||
<div className="rounded-lg border border-red-500/60 bg-red-900/40 px-4 py-3 text-sm text-red-100">
|
<div className="rounded-lg border border-red-500/60 bg-red-900/40 px-4 py-3 text-sm text-red-100">
|
||||||
{error}
|
{duplicatesError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{duplicatesLoading ? (
|
||||||
<div className="rounded-xl border border-gray-800 bg-gray-900/60 p-8 text-center text-sm text-gray-400">
|
<div className="rounded-xl border border-gray-800 bg-gray-900/60 p-8 text-center text-sm text-gray-400">
|
||||||
Daten werden geladen…
|
Daten werden geladen…
|
||||||
</div>
|
</div>
|
||||||
) : isEmpty ? (
|
) : totals.groups === 0 || isDuplicatesDisabled ? (
|
||||||
<div className="rounded-xl border border-green-600/40 bg-green-900/30 p-8 text-center text-sm text-green-100">
|
<div className="rounded-xl border border-green-600/40 bg-green-900/30 p-8 text-center text-sm text-green-100">
|
||||||
Es wurden keine doppelten Stacks gefunden.
|
{isDuplicatesDisabled
|
||||||
|
? "Wartungsmodus aktiv – Duplikat-Verwaltung ist vorübergehend deaktiviert."
|
||||||
|
: "Es wurden keine doppelten Stacks gefunden."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -243,7 +766,7 @@ export default function Maintenance() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleCleanup(entry)}
|
onClick={() => handleCleanup(entry)}
|
||||||
disabled={isProcessing || refreshing || loading}
|
disabled={isDuplicatesDisabled || isProcessing || duplicatesRefreshing || duplicatesLoading}
|
||||||
className="self-start rounded-lg bg-red-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-600 disabled:opacity-50"
|
className="self-start rounded-lg bg-red-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isProcessing ? "Bereinigung läuft…" : `Bereinigen (${duplicatesForEntry.length})`}
|
{isProcessing ? "Bereinigung läuft…" : `Bereinigen (${duplicatesForEntry.length})`}
|
||||||
|
|||||||
+39
-11
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { io } from "socket.io-client";
|
import { io } from "socket.io-client";
|
||||||
import { useToast } from "./components/ToastProvider.jsx";
|
import { useToast } from "./components/ToastProvider.jsx";
|
||||||
|
import { useMaintenance } from "./context/MaintenanceContext.jsx";
|
||||||
|
|
||||||
const SELECTION_PROMPT_STORAGE_KEY = "stackSelectionPreference";
|
const SELECTION_PROMPT_STORAGE_KEY = "stackSelectionPreference";
|
||||||
|
|
||||||
@@ -45,6 +46,12 @@ export default function Stacks() {
|
|||||||
|
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
const { maintenance, update } = useMaintenance();
|
||||||
|
const maintenanceActive = Boolean(maintenance?.active);
|
||||||
|
const maintenanceMessage = maintenance?.message;
|
||||||
|
const maintenanceLocked = maintenanceActive || Boolean(update?.running);
|
||||||
|
const maintenanceBanner = maintenanceLocked ? (maintenanceMessage || (update?.running ? "Portainer-Update läuft" : "Wartungsmodus aktiv")) : "";
|
||||||
|
|
||||||
const stacksByIdRef = useRef(new Map());
|
const stacksByIdRef = useRef(new Map());
|
||||||
|
|
||||||
const mergeStackState = useCallback((previousStacks, incomingStacks) => {
|
const mergeStackState = useCallback((previousStacks, incomingStacks) => {
|
||||||
@@ -158,6 +165,13 @@ export default function Stacks() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchStacks = useCallback(async ({ force = false, silent = false } = {}) => {
|
const fetchStacks = useCallback(async ({ force = false, silent = false } = {}) => {
|
||||||
|
if (maintenanceActive) {
|
||||||
|
if (!silent) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const hadCache = Boolean(stacksCache.data);
|
const hadCache = Boolean(stacksCache.data);
|
||||||
|
|
||||||
if (!force && hadCache) {
|
if (!force && hadCache) {
|
||||||
@@ -200,14 +214,14 @@ export default function Stacks() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [mergeStackState]);
|
}, [maintenanceActive, mergeStackState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStacks();
|
fetchStacks();
|
||||||
}, [fetchStacks]);
|
}, [fetchStacks]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof document === 'undefined') return undefined;
|
if (typeof document === 'undefined' || maintenanceActive) return undefined;
|
||||||
|
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
@@ -216,10 +230,10 @@ export default function Stacks() {
|
|||||||
}, STACKS_REFRESH_INTERVAL);
|
}, STACKS_REFRESH_INTERVAL);
|
||||||
|
|
||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId);
|
||||||
}, [fetchStacks]);
|
}, [fetchStacks, maintenanceActive]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof document === 'undefined') return undefined;
|
if (typeof document === 'undefined' || maintenanceActive) return undefined;
|
||||||
|
|
||||||
const handleVisibility = () => {
|
const handleVisibility = () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
@@ -231,7 +245,7 @@ export default function Stacks() {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('visibilitychange', handleVisibility);
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
};
|
};
|
||||||
}, [fetchStacks]);
|
}, [fetchStacks, maintenanceActive]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedStackIds(prev => {
|
setSelectedStackIds(prev => {
|
||||||
@@ -390,7 +404,7 @@ export default function Stacks() {
|
|||||||
const pageEnd = perPage === 'all' ? totalItems : Math.min(totalItems, page * perPageNumber);
|
const pageEnd = perPage === 'all' ? totalItems : Math.min(totalItems, page * perPageNumber);
|
||||||
|
|
||||||
const toggleStackSelection = (stackId, disabled) => {
|
const toggleStackSelection = (stackId, disabled) => {
|
||||||
if (disabled) return;
|
if (maintenanceLocked || disabled) return;
|
||||||
setSelectedStackIds(prev =>
|
setSelectedStackIds(prev =>
|
||||||
prev.includes(stackId)
|
prev.includes(stackId)
|
||||||
? prev.filter(id => id !== stackId)
|
? prev.filter(id => id !== stackId)
|
||||||
@@ -599,14 +613,23 @@ export default function Stacks() {
|
|||||||
? `Redeploy Auswahl (${selectedStackIds.length})`
|
? `Redeploy Auswahl (${selectedStackIds.length})`
|
||||||
: 'Redeploy All';
|
: 'Redeploy All';
|
||||||
|
|
||||||
const bulkActionDisabled = hasSelection
|
const bulkActionDisabled = maintenanceLocked || (hasSelection
|
||||||
? selectionPromptVisible || selectedStackIds.length === 0 || selectedStackIds.every(id => {
|
? selectionPromptVisible || 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.redeploying || targetStack.updateStatus === '✅' || targetStack.redeployDisabled;
|
||||||
})
|
})
|
||||||
: !hasOutdatedStacks || eligiblePageStacks.every(stack => stack.redeploying);
|
: !hasOutdatedStacks || eligiblePageStacks.every(stack => stack.redeploying));
|
||||||
|
|
||||||
const handleBulkRedeploy = () => {
|
const handleBulkRedeploy = () => {
|
||||||
|
if (maintenanceLocked) {
|
||||||
|
showToast({
|
||||||
|
variant: 'warning',
|
||||||
|
title: 'Wartungsmodus aktiv',
|
||||||
|
description: 'Redeploy-Aktionen sind während des Wartungsmodus deaktiviert.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (hasSelection) {
|
if (hasSelection) {
|
||||||
handleRedeploySelection();
|
handleRedeploySelection();
|
||||||
} else {
|
} else {
|
||||||
@@ -619,6 +642,11 @@ export default function Stacks() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl p-6">
|
<div className="mx-auto max-w-6xl p-6">
|
||||||
|
{maintenanceLocked && (
|
||||||
|
<div className="mb-6 rounded-lg border border-amber-500/60 bg-amber-900/30 px-4 py-3 text-sm text-amber-100">
|
||||||
|
{maintenanceBanner}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:gap-6">
|
<div className="flex flex-col gap-4 md:flex-row md:items-end md:gap-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -775,7 +803,7 @@ export default function Stacks() {
|
|||||||
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 = !isRedeploying && !isCurrent && !isSelfStack && !maintenanceLocked;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -827,8 +855,8 @@ export default function Stacks() {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRedeploy(stack.Id)}
|
onClick={() => handleRedeploy(stack.Id)}
|
||||||
disabled={isRedeploying}
|
disabled={isRedeploying || maintenanceLocked}
|
||||||
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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Redeploy
|
Redeploy
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const MaintenanceContext = createContext(null);
|
||||||
|
|
||||||
|
const INITIAL_STATE = {
|
||||||
|
maintenance: null,
|
||||||
|
update: null,
|
||||||
|
script: null,
|
||||||
|
loading: true,
|
||||||
|
error: "",
|
||||||
|
lastUpdated: null
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MaintenanceProvider({ children }) {
|
||||||
|
const [state, setState] = useState(INITIAL_STATE);
|
||||||
|
const pollingRef = useRef(null);
|
||||||
|
const wasRunningRef = useRef(false);
|
||||||
|
|
||||||
|
const applyState = useCallback((partial) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
...partial
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchConfig = useCallback(async () => {
|
||||||
|
applyState({ loading: true, error: "" });
|
||||||
|
try {
|
||||||
|
const response = await axios.get("/api/maintenance/config");
|
||||||
|
const data = response.data ?? {};
|
||||||
|
applyState({
|
||||||
|
maintenance: data.maintenance ?? null,
|
||||||
|
update: data.update ?? null,
|
||||||
|
script: data.script ?? null,
|
||||||
|
loading: false,
|
||||||
|
error: "",
|
||||||
|
lastUpdated: new Date()
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.error || err.message || "Fehler beim Laden der Wartungsdaten";
|
||||||
|
applyState({ loading: false, error: message });
|
||||||
|
}
|
||||||
|
}, [applyState]);
|
||||||
|
|
||||||
|
const refreshUpdateStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get("/api/maintenance/update-status");
|
||||||
|
const data = response.data ?? {};
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
maintenance: data.maintenance ?? prev.maintenance,
|
||||||
|
update: data.update ?? prev.update,
|
||||||
|
error: ""
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.error || err.message || "Fehler beim Aktualisieren des Wartungsstatus";
|
||||||
|
setState((prev) => ({ ...prev, error: message }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.update?.running) {
|
||||||
|
refreshUpdateStatus();
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
refreshUpdateStatus();
|
||||||
|
}, 5000);
|
||||||
|
pollingRef.current = intervalId;
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}
|
||||||
|
if (pollingRef.current) {
|
||||||
|
clearInterval(pollingRef.current);
|
||||||
|
pollingRef.current = null;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [state.update?.running, refreshUpdateStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentlyRunning = Boolean(state.update?.running);
|
||||||
|
if (!currentlyRunning && wasRunningRef.current) {
|
||||||
|
fetchConfig();
|
||||||
|
}
|
||||||
|
wasRunningRef.current = currentlyRunning;
|
||||||
|
}, [state.update?.running, fetchConfig]);
|
||||||
|
|
||||||
|
const triggerUpdate = useCallback(async (payload = {}) => {
|
||||||
|
await axios.post("/api/maintenance/portainer-update", payload);
|
||||||
|
await refreshUpdateStatus();
|
||||||
|
}, [refreshUpdateStatus]);
|
||||||
|
|
||||||
|
const saveScript = useCallback(async (script) => {
|
||||||
|
await axios.put("/api/maintenance/update-script", { script });
|
||||||
|
await fetchConfig();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
const resetScript = useCallback(async () => {
|
||||||
|
await axios.delete("/api/maintenance/update-script");
|
||||||
|
await fetchConfig();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({
|
||||||
|
maintenance: state.maintenance,
|
||||||
|
update: state.update,
|
||||||
|
script: state.script,
|
||||||
|
loading: state.loading,
|
||||||
|
error: state.error,
|
||||||
|
lastUpdated: state.lastUpdated,
|
||||||
|
refreshConfig: fetchConfig,
|
||||||
|
refreshUpdateStatus,
|
||||||
|
triggerUpdate,
|
||||||
|
saveScript,
|
||||||
|
resetScript
|
||||||
|
}), [state, fetchConfig, refreshUpdateStatus, triggerUpdate, saveScript, resetScript]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MaintenanceContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</MaintenanceContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMaintenance() {
|
||||||
|
const context = useContext(MaintenanceContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useMaintenance must be used within a MaintenanceProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -3,14 +3,17 @@ import ReactDOM from "react-dom/client";
|
|||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import App from "./App.jsx";
|
import App from "./App.jsx";
|
||||||
import ToastProvider from "./components/ToastProvider.jsx";
|
import ToastProvider from "./components/ToastProvider.jsx";
|
||||||
|
import MaintenanceProvider from "./context/MaintenanceContext.jsx";
|
||||||
import './index.css'; // Tailwind CSS
|
import './index.css'; // Tailwind CSS
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<MaintenanceProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<App />
|
<App />
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
</MaintenanceProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user