Merge feature/v03-notifications into dev
This commit is contained in:
+403
-17
@@ -6,6 +6,9 @@ import http from 'http';
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { Server } from 'socket.io';
|
import { Server } from 'socket.io';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import crypto from 'crypto';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { db } from './db/index.js';
|
import { db } from './db/index.js';
|
||||||
import {
|
import {
|
||||||
@@ -82,12 +85,13 @@ 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 PORTAINER_SCRIPT_SETTING_KEY = 'portainer_update_script';
|
||||||
|
const PORTAINER_SSH_CONFIG_KEY = 'portainer_ssh_config';
|
||||||
|
|
||||||
const DEFAULT_PORTAINER_UPDATE_SCRIPT = [
|
const DEFAULT_PORTAINER_UPDATE_SCRIPT = [
|
||||||
'docker stop portainer',
|
'docker stop portainer',
|
||||||
'docker rm portainer',
|
'docker rm portainer',
|
||||||
'docker pull portainer/portainer-ee:lts',
|
'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'
|
'docker run -d -p 8000:8000 -p 9443:9443 --name=portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ee:lts'
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
let portainerUpdateState = {
|
let portainerUpdateState = {
|
||||||
@@ -132,17 +136,19 @@ const getPortainerUpdateStatus = () => ({
|
|||||||
const getCustomPortainerScript = () => {
|
const getCustomPortainerScript = () => {
|
||||||
const row = getSetting(PORTAINER_SCRIPT_SETTING_KEY);
|
const row = getSetting(PORTAINER_SCRIPT_SETTING_KEY);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
const value = typeof row.value === 'string' ? row.value.trim() : '';
|
const raw = typeof row.value === 'string' ? row.value : '';
|
||||||
if (!value) return null;
|
if (!raw.trim()) return null;
|
||||||
return {
|
return {
|
||||||
script: row.value,
|
script: normalizeScriptText(raw),
|
||||||
updatedAt: row.updated_at || null
|
updatedAt: row.updated_at || null
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeScriptText = (script) => String(script ?? '').replace(/\r\n/g, '\n');
|
||||||
|
|
||||||
const saveCustomPortainerScript = (script) => {
|
const saveCustomPortainerScript = (script) => {
|
||||||
const normalized = String(script ?? '').replace(/\r?\n/g, '\n').trim();
|
const normalized = normalizeScriptText(script);
|
||||||
if (!normalized) {
|
if (!normalized.trim()) {
|
||||||
deleteSetting(PORTAINER_SCRIPT_SETTING_KEY);
|
deleteSetting(PORTAINER_SCRIPT_SETTING_KEY);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -166,6 +172,295 @@ const getEffectivePortainerScript = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const SSH_ENCRYPTION_KEY = crypto.createHash('sha256')
|
||||||
|
.update(process.env.PORTAINER_SSH_SECRET || process.env.PORTAINER_API_KEY || 'stackpulse-portainer-ssh-secret')
|
||||||
|
.digest();
|
||||||
|
|
||||||
|
const encryptSensitive = (value) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', SSH_ENCRYPTION_KEY, iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
return {
|
||||||
|
iv: iv.toString('base64'),
|
||||||
|
content: encrypted.toString('base64'),
|
||||||
|
tag: authTag.toString('base64')
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const decryptSensitive = (payload) => {
|
||||||
|
if (!payload || !payload.content) return '';
|
||||||
|
try {
|
||||||
|
const iv = Buffer.from(payload.iv, 'base64');
|
||||||
|
const content = Buffer.from(payload.content, 'base64');
|
||||||
|
const tag = Buffer.from(payload.tag, 'base64');
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', SSH_ENCRYPTION_KEY, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
const decrypted = Buffer.concat([decipher.update(content), decipher.final()]);
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('⚠️ [Maintenance] Konnte privaten SSH Schlüssel nicht entschlüsseln:', err.message);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PORTAINER_SSH_CONFIG = {
|
||||||
|
host: '',
|
||||||
|
port: 22,
|
||||||
|
username: '',
|
||||||
|
extraSshArgs: [],
|
||||||
|
privateKey: '',
|
||||||
|
privateKeyPassphrase: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeString = (value, fallback = '') => {
|
||||||
|
if (value === undefined || value === null) return fallback;
|
||||||
|
return String(value).trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePort = (value, fallback = DEFAULT_PORTAINER_SSH_CONFIG.port) => {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isNaN(parsed) ? fallback : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokenizeSshArgLine = (line) => {
|
||||||
|
if (!line) return [];
|
||||||
|
const tokens = [];
|
||||||
|
const regex = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(line)) !== null) {
|
||||||
|
if (match[1] !== undefined) {
|
||||||
|
tokens.push(match[1]);
|
||||||
|
} else if (match[2] !== undefined) {
|
||||||
|
tokens.push(match[2]);
|
||||||
|
} else if (match[3] !== undefined) {
|
||||||
|
tokens.push(match[3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeExtraArgs = (value, fallback = []) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((entry) => String(entry).trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
return Array.isArray(fallback) ? [...fallback] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePrivateKey = (value, fallback = DEFAULT_PORTAINER_SSH_CONFIG.privateKey) => {
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
if (value === null) return '';
|
||||||
|
if (typeof value !== 'string') return String(value);
|
||||||
|
return value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePrivateKeyPassphrase = (value, fallback = DEFAULT_PORTAINER_SSH_CONFIG.privateKeyPassphrase) => {
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
if (value === null) return '';
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPortainerSshConfig = () => {
|
||||||
|
const stored = getSetting(PORTAINER_SSH_CONFIG_KEY);
|
||||||
|
if (!stored) {
|
||||||
|
return { ...DEFAULT_PORTAINER_SSH_CONFIG };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = stored.value ? JSON.parse(stored.value) : {};
|
||||||
|
const decryptedKey = parsed.privateKeyEncrypted ? decryptSensitive(parsed.privateKeyEncrypted) : '';
|
||||||
|
return {
|
||||||
|
host: normalizeString(parsed.host),
|
||||||
|
port: normalizePort(parsed.port),
|
||||||
|
username: normalizeString(parsed.username),
|
||||||
|
extraSshArgs: normalizeExtraArgs(parsed.extraSshArgs),
|
||||||
|
privateKey: decryptedKey,
|
||||||
|
privateKeyPassphrase: parsed.privateKeyPassphraseEncrypted ? decryptSensitive(parsed.privateKeyPassphraseEncrypted) : ''
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('⚠️ [Maintenance] Konnte Portainer SSH Konfiguration nicht parsen:', err.message);
|
||||||
|
return { ...DEFAULT_PORTAINER_SSH_CONFIG };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistPortainerSshConfig = (config) => {
|
||||||
|
const payload = {
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
username: config.username,
|
||||||
|
extraSshArgs: config.extraSshArgs,
|
||||||
|
privateKeyEncrypted: config.privateKey ? encryptSensitive(config.privateKey) : null,
|
||||||
|
privateKeyPassphraseEncrypted: config.privateKeyPassphrase ? encryptSensitive(config.privateKeyPassphrase) : null
|
||||||
|
};
|
||||||
|
setSetting(PORTAINER_SSH_CONFIG_KEY, JSON.stringify(payload));
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeSshConfig = (base, overrides = {}) => ({
|
||||||
|
host: normalizeString(overrides.host, base.host),
|
||||||
|
port: normalizePort(overrides.port, base.port),
|
||||||
|
username: normalizeString(overrides.username, base.username),
|
||||||
|
extraSshArgs: normalizeExtraArgs(overrides.extraSshArgs, base.extraSshArgs),
|
||||||
|
privateKey: normalizePrivateKey(overrides.privateKey, base.privateKey),
|
||||||
|
privateKeyPassphrase: normalizePrivateKeyPassphrase(overrides.privateKeyPassphrase, base.privateKeyPassphrase)
|
||||||
|
});
|
||||||
|
|
||||||
|
const savePortainerSshConfig = (payload = {}) => {
|
||||||
|
const current = getPortainerSshConfig();
|
||||||
|
const next = mergeSshConfig(current, payload);
|
||||||
|
persistPortainerSshConfig(next);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletePortainerSshConfig = () => {
|
||||||
|
deleteSetting(PORTAINER_SSH_CONFIG_KEY);
|
||||||
|
return { ...DEFAULT_PORTAINER_SSH_CONFIG };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTempPrivateKeyFile = (privateKey) => {
|
||||||
|
const normalized = normalizePrivateKey(privateKey, '');
|
||||||
|
const content = normalized.endsWith('\n') ? normalized : `${normalized}\n`;
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stackpulse-ssh-'));
|
||||||
|
const filePath = path.join(tmpDir, 'id_rsa');
|
||||||
|
fs.writeFileSync(filePath, content, { mode: 0o600 });
|
||||||
|
return {
|
||||||
|
filePath,
|
||||||
|
cleanup: () => {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
} catch (err) { }
|
||||||
|
try {
|
||||||
|
fs.rmdirSync(tmpDir);
|
||||||
|
} catch (err) { }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTempAskPassScript = (passphrase) => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stackpulse-askpass-'));
|
||||||
|
const scriptPath = path.join(tmpDir, 'askpass.sh');
|
||||||
|
const scriptContent = "#!/bin/sh\nprintf '%s\\n' \"$STACKPULSE_SSH_PASS\"\n";
|
||||||
|
fs.writeFileSync(scriptPath, scriptContent, { mode: 0o700 });
|
||||||
|
return {
|
||||||
|
env: {
|
||||||
|
SSH_ASKPASS: scriptPath,
|
||||||
|
SSH_ASKPASS_REQUIRE: 'force',
|
||||||
|
DISPLAY: process.env.DISPLAY || ':9999',
|
||||||
|
STACKPULSE_SSH_PASS: passphrase
|
||||||
|
},
|
||||||
|
cleanup: () => {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(scriptPath);
|
||||||
|
} catch (err) { }
|
||||||
|
try {
|
||||||
|
fs.rmdirSync(tmpDir);
|
||||||
|
} catch (err) { }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSshCommandArgs = (config) => {
|
||||||
|
const sshConfig = mergeSshConfig(DEFAULT_PORTAINER_SSH_CONFIG, config);
|
||||||
|
if (!sshConfig.host) {
|
||||||
|
throw new Error('SSH Host ist nicht konfiguriert');
|
||||||
|
}
|
||||||
|
if (!sshConfig.username) {
|
||||||
|
throw new Error('SSH Benutzer ist nicht konfiguriert');
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'-p', String(sshConfig.port),
|
||||||
|
'-o', 'StrictHostKeyChecking=no'
|
||||||
|
];
|
||||||
|
|
||||||
|
const cleanupTasks = [];
|
||||||
|
const registerCleanup = (fn) => {
|
||||||
|
if (typeof fn === 'function') {
|
||||||
|
cleanupTasks.push(fn);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!sshConfig.privateKeyPassphrase) {
|
||||||
|
args.push('-o', 'BatchMode=yes');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sshConfig.privateKey) {
|
||||||
|
const { filePath, cleanup: dispose } = createTempPrivateKeyFile(sshConfig.privateKey);
|
||||||
|
registerCleanup(dispose);
|
||||||
|
args.push('-i', filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sshConfig.extraSshArgs.length) {
|
||||||
|
const expanded = sshConfig.extraSshArgs.flatMap((entry) => tokenizeSshArgLine(entry));
|
||||||
|
if (expanded.length) {
|
||||||
|
args.push(...expanded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let envOverrides = {};
|
||||||
|
if (sshConfig.privateKeyPassphrase) {
|
||||||
|
const { env, cleanup: dispose } = createTempAskPassScript(sshConfig.privateKeyPassphrase);
|
||||||
|
envOverrides = { ...envOverrides, ...env };
|
||||||
|
registerCleanup(dispose);
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(`${sshConfig.username}@${sshConfig.host}`);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
while (cleanupTasks.length) {
|
||||||
|
const task = cleanupTasks.pop();
|
||||||
|
try {
|
||||||
|
task();
|
||||||
|
} catch (err) { }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { args, sshConfig, cleanup, env: envOverrides };
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureSshConfigReady = () => getPortainerSshConfig();
|
||||||
|
|
||||||
|
const testSshConnection = async (configOverride = null) => {
|
||||||
|
const baseConfig = configOverride
|
||||||
|
? mergeSshConfig(getPortainerSshConfig(), configOverride)
|
||||||
|
: ensureSshConfigReady();
|
||||||
|
const hasHost = Boolean(baseConfig.host && baseConfig.username);
|
||||||
|
if (!hasHost) {
|
||||||
|
throw new Error('SSH-Konfiguration ist unvollständig (Host/Benutzer erforderlich).');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { args, env: envOverrides, cleanup } = buildSshCommandArgs(baseConfig);
|
||||||
|
const sshArgs = [...args, 'echo', '__PORTAINER_SSH_TEST__'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const childEnv = { ...process.env, ...envOverrides };
|
||||||
|
const child = spawn('ssh', sshArgs, { env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let output = '';
|
||||||
|
let errorOutput = '';
|
||||||
|
child.stdout.on('data', (chunk) => { output += chunk.toString(); });
|
||||||
|
child.stderr.on('data', (chunk) => { errorOutput += chunk.toString(); });
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0 && output.includes('__PORTAINER_SSH_TEST__')) {
|
||||||
|
resolve({ success: true, output: output.trim() });
|
||||||
|
} else {
|
||||||
|
const message = errorOutput.trim() || `SSH Test fehlgeschlagen (Exit-Code ${code})`;
|
||||||
|
reject(new Error(message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const detectPortainerContainer = async () => {
|
const detectPortainerContainer = async () => {
|
||||||
try {
|
try {
|
||||||
const containersRes = await axiosInstance.get(`/api/endpoints/${ENDPOINT_ID}/docker/containers/json`, {
|
const containersRes = await axiosInstance.get(`/api/endpoints/${ENDPOINT_ID}/docker/containers/json`, {
|
||||||
@@ -367,23 +662,52 @@ const maintenanceGuard = (req, res, next) => {
|
|||||||
const logScriptOutput = (data, level) => {
|
const logScriptOutput = (data, level) => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const text = data.toString();
|
const text = data.toString();
|
||||||
text.split(/\r?\n/)
|
text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).forEach((line) => addUpdateLog(line, level));
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.forEach((line) => addUpdateLog(line, level));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const executePortainerUpdateScript = async (script) => {
|
const executePortainerUpdateScript = async (script) => {
|
||||||
const normalized = String(script ?? '').replace(/\r?\n/g, '\n').trim();
|
const normalized = normalizeScriptText(script);
|
||||||
if (!normalized) {
|
if (!normalized.trim()) {
|
||||||
addUpdateLog('Kein Update-Skript definiert. Vorgang wird übersprungen.', 'warning');
|
addUpdateLog('Kein Update-Skript definiert. Vorgang wird übersprungen.', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
const scriptWithUnixNewlines = normalized.endsWith('\n') ? normalized : `${normalized}\n`;
|
||||||
const child = spawn('bash', ['-lc', `set -e\n${normalized}`], {
|
|
||||||
env: process.env,
|
const sshConfig = getPortainerSshConfig();
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
const useSsh = Boolean(sshConfig.host && sshConfig.username);
|
||||||
|
|
||||||
|
if (!useSsh) {
|
||||||
|
addUpdateLog('Führe Update-Skript lokal auf dem StackPulse-Host aus.', 'info');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn('bash', ['-lc', `set -e\n${scriptWithUnixNewlines}`], {
|
||||||
|
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}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { args, env: envOverrides, cleanup } = buildSshCommandArgs(sshConfig);
|
||||||
|
const sshArgs = [...args, 'bash', '-s'];
|
||||||
|
|
||||||
|
addUpdateLog(`Verbinde zu ${sshConfig.username}@${sshConfig.host} für Update-Skript`, 'info');
|
||||||
|
|
||||||
|
const sshPromise = new Promise((resolve, reject) => {
|
||||||
|
const childEnv = { ...process.env, ...envOverrides };
|
||||||
|
const child = spawn('ssh', sshArgs, {
|
||||||
|
env: childEnv,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
});
|
});
|
||||||
|
|
||||||
child.stdout.on('data', (chunk) => logScriptOutput(chunk, 'stdout'));
|
child.stdout.on('data', (chunk) => logScriptOutput(chunk, 'stdout'));
|
||||||
@@ -393,12 +717,19 @@ const executePortainerUpdateScript = async (script) => {
|
|||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(`Update-Skript beendet mit Exit-Code ${code}`));
|
reject(new Error(`SSH Befehl beendet mit Exit-Code ${code}`));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
child.stdin.write(`set -e
|
||||||
|
${scriptWithUnixNewlines}`);
|
||||||
|
child.stdin.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return sshPromise.finally(() => cleanup());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
let currentPortainerUpdatePromise = null;
|
let currentPortainerUpdatePromise = null;
|
||||||
|
|
||||||
const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) => {
|
const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) => {
|
||||||
@@ -696,6 +1027,7 @@ app.get('/api/maintenance/portainer-status', async (req, res) => {
|
|||||||
app.get('/api/maintenance/config', (req, res) => {
|
app.get('/api/maintenance/config', (req, res) => {
|
||||||
const custom = getCustomPortainerScript();
|
const custom = getCustomPortainerScript();
|
||||||
const effective = getEffectivePortainerScript();
|
const effective = getEffectivePortainerScript();
|
||||||
|
const ssh = getPortainerSshConfig();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
maintenance: getMaintenanceState(),
|
maintenance: getMaintenanceState(),
|
||||||
@@ -707,10 +1039,64 @@ app.get('/api/maintenance/config', (req, res) => {
|
|||||||
effective: effective.script,
|
effective: effective.script,
|
||||||
source: effective.source,
|
source: effective.source,
|
||||||
updatedAt: effective.updatedAt
|
updatedAt: effective.updatedAt
|
||||||
|
},
|
||||||
|
ssh: {
|
||||||
|
host: ssh.host,
|
||||||
|
port: ssh.port,
|
||||||
|
username: ssh.username,
|
||||||
|
extraSshArgs: ssh.extraSshArgs,
|
||||||
|
privateKeyStored: Boolean(ssh.privateKey),
|
||||||
|
privateKeyPassphraseStored: Boolean(ssh.privateKeyPassphrase)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.put('/api/maintenance/ssh-config', (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = savePortainerSshConfig(req.body || {});
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
ssh: {
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
username: config.username,
|
||||||
|
extraSshArgs: config.extraSshArgs,
|
||||||
|
privateKeyStored: Boolean(config.privateKey),
|
||||||
|
privateKeyPassphraseStored: Boolean(config.privateKeyPassphrase)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ [Maintenance] Fehler beim Speichern der SSH-Konfiguration:', err.message);
|
||||||
|
res.status(400).json({ error: err.message || 'Ungültige SSH-Konfiguration' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/maintenance/ssh-config', (req, res) => {
|
||||||
|
const config = deletePortainerSshConfig();
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
ssh: {
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
username: config.username,
|
||||||
|
extraSshArgs: config.extraSshArgs,
|
||||||
|
privateKeyStored: false,
|
||||||
|
privateKeyPassphraseStored: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/maintenance/test-ssh', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const override = req.body && Object.keys(req.body).length ? req.body : null;
|
||||||
|
const result = await testSshConnection(override);
|
||||||
|
res.json({ success: true, result });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ [Maintenance] SSH Test fehlgeschlagen:', err.message);
|
||||||
|
res.status(500).json({ error: err.message || 'SSH Test fehlgeschlagen' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.put('/api/maintenance/update-script', (req, res) => {
|
app.put('/api/maintenance/update-script', (req, res) => {
|
||||||
if (portainerUpdateState.running) {
|
if (portainerUpdateState.running) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ const LOG_LEVEL_STYLES = {
|
|||||||
warning: "text-amber-300",
|
warning: "text-amber-300",
|
||||||
error: "text-red-300",
|
error: "text-red-300",
|
||||||
stdout: "text-gray-300",
|
stdout: "text-gray-300",
|
||||||
stderr: "text-orange-300"
|
stderr: "text-orange-300",
|
||||||
|
debug: "text-slate-400"
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatLogTimestamp = (value) => {
|
const formatLogTimestamp = (value) => {
|
||||||
@@ -90,17 +91,30 @@ const resolveStackType = (type) => {
|
|||||||
return type ?? "-";
|
return type ?? "-";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createEmptySshDraft = () => ({
|
||||||
|
host: '',
|
||||||
|
port: '22',
|
||||||
|
username: '',
|
||||||
|
privateKey: '',
|
||||||
|
privateKeyPassphrase: '',
|
||||||
|
extraSshArgs: ''
|
||||||
|
});
|
||||||
|
|
||||||
export default function Maintenance() {
|
export default function Maintenance() {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const {
|
const {
|
||||||
maintenance: maintenanceMeta,
|
maintenance: maintenanceMeta,
|
||||||
update: updateState,
|
update: updateState,
|
||||||
script: scriptConfig,
|
script: scriptConfig,
|
||||||
|
ssh: sshConfig,
|
||||||
loading: maintenanceLoading,
|
loading: maintenanceLoading,
|
||||||
error: maintenanceError,
|
error: maintenanceError,
|
||||||
triggerUpdate,
|
triggerUpdate,
|
||||||
saveScript,
|
saveScript,
|
||||||
resetScript
|
resetScript,
|
||||||
|
saveSshConfig,
|
||||||
|
deleteSshConfig,
|
||||||
|
testSshConnection
|
||||||
} = useMaintenance();
|
} = useMaintenance();
|
||||||
|
|
||||||
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||||
@@ -112,12 +126,47 @@ export default function Maintenance() {
|
|||||||
const [updateActionLoading, setUpdateActionLoading] = useState(false);
|
const [updateActionLoading, setUpdateActionLoading] = useState(false);
|
||||||
const [updateActionError, setUpdateActionError] = useState("");
|
const [updateActionError, setUpdateActionError] = useState("");
|
||||||
|
|
||||||
|
const [sshDraft, setSshDraft] = useState(() => createEmptySshDraft());
|
||||||
|
const [sshKeyStored, setSshKeyStored] = useState(false);
|
||||||
|
const [sshPassphraseStored, setSshPassphraseStored] = useState(false);
|
||||||
|
const [showPrivateKey, setShowPrivateKey] = useState(false);
|
||||||
|
const [showPassphrase, setShowPassphrase] = useState(false);
|
||||||
|
const [sshSaving, setSshSaving] = useState(false);
|
||||||
|
const [sshTesting, setSshTesting] = useState(false);
|
||||||
|
const [sshDeleting, setSshDeleting] = useState(false);
|
||||||
|
const [sshTestResult, setSshTestResult] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scriptConfig) return;
|
if (!scriptConfig) return;
|
||||||
const nextValue = scriptConfig.custom ?? scriptConfig.default ?? scriptConfig.effective ?? "";
|
const nextValue = scriptConfig.custom ?? scriptConfig.default ?? scriptConfig.effective ?? "";
|
||||||
setScriptDraft(nextValue);
|
setScriptDraft(nextValue);
|
||||||
}, [scriptConfig]);
|
}, [scriptConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sshConfig) {
|
||||||
|
setSshDraft(createEmptySshDraft());
|
||||||
|
setSshKeyStored(false);
|
||||||
|
setSshPassphraseStored(false);
|
||||||
|
setShowPrivateKey(false);
|
||||||
|
setShowPassphrase(false);
|
||||||
|
setSshTestResult(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSshDraft({
|
||||||
|
host: sshConfig.host ?? '',
|
||||||
|
port: String(sshConfig.port ?? '22'),
|
||||||
|
username: sshConfig.username ?? '',
|
||||||
|
privateKey: '',
|
||||||
|
privateKeyPassphrase: '',
|
||||||
|
extraSshArgs: Array.isArray(sshConfig.extraSshArgs) ? sshConfig.extraSshArgs.join('\n') : ''
|
||||||
|
});
|
||||||
|
setSshKeyStored(Boolean(sshConfig.privateKeyStored));
|
||||||
|
setSshPassphraseStored(Boolean(sshConfig.privateKeyPassphraseStored));
|
||||||
|
setShowPrivateKey(false);
|
||||||
|
setShowPassphrase(false);
|
||||||
|
setSshTestResult(null);
|
||||||
|
}, [sshConfig]);
|
||||||
|
|
||||||
const scriptBaseline = useMemo(() => {
|
const scriptBaseline = useMemo(() => {
|
||||||
if (!scriptConfig) return "";
|
if (!scriptConfig) return "";
|
||||||
if (scriptConfig.source === "custom" && typeof scriptConfig.custom === "string") {
|
if (scriptConfig.source === "custom" && typeof scriptConfig.custom === "string") {
|
||||||
@@ -222,6 +271,45 @@ export default function Maintenance() {
|
|||||||
return { groups, duplicateCount };
|
return { groups, duplicateCount };
|
||||||
}, [duplicates]);
|
}, [duplicates]);
|
||||||
|
|
||||||
|
const handleSshDraftChange = useCallback((field, value) => {
|
||||||
|
setSshDraft((prev) => ({ ...prev, [field]: value }));
|
||||||
|
if (field === 'privateKey') {
|
||||||
|
setSshKeyStored(false);
|
||||||
|
}
|
||||||
|
if (field === 'privateKeyPassphrase') {
|
||||||
|
setSshPassphraseStored(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const normalizedSshDraft = useMemo(() => {
|
||||||
|
const normalized = {
|
||||||
|
host: sshDraft.host.trim(),
|
||||||
|
port: Number.parseInt(sshDraft.port, 10) || 22,
|
||||||
|
username: sshDraft.username.trim(),
|
||||||
|
extraSshArgs: sshDraft.extraSshArgs
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawKey = (sshDraft.privateKey ?? '');
|
||||||
|
const sanitizedKey = rawKey.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
|
||||||
|
if (sanitizedKey) {
|
||||||
|
normalized.privateKey = sanitizedKey;
|
||||||
|
} else if (!sshKeyStored) {
|
||||||
|
normalized.privateKey = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPassphrase = sshDraft.privateKeyPassphrase ?? '';
|
||||||
|
if (rawPassphrase) {
|
||||||
|
normalized.privateKeyPassphrase = rawPassphrase;
|
||||||
|
} else if (!sshPassphraseStored) {
|
||||||
|
normalized.privateKeyPassphrase = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}, [sshDraft, sshKeyStored, sshPassphraseStored]);
|
||||||
|
|
||||||
const handleScriptSave = useCallback(async () => {
|
const handleScriptSave = useCallback(async () => {
|
||||||
if (!scriptConfig) return;
|
if (!scriptConfig) return;
|
||||||
try {
|
try {
|
||||||
@@ -257,6 +345,68 @@ export default function Maintenance() {
|
|||||||
}
|
}
|
||||||
}, [resetScript, showToast]);
|
}, [resetScript, showToast]);
|
||||||
|
|
||||||
|
const handleSshSaveConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setSshSaving(true);
|
||||||
|
await saveSshConfig(normalizedSshDraft);
|
||||||
|
setSshTestResult(null);
|
||||||
|
setShowPrivateKey(false);
|
||||||
|
setShowPassphrase(false);
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "SSH-Konfiguration gespeichert",
|
||||||
|
description: "Verbindungseinstellungen wurden aktualisiert."
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.error || err.message || "SSH-Konfiguration konnte nicht gespeichert werden";
|
||||||
|
showToast({ variant: "error", title: "Speichern fehlgeschlagen", description: message });
|
||||||
|
} finally {
|
||||||
|
setSshSaving(false);
|
||||||
|
}
|
||||||
|
}, [normalizedSshDraft, saveSshConfig, showToast]);
|
||||||
|
|
||||||
|
const handleSshTestConnection = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setSshTesting(true);
|
||||||
|
const result = await testSshConnection(normalizedSshDraft);
|
||||||
|
setSshTestResult({ success: true, timestamp: new Date(), details: result?.result });
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "SSH-Verbindung erfolgreich",
|
||||||
|
description: "Verbindung zum Portainer-Host wurde hergestellt."
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.error || err.message || "SSH-Verbindung fehlgeschlagen";
|
||||||
|
setSshTestResult({ success: false, timestamp: new Date(), error: message });
|
||||||
|
showToast({ variant: "error", title: "SSH-Test fehlgeschlagen", description: message });
|
||||||
|
} finally {
|
||||||
|
setSshTesting(false);
|
||||||
|
}
|
||||||
|
}, [normalizedSshDraft, testSshConnection, showToast]);
|
||||||
|
|
||||||
|
const handleSshDeleteConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setSshDeleting(true);
|
||||||
|
await deleteSshConfig();
|
||||||
|
setSshDraft(createEmptySshDraft());
|
||||||
|
setSshKeyStored(false);
|
||||||
|
setSshPassphraseStored(false);
|
||||||
|
setShowPrivateKey(false);
|
||||||
|
setShowPassphrase(false);
|
||||||
|
setSshTestResult(null);
|
||||||
|
showToast({
|
||||||
|
variant: "info",
|
||||||
|
title: "SSH-Konfiguration gelöscht",
|
||||||
|
description: "Die Verbindungseinstellungen wurden zurückgesetzt."
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.error || err.message || "SSH-Konfiguration konnte nicht gelöscht werden";
|
||||||
|
showToast({ variant: "error", title: "Löschen fehlgeschlagen", description: message });
|
||||||
|
} finally {
|
||||||
|
setSshDeleting(false);
|
||||||
|
}
|
||||||
|
}, [deleteSshConfig, showToast]);
|
||||||
|
|
||||||
const handleTriggerUpdate = useCallback(async () => {
|
const handleTriggerUpdate = useCallback(async () => {
|
||||||
if (maintenanceActive || updateRunning) {
|
if (maintenanceActive || updateRunning) {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -575,7 +725,149 @@ export default function Maintenance() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-6 grid gap-6 lg:grid-cols-2">
|
<div className="mt-6 grid gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<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">SSH-Verbindung</h3>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-3 text-sm text-gray-200">
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="text-xs uppercase tracking-wide text-gray-400">Host</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sshDraft.host}
|
||||||
|
onChange={(event) => handleSshDraftChange('host', event.target.value)}
|
||||||
|
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm text-gray-100 focus:border-purple-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="text-xs uppercase tracking-wide text-gray-400">Port</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={sshDraft.port}
|
||||||
|
onChange={(event) => handleSshDraftChange('port', event.target.value)}
|
||||||
|
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm text-gray-100 focus:border-purple-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="text-xs uppercase tracking-wide text-gray-400">Benutzer</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sshDraft.username}
|
||||||
|
onChange={(event) => handleSshDraftChange('username', event.target.value)}
|
||||||
|
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm text-gray-100 focus:border-purple-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<div className="flex items-center justify-between text-xs uppercase tracking-wide text-gray-400">
|
||||||
|
<label htmlFor="maintenance-ssh-private-key" className="cursor-pointer">Privater SSH-Key</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPrivateKey((prev) => !prev)}
|
||||||
|
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
|
||||||
|
className="text-[11px] font-medium text-purple-300 transition hover:text-purple-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{showPrivateKey ? 'Verbergen' : 'Anzeigen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="maintenance-ssh-private-key"
|
||||||
|
rows={6}
|
||||||
|
value={sshDraft.privateKey}
|
||||||
|
onChange={(event) => handleSshDraftChange('privateKey', event.target.value)}
|
||||||
|
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 font-mono text-xs text-gray-100 focus:border-purple-500 focus:outline-none"
|
||||||
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
style={showPrivateKey ? undefined : { WebkitTextSecurity: 'disc', MozTextSecurity: 'disc' }}
|
||||||
|
/>
|
||||||
|
{sshKeyStored && !sshDraft.privateKey && (
|
||||||
|
<span className="text-[11px] text-gray-400">
|
||||||
|
Ein privater Schlüssel ist gespeichert. Neuer Inhalt ersetzt ihn oder lösche die Konfiguration unten.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<div className="flex items-center justify-between text-xs uppercase tracking-wide text-gray-400">
|
||||||
|
<label htmlFor="maintenance-ssh-passphrase" className="cursor-pointer">Passphrase</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassphrase((prev) => !prev)}
|
||||||
|
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
|
||||||
|
className="text-[11px] font-medium text-purple-300 transition hover:text-purple-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{showPassphrase ? 'Verbergen' : 'Anzeigen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="maintenance-ssh-passphrase"
|
||||||
|
type={showPassphrase ? 'text' : 'password'}
|
||||||
|
value={sshDraft.privateKeyPassphrase}
|
||||||
|
onChange={(event) => handleSshDraftChange('privateKeyPassphrase', event.target.value)}
|
||||||
|
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm text-gray-100 focus:border-purple-500 focus:outline-none"
|
||||||
|
placeholder="optional"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
{sshPassphraseStored && !sshDraft.privateKeyPassphrase && (
|
||||||
|
<span className="text-[11px] text-gray-400">
|
||||||
|
Eine Passphrase ist gespeichert. Neuer Wert ersetzt sie oder lösche die Konfiguration unten.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="text-xs uppercase tracking-wide text-gray-400">Weitere SSH-Argumente</span>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={sshDraft.extraSshArgs}
|
||||||
|
onChange={(event) => handleSshDraftChange('extraSshArgs', event.target.value)}
|
||||||
|
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 font-mono text-[11px] text-gray-100 focus:border-purple-500 focus:outline-none"
|
||||||
|
placeholder="je Zeile ein Argument (optional)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSshSaveConfig}
|
||||||
|
disabled={sshSaving || sshTesting || sshDeleting || 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"
|
||||||
|
>
|
||||||
|
{sshSaving ? 'Speichern…' : 'SSH-Konfiguration speichern'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSshTestConnection}
|
||||||
|
disabled={sshSaving || sshTesting || sshDeleting || 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"
|
||||||
|
>
|
||||||
|
{sshTesting ? 'Test läuft…' : 'Verbindung testen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSshDeleteConfig}
|
||||||
|
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
|
||||||
|
className="rounded-lg border border-red-600 px-4 py-2 text-sm font-medium text-red-200 transition hover:bg-red-900/40 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{sshDeleting ? 'Löschen…' : 'SSH-Einstellungen löschen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{sshTestResult && (
|
||||||
|
<p className={`mt-3 text-xs ${sshTestResult.success ? 'text-green-300' : 'text-red-300'}`}>
|
||||||
|
{sshTestResult.success ? 'SSH-Verbindung erfolgreich.' : `SSH-Verbindung fehlgeschlagen: ${sshTestResult.error}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border border-gray-700 bg-gray-900/40 p-4">
|
<div className="rounded-md border border-gray-700 bg-gray-900/40 p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-white">Update-Skript</h3>
|
<h3 className="text-sm font-semibold text-white">Update-Skript</h3>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const INITIAL_STATE = {
|
|||||||
maintenance: null,
|
maintenance: null,
|
||||||
update: null,
|
update: null,
|
||||||
script: null,
|
script: null,
|
||||||
|
ssh: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
error: "",
|
error: "",
|
||||||
lastUpdated: null
|
lastUpdated: null
|
||||||
@@ -33,6 +34,7 @@ export default function MaintenanceProvider({ children }) {
|
|||||||
maintenance: data.maintenance ?? null,
|
maintenance: data.maintenance ?? null,
|
||||||
update: data.update ?? null,
|
update: data.update ?? null,
|
||||||
script: data.script ?? null,
|
script: data.script ?? null,
|
||||||
|
ssh: data.ssh ?? null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: "",
|
error: "",
|
||||||
lastUpdated: new Date()
|
lastUpdated: new Date()
|
||||||
@@ -102,10 +104,26 @@ export default function MaintenanceProvider({ children }) {
|
|||||||
await fetchConfig();
|
await fetchConfig();
|
||||||
}, [fetchConfig]);
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
const saveSshConfig = useCallback(async (config) => {
|
||||||
|
await axios.put("/api/maintenance/ssh-config", config);
|
||||||
|
await fetchConfig();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
const deleteSshConfig = useCallback(async () => {
|
||||||
|
await axios.delete("/api/maintenance/ssh-config");
|
||||||
|
await fetchConfig();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
const testSshConnection = useCallback(async (config) => {
|
||||||
|
const response = await axios.post("/api/maintenance/test-ssh", config ?? {});
|
||||||
|
return response.data;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const value = useMemo(() => ({
|
const value = useMemo(() => ({
|
||||||
maintenance: state.maintenance,
|
maintenance: state.maintenance,
|
||||||
update: state.update,
|
update: state.update,
|
||||||
script: state.script,
|
script: state.script,
|
||||||
|
ssh: state.ssh,
|
||||||
loading: state.loading,
|
loading: state.loading,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
lastUpdated: state.lastUpdated,
|
lastUpdated: state.lastUpdated,
|
||||||
@@ -113,8 +131,11 @@ export default function MaintenanceProvider({ children }) {
|
|||||||
refreshUpdateStatus,
|
refreshUpdateStatus,
|
||||||
triggerUpdate,
|
triggerUpdate,
|
||||||
saveScript,
|
saveScript,
|
||||||
resetScript
|
resetScript,
|
||||||
}), [state, fetchConfig, refreshUpdateStatus, triggerUpdate, saveScript, resetScript]);
|
saveSshConfig,
|
||||||
|
deleteSshConfig,
|
||||||
|
testSshConnection
|
||||||
|
}), [state, fetchConfig, refreshUpdateStatus, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MaintenanceContext.Provider value={value}>
|
<MaintenanceContext.Provider value={value}>
|
||||||
|
|||||||
Reference in New Issue
Block a user