All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 16s
1494 lines
53 KiB
JavaScript
1494 lines
53 KiB
JavaScript
const AUTHORITY = 'https://auth.mischlabs.de/realms/mischlabs';
|
|
const CLIENT_ID = 'mischlabs-admin';
|
|
const REDIRECT_URI = `${window.location.origin}/admin.html`;
|
|
const SCOPES = 'openid profile email';
|
|
const CONFIG_URL = '/services.json?v=1';
|
|
const LOCAL_CONFIG_KEY = 'mischlabs.dashboard.config';
|
|
const TOKEN_KEY = 'mischlabs.admin.tokens';
|
|
const DASHBOARD_TOKEN_KEY = 'mischlabs.dashboard.tokens';
|
|
const PKCE_KEY = 'mischlabs.admin.pkce';
|
|
|
|
const loginButton = document.querySelector('#loginButton');
|
|
const logoutButton = document.querySelector('#logoutButton');
|
|
const headerUserBadge = document.querySelector('#headerUserBadge');
|
|
const loginPanel = document.querySelector('#loginPanel');
|
|
const adminPanel = document.querySelector('#adminPanel');
|
|
const loginMessage = document.querySelector('#loginMessage');
|
|
const adminUser = document.querySelector('#adminUser');
|
|
const serviceEditor = document.querySelector('#serviceEditor');
|
|
const checkAllButton = document.querySelector('#checkAllButton');
|
|
const addServiceButton = document.querySelector('#addServiceButton');
|
|
const saveServerButton = document.querySelector('#saveServerButton');
|
|
const editorFeedback = document.querySelector('#editorFeedback');
|
|
const resetLocalButton = document.querySelector('#resetLocalButton');
|
|
const downloadConfigButton = document.querySelector('#downloadConfigButton');
|
|
const refreshOpsButton = document.querySelector('#refreshOpsButton');
|
|
const runWatchtowerButton = document.querySelector('#runWatchtowerButton');
|
|
const dangerModeButton = document.querySelector('#dangerModeButton');
|
|
const dangerState = document.querySelector('#dangerState');
|
|
const diskStatus = document.querySelector('#diskStatus');
|
|
const watchtowerStatus = document.querySelector('#watchtowerStatus');
|
|
const containerStatus = document.querySelector('#containerStatus');
|
|
const servicesStatusBoard = document.querySelector('#servicesStatusBoard');
|
|
const refreshServicesButton = document.querySelector('#refreshServicesButton');
|
|
|
|
let config = null;
|
|
let tokenSet = null;
|
|
let opsRefreshTimer = null;
|
|
let latestWatchtower = null;
|
|
let lastManualWatchtowerRun = null;
|
|
let latestContainers = [];
|
|
let dangerToken = null;
|
|
let dangerExpiresAt = 0;
|
|
let dangerTimer = null;
|
|
let opsAutoRefreshTimer = null;
|
|
|
|
function base64UrlEncode(buffer) {
|
|
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=+$/g, '');
|
|
}
|
|
|
|
function randomString(length = 64) {
|
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
|
const bytes = new Uint8Array(length);
|
|
crypto.getRandomValues(bytes);
|
|
return [...bytes].map((byte) => chars[byte % chars.length]).join('');
|
|
}
|
|
|
|
async function createChallenge(verifier) {
|
|
const data = new TextEncoder().encode(verifier);
|
|
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
return base64UrlEncode(digest);
|
|
}
|
|
|
|
function decodeJwt(token) {
|
|
const payload = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
const json = decodeURIComponent(atob(payload).split('').map((char) => (
|
|
`%${(`00${char.charCodeAt(0).toString(16)}`).slice(-2)}`
|
|
)).join(''));
|
|
return JSON.parse(json);
|
|
}
|
|
|
|
function getStoredTokens() {
|
|
try {
|
|
const raw = sessionStorage.getItem(TOKEN_KEY) || sessionStorage.getItem(DASHBOARD_TOKEN_KEY);
|
|
return raw ? JSON.parse(raw) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function storeTokens(tokens) {
|
|
sessionStorage.setItem(TOKEN_KEY, JSON.stringify(tokens));
|
|
sessionStorage.setItem(DASHBOARD_TOKEN_KEY, JSON.stringify(tokens));
|
|
}
|
|
|
|
function clearTokens() {
|
|
sessionStorage.removeItem(TOKEN_KEY);
|
|
sessionStorage.removeItem(DASHBOARD_TOKEN_KEY);
|
|
sessionStorage.removeItem(PKCE_KEY);
|
|
}
|
|
|
|
function isTokenValid(tokens) {
|
|
if (!tokens?.id_token) return false;
|
|
try {
|
|
const claims = decodeJwt(tokens.id_token);
|
|
return Date.now() < claims.exp * 1000;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function login() {
|
|
const verifier = randomString();
|
|
const challenge = await createChallenge(verifier);
|
|
const state = randomString(32);
|
|
sessionStorage.setItem(PKCE_KEY, JSON.stringify({ verifier, state }));
|
|
|
|
const params = new URLSearchParams({
|
|
client_id: CLIENT_ID,
|
|
redirect_uri: REDIRECT_URI,
|
|
response_type: 'code',
|
|
scope: SCOPES,
|
|
state,
|
|
code_challenge: challenge,
|
|
code_challenge_method: 'S256'
|
|
});
|
|
|
|
window.location.href = `${AUTHORITY}/protocol/openid-connect/auth?${params}`;
|
|
}
|
|
|
|
async function handleCallback() {
|
|
const url = new URL(window.location.href);
|
|
const code = url.searchParams.get('code');
|
|
const state = url.searchParams.get('state');
|
|
const error = url.searchParams.get('error');
|
|
|
|
if (error) {
|
|
loginMessage.textContent = `Keycloak Fehler: ${error}`;
|
|
window.history.replaceState({}, document.title, REDIRECT_URI);
|
|
return;
|
|
}
|
|
|
|
if (!code) return;
|
|
|
|
const stored = JSON.parse(sessionStorage.getItem(PKCE_KEY) || '{}');
|
|
if (!stored.verifier || stored.state !== state) {
|
|
loginMessage.textContent = 'SSO Antwort konnte nicht verifiziert werden.';
|
|
return;
|
|
}
|
|
|
|
const body = new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
client_id: CLIENT_ID,
|
|
redirect_uri: REDIRECT_URI,
|
|
code,
|
|
code_verifier: stored.verifier
|
|
});
|
|
|
|
try {
|
|
const response = await fetch(`${AUTHORITY}/protocol/openid-connect/token`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Token endpoint returned ${response.status}`);
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
storeTokens(tokens);
|
|
window.history.replaceState({}, document.title, REDIRECT_URI);
|
|
} catch (error) {
|
|
loginMessage.textContent = `Token-Austausch fehlgeschlagen: ${error.message}. Keycloak Client/Web Origins pruefen.`;
|
|
}
|
|
}
|
|
|
|
function authorize(tokens) {
|
|
const claims = decodeJwt(tokens.id_token);
|
|
const username = String(claims.preferred_username || '').toLowerCase();
|
|
const email = String(claims.email || '').toLowerCase();
|
|
const allowed = username === 'mrdiderot' || email === 'mail.misch@pm.me';
|
|
|
|
if (!allowed) {
|
|
loginMessage.textContent = `Angemeldet als ${claims.preferred_username || claims.email}, aber nicht fuer diese Adminseite freigeschaltet.`;
|
|
return null;
|
|
}
|
|
|
|
return claims;
|
|
}
|
|
|
|
async function loadConfig() {
|
|
const local = localStorage.getItem(LOCAL_CONFIG_KEY);
|
|
if (local) return JSON.parse(local);
|
|
|
|
const response = await fetch(CONFIG_URL, { cache: 'no-store' });
|
|
return response.json();
|
|
}
|
|
|
|
function categoryOptions(selected) {
|
|
return config.categories.map((category) => (
|
|
`<option value="${category.id}" ${category.id === selected ? 'selected' : ''}>${category.label}</option>`
|
|
)).join('');
|
|
}
|
|
|
|
function iconOptions(selected) {
|
|
const icons = ['shield', 'folder', 'lock', 'film', 'headphones', 'book', 'git', 'chess', 'user'];
|
|
return icons.map((icon) => (
|
|
`<option value="${icon}" ${icon === selected ? 'selected' : ''}>${icon}</option>`
|
|
)).join('');
|
|
}
|
|
|
|
function renderEditor() {
|
|
serviceEditor.innerHTML = config.services.map((service, index) => `
|
|
<article class="admin-row" data-index="${index}">
|
|
<!-- Group 1: Identitaet & Kategorie -->
|
|
<div class="admin-form-group-row">
|
|
<label style="flex: 1 1 120px;">
|
|
Dienst ID (Eindeutig)
|
|
<input data-field="id" value="${service.id || ''}" placeholder="z.B. passwort-manager">
|
|
</label>
|
|
<label style="flex: 2 2 180px;">
|
|
Anzeigename
|
|
<input data-field="name" value="${service.name || ''}" placeholder="z.B. Passwords">
|
|
</label>
|
|
<label style="flex: 2 2 200px;">
|
|
Subdomain / Domain
|
|
<input data-field="domain" value="${service.domain || ''}" placeholder="z.B. password.mischlabs.de">
|
|
</label>
|
|
<label style="flex: 1.5 1.5 150px;">
|
|
Kategorie
|
|
<select data-field="category">${categoryOptions(service.category)}</select>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Group 2: URLs, Aesthetics & Icon -->
|
|
<div class="admin-form-group-row">
|
|
<label style="flex: 3 3 250px;">
|
|
Dienst-URL (Zieladresse)
|
|
<input data-field="url" value="${service.url || ''}" placeholder="z.B. https://password.mischlabs.de">
|
|
</label>
|
|
<label style="flex: 2 2 180px;">
|
|
Mobile URL Scheme (Optional)
|
|
<input data-field="mobileUrl" value="${service.mobileUrl || ''}" placeholder="z.B. bitwarden://">
|
|
</label>
|
|
<label style="flex: 1.5 1.5 140px;">
|
|
Accent-Farbe
|
|
<div style="display: flex; gap: 6px; align-items: center;">
|
|
<input type="color" class="accent-color-picker" value="${service.accent || '#38bdf8'}" style="width: 42px; min-width: 42px; height: 38px; padding: 2px; cursor: pointer; border: 1px solid var(--border); border-radius: var(--radius); background: rgba(17,22,36,0.92);">
|
|
<input data-field="accent" value="${service.accent || '#38bdf8'}" placeholder="#ffffff" style="font-family: monospace;">
|
|
</div>
|
|
</label>
|
|
<label style="flex: 1.2 1.2 120px;">
|
|
Icon
|
|
<select data-field="icon">${iconOptions(service.icon)}</select>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Group 3: Description, Keywords & Actions -->
|
|
<div class="admin-form-group-row">
|
|
<label style="flex: 2 2 250px;">
|
|
Beschreibung
|
|
<input data-field="description" value="${service.description || ''}" placeholder="Kurze Dienstbeschreibung...">
|
|
</label>
|
|
<label style="flex: 2 2 250px;">
|
|
Keywords (Suche)
|
|
<input data-field="keywords" value="${service.keywords || ''}" placeholder="Schluesselwoerter (durch Leerzeichen getrennt)...">
|
|
</label>
|
|
</div>
|
|
|
|
<div class="admin-row-actions">
|
|
<button class="ghost-button check-one mini" type="button">Verbindung Pruefen</button>
|
|
<button class="ghost-button danger-button delete-one mini" type="button" style="border-color: rgba(251, 113, 133, 0.3); color: #fb7185;">Loeschen</button>
|
|
</div>
|
|
<div class="diagnostic">Noch nicht geprueft.</div>
|
|
</article>
|
|
`).join('');
|
|
|
|
// Wire up two-way sync for color pickers and text inputs, and event listeners
|
|
serviceEditor.querySelectorAll('.admin-row').forEach((row) => {
|
|
const index = Number(row.dataset.index);
|
|
const service = config.services[index];
|
|
|
|
// Wire standard input changes
|
|
row.querySelectorAll('select, input:not(.accent-color-picker)').forEach((input) => {
|
|
input.addEventListener('input', () => {
|
|
service[input.dataset.field] = input.value;
|
|
});
|
|
input.addEventListener('change', () => {
|
|
service[input.dataset.field] = input.value;
|
|
});
|
|
});
|
|
|
|
// Wire color picker sync
|
|
const textInput = row.querySelector('input[data-field="accent"]');
|
|
const colorPicker = row.querySelector('.accent-color-picker');
|
|
|
|
if (textInput && colorPicker) {
|
|
colorPicker.addEventListener('input', () => {
|
|
textInput.value = colorPicker.value;
|
|
service.accent = colorPicker.value;
|
|
});
|
|
textInput.addEventListener('input', () => {
|
|
const val = textInput.value;
|
|
if (/^#[0-9A-Fa-f]{6}$/.test(val)) {
|
|
colorPicker.value = val;
|
|
}
|
|
service.accent = val;
|
|
});
|
|
}
|
|
});
|
|
|
|
serviceEditor.querySelectorAll('.check-one').forEach((button) => {
|
|
button.addEventListener('click', () => checkRow(button.closest('.admin-row')));
|
|
});
|
|
|
|
serviceEditor.querySelectorAll('.delete-one').forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
const row = button.closest('.admin-row');
|
|
const index = Number(row.dataset.index);
|
|
const service = config.services[index];
|
|
const confirmed = window.confirm(`Dienst "${service.name || service.id || 'unbekannt'}" wirklich loeschen?`);
|
|
if (!confirmed) return;
|
|
config.services.splice(index, 1);
|
|
renderEditor();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function probe(url) {
|
|
const started = performance.now();
|
|
const controller = new AbortController();
|
|
const timeout = window.setTimeout(() => controller.abort(), 6000);
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
mode: 'no-cors',
|
|
cache: 'no-store',
|
|
signal: controller.signal
|
|
});
|
|
return {
|
|
status: 'online',
|
|
ms: Math.round(performance.now() - started),
|
|
detail: response.type === 'opaque'
|
|
? 'Erreichbar. HTTP-Details wegen Browser-CORS nicht lesbar.'
|
|
: `HTTP ${response.status}`
|
|
};
|
|
} catch (error) {
|
|
const isTimeout = error.name === 'AbortError';
|
|
return {
|
|
status: isTimeout ? 'offline' : 'unknown',
|
|
ms: Math.round(performance.now() - started),
|
|
detail: isTimeout
|
|
? 'Timeout nach 6 Sekunden.'
|
|
: 'Der Browser blockiert diese Pruefung wahrscheinlich wegen CORS/CORB. Der Dienst kann trotzdem online sein.'
|
|
};
|
|
} finally {
|
|
window.clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
async function checkRow(row) {
|
|
const service = config.services[Number(row.dataset.index)];
|
|
const diagnostic = row.querySelector('.diagnostic');
|
|
diagnostic.textContent = 'Pruefe...';
|
|
row.classList.remove('is-online', 'is-offline', 'is-unknown');
|
|
|
|
const result = await probe(service.url);
|
|
row.classList.toggle('is-online', result.status === 'online');
|
|
row.classList.toggle('is-offline', result.status === 'offline');
|
|
row.classList.toggle('is-unknown', result.status === 'unknown');
|
|
|
|
const label = {
|
|
online: 'Online',
|
|
offline: 'Fehler',
|
|
unknown: 'Nicht im Browser pruefbar'
|
|
}[result.status];
|
|
|
|
diagnostic.textContent = `${label} - ${result.ms} ms - ${result.detail}`;
|
|
}
|
|
|
|
function addService() {
|
|
const newService = {
|
|
id: `dienst-${Date.now()}`,
|
|
name: 'Neuer Dienst',
|
|
description: 'Beschreibung des Dienstes',
|
|
domain: 'neu.mischlabs.de',
|
|
url: 'https://neu.mischlabs.de',
|
|
category: config.categories[0]?.id || 'access',
|
|
accent: '#38bdf8',
|
|
icon: 'shield',
|
|
keywords: 'neu dienst'
|
|
};
|
|
config.services.push(newService);
|
|
renderEditor();
|
|
|
|
// Scroll new row into view smoothly
|
|
const rows = serviceEditor.querySelectorAll('.admin-row');
|
|
const lastRow = rows[rows.length - 1];
|
|
if (lastRow) {
|
|
lastRow.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
lastRow.classList.add('is-new');
|
|
}
|
|
}
|
|
|
|
function showFeedback(type, message) {
|
|
if (!editorFeedback) return;
|
|
editorFeedback.style.display = 'block';
|
|
editorFeedback.textContent = message;
|
|
|
|
if (type === 'success') {
|
|
editorFeedback.style.background = 'rgba(52, 211, 153, 0.12)';
|
|
editorFeedback.style.borderColor = 'rgba(52, 211, 153, 0.35)';
|
|
editorFeedback.style.color = '#34d399';
|
|
} else {
|
|
editorFeedback.style.background = 'rgba(251, 113, 133, 0.12)';
|
|
editorFeedback.style.borderColor = 'rgba(251, 113, 133, 0.35)';
|
|
editorFeedback.style.color = '#fb7185';
|
|
}
|
|
|
|
if (type === 'success') {
|
|
setTimeout(() => {
|
|
editorFeedback.style.display = 'none';
|
|
}, 5000);
|
|
}
|
|
}
|
|
|
|
async function saveServer() {
|
|
if (!tokenSet?.id_token) {
|
|
showFeedback('error', 'Nicht angemeldet oder Sitzung abgelaufen.');
|
|
return;
|
|
}
|
|
|
|
if (!config || !Array.isArray(config.services) || !Array.isArray(config.categories)) {
|
|
showFeedback('error', 'Ungueltiges Konfigurationsformat.');
|
|
return;
|
|
}
|
|
|
|
for (const service of config.services) {
|
|
if (!service.id || !service.name || !service.domain || !service.url || !service.category) {
|
|
showFeedback('error', 'Bitte füllen Sie ID, Name, Domain, URL und Kategorie für alle Dienste aus.');
|
|
return;
|
|
}
|
|
}
|
|
|
|
const originalText = saveServerButton.textContent;
|
|
saveServerButton.disabled = true;
|
|
saveServerButton.textContent = 'Speichere...';
|
|
|
|
try {
|
|
const response = await fetch('/api/services/save', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${tokenSet.id_token}`
|
|
},
|
|
body: JSON.stringify(config)
|
|
});
|
|
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
throw new Error(data.error || `HTTP ${response.status}`);
|
|
}
|
|
|
|
showFeedback('success', 'Konfiguration erfolgreich auf dem Server gespeichert!');
|
|
localStorage.removeItem(LOCAL_CONFIG_KEY);
|
|
|
|
window.setTimeout(() => {
|
|
refreshOps();
|
|
}, 500);
|
|
|
|
} catch (error) {
|
|
showFeedback('error', `Speichern fehlgeschlagen: ${error.message}`);
|
|
} finally {
|
|
saveServerButton.disabled = false;
|
|
saveServerButton.textContent = originalText;
|
|
}
|
|
}
|
|
|
|
function downloadConfig() {
|
|
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = 'services.json';
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (!Number.isFinite(bytes)) return '--';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let value = bytes;
|
|
let unit = 0;
|
|
while (value >= 1024 && unit < units.length - 1) {
|
|
value /= 1024;
|
|
unit += 1;
|
|
}
|
|
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
|
|
}
|
|
|
|
function renderServicesStatus(services = []) {
|
|
if (!servicesStatusBoard) return;
|
|
if (!Array.isArray(services) || services.length === 0) {
|
|
servicesStatusBoard.innerHTML = '<div class="ops-line">Keine Dienst-Statusdaten vom Server erhalten.</div>';
|
|
return;
|
|
}
|
|
|
|
// Create a map of status by URL for easy lookup
|
|
const statusMap = new Map();
|
|
services.forEach((s) => {
|
|
if (s && s.url) {
|
|
statusMap.set(s.url, s);
|
|
}
|
|
});
|
|
|
|
// We want to render:
|
|
// 1. The main Landing Page (mischlabs.de)
|
|
// 2. All services defined in the config (from config.services)
|
|
const itemsToRender = [];
|
|
|
|
// Main landing page check
|
|
const mainSiteUrl = 'https://mischlabs.de';
|
|
const mainSiteStatus = statusMap.get(mainSiteUrl);
|
|
if (mainSiteStatus) {
|
|
itemsToRender.push({
|
|
name: 'MischLabs Landing Page',
|
|
domain: 'mischlabs.de',
|
|
url: mainSiteUrl,
|
|
status: mainSiteStatus
|
|
});
|
|
}
|
|
|
|
// Services from editor config
|
|
if (config && Array.isArray(config.services)) {
|
|
config.services.forEach((service) => {
|
|
const serviceStatus = statusMap.get(service.url);
|
|
if (serviceStatus) {
|
|
itemsToRender.push({
|
|
name: service.name,
|
|
domain: service.domain,
|
|
url: service.url,
|
|
status: serviceStatus
|
|
});
|
|
} else {
|
|
// Fallback if URL didn't match exactly but we can find a substring/partial match,
|
|
// or just show it as unknown
|
|
const fallbackStatus = services.find(s => s && s.url && s.url.includes(service.domain));
|
|
itemsToRender.push({
|
|
name: service.name,
|
|
domain: service.domain,
|
|
url: service.url,
|
|
status: fallbackStatus || { url: service.url, ok: false, error: 'Nicht geprueft' }
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
const offlineCount = itemsToRender.filter(item => !item.status.ok).length;
|
|
const totalCount = itemsToRender.length;
|
|
const summaryEl = document.querySelector('#servicesStatusSummary');
|
|
if (summaryEl) {
|
|
if (offlineCount > 0) {
|
|
summaryEl.innerHTML = `<span style="color: var(--down); font-weight: 700;">${offlineCount} offline</span> / ${totalCount} gesamt`;
|
|
} else {
|
|
summaryEl.innerHTML = `<span style="color: var(--ok); font-weight: 700;">Alle online</span> (${totalCount})`;
|
|
}
|
|
}
|
|
|
|
servicesStatusBoard.innerHTML = itemsToRender.map((item) => {
|
|
const s = item.status;
|
|
let statusClass = 'is-online';
|
|
let statusLabel = `HTTP ${s.statusCode || '200'}`;
|
|
let latencyText = `${s.ms ?? '--'} ms`;
|
|
|
|
if (s.error || s.ok === false) {
|
|
statusClass = 'is-offline';
|
|
statusLabel = s.statusCode ? `HTTP ${s.statusCode}` : 'Offline';
|
|
latencyText = s.error || 'Verbindung fehlgeschlagen';
|
|
} else if (s.ms > 1000) {
|
|
statusClass = 'is-warning';
|
|
statusLabel = 'Traege';
|
|
}
|
|
|
|
const tooltip = `${item.name}\nDomain: ${item.domain}\nStatus: ${statusLabel}\nLatenz: ${latencyText}`;
|
|
|
|
return `
|
|
<a class="status-board-item ${statusClass}" href="${item.url}" target="_blank" rel="noopener noreferrer" title="${tooltip}">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
|
|
<h4 style="margin: 0; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; flex-grow: 1;">${item.name}</h4>
|
|
<span class="service-inspect-trigger" data-url="${item.url}" data-name="${item.name}" title="Live-Diagnose ausführen" style="font-size: 0.85rem; cursor: pointer; padding: 2px 4px; background: rgba(255,255,255,0.08); border-radius: 4px; margin-left: 6px; transition: transform 0.1s ease; user-select: none;">🔍</span>
|
|
</div>
|
|
<div class="status-board-item-bar">
|
|
<div class="status-board-item-bar-inner"></div>
|
|
</div>
|
|
</a>
|
|
`;
|
|
}).join('');
|
|
|
|
servicesStatusBoard.querySelectorAll('.service-inspect-trigger').forEach((trigger) => {
|
|
trigger.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const url = trigger.dataset.url;
|
|
const name = trigger.dataset.name;
|
|
openServiceInspectModal(url, name);
|
|
});
|
|
});
|
|
|
|
const miniServices = document.getElementById('mini-services-status');
|
|
if (miniServices) {
|
|
let ledHtml = itemsToRender.map(item => {
|
|
const ok = item.status.ok;
|
|
return `<span class="mini-led ${ok ? 'mini-led-ok' : 'mini-led-down'}" title="${item.name}: ${ok ? 'Online' : 'Offline'}"></span>`;
|
|
}).join('');
|
|
miniServices.innerHTML = `
|
|
<div class="mini-status-text">
|
|
${offlineCount > 0 ? `<span style="color: var(--down); font-weight: 700;">${offlineCount} offline</span>` : `<span style="color: var(--ok); font-weight: 700;">Alle online</span>`}
|
|
<span class="mini-status-total">(${totalCount} Dienste)</span>
|
|
</div>
|
|
<div class="mini-led-grid">${ledHtml}</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
function renderDisks(disks = []) {
|
|
diskStatus.innerHTML = disks.map((disk) => {
|
|
if (disk.error) {
|
|
const hint = disk.mount
|
|
? `Mount fehlt: ${disk.mount}`
|
|
: disk.error;
|
|
return `<div class="ops-line is-warning"><strong>${disk.label}</strong><span>${hint}</span></div>`;
|
|
}
|
|
|
|
const level = disk.usedPercent >= 90 ? 'danger' : disk.usedPercent >= 80 ? 'warn' : 'ok';
|
|
return `
|
|
<div class="ops-line">
|
|
<strong>${disk.label}</strong>
|
|
<span>${formatBytes(disk.used)} / ${formatBytes(disk.total)} (${disk.usedPercent}%)</span>
|
|
</div>
|
|
<div class="usage-bar" data-level="${level}">
|
|
<span style="width:${Math.min(disk.usedPercent, 100)}%"></span>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
const miniDisks = document.getElementById('mini-disks-status');
|
|
if (miniDisks) {
|
|
miniDisks.innerHTML = disks.map((disk) => {
|
|
if (disk.error) return `<div class="mini-disk-row is-error"><strong>${disk.label}</strong>: Fehler</div>`;
|
|
return `
|
|
<div class="mini-disk-row">
|
|
<div class="mini-disk-meta"><strong>${disk.label}</strong><span>${disk.usedPercent}%</span></div>
|
|
<div class="usage-bar mini-bar" data-level="${disk.usedPercent >= 90 ? 'danger' : disk.usedPercent >= 80 ? 'warn' : 'ok'}">
|
|
<span style="width: ${disk.usedPercent}%"></span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
function renderWatchtower(watchtower) {
|
|
latestWatchtower = watchtower;
|
|
const officialSession = watchtower?.lastSession;
|
|
const statusGenerated = watchtower?.generatedAt
|
|
? new Date(watchtower.generatedAt).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
: '--';
|
|
const officialHtml = officialSession
|
|
? `
|
|
<div class="ops-line"><strong>Offizieller Lauf</strong><span>${officialSession.finishedAt ? new Date(officialSession.finishedAt).toLocaleString('de-DE') : '--'}</span></div>
|
|
<div class="ops-line"><strong>Geprueft</strong><span>${officialSession.scanned ?? '--'}</span></div>
|
|
<div class="ops-line"><strong>Aktualisiert</strong><span>${officialSession.updated ?? '--'}</span></div>
|
|
<div class="ops-line"><strong>Fehler</strong><span>${officialSession.failed ?? '--'}</span></div>
|
|
<div class="ops-line"><strong>Status aktualisiert</strong><span>${statusGenerated}</span></div>
|
|
`
|
|
: `
|
|
<div class="ops-line"><strong>Offizieller Lauf</strong><span>${watchtower?.tail?.slice(-2)?.join(' | ') || 'Noch nicht gefunden'}</span></div>
|
|
<div class="ops-line"><strong>Status aktualisiert</strong><span>${statusGenerated}</span></div>
|
|
`;
|
|
|
|
const miniWatchtower = document.getElementById('mini-watchtower-status');
|
|
if (miniWatchtower) {
|
|
let statusText = 'Keine Daten';
|
|
let subText = '';
|
|
if (lastManualWatchtowerRun) {
|
|
statusText = lastManualWatchtowerRun.running ? 'Läuft manuell...' : 'Manuell beendet';
|
|
subText = lastManualWatchtowerRun.startedAt ? new Date(lastManualWatchtowerRun.startedAt).toLocaleTimeString('de-DE') : '';
|
|
} else if (officialSession) {
|
|
statusText = 'Up-to-date';
|
|
subText = `Lauf: ${officialSession.finishedAt ? new Date(officialSession.finishedAt).toLocaleDateString('de-DE') : '--'}`;
|
|
}
|
|
miniWatchtower.innerHTML = `
|
|
<div class="mini-status-text"><span style="color: #67e8f9; font-weight: 700;">${statusText}</span></div>
|
|
<div class="mini-status-sub">${subText}</div>
|
|
`;
|
|
}
|
|
|
|
if (lastManualWatchtowerRun) {
|
|
const run = lastManualWatchtowerRun;
|
|
const session = run.session;
|
|
const finished = run.finishedAt ? new Date(run.finishedAt).toLocaleString('de-DE') : 'laeuft noch';
|
|
const state = run.running ? 'Laeuft' : `Beendet (${run.exitCode ?? '--'})`;
|
|
watchtowerStatus.innerHTML = `
|
|
${officialHtml}
|
|
<div class="ops-divider"></div>
|
|
<div class="ops-line"><strong>Manuell</strong><span>${state}</span></div>
|
|
<div class="ops-line"><strong>Gestartet</strong><span>${run.startedAt ? new Date(run.startedAt).toLocaleString('de-DE') : '--'}</span></div>
|
|
<div class="ops-line"><strong>Fertig</strong><span>${finished}</span></div>
|
|
<div class="ops-line"><strong>Manuell geprueft</strong><span>${session?.scanned ?? '--'}</span></div>
|
|
<div class="ops-line"><strong>Manuell aktualisiert</strong><span>${session?.updated ?? '--'}</span></div>
|
|
<div class="ops-line"><strong>Manuelle Fehler</strong><span>${session?.failed ?? (run.exitCode ? 1 : 0)}</span></div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
watchtowerStatus.innerHTML = officialHtml;
|
|
}
|
|
|
|
function renderContainers(containers) {
|
|
if (!Array.isArray(containers)) {
|
|
containerStatus.textContent = containers?.error || 'Containerdaten nicht verfuegbar.';
|
|
return;
|
|
}
|
|
|
|
latestContainers = containers;
|
|
const dangerActive = Date.now() < dangerExpiresAt && Boolean(dangerToken);
|
|
|
|
const important = [
|
|
'mischlabs',
|
|
'watchtower',
|
|
'gitea',
|
|
'gitea-runner',
|
|
'auth',
|
|
'nextcloud_app',
|
|
'vaultwarden',
|
|
'photography-website',
|
|
'michess',
|
|
'calibre-web',
|
|
'audiobookshelf'
|
|
];
|
|
|
|
const rows = containers
|
|
.filter((container) => important.includes(container.name))
|
|
.sort((a, b) => important.indexOf(a.name) - important.indexOf(b.name));
|
|
|
|
containerStatus.innerHTML = rows.map((container) => `
|
|
<div class="ops-line container-line" data-container="${container.name}">
|
|
<strong>${container.name}</strong>
|
|
<span>${container.status}</span>
|
|
<div style="display: flex; gap: 6px;">
|
|
<button class="ghost-button mini view-container-logs" type="button">Logs</button>
|
|
<button class="ghost-button mini restart-container ${dangerActive && !container.restartable ? 'is-danger-enabled' : ''}" type="button" title="${container.restartable ? 'Container neu starten' : (dangerActive ? 'Danger-Modus aktiv: Neustart erlaubt' : (container.restartBlockReason || 'Neustart gesperrt'))}" ${(container.restartable || dangerActive) ? '' : 'disabled'}>
|
|
Neustarten
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
containerStatus.querySelectorAll('.view-container-logs').forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
openLogsModal(button.closest('.container-line').dataset.container);
|
|
});
|
|
});
|
|
|
|
containerStatus.querySelectorAll('.restart-container').forEach((button) => {
|
|
button.addEventListener('click', () => restartContainer(button.closest('.container-line')));
|
|
});
|
|
|
|
const miniContainers = document.getElementById('mini-containers-status');
|
|
if (miniContainers && Array.isArray(containers)) {
|
|
const importantList = [
|
|
'mischlabs',
|
|
'watchtower',
|
|
'gitea',
|
|
'gitea-runner',
|
|
'auth',
|
|
'nextcloud_app',
|
|
'vaultwarden',
|
|
'photography-website',
|
|
'michess',
|
|
'calibre-web',
|
|
'audiobookshelf'
|
|
];
|
|
const activeImportant = containers.filter(c => importantList.includes(c.name) && c.state === 'running').length;
|
|
const totalImportant = containers.filter(c => importantList.includes(c.name)).length;
|
|
miniContainers.innerHTML = `
|
|
<div class="mini-status-text">
|
|
<span style="color: var(--ok); font-weight: 700;">${activeImportant} / ${totalImportant}</span>
|
|
<span class="mini-status-total">online</span>
|
|
</div>
|
|
<div class="mini-status-sub">${containers.length} Container insgesamt</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
async function restartContainer(row) {
|
|
const name = row?.dataset.container;
|
|
if (!name) return;
|
|
const dangerActive = Date.now() < dangerExpiresAt && Boolean(dangerToken);
|
|
|
|
const confirmed = window.confirm(`${name} jetzt neu starten? Der Dienst ist kurz nicht erreichbar.${dangerActive ? '\n\nDanger-Modus ist aktiv.' : ''}`);
|
|
if (!confirmed) return;
|
|
|
|
const button = row.querySelector('.restart-container');
|
|
const status = row.querySelector('span');
|
|
const previousText = status.textContent;
|
|
|
|
button.disabled = true;
|
|
status.textContent = 'Neustart laeuft...';
|
|
|
|
try {
|
|
const response = await fetch(`/api/containers/${encodeURIComponent(name)}/restart`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${tokenSet.id_token}`,
|
|
...(dangerActive ? { 'X-MischLabs-Danger-Token': dangerToken } : {})
|
|
}
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
|
|
|
|
status.textContent = `Neugestartet um ${new Date(payload.restartedAt).toLocaleTimeString('de-DE')}`;
|
|
window.clearTimeout(opsRefreshTimer);
|
|
opsRefreshTimer = window.setTimeout(refreshOps, 3500);
|
|
} catch (error) {
|
|
status.textContent = `Neustart fehlgeschlagen: ${error.message}`;
|
|
button.disabled = false;
|
|
window.setTimeout(() => {
|
|
status.textContent = previousText;
|
|
}, 6000);
|
|
}
|
|
}
|
|
|
|
function updateDangerState() {
|
|
const remaining = Math.max(0, dangerExpiresAt - Date.now());
|
|
if (!dangerToken || remaining <= 0) {
|
|
dangerToken = null;
|
|
dangerExpiresAt = 0;
|
|
dangerState.hidden = true;
|
|
dangerState.textContent = '';
|
|
dangerModeButton.textContent = 'Danger entsperren';
|
|
dangerModeButton.classList.remove('is-active-danger');
|
|
window.clearInterval(dangerTimer);
|
|
dangerTimer = null;
|
|
if (latestContainers.length) renderContainers(latestContainers);
|
|
return;
|
|
}
|
|
|
|
const seconds = Math.ceil(remaining / 1000);
|
|
dangerState.hidden = false;
|
|
dangerState.textContent = `Danger-Modus aktiv: geschuetzte Neustarts noch ${seconds}s erlaubt.`;
|
|
dangerModeButton.textContent = `Danger aktiv ${seconds}s`;
|
|
dangerModeButton.classList.add('is-active-danger');
|
|
}
|
|
|
|
async function unlockDangerMode() {
|
|
const confirmed = window.confirm('Danger-Modus fuer 2 Minuten aktivieren?\n\nDamit koennen auch geschuetzte Dienste wie Watchtower, Gitea, Keycloak oder Datenbanken neu gestartet werden. Nutze das nur bewusst.');
|
|
if (!confirmed) return;
|
|
|
|
dangerModeButton.disabled = true;
|
|
try {
|
|
const response = await fetch('/api/danger/unlock', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${tokenSet.id_token}`
|
|
}
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
|
|
|
|
dangerToken = payload.token;
|
|
dangerExpiresAt = new Date(payload.expiresAt).getTime();
|
|
updateDangerState();
|
|
if (latestContainers.length) renderContainers(latestContainers);
|
|
window.clearInterval(dangerTimer);
|
|
dangerTimer = window.setInterval(updateDangerState, 1000);
|
|
} catch (error) {
|
|
dangerState.hidden = false;
|
|
dangerState.textContent = `Danger-Modus konnte nicht aktiviert werden: ${error.message}`;
|
|
} finally {
|
|
dangerModeButton.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function runWatchtowerOnce() {
|
|
const confirmed = window.confirm('Watchtower jetzt manuell starten? Je nach Updates koennen Dienste kurz neu starten.');
|
|
if (!confirmed) return;
|
|
|
|
const previousText = runWatchtowerButton.textContent;
|
|
runWatchtowerButton.disabled = true;
|
|
runWatchtowerButton.textContent = 'Laeuft...';
|
|
watchtowerStatus.innerHTML = '<div class="ops-line"><strong>Manueller Lauf</strong><span>Gestartet...</span></div>';
|
|
|
|
try {
|
|
const response = await fetch('/api/watchtower/run-once', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${tokenSet.id_token}`
|
|
}
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
|
|
|
|
lastManualWatchtowerRun = {
|
|
id: payload.fullId || payload.id,
|
|
name: payload.name,
|
|
running: true,
|
|
startedAt: payload.startedAt,
|
|
session: null,
|
|
tail: []
|
|
};
|
|
watchtowerStatus.innerHTML = `
|
|
<div class="ops-line"><strong>Manueller Lauf</strong><span>Gestartet</span></div>
|
|
<div class="ops-line"><strong>Container</strong><span>${payload.name}</span></div>
|
|
`;
|
|
window.clearTimeout(opsRefreshTimer);
|
|
pollWatchtowerRun(payload.fullId || payload.id);
|
|
} catch (error) {
|
|
watchtowerStatus.textContent = `Watchtower konnte nicht gestartet werden: ${error.message}`;
|
|
} finally {
|
|
window.setTimeout(() => {
|
|
runWatchtowerButton.disabled = false;
|
|
runWatchtowerButton.textContent = previousText;
|
|
}, 5000);
|
|
}
|
|
}
|
|
|
|
async function pollWatchtowerRun(runId, attempt = 0) {
|
|
try {
|
|
const response = await fetch(`/api/watchtower/runs/${encodeURIComponent(runId)}`, {
|
|
cache: 'no-store',
|
|
headers: {
|
|
Authorization: `Bearer ${tokenSet.id_token}`
|
|
}
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
|
|
|
|
lastManualWatchtowerRun = payload;
|
|
renderWatchtower(latestWatchtower);
|
|
|
|
if (payload.running && attempt < 60) {
|
|
opsRefreshTimer = window.setTimeout(() => pollWatchtowerRun(runId, attempt + 1), 5000);
|
|
return;
|
|
}
|
|
|
|
opsRefreshTimer = window.setTimeout(refreshOps, 4000);
|
|
} catch (error) {
|
|
watchtowerStatus.textContent = `Manueller Watchtower-Lauf nicht lesbar: ${error.message}`;
|
|
}
|
|
}
|
|
|
|
async function refreshOps(options = {}) {
|
|
const silent = Boolean(options.silent);
|
|
if (!silent) {
|
|
diskStatus.textContent = 'Lade...';
|
|
watchtowerStatus.textContent = 'Lade...';
|
|
containerStatus.textContent = 'Lade...';
|
|
if (servicesStatusBoard) {
|
|
servicesStatusBoard.textContent = 'Lade...';
|
|
}
|
|
}
|
|
|
|
if (lastManualWatchtowerRun && lastManualWatchtowerRun.running) {
|
|
try {
|
|
const runId = lastManualWatchtowerRun.id;
|
|
const response = await fetch(`/api/watchtower/runs/${encodeURIComponent(runId)}`, {
|
|
cache: 'no-store',
|
|
headers: {
|
|
Authorization: `Bearer ${tokenSet?.id_token}`
|
|
}
|
|
});
|
|
if (response.ok) {
|
|
const payload = await response.json().catch(() => ({}));
|
|
lastManualWatchtowerRun = payload;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to refresh manual watchtower run status:', e);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/status', { cache: 'no-store' });
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
const status = await response.json();
|
|
renderDisks(status.disks);
|
|
renderWatchtower(status.watchtower);
|
|
renderContainers(status.containers);
|
|
renderServicesStatus(status.services);
|
|
} catch (error) {
|
|
diskStatus.textContent = `Statusproxy nicht erreichbar: ${error.message}`;
|
|
watchtowerStatus.textContent = 'Nicht verfuegbar.';
|
|
containerStatus.textContent = 'Nicht verfuegbar.';
|
|
if (servicesStatusBoard) {
|
|
servicesStatusBoard.textContent = `Nicht verfuegbar: ${error.message}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
function startOpsAutoRefresh() {
|
|
window.clearInterval(opsAutoRefreshTimer);
|
|
opsAutoRefreshTimer = window.setInterval(() => {
|
|
if (document.visibilityState === 'visible') {
|
|
refreshOps({ silent: true });
|
|
}
|
|
}, 60000);
|
|
}
|
|
|
|
let currentLogsContainer = '';
|
|
|
|
async function fetchContainerLogs(containerName, tail = 150) {
|
|
const consoleEl = document.getElementById('logsModalConsole');
|
|
if (!consoleEl) return;
|
|
consoleEl.textContent = 'Lade Logs...';
|
|
try {
|
|
const response = await fetch(`/api/containers/${encodeURIComponent(containerName)}/logs?tail=${tail}`, {
|
|
headers: {
|
|
Authorization: `Bearer ${tokenSet?.id_token}`
|
|
}
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
|
|
|
|
if (typeof data.logs === 'string') {
|
|
consoleEl.textContent = data.logs || 'Keine Logs vorhanden.';
|
|
} else {
|
|
consoleEl.textContent = JSON.stringify(data.logs, null, 2);
|
|
}
|
|
consoleEl.scrollTop = consoleEl.scrollHeight;
|
|
} catch (error) {
|
|
consoleEl.textContent = `Fehler beim Laden der Logs: ${error.message}`;
|
|
}
|
|
}
|
|
|
|
function openLogsModal(containerName) {
|
|
currentLogsContainer = containerName;
|
|
const overlay = document.getElementById('logsModalOverlay');
|
|
const title = document.getElementById('logsModalTitle');
|
|
const tailSelect = document.getElementById('logsTailSelect');
|
|
|
|
if (overlay && title) {
|
|
title.textContent = `Logs von "${containerName}"`;
|
|
if (tailSelect) {
|
|
tailSelect.value = "150";
|
|
}
|
|
overlay.removeAttribute('hidden');
|
|
overlay.classList.add('is-active');
|
|
fetchContainerLogs(containerName, 150);
|
|
}
|
|
}
|
|
|
|
function closeLogsModal() {
|
|
const overlay = document.getElementById('logsModalOverlay');
|
|
if (overlay) {
|
|
overlay.setAttribute('hidden', '');
|
|
overlay.classList.remove('is-active');
|
|
currentLogsContainer = '';
|
|
}
|
|
}
|
|
|
|
async function openServiceInspectModal(url, name) {
|
|
const overlay = document.getElementById('serviceModalOverlay');
|
|
const title = document.getElementById('serviceModalTitle');
|
|
const content = document.getElementById('serviceModalContent');
|
|
|
|
if (!overlay || !content) return;
|
|
|
|
title.textContent = `Inspektion: ${name}`;
|
|
content.innerHTML = '<div class="ops-line">Führe Live-Diagnose aus...</div>';
|
|
overlay.removeAttribute('hidden');
|
|
overlay.classList.add('is-active');
|
|
|
|
try {
|
|
const response = await fetch(`/api/services/inspect?url=${encodeURIComponent(url)}`, {
|
|
headers: {
|
|
Authorization: `Bearer ${tokenSet?.id_token}`
|
|
}
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
|
|
|
|
const statusClass = data.ok ? 'text-ok' : 'text-down';
|
|
let headersHtml = '';
|
|
if (data.headers) {
|
|
headersHtml = Object.entries(data.headers).map(([key, val]) => `
|
|
<div class="header-row" style="display: flex; justify-content: space-between; font-size: 0.8rem; border-bottom: 1px solid var(--surface-2); padding: 4px 0;">
|
|
<strong style="color: var(--dim); word-break: break-all;">${key}:</strong>
|
|
<span style="color: #fff; text-align: right; word-break: break-all; margin-left: 8px;">${val}</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
let previewHtml = '';
|
|
if (data.bodyPreview) {
|
|
const escaped = data.bodyPreview
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
previewHtml = `
|
|
<div style="margin-top: 14px;">
|
|
<h4 style="margin-bottom: 6px; font-size: 0.9rem;">Antwort-Vorschau (erste 1000 Zeichen):</h4>
|
|
<pre class="terminal-logs" style="max-height: 150px; font-size: 0.8rem; padding: 10px; background: rgba(0,0,0,0.3); border-radius: var(--radius); overflow-y: auto;">${escaped}</pre>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
content.innerHTML = `
|
|
<div class="ops-line">
|
|
<strong>Status:</strong>
|
|
<span class="${statusClass}" style="font-weight: 700; color: ${data.ok ? 'var(--ok)' : 'var(--down)'}">${data.statusCode ? `HTTP ${data.statusCode} ${data.statusMessage || ''}` : 'Fehler'}</span>
|
|
</div>
|
|
<div class="ops-line">
|
|
<strong>Latenz:</strong>
|
|
<span>${data.latency} ms</span>
|
|
</div>
|
|
${data.error ? `<div class="ops-line is-warning"><strong>Fehlerursache:</strong><span>${data.error}</span></div>` : ''}
|
|
|
|
<div style="margin-top: 14px;">
|
|
<h4 style="margin-bottom: 6px; font-size: 0.9rem;">HTTP Header:</h4>
|
|
<div class="headers-grid" style="max-height: 200px; overflow-y: auto; background: rgba(0,0,0,0.15); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px;">
|
|
${headersHtml || '<div style="color: var(--dim); font-size: 0.8rem;">Keine Header geladen</div>'}
|
|
</div>
|
|
</div>
|
|
${previewHtml}
|
|
`;
|
|
} catch (error) {
|
|
content.innerHTML = `<div class="ops-line is-warning"><strong>Fehler bei Diagnose:</strong><span>${error.message}</span></div>`;
|
|
}
|
|
}
|
|
|
|
function closeServiceModal() {
|
|
const overlay = document.getElementById('serviceModalOverlay');
|
|
if (overlay) {
|
|
overlay.setAttribute('hidden', '');
|
|
overlay.classList.remove('is-active');
|
|
}
|
|
}
|
|
|
|
async function startFolderScan(dirPath) {
|
|
const resultsEl = document.getElementById('scannerResults');
|
|
const btn = document.getElementById('scannerStartBtn');
|
|
if (!resultsEl || !btn) return;
|
|
|
|
btn.disabled = true;
|
|
const originalText = btn.textContent;
|
|
btn.textContent = 'Scanne...';
|
|
|
|
resultsEl.removeAttribute('hidden');
|
|
resultsEl.innerHTML = '<div class="ops-line">Scanne Verzeichnis und berechne Größen. Bitte warten...</div>';
|
|
|
|
try {
|
|
const response = await fetch(`/api/disks/scan-folder?path=${encodeURIComponent(dirPath)}`, {
|
|
headers: {
|
|
Authorization: `Bearer ${tokenSet?.id_token}`
|
|
}
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
|
|
|
|
if (!data.children || data.children.length === 0) {
|
|
resultsEl.innerHTML = '<div class="ops-line">Keine Dateien oder Verzeichnisse gefunden oder Zugriff verweigert.</div>';
|
|
return;
|
|
}
|
|
|
|
resultsEl.innerHTML = data.children.map((child) => {
|
|
const sizeStr = formatBytes(child.size);
|
|
const icon = child.isDirectory ? '📁' : child.isLink ? '🔗' : '📄';
|
|
const nameColor = child.isDirectory ? '#67e8f9' : '#fff';
|
|
|
|
let clickAction = '';
|
|
if (child.isDirectory && !child.error) {
|
|
clickAction = `class="scanner-folder-link" data-path="${child.path}" style="cursor: pointer; color: ${nameColor}; text-decoration: underline;"`;
|
|
} else {
|
|
clickAction = `style="color: ${nameColor};"`;
|
|
}
|
|
|
|
const errorText = child.error ? ' <span style="color: var(--down); font-size: 0.75rem;">(Fehler beim Lesen)</span>' : '';
|
|
|
|
return `
|
|
<div class="ops-line">
|
|
<span ${clickAction}>${icon} ${child.name}${errorText}</span>
|
|
<span>${sizeStr}</span>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
resultsEl.querySelectorAll('.scanner-folder-link').forEach((link) => {
|
|
link.addEventListener('click', () => {
|
|
const nextPath = link.dataset.path;
|
|
document.getElementById('scannerPathInput').value = nextPath;
|
|
startFolderScan(nextPath);
|
|
});
|
|
});
|
|
|
|
} catch (error) {
|
|
resultsEl.innerHTML = `<div class="ops-line is-warning"><strong>Scan fehlgeschlagen:</strong><span>${error.message}</span></div>`;
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = originalText;
|
|
}
|
|
}
|
|
|
|
function initFolderScanner() {
|
|
const btn = document.getElementById('scannerStartBtn');
|
|
const input = document.getElementById('scannerPathInput');
|
|
if (btn && input) {
|
|
btn.addEventListener('click', () => {
|
|
const val = input.value.trim();
|
|
if (val) startFolderScan(val);
|
|
});
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
const val = input.value.trim();
|
|
if (val) startFolderScan(val);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function initModals() {
|
|
const logsCloseBtn = document.getElementById('logsModalCloseBtn');
|
|
const logsOverlay = document.getElementById('logsModalOverlay');
|
|
const logsTailSelect = document.getElementById('logsTailSelect');
|
|
const logsRefreshBtn = document.getElementById('logsModalRefreshBtn');
|
|
|
|
if (logsCloseBtn) {
|
|
logsCloseBtn.addEventListener('click', closeLogsModal);
|
|
}
|
|
if (logsOverlay) {
|
|
logsOverlay.addEventListener('click', (e) => {
|
|
if (e.target === logsOverlay) closeLogsModal();
|
|
});
|
|
}
|
|
if (logsTailSelect) {
|
|
logsTailSelect.addEventListener('change', () => {
|
|
if (currentLogsContainer) {
|
|
fetchContainerLogs(currentLogsContainer, Number(logsTailSelect.value));
|
|
}
|
|
});
|
|
}
|
|
if (logsRefreshBtn) {
|
|
logsRefreshBtn.addEventListener('click', () => {
|
|
if (currentLogsContainer) {
|
|
const tail = logsTailSelect ? Number(logsTailSelect.value) : 150;
|
|
fetchContainerLogs(currentLogsContainer, tail);
|
|
}
|
|
});
|
|
}
|
|
|
|
const serviceCloseBtn = document.getElementById('serviceModalCloseBtn');
|
|
const serviceOverlay = document.getElementById('serviceModalOverlay');
|
|
|
|
if (serviceCloseBtn) {
|
|
serviceCloseBtn.addEventListener('click', closeServiceModal);
|
|
}
|
|
if (serviceOverlay) {
|
|
serviceOverlay.addEventListener('click', (e) => {
|
|
if (e.target === serviceOverlay) closeServiceModal();
|
|
});
|
|
}
|
|
}
|
|
|
|
function initCollapsibleCards() {
|
|
document.querySelectorAll('.ops-card-head').forEach((head) => {
|
|
const card = head.closest('.ops-card');
|
|
if (!card) return;
|
|
|
|
const bodyEl = card.querySelector('.services-status-board, .ops-list, .admin-table');
|
|
const id = bodyEl ? bodyEl.id : null;
|
|
const storageKey = id ? `ops-card-collapsed-${id}` : null;
|
|
|
|
if (storageKey) {
|
|
let isCollapsed;
|
|
const stored = localStorage.getItem(storageKey);
|
|
if (stored === null) {
|
|
// Default to collapsed for the service editor, expanded for others
|
|
isCollapsed = (id === 'serviceEditor');
|
|
} else {
|
|
isCollapsed = (stored === 'true');
|
|
}
|
|
card.classList.toggle('is-collapsed', isCollapsed);
|
|
}
|
|
|
|
head.addEventListener('click', (event) => {
|
|
if (event.target.closest('button')) {
|
|
return;
|
|
}
|
|
card.classList.toggle('is-collapsed');
|
|
if (storageKey) {
|
|
localStorage.setItem(storageKey, card.classList.contains('is-collapsed'));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
let activeTab = localStorage.getItem('active_admin_tab') || 'overview';
|
|
|
|
function switchTab(tabId) {
|
|
activeTab = tabId;
|
|
localStorage.setItem('active_admin_tab', tabId);
|
|
|
|
const tabCards = {
|
|
overview: document.getElementById('card-overview'),
|
|
services: document.getElementById('card-services'),
|
|
disks: document.getElementById('card-disks'),
|
|
watchtower: document.getElementById('card-watchtower'),
|
|
containers: document.getElementById('card-containers'),
|
|
config: document.getElementById('editorCard')
|
|
};
|
|
|
|
Object.entries(tabCards).forEach(([id, card]) => {
|
|
if (!card) return;
|
|
if (id === tabId) {
|
|
card.classList.add('is-active');
|
|
card.classList.remove('admin-tab-content');
|
|
card.classList.remove('is-collapsed');
|
|
} else {
|
|
card.classList.remove('is-active');
|
|
card.classList.add('admin-tab-content');
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll('.drawer-nav-item[data-tab]').forEach((item) => {
|
|
const isActive = item.dataset.tab === tabId;
|
|
item.classList.toggle('is-active', isActive);
|
|
});
|
|
|
|
closeNavDrawer();
|
|
}
|
|
|
|
function initTabNavigation() {
|
|
const hamburgerMenuBtn = document.getElementById('hamburgerMenuBtn');
|
|
const adminNavDrawer = document.getElementById('adminNavDrawer');
|
|
const adminNavBackdrop = document.getElementById('adminNavBackdrop');
|
|
const drawerCloseBtn = document.getElementById('drawerCloseBtn');
|
|
|
|
if (hamburgerMenuBtn && adminNavDrawer && adminNavBackdrop) {
|
|
hamburgerMenuBtn.addEventListener('click', () => {
|
|
adminNavDrawer.classList.add('is-open');
|
|
adminNavBackdrop.classList.add('is-open');
|
|
});
|
|
|
|
const closeNavDrawerLocal = () => {
|
|
adminNavDrawer.classList.remove('is-open');
|
|
adminNavBackdrop.classList.remove('is-open');
|
|
};
|
|
|
|
if (drawerCloseBtn) drawerCloseBtn.addEventListener('click', closeNavDrawerLocal);
|
|
adminNavBackdrop.addEventListener('click', closeNavDrawerLocal);
|
|
}
|
|
|
|
document.querySelectorAll('.drawer-nav-item[data-tab]').forEach((item) => {
|
|
item.addEventListener('click', () => {
|
|
switchTab(item.dataset.tab);
|
|
});
|
|
});
|
|
|
|
// Setup click handlers for mini cards inside the Overview tab
|
|
document.querySelectorAll('.overview-mini-card[data-target-tab]').forEach((card) => {
|
|
card.addEventListener('click', () => {
|
|
switchTab(card.dataset.targetTab);
|
|
});
|
|
});
|
|
|
|
switchTab(activeTab);
|
|
}
|
|
|
|
function closeNavDrawer() {
|
|
const adminNavDrawer = document.getElementById('adminNavDrawer');
|
|
const adminNavBackdrop = document.getElementById('adminNavBackdrop');
|
|
if (adminNavDrawer && adminNavBackdrop) {
|
|
adminNavDrawer.classList.remove('is-open');
|
|
adminNavBackdrop.classList.remove('is-open');
|
|
}
|
|
}
|
|
|
|
async function boot() {
|
|
await handleCallback();
|
|
|
|
// Check if we just logged out
|
|
const loggedOut = sessionStorage.getItem('mischlabs.admin.logged_out') === 'true';
|
|
if (loggedOut) {
|
|
sessionStorage.removeItem('mischlabs.admin.logged_out');
|
|
loginPanel.hidden = false;
|
|
adminPanel.hidden = true;
|
|
loginButton.hidden = false;
|
|
logoutButton.hidden = true;
|
|
if (headerUserBadge) headerUserBadge.hidden = true;
|
|
const hamburgerBtn = document.getElementById('hamburgerMenuBtn');
|
|
if (hamburgerBtn) hamburgerBtn.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
tokenSet = getStoredTokens();
|
|
if (isTokenValid(tokenSet)) {
|
|
storeTokens(tokenSet);
|
|
}
|
|
|
|
if (!isTokenValid(tokenSet)) {
|
|
if (loginMessage.textContent) {
|
|
loginPanel.hidden = false;
|
|
adminPanel.hidden = true;
|
|
loginButton.hidden = false;
|
|
logoutButton.hidden = true;
|
|
if (headerUserBadge) headerUserBadge.hidden = true;
|
|
const hamburgerBtn = document.getElementById('hamburgerMenuBtn');
|
|
if (hamburgerBtn) hamburgerBtn.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
// Automatic silent SSO login redirect
|
|
login();
|
|
return;
|
|
}
|
|
|
|
const claims = authorize(tokenSet);
|
|
if (!claims) return;
|
|
|
|
config = await loadConfig();
|
|
loginPanel.hidden = true;
|
|
adminPanel.hidden = false;
|
|
|
|
// Hide top header buttons and show hamburger button
|
|
const headerDashboardBtn = document.getElementById('headerDashboardBtn');
|
|
if (headerDashboardBtn) headerDashboardBtn.style.display = 'none';
|
|
loginButton.hidden = true;
|
|
logoutButton.hidden = true;
|
|
if (headerUserBadge) {
|
|
headerUserBadge.hidden = false;
|
|
headerUserBadge.innerHTML = `<span style="opacity: 0.76;">👤</span> Angemeldet als <strong style="color: #67e8f9; font-weight: 600; margin-left: 2px;">${claims.preferred_username || claims.email}</strong>`;
|
|
}
|
|
|
|
const hamburgerBtn = document.getElementById('hamburgerMenuBtn');
|
|
if (hamburgerBtn) hamburgerBtn.style.display = 'flex';
|
|
|
|
adminUser.textContent = `Angemeldet als ${claims.preferred_username || claims.email}`;
|
|
renderEditor();
|
|
initCollapsibleCards();
|
|
initTabNavigation();
|
|
initModals();
|
|
initFolderScanner();
|
|
refreshOps();
|
|
startOpsAutoRefresh();
|
|
}
|
|
|
|
loginButton.addEventListener('click', login);
|
|
const performLogout = () => {
|
|
const idToken = tokenSet?.id_token;
|
|
clearTokens();
|
|
sessionStorage.setItem('mischlabs.admin.logged_out', 'true');
|
|
const params = new URLSearchParams({
|
|
client_id: CLIENT_ID,
|
|
post_logout_redirect_uri: REDIRECT_URI
|
|
});
|
|
if (idToken) params.set('id_token_hint', idToken);
|
|
window.location.href = `${AUTHORITY}/protocol/openid-connect/logout?${params}`;
|
|
};
|
|
|
|
logoutButton.addEventListener('click', performLogout);
|
|
const drawerLogoutBtn = document.getElementById('drawerLogoutBtn');
|
|
if (drawerLogoutBtn) {
|
|
drawerLogoutBtn.addEventListener('click', performLogout);
|
|
}
|
|
|
|
checkAllButton.addEventListener('click', () => {
|
|
[...serviceEditor.querySelectorAll('.admin-row')].forEach((row) => checkRow(row));
|
|
});
|
|
|
|
if (addServiceButton) {
|
|
addServiceButton.addEventListener('click', addService);
|
|
}
|
|
|
|
if (saveServerButton) {
|
|
saveServerButton.addEventListener('click', saveServer);
|
|
}
|
|
|
|
resetLocalButton.addEventListener('click', () => {
|
|
localStorage.removeItem(LOCAL_CONFIG_KEY);
|
|
window.location.reload();
|
|
});
|
|
|
|
downloadConfigButton.addEventListener('click', downloadConfig);
|
|
refreshOpsButton.addEventListener('click', refreshOps);
|
|
if (refreshServicesButton) {
|
|
refreshServicesButton.addEventListener('click', () => refreshOps());
|
|
}
|
|
runWatchtowerButton.addEventListener('click', runWatchtowerOnce);
|
|
const viewWatchtowerLogsBtn = document.getElementById('viewWatchtowerLogsBtn');
|
|
if (viewWatchtowerLogsBtn) {
|
|
viewWatchtowerLogsBtn.addEventListener('click', () => {
|
|
openLogsModal('watchtower');
|
|
});
|
|
}
|
|
dangerModeButton.addEventListener('click', unlockDangerMode);
|
|
|
|
boot().catch((error) => {
|
|
loginMessage.textContent = `Adminseite konnte nicht starten: ${error.message}`;
|
|
});
|