Files
dashcaddy/status/js/core/grid.js
T
Krystie e99413150e
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
[glm-grade=B] fix: dead dashboard WS, corrupted error.log, auth-polling storm, re-auth freeze + CVE bumps
Adversarial audit 2026-08-16 (GLM-5.3 delegate, 2 rounds, 141 tool calls):

P0-1: Dashboard WebSocket (/api/v1/ws) dead on EVERY boot since DC-076.
server.js passed module exports (DependencyManager class, {AutoRestartManager}
namespace, SSLMonitor class) instead of createApp()'s live instances — first
.on() threw ERR_INVALID_ARG_TYPE, catch swallowed it. Fix: app.locals.ctx
exposed in src/app.js; server.js passes all 8 real EventEmitter instances.

P0-2: error.log corrupted since 2026-07-14. errorMiddleware called
logError(FILE, SIZE, path, err, meta) — 5 args into a 3-arg wrapper —
logging 'Error: 5242880' garbage every ~60s and DISCARDING the real error
object. Fix: correct 3-arg call + legacy-shape guard in logErrorWrapper +
~74 log.error sites swept to pass real error objects (AST-verified scope-
safe 71/71, 29/29 modules load clean).

P0-3: auth-polling storm (stranded grade=B commit never landed in prod):
401/403 behind TOTP gate hammered /api/v1/services/status + SSE reconnect
every 2-8s, with misleading direct-probe fallback marking services 'up'.
Fix landed + B-round MEDIUM follow-up: TOTP re-auth success now clears
_dcAuthLost, resumes SSE (new _sseResume clears the latch), and refreshes.

Also: eslintignore static-sites/ (33→0 errors); nodemailer 8→9.0.5 and
sharp 0.33→0.35.3 (3 high CVEs killed; jest green on new majors);
dockerode@5/uuid deferred (semver-major, Docker API surface).

Verification: 80/80 suites, 1837/1837 tests; ESLint 0 errors/743 warnings;
node --check all changed files; bundles rebuilt + SW cache bumped.

Judges: Codex quota-dead until Aug 19 (verified live) — GLM adversarial
delegate per operator directive 2026-08-07. Round 1: 98-call mechanical
verification (timed out pre-verdict). Round 2 (this grade): B, one MEDIUM
(re-auth freeze) — fixed in this commit as prescribed.
2026-08-16 04:18:07 -07:00

488 lines
19 KiB
JavaScript

