From 5ea0a548ec465397e7e16a72fb57fb533fdb90bb Mon Sep 17 00:00:00 2001 From: Mirza Hasanbasic Date: Thu, 12 Feb 2026 22:50:30 +0100 Subject: [PATCH] changes made --- assets/css/style.css | 243 +++++++++++++++++++ assets/js/main.js | 559 ++++++++++++++++++++++++++++++++++++++----- index.html | 260 ++------------------ services.json | 79 ++++++ 4 files changed, 835 insertions(+), 306 deletions(-) create mode 100644 services.json diff --git a/assets/css/style.css b/assets/css/style.css index 955a2d8..ef0a382 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -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 { diff --git a/assets/js/main.js b/assets/js/main.js index baa1407..2526f30 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -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 = ''; + 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 = ` ${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 = ` +
+
+ ${mon.label} + Checking... +
`; + + 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 = `${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(); })(); diff --git a/index.html b/index.html index b88d4d1..9518c4e 100644 --- a/index.html +++ b/index.html @@ -30,13 +30,7 @@