All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 33s
433 lines
13 KiB
JavaScript
433 lines
13 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 PKCE_KEY = 'mischlabs.admin.pkce';
|
|
|
|
const loginButton = document.querySelector('#loginButton');
|
|
const logoutButton = document.querySelector('#logoutButton');
|
|
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 diskStatus = document.querySelector('#diskStatus');
|
|
const watchtowerStatus = document.querySelector('#watchtowerStatus');
|
|
const containerStatus = document.querySelector('#containerStatus');
|
|
|
|
let config = null;
|
|
let tokenSet = 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);
|
|
return raw ? JSON.parse(raw) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function storeTokens(tokens) {
|
|
sessionStorage.setItem(TOKEN_KEY, JSON.stringify(tokens));
|
|
}
|
|
|
|
function clearTokens() {
|
|
sessionStorage.removeItem(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 renderEditor() {
|
|
serviceEditor.innerHTML = config.services.map((service, index) => `
|
|
<article class="admin-row" data-index="${index}">
|
|
<div>
|
|
<strong>${service.name}</strong>
|
|
<span>${service.domain}</span>
|
|
</div>
|
|
<label>
|
|
Kategorie
|
|
<select data-field="category">${categoryOptions(service.category)}</select>
|
|
</label>
|
|
<label>
|
|
URL
|
|
<input data-field="url" value="${service.url}">
|
|
</label>
|
|
<button class="ghost-button check-one" type="button">Pruefen</button>
|
|
<div class="diagnostic">Noch nicht geprueft.</div>
|
|
</article>
|
|
`).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 renderDisks(disks = []) {
|
|
diskStatus.innerHTML = disks.map((disk) => {
|
|
if (disk.error) {
|
|
return `<div class="ops-line"><strong>${disk.label}</strong><span>${disk.error}</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('');
|
|
}
|
|
|
|
function renderWatchtower(watchtower) {
|
|
const session = watchtower?.lastSession;
|
|
if (!session) {
|
|
watchtowerStatus.textContent = 'Kein Watchtower-Lauf gefunden.';
|
|
return;
|
|
}
|
|
|
|
const finished = session.finishedAt ? new Date(session.finishedAt).toLocaleString('de-DE') : '--';
|
|
watchtowerStatus.innerHTML = `
|
|
<div class="ops-line"><strong>Letzter Lauf</strong><span>${finished}</span></div>
|
|
<div class="ops-line"><strong>Geprueft</strong><span>${session.scanned ?? '--'}</span></div>
|
|
<div class="ops-line"><strong>Aktualisiert</strong><span>${session.updated ?? '--'}</span></div>
|
|
<div class="ops-line"><strong>Fehler</strong><span>${session.failed ?? '--'}</span></div>
|
|
`;
|
|
}
|
|
|
|
function renderContainers(containers) {
|
|
if (!Array.isArray(containers)) {
|
|
containerStatus.textContent = containers?.error || 'Containerdaten nicht verfuegbar.';
|
|
return;
|
|
}
|
|
|
|
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">
|
|
<strong>${container.name}</strong>
|
|
<span>${container.status}</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
async function refreshOps() {
|
|
diskStatus.textContent = 'Lade...';
|
|
watchtowerStatus.textContent = 'Lade...';
|
|
containerStatus.textContent = 'Lade...';
|
|
|
|
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);
|
|
} catch (error) {
|
|
diskStatus.textContent = `Statusproxy nicht erreichbar: ${error.message}`;
|
|
watchtowerStatus.textContent = 'Nicht verfuegbar.';
|
|
containerStatus.textContent = 'Nicht verfuegbar.';
|
|
}
|
|
}
|
|
|
|
async function boot() {
|
|
await handleCallback();
|
|
tokenSet = getStoredTokens();
|
|
|
|
if (!isTokenValid(tokenSet)) {
|
|
loginPanel.hidden = false;
|
|
adminPanel.hidden = true;
|
|
loginButton.hidden = false;
|
|
logoutButton.hidden = true;
|
|
return;
|
|
}
|
|
|
|
const claims = authorize(tokenSet);
|
|
if (!claims) return;
|
|
|
|
config = await loadConfig();
|
|
loginPanel.hidden = true;
|
|
adminPanel.hidden = false;
|
|
loginButton.hidden = true;
|
|
logoutButton.hidden = false;
|
|
adminUser.textContent = `Angemeldet als ${claims.preferred_username || claims.email}`;
|
|
renderEditor();
|
|
refreshOps();
|
|
}
|
|
|
|
loginButton.addEventListener('click', login);
|
|
logoutButton.addEventListener('click', () => {
|
|
const idToken = tokenSet?.id_token;
|
|
clearTokens();
|
|
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}`;
|
|
});
|
|
|
|
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);
|
|
|
|
boot().catch((error) => {
|
|
loginMessage.textContent = `Adminseite konnte nicht starten: ${error.message}`;
|
|
});
|