Files
Homepage/js/services-loader.js

217 lines
8.1 KiB
JavaScript

// 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 = '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 = '<p class="notes">No services found in services.xml</p>';
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 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 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
if(hostAttr){
// Service has a public hostname or URL
if(/^https?:\/\//i.test(hostAttr)){
href = hostAttr;
healthCheckUrl = hostAttr;
} else {
const hasPortInHost = /:\d+$/.test(hostAttr);
if(hasPortInHost){
href = `${proto}://${hostAttr}`;
healthCheckUrl = `${proto}://${hostAttr}`;
} else {
href = `https://${hostAttr}`;
healthCheckUrl = `https://${hostAttr}`;
}
}
} else {
// Local service - use Tailscale IP if configured, otherwise current hostname
const targetHost = tailscaleIP || host;
let portPart = '';
if(port && !((proto==='http'&&port==='80')||(proto==='https'&&port==='443'))){ portPart = ':'+port; }
href = `${proto}://${targetHost}${portPart}`;
healthCheckUrl = `${proto}://${targetHost}${portPart}`;
}
const a = document.createElement('a');
a.className = 'card';
a.href = href;
a.target = '_blank';
a.rel = 'noreferrer';
a.dataset.serviceName = name.toLowerCase(); // For search filtering
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)
(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
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 = '<p class="notes">Error loading services.xml — see console for details.</p>';
}
})();