Add JavaScript modules for enhanced functionality and dynamic content loading
This commit is contained in:
@@ -10,6 +10,8 @@ services:
|
|||||||
- ./services.xml:/usr/share/nginx/html/services.xml:ro
|
- ./services.xml:/usr/share/nginx/html/services.xml:ro
|
||||||
- ./styles.css:/usr/share/nginx/html/styles.css:ro
|
- ./styles.css:/usr/share/nginx/html/styles.css:ro
|
||||||
- ./logos:/usr/share/nginx/html/logos:ro
|
- ./logos:/usr/share/nginx/html/logos:ro
|
||||||
|
- ./js:/usr/share/nginx/html/js:ro
|
||||||
|
- ./README.md:/usr/share/nginx/html/README.md:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
|
|||||||
464
index.html
464
index.html
@@ -33,462 +33,12 @@
|
|||||||
<footer>
|
<footer>
|
||||||
<small>Generated: static homepage — served by nginx in a container. Edit and rebuild to update.</small>
|
<small>Generated: static homepage — served by nginx in a container. Edit and rebuild to update.</small>
|
||||||
</footer>
|
</footer>
|
||||||
<script>
|
|
||||||
// Galaxy background - exact CodePen recreation with moon and terrain
|
<!-- JavaScript modules -->
|
||||||
(function initGalaxy(){
|
<script src="/js/galaxy-background.js"></script>
|
||||||
console.clear();
|
<script src="/js/services-loader.js"></script>
|
||||||
|
<script src="/js/search.js"></script>
|
||||||
var ww = window.innerWidth,
|
<script src="/js/keyboard-nav.js"></script>
|
||||||
wh = window.innerHeight;
|
<script src="/js/readme-loader.js"></script>
|
||||||
|
|
||||||
var renderer = new THREE.WebGLRenderer({
|
|
||||||
antialias: true,
|
|
||||||
canvas: document.querySelector('#galaxy-canvas')
|
|
||||||
});
|
|
||||||
renderer.setSize(ww, wh);
|
|
||||||
renderer.setClearColor(0x001a2d);
|
|
||||||
|
|
||||||
var scene = new THREE.Scene();
|
|
||||||
scene.fog = new THREE.Fog(0x001a2d, 80, 140);
|
|
||||||
|
|
||||||
var camera = new THREE.PerspectiveCamera(45, ww/wh, 0.1, 200);
|
|
||||||
camera.position.x = 70;
|
|
||||||
camera.position.y = 30;
|
|
||||||
camera.position.z = 5;
|
|
||||||
camera.lookAt(new THREE.Vector3());
|
|
||||||
|
|
||||||
/* LIGHTS */
|
|
||||||
var moonLight = new THREE.PointLight(0xffffff, 2, 150);
|
|
||||||
scene.add(moonLight);
|
|
||||||
|
|
||||||
var moon;
|
|
||||||
function createMoon() {
|
|
||||||
var geometry = new THREE.SphereGeometry(8, 32, 32);
|
|
||||||
var material = new THREE.MeshPhongMaterial({
|
|
||||||
color: 0x26fdd9,
|
|
||||||
shininess: 15,
|
|
||||||
emissive: 0x2bb2e6,
|
|
||||||
emissiveIntensity: 0.8
|
|
||||||
});
|
|
||||||
moon = new THREE.Mesh(geometry, material);
|
|
||||||
moon.position.x = -9;
|
|
||||||
moon.position.z = -6.5;
|
|
||||||
moon.position.y = 1;
|
|
||||||
moon.rotation.y = -1;
|
|
||||||
scene.add(moon);
|
|
||||||
moonLight.position.copy(moon.position);
|
|
||||||
moonLight.position.y += 4;
|
|
||||||
var moonLight2 = new THREE.PointLight(0xffffff, 0.6, 150);
|
|
||||||
scene.add(moonLight2);
|
|
||||||
moonLight2.position.x += 20;
|
|
||||||
moonLight2.position.y -= 20;
|
|
||||||
moonLight2.position.z -= 25;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize simplex noise
|
|
||||||
var noise = new SimplexNoise();
|
|
||||||
|
|
||||||
function createTerrain() {
|
|
||||||
var geometry = new THREE.PlaneGeometry(150, 150, 120, 120);
|
|
||||||
var m = new THREE.Matrix4();
|
|
||||||
m.makeRotationX(Math.PI * -0.5);
|
|
||||||
geometry.applyMatrix4(m);
|
|
||||||
|
|
||||||
var positions = geometry.attributes.position;
|
|
||||||
for(var i = 0; i < positions.count; i++) {
|
|
||||||
var x = positions.getX(i);
|
|
||||||
var z = positions.getZ(i);
|
|
||||||
var ratio = noise.noise3D(x * 0.03, z * 0.03, 0);
|
|
||||||
positions.setY(i, ratio * 10);
|
|
||||||
}
|
|
||||||
positions.needsUpdate = true;
|
|
||||||
geometry.computeVertexNormals();
|
|
||||||
|
|
||||||
var material = new THREE.MeshPhongMaterial({
|
|
||||||
color: 0x198257,
|
|
||||||
emissive: 0x032f50
|
|
||||||
});
|
|
||||||
var plane = new THREE.Mesh(geometry, material);
|
|
||||||
scene.add(plane);
|
|
||||||
}
|
|
||||||
|
|
||||||
var stars = new THREE.Group();
|
|
||||||
scene.add(stars);
|
|
||||||
var starsLights = new THREE.Group();
|
|
||||||
scene.add(starsLights);
|
|
||||||
var starsAmount = 20;
|
|
||||||
|
|
||||||
function createStars() {
|
|
||||||
var geometry = new THREE.SphereGeometry(0.3, 16, 16);
|
|
||||||
var material = new THREE.MeshBasicMaterial({color: 0xffffff});
|
|
||||||
for(var i = 0; i < starsAmount; i++) {
|
|
||||||
var star = new THREE.Mesh(geometry, material);
|
|
||||||
star.position.x = (Math.random() - 0.5) * 150;
|
|
||||||
star.position.z = (Math.random() - 0.5) * 150;
|
|
||||||
var ratio = noise.noise3D(star.position.x * 0.03, star.position.z * 0.03, 0);
|
|
||||||
star.position.y = ratio * 10 + 0.3;
|
|
||||||
stars.add(star);
|
|
||||||
var velX = (Math.random() + 0.1) * 0.1 * (Math.random() < 0.5 ? -1 : 1);
|
|
||||||
var velY = (Math.random() + 0.1) * 0.1 * (Math.random() < 0.5 ? -1 : 1);
|
|
||||||
star.vel = new THREE.Vector2(velX, velY);
|
|
||||||
var starLight = new THREE.PointLight(0xffffff, 0.8, 3);
|
|
||||||
starLight.position.copy(star.position);
|
|
||||||
starLight.position.y += 0.5;
|
|
||||||
starsLights.add(starLight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStar(star, index) {
|
|
||||||
if(star.position.x < -75) {
|
|
||||||
star.position.x = 75;
|
|
||||||
}
|
|
||||||
if(star.position.x > 75) {
|
|
||||||
star.position.x = -75;
|
|
||||||
}
|
|
||||||
if(star.position.z < -75) {
|
|
||||||
star.position.z = 75;
|
|
||||||
}
|
|
||||||
if(star.position.z > 75) {
|
|
||||||
star.position.z = -75;
|
|
||||||
}
|
|
||||||
|
|
||||||
star.position.x += star.vel.x;
|
|
||||||
star.position.z += star.vel.y;
|
|
||||||
var ratio = noise.noise3D(star.position.x * 0.03, star.position.z * 0.03, 0);
|
|
||||||
star.position.y = ratio * 10 + 0.3;
|
|
||||||
|
|
||||||
starsLights.children[index].position.copy(star.position);
|
|
||||||
starsLights.children[index].position.y += 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(a) {
|
|
||||||
requestAnimationFrame(render);
|
|
||||||
for(var i = 0; i < starsAmount; i++) {
|
|
||||||
updateStar(stars.children[i], i);
|
|
||||||
}
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onResize() {
|
|
||||||
ww = window.innerWidth;
|
|
||||||
wh = window.innerHeight;
|
|
||||||
camera.aspect = ww / wh;
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
renderer.setSize(ww, wh);
|
|
||||||
}
|
|
||||||
|
|
||||||
createMoon();
|
|
||||||
createTerrain();
|
|
||||||
createStars();
|
|
||||||
requestAnimationFrame(render);
|
|
||||||
window.addEventListener('resize', onResize);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<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>';
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Fetch and render README.md
|
|
||||||
(async function loadReadme(){
|
|
||||||
try {
|
|
||||||
const response = await fetch('/README.md', {cache: 'no-cache'});
|
|
||||||
if(!response.ok) throw new Error('README not found');
|
|
||||||
const markdown = await response.text();
|
|
||||||
const html = marked.parse(markdown);
|
|
||||||
document.getElementById('readme-content').innerHTML = html;
|
|
||||||
} catch(err) {
|
|
||||||
console.error('Error loading README:', err);
|
|
||||||
document.getElementById('readme-content').innerHTML = '<p>Documentation unavailable.</p>';
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
149
js/galaxy-background.js
Normal file
149
js/galaxy-background.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
// Galaxy background - moonlit terrain with floating stars
|
||||||
|
(function initGalaxy(){
|
||||||
|
console.clear();
|
||||||
|
|
||||||
|
var ww = window.innerWidth,
|
||||||
|
wh = window.innerHeight;
|
||||||
|
|
||||||
|
var renderer = new THREE.WebGLRenderer({
|
||||||
|
antialias: true,
|
||||||
|
canvas: document.querySelector('#galaxy-canvas')
|
||||||
|
});
|
||||||
|
renderer.setSize(ww, wh);
|
||||||
|
renderer.setClearColor(0x001a2d);
|
||||||
|
|
||||||
|
var scene = new THREE.Scene();
|
||||||
|
scene.fog = new THREE.Fog(0x001a2d, 80, 140);
|
||||||
|
|
||||||
|
var camera = new THREE.PerspectiveCamera(45, ww/wh, 0.1, 200);
|
||||||
|
camera.position.x = 70;
|
||||||
|
camera.position.y = 30;
|
||||||
|
camera.position.z = 5;
|
||||||
|
camera.lookAt(new THREE.Vector3());
|
||||||
|
|
||||||
|
/* LIGHTS */
|
||||||
|
var moonLight = new THREE.PointLight(0xffffff, 2, 150);
|
||||||
|
scene.add(moonLight);
|
||||||
|
|
||||||
|
var moon;
|
||||||
|
function createMoon() {
|
||||||
|
var geometry = new THREE.SphereGeometry(8, 32, 32);
|
||||||
|
var material = new THREE.MeshPhongMaterial({
|
||||||
|
color: 0x26fdd9,
|
||||||
|
shininess: 15,
|
||||||
|
emissive: 0x2bb2e6,
|
||||||
|
emissiveIntensity: 0.8
|
||||||
|
});
|
||||||
|
moon = new THREE.Mesh(geometry, material);
|
||||||
|
moon.position.x = -9;
|
||||||
|
moon.position.z = -6.5;
|
||||||
|
moon.position.y = 1;
|
||||||
|
moon.rotation.y = -1;
|
||||||
|
scene.add(moon);
|
||||||
|
moonLight.position.copy(moon.position);
|
||||||
|
moonLight.position.y += 4;
|
||||||
|
var moonLight2 = new THREE.PointLight(0xffffff, 0.6, 150);
|
||||||
|
scene.add(moonLight2);
|
||||||
|
moonLight2.position.x += 20;
|
||||||
|
moonLight2.position.y -= 20;
|
||||||
|
moonLight2.position.z -= 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize simplex noise
|
||||||
|
var noise = new SimplexNoise();
|
||||||
|
|
||||||
|
function createTerrain() {
|
||||||
|
var geometry = new THREE.PlaneGeometry(150, 150, 120, 120);
|
||||||
|
var m = new THREE.Matrix4();
|
||||||
|
m.makeRotationX(Math.PI * -0.5);
|
||||||
|
geometry.applyMatrix4(m);
|
||||||
|
|
||||||
|
var positions = geometry.attributes.position;
|
||||||
|
for(var i = 0; i < positions.count; i++) {
|
||||||
|
var x = positions.getX(i);
|
||||||
|
var z = positions.getZ(i);
|
||||||
|
var ratio = noise.noise3D(x * 0.03, z * 0.03, 0);
|
||||||
|
positions.setY(i, ratio * 10);
|
||||||
|
}
|
||||||
|
positions.needsUpdate = true;
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
|
||||||
|
var material = new THREE.MeshPhongMaterial({
|
||||||
|
color: 0x198257,
|
||||||
|
emissive: 0x032f50
|
||||||
|
});
|
||||||
|
var plane = new THREE.Mesh(geometry, material);
|
||||||
|
scene.add(plane);
|
||||||
|
}
|
||||||
|
|
||||||
|
var stars = new THREE.Group();
|
||||||
|
scene.add(stars);
|
||||||
|
var starsLights = new THREE.Group();
|
||||||
|
scene.add(starsLights);
|
||||||
|
var starsAmount = 20;
|
||||||
|
|
||||||
|
function createStars() {
|
||||||
|
var geometry = new THREE.SphereGeometry(0.3, 16, 16);
|
||||||
|
var material = new THREE.MeshBasicMaterial({color: 0xffffff});
|
||||||
|
for(var i = 0; i < starsAmount; i++) {
|
||||||
|
var star = new THREE.Mesh(geometry, material);
|
||||||
|
star.position.x = (Math.random() - 0.5) * 150;
|
||||||
|
star.position.z = (Math.random() - 0.5) * 150;
|
||||||
|
var ratio = noise.noise3D(star.position.x * 0.03, star.position.z * 0.03, 0);
|
||||||
|
star.position.y = ratio * 10 + 0.3;
|
||||||
|
stars.add(star);
|
||||||
|
var velX = (Math.random() + 0.1) * 0.1 * (Math.random() < 0.5 ? -1 : 1);
|
||||||
|
var velY = (Math.random() + 0.1) * 0.1 * (Math.random() < 0.5 ? -1 : 1);
|
||||||
|
star.vel = new THREE.Vector2(velX, velY);
|
||||||
|
var starLight = new THREE.PointLight(0xffffff, 0.8, 3);
|
||||||
|
starLight.position.copy(star.position);
|
||||||
|
starLight.position.y += 0.5;
|
||||||
|
starsLights.add(starLight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStar(star, index) {
|
||||||
|
if(star.position.x < -75) {
|
||||||
|
star.position.x = 75;
|
||||||
|
}
|
||||||
|
if(star.position.x > 75) {
|
||||||
|
star.position.x = -75;
|
||||||
|
}
|
||||||
|
if(star.position.z < -75) {
|
||||||
|
star.position.z = 75;
|
||||||
|
}
|
||||||
|
if(star.position.z > 75) {
|
||||||
|
star.position.z = -75;
|
||||||
|
}
|
||||||
|
|
||||||
|
star.position.x += star.vel.x;
|
||||||
|
star.position.z += star.vel.y;
|
||||||
|
var ratio = noise.noise3D(star.position.x * 0.03, star.position.z * 0.03, 0);
|
||||||
|
star.position.y = ratio * 10 + 0.3;
|
||||||
|
|
||||||
|
starsLights.children[index].position.copy(star.position);
|
||||||
|
starsLights.children[index].position.y += 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(a) {
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
for(var i = 0; i < starsAmount; i++) {
|
||||||
|
updateStar(stars.children[i], i);
|
||||||
|
}
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize() {
|
||||||
|
ww = window.innerWidth;
|
||||||
|
wh = window.innerHeight;
|
||||||
|
camera.aspect = ww / wh;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(ww, wh);
|
||||||
|
}
|
||||||
|
|
||||||
|
createMoon();
|
||||||
|
createTerrain();
|
||||||
|
createStars();
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
})();
|
||||||
54
js/keyboard-nav.js
Normal file
54
js/keyboard-nav.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Keyboard navigation for service cards
|
||||||
|
(function initKeyboardNav(){
|
||||||
|
let selectedIndex = -1;
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
|
||||||
|
// Wait for services to load
|
||||||
|
const checkServicesLoaded = setInterval(() => {
|
||||||
|
if(window.servicesData){
|
||||||
|
clearInterval(checkServicesLoaded);
|
||||||
|
setupKeyboardNav();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
function setupKeyboardNav(){
|
||||||
|
const { allServices } = window.servicesData;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
13
js/readme-loader.js
Normal file
13
js/readme-loader.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// Fetch and render README.md
|
||||||
|
(async function loadReadme(){
|
||||||
|
try {
|
||||||
|
const response = await fetch('/README.md', {cache: 'no-cache'});
|
||||||
|
if(!response.ok) throw new Error('README not found');
|
||||||
|
const markdown = await response.text();
|
||||||
|
const html = marked.parse(markdown);
|
||||||
|
document.getElementById('readme-content').innerHTML = html;
|
||||||
|
} catch(err) {
|
||||||
|
console.error('Error loading README:', err);
|
||||||
|
document.getElementById('readme-content').innerHTML = '<p>Documentation unavailable.</p>';
|
||||||
|
}
|
||||||
|
})();
|
||||||
58
js/search.js
Normal file
58
js/search.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// Search functionality
|
||||||
|
(function initSearch(){
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
|
||||||
|
// Wait for services to load
|
||||||
|
const checkServicesLoaded = setInterval(() => {
|
||||||
|
if(window.servicesData){
|
||||||
|
clearInterval(checkServicesLoaded);
|
||||||
|
setupSearch();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
function setupSearch(){
|
||||||
|
const { allServices, container } = window.servicesData;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
202
js/services-loader.js
Normal file
202
js/services-loader.js
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
// 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.
|
||||||
|
let href = '';
|
||||||
|
if(hostAttr){
|
||||||
|
if(/^https?:\/\//i.test(hostAttr)){
|
||||||
|
href = hostAttr;
|
||||||
|
} else {
|
||||||
|
const hasPortInHost = /:\d+$/.test(hostAttr);
|
||||||
|
if(hasPortInHost){
|
||||||
|
href = `${proto}://${hostAttr}`;
|
||||||
|
} else {
|
||||||
|
href = `https://${hostAttr}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
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>';
|
||||||
|
}
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user