Files
Homepage/index.html

329 lines
13 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Services Homepage</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header>
<h1>My Services</h1>
<p class="subtitle">Quick links to the commonly used containers on this host</p>
<div class="search-container">
<input type="text" id="search-input" placeholder="🔍 Search services..." />
</div>
</header>
<main>
<section id="services-container">
<!-- Service groups will be populated dynamically from /services.xml -->
</section>
<section class="notes">
<h2>Notes</h2>
<ul>
<li>If any service is behind a reverse proxy or uses host networking, the path/host may differ.</li>
<li>Edit <code>services.xml</code> in this repo to add/remove links.</li>
</ul>
</section>
</main>
<footer>
<small>Generated: static homepage — served by nginx in a container. Edit and rebuild to update.</small>
</footer>
<script>
// 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);
}
// 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);
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);
grid.appendChild(card);
});
container.appendChild(grid);
}
// Function to create a service card
function createServiceCard(s, host, allServices) {
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.
// Rules:
// - If hostAttr starts with http(s)://, use it as-is.
// - If hostAttr is a hostname (no protocol) and does NOT include a port, prefer HTTPS and DO NOT append the service port.
// - If hostAttr includes a port (example.com:8096), use it as provided (no extra port appended).
// - If no hostAttr, fall back to current page hostname and use the service port.
let href = '';
if(hostAttr){
if(/^https?:\/\//i.test(hostAttr)){
href = hostAttr;
} else {
const hasPortInHost = /:\d+$/.test(hostAttr);
if(hasPortInHost){
// host includes port, use the host as provided with the proto
href = `${proto}://${hostAttr}`;
} else {
// Hostname without port: prefer HTTPS and do NOT append numeric port.
href = `https://${hostAttr}`;
}
}
} else {
// Fallback to current page hostname and include port if non-standard
let portPart = '';
if(port && !((proto==='http'&&port==='80')||(proto==='https'&&port==='443'))){ portPart = ':'+port; }
href = `${proto}://${host}${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 && href){
// Show checking indicator initially
a.appendChild(statusDot);
// Perform health check
(async function performHealthCheck(){
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
const response = await fetch(href, {
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
// We consider this as "online"
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;
}
// Search functionality
searchInput.addEventListener('input', (e) => {
const searchTerm = e.target.value.toLowerCase().trim();
let visibleCount = 0;
// Also hide/show group headers
const groupSections = container.querySelectorAll('.service-group');
allServices.forEach(card => {
const serviceName = card.dataset.serviceName;
if(serviceName.includes(searchTerm)){
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
// Hide empty groups
groupSections.forEach(section => {
const visibleCards = section.querySelectorAll('.card:not([style*="display: none"])');
section.style.display = visibleCards.length > 0 ? '' : 'none';
});
// Show message if no results
const existingMsg = container.querySelector('.no-results');
if(existingMsg) existingMsg.remove();
if(visibleCount === 0 && searchTerm !== ''){
const msg = document.createElement('p');
msg.className = 'notes no-results';
msg.textContent = `No services found matching "${e.target.value}"`;
container.appendChild(msg);
}
});
// Focus search on '/' key
document.addEventListener('keydown', (e) => {
if(e.key === '/' && document.activeElement !== searchInput){
e.preventDefault();
searchInput.focus();
}
});
// Keyboard navigation for service cards
let selectedIndex = -1;
const selectCard = (index) => {
const visibleCards = allServices.filter(card => card.style.display !== 'none');
if(visibleCards.length === 0) return;
// Remove previous selection
visibleCards.forEach(card => card.classList.remove('selected'));
// Update index
if(index < 0) index = visibleCards.length - 1;
if(index >= visibleCards.length) index = 0;
selectedIndex = index;
// Add selection
visibleCards[selectedIndex].classList.add('selected');
visibleCards[selectedIndex].scrollIntoView({behavior: 'smooth', block: 'nearest'});
};
document.addEventListener('keydown', (e) => {
if(document.activeElement === searchInput) return;
const visibleCards = allServices.filter(card => card.style.display !== 'none');
if(e.key === 'ArrowRight' || e.key === 'ArrowDown'){
e.preventDefault();
selectCard(selectedIndex + 1);
} else if(e.key === 'ArrowLeft' || e.key === 'ArrowUp'){
e.preventDefault();
selectCard(selectedIndex - 1);
} else if(e.key === 'Enter' && selectedIndex >= 0){
e.preventDefault();
visibleCards[selectedIndex].click();
} else if(e.key === 'Escape'){
visibleCards.forEach(card => card.classList.remove('selected'));
selectedIndex = -1;
}
});
}catch(err){
console.error(err);
container.innerHTML = '<p class="notes">Error loading services.xml — see console for details.</p>';
}
})();
</script>
</body>
</html>