v0.5 Push

This commit is contained in:
root
2025-11-12 10:48:25 +00:00
parent fc778da204
commit 81b7613c73
30 changed files with 1853 additions and 1306 deletions
+44 -50
View File
@@ -1,6 +1,6 @@
![StackPulse Logo](assets/images/stackpulse.png)
# 📦 StackPulse ![Release](https://img.shields.io/badge/release-v0.2-blue.svg)
# 📦 StackPulse ![Release](https://img.shields.io/badge/release-v0.5-blue.svg)
**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!
@@ -67,7 +67,7 @@ Ziel:
</details>
<details open>
<details>
<summary>✅ v0.4 Release</summary>
### Backend
@@ -86,27 +86,35 @@ Ziel:
</details>
<details open>
<summary>🟡 v0.5 In Entwicklung</summary>
<summary> v0.5 Release</summary>
### 🛠️ Backend
- [x] Neue API-Endpunkte für Logging, Benutzer- und Gruppenverwaltung sowie Superuser-Registrierung
- [x] Authentifizierung und Log-In
- [x] Erweiterte Protokollierung und Datenbank-Migration
- [x] Präzisere Steuerung der globalen Benutzerrechte
### 💻 Frontend
- [x] Neue Log-In-Seite
- [x] Benutzer- und Gruppenverwaltung mit granularen Rechteeinstellungen
- [x] Erweiterte Log-Ausgabe mit Filtern und Suchoptionen
- [x] Konsolidierte und vereinheitlichte Ansichten im Dashboard
### ✨ Features
- [x] Vollständiges Authentifizierungs- und Berechtigungssystem
- [x] Erweiterte Logs in allen Bereichen
- [x] Speicherung von Server- und Superuser-Einstellungen in der Datenbank
</details>
<details open>
<summary>🟡 v0.6 In Entwicklung</summary>
### Backend
- [x] Neue API-Endpunkte für Logging, Benutzer- und Gruppenverwaltung und Superuser-Registrierung
- [x] Authentifizierung und Log-In
- [x] erweitertes Logging
- [x] Datenbank erweitert und Migration erweitert
- [ ] Feinsteuerung der globalen Userrechte am Benutzer
### Frontend
- [x] Log-In-Seite
- [ ] Benutzer- und Gruppenverwaltung
- [x] Erweiterte Log-Ausgabe
- [x] Konsolidierung der Ansichten und Filter im Frontend
- [ ] Benutzerverwaltung mit granularer Steuerung von Einzelrechten
### Features
- [ ] Authentifizierungssystem und Benutzerverwaltung mit Gruppenrechten
- [x] Erweiterung der Logs auf alle Bereiche
- [x] Speichern der Server- und Superusereinstellungen in die Datenbank
</details>
@@ -117,10 +125,8 @@ Ziel:
- Monitoring (Status, CPU/RAM)
- Verbesserte UI/UX
- Erweiterte Filterungen udn Sortierungen
- Multi-Server/Endpoint Verwaltung
- Multi-Server Verwaltung
- Integration Community Edition
- Integration Community Edition
</details>
---
@@ -158,41 +164,29 @@ stackpulse/
### Mit Compose starten
```bash
version: "2.4"
version: '3.8'
services:
stackpulse:
container_name: stackpulse
image: ghcr.io/mboehmlaender/stackpulse
ports:
- '4001:4001'
volumes:
- stackpulse_data:/app/backend/data
environment:
PORTAINER_URL: "Your_Portainer_Server_Address"
PORTAINER_API_KEY: "Your_Portainer_API_Key"
PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_ID"
SELF_STACK_ID: "Stackpulse ID"
restart: unless-stopped
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "4001:4001"
volumes:
- stackpulse_data:/app/backend/data
restart: unless-stopped
environment:
- PORTAINER_URL=Your_Portainer_Server_Adress (optional)
- PORTAINER_API_KEY=Your_Portainer_API_Key (optional)
- SUPERUSER_USERNAME=Your_Superuser_Username (optional)
- SUPERUSER_EMAIL=Your_Superuser_Email (optional)
- SUPERUSER_PASSWORD=Your_Superuser_Password (optional)
- SELF_STACK_ID=Your_StackPulse_Stack_ID (optional)
volumes:
stackpulse_data:
```
Die PORTAINER_ENDPOINT_ID erhältst du, wenn du die die URL im Browser ansiehst, wenn du das Dashboard in Portainer öffnest:
![PORTAINER_ENDPOINT_ID](assets/images/ENDPOINT_ID.png)
Die 3 wäre in diesem Fall Endpoint-ID.
Die STACK_SELF_ID findest du, wenn du das Frontend von StackPulse öffnest:
![SELF_STACK_ID](assets/images/SELF_STACK_ID.png)
Diese ID kann erst nach dem Deploy von Stackpulse ausgelesen werden. Vergiss daher nicht, nach dem Hinterlegen der ID in den Variablen das Stack noch einmal zu redeployen!
---
## 📋 Voraussetzungen
- Node.js >= 20
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

