Update
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
# 📦 StackPulse 
|
# 📦 StackPulse 
|
||||||
|
|
||||||
**StackPulse** ist eine kleine Web-App, die über die Portainer-API deine Docker-Stacks verwaltet und aktualisiert.
|
**StackPulse** ist eine kleine Web-App, die über die Portainer-API deine Docker-Stacks verwaltet und aktualisiert.
|
||||||
Aktuell funktioniert StackPulse nur mit der Business-Edition von Portainer. Die Communitiy-Edition wird in einem späteren Release implementiert!
|
StackPulse funktioniert nur mit der Business-Edition von Portainer. Die Communitiy-Edition wird nicht unterstützt!
|
||||||
Sie besteht aus einem **Backend (Node.js/Express)** und einem **Frontend (React/Tailwind)**.
|
Sie besteht aus einem **Backend (Node.js/Express)** und einem **Frontend (React/Tailwind)**.
|
||||||
|
|
||||||
Ziel:
|
Ziel:
|
||||||
@@ -124,9 +124,8 @@ Ziel:
|
|||||||
- Notifications (z. B. via Webhooks oder Mail)
|
- Notifications (z. B. via Webhooks oder Mail)
|
||||||
- Monitoring (Status, CPU/RAM)
|
- Monitoring (Status, CPU/RAM)
|
||||||
- Verbesserte UI/UX
|
- Verbesserte UI/UX
|
||||||
- Erweiterte Filterungen udn Sortierungen
|
- Erweiterte Filterungen und Sortierungen
|
||||||
- Multi-Server Verwaltung
|
- Multi-Server Verwaltung
|
||||||
- Integration Community Edition
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
FROM node:22-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm install --omit=dev
|
|
||||||
|
|
||||||
COPY src ./src
|
|
||||||
|
|
||||||
EXPOSE 7070
|
|
||||||
|
|
||||||
CMD ["node", "src/index.js"]
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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}`);
|
|
||||||
});
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
+19
-10
@@ -69,6 +69,21 @@ CREATE TABLE user_group_memberships (
|
|||||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE
|
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_server_assignments (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
server_id INTEGER NOT NULL,
|
||||||
|
group_id INTEGER,
|
||||||
|
use_global_group INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, server_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_user_server_assignments_user ON user_server_assignments (user_id);
|
||||||
|
CREATE INDEX idx_user_server_assignments_server ON user_server_assignments (server_id);
|
||||||
|
|
||||||
CREATE TABLE permissions (
|
CREATE TABLE permissions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
key TEXT NOT NULL UNIQUE,
|
key TEXT NOT NULL UNIQUE,
|
||||||
@@ -100,13 +115,15 @@ CREATE TABLE permission_sections (
|
|||||||
CREATE TABLE permission_groups (
|
CREATE TABLE permission_groups (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
section_id INTEGER NOT NULL,
|
section_id INTEGER NOT NULL,
|
||||||
|
parent_group_id INTEGER,
|
||||||
key TEXT NOT NULL,
|
key TEXT NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(section_id, key),
|
UNIQUE(section_id, key),
|
||||||
FOREIGN KEY (section_id) REFERENCES permission_sections(id) ON DELETE CASCADE
|
FOREIGN KEY (section_id) REFERENCES permission_sections(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (parent_group_id) REFERENCES permission_groups(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE permission_items (
|
CREATE TABLE permission_items (
|
||||||
@@ -118,6 +135,7 @@ CREATE TABLE permission_items (
|
|||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
default_level TEXT NOT NULL,
|
default_level TEXT NOT NULL,
|
||||||
available_levels TEXT NOT NULL,
|
available_levels TEXT NOT NULL,
|
||||||
|
is_global_scope INTEGER NOT NULL DEFAULT 1,
|
||||||
is_required INTEGER NOT NULL DEFAULT 0,
|
is_required INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -210,15 +228,6 @@ CREATE TABLE server_update_scripts (
|
|||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
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 INDEX idx_server_update_scripts_server ON server_update_scripts (server_id);
|
||||||
|
|
||||||
CREATE TABLE user_settings (
|
CREATE TABLE user_settings (
|
||||||
|
|||||||
+234
-233
@@ -2,7 +2,6 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { db } from './index.js';
|
import { db } from './index.js';
|
||||||
import crypto from 'crypto';
|
|
||||||
|
|
||||||
const BLUEPRINT_FILENAME = 'dbs';
|
const BLUEPRINT_FILENAME = 'dbs';
|
||||||
const PERMISSION_BLUEPRINT = [
|
const PERMISSION_BLUEPRINT = [
|
||||||
@@ -16,6 +15,7 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
key: 'stacks-redeploy-single',
|
key: 'stacks-redeploy-single',
|
||||||
label: 'Redeploy einzeln',
|
label: 'Redeploy einzeln',
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
|
global: false,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', 'none'],
|
levels: ['full', 'none'],
|
||||||
dependencies: []
|
dependencies: []
|
||||||
@@ -24,6 +24,7 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
key: 'stacks-redeploy-selection',
|
key: 'stacks-redeploy-selection',
|
||||||
label: 'Redeploy Auswahl',
|
label: 'Redeploy Auswahl',
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
|
global: false,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', 'none'],
|
levels: ['full', 'none'],
|
||||||
dependencies: []
|
dependencies: []
|
||||||
@@ -32,6 +33,7 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
key: 'stacks-redeploy-all',
|
key: 'stacks-redeploy-all',
|
||||||
label: 'Redeploy Alle',
|
label: 'Redeploy Alle',
|
||||||
sortOrder: 2,
|
sortOrder: 2,
|
||||||
|
global: false,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', 'none'],
|
levels: ['full', 'none'],
|
||||||
dependencies: []
|
dependencies: []
|
||||||
@@ -48,6 +50,7 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
key: 'logs-access',
|
key: 'logs-access',
|
||||||
label: 'Bereich & Navigation',
|
label: 'Bereich & Navigation',
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
|
global: true,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', 'none'],
|
levels: ['full', 'none'],
|
||||||
isRequired: 1,
|
isRequired: 1,
|
||||||
@@ -57,6 +60,7 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
key: 'logs-export',
|
key: 'logs-export',
|
||||||
label: 'Logs Exportieren',
|
label: 'Logs Exportieren',
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
|
global: true,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', 'none'],
|
levels: ['full', 'none'],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
@@ -67,6 +71,7 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
key: 'logs-delete',
|
key: 'logs-delete',
|
||||||
label: 'Logs löschen',
|
label: 'Logs löschen',
|
||||||
sortOrder: 2,
|
sortOrder: 2,
|
||||||
|
global: true,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', 'none'],
|
levels: ['full', 'none'],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
@@ -85,6 +90,7 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
key: 'users-access',
|
key: 'users-access',
|
||||||
label: 'Bereich & Navigation',
|
label: 'Bereich & Navigation',
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
|
global: true,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', 'none'],
|
levels: ['full', 'none'],
|
||||||
isRequired: 1,
|
isRequired: 1,
|
||||||
@@ -94,6 +100,7 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
key: 'users-edit',
|
key: 'users-edit',
|
||||||
label: 'Benutzer bearbeiten',
|
label: 'Benutzer bearbeiten',
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
|
global: true,
|
||||||
defaultLevel: 'read',
|
defaultLevel: 'read',
|
||||||
levels: ['full', 'read'],
|
levels: ['full', 'read'],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
@@ -104,6 +111,7 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
key: 'users-delete',
|
key: 'users-delete',
|
||||||
label: 'Benutzer löschen',
|
label: 'Benutzer löschen',
|
||||||
sortOrder: 2,
|
sortOrder: 2,
|
||||||
|
global: true,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', 'none'],
|
levels: ['full', 'none'],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
@@ -114,6 +122,7 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
key: 'users-security-phrase',
|
key: 'users-security-phrase',
|
||||||
label: 'Sicherheitsschlüssel',
|
label: 'Sicherheitsschlüssel',
|
||||||
sortOrder: 3,
|
sortOrder: 3,
|
||||||
|
global: true,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', 'none'],
|
levels: ['full', 'none'],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
@@ -132,6 +141,7 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
key: 'user-groups-access',
|
key: 'user-groups-access',
|
||||||
label: 'Bereich & Navigation',
|
label: 'Bereich & Navigation',
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
|
global: true,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', 'none'],
|
levels: ['full', 'none'],
|
||||||
isRequired: 1,
|
isRequired: 1,
|
||||||
@@ -141,6 +151,7 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
key: 'user-groups-edit',
|
key: 'user-groups-edit',
|
||||||
label: 'Benutzergruppen bearbeiten',
|
label: 'Benutzergruppen bearbeiten',
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
|
global: true,
|
||||||
defaultLevel: 'read',
|
defaultLevel: 'read',
|
||||||
levels: ['full', 'read'],
|
levels: ['full', 'read'],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
@@ -151,6 +162,7 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
key: 'user-groups-delete',
|
key: 'user-groups-delete',
|
||||||
label: 'Benutzergruppen löschen',
|
label: 'Benutzergruppen löschen',
|
||||||
sortOrder: 2,
|
sortOrder: 2,
|
||||||
|
global: true,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', 'none'],
|
levels: ['full', 'none'],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
@@ -169,6 +181,7 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
key: 'maintenance-access',
|
key: 'maintenance-access',
|
||||||
label: 'Bereich & Navigation',
|
label: 'Bereich & Navigation',
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
|
global: true,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', 'none'],
|
levels: ['full', 'none'],
|
||||||
isRequired: 1,
|
isRequired: 1,
|
||||||
@@ -185,8 +198,9 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
key: 'maintenance-server-manage',
|
key: 'maintenance-server-manage',
|
||||||
label: 'Server-Sektion',
|
label: 'Server-Sektion',
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
|
global: true,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', 'read', 'none'],
|
levels: ['full', 'none'],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
||||||
]
|
]
|
||||||
@@ -195,8 +209,9 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
key: 'maintenance-server-edit',
|
key: 'maintenance-server-edit',
|
||||||
label: 'Server bearbeiten',
|
label: 'Server bearbeiten',
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
|
global: false,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', 'none'],
|
levels: ['full', 'read', 'none'],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
||||||
{ dependsOnKey: 'maintenance-server-manage', requiredLevel: '!=none' }
|
{ dependsOnKey: 'maintenance-server-manage', requiredLevel: '!=none' }
|
||||||
@@ -206,6 +221,7 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
key: 'maintenance-server-delete',
|
key: 'maintenance-server-delete',
|
||||||
label: 'Server löschen',
|
label: 'Server löschen',
|
||||||
sortOrder: 2,
|
sortOrder: 2,
|
||||||
|
global: false,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', 'none'],
|
levels: ['full', 'none'],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
@@ -214,6 +230,58 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
{ dependsOnKey: 'maintenance-server-edit', requiredLevel: '=full' }
|
{ dependsOnKey: 'maintenance-server-edit', requiredLevel: '=full' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
key: 'maintenance-portainer-group',
|
||||||
|
title: 'Portainer',
|
||||||
|
sortOrder: 1,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'maintenance-ssh-update',
|
||||||
|
label: 'SSH/Update-Skript',
|
||||||
|
sortOrder: 0,
|
||||||
|
global: false,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'read', 'none'],
|
||||||
|
dependencies: [
|
||||||
|
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
||||||
|
{ dependsOnKey: 'maintenance-server-edit', requiredLevel: '!=none' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maintenance-update',
|
||||||
|
label: 'Update durchführen',
|
||||||
|
sortOrder: 1,
|
||||||
|
global: false,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
dependencies: [
|
||||||
|
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
||||||
|
{ dependsOnKey: 'maintenance-server-edit', requiredLevel: '!=none' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maintenance-duplicates-group',
|
||||||
|
title: 'Doppelte Stacks',
|
||||||
|
sortOrder: 2,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'maintenance-duplicates',
|
||||||
|
label: 'Doppelte Stacks',
|
||||||
|
sortOrder: 0,
|
||||||
|
global: false,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'read', 'none'],
|
||||||
|
dependencies: [
|
||||||
|
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
||||||
|
{ dependsOnKey: 'maintenance-server-edit', requiredLevel: '!=none' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -225,6 +293,7 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
key: 'maintenance-superuser-delete',
|
key: 'maintenance-superuser-delete',
|
||||||
label: 'Superuser löschen',
|
label: 'Superuser löschen',
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
|
global: true,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', 'none'],
|
levels: ['full', 'none'],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
@@ -234,58 +303,20 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'maintenance-portainer-group',
|
key: 'maintenance-mtls-group',
|
||||||
title: 'Portainer',
|
title: 'mTLS',
|
||||||
sortOrder: 2,
|
sortOrder: 2,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
key: 'maintenance-portainer',
|
key: 'maintenance-mtls',
|
||||||
label: 'Portainer-Sektion',
|
label: 'mTLS Sektion',
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
|
global: true,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', 'none'],
|
levels: ['full', 'none'],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'maintenance-ssh-update',
|
|
||||||
label: 'SSH/Update-Skript',
|
|
||||||
sortOrder: 1,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'read', 'none'],
|
|
||||||
dependencies: [
|
|
||||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
|
||||||
{ dependsOnKey: 'maintenance-portainer', requiredLevel: '!=none' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'maintenance-update',
|
|
||||||
label: 'Update durchführen',
|
|
||||||
sortOrder: 2,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'none'],
|
|
||||||
dependencies: [
|
|
||||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
|
||||||
{ dependsOnKey: 'maintenance-portainer', requiredLevel: '!=none' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'maintenance-duplicates-group',
|
|
||||||
title: 'Doppelte Stacks',
|
|
||||||
sortOrder: 3,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
key: 'maintenance-duplicates',
|
|
||||||
label: 'Doppelte Stacks',
|
|
||||||
sortOrder: 0,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'read', 'none'],
|
|
||||||
dependencies: [
|
|
||||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -381,7 +412,13 @@ const tableExists = (tableName) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const dropDeprecatedArtifacts = () => {
|
const dropDeprecatedArtifacts = () => {
|
||||||
const deprecatedTables = ['user_endpoint_permission_overrides', 'endpoints'];
|
const deprecatedTables = [
|
||||||
|
'user_endpoint_permission_overrides',
|
||||||
|
'endpoints',
|
||||||
|
'server_agents',
|
||||||
|
'agent_tls_material',
|
||||||
|
'agent_bootstrap_tokens'
|
||||||
|
];
|
||||||
deprecatedTables.forEach((table) => {
|
deprecatedTables.forEach((table) => {
|
||||||
if (tableExists(table)) {
|
if (tableExists(table)) {
|
||||||
db.exec(`DROP TABLE IF EXISTS ${table}`);
|
db.exec(`DROP TABLE IF EXISTS ${table}`);
|
||||||
@@ -453,20 +490,24 @@ const ensurePermissionSeeds = () => {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const selectGroup = db.prepare(`
|
const selectGroup = db.prepare(`
|
||||||
SELECT id, title, sort_order
|
SELECT id, title, sort_order, parent_group_id
|
||||||
FROM permission_groups
|
FROM permission_groups
|
||||||
WHERE section_id = ? AND key = ?
|
WHERE section_id = ? AND key = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`);
|
`);
|
||||||
const insertGroup = db.prepare(`
|
const insertGroup = db.prepare(`
|
||||||
INSERT INTO permission_groups (section_id, key, title, sort_order, created_at, updated_at)
|
INSERT INTO permission_groups (section_id, parent_group_id, key, title, sort_order, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
`);
|
`);
|
||||||
const updateGroup = db.prepare(`
|
const updateGroup = db.prepare(`
|
||||||
UPDATE permission_groups
|
UPDATE permission_groups
|
||||||
SET title = ?, sort_order = ?, updated_at = CURRENT_TIMESTAMP
|
SET title = ?, sort_order = ?, parent_group_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`);
|
`);
|
||||||
|
const deleteUnusedGroups = db.prepare(`
|
||||||
|
DELETE FROM permission_groups
|
||||||
|
WHERE key NOT IN (SELECT value FROM json_each(?))
|
||||||
|
`);
|
||||||
|
|
||||||
const selectItem = db.prepare(`
|
const selectItem = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -477,6 +518,7 @@ const ensurePermissionSeeds = () => {
|
|||||||
sort_order,
|
sort_order,
|
||||||
default_level,
|
default_level,
|
||||||
available_levels,
|
available_levels,
|
||||||
|
is_global_scope,
|
||||||
is_required
|
is_required
|
||||||
FROM permission_items
|
FROM permission_items
|
||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
@@ -491,11 +533,12 @@ const ensurePermissionSeeds = () => {
|
|||||||
sort_order,
|
sort_order,
|
||||||
default_level,
|
default_level,
|
||||||
available_levels,
|
available_levels,
|
||||||
|
is_global_scope,
|
||||||
is_required,
|
is_required,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
const updateItem = db.prepare(`
|
const updateItem = db.prepare(`
|
||||||
@@ -507,10 +550,24 @@ const ensurePermissionSeeds = () => {
|
|||||||
sort_order = ?,
|
sort_order = ?,
|
||||||
default_level = ?,
|
default_level = ?,
|
||||||
available_levels = ?,
|
available_levels = ?,
|
||||||
|
is_global_scope = ?,
|
||||||
is_required = ?,
|
is_required = ?,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`);
|
`);
|
||||||
|
const deleteUnusedItems = db.prepare(`
|
||||||
|
DELETE FROM permission_items
|
||||||
|
WHERE key NOT IN (SELECT value FROM json_each(?))
|
||||||
|
`);
|
||||||
|
const deleteOrphanDependencies = db.prepare(`
|
||||||
|
DELETE FROM permission_dependencies
|
||||||
|
WHERE permission_id NOT IN (SELECT id FROM permission_items)
|
||||||
|
OR depends_on_permission_id NOT IN (SELECT id FROM permission_items)
|
||||||
|
`);
|
||||||
|
const deleteOrphanPermissionValues = db.prepare(`
|
||||||
|
DELETE FROM group_permission_values
|
||||||
|
WHERE permission_id NOT IN (SELECT id FROM permission_items)
|
||||||
|
`);
|
||||||
|
|
||||||
const selectDependency = db.prepare(`
|
const selectDependency = db.prepare(`
|
||||||
SELECT id, required_level
|
SELECT id, required_level
|
||||||
@@ -536,6 +593,100 @@ const ensurePermissionSeeds = () => {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const itemKeyToId = new Map();
|
const itemKeyToId = new Map();
|
||||||
|
const dependencyQueue = [];
|
||||||
|
const itemKeys = new Set();
|
||||||
|
const groupKeys = new Set();
|
||||||
|
|
||||||
|
const upsertItem = ({ item, sectionId, groupId = null, sortOrder }) => {
|
||||||
|
const existingItem = selectItem.get(item.key);
|
||||||
|
const itemSortOrder = typeof sortOrder === 'number' ? sortOrder : 0;
|
||||||
|
const levelOptions = Array.isArray(item.levels) && item.levels.length
|
||||||
|
? item.levels
|
||||||
|
: ['full', 'read', 'none'];
|
||||||
|
const availableLevels = JSON.stringify(levelOptions);
|
||||||
|
const isRequired = item.isRequired ? 1 : 0;
|
||||||
|
const isGlobal = item.global === false ? 0 : 1;
|
||||||
|
const label = item.label || item.key || '';
|
||||||
|
|
||||||
|
if (!existingItem) {
|
||||||
|
const result = insertItem.run(
|
||||||
|
sectionId,
|
||||||
|
groupId,
|
||||||
|
item.key,
|
||||||
|
label,
|
||||||
|
itemSortOrder,
|
||||||
|
item.defaultLevel || 'none',
|
||||||
|
availableLevels,
|
||||||
|
isGlobal,
|
||||||
|
isRequired
|
||||||
|
);
|
||||||
|
itemKeyToId.set(item.key, Number(result.lastInsertRowid));
|
||||||
|
console.log(`ℹ️ Berechtigung ${item.key} ${groupId ? `in Gruppe ${groupId}` : ''} angelegt`);
|
||||||
|
} else {
|
||||||
|
const hasChanges =
|
||||||
|
existingItem.section_id !== sectionId ||
|
||||||
|
existingItem.group_id !== groupId ||
|
||||||
|
existingItem.label !== label ||
|
||||||
|
(existingItem.sort_order ?? itemSortOrder) !== itemSortOrder ||
|
||||||
|
existingItem.default_level !== (item.defaultLevel || 'none') ||
|
||||||
|
existingItem.available_levels !== availableLevels ||
|
||||||
|
Number(existingItem.is_global_scope ?? 1) !== isGlobal ||
|
||||||
|
Number(existingItem.is_required) !== isRequired;
|
||||||
|
updateItem.run(
|
||||||
|
sectionId,
|
||||||
|
groupId,
|
||||||
|
label,
|
||||||
|
itemSortOrder,
|
||||||
|
item.defaultLevel || 'none',
|
||||||
|
availableLevels,
|
||||||
|
isGlobal,
|
||||||
|
isRequired,
|
||||||
|
existingItem.id
|
||||||
|
);
|
||||||
|
itemKeyToId.set(item.key, existingItem.id);
|
||||||
|
if (hasChanges) {
|
||||||
|
console.log(`ℹ️ Berechtigung ${item.key} aktualisiert`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
itemKeys.add(item.key);
|
||||||
|
dependencyQueue.push({ itemKey: item.key, dependencies: item.dependencies || [] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const processGroups = (groups, sectionId, parentGroupId = null) => {
|
||||||
|
const list = Array.isArray(groups) ? groups : [];
|
||||||
|
list.forEach((group, groupIdx) => {
|
||||||
|
const sortOrder = typeof group.sortOrder === 'number' ? group.sortOrder : groupIdx;
|
||||||
|
const existingGroup = selectGroup.get(sectionId, group.key);
|
||||||
|
let groupId;
|
||||||
|
if (!existingGroup) {
|
||||||
|
const result = insertGroup.run(sectionId, parentGroupId, group.key, group.title, sortOrder);
|
||||||
|
groupId = Number(result.lastInsertRowid);
|
||||||
|
console.log(`ℹ️ Berechtigungs-Gruppe ${group.key} in Sektion ${sectionId} angelegt`);
|
||||||
|
} else {
|
||||||
|
const hasGroupChanges =
|
||||||
|
existingGroup.title !== group.title ||
|
||||||
|
(existingGroup.sort_order ?? sortOrder) !== sortOrder ||
|
||||||
|
existingGroup.parent_group_id !== parentGroupId;
|
||||||
|
updateGroup.run(group.title, sortOrder, parentGroupId, existingGroup.id);
|
||||||
|
groupId = existingGroup.id;
|
||||||
|
if (hasGroupChanges) {
|
||||||
|
console.log(`ℹ️ Berechtigungs-Gruppe ${group.key} in Sektion ${sectionId} aktualisiert`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groupKeys.add(group.key);
|
||||||
|
|
||||||
|
const groupItems = Array.isArray(group.items) ? group.items : [];
|
||||||
|
groupItems.forEach((item, itemIdx) => {
|
||||||
|
upsertItem({ item, sectionId, groupId, sortOrder: typeof item.sortOrder === 'number' ? item.sortOrder : itemIdx });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (group.groups) {
|
||||||
|
processGroups(group.groups, sectionId, groupId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
PERMISSION_BLUEPRINT.forEach((section, sectionIndex) => {
|
PERMISSION_BLUEPRINT.forEach((section, sectionIndex) => {
|
||||||
const existingSection = selectSection.get(section.key);
|
const existingSection = selectSection.get(section.key);
|
||||||
@@ -568,190 +719,41 @@ const ensurePermissionSeeds = () => {
|
|||||||
|
|
||||||
const sectionItems = Array.isArray(section.items) ? section.items : [];
|
const sectionItems = Array.isArray(section.items) ? section.items : [];
|
||||||
sectionItems.forEach((item, itemIdx) => {
|
sectionItems.forEach((item, itemIdx) => {
|
||||||
const existingItem = selectItem.get(item.key);
|
upsertItem({ item, sectionId, groupId: null, sortOrder: typeof item.sortOrder === 'number' ? item.sortOrder : itemIdx });
|
||||||
const sortOrder = typeof item.sortOrder === 'number' ? item.sortOrder : itemIdx;
|
});
|
||||||
const levelOptions = Array.isArray(item.levels) && item.levels.length
|
|
||||||
? item.levels
|
processGroups(section.groups, sectionId, null);
|
||||||
: ['full', 'read', 'none'];
|
});
|
||||||
const availableLevels = JSON.stringify(levelOptions);
|
|
||||||
const isRequired = item.isRequired ? 1 : 0;
|
dependencyQueue.forEach(({ itemKey, dependencies }) => {
|
||||||
if (!existingItem) {
|
const permissionId = itemKeyToId.get(itemKey);
|
||||||
const result = insertItem.run(
|
if (!permissionId) return;
|
||||||
sectionId,
|
dependencies.forEach((dependency) => {
|
||||||
null,
|
const dependsId = itemKeyToId.get(dependency.dependsOnKey);
|
||||||
item.key,
|
if (!dependsId) return;
|
||||||
item.label,
|
const existing = selectDependency.get(permissionId, dependsId);
|
||||||
sortOrder,
|
const requiredLevel = dependency.requiredLevel ?? null;
|
||||||
item.defaultLevel || 'none',
|
if (!existing) {
|
||||||
availableLevels,
|
insertDependency.run(permissionId, dependsId, requiredLevel);
|
||||||
isRequired
|
console.log(`ℹ️ Abhängigkeit ${itemKey} -> ${dependency.dependsOnKey} angelegt`);
|
||||||
);
|
} else if (existing.required_level !== requiredLevel) {
|
||||||
itemKeyToId.set(item.key, Number(result.lastInsertRowid));
|
updateDependency.run(requiredLevel, existing.id);
|
||||||
console.log(`ℹ️ Berechtigung ${item.key} angelegt`);
|
console.log(`ℹ️ Abhängigkeit ${itemKey} -> ${dependency.dependsOnKey} aktualisiert`);
|
||||||
} else {
|
|
||||||
const hasChanges =
|
|
||||||
existingItem.section_id !== sectionId ||
|
|
||||||
existingItem.group_id !== null ||
|
|
||||||
existingItem.label !== item.label ||
|
|
||||||
(existingItem.sort_order ?? sortOrder) !== sortOrder ||
|
|
||||||
existingItem.default_level !== (item.defaultLevel || 'none') ||
|
|
||||||
existingItem.available_levels !== availableLevels ||
|
|
||||||
Number(existingItem.is_required) !== isRequired;
|
|
||||||
updateItem.run(
|
|
||||||
sectionId,
|
|
||||||
null,
|
|
||||||
item.label,
|
|
||||||
sortOrder,
|
|
||||||
item.defaultLevel || 'none',
|
|
||||||
availableLevels,
|
|
||||||
isRequired,
|
|
||||||
existingItem.id
|
|
||||||
);
|
|
||||||
itemKeyToId.set(item.key, existingItem.id);
|
|
||||||
if (hasChanges) {
|
|
||||||
console.log(`ℹ️ Berechtigung ${item.key} aktualisiert`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
|
|
||||||
sectionGroups.forEach((group, groupIdx) => {
|
|
||||||
const existingGroup = selectGroup.get(sectionId, group.key);
|
|
||||||
const sortOrder = typeof group.sortOrder === 'number' ? group.sortOrder : groupIdx;
|
|
||||||
let groupId;
|
|
||||||
if (!existingGroup) {
|
|
||||||
const result = insertGroup.run(sectionId, group.key, group.title, sortOrder);
|
|
||||||
groupId = Number(result.lastInsertRowid);
|
|
||||||
console.log(`ℹ️ Berechtigungs-Gruppe ${group.key} in Sektion ${section.key} angelegt`);
|
|
||||||
} else {
|
|
||||||
const hasGroupChanges =
|
|
||||||
existingGroup.title !== group.title ||
|
|
||||||
(existingGroup.sort_order ?? sortOrder) !== sortOrder;
|
|
||||||
updateGroup.run(group.title, sortOrder, existingGroup.id);
|
|
||||||
groupId = existingGroup.id;
|
|
||||||
if (hasGroupChanges) {
|
|
||||||
console.log(`ℹ️ Berechtigungs-Gruppe ${group.key} in Sektion ${section.key} aktualisiert`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const groupItems = Array.isArray(group.items) ? group.items : [];
|
|
||||||
groupItems.forEach((item, itemIdx) => {
|
|
||||||
const existingItem = selectItem.get(item.key);
|
|
||||||
const itemSortOrder = typeof item.sortOrder === 'number' ? item.sortOrder : itemIdx;
|
|
||||||
const levelOptions = Array.isArray(item.levels) && item.levels.length
|
|
||||||
? item.levels
|
|
||||||
: ['full', 'read', 'none'];
|
|
||||||
const availableLevels = JSON.stringify(levelOptions);
|
|
||||||
const isRequired = item.isRequired ? 1 : 0;
|
|
||||||
if (!existingItem) {
|
|
||||||
const result = insertItem.run(
|
|
||||||
sectionId,
|
|
||||||
groupId,
|
|
||||||
item.key,
|
|
||||||
item.label,
|
|
||||||
itemSortOrder,
|
|
||||||
item.defaultLevel || 'none',
|
|
||||||
availableLevels,
|
|
||||||
isRequired
|
|
||||||
);
|
|
||||||
itemKeyToId.set(item.key, Number(result.lastInsertRowid));
|
|
||||||
console.log(`ℹ️ Berechtigung ${item.key} in Gruppe ${group.key} angelegt`);
|
|
||||||
} else {
|
|
||||||
const hasChanges =
|
|
||||||
existingItem.section_id !== sectionId ||
|
|
||||||
existingItem.group_id !== groupId ||
|
|
||||||
existingItem.label !== item.label ||
|
|
||||||
(existingItem.sort_order ?? itemSortOrder) !== itemSortOrder ||
|
|
||||||
existingItem.default_level !== (item.defaultLevel || 'none') ||
|
|
||||||
existingItem.available_levels !== availableLevels ||
|
|
||||||
Number(existingItem.is_required) !== isRequired;
|
|
||||||
updateItem.run(
|
|
||||||
sectionId,
|
|
||||||
groupId,
|
|
||||||
item.label,
|
|
||||||
itemSortOrder,
|
|
||||||
item.defaultLevel || 'none',
|
|
||||||
availableLevels,
|
|
||||||
isRequired,
|
|
||||||
existingItem.id
|
|
||||||
);
|
|
||||||
itemKeyToId.set(item.key, existingItem.id);
|
|
||||||
if (hasChanges) {
|
|
||||||
console.log(`ℹ️ Berechtigung ${item.key} in Gruppe ${group.key} aktualisiert`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
PERMISSION_BLUEPRINT.forEach((section) => {
|
// Cleanup obsolete entries
|
||||||
const sectionItems = Array.isArray(section.items) ? section.items : [];
|
if (itemKeys.size > 0) {
|
||||||
sectionItems.forEach((item) => {
|
const itemKeyJson = JSON.stringify(Array.from(itemKeys));
|
||||||
const permissionId = itemKeyToId.get(item.key);
|
deleteOrphanDependencies.run();
|
||||||
if (!permissionId) return;
|
deleteOrphanPermissionValues.run();
|
||||||
(item.dependencies || []).forEach((dependency) => {
|
deleteUnusedItems.run(itemKeyJson);
|
||||||
const dependsId = itemKeyToId.get(dependency.dependsOnKey);
|
}
|
||||||
if (!dependsId) return;
|
if (groupKeys.size > 0) {
|
||||||
const existing = selectDependency.get(permissionId, dependsId);
|
const groupKeyJson = JSON.stringify(Array.from(groupKeys));
|
||||||
const requiredLevel = dependency.requiredLevel ?? null;
|
deleteUnusedGroups.run(groupKeyJson);
|
||||||
if (!existing) {
|
}
|
||||||
insertDependency.run(permissionId, dependsId, requiredLevel);
|
|
||||||
console.log(
|
|
||||||
`ℹ️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} angelegt`
|
|
||||||
);
|
|
||||||
} else if (existing.required_level !== requiredLevel) {
|
|
||||||
updateDependency.run(requiredLevel, existing.id);
|
|
||||||
console.log(
|
|
||||||
`ℹ️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} aktualisiert`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
|
|
||||||
sectionGroups.forEach((group) => {
|
|
||||||
const groupItems = Array.isArray(group.items) ? group.items : [];
|
|
||||||
groupItems.forEach((item) => {
|
|
||||||
const permissionId = itemKeyToId.get(item.key);
|
|
||||||
if (!permissionId) return;
|
|
||||||
(item.dependencies || []).forEach((dependency) => {
|
|
||||||
const dependsId = itemKeyToId.get(dependency.dependsOnKey);
|
|
||||||
if (!dependsId) return;
|
|
||||||
const requiredLevel = dependency.requiredLevel ?? null;
|
|
||||||
const existing = selectDependency.get(permissionId, dependsId);
|
|
||||||
if (!existing) {
|
|
||||||
insertDependency.run(permissionId, dependsId, requiredLevel);
|
|
||||||
console.log(
|
|
||||||
`ℹ️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} angelegt`
|
|
||||||
);
|
|
||||||
} else if (existing.required_level !== requiredLevel) {
|
|
||||||
updateDependency.run(requiredLevel, existing.id);
|
|
||||||
console.log(
|
|
||||||
`ℹ️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} aktualisiert`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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 = () => {
|
export const ensureDatabaseSchema = () => {
|
||||||
@@ -762,7 +764,6 @@ export const ensureDatabaseSchema = () => {
|
|||||||
|
|
||||||
dropDeprecatedArtifacts();
|
dropDeprecatedArtifacts();
|
||||||
ensureTablesAndColumns(tableDefinitions);
|
ensureTablesAndColumns(tableDefinitions);
|
||||||
ensureServerAgentEntries();
|
|
||||||
ensureIndexes(indexDefinitions);
|
ensureIndexes(indexDefinitions);
|
||||||
ensurePermissionSeeds();
|
ensurePermissionSeeds();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+539
-176
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ const selectSections = db.prepare(`
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const selectGroups = db.prepare(`
|
const selectGroups = db.prepare(`
|
||||||
SELECT id, section_id, key, title, sort_order
|
SELECT id, section_id, parent_group_id, key, title, sort_order
|
||||||
FROM permission_groups
|
FROM permission_groups
|
||||||
ORDER BY section_id ASC, sort_order ASC, id ASC
|
ORDER BY section_id ASC, sort_order ASC, id ASC
|
||||||
`);
|
`);
|
||||||
@@ -28,6 +28,7 @@ const selectItems = db.prepare(`
|
|||||||
sort_order,
|
sort_order,
|
||||||
default_level,
|
default_level,
|
||||||
available_levels,
|
available_levels,
|
||||||
|
is_global_scope,
|
||||||
is_required
|
is_required
|
||||||
FROM permission_items
|
FROM permission_items
|
||||||
ORDER BY section_id ASC, COALESCE(group_id, 0) ASC, sort_order ASC, id ASC
|
ORDER BY section_id ASC, COALESCE(group_id, 0) ASC, sort_order ASC, id ASC
|
||||||
@@ -39,7 +40,7 @@ const selectDependencies = db.prepare(`
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const selectPermissionItemsForMap = db.prepare(`
|
const selectPermissionItemsForMap = db.prepare(`
|
||||||
SELECT id, key, available_levels, default_level
|
SELECT id, key, available_levels, default_level, is_global_scope
|
||||||
FROM permission_items
|
FROM permission_items
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -81,6 +82,8 @@ const getLevelPriority = (level) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let cachedPermissionItems = null;
|
let cachedPermissionItems = null;
|
||||||
|
let cachedServerScopedKeys = null;
|
||||||
|
let cachedScopeByKey = null;
|
||||||
|
|
||||||
const getPermissionItems = () => {
|
const getPermissionItems = () => {
|
||||||
if (!cachedPermissionItems) {
|
if (!cachedPermissionItems) {
|
||||||
@@ -91,6 +94,24 @@ const getPermissionItems = () => {
|
|||||||
|
|
||||||
const resetPermissionItemsCache = () => {
|
const resetPermissionItemsCache = () => {
|
||||||
cachedPermissionItems = null;
|
cachedPermissionItems = null;
|
||||||
|
cachedServerScopedKeys = null;
|
||||||
|
cachedScopeByKey = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScopeCache = () => {
|
||||||
|
if (!cachedScopeByKey || !cachedServerScopedKeys) {
|
||||||
|
const items = getPermissionItems();
|
||||||
|
cachedScopeByKey = new Map();
|
||||||
|
cachedServerScopedKeys = new Set();
|
||||||
|
items.forEach((item) => {
|
||||||
|
const isGlobal = item.is_global_scope !== 0;
|
||||||
|
cachedScopeByKey.set(item.key, { isGlobal });
|
||||||
|
if (!isGlobal) {
|
||||||
|
cachedServerScopedKeys.add(item.key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { scopeByKey: cachedScopeByKey, serverScopedKeys: cachedServerScopedKeys };
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getPermissionStructure() {
|
export function getPermissionStructure() {
|
||||||
@@ -109,10 +130,23 @@ export function getPermissionStructure() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const groupsBySectionId = new Map();
|
const groupsBySectionId = new Map();
|
||||||
groups.forEach((group) => {
|
const groupsByParentId = new Map();
|
||||||
const list = groupsBySectionId.get(group.section_id) || [];
|
const sortedGroups = [...groups].sort((a, b) => {
|
||||||
list.push(group);
|
const sortDiff = (a.sort_order ?? 0) - (b.sort_order ?? 0);
|
||||||
groupsBySectionId.set(group.section_id, list);
|
if (sortDiff !== 0) return sortDiff;
|
||||||
|
return a.id - b.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
sortedGroups.forEach((group) => {
|
||||||
|
if (group.parent_group_id) {
|
||||||
|
const list = groupsByParentId.get(group.parent_group_id) || [];
|
||||||
|
list.push(group);
|
||||||
|
groupsByParentId.set(group.parent_group_id, list);
|
||||||
|
} else {
|
||||||
|
const list = groupsBySectionId.get(group.section_id) || [];
|
||||||
|
list.push(group);
|
||||||
|
groupsBySectionId.set(group.section_id, list);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const itemsBySectionId = new Map();
|
const itemsBySectionId = new Map();
|
||||||
@@ -154,18 +188,26 @@ export function getPermissionStructure() {
|
|||||||
defaultLevel: item.default_level || 'none',
|
defaultLevel: item.default_level || 'none',
|
||||||
availableLevels,
|
availableLevels,
|
||||||
isRequired: Boolean(item.is_required),
|
isRequired: Boolean(item.is_required),
|
||||||
|
isGlobal: item.is_global_scope !== 0,
|
||||||
dependencies: formattedDependencies
|
dependencies: formattedDependencies
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildGroupTree = (group) => {
|
||||||
|
const children = groupsByParentId.get(group.id) || [];
|
||||||
|
return {
|
||||||
|
key: group.key,
|
||||||
|
title: group.title,
|
||||||
|
sortOrder: group.sort_order ?? 0,
|
||||||
|
items: (itemsByGroupId.get(group.id) || []).map(formatItem),
|
||||||
|
groups: children.map(buildGroupTree)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const formattedSections = sections.map((section) => {
|
const formattedSections = sections.map((section) => {
|
||||||
const sectionItems = (itemsBySectionId.get(section.id) || []).map(formatItem);
|
const sectionItems = (itemsBySectionId.get(section.id) || []).map(formatItem);
|
||||||
const sectionGroups = (groupsBySectionId.get(section.id) || []).map((group) => ({
|
const rootGroups = groupsBySectionId.get(section.id) || [];
|
||||||
key: group.key,
|
const sectionGroups = rootGroups.map(buildGroupTree);
|
||||||
title: group.title,
|
|
||||||
sortOrder: group.sort_order ?? 0,
|
|
||||||
items: (itemsByGroupId.get(group.id) || []).map(formatItem)
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: section.key,
|
key: section.key,
|
||||||
@@ -270,13 +312,24 @@ export function saveGroupPermissionValues(groupId, values = {}) {
|
|||||||
const serverEditLevel = getSubmittedLevel('maintenance-server-edit');
|
const serverEditLevel = getSubmittedLevel('maintenance-server-edit');
|
||||||
const serverDeleteLevel = getSubmittedLevel('maintenance-server-delete');
|
const serverDeleteLevel = getSubmittedLevel('maintenance-server-delete');
|
||||||
|
|
||||||
if (getLevelPriority(serverDeleteLevel) >= getLevelPriority('full') &&
|
// Enforce dependency: "server löschen" darf maximal so hoch sein wie manage/edit.
|
||||||
(getLevelPriority(serverManageLevel) < getLevelPriority('full') ||
|
const deletePriority = getLevelPriority(serverDeleteLevel);
|
||||||
getLevelPriority(serverEditLevel) < getLevelPriority('full'))) {
|
const allowedDeletePriority = Math.min(
|
||||||
const error = new Error('PERMISSION_DEPENDENCY_LEVEL');
|
getLevelPriority(serverManageLevel),
|
||||||
error.code = 'PERMISSION_DEPENDENCY_LEVEL';
|
getLevelPriority(serverEditLevel)
|
||||||
error.permissionKey = 'maintenance-server-delete';
|
);
|
||||||
throw error;
|
if (deletePriority > allowedDeletePriority) {
|
||||||
|
const allowedLevelEntry = Object.entries(LEVEL_PRIORITY).find(([, priority]) => priority === allowedDeletePriority);
|
||||||
|
const allowedLevel = allowedLevelEntry ? allowedLevelEntry[0] : 'none';
|
||||||
|
const deleteEntry = normalizedEntries.find((entry) => entry.item.key === 'maintenance-server-delete');
|
||||||
|
if (deleteEntry) {
|
||||||
|
deleteEntry.level = allowedLevel;
|
||||||
|
} else {
|
||||||
|
normalizedEntries.push({
|
||||||
|
item: itemMap.get('maintenance-server-delete'),
|
||||||
|
level: allowedLevel
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const runSave = db.transaction(() => {
|
const runSave = db.transaction(() => {
|
||||||
@@ -324,6 +377,32 @@ export function getSuperuserPermissionMap() {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getServerScopedPermissionKeys() {
|
||||||
|
const { serverScopedKeys } = getScopeCache();
|
||||||
|
return Array.from(serverScopedKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isServerScopedPermission(permissionKey) {
|
||||||
|
if (!permissionKey) return false;
|
||||||
|
const { serverScopedKeys, scopeByKey } = getScopeCache();
|
||||||
|
if (serverScopedKeys.has(permissionKey)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const scope = scopeByKey.get(permissionKey);
|
||||||
|
return scope ? scope.isGlobal === false : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyServerScopedOverride(basePermissions = {}, overridePermissions = {}) {
|
||||||
|
const merged = { ...basePermissions };
|
||||||
|
const { serverScopedKeys } = getScopeCache();
|
||||||
|
serverScopedKeys.forEach((key) => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(overridePermissions, key)) {
|
||||||
|
merged[key] = overridePermissions[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
export function getEffectivePermissionsForGroup(groupId) {
|
export function getEffectivePermissionsForGroup(groupId) {
|
||||||
const defaults = getDefaultPermissionMap();
|
const defaults = getDefaultPermissionMap();
|
||||||
const numericId = Number(groupId);
|
const numericId = Number(groupId);
|
||||||
|
|||||||
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
@@ -34,8 +34,8 @@
|
|||||||
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
||||||
|
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-027ca62d.js"></script>
|
<script type="module" crossorigin src="/assets/index-c3010de9.js"></script>
|
||||||
<link rel="stylesheet" href="/assets/index-4d48f088.css">
|
<link rel="stylesheet" href="/assets/index-c7ee792b.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+6
-61
@@ -14,17 +14,6 @@ const updateServer = db.prepare(`
|
|||||||
SET name = ?, url = ?, updated_at = CURRENT_TIMESTAMP
|
SET name = ?, url = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
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 = ?');
|
const deleteServerStmt = db.prepare('DELETE FROM servers WHERE id = ?');
|
||||||
|
|
||||||
const selectApiKeyByServerId = db.prepare('SELECT * FROM server_api_keys WHERE server_id = ?');
|
const selectApiKeyByServerId = db.prepare('SELECT * FROM server_api_keys WHERE server_id = ?');
|
||||||
@@ -44,6 +33,10 @@ const deleteApiKeyByServerId = db.prepare('DELETE FROM server_api_keys WHERE ser
|
|||||||
const countServersStmt = db.prepare('SELECT COUNT(*) as count FROM servers');
|
const countServersStmt = db.prepare('SELECT COUNT(*) as count FROM servers');
|
||||||
const selectFirstServerStmt = db.prepare('SELECT * FROM servers ORDER BY id ASC LIMIT 1');
|
const selectFirstServerStmt = db.prepare('SELECT * FROM servers ORDER BY id ASC LIMIT 1');
|
||||||
|
|
||||||
|
function listServers() {
|
||||||
|
return selectAllServers.all();
|
||||||
|
}
|
||||||
|
|
||||||
const API_KEY_SECRET = crypto.createHash('sha256')
|
const API_KEY_SECRET = crypto.createHash('sha256')
|
||||||
.update(process.env.PORTAINER_API_SECRET || process.env.PORTAINER_API_KEY || 'stackpulse-portainer-api-key')
|
.update(process.env.PORTAINER_API_SECRET || process.env.PORTAINER_API_KEY || 'stackpulse-portainer-api-key')
|
||||||
.digest();
|
.digest();
|
||||||
@@ -136,49 +129,6 @@ function ensureServer({ name, url }) {
|
|||||||
return created;
|
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) {
|
function getServerById(serverId) {
|
||||||
const id = Number(serverId);
|
const id = Number(serverId);
|
||||||
if (!Number.isFinite(id)) {
|
if (!Number.isFinite(id)) {
|
||||||
@@ -371,15 +321,12 @@ function ensureDefaultsFromEnv() {
|
|||||||
function getServerStatus(serverId) {
|
function getServerStatus(serverId) {
|
||||||
const server = getServerById(serverId);
|
const server = getServerById(serverId);
|
||||||
const apiKeyMeta = selectApiKeyByServerId.get(server.id) || null;
|
const apiKeyMeta = selectApiKeyByServerId.get(server.id) || null;
|
||||||
const agent = getAgentConfig(server.id, { autoCreate: true });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server,
|
server,
|
||||||
apiKey: {
|
apiKey: {
|
||||||
hasKey: Boolean(apiKeyMeta),
|
hasKey: Boolean(apiKeyMeta),
|
||||||
updatedAt: apiKeyMeta?.updated_at ?? null
|
updatedAt: apiKeyMeta?.updated_at ?? null
|
||||||
},
|
}
|
||||||
agent
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,10 +431,8 @@ export {
|
|||||||
ensureDefaultsFromEnv,
|
ensureDefaultsFromEnv,
|
||||||
ensureServer,
|
ensureServer,
|
||||||
getServerById,
|
getServerById,
|
||||||
|
listServers,
|
||||||
setServerApiKey,
|
setServerApiKey,
|
||||||
getAgentConfig,
|
|
||||||
setAgentConfig,
|
|
||||||
generateAgentToken,
|
|
||||||
getServerConnection,
|
getServerConnection,
|
||||||
updateServerDetails,
|
updateServerDetails,
|
||||||
getServerStatus,
|
getServerStatus,
|
||||||
|
|||||||
@@ -75,6 +75,48 @@ const insertMembershipForUser = db.prepare(`
|
|||||||
VALUES (?, ?)
|
VALUES (?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const selectServerByIdForAssignment = db.prepare('SELECT id, name, url FROM servers WHERE id = ?');
|
||||||
|
|
||||||
|
const selectAssignmentsByUser = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
usa.user_id,
|
||||||
|
usa.server_id,
|
||||||
|
usa.group_id,
|
||||||
|
usa.use_global_group,
|
||||||
|
usa.created_at,
|
||||||
|
usa.updated_at,
|
||||||
|
s.name AS server_name,
|
||||||
|
s.url AS server_url,
|
||||||
|
g.name AS group_name
|
||||||
|
FROM user_server_assignments usa
|
||||||
|
JOIN servers s ON s.id = usa.server_id
|
||||||
|
LEFT JOIN user_groups g ON g.id = usa.group_id
|
||||||
|
WHERE usa.user_id = ?
|
||||||
|
ORDER BY s.name ASC, usa.server_id ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const upsertUserServerAssignment = db.prepare(`
|
||||||
|
INSERT INTO user_server_assignments (
|
||||||
|
user_id,
|
||||||
|
server_id,
|
||||||
|
group_id,
|
||||||
|
use_global_group,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
ON CONFLICT(user_id, server_id) DO UPDATE SET
|
||||||
|
group_id = excluded.group_id,
|
||||||
|
use_global_group = excluded.use_global_group,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`);
|
||||||
|
|
||||||
|
const deleteUserServerAssignments = db.prepare(`
|
||||||
|
DELETE FROM user_server_assignments
|
||||||
|
WHERE user_id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
const selectSecurityPhraseByUsername = db.prepare(`
|
const selectSecurityPhraseByUsername = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
@@ -883,6 +925,176 @@ export function updateUserDetails(userId, { username, email, password, avatarCol
|
|||||||
return updatedUser;
|
return updatedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sanitizeAssignment = (row) => ({
|
||||||
|
userId: row.user_id,
|
||||||
|
serverId: row.server_id,
|
||||||
|
groupId: row.group_id ?? null,
|
||||||
|
groupName: row.group_name || null,
|
||||||
|
serverName: row.server_name || null,
|
||||||
|
serverUrl: row.server_url || null,
|
||||||
|
useGlobalGroup: Boolean(row.use_global_group),
|
||||||
|
createdAt: row.created_at || null,
|
||||||
|
updatedAt: row.updated_at || null
|
||||||
|
});
|
||||||
|
|
||||||
|
export function getUserServerAssignments(userId) {
|
||||||
|
const numericUserId = Number(userId);
|
||||||
|
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||||
|
const error = new Error('INVALID_USER_ID');
|
||||||
|
error.code = 'INVALID_USER_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = selectUserCredentialsById.get(numericUserId);
|
||||||
|
if (!existingUser) {
|
||||||
|
const error = new Error('USER_NOT_FOUND');
|
||||||
|
error.code = 'USER_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const superuserMembership = isUserInGroupStatement.get(numericUserId, SUPERUSER_GROUP_NAME);
|
||||||
|
if (superuserMembership?.has_membership) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = selectAssignmentsByUser.all(numericUserId);
|
||||||
|
return rows.map(sanitizeAssignment);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUserServerAssignments(userId, assignments = [], options = {}) {
|
||||||
|
const actor = options.actor || null;
|
||||||
|
const numericUserId = Number(userId);
|
||||||
|
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||||
|
const error = new Error('INVALID_USER_ID');
|
||||||
|
error.code = 'INVALID_USER_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRecord = selectUserCredentialsById.get(numericUserId);
|
||||||
|
if (!userRecord) {
|
||||||
|
const error = new Error('USER_NOT_FOUND');
|
||||||
|
error.code = 'USER_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const superuserMembership = isUserInGroupStatement.get(numericUserId, SUPERUSER_GROUP_NAME);
|
||||||
|
if (superuserMembership?.has_membership) {
|
||||||
|
const error = new Error('USER_SUPERUSER_PROTECTED');
|
||||||
|
error.code = 'USER_SUPERUSER_PROTECTED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedList = Array.isArray(assignments) ? assignments : [];
|
||||||
|
const missingServers = [];
|
||||||
|
const missingGroups = [];
|
||||||
|
const normalizedAssignments = new Map();
|
||||||
|
|
||||||
|
normalizedList.forEach((entry) => {
|
||||||
|
const rawServerId = entry?.serverId ?? entry?.server_id ?? entry?.id;
|
||||||
|
const serverId = Number(rawServerId);
|
||||||
|
if (!Number.isFinite(serverId) || serverId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverRow = selectServerByIdForAssignment.get(serverId);
|
||||||
|
if (!serverRow) {
|
||||||
|
missingServers.push(serverId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useGlobalGroup =
|
||||||
|
entry?.useGlobalGroup === true ||
|
||||||
|
entry?.use_global_group === 1 ||
|
||||||
|
entry?.use_global_group === true;
|
||||||
|
|
||||||
|
const rawGroupId = entry?.groupId ?? entry?.group_id;
|
||||||
|
const groupId = Number(rawGroupId);
|
||||||
|
const normalizedGroupId = Number.isFinite(groupId) && groupId > 0 ? groupId : null;
|
||||||
|
|
||||||
|
if (!useGlobalGroup && normalizedGroupId === null) {
|
||||||
|
missingGroups.push(serverId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedGroupId !== null) {
|
||||||
|
const groupRow = selectGroupById.get(normalizedGroupId);
|
||||||
|
if (!groupRow) {
|
||||||
|
missingGroups.push(serverId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedAssignments.set(serverId, {
|
||||||
|
serverId,
|
||||||
|
groupId: normalizedGroupId,
|
||||||
|
useGlobalGroup
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (missingServers.length > 0) {
|
||||||
|
const error = new Error('SERVER_NOT_FOUND');
|
||||||
|
error.code = 'SERVER_NOT_FOUND';
|
||||||
|
error.missingServerIds = missingServers;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingGroups.length > 0) {
|
||||||
|
const error = new Error('GROUP_NOT_FOUND');
|
||||||
|
error.code = 'GROUP_NOT_FOUND';
|
||||||
|
error.missingServerIds = missingGroups;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousAssignments = selectAssignmentsByUser.all(numericUserId);
|
||||||
|
|
||||||
|
const applyAssignments = db.transaction(() => {
|
||||||
|
deleteUserServerAssignments.run(numericUserId);
|
||||||
|
Array.from(normalizedAssignments.values()).forEach((assignment) => {
|
||||||
|
upsertUserServerAssignment.run(
|
||||||
|
numericUserId,
|
||||||
|
assignment.serverId,
|
||||||
|
assignment.groupId,
|
||||||
|
assignment.useGlobalGroup ? 1 : 0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
applyAssignments();
|
||||||
|
|
||||||
|
const updatedAssignments = selectAssignmentsByUser.all(numericUserId);
|
||||||
|
const sanitizedUpdated = updatedAssignments.map(sanitizeAssignment);
|
||||||
|
|
||||||
|
logEvent({
|
||||||
|
category: 'benutzer',
|
||||||
|
eventType: 'benutzer-server-zuordnung-aktualisiert',
|
||||||
|
action: 'aktualisieren',
|
||||||
|
status: 'erfolgreich',
|
||||||
|
entityType: 'benutzer',
|
||||||
|
entityId: String(numericUserId),
|
||||||
|
entityName: userRecord?.username ?? `ID ${numericUserId}`,
|
||||||
|
message: `Serverzuordnungen für Benutzer "${userRecord?.username ?? numericUserId}" aktualisiert`,
|
||||||
|
metadata: {
|
||||||
|
assignments: sanitizedUpdated.map((entry) => ({
|
||||||
|
serverId: entry.serverId,
|
||||||
|
serverName: entry.serverName,
|
||||||
|
useGlobalGroup: entry.useGlobalGroup,
|
||||||
|
groupId: entry.groupId,
|
||||||
|
groupName: entry.groupName
|
||||||
|
})),
|
||||||
|
previousAssignments: previousAssignments.map((entry) => ({
|
||||||
|
serverId: entry.server_id,
|
||||||
|
serverName: entry.server_name,
|
||||||
|
useGlobalGroup: Boolean(entry.use_global_group),
|
||||||
|
groupId: entry.group_id,
|
||||||
|
groupName: entry.group_name || null
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
...resolveActorFields(actor)
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitizedUpdated;
|
||||||
|
}
|
||||||
|
|
||||||
const deleteMembershipsStatement = db.prepare(`
|
const deleteMembershipsStatement = db.prepare(`
|
||||||
DELETE FROM user_group_memberships
|
DELETE FROM user_group_memberships
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
|
|||||||
@@ -8,13 +8,18 @@ StackPulse folgt einer klassischen Client/Server-Architektur, bei der das Backen
|
|||||||
│ (React) │ ◀───── Auth-Cookie + JSON ─ │ (Express) │ ◀─────────────── │ Business Ed. │
|
│ (React) │ ◀───── Auth-Cookie + JSON ─ │ (Express) │ ◀─────────────── │ Business Ed. │
|
||||||
└────────────┘ Redeploy Events └───────────────┘ (Axios Proxy) └────────────────┘
|
└────────────┘ Redeploy Events └───────────────┘ (Axios Proxy) └────────────────┘
|
||||||
│
|
│
|
||||||
|
mTLS (optional)│
|
||||||
|
▼
|
||||||
|
StackPulse Agent
|
||||||
|
│
|
||||||
▼
|
▼
|
||||||
SQLite Datenbank
|
SQLite Datenbank
|
||||||
```
|
```
|
||||||
|
|
||||||
## Hauptschichten
|
## Hauptschichten
|
||||||
- **Frontend** (Vite + Material Tailwind): stellt alle Views, Formulare, Tabellen und Echtzeit-Widgets bereit. Die `AuthProvider`-Komponente verwaltet Session, Permissions und Socket-Verbindungen.
|
- **Frontend** (Vite + Material Tailwind): stellt Views, Tabellen, Setup-Wizard und Echtzeit-Widgets bereit. `AuthProvider` verwaltet Session, Permissions und Socket-Verbindungen.
|
||||||
- **Backend** (Express + better-sqlite3): aggregiert Daten aus Portainer, persistiert Benutzer/Gruppen/Logs, verwaltet Sessions und stößt Redeploy- sowie Wartungsprozesse an.
|
- **Backend** (Express + better-sqlite3): aggregiert Daten aus Portainer, persistiert Benutzer/Gruppen/Logs, verwaltet Sessions, Redeploy-Queue und Maintenance/Agent-Funktionen.
|
||||||
|
- **Agent (optional)**: kleiner Node-Dienst, der lokal auf dem Portainer-Host läuft. Liefert Stack-/Portainer-Metadaten per REST, authentifiziert via `X-Agent-Token` und kann mTLS-Zertifikate aus dem Backend beziehen.
|
||||||
- **Persistenz** (SQLite): Datei `backend/data/stackpulse.db`, WAL-Modus aktiviert. Tabellen umfassen u. a. `users`, `groups`, `permissions`, `servers`, `server_api_keys`, `event_logs`, `settings` sowie Hilfstabellen für Tokens und Sicherheitspassphrasen.
|
- **Persistenz** (SQLite): Datei `backend/data/stackpulse.db`, WAL-Modus aktiviert. Tabellen umfassen u. a. `users`, `groups`, `permissions`, `servers`, `server_api_keys`, `event_logs`, `settings` sowie Hilfstabellen für Tokens und Sicherheitspassphrasen.
|
||||||
|
|
||||||
## Datenflüsse
|
## Datenflüsse
|
||||||
@@ -23,7 +28,7 @@ StackPulse folgt einer klassischen Client/Server-Architektur, bei der das Backen
|
|||||||
3. **Stack-Übersicht**: Frontend ruft `/api/stacks` auf. Das Backend proxyt `Portainer /api/endpoints/:id/docker/stacks`, ergänzt Caching, interne Metadaten und liefert Redeploy-Status.
|
3. **Stack-Übersicht**: Frontend ruft `/api/stacks` auf. Das Backend proxyt `Portainer /api/endpoints/:id/docker/stacks`, ergänzt Caching, interne Metadaten und liefert Redeploy-Status.
|
||||||
4. **Redeploy**: UI stößt `/api/stacks/:id/redeploy`, `/api/stacks/redeploy-selection` oder `/api/stacks/redeploy-all` an. Das Backend ruft Portainer-Endpoints, protokolliert Events und broadcastet Fortschritt via Socket.IO `redeployStatus`.
|
4. **Redeploy**: UI stößt `/api/stacks/:id/redeploy`, `/api/stacks/redeploy-selection` oder `/api/stacks/redeploy-all` an. Das Backend ruft Portainer-Endpoints, protokolliert Events und broadcastet Fortschritt via Socket.IO `redeployStatus`.
|
||||||
5. **Logging**: Jeder relevante Vorgang erzeugt Einträge in `event_logs` (siehe `logging/eventLogs.js`). Frontend filtert/paginiert über `/api/logs` und kann Exporte `/api/logs/export` herunterladen.
|
5. **Logging**: Jeder relevante Vorgang erzeugt Einträge in `event_logs` (siehe `logging/eventLogs.js`). Frontend filtert/paginiert über `/api/logs` und kann Exporte `/api/logs/export` herunterladen.
|
||||||
6. **Wartung**: `/api/maintenance/*` verwaltet Maintenance-Mode, SSH-Zugänge, Update-Skripte und serverseitige Settings (`db/settings.js`).
|
6. **Wartung**: `/api/maintenance/*` verwaltet Maintenance-Mode, SSH-Zugänge, Update-Skripte sowie weitere Wartungsfunktionen.
|
||||||
|
|
||||||
## Verzeichnis-Referenz
|
## Verzeichnis-Referenz
|
||||||
| Pfad | Inhalt |
|
| Pfad | Inhalt |
|
||||||
|
|||||||
+10
-9
@@ -11,10 +11,10 @@ Das Backend implementiert alle Anwendungsfälle über Express-Routen, Socket.IO
|
|||||||
| Modul | Pfad | Aufgabe |
|
| Modul | Pfad | Aufgabe |
|
||||||
|-------|------|---------|
|
|-------|------|---------|
|
||||||
| Auth/Superuser | `auth/superuser.js` | Registrieren/Verifizieren von Superusern, Login, Passwortvalidierung, Sicherheitsphrasen. |
|
| Auth/Superuser | `auth/superuser.js` | Registrieren/Verifizieren von Superusern, Login, Passwortvalidierung, Sicherheitsphrasen. |
|
||||||
| Users & Groups | `users/*`, `groups/*`, `permissions/*` | CRUD für Benutzer, Gruppen, Rechtezuweisung, Ableitung effektiver Berechtigungen. |
|
| Users & Groups | `users/*`, `groups/*`, `permissions/*` | CRUD für Benutzer, Gruppen, Rechtezuweisung, Ableitung effektiver Berechtigungen. Rechte-Labels z. B. `Bereich & Navigation`, `Benutzergruppen bearbeiten`, `Benutzer löschen`. |
|
||||||
| Setup | `setup/index.js` | Verwaltung registrierter Portainer-Server, sichere Ablage der API-Keys, Setup-Status. |
|
| Setup | `setup/index.js` | Verwaltung registrierter Portainer-Server, sichere Ablage der API-Keys, Setup-Status. |
|
||||||
| Logging | `logging/eventLogs.js` | Einheitliches Ereignislogging (Insert, Filter, Delete, Export). |
|
| Logging | `logging/eventLogs.js` | Einheitliches Ereignislogging (Insert, Filter, Delete, Export). |
|
||||||
| Maintenance | `maintenance/state.js` + helper in `index.js` | Wartungsmodus, SSH-Konfiguration, Update-Skripte und Ausführung. |
|
| Maintenance | `maintenance/state.js` + Helper in `index.js` | Wartungsmodus, SSH-Konfiguration, Update-Skripte und Ausführung. |
|
||||||
| Portainer Proxy | Funktionen in `index.js` | Kommunikation mit Portainer (`axiosInstance` + dynamische `baseURL`), Environment-Erkennung und Redeploy-Strecke. |
|
| Portainer Proxy | Funktionen in `index.js` | Kommunikation mit Portainer (`axiosInstance` + dynamische `baseURL`), Environment-Erkennung und Redeploy-Strecke. |
|
||||||
|
|
||||||
## API-Überblick (Auszug)
|
## API-Überblick (Auszug)
|
||||||
@@ -56,7 +56,7 @@ Das Backend implementiert alle Anwendungsfälle über Express-Routen, Socket.IO
|
|||||||
| GET | `/api/stacks` | Aggregierte Liste inkl. Filter, Self-Stack-Blockade und Redeploy-Status. |
|
| GET | `/api/stacks` | Aggregierte Liste inkl. Filter, Self-Stack-Blockade und Redeploy-Status. |
|
||||||
| PUT | `/api/stacks/:id/redeploy` | Redeploy eines einzelnen Stacks. |
|
| PUT | `/api/stacks/:id/redeploy` | Redeploy eines einzelnen Stacks. |
|
||||||
| PUT | `/api/stacks/redeploy-selection` | Redeploy einer Liste übermittelter `stackIds`. |
|
| PUT | `/api/stacks/redeploy-selection` | Redeploy einer Liste übermittelter `stackIds`. |
|
||||||
| PUT | `/api/stacks/redeploy-all` | Redeploy aller Stacks (Permission `stacks-redeploy-all`). |
|
| PUT | `/api/stacks/redeploy-all` | Redeploy aller Stacks (Recht `Redeploy Alle`). |
|
||||||
|
|
||||||
Das Backend veröffentlicht den Redeploy-Fortschritt parallel über Socket.IO (`redeployStatus` Event) und unterscheidet die Phasen `queued`, `started`, `success`, `error`, `info`.
|
Das Backend veröffentlicht den Redeploy-Fortschritt parallel über Socket.IO (`redeployStatus` Event) und unterscheidet die Phasen `queued`, `started`, `success`, `error`, `info`.
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ Das Backend veröffentlicht den Redeploy-Fortschritt parallel über Socket.IO (`
|
|||||||
|
|
||||||
## Datenbank & Schema
|
## Datenbank & Schema
|
||||||
- `ensureDatabaseSchema()` erzeugt Tabellen bei jedem Start, inklusive Default-Gruppen, Permissions, Settings, Server-Einträge und Trigger.
|
- `ensureDatabaseSchema()` erzeugt Tabellen bei jedem Start, inklusive Default-Gruppen, Permissions, Settings, Server-Einträge und Trigger.
|
||||||
- Permissions werden anhand der Blueprint-Datei `backend/db/dbs` eingelesen. Jede Permission definiert Key, Label, Level (`full`, `read`, `none`) und Abhängigkeiten.
|
- Permissions werden anhand der Blueprint-Datei `backend/db/dbs` eingelesen. Jede Permission definiert Key, Label (z. B. `Bereich & Navigation`, `Server bearbeiten`, `SSH/Update-Skript`, `Update durchführen`, `Doppelte Stacks`), Level (`full`, `read`, `none`) und Abhängigkeiten.
|
||||||
- Events werden in `event_logs` gespeichert. Relevante Felder:
|
- Events werden in `event_logs` gespeichert. Relevante Felder:
|
||||||
- `category`, `event_type`, `action`, `status`, `severity`
|
- `category`, `event_type`, `action`, `status`, `severity`
|
||||||
- `entity_type`, `entity_id`, `entity_name`
|
- `entity_type`, `entity_id`, `entity_name`
|
||||||
@@ -85,13 +85,14 @@ Das Backend veröffentlicht den Redeploy-Fortschritt parallel über Socket.IO (`
|
|||||||
- Filter-Parameter (`ids`, `categories`, `eventTypes`, `status`, `stackIds`, Zeitfenster) werden in `buildEventLogFilter` zentral verarbeitet.
|
- Filter-Parameter (`ids`, `categories`, `eventTypes`, `status`, `stackIds`, Zeitfenster) werden in `buildEventLogFilter` zentral verarbeitet.
|
||||||
|
|
||||||
## Berechtigungssystem
|
## Berechtigungssystem
|
||||||
- Jeder Benutzer gehört zu beliebig vielen Gruppen; die effektiven Rechte ergeben sich aus der höchsten Ausprägung pro Permission.
|
- Jeder Benutzer gehört zu beliebig vielen Gruppen; die effektiven Rechte ergeben sich aus der höchsten Ausprägung pro Permission (Labels wie `Bereich & Navigation`, `Server bearbeiten`, `SSH/Update-Skript`, `Update durchführen`, `Doppelte Stacks`).
|
||||||
- Superuser erhalten alle Berechtigungen und können nicht über das UI entzogen werden.
|
- Superuser erhalten alle Berechtigungen und können nicht über das UI entzogen werden.
|
||||||
- `PermissionGate` im Frontend spiegelt die gleichen Keys. Wichtigste Bereiche:
|
- `PermissionGate` im Frontend spiegelt die gleichen Keys. Wichtigste Bereiche:
|
||||||
- `stacks-*` (Redeploy)
|
- `Redeploy einzeln/Auswahl/Alle`
|
||||||
- `logs-*`
|
- `Bereich & Navigation (Logs)`, `Logs Exportieren`, `Logs löschen`
|
||||||
- `users-*` & `user-groups-*`
|
- `Bereich & Navigation (Benutzer)`, `Benutzer bearbeiten/löschen`, `Sicherheitsschlüssel`
|
||||||
- `maintenance-*` (inkl. Unterrechte `maintenance-server-*`, `maintenance-ssh-update`, `maintenance-update`)
|
- `Bereich & Navigation (Benutzergruppen)`, `Benutzergruppen bearbeiten/löschen`
|
||||||
|
- Wartung: `Bereich & Navigation (Wartung)`, `Server-Sektion`, `Server bearbeiten/löschen`, `SSH/Update-Skript`, `Update durchführen`, `Doppelte Stacks`
|
||||||
|
|
||||||
## Redeploy-Workflow
|
## Redeploy-Workflow
|
||||||
1. Eingehender Request wird mit `maintenanceGuard` geprüft (Maintenance-Modus blockiert optional Redeploys).
|
1. Eingehender Request wird mit `maintenanceGuard` geprüft (Maintenance-Modus blockiert optional Redeploys).
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Umgebungsvariablen
|
||||||
|
|
||||||
|
Diese Seite sammelt alle relevanten Umgebungsvariablen für StackPulse (Backend) und den optionalen StackPulse Agent. Werte ohne Default sind in der Regel optional, sofern sie nicht explizit als „erforderlich“ markiert sind.
|
||||||
|
|
||||||
|
## StackPulse Backend
|
||||||
|
| Variable | Default | Erforderlich | Beschreibung |
|
||||||
|
|----------|---------|--------------|--------------|
|
||||||
|
| `PORTAINER_URL` | – | Nein | Basis-URL zu Portainer. Kann im Setup-UI hinterlegt werden; ohne Wert schlägt Portainer-Zugriff fehl, bis ein Server gespeichert ist. |
|
||||||
|
| `PORTAINER_API_KEY` | – | Nein | API-Key für Portainer. Kann im Setup-UI gespeichert (verschlüsselt) werden; ohne Wert keine API-Aufrufe. |
|
||||||
|
| `PORTAINER_SERVER_NAME` | – | Nein | Anzeigename für den initialen Servereintrag (nur kosmetisch). |
|
||||||
|
| `PORTAINER_API_SECRET` | `stackpulse-portainer-api-key` | Nein | AES-Schlüssel für gespeicherte API-Keys. Stabil halten, sonst müssen Keys neu eingetragen werden. |
|
||||||
|
| `SUPERUSER_USERNAME` | – | Nein | Legt beim Start einen Superuser an, falls noch keiner existiert; alternativ im Setup-UI anlegen. |
|
||||||
|
| `SUPERUSER_EMAIL` | – | Nein | E-Mail des initialen Superusers. |
|
||||||
|
| `SUPERUSER_PASSWORD` | – | Nein | Passwort des initialen Superusers. |
|
||||||
|
| `SELF_STACK_ID` | – | Nein | ID des StackPulse-Stacks; verhindert Self-Redeploy im UI. |
|
||||||
|
| `AUTH_COOKIE_NAME` | `sp_auth_token` | Nein | Name des Auth-Cookies. |
|
||||||
|
| `AUTH_SESSION_TTL_MS` | `43200000` (12h) | Nein | Session-Lebensdauer in Millisekunden. |
|
||||||
|
| `AUTH_COOKIE_SECURE` | `false` | Nein | Setzt Cookie nur über HTTPS, wenn `true`. |
|
||||||
|
| `SECURITY_PHRASE_SECRET` | `stackpulse-security-phrase-secret` | Nein | Secret für verschlüsselte Sicherheitsphrasen; stabil halten, sonst Phrasen neu verteilen. |
|
||||||
|
| `PORTAINER_SSH_SECRET` | – (Fallback API-Key/Default) | Nein | Secret für verschlüsselte SSH-Passwörter; stabil halten, sonst Passwort neu setzen. |
|
||||||
|
| `AGENT_MTLS_KEY` | generiert | Nein | Optional: eigenes PEM-Key für internen mTLS-Server, falls nicht automatisch erzeugt. |
|
||||||
|
| `AGENT_MTLS_CERT` | generiert | Nein | Optional: eigenes PEM-Zertifikat für internen mTLS-Server. |
|
||||||
|
| `AGENT_MTLS_PORT` | `4441` | Nein | Port des internen mTLS-Endpunkts (Agent). |
|
||||||
|
| `DISPLAY` | `:9999` | Nein | Wird an Child-Prozesse (Update-Skript) weitergereicht. |
|
||||||
|
|
||||||
|
## StackPulse Agent
|
||||||
|
| Variable | Default | Erforderlich | Beschreibung |
|
||||||
|
|----------|---------|--------------|--------------|
|
||||||
|
| `AGENT_PORT` | `7070` | Nein | Listenport des Agents (HTTPS mit mTLS). |
|
||||||
|
| `AGENT_TOKEN` | – | Ja | Shared Secret für Auth (`X-Agent-Token`) bei allen Agent-Endpoints. |
|
||||||
|
| `DOCKER_SOCKET` | `/var/run/docker.sock` | Nein | Pfad zum Docker-Socket; nötig für Stack-/Image-Infos. |
|
||||||
|
| `CONTROL_PLANE_URL` | – | Ja (für Bootstrap/Restore) | Basis-URL des StackPulse-Backends für Zertifikatsabruf ohne mTLS (z. B. `https://stackpulse:4001`). |
|
||||||
|
| `CONTROL_PLANE_MTLS_URL` | – | Nein (empfohlen) | Bevorzugte mTLS-URL des Backends (z. B. `https://stackpulse:4441`). Wird für alle Agent→Backend-Calls genutzt, sobald Zertifikate vorliegen; fehlt sie, wird aus `CONTROL_PLANE_URL` + `AGENT_MTLS_PORT` abgeleitet. |
|
||||||
|
| `AGENT_BOOTSTRAP_TOKEN` | – | Nein (für Erstbootstrap) | Einmal-Token aus dem Wartungsbereich, um mTLS-Zertifikate zu ziehen. Pflicht, wenn keine Zertifikate vorliegen und das Backend noch kein Material für den Agent gespeichert hat. |
|
||||||
|
| `AGENT_MTLS_PORT` | `4441` | Nein | Fallback-Port, falls `CONTROL_PLANE_MTLS_URL` keinen Port enthält. |
|
||||||
|
| `AGENT_SERVER_KEY` / `AGENT_SERVER_CERT` | – | Nein | PEM-Material für den Agent-Server; nur nötig bei eigenen Zertifikaten statt Backend-CA. |
|
||||||
|
| `AGENT_CLIENT_KEY` / `AGENT_CLIENT_CERT` / `AGENT_CA_CERT` | – | Nein | PEM-Material für Client/CA; nur bei eigener PKI nötig. |
|
||||||
|
| `REGISTRY_TOKEN` | – | Nein | Bearer-Token für Registry-Abfragen (Digest-Vergleich). |
|
||||||
|
| `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` | – | Nein | Basic-Auth-Creds für Registries. |
|
||||||
|
| `GITHUB_USERNAME` / `GITHUB_TOKEN` / `GITHUB_PAT` | – | Nein | Alternative Credential-Quellen für GitHub Packages. |
|
||||||
|
|
||||||
|
|
||||||
|
Agent-Betrieb (wähle eine Variante):
|
||||||
|
|
||||||
|
- **Dynamisch über Backend (empfohlen)**
|
||||||
|
- Pflicht: `AGENT_TOKEN` + `CONTROL_PLANE_URL` (optional `CONTROL_PLANE_MTLS_URL`).
|
||||||
|
- Falls keine Zertifikate existieren: einmaliges `AGENT_BOOTSTRAP_TOKEN` setzen.
|
||||||
|
- Der Agent zieht Zertifikate vom Backend, schaltet auf mTLS um und braucht danach kein Bootstrap-Token mehr.
|
||||||
|
- **Statisch per ENV**
|
||||||
|
- Liefere PEMs: `AGENT_SERVER_KEY`/`AGENT_SERVER_CERT` + `AGENT_CLIENT_KEY`/`AGENT_CLIENT_CERT`/`AGENT_CA_CERT`.
|
||||||
|
- Dann kein `CONTROL_PLANE_URL`/Bootstrap nötig, da mTLS-Material bereits vorhanden ist.
|
||||||
|
- `AGENT_TOKEN` bleibt erforderlich.
|
||||||
|
|
||||||
|
Hinweise:
|
||||||
|
|
||||||
|
- Server-URL und API-Key kannst du im Setup-UI pflegen; Umgebungsvariablen setzen nur den Initialzustand.
|
||||||
|
- Secrets (`SECURITY_PHRASE_SECRET`, `PORTAINER_SSH_SECRET`, `PORTAINER_API_SECRET`) in Produktion einmalig setzen und stabil halten – sonst werden bestehende verschlüsselte Werte unlesbar.
|
||||||
|
|
||||||
|
- `CONTROL_PLANE_MTLS_URL`: bevorzugte mTLS-Zieladresse. Fehlt sie, leitet der Agent sie aus `CONTROL_PLANE_URL` + `AGENT_MTLS_PORT` ab und nutzt sie für alle Aufrufe, sobald Zertifikate vorliegen.
|
||||||
+44
-68
@@ -1,80 +1,61 @@
|
|||||||
# Getting Started
|
# Getting Started
|
||||||
|
|
||||||
Dieser Leitfaden hilft dir, StackPulse lokal oder als Container aufzusetzen und die ersten Schritte im UI abzuschließen.
|
Dieser Leitfaden beschreibt ausschließlich den Docker-Deploy über die bereitgestellten GHCR-Images und die anschließende Einrichtung im UI.
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
- Node.js **>= 20** und npm für lokale Entwicklung
|
- Docker & Docker Compose
|
||||||
- Docker & Docker Compose (optional aber empfohlen für Deployment)
|
- Portainer-Instanz (Business Edition empfohlen; bei Community/Edge zusätzlich Agent einsetzen)
|
||||||
- Zugriff auf eine **Portainer Business Edition** samt API-Key
|
|
||||||
- Shell-Zugang zum Host, auf dem StackPulse laufen soll
|
|
||||||
|
|
||||||
## Repository & Abhängigkeiten
|
## Zentrale Umgebungsvariablen (Kurzfassung)
|
||||||
```bash
|
| Variable | Erforderlich | Beschreibung |
|
||||||
# Repository klonen
|
|----------|--------------|--------------|
|
||||||
# git clone <repo-url>
|
| `PORTAINER_URL` | Nein | Basis-URL zu Portainer; kann im Setup-UI hinterlegt werden, falls nicht gesetzt. |
|
||||||
cd stackpulse
|
| `PORTAINER_API_KEY` | Nein | API-Key; kann im Setup gespeichert werden (wird verschlüsselt). |
|
||||||
|
| `PORTAINER_SERVER_NAME` | Nein | Anzeige-Name für den initialen Servereintrag. |
|
||||||
|
| `PORTAINER_API_SECRET` | Nein | AES-Schlüssel für gespeicherte API-Keys; setze stabilen Wert, sonst muss der Key neu eingegeben werden. |
|
||||||
|
| `SUPERUSER_USERNAME`, `SUPERUSER_EMAIL`, `SUPERUSER_PASSWORD` | Nein | Legt automatisch einen Superuser an, falls noch keiner existiert; sonst im Setup-UI anlegen. |
|
||||||
|
| `SELF_STACK_ID` | Nein | ID des StackPulse-Stacks; schützt vor Self-Redeploy. |
|
||||||
|
| `AUTH_COOKIE_NAME`, `AUTH_SESSION_TTL_MS`, `AUTH_COOKIE_SECURE` | Nein | Session/Cookie-Tuning (Name, Dauer, Secure-Flag). |
|
||||||
|
| `SECURITY_PHRASE_SECRET` | Nein | Secret für verschlüsselte Sicherheitsphrasen; stabil halten, sonst Phrasen neu verteilen. |
|
||||||
|
| `PORTAINER_SSH_SECRET` | Nein | Secret für verschlüsselte SSH-Passwörter; stabil halten, sonst Passwort neu setzen. |
|
||||||
|
| `DISPLAY` | Nein | Wird an Update-Childprozesse weitergereicht (Default `:9999`). |
|
||||||
|
|
||||||
# Skript ausführbar machen
|
Weitere Variablen (Agent, Registry, mTLS) findest du im Detail unter [Umgebungsvariablen](environment.md). Einstellungen wie Server-URL/API-Key, SSH-Config oder Update-Skript kannst du alternativ im UI (Setup/Wartung) verwalten.
|
||||||
chmod +x scripts/start-dev.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Lokale Dev-Umgebung
|
**Hinweis zu Secrets:**
|
||||||
Das Skript `scripts/start-dev.sh` richtet Backend und Frontend automatisch ein:
|
- `SECURITY_PHRASE_SECRET` und `PORTAINER_SSH_SECRET` steuern die AES-256-GCM-Verschlüsselung von Sicherheitsphrasen bzw. hinterlegten SSH-Passwörtern.
|
||||||
|
- Wenn sie nicht gesetzt sind, nutzt StackPulse fixe Fallbacks (`stackpulse-security-phrase-secret` bzw. `stackpulse-portainer-ssh-secret` / API-Key). Setze in Produktion eigene, stabile Werte – ein späterer Secret-Wechsel macht zuvor gespeicherte Phrasen/Passwörter unlesbar.
|
||||||
1. Backend: `npm install`, `npm run migrate` (erstellt/aktualisiert `backend/data/stackpulse.db`), `npm start`
|
- Eine vollständige Übersicht aller Variablen (inkl. Agent) findest du unter „Technik → Umgebungsvariablen“.
|
||||||
2. Frontend: `npm install`, `npm run dev` (Port 5173) und ein einmaliges `npm run build`, um statische Assets ins Backend zu spiegeln
|
|
||||||
|
|
||||||
Nach erfolgreichem Start:
|
|
||||||
- Frontend dev server: http://localhost:5173
|
|
||||||
- Backend API & statische Assets: http://localhost:4001
|
|
||||||
|
|
||||||
Zum Stoppen STRG+C verwenden.
|
|
||||||
|
|
||||||
## Manuelles Starten
|
|
||||||
```bash
|
|
||||||
# Backend
|
|
||||||
cd backend
|
|
||||||
npm install
|
|
||||||
npm run migrate
|
|
||||||
PORTAINER_URL=https://portainer.local npm start
|
|
||||||
|
|
||||||
# Frontend in zweiter Shell
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
Das Backend liest Umgebungsvariablen beim Start. Alternativ kannst du eine `.env` neben `backend/index.js` anlegen.
|
|
||||||
|
|
||||||
## Zentrale Umgebungsvariablen
|
|
||||||
| Variable | Beschreibung |
|
|
||||||
|----------|--------------|
|
|
||||||
| `PORTAINER_URL` | Basis-URL zu deiner Portainer-Instanz (inkl. Protokoll). Kann später im Setup-UI überschrieben werden. |
|
|
||||||
| `PORTAINER_API_KEY` | API-Key für Portainer. Wird in der DB verschlüsselt abgelegt, sobald du ihn im Setup speicherst. |
|
|
||||||
| `PORTAINER_SERVER_NAME` | Optionaler Anzeigename für den Server während des Setups. |
|
|
||||||
| `PORTAINER_API_SECRET` | Überschreibt den Schlüssel, der zur Verschlüsselung des API-Keys verwendet wird. |
|
|
||||||
| `SUPERUSER_USERNAME`, `SUPERUSER_EMAIL`, `SUPERUSER_PASSWORD` | Legt einen Superuser automatisch beim Start an (falls nicht vorhanden). |
|
|
||||||
| `SELF_STACK_ID` | Stack-ID von StackPulse selbst. Wird genutzt, um eigene Redeploys zu blockieren. |
|
|
||||||
| `AUTH_COOKIE_NAME`, `AUTH_SESSION_TTL_MS`, `AUTH_COOKIE_SECURE` | Feintuning für die Session-Verwaltung der API. |
|
|
||||||
| `SECURITY_PHRASE_SECRET` | Seed für die Generierung kryptografischer Sicherheitsphrasen. |
|
|
||||||
| `PORTAINER_SSH_SECRET` | Secret für SSH-Passwortverschlüsselung (Wartungs-Update). |
|
|
||||||
| `DISPLAY` | Wird an Kindprozesse weitergereicht, wenn Portainer-Updates ausgelöst werden müssen (Default `:9999`). |
|
|
||||||
|
|
||||||
Weitere Einstellungen (SSH-Config, Update-Skripte) verwaltest du später direkt im Maintenance-Bereich des UI.
|
|
||||||
|
|
||||||
## Ersteinrichtung im UI
|
## Ersteinrichtung im UI
|
||||||
1. Öffne das Frontend (`/setup` führt dich automatisch durch den Wizard).
|
1. Öffne das Frontend.
|
||||||
2. Lege den Portainer-Server und den zugehörigen API-Key an – StackPulse testet die Verbindung per `/api/setup/test-portainer`.
|
2. Lege den Portainer-Server und den zugehörigen API-Key an (der Wizard führt durch den Test).
|
||||||
3. Registriere einen Superuser oder verwende den per Umgebungsvariablen automatisch angelegten Account.
|
3. Registriere einen Superuser oder verwende den per Umgebungsvariablen automatisch angelegten Account.
|
||||||
4. Nach erfolgreicher Einrichtung kannst du dich anmelden und erhältst Zugriff auf Dashboard, Logs, Benutzer & Wartung.
|
4. Nach erfolgreicher Einrichtung kannst du dich anmelden und erhältst Zugriff auf Dashboard, Logs, Benutzer & Wartung.
|
||||||
|
|
||||||
## Docker-Deployment
|
## Docker Run (Single Container)
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-p 4001:4001 \
|
||||||
|
-e PORTAINER_URL=https://portainer.example.com \
|
||||||
|
-e PORTAINER_API_KEY=xxxx \
|
||||||
|
-e SUPERUSER_USERNAME=admin \
|
||||||
|
-e SUPERUSER_EMAIL=admin@example.com \
|
||||||
|
-e SUPERUSER_PASSWORD=changeme \
|
||||||
|
-e SELF_STACK_ID=123 \
|
||||||
|
-e SECURITY_PHRASE_SECRET=$(openssl rand -hex 32) \
|
||||||
|
-e PORTAINER_SSH_SECRET=$(openssl rand -hex 32) \
|
||||||
|
-v stackpulse_data:/app/backend/data \
|
||||||
|
ghcr.io/mboehmlaender/stackpulse:latest
|
||||||
|
```
|
||||||
|
Das Volume `stackpulse_data` persistiert die SQLite-Datenbank. Passen Sie Secrets und Ports an Ihre Umgebung an; weitere Variablen siehe Kapitel „Umgebungsvariablen“.
|
||||||
|
|
||||||
|
## Docker-Deployment (Compose)
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yml (Auszug)
|
# docker-compose.yml (Auszug)
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
image: ghcr.io/mboehmlaender/stackpulse:latest
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
ports:
|
ports:
|
||||||
- "4001:4001"
|
- "4001:4001"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -91,13 +72,8 @@ volumes:
|
|||||||
stackpulse_data:
|
stackpulse_data:
|
||||||
```
|
```
|
||||||
|
|
||||||
1. `docker compose up -d --build`
|
1. `docker compose up -d`
|
||||||
2. Das Frontend ist anschließend unter Port 4001 (via Backend `public/`) erreichbar.
|
2. Das Frontend ist anschließend unter Port 4001 erreichbar.
|
||||||
3. Datenbank & Einstellungen liegen im benannten Volume `stackpulse_data`.
|
3. Datenbank & Einstellungen liegen im benannten Volume `stackpulse_data`.
|
||||||
|
|
||||||
## Datenbank & Migration
|
|
||||||
- Datei: `backend/data/stackpulse.db`
|
|
||||||
- Migration: `npm run migrate` (führt `backend/db/ensure.js` aus und erzeugt Schema, Rechte, Defaults)
|
|
||||||
- Backup: Datei oder Volume regelmäßig sichern; enthält Benutzer, Logs und gespeicherte Secrets.
|
|
||||||
|
|
||||||
Damit bist du bereit für die tieferen Kapitel zu Architektur sowie Backend- und Frontend-Details.
|
Damit bist du bereit für die tieferen Kapitel zu Architektur sowie Backend- und Frontend-Details.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ StackPulse ist eine eigenständige Web-Anwendung, die die Portainer Business Edi
|
|||||||
- **Auditierbare Historie** durch strukturierte Event-Logs mit Export-, Lösch- und Filterfunktionen
|
- **Auditierbare Historie** durch strukturierte Event-Logs mit Export-, Lösch- und Filterfunktionen
|
||||||
- **Feingranulare Rechteverwaltung** mit Gruppenrechten, Superuser-Handling und Sicherheitsphrasen
|
- **Feingranulare Rechteverwaltung** mit Gruppenrechten, Superuser-Handling und Sicherheitsphrasen
|
||||||
- **Wartungsautomatisierung** (SSH Update-Skripte, Maintenance-Mode) ohne direkten Portainer-Zugang
|
- **Wartungsautomatisierung** (SSH Update-Skripte, Maintenance-Mode) ohne direkten Portainer-Zugang
|
||||||
|
- **Agent-Integration** (mTLS) für Portainer Community/Edge, um Stacks & Versionen auch ohne Business-API auszulesen
|
||||||
- **Bereitstellung als Docker-Image** sowie als lokale Dev-Umgebung via `scripts/start-dev.sh`
|
- **Bereitstellung als Docker-Image** sowie als lokale Dev-Umgebung via `scripts/start-dev.sh`
|
||||||
|
|
||||||
## Technische Eckdaten
|
## Technische Eckdaten
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Lokale Entwicklung & Migration
|
||||||
|
|
||||||
|
Dieser Abschnitt richtet sich an Contributor:innen, die StackPulse lokal bauen oder erweitern möchten. Für produktive Deployments nutze die GHCR-Images wie im Getting Started beschrieben.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
- Node.js **>= 20** und npm
|
||||||
|
- Shell-Zugang zum Host
|
||||||
|
- Docker (optional) für lokale Tests
|
||||||
|
|
||||||
|
## Repository vorbereiten
|
||||||
|
```bash
|
||||||
|
# Repository klonen
|
||||||
|
# git clone <repo-url>
|
||||||
|
cd stackpulse
|
||||||
|
|
||||||
|
# Startskript ausführbar machen
|
||||||
|
chmod +x scripts/start-dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schnellstart (Dev-Skript)
|
||||||
|
Das Skript `scripts/start-dev.sh` richtet Backend und Frontend ein und startet beide im Dev-Modus:
|
||||||
|
1. Backend: `npm install`, `npm run migrate`, `npm start`
|
||||||
|
2. Frontend: `npm install`, `npm run dev` (Port 5173) und einmalig `npm run build` für statische Assets
|
||||||
|
|
||||||
|
Nach dem Start:
|
||||||
|
- Frontend Dev Server: http://localhost:5173
|
||||||
|
- Backend API & statische Assets: http://localhost:4001
|
||||||
|
|
||||||
|
Stoppen mit `STRG+C` in der Shell, in der das Skript läuft.
|
||||||
|
|
||||||
|
## Manuelles Starten
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run migrate
|
||||||
|
PORTAINER_URL=https://portainer.local npm start
|
||||||
|
|
||||||
|
# Frontend (zweite Shell)
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
Das Backend liest Umgebungsvariablen beim Start. Alternativ kannst du eine `.env` neben `backend/index.js` anlegen.
|
||||||
|
|
||||||
|
## Datenbank & Migration
|
||||||
|
- Datei: `backend/data/stackpulse.db` (SQLite, WAL aktiviert)
|
||||||
|
- Migration: `npm run migrate` (führt `backend/db/ensure.js` aus, erzeugt/aktualisiert Schema & Defaults)
|
||||||
|
- Backup: Datei oder Volume regelmäßig sichern; enthält Benutzer, Logs und gespeicherte Secrets
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
- Die lokalen Builds sind für Entwicklung gedacht. Für Deployments verwende `ghcr.io/mboehmlaender/stackpulse`.
|
||||||
|
- Einige Einstellungen (z. B. Server-URL, API-Key, SSH-Config) kannst du im UI setzen; für lokale Tests reichen die ENV-Defaults und das Setup-UI.
|
||||||
+6
-2
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
Dieser Abschnitt deckt typische Aufgaben für Betriebsteams ab – von Backups über Wartungsmodus bis zu Störungssuche.
|
Dieser Abschnitt deckt typische Aufgaben für Betriebsteams ab – von Backups über Wartungsmodus bis zu Störungssuche.
|
||||||
|
|
||||||
|
**Relevante Rechte**
|
||||||
|
- `Superuser löschen`
|
||||||
|
- `Bereich & Navigation (Wartung)`
|
||||||
|
|
||||||
## Laufender Betrieb
|
## Laufender Betrieb
|
||||||
- **Services überwachen**: StackPulse stellt nur einen Prozess bereit (Node.js/Express). Nutze Systemd/Docker Healthchecks, indem du regelmäßig `/api/auth/session` (für Auth) oder `/api/maintenance/update-status` (für Backend + DB) abfragst.
|
- **Services überwachen**: StackPulse stellt nur einen Prozess bereit (Node.js/Express). Nutze Systemd/Docker Healthchecks, indem du regelmäßig `/api/auth/session` (für Auth) oder `/api/maintenance/update-status` (für Backend + DB) abfragst.
|
||||||
- **Ports**: Standardmäßig lauscht nur Port 4001 (HTTP + Socket.IO). Hinter einem Reverse Proxy sollte TLS terminiert werden.
|
- **Ports**: Standardmäßig lauscht nur Port 4001 (HTTP + Socket.IO). Hinter einem Reverse Proxy sollte TLS terminiert werden.
|
||||||
@@ -13,12 +17,12 @@ Dieser Abschnitt deckt typische Aufgaben für Betriebsteams ab – von Backups
|
|||||||
3. **Restore**: Ersetze die Datei im Volume und starte die App neu. Beim ersten Start führt `ensureDatabaseSchema` automatisch eventuelle Nachmigrationen aus.
|
3. **Restore**: Ersetze die Datei im Volume und starte die App neu. Beim ersten Start führt `ensureDatabaseSchema` automatisch eventuelle Nachmigrationen aus.
|
||||||
|
|
||||||
## Benutzer- & Rechteverwaltung
|
## Benutzer- & Rechteverwaltung
|
||||||
- Superuser können über `/api/auth/superuser/*` oder im UI registriert werden. Entfernen ist nur mit `maintenance-superuser-delete` möglich.
|
- Superuser können über `/api/auth/superuser/*` oder im UI registriert werden. Entfernen ist nur mit `Superuser löschen` möglich.
|
||||||
- Benutzerkonten lassen sich temporär deaktivieren (`/api/users/:id/active`). Die Historie bleibt erhalten.
|
- Benutzerkonten lassen sich temporär deaktivieren (`/api/users/:id/active`). Die Historie bleibt erhalten.
|
||||||
- Sicherheitsphrasen sind notwendig für Passwort-Reset. Admins können neue Phrasen erzeugen (`/api/users/:id/security-phrase/renew`), müssen diese aber dem Benutzer sicher zustellen.
|
- Sicherheitsphrasen sind notwendig für Passwort-Reset. Admins können neue Phrasen erzeugen (`/api/users/:id/security-phrase/renew`), müssen diese aber dem Benutzer sicher zustellen.
|
||||||
|
|
||||||
## Wartungsmodus & Updates
|
## Wartungsmodus & Updates
|
||||||
- Maintenance Mode aktivieren: `curl -X POST http://host:4001/api/maintenance/mode -H 'Content-Type: application/json' -d '{"active":true,"message":"Cluster Upgrade"}'` (Permission `maintenance-access`). Das UI informiert alle Nutzer und blockiert Redeploys.
|
- Maintenance Mode aktivieren: `curl -X POST http://host:4001/api/maintenance/mode -H 'Content-Type: application/json' -d '{"active":true,"message":"Cluster Upgrade"}'` (Recht `Bereich & Navigation (Wartung)`). Das UI informiert alle Nutzer und blockiert Redeploys.
|
||||||
- SSH-/Update-Setup: Hinterlege Host, Port, Benutzer, Passwort (optional) und zusätzliche SSH-Argumente. Passwörter werden verschlüsselt gespeichert (`PORTAINER_SSH_SECRET`).
|
- SSH-/Update-Setup: Hinterlege Host, Port, Benutzer, Passwort (optional) und zusätzliche SSH-Argumente. Passwörter werden verschlüsselt gespeichert (`PORTAINER_SSH_SECRET`).
|
||||||
- Update-Skript: Das Default-Skript (`DEFAULT_PORTAINER_UPDATE_SCRIPT`) führt `docker stop`, `docker run` etc. aus. Du kannst im UI oder via API ein eigenes Skript setzen – wird erst aktualisiert, wenn kein Update läuft.
|
- Update-Skript: Das Default-Skript (`DEFAULT_PORTAINER_UPDATE_SCRIPT`) führt `docker stop`, `docker run` etc. aus. Du kannst im UI oder via API ein eigenes Skript setzen – wird erst aktualisiert, wenn kein Update läuft.
|
||||||
- Update anstoßen: `POST /api/maintenance/portainer-update` triggert die SSH-Kommandos. Status & Logausgaben können über `/api/maintenance/update-status` überwacht werden.
|
- Update anstoßen: `POST /api/maintenance/portainer-update` triggert die SSH-Kommandos. Status & Logausgaben können über `/api/maintenance/update-status` überwacht werden.
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
# Benutzergruppen & Rechte
|
# Benutzergruppen & Rechte
|
||||||
|
|
||||||
Dieser Bereich verwaltet Rollenmodelle und bestimmt, welche Aktionen Benutzer:innen durchführen dürfen. Du benötigst `user-groups-access` zum Lesen sowie `user-groups-edit`/`user-groups-delete` für Änderungen.
|
Dieser Bereich verwaltet Rollenmodelle und bestimmt, welche Aktionen Benutzer:innen durchführen dürfen.
|
||||||
|
|
||||||
|
**Relevante Rechte**
|
||||||
|
- `Bereich & Navigation (Benutzergruppen)`
|
||||||
|
- `Benutzergruppen bearbeiten`
|
||||||
|
- `Benutzergruppen löschen`
|
||||||
|
|
||||||
## Gruppen anlegen
|
## Gruppen anlegen
|
||||||
1. Button **Gruppe erstellen** wählen.
|
1. Button **Gruppe erstellen** wählen.
|
||||||
@@ -23,7 +28,7 @@ Der Rechte-Dialog zeigt die gesamte Permissionstruktur in Kategorien (Stacks, Lo
|
|||||||
- **none** – kein Zugriff
|
- **none** – kein Zugriff
|
||||||
- **read** – Zugriff auf Ansicht/Lesen
|
- **read** – Zugriff auf Ansicht/Lesen
|
||||||
- **full** – volle Bearbeitungsrechte
|
- **full** – volle Bearbeitungsrechte
|
||||||
3. Abhängigkeiten werden automatisch hervorgehoben. Beispiel: Um `logs-delete` nutzen zu dürfen, muss `logs-access` mindestens `full` sein.
|
3. Abhängigkeiten werden automatisch hervorgehoben. Beispiel: Um `Logs löschen` nutzen zu dürfen, muss `Bereich & Navigation (Logs)` mindestens `Vollzugriff` sein.
|
||||||
4. Speichern bestätigt die Anpassungen. StackPulse aktualisiert sofort die effektiven Rechte aller Benutzer in dieser Gruppe.
|
4. Speichern bestätigt die Anpassungen. StackPulse aktualisiert sofort die effektiven Rechte aller Benutzer in dieser Gruppe.
|
||||||
|
|
||||||
## Benutzer zu Gruppen hinzufügen
|
## Benutzer zu Gruppen hinzufügen
|
||||||
@@ -36,6 +41,6 @@ Der Rechte-Dialog zeigt die gesamte Permissionstruktur in Kategorien (Stacks, Lo
|
|||||||
- Löschen entfernt keine Benutzer, aber deren effektive Rechte können sich dadurch verringern.
|
- Löschen entfernt keine Benutzer, aber deren effektive Rechte können sich dadurch verringern.
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
- Erstelle zunächst eine Basisgruppe (z. B. „Viewer“) mit `read`-Rechten für Stacks und Logs.
|
- Erstelle zunächst eine Basisgruppe (z. B. „Viewer“) mit Leserechten für Stacks und Logs.
|
||||||
- Verwende separate Gruppen für sensible Aktionen wie Wartung oder Superuser-Verwaltung.
|
- Verwende separate Gruppen für sensible Aktionen wie Wartung oder Superuser-Verwaltung.
|
||||||
- Nutze sprechende Beschreibungen, damit Kolleg:innen sofort erkennen, wofür eine Gruppe gedacht ist.
|
- Nutze sprechende Beschreibungen, damit Kolleg:innen sofort erkennen, wofür eine Gruppe gedacht ist.
|
||||||
|
|||||||
@@ -1,43 +1,62 @@
|
|||||||
# Wartungsbereich
|
# Wartungsbereich
|
||||||
|
|
||||||
Der Wartungsbereich bündelt alle Funktionen rund um Maintenance-Mode, Serververwaltung, Update-Skripte und SSH-Verbindungen. Sichtbar für Benutzer:innen mit `maintenance-access` (Teilbereiche erfordern zusätzliche Unterrechte).
|
Der Wartungsbereich bündelt Maintenance-Mode, Serververwaltung (inkl. Agent-Integration), Update-Skripte und SSH-Konfiguration. Sichtbar für Benutzer:innen mit `Bereich & Navigation (Wartung)`; Unterbereiche haben eigene Rechte:
|
||||||
|
|
||||||
## Portainer-Status
|
**Relevante Rechte**
|
||||||
- Klick auf **Status prüfen**, um eine Live-Abfrage gegen Portainer durchzuführen. Ergebnis zeigt API-Verfügbarkeit, Endpoint-IDs und Auth-Status.
|
- `Bereich & Navigation (Wartung)`
|
||||||
- Häufige Fehler werden direkt erklärt (z. B. fehlender API-Key oder Netzwerkprobleme).
|
- `Server-Sektion`
|
||||||
|
- `Server bearbeiten` / `Server löschen`
|
||||||
|
- `SSH/Update-Skript`
|
||||||
|
- `Update durchführen`
|
||||||
|
- `Agent`
|
||||||
|
- `Doppelte Stacks`
|
||||||
|
- `mTLS Sektion`
|
||||||
|
- `Superuser löschen`
|
||||||
|
|
||||||
|
- `Server-Sektion` (lesen) zeigt die Serverübersicht.
|
||||||
|
- `Server bearbeiten` (lesen) zeigt Server-Details inkl. Portainer-Status; Felder bleiben schreibgeschützt. Vollzugriff erlaubt Bearbeiten/Speichern.
|
||||||
|
- `Server löschen` löscht Server (nur bei Vollzugriff).
|
||||||
|
- `SSH/Update-Skript` und `Update durchführen` hängen am Unterpunkt Portainer/SSH und erfordern Vollzugriff auf Server-Edit.
|
||||||
|
- `Agent` (lesen/voll) steuert die Agent-Sektion im Server-Detail.
|
||||||
|
|
||||||
## Maintenance-Mode
|
## Maintenance-Mode
|
||||||
1. Schalter **Wartungsmodus aktivieren** toggeln.
|
1. Schalter **Wartungsmodus aktivieren** umlegen.
|
||||||
2. Optionale Nachricht eingeben (z. B. „Upgrade um 22:00 Uhr“); sie erscheint im gesamten UI als Banner.
|
2. Jede Aktivierung/Deaktivierung wird in den Logs protokolliert.
|
||||||
3. Aktivieren/Deaktivieren protokolliert automatisch Events in den Logs.
|
|
||||||
|
|
||||||
## Server & API-Key verwalten
|
## Serververwaltung & API-Key
|
||||||
- Register „Server“ listet alle Portainer-Instanzen.
|
- Liste zeigt alle eingetragenen Portainer-Server.
|
||||||
- Über **Server hinzufügen** kannst du Name + URL ergänzen und anschließend API-Key speichern.
|
- **Server hinzufügen**/**bearbeiten**/**löschen** nur mit `Server bearbeiten` (Vollzugriff); löschen zusätzlich `Server löschen`.
|
||||||
- Buttons in der Tabelle:
|
- Im Server-Detail:
|
||||||
- **API-Key aktualisieren** – neues Token einsetzen.
|
- **Server-URL/Name** und **Portainer API-Key**: editierbar nur mit `Server bearbeiten` Vollzugriff; bei Leserecht sichtbar, aber gesperrt.
|
||||||
- **Server löschen** – nur möglich, wenn mindestens ein weiterer Server existiert oder du sofort neue Daten einträgst.
|
- **Self-Stack-ID**: Stack-ID von StackPulse selbst; blockiert Redeploys dieses Stacks.
|
||||||
|
- **Status prüfen**: Live-Check gegen Portainer (Version, Edition, Update vorhanden). Sichtbar ab `Server bearbeiten` (lesen).
|
||||||
|
|
||||||
## Self-Stack ID
|
## Agent-Integration (Portainer CE / Edge)
|
||||||
- Feld „Eigener Stack“ akzeptiert die Stack-ID, in der StackPulse selbst läuft.
|
Für die Community Edition von Stackpulse kannst du je Server einen StackPulse Agent hinterlegen. Er liefert Stack- und Portainer-Metadaten, wenn die Business-API fehlt. Hierzu muss auch der Agent auf dem Server der Portainer Instanz laufen. Der Agent wird für die Business Edition nicht benötigt.
|
||||||
- Nach dem Speichern sind Redeploy-Aktionen für diesen Stack im Dashboard deaktiviert.
|
|
||||||
|
1. Im Server-Detail **Agent konfigurieren** öffnen.
|
||||||
|
2. **Agent URL** eintragen, der **Agent Token** wird automatisch generiert und kann nicht geändert werden (Token = `AGENT_TOKEN` des Agents).
|
||||||
|
StackPulse prüft Erreichbarkeit und zeigt Online-/Fehlerstatus. Sichtbar mit `Agent` lesen, Bearbeitung mit `Agent` voll.
|
||||||
|
3. mTLS-Zertifikate verwalten:
|
||||||
|
- **Bootstrap-Token erzeugen** (Einmal-Token) und im Agent als `AGENT_BOOTSTRAP_TOKEN` setzen.
|
||||||
|
- Agent holt Zertifikate automatisch ab; Status im Zertifikate-Abschnitt.
|
||||||
|
- **Zertifikate anzeigen** (PEM) (nur bei entsprechenden Rechten)
|
||||||
|
4. Portainer-Version kann gelesen werden, wenn der Agent online ist.
|
||||||
|
|
||||||
## SSH-Konfiguration
|
## SSH-Konfiguration
|
||||||
Erforderliche Permission: `maintenance-ssh-update`.
|
Erfordert `SSH/Update-Skript` (sichtbar/bearbeitbar nur bei Vollzugriff auf Server-Edit).
|
||||||
1. Host, Port, Benutzername eintragen. Optional Passwort und zusätzliche SSH-Argumente (z. B. `-o StrictHostKeyChecking=no`).
|
1. Host, Port, Benutzername und Passwort sowie eventuell notwendige Extra-Argumente eintragen.
|
||||||
2. Passwort wird verschlüsselt gespeichert; alternativ kannst du Public-Key-Auth nutzen und das Feld leer lassen.
|
2. Passwort wird verschlüsselt gespeichert.
|
||||||
3. **Verbindung testen**: StackPulse baut eine SSH-Verbindung auf und zeigt das Ergebnis in Echtzeit.
|
3. **Verbindung testen** prüft Erreichbarkeit und meldet Ergebnis direkt.
|
||||||
|
|
||||||
## Update-Skript
|
## Update-Skript & Portainer-Update
|
||||||
- Standard-Skript wird angezeigt und kann als Ausgangspunkt genutzt werden.
|
- Standard-Skript ist sichtbar und kann überschrieben werden (Recht `SSH/Update-Skript` + Server-Edit Vollzugriff). **Zurücksetzen** stellt den Standard wieder her.
|
||||||
- Eigenes Skript: Inhalt in das Editorfeld kopieren und speichern. StackPulse prüft auf leere Zeilen und Basisbefehle.
|
- Start eines Updates (`Update durchführen` + Server-Edit Vollzugriff):
|
||||||
- Skript zurücksetzen entfernt die benutzerdefinierte Variante und stellt den Default wieder her.
|
1) Wartungsmodus wird automatisch aktiviert.
|
||||||
|
2) SSH-Konfiguration und Skript prüfen.
|
||||||
## Portainer-Update auslösen
|
3) **Update starten** klicken. Der Fortschritt und die Logausgaben erscheinen im Fenster.
|
||||||
1. Stelle sicher, dass Wartungsmodus aktiv ist und SSH-Konfiguration + Skript korrekt sind.
|
4) Während ein Update läuft, sind Änderungen am System gesperrt.
|
||||||
2. Klicke auf **Update starten**. Der aktuelle Fortschritt erscheint in einem Logpanel.
|
|
||||||
3. Der Button bleibt deaktiviert, solange ein Update läuft. Über **Status aktualisieren** kannst du den Fortschritt erneut abfragen.
|
|
||||||
|
|
||||||
## Fehlerbehandlung
|
## Fehlerbehandlung
|
||||||
- Bei SSH-Fehlern zeigt StackPulse die genaue Ausgabe an. Passe Host/Port/Benutzer oder Skript an.
|
- SSH-/Update-Fehler werden mit Klartextausgabe angezeigt. Bei Bedarf Host/Port/User, Passwort oder Skript anpassen.
|
||||||
- Falls das Update-Skript Probleme verursacht, kannst du es jederzeit stoppen, indem du Portainer manuell prüfst und ggf. das Skript anpasst.
|
- Agent offline: URL/Token prüfen, Bootstrap-Token ggf. neu erzeugen und den Agent neu starten.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Setup-Assistent
|
# Setup-Assistent
|
||||||
|
|
||||||
Beim ersten Start erscheint automatisch der Setup-Bereich. Du erreichst ihn später jederzeit über `/setup`, solange nicht alle Schritte abgeschlossen sind.
|
Beim ersten Start erscheint automatisch der Setup-Bereich und führt dich durch alle Schritte, bis die Einrichtung abgeschlossen ist.
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
- Portainer Business Edition mit gültigem API-Key
|
- Portainer Business Edition mit gültigem API-Key
|
||||||
@@ -31,5 +31,5 @@ Beim ersten Start erscheint automatisch der Setup-Bereich. Du erreichst ihn spä
|
|||||||
|
|
||||||
## Setup wiederholen oder ändern
|
## Setup wiederholen oder ändern
|
||||||
- Über das Dashboard → **Wartung** → Abschnitt *Serververwaltung* kannst du zusätzliche Portainer-Server hinzufügen oder entfernen.
|
- Über das Dashboard → **Wartung** → Abschnitt *Serververwaltung* kannst du zusätzliche Portainer-Server hinzufügen oder entfernen.
|
||||||
- Superuser lässt sich im Bereich **Benutzer** löschen, sofern du `maintenance-superuser-delete` besitzt.
|
- Superuser lässt sich im Bereich **Benutzer** löschen, sofern du das Recht `Superuser löschen` besitzt.
|
||||||
- Der Self-Stack (StackPulse-eigener Stack) kann in der Wartung gepflegt werden, damit er nicht versehentlich redeployed wird.
|
- Der Self-Stack (StackPulse-eigener Stack) kann in der Wartung gepflegt werden, damit er nicht versehentlich redeployed wird.
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
Die Stacks-Ansicht ist der Startpunkt nach dem Login und liefert einen Überblick über alle Stacks der angebundenen Portainer-Instanz.
|
Die Stacks-Ansicht ist der Startpunkt nach dem Login und liefert einen Überblick über alle Stacks der angebundenen Portainer-Instanz.
|
||||||
|
|
||||||
|
**Relevante Rechte**
|
||||||
|
- `Redeploy einzeln`
|
||||||
|
- `Redeploy Auswahl`
|
||||||
|
- `Redeploy Alle`
|
||||||
|
|
||||||
## Aufbau
|
## Aufbau
|
||||||
- **Header** mit Suchfeld, Filterchips für Status sowie Buttons für Redeploy-Aktionen.
|
- **Header** mit Suchfeld, Filterchips für Status sowie Buttons für Redeploy-Aktionen.
|
||||||
- **Tabelle** mit Name, Endpoint, Status, letzte Aktion, Redeploy-Status und verfügbaren Buttons.
|
- **Tabelle** mit Name, Endpoint, Status, letzte Aktion, Redeploy-Status und verfügbaren Buttons.
|
||||||
@@ -15,9 +20,9 @@ Die Stacks-Ansicht ist der Startpunkt nach dem Login und liefert einen Überblic
|
|||||||
## Redeploy-Aktionen
|
## Redeploy-Aktionen
|
||||||
| Aktion | Voraussetzung | Vorgehen |
|
| Aktion | Voraussetzung | Vorgehen |
|
||||||
|--------|---------------|----------|
|
|--------|---------------|----------|
|
||||||
| **Einzelner Stack** | Permission `stacks-redeploy-single` | Button **Redeploy** in der jeweiligen Zeile klicken und bestätigen. |
|
| **Einzelner Stack** | Recht `Redeploy einzeln` | Button **Redeploy** in der jeweiligen Zeile klicken und bestätigen. |
|
||||||
| **Mehrere Stacks** | `stacks-redeploy-selection` | Checkboxen der gewünschten Stacks aktivieren, anschließend **Redeploy Auswahl**. |
|
| **Mehrere Stacks** | Recht `Redeploy Auswahl` | Checkboxen der gewünschten Stacks aktivieren, anschließend **Redeploy Auswahl**. |
|
||||||
| **Alle Stacks** | `stacks-redeploy-all` | Button **Redeploy alle** auslösen. Nutze vorher Filter, um sicherzugehen, dass alle Stacks korrekt angezeigt werden. |
|
| **Alle Stacks** | Recht `Redeploy Alle` | Button **Redeploy alle** auslösen. Nutze vorher Filter, um sicherzugehen, dass alle Stacks korrekt angezeigt werden. |
|
||||||
|
|
||||||
Während eines Redeploys erscheint pro Stack ein Fortschrittsbadge. Du kannst das Panel schließen; beim nächsten Besuch wird der aktuelle Status aus dem Backend gelesen.
|
Während eines Redeploys erscheint pro Stack ein Fortschrittsbadge. Du kannst das Panel schließen; beim nächsten Besuch wird der aktuelle Status aus dem Backend gelesen.
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,52 @@ const normalizePermissions = (raw) => {
|
|||||||
}, {});
|
}, {});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeServerPermissions = (raw) => {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return Object.entries(raw).reduce((acc, [serverId, value]) => {
|
||||||
|
const numericId = Number(serverId);
|
||||||
|
if (!Number.isFinite(numericId)) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
const permissionMap = value?.permissions && typeof value.permissions === "object"
|
||||||
|
? value.permissions
|
||||||
|
: value;
|
||||||
|
acc[numericId] = {
|
||||||
|
permissions: normalizePermissions(permissionMap),
|
||||||
|
groupId: Number.isFinite(Number(value?.groupId)) ? Number(value.groupId) : null,
|
||||||
|
useGlobalGroup: Boolean(value?.useGlobalGroup)
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeServerAssignments = (raw) => {
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
.map((entry) => {
|
||||||
|
const serverId = Number(entry?.serverId ?? entry?.server_id ?? entry?.id);
|
||||||
|
if (!Number.isFinite(serverId)) return null;
|
||||||
|
return {
|
||||||
|
serverId,
|
||||||
|
groupId: Number.isFinite(Number(entry?.groupId ?? entry?.group_id)) ? Number(entry?.groupId ?? entry?.group_id) : null,
|
||||||
|
useGlobalGroup: Boolean(entry?.useGlobalGroup ?? entry?.use_global_group),
|
||||||
|
serverName: entry?.serverName || entry?.server_name || null,
|
||||||
|
serverUrl: entry?.serverUrl || entry?.server_url || null
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
const [state, setState] = useState({
|
const [state, setState] = useState({
|
||||||
user: null,
|
user: null,
|
||||||
permissions: {},
|
permissions: {},
|
||||||
|
serverPermissions: {},
|
||||||
|
serverAssignments: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
lastErrorCode: null,
|
lastErrorCode: null,
|
||||||
@@ -36,9 +78,13 @@ export function AuthProvider({ children }) {
|
|||||||
const setSession = useCallback((payload) => {
|
const setSession = useCallback((payload) => {
|
||||||
const user = payload?.user ?? null;
|
const user = payload?.user ?? null;
|
||||||
const permissions = normalizePermissions(payload?.permissions);
|
const permissions = normalizePermissions(payload?.permissions);
|
||||||
|
const serverPermissions = normalizeServerPermissions(payload?.serverPermissions);
|
||||||
|
const serverAssignments = normalizeServerAssignments(payload?.serverAssignments ?? user?.serverAssignments);
|
||||||
setState({
|
setState({
|
||||||
user,
|
user,
|
||||||
permissions,
|
permissions,
|
||||||
|
serverPermissions,
|
||||||
|
serverAssignments,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
lastErrorCode: null,
|
lastErrorCode: null,
|
||||||
@@ -50,6 +96,8 @@ export function AuthProvider({ children }) {
|
|||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
user: null,
|
user: null,
|
||||||
permissions: {},
|
permissions: {},
|
||||||
|
serverPermissions: {},
|
||||||
|
serverAssignments: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
lastErrorCode: null,
|
lastErrorCode: null,
|
||||||
@@ -80,6 +128,8 @@ export function AuthProvider({ children }) {
|
|||||||
error: payload?.error ?? null,
|
error: payload?.error ?? null,
|
||||||
lastErrorCode: payload?.error ?? null,
|
lastErrorCode: payload?.error ?? null,
|
||||||
initialized: true,
|
initialized: true,
|
||||||
|
serverPermissions: {},
|
||||||
|
serverAssignments: []
|
||||||
});
|
});
|
||||||
return { ok: false, status: response.status, error: payload?.error ?? null };
|
return { ok: false, status: response.status, error: payload?.error ?? null };
|
||||||
}
|
}
|
||||||
@@ -89,9 +139,11 @@ export function AuthProvider({ children }) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.message || "NETWORK_ERROR";
|
const message = err?.message || "NETWORK_ERROR";
|
||||||
setState({
|
setState({
|
||||||
user: null,
|
user: null,
|
||||||
permissions: {},
|
permissions: {},
|
||||||
loading: false,
|
serverPermissions: {},
|
||||||
|
serverAssignments: [],
|
||||||
|
loading: false,
|
||||||
error: message,
|
error: message,
|
||||||
lastErrorCode: "NETWORK_ERROR",
|
lastErrorCode: "NETWORK_ERROR",
|
||||||
initialized: true,
|
initialized: true,
|
||||||
@@ -140,10 +192,36 @@ export function AuthProvider({ children }) {
|
|||||||
[state.permissions, state.user]
|
[state.permissions, state.user]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasServerPermission = useCallback(
|
||||||
|
(serverId, permissionKey, requiredLevel = "full") => {
|
||||||
|
if (!permissionKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (state.user?.isSuperuser) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const numericServerId = Number(serverId);
|
||||||
|
let permissionMap = null;
|
||||||
|
if (Number.isFinite(numericServerId)) {
|
||||||
|
const entry = state.serverPermissions?.[numericServerId] || state.serverPermissions?.[String(numericServerId)];
|
||||||
|
if (entry?.permissions && typeof entry.permissions === "object") {
|
||||||
|
permissionMap = entry.permissions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const basePermissions = permissionMap || state.permissions || {};
|
||||||
|
const current = basePermissions[permissionKey] ?? "none";
|
||||||
|
const currentPriority = LEVEL_PRIORITY[current] ?? 0;
|
||||||
|
const requiredPriority = LEVEL_PRIORITY[requiredLevel] ?? LEVEL_PRIORITY.full;
|
||||||
|
return currentPriority >= requiredPriority;
|
||||||
|
},
|
||||||
|
[state.serverPermissions, state.permissions, state.user]
|
||||||
|
);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
user: state.user,
|
user: state.user,
|
||||||
permissions: state.permissions,
|
permissions: state.permissions,
|
||||||
|
serverPermissions: state.serverPermissions,
|
||||||
loading: state.loading,
|
loading: state.loading,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
lastErrorCode: state.lastErrorCode,
|
lastErrorCode: state.lastErrorCode,
|
||||||
@@ -154,8 +232,10 @@ export function AuthProvider({ children }) {
|
|||||||
clearSession,
|
clearSession,
|
||||||
logout,
|
logout,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
|
hasServerPermission,
|
||||||
|
serverAssignments: state.serverAssignments,
|
||||||
}),
|
}),
|
||||||
[state, refreshSession, setSession, clearSession, logout, hasPermission]
|
[state, refreshSession, setSession, clearSession, logout, hasPermission, hasServerPermission]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,6 @@ import {
|
|||||||
} from "@material-tailwind/react";
|
} from "@material-tailwind/react";
|
||||||
|
|
||||||
import { useToast } from "@/components/ToastProvider.jsx";
|
import { useToast } from "@/components/ToastProvider.jsx";
|
||||||
import { platformSettingsData } from "@/data";
|
|
||||||
import { AVATAR_COLORS } from "@/data/avatarColors.js";
|
import { AVATAR_COLORS } from "@/data/avatarColors.js";
|
||||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
@@ -69,6 +68,47 @@ const mapUser = (item) => ({
|
|||||||
securityPhraseDownloadedAt: item?.securityPhraseDownloadedAt || null
|
securityPhraseDownloadedAt: item?.securityPhraseDownloadedAt || null
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const normalizeServer = (item) => {
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const id = Number(item.id ?? item.serverId ?? item.server_id);
|
||||||
|
if (!Number.isFinite(id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const name = item.name || item.serverName || item.server_name || `Server ${id}`;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
url: item.url || item.serverUrl || item.server_url || ""
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeAssignmentsList = (list, servers = []) => {
|
||||||
|
const serverMap = new Map(servers.map((server) => [server.id, server]));
|
||||||
|
if (!Array.isArray(list)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
.map((entry) => {
|
||||||
|
const serverId = Number(entry?.serverId ?? entry?.server_id);
|
||||||
|
if (!Number.isFinite(serverId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const groupId = Number(entry?.groupId ?? entry?.group_id);
|
||||||
|
const serverFallback = serverMap.get(serverId);
|
||||||
|
return {
|
||||||
|
serverId,
|
||||||
|
serverName: entry?.serverName || entry?.server_name || serverFallback?.name || "",
|
||||||
|
serverUrl: entry?.serverUrl || entry?.server_url || serverFallback?.url || "",
|
||||||
|
groupId: Number.isFinite(groupId) ? groupId : null,
|
||||||
|
groupName: entry?.groupName || entry?.group_name || "",
|
||||||
|
useGlobalGroup: Boolean(entry?.useGlobalGroup ?? entry?.use_global_group)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
const extractPrimaryGroupId = (user) => {
|
const extractPrimaryGroupId = (user) => {
|
||||||
if (!user || !Array.isArray(user.groups) || user.groups.length === 0) {
|
if (!user || !Array.isArray(user.groups) || user.groups.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -123,6 +163,11 @@ export function UserDetails() {
|
|||||||
const [securityPhraseLoading, setSecurityPhraseLoading] = useState(false);
|
const [securityPhraseLoading, setSecurityPhraseLoading] = useState(false);
|
||||||
const [securityPhraseError, setSecurityPhraseError] = useState("");
|
const [securityPhraseError, setSecurityPhraseError] = useState("");
|
||||||
const [renewingSecurityPhrase, setRenewingSecurityPhrase] = useState(false);
|
const [renewingSecurityPhrase, setRenewingSecurityPhrase] = useState(false);
|
||||||
|
const [serverAssignments, setServerAssignments] = useState([]);
|
||||||
|
const [availableServers, setAvailableServers] = useState([]);
|
||||||
|
const [assignmentsError, setAssignmentsError] = useState("");
|
||||||
|
const [savingAssignments, setSavingAssignments] = useState(false);
|
||||||
|
const initialAssignmentsRef = useRef([]);
|
||||||
|
|
||||||
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||||
const maintenanceMessage = maintenanceMeta?.message;
|
const maintenanceMessage = maintenanceMeta?.message;
|
||||||
@@ -172,11 +217,22 @@ export function UserDetails() {
|
|||||||
if (!item.id) {
|
if (!item.id) {
|
||||||
throw new Error("USER_NOT_FOUND");
|
throw new Error("USER_NOT_FOUND");
|
||||||
}
|
}
|
||||||
|
const normalizedServers = Array.isArray(response.data?.servers)
|
||||||
|
? response.data.servers.map(normalizeServer).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
setAvailableServers(normalizedServers);
|
||||||
|
const normalizedAssignments = normalizeAssignmentsList(
|
||||||
|
response.data?.item?.serverAssignments ?? response.data?.serverAssignments ?? [],
|
||||||
|
normalizedServers
|
||||||
|
);
|
||||||
|
initialAssignmentsRef.current = normalizedAssignments;
|
||||||
|
setServerAssignments(normalizedAssignments);
|
||||||
|
setAssignmentsError("");
|
||||||
setUser(item);
|
setUser(item);
|
||||||
setSecurityPhraseWords([]);
|
setSecurityPhraseWords([]);
|
||||||
setSecurityPhraseDownloadedAt(item.securityPhraseDownloadedAt || null);
|
setSecurityPhraseDownloadedAt(item.securityPhraseDownloadedAt || null);
|
||||||
setSecurityPhraseError("");
|
setSecurityPhraseError("");
|
||||||
const initialValues = buildInitialFormValues(item);
|
const initialValues = buildInitialFormValues(item);
|
||||||
initialFormValuesRef.current = { ...initialValues };
|
initialFormValuesRef.current = { ...initialValues };
|
||||||
setFormValues(initialValues);
|
setFormValues(initialValues);
|
||||||
setSaveError("");
|
setSaveError("");
|
||||||
@@ -195,6 +251,9 @@ export function UserDetails() {
|
|||||||
setUser(null);
|
setUser(null);
|
||||||
initialFormValuesRef.current = buildInitialFormValues(null);
|
initialFormValuesRef.current = buildInitialFormValues(null);
|
||||||
setFormValues(buildInitialFormValues(null));
|
setFormValues(buildInitialFormValues(null));
|
||||||
|
setServerAssignments([]);
|
||||||
|
setAvailableServers([]);
|
||||||
|
setAssignmentsError("");
|
||||||
setError(message);
|
setError(message);
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
@@ -341,6 +400,208 @@ export function UserDetails() {
|
|||||||
}));
|
}));
|
||||||
}, [canEditUsers]);
|
}, [canEditUsers]);
|
||||||
|
|
||||||
|
const assignmentsChanged = useMemo(() => {
|
||||||
|
const serialize = (list) =>
|
||||||
|
JSON.stringify(
|
||||||
|
list
|
||||||
|
.map((entry) => ({
|
||||||
|
serverId: Number(entry.serverId) || null,
|
||||||
|
groupId: entry.useGlobalGroup ? null : (Number(entry.groupId) || null),
|
||||||
|
useGlobalGroup: Boolean(entry.useGlobalGroup)
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (a.serverId || 0) - (b.serverId || 0))
|
||||||
|
);
|
||||||
|
return serialize(serverAssignments) !== serialize(initialAssignmentsRef.current);
|
||||||
|
}, [serverAssignments]);
|
||||||
|
|
||||||
|
const usedServerIds = useMemo(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
serverAssignments
|
||||||
|
.map((entry) => Number(entry.serverId))
|
||||||
|
.filter((value) => Number.isFinite(value) && value > 0)
|
||||||
|
),
|
||||||
|
[serverAssignments]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getServerOptionsForRow = useCallback(
|
||||||
|
(currentServerId) => {
|
||||||
|
return availableServers.filter(
|
||||||
|
(server) => server.id === currentServerId || !usedServerIds.has(server.id)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[availableServers, usedServerIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddAssignment = useCallback(() => {
|
||||||
|
if (!canEditUsers || isSuperuserUser) return;
|
||||||
|
const usedServerIds = new Set(
|
||||||
|
serverAssignments
|
||||||
|
.map((entry) => Number(entry.serverId))
|
||||||
|
.filter((value) => Number.isFinite(value) && value > 0)
|
||||||
|
);
|
||||||
|
const nextServer = availableServers.find((server) => !usedServerIds.has(server.id));
|
||||||
|
if (!nextServer) {
|
||||||
|
setAssignmentsError("Alle verfügbaren Server sind bereits zugeordnet.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAssignmentsError("");
|
||||||
|
setServerAssignments((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
serverId: nextServer.id,
|
||||||
|
serverName: nextServer.name ?? "",
|
||||||
|
serverUrl: nextServer.url ?? "",
|
||||||
|
groupId: null,
|
||||||
|
groupName: "",
|
||||||
|
useGlobalGroup: true
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}, [canEditUsers, serverAssignments, availableServers]);
|
||||||
|
|
||||||
|
const handleAssignmentServerChange = useCallback(
|
||||||
|
(index, value) => {
|
||||||
|
if (!canEditUsers || isSuperuserUser) return;
|
||||||
|
const numeric = Number(value);
|
||||||
|
const server = availableServers.find((item) => item.id === numeric);
|
||||||
|
setServerAssignments((prev) =>
|
||||||
|
prev.map((entry, idx) =>
|
||||||
|
idx === index
|
||||||
|
? {
|
||||||
|
...entry,
|
||||||
|
serverId: Number.isFinite(numeric) ? numeric : null,
|
||||||
|
serverName: server?.name || entry.serverName,
|
||||||
|
serverUrl: server?.url || entry.serverUrl
|
||||||
|
}
|
||||||
|
: entry
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[availableServers, canEditUsers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAssignmentGroupChange = useCallback(
|
||||||
|
(index, value) => {
|
||||||
|
if (!canEditUsers || isSuperuserUser) return;
|
||||||
|
const numeric = Number(value);
|
||||||
|
setServerAssignments((prev) =>
|
||||||
|
prev.map((entry, idx) =>
|
||||||
|
idx === index
|
||||||
|
? {
|
||||||
|
...entry,
|
||||||
|
groupId: Number.isFinite(numeric) ? numeric : null,
|
||||||
|
useGlobalGroup: false
|
||||||
|
}
|
||||||
|
: entry
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[canEditUsers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAssignmentUseGlobalToggle = useCallback(
|
||||||
|
(index, checked) => {
|
||||||
|
if (!canEditUsers || isSuperuserUser) return;
|
||||||
|
setServerAssignments((prev) =>
|
||||||
|
prev.map((entry, idx) =>
|
||||||
|
idx === index
|
||||||
|
? {
|
||||||
|
...entry,
|
||||||
|
useGlobalGroup: Boolean(checked),
|
||||||
|
groupId: checked ? null : entry.groupId
|
||||||
|
}
|
||||||
|
: entry
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[canEditUsers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemoveAssignment = useCallback(
|
||||||
|
(index) => {
|
||||||
|
if (!canEditUsers || isSuperuserUser) return;
|
||||||
|
setServerAssignments((prev) => prev.filter((_, idx) => idx !== index));
|
||||||
|
},
|
||||||
|
[canEditUsers, isSuperuserUser]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSaveAssignments = useCallback(async () => {
|
||||||
|
if (!canEditUsers || !numericUserId || !assignmentsChanged || isSuperuserUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPayload = [];
|
||||||
|
const seenServers = new Set();
|
||||||
|
for (const assignment of serverAssignments) {
|
||||||
|
const serverId = Number(assignment.serverId);
|
||||||
|
if (!Number.isFinite(serverId) || serverId <= 0) {
|
||||||
|
setAssignmentsError("Bitte wähle für jede Zuordnung einen Server aus.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (seenServers.has(serverId)) {
|
||||||
|
setAssignmentsError("Jeder Server kann nur einmal zugeordnet werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seenServers.add(serverId);
|
||||||
|
const useGlobalGroup = Boolean(assignment.useGlobalGroup);
|
||||||
|
const groupId = Number(assignment.groupId);
|
||||||
|
if (!useGlobalGroup && (!Number.isFinite(groupId) || groupId <= 0)) {
|
||||||
|
setAssignmentsError("Bitte wähle eine Rechtegruppe oder aktiviere die globale Gruppe.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
normalizedPayload.push({
|
||||||
|
serverId,
|
||||||
|
groupId: useGlobalGroup ? null : groupId,
|
||||||
|
useGlobalGroup
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setAssignmentsError("");
|
||||||
|
setSavingAssignments(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.put(
|
||||||
|
`/api/users/${numericUserId}/server-assignments`,
|
||||||
|
{ assignments: normalizedPayload }
|
||||||
|
);
|
||||||
|
const normalizedAssignments = normalizeAssignmentsList(
|
||||||
|
response.data?.items ?? [],
|
||||||
|
availableServers
|
||||||
|
);
|
||||||
|
initialAssignmentsRef.current = normalizedAssignments;
|
||||||
|
setServerAssignments(normalizedAssignments);
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "Serverzuordnungen gespeichert",
|
||||||
|
description: "Die Zuordnungen wurden erfolgreich aktualisiert."
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const serverError = err.response?.data?.error;
|
||||||
|
let message = "Die Serverzuordnungen konnten nicht gespeichert werden.";
|
||||||
|
if (serverError === "USER_NOT_FOUND") {
|
||||||
|
message = "Der Benutzer wurde nicht gefunden.";
|
||||||
|
} else if (serverError === "INVALID_USER_ID") {
|
||||||
|
message = "Die Benutzer-ID ist ungültig.";
|
||||||
|
} else if (serverError === "USER_SUPERUSER_PROTECTED") {
|
||||||
|
message = "Für den Superuser können keine Server-Zuordnungen gesetzt werden.";
|
||||||
|
} else if (serverError === "SERVER_NOT_FOUND") {
|
||||||
|
message = "Ein ausgewählter Server existiert nicht mehr.";
|
||||||
|
} else if (serverError === "GROUP_NOT_FOUND") {
|
||||||
|
message = "Eine ausgewählte Rechtegruppe existiert nicht mehr.";
|
||||||
|
} else if (serverError === "INSUFFICIENT_PERMISSIONS") {
|
||||||
|
message = "Keine Berechtigung zum Ändern der Zuordnungen.";
|
||||||
|
}
|
||||||
|
setAssignmentsError(message);
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Speichern fehlgeschlagen",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSavingAssignments(false);
|
||||||
|
}
|
||||||
|
}, [canEditUsers, numericUserId, serverAssignments, availableServers, assignmentsChanged, showToast]);
|
||||||
|
|
||||||
const handleSaveUser = useCallback(async () => {
|
const handleSaveUser = useCallback(async () => {
|
||||||
if (!canEditUsers || !user || !hasChanges) {
|
if (!canEditUsers || !user || !hasChanges) {
|
||||||
return;
|
return;
|
||||||
@@ -775,30 +1036,171 @@ export function UserDetails() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Typography variant="h6" color="blue-gray" className="mb-3">
|
<Typography variant="h6" color="blue-gray" className="mb-3">
|
||||||
Platform Settings
|
Server-Zuordnungen
|
||||||
</Typography>
|
</Typography>
|
||||||
<div className="flex flex-col gap-12">
|
{isSuperuserUser ? (
|
||||||
{platformSettingsData.map(({ title, options }) => (
|
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
<div key={title}>
|
Für den Superuser gelten immer alle Rechte und es können keine server-spezifischen Zuordnungen gesetzt werden.
|
||||||
<Typography className="mb-4 block text-xs font-semibold uppercase text-blue-gray-500">
|
</div>
|
||||||
{title}
|
) : (
|
||||||
</Typography>
|
<>
|
||||||
<div className="flex flex-col gap-6">
|
<Typography className="mb-4 text-sm text-blue-gray-500">
|
||||||
{options.map(({ checked, label }) => (
|
Ohne Zuordnungen hat der Benutzer Zugriff auf alle Server mit seinen globalen Rechten.
|
||||||
<Switch
|
Lege hier Server-spezifische Gruppen fest oder markiere den Server explizit zur Nutzung der globalen Gruppe.
|
||||||
key={label}
|
</Typography>
|
||||||
id={label}
|
{assignmentsError && (
|
||||||
label={label}
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
defaultChecked={checked}
|
{assignmentsError}
|
||||||
labelProps={{
|
|
||||||
className: "text-sm font-normal text-blue-gray-500",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{serverAssignments.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-blue-gray-100 bg-blue-gray-50/60 px-4 py-3 text-sm text-blue-gray-600">
|
||||||
|
Keine Server zugeordnet. Die globalen Gruppen gelten für alle Server.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
serverAssignments.map((assignment, index) => {
|
||||||
|
const serverOptions = getServerOptionsForRow(assignment.serverId);
|
||||||
|
const groupSelectValue =
|
||||||
|
assignment.useGlobalGroup || !assignment.groupId
|
||||||
|
? ""
|
||||||
|
: String(assignment.groupId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`assignment-${assignment.serverId ?? index}-${index}`}
|
||||||
|
className="rounded-lg border border-blue-gray-100 bg-white px-4 py-3 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
Server
|
||||||
|
</Typography>
|
||||||
|
<Select
|
||||||
|
label="Server auswählen"
|
||||||
|
value={
|
||||||
|
assignment.serverId
|
||||||
|
? String(assignment.serverId)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onChange={(value) =>
|
||||||
|
handleAssignmentServerChange(index, value)
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
maintenanceLocked ||
|
||||||
|
savingAssignments ||
|
||||||
|
!canEditUsers
|
||||||
|
}
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
{serverOptions.map((server) => (
|
||||||
|
<Option key={server.id} value={String(server.id)}>
|
||||||
|
{server.name}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
Rechtegruppe
|
||||||
|
</Typography>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Switch
|
||||||
|
id={`assignment-global-${index}`}
|
||||||
|
label="Globale Gruppe verwenden"
|
||||||
|
checked={assignment.useGlobalGroup}
|
||||||
|
onChange={({ target }) =>
|
||||||
|
handleAssignmentUseGlobalToggle(
|
||||||
|
index,
|
||||||
|
target?.checked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
maintenanceLocked ||
|
||||||
|
savingAssignments ||
|
||||||
|
!canEditUsers
|
||||||
|
}
|
||||||
|
labelProps={{
|
||||||
|
className: "text-sm font-normal text-blue-gray-600"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Server-Gruppe wählen"
|
||||||
|
value={groupSelectValue}
|
||||||
|
onChange={(value) =>
|
||||||
|
handleAssignmentGroupChange(index, value)
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
maintenanceLocked ||
|
||||||
|
savingAssignments ||
|
||||||
|
!canEditUsers ||
|
||||||
|
assignment.useGlobalGroup
|
||||||
|
}
|
||||||
|
variant="outlined"
|
||||||
|
className="max-w-xl"
|
||||||
|
>
|
||||||
|
<Option value="">Globale Gruppe</Option>
|
||||||
|
{availableGroups.map((group) => (
|
||||||
|
<Option key={group.id} value={String(group.id)}>
|
||||||
|
{group.name}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
color="red"
|
||||||
|
className="normal-case"
|
||||||
|
onClick={() => handleRemoveAssignment(index)}
|
||||||
|
disabled={
|
||||||
|
maintenanceLocked ||
|
||||||
|
savingAssignments ||
|
||||||
|
!canEditUsers
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||||
</div>
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="blue"
|
||||||
|
className="normal-case"
|
||||||
|
onClick={handleAddAssignment}
|
||||||
|
disabled={
|
||||||
|
maintenanceLocked ||
|
||||||
|
savingAssignments ||
|
||||||
|
!canEditUsers ||
|
||||||
|
availableServers.length === 0 ||
|
||||||
|
usedServerIds.size >= availableServers.length
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Server hinzufügen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
className="normal-case"
|
||||||
|
onClick={handleSaveAssignments}
|
||||||
|
disabled={
|
||||||
|
maintenanceLocked ||
|
||||||
|
savingAssignments ||
|
||||||
|
!canEditUsers ||
|
||||||
|
!assignmentsChanged
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{savingAssignments ? "Speichert ..." : "Zuordnungen speichern"}
|
||||||
|
</Button>
|
||||||
|
{savingAssignments && <Spinner className="h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -78,8 +78,14 @@ const LEVEL_PRIORITY = {
|
|||||||
full: 2
|
full: 2
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizePermissionLevel = (key, value) =>
|
const isDependencySatisfied = (value, requirement) => {
|
||||||
key === "maintenance-server-delete" && value !== "full" ? "none" : value;
|
const req = requirement || "!=none";
|
||||||
|
if (req === "!=none") return value !== "none";
|
||||||
|
if (req.startsWith("!=")) return value !== req.substring(2);
|
||||||
|
if (req.startsWith("=")) return value === req.substring(1);
|
||||||
|
if (!req) return true;
|
||||||
|
return value === req;
|
||||||
|
};
|
||||||
|
|
||||||
export function UserGroupDetail() {
|
export function UserGroupDetail() {
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
@@ -177,22 +183,28 @@ export function UserGroupDetail() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeGroup = (group, groupIdx = 0) => {
|
||||||
|
const groupItems = Array.isArray(group.items)
|
||||||
|
? group.items.map((item, itemIdx) => normalizeItem(item, itemIdx)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const childGroups = Array.isArray(group.groups)
|
||||||
|
? group.groups.map((child, childIdx) => normalizeGroup(child, childIdx))
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
sortOrder: typeof group.sortOrder === "number" ? group.sortOrder : groupIdx,
|
||||||
|
items: groupItems,
|
||||||
|
groups: childGroups
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const normalizedSections = rawSections.map((section, sectionIndex) => {
|
const normalizedSections = rawSections.map((section, sectionIndex) => {
|
||||||
const sectionItems = Array.isArray(section.items)
|
const sectionItems = Array.isArray(section.items)
|
||||||
? section.items.map((item, itemIdx) => normalizeItem(item, itemIdx)).filter(Boolean)
|
? section.items.map((item, itemIdx) => normalizeItem(item, itemIdx)).filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const sectionGroups = Array.isArray(section.groups)
|
const sectionGroups = Array.isArray(section.groups)
|
||||||
? section.groups.map((group, groupIdx) => {
|
? section.groups.map((group, groupIdx) => normalizeGroup(group, groupIdx))
|
||||||
const groupItems = Array.isArray(group.items)
|
|
||||||
? group.items.map((item, itemIdx) => normalizeItem(item, itemIdx)).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
return {
|
|
||||||
...group,
|
|
||||||
sortOrder: typeof group.sortOrder === "number" ? group.sortOrder : groupIdx,
|
|
||||||
items: groupItems
|
|
||||||
};
|
|
||||||
})
|
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -515,16 +527,52 @@ export function UserGroupDetail() {
|
|||||||
if (!permissionKey || !canAdjustPermissions || isSuperuserGroup) {
|
if (!permissionKey || !canAdjustPermissions || isSuperuserGroup) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextValue = normalizePermissionLevel(permissionKey, value);
|
const getValueWithDefaults = (key, selectionMap) =>
|
||||||
setPermissionSelection((prev) => {
|
selectionMap[key] ?? permissionDefaults[key] ?? "none";
|
||||||
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 && !shouldResetEdit && !shouldResetDeleteFromEdit && prev[permissionKey] === nextValue) {
|
const collectAllItems = () => {
|
||||||
|
const items = [];
|
||||||
|
const walkGroups = (groups) => {
|
||||||
|
(groups || []).forEach((group) => {
|
||||||
|
(group.items || []).forEach((item) => items.push(item));
|
||||||
|
if (group.groups) {
|
||||||
|
walkGroups(group.groups);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
permissionSections.forEach((section) => {
|
||||||
|
(section.items || []).forEach((item) => items.push(item));
|
||||||
|
walkGroups(section.groups);
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allItems = collectAllItems();
|
||||||
|
const itemMap = new Map(allItems.map((item) => [item.key, item]));
|
||||||
|
|
||||||
|
const clampToAllowed = (itemKey, desiredLevel) => {
|
||||||
|
const item = itemMap.get(itemKey);
|
||||||
|
if (!item) return desiredLevel;
|
||||||
|
const allowed = Array.isArray(item.availableLevels) && item.availableLevels.length
|
||||||
|
? item.availableLevels
|
||||||
|
: LEVEL_OPTIONS.map((opt) => opt.value);
|
||||||
|
if (allowed.includes(desiredLevel)) {
|
||||||
|
return desiredLevel;
|
||||||
|
}
|
||||||
|
// Fallback: wähle niedrigstes erlaubtes Level
|
||||||
|
return allowed.reduce((lowest, candidate) => {
|
||||||
|
return LEVEL_PRIORITY[candidate] < LEVEL_PRIORITY[lowest] ? candidate : lowest;
|
||||||
|
}, allowed[0] || "none");
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextValue = clampToAllowed(permissionKey, value);
|
||||||
|
|
||||||
|
|
||||||
|
setPermissionSelection((prev) => {
|
||||||
|
const currentValue = getValueWithDefaults(permissionKey, prev);
|
||||||
|
const isDowngrade = LEVEL_PRIORITY[nextValue] < LEVEL_PRIORITY[currentValue];
|
||||||
|
|
||||||
|
if (prev[permissionKey] === nextValue) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,19 +581,31 @@ export function UserGroupDetail() {
|
|||||||
[permissionKey]: nextValue
|
[permissionKey]: nextValue
|
||||||
};
|
};
|
||||||
|
|
||||||
if (shouldResetDelete) {
|
// Kaskade: Nur bei Downgrade des auslösenden Rechts abhängige Rechte herabstufen.
|
||||||
nextState["maintenance-server-delete"] = "none";
|
if (isDowngrade) {
|
||||||
}
|
let changed = true;
|
||||||
if (shouldResetEdit) {
|
while (changed) {
|
||||||
nextState["maintenance-server-edit"] = "none";
|
changed = false;
|
||||||
}
|
allItems.forEach((item) => {
|
||||||
if (shouldResetDeleteFromEdit) {
|
const dependencies = Array.isArray(item.dependencies) ? item.dependencies : [];
|
||||||
nextState["maintenance-server-delete"] = "none";
|
if (!dependencies.length) return;
|
||||||
|
const satisfied = dependencies.every((dep) =>
|
||||||
|
isDependencySatisfied(getValueWithDefaults(dep.key, nextState), dep.requiredLevel)
|
||||||
|
);
|
||||||
|
if (!satisfied) {
|
||||||
|
const cascadedValue = clampToAllowed(item.key, nextValue);
|
||||||
|
if (getValueWithDefaults(item.key, nextState) !== cascadedValue) {
|
||||||
|
nextState[item.key] = cascadedValue;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextState;
|
return nextState;
|
||||||
});
|
});
|
||||||
}, [canAdjustPermissions, isSuperuserGroup]);
|
}, [canAdjustPermissions, isSuperuserGroup, permissionDefaults, permissionSections]);
|
||||||
|
|
||||||
const getPermissionValue = useCallback(
|
const getPermissionValue = useCallback(
|
||||||
(permissionKey) => {
|
(permissionKey) => {
|
||||||
@@ -559,7 +619,7 @@ export function UserGroupDetail() {
|
|||||||
value = permissionDefaults[permissionKey];
|
value = permissionDefaults[permissionKey];
|
||||||
}
|
}
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
return normalizePermissionLevel(permissionKey, value);
|
return value;
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
@@ -572,12 +632,6 @@ export function UserGroupDetail() {
|
|||||||
),
|
),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
const radioCheckedIcon = useMemo(
|
|
||||||
() => (
|
|
||||||
<span className="mx-auto block h-2.5 w-2.5 rounded-full border border-blue-gray-700 bg-gray-900" />
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isPermissionRowVisible = useCallback(
|
const isPermissionRowVisible = useCallback(
|
||||||
(row) => {
|
(row) => {
|
||||||
@@ -617,9 +671,7 @@ export function UserGroupDetail() {
|
|||||||
|
|
||||||
const rowName = `permission-${ownerKey}-${row.key}`;
|
const rowName = `permission-${ownerKey}-${row.key}`;
|
||||||
const currentValue = getPermissionValue(row.key) ?? "none";
|
const currentValue = getPermissionValue(row.key) ?? "none";
|
||||||
const displayValue = normalizePermissionLevel(row.key, currentValue);
|
const displayValue = currentValue;
|
||||||
const serverManageLevel = getPermissionValue("maintenance-server-manage") ?? "none";
|
|
||||||
const serverEditLevel = getPermissionValue("maintenance-server-edit") ?? "none";
|
|
||||||
const { addTopBorder = true } = options;
|
const { addTopBorder = true } = options;
|
||||||
|
|
||||||
const rowClasses = [
|
const rowClasses = [
|
||||||
@@ -632,11 +684,7 @@ export function UserGroupDetail() {
|
|||||||
const baseLevels = Array.isArray(row.availableLevels) && row.availableLevels.length
|
const baseLevels = Array.isArray(row.availableLevels) && row.availableLevels.length
|
||||||
? row.availableLevels
|
? row.availableLevels
|
||||||
: LEVEL_OPTIONS.map((option) => option.value);
|
: LEVEL_OPTIONS.map((option) => option.value);
|
||||||
const availableLevels = new Set(
|
const availableLevels = new Set(baseLevels);
|
||||||
row.key === "maintenance-server-delete"
|
|
||||||
? baseLevels.filter((level) => level === "full" || level === "none")
|
|
||||||
: baseLevels
|
|
||||||
);
|
|
||||||
const levelOptions = LEVEL_OPTIONS;
|
const levelOptions = LEVEL_OPTIONS;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -648,22 +696,12 @@ export function UserGroupDetail() {
|
|||||||
{levelOptions.map((option) => {
|
{levelOptions.map((option) => {
|
||||||
const optionId = `${rowName}-${option.value}`;
|
const optionId = `${rowName}-${option.value}`;
|
||||||
const checked = displayValue === option.value;
|
const checked = displayValue === option.value;
|
||||||
const requiresManageFull =
|
|
||||||
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 =
|
const disabled =
|
||||||
!availableLevels.has(option.value) ||
|
!availableLevels.has(option.value) ||
|
||||||
permissionsLoading ||
|
permissionsLoading ||
|
||||||
savingGroup ||
|
savingGroup ||
|
||||||
!canAdjustPermissions ||
|
!canAdjustPermissions ||
|
||||||
isSuperuserGroup ||
|
isSuperuserGroup;
|
||||||
requiresManageFull ||
|
|
||||||
requiresEditFull;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -689,7 +727,6 @@ export function UserGroupDetail() {
|
|||||||
className: "p-0"
|
className: "p-0"
|
||||||
}}
|
}}
|
||||||
icon={radioIcon}
|
icon={radioIcon}
|
||||||
checkedIcon={radioCheckedIcon}
|
|
||||||
/>
|
/>
|
||||||
</ListItemPrefix>
|
</ListItemPrefix>
|
||||||
<Typography
|
<Typography
|
||||||
@@ -708,6 +745,30 @@ export function UserGroupDetail() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const groupHasVisibleRows = (grp) => {
|
||||||
|
if (!grp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const groupItems = Array.isArray(grp.items) ? grp.items : [];
|
||||||
|
const childGroups = Array.isArray(grp.groups) ? grp.groups : [];
|
||||||
|
if (groupItems.some(isPermissionRowVisible)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return childGroups.some(groupHasVisibleRows);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionHasVisibleRows = (section) => {
|
||||||
|
if (!section) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const sectionItems = Array.isArray(section.items) ? section.items : [];
|
||||||
|
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
|
||||||
|
if (sectionItems.some(isPermissionRowVisible)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return sectionGroups.some(groupHasVisibleRows);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-12 flex flex-col gap-12">
|
<div className="mt-12 flex flex-col gap-12">
|
||||||
@@ -725,9 +786,6 @@ export function UserGroupDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="relative h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover\tbg-center">
|
|
||||||
<div className="absolute inset-0 h-full w-full bg-gray-900/75" />
|
|
||||||
</div>
|
|
||||||
<Card className="mx-3 -mt-16 mb-6 lg:mx-4 border border-blue-gray-100">
|
<Card className="mx-3 -mt-16 mb-6 lg:mx-4 border border-blue-gray-100">
|
||||||
<CardBody className="p-4">
|
<CardBody className="p-4">
|
||||||
<div className="mb-10 flex flex-wrap items-center justify-between gap-6">
|
<div className="mb-10 flex flex-wrap items-center justify-between gap-6">
|
||||||
@@ -893,7 +951,7 @@ export function UserGroupDetail() {
|
|||||||
Die Superuser-Gruppe besitzt automatisch Vollzugriff auf alle Bereiche. Berechtigungen können hier nicht angepasst werden.
|
Die Superuser-Gruppe besitzt automatisch Vollzugriff auf alle Bereiche. Berechtigungen können hier nicht angepasst werden.
|
||||||
</div>
|
</div>
|
||||||
) : !canViewPermissionSettings ? null : (
|
) : !canViewPermissionSettings ? null : (
|
||||||
<div className="rounded-xl border border-blue-gray-100 bg-white shadow-sm">
|
<>
|
||||||
{permissionsLoading && (
|
{permissionsLoading && (
|
||||||
<div className="border-b border-blue-gray-100 px-4 py-4">
|
<div className="border-b border-blue-gray-100 px-4 py-4">
|
||||||
<div className="flex items-center gap-3 text-sm text-blue-gray-500">
|
<div className="flex items-center gap-3 text-sm text-blue-gray-500">
|
||||||
@@ -913,53 +971,85 @@ export function UserGroupDetail() {
|
|||||||
Für diese Gruppe sind keine Berechtigungen definiert.
|
Für diese Gruppe sind keine Berechtigungen definiert.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
permissionSections.map((section, sectionIndex) => {
|
(() => {
|
||||||
const sectionItems = Array.isArray(section.items) ? section.items : [];
|
const renderedSections = permissionSections
|
||||||
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
|
.map((section, sectionIndex) => {
|
||||||
|
const sectionItems = Array.isArray(section.items) ? section.items : [];
|
||||||
|
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
|
||||||
|
|
||||||
return (
|
if (!sectionHasVisibleRows(section)) {
|
||||||
<div
|
return null;
|
||||||
key={section.key || sectionIndex}
|
}
|
||||||
className={sectionIndex === 0 ? "" : "border-t border-blue-gray-100"}
|
|
||||||
>
|
|
||||||
<div className="border-b border-blue-gray-100 px-4 py-4">
|
|
||||||
<Typography variant="h6" className="text-lg font-semibold text-blue-gray-700">
|
|
||||||
{section.title}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
{sectionItems.map((row, rowIndex) =>
|
|
||||||
renderPermissionRow(row, section.key || sectionIndex, {
|
|
||||||
addTopBorder: rowIndex !== 0
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
{sectionGroups.map((group, groupIdx) => {
|
|
||||||
const groupItems = Array.isArray(group.items) ? group.items : [];
|
|
||||||
const hasVisibleRows = groupItems.some(isPermissionRowVisible);
|
|
||||||
|
|
||||||
if (!hasVisibleRows) {
|
const renderGroup = (currentGroup, path = `${section.key || sectionIndex}-group`, depth = 0) => {
|
||||||
|
if (!groupHasVisibleRows(currentGroup)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const groupItems = Array.isArray(currentGroup.items) ? currentGroup.items : [];
|
||||||
|
const childGroups = Array.isArray(currentGroup.groups) ? currentGroup.groups : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={group.key || `${section.key || sectionIndex}-${groupIdx}`} className="border-t border-blue-gray-100">
|
<div
|
||||||
<div className="border-b border-blue-gray-100 px-4 py-3">
|
key={currentGroup.key || path}
|
||||||
<Typography className="text-sm font-semibold uppercase tracking-wide text-blue-gray-600">
|
className=""
|
||||||
{group.title}
|
>
|
||||||
|
<div className="border-b border-blue-gray-100 bg-blue-gray-50/80 px-4 py-3">
|
||||||
|
<Typography className={`${depth > 0 ? "ml-2" : ""} text-xs font-semibold uppercase tracking-wide text-blue-gray-600`}>
|
||||||
|
{currentGroup.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
{groupItems.map((row, rowIndex) =>
|
<div className="divide-y divide-blue-gray-100">
|
||||||
renderPermissionRow(row, group.key || `${section.key || sectionIndex}-${groupIdx}`, {
|
{groupItems.map((row) =>
|
||||||
addTopBorder: rowIndex !== 0
|
renderPermissionRow(row, `${path}-${row.key}`, {
|
||||||
})
|
addTopBorder: false
|
||||||
)}
|
})
|
||||||
|
)}
|
||||||
|
{childGroups.map((childGroup, childIdx) =>
|
||||||
|
renderGroup(childGroup, `${path}-${childGroup.key || childIdx}`, depth + 1)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
};
|
||||||
</div>
|
|
||||||
);
|
return (
|
||||||
})
|
<div
|
||||||
|
key={section.key || sectionIndex}
|
||||||
|
className="overflow-hidden rounded-xl border border-blue-gray-100 bg-white shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="border-b border-blue-gray-100 bg-blue-gray-50/70 px-4 py-3">
|
||||||
|
<Typography variant="h6" className="text-lg font-semibold text-blue-gray-700">
|
||||||
|
{section.title}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-blue-gray-100">
|
||||||
|
{sectionItems.map((row) =>
|
||||||
|
renderPermissionRow(row, section.key || sectionIndex, {
|
||||||
|
addTopBorder: false
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
{sectionGroups.map((group, groupIdx) =>
|
||||||
|
renderGroup(group, `${section.key || sectionIndex}-${groupIdx}`, 0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (renderedSections.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-blue-gray-100 px-4 py-4 text-sm text-blue-gray-500">
|
||||||
|
Für diese Gruppe sind keine Berechtigungen definiert.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="flex flex-col gap-4">{renderedSections}</div>;
|
||||||
|
})()
|
||||||
))}
|
))}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -12,9 +12,12 @@ nav:
|
|||||||
- Wartung: user-guide/maintenance.md
|
- Wartung: user-guide/maintenance.md
|
||||||
- Benutzer: user-guide/users.md
|
- Benutzer: user-guide/users.md
|
||||||
- Benutzergruppen & Rechte: user-guide/groups.md
|
- Benutzergruppen & Rechte: user-guide/groups.md
|
||||||
- Technik:
|
- Getting Started:
|
||||||
- Getting Started: getting-started.md
|
- Getting Started: getting-started.md
|
||||||
|
- Umgebungsvariablen: environment.md
|
||||||
|
- Technik:
|
||||||
- Architektur: architecture.md
|
- Architektur: architecture.md
|
||||||
- Backend & API: backend.md
|
- Backend & API: backend.md
|
||||||
- Frontend: frontend.md
|
- Frontend: frontend.md
|
||||||
|
- Lokale Entwicklung: local-dev.md
|
||||||
- Operations & Wartung: operations.md
|
- Operations & Wartung: operations.md
|
||||||
|
|||||||
Reference in New Issue
Block a user