(() => { 'use strict'; // ── Config Loader ── async function loadConfig() { const res = await fetch('/services.json'); if (!res.ok) throw new Error(`Failed to load services.json: ${res.status}`); return res.json(); } // ── Nav Renderer ── function renderNav(config) { const nav = document.getElementById('nav'); config.categories.filter(c => c.showInNav).forEach(cat => { const a = document.createElement('a'); a.href = `#${cat.id}`; a.textContent = cat.navLabel; nav.appendChild(a); }); // Search button in nav const btn = document.createElement('button'); btn.className = 'nav-search-btn'; btn.setAttribute('aria-label', 'Search services'); btn.innerHTML = ''; btn.addEventListener('click', () => SearchOverlay.open()); nav.appendChild(btn); } // ── Services Renderer ── function renderServices(config) { const container = document.getElementById('services-container'); const grouped = {}; config.categories.forEach(cat => { grouped[cat.id] = []; }); config.services.forEach(svc => { if (grouped[svc.category]) grouped[svc.category].push(svc); }); config.categories.forEach(cat => { const services = grouped[cat.id]; if (!services || services.length === 0) return; const section = document.createElement('section'); section.id = cat.id; section.className = 'category'; const inner = document.createElement('div'); inner.className = 'container'; const title = document.createElement('h2'); title.className = 'section-title'; title.innerHTML = ` ${cat.title}`; const grid = document.createElement('div'); grid.className = 'card-grid'; services.forEach((svc, i) => { const card = document.createElement('a'); card.href = svc.url; card.className = 'service-card'; card.dataset.name = svc.name.toLowerCase(); card.dataset.category = cat.id; card.style.animationDelay = `${(i + 1) * 0.02}s`; // Status dot const dot = document.createElement('span'); dot.className = 'card-status-dot unknown'; card.appendChild(dot); const icon = document.createElement('i'); icon.className = svc.icon; card.appendChild(icon); const name = document.createElement('span'); name.className = 'card-name'; name.textContent = svc.name; card.appendChild(name); if (svc.sub) { const sub = document.createElement('span'); sub.className = 'card-sub'; sub.textContent = svc.sub; card.appendChild(sub); } grid.appendChild(card); }); inner.appendChild(title); inner.appendChild(grid); section.appendChild(inner); container.appendChild(section); }); } // ── Status Bar Renderer ── function renderStatusBar(config) { const grid = document.getElementById('status-grid'); config.statusMonitors.forEach(mon => { const card = document.createElement('a'); card.href = mon.url; card.className = 'status-card'; card.id = mon.id; card.innerHTML = `
${mon.label} Checking...
`; grid.appendChild(card); }); } // ── Status Monitor ── class StatusMonitor { constructor(config) { this.config = config; // Rich monitor parsers this.parsers = { glances: (data) => { // Glances v4 uses 'cpu'/'mem', v3 uses 'cpu_percent'/'mem_percent' const cpu = data.cpu ?? data.cpu_percent; const mem = data.mem ?? data.mem_percent; if (cpu == null || mem == null) return 'No data'; return `CPU ${Math.round(cpu)}% | RAM ${Math.round(mem)}%`; }, uptime: (data) => { const groups = data.heartbeatList || {}; const monitors = Object.values(groups); const total = monitors.length; if (total === 0) return 'No monitors'; const up = monitors.filter(beats => Array.isArray(beats) && beats.length > 0 && beats[beats.length - 1].status === 1).length; return `${up}/${total} services up`; }, cadvisor: (data) => { const cores = data.num_cores || '?'; const ramGB = data.memory_capacity ? (data.memory_capacity / 1073741824).toFixed(0) : '?'; return `${cores} cores | ${ramGB} GB RAM`; } }; this.pollRichStatus(); this.pollHealthChecks(); setInterval(() => this.pollRichStatus(), 60000); setInterval(() => this.pollHealthChecks(), 120000); } // ── Rich status monitors (3 detailed cards) ── async pollRichStatus() { this.config.statusMonitors.forEach(mon => this.checkRich(mon)); } async checkRich(mon) { const card = document.getElementById(mon.id); if (!card) return; const dot = card.querySelector('.status-dot'); const value = card.querySelector('.status-value'); dot.className = 'status-dot loading'; try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); const res = await fetch(mon.apiUrl, { signal: controller.signal }); clearTimeout(timeout); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); console.log(`[StatusMonitor] ${mon.label} raw:`, JSON.stringify(data).slice(0, 500)); const result = this.parsers[mon.parseType](data); dot.className = 'status-dot ok'; value.textContent = result; } catch (err) { console.warn(`[StatusMonitor] ${mon.label} failed:`, err); dot.className = 'status-dot error'; value.textContent = 'Unreachable'; } } // ── Per-card health checks (simple up/down) ── async pollHealthChecks() { const services = this.config.services.filter(s => s.healthCheckUrl); const batchSize = 6; for (let i = 0; i < services.length; i += batchSize) { const batch = services.slice(i, i + batchSize); await Promise.allSettled(batch.map(svc => this.checkHealth(svc))); if (i + batchSize < services.length) { await new Promise(r => setTimeout(r, 200)); } } } async checkHealth(svc) { const card = document.querySelector(`.service-card[data-name="${svc.name.toLowerCase()}"]`); if (!card) return; const dot = card.querySelector('.card-status-dot'); if (!dot) return; dot.className = 'card-status-dot loading'; try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8000); const url = svc.healthCheckUrl; const isRelative = url.startsWith('/'); const isHttps = url.startsWith('https://'); // Relative URLs and HTTPS: normal fetch. HTTP cross-origin: no-cors. const fetchOpts = { signal: controller.signal }; if (!isRelative && !isHttps) { fetchOpts.mode = 'no-cors'; } await fetch(url, fetchOpts); clearTimeout(timeout); dot.className = 'card-status-dot ok'; } catch { dot.className = 'card-status-dot error'; } } } // ── Search Overlay ── const SearchOverlay = { overlay: null, input: null, results: null, services: [], selectedIndex: -1, isOpen: false, init(config) { this.overlay = document.getElementById('search-overlay'); this.input = document.getElementById('search-input'); this.results = document.getElementById('search-results'); this.services = config.services; // Close on backdrop click this.overlay.addEventListener('click', (e) => { if (e.target === this.overlay) this.close(); }); // Input handler this.input.addEventListener('input', () => { this.selectedIndex = -1; this.renderResults(this.input.value.trim()); this.filterCards(this.input.value.trim()); }); // Keyboard navigation this.input.addEventListener('keydown', (e) => { const items = this.results.querySelectorAll('.search-result-item'); if (e.key === 'ArrowDown') { e.preventDefault(); this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1); this.highlightItem(items); } else if (e.key === 'ArrowUp') { e.preventDefault(); this.selectedIndex = Math.max(this.selectedIndex - 1, 0); this.highlightItem(items); } else if (e.key === 'Enter') { e.preventDefault(); if (this.selectedIndex >= 0 && items[this.selectedIndex]) { window.location.href = items[this.selectedIndex].dataset.url; } else if (items.length === 1) { window.location.href = items[0].dataset.url; } } else if (e.key === 'Escape') { this.close(); } }); // Global '/' shortcut document.addEventListener('keydown', (e) => { if (e.key === '/' && !this.isOpen && !isInputFocused()) { e.preventDefault(); this.open(); } else if (e.key === 'Escape' && this.isOpen) { this.close(); } }); }, open() { this.isOpen = true; this.overlay.classList.remove('hidden'); this.input.value = ''; this.results.innerHTML = ''; this.selectedIndex = -1; this.clearCardFilters(); setTimeout(() => this.input.focus(), 50); }, close() { this.isOpen = false; this.overlay.classList.add('hidden'); this.input.value = ''; this.results.innerHTML = ''; this.selectedIndex = -1; this.clearCardFilters(); }, fuzzyMatch(query, text) { query = query.toLowerCase(); text = text.toLowerCase(); let qi = 0; for (let ti = 0; ti < text.length && qi < query.length; ti++) { if (text[ti] === query[qi]) qi++; } return qi === query.length; }, renderResults(query) { this.results.innerHTML = ''; if (!query) return; const matches = this.services.filter(svc => this.fuzzyMatch(query, svc.name)); matches.forEach((svc, i) => { const item = document.createElement('div'); item.className = 'search-result-item' + (i === this.selectedIndex ? ' active' : ''); item.dataset.url = svc.url; item.innerHTML = `${svc.name}`; item.addEventListener('click', () => { window.location.href = svc.url; }); item.addEventListener('mouseenter', () => { this.selectedIndex = i; this.highlightItem(this.results.querySelectorAll('.search-result-item')); }); this.results.appendChild(item); }); if (matches.length === 0) { const empty = document.createElement('div'); empty.className = 'search-result-empty'; empty.textContent = 'No services found'; this.results.appendChild(empty); } }, highlightItem(items) { items.forEach((item, i) => { item.classList.toggle('active', i === this.selectedIndex); }); if (items[this.selectedIndex]) { items[this.selectedIndex].scrollIntoView({ block: 'nearest' }); } }, filterCards(query) { const allCards = document.querySelectorAll('.service-card'); const allSections = document.querySelectorAll('.category'); if (!query) { this.clearCardFilters(); return; } allCards.forEach(card => { const name = card.dataset.name || ''; const match = this.fuzzyMatch(query, name); card.classList.toggle('search-hidden', !match); card.classList.toggle('search-highlight', match); }); // Hide sections with no visible cards allSections.forEach(section => { const visibleCards = section.querySelectorAll('.service-card:not(.search-hidden)'); section.classList.toggle('search-hidden', visibleCards.length === 0); }); }, clearCardFilters() { document.querySelectorAll('.search-hidden').forEach(el => el.classList.remove('search-hidden')); document.querySelectorAll('.search-highlight').forEach(el => el.classList.remove('search-highlight')); } }; function isInputFocused() { const el = document.activeElement; return el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable); } // ── Hero Widget (Clock / Weather / Greeting) ── class HeroWidget { constructor(config) { this.el = document.getElementById('hero-widget'); this.city = config.weather?.city || 'Copenhagen'; this.weatherData = null; this.render(); this.updateTime(); this.fetchWeather(); setInterval(() => this.updateTime(), 1000); setInterval(() => this.fetchWeather(), 900000); // 15 min } render() { this.el.innerHTML = `

Loading weather...

`; } updateTime() { const now = new Date(); const hour = now.getHours(); let greeting; if (hour < 5) greeting = 'Good Night'; else if (hour < 12) greeting = 'Good Morning'; else if (hour < 17) greeting = 'Good Afternoon'; else if (hour < 21) greeting = 'Good Evening'; else greeting = 'Good Night'; const greetEl = document.getElementById('hero-greeting'); const clockEl = document.getElementById('hero-clock'); const dateEl = document.getElementById('hero-date'); if (greetEl) greetEl.textContent = greeting; if (clockEl) clockEl.textContent = now.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); if (dateEl) dateEl.textContent = now.toLocaleDateString('en-GB', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); } async fetchWeather() { const weatherEl = document.getElementById('hero-weather'); if (!weatherEl) return; try { const res = await fetch(`https://wttr.in/${encodeURIComponent(this.city)}?format=j1`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const current = data.current_condition?.[0]; if (!current) throw new Error('No data'); const temp = current.temp_C; const desc = current.weatherDesc?.[0]?.value || ''; const code = parseInt(current.weatherCode, 10); const iconClass = this.weatherIcon(code); weatherEl.innerHTML = ` ${temp}°C — ${desc}`; this.weatherData = current; } catch { weatherEl.innerHTML = ` Weather unavailable`; } } weatherIcon(code) { // wttr.in weather codes to Remix Icon if (code === 113) return 'ri-sun-line'; // Clear/Sunny if (code === 116) return 'ri-sun-cloudy-line'; // Partly cloudy if (code === 119 || code === 122) return 'ri-cloudy-line'; // Cloudy / Overcast if (code === 143 || code === 248 || code === 260) return 'ri-mist-line'; // Fog/Mist if ([176, 263, 266, 293, 296, 299, 302, 305, 308, 353, 356, 359].includes(code)) return 'ri-rainy-line'; // Rain if ([179, 182, 185, 227, 230, 323, 326, 329, 332, 335, 338, 368, 371, 374, 377].includes(code)) return 'ri-snowy-line'; // Snow if ([200, 386, 389, 392, 395].includes(code)) return 'ri-thunderstorms-line'; // Thunder if ([281, 284, 311, 314, 317, 320, 350, 362, 365].includes(code)) return 'ri-drizzle-line'; // Sleet/Freezing return 'ri-cloudy-line'; } } // ── Mobile Nav Toggle (event delegation for dynamic links) ── function initNavToggle() { const navToggle = document.getElementById('nav-toggle'); const nav = document.getElementById('nav'); navToggle.addEventListener('click', () => { const open = nav.classList.toggle('open'); navToggle.setAttribute('aria-expanded', open); navToggle.querySelector('i').className = open ? 'ri-close-line' : 'ri-menu-line'; }); // Event delegation for dynamically added nav links nav.addEventListener('click', (e) => { if (e.target.closest('a')) { nav.classList.remove('open'); navToggle.setAttribute('aria-expanded', 'false'); navToggle.querySelector('i').className = 'ri-menu-line'; } }); } // ── Header Scroll Effect ── function initScrollEffect() { const header = document.getElementById('header'); let ticking = false; window.addEventListener('scroll', () => { if (!ticking) { requestAnimationFrame(() => { header.classList.toggle('scrolled', window.scrollY > 50); ticking = false; }); ticking = true; } }); } // ── Init ── async function init() { // These work independently of config initNavToggle(); initScrollEffect(); try { const config = await loadConfig(); renderNav(config); renderStatusBar(config); renderServices(config); new StatusMonitor(config); SearchOverlay.init(config); new HeroWidget(config); } catch (err) { console.error('Failed to initialize dashboard:', err); const container = document.getElementById('services-container'); if (container) { container.innerHTML = `

Failed to load services

Could not load services.json. Check the console for details.

`; } } } init(); })();