Apps can now be served at domain.com/appname/ instead of requiring subdomain DNS records (appname.domain.com). Supports three subpath modes per template: native (URL base env var), strip (handle_path), and none (incompatible warning). Tested on Linux with deploy/removal lifecycle verified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
385 lines
14 KiB
JavaScript
385 lines
14 KiB
JavaScript
// ========== GRID & STATUS HELPERS ==========
|
|
(function () {
|
|
|
|
/* Enhanced status helpers with response time tracking */
|
|
function setQuick(id, up, responseTime = null) {
|
|
const dot = document.getElementById(id + '-dot');
|
|
const pill = document.getElementById(id + '-pill');
|
|
const timeEl = document.getElementById(id + '-time');
|
|
const card = document.querySelector(`[data-app="${id}"]`);
|
|
|
|
if (dot) {
|
|
dot.classList.toggle('ok', up);
|
|
dot.classList.toggle('bad', !up);
|
|
}
|
|
|
|
if (pill) {
|
|
pill.textContent = up ? 'ON' : 'OFF';
|
|
pill.classList.toggle('on', up);
|
|
pill.classList.toggle('off', !up);
|
|
}
|
|
|
|
if (timeEl && responseTime !== null) {
|
|
timeEl.textContent = up ? `${responseTime}ms` : 'timeout';
|
|
timeEl.className = `response-time ${getResponseTimeClass(responseTime, up)}`;
|
|
}
|
|
|
|
// Update card status for icon coloring
|
|
if (card) {
|
|
card.setAttribute('data-status', up ? 'on' : 'off');
|
|
}
|
|
|
|
// Internet card packet blink effect
|
|
if (id === 'internet' && dot && up) {
|
|
blinkInternetPacket(dot);
|
|
}
|
|
}
|
|
|
|
// Internet card packet activity blink
|
|
let internetBlinkInterval = null;
|
|
function blinkInternetPacket(dot) {
|
|
// Alternate between rx (green) and tx (blue) to simulate bidirectional traffic
|
|
const isRx = Math.random() > 0.5;
|
|
dot.classList.add(isRx ? 'packet-rx' : 'packet-tx');
|
|
setTimeout(() => {
|
|
dot.classList.remove('packet-rx', 'packet-tx');
|
|
}, 150);
|
|
}
|
|
|
|
// Continuous packet simulation for Internet card when online
|
|
function startInternetPacketSimulation() {
|
|
if (internetBlinkInterval) return;
|
|
internetBlinkInterval = setInterval(() => {
|
|
const dot = document.getElementById('internet-dot');
|
|
const card = document.querySelector('[data-app="internet"]');
|
|
if (dot && card && card.getAttribute('data-status') === 'on') {
|
|
blinkInternetPacket(dot);
|
|
}
|
|
}, 300 + Math.random() * 400); // Random interval 300-700ms
|
|
}
|
|
|
|
function stopInternetPacketSimulation() {
|
|
if (internetBlinkInterval) {
|
|
clearInterval(internetBlinkInterval);
|
|
internetBlinkInterval = null;
|
|
}
|
|
}
|
|
|
|
// Start simulation on page load
|
|
startInternetPacketSimulation();
|
|
|
|
function getResponseTimeClass(time, isUp) {
|
|
if (!isUp) return 'timeout';
|
|
if (time < 200) return 'excellent';
|
|
if (time < 500) return 'good';
|
|
if (time < 1000) return 'fair';
|
|
return 'slow';
|
|
}
|
|
|
|
async function checkServiceWithTiming(id) {
|
|
const startTime = performance.now();
|
|
try {
|
|
const r = await fetch('/probe/' + id, { cache: 'no-store' });
|
|
const endTime = performance.now();
|
|
const responseTime = Math.round(endTime - startTime);
|
|
const isUp = (r.status >= 200 && r.status < 400) || r.status === 401 || r.status === 403;
|
|
return { isUp, responseTime };
|
|
} catch {
|
|
const endTime = performance.now();
|
|
const responseTime = Math.round(endTime - startTime);
|
|
return { isUp: false, responseTime };
|
|
}
|
|
}
|
|
|
|
/* App grid - loaded from API */
|
|
window.APPS = []; // Use window.APPS as the main array
|
|
|
|
// Load services from API
|
|
async function loadServices() {
|
|
try {
|
|
if (window.SkeletonLoader) window.SkeletonLoader.show(6);
|
|
const response = await fetch('/api/v1/services', { cache: 'no-store' });
|
|
if (response.ok) {
|
|
window.APPS = await response.json();
|
|
if (window.SkeletonLoader) window.SkeletonLoader.hide();
|
|
} else {
|
|
console.error('Failed to load services:', response.status);
|
|
if (window.SkeletonLoader) window.SkeletonLoader.hide();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load services:', error);
|
|
if (window.SkeletonLoader) window.SkeletonLoader.hide();
|
|
}
|
|
}
|
|
|
|
function serviceUrl(id) { return buildServiceUrl(id); }
|
|
function el(tag, cls, txt) { const n = document.createElement(tag); if (cls) n.className = cls; if (txt) n.textContent = txt; return n; }
|
|
|
|
function buildGrid() {
|
|
const root = document.getElementById('cards'); root.innerHTML = '';
|
|
for (let i = 0; i < window.APPS.length; i++) {
|
|
const s = window.APPS[i];
|
|
if (s.id === 'ca') continue; // DashCA lives in top anchor row
|
|
const card = el('div', 'card');
|
|
card.setAttribute('data-app', s.id);
|
|
card.setAttribute('data-status', 'off'); // Initial status
|
|
if (s.recipeId) card.setAttribute('data-recipe-id', s.recipeId);
|
|
|
|
const dot = el('span', 'dot bad at-bl'); dot.id = 'dot-' + s.id + '-grid'; card.appendChild(dot);
|
|
|
|
const row = el('div', 'row');
|
|
const wrap = el('div', 'logo-wrap');
|
|
|
|
// Use reliable PNG images with automatic CDN fallback
|
|
const img = document.createElement('img');
|
|
img.src = s.logo;
|
|
img.alt = s.name;
|
|
img.className = 'logo-img';
|
|
img.onerror = function() {
|
|
// Try CDN fallback with multiple naming strategies
|
|
// Use id, appTemplate, or derive from name
|
|
let appId = s.id || s.appTemplate;
|
|
if (!appId && s.name) {
|
|
// Derive ID from name (lowercase, remove spaces)
|
|
appId = s.name.toLowerCase().replace(/\s+/g, '-');
|
|
}
|
|
|
|
if (appId) {
|
|
// Try different CDN URL formats
|
|
const cdnUrls = [
|
|
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${appId}.png`,
|
|
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${appId.toLowerCase()}.png`,
|
|
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${appId.replace(/-/g, '')}.png`,
|
|
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${s.name.toLowerCase().replace(/\s+/g, '-')}.png`
|
|
];
|
|
|
|
// Remove duplicates
|
|
const uniqueUrls = [...new Set(cdnUrls)];
|
|
|
|
// Find the next URL to try
|
|
const currentIndex = uniqueUrls.indexOf(this.src);
|
|
const nextIndex = currentIndex + 1;
|
|
|
|
if (nextIndex < uniqueUrls.length) {
|
|
this.src = uniqueUrls[nextIndex];
|
|
} else {
|
|
this.style.display = 'none';
|
|
}
|
|
} else {
|
|
this.style.display = 'none';
|
|
}
|
|
};
|
|
|
|
wrap.appendChild(img);
|
|
row.appendChild(wrap);
|
|
|
|
const nameSpan = el('span', 'name', s.name);
|
|
row.appendChild(nameSpan);
|
|
|
|
// Add Tailscale badge if service is protected
|
|
if (s.tailscaleOnly) {
|
|
const tsBadge = el('span', 'ts-badge', '🔐');
|
|
tsBadge.title = 'Tailscale-only access';
|
|
tsBadge.style.cssText = 'margin-left: 6px; font-size: 0.75rem; opacity: 0.8;';
|
|
nameSpan.appendChild(tsBadge);
|
|
}
|
|
|
|
row.appendChild(el('span', 'spacer'));
|
|
|
|
const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill);
|
|
|
|
// Update available badge (hidden by default, shown when update detected)
|
|
const updateBadge = el('span', 'update-available-badge', 'UPDATE');
|
|
updateBadge.id = 'update-badge-' + s.id;
|
|
updateBadge.title = 'Update available';
|
|
row.appendChild(updateBadge);
|
|
|
|
card.appendChild(row);
|
|
|
|
// Add response time row
|
|
const responseRow = el('div', 'response-row');
|
|
const timeSpan = el('span', 'response-time', '--'); timeSpan.id = 'time-' + s.id;
|
|
responseRow.appendChild(timeSpan);
|
|
card.appendChild(responseRow);
|
|
|
|
// Add health/uptime row
|
|
const healthRow = el('div', 'health-row');
|
|
healthRow.id = 'health-' + s.id;
|
|
const uptimeChip = el('span', 'uptime-chip', '--');
|
|
uptimeChip.id = 'uptime-' + s.id;
|
|
healthRow.appendChild(uptimeChip);
|
|
const uptimeMiniBar = document.createElement('div');
|
|
uptimeMiniBar.className = 'uptime-mini-bar';
|
|
const uptimeFill = document.createElement('div');
|
|
uptimeFill.className = 'fill';
|
|
uptimeFill.id = 'uptime-bar-' + s.id;
|
|
uptimeFill.style.width = '0%';
|
|
uptimeMiniBar.appendChild(uptimeFill);
|
|
healthRow.appendChild(uptimeMiniBar);
|
|
card.appendChild(healthRow);
|
|
|
|
const btnRow = el('div', 'btn-row');
|
|
|
|
// Add logs button for services with containerIds
|
|
if (s.containerId) {
|
|
const logsBtn = el('button', 'logs-btn', '📋');
|
|
logsBtn.title = 'View container logs';
|
|
logsBtn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
window.openContainerLogsModal(s.containerId, s.name);
|
|
};
|
|
btnRow.appendChild(logsBtn);
|
|
|
|
// Add update button for Docker containers
|
|
const updateBtn = el('button', 'update-btn', '⬆️');
|
|
updateBtn.title = 'Update container to latest version';
|
|
updateBtn.id = `update-btn-${s.id}`;
|
|
updateBtn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
window.updateContainer(s.containerId, s.name, s.id);
|
|
};
|
|
btnRow.appendChild(updateBtn);
|
|
}
|
|
|
|
// Add logs button for services with logPath (native apps)
|
|
if (s.logPath && !s.containerId) {
|
|
const logsBtn = el('button', 'logs-btn', '📋');
|
|
logsBtn.title = 'View application logs';
|
|
logsBtn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
window.openFileLogsModal(s.logPath, s.name);
|
|
};
|
|
btnRow.appendChild(logsBtn);
|
|
}
|
|
|
|
// Add credentials button for services that support auto-login
|
|
if (s.isExternal || s.appTemplate || s.url) {
|
|
const credsBtn = el('button', 'creds-btn', '🔑');
|
|
credsBtn.title = 'Auto-login credentials';
|
|
credsBtn.id = `creds-btn-${s.id}`;
|
|
credsBtn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
window.openServiceCredsModal(s);
|
|
};
|
|
btnRow.appendChild(credsBtn);
|
|
}
|
|
|
|
// Add options button for all services except 'internet'
|
|
if (s.id !== 'internet') {
|
|
const optBtn = el('button', 'options-btn', '⚙️');
|
|
optBtn.title = 'Edit service settings';
|
|
optBtn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
window.openServiceEditModal(s);
|
|
};
|
|
btnRow.appendChild(optBtn);
|
|
}
|
|
|
|
// Add delete button for all services except Internet
|
|
if (s.id !== 'internet') {
|
|
const delBtn = el('button', 'delete-btn', '🗑️');
|
|
delBtn.title = 'Delete this service';
|
|
delBtn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
window.deleteService(s.id, s.name);
|
|
};
|
|
btnRow.appendChild(delBtn);
|
|
}
|
|
|
|
const btn = el('button', null, 'Open');
|
|
btn.onclick = () => window.open(serviceUrl(s.id), '_blank', 'noopener');
|
|
btnRow.appendChild(btn);
|
|
card.appendChild(btnRow);
|
|
|
|
root.appendChild(card);
|
|
|
|
// Staggered loading animation
|
|
setTimeout(() => {
|
|
card.classList.add('loaded');
|
|
}, i * 100); // 100ms delay between each card
|
|
}
|
|
|
|
// Group recipe cards visually after grid is built
|
|
if (window.groupRecipeCards) setTimeout(window.groupRecipeCards, 50);
|
|
}
|
|
|
|
function setBadge(id, up, responseTime = null) {
|
|
const dot = document.getElementById('dot-' + id + '-grid');
|
|
const pill = document.getElementById('badge-' + id);
|
|
const timeEl = document.getElementById('time-' + id);
|
|
const card = document.querySelector(`[data-app="${id}"]`);
|
|
|
|
if (dot) {
|
|
dot.classList.toggle('ok', up);
|
|
dot.classList.toggle('bad', !up);
|
|
}
|
|
|
|
if (pill) {
|
|
pill.textContent = up ? 'ON' : 'OFF';
|
|
pill.classList.toggle('on', up);
|
|
pill.classList.toggle('off', !up);
|
|
}
|
|
|
|
if (timeEl && responseTime !== null) {
|
|
timeEl.textContent = up ? `${responseTime}ms` : 'timeout';
|
|
timeEl.className = `response-time ${getResponseTimeClass(responseTime, up)}`;
|
|
}
|
|
|
|
// Update card status for icon coloring
|
|
if (card) {
|
|
card.setAttribute('data-status', up ? 'on' : 'off');
|
|
}
|
|
}
|
|
|
|
async function refreshAll() {
|
|
// Check DNS servers dynamically (only those configured in SITE.dnsServers)
|
|
const dnsIds = Object.keys(SITE.dnsServers);
|
|
const topChecks = dnsIds.map(id => checkServiceWithTiming(id));
|
|
topChecks.push(checkServiceWithTiming('internet'));
|
|
const topResults = await Promise.all(topChecks);
|
|
|
|
dnsIds.forEach((id, i) => setQuick(id, topResults[i].isUp, topResults[i].responseTime));
|
|
const internetResult = topResults[topResults.length - 1];
|
|
setQuick('internet', internetResult.isUp, internetResult.responseTime);
|
|
|
|
// Check app services with timing
|
|
const appResults = await Promise.all(
|
|
window.APPS.map(async s => {
|
|
const result = await checkServiceWithTiming(s.id);
|
|
return { id: s.id, ...result };
|
|
})
|
|
);
|
|
|
|
appResults.forEach(result => {
|
|
setBadge(result.id, result.isUp, result.responseTime);
|
|
});
|
|
|
|
const stamp = document.getElementById('stamp');
|
|
if (stamp) stamp.textContent = 'last check: ' + new Date().toLocaleTimeString();
|
|
}
|
|
|
|
// DNS open buttons — use event delegation on .top container
|
|
document.querySelector('.top')?.addEventListener('click', (e) => {
|
|
const openBtn = e.target.closest('[id$="-open"]');
|
|
if (!openBtn) return;
|
|
const id = openBtn.id.replace('-open', '');
|
|
if (SITE.dnsServers[id]) window.open(serviceUrl(id), '_blank', 'noopener');
|
|
});
|
|
document.getElementById('ca-open')?.addEventListener('click', () => window.open(serviceUrl('ca'), '_blank', 'noopener'));
|
|
document.getElementById('creds-btn-ca')?.addEventListener('click', (e) => { e.stopPropagation(); const s = window.APPS.find(a => a.id === 'ca'); if (s && window.openServiceCredsModal) window.openServiceCredsModal(s); });
|
|
document.getElementById('options-btn-ca')?.addEventListener('click', (e) => { e.stopPropagation(); const s = window.APPS.find(a => a.id === 'ca'); if (s && window.openServiceEditModal) window.openServiceEditModal(s); });
|
|
document.getElementById('delete-btn-ca')?.addEventListener('click', (e) => { e.stopPropagation(); if (window.deleteService) window.deleteService('ca', 'DashCA'); });
|
|
|
|
// Window exports
|
|
window.loadServices = loadServices;
|
|
window.buildGrid = buildGrid;
|
|
window.refreshAll = refreshAll;
|
|
window.setQuick = setQuick;
|
|
window.setBadge = setBadge;
|
|
window.getResponseTimeClass = getResponseTimeClass;
|
|
window.checkServiceWithTiming = checkServiceWithTiming;
|
|
window.serviceUrl = serviceUrl;
|
|
window.el = el;
|
|
|
|
})();
|