// ========== GRID & STATUS HELPERS ========== (function () { /* Enhanced status helpers with response time tracking */ function setQuick(id, up, responseTime = null) { const dot = document.getElementById(id + '-dot'); const pill = document.getElementById(id + '-pill'); const timeEl = document.getElementById(id + '-time'); const card = document.querySelector(`[data-app="${id}"]`); if (dot) { dot.classList.toggle('ok', up); dot.classList.toggle('bad', !up); } if (pill) { pill.textContent = up ? 'ON' : 'OFF'; pill.classList.toggle('on', up); pill.classList.toggle('off', !up); } if (timeEl && responseTime !== null) { timeEl.textContent = up ? `${responseTime}ms` : 'timeout'; timeEl.className = `response-time ${getResponseTimeClass(responseTime, up)}`; } // Update card status for icon coloring if (card) { card.setAttribute('data-status', up ? 'on' : 'off'); } } function getResponseTimeClass(time, isUp) { if (!isUp) return 'timeout'; if (time < 200) return 'excellent'; if (time < 500) return 'good'; if (time < 1000) return 'fair'; return 'slow'; } async function checkServiceWithTiming(id) { const startTime = performance.now(); try { const r = await fetch('/probe/' + id, { cache: 'no-store' }); const endTime = performance.now(); const responseTime = Math.round(endTime - startTime); const isUp = (r.status >= 200 && r.status < 400) || r.status === 401 || r.status === 403; return { isUp, responseTime }; } catch { const endTime = performance.now(); const responseTime = Math.round(endTime - startTime); return { isUp: false, responseTime }; } } /* App grid - loaded from API */ window.APPS = []; // Use window.APPS as the main array let refreshInFlight = null; let refreshQueued = false; // Load services from API async function loadServices() { try { if (window.SkeletonLoader) window.SkeletonLoader.show(6); const response = await fetch('/api/v1/services', { cache: 'no-store' }); if (response.ok) { window.APPS = await response.json(); if (window.SkeletonLoader) window.SkeletonLoader.hide(); } else { console.error('Failed to load services:', response.status); if (window.SkeletonLoader) window.SkeletonLoader.hide(); } } catch (error) { console.error('Failed to load services:', error); if (window.SkeletonLoader) window.SkeletonLoader.hide(); } } function serviceUrl(id) { const svc = window.APPS?.find(a => a.id === id); if (svc?.url) return svc.url.startsWith('http') ? svc.url : 'https://' + svc.url; if (svc?.isExternal && svc.externalUrl) return svc.externalUrl; const dns = SITE.dnsServers?.[id]; if (dns) return 'http://' + dns.ip + ':' + (dns.port || 5380); return buildServiceUrl(id); } function el(tag, cls, txt) { const n = document.createElement(tag); if (cls) n.className = cls; if (txt) n.textContent = txt; return n; } function buildGrid() { const root = document.getElementById('cards'); root.innerHTML = ''; for (let i = 0; i < window.APPS.length; i++) { const s = window.APPS[i]; if (s.id === 'ca') continue; // DashCA lives in top anchor row const card = el('div', 'card'); card.setAttribute('data-app', s.id); card.setAttribute('data-status', 'off'); // Initial status if (s.recipeId) card.setAttribute('data-recipe-id', s.recipeId); const dot = el('span', 'dot bad at-bl'); dot.id = 'dot-' + s.id + '-grid'; card.appendChild(dot); const row = el('div', 'row'); const wrap = el('div', 'logo-wrap'); // Use reliable PNG images with automatic CDN fallback const img = document.createElement('img'); img.src = s.logo; img.alt = s.name; img.className = 'logo-img'; img.onerror = function() { // Try CDN fallback with multiple naming strategies // Use id, appTemplate, or derive from name let appId = s.id || s.appTemplate; if (!appId && s.name) { // Derive ID from name (lowercase, remove spaces) appId = s.name.toLowerCase().replace(/\s+/g, '-'); } if (appId) { // Try different CDN URL formats const cdnUrls = [ `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${appId}.png`, `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${appId.toLowerCase()}.png`, `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${appId.replace(/-/g, '')}.png`, `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${s.name.toLowerCase().replace(/\s+/g, '-')}.png` ]; // Remove duplicates const uniqueUrls = [...new Set(cdnUrls)]; // Find the next URL to try const currentIndex = uniqueUrls.indexOf(this.src); const nextIndex = currentIndex + 1; if (nextIndex < uniqueUrls.length) { this.src = uniqueUrls[nextIndex]; } else { this.style.display = 'none'; } } else { this.style.display = 'none'; } }; wrap.appendChild(img); row.appendChild(wrap); const nameSpan = el('span', 'name', s.name); row.appendChild(nameSpan); // Add Tailscale badge if service is protected if (s.tailscaleOnly) { const tsBadge = el('span', 'ts-badge', '🔐'); tsBadge.title = 'Tailscale-only access'; tsBadge.style.cssText = 'margin-left: 6px; font-size: 0.75rem; opacity: 0.8;'; nameSpan.appendChild(tsBadge); } row.appendChild(el('span', 'spacer')); const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill); // Update available badge (hidden by default, shown when update detected) const updateBadge = el('span', 'update-available-badge', 'UPDATE'); updateBadge.id = 'update-badge-' + s.id; updateBadge.title = 'Update available'; row.appendChild(updateBadge); card.appendChild(row); // Add response time row const responseRow = el('div', 'response-row'); const timeSpan = el('span', 'response-time', '--'); timeSpan.id = 'time-' + s.id; responseRow.appendChild(timeSpan); card.appendChild(responseRow); // Add health/uptime row const healthRow = el('div', 'health-row'); healthRow.id = 'health-' + s.id; const uptimeChip = el('span', 'uptime-chip', '--'); uptimeChip.id = 'uptime-' + s.id; healthRow.appendChild(uptimeChip); const uptimeMiniBar = document.createElement('div'); uptimeMiniBar.className = 'uptime-mini-bar'; const uptimeFill = document.createElement('div'); uptimeFill.className = 'fill'; uptimeFill.id = 'uptime-bar-' + s.id; uptimeFill.style.width = '0%'; uptimeMiniBar.appendChild(uptimeFill); healthRow.appendChild(uptimeMiniBar); card.appendChild(healthRow); const btnRow = el('div', 'btn-row'); // Add logs button for services with containerIds if (s.containerId) { const logsBtn = el('button', 'logs-btn', '📋'); logsBtn.title = 'View container logs'; logsBtn.onclick = (e) => { e.stopPropagation(); window.openContainerLogsModal(s.containerId, s.name); }; btnRow.appendChild(logsBtn); // Add update button for Docker containers const updateBtn = el('button', 'update-btn', '⬆️'); updateBtn.title = 'Update container to latest version'; updateBtn.id = `update-btn-${s.id}`; updateBtn.onclick = (e) => { e.stopPropagation(); window.updateContainer(s.containerId, s.name, s.id); }; btnRow.appendChild(updateBtn); // Terminal exec button (subtle — visible on hover) const execBtn = el('button', 'exec-btn', '>_'); execBtn.title = 'Open terminal'; execBtn.onclick = (e) => { e.stopPropagation(); if (window.openExecModal) window.openExecModal(s.containerId, s.name); }; btnRow.appendChild(execBtn); } // Add logs button for services with logPath (native apps) if (s.logPath && !s.containerId) { const logsBtn = el('button', 'logs-btn', '📋'); logsBtn.title = 'View application logs'; logsBtn.onclick = (e) => { e.stopPropagation(); window.openFileLogsModal(s.logPath, s.name); }; btnRow.appendChild(logsBtn); } // Add credentials button for services that support auto-login if (s.isExternal || s.appTemplate || s.url) { const credsBtn = el('button', 'creds-btn', '🔑'); credsBtn.title = 'Auto-login credentials'; credsBtn.id = `creds-btn-${s.id}`; credsBtn.onclick = (e) => { e.stopPropagation(); window.openServiceCredsModal(s); }; btnRow.appendChild(credsBtn); } // Add options button for all services except 'internet' if (s.id !== 'internet') { const optBtn = el('button', 'options-btn', '⚙️'); optBtn.title = 'Edit service settings'; optBtn.onclick = (e) => { e.stopPropagation(); window.openServiceEditModal(s); }; btnRow.appendChild(optBtn); } // Add delete button for all services except Internet if (s.id !== 'internet') { const delBtn = el('button', 'delete-btn', '🗑️'); delBtn.title = 'Delete this service'; delBtn.onclick = (e) => { e.stopPropagation(); window.deleteService(s.id, s.name); }; btnRow.appendChild(delBtn); } const btn = el('button', null, 'Open'); btn.onclick = () => window.open(serviceUrl(s.id), '_blank', 'noopener'); btnRow.appendChild(btn); card.appendChild(btnRow); card.style.transitionDelay = `${Math.min(i * 45, 270)}ms`; root.appendChild(card); } requestAnimationFrame(() => { root.querySelectorAll('.card').forEach(card => card.classList.add('loaded')); }); // Group recipe cards visually after grid is built if (window.groupRecipeCards) requestAnimationFrame(() => window.groupRecipeCards()); } function setBadge(id, up, responseTime = null) { const dot = document.getElementById('dot-' + id + '-grid'); const pill = document.getElementById('badge-' + id); const timeEl = document.getElementById('time-' + id); const card = document.querySelector(`[data-app="${id}"]`); if (dot) { dot.classList.toggle('ok', up); dot.classList.toggle('bad', !up); } if (pill) { pill.textContent = up ? 'ON' : 'OFF'; pill.classList.toggle('on', up); pill.classList.toggle('off', !up); } if (timeEl && responseTime !== null) { timeEl.textContent = up ? `${responseTime}ms` : 'timeout'; timeEl.className = `response-time ${getResponseTimeClass(responseTime, up)}`; } // Update card status for icon coloring if (card) { card.setAttribute('data-status', up ? 'on' : 'off'); } } async function refreshAll() { if (refreshInFlight) { refreshQueued = true; return refreshInFlight; } function updateStamp(label, checkedAt = new Date()) { const stamp = document.getElementById('stamp'); if (stamp) stamp.textContent = `${label}: ${new Date(checkedAt).toLocaleTimeString()}`; } function applyBatchResults(statuses) { const dnsIds = Object.keys(SITE.dnsServers); dnsIds.forEach((id) => { const result = statuses[id]; if (result) setQuick(id, result.isUp, result.responseTime); }); if (statuses.internet) { setQuick('internet', statuses.internet.isUp, statuses.internet.responseTime); } window.APPS.forEach((service) => { const result = statuses[service.id]; if (result) setBadge(service.id, result.isUp, result.responseTime); }); } async function refreshFallback() { const dnsIds = Object.keys(SITE.dnsServers); const topChecks = dnsIds.map(id => checkServiceWithTiming(id)); topChecks.push(checkServiceWithTiming('internet')); const topResults = await Promise.all(topChecks); dnsIds.forEach((id, i) => setQuick(id, topResults[i].isUp, topResults[i].responseTime)); const internetResult = topResults[topResults.length - 1]; setQuick('internet', internetResult.isUp, internetResult.responseTime); const appResults = await Promise.all( window.APPS.map(async s => { const result = await checkServiceWithTiming(s.id); return { id: s.id, ...result }; }) ); appResults.forEach(result => { setBadge(result.id, result.isUp, result.responseTime); }); } refreshInFlight = (async () => { try { const response = await fetch('/api/v1/services/status', { cache: 'no-store' }); if (!response.ok) { throw new Error(`Status refresh failed (${response.status})`); } const data = await response.json(); applyBatchResults(data.statuses || {}); updateStamp('last check', data.checkedAt || new Date()); } catch (batchError) { console.warn('Batched status refresh failed, falling back to direct probes:', batchError); try { await refreshFallback(); updateStamp('last check'); } catch (fallbackError) { console.error('Dashboard refresh failed:', fallbackError); updateStamp('last failed'); } } finally { refreshInFlight = null; if (refreshQueued) { refreshQueued = false; setTimeout(() => { window.refreshAll(); }, 0); } } })(); return refreshInFlight; } // DNS open buttons — use event delegation on .top container document.querySelector('.top')?.addEventListener('click', (e) => { const openBtn = e.target.closest('[id$="-open"]'); if (!openBtn) return; const id = openBtn.id.replace('-open', ''); if (SITE.dnsServers[id]) window.open(serviceUrl(id), '_blank', 'noopener'); }); document.getElementById('ca-open')?.addEventListener('click', () => window.open(serviceUrl('ca'), '_blank', 'noopener')); document.getElementById('creds-btn-ca')?.addEventListener('click', (e) => { e.stopPropagation(); const s = window.APPS.find(a => a.id === 'ca'); if (s && window.openServiceCredsModal) window.openServiceCredsModal(s); }); document.getElementById('options-btn-ca')?.addEventListener('click', (e) => { e.stopPropagation(); const s = window.APPS.find(a => a.id === 'ca'); if (s && window.openServiceEditModal) window.openServiceEditModal(s); }); document.getElementById('delete-btn-ca')?.addEventListener('click', (e) => { e.stopPropagation(); if (window.deleteService) window.deleteService('ca', 'DashCA'); }); // Window exports window.loadServices = loadServices; window.buildGrid = buildGrid; window.refreshAll = refreshAll; window.setQuick = setQuick; window.setBadge = setBadge; window.getResponseTimeClass = getResponseTimeClass; window.checkServiceWithTiming = checkServiceWithTiming; window.serviceUrl = serviceUrl; window.el = el; })();