v0.5 Push
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||

|
||||
|
||||
# 📦 StackPulse 
|
||||
# 📦 StackPulse 
|
||||
|
||||
**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:
|
||||
|
||||

|
||||
|
||||
Die 3 wäre in diesem Fall Endpoint-ID.
|
||||
|
||||
Die STACK_SELF_ID findest du, wenn du das Frontend von StackPulse öffnest:
|
||||
|
||||

|
||||
|
||||
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 |
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 & Endpoints</span>
|
||||
<span>Server & 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+668
-318
File diff suppressed because it is too large
Load Diff
@@ -89,6 +89,7 @@ export const routes = [
|
||||
name: "passwort vergessen",
|
||||
path: "/forgot-password",
|
||||
element: <ForgotPassword />,
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
icon: <ArrowLeftOnRectangleIcon {...icon} />,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user