changes made

This commit is contained in:
2026-02-12 22:50:30 +01:00
parent aa3f5a5fc0
commit 5ea0a548ec
4 changed files with 835 additions and 306 deletions

View File

@@ -232,6 +232,7 @@ a {
/* ── Service Cards ── */
.service-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
@@ -405,6 +406,248 @@ a {
font-style: normal;
}
/* ── Card Status Dots ── */
.card-status-dot {
position: absolute;
top: 10px;
right: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
opacity: 0.3;
transition: background 0.3s, opacity 0.3s, box-shadow 0.3s;
}
.card-status-dot.loading {
background: var(--yellow);
opacity: 1;
box-shadow: 0 0 6px var(--yellow);
animation: pulse 1.5s ease-in-out infinite;
}
.card-status-dot.ok {
background: var(--green);
opacity: 1;
box-shadow: 0 0 6px var(--green);
animation: none;
}
.card-status-dot.error {
background: var(--red);
opacity: 1;
box-shadow: 0 0 6px var(--red);
animation: none;
}
.card-status-dot.unknown {
background: var(--text-muted);
opacity: 0.3;
box-shadow: none;
animation: none;
}
/* ── Search Overlay ── */
.search-overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
}
.search-overlay.hidden {
display: none;
}
.search-container {
width: 100%;
max-width: 560px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 16px 64px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.search-input-wrap {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.search-input-wrap > i {
font-size: 20px;
color: var(--text-muted);
flex-shrink: 0;
}
.search-input-wrap input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 16px;
font-family: inherit;
color: var(--text);
}
.search-input-wrap input::placeholder {
color: var(--text-muted);
opacity: 0.6;
}
.search-input-wrap kbd {
padding: 2px 8px;
font-size: 12px;
font-family: inherit;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border);
border-radius: 4px;
flex-shrink: 0;
}
.search-results {
max-height: 320px;
overflow-y: auto;
}
.search-result-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
cursor: pointer;
transition: background 0.15s;
}
.search-result-item:hover,
.search-result-item.active {
background: rgba(255, 196, 81, 0.08);
}
.search-result-item i {
font-size: 18px;
color: var(--accent);
flex-shrink: 0;
}
.search-result-item span {
font-size: 14px;
font-weight: 500;
color: var(--text);
}
.search-result-empty {
padding: 24px 20px;
text-align: center;
font-size: 14px;
color: var(--text-muted);
}
/* ── Search Card Visibility ── */
.search-hidden {
display: none !important;
}
.search-highlight {
border-color: var(--accent) !important;
box-shadow: 0 0 12px var(--accent-glow) !important;
}
/* ── Nav Search Button ── */
.nav-search-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 14px;
border-radius: var(--radius-sm);
font-size: 16px;
color: var(--text-muted);
background: none;
border: none;
cursor: pointer;
transition: color 0.2s, background 0.2s;
font-family: inherit;
}
.nav-search-btn:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.06);
}
/* ── Hero Widget ── */
.hero-greeting {
font-size: clamp(16px, 2.5vw, 20px);
color: var(--text-muted);
margin-bottom: 4px;
}
.hero-clock {
font-size: clamp(36px, 6vw, 64px);
font-weight: 700;
color: var(--text-heading);
font-variant-numeric: tabular-nums;
letter-spacing: 2px;
line-height: 1.1;
margin-bottom: 4px;
}
.hero-date {
font-size: clamp(14px, 2vw, 16px);
color: var(--text-muted);
margin-bottom: 12px;
}
.hero-weather {
font-size: 14px;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.hero-weather i {
font-size: 16px;
color: var(--accent);
}
/* ── Spin animation for loading icon ── */
.ri-spin {
display: inline-block;
animation: riSpin 1s linear infinite;
}
@keyframes riSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ── Mobile Adjustments ── */
@media (max-width: 768px) {
.search-overlay {
padding: 8vh 16px 0;
}
.search-input-wrap kbd {
display: none;
}
.nav-search-btn {
padding: 14px 16px;
font-size: 18px;
}
}
/* ── Reduced Motion ── */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {

View File

@@ -1,105 +1,534 @@
(() => {
'use strict';
// ── Mobile Nav Toggle ──
const navToggle = document.getElementById('nav-toggle');
const nav = document.getElementById('nav');
// ── 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();
}
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';
});
// Close nav on link click (mobile)
nav.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => {
nav.classList.remove('open');
navToggle.setAttribute('aria-expanded', 'false');
navToggle.querySelector('i').className = 'ri-menu-line';
// ── 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);
});
});
// ── Header Scroll Effect ──
const header = document.getElementById('header');
let ticking = false;
// Search button in nav
const btn = document.createElement('button');
btn.className = 'nav-search-btn';
btn.setAttribute('aria-label', 'Search services');
btn.innerHTML = '<i class="ri-search-line"></i>';
btn.addEventListener('click', () => SearchOverlay.open());
nav.appendChild(btn);
}
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
header.classList.toggle('scrolled', window.scrollY > 50);
ticking = false;
// ── 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 = `<i class="${cat.icon}"></i> ${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);
});
ticking = true;
}
});
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 = `
<div class="status-dot loading"></div>
<div class="status-info">
<span class="status-label">${mon.label}</span>
<span class="status-value">Checking...</span>
</div>`;
grid.appendChild(card);
});
}
// ── Status Monitor ──
class StatusMonitor {
constructor() {
this.services = [
{
id: 'status-glances',
url: '/proxy/glances/api/4/quicklook',
parse: (data) => `CPU ${Math.round(data.cpu_percent)}% | RAM ${Math.round(data.mem_percent)}%`
constructor(config) {
this.config = config;
// Rich monitor parsers
this.parsers = {
glances: (data) => `CPU ${Math.round(data.cpu_percent)}% | RAM ${Math.round(data.mem_percent)}%`,
uptime: (data) => {
const groups = data.heartbeatList || {};
const monitors = Object.values(groups);
const total = monitors.length;
const up = monitors.filter(beats => beats.length > 0 && beats[beats.length - 1].status === 1).length;
return `${up}/${total} services up`;
},
{
id: 'status-uptime',
url: '/proxy/uptime/api/status-page/heartbeat/default',
parse: (data) => {
const groups = data.heartbeatList || {};
const monitors = Object.values(groups);
const total = monitors.length;
const up = monitors.filter(beats => beats.length > 0 && beats[beats.length - 1].status === 1).length;
return `${up}/${total} services up`;
}
},
{
id: 'status-cadvisor',
url: '/proxy/cadvisor/api/v1.0/machine',
parse: (data) => {
const cores = data.num_cores || '?';
const ramGB = data.memory_capacity ? (data.memory_capacity / 1073741824).toFixed(0) : '?';
return `${cores} cores | ${ramGB} GB RAM`;
}
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.poll();
setInterval(() => this.poll(), 60000);
this.pollRichStatus();
this.pollHealthChecks();
setInterval(() => this.pollRichStatus(), 60000);
setInterval(() => this.pollHealthChecks(), 120000);
}
async poll() {
this.services.forEach(svc => this.check(svc));
// ── Rich status monitors (3 detailed cards) ──
async pollRichStatus() {
this.config.statusMonitors.forEach(mon => this.checkRich(mon));
}
async check(svc) {
const card = document.getElementById(svc.id);
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(svc.url, { signal: controller.signal });
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();
dot.className = 'status-dot ok';
value.textContent = svc.parse(data);
value.textContent = this.parsers[mon.parseType](data);
} catch {
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';
}
}
}
new StatusMonitor();
// ── 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 = `<i class="${svc.icon}"></i><span>${svc.name}</span>`;
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 = `
<p class="hero-greeting" id="hero-greeting"></p>
<p class="hero-clock" id="hero-clock"></p>
<p class="hero-date" id="hero-date"></p>
<p class="hero-weather" id="hero-weather"><i class="ri-loader-4-line ri-spin"></i> Loading weather...</p>`;
}
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 = `<i class="${iconClass}"></i> ${temp}°C — ${desc}`;
this.weatherData = current;
} catch {
weatherEl.innerHTML = `<i class="ri-cloud-off-line"></i> 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 = `
<div class="container" style="padding: 60px 24px; text-align: center;">
<i class="ri-error-warning-line" style="font-size: 48px; color: var(--red); margin-bottom: 16px;"></i>
<h2 style="color: var(--text-heading); margin-bottom: 8px;">Failed to load services</h2>
<p style="color: var(--text-muted);">Could not load services.json. Check the console for details.</p>
</div>`;
}
}
}
init();
})();