+3 -4
View File
@@ -1,7 +1,6 @@
PORTAINER_URL=Your_Portainer_Server_Adress
PORTAINER_API_KEY=Your_Portainer_API_Key
PORTAINER_ENDPOINT_ID=Your_Portainer_Endpoint_ID
PORTAINER_URL=Your_Portainer_Server_Adress (optional)
PORTAINER_API_KEY=Your_Portainer_API_Key (optional)
SUPERUSER_USERNAME=Your_Superuser_Username (optional)
SUPERUSER_EMAIL=Your_Superuser_Email (optional)
SUPERUSER_PASSWORD=Your_Superuser_Password (optional)
SELF_STACK_ID=Your_StackPulse_Stack_ID
SELF_STACK_ID=Your_StackPulse_Stack_ID (optional)
-27
View File
@@ -159,18 +159,6 @@ CREATE TABLE servers (
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
name TEXT NOT NULL,
external_id TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
UNIQUE (server_id, external_id)
);
CREATE TABLE user_server_permission_overrides (
user_id INTEGER NOT NULL,
server_id INTEGER NOT NULL,
@@ -186,21 +174,6 @@ CREATE TABLE user_server_permission_overrides (
CREATE INDEX idx_user_server_permission_overrides_user_server
ON user_server_permission_overrides (user_id, server_id);
CREATE TABLE user_endpoint_permission_overrides (
user_id INTEGER NOT NULL,
endpoint_id INTEGER NOT NULL,
permission_id INTEGER NOT NULL,
change_type TEXT NOT NULL CHECK (change_type IN ('ADD', 'REMOVE')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, endpoint_id, permission_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (endpoint_id) REFERENCES endpoints(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
);
CREATE INDEX idx_user_endpoint_permission_overrides_user_endpoint
ON user_endpoint_permission_overrides (user_id, endpoint_id);
CREATE TABLE server_api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL UNIQUE,
+33 -5
View File
@@ -177,12 +177,12 @@ const PERMISSION_BLUEPRINT = [
groups: [
{
key: 'maintenance-server-group',
title: 'Server & Endpoints',
title: 'Server',
sortOrder: 0,
items: [
{
key: 'maintenance-server-manage',
label: 'Server/Endpoint-Sektion',
label: 'Server-Sektion',
sortOrder: 0,
defaultLevel: 'none',
levels: ['full', 'read', 'none'],
@@ -192,7 +192,7 @@ const PERMISSION_BLUEPRINT = [
},
{
key: 'maintenance-server-delete',
label: 'Server/Endpoint löschen',
label: 'Server löschen',
sortOrder: 1,
defaultLevel: 'none',
levels: ['full', 'none'],
@@ -203,10 +203,27 @@ const PERMISSION_BLUEPRINT = [
}
]
},
{
key: 'maintenance-superuser-group',
title: 'Superuser',
sortOrder: 1,
items: [
{
key: 'maintenance-superuser-delete',
label: 'Superuser löschen',
sortOrder: 0,
defaultLevel: 'none',
levels: ['full', 'none'],
dependencies: [
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
]
}
]
},
{
key: 'maintenance-portainer-group',
title: 'Portainer',
sortOrder: 1,
sortOrder: 2,
items: [
{
key: 'maintenance-portainer',
@@ -245,7 +262,7 @@ const PERMISSION_BLUEPRINT = [
{
key: 'maintenance-duplicates-group',
title: 'Doppelte Stacks',
sortOrder: 2,
sortOrder: 3,
items: [
{
key: 'maintenance-duplicates',
@@ -350,6 +367,16 @@ const tableExists = (tableName) => {
return Boolean(result);
};
const dropDeprecatedArtifacts = () => {
const deprecatedTables = ['user_endpoint_permission_overrides', 'endpoints'];
deprecatedTables.forEach((table) => {
if (tableExists(table)) {
db.exec(`DROP TABLE IF EXISTS ${table}`);
console.log(`️ Veraltete Tabelle ${table} entfernt`);
}
});
};
const getExistingColumns = (tableName) => {
const pragma = db.prepare(`PRAGMA table_info(${tableName})`).all();
return new Set(pragma.map((column) => column.name));
@@ -701,6 +728,7 @@ export const ensureDatabaseSchema = () => {
const tableDefinitions = parseTableDefinitions(statements);
const indexDefinitions = parseIndexStatements(statements);
dropDeprecatedArtifacts();
ensureTablesAndColumns(tableDefinitions);
ensureIndexes(indexDefinitions);
ensurePermissionSeeds();
+349 -183
View File
@@ -34,15 +34,12 @@ import { getSetting, setSetting, deleteSetting } from './db/settings.js';
import { activateMaintenanceMode, deactivateMaintenanceMode, getMaintenanceState, isMaintenanceModeActive } from './maintenance/state.js';
import {
ensureDefaultsFromEnv,
getActiveEndpointExternalId,
getActiveApiKey,
getActiveServerUrl,
hasServer,
hasEndpoint,
hasApiKey,
getSetupStatus,
completeSetup,
removeEndpoint,
removeServer,
setServerApiKey
} from './setup/index.js';
@@ -101,16 +98,6 @@ app.get('*', (req, res, next) => {
const PORT = 4001;
const requireActiveEndpointId = () => {
const value = getActiveEndpointExternalId();
if (!value) {
const error = new Error('ENDPOINT_NOT_CONFIGURED');
error.code = 'ENDPOINT_NOT_CONFIGURED';
throw error;
}
return value;
};
const agent = new https.Agent({ rejectUnauthorized: false });
const resolvePortainerBaseUrl = () => {
@@ -142,6 +129,111 @@ axiosInstance.interceptors.request.use((config) => {
return config;
});
const PORTAINER_ENVIRONMENT_CACHE_TTL_MS = 5 * 60 * 1000;
let cachedPortainerEnvironment = { id: null, fetchedAt: 0 };
let resolvingEnvironmentPromise = null;
const resolvePortainerEnvironmentId = async () => {
const now = Date.now();
if (cachedPortainerEnvironment.id && now - cachedPortainerEnvironment.fetchedAt < PORTAINER_ENVIRONMENT_CACHE_TTL_MS) {
return cachedPortainerEnvironment.id;
}
if (resolvingEnvironmentPromise) {
return resolvingEnvironmentPromise;
}
resolvingEnvironmentPromise = (async () => {
try {
const endpointsRes = await axiosInstance.get('/api/endpoints');
const endpoints = Array.isArray(endpointsRes.data) ? endpointsRes.data : [];
if (!endpoints.length) {
const error = new Error('PORTAINER_ENVIRONMENT_NOT_FOUND');
error.code = 'PORTAINER_ENVIRONMENT_NOT_FOUND';
throw error;
}
const preferred =
endpoints.find((endpoint) => endpoint.Type === 1 && (!endpoint.URL || endpoint.URL === '')) ||
endpoints[0];
const resolvedId = preferred?.Id ?? preferred?.ID ?? null;
if (!resolvedId) {
const error = new Error('PORTAINER_ENVIRONMENT_NOT_FOUND');
error.code = 'PORTAINER_ENVIRONMENT_NOT_FOUND';
throw error;
}
cachedPortainerEnvironment = {
id: String(resolvedId),
fetchedAt: Date.now()
};
return cachedPortainerEnvironment.id;
} catch (error) {
const normalized = error.response?.data?.message || error.message || 'PORTAINER_ENVIRONMENT_NOT_FOUND';
console.error('⚠️ [Portainer] Environment-Erkennung fehlgeschlagen:', normalized);
const wrapped = new Error('PORTAINER_ENVIRONMENT_NOT_FOUND');
wrapped.code = 'PORTAINER_ENVIRONMENT_NOT_FOUND';
throw wrapped;
} finally {
resolvingEnvironmentPromise = null;
}
})();
return resolvingEnvironmentPromise;
};
const normalizeExternalPortainerUrl = (value = '') => {
if (typeof value !== 'string') return '';
const trimmed = value.trim();
if (!trimmed) return '';
try {
const hasProtocol = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed);
const candidate = hasProtocol ? trimmed : `https://${trimmed}`;
const normalized = new URL(candidate);
normalized.hash = '';
return normalized.toString().replace(/\/$/, '');
} catch {
return '';
}
};
const createPortainerSetupClient = ({ baseURL, apiKey }) => {
if (!baseURL) {
const error = new Error('SERVER_URL_REQUIRED');
error.code = 'SERVER_URL_REQUIRED';
throw error;
}
if (!apiKey) {
const error = new Error('API_KEY_REQUIRED');
error.code = 'API_KEY_REQUIRED';
throw error;
}
return axios.create({
baseURL,
httpsAgent: agent,
headers: {
'X-API-Key': apiKey
}
});
};
const extractPortainerCredentials = (payload = {}) => {
const rawServer = payload.server ?? {};
const serverUrl =
typeof rawServer.url === 'string' ? rawServer.url :
typeof payload.serverUrl === 'string' ? payload.serverUrl :
typeof payload.url === 'string' ? payload.url : '';
const apiKey =
typeof payload.apiKey === 'string' ? payload.apiKey :
typeof payload.key === 'string' ? payload.key :
typeof payload.api === 'string' ? payload.api :
typeof payload?.api?.value === 'string' ? payload.api.value :
'';
return {
serverUrl: serverUrl ? serverUrl.trim() : '',
apiKey: apiKey ? apiKey.trim() : ''
};
};
const REDEPLOY_PHASES = {
QUEUED: 'queued',
STARTED: 'started',
@@ -157,10 +249,20 @@ const DEFAULT_SESSION_TTL_MS = 1000 * 60 * 60 * 12;
const envSessionTtl = Number(process.env.AUTH_SESSION_TTL_MS);
const AUTH_SESSION_TTL_MS = Number.isFinite(envSessionTtl) && envSessionTtl > 0 ? envSessionTtl : DEFAULT_SESSION_TTL_MS;
const parseBooleanEnv = (value, fallback) => {
if (value === undefined || value === null) return fallback;
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
};
const AUTH_COOKIE_SECURE = parseBooleanEnv(process.env.AUTH_COOKIE_SECURE, false);
const AUTH_COOKIE_OPTIONS = {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
secure: AUTH_COOKIE_SECURE,
path: '/'
};
@@ -177,7 +279,9 @@ const PUBLIC_API_ROUTES = [
{ method: 'POST', matcher: /^\/api\/auth\/recover\/verify$/ },
{ method: 'POST', matcher: /^\/api\/auth\/recover\/reset$/ },
{ method: 'GET', matcher: /^\/api\/setup\/status$/ },
{ method: 'POST', matcher: /^\/api\/setup\/complete$/ }
{ method: 'POST', matcher: /^\/api\/setup\/complete$/ },
{ method: 'POST', matcher: /^\/api\/setup\/test-portainer$/ },
{ method: 'POST', matcher: /^\/api\/setup\/portainer-stacks$/ }
];
const sanitizeUser = (user) => ({
@@ -422,7 +526,6 @@ const logStackEvent = ({
status,
message,
redeployType = null,
endpointId = null,
metadata = {}
}) => {
const metadataPayload = {
@@ -433,11 +536,8 @@ const logStackEvent = ({
metadataPayload.redeployType = redeployType;
}
if (endpointId !== undefined && endpointId !== null) {
metadataPayload.endpointId = String(endpointId);
}
const hasMetadata = Object.keys(metadataPayload).length > 0;
const serverContextId = getActiveServerUrl() || null;
logEvent({
category: 'stack',
@@ -447,8 +547,8 @@ const logStackEvent = ({
entityType: 'stack',
entityId: stackId !== undefined && stackId !== null ? String(stackId) : null,
entityName: stackName ?? null,
contextType: endpointId !== undefined && endpointId !== null ? 'endpoint' : null,
contextId: endpointId !== undefined && endpointId !== null ? String(endpointId) : null,
contextType: serverContextId ? 'server' : null,
contextId: serverContextId,
message: message ?? null,
metadata: hasMetadata ? metadataPayload : null
});
@@ -463,7 +563,76 @@ const parseJsonColumn = (value) => {
}
};
const SELF_STACK_ID = process.env.SELF_STACK_ID ? String(process.env.SELF_STACK_ID) : null;
const SELF_STACK_SETTING_KEY = 'self_stack_id';
let cachedSelfStackId = null;
let selfStackLoaded = false;
const loadSelfStackId = () => {
try {
const stored = getSetting(SELF_STACK_SETTING_KEY);
if (stored && stored.value) {
cachedSelfStackId = String(stored.value);
} else if (process.env.SELF_STACK_ID) {
cachedSelfStackId = String(process.env.SELF_STACK_ID);
} else {
cachedSelfStackId = null;
}
} catch (error) {
console.warn('⚠️ [Setup] Konnte Self-Stack-ID nicht laden:', error.message);
cachedSelfStackId = process.env.SELF_STACK_ID ? String(process.env.SELF_STACK_ID) : null;
} finally {
selfStackLoaded = true;
}
};
const getSelfStackId = () => {
if (!selfStackLoaded) {
loadSelfStackId();
}
return cachedSelfStackId;
};
const persistSelfStackId = (value) => {
const normalized = typeof value === 'string'
? value.trim()
: value !== undefined && value !== null
? String(value).trim()
: '';
if (!normalized) {
deleteSetting(SELF_STACK_SETTING_KEY);
cachedSelfStackId = process.env.SELF_STACK_ID ? String(process.env.SELF_STACK_ID) : null;
} else {
setSetting(SELF_STACK_SETTING_KEY, normalized);
cachedSelfStackId = normalized;
}
selfStackLoaded = true;
return cachedSelfStackId;
};
const getEnvSelfStackId = () => {
const raw = typeof process.env.SELF_STACK_ID === 'string' ? process.env.SELF_STACK_ID : '';
return raw.trim();
};
const applySelfStackStatus = (status) => {
const envSelfStackId = getEnvSelfStackId();
const currentSelfStackId = getSelfStackId();
const envDefaults = { ...(status.envDefaults || {}) };
envDefaults.selfStackId = currentSelfStackId || envSelfStackId || envDefaults.selfStackId || '';
return {
...status,
envDefaults,
selfStack: {
current: currentSelfStackId,
envProvided: Boolean(envSelfStackId)
}
};
};
const buildSetupStatusResponse = () => applySelfStackStatus(getSetupStatus());
const PORTAINER_SCRIPT_SETTING_KEY = 'portainer_update_script';
const PORTAINER_SSH_CONFIG_KEY = 'portainer_ssh_config';
@@ -810,7 +979,7 @@ const testSshConnection = async (configOverride = null) => {
const detectPortainerContainer = async () => {
try {
const endpointId = requireActiveEndpointId();
const endpointId = await resolvePortainerEnvironmentId();
const containersRes = await axiosInstance.get(`/api/endpoints/${endpointId}/docker/containers/json`, {
params: { all: true }
});
@@ -1128,7 +1297,7 @@ let currentPortainerUpdatePromise = null;
const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) => {
let maintenanceActivated = false;
const endpointId = requireActiveEndpointId();
const serverContextId = getActiveServerUrl() || null;
try {
addUpdateLog(`Portainer-Update gestartet (Quelle: ${scriptSource})`, 'info');
@@ -1158,8 +1327,8 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
entityType: 'service',
entityId: 'portainer',
entityName: 'Portainer',
contextType: 'endpoint',
contextId: String(endpointId),
contextType: serverContextId ? 'server' : null,
contextId: serverContextId,
message: `Portainer Update gestartet (Ziel: ${targetVersion ?? 'unbekannt'})`,
metadata: {
targetVersion: targetVersion ?? null,
@@ -1176,8 +1345,8 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
entityType: 'system',
entityId: 'wartung',
entityName: 'StackPulse Wartung',
contextType: 'endpoint',
contextId: String(endpointId),
contextType: serverContextId ? 'server' : null,
contextId: serverContextId,
message: 'Wartungsmodus aktiviert (Portainer Update)',
metadata: {
reason: 'portainer-update',
@@ -1215,8 +1384,8 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
entityType: 'service',
entityId: 'portainer',
entityName: 'Portainer',
contextType: 'endpoint',
contextId: String(endpointId),
contextType: serverContextId ? 'server' : null,
contextId: serverContextId,
message: `Portainer Update abgeschlossen (Version: ${finalVersion ?? 'unbekannt'})`,
metadata: {
resultVersion: finalVersion ?? null
@@ -1247,8 +1416,8 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
entityType: 'system',
entityId: 'wartung',
entityName: 'StackPulse Wartung',
contextType: 'endpoint',
contextId: String(endpointId),
contextType: serverContextId ? 'server' : null,
contextId: serverContextId,
message: 'Wartungsmodus deaktiviert',
metadata: {
reason: 'portainer-update'
@@ -1266,8 +1435,8 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
entityType: 'service',
entityId: 'portainer',
entityName: 'Portainer',
contextType: 'endpoint',
contextId: String(endpointId),
contextType: serverContextId ? 'server' : null,
contextId: serverContextId,
message,
source: 'system'
});
@@ -1287,19 +1456,19 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
deactivateMaintenanceMode({ message: 'Portainer Update fehlgeschlagen' });
logEvent({
category: 'wartung',
eventType: 'Wartungsmodus',
action: 'deaktivieren',
status: 'fehler',
entityType: 'system',
entityId: 'wartung',
entityName: 'StackPulse Wartung',
contextType: 'endpoint',
contextId: String(endpointId),
message: 'Wartungsmodus deaktiviert (Fehler)',
metadata: {
reason: 'portainer-update'
},
source: 'system'
eventType: 'Wartungsmodus',
action: 'deaktivieren',
status: 'fehler',
entityType: 'system',
entityId: 'wartung',
entityName: 'StackPulse Wartung',
contextType: serverContextId ? 'server' : null,
contextId: serverContextId,
message: 'Wartungsmodus deaktiviert (Fehler)',
metadata: {
reason: 'portainer-update'
},
source: 'system'
});
}
} finally {
@@ -1308,17 +1477,17 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
};
const fetchPortainerStacks = async () => {
const endpointId = requireActiveEndpointId();
const stacksRes = await axiosInstance.get('/api/stacks');
return stacksRes.data.filter((stack) => String(stack.EndpointId) === String(endpointId));
return Array.isArray(stacksRes.data) ? stacksRes.data : [];
};
const buildStackCollections = (stacks = []) => {
const currentSelfStackId = getSelfStackId();
const collections = new Map();
stacks.forEach((stack) => {
const name = stack.Name || 'Unbenannt';
const isSelf = SELF_STACK_ID && String(stack.Id) === SELF_STACK_ID;
const isSelf = currentSelfStackId && String(stack.Id) === currentSelfStackId;
const entry = collections.get(name);
if (!entry) {
@@ -1390,10 +1559,11 @@ const isStackOutdated = async (stackId) => {
};
const filterOutdatedStacks = async (stacks = []) => {
const currentSelfStackId = getSelfStackId();
const results = await Promise.all(
stacks.map(async (stack) => ({
stack,
outdated: SELF_STACK_ID && String(stack.Id) === SELF_STACK_ID
outdated: currentSelfStackId && String(stack.Id) === currentSelfStackId
? false
: await isStackOutdated(stack.Id)
}))
@@ -1427,16 +1597,11 @@ const shouldFallbackToStackFile = (message) => {
const redeployStackById = async (stackId, redeployType) => {
let stack;
const messages = getRedeployMessages(redeployType);
const endpointId = requireActiveEndpointId();
try {
const stackRes = await axiosInstance.get(`/api/stacks/${stackId}`);
stack = stackRes.data;
if (String(stack.EndpointId) !== String(endpointId)) {
throw new Error(`Stack gehört nicht zum Endpoint ${endpointId}`);
}
const targetId = stack.Id || stackId;
const targetName = stack.Name || `Stack ${stackId}`;
@@ -1451,7 +1616,6 @@ const redeployStackById = async (stackId, redeployType) => {
stackName: targetName,
status: 'started',
message: messages.started,
endpointId: stack.EndpointId,
redeployType
});
@@ -1531,7 +1695,6 @@ const redeployStackById = async (stackId, redeployType) => {
stackName: targetName,
status: 'success',
message: messages.success,
endpointId: stack.EndpointId,
redeployType
});
@@ -1553,7 +1716,6 @@ const redeployStackById = async (stackId, redeployType) => {
stackName: fallbackName,
status: 'error',
message: errorMessage,
endpointId: stack?.EndpointId || endpointId,
redeployType
});
@@ -1575,7 +1737,7 @@ app.use((req, res, next) => {
return res.status(403).json({ error: 'SUPERUSER_REQUIRED' });
}
if (!hasServer() || !hasEndpoint() || !hasApiKey()) {
if (!hasServer() || !hasApiKey()) {
return res.status(403).json({ error: 'SETUP_REQUIRED' });
}
@@ -1623,11 +1785,11 @@ app.use((req, res, next) => {
next();
});
// --- API Endpoints ---
// --- API Routes ---
app.get('/api/setup/status', (req, res) => {
try {
const status = getSetupStatus();
const status = buildSetupStatusResponse();
res.json(status);
} catch (error) {
console.error('⚠️ [Setup] Statusabfrage fehlgeschlagen:', error);
@@ -1635,12 +1797,60 @@ app.get('/api/setup/status', (req, res) => {
}
});
app.post('/api/setup/test-portainer', async (req, res) => {
const { serverUrl, apiKey } = extractPortainerCredentials(req.body);
const normalizedUrl = normalizeExternalPortainerUrl(serverUrl);
if (!normalizedUrl) {
return res.status(400).json({ error: 'SERVER_URL_INVALID' });
}
if (!apiKey) {
return res.status(400).json({ error: 'API_KEY_REQUIRED' });
}
try {
const client = createPortainerSetupClient({ baseURL: normalizedUrl, apiKey });
const statusRes = await client.get('/api/status');
res.json({
success: true,
status: statusRes.data ?? null
});
} catch (error) {
const message = error.response?.data?.message || error.message || 'PORTAINER_TEST_FAILED';
const status = error.response?.status ?? 400;
res.status(status === 401 ? 401 : 400).json({ error: 'PORTAINER_TEST_FAILED', message });
}
});
app.post('/api/setup/portainer-stacks', async (req, res) => {
const { serverUrl, apiKey } = extractPortainerCredentials(req.body);
const normalizedUrl = normalizeExternalPortainerUrl(serverUrl);
if (!normalizedUrl) {
return res.status(400).json({ error: 'SERVER_URL_INVALID' });
}
if (!apiKey) {
return res.status(400).json({ error: 'API_KEY_REQUIRED' });
}
try {
const client = createPortainerSetupClient({ baseURL: normalizedUrl, apiKey });
const stacksRes = await client.get('/api/stacks');
const stacks = Array.isArray(stacksRes.data) ? stacksRes.data : [];
res.json({ success: true, stacks });
} catch (error) {
const message = error.response?.data?.message || error.message || 'PORTAINER_STACK_FETCH_FAILED';
const status = error.response?.status ?? 400;
res.status(status === 401 ? 401 : 400).json({ error: 'PORTAINER_STACK_FETCH_FAILED', message });
}
});
app.post('/api/setup/complete', (req, res) => {
const { superuser: superuserInput, server: serverInput, endpoint: endpointInput, apiKey: apiKeyInput } = req.body ?? {};
const { superuser: superuserInput, server: serverInput, apiKey: apiKeyInput, selfStackId: selfStackInput } = req.body ?? {};
const hasSelfStackInput = req.body ? Object.prototype.hasOwnProperty.call(req.body, 'selfStackId') : false;
const initialStatus = getSetupStatus();
const needsSuperuser = initialStatus.requirements.superuser;
const needsServer = initialStatus.requirements.server;
const needsEndpoint = initialStatus.requirements.endpoint;
const needsApiKey = initialStatus.requirements.apiKey;
const created = {};
@@ -1658,22 +1868,6 @@ app.post('/api/setup/complete', (req, res) => {
};
};
const normalizeEndpointInput = (input) => {
if (!input) return null;
const name = typeof input.name === 'string' ? input.name.trim() : '';
const externalRaw = input.externalId !== undefined && input.externalId !== null
? String(input.externalId).trim()
: '';
const serverIdRaw = input.serverId !== undefined && input.serverId !== null ? Number(input.serverId) : null;
const serverId = Number.isFinite(serverIdRaw) ? serverIdRaw : null;
return {
name: name || null,
externalId: externalRaw || '',
serverId
};
};
const normalizeApiKeyInput = (input) => {
if (!input) return null;
if (typeof input === 'string') {
@@ -1691,53 +1885,20 @@ app.post('/api/setup/complete', (req, res) => {
};
try {
let serverPayload = normalizeServerInput(serverInput);
let endpointPayload = normalizeEndpointInput(endpointInput);
const serverPayload = normalizeServerInput(serverInput);
const apiKeyPayload = normalizeApiKeyInput(apiKeyInput);
if (needsServer && (!serverPayload || !serverPayload.url)) {
return res.status(400).json({ error: 'SERVER_DETAILS_REQUIRED' });
}
if (endpointPayload && !endpointPayload.externalId) {
endpointPayload.externalId = '';
}
if (needsEndpoint && (!endpointPayload || !endpointPayload.externalId)) {
const fallbackExternal = initialStatus.envDefaults.endpointExternalId?.trim();
if (fallbackExternal) {
endpointPayload = endpointPayload || {};
endpointPayload.externalId = fallbackExternal;
endpointPayload.name = endpointPayload.name || initialStatus.envDefaults.endpointName || `Endpoint ${fallbackExternal}`;
} else {
return res.status(400).json({ error: 'ENDPOINT_DETAILS_REQUIRED' });
}
}
const shouldHandleInfrastructure =
needsServer ||
needsEndpoint ||
(serverPayload && serverPayload.url) ||
(endpointPayload && endpointPayload.externalId);
if (shouldHandleInfrastructure) {
const endpointInputPayload = endpointPayload;
if (!endpointInputPayload || !endpointInputPayload.externalId) {
return res.status(400).json({ error: 'ENDPOINT_DETAILS_REQUIRED' });
}
if (needsServer || (serverPayload && serverPayload.url)) {
const setupResult = completeSetup({
server: serverPayload && serverPayload.url ? serverPayload : null,
endpoint: endpointInputPayload
server: serverPayload && serverPayload.url ? serverPayload : null
});
created.server = setupResult.server;
created.endpoint = setupResult.endpoint;
created.defaultEndpoint = setupResult.defaultEndpoint;
}
let targetServerId = apiKeyPayload?.serverId ?? created.server?.id ?? endpointPayload?.serverId ?? null;
if (needsSuperuser) {
const username = typeof superuserInput?.username === 'string' ? superuserInput.username.trim() : '';
const email = typeof superuserInput?.email === 'string' ? superuserInput.email.trim() : '';
@@ -1753,6 +1914,7 @@ app.post('/api/setup/complete', (req, res) => {
const statusSnapshot = getSetupStatus();
let targetServerId = apiKeyPayload?.serverId ?? created.server?.id ?? null;
if (!targetServerId) {
targetServerId = statusSnapshot.servers.items?.[0]?.id ?? null;
}
@@ -1767,8 +1929,18 @@ app.post('/api/setup/complete', (req, res) => {
return res.status(400).json({ error: 'API_KEY_REQUIRED' });
}
const finalStatus = getSetupStatus();
created.defaultEndpoint = finalStatus.endpoints.default;
if (hasSelfStackInput) {
const normalizedSelfStackId = typeof selfStackInput === 'string'
? selfStackInput.trim()
: selfStackInput !== undefined && selfStackInput !== null
? String(selfStackInput).trim()
: '';
const nextSelfStackId = persistSelfStackId(normalizedSelfStackId);
created.selfStackId = nextSelfStackId || null;
}
const finalStatus = buildSetupStatusResponse();
res.status(201).json({
success: finalStatus.setupComplete,
status: finalStatus,
@@ -1780,8 +1952,6 @@ app.post('/api/setup/complete', (req, res) => {
case 'SERVER_URL_REQUIRED':
case 'SERVER_NAME_REQUIRED':
case 'SERVER_DETAILS_REQUIRED':
case 'ENDPOINT_DETAILS_REQUIRED':
case 'ENDPOINT_EXTERNAL_ID_REQUIRED':
case 'USERNAME_REQUIRED':
case 'EMAIL_INVALID':
case 'PASSWORD_TOO_SHORT':
@@ -1802,31 +1972,6 @@ app.post('/api/setup/complete', (req, res) => {
}
});
app.delete('/api/setup/endpoints/:id', requirePermission('maintenance-server-delete', 'full'), (req, res) => {
const { id } = req.params;
const numericId = Number(id);
if (!Number.isFinite(numericId)) {
return res.status(400).json({ error: 'ENDPOINT_ID_INVALID' });
}
try {
const result = removeEndpoint(numericId);
const status = getSetupStatus();
res.json({ success: true, removed: result, status });
} catch (error) {
const code = error.code || error.message;
switch (code) {
case 'ENDPOINT_ID_INVALID':
return res.status(400).json({ error: 'ENDPOINT_ID_INVALID' });
case 'ENDPOINT_NOT_FOUND':
return res.status(404).json({ error: 'ENDPOINT_NOT_FOUND' });
default:
console.error('⚠️ [Setup] Endpoint konnte nicht gelöscht werden:', error);
return res.status(500).json({ error: 'ENDPOINT_DELETE_FAILED' });
}
}
});
app.delete('/api/setup/servers/:id', requirePermission('maintenance-server-delete', 'full'), (req, res) => {
const { id } = req.params;
const numericId = Number(id);
@@ -1836,7 +1981,7 @@ app.delete('/api/setup/servers/:id', requirePermission('maintenance-server-delet
try {
const result = removeServer(numericId);
const status = getSetupStatus();
const status = buildSetupStatusResponse();
res.json({ success: true, removed: result, status });
} catch (error) {
const code = error.code || error.message;
@@ -1863,7 +2008,7 @@ app.put('/api/setup/servers/:id/api-key', requirePermission('maintenance-server-
try {
const result = setServerApiKey({ serverId: numericId, apiKey: rawValue });
const status = getSetupStatus();
const status = buildSetupStatusResponse();
res.json({ success: true, updated: result, status });
} catch (error) {
const code = error.code || error.message;
@@ -1884,12 +2029,39 @@ app.put('/api/setup/servers/:id/api-key', requirePermission('maintenance-server-
}
});
app.put('/api/setup/self-stack', requirePermission('maintenance-server-manage', 'full'), (req, res) => {
const hasPayload = req.body ? Object.prototype.hasOwnProperty.call(req.body, 'selfStackId') : false;
if (!hasPayload) {
return res.status(400).json({ error: 'SELF_STACK_ID_MISSING' });
}
const inputValue = req.body.selfStackId;
const normalizedSelfStackId = typeof inputValue === 'string'
? inputValue.trim()
: inputValue !== undefined && inputValue !== null
? String(inputValue).trim()
: '';
try {
const nextValue = persistSelfStackId(normalizedSelfStackId) || null;
const status = buildSetupStatusResponse();
res.json({
success: true,
value: nextValue,
status
});
} catch (error) {
console.error('⚠️ [Setup] Self-Stack-ID konnte nicht aktualisiert werden:', error);
res.status(500).json({ error: 'SELF_STACK_ID_UPDATE_FAILED' });
}
});
app.post('/api/auth/login', (req, res) => {
if (!hasSuperuser()) {
return res.status(403).json({ error: 'SUPERUSER_REQUIRED' });
}
if (!hasServer() || !hasEndpoint() || !hasApiKey()) {
if (!hasServer() || !hasApiKey()) {
return res.status(403).json({ error: 'SETUP_REQUIRED' });
}
@@ -2006,7 +2178,7 @@ app.get('/api/auth/session', (req, res) => {
return res.status(403).json({ error: 'SUPERUSER_REQUIRED' });
}
if (!hasServer() || !hasEndpoint() || !hasApiKey()) {
if (!hasServer() || !hasApiKey()) {
return res.status(403).json({ error: 'SETUP_REQUIRED' });
}
@@ -2571,7 +2743,7 @@ app.post('/api/auth/superuser/register', (req, res) => {
}
});
app.delete('/api/auth/superuser', (req, res) => {
app.delete('/api/auth/superuser', requirePermission('maintenance-superuser-delete', 'full'), (req, res) => {
if (!hasSuperuser()) {
return res.status(404).json({ error: 'SUPERUSER_NOT_FOUND' });
}
@@ -2600,6 +2772,7 @@ app.get('/api/stacks', maintenanceGuard, async (req, res) => {
console.warn(`⚠️ [API] GET /api/stacks: Doppelte Stack-Namen erkannt: ${duplicateNames.join(', ')}`);
}
const currentSelfStackId = getSelfStackId();
const stacksWithStatus = await Promise.all(
canonicalStacks.map(async (stack) => {
try {
@@ -2615,7 +2788,7 @@ app.get('/api/stacks', maintenanceGuard, async (req, res) => {
redeploying: redeployPhase === REDEPLOY_PHASES.STARTED || redeployPhase === REDEPLOY_PHASES.QUEUED,
redeployPhase,
redeployQueued: redeployPhase === REDEPLOY_PHASES.QUEUED,
redeployDisabled: SELF_STACK_ID ? String(stack.Id) === SELF_STACK_ID : false,
redeployDisabled: currentSelfStackId ? String(stack.Id) === currentSelfStackId : false,
duplicateName: duplicateNameSet.has(stack.Name)
};
} catch (err) {
@@ -2628,7 +2801,7 @@ app.get('/api/stacks', maintenanceGuard, async (req, res) => {
redeploying: redeployPhase === REDEPLOY_PHASES.STARTED || redeployPhase === REDEPLOY_PHASES.QUEUED,
redeployPhase,
redeployQueued: redeployPhase === REDEPLOY_PHASES.QUEUED,
redeployDisabled: SELF_STACK_ID ? String(stack.Id) === SELF_STACK_ID : false,
redeployDisabled: currentSelfStackId ? String(stack.Id) === currentSelfStackId : false,
duplicateName: duplicateNameSet.has(stack.Name)
};
}
@@ -2959,7 +3132,6 @@ app.post(
stackName: target.canonical.Name,
status: 'started',
message: `Bereinigung doppelter Stacks gestartet (${duplicatesToDelete.length} Einträge)`,
endpointId: target.canonical.EndpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE,
metadata: {
duplicateIds: duplicateIds
@@ -2978,7 +3150,6 @@ app.post(
results.push({
id: stack.Id,
name: stack.Name,
endpointId: stack.EndpointId,
status: 'deleted'
});
} catch (err) {
@@ -2988,7 +3159,6 @@ app.post(
results.push({
id: stack.Id,
name: stack.Name,
endpointId: stack.EndpointId,
status: 'error',
message
});
@@ -3002,7 +3172,6 @@ app.post(
stackName: target.canonical.Name,
status: 'error',
message: `Bereinigung fehlgeschlagen für IDs: ${failedIds}`,
endpointId: target.canonical.EndpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE,
metadata: {
duplicateIds: duplicateIds
@@ -3013,8 +3182,7 @@ app.post(
success: false,
canonical: {
id: target.canonical.Id,
name: target.canonical.Name,
endpointId: target.canonical.EndpointId
name: target.canonical.Name
},
results
});
@@ -3025,7 +3193,6 @@ app.post(
stackName: target.canonical.Name,
status: 'success',
message: `Bereinigung abgeschlossen. Entfernte IDs: ${results.map((entry) => entry.id).join(', ')}`,
endpointId: target.canonical.EndpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE,
metadata: {
removedDuplicates: results.filter((entry) => entry.status === 'deleted').map((entry) => entry.id)
@@ -3036,8 +3203,7 @@ app.post(
success: true,
canonical: {
id: target.canonical.Id,
name: target.canonical.Name,
endpointId: target.canonical.EndpointId
name: target.canonical.Name
},
removed: results.length,
results
@@ -3107,7 +3273,9 @@ app.get('/api/logs', requirePermission('logs-access', 'read'), (req, res) => {
const metadata = parseJsonColumn(row.metadata);
const legacyStack = row.entityType === 'stack' ? (row.entityId ?? null) : null;
const legacyStackName = row.entityType === 'stack' ? (row.entityName ?? row.entityId ?? null) : null;
const legacyEndpoint = row.contextType === 'endpoint' ? (row.contextId ?? null) : null;
const serverContext = row.contextType === 'server'
? (row.contextId ?? null)
: null;
return {
...row,
@@ -3118,7 +3286,7 @@ app.get('/api/logs', requirePermission('logs-access', 'read'), (req, res) => {
stackId: legacyStack,
stackName: legacyStackName,
redeployType: row.eventType ?? null,
endpoint: legacyEndpoint
server: serverContext
};
});
const total = db.prepare(countQuery).get(params)?.total ?? logs.length;
@@ -3204,12 +3372,11 @@ app.put(
async (req, res) => {
console.log(`🚀 PUT /api/stacks/redeploy-all: Redeploy ALL gestartet`);
let endpointId;
const serverContextId = getActiveServerUrl() || null;
try {
endpointId = requireActiveEndpointId();
const stacksRes = await axiosInstance.get('/api/stacks');
const filteredStacks = stacksRes.data.filter(stack => String(stack.EndpointId) === String(endpointId));
const filteredStacks = Array.isArray(stacksRes.data) ? stacksRes.data : [];
console.log("📦 Redeploy ALL für folgende Stacks:");
filteredStacks.forEach(s => console.log(` - ${s.Name}`));
@@ -3232,8 +3399,8 @@ app.put(
entityType: 'bulk-operation',
entityId: 'redeploy-alle',
entityName: 'Redeploy ALLE',
contextType: 'endpoint',
contextId: String(endpointId),
contextType: serverContextId ? 'server' : null,
contextId: serverContextId,
message: `Redeploy ALLE gestartet für: ${stackSummary}`,
metadata: {
stacks: stackSummaryList
@@ -3271,8 +3438,8 @@ app.put(
entityType: 'bulk-operation',
entityId: 'redeploy-alle',
entityName: 'Redeploy ALLE',
contextType: 'endpoint',
contextId: String(endpointId),
contextType: serverContextId ? 'server' : null,
contextId: serverContextId,
message: 'Redeploy ALLE abgeschlossen',
metadata: {
processedStacks: stackSummaryList
@@ -3291,8 +3458,8 @@ app.put(
entityType: 'bulk-operation',
entityId: 'redeploy-alle',
entityName: 'Redeploy ALLE',
contextType: endpointId !== undefined && endpointId !== null ? 'endpoint' : null,
contextId: endpointId !== undefined && endpointId !== null ? String(endpointId) : null,
contextType: serverContextId ? 'server' : null,
contextId: serverContextId,
message,
source: 'system'
});
@@ -3315,10 +3482,9 @@ app.put(
return res.status(400).json({ error: 'stackIds muss eine nicht leere Array sein' });
}
let endpointId;
const serverContextId = getActiveServerUrl() || null;
try {
endpointId = requireActiveEndpointId();
const normalizedIds = stackIds.map((id) => String(id));
const { filteredStacks } = await loadStackCollections();
const stacksById = new Map(filteredStacks.map((stack) => [String(stack.Id), stack]));
@@ -3352,8 +3518,8 @@ app.put(
entityType: 'bulk-operation',
entityId: 'redeploy-auswahl',
entityName: `Redeploy Auswahl (${stackIds.length})`,
contextType: 'endpoint',
contextId: String(endpointId),
contextType: serverContextId ? 'server' : null,
contextId: serverContextId,
message: `Redeploy Auswahl gestartet für: ${summaryText}`,
metadata: {
requestedStackIds: normalizedIds
@@ -3370,8 +3536,8 @@ app.put(
entityType: 'bulk-operation',
entityId: 'redeploy-auswahl',
entityName: `Redeploy Auswahl (${stackIds.length})`,
contextType: 'endpoint',
contextId: String(endpointId),
contextType: serverContextId ? 'server' : null,
contextId: serverContextId,
message: 'Redeploy Auswahl übersprungen: keine veralteten Stacks',
metadata: {
requestedStackIds: normalizedIds,
@@ -3407,8 +3573,8 @@ app.put(
entityType: 'bulk-operation',
entityId: 'redeploy-auswahl',
entityName: `Redeploy Auswahl (${stackIds.length})`,
contextType: 'endpoint',
contextId: String(endpointId),
contextType: serverContextId ? 'server' : null,
contextId: serverContextId,
message: 'Redeploy Auswahl abgeschlossen',
metadata: {
processedStackIds: eligibleStacks.map((stack) => String(stack.Id))
@@ -3428,8 +3594,8 @@ app.put(
entityType: 'bulk-operation',
entityId: 'redeploy-auswahl',
entityName: `Redeploy Auswahl (${Array.isArray(stackIds) ? stackIds.length : 0})`,
contextType: endpointId !== undefined && endpointId !== null ? 'endpoint' : null,
contextId: endpointId !== undefined && endpointId !== null ? String(endpointId) : null,
contextType: serverContextId ? 'server' : null,
contextId: serverContextId,
message,
metadata: {
requestedStackIds: normalized
+6 -6
View File
@@ -98,14 +98,14 @@ export function buildEventLogFilter(queryParams = {}) {
filters.push(`(entity_type = 'stack' AND entity_id IN (${placeholders.join(', ')}))`);
}
const legacyEndpoints = valueToArray(queryParams.endpoints ?? queryParams.endpoint);
if (legacyEndpoints.length) {
const placeholders = legacyEndpoints.map((_, idx) => {
const key = `legacyEndpoint${idx}`;
params[key] = legacyEndpoints[idx];
const serverContexts = valueToArray(queryParams.servers ?? queryParams.server);
if (serverContexts.length) {
const placeholders = serverContexts.map((_, idx) => {
const key = `serverContext${idx}`;
params[key] = serverContexts[idx];
return `@${key}`;
});
filters.push(`(context_type = 'endpoint' AND context_id IN (${placeholders.join(', ')}))`);
filters.push(`(context_type = 'server' AND context_id IN (${placeholders.join(', ')}))`);
}
const messageQuery = singleValue(queryParams.message ?? queryParams.text);
+20
View File
@@ -257,6 +257,26 @@ export function saveGroupPermissionValues(groupId, values = {}) {
normalizedEntries.push({ item, level });
});
const getSubmittedLevel = (permissionKey) => {
const entry = normalizedEntries.find((entry) => entry.item.key === permissionKey);
if (entry) {
return entry.level;
}
const fallback = itemMap.get(permissionKey);
return fallback ? fallback.defaultLevel || 'none' : 'none';
};
const serverManageLevel = getSubmittedLevel('maintenance-server-manage');
const serverDeleteLevel = getSubmittedLevel('maintenance-server-delete');
if (getLevelPriority(serverDeleteLevel) >= getLevelPriority('full') &&
getLevelPriority(serverManageLevel) < getLevelPriority('full')) {
const error = new Error('PERMISSION_DEPENDENCY_LEVEL');
error.code = 'PERMISSION_DEPENDENCY_LEVEL';
error.permissionKey = 'maintenance-server-delete';
throw error;
}
const runSave = db.transaction(() => {
deleteGroupPermissionValuesStmt.run(numericId);
normalizedEntries.forEach(({ item, level }) => {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -34,8 +34,8 @@
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
<script type="module" crossorigin src="/assets/index-65b35a5f.js"></script>
<link rel="stylesheet" href="/assets/index-328d6adf.css">
<script type="module" crossorigin src="/assets/index-71db1823.js"></script>
<link rel="stylesheet" href="/assets/index-dad5f6fa.css">
</head>
<body>
<div id="root"></div>
+12 -288
View File
@@ -15,26 +15,6 @@ const updateServer = db.prepare(`
WHERE id = ?
`);
const selectAllEndpoints = db.prepare(`
SELECT e.*, s.name as server_name, s.url as server_url
FROM endpoints e
INNER JOIN servers s ON s.id = e.server_id
ORDER BY e.id ASC
`);
const selectEndpointById = db.prepare('SELECT * FROM endpoints WHERE id = ?');
const selectEndpointByExternalId = db.prepare('SELECT * FROM endpoints WHERE external_id = ?');
const selectEndpointByServerAndExternal = db.prepare('SELECT * FROM endpoints WHERE server_id = ? AND external_id = ?');
const selectEndpointsByServerId = db.prepare('SELECT * FROM endpoints WHERE server_id = ?');
const insertEndpoint = db.prepare(`
INSERT INTO endpoints (server_id, name, external_id, is_default)
VALUES (?, ?, ?, ?)
`);
const updateEndpoint = db.prepare(`
UPDATE endpoints
SET name = ?, external_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const deleteEndpointStmt = db.prepare('DELETE FROM endpoints WHERE id = ?');
const deleteServerStmt = db.prepare('DELETE FROM servers WHERE id = ?');
const selectApiKeyByServerId = db.prepare('SELECT * FROM server_api_keys WHERE server_id = ?');
@@ -51,29 +31,8 @@ const upsertApiKey = db.prepare(`
`);
const deleteApiKeyByServerId = db.prepare('DELETE FROM server_api_keys WHERE server_id = ?');
const clearDefaultEndpointStmt = db.prepare('UPDATE endpoints SET is_default = 0 WHERE is_default != 0');
const setDefaultEndpointStmt = db.prepare('UPDATE endpoints SET is_default = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?');
const selectDefaultEndpointStmt = db.prepare(`
SELECT e.*, s.name as server_name, s.url as server_url
FROM endpoints e
INNER JOIN servers s ON s.id = e.server_id
WHERE e.is_default = 1
LIMIT 1
`);
const countServersStmt = db.prepare('SELECT COUNT(*) as count FROM servers');
const countEndpointsStmt = db.prepare('SELECT COUNT(*) as count FROM endpoints');
const selectFirstServerStmt = db.prepare('SELECT * FROM servers ORDER BY id ASC LIMIT 1');
const selectFirstEndpointStmt = db.prepare(`
SELECT e.*, s.name as server_name, s.url as server_url
FROM endpoints e
INNER JOIN servers s ON s.id = e.server_id
ORDER BY e.id ASC
LIMIT 1
`);
const transactionalSetDefaultEndpoint = db.transaction((endpointId) => {
clearDefaultEndpointStmt.run();
setDefaultEndpointStmt.run(endpointId);
});
const API_KEY_SECRET = crypto.createHash('sha256')
.update(process.env.PORTAINER_API_SECRET || process.env.PORTAINER_API_KEY || 'stackpulse-portainer-api-key')
@@ -167,89 +126,6 @@ function ensureServer({ name, url }) {
return created;
}
function ensureEndpoint({ serverId, name, externalId, makeDefault = false }) {
if (!serverId) {
const error = new Error('SERVER_REQUIRED');
error.code = 'SERVER_REQUIRED';
throw error;
}
const server = selectServerById.get(serverId);
if (!server) {
const error = new Error('SERVER_NOT_FOUND');
error.code = 'SERVER_NOT_FOUND';
throw error;
}
const trimmedExternal = String(externalId ?? '').trim();
if (!trimmedExternal) {
const error = new Error('ENDPOINT_EXTERNAL_ID_REQUIRED');
error.code = 'ENDPOINT_EXTERNAL_ID_REQUIRED';
throw error;
}
const normalizedName = String(name || `Endpoint ${trimmedExternal}`).trim();
if (!normalizedName) {
const error = new Error('ENDPOINT_NAME_REQUIRED');
error.code = 'ENDPOINT_NAME_REQUIRED';
throw error;
}
let existing = selectEndpointByServerAndExternal.get(serverId, trimmedExternal);
if (existing) {
if (existing.name !== normalizedName || existing.external_id !== trimmedExternal) {
updateEndpoint.run(normalizedName, trimmedExternal, existing.id);
existing = selectEndpointById.get(existing.id);
console.log(`️ [Setup] Endpoint aktualisiert: ${existing.name} (${existing.id}) für Server ${server.name} (${server.id})`);
}
} else {
const isDefault = makeDefault || countEndpointsStmt.get().count === 0 ? 1 : 0;
const result = insertEndpoint.run(serverId, normalizedName, trimmedExternal, isDefault);
existing = selectEndpointById.get(result.lastInsertRowid);
console.log(`✅ [Setup] Endpoint angelegt: ${existing.name} (${existing.id}) für Server ${server.name} (${server.id})`);
}
if (makeDefault && existing && !existing.is_default) {
transactionalSetDefaultEndpoint(existing.id);
existing = selectEndpointById.get(existing.id);
}
return existing;
}
function removeEndpoint(endpointId) {
const id = Number(endpointId);
if (!Number.isFinite(id)) {
const error = new Error('ENDPOINT_ID_INVALID');
error.code = 'ENDPOINT_ID_INVALID';
throw error;
}
const endpoint = selectEndpointById.get(id);
if (!endpoint) {
const error = new Error('ENDPOINT_NOT_FOUND');
error.code = 'ENDPOINT_NOT_FOUND';
throw error;
}
const wasDefault = Boolean(endpoint.is_default);
deleteEndpointStmt.run(id);
if (wasDefault) {
const fallback = selectFirstEndpointStmt.get();
if (fallback) {
transactionalSetDefaultEndpoint(fallback.id);
console.log(`️ [Setup] Neuer Standard-Endpoint gesetzt: ${fallback.name} (${fallback.id})`);
}
}
console.log(`🗑️ [Setup] Endpoint entfernt: ${endpoint.name} (${endpoint.id}) für Server ${endpoint.server_id}`);
return {
endpoint,
removed: true
};
}
function setServerApiKey({ serverId, apiKey }) {
const id = Number(serverId);
if (!Number.isFinite(id)) {
@@ -289,43 +165,7 @@ function setServerApiKey({ serverId, apiKey }) {
};
}
function setDefaultEndpoint(endpointId) {
if (!endpointId) return null;
const endpoint = selectEndpointById.get(endpointId);
if (!endpoint) {
const error = new Error('ENDPOINT_NOT_FOUND');
error.code = 'ENDPOINT_NOT_FOUND';
throw error;
}
transactionalSetDefaultEndpoint(endpointId);
return selectEndpointById.get(endpointId);
}
function getDefaultEndpoint() {
let endpoint = selectDefaultEndpointStmt.get();
if (!endpoint) {
endpoint = selectFirstEndpointStmt.get();
if (endpoint) {
transactionalSetDefaultEndpoint(endpoint.id);
endpoint = selectDefaultEndpointStmt.get();
}
}
return endpoint || null;
}
function getActiveEndpointExternalId() {
const endpoint = getDefaultEndpoint();
return endpoint ? endpoint.external_id : null;
}
function getActiveApiKey() {
const defaultEndpoint = getDefaultEndpoint();
if (defaultEndpoint) {
const row = selectApiKeyByServerId.get(defaultEndpoint.server_id);
const key = decryptApiKey(row);
if (key) return key;
}
const firstServer = selectFirstServerStmt.get();
if (firstServer) {
const row = selectApiKeyByServerId.get(firstServer.id);
@@ -338,21 +178,6 @@ function getActiveApiKey() {
}
function getActiveServerUrl() {
const endpoint = getDefaultEndpoint();
if (endpoint) {
const normalizedEndpointUrl = endpoint.server_url ? normalizeUrl(endpoint.server_url) : '';
if (normalizedEndpointUrl) {
return normalizedEndpointUrl;
}
const server = selectServerById.get(endpoint.server_id);
if (server?.url) {
const normalizedServerUrl = normalizeUrl(server.url);
if (normalizedServerUrl) {
return normalizedServerUrl;
}
}
}
const firstServer = selectFirstServerStmt.get();
if (firstServer?.url) {
const normalizedUrl = normalizeUrl(firstServer.url);
@@ -386,8 +211,6 @@ function removeServer(serverId) {
throw error;
}
const relatedEndpoints = selectEndpointsByServerId.all(id);
const hadDefaultEndpoint = relatedEndpoints.some((endpoint) => endpoint.is_default);
const existingApiKey = selectApiKeyByServerId.get(id);
deleteServerStmt.run(id);
@@ -396,41 +219,26 @@ function removeServer(serverId) {
console.log(`🗑️ [Setup] API-Key entfernt: Server ${server.name} (${server.id})`);
}
if (hadDefaultEndpoint) {
const fallback = getDefaultEndpoint();
if (fallback) {
console.log(`️ [Setup] Neuer Standard-Endpoint gesetzt: ${fallback.name} (${fallback.id})`);
}
}
console.log(`🗑️ [Setup] Server entfernt: ${server.name} (${server.id}) entfernte Endpoints: ${relatedEndpoints.length}`);
console.log(`🗑️ [Setup] Server entfernt: ${server.name} (${server.id})`);
return {
server,
removed: true,
endpointsRemoved: relatedEndpoints.length
removed: true
};
}
function hasEndpoint() {
const { count } = countEndpointsStmt.get();
return count > 0;
}
function hasApiKey() {
const { count } = countApiKeysStmt.get();
return count > 0;
}
function hasCompleteSetup() {
return hasSuperuser() && hasServer() && hasEndpoint() && hasApiKey();
return hasSuperuser() && hasServer() && hasApiKey();
}
function ensureDefaultsFromEnv() {
const envServerUrlRaw = process.env.PORTAINER_URL;
const envServerName = process.env.PORTAINER_SERVER_NAME;
const envEndpointIdRaw = process.env.PORTAINER_ENDPOINT_ID;
const envEndpointName = process.env.PORTAINER_ENDPOINT_NAME;
let server = null;
if (envServerUrlRaw) {
try {
@@ -440,31 +248,6 @@ function ensureDefaultsFromEnv() {
}
}
const trimmedEndpointId = typeof envEndpointIdRaw === 'string' ? envEndpointIdRaw.trim() : '';
if (trimmedEndpointId) {
const existingEndpoint = selectEndpointByExternalId.get(trimmedEndpointId);
if (!existingEndpoint) {
const targetServer = server || selectFirstServerStmt.get();
if (targetServer) {
try {
const endpointName = envEndpointName || `Endpoint ${trimmedEndpointId}`;
ensureEndpoint({
serverId: targetServer.id,
name: endpointName,
externalId: trimmedEndpointId,
makeDefault: true
});
} catch (error) {
console.error('⚠️ [Setup] Konnte Endpoint aus Umgebungsvariablen nicht anlegen:', error.message);
}
} else {
console.warn('⚠️ [Setup] Endpoint aus Umgebungsvariablen benötigt einen vorhandenen Server.');
}
} else if (!existingEndpoint.is_default) {
transactionalSetDefaultEndpoint(existingEndpoint.id);
}
}
const envApiKeyRaw = typeof process.env.PORTAINER_API_KEY === 'string' ? process.env.PORTAINER_API_KEY : '';
if (envApiKeyRaw.trim()) {
const targetServer = server || selectFirstServerStmt.get();
@@ -480,8 +263,6 @@ function ensureDefaultsFromEnv() {
function getSetupStatus() {
const servers = selectAllServers.all();
const endpoints = selectAllEndpoints.all();
const defaultEndpoint = getDefaultEndpoint();
const apiKeyRecords = selectAllApiKeys.all();
const apiKeyMap = new Map(apiKeyRecords.map((entry) => [entry.server_id, entry]));
@@ -489,10 +270,6 @@ function getSetupStatus() {
const envServerName = rawEnvServerName.trim();
const rawEnvServerUrl = typeof process.env.PORTAINER_URL === 'string' ? process.env.PORTAINER_URL : '';
const envServerUrl = rawEnvServerUrl.trim();
const rawEnvEndpointName = typeof process.env.PORTAINER_ENDPOINT_NAME === 'string' ? process.env.PORTAINER_ENDPOINT_NAME : '';
const envEndpointName = rawEnvEndpointName.trim();
const rawEnvEndpointId = typeof process.env.PORTAINER_ENDPOINT_ID === 'string' ? process.env.PORTAINER_ENDPOINT_ID : '';
const envEndpointId = rawEnvEndpointId.trim();
const rawEnvApiKey = typeof process.env.PORTAINER_API_KEY === 'string' ? process.env.PORTAINER_API_KEY : '';
const envApiKeyTrimmed = rawEnvApiKey.trim();
const envApiKeyProvided = Boolean(envApiKeyTrimmed);
@@ -503,7 +280,6 @@ function getSetupStatus() {
const envSuperuserEmail = envSuperuserEmailRaw.trim();
const serverRequired = servers.length === 0;
const endpointRequired = endpoints.length === 0;
const apiKeyItems = servers.map((server) => {
const keyMeta = apiKeyMap.get(server.id) || null;
@@ -518,7 +294,7 @@ function getSetupStatus() {
const apiKeyRequired = servers.length > 0 && apiKeyCount === 0;
const superuserExists = hasSuperuser();
const setupComplete = superuserExists && !serverRequired && !endpointRequired && !apiKeyRequired;
const setupComplete = superuserExists && !serverRequired && !apiKeyRequired;
return {
superuser: {
@@ -536,13 +312,6 @@ function getSetupStatus() {
requireInput: serverRequired,
envProvided: Boolean(envServerUrl)
},
endpoints: {
count: endpoints.length,
items: endpoints,
requireInput: endpointRequired,
envProvided: Boolean(envEndpointId),
default: defaultEndpoint
},
apiKeys: {
count: apiKeyCount,
items: apiKeyItems,
@@ -553,7 +322,6 @@ function getSetupStatus() {
requirements: {
superuser: !superuserExists,
server: serverRequired,
endpoint: endpointRequired,
apiKey: apiKeyRequired
},
setupComplete,
@@ -561,9 +329,6 @@ function getSetupStatus() {
serverName: envServerName || (envServerUrl ? deriveServerName(envServerUrl) : ''),
serverNameFromEnv: rawEnvServerName,
serverUrl: envServerUrl,
endpointName: envEndpointName || (envEndpointId ? `Endpoint ${envEndpointId}` : ''),
endpointNameFromEnv: rawEnvEndpointName,
endpointExternalId: envEndpointId,
apiKeyProvided: envApiKeyProvided,
apiKeyValue: rawEnvApiKey,
superuserUsername: envSuperuserUsernameRaw,
@@ -573,77 +338,36 @@ function getSetupStatus() {
};
}
const createEndpointWithDefaultTransaction = db.transaction(({ serverInput, endpointInput }) => {
let server = null;
function completeSetup({ server: serverInput }) {
let serverRecord = null;
if (endpointInput?.serverId) {
const byId = selectServerById.get(endpointInput.serverId);
if (!byId) {
const error = new Error('SERVER_NOT_FOUND');
error.code = 'SERVER_NOT_FOUND';
throw error;
}
server = byId;
if (serverInput && typeof serverInput.url === 'string' && serverInput.url.trim()) {
serverRecord = ensureServer(serverInput);
} else {
serverRecord = selectFirstServerStmt.get();
}
if (serverInput) {
server = ensureServer(serverInput);
}
if (!server) {
server = selectFirstServerStmt.get();
}
if (!server) {
if (!serverRecord) {
const error = new Error('SERVER_DETAILS_REQUIRED');
error.code = 'SERVER_DETAILS_REQUIRED';
throw error;
}
if (!endpointInput) {
const error = new Error('ENDPOINT_DETAILS_REQUIRED');
error.code = 'ENDPOINT_DETAILS_REQUIRED';
throw error;
}
const endpoint = ensureEndpoint({
serverId: server.id,
name: endpointInput.name,
externalId: endpointInput.externalId,
makeDefault: true
});
return {
server,
endpoint
};
});
function completeSetup({ server: serverInput, endpoint: endpointInput }) {
const result = createEndpointWithDefaultTransaction({ serverInput, endpointInput });
return {
server: result.server,
endpoint: result.endpoint,
defaultEndpoint: getDefaultEndpoint()
server: serverRecord
};
}
export {
ensureDefaultsFromEnv,
ensureServer,
ensureEndpoint,
setServerApiKey,
setDefaultEndpoint,
getDefaultEndpoint,
getActiveEndpointExternalId,
getActiveApiKey,
getActiveServerUrl,
hasServer,
hasEndpoint,
hasApiKey,
hasCompleteSetup,
getSetupStatus,
completeSetup,
removeEndpoint,
removeServer
};
+7 -10
View File
@@ -8,16 +8,13 @@ services:
- "4001:4001"
volumes:
- stackpulse_data:/app/backend/data
environment:
PORTAINER_URL: "Your_Portainer_Server_Address"
PORTAINER_API_KEY: "Your_Portainer_API_Key"
PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_ID"
SELF_STACK_ID: "Stackpulse ID"
SUPERUSER_USERNAME: "Your_Superuser_Username" (optional)
SUPERUSER_EMAIL: "Your_Superuser_Email" (optional)
SUPERUSER_PASSWORD: "Your_Superuser_Password" (optional)
SELF_STACK_ID: "Your_StackPulse_Stack_ID"
restart: unless-stopped
environment:
- PORTAINER_URL=Your_Portainer_Server_Adress (optional)
- PORTAINER_API_KEY=Your_Portainer_API_Key (optional)
- SUPERUSER_USERNAME=Your_Superuser_Username (optional)
- SUPERUSER_EMAIL=Your_Superuser_Email (optional)
- SUPERUSER_PASSWORD=Your_Superuser_Password (optional)
- SELF_STACK_ID=Your_StackPulse_Stack_ID (optional)
volumes:
stackpulse_data:
+30 -13
View File
@@ -1,5 +1,6 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import axios from "axios";
import { useAuth } from "@/components/AuthProvider.jsx";
const MaintenanceContext = createContext(null);
@@ -14,6 +15,8 @@ const INITIAL_STATE = {
};
export default function MaintenanceProvider({ children }) {
const { isAuthenticated, hasPermission, user } = useAuth();
const canAccessMaintenance = Boolean(isAuthenticated && (user?.isSuperuser || hasPermission("maintenance-access", "read")));
const [state, setState] = useState(INITIAL_STATE);
const pollingRef = useRef(null);
const wasRunningRef = useRef(false);
@@ -26,6 +29,10 @@ export default function MaintenanceProvider({ children }) {
}, []);
const fetchConfig = useCallback(async () => {
if (!canAccessMaintenance) {
applyState({ loading: false, error: "" });
return null;
}
applyState({ loading: true, error: "" });
try {
const response = await axios.get("/api/maintenance/config");
@@ -43,9 +50,12 @@ export default function MaintenanceProvider({ children }) {
const message = err.response?.data?.error || err.message || "Fehler beim Laden der Wartungsdaten";
applyState({ loading: false, error: message });
}
}, [applyState]);
}, [applyState, canAccessMaintenance]);
const refreshUpdateStatus = useCallback(async () => {
if (!canAccessMaintenance) {
return;
}
try {
const response = await axios.get("/api/maintenance/update-status");
const data = response.data ?? {};
@@ -59,13 +69,20 @@ export default function MaintenanceProvider({ children }) {
const message = err.response?.data?.error || err.message || "Fehler beim Aktualisieren des Wartungsstatus";
setState((prev) => ({ ...prev, error: message }));
}
}, []);
}, [canAccessMaintenance]);
useEffect(() => {
if (!canAccessMaintenance) {
applyState({ loading: false, error: "" });
return;
}
fetchConfig();
}, [fetchConfig]);
}, [applyState, fetchConfig, canAccessMaintenance]);
useEffect(() => {
if (!canAccessMaintenance) {
return undefined;
}
if (state.update?.running) {
refreshUpdateStatus();
const intervalId = setInterval(() => {
@@ -79,15 +96,15 @@ export default function MaintenanceProvider({ children }) {
pollingRef.current = null;
}
return undefined;
}, [state.update?.running, refreshUpdateStatus]);
}, [canAccessMaintenance, state.update?.running, refreshUpdateStatus]);
useEffect(() => {
const currentlyRunning = Boolean(state.update?.running);
if (!currentlyRunning && wasRunningRef.current) {
if (canAccessMaintenance && !currentlyRunning && wasRunningRef.current) {
fetchConfig();
}
wasRunningRef.current = currentlyRunning;
}, [state.update?.running, fetchConfig]);
}, [canAccessMaintenance, state.update?.running, fetchConfig]);
const triggerUpdate = useCallback(async (payload = {}) => {
await axios.post("/api/maintenance/portainer-update", payload);
@@ -145,11 +162,6 @@ export default function MaintenanceProvider({ children }) {
return response.data ?? {};
}, []);
const deleteSetupEndpoint = useCallback(async (endpointId) => {
const response = await axios.delete(`/api/setup/endpoints/${endpointId}`);
return response.data ?? { success: false };
}, []);
const deleteSetupServer = useCallback(async (serverId) => {
const response = await axios.delete(`/api/setup/servers/${serverId}`);
return response.data ?? { success: false };
@@ -160,6 +172,11 @@ export default function MaintenanceProvider({ children }) {
return response.data ?? { success: false };
}, []);
const updateSelfStackId = useCallback(async (selfStackId) => {
const response = await axios.put("/api/setup/self-stack", { selfStackId });
return response.data ?? { success: false };
}, []);
const removeSuperuserAccount = useCallback(async () => {
const response = await axios.delete("/api/auth/superuser");
return response.data ?? { success: false };
@@ -184,11 +201,11 @@ export default function MaintenanceProvider({ children }) {
testSshConnection,
fetchSuperuserStatus,
fetchSetupStatus,
deleteSetupEndpoint,
deleteSetupServer,
updateSetupApiKey,
updateSelfStackId,
removeSuperuserAccount
}), [state, fetchConfig, refreshUpdateStatus, setMaintenanceMode, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection, fetchSuperuserStatus, fetchSetupStatus, deleteSetupEndpoint, deleteSetupServer, updateSetupApiKey, removeSuperuserAccount]);
}), [state, fetchConfig, refreshUpdateStatus, setMaintenanceMode, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection, fetchSuperuserStatus, fetchSetupStatus, deleteSetupServer, updateSetupApiKey, updateSelfStackId, removeSuperuserAccount]);
return (
<MaintenanceContext.Provider value={value}>
+9 -1
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import axios from "axios";
import {
Card,
@@ -12,6 +12,7 @@ import {
} from "@material-tailwind/react";
import { useNavigate } from "react-router-dom";
import { useToast } from "@/components/ToastProvider.jsx";
import { useAuth } from "@/components/AuthProvider.jsx";
const PHRASE_WORD_COUNT = 8;
@@ -36,6 +37,7 @@ const buildPhrasePayload = (words = []) => {
export function ForgotPassword() {
const navigate = useNavigate();
const { showToast } = useToast();
const { user } = useAuth();
const [username, setUsername] = useState("");
const [phraseInputs, setPhraseInputs] = useState(Array(PHRASE_WORD_COUNT).fill(""));
@@ -50,6 +52,12 @@ export function ForgotPassword() {
const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState("");
useEffect(() => {
if (user) {
navigate("/dashboard/stacks", { replace: true });
}
}, [user, navigate]);
const phraseWords = useMemo(() => {
if (fileContent.trim()) {
return extractWordsFromText(fileContent);
+32 -1
View File
@@ -26,10 +26,41 @@ import {
ordersOverviewData,
} from "@/data";
import { CheckCircleIcon, ClockIcon } from "@heroicons/react/24/solid";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
const UPDATE_STAGE_LABELS = {
initializing: "Vorbereitung",
"activating-maintenance": "Wartungsmodus aktivieren",
"executing-script": "Skript wird ausgeführt",
waiting: "Warte auf Portainer",
completed: "Abgeschlossen",
failed: "Fehlgeschlagen"
};
export function Home() {
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
const updateRunning = Boolean(updateState?.running);
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "";
const maintenanceLocked = maintenanceActive || updateRunning;
return (
<div className="mt-12">
{(maintenanceActive || updateRunning) && (
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100 mb-8">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
</span>
)}
</div>
</div>
)}
<div className="mb-12 grid gap-y-10 gap-x-6 md:grid-cols-2 xl:grid-cols-4">
{statisticsCardsData.map(({ icon, title, footer, ...rest }) => (
<StatisticsCard
@@ -87,7 +118,7 @@ export function Home() {
</div>
<Menu placement="left-start">
<MenuHandler>
<IconButton size="sm" variant="text" color="blue-gray">
<IconButton size="sm" variant="text" color="blue-gray" disabled={maintenanceLocked}>
<EllipsisVerticalIcon
strokeWidth={3}
fill="currenColor"
+17 -16
View File
@@ -693,18 +693,19 @@ export function Logs() {
const toggleOpen = () => setOpen((cur) => !cur);
return (
<div className="mt-12 mb-8 flex flex-col gap-12">
{(maintenanceActive || updateRunning) && (<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
{(maintenanceActive || updateRunning) && (
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
)}
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
</span>
)}
</div>
</div>
</div>
)}
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
@@ -738,12 +739,12 @@ export function Logs() {
<>
<Button
onClick={() => handleExport('txt')}
disabled={actionLoading || loading}
disabled={maintenanceLocked || actionLoading || loading}
className="w-full md:flex-1">
Export TXT</Button>
<Button
onClick={() => handleExport('sql')}
disabled={actionLoading || loading}
disabled={maintenanceLocked || actionLoading || loading}
className="w-full md:flex-1">
Export SQL</Button>
</>
@@ -751,7 +752,7 @@ export function Logs() {
{canDeleteLogs && (
<Button
onClick={handleDeleteFiltered}
disabled={actionLoading || loading || logs.length === 0}
disabled={maintenanceLocked || actionLoading || loading || logs.length === 0}
className="w-full md:flex-1 bg-sunsetCoral-500 hover:bg-sunsetCoral-600">
Angezeigte löschen</Button>
)}
@@ -1075,7 +1076,7 @@ export function Logs() {
}}
variant="static"
label="Kontext-ID"
placeholder="z. B. Endpoint-ID"
placeholder="z. B. Server-ID"
/>
</div>
</div>
@@ -1141,7 +1142,7 @@ export function Logs() {
<div className="flex-1 min-w-[180px]">
<Button
onClick={handleResetFilters}
disabled={actionLoading || loading}
disabled={maintenanceLocked || actionLoading || loading}
className="w-full"
>
Zurücksetzen
@@ -1298,7 +1299,7 @@ export function Logs() {
<Typography variant="small">
<button
onClick={() => handleDeleteLog(log.id)}
disabled={actionLoading}
disabled={maintenanceLocked || actionLoading}
className="rounded-md border border-sunsetCoral-600 px-3 py-1 text-xs text-sunsetCoral-800 transition hover:bg-sunsetCoral-600/20 disabled:opacity-60"
>
Löschen
+168 -112
View File
@@ -10,7 +10,8 @@ import {
CardBody,
Button,
Switch,
Input
Input,
Alert
} from "@material-tailwind/react";
const UPDATE_STATUS_LABELS = {
@@ -128,9 +129,9 @@ export function Maintenance() {
testSshConnection,
fetchSuperuserStatus,
fetchSetupStatus,
deleteSetupEndpoint,
deleteSetupServer,
updateSetupApiKey,
updateSelfStackId,
removeSuperuserAccount
} = useMaintenance();
@@ -140,6 +141,7 @@ export function Maintenance() {
const canViewServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-manage", "read"));
const canEditServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-manage", "full"));
const canDeleteServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-delete", "full"));
const canDeleteSuperuser = Boolean(isSuperuserAccount || hasPermission("maintenance-superuser-delete", "full"));
const canViewPortainer = Boolean(isSuperuserAccount || hasPermission("maintenance-portainer", "read"));
const canManagePortainerUpdate = Boolean(isSuperuserAccount || hasPermission("maintenance-update", "full"));
const canViewSsh = Boolean(isSuperuserAccount || hasPermission("maintenance-ssh-update", "read"));
@@ -151,6 +153,7 @@ export function Maintenance() {
const maintenanceMessage = maintenanceMeta?.message;
const maintenanceExtraType = maintenanceMeta?.extra?.type;
const updateRunning = Boolean(updateState?.running);
const maintenanceLocked = maintenanceActive || updateRunning;
const [scriptDraft, setScriptDraft] = useState("");
const [scriptSaving, setScriptSaving] = useState(false);
@@ -214,7 +217,9 @@ export function Maintenance() {
const [apiKeyDrafts, setApiKeyDrafts] = useState({});
const [apiKeyUpdatingId, setApiKeyUpdatingId] = useState(null);
const [serverDeleteId, setServerDeleteId] = useState(null);
const [endpointDeleteId, setEndpointDeleteId] = useState(null);
const [selfStackDraft, setSelfStackDraft] = useState("");
const [selfStackSaving, setSelfStackSaving] = useState(false);
const [selfStackError, setSelfStackError] = useState("");
const [duplicates, setDuplicates] = useState([]);
const [duplicatesLoading, setDuplicatesLoading] = useState(true);
@@ -381,12 +386,21 @@ export function Maintenance() {
}, [fetchDuplicates]);
const setupServers = useMemo(() => setupResources?.servers?.items ?? [], [setupResources]);
const setupEndpoints = useMemo(() => setupResources?.endpoints?.items ?? [], [setupResources]);
const apiKeyInfoMap = useMemo(() => {
const items = setupResources?.apiKeys?.items ?? [];
return new Map(items.map((entry) => [entry.serverId, entry]));
}, [setupResources]);
const setupComplete = useMemo(() => Boolean(setupResources?.setupComplete), [setupResources]);
const selfStackInfo = setupResources?.selfStack ?? null;
const currentSelfStackValue = selfStackInfo?.current ?? "";
const envSelfStackValue = setupResources?.envDefaults?.selfStackId ?? "";
useEffect(() => {
setSelfStackDraft(currentSelfStackValue || "");
setSelfStackError("");
}, [currentSelfStackValue]);
const selfStackDirty = selfStackDraft !== currentSelfStackValue;
const totals = useMemo(() => {
const groups = Array.isArray(duplicates) ? duplicates.length : 0;
@@ -525,7 +539,7 @@ export function Maintenance() {
}, [canManageSsh, deleteSshConfig, showToast]);
const handleSuperuserDelete = useCallback(async () => {
if (superuserDeleteLoading || superuserStatusLoading || !superuserExists) {
if (!canDeleteSuperuser || superuserDeleteLoading || superuserStatusLoading || !superuserExists) {
return;
}
@@ -566,48 +580,7 @@ export function Maintenance() {
} finally {
setSuperuserDeleteLoading(false);
}
}, [superuserDeleteLoading, superuserStatusLoading, superuserExists, removeSuperuserAccount, loadSuperuserStatus, showToast]);
const handleDeleteEndpoint = useCallback(async (endpointId) => {
if (!canDeleteServers) {
return;
}
if (endpointDeleteId === endpointId) {
return;
}
const target = setupEndpoints.find((entry) => entry.id === endpointId);
if (!target) {
return;
}
const label = target.name || `Endpoint ${target.external_id}`;
const confirmMessage = `Endpoint "${label}" wirklich löschen?${target.is_default ? "\nDieser Endpoint ist aktuell als Standard markiert." : ""}`;
if (typeof window !== "undefined") {
const confirmed = window.confirm(confirmMessage);
if (!confirmed) {
return;
}
}
setEndpointDeleteId(endpointId);
try {
await deleteSetupEndpoint(endpointId);
showToast({
variant: "success",
title: "Endpoint gelöscht",
description: `Endpoint "${label}" wurde entfernt.`
});
await loadSetupResources({ silent: true });
} catch (err) {
const message = err.response?.data?.error || err.message || "Endpoint konnte nicht gelöscht werden.";
showToast({
variant: "error",
title: "Endpoint löschen fehlgeschlagen",
description: message
});
} finally {
setEndpointDeleteId(null);
}
}, [canDeleteServers, deleteSetupEndpoint, endpointDeleteId, loadSetupResources, setupEndpoints, showToast]);
}, [canDeleteSuperuser, superuserDeleteLoading, superuserStatusLoading, superuserExists, removeSuperuserAccount, loadSuperuserStatus, showToast]);
const handleDeleteServer = useCallback(async (serverId) => {
if (!canDeleteServers) {
@@ -620,12 +593,10 @@ export function Maintenance() {
if (!target) {
return;
}
const linkedEndpoints = setupEndpoints.filter((entry) => entry.server_id === serverId);
const label = target.name || target.url || `Server ${serverId}`;
const confirmMessage = [
`Server "${label}" wirklich löschen?`,
linkedEndpoints.length ? `Dabei werden ${linkedEndpoints.length} zugeordnete Endpoint(s) entfernt.` : null,
"Das System benötigt anschließend erneut einen gültigen Server/Endpoint im Setup."
"Das System benötigt anschließend erneut einen gültigen Server im Setup."
].filter(Boolean).join("\n");
if (typeof window !== "undefined") {
@@ -654,7 +625,7 @@ export function Maintenance() {
} finally {
setServerDeleteId(null);
}
}, [canDeleteServers, deleteSetupServer, loadSetupResources, serverDeleteId, setupEndpoints, setupServers, showToast]);
}, [canDeleteServers, deleteSetupServer, loadSetupResources, serverDeleteId, setupServers, showToast]);
const handleApiKeyDraftChange = useCallback((serverId, value) => {
if (!canEditServers) return;
@@ -706,6 +677,79 @@ export function Maintenance() {
}
}, [apiKeyDrafts, apiKeyUpdatingId, canEditServers, loadSetupResources, showToast, updateSetupApiKey]);
const handleSelfStackDraftChange = useCallback((value) => {
if (!canEditServers) return;
setSelfStackDraft(value);
setSelfStackError("");
}, [canEditServers]);
const handleSelfStackSave = useCallback(async () => {
if (!canEditServers || !selfStackDirty) {
return;
}
setSelfStackSaving(true);
setSelfStackError("");
const normalizedValue = typeof selfStackDraft === "string" ? selfStackDraft.trim() : "";
try {
await updateSelfStackId(normalizedValue);
showToast({
variant: "success",
title: "Self-Stack-ID gespeichert",
description: normalizedValue
? `Self-Stack-ID wurde auf "${normalizedValue}" gesetzt.`
: "Self-Stack-ID wurde entfernt."
});
await loadSetupResources({ silent: true });
} catch (err) {
const message = err.response?.data?.error || err.message || "Self-Stack-ID konnte nicht aktualisiert werden.";
setSelfStackError(message);
showToast({
variant: "error",
title: "Self-Stack-ID",
description: message
});
} finally {
setSelfStackSaving(false);
}
}, [canEditServers, loadSetupResources, selfStackDirty, selfStackDraft, showToast, updateSelfStackId]);
const handleSelfStackRemove = useCallback(async () => {
if (!canEditServers) {
return;
}
if (!selfStackDraft && !currentSelfStackValue) {
return;
}
if (typeof window !== "undefined") {
const confirmed = window.confirm("Self-Stack-ID wirklich entfernen?");
if (!confirmed) {
return;
}
}
setSelfStackSaving(true);
setSelfStackError("");
try {
await updateSelfStackId("");
setSelfStackDraft("");
showToast({
variant: "success",
title: "Self-Stack-ID entfernt",
description: "Die Self-Stack-ID wurde gelöscht."
});
await loadSetupResources({ silent: true });
} catch (err) {
const message = err.response?.data?.error || err.message || "Self-Stack-ID konnte nicht entfernt werden.";
setSelfStackError(message);
showToast({
variant: "error",
title: "Self-Stack-ID",
description: message
});
} finally {
setSelfStackSaving(false);
}
}, [canEditServers, currentSelfStackValue, loadSetupResources, selfStackDraft, showToast, updateSelfStackId]);
const handleMaintenanceToggle = useCallback(async (nextActive) => {
if (!canControlMaintenance) return;
if (maintenanceLoading || maintenanceToggleLoading) return;
@@ -935,7 +979,7 @@ export function Maintenance() {
color="white"
className="flex items-center justify-between"
>
<span>Server &amp; Endpoints</span>
<span>Server &amp; API-Keys</span>
</Typography>
</CardHeader>
@@ -1008,7 +1052,7 @@ export function Maintenance() {
color="red"
size="sm"
onClick={() => handleDeleteServer(server.id)}
disabled={!canDeleteServers || setupResourcesLoading || serverDeleteId === server.id || endpointDeleteId !== null || apiKeyUpdatingId === server.id}
disabled={maintenanceLocked || !canDeleteServers || setupResourcesLoading || serverDeleteId === server.id || apiKeyUpdatingId === server.id}
>
{serverDeleteId === server.id ? "Lösche…" : "Löschen"}
</Button>
@@ -1016,7 +1060,7 @@ export function Maintenance() {
color="blue"
size="sm"
onClick={() => handleApiKeyUpdate(server.id)}
disabled={!canEditServers || setupResourcesLoading || apiKeyUpdatingId === server.id}
disabled={maintenanceLocked || !canEditServers || setupResourcesLoading || apiKeyUpdatingId === server.id}
>
{apiKeyUpdatingId === server.id ? "Speichere…" : "API-Key speichern"}
</Button>
@@ -1029,61 +1073,74 @@ export function Maintenance() {
)}
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-3 border-t border-blue-gray-50 pt-4">
<div className="flex flex-col gap-1">
<Typography variant="small" color="blue-gray" className="font-semibold uppercase">
Endpoints
Self-Stack-ID
</Typography>
<Typography variant="small" color="blue-gray">
{setupEndpoints.length} vorhanden
</Typography>
</div>
{setupEndpoints.length === 0 ? (
<p className="text-sm text-stormGrey-500">
Es sind derzeit keine Endpoints hinterlegt.
<p className="text-xs text-stormGrey-500">
Optional lass das Feld leer, wenn keine Self-Stack-ID benötigt wird.
</p>
) : (
<div className="flex flex-col gap-3">
{setupEndpoints.map((endpoint) => (
<div
key={endpoint.id}
className="flex flex-col gap-2 rounded-md border border-blue-gray-100 p-3 md:flex-row md:items-center md:justify-between"
>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-stormGrey-900">
{endpoint.name || `Endpoint ${endpoint.external_id}`}
</p>
{endpoint.is_default ? (
<span className="rounded-full bg-arcticBlue-100 px-2 py-0.5 text-[10px] font-semibold text-arcticBlue-700">
Standard
</span>
) : null}
</div>
<p className="text-xs text-stormGrey-500">
ID: {endpoint.external_id} · Server: {endpoint.server_name || endpoint.server_url}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="text"
color="red"
size="sm"
onClick={() => handleDeleteEndpoint(endpoint.id)}
disabled={!canDeleteServers || setupResourcesLoading || endpointDeleteId === endpoint.id || serverDeleteId !== null}
>
{endpointDeleteId === endpoint.id ? "Lösche…" : "Löschen"}
</Button>
</div>
</div>
))}
</div>
{selfStackInfo?.envProvided && (
<p className="text-xs text-stormGrey-500">
Hinweis: Eine Umgebungsvariable liefert bereits einen Standardwert.
</p>
)}
<div className="flex flex-col gap-2 md:flex-row md:items-end">
<div className="flex-1">
<Input
label="Self-Stack-ID"
value={selfStackDraft}
onChange={(event) => handleSelfStackDraftChange(event.target.value)}
disabled={!canEditServers || setupResourcesLoading || selfStackSaving}
/>
</div>
{canEditServers && (
<div className="flex items-center justify-end gap-2">
<Button
variant="text"
color="red"
size="sm"
onClick={handleSelfStackRemove}
disabled={maintenanceLocked || selfStackSaving || (!currentSelfStackValue && !selfStackDraft)}
>
Entfernen
</Button>
<Button
color="blue"
size="sm"
onClick={handleSelfStackSave}
disabled={maintenanceLocked || selfStackSaving || setupResourcesLoading || !selfStackDirty}
>
{selfStackSaving ? "Speichere…" : "Self-Stack-ID speichern"}
</Button>
</div>
)}
</div>
{!canEditServers && (
<p className="text-xs text-stormGrey-500">
Bearbeitung benötigt das Gruppenrecht Server-Sektion.
</p>
)}
<p className="text-xs text-stormGrey-500">
Aktuell gespeichert: {currentSelfStackValue || "nicht gesetzt"}
</p>
{envSelfStackValue && (
<p className="text-xs text-stormGrey-500">
Vorgabe aus Umgebung: {envSelfStackValue}
</p>
)}
{selfStackError && (
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
{selfStackError}
</Alert>
)}
</div>
{!setupComplete && (
<Alert color="amber" className="border border-amber-200 bg-amber-50 text-amber-800">
Das Setup ist aktuell unvollständig. Bitte öffne den Setup-Bereich, um Server, Endpoint und API-Key erneut festzulegen.
Das Setup ist aktuell unvollständig. Bitte öffne den Setup-Bereich, um Server und API-Key erneut festzulegen.
</Alert>
)}
@@ -1093,7 +1150,7 @@ export function Maintenance() {
color="gray"
size="sm"
onClick={() => loadSetupResources()}
disabled={setupResourcesLoading}
disabled={maintenanceLocked || setupResourcesLoading}
>
Aktualisieren
</Button>
@@ -1142,7 +1199,7 @@ export function Maintenance() {
variant="outlined"
color="gray"
onClick={() => loadSuperuserStatus()}
disabled={superuserStatusLoading || superuserDeleteLoading}
disabled={maintenanceLocked || superuserStatusLoading || superuserDeleteLoading}
className="w-full sm:w-auto"
>
Status aktualisieren
@@ -1150,7 +1207,7 @@ export function Maintenance() {
<Button
color="red"
onClick={handleSuperuserDelete}
disabled={superuserStatusLoading || superuserDeleteLoading || !superuserExists}
disabled={maintenanceLocked || !canDeleteSuperuser || superuserStatusLoading || superuserDeleteLoading || !superuserExists}
className="w-full sm:w-auto"
>
{superuserDeleteLoading ? "Wird gelöscht…" : "Superuser löschen"}
@@ -1203,7 +1260,7 @@ export function Maintenance() {
<div className="flex items-center gap-3">
<Button
onClick={() => fetchPortainerStatus({ silent: false })}
disabled={portainerLoading || portainerRefreshing}
disabled={maintenanceLocked || portainerLoading || portainerRefreshing}
className="w-full">
Status aktualisieren
</Button>
@@ -1424,14 +1481,14 @@ export function Maintenance() {
<Button
onClick={handleSshSaveConfig}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || maintenanceLocked}
className="mt-3 w-full">
{sshSaving ? 'Speichern…' : 'SSH-Konfiguration speichern'}
</Button>
<Button
onClick={handleSshTestConnection}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || maintenanceLocked}
className="w-full bg-arcticBlue-500 hover:bg-arcticBlue-600">
{sshTesting ? 'Test läuft…' : 'Verbindung testen'}
</Button>
@@ -1439,7 +1496,7 @@ export function Maintenance() {
<Button
onClick={handleSshDeleteConfig}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || maintenanceLocked}
className="w-full hover:bg-sunsetCoral-600 bg-sunsetCoral-500"
>
{sshDeleting ? 'Löschen…' : 'SSH-Einstellungen löschen'}
@@ -1469,14 +1526,14 @@ export function Maintenance() {
<div className="mt-3 grid gap-2">
<Button
onClick={handleScriptSave}
disabled={!canManageSsh || !scriptIsDirty || scriptSaving || updateRunning}
disabled={!canManageSsh || !scriptIsDirty || scriptSaving || maintenanceLocked}
className="mt-3 w-full">
Speichern
</Button>
<Button
color="purple"
onClick={handleScriptReset}
disabled={!canManageSsh || !scriptConfig || scriptConfig.source !== "custom" || scriptSaving || updateRunning}
disabled={!canManageSsh || !scriptConfig || scriptConfig.source !== "custom" || scriptSaving || maintenanceLocked}
className="w-full hover:bg-sunsetCoral-600 bg-sunsetCoral-500">
Standard wiederherstellen
</Button>
@@ -1552,7 +1609,7 @@ export function Maintenance() {
<Button
onClick={handleTriggerUpdate}
disabled={!canManagePortainerUpdate || disableUpdateButton || updateActionLoading}
disabled={maintenanceLocked || !canManagePortainerUpdate || disableUpdateButton || updateActionLoading}
className="mt-3 w-full">
{updateActionLoading ? "Update wird gestartet…" : "Portainer aktualisieren"}
</Button>
@@ -1649,7 +1706,7 @@ export function Maintenance() {
</span>
</div>
<p className="text-sm text-gray-300">
Behaltener Stack: ID {canonicalId} (Endpoint {entry?.canonical?.EndpointId ?? "-"})
Behaltener Stack: ID {canonicalId}
</p>
<p className="text-xs text-gray-500">
Typ: {resolveStackType(entry?.canonical?.Type)} Erstellt: {formatCreatedAt(entry?.canonical?.Created)}
@@ -1674,7 +1731,6 @@ export function Maintenance() {
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-semibold text-white">ID: {duplicate.Id}</span>
<span>Endpoint: {duplicate.EndpointId ?? "-"}</span>
<span>Typ: {resolveStackType(duplicate.Type)}</span>
<span>Erstellt: {formatCreatedAt(duplicate.Created)}</span>
</div>
@@ -11,6 +11,15 @@ import {
import { useNavigate } from "react-router-dom";
import { ArrowDownTrayIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
import { useAuth } from "@/components/AuthProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
const UPDATE_STAGE_LABELS = {
initializing: "Vorbereitung",
"activating-maintenance": "Wartungsmodus aktivieren",
"executing-script": "Skript wird ausgeführt",
waiting: "Warte auf Portainer",
completed: "Abgeschlossen",
failed: "Fehlgeschlagen"
};
const formatWordsAsText = (words = []) => {
if (!Array.isArray(words) || words.length === 0) {
@@ -37,6 +46,7 @@ const groupWords = (words = []) => {
export function SecurityPhrase() {
const navigate = useNavigate();
const { user, setSession, refreshSession } = useAuth();
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [words, setWords] = useState([]);
@@ -44,6 +54,11 @@ export function SecurityPhrase() {
const [downloaded, setDownloaded] = useState(false);
const requiresDownload = Boolean(user?.requiresSecurityPhraseDownload);
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
const updateRunning = Boolean(updateState?.running);
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "";
const maintenanceLocked = maintenanceActive || updateRunning;
const groupedWords = useMemo(() => groupWords(words), [words]);
@@ -83,6 +98,11 @@ export function SecurityPhrase() {
navigate("/dashboard/stacks", { replace: true });
return;
}
if (err?.response?.status === 401) {
await refreshSession();
navigate("/auth/sign-in", { replace: true });
return;
}
const message = err?.response?.data?.error || err?.message || "Sicherheitsschlüssel konnte nicht geladen werden.";
setError(message);
@@ -133,6 +153,11 @@ export function SecurityPhrase() {
setDownloaded(Boolean(downloadedAt));
navigate("/dashboard/stacks", { replace: true });
} catch (err) {
if (err?.response?.status === 401) {
await refreshSession();
navigate("/auth/sign-in", { replace: true });
return;
}
const message = err?.response?.data?.error || err?.message || "Download konnte nicht bestätigt werden.";
setError(message);
} finally {
@@ -223,7 +248,7 @@ export function SecurityPhrase() {
size="md"
className="flex items-center gap-2 normal-case"
onClick={handleDownload}
disabled={saving || downloaded || words.length === 0}
disabled={maintenanceLocked || saving || downloaded || words.length === 0}
>
<ArrowDownTrayIcon className="h-5 w-5" />
Sicherheitsschlüssel herunterladen
+12 -11
View File
@@ -819,18 +819,19 @@ export function Stacks() {
<div className="mt-12 mb-8 flex flex-col gap-12">
{(maintenanceActive || updateRunning) && (<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
{(maintenanceActive || updateRunning) && (
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
)}
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
</span>
)}
</div>
</div>
</div>
)}
<Card>
@@ -1072,7 +1073,7 @@ export function Stacks() {
) : (canRedeploySingle ? (
<Button
onClick={() => handleRedeploy(stack.Id)}
disabled={isBusy}
disabled={maintenanceLocked || isBusy}
className="disabled:opacity-50 disabled:cursor-not-allowed"
>
Redeploy
+52 -16
View File
@@ -21,6 +21,15 @@ import { useAuth } from "@/components/AuthProvider.jsx";
const _ = AVATAR_COLORS.join(" ");
const UPDATE_STAGE_LABELS = {
initializing: "Vorbereitung",
"activating-maintenance": "Wartungsmodus aktivieren",
"executing-script": "Skript wird ausgeführt",
waiting: "Warte auf Portainer",
completed: "Abgeschlossen",
failed: "Fehlgeschlagen"
};
const normalizeUserGroups = (rawGroups) => {
if (!Array.isArray(rawGroups)) {
return [];
@@ -95,7 +104,7 @@ const buildInitialFormValues = (user) => {
export function UserDetails() {
const { userId } = useParams();
const { showToast } = useToast();
const { maintenance } = useMaintenance();
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
const { hasPermission, user: authUser } = useAuth();
const [user, setUser] = useState(null);
@@ -115,7 +124,11 @@ export function UserDetails() {
const [securityPhraseError, setSecurityPhraseError] = useState("");
const [renewingSecurityPhrase, setRenewingSecurityPhrase] = useState(false);
const maintenanceActive = Boolean(maintenance?.active);
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
const updateRunning = Boolean(updateState?.running);
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "";
const maintenanceLocked = maintenanceActive || updateRunning;
const canEditUsers = Boolean(authUser?.isSuperuser || hasPermission("users-edit", "full"));
const canReadUsers = Boolean(authUser?.isSuperuser || hasPermission("users-edit", "read"));
@@ -132,6 +145,8 @@ export function UserDetails() {
}
return user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser");
}, [user]);
const canCurrentUserEditSuperuser = authUser?.isSuperuser && user?.id && Number(authUser.id) === Number(user.id);
const superuserFieldsLocked = isSuperuserUser && !canCurrentUserEditSuperuser;
const numericUserId = useMemo(() => {
const asNumber = Number(userId);
@@ -161,7 +176,7 @@ export function UserDetails() {
setSecurityPhraseWords([]);
setSecurityPhraseDownloadedAt(item.securityPhraseDownloadedAt || null);
setSecurityPhraseError("");
const initialValues = buildInitialFormValues(item);
const initialValues = buildInitialFormValues(item);
initialFormValuesRef.current = { ...initialValues };
setFormValues(initialValues);
setSaveError("");
@@ -436,7 +451,7 @@ export function UserDetails() {
}, [canRenewSecurityPhrase, fetchSecurityPhrase, renewingSecurityPhrase, securityPhraseLoading]);
const handleRenewSecurityPhrase = useCallback(async () => {
if (!canRenewSecurityPhrase || !numericUserId || maintenanceActive || renewingSecurityPhrase) {
if (!canRenewSecurityPhrase || !numericUserId || maintenanceLocked || renewingSecurityPhrase) {
return;
}
@@ -472,7 +487,7 @@ export function UserDetails() {
}, [
canRenewSecurityPhrase,
numericUserId,
maintenanceActive,
maintenanceLocked,
renewingSecurityPhrase,
showToast
]);
@@ -560,17 +575,26 @@ export function UserDetails() {
fetchSecurityPhrase();
}, [hasLoaded, canManageSecurityPhrase, fetchSecurityPhrase, numericUserId]);
const inputDisabled = maintenanceActive || savingUser || !user || !canEditUsers;
const selectDisabled = maintenanceActive || savingUser || !user || groupsLoading || !canEditUsers || isSuperuserUser;
const avatarSelectDisabled = maintenanceActive || savingUser || !user || !canEditUsers;
const inputDisabled = maintenanceLocked || savingUser || !user || !canEditUsers;
const selectDisabled = maintenanceLocked || savingUser || !user || groupsLoading || !canEditUsers || isSuperuserUser;
const avatarSelectDisabled = maintenanceLocked || savingUser || !user || !canEditUsers || superuserFieldsLocked;
const groupSelectValue = formValues.groupId ? String(formValues.groupId) : "";
return (
<>
<div className="mt-12 flex flex-col gap-12">
{maintenanceActive && (
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Wartungsmodus aktiv Änderungen sind deaktiviert.
{(maintenanceActive || updateRunning) && (
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
</span>
)}
</div>
</div>
)}
<div className="relative h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover\tbg-center">
@@ -600,7 +624,7 @@ export function UserDetails() {
color="green"
className="normal-case"
onClick={handleSaveUser}
disabled={maintenanceActive || savingUser}
disabled={maintenanceLocked || savingUser}
>
{savingUser ? "Speichert ..." : "Änderungen speichern"}
</Button>
@@ -627,6 +651,16 @@ export function UserDetails() {
<Typography variant="h6" color="blue-gray" className="mb-4">
Allgemeine Einstellungen
</Typography>
{(isSuperuserUser) && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Systemgruppe Name und Beschreibung sind geschützt.
{superuserFieldsLocked && (
<span className="mt-1 block text-xs text-amber-600">
Nur der Superuser selbst kann Benutzername, E-Mail-Adresse und Passwort ändern.
</span>
)}
</div>
)}
<div className="mb-6">
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Benutzername
@@ -635,7 +669,7 @@ export function UserDetails() {
value={formValues.username}
onChange={handleUsernameChange}
placeholder="Benutzername"
disabled={inputDisabled}
disabled={inputDisabled || superuserFieldsLocked}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none"
@@ -651,7 +685,7 @@ export function UserDetails() {
value={formValues.email}
onChange={handleEmailChange}
placeholder="benutzer@example.com"
disabled={inputDisabled}
disabled={inputDisabled || superuserFieldsLocked}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none"
@@ -667,7 +701,7 @@ export function UserDetails() {
value={formValues.password}
onChange={handlePasswordChange}
placeholder="Passwort setzen"
disabled={inputDisabled}
disabled={inputDisabled || superuserFieldsLocked}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none"
@@ -787,6 +821,7 @@ export function UserDetails() {
onClick={handleReloadSecurityPhrase}
disabled={
!canRenewSecurityPhrase ||
maintenanceLocked ||
securityPhraseLoading ||
renewingSecurityPhrase
}
@@ -799,7 +834,7 @@ export function UserDetails() {
onClick={handleRenewSecurityPhrase}
disabled={
!canRenewSecurityPhrase ||
maintenanceActive ||
maintenanceLocked ||
securityPhraseLoading ||
renewingSecurityPhrase
}
@@ -824,6 +859,7 @@ export function UserDetails() {
onClick={handleReloadSecurityPhrase}
disabled={
!canRenewSecurityPhrase ||
maintenanceLocked ||
securityPhraseLoading ||
renewingSecurityPhrase
}
+174 -112
View File
@@ -24,6 +24,15 @@ import { AVATAR_COLORS } from "@/data/avatarColors.js";
const _ = AVATAR_COLORS.join(" ");
const UPDATE_STAGE_LABELS = {
initializing: "Vorbereitung",
"activating-maintenance": "Wartungsmodus aktivieren",
"executing-script": "Skript wird ausgeführt",
waiting: "Warte auf Portainer",
completed: "Abgeschlossen",
failed: "Fehlgeschlagen"
};
const mapGroup = (item) => ({
id: item?.id ?? null,
name: item?.name || "",
@@ -63,11 +72,19 @@ const LEVEL_OPTIONS = [
{ value: "read", label: "Nur lesen" },
{ value: "none", label: "Kein Zugriff" }
];
const LEVEL_PRIORITY = {
none: 0,
read: 1,
full: 2
};
const normalizePermissionLevel = (key, value) =>
key === "maintenance-server-delete" && value !== "full" ? "none" : value;
export function UserGroupDetail() {
const { groupId } = useParams();
const { showToast } = useToast();
const { maintenance } = useMaintenance();
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
const { hasPermission, user: authUser } = useAuth();
const [group, setGroup] = useState(null);
@@ -85,12 +102,17 @@ export function UserGroupDetail() {
const [permissionsLoading, setPermissionsLoading] = useState(false);
const [permissionsError, setPermissionsError] = useState("");
const maintenanceActive = Boolean(maintenance?.active);
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
const updateRunning = Boolean(updateState?.running);
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "";
const maintenanceLocked = maintenanceActive || updateRunning;
const isSuperuserGroup = useMemo(() => (group?.name || "").toLowerCase() === "superuser", [group]);
const isSuperuserAccount = Boolean(authUser?.isSuperuser);
const canEditGroupDetails = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "full"));
const canViewPermissionSettings = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "read"));
const canAdjustPermissions = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "full"));
const superuserGroupLocked = isSuperuserGroup && !isSuperuserAccount;
const numericGroupId = useMemo(() => {
const candidate = Number(groupId);
@@ -162,15 +184,15 @@ export function UserGroupDetail() {
const sectionGroups = Array.isArray(section.groups)
? section.groups.map((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
};
})
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 {
@@ -485,22 +507,33 @@ export function UserGroupDetail() {
return group?.avatarColor || "";
}, [formValues.avatarColor, group]);
const inputDisabled = maintenanceActive || savingGroup || !group || isSuperuserGroup || !canEditGroupDetails;
const selectDisabled = maintenanceActive || savingGroup || !group || !canEditGroupDetails;
const inputDisabled = maintenanceLocked || savingGroup || !group || superuserGroupLocked || !canEditGroupDetails;
const selectDisabled = maintenanceLocked || savingGroup || !group || superuserGroupLocked || !canEditGroupDetails;
const selectValue = formValues.avatarColor || "";
const handlePermissionChange = useCallback((permissionKey, value) => {
if (!permissionKey || !canAdjustPermissions || isSuperuserGroup) {
return;
}
const nextValue = normalizePermissionLevel(permissionKey, value);
setPermissionSelection((prev) => {
if (prev[permissionKey] === value) {
const shouldResetDelete =
permissionKey === "maintenance-server-manage" && nextValue !== "full";
if (!shouldResetDelete && prev[permissionKey] === nextValue) {
return prev;
}
return {
const nextState = {
...prev,
[permissionKey]: value
[permissionKey]: nextValue
};
if (shouldResetDelete) {
nextState["maintenance-server-delete"] = "none";
}
return nextState;
});
}, [canAdjustPermissions, isSuperuserGroup]);
@@ -509,13 +542,16 @@ export function UserGroupDetail() {
if (!permissionKey) {
return null;
}
let value = null;
if (Object.prototype.hasOwnProperty.call(permissionSelection, permissionKey)) {
return permissionSelection[permissionKey];
value = permissionSelection[permissionKey];
} else if (Object.prototype.hasOwnProperty.call(permissionDefaults, permissionKey)) {
value = permissionDefaults[permissionKey];
}
if (Object.prototype.hasOwnProperty.call(permissionDefaults, permissionKey)) {
return permissionDefaults[permissionKey];
if (typeof value === "string") {
return normalizePermissionLevel(permissionKey, value);
}
return null;
return value;
},
[permissionSelection, permissionDefaults]
);
@@ -571,6 +607,8 @@ export function UserGroupDetail() {
const rowName = `permission-${ownerKey}-${row.key}`;
const currentValue = getPermissionValue(row.key) ?? "none";
const displayValue = normalizePermissionLevel(row.key, currentValue);
const serverManageLevel = getPermissionValue("maintenance-server-manage") ?? "none";
const { addTopBorder = true } = options;
const rowClasses = [
@@ -580,10 +618,13 @@ export function UserGroupDetail() {
.filter(Boolean)
.join(" ");
const baseLevels = Array.isArray(row.availableLevels) && row.availableLevels.length
? row.availableLevels
: LEVEL_OPTIONS.map((option) => option.value);
const availableLevels = new Set(
Array.isArray(row.availableLevels) && row.availableLevels.length
? row.availableLevels
: LEVEL_OPTIONS.map((option) => option.value)
row.key === "maintenance-server-delete"
? baseLevels.filter((level) => level === "full" || level === "none")
: baseLevels
);
const levelOptions = LEVEL_OPTIONS;
@@ -595,13 +636,18 @@ export function UserGroupDetail() {
<List className="flex-col gap-1.5 md:ml-auto md:flex-row md:flex-nowrap md:w-1/2 md:items-center md:justify-end md:gap-3">
{levelOptions.map((option) => {
const optionId = `${rowName}-${option.value}`;
const checked = currentValue === 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 disabled =
!availableLevels.has(option.value) ||
permissionsLoading ||
savingGroup ||
!canAdjustPermissions ||
isSuperuserGroup;
isSuperuserGroup ||
requiresManageFull;
return (
<ListItem
@@ -610,9 +656,8 @@ export function UserGroupDetail() {
>
<label
htmlFor={optionId}
className={`inline-flex w-full items-center gap-2 rounded-lg px-3 py-2 min-h-[38px] ${
disabled ? "cursor-not-allowed bg-blue-gray-50/40" : "cursor-pointer hover:bg-blue-gray-50/60"
}`}
className={`inline-flex w-full items-center gap-2 rounded-lg px-3 py-2 min-h-[38px] ${disabled ? "cursor-not-allowed bg-blue-gray-50/40" : "cursor-pointer hover:bg-blue-gray-50/60"
}`}
>
<ListItemPrefix className="flex-shrink-0">
<Radio
@@ -633,9 +678,8 @@ export function UserGroupDetail() {
</ListItemPrefix>
<Typography
color="blue-gray"
className={`text-sm font-medium leading-5 whitespace-nowrap ${
disabled ? "text-blue-gray-300" : "text-blue-gray-600"
}`}
className={`text-sm font-medium leading-5 whitespace-nowrap ${disabled ? "text-blue-gray-300" : "text-blue-gray-600"
}`}
>
{option.label}
</Typography>
@@ -651,9 +695,18 @@ export function UserGroupDetail() {
return (
<>
<div className="mt-12 flex flex-col gap-12">
{maintenanceActive && (
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Wartungsmodus aktiv Änderungen sind deaktiviert.
{(maintenanceActive || updateRunning) && (
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
</span>
)}
</div>
</div>
)}
<div className="relative h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover\tbg-center">
@@ -683,7 +736,7 @@ export function UserGroupDetail() {
color="green"
className="normal-case"
onClick={handleSaveGroup}
disabled={maintenanceActive || savingGroup}
disabled={maintenanceLocked || savingGroup}
>
{savingGroup ? "Speichert ..." : "Änderungen speichern"}
</Button>
@@ -710,11 +763,14 @@ export function UserGroupDetail() {
<Typography variant="h6" color="blue-gray" className="mb-4">
Gruppendaten
</Typography>
{(maintenanceActive || isSuperuserGroup) && (
{isSuperuserGroup && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{maintenanceActive
? "Wartungsmodus aktiv Änderungen sind deaktiviert."
: "Systemgruppe Name und Beschreibung sind geschützt."}
Systemgruppe Name und Beschreibung sind geschützt.
{superuserGroupLocked && (
<span className="mt-1 block text-xs text-amber-600">
Nur der Superuser darf die Superuser-Gruppe bearbeiten.
</span>
)}
</div>
)}
<div className="mb-6">
@@ -810,81 +866,87 @@ export function UserGroupDetail() {
</div>
</div>
<div className="lg:col-span-2 xl:col-span-3">
<Typography variant="h6" color="blue-gray" className="mb-4">
Berechtigungen (Ansicht)
</Typography>
{isSuperuserGroup ? (
<div className="rounded-xl border border-blue-gray-100 bg-white px-4 py-4 text-sm text-blue-gray-600 shadow-sm">
Die Superuser-Gruppe besitzt automatisch Vollzugriff auf alle Bereiche. Berechtigungen können hier nicht angepasst werden.
</div>
) : !canViewPermissionSettings ? null : (
<div className="rounded-xl border border-blue-gray-100 bg-white shadow-sm">
{permissionsLoading && (
<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">
<Spinner className="h-4 w-4" />
<span>Berechtigungen werden geladen ...</span>
</div>
{(!maintenanceActive && !updateRunning) && (
<>
<Typography variant="h6" color="blue-gray" className="mb-4">
Berechtigungen (Ansicht)
</Typography>
{isSuperuserGroup ? (
<div className="rounded-xl border border-blue-gray-100 bg-white px-4 py-4 text-sm text-blue-gray-600 shadow-sm">
Die Superuser-Gruppe besitzt automatisch Vollzugriff auf alle Bereiche. Berechtigungen können hier nicht angepasst werden.
</div>
)}
{!permissionsLoading && permissionsError && (
<div className="border-b border-blue-gray-100 px-4 py-4 text-sm text-red-700">
{permissionsError}
</div>
)}
{!permissionsLoading && !permissionsError &&
(permissionSections.length === 0 ? (
<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>
) : (
permissionSections.map((section, sectionIndex) => {
const sectionItems = Array.isArray(section.items) ? section.items : [];
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
return (
<div
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) {
return null;
}
return (
<div key={group.key || `${section.key || sectionIndex}-${groupIdx}`} className="border-t border-blue-gray-100">
<div className="border-b border-blue-gray-100 px-4 py-3">
<Typography className="text-sm font-semibold uppercase tracking-wide text-blue-gray-600">
{group.title}
</Typography>
</div>
{groupItems.map((row, rowIndex) =>
renderPermissionRow(row, group.key || `${section.key || sectionIndex}-${groupIdx}`, {
addTopBorder: rowIndex !== 0
})
)}
</div>
);
})}
) : !canViewPermissionSettings ? null : (
<div className="rounded-xl border border-blue-gray-100 bg-white shadow-sm">
{permissionsLoading && (
<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">
<Spinner className="h-4 w-4" />
<span>Berechtigungen werden geladen ...</span>
</div>
);
})
))}
</div>
</div>
)}
{!permissionsLoading && permissionsError && (
<div className="border-b border-blue-gray-100 px-4 py-4 text-sm text-red-700">
{permissionsError}
</div>
)}
{!permissionsLoading && !permissionsError &&
(permissionSections.length === 0 ? (
<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>
) : (
permissionSections.map((section, sectionIndex) => {
const sectionItems = Array.isArray(section.items) ? section.items : [];
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
return (
<div
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) {
return null;
}
return (
<div key={group.key || `${section.key || sectionIndex}-${groupIdx}`} className="border-t border-blue-gray-100">
<div className="border-b border-blue-gray-100 px-4 py-3">
<Typography className="text-sm font-semibold uppercase tracking-wide text-blue-gray-600">
{group.title}
</Typography>
</div>
{groupItems.map((row, rowIndex) =>
renderPermissionRow(row, group.key || `${section.key || sectionIndex}-${groupIdx}`, {
addTopBorder: rowIndex !== 0
})
)}
</div>
);
})}
</div>
);
})
))}
</div>
)}
</>
)}
</div>
</div>
+40 -12
View File
@@ -17,6 +17,15 @@ import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useToast } from "@/components/ToastProvider.jsx";
import { useAuth } from "@/components/AuthProvider.jsx";
const UPDATE_STAGE_LABELS = {
initializing: "Vorbereitung",
"activating-maintenance": "Wartungsmodus aktivieren",
"executing-script": "Skript wird ausgeführt",
waiting: "Warte auf Portainer",
completed: "Abgeschlossen",
failed: "Fehlgeschlagen"
};
export function Usergroups() {
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(false);
@@ -29,8 +38,12 @@ export function Usergroups() {
const [deletingGroupId, setDeletingGroupId] = useState(null);
const { showToast } = useToast();
const { maintenance } = useMaintenance();
const maintenanceActive = Boolean(maintenance?.active);
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
const updateRunning = Boolean(updateState?.running);
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "";
const maintenanceLocked = maintenanceActive || updateRunning;
const navigate = useNavigate();
const { hasPermission } = useAuth();
@@ -190,7 +203,8 @@ export function Usergroups() {
}, []);
const handleCreateGroup = useCallback(async () => {
if (maintenanceActive) {
if (maintenanceLocked) {
setCreateGroupError("Im Wartungsmodus oder während eines Updates können keine Gruppen angelegt werden.");
return;
}
if (!canEditGroups) {
@@ -236,7 +250,7 @@ export function Usergroups() {
setCreatingGroup(false);
}
}, [
maintenanceActive,
maintenanceLocked,
canEditGroups,
newGroupName,
newGroupDescription,
@@ -245,10 +259,10 @@ export function Usergroups() {
fetchGroups
]);
const createGroupDisabled = maintenanceActive || creatingGroup;
const createGroupDisabled = maintenanceLocked || creatingGroup;
const handleDeleteGroup = useCallback(async (group) => {
if (maintenanceActive || !group?.id) {
if (maintenanceLocked || !group?.id) {
return;
}
@@ -322,13 +336,22 @@ export function Usergroups() {
} finally {
setDeletingGroupId(null);
}
}, [maintenanceActive, canDeleteGroups, showToast, fetchGroups]);
}, [maintenanceLocked, canDeleteGroups, showToast, fetchGroups]);
return (
<div className="mt-12 mb-8 flex flex-col gap-12">
{maintenanceActive && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Wartungsmodus aktiv Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
{(maintenanceActive || updateRunning) && (
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
</span>
)}
</div>
</div>
)}
<Card className="border border-blue-gray-100 shadow-sm">
@@ -374,7 +397,12 @@ export function Usergroups() {
<Button color="green" onClick={handleCreateGroup} disabled={createGroupDisabled}>
{creatingGroup ? "Speichert ..." : "Gruppe anlegen"}
</Button>
<Button variant="text" color="blue-gray" onClick={resetNewGroupForm} disabled={creatingGroup}>
<Button
variant="text"
color="blue-gray"
onClick={resetNewGroupForm}
disabled={creatingGroup || maintenanceLocked}
>
Formular zurücksetzen
</Button>
</div>
@@ -498,7 +526,7 @@ export function Usergroups() {
color="red"
onClick={() => handleDeleteGroup(group)}
disabled={
maintenanceActive ||
maintenanceLocked ||
deletingGroupId === group.id ||
Number(group.memberCount) > 0
}
+49 -21
View File
@@ -17,6 +17,15 @@ import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useToast } from "@/components/ToastProvider.jsx";
import { useAuth } from "@/components/AuthProvider.jsx";
const UPDATE_STAGE_LABELS = {
initializing: "Vorbereitung",
"activating-maintenance": "Wartungsmodus aktivieren",
"executing-script": "Skript wird ausgeführt",
waiting: "Warte auf Portainer",
completed: "Abgeschlossen",
failed: "Fehlgeschlagen"
};
const normalizeUserGroups = (rawGroups) => {
if (!Array.isArray(rawGroups)) {
return [];
@@ -61,8 +70,12 @@ export function Users() {
const [togglingUserId, setTogglingUserId] = useState(null);
const { showToast } = useToast();
const { maintenance } = useMaintenance();
const maintenanceActive = Boolean(maintenance?.active);
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
const updateRunning = Boolean(updateState?.running);
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "";
const maintenanceLocked = maintenanceActive || updateRunning;
const navigate = useNavigate();
const noop = useCallback(() => { }, []);
const { hasPermission } = useAuth();
@@ -253,7 +266,7 @@ export function Users() {
}, []);
const handleDeleteUser = useCallback(async (user) => {
if (maintenanceActive || !user?.id) {
if (maintenanceLocked || !user?.id) {
return;
}
if (!canDeleteUsers) {
@@ -314,7 +327,7 @@ export function Users() {
} finally {
setDeletingUserId(null);
}
}, [maintenanceActive, canDeleteUsers, showToast, fetchUsers]);
}, [maintenanceLocked, canDeleteUsers, showToast, fetchUsers]);
const handleToggleUserStatus = useCallback(async (user) => {
if (!user?.id) {
@@ -323,11 +336,11 @@ export function Users() {
if (!canManageUsers) {
return;
}
if (maintenanceActive) {
if (maintenanceLocked) {
showToast({
variant: "error",
title: "Aktion nicht möglich",
description: "Im Wartungsmodus können keine Statusänderungen durchgeführt werden."
description: "Im Wartungsmodus oder während eines Updates sind Statusänderungen gesperrt."
});
return;
}
@@ -386,10 +399,11 @@ export function Users() {
} finally {
setTogglingUserId(null);
}
}, [maintenanceActive, canManageUsers, showToast, fetchUsers]);
}, [maintenanceLocked, canManageUsers, showToast, fetchUsers]);
const handleCreateUser = useCallback(async () => {
if (maintenanceActive) {
if (maintenanceLocked) {
setCreateError("Im Wartungsmodus oder während eines Updates können keine Benutzer angelegt werden.");
return;
}
if (!canManageUsers) {
@@ -467,7 +481,7 @@ export function Users() {
setCreatingUser(false);
}
}, [
maintenanceActive,
maintenanceLocked,
canManageUsers,
newUsername,
newEmail,
@@ -479,8 +493,8 @@ export function Users() {
]);
const hasSelectableGroups = availableGroups.length > 0;
const createDisabled = maintenanceActive || creatingUser || groupsLoading || !hasSelectableGroups;
const groupSelectDisabled = maintenanceActive || creatingUser || groupsLoading || !hasSelectableGroups;
const createDisabled = maintenanceLocked || creatingUser || groupsLoading || !hasSelectableGroups;
const groupSelectDisabled = maintenanceLocked || creatingUser || groupsLoading || !hasSelectableGroups;
const renderSelectedGroup = useCallback(
(element) => {
if (element && element.props && element.props.children) {
@@ -497,9 +511,18 @@ export function Users() {
return (
<div className="mt-12 mb-8 flex flex-col gap-12">
{maintenanceActive && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Wartungsmodus aktiv Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
{(maintenanceActive || updateRunning) && (
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
</span>
)}
</div>
</div>
)}
<Card>
@@ -539,14 +562,14 @@ export function Users() {
label="Benutzername"
value={newUsername}
onChange={(event) => setNewUsername(event.target.value)}
disabled={maintenanceActive || creatingUser}
disabled={maintenanceLocked || creatingUser}
crossOrigin=""
/>
<Input
label="E-Mail (optional)"
value={newEmail}
onChange={(event) => setNewEmail(event.target.value)}
disabled={maintenanceActive || creatingUser}
disabled={maintenanceLocked || creatingUser}
crossOrigin=""
/>
<Input
@@ -554,7 +577,7 @@ export function Users() {
label="Passwort"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
disabled={maintenanceActive || creatingUser}
disabled={maintenanceLocked || creatingUser}
crossOrigin=""
/>
<Select
@@ -582,7 +605,12 @@ export function Users() {
<Button color="green" onClick={handleCreateUser} disabled={createDisabled}>
{creatingUser ? "Speichert ..." : "Benutzer anlegen"}
</Button>
<Button variant="text" color="blue-gray" onClick={resetNewUserForm} disabled={creatingUser}>
<Button
variant="text"
color="blue-gray"
onClick={resetNewUserForm}
disabled={creatingUser || maintenanceLocked}
>
Formular zurücksetzen
</Button>
</div>
@@ -694,10 +722,10 @@ export function Users() {
{canManageUsers ? (
<button
type="button"
className={`inline-flex items-center rounded-full px-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${maintenanceActive ? "cursor-not-allowed" : "cursor-pointer"}`}
className={`inline-flex items-center rounded-full px-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${maintenanceLocked ? "cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => handleToggleUserStatus(user)}
disabled={
maintenanceActive ||
maintenanceLocked ||
togglingUserId === user.id ||
(Array.isArray(user.groups) &&
user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser" && user.isActive))
@@ -746,7 +774,7 @@ export function Users() {
variant="text"
color="red"
onClick={() => handleDeleteUser(user)}
disabled={maintenanceActive || deletingUserId === user.id}
disabled={maintenanceLocked || deletingUserId === user.id}
>
{deletingUserId === user.id ? "Löscht ..." : "Löschen"}
</Button>
File diff suppressed because it is too large Load Diff
+1
View File
@@ -89,6 +89,7 @@ export const routes = [
name: "passwort vergessen",
path: "/forgot-password",
element: <ForgotPassword />,
hidden: true,
},
{
icon: <ArrowLeftOnRectangleIcon {...icon} />,
+5 -2
View File
@@ -22,7 +22,7 @@ export function Sidenav({ brandImg, brandName, routes }) {
} fixed inset-0 z-50 my-4 ml-4 h-[calc(100vh-32px)] w-72 rounded-xl transition-transform duration-300 xl:translate-x-0 border border-blue-gray-100`}
>
<div className="relative py-6 px-8 text-right">
<img src={logo} alt="StackPulse" className="w-auto" /><span className="mt-1 text-xs text-stormGrey-500 block antialiased font-sans">v0.4</span>
<img src={logo} alt="StackPulse" className="w-auto" /><span className="mt-1 text-xs text-stormGrey-500 block antialiased font-sans">v0.5</span>
<IconButton
variant="text"
@@ -36,7 +36,10 @@ export function Sidenav({ brandImg, brandName, routes }) {
</div>
<div className="m-4">
{routes.map(({ layout, title, pages }, key) => {
const visiblePages = pages.filter(({ permission }) => {
const visiblePages = pages.filter(({ permission, hidden }) => {
if (hidden) {
return false;
}
if (!permission || !permission.key) {
return true;
}