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.
|
**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!
|
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>
|
||||||
|
|
||||||
<details open>
|
<details>
|
||||||
<summary>✅ v0.4 – Release</summary>
|
<summary>✅ v0.4 – Release</summary>
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
@@ -86,27 +86,35 @@ Ziel:
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details open>
|
<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
|
### 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
|
### 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
|
### Features
|
||||||
- [ ] Authentifizierungssystem und Benutzerverwaltung mit Gruppenrechten
|
|
||||||
- [x] Erweiterung der Logs auf alle Bereiche
|
|
||||||
- [x] Speichern der Server- und Superusereinstellungen in die Datenbank
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -117,10 +125,8 @@ Ziel:
|
|||||||
- Monitoring (Status, CPU/RAM)
|
- Monitoring (Status, CPU/RAM)
|
||||||
- Verbesserte UI/UX
|
- Verbesserte UI/UX
|
||||||
- Erweiterte Filterungen udn Sortierungen
|
- Erweiterte Filterungen udn Sortierungen
|
||||||
- Multi-Server/Endpoint Verwaltung
|
- Multi-Server Verwaltung
|
||||||
- Integration Community Edition
|
- Integration Community Edition
|
||||||
- Integration Community Edition
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -158,41 +164,29 @@ stackpulse/
|
|||||||
|
|
||||||
### Mit Compose starten
|
### Mit Compose starten
|
||||||
```bash
|
```bash
|
||||||
version: "2.4"
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
stackpulse:
|
app:
|
||||||
container_name: stackpulse
|
build:
|
||||||
image: ghcr.io/mboehmlaender/stackpulse
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- '4001:4001'
|
- "4001:4001"
|
||||||
volumes:
|
volumes:
|
||||||
- stackpulse_data:/app/backend/data
|
- 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
|
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:
|
volumes:
|
||||||
stackpulse_data:
|
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
|
## 📋 Voraussetzungen
|
||||||
|
|
||||||
- Node.js >= 20
|
- 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_URL=Your_Portainer_Server_Adress (optional)
|
||||||
PORTAINER_API_KEY=Your_Portainer_API_Key
|
PORTAINER_API_KEY=Your_Portainer_API_Key (optional)
|
||||||
PORTAINER_ENDPOINT_ID=Your_Portainer_Endpoint_ID
|
|
||||||
SUPERUSER_USERNAME=Your_Superuser_Username (optional)
|
SUPERUSER_USERNAME=Your_Superuser_Username (optional)
|
||||||
SUPERUSER_EMAIL=Your_Superuser_Email (optional)
|
SUPERUSER_EMAIL=Your_Superuser_Email (optional)
|
||||||
SUPERUSER_PASSWORD=Your_Superuser_Password (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
|
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 (
|
CREATE TABLE user_server_permission_overrides (
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
server_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
|
CREATE INDEX idx_user_server_permission_overrides_user_server
|
||||||
ON user_server_permission_overrides (user_id, server_id);
|
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 (
|
CREATE TABLE server_api_keys (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
server_id INTEGER NOT NULL UNIQUE,
|
server_id INTEGER NOT NULL UNIQUE,
|
||||||
|
|||||||
@@ -177,12 +177,12 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
key: 'maintenance-server-group',
|
key: 'maintenance-server-group',
|
||||||
title: 'Server & Endpoints',
|
title: 'Server',
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
key: 'maintenance-server-manage',
|
key: 'maintenance-server-manage',
|
||||||
label: 'Server/Endpoint-Sektion',
|
label: 'Server-Sektion',
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', 'read', 'none'],
|
levels: ['full', 'read', 'none'],
|
||||||
@@ -192,7 +192,7 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'maintenance-server-delete',
|
key: 'maintenance-server-delete',
|
||||||
label: 'Server/Endpoint löschen',
|
label: 'Server löschen',
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
defaultLevel: 'none',
|
defaultLevel: 'none',
|
||||||
levels: ['full', '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',
|
key: 'maintenance-portainer-group',
|
||||||
title: 'Portainer',
|
title: 'Portainer',
|
||||||
sortOrder: 1,
|
sortOrder: 2,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
key: 'maintenance-portainer',
|
key: 'maintenance-portainer',
|
||||||
@@ -245,7 +262,7 @@ const PERMISSION_BLUEPRINT = [
|
|||||||
{
|
{
|
||||||
key: 'maintenance-duplicates-group',
|
key: 'maintenance-duplicates-group',
|
||||||
title: 'Doppelte Stacks',
|
title: 'Doppelte Stacks',
|
||||||
sortOrder: 2,
|
sortOrder: 3,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
key: 'maintenance-duplicates',
|
key: 'maintenance-duplicates',
|
||||||
@@ -350,6 +367,16 @@ const tableExists = (tableName) => {
|
|||||||
return Boolean(result);
|
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 getExistingColumns = (tableName) => {
|
||||||
const pragma = db.prepare(`PRAGMA table_info(${tableName})`).all();
|
const pragma = db.prepare(`PRAGMA table_info(${tableName})`).all();
|
||||||
return new Set(pragma.map((column) => column.name));
|
return new Set(pragma.map((column) => column.name));
|
||||||
@@ -701,6 +728,7 @@ export const ensureDatabaseSchema = () => {
|
|||||||
const tableDefinitions = parseTableDefinitions(statements);
|
const tableDefinitions = parseTableDefinitions(statements);
|
||||||
const indexDefinitions = parseIndexStatements(statements);
|
const indexDefinitions = parseIndexStatements(statements);
|
||||||
|
|
||||||
|
dropDeprecatedArtifacts();
|
||||||
ensureTablesAndColumns(tableDefinitions);
|
ensureTablesAndColumns(tableDefinitions);
|
||||||
ensureIndexes(indexDefinitions);
|
ensureIndexes(indexDefinitions);
|
||||||
ensurePermissionSeeds();
|
ensurePermissionSeeds();
|
||||||
|
|||||||
+338
-172
@@ -34,15 +34,12 @@ import { getSetting, setSetting, deleteSetting } from './db/settings.js';
|
|||||||
import { activateMaintenanceMode, deactivateMaintenanceMode, getMaintenanceState, isMaintenanceModeActive } from './maintenance/state.js';
|
import { activateMaintenanceMode, deactivateMaintenanceMode, getMaintenanceState, isMaintenanceModeActive } from './maintenance/state.js';
|
||||||
import {
|
import {
|
||||||
ensureDefaultsFromEnv,
|
ensureDefaultsFromEnv,
|
||||||
getActiveEndpointExternalId,
|
|
||||||
getActiveApiKey,
|
getActiveApiKey,
|
||||||
getActiveServerUrl,
|
getActiveServerUrl,
|
||||||
hasServer,
|
hasServer,
|
||||||
hasEndpoint,
|
|
||||||
hasApiKey,
|
hasApiKey,
|
||||||
getSetupStatus,
|
getSetupStatus,
|
||||||
completeSetup,
|
completeSetup,
|
||||||
removeEndpoint,
|
|
||||||
removeServer,
|
removeServer,
|
||||||
setServerApiKey
|
setServerApiKey
|
||||||
} from './setup/index.js';
|
} from './setup/index.js';
|
||||||
@@ -101,16 +98,6 @@ app.get('*', (req, res, next) => {
|
|||||||
|
|
||||||
const PORT = 4001;
|
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 agent = new https.Agent({ rejectUnauthorized: false });
|
||||||
|
|
||||||
const resolvePortainerBaseUrl = () => {
|
const resolvePortainerBaseUrl = () => {
|
||||||
@@ -142,6 +129,111 @@ axiosInstance.interceptors.request.use((config) => {
|
|||||||
return 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 = {
|
const REDEPLOY_PHASES = {
|
||||||
QUEUED: 'queued',
|
QUEUED: 'queued',
|
||||||
STARTED: 'started',
|
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 envSessionTtl = Number(process.env.AUTH_SESSION_TTL_MS);
|
||||||
const AUTH_SESSION_TTL_MS = Number.isFinite(envSessionTtl) && envSessionTtl > 0 ? envSessionTtl : DEFAULT_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 = {
|
const AUTH_COOKIE_OPTIONS = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: AUTH_COOKIE_SECURE,
|
||||||
path: '/'
|
path: '/'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -177,7 +279,9 @@ const PUBLIC_API_ROUTES = [
|
|||||||
{ method: 'POST', matcher: /^\/api\/auth\/recover\/verify$/ },
|
{ method: 'POST', matcher: /^\/api\/auth\/recover\/verify$/ },
|
||||||
{ method: 'POST', matcher: /^\/api\/auth\/recover\/reset$/ },
|
{ method: 'POST', matcher: /^\/api\/auth\/recover\/reset$/ },
|
||||||
{ method: 'GET', matcher: /^\/api\/setup\/status$/ },
|
{ 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) => ({
|
const sanitizeUser = (user) => ({
|
||||||
@@ -422,7 +526,6 @@ const logStackEvent = ({
|
|||||||
status,
|
status,
|
||||||
message,
|
message,
|
||||||
redeployType = null,
|
redeployType = null,
|
||||||
endpointId = null,
|
|
||||||
metadata = {}
|
metadata = {}
|
||||||
}) => {
|
}) => {
|
||||||
const metadataPayload = {
|
const metadataPayload = {
|
||||||
@@ -433,11 +536,8 @@ const logStackEvent = ({
|
|||||||
metadataPayload.redeployType = redeployType;
|
metadataPayload.redeployType = redeployType;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endpointId !== undefined && endpointId !== null) {
|
|
||||||
metadataPayload.endpointId = String(endpointId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasMetadata = Object.keys(metadataPayload).length > 0;
|
const hasMetadata = Object.keys(metadataPayload).length > 0;
|
||||||
|
const serverContextId = getActiveServerUrl() || null;
|
||||||
|
|
||||||
logEvent({
|
logEvent({
|
||||||
category: 'stack',
|
category: 'stack',
|
||||||
@@ -447,8 +547,8 @@ const logStackEvent = ({
|
|||||||
entityType: 'stack',
|
entityType: 'stack',
|
||||||
entityId: stackId !== undefined && stackId !== null ? String(stackId) : null,
|
entityId: stackId !== undefined && stackId !== null ? String(stackId) : null,
|
||||||
entityName: stackName ?? null,
|
entityName: stackName ?? null,
|
||||||
contextType: endpointId !== undefined && endpointId !== null ? 'endpoint' : null,
|
contextType: serverContextId ? 'server' : null,
|
||||||
contextId: endpointId !== undefined && endpointId !== null ? String(endpointId) : null,
|
contextId: serverContextId,
|
||||||
message: message ?? null,
|
message: message ?? null,
|
||||||
metadata: hasMetadata ? metadataPayload : 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_SCRIPT_SETTING_KEY = 'portainer_update_script';
|
||||||
const PORTAINER_SSH_CONFIG_KEY = 'portainer_ssh_config';
|
const PORTAINER_SSH_CONFIG_KEY = 'portainer_ssh_config';
|
||||||
|
|
||||||
@@ -810,7 +979,7 @@ const testSshConnection = async (configOverride = null) => {
|
|||||||
|
|
||||||
const detectPortainerContainer = async () => {
|
const detectPortainerContainer = async () => {
|
||||||
try {
|
try {
|
||||||
const endpointId = requireActiveEndpointId();
|
const endpointId = await resolvePortainerEnvironmentId();
|
||||||
const containersRes = await axiosInstance.get(`/api/endpoints/${endpointId}/docker/containers/json`, {
|
const containersRes = await axiosInstance.get(`/api/endpoints/${endpointId}/docker/containers/json`, {
|
||||||
params: { all: true }
|
params: { all: true }
|
||||||
});
|
});
|
||||||
@@ -1128,7 +1297,7 @@ let currentPortainerUpdatePromise = null;
|
|||||||
|
|
||||||
const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) => {
|
const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) => {
|
||||||
let maintenanceActivated = false;
|
let maintenanceActivated = false;
|
||||||
const endpointId = requireActiveEndpointId();
|
const serverContextId = getActiveServerUrl() || null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
addUpdateLog(`Portainer-Update gestartet (Quelle: ${scriptSource})`, 'info');
|
addUpdateLog(`Portainer-Update gestartet (Quelle: ${scriptSource})`, 'info');
|
||||||
@@ -1158,8 +1327,8 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
|||||||
entityType: 'service',
|
entityType: 'service',
|
||||||
entityId: 'portainer',
|
entityId: 'portainer',
|
||||||
entityName: 'Portainer',
|
entityName: 'Portainer',
|
||||||
contextType: 'endpoint',
|
contextType: serverContextId ? 'server' : null,
|
||||||
contextId: String(endpointId),
|
contextId: serverContextId,
|
||||||
message: `Portainer Update gestartet (Ziel: ${targetVersion ?? 'unbekannt'})`,
|
message: `Portainer Update gestartet (Ziel: ${targetVersion ?? 'unbekannt'})`,
|
||||||
metadata: {
|
metadata: {
|
||||||
targetVersion: targetVersion ?? null,
|
targetVersion: targetVersion ?? null,
|
||||||
@@ -1176,8 +1345,8 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
|||||||
entityType: 'system',
|
entityType: 'system',
|
||||||
entityId: 'wartung',
|
entityId: 'wartung',
|
||||||
entityName: 'StackPulse Wartung',
|
entityName: 'StackPulse Wartung',
|
||||||
contextType: 'endpoint',
|
contextType: serverContextId ? 'server' : null,
|
||||||
contextId: String(endpointId),
|
contextId: serverContextId,
|
||||||
message: 'Wartungsmodus aktiviert (Portainer Update)',
|
message: 'Wartungsmodus aktiviert (Portainer Update)',
|
||||||
metadata: {
|
metadata: {
|
||||||
reason: 'portainer-update',
|
reason: 'portainer-update',
|
||||||
@@ -1215,8 +1384,8 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
|||||||
entityType: 'service',
|
entityType: 'service',
|
||||||
entityId: 'portainer',
|
entityId: 'portainer',
|
||||||
entityName: 'Portainer',
|
entityName: 'Portainer',
|
||||||
contextType: 'endpoint',
|
contextType: serverContextId ? 'server' : null,
|
||||||
contextId: String(endpointId),
|
contextId: serverContextId,
|
||||||
message: `Portainer Update abgeschlossen (Version: ${finalVersion ?? 'unbekannt'})`,
|
message: `Portainer Update abgeschlossen (Version: ${finalVersion ?? 'unbekannt'})`,
|
||||||
metadata: {
|
metadata: {
|
||||||
resultVersion: finalVersion ?? null
|
resultVersion: finalVersion ?? null
|
||||||
@@ -1247,8 +1416,8 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
|||||||
entityType: 'system',
|
entityType: 'system',
|
||||||
entityId: 'wartung',
|
entityId: 'wartung',
|
||||||
entityName: 'StackPulse Wartung',
|
entityName: 'StackPulse Wartung',
|
||||||
contextType: 'endpoint',
|
contextType: serverContextId ? 'server' : null,
|
||||||
contextId: String(endpointId),
|
contextId: serverContextId,
|
||||||
message: 'Wartungsmodus deaktiviert',
|
message: 'Wartungsmodus deaktiviert',
|
||||||
metadata: {
|
metadata: {
|
||||||
reason: 'portainer-update'
|
reason: 'portainer-update'
|
||||||
@@ -1266,8 +1435,8 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
|||||||
entityType: 'service',
|
entityType: 'service',
|
||||||
entityId: 'portainer',
|
entityId: 'portainer',
|
||||||
entityName: 'Portainer',
|
entityName: 'Portainer',
|
||||||
contextType: 'endpoint',
|
contextType: serverContextId ? 'server' : null,
|
||||||
contextId: String(endpointId),
|
contextId: serverContextId,
|
||||||
message,
|
message,
|
||||||
source: 'system'
|
source: 'system'
|
||||||
});
|
});
|
||||||
@@ -1293,8 +1462,8 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
|||||||
entityType: 'system',
|
entityType: 'system',
|
||||||
entityId: 'wartung',
|
entityId: 'wartung',
|
||||||
entityName: 'StackPulse Wartung',
|
entityName: 'StackPulse Wartung',
|
||||||
contextType: 'endpoint',
|
contextType: serverContextId ? 'server' : null,
|
||||||
contextId: String(endpointId),
|
contextId: serverContextId,
|
||||||
message: 'Wartungsmodus deaktiviert (Fehler)',
|
message: 'Wartungsmodus deaktiviert (Fehler)',
|
||||||
metadata: {
|
metadata: {
|
||||||
reason: 'portainer-update'
|
reason: 'portainer-update'
|
||||||
@@ -1308,17 +1477,17 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchPortainerStacks = async () => {
|
const fetchPortainerStacks = async () => {
|
||||||
const endpointId = requireActiveEndpointId();
|
|
||||||
const stacksRes = await axiosInstance.get('/api/stacks');
|
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 buildStackCollections = (stacks = []) => {
|
||||||
|
const currentSelfStackId = getSelfStackId();
|
||||||
const collections = new Map();
|
const collections = new Map();
|
||||||
|
|
||||||
stacks.forEach((stack) => {
|
stacks.forEach((stack) => {
|
||||||
const name = stack.Name || 'Unbenannt';
|
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);
|
const entry = collections.get(name);
|
||||||
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
@@ -1390,10 +1559,11 @@ const isStackOutdated = async (stackId) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filterOutdatedStacks = async (stacks = []) => {
|
const filterOutdatedStacks = async (stacks = []) => {
|
||||||
|
const currentSelfStackId = getSelfStackId();
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
stacks.map(async (stack) => ({
|
stacks.map(async (stack) => ({
|
||||||
stack,
|
stack,
|
||||||
outdated: SELF_STACK_ID && String(stack.Id) === SELF_STACK_ID
|
outdated: currentSelfStackId && String(stack.Id) === currentSelfStackId
|
||||||
? false
|
? false
|
||||||
: await isStackOutdated(stack.Id)
|
: await isStackOutdated(stack.Id)
|
||||||
}))
|
}))
|
||||||
@@ -1427,16 +1597,11 @@ const shouldFallbackToStackFile = (message) => {
|
|||||||
const redeployStackById = async (stackId, redeployType) => {
|
const redeployStackById = async (stackId, redeployType) => {
|
||||||
let stack;
|
let stack;
|
||||||
const messages = getRedeployMessages(redeployType);
|
const messages = getRedeployMessages(redeployType);
|
||||||
const endpointId = requireActiveEndpointId();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stackRes = await axiosInstance.get(`/api/stacks/${stackId}`);
|
const stackRes = await axiosInstance.get(`/api/stacks/${stackId}`);
|
||||||
stack = stackRes.data;
|
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 targetId = stack.Id || stackId;
|
||||||
const targetName = stack.Name || `Stack ${stackId}`;
|
const targetName = stack.Name || `Stack ${stackId}`;
|
||||||
|
|
||||||
@@ -1451,7 +1616,6 @@ const redeployStackById = async (stackId, redeployType) => {
|
|||||||
stackName: targetName,
|
stackName: targetName,
|
||||||
status: 'started',
|
status: 'started',
|
||||||
message: messages.started,
|
message: messages.started,
|
||||||
endpointId: stack.EndpointId,
|
|
||||||
redeployType
|
redeployType
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1531,7 +1695,6 @@ const redeployStackById = async (stackId, redeployType) => {
|
|||||||
stackName: targetName,
|
stackName: targetName,
|
||||||
status: 'success',
|
status: 'success',
|
||||||
message: messages.success,
|
message: messages.success,
|
||||||
endpointId: stack.EndpointId,
|
|
||||||
redeployType
|
redeployType
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1553,7 +1716,6 @@ const redeployStackById = async (stackId, redeployType) => {
|
|||||||
stackName: fallbackName,
|
stackName: fallbackName,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: errorMessage,
|
message: errorMessage,
|
||||||
endpointId: stack?.EndpointId || endpointId,
|
|
||||||
redeployType
|
redeployType
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1575,7 +1737,7 @@ app.use((req, res, next) => {
|
|||||||
return res.status(403).json({ error: 'SUPERUSER_REQUIRED' });
|
return res.status(403).json({ error: 'SUPERUSER_REQUIRED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasServer() || !hasEndpoint() || !hasApiKey()) {
|
if (!hasServer() || !hasApiKey()) {
|
||||||
return res.status(403).json({ error: 'SETUP_REQUIRED' });
|
return res.status(403).json({ error: 'SETUP_REQUIRED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1623,11 +1785,11 @@ app.use((req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- API Endpoints ---
|
// --- API Routes ---
|
||||||
|
|
||||||
app.get('/api/setup/status', (req, res) => {
|
app.get('/api/setup/status', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const status = getSetupStatus();
|
const status = buildSetupStatusResponse();
|
||||||
res.json(status);
|
res.json(status);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('⚠️ [Setup] Statusabfrage fehlgeschlagen:', 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) => {
|
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 initialStatus = getSetupStatus();
|
||||||
const needsSuperuser = initialStatus.requirements.superuser;
|
const needsSuperuser = initialStatus.requirements.superuser;
|
||||||
const needsServer = initialStatus.requirements.server;
|
const needsServer = initialStatus.requirements.server;
|
||||||
const needsEndpoint = initialStatus.requirements.endpoint;
|
|
||||||
const needsApiKey = initialStatus.requirements.apiKey;
|
const needsApiKey = initialStatus.requirements.apiKey;
|
||||||
|
|
||||||
const created = {};
|
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) => {
|
const normalizeApiKeyInput = (input) => {
|
||||||
if (!input) return null;
|
if (!input) return null;
|
||||||
if (typeof input === 'string') {
|
if (typeof input === 'string') {
|
||||||
@@ -1691,53 +1885,20 @@ app.post('/api/setup/complete', (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let serverPayload = normalizeServerInput(serverInput);
|
const serverPayload = normalizeServerInput(serverInput);
|
||||||
let endpointPayload = normalizeEndpointInput(endpointInput);
|
|
||||||
const apiKeyPayload = normalizeApiKeyInput(apiKeyInput);
|
const apiKeyPayload = normalizeApiKeyInput(apiKeyInput);
|
||||||
|
|
||||||
if (needsServer && (!serverPayload || !serverPayload.url)) {
|
if (needsServer && (!serverPayload || !serverPayload.url)) {
|
||||||
return res.status(400).json({ error: 'SERVER_DETAILS_REQUIRED' });
|
return res.status(400).json({ error: 'SERVER_DETAILS_REQUIRED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endpointPayload && !endpointPayload.externalId) {
|
if (needsServer || (serverPayload && serverPayload.url)) {
|
||||||
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' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const setupResult = completeSetup({
|
const setupResult = completeSetup({
|
||||||
server: serverPayload && serverPayload.url ? serverPayload : null,
|
server: serverPayload && serverPayload.url ? serverPayload : null
|
||||||
endpoint: endpointInputPayload
|
|
||||||
});
|
});
|
||||||
|
|
||||||
created.server = setupResult.server;
|
created.server = setupResult.server;
|
||||||
created.endpoint = setupResult.endpoint;
|
|
||||||
created.defaultEndpoint = setupResult.defaultEndpoint;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetServerId = apiKeyPayload?.serverId ?? created.server?.id ?? endpointPayload?.serverId ?? null;
|
|
||||||
|
|
||||||
if (needsSuperuser) {
|
if (needsSuperuser) {
|
||||||
const username = typeof superuserInput?.username === 'string' ? superuserInput.username.trim() : '';
|
const username = typeof superuserInput?.username === 'string' ? superuserInput.username.trim() : '';
|
||||||
const email = typeof superuserInput?.email === 'string' ? superuserInput.email.trim() : '';
|
const email = typeof superuserInput?.email === 'string' ? superuserInput.email.trim() : '';
|
||||||
@@ -1753,6 +1914,7 @@ app.post('/api/setup/complete', (req, res) => {
|
|||||||
|
|
||||||
const statusSnapshot = getSetupStatus();
|
const statusSnapshot = getSetupStatus();
|
||||||
|
|
||||||
|
let targetServerId = apiKeyPayload?.serverId ?? created.server?.id ?? null;
|
||||||
if (!targetServerId) {
|
if (!targetServerId) {
|
||||||
targetServerId = statusSnapshot.servers.items?.[0]?.id ?? null;
|
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' });
|
return res.status(400).json({ error: 'API_KEY_REQUIRED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalStatus = getSetupStatus();
|
if (hasSelfStackInput) {
|
||||||
created.defaultEndpoint = finalStatus.endpoints.default;
|
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({
|
res.status(201).json({
|
||||||
success: finalStatus.setupComplete,
|
success: finalStatus.setupComplete,
|
||||||
status: finalStatus,
|
status: finalStatus,
|
||||||
@@ -1780,8 +1952,6 @@ app.post('/api/setup/complete', (req, res) => {
|
|||||||
case 'SERVER_URL_REQUIRED':
|
case 'SERVER_URL_REQUIRED':
|
||||||
case 'SERVER_NAME_REQUIRED':
|
case 'SERVER_NAME_REQUIRED':
|
||||||
case 'SERVER_DETAILS_REQUIRED':
|
case 'SERVER_DETAILS_REQUIRED':
|
||||||
case 'ENDPOINT_DETAILS_REQUIRED':
|
|
||||||
case 'ENDPOINT_EXTERNAL_ID_REQUIRED':
|
|
||||||
case 'USERNAME_REQUIRED':
|
case 'USERNAME_REQUIRED':
|
||||||
case 'EMAIL_INVALID':
|
case 'EMAIL_INVALID':
|
||||||
case 'PASSWORD_TOO_SHORT':
|
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) => {
|
app.delete('/api/setup/servers/:id', requirePermission('maintenance-server-delete', 'full'), (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const numericId = Number(id);
|
const numericId = Number(id);
|
||||||
@@ -1836,7 +1981,7 @@ app.delete('/api/setup/servers/:id', requirePermission('maintenance-server-delet
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = removeServer(numericId);
|
const result = removeServer(numericId);
|
||||||
const status = getSetupStatus();
|
const status = buildSetupStatusResponse();
|
||||||
res.json({ success: true, removed: result, status });
|
res.json({ success: true, removed: result, status });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = error.code || error.message;
|
const code = error.code || error.message;
|
||||||
@@ -1863,7 +2008,7 @@ app.put('/api/setup/servers/:id/api-key', requirePermission('maintenance-server-
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = setServerApiKey({ serverId: numericId, apiKey: rawValue });
|
const result = setServerApiKey({ serverId: numericId, apiKey: rawValue });
|
||||||
const status = getSetupStatus();
|
const status = buildSetupStatusResponse();
|
||||||
res.json({ success: true, updated: result, status });
|
res.json({ success: true, updated: result, status });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = error.code || error.message;
|
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) => {
|
app.post('/api/auth/login', (req, res) => {
|
||||||
if (!hasSuperuser()) {
|
if (!hasSuperuser()) {
|
||||||
return res.status(403).json({ error: 'SUPERUSER_REQUIRED' });
|
return res.status(403).json({ error: 'SUPERUSER_REQUIRED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasServer() || !hasEndpoint() || !hasApiKey()) {
|
if (!hasServer() || !hasApiKey()) {
|
||||||
return res.status(403).json({ error: 'SETUP_REQUIRED' });
|
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' });
|
return res.status(403).json({ error: 'SUPERUSER_REQUIRED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasServer() || !hasEndpoint() || !hasApiKey()) {
|
if (!hasServer() || !hasApiKey()) {
|
||||||
return res.status(403).json({ error: 'SETUP_REQUIRED' });
|
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()) {
|
if (!hasSuperuser()) {
|
||||||
return res.status(404).json({ error: 'SUPERUSER_NOT_FOUND' });
|
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(', ')}`);
|
console.warn(`⚠️ [API] GET /api/stacks: Doppelte Stack-Namen erkannt: ${duplicateNames.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentSelfStackId = getSelfStackId();
|
||||||
const stacksWithStatus = await Promise.all(
|
const stacksWithStatus = await Promise.all(
|
||||||
canonicalStacks.map(async (stack) => {
|
canonicalStacks.map(async (stack) => {
|
||||||
try {
|
try {
|
||||||
@@ -2615,7 +2788,7 @@ app.get('/api/stacks', maintenanceGuard, async (req, res) => {
|
|||||||
redeploying: redeployPhase === REDEPLOY_PHASES.STARTED || redeployPhase === REDEPLOY_PHASES.QUEUED,
|
redeploying: redeployPhase === REDEPLOY_PHASES.STARTED || redeployPhase === REDEPLOY_PHASES.QUEUED,
|
||||||
redeployPhase,
|
redeployPhase,
|
||||||
redeployQueued: redeployPhase === REDEPLOY_PHASES.QUEUED,
|
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)
|
duplicateName: duplicateNameSet.has(stack.Name)
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2628,7 +2801,7 @@ app.get('/api/stacks', maintenanceGuard, async (req, res) => {
|
|||||||
redeploying: redeployPhase === REDEPLOY_PHASES.STARTED || redeployPhase === REDEPLOY_PHASES.QUEUED,
|
redeploying: redeployPhase === REDEPLOY_PHASES.STARTED || redeployPhase === REDEPLOY_PHASES.QUEUED,
|
||||||
redeployPhase,
|
redeployPhase,
|
||||||
redeployQueued: redeployPhase === REDEPLOY_PHASES.QUEUED,
|
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)
|
duplicateName: duplicateNameSet.has(stack.Name)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2959,7 +3132,6 @@ app.post(
|
|||||||
stackName: target.canonical.Name,
|
stackName: target.canonical.Name,
|
||||||
status: 'started',
|
status: 'started',
|
||||||
message: `Bereinigung doppelter Stacks gestartet (${duplicatesToDelete.length} Einträge)`,
|
message: `Bereinigung doppelter Stacks gestartet (${duplicatesToDelete.length} Einträge)`,
|
||||||
endpointId: target.canonical.EndpointId,
|
|
||||||
redeployType: REDEPLOY_TYPES.MAINTENANCE,
|
redeployType: REDEPLOY_TYPES.MAINTENANCE,
|
||||||
metadata: {
|
metadata: {
|
||||||
duplicateIds: duplicateIds
|
duplicateIds: duplicateIds
|
||||||
@@ -2978,7 +3150,6 @@ app.post(
|
|||||||
results.push({
|
results.push({
|
||||||
id: stack.Id,
|
id: stack.Id,
|
||||||
name: stack.Name,
|
name: stack.Name,
|
||||||
endpointId: stack.EndpointId,
|
|
||||||
status: 'deleted'
|
status: 'deleted'
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2988,7 +3159,6 @@ app.post(
|
|||||||
results.push({
|
results.push({
|
||||||
id: stack.Id,
|
id: stack.Id,
|
||||||
name: stack.Name,
|
name: stack.Name,
|
||||||
endpointId: stack.EndpointId,
|
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message
|
message
|
||||||
});
|
});
|
||||||
@@ -3002,7 +3172,6 @@ app.post(
|
|||||||
stackName: target.canonical.Name,
|
stackName: target.canonical.Name,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: `Bereinigung fehlgeschlagen für IDs: ${failedIds}`,
|
message: `Bereinigung fehlgeschlagen für IDs: ${failedIds}`,
|
||||||
endpointId: target.canonical.EndpointId,
|
|
||||||
redeployType: REDEPLOY_TYPES.MAINTENANCE,
|
redeployType: REDEPLOY_TYPES.MAINTENANCE,
|
||||||
metadata: {
|
metadata: {
|
||||||
duplicateIds: duplicateIds
|
duplicateIds: duplicateIds
|
||||||
@@ -3013,8 +3182,7 @@ app.post(
|
|||||||
success: false,
|
success: false,
|
||||||
canonical: {
|
canonical: {
|
||||||
id: target.canonical.Id,
|
id: target.canonical.Id,
|
||||||
name: target.canonical.Name,
|
name: target.canonical.Name
|
||||||
endpointId: target.canonical.EndpointId
|
|
||||||
},
|
},
|
||||||
results
|
results
|
||||||
});
|
});
|
||||||
@@ -3025,7 +3193,6 @@ app.post(
|
|||||||
stackName: target.canonical.Name,
|
stackName: target.canonical.Name,
|
||||||
status: 'success',
|
status: 'success',
|
||||||
message: `Bereinigung abgeschlossen. Entfernte IDs: ${results.map((entry) => entry.id).join(', ')}`,
|
message: `Bereinigung abgeschlossen. Entfernte IDs: ${results.map((entry) => entry.id).join(', ')}`,
|
||||||
endpointId: target.canonical.EndpointId,
|
|
||||||
redeployType: REDEPLOY_TYPES.MAINTENANCE,
|
redeployType: REDEPLOY_TYPES.MAINTENANCE,
|
||||||
metadata: {
|
metadata: {
|
||||||
removedDuplicates: results.filter((entry) => entry.status === 'deleted').map((entry) => entry.id)
|
removedDuplicates: results.filter((entry) => entry.status === 'deleted').map((entry) => entry.id)
|
||||||
@@ -3036,8 +3203,7 @@ app.post(
|
|||||||
success: true,
|
success: true,
|
||||||
canonical: {
|
canonical: {
|
||||||
id: target.canonical.Id,
|
id: target.canonical.Id,
|
||||||
name: target.canonical.Name,
|
name: target.canonical.Name
|
||||||
endpointId: target.canonical.EndpointId
|
|
||||||
},
|
},
|
||||||
removed: results.length,
|
removed: results.length,
|
||||||
results
|
results
|
||||||
@@ -3107,7 +3273,9 @@ app.get('/api/logs', requirePermission('logs-access', 'read'), (req, res) => {
|
|||||||
const metadata = parseJsonColumn(row.metadata);
|
const metadata = parseJsonColumn(row.metadata);
|
||||||
const legacyStack = row.entityType === 'stack' ? (row.entityId ?? null) : null;
|
const legacyStack = row.entityType === 'stack' ? (row.entityId ?? null) : null;
|
||||||
const legacyStackName = row.entityType === 'stack' ? (row.entityName ?? 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 {
|
return {
|
||||||
...row,
|
...row,
|
||||||
@@ -3118,7 +3286,7 @@ app.get('/api/logs', requirePermission('logs-access', 'read'), (req, res) => {
|
|||||||
stackId: legacyStack,
|
stackId: legacyStack,
|
||||||
stackName: legacyStackName,
|
stackName: legacyStackName,
|
||||||
redeployType: row.eventType ?? null,
|
redeployType: row.eventType ?? null,
|
||||||
endpoint: legacyEndpoint
|
server: serverContext
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const total = db.prepare(countQuery).get(params)?.total ?? logs.length;
|
const total = db.prepare(countQuery).get(params)?.total ?? logs.length;
|
||||||
@@ -3204,12 +3372,11 @@ app.put(
|
|||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
console.log(`🚀 PUT /api/stacks/redeploy-all: Redeploy ALL gestartet`);
|
console.log(`🚀 PUT /api/stacks/redeploy-all: Redeploy ALL gestartet`);
|
||||||
|
|
||||||
let endpointId;
|
const serverContextId = getActiveServerUrl() || null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
endpointId = requireActiveEndpointId();
|
|
||||||
const stacksRes = await axiosInstance.get('/api/stacks');
|
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:");
|
console.log("📦 Redeploy ALL für folgende Stacks:");
|
||||||
filteredStacks.forEach(s => console.log(` - ${s.Name}`));
|
filteredStacks.forEach(s => console.log(` - ${s.Name}`));
|
||||||
@@ -3232,8 +3399,8 @@ app.put(
|
|||||||
entityType: 'bulk-operation',
|
entityType: 'bulk-operation',
|
||||||
entityId: 'redeploy-alle',
|
entityId: 'redeploy-alle',
|
||||||
entityName: 'Redeploy ALLE',
|
entityName: 'Redeploy ALLE',
|
||||||
contextType: 'endpoint',
|
contextType: serverContextId ? 'server' : null,
|
||||||
contextId: String(endpointId),
|
contextId: serverContextId,
|
||||||
message: `Redeploy ALLE gestartet für: ${stackSummary}`,
|
message: `Redeploy ALLE gestartet für: ${stackSummary}`,
|
||||||
metadata: {
|
metadata: {
|
||||||
stacks: stackSummaryList
|
stacks: stackSummaryList
|
||||||
@@ -3271,8 +3438,8 @@ app.put(
|
|||||||
entityType: 'bulk-operation',
|
entityType: 'bulk-operation',
|
||||||
entityId: 'redeploy-alle',
|
entityId: 'redeploy-alle',
|
||||||
entityName: 'Redeploy ALLE',
|
entityName: 'Redeploy ALLE',
|
||||||
contextType: 'endpoint',
|
contextType: serverContextId ? 'server' : null,
|
||||||
contextId: String(endpointId),
|
contextId: serverContextId,
|
||||||
message: 'Redeploy ALLE abgeschlossen',
|
message: 'Redeploy ALLE abgeschlossen',
|
||||||
metadata: {
|
metadata: {
|
||||||
processedStacks: stackSummaryList
|
processedStacks: stackSummaryList
|
||||||
@@ -3291,8 +3458,8 @@ app.put(
|
|||||||
entityType: 'bulk-operation',
|
entityType: 'bulk-operation',
|
||||||
entityId: 'redeploy-alle',
|
entityId: 'redeploy-alle',
|
||||||
entityName: 'Redeploy ALLE',
|
entityName: 'Redeploy ALLE',
|
||||||
contextType: endpointId !== undefined && endpointId !== null ? 'endpoint' : null,
|
contextType: serverContextId ? 'server' : null,
|
||||||
contextId: endpointId !== undefined && endpointId !== null ? String(endpointId) : null,
|
contextId: serverContextId,
|
||||||
message,
|
message,
|
||||||
source: 'system'
|
source: 'system'
|
||||||
});
|
});
|
||||||
@@ -3315,10 +3482,9 @@ app.put(
|
|||||||
return res.status(400).json({ error: 'stackIds muss eine nicht leere Array sein' });
|
return res.status(400).json({ error: 'stackIds muss eine nicht leere Array sein' });
|
||||||
}
|
}
|
||||||
|
|
||||||
let endpointId;
|
const serverContextId = getActiveServerUrl() || null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
endpointId = requireActiveEndpointId();
|
|
||||||
const normalizedIds = stackIds.map((id) => String(id));
|
const normalizedIds = stackIds.map((id) => String(id));
|
||||||
const { filteredStacks } = await loadStackCollections();
|
const { filteredStacks } = await loadStackCollections();
|
||||||
const stacksById = new Map(filteredStacks.map((stack) => [String(stack.Id), stack]));
|
const stacksById = new Map(filteredStacks.map((stack) => [String(stack.Id), stack]));
|
||||||
@@ -3352,8 +3518,8 @@ app.put(
|
|||||||
entityType: 'bulk-operation',
|
entityType: 'bulk-operation',
|
||||||
entityId: 'redeploy-auswahl',
|
entityId: 'redeploy-auswahl',
|
||||||
entityName: `Redeploy Auswahl (${stackIds.length})`,
|
entityName: `Redeploy Auswahl (${stackIds.length})`,
|
||||||
contextType: 'endpoint',
|
contextType: serverContextId ? 'server' : null,
|
||||||
contextId: String(endpointId),
|
contextId: serverContextId,
|
||||||
message: `Redeploy Auswahl gestartet für: ${summaryText}`,
|
message: `Redeploy Auswahl gestartet für: ${summaryText}`,
|
||||||
metadata: {
|
metadata: {
|
||||||
requestedStackIds: normalizedIds
|
requestedStackIds: normalizedIds
|
||||||
@@ -3370,8 +3536,8 @@ app.put(
|
|||||||
entityType: 'bulk-operation',
|
entityType: 'bulk-operation',
|
||||||
entityId: 'redeploy-auswahl',
|
entityId: 'redeploy-auswahl',
|
||||||
entityName: `Redeploy Auswahl (${stackIds.length})`,
|
entityName: `Redeploy Auswahl (${stackIds.length})`,
|
||||||
contextType: 'endpoint',
|
contextType: serverContextId ? 'server' : null,
|
||||||
contextId: String(endpointId),
|
contextId: serverContextId,
|
||||||
message: 'Redeploy Auswahl übersprungen: keine veralteten Stacks',
|
message: 'Redeploy Auswahl übersprungen: keine veralteten Stacks',
|
||||||
metadata: {
|
metadata: {
|
||||||
requestedStackIds: normalizedIds,
|
requestedStackIds: normalizedIds,
|
||||||
@@ -3407,8 +3573,8 @@ app.put(
|
|||||||
entityType: 'bulk-operation',
|
entityType: 'bulk-operation',
|
||||||
entityId: 'redeploy-auswahl',
|
entityId: 'redeploy-auswahl',
|
||||||
entityName: `Redeploy Auswahl (${stackIds.length})`,
|
entityName: `Redeploy Auswahl (${stackIds.length})`,
|
||||||
contextType: 'endpoint',
|
contextType: serverContextId ? 'server' : null,
|
||||||
contextId: String(endpointId),
|
contextId: serverContextId,
|
||||||
message: 'Redeploy Auswahl abgeschlossen',
|
message: 'Redeploy Auswahl abgeschlossen',
|
||||||
metadata: {
|
metadata: {
|
||||||
processedStackIds: eligibleStacks.map((stack) => String(stack.Id))
|
processedStackIds: eligibleStacks.map((stack) => String(stack.Id))
|
||||||
@@ -3428,8 +3594,8 @@ app.put(
|
|||||||
entityType: 'bulk-operation',
|
entityType: 'bulk-operation',
|
||||||
entityId: 'redeploy-auswahl',
|
entityId: 'redeploy-auswahl',
|
||||||
entityName: `Redeploy Auswahl (${Array.isArray(stackIds) ? stackIds.length : 0})`,
|
entityName: `Redeploy Auswahl (${Array.isArray(stackIds) ? stackIds.length : 0})`,
|
||||||
contextType: endpointId !== undefined && endpointId !== null ? 'endpoint' : null,
|
contextType: serverContextId ? 'server' : null,
|
||||||
contextId: endpointId !== undefined && endpointId !== null ? String(endpointId) : null,
|
contextId: serverContextId,
|
||||||
message,
|
message,
|
||||||
metadata: {
|
metadata: {
|
||||||
requestedStackIds: normalized
|
requestedStackIds: normalized
|
||||||
|
|||||||
@@ -98,14 +98,14 @@ export function buildEventLogFilter(queryParams = {}) {
|
|||||||
filters.push(`(entity_type = 'stack' AND entity_id IN (${placeholders.join(', ')}))`);
|
filters.push(`(entity_type = 'stack' AND entity_id IN (${placeholders.join(', ')}))`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyEndpoints = valueToArray(queryParams.endpoints ?? queryParams.endpoint);
|
const serverContexts = valueToArray(queryParams.servers ?? queryParams.server);
|
||||||
if (legacyEndpoints.length) {
|
if (serverContexts.length) {
|
||||||
const placeholders = legacyEndpoints.map((_, idx) => {
|
const placeholders = serverContexts.map((_, idx) => {
|
||||||
const key = `legacyEndpoint${idx}`;
|
const key = `serverContext${idx}`;
|
||||||
params[key] = legacyEndpoints[idx];
|
params[key] = serverContexts[idx];
|
||||||
return `@${key}`;
|
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);
|
const messageQuery = singleValue(queryParams.message ?? queryParams.text);
|
||||||
|
|||||||
@@ -257,6 +257,26 @@ export function saveGroupPermissionValues(groupId, values = {}) {
|
|||||||
normalizedEntries.push({ item, level });
|
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(() => {
|
const runSave = db.transaction(() => {
|
||||||
deleteGroupPermissionValuesStmt.run(numericId);
|
deleteGroupPermissionValuesStmt.run(numericId);
|
||||||
normalizedEntries.forEach(({ item, level }) => {
|
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 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>
|
<script type="module" crossorigin src="/assets/index-71db1823.js"></script>
|
||||||
<link rel="stylesheet" href="/assets/index-328d6adf.css">
|
<link rel="stylesheet" href="/assets/index-dad5f6fa.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+12
-288
@@ -15,26 +15,6 @@ const updateServer = db.prepare(`
|
|||||||
WHERE id = ?
|
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 deleteServerStmt = db.prepare('DELETE FROM servers WHERE id = ?');
|
||||||
|
|
||||||
const selectApiKeyByServerId = db.prepare('SELECT * FROM server_api_keys WHERE server_id = ?');
|
const selectApiKeyByServerId = db.prepare('SELECT * FROM server_api_keys WHERE server_id = ?');
|
||||||
@@ -51,29 +31,8 @@ const upsertApiKey = db.prepare(`
|
|||||||
`);
|
`);
|
||||||
const deleteApiKeyByServerId = db.prepare('DELETE FROM server_api_keys WHERE server_id = ?');
|
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 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 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')
|
const API_KEY_SECRET = crypto.createHash('sha256')
|
||||||
.update(process.env.PORTAINER_API_SECRET || process.env.PORTAINER_API_KEY || 'stackpulse-portainer-api-key')
|
.update(process.env.PORTAINER_API_SECRET || process.env.PORTAINER_API_KEY || 'stackpulse-portainer-api-key')
|
||||||
@@ -167,89 +126,6 @@ function ensureServer({ name, url }) {
|
|||||||
return created;
|
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 }) {
|
function setServerApiKey({ serverId, apiKey }) {
|
||||||
const id = Number(serverId);
|
const id = Number(serverId);
|
||||||
if (!Number.isFinite(id)) {
|
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() {
|
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();
|
const firstServer = selectFirstServerStmt.get();
|
||||||
if (firstServer) {
|
if (firstServer) {
|
||||||
const row = selectApiKeyByServerId.get(firstServer.id);
|
const row = selectApiKeyByServerId.get(firstServer.id);
|
||||||
@@ -338,21 +178,6 @@ function getActiveApiKey() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getActiveServerUrl() {
|
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();
|
const firstServer = selectFirstServerStmt.get();
|
||||||
if (firstServer?.url) {
|
if (firstServer?.url) {
|
||||||
const normalizedUrl = normalizeUrl(firstServer.url);
|
const normalizedUrl = normalizeUrl(firstServer.url);
|
||||||
@@ -386,8 +211,6 @@ function removeServer(serverId) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const relatedEndpoints = selectEndpointsByServerId.all(id);
|
|
||||||
const hadDefaultEndpoint = relatedEndpoints.some((endpoint) => endpoint.is_default);
|
|
||||||
const existingApiKey = selectApiKeyByServerId.get(id);
|
const existingApiKey = selectApiKeyByServerId.get(id);
|
||||||
|
|
||||||
deleteServerStmt.run(id);
|
deleteServerStmt.run(id);
|
||||||
@@ -396,41 +219,26 @@ function removeServer(serverId) {
|
|||||||
console.log(`🗑️ [Setup] API-Key entfernt: Server ${server.name} (${server.id})`);
|
console.log(`🗑️ [Setup] API-Key entfernt: Server ${server.name} (${server.id})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hadDefaultEndpoint) {
|
console.log(`🗑️ [Setup] Server entfernt: ${server.name} (${server.id})`);
|
||||||
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}`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server,
|
server,
|
||||||
removed: true,
|
removed: true
|
||||||
endpointsRemoved: relatedEndpoints.length
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasEndpoint() {
|
|
||||||
const { count } = countEndpointsStmt.get();
|
|
||||||
return count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasApiKey() {
|
function hasApiKey() {
|
||||||
const { count } = countApiKeysStmt.get();
|
const { count } = countApiKeysStmt.get();
|
||||||
return count > 0;
|
return count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasCompleteSetup() {
|
function hasCompleteSetup() {
|
||||||
return hasSuperuser() && hasServer() && hasEndpoint() && hasApiKey();
|
return hasSuperuser() && hasServer() && hasApiKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureDefaultsFromEnv() {
|
function ensureDefaultsFromEnv() {
|
||||||
const envServerUrlRaw = process.env.PORTAINER_URL;
|
const envServerUrlRaw = process.env.PORTAINER_URL;
|
||||||
const envServerName = process.env.PORTAINER_SERVER_NAME;
|
const envServerName = process.env.PORTAINER_SERVER_NAME;
|
||||||
const envEndpointIdRaw = process.env.PORTAINER_ENDPOINT_ID;
|
|
||||||
const envEndpointName = process.env.PORTAINER_ENDPOINT_NAME;
|
|
||||||
let server = null;
|
let server = null;
|
||||||
if (envServerUrlRaw) {
|
if (envServerUrlRaw) {
|
||||||
try {
|
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 : '';
|
const envApiKeyRaw = typeof process.env.PORTAINER_API_KEY === 'string' ? process.env.PORTAINER_API_KEY : '';
|
||||||
if (envApiKeyRaw.trim()) {
|
if (envApiKeyRaw.trim()) {
|
||||||
const targetServer = server || selectFirstServerStmt.get();
|
const targetServer = server || selectFirstServerStmt.get();
|
||||||
@@ -480,8 +263,6 @@ function ensureDefaultsFromEnv() {
|
|||||||
|
|
||||||
function getSetupStatus() {
|
function getSetupStatus() {
|
||||||
const servers = selectAllServers.all();
|
const servers = selectAllServers.all();
|
||||||
const endpoints = selectAllEndpoints.all();
|
|
||||||
const defaultEndpoint = getDefaultEndpoint();
|
|
||||||
const apiKeyRecords = selectAllApiKeys.all();
|
const apiKeyRecords = selectAllApiKeys.all();
|
||||||
const apiKeyMap = new Map(apiKeyRecords.map((entry) => [entry.server_id, entry]));
|
const apiKeyMap = new Map(apiKeyRecords.map((entry) => [entry.server_id, entry]));
|
||||||
|
|
||||||
@@ -489,10 +270,6 @@ function getSetupStatus() {
|
|||||||
const envServerName = rawEnvServerName.trim();
|
const envServerName = rawEnvServerName.trim();
|
||||||
const rawEnvServerUrl = typeof process.env.PORTAINER_URL === 'string' ? process.env.PORTAINER_URL : '';
|
const rawEnvServerUrl = typeof process.env.PORTAINER_URL === 'string' ? process.env.PORTAINER_URL : '';
|
||||||
const envServerUrl = rawEnvServerUrl.trim();
|
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 rawEnvApiKey = typeof process.env.PORTAINER_API_KEY === 'string' ? process.env.PORTAINER_API_KEY : '';
|
||||||
const envApiKeyTrimmed = rawEnvApiKey.trim();
|
const envApiKeyTrimmed = rawEnvApiKey.trim();
|
||||||
const envApiKeyProvided = Boolean(envApiKeyTrimmed);
|
const envApiKeyProvided = Boolean(envApiKeyTrimmed);
|
||||||
@@ -503,7 +280,6 @@ function getSetupStatus() {
|
|||||||
const envSuperuserEmail = envSuperuserEmailRaw.trim();
|
const envSuperuserEmail = envSuperuserEmailRaw.trim();
|
||||||
|
|
||||||
const serverRequired = servers.length === 0;
|
const serverRequired = servers.length === 0;
|
||||||
const endpointRequired = endpoints.length === 0;
|
|
||||||
|
|
||||||
const apiKeyItems = servers.map((server) => {
|
const apiKeyItems = servers.map((server) => {
|
||||||
const keyMeta = apiKeyMap.get(server.id) || null;
|
const keyMeta = apiKeyMap.get(server.id) || null;
|
||||||
@@ -518,7 +294,7 @@ function getSetupStatus() {
|
|||||||
const apiKeyRequired = servers.length > 0 && apiKeyCount === 0;
|
const apiKeyRequired = servers.length > 0 && apiKeyCount === 0;
|
||||||
|
|
||||||
const superuserExists = hasSuperuser();
|
const superuserExists = hasSuperuser();
|
||||||
const setupComplete = superuserExists && !serverRequired && !endpointRequired && !apiKeyRequired;
|
const setupComplete = superuserExists && !serverRequired && !apiKeyRequired;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
superuser: {
|
superuser: {
|
||||||
@@ -536,13 +312,6 @@ function getSetupStatus() {
|
|||||||
requireInput: serverRequired,
|
requireInput: serverRequired,
|
||||||
envProvided: Boolean(envServerUrl)
|
envProvided: Boolean(envServerUrl)
|
||||||
},
|
},
|
||||||
endpoints: {
|
|
||||||
count: endpoints.length,
|
|
||||||
items: endpoints,
|
|
||||||
requireInput: endpointRequired,
|
|
||||||
envProvided: Boolean(envEndpointId),
|
|
||||||
default: defaultEndpoint
|
|
||||||
},
|
|
||||||
apiKeys: {
|
apiKeys: {
|
||||||
count: apiKeyCount,
|
count: apiKeyCount,
|
||||||
items: apiKeyItems,
|
items: apiKeyItems,
|
||||||
@@ -553,7 +322,6 @@ function getSetupStatus() {
|
|||||||
requirements: {
|
requirements: {
|
||||||
superuser: !superuserExists,
|
superuser: !superuserExists,
|
||||||
server: serverRequired,
|
server: serverRequired,
|
||||||
endpoint: endpointRequired,
|
|
||||||
apiKey: apiKeyRequired
|
apiKey: apiKeyRequired
|
||||||
},
|
},
|
||||||
setupComplete,
|
setupComplete,
|
||||||
@@ -561,9 +329,6 @@ function getSetupStatus() {
|
|||||||
serverName: envServerName || (envServerUrl ? deriveServerName(envServerUrl) : ''),
|
serverName: envServerName || (envServerUrl ? deriveServerName(envServerUrl) : ''),
|
||||||
serverNameFromEnv: rawEnvServerName,
|
serverNameFromEnv: rawEnvServerName,
|
||||||
serverUrl: envServerUrl,
|
serverUrl: envServerUrl,
|
||||||
endpointName: envEndpointName || (envEndpointId ? `Endpoint ${envEndpointId}` : ''),
|
|
||||||
endpointNameFromEnv: rawEnvEndpointName,
|
|
||||||
endpointExternalId: envEndpointId,
|
|
||||||
apiKeyProvided: envApiKeyProvided,
|
apiKeyProvided: envApiKeyProvided,
|
||||||
apiKeyValue: rawEnvApiKey,
|
apiKeyValue: rawEnvApiKey,
|
||||||
superuserUsername: envSuperuserUsernameRaw,
|
superuserUsername: envSuperuserUsernameRaw,
|
||||||
@@ -573,77 +338,36 @@ function getSetupStatus() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const createEndpointWithDefaultTransaction = db.transaction(({ serverInput, endpointInput }) => {
|
function completeSetup({ server: serverInput }) {
|
||||||
let server = null;
|
let serverRecord = null;
|
||||||
|
|
||||||
if (endpointInput?.serverId) {
|
if (serverInput && typeof serverInput.url === 'string' && serverInput.url.trim()) {
|
||||||
const byId = selectServerById.get(endpointInput.serverId);
|
serverRecord = ensureServer(serverInput);
|
||||||
if (!byId) {
|
} else {
|
||||||
const error = new Error('SERVER_NOT_FOUND');
|
serverRecord = selectFirstServerStmt.get();
|
||||||
error.code = 'SERVER_NOT_FOUND';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
server = byId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (serverInput) {
|
if (!serverRecord) {
|
||||||
server = ensureServer(serverInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!server) {
|
|
||||||
server = selectFirstServerStmt.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!server) {
|
|
||||||
const error = new Error('SERVER_DETAILS_REQUIRED');
|
const error = new Error('SERVER_DETAILS_REQUIRED');
|
||||||
error.code = 'SERVER_DETAILS_REQUIRED';
|
error.code = 'SERVER_DETAILS_REQUIRED';
|
||||||
throw error;
|
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 {
|
return {
|
||||||
server,
|
server: serverRecord
|
||||||
endpoint
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function completeSetup({ server: serverInput, endpoint: endpointInput }) {
|
|
||||||
const result = createEndpointWithDefaultTransaction({ serverInput, endpointInput });
|
|
||||||
return {
|
|
||||||
server: result.server,
|
|
||||||
endpoint: result.endpoint,
|
|
||||||
defaultEndpoint: getDefaultEndpoint()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ensureDefaultsFromEnv,
|
ensureDefaultsFromEnv,
|
||||||
ensureServer,
|
ensureServer,
|
||||||
ensureEndpoint,
|
|
||||||
setServerApiKey,
|
setServerApiKey,
|
||||||
setDefaultEndpoint,
|
|
||||||
getDefaultEndpoint,
|
|
||||||
getActiveEndpointExternalId,
|
|
||||||
getActiveApiKey,
|
getActiveApiKey,
|
||||||
getActiveServerUrl,
|
getActiveServerUrl,
|
||||||
hasServer,
|
hasServer,
|
||||||
hasEndpoint,
|
|
||||||
hasApiKey,
|
hasApiKey,
|
||||||
hasCompleteSetup,
|
hasCompleteSetup,
|
||||||
getSetupStatus,
|
getSetupStatus,
|
||||||
completeSetup,
|
completeSetup,
|
||||||
removeEndpoint,
|
|
||||||
removeServer
|
removeServer
|
||||||
};
|
};
|
||||||
|
|||||||
+7
-10
@@ -8,16 +8,13 @@ services:
|
|||||||
- "4001:4001"
|
- "4001:4001"
|
||||||
volumes:
|
volumes:
|
||||||
- stackpulse_data:/app/backend/data
|
- 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
|
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:
|
volumes:
|
||||||
stackpulse_data:
|
stackpulse_data:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
|
||||||
const MaintenanceContext = createContext(null);
|
const MaintenanceContext = createContext(null);
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ const INITIAL_STATE = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function MaintenanceProvider({ children }) {
|
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 [state, setState] = useState(INITIAL_STATE);
|
||||||
const pollingRef = useRef(null);
|
const pollingRef = useRef(null);
|
||||||
const wasRunningRef = useRef(false);
|
const wasRunningRef = useRef(false);
|
||||||
@@ -26,6 +29,10 @@ export default function MaintenanceProvider({ children }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchConfig = useCallback(async () => {
|
const fetchConfig = useCallback(async () => {
|
||||||
|
if (!canAccessMaintenance) {
|
||||||
|
applyState({ loading: false, error: "" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
applyState({ loading: true, error: "" });
|
applyState({ loading: true, error: "" });
|
||||||
try {
|
try {
|
||||||
const response = await axios.get("/api/maintenance/config");
|
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";
|
const message = err.response?.data?.error || err.message || "Fehler beim Laden der Wartungsdaten";
|
||||||
applyState({ loading: false, error: message });
|
applyState({ loading: false, error: message });
|
||||||
}
|
}
|
||||||
}, [applyState]);
|
}, [applyState, canAccessMaintenance]);
|
||||||
|
|
||||||
const refreshUpdateStatus = useCallback(async () => {
|
const refreshUpdateStatus = useCallback(async () => {
|
||||||
|
if (!canAccessMaintenance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get("/api/maintenance/update-status");
|
const response = await axios.get("/api/maintenance/update-status");
|
||||||
const data = response.data ?? {};
|
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";
|
const message = err.response?.data?.error || err.message || "Fehler beim Aktualisieren des Wartungsstatus";
|
||||||
setState((prev) => ({ ...prev, error: message }));
|
setState((prev) => ({ ...prev, error: message }));
|
||||||
}
|
}
|
||||||
}, []);
|
}, [canAccessMaintenance]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!canAccessMaintenance) {
|
||||||
|
applyState({ loading: false, error: "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
}, [fetchConfig]);
|
}, [applyState, fetchConfig, canAccessMaintenance]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!canAccessMaintenance) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
if (state.update?.running) {
|
if (state.update?.running) {
|
||||||
refreshUpdateStatus();
|
refreshUpdateStatus();
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
@@ -79,15 +96,15 @@ export default function MaintenanceProvider({ children }) {
|
|||||||
pollingRef.current = null;
|
pollingRef.current = null;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [state.update?.running, refreshUpdateStatus]);
|
}, [canAccessMaintenance, state.update?.running, refreshUpdateStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentlyRunning = Boolean(state.update?.running);
|
const currentlyRunning = Boolean(state.update?.running);
|
||||||
if (!currentlyRunning && wasRunningRef.current) {
|
if (canAccessMaintenance && !currentlyRunning && wasRunningRef.current) {
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
}
|
}
|
||||||
wasRunningRef.current = currentlyRunning;
|
wasRunningRef.current = currentlyRunning;
|
||||||
}, [state.update?.running, fetchConfig]);
|
}, [canAccessMaintenance, state.update?.running, fetchConfig]);
|
||||||
|
|
||||||
const triggerUpdate = useCallback(async (payload = {}) => {
|
const triggerUpdate = useCallback(async (payload = {}) => {
|
||||||
await axios.post("/api/maintenance/portainer-update", payload);
|
await axios.post("/api/maintenance/portainer-update", payload);
|
||||||
@@ -145,11 +162,6 @@ export default function MaintenanceProvider({ children }) {
|
|||||||
return response.data ?? {};
|
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 deleteSetupServer = useCallback(async (serverId) => {
|
||||||
const response = await axios.delete(`/api/setup/servers/${serverId}`);
|
const response = await axios.delete(`/api/setup/servers/${serverId}`);
|
||||||
return response.data ?? { success: false };
|
return response.data ?? { success: false };
|
||||||
@@ -160,6 +172,11 @@ export default function MaintenanceProvider({ children }) {
|
|||||||
return response.data ?? { success: false };
|
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 removeSuperuserAccount = useCallback(async () => {
|
||||||
const response = await axios.delete("/api/auth/superuser");
|
const response = await axios.delete("/api/auth/superuser");
|
||||||
return response.data ?? { success: false };
|
return response.data ?? { success: false };
|
||||||
@@ -184,11 +201,11 @@ export default function MaintenanceProvider({ children }) {
|
|||||||
testSshConnection,
|
testSshConnection,
|
||||||
fetchSuperuserStatus,
|
fetchSuperuserStatus,
|
||||||
fetchSetupStatus,
|
fetchSetupStatus,
|
||||||
deleteSetupEndpoint,
|
|
||||||
deleteSetupServer,
|
deleteSetupServer,
|
||||||
updateSetupApiKey,
|
updateSetupApiKey,
|
||||||
|
updateSelfStackId,
|
||||||
removeSuperuserAccount
|
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 (
|
return (
|
||||||
<MaintenanceContext.Provider value={value}>
|
<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 axios from "axios";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@material-tailwind/react";
|
} from "@material-tailwind/react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useToast } from "@/components/ToastProvider.jsx";
|
import { useToast } from "@/components/ToastProvider.jsx";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
|
||||||
const PHRASE_WORD_COUNT = 8;
|
const PHRASE_WORD_COUNT = 8;
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ const buildPhrasePayload = (words = []) => {
|
|||||||
export function ForgotPassword() {
|
export function ForgotPassword() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [phraseInputs, setPhraseInputs] = useState(Array(PHRASE_WORD_COUNT).fill(""));
|
const [phraseInputs, setPhraseInputs] = useState(Array(PHRASE_WORD_COUNT).fill(""));
|
||||||
@@ -50,6 +52,12 @@ export function ForgotPassword() {
|
|||||||
const [resetting, setResetting] = useState(false);
|
const [resetting, setResetting] = useState(false);
|
||||||
const [resetError, setResetError] = useState("");
|
const [resetError, setResetError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
navigate("/dashboard/stacks", { replace: true });
|
||||||
|
}
|
||||||
|
}, [user, navigate]);
|
||||||
|
|
||||||
const phraseWords = useMemo(() => {
|
const phraseWords = useMemo(() => {
|
||||||
if (fileContent.trim()) {
|
if (fileContent.trim()) {
|
||||||
return extractWordsFromText(fileContent);
|
return extractWordsFromText(fileContent);
|
||||||
|
|||||||
@@ -26,10 +26,41 @@ import {
|
|||||||
ordersOverviewData,
|
ordersOverviewData,
|
||||||
} from "@/data";
|
} from "@/data";
|
||||||
import { CheckCircleIcon, ClockIcon } from "@heroicons/react/24/solid";
|
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() {
|
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 (
|
return (
|
||||||
<div className="mt-12">
|
<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">
|
<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 }) => (
|
{statisticsCardsData.map(({ icon, title, footer, ...rest }) => (
|
||||||
<StatisticsCard
|
<StatisticsCard
|
||||||
@@ -87,7 +118,7 @@ export function Home() {
|
|||||||
</div>
|
</div>
|
||||||
<Menu placement="left-start">
|
<Menu placement="left-start">
|
||||||
<MenuHandler>
|
<MenuHandler>
|
||||||
<IconButton size="sm" variant="text" color="blue-gray">
|
<IconButton size="sm" variant="text" color="blue-gray" disabled={maintenanceLocked}>
|
||||||
<EllipsisVerticalIcon
|
<EllipsisVerticalIcon
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
fill="currenColor"
|
fill="currenColor"
|
||||||
|
|||||||
@@ -693,7 +693,8 @@ export function Logs() {
|
|||||||
const toggleOpen = () => setOpen((cur) => !cur);
|
const toggleOpen = () => setOpen((cur) => !cur);
|
||||||
return (
|
return (
|
||||||
<div className="mt-12 mb-8 flex flex-col gap-12">
|
<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">
|
{(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">
|
<div className="flex flex-col gap-1">
|
||||||
<span>
|
<span>
|
||||||
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}.
|
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}.
|
||||||
@@ -738,12 +739,12 @@ export function Logs() {
|
|||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleExport('txt')}
|
onClick={() => handleExport('txt')}
|
||||||
disabled={actionLoading || loading}
|
disabled={maintenanceLocked || actionLoading || loading}
|
||||||
className="w-full md:flex-1">
|
className="w-full md:flex-1">
|
||||||
Export TXT</Button>
|
Export TXT</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleExport('sql')}
|
onClick={() => handleExport('sql')}
|
||||||
disabled={actionLoading || loading}
|
disabled={maintenanceLocked || actionLoading || loading}
|
||||||
className="w-full md:flex-1">
|
className="w-full md:flex-1">
|
||||||
Export SQL</Button>
|
Export SQL</Button>
|
||||||
</>
|
</>
|
||||||
@@ -751,7 +752,7 @@ export function Logs() {
|
|||||||
{canDeleteLogs && (
|
{canDeleteLogs && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDeleteFiltered}
|
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">
|
className="w-full md:flex-1 bg-sunsetCoral-500 hover:bg-sunsetCoral-600">
|
||||||
Angezeigte löschen</Button>
|
Angezeigte löschen</Button>
|
||||||
)}
|
)}
|
||||||
@@ -1075,7 +1076,7 @@ export function Logs() {
|
|||||||
}}
|
}}
|
||||||
variant="static"
|
variant="static"
|
||||||
label="Kontext-ID"
|
label="Kontext-ID"
|
||||||
placeholder="z. B. Endpoint-ID"
|
placeholder="z. B. Server-ID"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1141,7 +1142,7 @@ export function Logs() {
|
|||||||
<div className="flex-1 min-w-[180px]">
|
<div className="flex-1 min-w-[180px]">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleResetFilters}
|
onClick={handleResetFilters}
|
||||||
disabled={actionLoading || loading}
|
disabled={maintenanceLocked || actionLoading || loading}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
Zurücksetzen
|
Zurücksetzen
|
||||||
@@ -1298,7 +1299,7 @@ export function Logs() {
|
|||||||
<Typography variant="small">
|
<Typography variant="small">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteLog(log.id)}
|
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"
|
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
|
Löschen
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
CardBody,
|
CardBody,
|
||||||
Button,
|
Button,
|
||||||
Switch,
|
Switch,
|
||||||
Input
|
Input,
|
||||||
|
Alert
|
||||||
} from "@material-tailwind/react";
|
} from "@material-tailwind/react";
|
||||||
|
|
||||||
const UPDATE_STATUS_LABELS = {
|
const UPDATE_STATUS_LABELS = {
|
||||||
@@ -128,9 +129,9 @@ export function Maintenance() {
|
|||||||
testSshConnection,
|
testSshConnection,
|
||||||
fetchSuperuserStatus,
|
fetchSuperuserStatus,
|
||||||
fetchSetupStatus,
|
fetchSetupStatus,
|
||||||
deleteSetupEndpoint,
|
|
||||||
deleteSetupServer,
|
deleteSetupServer,
|
||||||
updateSetupApiKey,
|
updateSetupApiKey,
|
||||||
|
updateSelfStackId,
|
||||||
removeSuperuserAccount
|
removeSuperuserAccount
|
||||||
} = useMaintenance();
|
} = useMaintenance();
|
||||||
|
|
||||||
@@ -140,6 +141,7 @@ export function Maintenance() {
|
|||||||
const canViewServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-manage", "read"));
|
const canViewServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-manage", "read"));
|
||||||
const canEditServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-manage", "full"));
|
const canEditServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-manage", "full"));
|
||||||
const canDeleteServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-delete", "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 canViewPortainer = Boolean(isSuperuserAccount || hasPermission("maintenance-portainer", "read"));
|
||||||
const canManagePortainerUpdate = Boolean(isSuperuserAccount || hasPermission("maintenance-update", "full"));
|
const canManagePortainerUpdate = Boolean(isSuperuserAccount || hasPermission("maintenance-update", "full"));
|
||||||
const canViewSsh = Boolean(isSuperuserAccount || hasPermission("maintenance-ssh-update", "read"));
|
const canViewSsh = Boolean(isSuperuserAccount || hasPermission("maintenance-ssh-update", "read"));
|
||||||
@@ -151,6 +153,7 @@ export function Maintenance() {
|
|||||||
const maintenanceMessage = maintenanceMeta?.message;
|
const maintenanceMessage = maintenanceMeta?.message;
|
||||||
const maintenanceExtraType = maintenanceMeta?.extra?.type;
|
const maintenanceExtraType = maintenanceMeta?.extra?.type;
|
||||||
const updateRunning = Boolean(updateState?.running);
|
const updateRunning = Boolean(updateState?.running);
|
||||||
|
const maintenanceLocked = maintenanceActive || updateRunning;
|
||||||
|
|
||||||
const [scriptDraft, setScriptDraft] = useState("");
|
const [scriptDraft, setScriptDraft] = useState("");
|
||||||
const [scriptSaving, setScriptSaving] = useState(false);
|
const [scriptSaving, setScriptSaving] = useState(false);
|
||||||
@@ -214,7 +217,9 @@ export function Maintenance() {
|
|||||||
const [apiKeyDrafts, setApiKeyDrafts] = useState({});
|
const [apiKeyDrafts, setApiKeyDrafts] = useState({});
|
||||||
const [apiKeyUpdatingId, setApiKeyUpdatingId] = useState(null);
|
const [apiKeyUpdatingId, setApiKeyUpdatingId] = useState(null);
|
||||||
const [serverDeleteId, setServerDeleteId] = 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 [duplicates, setDuplicates] = useState([]);
|
||||||
const [duplicatesLoading, setDuplicatesLoading] = useState(true);
|
const [duplicatesLoading, setDuplicatesLoading] = useState(true);
|
||||||
@@ -381,12 +386,21 @@ export function Maintenance() {
|
|||||||
}, [fetchDuplicates]);
|
}, [fetchDuplicates]);
|
||||||
|
|
||||||
const setupServers = useMemo(() => setupResources?.servers?.items ?? [], [setupResources]);
|
const setupServers = useMemo(() => setupResources?.servers?.items ?? [], [setupResources]);
|
||||||
const setupEndpoints = useMemo(() => setupResources?.endpoints?.items ?? [], [setupResources]);
|
|
||||||
const apiKeyInfoMap = useMemo(() => {
|
const apiKeyInfoMap = useMemo(() => {
|
||||||
const items = setupResources?.apiKeys?.items ?? [];
|
const items = setupResources?.apiKeys?.items ?? [];
|
||||||
return new Map(items.map((entry) => [entry.serverId, entry]));
|
return new Map(items.map((entry) => [entry.serverId, entry]));
|
||||||
}, [setupResources]);
|
}, [setupResources]);
|
||||||
const setupComplete = useMemo(() => Boolean(setupResources?.setupComplete), [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 totals = useMemo(() => {
|
||||||
const groups = Array.isArray(duplicates) ? duplicates.length : 0;
|
const groups = Array.isArray(duplicates) ? duplicates.length : 0;
|
||||||
@@ -525,7 +539,7 @@ export function Maintenance() {
|
|||||||
}, [canManageSsh, deleteSshConfig, showToast]);
|
}, [canManageSsh, deleteSshConfig, showToast]);
|
||||||
|
|
||||||
const handleSuperuserDelete = useCallback(async () => {
|
const handleSuperuserDelete = useCallback(async () => {
|
||||||
if (superuserDeleteLoading || superuserStatusLoading || !superuserExists) {
|
if (!canDeleteSuperuser || superuserDeleteLoading || superuserStatusLoading || !superuserExists) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,48 +580,7 @@ export function Maintenance() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSuperuserDeleteLoading(false);
|
setSuperuserDeleteLoading(false);
|
||||||
}
|
}
|
||||||
}, [superuserDeleteLoading, superuserStatusLoading, superuserExists, removeSuperuserAccount, loadSuperuserStatus, showToast]);
|
}, [canDeleteSuperuser, 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]);
|
|
||||||
|
|
||||||
const handleDeleteServer = useCallback(async (serverId) => {
|
const handleDeleteServer = useCallback(async (serverId) => {
|
||||||
if (!canDeleteServers) {
|
if (!canDeleteServers) {
|
||||||
@@ -620,12 +593,10 @@ export function Maintenance() {
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const linkedEndpoints = setupEndpoints.filter((entry) => entry.server_id === serverId);
|
|
||||||
const label = target.name || target.url || `Server ${serverId}`;
|
const label = target.name || target.url || `Server ${serverId}`;
|
||||||
const confirmMessage = [
|
const confirmMessage = [
|
||||||
`Server "${label}" wirklich löschen?`,
|
`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 im Setup."
|
||||||
"Das System benötigt anschließend erneut einen gültigen Server/Endpoint im Setup."
|
|
||||||
].filter(Boolean).join("\n");
|
].filter(Boolean).join("\n");
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -654,7 +625,7 @@ export function Maintenance() {
|
|||||||
} finally {
|
} finally {
|
||||||
setServerDeleteId(null);
|
setServerDeleteId(null);
|
||||||
}
|
}
|
||||||
}, [canDeleteServers, deleteSetupServer, loadSetupResources, serverDeleteId, setupEndpoints, setupServers, showToast]);
|
}, [canDeleteServers, deleteSetupServer, loadSetupResources, serverDeleteId, setupServers, showToast]);
|
||||||
|
|
||||||
const handleApiKeyDraftChange = useCallback((serverId, value) => {
|
const handleApiKeyDraftChange = useCallback((serverId, value) => {
|
||||||
if (!canEditServers) return;
|
if (!canEditServers) return;
|
||||||
@@ -706,6 +677,79 @@ export function Maintenance() {
|
|||||||
}
|
}
|
||||||
}, [apiKeyDrafts, apiKeyUpdatingId, canEditServers, loadSetupResources, showToast, updateSetupApiKey]);
|
}, [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) => {
|
const handleMaintenanceToggle = useCallback(async (nextActive) => {
|
||||||
if (!canControlMaintenance) return;
|
if (!canControlMaintenance) return;
|
||||||
if (maintenanceLoading || maintenanceToggleLoading) return;
|
if (maintenanceLoading || maintenanceToggleLoading) return;
|
||||||
@@ -935,7 +979,7 @@ export function Maintenance() {
|
|||||||
color="white"
|
color="white"
|
||||||
className="flex items-center justify-between"
|
className="flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<span>Server & Endpoints</span>
|
<span>Server & API-Keys</span>
|
||||||
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -1008,7 +1052,7 @@ export function Maintenance() {
|
|||||||
color="red"
|
color="red"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDeleteServer(server.id)}
|
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"}
|
{serverDeleteId === server.id ? "Lösche…" : "Löschen"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1016,7 +1060,7 @@ export function Maintenance() {
|
|||||||
color="blue"
|
color="blue"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleApiKeyUpdate(server.id)}
|
onClick={() => handleApiKeyUpdate(server.id)}
|
||||||
disabled={!canEditServers || setupResourcesLoading || apiKeyUpdatingId === server.id}
|
disabled={maintenanceLocked || !canEditServers || setupResourcesLoading || apiKeyUpdatingId === server.id}
|
||||||
>
|
>
|
||||||
{apiKeyUpdatingId === server.id ? "Speichere…" : "API-Key speichern"}
|
{apiKeyUpdatingId === server.id ? "Speichere…" : "API-Key speichern"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1029,61 +1073,74 @@ export function Maintenance() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3 border-t border-blue-gray-50 pt-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-1">
|
||||||
<Typography variant="small" color="blue-gray" className="font-semibold uppercase">
|
<Typography variant="small" color="blue-gray" className="font-semibold uppercase">
|
||||||
Endpoints
|
Self-Stack-ID
|
||||||
</Typography>
|
</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>
|
|
||||||
) : (
|
|
||||||
<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">
|
<p className="text-xs text-stormGrey-500">
|
||||||
ID: {endpoint.external_id} · Server: {endpoint.server_name || endpoint.server_url}
|
Optional – lass das Feld leer, wenn keine Self-Stack-ID benötigt wird.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
{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
|
<Button
|
||||||
variant="text"
|
variant="text"
|
||||||
color="red"
|
color="red"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDeleteEndpoint(endpoint.id)}
|
onClick={handleSelfStackRemove}
|
||||||
disabled={!canDeleteServers || setupResourcesLoading || endpointDeleteId === endpoint.id || serverDeleteId !== null}
|
disabled={maintenanceLocked || selfStackSaving || (!currentSelfStackValue && !selfStackDraft)}
|
||||||
>
|
>
|
||||||
{endpointDeleteId === endpoint.id ? "Lösche…" : "Löschen"}
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="blue"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSelfStackSave}
|
||||||
|
disabled={maintenanceLocked || selfStackSaving || setupResourcesLoading || !selfStackDirty}
|
||||||
|
>
|
||||||
|
{selfStackSaving ? "Speichere…" : "Self-Stack-ID speichern"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
{!canEditServers && (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
{!setupComplete && (
|
{!setupComplete && (
|
||||||
<Alert color="amber" className="border border-amber-200 bg-amber-50 text-amber-800">
|
<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>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1093,7 +1150,7 @@ export function Maintenance() {
|
|||||||
color="gray"
|
color="gray"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => loadSetupResources()}
|
onClick={() => loadSetupResources()}
|
||||||
disabled={setupResourcesLoading}
|
disabled={maintenanceLocked || setupResourcesLoading}
|
||||||
>
|
>
|
||||||
Aktualisieren
|
Aktualisieren
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1142,7 +1199,7 @@ export function Maintenance() {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={() => loadSuperuserStatus()}
|
onClick={() => loadSuperuserStatus()}
|
||||||
disabled={superuserStatusLoading || superuserDeleteLoading}
|
disabled={maintenanceLocked || superuserStatusLoading || superuserDeleteLoading}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
Status aktualisieren
|
Status aktualisieren
|
||||||
@@ -1150,7 +1207,7 @@ export function Maintenance() {
|
|||||||
<Button
|
<Button
|
||||||
color="red"
|
color="red"
|
||||||
onClick={handleSuperuserDelete}
|
onClick={handleSuperuserDelete}
|
||||||
disabled={superuserStatusLoading || superuserDeleteLoading || !superuserExists}
|
disabled={maintenanceLocked || !canDeleteSuperuser || superuserStatusLoading || superuserDeleteLoading || !superuserExists}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{superuserDeleteLoading ? "Wird gelöscht…" : "Superuser löschen"}
|
{superuserDeleteLoading ? "Wird gelöscht…" : "Superuser löschen"}
|
||||||
@@ -1203,7 +1260,7 @@ export function Maintenance() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => fetchPortainerStatus({ silent: false })}
|
onClick={() => fetchPortainerStatus({ silent: false })}
|
||||||
disabled={portainerLoading || portainerRefreshing}
|
disabled={maintenanceLocked || portainerLoading || portainerRefreshing}
|
||||||
className="w-full">
|
className="w-full">
|
||||||
Status aktualisieren
|
Status aktualisieren
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1424,14 +1481,14 @@ export function Maintenance() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSshSaveConfig}
|
onClick={handleSshSaveConfig}
|
||||||
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
|
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || maintenanceLocked}
|
||||||
className="mt-3 w-full">
|
className="mt-3 w-full">
|
||||||
{sshSaving ? 'Speichern…' : 'SSH-Konfiguration speichern'}
|
{sshSaving ? 'Speichern…' : 'SSH-Konfiguration speichern'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSshTestConnection}
|
onClick={handleSshTestConnection}
|
||||||
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
|
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || maintenanceLocked}
|
||||||
className="w-full bg-arcticBlue-500 hover:bg-arcticBlue-600">
|
className="w-full bg-arcticBlue-500 hover:bg-arcticBlue-600">
|
||||||
{sshTesting ? 'Test läuft…' : 'Verbindung testen'}
|
{sshTesting ? 'Test läuft…' : 'Verbindung testen'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1439,7 +1496,7 @@ export function Maintenance() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSshDeleteConfig}
|
onClick={handleSshDeleteConfig}
|
||||||
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
|
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || maintenanceLocked}
|
||||||
className="w-full hover:bg-sunsetCoral-600 bg-sunsetCoral-500"
|
className="w-full hover:bg-sunsetCoral-600 bg-sunsetCoral-500"
|
||||||
>
|
>
|
||||||
{sshDeleting ? 'Löschen…' : 'SSH-Einstellungen löschen'}
|
{sshDeleting ? 'Löschen…' : 'SSH-Einstellungen löschen'}
|
||||||
@@ -1469,14 +1526,14 @@ export function Maintenance() {
|
|||||||
<div className="mt-3 grid gap-2">
|
<div className="mt-3 grid gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleScriptSave}
|
onClick={handleScriptSave}
|
||||||
disabled={!canManageSsh || !scriptIsDirty || scriptSaving || updateRunning}
|
disabled={!canManageSsh || !scriptIsDirty || scriptSaving || maintenanceLocked}
|
||||||
className="mt-3 w-full">
|
className="mt-3 w-full">
|
||||||
Speichern
|
Speichern
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={handleScriptReset}
|
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">
|
className="w-full hover:bg-sunsetCoral-600 bg-sunsetCoral-500">
|
||||||
Standard wiederherstellen
|
Standard wiederherstellen
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1552,7 +1609,7 @@ export function Maintenance() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleTriggerUpdate}
|
onClick={handleTriggerUpdate}
|
||||||
disabled={!canManagePortainerUpdate || disableUpdateButton || updateActionLoading}
|
disabled={maintenanceLocked || !canManagePortainerUpdate || disableUpdateButton || updateActionLoading}
|
||||||
className="mt-3 w-full">
|
className="mt-3 w-full">
|
||||||
{updateActionLoading ? "Update wird gestartet…" : "Portainer aktualisieren"}
|
{updateActionLoading ? "Update wird gestartet…" : "Portainer aktualisieren"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1649,7 +1706,7 @@ export function Maintenance() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-300">
|
<p className="text-sm text-gray-300">
|
||||||
Behaltener Stack: ID {canonicalId} (Endpoint {entry?.canonical?.EndpointId ?? "-"})
|
Behaltener Stack: ID {canonicalId}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Typ: {resolveStackType(entry?.canonical?.Type)} • Erstellt: {formatCreatedAt(entry?.canonical?.Created)}
|
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">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<span className="font-semibold text-white">ID: {duplicate.Id}</span>
|
<span className="font-semibold text-white">ID: {duplicate.Id}</span>
|
||||||
<span>Endpoint: {duplicate.EndpointId ?? "-"}</span>
|
|
||||||
<span>Typ: {resolveStackType(duplicate.Type)}</span>
|
<span>Typ: {resolveStackType(duplicate.Type)}</span>
|
||||||
<span>Erstellt: {formatCreatedAt(duplicate.Created)}</span>
|
<span>Erstellt: {formatCreatedAt(duplicate.Created)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ import {
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ArrowDownTrayIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
|
import { ArrowDownTrayIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
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 = []) => {
|
const formatWordsAsText = (words = []) => {
|
||||||
if (!Array.isArray(words) || words.length === 0) {
|
if (!Array.isArray(words) || words.length === 0) {
|
||||||
@@ -37,6 +46,7 @@ const groupWords = (words = []) => {
|
|||||||
export function SecurityPhrase() {
|
export function SecurityPhrase() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, setSession, refreshSession } = useAuth();
|
const { user, setSession, refreshSession } = useAuth();
|
||||||
|
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [words, setWords] = useState([]);
|
const [words, setWords] = useState([]);
|
||||||
@@ -44,6 +54,11 @@ export function SecurityPhrase() {
|
|||||||
const [downloaded, setDownloaded] = useState(false);
|
const [downloaded, setDownloaded] = useState(false);
|
||||||
|
|
||||||
const requiresDownload = Boolean(user?.requiresSecurityPhraseDownload);
|
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]);
|
const groupedWords = useMemo(() => groupWords(words), [words]);
|
||||||
|
|
||||||
@@ -83,6 +98,11 @@ export function SecurityPhrase() {
|
|||||||
navigate("/dashboard/stacks", { replace: true });
|
navigate("/dashboard/stacks", { replace: true });
|
||||||
return;
|
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.";
|
const message = err?.response?.data?.error || err?.message || "Sicherheitsschlüssel konnte nicht geladen werden.";
|
||||||
setError(message);
|
setError(message);
|
||||||
@@ -133,6 +153,11 @@ export function SecurityPhrase() {
|
|||||||
setDownloaded(Boolean(downloadedAt));
|
setDownloaded(Boolean(downloadedAt));
|
||||||
navigate("/dashboard/stacks", { replace: true });
|
navigate("/dashboard/stacks", { replace: true });
|
||||||
} catch (err) {
|
} 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.";
|
const message = err?.response?.data?.error || err?.message || "Download konnte nicht bestätigt werden.";
|
||||||
setError(message);
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -223,7 +248,7 @@ export function SecurityPhrase() {
|
|||||||
size="md"
|
size="md"
|
||||||
className="flex items-center gap-2 normal-case"
|
className="flex items-center gap-2 normal-case"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
disabled={saving || downloaded || words.length === 0}
|
disabled={maintenanceLocked || saving || downloaded || words.length === 0}
|
||||||
>
|
>
|
||||||
<ArrowDownTrayIcon className="h-5 w-5" />
|
<ArrowDownTrayIcon className="h-5 w-5" />
|
||||||
Sicherheitsschlüssel herunterladen
|
Sicherheitsschlüssel herunterladen
|
||||||
|
|||||||
@@ -819,7 +819,8 @@ export function Stacks() {
|
|||||||
|
|
||||||
<div className="mt-12 mb-8 flex flex-col gap-12">
|
<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">
|
{(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">
|
<div className="flex flex-col gap-1">
|
||||||
<span>
|
<span>
|
||||||
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}.
|
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}.
|
||||||
@@ -1072,7 +1073,7 @@ export function Stacks() {
|
|||||||
) : (canRedeploySingle ? (
|
) : (canRedeploySingle ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleRedeploy(stack.Id)}
|
onClick={() => handleRedeploy(stack.Id)}
|
||||||
disabled={isBusy}
|
disabled={maintenanceLocked || isBusy}
|
||||||
className="disabled:opacity-50 disabled:cursor-not-allowed"
|
className="disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Redeploy
|
Redeploy
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ import { useAuth } from "@/components/AuthProvider.jsx";
|
|||||||
|
|
||||||
const _ = AVATAR_COLORS.join(" ");
|
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) => {
|
const normalizeUserGroups = (rawGroups) => {
|
||||||
if (!Array.isArray(rawGroups)) {
|
if (!Array.isArray(rawGroups)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -95,7 +104,7 @@ const buildInitialFormValues = (user) => {
|
|||||||
export function UserDetails() {
|
export function UserDetails() {
|
||||||
const { userId } = useParams();
|
const { userId } = useParams();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const { maintenance } = useMaintenance();
|
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
|
||||||
const { hasPermission, user: authUser } = useAuth();
|
const { hasPermission, user: authUser } = useAuth();
|
||||||
|
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
@@ -115,7 +124,11 @@ export function UserDetails() {
|
|||||||
const [securityPhraseError, setSecurityPhraseError] = useState("");
|
const [securityPhraseError, setSecurityPhraseError] = useState("");
|
||||||
const [renewingSecurityPhrase, setRenewingSecurityPhrase] = useState(false);
|
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 canEditUsers = Boolean(authUser?.isSuperuser || hasPermission("users-edit", "full"));
|
||||||
const canReadUsers = Boolean(authUser?.isSuperuser || hasPermission("users-edit", "read"));
|
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");
|
return user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser");
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
const canCurrentUserEditSuperuser = authUser?.isSuperuser && user?.id && Number(authUser.id) === Number(user.id);
|
||||||
|
const superuserFieldsLocked = isSuperuserUser && !canCurrentUserEditSuperuser;
|
||||||
|
|
||||||
const numericUserId = useMemo(() => {
|
const numericUserId = useMemo(() => {
|
||||||
const asNumber = Number(userId);
|
const asNumber = Number(userId);
|
||||||
@@ -436,7 +451,7 @@ export function UserDetails() {
|
|||||||
}, [canRenewSecurityPhrase, fetchSecurityPhrase, renewingSecurityPhrase, securityPhraseLoading]);
|
}, [canRenewSecurityPhrase, fetchSecurityPhrase, renewingSecurityPhrase, securityPhraseLoading]);
|
||||||
|
|
||||||
const handleRenewSecurityPhrase = useCallback(async () => {
|
const handleRenewSecurityPhrase = useCallback(async () => {
|
||||||
if (!canRenewSecurityPhrase || !numericUserId || maintenanceActive || renewingSecurityPhrase) {
|
if (!canRenewSecurityPhrase || !numericUserId || maintenanceLocked || renewingSecurityPhrase) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,7 +487,7 @@ export function UserDetails() {
|
|||||||
}, [
|
}, [
|
||||||
canRenewSecurityPhrase,
|
canRenewSecurityPhrase,
|
||||||
numericUserId,
|
numericUserId,
|
||||||
maintenanceActive,
|
maintenanceLocked,
|
||||||
renewingSecurityPhrase,
|
renewingSecurityPhrase,
|
||||||
showToast
|
showToast
|
||||||
]);
|
]);
|
||||||
@@ -560,17 +575,26 @@ export function UserDetails() {
|
|||||||
fetchSecurityPhrase();
|
fetchSecurityPhrase();
|
||||||
}, [hasLoaded, canManageSecurityPhrase, fetchSecurityPhrase, numericUserId]);
|
}, [hasLoaded, canManageSecurityPhrase, fetchSecurityPhrase, numericUserId]);
|
||||||
|
|
||||||
const inputDisabled = maintenanceActive || savingUser || !user || !canEditUsers;
|
const inputDisabled = maintenanceLocked || savingUser || !user || !canEditUsers;
|
||||||
const selectDisabled = maintenanceActive || savingUser || !user || groupsLoading || !canEditUsers || isSuperuserUser;
|
const selectDisabled = maintenanceLocked || savingUser || !user || groupsLoading || !canEditUsers || isSuperuserUser;
|
||||||
const avatarSelectDisabled = maintenanceActive || savingUser || !user || !canEditUsers;
|
const avatarSelectDisabled = maintenanceLocked || savingUser || !user || !canEditUsers || superuserFieldsLocked;
|
||||||
const groupSelectValue = formValues.groupId ? String(formValues.groupId) : "";
|
const groupSelectValue = formValues.groupId ? String(formValues.groupId) : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-12 flex flex-col gap-12">
|
<div className="mt-12 flex flex-col gap-12">
|
||||||
{maintenanceActive && (
|
{(maintenanceActive || updateRunning) && (
|
||||||
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
|
||||||
Wartungsmodus aktiv – Änderungen sind deaktiviert.
|
<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>
|
||||||
)}
|
)}
|
||||||
<div className="relative h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover\tbg-center">
|
<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"
|
color="green"
|
||||||
className="normal-case"
|
className="normal-case"
|
||||||
onClick={handleSaveUser}
|
onClick={handleSaveUser}
|
||||||
disabled={maintenanceActive || savingUser}
|
disabled={maintenanceLocked || savingUser}
|
||||||
>
|
>
|
||||||
{savingUser ? "Speichert ..." : "Änderungen speichern"}
|
{savingUser ? "Speichert ..." : "Änderungen speichern"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -627,6 +651,16 @@ export function UserDetails() {
|
|||||||
<Typography variant="h6" color="blue-gray" className="mb-4">
|
<Typography variant="h6" color="blue-gray" className="mb-4">
|
||||||
Allgemeine Einstellungen
|
Allgemeine Einstellungen
|
||||||
</Typography>
|
</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">
|
<div className="mb-6">
|
||||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
Benutzername
|
Benutzername
|
||||||
@@ -635,7 +669,7 @@ export function UserDetails() {
|
|||||||
value={formValues.username}
|
value={formValues.username}
|
||||||
onChange={handleUsernameChange}
|
onChange={handleUsernameChange}
|
||||||
placeholder="Benutzername"
|
placeholder="Benutzername"
|
||||||
disabled={inputDisabled}
|
disabled={inputDisabled || superuserFieldsLocked}
|
||||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||||
labelProps={{
|
labelProps={{
|
||||||
className: "before:content-none after:content-none"
|
className: "before:content-none after:content-none"
|
||||||
@@ -651,7 +685,7 @@ export function UserDetails() {
|
|||||||
value={formValues.email}
|
value={formValues.email}
|
||||||
onChange={handleEmailChange}
|
onChange={handleEmailChange}
|
||||||
placeholder="benutzer@example.com"
|
placeholder="benutzer@example.com"
|
||||||
disabled={inputDisabled}
|
disabled={inputDisabled || superuserFieldsLocked}
|
||||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||||
labelProps={{
|
labelProps={{
|
||||||
className: "before:content-none after:content-none"
|
className: "before:content-none after:content-none"
|
||||||
@@ -667,7 +701,7 @@ export function UserDetails() {
|
|||||||
value={formValues.password}
|
value={formValues.password}
|
||||||
onChange={handlePasswordChange}
|
onChange={handlePasswordChange}
|
||||||
placeholder="Passwort setzen"
|
placeholder="Passwort setzen"
|
||||||
disabled={inputDisabled}
|
disabled={inputDisabled || superuserFieldsLocked}
|
||||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||||
labelProps={{
|
labelProps={{
|
||||||
className: "before:content-none after:content-none"
|
className: "before:content-none after:content-none"
|
||||||
@@ -787,6 +821,7 @@ export function UserDetails() {
|
|||||||
onClick={handleReloadSecurityPhrase}
|
onClick={handleReloadSecurityPhrase}
|
||||||
disabled={
|
disabled={
|
||||||
!canRenewSecurityPhrase ||
|
!canRenewSecurityPhrase ||
|
||||||
|
maintenanceLocked ||
|
||||||
securityPhraseLoading ||
|
securityPhraseLoading ||
|
||||||
renewingSecurityPhrase
|
renewingSecurityPhrase
|
||||||
}
|
}
|
||||||
@@ -799,7 +834,7 @@ export function UserDetails() {
|
|||||||
onClick={handleRenewSecurityPhrase}
|
onClick={handleRenewSecurityPhrase}
|
||||||
disabled={
|
disabled={
|
||||||
!canRenewSecurityPhrase ||
|
!canRenewSecurityPhrase ||
|
||||||
maintenanceActive ||
|
maintenanceLocked ||
|
||||||
securityPhraseLoading ||
|
securityPhraseLoading ||
|
||||||
renewingSecurityPhrase
|
renewingSecurityPhrase
|
||||||
}
|
}
|
||||||
@@ -824,6 +859,7 @@ export function UserDetails() {
|
|||||||
onClick={handleReloadSecurityPhrase}
|
onClick={handleReloadSecurityPhrase}
|
||||||
disabled={
|
disabled={
|
||||||
!canRenewSecurityPhrase ||
|
!canRenewSecurityPhrase ||
|
||||||
|
maintenanceLocked ||
|
||||||
securityPhraseLoading ||
|
securityPhraseLoading ||
|
||||||
renewingSecurityPhrase
|
renewingSecurityPhrase
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ import { AVATAR_COLORS } from "@/data/avatarColors.js";
|
|||||||
|
|
||||||
const _ = AVATAR_COLORS.join(" ");
|
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) => ({
|
const mapGroup = (item) => ({
|
||||||
id: item?.id ?? null,
|
id: item?.id ?? null,
|
||||||
name: item?.name || "",
|
name: item?.name || "",
|
||||||
@@ -63,11 +72,19 @@ const LEVEL_OPTIONS = [
|
|||||||
{ value: "read", label: "Nur lesen" },
|
{ value: "read", label: "Nur lesen" },
|
||||||
{ value: "none", label: "Kein Zugriff" }
|
{ 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() {
|
export function UserGroupDetail() {
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const { maintenance } = useMaintenance();
|
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
|
||||||
const { hasPermission, user: authUser } = useAuth();
|
const { hasPermission, user: authUser } = useAuth();
|
||||||
|
|
||||||
const [group, setGroup] = useState(null);
|
const [group, setGroup] = useState(null);
|
||||||
@@ -85,12 +102,17 @@ export function UserGroupDetail() {
|
|||||||
const [permissionsLoading, setPermissionsLoading] = useState(false);
|
const [permissionsLoading, setPermissionsLoading] = useState(false);
|
||||||
const [permissionsError, setPermissionsError] = useState("");
|
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 isSuperuserGroup = useMemo(() => (group?.name || "").toLowerCase() === "superuser", [group]);
|
||||||
const isSuperuserAccount = Boolean(authUser?.isSuperuser);
|
const isSuperuserAccount = Boolean(authUser?.isSuperuser);
|
||||||
const canEditGroupDetails = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "full"));
|
const canEditGroupDetails = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "full"));
|
||||||
const canViewPermissionSettings = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "read"));
|
const canViewPermissionSettings = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "read"));
|
||||||
const canAdjustPermissions = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "full"));
|
const canAdjustPermissions = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "full"));
|
||||||
|
const superuserGroupLocked = isSuperuserGroup && !isSuperuserAccount;
|
||||||
|
|
||||||
const numericGroupId = useMemo(() => {
|
const numericGroupId = useMemo(() => {
|
||||||
const candidate = Number(groupId);
|
const candidate = Number(groupId);
|
||||||
@@ -485,22 +507,33 @@ export function UserGroupDetail() {
|
|||||||
return group?.avatarColor || "";
|
return group?.avatarColor || "";
|
||||||
}, [formValues.avatarColor, group]);
|
}, [formValues.avatarColor, group]);
|
||||||
|
|
||||||
const inputDisabled = maintenanceActive || savingGroup || !group || isSuperuserGroup || !canEditGroupDetails;
|
const inputDisabled = maintenanceLocked || savingGroup || !group || superuserGroupLocked || !canEditGroupDetails;
|
||||||
const selectDisabled = maintenanceActive || savingGroup || !group || !canEditGroupDetails;
|
const selectDisabled = maintenanceLocked || savingGroup || !group || superuserGroupLocked || !canEditGroupDetails;
|
||||||
const selectValue = formValues.avatarColor || "";
|
const selectValue = formValues.avatarColor || "";
|
||||||
|
|
||||||
const handlePermissionChange = useCallback((permissionKey, value) => {
|
const handlePermissionChange = useCallback((permissionKey, value) => {
|
||||||
if (!permissionKey || !canAdjustPermissions || isSuperuserGroup) {
|
if (!permissionKey || !canAdjustPermissions || isSuperuserGroup) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const nextValue = normalizePermissionLevel(permissionKey, value);
|
||||||
setPermissionSelection((prev) => {
|
setPermissionSelection((prev) => {
|
||||||
if (prev[permissionKey] === value) {
|
const shouldResetDelete =
|
||||||
|
permissionKey === "maintenance-server-manage" && nextValue !== "full";
|
||||||
|
|
||||||
|
if (!shouldResetDelete && prev[permissionKey] === nextValue) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
|
const nextState = {
|
||||||
...prev,
|
...prev,
|
||||||
[permissionKey]: value
|
[permissionKey]: nextValue
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (shouldResetDelete) {
|
||||||
|
nextState["maintenance-server-delete"] = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextState;
|
||||||
});
|
});
|
||||||
}, [canAdjustPermissions, isSuperuserGroup]);
|
}, [canAdjustPermissions, isSuperuserGroup]);
|
||||||
|
|
||||||
@@ -509,13 +542,16 @@ export function UserGroupDetail() {
|
|||||||
if (!permissionKey) {
|
if (!permissionKey) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
let value = null;
|
||||||
if (Object.prototype.hasOwnProperty.call(permissionSelection, permissionKey)) {
|
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)) {
|
if (typeof value === "string") {
|
||||||
return permissionDefaults[permissionKey];
|
return normalizePermissionLevel(permissionKey, value);
|
||||||
}
|
}
|
||||||
return null;
|
return value;
|
||||||
},
|
},
|
||||||
[permissionSelection, permissionDefaults]
|
[permissionSelection, permissionDefaults]
|
||||||
);
|
);
|
||||||
@@ -571,6 +607,8 @@ export function UserGroupDetail() {
|
|||||||
|
|
||||||
const rowName = `permission-${ownerKey}-${row.key}`;
|
const rowName = `permission-${ownerKey}-${row.key}`;
|
||||||
const currentValue = getPermissionValue(row.key) ?? "none";
|
const currentValue = getPermissionValue(row.key) ?? "none";
|
||||||
|
const displayValue = normalizePermissionLevel(row.key, currentValue);
|
||||||
|
const serverManageLevel = getPermissionValue("maintenance-server-manage") ?? "none";
|
||||||
const { addTopBorder = true } = options;
|
const { addTopBorder = true } = options;
|
||||||
|
|
||||||
const rowClasses = [
|
const rowClasses = [
|
||||||
@@ -580,10 +618,13 @@ export function UserGroupDetail() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
const availableLevels = new Set(
|
const baseLevels = Array.isArray(row.availableLevels) && row.availableLevels.length
|
||||||
Array.isArray(row.availableLevels) && row.availableLevels.length
|
|
||||||
? row.availableLevels
|
? row.availableLevels
|
||||||
: LEVEL_OPTIONS.map((option) => option.value)
|
: LEVEL_OPTIONS.map((option) => option.value);
|
||||||
|
const availableLevels = new Set(
|
||||||
|
row.key === "maintenance-server-delete"
|
||||||
|
? baseLevels.filter((level) => level === "full" || level === "none")
|
||||||
|
: baseLevels
|
||||||
);
|
);
|
||||||
const levelOptions = LEVEL_OPTIONS;
|
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">
|
<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) => {
|
{levelOptions.map((option) => {
|
||||||
const optionId = `${rowName}-${option.value}`;
|
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 =
|
const disabled =
|
||||||
!availableLevels.has(option.value) ||
|
!availableLevels.has(option.value) ||
|
||||||
permissionsLoading ||
|
permissionsLoading ||
|
||||||
savingGroup ||
|
savingGroup ||
|
||||||
!canAdjustPermissions ||
|
!canAdjustPermissions ||
|
||||||
isSuperuserGroup;
|
isSuperuserGroup ||
|
||||||
|
requiresManageFull;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -610,8 +656,7 @@ export function UserGroupDetail() {
|
|||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
htmlFor={optionId}
|
htmlFor={optionId}
|
||||||
className={`inline-flex w-full items-center gap-2 rounded-lg px-3 py-2 min-h-[38px] ${
|
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"
|
||||||
disabled ? "cursor-not-allowed bg-blue-gray-50/40" : "cursor-pointer hover:bg-blue-gray-50/60"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ListItemPrefix className="flex-shrink-0">
|
<ListItemPrefix className="flex-shrink-0">
|
||||||
@@ -633,8 +678,7 @@ export function UserGroupDetail() {
|
|||||||
</ListItemPrefix>
|
</ListItemPrefix>
|
||||||
<Typography
|
<Typography
|
||||||
color="blue-gray"
|
color="blue-gray"
|
||||||
className={`text-sm font-medium leading-5 whitespace-nowrap ${
|
className={`text-sm font-medium leading-5 whitespace-nowrap ${disabled ? "text-blue-gray-300" : "text-blue-gray-600"
|
||||||
disabled ? "text-blue-gray-300" : "text-blue-gray-600"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -651,9 +695,18 @@ export function UserGroupDetail() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-12 flex flex-col gap-12">
|
<div className="mt-12 flex flex-col gap-12">
|
||||||
{maintenanceActive && (
|
{(maintenanceActive || updateRunning) && (
|
||||||
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
|
||||||
Wartungsmodus aktiv – Änderungen sind deaktiviert.
|
<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>
|
||||||
)}
|
)}
|
||||||
<div className="relative h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover\tbg-center">
|
<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"
|
color="green"
|
||||||
className="normal-case"
|
className="normal-case"
|
||||||
onClick={handleSaveGroup}
|
onClick={handleSaveGroup}
|
||||||
disabled={maintenanceActive || savingGroup}
|
disabled={maintenanceLocked || savingGroup}
|
||||||
>
|
>
|
||||||
{savingGroup ? "Speichert ..." : "Änderungen speichern"}
|
{savingGroup ? "Speichert ..." : "Änderungen speichern"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -710,11 +763,14 @@ export function UserGroupDetail() {
|
|||||||
<Typography variant="h6" color="blue-gray" className="mb-4">
|
<Typography variant="h6" color="blue-gray" className="mb-4">
|
||||||
Gruppendaten
|
Gruppendaten
|
||||||
</Typography>
|
</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">
|
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
{maintenanceActive
|
Systemgruppe – Name und Beschreibung sind geschützt.
|
||||||
? "Wartungsmodus aktiv – Änderungen sind deaktiviert."
|
{superuserGroupLocked && (
|
||||||
: "Systemgruppe – Name und Beschreibung sind geschützt."}
|
<span className="mt-1 block text-xs text-amber-600">
|
||||||
|
Nur der Superuser darf die Superuser-Gruppe bearbeiten.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -810,9 +866,12 @@ export function UserGroupDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-2 xl:col-span-3">
|
<div className="lg:col-span-2 xl:col-span-3">
|
||||||
|
{(!maintenanceActive && !updateRunning) && (
|
||||||
|
<>
|
||||||
<Typography variant="h6" color="blue-gray" className="mb-4">
|
<Typography variant="h6" color="blue-gray" className="mb-4">
|
||||||
Berechtigungen (Ansicht)
|
Berechtigungen (Ansicht)
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{isSuperuserGroup ? (
|
{isSuperuserGroup ? (
|
||||||
<div className="rounded-xl border border-blue-gray-100 bg-white px-4 py-4 text-sm text-blue-gray-600 shadow-sm">
|
<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.
|
Die Superuser-Gruppe besitzt automatisch Vollzugriff auf alle Bereiche. Berechtigungen können hier nicht angepasst werden.
|
||||||
@@ -886,6 +945,9 @@ export function UserGroupDetail() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
|||||||
import { useToast } from "@/components/ToastProvider.jsx";
|
import { useToast } from "@/components/ToastProvider.jsx";
|
||||||
import { useAuth } from "@/components/AuthProvider.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() {
|
export function Usergroups() {
|
||||||
const [groups, setGroups] = useState([]);
|
const [groups, setGroups] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -29,8 +38,12 @@ export function Usergroups() {
|
|||||||
const [deletingGroupId, setDeletingGroupId] = useState(null);
|
const [deletingGroupId, setDeletingGroupId] = useState(null);
|
||||||
|
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const { maintenance } = useMaintenance();
|
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
|
||||||
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 navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
|
|
||||||
@@ -190,7 +203,8 @@ export function Usergroups() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCreateGroup = useCallback(async () => {
|
const handleCreateGroup = useCallback(async () => {
|
||||||
if (maintenanceActive) {
|
if (maintenanceLocked) {
|
||||||
|
setCreateGroupError("Im Wartungsmodus oder während eines Updates können keine Gruppen angelegt werden.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!canEditGroups) {
|
if (!canEditGroups) {
|
||||||
@@ -236,7 +250,7 @@ export function Usergroups() {
|
|||||||
setCreatingGroup(false);
|
setCreatingGroup(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
maintenanceActive,
|
maintenanceLocked,
|
||||||
canEditGroups,
|
canEditGroups,
|
||||||
newGroupName,
|
newGroupName,
|
||||||
newGroupDescription,
|
newGroupDescription,
|
||||||
@@ -245,10 +259,10 @@ export function Usergroups() {
|
|||||||
fetchGroups
|
fetchGroups
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const createGroupDisabled = maintenanceActive || creatingGroup;
|
const createGroupDisabled = maintenanceLocked || creatingGroup;
|
||||||
|
|
||||||
const handleDeleteGroup = useCallback(async (group) => {
|
const handleDeleteGroup = useCallback(async (group) => {
|
||||||
if (maintenanceActive || !group?.id) {
|
if (maintenanceLocked || !group?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,13 +336,22 @@ export function Usergroups() {
|
|||||||
} finally {
|
} finally {
|
||||||
setDeletingGroupId(null);
|
setDeletingGroupId(null);
|
||||||
}
|
}
|
||||||
}, [maintenanceActive, canDeleteGroups, showToast, fetchGroups]);
|
}, [maintenanceLocked, canDeleteGroups, showToast, fetchGroups]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-12 mb-8 flex flex-col gap-12">
|
<div className="mt-12 mb-8 flex flex-col gap-12">
|
||||||
{maintenanceActive && (
|
{(maintenanceActive || updateRunning) && (
|
||||||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
|
||||||
Wartungsmodus aktiv – Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
|
<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 className="border border-blue-gray-100 shadow-sm">
|
<Card className="border border-blue-gray-100 shadow-sm">
|
||||||
@@ -374,7 +397,12 @@ export function Usergroups() {
|
|||||||
<Button color="green" onClick={handleCreateGroup} disabled={createGroupDisabled}>
|
<Button color="green" onClick={handleCreateGroup} disabled={createGroupDisabled}>
|
||||||
{creatingGroup ? "Speichert ..." : "Gruppe anlegen"}
|
{creatingGroup ? "Speichert ..." : "Gruppe anlegen"}
|
||||||
</Button>
|
</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
|
Formular zurücksetzen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -498,7 +526,7 @@ export function Usergroups() {
|
|||||||
color="red"
|
color="red"
|
||||||
onClick={() => handleDeleteGroup(group)}
|
onClick={() => handleDeleteGroup(group)}
|
||||||
disabled={
|
disabled={
|
||||||
maintenanceActive ||
|
maintenanceLocked ||
|
||||||
deletingGroupId === group.id ||
|
deletingGroupId === group.id ||
|
||||||
Number(group.memberCount) > 0
|
Number(group.memberCount) > 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
|||||||
import { useToast } from "@/components/ToastProvider.jsx";
|
import { useToast } from "@/components/ToastProvider.jsx";
|
||||||
import { useAuth } from "@/components/AuthProvider.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) => {
|
const normalizeUserGroups = (rawGroups) => {
|
||||||
if (!Array.isArray(rawGroups)) {
|
if (!Array.isArray(rawGroups)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -61,8 +70,12 @@ export function Users() {
|
|||||||
const [togglingUserId, setTogglingUserId] = useState(null);
|
const [togglingUserId, setTogglingUserId] = useState(null);
|
||||||
|
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const { maintenance } = useMaintenance();
|
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
|
||||||
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 navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const noop = useCallback(() => { }, []);
|
const noop = useCallback(() => { }, []);
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
@@ -253,7 +266,7 @@ export function Users() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDeleteUser = useCallback(async (user) => {
|
const handleDeleteUser = useCallback(async (user) => {
|
||||||
if (maintenanceActive || !user?.id) {
|
if (maintenanceLocked || !user?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!canDeleteUsers) {
|
if (!canDeleteUsers) {
|
||||||
@@ -314,7 +327,7 @@ export function Users() {
|
|||||||
} finally {
|
} finally {
|
||||||
setDeletingUserId(null);
|
setDeletingUserId(null);
|
||||||
}
|
}
|
||||||
}, [maintenanceActive, canDeleteUsers, showToast, fetchUsers]);
|
}, [maintenanceLocked, canDeleteUsers, showToast, fetchUsers]);
|
||||||
|
|
||||||
const handleToggleUserStatus = useCallback(async (user) => {
|
const handleToggleUserStatus = useCallback(async (user) => {
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
@@ -323,11 +336,11 @@ export function Users() {
|
|||||||
if (!canManageUsers) {
|
if (!canManageUsers) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (maintenanceActive) {
|
if (maintenanceLocked) {
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: "Aktion nicht möglich",
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -386,10 +399,11 @@ export function Users() {
|
|||||||
} finally {
|
} finally {
|
||||||
setTogglingUserId(null);
|
setTogglingUserId(null);
|
||||||
}
|
}
|
||||||
}, [maintenanceActive, canManageUsers, showToast, fetchUsers]);
|
}, [maintenanceLocked, canManageUsers, showToast, fetchUsers]);
|
||||||
|
|
||||||
const handleCreateUser = useCallback(async () => {
|
const handleCreateUser = useCallback(async () => {
|
||||||
if (maintenanceActive) {
|
if (maintenanceLocked) {
|
||||||
|
setCreateError("Im Wartungsmodus oder während eines Updates können keine Benutzer angelegt werden.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!canManageUsers) {
|
if (!canManageUsers) {
|
||||||
@@ -467,7 +481,7 @@ export function Users() {
|
|||||||
setCreatingUser(false);
|
setCreatingUser(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
maintenanceActive,
|
maintenanceLocked,
|
||||||
canManageUsers,
|
canManageUsers,
|
||||||
newUsername,
|
newUsername,
|
||||||
newEmail,
|
newEmail,
|
||||||
@@ -479,8 +493,8 @@ export function Users() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const hasSelectableGroups = availableGroups.length > 0;
|
const hasSelectableGroups = availableGroups.length > 0;
|
||||||
const createDisabled = maintenanceActive || creatingUser || groupsLoading || !hasSelectableGroups;
|
const createDisabled = maintenanceLocked || creatingUser || groupsLoading || !hasSelectableGroups;
|
||||||
const groupSelectDisabled = maintenanceActive || creatingUser || groupsLoading || !hasSelectableGroups;
|
const groupSelectDisabled = maintenanceLocked || creatingUser || groupsLoading || !hasSelectableGroups;
|
||||||
const renderSelectedGroup = useCallback(
|
const renderSelectedGroup = useCallback(
|
||||||
(element) => {
|
(element) => {
|
||||||
if (element && element.props && element.props.children) {
|
if (element && element.props && element.props.children) {
|
||||||
@@ -497,9 +511,18 @@ export function Users() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-12 mb-8 flex flex-col gap-12">
|
<div className="mt-12 mb-8 flex flex-col gap-12">
|
||||||
{maintenanceActive && (
|
{(maintenanceActive || updateRunning) && (
|
||||||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
|
||||||
Wartungsmodus aktiv – Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
|
<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>
|
<Card>
|
||||||
@@ -539,14 +562,14 @@ export function Users() {
|
|||||||
label="Benutzername"
|
label="Benutzername"
|
||||||
value={newUsername}
|
value={newUsername}
|
||||||
onChange={(event) => setNewUsername(event.target.value)}
|
onChange={(event) => setNewUsername(event.target.value)}
|
||||||
disabled={maintenanceActive || creatingUser}
|
disabled={maintenanceLocked || creatingUser}
|
||||||
crossOrigin=""
|
crossOrigin=""
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="E-Mail (optional)"
|
label="E-Mail (optional)"
|
||||||
value={newEmail}
|
value={newEmail}
|
||||||
onChange={(event) => setNewEmail(event.target.value)}
|
onChange={(event) => setNewEmail(event.target.value)}
|
||||||
disabled={maintenanceActive || creatingUser}
|
disabled={maintenanceLocked || creatingUser}
|
||||||
crossOrigin=""
|
crossOrigin=""
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
@@ -554,7 +577,7 @@ export function Users() {
|
|||||||
label="Passwort"
|
label="Passwort"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(event) => setNewPassword(event.target.value)}
|
onChange={(event) => setNewPassword(event.target.value)}
|
||||||
disabled={maintenanceActive || creatingUser}
|
disabled={maintenanceLocked || creatingUser}
|
||||||
crossOrigin=""
|
crossOrigin=""
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
@@ -582,7 +605,12 @@ export function Users() {
|
|||||||
<Button color="green" onClick={handleCreateUser} disabled={createDisabled}>
|
<Button color="green" onClick={handleCreateUser} disabled={createDisabled}>
|
||||||
{creatingUser ? "Speichert ..." : "Benutzer anlegen"}
|
{creatingUser ? "Speichert ..." : "Benutzer anlegen"}
|
||||||
</Button>
|
</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
|
Formular zurücksetzen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -694,10 +722,10 @@ export function Users() {
|
|||||||
{canManageUsers ? (
|
{canManageUsers ? (
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onClick={() => handleToggleUserStatus(user)}
|
||||||
disabled={
|
disabled={
|
||||||
maintenanceActive ||
|
maintenanceLocked ||
|
||||||
togglingUserId === user.id ||
|
togglingUserId === user.id ||
|
||||||
(Array.isArray(user.groups) &&
|
(Array.isArray(user.groups) &&
|
||||||
user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser" && user.isActive))
|
user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser" && user.isActive))
|
||||||
@@ -746,7 +774,7 @@ export function Users() {
|
|||||||
variant="text"
|
variant="text"
|
||||||
color="red"
|
color="red"
|
||||||
onClick={() => handleDeleteUser(user)}
|
onClick={() => handleDeleteUser(user)}
|
||||||
disabled={maintenanceActive || deletingUserId === user.id}
|
disabled={maintenanceLocked || deletingUserId === user.id}
|
||||||
>
|
>
|
||||||
{deletingUserId === user.id ? "Löscht ..." : "Löschen"}
|
{deletingUserId === user.id ? "Löscht ..." : "Löschen"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+655
-305
File diff suppressed because it is too large
Load Diff
@@ -89,6 +89,7 @@ export const routes = [
|
|||||||
name: "passwort vergessen",
|
name: "passwort vergessen",
|
||||||
path: "/forgot-password",
|
path: "/forgot-password",
|
||||||
element: <ForgotPassword />,
|
element: <ForgotPassword />,
|
||||||
|
hidden: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <ArrowLeftOnRectangleIcon {...icon} />,
|
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`}
|
} 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">
|
<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
|
<IconButton
|
||||||
variant="text"
|
variant="text"
|
||||||
@@ -36,7 +36,10 @@ export function Sidenav({ brandImg, brandName, routes }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="m-4">
|
<div className="m-4">
|
||||||
{routes.map(({ layout, title, pages }, key) => {
|
{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) {
|
if (!permission || !permission.key) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user