Files
Homepage/js/services-loader.js

267 lines
11 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 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
const encodedHost = encodeURIComponent(parsedHost);
const hcPort = parsedPort || (pageIsSecure ? '443' : (parsedProto === 'https' ? '443' : '80'));
healthCheckUrl = `/healthcheck?host=${encodedHost}&port=${encodeURIComponent(hcPort)}&proto=${encodeURIComponent(parsedProto)}`;
} 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?host=${encodedHost}&port=${encodeURIComponent(hcPort)}&proto=${encodeURIComponent(desiredProto)}`;
// 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 = '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)
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 = '<p class="notes">Error loading services.xml — see console for details.</p>';
}
})();