268 lines
11 KiB
JavaScript
268 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 = '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 = '<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 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 = '<p class="notes">Error loading services.xml — see console for details.</p>';
|
|
}
|
|
})();
|