// Fetch services.xml and render the service cards with logos. (async function(){ const container = document.getElementById('services-container'); const searchInput = document.getElementById('search-input'); const host = window.location.hostname; let allServices = []; // Store all service elements for filtering try{ const res = await fetch('/services.xml', {cache: 'no-cache'}); if(!res.ok){ throw new Error('Failed to load services.xml'); } const text = await res.text(); const parser = new DOMParser(); const doc = parser.parseFromString(text, 'application/xml'); // Check for XML parsing errors const parseError = doc.querySelector('parsererror'); if(parseError){ throw new Error('XML parsing error: ' + parseError.textContent); } // Get Tailscale IP from root services element const servicesRoot = doc.getElementsByTagName('services')[0]; const tailscaleIP = servicesRoot ? servicesRoot.getAttribute('tailscale-ip') : null; console.log('Tailscale IP:', tailscaleIP || 'not configured'); // Check if we have groups or just services const groups = Array.from(doc.getElementsByTagName('group')); const hasGroups = groups.length > 0; console.log('Found', groups.length, 'groups'); if(hasGroups){ // Render grouped services groups.forEach(group => { const groupName = group.getAttribute('name') || 'Services'; const services = Array.from(group.getElementsByTagName('service')); console.log('Group:', groupName, 'has', services.length, 'services'); if(services.length === 0) return; // Create group section const groupSection = document.createElement('section'); groupSection.className = 'service-group'; const groupHeader = document.createElement('h2'); groupHeader.className = 'group-header'; groupHeader.textContent = groupName; groupSection.appendChild(groupHeader); const grid = document.createElement('div'); grid.className = 'services-grid'; services.forEach(s => { const card = createServiceCard(s, host, allServices, tailscaleIP); grid.appendChild(card); }); groupSection.appendChild(grid); container.appendChild(groupSection); }); } else { // Fallback: render ungrouped services const services = Array.from(doc.getElementsByTagName('service')); if(services.length === 0){ container.innerHTML = '
No services found in services.xml
'; return; } const grid = document.createElement('div'); grid.className = 'grid'; grid.id = 'services-grid'; services.forEach(s => { const card = createServiceCard(s, host, allServices, tailscaleIP); grid.appendChild(card); }); container.appendChild(grid); } // Function to create a service card function createServiceCard(s, host, allServices, tailscaleIP) { const name = s.getAttribute('name') || s.getAttribute('id') || 'unknown'; const serviceId = s.getAttribute('id') || name.toLowerCase().replace(/\s+/g, '-'); const proto = s.getAttribute('proto') || 'http'; const port = s.getAttribute('port') || ''; const logo = s.getAttribute('logo') || ''; const hostAttr = s.getAttribute('host'); // optional public hostname or full URL const localIpAttr = s.getAttribute('local-ip'); // optional local IP for proxied health-check const manualStatus = s.getAttribute('status'); // optional: 'online', 'offline', 'maintenance' const checkHealth = s.getAttribute('check-health') !== 'false'; // default true, set to false to disable // Build href: prefer explicit host attribute when present. let href = ''; let healthCheckUrl = ''; // Separate URL for health checks const pageIsSecure = window.location.protocol === 'https:'; const desiredProto = pageIsSecure ? 'https' : proto; // use https for health checks when page is secure to avoid mixed content if(hostAttr){ // Service has a public hostname or URL // Parse hostAttr to extract hostname and optional port let parsedHost = hostAttr; let parsedPort = port; let parsedProto = proto; if(/^https?:\/\//i.test(hostAttr)){ // remove protocol const u = new URL(hostAttr); parsedHost = u.hostname; parsedPort = u.port || parsedPort; parsedProto = u.protocol.replace(':','') || parsedProto; href = hostAttr; } else { const hasPortInHost = /:\d+$/.test(hostAttr); if(hasPortInHost){ // host:port href = `${proto}://${hostAttr}`; parsedHost = hostAttr.split(':')[0]; parsedPort = hostAttr.split(':')[1]; } else { href = `https://${hostAttr}`; parsedHost = hostAttr; } } // Always proxy health checks via same-origin endpoint to avoid mixed-content healthCheckUrl = `/healthcheck?id=${encodeURIComponent(serviceId)}`; } else { // Local service - use Tailscale IP if configured, otherwise current hostname const targetHost = localIpAttr || tailscaleIP || host; let portPart = ''; if(port && !((proto==='http'&&port==='80')||(proto==='https'&&port==='443'))){ portPart = ':'+port; } // Keep the link protocol as the defined proto (so clicking uses the intended protocol), // but use the server-side health proxy for health checks to avoid mixed content blocking. href = `${proto}://${targetHost}${portPart}`; // Use our same-origin proxy path which nginx will forward to the health-proxy service. const encodedHost = encodeURIComponent(targetHost); const hcPort = port || (desiredProto === 'https' ? '443' : '80'); if(localIpAttr){ console.log(`Service ${name}: using local-ip ${localIpAttr} for proxied health checks`); } else if(tailscaleIP){ console.log(`Service ${name}: using tailscale-ip ${tailscaleIP} for proxied health checks`); } healthCheckUrl = `/healthcheck?id=${encodeURIComponent(serviceId)}`; // Warn when site is secure but service link is HTTP and target is a private IP if(pageIsSecure && proto === 'http' && targetHost === tailscaleIP){ console.warn(`Service ${name} will use HTTP at ${href}, but the page is loaded over HTTPS — mixed-content checks will prevent programmatic health checks to the service. Consider enabling TLS or providing a public hostname.`); } } const a = document.createElement('a'); a.className = 'service-card'; a.href = href; a.target = '_blank'; a.rel = 'noreferrer'; a.dataset.serviceName = name.toLowerCase(); // For search filtering a.dataset.serviceId = serviceId; // For drag-drop and identification a.setAttribute('draggable', 'true'); // Enable drag-and-drop const img = document.createElement('img'); img.className = 'logo'; img.src = logo ? ('/logos/'+logo) : '/logos/default.svg'; img.alt = name + ' logo'; const span = document.createElement('div'); span.className = 'label'; span.textContent = `${name}`; a.appendChild(img); a.appendChild(span); // Add status indicator - will be updated by health check const statusDot = document.createElement('span'); statusDot.className = 'status-dot status-checking'; statusDot.title = 'Checking status...'; let currentStatus = 'checking'; // If maintenance mode is set manually, use that and skip health check if(manualStatus === 'maintenance'){ statusDot.className = 'status-dot status-maintenance'; statusDot.title = 'Status: maintenance'; currentStatus = 'maintenance'; a.appendChild(statusDot); } else if(checkHealth && healthCheckUrl){ // Show checking indicator initially a.appendChild(statusDot); // Perform health check using healthCheckUrl (Tailscale IP for local services) if(healthCheckUrl !== href){ console.log(`Service ${name}: using healthCheckUrl (${healthCheckUrl}) while link is ${href}`); } (async function performHealthCheck(){ try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout const response = await fetch(healthCheckUrl, { method: 'HEAD', mode: 'no-cors', // Allow cross-origin requests cache: 'no-cache', signal: controller.signal }); clearTimeout(timeoutId); // In no-cors mode, opaque responses mean the server responded currentStatus = 'online'; statusDot.className = 'status-dot status-online'; statusDot.title = 'Status: online'; } catch(err) { // Connection failed or timed out // If the page is not secure, try fallback to http if we used https for health check if(!pageIsSecure && healthCheckUrl.startsWith('https:')){ try { const fallback = healthCheckUrl.replace(/^https:/i, 'http:'); const controller2 = new AbortController(); const timeoutId2 = setTimeout(() => controller2.abort(), 5000); await fetch(fallback, { method: 'HEAD', mode: 'no-cors', cache: 'no-cache', signal: controller2.signal }); clearTimeout(timeoutId2); currentStatus = 'online'; statusDot.className = 'status-dot status-online'; statusDot.title = 'Status: online'; return; } catch(fErr){ // continue to mark offline } } currentStatus = 'offline'; statusDot.className = 'status-dot status-offline'; statusDot.title = 'Status: offline'; } })(); } else if(manualStatus){ // Use manual status if health check is disabled statusDot.className = `status-dot status-${manualStatus}`; statusDot.title = `Status: ${manualStatus}`; currentStatus = manualStatus; a.appendChild(statusDot); } // Add info button if both hostname and port are available if((hostAttr || host) && port){ const infoBtn = document.createElement('button'); infoBtn.className = 'info-btn'; infoBtn.title = 'Connection details'; infoBtn.innerHTML = 'ⓘ'; infoBtn.onclick = (e) => { e.preventDefault(); e.stopPropagation(); const displayHost = hostAttr || host; const details = `Service: ${name}\nHost: ${displayHost}\nPort: ${port}\nProtocol: ${proto}`; alert(details); }; a.appendChild(infoBtn); } allServices.push(a); return a; } // Export allServices for use by search and keyboard navigation window.servicesData = { allServices, container }; }catch(err){ console.error(err); container.innerHTML = 'Error loading services.xml — see console for details.
'; } })();