Agent+MultiServer

This commit is contained in:
2025-11-25 16:02:00 +00:00
parent 9b25cb807a
commit fc3ed57a40
26 changed files with 3233 additions and 338 deletions
+12
View File
@@ -0,0 +1,12 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY src ./src
EXPOSE 7070
CMD ["node", "src/index.js"]
+34
View File
@@ -0,0 +1,34 @@
# Stackpulse Agent
Der Stackpulse Agent stellt Docker- und Host-Metadaten über eine REST-API bereit. Er verbindet sich lokal über `/var/run/docker.sock`, validiert alle Anfragen per `X-Agent-Token` und läuft standardmäßig auf Port `7070`.
## Start lokal
1. Abhängigkeiten installieren: `npm install`
2. Starten: `AGENT_TOKEN=<token> npm start`
3. Optional: Port via `AGENT_PORT` anpassen.
## Als Docker-Container
```
docker build -t stackpulse-agent ./agent
docker run -d \
-p 7070:7070 \
-e AGENT_TOKEN=<token> \
-v /var/run/docker.sock:/var/run/docker.sock \
stackpulse-agent
```
Der Agent liest nur Docker-Daten und verändert keine Container oder Images.
## Stack-Image-Checks (CE-/BE-Parität)
Endpoint: `GET /stack-images?checkUpdates=true|false`
- Liefert pro Stack (Compose: `com.docker.compose.project`, Swarm: `com.docker.stack.namespace`) die verwendeten Images.
- Optional `checkUpdates=true` für lokalen/remote Digest-Vergleich (`updateAvailable`).
### Portainer-Version
- `GET /portainer/version` liest den laufenden Portainer-Container (Image/Tag/Labels) aus, ermittelt lokale Digest und vergleicht mit Registry-Digest (Update-Indikator). Funktioniert ohne Portainer-API.
Optionale Registry-Creds (für private Registries):
- `REGISTRY_TOKEN` (Bearer)
- oder `REGISTRY_USERNAME` + `REGISTRY_PASSWORD` (Basic)
Hinweis: Für Swarm-Stacks muss der Agent Swarm-Services lesen können (`docker.listServices`). Ohne Swarm-Manager-Rolle bleibt die Swarm-Liste leer.
+13
View File
@@ -0,0 +1,13 @@
{
"name": "stackpulse-agent",
"version": "0.0.1",
"description": "Stackpulse Agent for Docker metadata and update checks",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"dockerode": "^4.0.2",
"express": "^4.19.2"
}
}
+49
View File
@@ -0,0 +1,49 @@
const express = require('express');
const Docker = require('dockerode');
const infoRoutes = require('./routes/info');
const containerRoutes = require('./routes/containers');
const imageRoutes = require('./routes/images');
const stackRoutes = require('./routes/stacks');
const portainerRoutes = require('./routes/portainer');
const AGENT_PORT = parseInt(process.env.AGENT_PORT || '7070', 10);
const AGENT_TOKEN = process.env.AGENT_TOKEN;
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
const app = express();
const docker = new Docker({ socketPath: DOCKER_SOCKET });
app.use(express.json());
if (!AGENT_TOKEN) {
// Keep the server running to allow late injection, but warn loudly.
console.warn('AGENT_TOKEN is not set. All requests will be rejected until it is configured.');
}
app.use((req, res, next) => {
const token = req.header('X-Agent-Token');
if (!token || token !== AGENT_TOKEN) {
return res.status(403).json({ error: 'Forbidden' });
}
return next();
});
app.use(infoRoutes(docker));
app.use(containerRoutes(docker));
app.use(imageRoutes(docker));
app.use(stackRoutes(docker));
app.use(portainerRoutes(docker));
app.use((req, res) => res.status(404).json({ error: 'Not found' }));
app.use((err, req, res, next) => {
console.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
});
app.listen(AGENT_PORT, () => {
console.log(`Stackpulse Agent listening on port ${AGENT_PORT}`);
});
+76
View File
@@ -0,0 +1,76 @@
const express = require('express');
module.exports = function containerRoutes(docker) {
const router = express.Router();
router.get('/containers', async (req, res, next) => {
try {
const containers = await docker.listContainers({ all: true });
const payload = containers.map((container) => ({
id: container.Id,
name: Array.isArray(container.Names) && container.Names.length > 0
? container.Names[0].replace(/^\//, '')
: null,
status: container.Status,
image: container.Image,
ports: container.Ports,
labels: container.Labels,
}));
res.json(payload);
} catch (err) {
next(err);
}
});
router.get('/stacks', async (req, res, next) => {
try {
const containers = await docker.listContainers({ all: true });
const stacks = containers.reduce((acc, container) => {
const composeProject = container.Labels?.['com.docker.compose.project'];
const swarmStack = container.Labels?.['com.docker.stack.namespace'];
const stackName = composeProject || swarmStack || 'unassigned';
if (!acc[stackName]) {
acc[stackName] = [];
}
acc[stackName].push({
id: container.Id,
name: Array.isArray(container.Names) && container.Names.length > 0
? container.Names[0].replace(/^\//, '')
: null,
status: container.Status,
image: container.Image,
ports: container.Ports,
labels: container.Labels,
});
return acc;
}, {});
const payload = Object.entries(stacks).map(([name, stackContainers]) => ({
name,
containers: stackContainers,
}));
res.json(payload);
} catch (err) {
next(err);
}
});
router.get('/container/:id', async (req, res, next) => {
try {
const container = docker.getContainer(req.params.id);
const inspect = await container.inspect();
res.json(inspect);
} catch (err) {
next(err);
}
});
return router;
};
+41
View File
@@ -0,0 +1,41 @@
const express = require('express');
const { checkImageUpdate, getLocalImageInfo } = require('../services/registryService');
module.exports = function imageRoutes(docker) {
const router = express.Router();
router.get('/images', async (req, res, next) => {
try {
const images = await docker.listImages();
const payload = images.map((image) => ({
repoTags: image.RepoTags || [],
size: image.Size,
id: image.Id,
created: image.Created,
}));
res.json(payload);
} catch (err) {
next(err);
}
});
// Accept full image name with slashes/colons: /image/check/<image> (URL-encoded)
router.get('/image/check/*', async (req, res) => {
const raw = req.params[0] || req.params.name || '';
if (!raw) {
return res.status(400).json({ error: 'invalid_image' });
}
const imageName = decodeURIComponent(raw);
try {
const result = await checkImageUpdate(docker, imageName);
res.json(result);
} catch (err) {
const status = err.status || 500;
res.status(status).json({ error: err.code || err.message || 'Internal error' });
}
});
return router;
};
+33
View File
@@ -0,0 +1,33 @@
const express = require('express');
const os = require('os');
module.exports = function infoRoutes(docker) {
const router = express.Router();
router.get('/info', async (req, res, next) => {
try {
const [dockerVersion, containers, images] = await Promise.all([
docker.version(),
docker.listContainers({ all: true }),
docker.listImages(),
]);
res.json({
dockerVersion: dockerVersion.Version,
hostname: os.hostname(),
uptime: os.uptime(),
platform: os.platform(),
containerCount: containers.length,
imageCount: images.length,
});
} catch (err) {
next(err);
}
});
router.get('/events', (req, res) => {
res.json({ message: 'Events endpoint not implemented yet' });
});
return router;
};
+80
View File
@@ -0,0 +1,80 @@
const express = require('express');
const { checkImageUpdate } = require('../services/registryService');
function extractTag(imageName) {
const parts = imageName.split(':');
return parts.length > 1 ? parts[parts.length - 1] : 'latest';
}
function pickPortainerContainer(containers) {
return containers.find((container) => {
const labels = container.Labels || {};
if (labels['io.portainer.container']) return true;
const image = container.Image || '';
return /portainer/i.test(image);
});
}
async function getPortainerVersionInfo(docker) {
const containers = await docker.listContainers({ all: true });
const portainer = pickPortainerContainer(containers);
if (!portainer) {
return null;
}
const imageName = portainer.Image;
const tag = extractTag(imageName);
let version = null;
let localDigest = null;
let remoteDigest = null;
let updateAvailable = false;
try {
const inspect = await docker.getImage(imageName).inspect();
const labels = inspect?.Config?.Labels || {};
version = labels['org.opencontainers.image.version'] || labels.version || tag;
const repoDigests = inspect?.RepoDigests || [];
localDigest = repoDigests.length ? repoDigests[0].split('@')[1] || repoDigests[0] : null;
} catch {
version = tag;
localDigest = null;
}
try {
const info = await checkImageUpdate(docker, imageName);
remoteDigest = info.remoteDigest;
localDigest = info.localDigest || localDigest;
updateAvailable = info.updateAvailable;
} catch {
remoteDigest = null;
updateAvailable = false;
}
return {
containerId: portainer.Id,
image: imageName,
tag,
version,
localDigest,
remoteDigest,
updateAvailable
};
}
module.exports = function portainerVersionRoutes(docker) {
const router = express.Router();
router.get('/portainer/version', async (req, res, next) => {
try {
const info = await getPortainerVersionInfo(docker);
if (!info) {
return res.status(404).json({ error: 'Portainer container not found' });
}
res.json(info);
} catch (err) {
next(err);
}
});
return router;
};
+135
View File
@@ -0,0 +1,135 @@
const express = require('express');
const { checkImageUpdate, getLocalImageInfo } = require('../services/registryService');
async function getComposeStacks(docker) {
const containers = await docker.listContainers({ all: true });
const stacks = containers.reduce((acc, container) => {
const composeProject = container.Labels?.['com.docker.compose.project'];
if (!composeProject) return acc;
if (!acc[composeProject]) {
acc[composeProject] = {
name: composeProject,
type: 'compose',
images: new Map(),
};
}
acc[composeProject].images.set(container.Image, {
source: 'container',
name: container.Image
});
return acc;
}, {});
return Object.values(stacks);
}
async function getSwarmStacks(docker) {
try {
const services = await docker.listServices();
const stacks = services.reduce((acc, service) => {
const stackNs = service.Spec?.Labels?.['com.docker.stack.namespace'];
if (!stackNs) return acc;
if (!acc[stackNs]) {
acc[stackNs] = {
name: stackNs,
type: 'swarm',
images: new Map(),
};
}
const image = service.Spec?.TaskTemplate?.ContainerSpec?.Image;
if (image) {
acc[stackNs].images.set(image, {
source: service.Spec?.Name || 'service',
name: image
});
}
return acc;
}, {});
return Object.values(stacks);
} catch (err) {
// Not a swarm or no permissions; just return empty
if (err.statusCode === 503 || /This node is not a swarm manager/i.test(err.message || '')) {
return [];
}
throw err;
}
}
async function enrichImagesWithDigests(docker, imageEntries) {
return Promise.all(imageEntries.map(async (entry) => {
try {
return await checkImageUpdate(docker, entry.name);
} catch (err) {
const status = err.status || 500;
return {
...entry,
localDigest: null,
remoteDigest: null,
updateAvailable: false,
error: err.code || err.message || `update_check_failed_${status}`
};
}
}));
}
module.exports = function stackRoutes(docker) {
const router = express.Router();
router.get('/stack-images', async (req, res, next) => {
const withChecks = req.query.checkUpdates === 'true';
try {
const [composeStacks, swarmStacks] = await Promise.all([
getComposeStacks(docker),
getSwarmStacks(docker)
]);
const combined = [...composeStacks, ...swarmStacks];
const result = await Promise.all(combined.map(async (stack) => {
const images = Array.from(stack.images.values());
if (!withChecks) {
return { name: stack.name, type: stack.type, images };
}
const enriched = await enrichImagesWithDigests(docker, images);
return { name: stack.name, type: stack.type, images: enriched };
}));
res.json(result);
} catch (err) {
next(err);
}
});
// Always return local/remote digests with error details when resolution fails
router.get('/stack-images/status', async (req, res, next) => {
try {
const [composeStacks, swarmStacks] = await Promise.all([
getComposeStacks(docker),
getSwarmStacks(docker)
]);
const combined = [...composeStacks, ...swarmStacks];
const result = await Promise.all(combined.map(async (stack) => {
const images = Array.from(stack.images.values());
const enriched = await enrichImagesWithDigests(docker, images);
return { name: stack.name, type: stack.type, images: enriched };
}));
res.json(result);
} catch (err) {
next(err);
}
});
return router;
};
+372
View File
@@ -0,0 +1,372 @@
const DEFAULT_REGISTRY = 'dockerhub';
const GHCR_HOST = 'ghcr.io';
const DOCKERHUB_HOST = 'registry-1.docker.io';
const ACCEPT_HEADERS = [
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.v2+json'
];
function createError(status, code, message) {
const err = new Error(message || code);
err.status = status;
err.code = code;
return err;
}
function detectRegistry(imageName = '') {
if (imageName.startsWith(`${GHCR_HOST}/`)) return 'ghcr';
if (!imageName.includes('/')) return 'dockerhub';
return 'generic';
}
function splitImageIntoComponents(imageName) {
if (!imageName) throw createError(400, 'invalid_image', 'Image name required');
const registryType = detectRegistry(imageName);
let registryHost;
let remainder = imageName;
if (registryType === 'ghcr') {
registryHost = GHCR_HOST;
remainder = imageName.replace(`${GHCR_HOST}/`, '');
} else if (registryType === 'dockerhub') {
registryHost = DOCKERHUB_HOST;
if (!imageName.includes('/')) {
remainder = `library/${imageName}`;
} else if (imageName.startsWith('docker.io/')) {
remainder = imageName.replace('docker.io/', '');
} else if (imageName.startsWith(`${DOCKERHUB_HOST}/`)) {
remainder = imageName.replace(`${DOCKERHUB_HOST}/`, '');
}
} else {
const firstSlash = imageName.indexOf('/');
registryHost = imageName.slice(0, firstSlash);
remainder = imageName.slice(firstSlash + 1);
}
const digestIndex = remainder.indexOf('@');
let repoPart = remainder;
let digest = null;
if (digestIndex !== -1) {
repoPart = remainder.slice(0, digestIndex);
digest = remainder.slice(digestIndex + 1);
}
const lastColon = repoPart.lastIndexOf(':');
let tag = 'latest';
let repository = repoPart;
if (lastColon > -1 && repoPart.indexOf('/') < lastColon) {
tag = repoPart.slice(lastColon + 1);
repository = repoPart.slice(0, lastColon);
}
return {
registryType,
registryHost,
repository,
tag,
digest,
fullImage: imageName
};
}
async function getLocalImageInfo(docker, imageName, preferredTag = null) {
try {
const image = docker.getImage(imageName);
const inspect = await image.inspect();
const repoTags = inspect?.RepoTags || [];
const repoDigests = inspect?.RepoDigests || [];
let localTagFull = repoTags.length ? repoTags[0] : null;
let localDigestFull = repoDigests.length ? repoDigests[0] : null;
// Fallback: search listImages for matching repoTag when digest is missing
if (!localDigestFull) {
const images = await docker.listImages();
const matchByTag = images.find((img) => (img.RepoTags || []).includes(imageName));
const normalizedRepo = imageName.includes(':') ? imageName.split(':')[0] : imageName;
const matchByRepo = images.find((img) => (img.RepoTags || []).some((t) => t.startsWith(`${normalizedRepo}:`)));
const candidate = matchByTag || matchByRepo;
if (candidate) {
localDigestFull = (candidate.RepoDigests || [])[0] || null;
localTagFull = (candidate.RepoTags || [])[0] || localTagFull;
}
}
let localTag = preferredTag || null;
if (!localTag) {
localTag = localTagFull && localTagFull.includes(':')
? localTagFull.split(':').pop()
: null;
}
const localDigestHash = localDigestFull && localDigestFull.includes('@')
? localDigestFull.split('@')[1]
: localDigestFull;
return { localTag, localDigestFull, localDigestHash };
} catch (err) {
throw createError(404, 'image_not_found', `Local image not found: ${imageName}`);
}
}
function buildBasicAuthHeader() {
const username = process.env.REGISTRY_USERNAME || process.env.GITHUB_USERNAME;
const password = process.env.REGISTRY_PASSWORD || process.env.GITHUB_TOKEN || process.env.GITHUB_PAT;
if (!username || !password) return {};
const base = Buffer.from(`${username}:${password}`, 'utf8').toString('base64');
return { Authorization: `Basic ${base}` };
}
function buildBearerHeader() {
const token = process.env.REGISTRY_TOKEN;
if (!token) return {};
return { Authorization: `Bearer ${token}` };
}
async function fetchRemoteManifest(registryHost, repository, reference, headers) {
const url = `https://${registryHost}/v2/${repository}/manifests/${reference}`;
const res = await fetch(url, { headers });
const body = await res.text();
return { res, body };
}
async function fetchRegistryTokenDockerHub(repository) {
const headers = {
Accept: ACCEPT_HEADERS.join(', '),
...buildBearerHeader(),
...buildBasicAuthHeader(),
};
const url = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repository}:pull`;
const res = await fetch(url, { headers });
if (!res.ok) {
const body = await res.text();
throw createError(res.status === 401 ? 403 : res.status, 'token_error', `Failed to fetch Docker Hub token: ${res.status} ${body || ''}`.trim());
}
const data = await res.json();
if (!data.token) {
throw createError(403, 'token_error', 'Token missing in Docker Hub response');
}
return data.token;
}
async function fetchAnonymousTokenDockerHub(repository) {
const headers = {
Accept: ACCEPT_HEADERS.join(', ')
};
const url = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repository}:pull`;
const res = await fetch(url, { headers });
if (!res.ok) {
const body = await res.text();
throw createError(res.status === 401 ? 403 : res.status, 'token_error', `Failed to fetch Docker Hub token anonymously: ${res.status} ${body || ''}`.trim());
}
const data = await res.json();
if (!data.token) {
throw createError(403, 'token_error', 'Token missing in Docker Hub anonymous response');
}
return data.token;
}
async function resolveRemoteDigestDockerHub(repository, tag, localDigestHash) {
const accept = { Accept: ACCEPT_HEADERS.join(', ') };
let token;
try {
token = await fetchRegistryTokenDockerHub(repository);
} catch {
token = null;
}
let headers = token ? { ...accept, Authorization: `Bearer ${token}` } : { ...accept };
let { res, body } = await fetchRemoteManifest(DOCKERHUB_HOST, repository, tag, headers);
if (res.status === 404 && localDigestHash) {
({ res, body } = await fetchRemoteManifest(DOCKERHUB_HOST, repository, localDigestHash, headers));
}
if ((res.status === 401 || res.status === 403) && !token) {
try {
const anonToken = await fetchAnonymousTokenDockerHub(repository);
headers = { ...accept, Authorization: `Bearer ${anonToken}` };
({ res, body } = await fetchRemoteManifest(DOCKERHUB_HOST, repository, tag, headers));
if (res.status === 404 && localDigestHash) {
({ res, body } = await fetchRemoteManifest(DOCKERHUB_HOST, repository, localDigestHash, headers));
}
} catch {
// fall through
}
}
if (!res.ok) {
if (res.status === 404) throw createError(404, 'tag_not_found', 'Tag not found on registry');
if (res.status === 401 || res.status === 403) throw createError(403, 'auth_required', body || 'Unauthorized');
throw createError(502, 'registry_error', `Registry responded with ${res.status} ${body || ''}`.trim());
}
const digest = extractDigestFromManifest(body);
if (!digest) throw createError(500, 'digest_missing', 'Could not extract digest from manifest');
return digest;
}
async function resolveRemoteDigestGeneric(registryHost, repository, tag, localDigestHash) {
const accept = { Accept: ACCEPT_HEADERS.join(', ') };
const headers = {
...accept,
...buildBearerHeader(),
...buildBasicAuthHeader(),
};
let { res, body } = await fetchRemoteManifest(registryHost, repository, tag, headers);
if (res.status === 404 && localDigestHash) {
({ res, body } = await fetchRemoteManifest(registryHost, repository, localDigestHash, headers));
}
if (!res.ok) {
if (res.status === 404) throw createError(404, 'tag_not_found', 'Tag not found on registry');
if (res.status === 401 || res.status === 403) throw createError(403, 'auth_required', body || 'Unauthorized');
throw createError(502, 'registry_error', `Registry responded with ${res.status} ${body || ''}`.trim());
}
const digest = extractDigestFromManifest(body);
if (!digest) throw createError(500, 'digest_missing', 'Could not extract digest from manifest');
return digest;
}
async function resolveRemoteDigest(registryType, registryHost, repository, tag, localDigestHash) {
const fetchDigestViaBuildx = async (ref) => {
const { execFile } = require('child_process');
return new Promise((resolve, reject) => {
execFile('docker', ['buildx', 'imagetools', 'inspect', ref], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
if (err) {
const msg = stderr || err.message || 'docker buildx imagetools inspect failed';
return reject(createError(502, 'registry_error', msg.trim()));
}
const match = stdout.match(/Digest:\s*(sha256:[a-fA-F0-9]+)/);
if (match && match[1]) {
return resolve(match[1]);
}
return reject(createError(500, 'digest_missing', 'Could not extract digest from imagetools inspect'));
});
});
};
const fetchRemoteDigestViaDocker = async (ref) => {
const { execFile } = require('child_process');
return new Promise((resolve, reject) => {
execFile('docker', ['manifest', 'inspect', ref], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
if (err) {
const msg = stderr || err.message || 'docker manifest inspect failed';
return reject(createError(502, 'registry_error', msg.trim()));
}
try {
const json = JSON.parse(stdout);
if (Array.isArray(json.manifests) && json.manifests.length > 0) {
const candidate = json.manifests[0];
if (candidate?.digest) {
return resolve(candidate.digest);
}
}
if (json?.config?.digest) {
return resolve(json.config.digest);
}
if (Array.isArray(json?.layers) && json.layers.length > 0 && json.layers[0].digest) {
return resolve(json.layers[0].digest);
}
} catch {
// fall through to error
}
return reject(createError(500, 'digest_missing', 'Could not extract digest from docker manifest inspect'));
});
});
};
if (registryType === 'ghcr') {
// Use docker buildx imagetools inspect for GHCR: tag first, then digest fallback
const host = registryHost || GHCR_HOST;
try {
const digest = await fetchDigestViaBuildx(`${host}/${repository}:${tag}`);
return digest;
} catch (err) {
if (localDigestHash) {
return fetchDigestViaBuildx(`${host}/${repository}@${localDigestHash}`);
}
throw err.code ? err : createError(502, 'registry_error', err.message || 'GHCR lookup failed');
}
}
if (registryType === 'dockerhub') {
const host = 'docker.io';
try {
return await fetchDigestViaBuildx(`${host}/${repository}:${tag}`);
} catch (err) {
if (localDigestHash) {
try {
return await fetchDigestViaBuildx(`${host}/${repository}@${localDigestHash}`);
} catch {
// fall through
}
}
}
// Fallback to manifest inspect/HTTP if buildx fails
try {
return await fetchRemoteDigestViaDocker(`${host}/${repository}:${tag}`);
} catch (err) {
if (localDigestHash) {
try {
return await fetchRemoteDigestViaDocker(`${host}/${repository}@${localDigestHash}`);
} catch {
// fall through
}
}
}
return resolveRemoteDigestDockerHub(repository, tag, localDigestHash);
}
// generic
try {
return await fetchDigestViaBuildx(`${registryHost}/${repository}:${tag}`);
} catch (err) {
if (localDigestHash) {
try {
return await fetchDigestViaBuildx(`${registryHost}/${repository}@${localDigestHash}`);
} catch {
// fall through
}
}
}
// fallback manifest path
try {
return await fetchRemoteDigestViaDocker(`${registryHost}/${repository}:${tag}`);
} catch (err) {
if (localDigestHash) {
try {
return await fetchRemoteDigestViaDocker(`${registryHost}/${repository}@${localDigestHash}`);
} catch {
// fall through to HTTP
}
}
}
return resolveRemoteDigestGeneric(registryHost, repository, tag, localDigestHash);
}
async function checkImageUpdate(docker, imageName) {
const { registryType, registryHost, repository, tag } = splitImageIntoComponents(imageName);
const { localTag, localDigestHash } = await getLocalImageInfo(docker, imageName, tag);
const remoteDigest = await resolveRemoteDigest(registryType, registryHost, repository, tag, localDigestHash);
return {
image: imageName,
localTag,
localDigest: localDigestHash,
remoteDigest,
updateAvailable: Boolean(localDigestHash && remoteDigest && localDigestHash !== remoteDigest)
};
}
module.exports = {
detectRegistry,
splitImageIntoComponents,
getLocalImageInfo,
checkImageUpdate,
resolveRemoteDigestDockerHub,
resolveRemoteDigestGeneric
};
+10
View File
@@ -209,6 +209,16 @@ CREATE TABLE server_update_scripts (
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE TABLE server_agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL UNIQUE,
agent_url TEXT,
agent_token TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX idx_server_update_scripts_server ON server_update_scripts (server_id);
CREATE TABLE user_settings (
+35 -2
View File
@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { db } from './index.js';
import crypto from 'crypto';
const BLUEPRINT_FILENAME = 'dbs';
const PERMISSION_BLUEPRINT = [
@@ -191,8 +192,8 @@ const PERMISSION_BLUEPRINT = [
]
},
{
key: 'maintenance-server-delete',
label: 'Server löschen',
key: 'maintenance-server-edit',
label: 'Server bearbeiten',
sortOrder: 1,
defaultLevel: 'none',
levels: ['full', 'none'],
@@ -200,6 +201,18 @@ const PERMISSION_BLUEPRINT = [
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
{ dependsOnKey: 'maintenance-server-manage', requiredLevel: '!=none' }
]
},
{
key: 'maintenance-server-delete',
label: 'Server löschen',
sortOrder: 2,
defaultLevel: 'none',
levels: ['full', 'none'],
dependencies: [
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
{ dependsOnKey: 'maintenance-server-manage', requiredLevel: 'full' },
{ dependsOnKey: 'maintenance-server-edit', requiredLevel: '=full' }
]
}
]
},
@@ -722,6 +735,25 @@ const ensurePermissionSeeds = () => {
});
};
const ensureServerAgentEntries = () => {
if (!tableExists('server_agents')) return;
const servers = db.prepare('SELECT id FROM servers').all();
const insertAgent = db.prepare(`
INSERT OR IGNORE INTO server_agents (server_id, agent_url, agent_token, created_at, updated_at)
VALUES (?, NULL, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
const updateMissingToken = db.prepare(`
UPDATE server_agents
SET agent_token = ?
WHERE server_id = ? AND (agent_token IS NULL OR agent_token = '')
`);
servers.forEach((server) => {
const token = `sp_${crypto.randomBytes(16).toString('hex')}`;
insertAgent.run(server.id, token);
updateMissingToken.run(token, server.id);
});
};
export const ensureDatabaseSchema = () => {
try {
const statements = loadBlueprintStatements();
@@ -730,6 +762,7 @@ export const ensureDatabaseSchema = () => {
dropDeprecatedArtifacts();
ensureTablesAndColumns(tableDefinitions);
ensureServerAgentEntries();
ensureIndexes(indexDefinitions);
ensurePermissionSeeds();
} catch (error) {
+680 -49
View File
@@ -41,7 +41,15 @@ import {
getSetupStatus,
completeSetup,
removeServer,
setServerApiKey
setServerApiKey,
getServerById,
ensureServer,
getServerConnection,
updateServerDetails,
getServerStatus,
getAgentConfig,
setAgentConfig,
generateAgentToken
} from './setup/index.js';
import {
listUsers,
@@ -633,6 +641,36 @@ const applySelfStackStatus = (status) => {
const buildSetupStatusResponse = () => applySelfStackStatus(getSetupStatus());
const isCommunityEdition = async () => {
try {
const summary = await fetchPortainerStatusSummary();
const edition = (summary?.edition || '').toLowerCase();
return edition.includes('community');
} catch {
return false;
}
};
const pingAgent = async (agentUrl, agentToken) => {
if (!agentUrl) return { online: false, error: 'agent_url_missing' };
const url = agentUrl.replace(/\/+$/, '');
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const resp = await fetch(`${url}/info`, {
headers: { 'X-Agent-Token': agentToken || '' },
signal: controller.signal
});
clearTimeout(timeout);
if (!resp.ok) {
return { online: false, error: `status_${resp.status}` };
}
return { online: true, error: null };
} catch (err) {
return { online: false, error: err?.message || 'agent_unreachable' };
}
};
const LEGACY_PORTAINER_SCRIPT_SETTING_KEY = 'portainer_update_script';
const LEGACY_PORTAINER_SSH_CONFIG_KEY = 'portainer_ssh_config';
@@ -667,13 +705,22 @@ const upsertServerUpdateScriptStmt = db.prepare(`
const deleteServerUpdateScriptStmt = db.prepare('DELETE FROM server_update_scripts WHERE server_id = ?');
const deleteAllServerUpdateScriptsStmt = db.prepare('DELETE FROM server_update_scripts');
const DEFAULT_PORTAINER_UPDATE_SCRIPT = [
const DEFAULT_PORTAINER_UPDATE_SCRIPT_BE = [
'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 -v portainer_data:/data portainer/portainer-ee:lts'
].join('\n');
const DEFAULT_PORTAINER_UPDATE_SCRIPT_CE = [
'docker stop portainer',
'docker rm portainer',
'docker pull portainer/portainer-ce:lts',
'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-ce:lts'
].join('\n');
const DEFAULT_PORTAINER_UPDATE_SCRIPT = DEFAULT_PORTAINER_UPDATE_SCRIPT_BE;
let portainerUpdateState = {
running: false,
status: 'idle',
@@ -744,27 +791,45 @@ const migrateLegacyUpdateScript = (serverIdHint = null) => {
legacyUpdateScriptMigrated = true;
};
const resolveStoredUpdateScriptRow = () => {
const primaryServerId = resolvePrimaryServerId();
const resolveStoredUpdateScriptRow = (preferredServerId = null, fallbackToAny = true) => {
const primaryServerId = preferredServerId ?? resolvePrimaryServerId();
migrateLegacyUpdateScript(primaryServerId);
if (primaryServerId) {
const row = selectServerUpdateScriptStmt.get(primaryServerId);
if (row) return row;
if (row) return { row, serverId: primaryServerId };
if (!fallbackToAny) {
return { row: null, serverId: primaryServerId };
}
}
return selectAnyServerUpdateScriptStmt.get() ?? null;
if (!fallbackToAny) {
return { row: null, serverId: primaryServerId ?? null };
}
const fallback = selectAnyServerUpdateScriptStmt.get();
if (fallback) {
return { row: fallback, serverId: fallback.server_id };
}
return { row: null, serverId: primaryServerId ?? null };
};
const resolveTargetServerIdForUpdateScript = () => {
const resolveTargetServerIdForUpdateScript = (preferredServerId = null, fallbackToAny = true) => {
if (preferredServerId) {
return preferredServerId;
}
if (!fallbackToAny) {
return null;
}
const primaryId = resolvePrimaryServerId();
if (primaryId) return primaryId;
const fallback = selectAnyServerUpdateScriptStmt.get();
return fallback?.server_id ?? null;
};
const getCustomPortainerScript = () => {
const row = resolveStoredUpdateScriptRow();
const getCustomPortainerScript = (serverId = null) => {
const { row } = resolveStoredUpdateScriptRow(serverId, !serverId);
if (!row) return null;
const normalized = normalizeScriptText(row.custom_script ?? '');
if (!normalized.trim()) return null;
@@ -774,14 +839,14 @@ const getCustomPortainerScript = () => {
};
};
const saveCustomPortainerScript = (script) => {
const saveCustomPortainerScript = (script, serverId = null) => {
const normalized = normalizeScriptText(script);
const trimmed = normalized.trim();
const serverId = resolveTargetServerIdForUpdateScript();
const targetServerId = resolveTargetServerIdForUpdateScript(serverId, !serverId);
if (!trimmed) {
if (serverId) {
deleteServerUpdateScriptStmt.run(serverId);
if (targetServerId) {
deleteServerUpdateScriptStmt.run(targetServerId);
} else {
deleteAllServerUpdateScriptsStmt.run();
}
@@ -789,17 +854,17 @@ const saveCustomPortainerScript = (script) => {
return null;
}
if (!serverId) {
if (!targetServerId) {
throw new Error('SERVER_NOT_CONFIGURED');
}
upsertServerUpdateScriptStmt.run(serverId, normalized);
upsertServerUpdateScriptStmt.run(targetServerId, normalized);
deleteSetting(LEGACY_PORTAINER_SCRIPT_SETTING_KEY);
return normalized;
};
const getEffectivePortainerScript = () => {
const custom = getCustomPortainerScript();
const getEffectivePortainerScript = (serverId = null, defaultScript = DEFAULT_PORTAINER_UPDATE_SCRIPT) => {
const custom = getCustomPortainerScript(serverId);
if (custom) {
return {
script: custom.script,
@@ -808,12 +873,30 @@ const getEffectivePortainerScript = () => {
};
}
return {
script: DEFAULT_PORTAINER_UPDATE_SCRIPT,
script: defaultScript,
source: 'default',
updatedAt: null
};
};
const resolveServerEdition = async (serverId) => {
try {
const { server, apiKey } = getServerConnection(serverId);
if (!server?.url || !apiKey) return 'Business Edition';
const client = createPortainerSetupClient({ baseURL: server.url, apiKey });
try {
const licenseInfoRes = await client.get('/api/licenses/info');
const licenseInfo = licenseInfoRes.data ?? {};
const edition = extractPortainerEdition(licenseInfo) ?? 'Business Edition';
return edition;
} catch {
return 'Community Edition';
}
} catch {
return 'Business Edition';
}
};
const SSH_ENCRYPTION_KEY = crypto.createHash('sha256')
.update(process.env.PORTAINER_SSH_SECRET || process.env.PORTAINER_API_KEY || 'stackpulse-portainer-ssh-secret')
@@ -980,16 +1063,30 @@ const migrateLegacySshConfig = (serverIdHint = null) => {
legacySshConfigMigrated = true;
};
const resolveStoredSshConfigRow = () => {
const primaryServerId = resolvePrimaryServerId();
const resolveStoredSshConfigRow = (preferredServerId = null, fallbackToAny = true) => {
const primaryServerId = preferredServerId ?? resolvePrimaryServerId();
migrateLegacySshConfig(primaryServerId);
if (primaryServerId) {
const row = selectServerSshConfigByServerStmt.get(primaryServerId);
if (row) return row;
if (row) {
return { row, serverId: primaryServerId };
}
if (!fallbackToAny) {
return { row: null, serverId: primaryServerId };
}
}
return selectAnyServerSshConfigStmt.get() ?? null;
if (!fallbackToAny) {
return { row: null, serverId: primaryServerId ?? null };
}
const fallback = selectAnyServerSshConfigStmt.get();
if (fallback) {
return { row: fallback, serverId: fallback.server_id };
}
return { row: null, serverId: primaryServerId ?? null };
};
const persistPortainerSshConfig = (serverId, config) => {
@@ -1010,8 +1107,8 @@ const persistPortainerSshConfig = (serverId, config) => {
);
};
const getPortainerSshConfig = () => {
const row = resolveStoredSshConfigRow();
const getPortainerSshConfig = (serverId = null) => {
const { row } = resolveStoredSshConfigRow(serverId, !serverId);
if (!row) {
return { ...DEFAULT_PORTAINER_SSH_CONFIG };
}
@@ -1043,28 +1140,34 @@ const mergeSshConfig = (base, overrides = {}) => ({
extraSshArgs: normalizeExtraArgs(overrides.extraSshArgs, base.extraSshArgs)
});
const resolveTargetServerIdForSshConfig = () => {
const resolveTargetServerIdForSshConfig = (preferredServerId = null, fallbackToAny = true) => {
if (preferredServerId) {
return preferredServerId;
}
if (!fallbackToAny) {
return null;
}
const primaryId = resolvePrimaryServerId();
if (primaryId) return primaryId;
const fallback = selectAnyServerSshConfigStmt.get();
return fallback?.server_id ?? null;
};
const savePortainerSshConfig = (payload = {}) => {
const current = getPortainerSshConfig();
const savePortainerSshConfig = (payload = {}, serverId = null) => {
const current = getPortainerSshConfig(serverId);
const next = mergeSshConfig(current, payload);
const serverId = resolveTargetServerIdForSshConfig();
if (!serverId) {
const targetServerId = resolveTargetServerIdForSshConfig(serverId, !serverId);
if (!targetServerId) {
throw new Error('SERVER_NOT_CONFIGURED');
}
persistPortainerSshConfig(serverId, next);
persistPortainerSshConfig(targetServerId, next);
return next;
};
const deletePortainerSshConfig = () => {
const serverId = resolveTargetServerIdForSshConfig();
if (serverId) {
deleteServerSshConfigStmt.run(serverId);
const deletePortainerSshConfig = (serverId = null) => {
const targetServerId = resolveTargetServerIdForSshConfig(serverId, !serverId);
if (targetServerId) {
deleteServerSshConfigStmt.run(targetServerId);
} else {
deleteAllServerSshConfigsStmt.run();
}
@@ -1150,17 +1253,19 @@ const buildSshCommandArgs = (config) => {
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);
const ensureSshConfigReady = (serverId = null) => {
const config = getPortainerSshConfig(serverId);
const hasHost = Boolean(config.host && config.username);
if (!hasHost) {
throw new Error('SSH-Konfiguration ist unvollständig (Host/Benutzer erforderlich).');
}
return config;
};
const testSshConnection = async (configOverride = null, serverId = null) => {
const baseConfig = configOverride
? mergeSshConfig(getPortainerSshConfig(serverId), configOverride)
: ensureSshConfigReady(serverId);
const { args, env: envOverrides, cleanup } = buildSshCommandArgs(baseConfig);
const sshArgs = [...args, 'echo', '__PORTAINER_SSH_TEST__'];
@@ -1299,9 +1404,25 @@ const compareSemver = (a, b) => {
return 0;
};
const extractPortainerEdition = (payload) => {
if (!payload || typeof payload !== 'object') return null;
const enumType = payload.type ?? payload.Type;
if (enumType === 2 || enumType === 3) return 'Business Edition';
if (enumType === 1) return 'Community Edition';
return null;
};
const extractPortainerBuild = (payload) => {
if (!payload || typeof payload !== 'object') return null;
return payload.BuildNumber
?? payload.Server?.Build
?? payload.VersionInfo?.BuildNumber
?? null;
};
const fetchPortainerStatusSummary = async () => {
const statusRes = await axiosInstance.get('/api/status');
const statusData = statusRes.data ?? {};
let statusData = statusRes.data ?? {};
const currentVersion = statusData.Version
?? statusData.ServerVersion
@@ -1309,8 +1430,31 @@ const fetchPortainerStatusSummary = async () => {
?? statusData.ServerInfo?.Version
?? statusData.ServerVersionNumber
?? null;
const edition = statusData.Edition ?? statusData.Server?.Edition ?? null;
const build = statusData.BuildNumber ?? statusData.Server?.Build ?? null;
let edition = null;
const build = extractPortainerBuild(statusData);
// Edition ausschließlich über Lizenz-Endpoint ableiten
let effectiveEdition = null;
let effectiveBuild = build;
try {
const licenseInfoRes = await axiosInstance.get('/api/licenses/info');
const licenseInfo = licenseInfoRes.data ?? {};
effectiveEdition = extractPortainerEdition(licenseInfo) ?? 'Business Edition';
} catch (err) {
// Endpoint fehlt -> Community Edition
effectiveEdition = 'Community Edition';
}
// optional: Build über /api/system/status ergänzen, falls leer
if (!effectiveBuild) {
try {
const systemStatusRes = await axiosInstance.get('/api/system/status');
const systemStatus = systemStatusRes.data ?? {};
effectiveBuild = extractPortainerBuild(systemStatus) ?? effectiveBuild;
} catch (err) {
// Build bleibt ggf. null
}
}
const errors = {};
let latestVersion = null;
@@ -2055,6 +2199,43 @@ app.post('/api/setup/portainer-stacks', async (req, res) => {
}
});
app.get('/api/servers/:id/agent', requirePermission('maintenance-server-edit', 'read'), async (req, res) => {
const { id } = req.params;
try {
const config = getAgentConfig(id, { autoCreate: true });
const editionFlag = await isCommunityEdition();
const { online, error } = await pingAgent(config?.agentUrl, config?.agentToken);
res.json({
serverId: Number(id),
agentUrl: config?.agentUrl || null,
agentToken: config?.agentToken || null,
online,
agentError: error || null,
community: editionFlag
});
} catch (err) {
res.status(400).json({ error: err.code || err.message || 'AGENT_CONFIG_FAILED' });
}
});
app.put('/api/servers/:id/agent', requirePermission('maintenance-server-edit', 'full'), async (req, res) => {
const { id } = req.params;
try {
const payload = {
agentUrl: req.body?.agentUrl,
agentToken: req.body?.agentToken
};
// Token ist systemgeneriert; wenn keiner übergeben, wird Auto-Token gesetzt
const config = setAgentConfig(id, {
agentUrl: payload.agentUrl,
agentToken: payload.agentToken || undefined
});
res.json(config);
} catch (err) {
res.status(400).json({ error: err.code || err.message || 'AGENT_CONFIG_FAILED' });
}
});
app.post('/api/setup/complete', (req, res) => {
const { superuser: superuserInput, server: serverInput, apiKey: apiKeyInput, selfStackId: selfStackInput } = req.body ?? {};
const hasSelfStackInput = req.body ? Object.prototype.hasOwnProperty.call(req.body, 'selfStackId') : false;
@@ -2139,6 +2320,12 @@ app.post('/api/setup/complete', (req, res) => {
return res.status(400).json({ error: 'API_KEY_REQUIRED' });
}
if (targetServerId) {
// Ensure agent token exists for CE servers
const agent = setAgentConfig(targetServerId, { agentUrl: null, agentToken: undefined });
created.agent = agent;
}
if (hasSelfStackInput) {
const normalizedSelfStackId = typeof selfStackInput === 'string'
? selfStackInput.trim()
@@ -2182,6 +2369,450 @@ app.post('/api/setup/complete', (req, res) => {
}
});
app.get('/api/setup/servers/:id', requirePermission('maintenance-server-manage', 'read'), (req, res) => {
const { id } = req.params;
try {
const detail = getServerStatus(id);
res.json({ success: true, ...detail });
} catch (error) {
const code = error.code || error.message;
switch (code) {
case 'SERVER_ID_INVALID':
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
case 'SERVER_NOT_FOUND':
return res.status(404).json({ error: 'SERVER_NOT_FOUND' });
default:
console.error('⚠️ [Setup] Serverdetails konnten nicht geladen werden:', error);
return res.status(500).json({ error: 'SERVER_FETCH_FAILED' });
}
}
});
app.get('/api/setup/servers/:id/check', requirePermission('maintenance-server-manage', 'read'), async (req, res) => {
const { id } = req.params;
try {
const { server, apiKey } = getServerConnection(id);
const agentConfig = getAgentConfig(id, { autoCreate: true });
if (!apiKey) {
return res.json({
success: true,
online: false,
portainer: {
currentVersion: null,
latestVersion: null,
updateAvailable: null,
edition: null,
build: null
}
});
}
const client = createPortainerSetupClient({ baseURL: server.url, apiKey });
const statusRes = await client.get('/api/status');
let statusData = statusRes.data ?? {};
const currentVersion = statusData.Version
?? statusData.ServerVersion
?? statusData.Server?.Version
?? statusData.ServerInfo?.Version
?? statusData.ServerVersionNumber
?? null;
let edition = null;
let build = extractPortainerBuild(statusData);
// Edition ausschließlich über Lizenz-Endpoint ableiten
try {
const licenseInfoRes = await client.get('/api/licenses/info');
const licenseInfo = licenseInfoRes.data ?? {};
edition = extractPortainerEdition(licenseInfo) ?? 'Business Edition';
} catch (err) {
edition = 'Community Edition';
}
// optional: Build über /api/system/status ergänzen, falls leer
if (!build) {
try {
const systemStatusRes = await client.get('/api/system/status');
const systemStatus = systemStatusRes.data ?? {};
build = extractPortainerBuild(systemStatus) ?? build;
} catch (err) {
// Build bleibt ggf. null
}
}
// Hole Version/Edition analog BE über /system/version (falls verfügbar)
try {
const sysVerRes = await client.get('/api/system/version');
const sysVer = sysVerRes.data ?? {};
const apiVersion = sysVer.Version || sysVer.version || null;
const apiEdition = sysVer.Edition || sysVer.edition || null;
if (apiVersion) {
statusData = { ...statusData, Version: apiVersion };
}
if (apiEdition) {
edition = apiEdition;
}
} catch (err) {
// ignore, CE ohne endpoint oder Fehler
}
let agentOnline = null;
let agentError = null;
let agentVersion = null;
if ((edition || '').toLowerCase().includes('community') && agentConfig?.agentUrl) {
const { online, error } = await pingAgent(agentConfig.agentUrl, agentConfig.agentToken);
agentOnline = online;
agentError = error;
if (online) {
try {
const agentRes = await fetch(`${agentConfig.agentUrl.replace(/\/+$/, '')}/portainer/version`, {
headers: { 'X-Agent-Token': agentConfig.agentToken || '' }
});
if (agentRes.ok) {
agentVersion = await agentRes.json();
} else {
agentError = `agent_status_${agentRes.status}`;
}
} catch (err) {
agentError = err?.message || 'agent_fetch_failed';
}
}
}
const effectiveCurrentVersion = statusData.Version
?? statusData.ServerVersion
?? statusData.Server?.Version
?? statusData.ServerInfo?.Version
?? statusData.ServerVersionNumber
?? currentVersion;
res.json({
success: true,
online: true,
portainer: {
currentVersion: effectiveCurrentVersion,
latestVersion: null,
updateAvailable: false,
edition,
build
},
agent: {
url: agentConfig?.agentUrl || null,
token: agentConfig?.agentToken || null,
online: agentOnline,
error: agentError
}
});
} catch (error) {
const code = error.code || error.message;
if (code === 'SERVER_ID_INVALID') {
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
}
if (code === 'SERVER_NOT_FOUND') {
return res.status(404).json({ error: 'SERVER_NOT_FOUND' });
}
if (code === 'API_KEY_REQUIRED' || code === 'SERVER_URL_REQUIRED') {
return res.status(400).json({ error: code });
}
console.error('⚠️ [Setup] Server-Check fehlgeschlagen:', error);
return res.status(500).json({ error: 'SERVER_CHECK_FAILED' });
}
});
app.post('/api/setup/servers', requirePermission('maintenance-server-edit', 'full'), async (req, res) => {
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
const urlRaw = typeof req.body?.url === 'string' ? req.body.url.trim() : '';
const apiKeyRaw = typeof req.body?.apiKey === 'string'
? req.body.apiKey
: typeof req.body?.key === 'string'
? req.body.key
: '';
const normalizedExternalUrl = normalizeExternalPortainerUrl(urlRaw);
if (!normalizedExternalUrl) {
return res.status(400).json({ error: 'SERVER_URL_INVALID' });
}
if (!apiKeyRaw.trim()) {
return res.status(400).json({ error: 'API_KEY_REQUIRED' });
}
try {
// verify connectivity
const client = createPortainerSetupClient({ baseURL: normalizedExternalUrl, apiKey: apiKeyRaw.trim() });
await client.get('/api/status');
const server = ensureServer({ name, url: normalizedExternalUrl });
setServerApiKey({ serverId: server.id, apiKey: apiKeyRaw.trim() });
const status = buildSetupStatusResponse();
res.status(201).json({ success: true, server, status });
} catch (error) {
const code = error.code || error.message;
if (code === 'SERVER_URL_REQUIRED' || code === 'SERVER_NAME_REQUIRED') {
return res.status(400).json({ error: code });
}
if (code === 'SERVER_URL_TAKEN') {
return res.status(409).json({ error: 'SERVER_URL_TAKEN' });
}
if (code === 'SERVER_ID_INVALID') {
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
}
if (code === 'SERVER_NOT_FOUND') {
return res.status(404).json({ error: 'SERVER_NOT_FOUND' });
}
if (error.response?.status === 401) {
return res.status(401).json({ error: 'API_KEY_INVALID', message: error.response?.data?.message || 'Portainer API-Key ungültig' });
}
if (error.response?.status === 404) {
return res.status(404).json({ error: 'SERVER_UNREACHABLE', message: error.response?.data?.message || 'Server nicht erreichbar' });
}
console.error('⚠️ [Setup] Server konnte nicht erstellt werden:', error);
return res.status(500).json({ error: 'SERVER_CREATE_FAILED' });
}
});
app.get('/api/setup/servers/:id/ssh-config', requirePermission('maintenance-ssh-update', 'read'), (req, res) => {
const serverId = Number(req.params.id);
if (!Number.isFinite(serverId)) {
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
}
try {
getServerById(serverId);
const config = getPortainerSshConfig(serverId);
res.json({
success: true,
ssh: {
host: config.host,
port: config.port,
username: config.username,
extraSshArgs: config.extraSshArgs,
passwordStored: Boolean(config.password)
}
});
} catch (error) {
const code = error.code || error.message;
if (code === 'SERVER_NOT_FOUND') {
return res.status(404).json({ error: 'SERVER_NOT_FOUND' });
}
return res.status(500).json({ error: 'SSH_CONFIG_LOAD_FAILED' });
}
});
app.put('/api/setup/servers/:id/ssh-config', requirePermission('maintenance-ssh-update', 'full'), (req, res) => {
const serverId = Number(req.params.id);
if (!Number.isFinite(serverId)) {
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
}
try {
getServerById(serverId);
const config = savePortainerSshConfig(req.body || {}, serverId);
res.json({
success: true,
ssh: {
host: config.host,
port: config.port,
username: config.username,
extraSshArgs: config.extraSshArgs,
passwordStored: Boolean(config.password)
}
});
} catch (error) {
const code = error.code || error.message;
if (code === 'SERVER_NOT_FOUND') {
return res.status(404).json({ error: 'SERVER_NOT_FOUND' });
}
console.error('❌ [Setup] Server-spezifische SSH-Konfiguration konnte nicht gespeichert werden:', error);
return res.status(400).json({ error: code || 'SSH_CONFIG_SAVE_FAILED' });
}
});
app.delete('/api/setup/servers/:id/ssh-config', requirePermission('maintenance-ssh-update', 'full'), (req, res) => {
const serverId = Number(req.params.id);
if (!Number.isFinite(serverId)) {
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
}
try {
getServerById(serverId);
const config = deletePortainerSshConfig(serverId);
res.json({
success: true,
ssh: {
host: config.host,
port: config.port,
username: config.username,
extraSshArgs: config.extraSshArgs,
passwordStored: false
}
});
} catch (error) {
const code = error.code || error.message;
if (code === 'SERVER_NOT_FOUND') {
return res.status(404).json({ error: 'SERVER_NOT_FOUND' });
}
console.error('❌ [Setup] Server-spezifische SSH-Konfiguration konnte nicht gelöscht werden:', error);
return res.status(500).json({ error: 'SSH_CONFIG_DELETE_FAILED' });
}
});
app.post('/api/setup/servers/:id/test-ssh', requirePermission('maintenance-ssh-update', 'full'), async (req, res) => {
const serverId = Number(req.params.id);
if (!Number.isFinite(serverId)) {
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
}
try {
getServerById(serverId);
const override = req.body && Object.keys(req.body).length ? req.body : null;
const result = await testSshConnection(override, serverId);
res.json({ success: true, result });
} catch (error) {
const code = error.code || error.message;
if (code === 'SERVER_NOT_FOUND') {
return res.status(404).json({ error: 'SERVER_NOT_FOUND' });
}
console.error('❌ [Setup] SSH Test für Server fehlgeschlagen:', error);
res.status(500).json({ error: code || 'SSH_TEST_FAILED' });
}
});
app.get('/api/setup/servers/:id/update-script', requirePermission('maintenance-ssh-update', 'read'), async (req, res) => {
const serverId = Number(req.params.id);
if (!Number.isFinite(serverId)) {
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
}
try {
getServerById(serverId);
const edition = (await resolveServerEdition(serverId) || '').toLowerCase();
const defaultScript = edition.includes('community')
? DEFAULT_PORTAINER_UPDATE_SCRIPT_CE
: DEFAULT_PORTAINER_UPDATE_SCRIPT_BE;
const script = getEffectivePortainerScript(serverId, defaultScript);
const custom = getCustomPortainerScript(serverId);
res.json({
success: true,
script: {
default: defaultScript,
custom: custom?.script ?? null,
customUpdatedAt: custom?.updatedAt ?? null,
effective: script.script,
source: script.source,
updatedAt: script.updatedAt
}
});
} catch (error) {
const code = error.code || error.message;
if (code === 'SERVER_NOT_FOUND') {
return res.status(404).json({ error: 'SERVER_NOT_FOUND' });
}
console.error('❌ [Setup] Update-Skript konnte nicht geladen werden:', error);
res.status(500).json({ error: 'UPDATE_SCRIPT_LOAD_FAILED' });
}
});
app.put('/api/setup/servers/:id/update-script', requirePermission('maintenance-ssh-update', 'full'), async (req, res) => {
const serverId = Number(req.params.id);
if (!Number.isFinite(serverId)) {
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
}
if (portainerUpdateState.running) {
return res.status(409).json({ error: 'Aktualisierung läuft. Skript kann derzeit nicht geändert werden.' });
}
const incoming = req.body?.script;
if (typeof incoming !== 'string') {
return res.status(400).json({ error: 'Feld "script" (string) wird benötigt.' });
}
try {
getServerById(serverId);
saveCustomPortainerScript(incoming, serverId);
const custom = getCustomPortainerScript(serverId);
const edition = (await resolveServerEdition(serverId) || '').toLowerCase();
const defaultScript = edition.includes('community')
? DEFAULT_PORTAINER_UPDATE_SCRIPT_CE
: DEFAULT_PORTAINER_UPDATE_SCRIPT_BE;
const effective = getEffectivePortainerScript(serverId, defaultScript);
res.json({
success: true,
script: {
default: defaultScript,
custom: custom?.script ?? null,
customUpdatedAt: custom?.updatedAt ?? null,
effective: effective.script,
source: effective.source,
updatedAt: effective.updatedAt
}
});
} catch (error) {
const code = error.code || error.message;
if (code === 'SERVER_NOT_FOUND') {
return res.status(404).json({ error: 'SERVER_NOT_FOUND' });
}
console.error('❌ [Setup] Update-Skript konnte nicht gespeichert werden:', error);
res.status(400).json({ error: code || 'UPDATE_SCRIPT_SAVE_FAILED' });
}
});
app.delete('/api/setup/servers/:id/update-script', requirePermission('maintenance-ssh-update', 'full'), async (req, res) => {
const serverId = Number(req.params.id);
if (!Number.isFinite(serverId)) {
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
}
if (portainerUpdateState.running) {
return res.status(409).json({ error: 'Aktualisierung läuft. Skript kann derzeit nicht geändert werden.' });
}
try {
getServerById(serverId);
saveCustomPortainerScript('', serverId);
const edition = (await resolveServerEdition(serverId) || '').toLowerCase();
const defaultScript = edition.includes('community')
? DEFAULT_PORTAINER_UPDATE_SCRIPT_CE
: DEFAULT_PORTAINER_UPDATE_SCRIPT_BE;
const effective = getEffectivePortainerScript(serverId, defaultScript);
res.json({
success: true,
script: {
default: defaultScript,
custom: null,
customUpdatedAt: null,
effective: effective.script,
source: effective.source,
updatedAt: effective.updatedAt
}
});
} catch (error) {
const code = error.code || error.message;
if (code === 'SERVER_NOT_FOUND') {
return res.status(404).json({ error: 'SERVER_NOT_FOUND' });
}
console.error('❌ [Setup] Update-Skript konnte nicht gelöscht werden:', error);
res.status(400).json({ error: code || 'UPDATE_SCRIPT_DELETE_FAILED' });
}
});
app.put('/api/setup/servers/:id', requirePermission('maintenance-server-edit', 'full'), (req, res) => {
const { id } = req.params;
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
const url = typeof req.body?.url === 'string' ? req.body.url.trim() : '';
try {
const server = updateServerDetails(id, { name, url });
const status = buildSetupStatusResponse();
res.json({ success: true, server, status });
} catch (error) {
const code = error.code || error.message;
switch (code) {
case 'SERVER_ID_INVALID':
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
case 'SERVER_NOT_FOUND':
return res.status(404).json({ error: 'SERVER_NOT_FOUND' });
case 'SERVER_URL_REQUIRED':
case 'SERVER_NAME_REQUIRED':
return res.status(400).json({ error: code });
case 'SERVER_URL_TAKEN':
return res.status(409).json({ error: 'SERVER_URL_TAKEN' });
default:
console.error('⚠️ [Setup] Server konnte nicht aktualisiert werden:', error);
return res.status(500).json({ error: 'SERVER_UPDATE_FAILED' });
}
}
});
app.delete('/api/setup/servers/:id', requirePermission('maintenance-server-delete', 'full'), (req, res) => {
const { id } = req.params;
const numericId = Number(id);
@@ -2201,13 +2832,13 @@ app.delete('/api/setup/servers/:id', requirePermission('maintenance-server-delet
case 'SERVER_NOT_FOUND':
return res.status(404).json({ error: 'SERVER_NOT_FOUND' });
default:
console.error('⚠️ [Setup] Server konnte nicht gelöscht werden:', error);
return res.status(500).json({ error: 'SERVER_DELETE_FAILED' });
console.error('⚠️ [Setup] Server konnte nicht gelöscht werden:', error);
return res.status(500).json({ error: 'SERVER_DELETE_FAILED' });
}
}
});
app.put('/api/setup/servers/:id/api-key', requirePermission('maintenance-server-manage', 'full'), (req, res) => {
app.put('/api/setup/servers/:id/api-key', requirePermission('maintenance-server-edit', 'full'), (req, res) => {
const { id } = req.params;
const numericId = Number(id);
if (!Number.isFinite(numericId)) {
@@ -2234,12 +2865,12 @@ app.put('/api/setup/servers/:id/api-key', requirePermission('maintenance-server-
return res.status(500).json({ error: 'API_KEY_ENCRYPT_FAILED' });
default:
console.error('⚠️ [Setup] API-Key konnte nicht aktualisiert werden:', error);
return res.status(500).json({ error: 'API_KEY_UPDATE_FAILED' });
return res.status(500).json({ error: 'API_KEY_UPDATE_FAILED' });
}
}
});
app.put('/api/setup/self-stack', requirePermission('maintenance-server-manage', 'full'), (req, res) => {
app.put('/api/setup/self-stack', requirePermission('maintenance-server-edit', 'full'), (req, res) => {
const hasPayload = req.body ? Object.prototype.hasOwnProperty.call(req.body, 'selfStackId') : false;
if (!hasPayload) {
return res.status(400).json({ error: 'SELF_STACK_ID_MISSING' });
+3 -1
View File
@@ -267,10 +267,12 @@ export function saveGroupPermissionValues(groupId, values = {}) {
};
const serverManageLevel = getSubmittedLevel('maintenance-server-manage');
const serverEditLevel = getSubmittedLevel('maintenance-server-edit');
const serverDeleteLevel = getSubmittedLevel('maintenance-server-delete');
if (getLevelPriority(serverDeleteLevel) >= getLevelPriority('full') &&
getLevelPriority(serverManageLevel) < getLevelPriority('full')) {
(getLevelPriority(serverManageLevel) < getLevelPriority('full') ||
getLevelPriority(serverEditLevel) < getLevelPriority('full'))) {
const error = new Error('PERMISSION_DEPENDENCY_LEVEL');
error.code = 'PERMISSION_DEPENDENCY_LEVEL';
error.permissionKey = 'maintenance-server-delete';
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -34,8 +34,8 @@
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
<script type="module" crossorigin src="/assets/index-71db1823.js"></script>
<link rel="stylesheet" href="/assets/index-dad5f6fa.css">
<script type="module" crossorigin src="/assets/index-027ca62d.js"></script>
<link rel="stylesheet" href="/assets/index-4d48f088.css">
</head>
<body>
<div id="root"></div>
+129
View File
@@ -14,6 +14,16 @@ const updateServer = db.prepare(`
SET name = ?, url = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const selectAgentByServerId = db.prepare('SELECT * FROM server_agents WHERE server_id = ?');
const selectAllAgents = db.prepare('SELECT * FROM server_agents');
const upsertAgent = db.prepare(`
INSERT INTO server_agents (server_id, agent_url, agent_token, created_at, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(server_id) DO UPDATE SET
agent_url = excluded.agent_url,
agent_token = excluded.agent_token,
updated_at = CURRENT_TIMESTAMP
`);
const deleteServerStmt = db.prepare('DELETE FROM servers WHERE id = ?');
@@ -126,6 +136,96 @@ function ensureServer({ name, url }) {
return created;
}
const generateAgentToken = () => `sp_${crypto.randomBytes(16).toString('hex')}`;
function getAgentConfig(serverId, { autoCreate = false } = {}) {
const id = Number(serverId);
if (!Number.isFinite(id)) {
const error = new Error('SERVER_ID_INVALID');
error.code = 'SERVER_ID_INVALID';
throw error;
}
const server = getServerById(id);
let agent = selectAgentByServerId.get(server.id) || null;
if (!agent && autoCreate) {
const token = generateAgentToken();
upsertAgent.run(server.id, null, token);
agent = selectAgentByServerId.get(server.id) || null;
}
return agent ? { serverId: server.id, agentUrl: agent.agent_url, agentToken: agent.agent_token } : null;
}
function setAgentConfig(serverId, { agentUrl, agentToken } = {}) {
const id = Number(serverId);
if (!Number.isFinite(id)) {
const error = new Error('SERVER_ID_INVALID');
error.code = 'SERVER_ID_INVALID';
throw error;
}
const server = getServerById(id);
const normalizedUrl = typeof agentUrl === 'string' && agentUrl.trim().length
? agentUrl.trim().replace(/\/+$/, '')
: null;
let normalizedToken = typeof agentToken === 'string' ? agentToken.trim() : '';
if (!normalizedToken) {
const existing = selectAgentByServerId.get(server.id);
normalizedToken = existing?.agent_token || generateAgentToken();
}
if (!normalizedToken.startsWith('sp_')) {
normalizedToken = `sp_${normalizedToken}`;
}
upsertAgent.run(server.id, normalizedUrl, normalizedToken);
const agent = selectAgentByServerId.get(server.id);
return { serverId: server.id, agentUrl: agent.agent_url, agentToken: agent.agent_token };
}
function getServerById(serverId) {
const id = Number(serverId);
if (!Number.isFinite(id)) {
const error = new Error('SERVER_ID_INVALID');
error.code = 'SERVER_ID_INVALID';
throw error;
}
const server = selectServerById.get(id);
if (!server) {
const error = new Error('SERVER_NOT_FOUND');
error.code = 'SERVER_NOT_FOUND';
throw error;
}
return server;
}
function updateServerDetails(serverId, { name, url }) {
const existing = getServerById(serverId);
const normalizedUrl = normalizeUrl(url || existing.url);
if (!normalizedUrl) {
const error = new Error('SERVER_URL_REQUIRED');
error.code = 'SERVER_URL_REQUIRED';
throw error;
}
const normalizedName = (name || deriveServerName(normalizedUrl)).trim();
if (!normalizedName) {
const error = new Error('SERVER_NAME_REQUIRED');
error.code = 'SERVER_NAME_REQUIRED';
throw error;
}
const other = selectServerByUrl.get(normalizedUrl);
if (other && other.id !== existing.id) {
const error = new Error('SERVER_URL_TAKEN');
error.code = 'SERVER_URL_TAKEN';
throw error;
}
updateServer.run(normalizedName, normalizedUrl, existing.id);
const updated = selectServerById.get(existing.id);
console.log(`️ [Setup] Server aktualisiert: ${updated.name} (${updated.id})`);
return updated;
}
function setServerApiKey({ serverId, apiKey }) {
const id = Number(serverId);
if (!Number.isFinite(id)) {
@@ -165,6 +265,13 @@ function setServerApiKey({ serverId, apiKey }) {
};
}
function getServerConnection(serverId) {
const server = getServerById(serverId);
const apiKeyRow = selectApiKeyByServerId.get(server.id);
const apiKey = decryptApiKey(apiKeyRow);
return { server, apiKey };
}
function getActiveApiKey() {
const firstServer = selectFirstServerStmt.get();
if (firstServer) {
@@ -261,6 +368,21 @@ function ensureDefaultsFromEnv() {
}
}
function getServerStatus(serverId) {
const server = getServerById(serverId);
const apiKeyMeta = selectApiKeyByServerId.get(server.id) || null;
const agent = getAgentConfig(server.id, { autoCreate: true });
return {
server,
apiKey: {
hasKey: Boolean(apiKeyMeta),
updatedAt: apiKeyMeta?.updated_at ?? null
},
agent
};
}
function getSetupStatus() {
const servers = selectAllServers.all();
const apiKeyRecords = selectAllApiKeys.all();
@@ -361,7 +483,14 @@ function completeSetup({ server: serverInput }) {
export {
ensureDefaultsFromEnv,
ensureServer,
getServerById,
setServerApiKey,
getAgentConfig,
setAgentConfig,
generateAgentToken,
getServerConnection,
updateServerDetails,
getServerStatus,
getActiveApiKey,
getActiveServerUrl,
hasServer,
+9
View File
@@ -12,6 +12,7 @@ import routes from "@/routes";
import { UserDetails } from "@/pages/dashboard/userDetails.jsx";
import { UserGroupDetail } from "@/pages/dashboard/userGroupDetail.jsx";
import { SecurityPhrase } from "@/pages/dashboard/securityPhrase.jsx";
import { ServerDetail } from "@/pages/dashboard/serverDetail.jsx";
import { useMaterialTailwindController, setOpenConfigurator } from "@/components";
import { useAuth } from "@/components/AuthProvider.jsx";
import PermissionGate from "@/components/PermissionGate.jsx";
@@ -210,6 +211,14 @@ export function Dashboard() {
</PermissionGate>
}
/>
<Route
path="maintenance/servers/:serverId"
element={
<PermissionGate permission="maintenance-server-manage" requiredLevel="read">
<ServerDetail />
</PermissionGate>
}
/>
</Routes>
{!isSecurityPhraseRoute && (
<div className="text-blue-gray-600">
+1
View File
@@ -6,3 +6,4 @@ export * from "@/pages/dashboard/usergroups";
export * from "@/pages/dashboard/userDetails";
export * from "@/pages/dashboard/userGroupDetail";
export * from "@/pages/dashboard/securityPhrase";
export * from "@/pages/dashboard/serverDetail";
+381 -187
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import axios from "axios";
import { useMaintenance } from "@/components/MaintenanceProvider";
import { useAuth } from "@/components/AuthProvider.jsx";
@@ -11,8 +12,12 @@ import {
Button,
Switch,
Input,
Alert
Alert,
Chip,
Select,
Option
} from "@material-tailwind/react";
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
const UPDATE_STATUS_LABELS = {
idle: "Bereit",
@@ -111,6 +116,7 @@ const createEmptySshDraft = () => ({
});
export function Maintenance() {
const navigate = useNavigate();
const { hasPermission, user: authUser } = useAuth();
const showToast = useCallback(() => { }, []);
const {
@@ -139,7 +145,7 @@ export function Maintenance() {
const canViewMaintenance = Boolean(isSuperuserAccount || hasPermission("maintenance-access", "read"));
const canControlMaintenance = Boolean(isSuperuserAccount || hasPermission("maintenance-access", "full"));
const canViewServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-manage", "read"));
const canEditServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-manage", "full"));
const canEditServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-edit", "full"));
const canDeleteServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-delete", "full"));
const canDeleteSuperuser = Boolean(isSuperuserAccount || hasPermission("maintenance-superuser-delete", "full"));
const canViewPortainer = Boolean(isSuperuserAccount || hasPermission("maintenance-portainer", "read"));
@@ -148,6 +154,8 @@ export function Maintenance() {
const canManageSsh = Boolean(isSuperuserAccount || hasPermission("maintenance-ssh-update", "full"));
const canViewDuplicates = Boolean(isSuperuserAccount || hasPermission("maintenance-duplicates", "read"));
const canManageDuplicates = Boolean(isSuperuserAccount || hasPermission("maintenance-duplicates", "full"));
const showPortainerSection = false;
const showDuplicateSection = false;
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
@@ -214,6 +222,11 @@ export function Maintenance() {
const [setupResources, setSetupResources] = useState(null);
const [setupResourcesLoading, setSetupResourcesLoading] = useState(true);
const [setupResourcesError, setSetupResourcesError] = useState("");
const [createServerName, setCreateServerName] = useState("");
const [createServerUrl, setCreateServerUrl] = useState("");
const [createServerApiKey, setCreateServerApiKey] = useState("");
const [createServerError, setCreateServerError] = useState("");
const [creatingServer, setCreatingServer] = useState(false);
const [apiKeyDrafts, setApiKeyDrafts] = useState({});
const [apiKeyUpdatingId, setApiKeyUpdatingId] = useState(null);
const [serverDeleteId, setServerDeleteId] = useState(null);
@@ -229,6 +242,35 @@ export function Maintenance() {
const [duplicatesUpdatedAt, setDuplicatesUpdatedAt] = useState(null);
const duplicatesRequestRef = useRef(null);
const [serverStatuses, setServerStatuses] = useState(new Map());
const [serverStatusesLoading, setServerStatusesLoading] = useState(false);
const [serverStatusesError, setServerStatusesError] = useState("");
const setupServers = useMemo(() => setupResources?.servers?.items ?? [], [setupResources]);
const apiKeyInfoMap = useMemo(() => {
const items = setupResources?.apiKeys?.items ?? [];
return new Map(items.map((entry) => [entry.serverId, entry]));
}, [setupResources]);
const setupComplete = useMemo(() => Boolean(setupResources?.setupComplete), [setupResources]);
const selfStackInfo = setupResources?.selfStack ?? null;
const currentSelfStackValue = selfStackInfo?.current ?? "";
const envSelfStackValue = setupResources?.envDefaults?.selfStackId ?? "";
const {
page,
perPage,
perPageOptions,
perPageIsAll,
handlePerPageChange,
setPage,
setTotals,
resetPagination
} = usePage();
useEffect(() => () => resetPagination(), [resetPagination]);
const [searchQuery, setSearchQuery] = useState("");
const [portainerStatus, setPortainerStatus] = useState(null);
const [portainerLoading, setPortainerLoading] = useState(true);
const [portainerError, setPortainerError] = useState("");
@@ -385,15 +427,78 @@ export function Maintenance() {
fetchDuplicates();
}, [fetchDuplicates]);
const setupServers = useMemo(() => setupResources?.servers?.items ?? [], [setupResources]);
const apiKeyInfoMap = useMemo(() => {
const items = setupResources?.apiKeys?.items ?? [];
return new Map(items.map((entry) => [entry.serverId, entry]));
}, [setupResources]);
const setupComplete = useMemo(() => Boolean(setupResources?.setupComplete), [setupResources]);
const selfStackInfo = setupResources?.selfStack ?? null;
const currentSelfStackValue = selfStackInfo?.current ?? "";
const envSelfStackValue = setupResources?.envDefaults?.selfStackId ?? "";
useEffect(() => {
if (!canViewServers || setupResourcesLoading) {
return;
}
const fetchStatuses = async () => {
if (!setupServers.length) {
setServerStatuses(new Map());
return;
}
setServerStatusesLoading(true);
setServerStatusesError("");
try {
const entries = await Promise.all(
setupServers.map(async (server) => {
try {
const res = await axios.get(`/api/setup/servers/${server.id}/check`);
const payload = res.data ?? {};
return [
server.id,
{
online: Boolean(payload.online),
portainer: payload.portainer ?? {},
error: null
}
];
} catch (err) {
const message = err.response?.data?.error || err.message || "Status unbekannt";
return [
server.id,
{
online: false,
portainer: {},
error: message
}
];
}
})
);
setServerStatuses(new Map(entries));
} catch (err) {
const message = err.response?.data?.error || err.message || "Server-Status konnte nicht geladen werden.";
setServerStatusesError(message);
} finally {
setServerStatusesLoading(false);
}
};
fetchStatuses();
}, [canViewServers, setupResourcesLoading, setupServers, setTotals]);
const filteredServers = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) {
return setupServers;
}
return setupServers.filter((server) => {
const haystacks = [server.name, server.url].map((value) => String(value || "").toLowerCase());
return haystacks.some((value) => value.includes(query));
});
}, [searchQuery, setupServers]);
useEffect(() => {
setTotals(filteredServers.length);
}, [filteredServers.length, setTotals]);
const paginatedServers = useMemo(() => {
if (perPageIsAll) {
return filteredServers;
}
const start = (page - 1) * perPage;
return filteredServers.slice(start, start + perPage);
}, [filteredServers, page, perPage, perPageIsAll]);
useEffect(() => {
setSelfStackDraft(currentSelfStackValue || "");
@@ -401,6 +506,48 @@ export function Maintenance() {
}, [currentSelfStackValue]);
const selfStackDirty = selfStackDraft !== currentSelfStackValue;
const handleSearchChange = useCallback((event) => {
setSearchQuery(event.target.value);
}, []);
useEffect(() => {
if (page !== 1) {
setPage(1);
}
}, [searchQuery, page, setPage]);
const handleCreateServer = useCallback(async () => {
if (!canEditServers || creatingServer) return;
const name = (createServerName || "").trim();
const url = (createServerUrl || "").trim();
const apiKey = (createServerApiKey || "").trim();
if (!url || !apiKey) {
setCreateServerError("Bitte Name, URL und API-Key angeben.");
return;
}
setCreateServerError("");
setCreatingServer(true);
try {
await axios.post("/api/setup/servers", { name, url, apiKey });
setCreateServerName("");
setCreateServerUrl("");
setCreateServerApiKey("");
await loadSetupResources({ silent: true });
} catch (err) {
const message = err.response?.data?.message || err.response?.data?.error || err.message || "Server konnte nicht angelegt werden.";
setCreateServerError(message);
} finally {
setCreatingServer(false);
}
}, [canEditServers, createServerApiKey, createServerName, createServerUrl, creatingServer, loadSetupResources]);
const handleResetCreateServer = useCallback(() => {
if (creatingServer) return;
setCreateServerName("");
setCreateServerUrl("");
setCreateServerApiKey("");
setCreateServerError("");
}, [creatingServer]);
const totals = useMemo(() => {
const groups = Array.isArray(duplicates) ? duplicates.length : 0;
@@ -974,189 +1121,236 @@ export function Maintenance() {
{canViewServers && (
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
variant="h6"
color="white"
className="flex items-center justify-between"
>
<span>Server &amp; API-Keys</span>
<Typography variant="h6" color="white" className="flex items-center justify-between">
<span>Server</span>
<Typography variant="small" color="white">
{setupServers.length} vorhanden
</Typography>
</Typography>
</CardHeader>
<CardBody className="flex flex-col gap-4 p-4">
{setupResourcesLoading && (
<p className="text-sm text-stormGrey-500">Daten werden geladen</p>
)}
{!setupResourcesLoading && setupResourcesError && (
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
{setupResourcesError}
</Alert>
)}
{!setupResourcesLoading && !setupResourcesError && (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<Typography variant="small" color="blue-gray" className="font-semibold uppercase">
Server
</Typography>
<Typography variant="small" color="blue-gray">
{setupServers.length} vorhanden
</Typography>
</div>
{setupServers.length === 0 ? (
<p className="text-sm text-stormGrey-500">
Es sind derzeit keine Server hinterlegt.
</p>
) : (
<div className="flex flex-col gap-3">
{setupServers.map((server) => {
const apiKeyInfo = apiKeyInfoMap.get(server.id) || null;
const hasKey = Boolean(apiKeyInfo?.hasKey);
const apiKeyUpdatedLabel = apiKeyInfo?.updatedAt
? new Date(apiKeyInfo.updatedAt).toLocaleString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
})
: null;
return (
<div
key={server.id}
className="flex flex-col gap-3 rounded-md border border-blue-gray-100 p-3 md:flex-row md:items-start md:justify-between"
>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-stormGrey-900">
{server.name || "Ohne Namen"}
</p>
<p className="text-xs text-stormGrey-500 break-all">
{server.url}
</p>
<p className="text-xs text-stormGrey-500">
API-Key: {hasKey ? "vorhanden" : "nicht hinterlegt"}{apiKeyUpdatedLabel ? ` (aktualisiert am ${apiKeyUpdatedLabel})` : ""}
</p>
</div>
<div className="flex w-full flex-col gap-2 md:max-w-xs">
<Input
type="password"
label="Neuer API-Key"
value={apiKeyDrafts[server.id] ?? ""}
onChange={(event) => handleApiKeyDraftChange(server.id, event.target.value)}
disabled={!canEditServers || setupResourcesLoading || apiKeyUpdatingId === server.id}
/>
<div className="flex items-center gap-2 justify-end">
<Button
variant="text"
color="red"
size="sm"
onClick={() => handleDeleteServer(server.id)}
disabled={maintenanceLocked || !canDeleteServers || setupResourcesLoading || serverDeleteId === server.id || apiKeyUpdatingId === server.id}
>
{serverDeleteId === server.id ? "Lösche…" : "Löschen"}
</Button>
<Button
color="blue"
size="sm"
onClick={() => handleApiKeyUpdate(server.id)}
disabled={maintenanceLocked || !canEditServers || setupResourcesLoading || apiKeyUpdatingId === server.id}
>
{apiKeyUpdatingId === server.id ? "Speichere…" : "API-Key speichern"}
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
<CardBody className="p-4">
{canEditServers && (
<div className="mb-8 rounded-lg border border-blue-gray-50 p-4">
<Typography variant="h6" color="blue-gray" className="mb-2">
Neuen Server hinzufügen
</Typography>
<p className="text-sm text-stormGrey-600 mb-4">
Hinterlege deine Portainer-Instanz. Die Verbindung wird beim Speichern automatisch geprüft.
</p>
<div className="grid gap-4 md:grid-cols-3">
<Input
label="Servername"
value={createServerName}
onChange={(event) => setCreateServerName(event.target.value)}
disabled={creatingServer || maintenanceLocked}
crossOrigin=""
/>
<Input
label="Server-URL oder IP"
value={createServerUrl}
onChange={(event) => setCreateServerUrl(event.target.value)}
disabled={creatingServer || maintenanceLocked}
crossOrigin=""
/>
<Input
type="password"
label="Portainer API-Key"
value={createServerApiKey}
onChange={(event) => setCreateServerApiKey(event.target.value)}
disabled={creatingServer || maintenanceLocked}
crossOrigin=""
/>
</div>
<div className="flex flex-col gap-3 border-t border-blue-gray-50 pt-4">
<div className="flex flex-col gap-1">
<Typography variant="small" color="blue-gray" className="font-semibold uppercase">
Self-Stack-ID
</Typography>
<p className="text-xs text-stormGrey-500">
Optional lass das Feld leer, wenn keine Self-Stack-ID benötigt wird.
</p>
</div>
{selfStackInfo?.envProvided && (
<p className="text-xs text-stormGrey-500">
Hinweis: Eine Umgebungsvariable liefert bereits einen Standardwert.
</p>
)}
<div className="flex flex-col gap-2 md:flex-row md:items-end">
<div className="flex-1">
<Input
label="Self-Stack-ID"
value={selfStackDraft}
onChange={(event) => handleSelfStackDraftChange(event.target.value)}
disabled={!canEditServers || setupResourcesLoading || selfStackSaving}
/>
</div>
{canEditServers && (
<div className="flex items-center justify-end gap-2">
<Button
variant="text"
color="red"
size="sm"
onClick={handleSelfStackRemove}
disabled={maintenanceLocked || selfStackSaving || (!currentSelfStackValue && !selfStackDraft)}
>
Entfernen
</Button>
<Button
color="blue"
size="sm"
onClick={handleSelfStackSave}
disabled={maintenanceLocked || selfStackSaving || setupResourcesLoading || !selfStackDirty}
>
{selfStackSaving ? "Speichere…" : "Self-Stack-ID speichern"}
</Button>
</div>
)}
</div>
{!canEditServers && (
<p className="text-xs text-stormGrey-500">
Bearbeitung benötigt das Gruppenrecht Server-Sektion.
</p>
)}
<p className="text-xs text-stormGrey-500">
Aktuell gespeichert: {currentSelfStackValue || "nicht gesetzt"}
</p>
{envSelfStackValue && (
<p className="text-xs text-stormGrey-500">
Vorgabe aus Umgebung: {envSelfStackValue}
</p>
)}
{selfStackError && (
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
{selfStackError}
</Alert>
)}
</div>
{!setupComplete && (
<Alert color="amber" className="border border-amber-200 bg-amber-50 text-amber-800">
Das Setup ist aktuell unvollständig. Bitte öffne den Setup-Bereich, um Server und API-Key erneut festzulegen.
</Alert>
)}
<div className="flex items-center justify-end">
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
<Button
variant="outlined"
color="gray"
size="sm"
onClick={() => loadSetupResources()}
disabled={maintenanceLocked || setupResourcesLoading}
color="green"
onClick={handleCreateServer}
disabled={creatingServer || maintenanceLocked}
>
Aktualisieren
{creatingServer ? "Verifiziere …" : "Server anlegen"}
</Button>
<Button
variant="text"
color="blue-gray"
onClick={handleResetCreateServer}
disabled={creatingServer}
>
Formular zurücksetzen
</Button>
</div>
{createServerError && (
<p className="mt-2 text-sm text-sunsetCoral-600">{createServerError}</p>
)}
</div>
)}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="md:flex-1">
<Input
label="Suchen nach Name oder URL"
value={searchQuery}
onChange={handleSearchChange}
crossOrigin=""
/>
</div>
<div className="md:mt-0 mt-4 md:flex-1">
<Select variant="static" label="Einträge pro Seite" onChange={handlePerPageChange} value={perPage}>
{perPageOptions.map(({ value, label }) => (
<Option key={value} value={value}>
{label}
</Option>
))}
</Select>
</div>
</div>
{setupResourcesError && (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{setupResourcesError}
</div>
)}
{serverStatusesError && !setupResourcesLoading && (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{serverStatusesError}
</div>
)}
<div className="overflow-x-auto rounded-lg border border-blue-gray-50 mt-6">
<table className="w-full min-w-[720px] table-auto text-left">
<thead>
<tr className="bg-blue-gray-50/50 text-xs uppercase tracking-wide text-stormGrey-400">
<th className="px-6 py-4 font-semibold">Name</th>
<th className="px-6 py-4 font-semibold">IP / Host</th>
<th className="px-6 py-4 font-semibold">Server Status</th>
<th className="px-6 py-4 font-semibold">Portainer Status</th>
<th className="px-6 py-4 font-semibold">Edition</th>
<th className="px-6 py-4 font-semibold text-right">Aktionen</th>
</tr>
</thead>
<tbody>
{setupResourcesLoading ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-blue-gray-400">
Server werden geladen ...
</td>
</tr>
) : filteredServers.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-blue-gray-400">
Keine Server gefunden.
</td>
</tr>
) : (
paginatedServers.map((server, index) => {
const rowClass = index === paginatedServers.length - 1 ? "" : "border-b border-blue-gray-50";
const statusEntry = serverStatuses.get(server.id) || {};
const serverOnline = statusEntry.online === true;
const portainer = statusEntry.portainer || {};
const portainerStatus = portainer.updateAvailable === true
? "update"
: portainer.updateAvailable === false
? "ok"
: "unknown";
const portainerEdition = portainer.edition || "-";
const hostLabel = (() => {
try {
const parsed = new URL(server.url);
return parsed.hostname || server.url;
} catch {
return server.url;
}
})();
return (
<tr key={server.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
<td className="px-6 py-4">
<Typography variant="small" className="font-medium text-stormGrey-900">
{server.name || ""}
</Typography>
<Typography variant="small" className="text-xs text-stormGrey-500">
{server.url}
</Typography>
</td>
<td className="px-6 py-4">
<Typography variant="small">{hostLabel || ""}</Typography>
</td>
<td className="px-6 py-4">
{serverStatusesLoading && !statusEntry ? (
<span className="text-xs text-stormGrey-400">Prüfe</span>
) : (
<Chip
value={serverOnline ? "Online" : "Offline"}
size="sm"
color={serverOnline ? "green" : "red"}
variant="ghost"
/>
)}
</td>
<td className="px-6 py-4">
{serverStatusesLoading && !statusEntry ? (
<span className="text-xs text-stormGrey-400">Prüfe</span>
) : (
<Chip
value={
portainerStatus === "update"
? "Update vorhanden"
: portainerStatus === "ok"
? "Aktuell"
: "Unbekannt"
}
size="sm"
color={
portainerStatus === "update"
? "amber"
: portainerStatus === "ok"
? "green"
: "blue-gray"
}
variant="ghost"
/>
)}
</td>
<td className="px-6 py-4">
{serverStatusesLoading && !statusEntry ? (
<span className="text-xs text-stormGrey-400">Prüfe</span>
) : (
<Typography variant="small">{portainerEdition}</Typography>
)}
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outlined"
color="blue-gray"
onClick={() => navigate(`/dashboard/maintenance/servers/${server.id}`)}
disabled={!canEditServers}
>
Bearbeiten
</Button>
<Button
size="sm"
variant="text"
color="red"
onClick={() => handleDeleteServer(server.id)}
disabled={maintenanceLocked || !canDeleteServers || serverDeleteId === server.id}
>
{serverDeleteId === server.id ? "Lösche…" : "Löschen"}
</Button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<div className="mt-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<Typography variant="small" color="blue-gray" className="text-sm">
{perPageIsAll ? "Alle Server" : `${paginatedServers.length} von ${filteredServers.length}`} angezeigt
</Typography>
<PaginationControls />
</div>
</CardBody>
</Card>
)}
@@ -1220,7 +1414,7 @@ export function Maintenance() {
</Card>
)}
{canViewPortainer && (
{showPortainerSection && canViewPortainer && (
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
@@ -1624,7 +1818,7 @@ export function Maintenance() {
</Card>
)}
{canViewDuplicates && (
{showDuplicateSection && canViewDuplicates && (
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
File diff suppressed because it is too large Load Diff
@@ -519,8 +519,12 @@ export function UserGroupDetail() {
setPermissionSelection((prev) => {
const shouldResetDelete =
permissionKey === "maintenance-server-manage" && nextValue !== "full";
const shouldResetEdit =
permissionKey === "maintenance-server-manage" && nextValue === "none";
const shouldResetDeleteFromEdit =
permissionKey === "maintenance-server-edit" && nextValue !== "full";
if (!shouldResetDelete && prev[permissionKey] === nextValue) {
if (!shouldResetDelete && !shouldResetEdit && !shouldResetDeleteFromEdit && prev[permissionKey] === nextValue) {
return prev;
}
@@ -532,6 +536,12 @@ export function UserGroupDetail() {
if (shouldResetDelete) {
nextState["maintenance-server-delete"] = "none";
}
if (shouldResetEdit) {
nextState["maintenance-server-edit"] = "none";
}
if (shouldResetDeleteFromEdit) {
nextState["maintenance-server-delete"] = "none";
}
return nextState;
});
@@ -609,6 +619,7 @@ export function UserGroupDetail() {
const currentValue = getPermissionValue(row.key) ?? "none";
const displayValue = normalizePermissionLevel(row.key, currentValue);
const serverManageLevel = getPermissionValue("maintenance-server-manage") ?? "none";
const serverEditLevel = getPermissionValue("maintenance-server-edit") ?? "none";
const { addTopBorder = true } = options;
const rowClasses = [
@@ -641,13 +652,18 @@ export function UserGroupDetail() {
row.key === "maintenance-server-delete" &&
option.value === "full" &&
(LEVEL_PRIORITY[serverManageLevel] ?? 0) < LEVEL_PRIORITY.full;
const requiresEditFull =
row.key === "maintenance-server-delete" &&
option.value === "full" &&
(LEVEL_PRIORITY[serverEditLevel] ?? 0) < LEVEL_PRIORITY.full;
const disabled =
!availableLevels.has(option.value) ||
permissionsLoading ||
savingGroup ||
!canAdjustPermissions ||
isSuperuserGroup ||
requiresManageFull;
requiresManageFull ||
requiresEditFull;
return (
<ListItem
+1 -1
View File
@@ -2,8 +2,8 @@ import {
Square3Stack3DIcon,
WrenchScrewdriverIcon,
ListBulletIcon,
ServerStackIcon,
RectangleStackIcon,
ServerStackIcon,
ArrowLeftOnRectangleIcon,
UserIcon,
UserGroupIcon,