// ========== GRID & STATUS HELPERS ==========
(function () {
function statusLabel(up) {
const i18n = window.DCI18n;
if (!i18n || !i18n.isLoaded()) return up ? 'ON' : 'OFF';
return i18n.t(up ? 'card.status.on' : 'card.status.off');
}
/* 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 = statusLabel(up);
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');
}
}
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
let refreshInFlight = null;
let refreshQueued = false;
// 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) {
const result = await response.json();
// Standard envelope: { success: true, services: [...], pagination?: {...} }
window.APPS = result.services || [];
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) {
const svc = window.APPS?.find(a => a.id === id);
if (svc?.url) return svc.url.startsWith('http') ? svc.url : 'https://' + svc.url;
if (svc?.isExternal && svc.externalUrl) return svc.externalUrl;
const dns = SITE.dnsServers?.[id];
if (dns) return 'http://' + dns.ip + ':' + (dns.port || 5380);
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.containerId) card.setAttribute('data-container-id', s.containerId);
if (s.category) card.setAttribute('data-category', s.category);
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);
}
// Add Category badge if service has one (colored pill with icon)
if (s.category) {
const cats = (typeof DC !== 'undefined' && DC.CATEGORIES) || window.DC_CATEGORIES || {};
const catInfo = cats[s.category] || {};
const catBadge = el('span', 'cat-badge', `${catInfo.icon || ''} ${s.category}`.trim());
catBadge.title = `Category: ${s.category}`;
catBadge.style.cssText = `margin-left: 6px; font-size: 0.65rem; padding: 1px 6px; border-radius: 999px; background: color-mix(in srgb, ${catInfo.color || '#7f8c8d'} 25%, transparent); color: ${catInfo.color || '#7f8c8d'}; border: 1px solid color-mix(in srgb, ${catInfo.color || '#7f8c8d'} 50%, transparent); white-space: nowrap; font-weight: 500;`;
nameSpan.appendChild(catBadge);
}
row.appendChild(el('span', 'spacer'));
const pill = el('span', 'badge off', 'OFF');
pill.id = 'badge-' + s.id;
pill.setAttribute('data-i18n-live-status', 'binary');
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);
// Terminal exec button (subtle — visible on hover)
const execBtn = el('button', 'exec-btn', '>_');
execBtn.title = 'Open terminal';
execBtn.onclick = (e) => {
e.stopPropagation();
if (window.openExecModal) window.openExecModal(s.containerId, s.name);
};
btnRow.appendChild(execBtn);
}
// 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 share button for all services except 'internet' (DC-058).
// Calls into the share-modal module registered on window. We don't
// hard-require the module — if share-modal.js was excluded from the
// bundle, the button still renders but clicking it surfaces a clear
// error toast instead of a silent no-op.
if (s.id !== 'internet') {
const shareBtn = el('button', 'share-btn', '🔗');
shareBtn.title = 'Share this service (Pro)';
shareBtn.onclick = (e) => {
e.stopPropagation();
if (typeof window.openShareModal === 'function') {
window.openShareModal(s);
} else if (typeof window.showNotification === 'function') {
window.showNotification('Share modal not loaded. Refresh the page.', 'error');
}
};
btnRow.appendChild(shareBtn);
}
// 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.setAttribute('data-i18n', 'action.open');
btn.onclick = () => window.open(serviceUrl(s.id), '_blank', 'noopener');
btnRow.appendChild(btn);
card.appendChild(btnRow);
card.style.transitionDelay = `${Math.min(i * 45, 270)}ms`;
root.appendChild(card);
}
// Cards can be created after the initial language pass. Translate their
// initial live state and controls immediately instead of waiting for poll.
if (window.DCI18n && window.DCI18n.isLoaded()) {
window.DCI18n.applyTranslations();
}
requestAnimationFrame(() => {
root.querySelectorAll('.card').forEach(card => card.classList.add('loaded'));
});
// Group recipe cards visually after grid is built
if (window.groupRecipeCards) requestAnimationFrame(() => window.groupRecipeCards());
// Refresh the service filter so the category dropdown reflects new services
if (window.refreshServiceFilter) window.refreshServiceFilter();
}
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 = statusLabel(up);
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() {
// Skip if auth has been lost (e.g. TOTP gate activated externally).
// The polling interval in init.js also checks this flag.
if (window._dcAuthLost) return;
if (refreshInFlight) {
refreshQueued = true;
return refreshInFlight;
}
function updateStamp(label, checkedAt = new Date()) {
const stamp = document.getElementById('stamp');
if (stamp) stamp.textContent = `${label}: ${new Date(checkedAt).toLocaleTimeString()}`;
}
function applyBatchResults(statuses) {
const dnsIds = Object.keys(SITE.dnsServers);
dnsIds.forEach((id) => {
const result = statuses[id];
if (result) setQuick(id, result.isUp, result.responseTime);
});
if (statuses.internet) {
setQuick('internet', statuses.internet.isUp, statuses.internet.responseTime);
}
window.APPS.forEach((service) => {
const result = statuses[service.id];
if (result) setBadge(service.id, result.isUp, result.responseTime);
});
}
async function refreshFallback() {
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);
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);
});
}
refreshInFlight = (async () => {
try {
const response = await fetch('/api/v1/services/status', { cache: 'no-store' });
if (response.status === 401 || response.status === 403) {
// Auth lost — stop the polling loop and close SSE; do NOT fall
// through to direct probes (those would misleadingly mark
// services as healthy since /probe/ treats 401/403 as "up").
window._dcAuthLost = true;
if (window._sseReconnect && window._sseClose) {
window._sseClose(); // tell SSE to stop reconnecting
}
updateStamp('auth required');
return; // skip the fallback entirely
}
if (!response.ok) {
throw new Error(`Status refresh failed (${response.status})`);
}
window._dcAuthLost = false; // auth working again
const data = await response.json();
applyBatchResults(data.statuses || {});
updateStamp('last check', data.checkedAt || new Date());
} catch (batchError) {
console.warn('Batched status refresh failed, falling back to direct probes:', batchError);
try {
await refreshFallback();
updateStamp('last check');
} catch (fallbackError) {
console.error('Dashboard refresh failed:', fallbackError);
updateStamp('last failed');
}
} finally {
refreshInFlight = null;
if (refreshQueued && !window._dcAuthLost) {
refreshQueued = false;
setTimeout(() => { window.refreshAll(); }, 0);
} else {
refreshQueued = false;
}
}
})();
return refreshInFlight;
}
// 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;
})();