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 saveLocalButton = document.querySelector('#saveLocalButton');
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) => (
``
)).join('');
}
function renderEditor() {
serviceEditor.innerHTML = config.services.map((service, index) => `
${service.name}
${service.domain}
Noch nicht geprueft.
`).join('');
serviceEditor.querySelectorAll('select,input').forEach((input) => {
input.addEventListener('change', () => {
const row = input.closest('.admin-row');
const service = config.services[Number(row.dataset.index)];
service[input.dataset.field] = input.value;
});
});
serviceEditor.querySelectorAll('.check-one').forEach((button) => {
button.addEventListener('click', () => checkRow(button.closest('.admin-row')));
});
}
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 saveLocal() {
localStorage.setItem(LOCAL_CONFIG_KEY, JSON.stringify(config, null, 2));
}
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 = '
Keine Dienst-Statusdaten vom Server erhalten.
';
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 = `${offlineCount} offline / ${totalCount} gesamt`;
} else {
summaryEl.innerHTML = `Alle online (${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 `
${item.name}
🔍
`;
}).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 ``;
}).join('');
miniServices.innerHTML = `
${offlineCount > 0 ? `${offlineCount} offline` : `Alle online`}
(${totalCount} Dienste)
${ledHtml}
`;
}
}
function renderDisks(disks = []) {
diskStatus.innerHTML = disks.map((disk) => {
if (disk.error) {
const hint = disk.mount
? `Mount fehlt: ${disk.mount}`
: disk.error;
return `${disk.label}${hint}
`;
}
const level = disk.usedPercent >= 90 ? 'danger' : disk.usedPercent >= 80 ? 'warn' : 'ok';
return `
${disk.label}
${formatBytes(disk.used)} / ${formatBytes(disk.total)} (${disk.usedPercent}%)
`;
}).join('');
const miniDisks = document.getElementById('mini-disks-status');
if (miniDisks) {
miniDisks.innerHTML = disks.map((disk) => {
if (disk.error) return `${disk.label}: Fehler
`;
return `
${disk.label}${disk.usedPercent}%
`;
}).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
? `
Offizieller Lauf${officialSession.finishedAt ? new Date(officialSession.finishedAt).toLocaleString('de-DE') : '--'}
Geprueft${officialSession.scanned ?? '--'}
Aktualisiert${officialSession.updated ?? '--'}
Fehler${officialSession.failed ?? '--'}
Status aktualisiert${statusGenerated}
`
: `
Offizieller Lauf${watchtower?.tail?.slice(-2)?.join(' | ') || 'Noch nicht gefunden'}
Status aktualisiert${statusGenerated}
`;
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 = `
${statusText}
${subText}
`;
}
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}
Manuell${state}
Gestartet${run.startedAt ? new Date(run.startedAt).toLocaleString('de-DE') : '--'}
Fertig${finished}
Manuell geprueft${session?.scanned ?? '--'}
Manuell aktualisiert${session?.updated ?? '--'}
Manuelle Fehler${session?.failed ?? (run.exitCode ? 1 : 0)}
`;
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) => `
${container.name}
${container.status}
`).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 = `
${activeImportant} / ${totalImportant}
online
${containers.length} Container insgesamt
`;
}
}
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 = 'Manueller LaufGestartet...
';
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 = `
Manueller LaufGestartet
Container${payload.name}
`;
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 = 'Führe Live-Diagnose aus...
';
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]) => `
`).join('');
}
let previewHtml = '';
if (data.bodyPreview) {
const escaped = data.bodyPreview
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
previewHtml = `
Antwort-Vorschau (erste 1000 Zeichen):
${escaped}
`;
}
content.innerHTML = `
Status:
${data.statusCode ? `HTTP ${data.statusCode} ${data.statusMessage || ''}` : 'Fehler'}
Latenz:
${data.latency} ms
${data.error ? `Fehlerursache:${data.error}
` : ''}
HTTP Header:
${previewHtml}
`;
} catch (error) {
content.innerHTML = `Fehler bei Diagnose:${error.message}
`;
}
}
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 = 'Scanne Verzeichnis und berechne Größen. Bitte warten...
';
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 = 'Keine Dateien oder Verzeichnisse gefunden oder Zugriff verweigert.
';
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 ? ' (Fehler beim Lesen)' : '';
return `
${icon} ${child.name}${errorText}
${sizeStr}
`;
}).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 = `Scan fehlgeschlagen:${error.message}
`;
} 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 = `👤 Angemeldet als ${claims.preferred_username || claims.email}`;
}
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));
});
saveLocalButton.addEventListener('click', () => {
saveLocal();
window.alert('Lokal gespeichert. Das Dashboard in diesem Browser nutzt diese Aenderungen.');
});
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}`;
});