showDashboard now explicitly resets the status panel to display:none and clears its innerHTML on every call. This prevents a stale display value from a previous session making toggleStatusPanel think it is already open (causing it to hide on the first click instead of showing). Also cancel the status refresh timer on logout.
785 lines
26 KiB
JavaScript
785 lines
26 KiB
JavaScript
let currentUser = null;
|
|
let downloads = [];
|
|
let isAdmin = false;
|
|
let showAll = false;
|
|
let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
|
|
const SPLASH_MIN_MS = 1200; // minimum splash display time
|
|
|
|
// SSE stream state
|
|
let sseSource = null;
|
|
let sseReconnectTimer = null;
|
|
const SSE_RECONNECT_MS = 3000; // browser already retries, but we add explicit backoff too
|
|
|
|
// Apply saved theme immediately (before DOMContentLoaded to avoid flash)
|
|
(function() {
|
|
const saved = localStorage.getItem('sofarr-theme') || 'light';
|
|
document.documentElement.setAttribute('data-theme', saved);
|
|
})();
|
|
|
|
// Check authentication on load
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
checkAuthentication();
|
|
initThemeSwitcher();
|
|
|
|
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
|
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
|
document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle);
|
|
document.getElementById('status-btn').addEventListener('click', toggleStatusPanel);
|
|
});
|
|
|
|
function initThemeSwitcher() {
|
|
const saved = localStorage.getItem('sofarr-theme') || 'light';
|
|
document.querySelectorAll('.theme-btn').forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.theme === saved);
|
|
btn.addEventListener('click', () => setTheme(btn.dataset.theme));
|
|
});
|
|
}
|
|
|
|
function setTheme(theme) {
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
localStorage.setItem('sofarr-theme', theme);
|
|
document.querySelectorAll('.theme-btn').forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.theme === theme);
|
|
});
|
|
}
|
|
|
|
// --- SSE connection management ---
|
|
|
|
function startSSE() {
|
|
stopSSE();
|
|
const params = showAll ? '?showAll=true' : '';
|
|
const source = new EventSource('/api/dashboard/stream' + params);
|
|
sseSource = source;
|
|
|
|
let firstMessage = true;
|
|
source.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
currentUser = data.user;
|
|
isAdmin = !!data.isAdmin;
|
|
downloads = data.downloads;
|
|
document.getElementById('currentUser').textContent = currentUser || '-';
|
|
renderDownloads();
|
|
hideError();
|
|
if (firstMessage) { firstMessage = false; hideLoading(); }
|
|
} catch (err) {
|
|
console.error('[SSE] Failed to parse message:', err);
|
|
}
|
|
};
|
|
|
|
source.onerror = () => {
|
|
// EventSource retries automatically; we just log and show a reconnecting indicator
|
|
console.warn('[SSE] Connection lost, browser will retry...');
|
|
};
|
|
|
|
console.log('[SSE] Stream connected');
|
|
}
|
|
|
|
function stopSSE() {
|
|
if (sseReconnectTimer) { clearTimeout(sseReconnectTimer); sseReconnectTimer = null; }
|
|
if (sseSource) {
|
|
sseSource.close();
|
|
sseSource = null;
|
|
console.log('[SSE] Stream closed');
|
|
}
|
|
}
|
|
|
|
function handleShowAllToggle(e) {
|
|
showAll = e.target.checked;
|
|
// Re-open stream with updated showAll param
|
|
startSSE();
|
|
}
|
|
|
|
function fadeOutLogin() {
|
|
return new Promise(resolve => {
|
|
const login = document.getElementById('login-container');
|
|
login.classList.add('fade-out');
|
|
login.addEventListener('transitionend', () => {
|
|
login.style.display = 'none';
|
|
login.classList.remove('fade-out');
|
|
resolve();
|
|
}, { once: true });
|
|
});
|
|
}
|
|
|
|
function showSplash() {
|
|
const splash = document.getElementById('splash-screen');
|
|
splash.style.display = 'flex';
|
|
splash.style.opacity = '1';
|
|
splash.classList.remove('fade-out');
|
|
}
|
|
|
|
function dismissSplash(startTime) {
|
|
return new Promise(resolve => {
|
|
const elapsed = Date.now() - (startTime || 0);
|
|
const remaining = Math.max(0, SPLASH_MIN_MS - elapsed);
|
|
setTimeout(() => {
|
|
const splash = document.getElementById('splash-screen');
|
|
splash.classList.add('fade-out');
|
|
// Fallback: resolve after transition duration + buffer in case
|
|
// transitionend never fires (e.g. display was toggled in same frame)
|
|
const TRANSITION_MS = 400;
|
|
const fallback = setTimeout(() => {
|
|
splash.style.display = 'none';
|
|
resolve();
|
|
}, TRANSITION_MS + 100);
|
|
splash.addEventListener('transitionend', () => {
|
|
clearTimeout(fallback);
|
|
splash.style.display = 'none';
|
|
resolve();
|
|
}, { once: true });
|
|
}, remaining);
|
|
});
|
|
}
|
|
|
|
async function checkAuthentication() {
|
|
const splashStart = Date.now();
|
|
try {
|
|
// Fetch both auth state and a fresh CSRF token in parallel
|
|
const [meRes, csrfRes] = await Promise.all([
|
|
fetch('/api/auth/me'),
|
|
fetch('/api/auth/csrf')
|
|
]);
|
|
const data = await meRes.json();
|
|
const csrfData = await csrfRes.json();
|
|
if (csrfData.csrfToken) csrfToken = csrfData.csrfToken;
|
|
|
|
if (data.authenticated) {
|
|
currentUser = data.user;
|
|
isAdmin = !!data.user.isAdmin;
|
|
showDashboard();
|
|
showLoading();
|
|
startSSE();
|
|
await dismissSplash(splashStart);
|
|
} else {
|
|
await dismissSplash(splashStart);
|
|
showLogin();
|
|
}
|
|
} catch (err) {
|
|
console.error('Authentication check failed:', err);
|
|
await dismissSplash(splashStart);
|
|
showLogin();
|
|
}
|
|
}
|
|
|
|
async function handleLogin(e) {
|
|
e.preventDefault();
|
|
|
|
const username = document.getElementById('username').value;
|
|
const password = document.getElementById('password').value;
|
|
const rememberMe = document.getElementById('remember-me').checked;
|
|
|
|
try {
|
|
const response = await fetch('/api/auth/login', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ username, password, rememberMe })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
currentUser = data.user;
|
|
isAdmin = !!data.user.isAdmin;
|
|
// Store CSRF token returned by login for use in subsequent requests
|
|
if (data.csrfToken) csrfToken = data.csrfToken;
|
|
// Fade out login, then show splash while opening SSE stream.
|
|
// requestAnimationFrame ensures the browser paints the splash at
|
|
// opacity:1 before dismissSplash adds fade-out, so the CSS
|
|
// transition fires and transitionend is guaranteed.
|
|
await fadeOutLogin();
|
|
showSplash();
|
|
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
showDashboard();
|
|
showLoading();
|
|
const splashStart = Date.now();
|
|
startSSE();
|
|
await dismissSplash(splashStart);
|
|
} else {
|
|
showLoginError(data.error || 'Login failed');
|
|
}
|
|
} catch (err) {
|
|
showLoginError('Login failed. Please try again.');
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
async function handleLogout() {
|
|
try {
|
|
stopSSE();
|
|
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
|
await fetch('/api/auth/logout', {
|
|
method: 'POST',
|
|
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {}
|
|
});
|
|
currentUser = null;
|
|
csrfToken = null;
|
|
downloads = [];
|
|
showLogin();
|
|
} catch (err) {
|
|
console.error('Logout failed:', err);
|
|
}
|
|
}
|
|
|
|
function showLogin() {
|
|
document.getElementById('login-container').style.display = 'flex';
|
|
document.getElementById('dashboard-container').style.display = 'none';
|
|
hideLoginError();
|
|
}
|
|
|
|
function showDashboard() {
|
|
document.getElementById('login-container').style.display = 'none';
|
|
document.getElementById('dashboard-container').style.display = 'block';
|
|
document.getElementById('currentUser').textContent = currentUser.name || '-';
|
|
// Always start with status panel hidden (guards against stale display value on re-login)
|
|
const sp = document.getElementById('status-panel');
|
|
sp.style.display = 'none';
|
|
sp.innerHTML = '';
|
|
document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none';
|
|
}
|
|
|
|
function showLoginError(message) {
|
|
const errorDiv = document.getElementById('login-error');
|
|
errorDiv.textContent = message;
|
|
errorDiv.style.display = 'block';
|
|
}
|
|
|
|
function hideLoginError() {
|
|
const errorDiv = document.getElementById('login-error');
|
|
errorDiv.style.display = 'none';
|
|
}
|
|
|
|
// fetchUserDownloads is kept for the showAll toggle re-connection case
|
|
// but the primary data path is now via SSE (startSSE / EventSource).
|
|
|
|
function renderDownloads() {
|
|
const downloadsList = document.getElementById('downloads-list');
|
|
const noDownloads = document.getElementById('no-downloads');
|
|
|
|
if (downloads.length === 0) {
|
|
noDownloads.style.display = 'block';
|
|
downloadsList.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
noDownloads.style.display = 'none';
|
|
|
|
// Get existing cards
|
|
const existingCards = new Map();
|
|
downloadsList.querySelectorAll('.download-card').forEach(card => {
|
|
existingCards.set(card.dataset.id, card);
|
|
});
|
|
|
|
// Track which downloads we've processed
|
|
const processedIds = new Set();
|
|
|
|
downloads.forEach(download => {
|
|
const id = download.title;
|
|
processedIds.add(id);
|
|
|
|
const existingCard = existingCards.get(id);
|
|
if (existingCard) {
|
|
// Update existing card
|
|
updateDownloadCard(existingCard, download);
|
|
} else {
|
|
// Create new card
|
|
const card = createDownloadCard(download);
|
|
downloadsList.appendChild(card);
|
|
}
|
|
});
|
|
|
|
// Remove cards for downloads that no longer exist
|
|
existingCards.forEach((card, id) => {
|
|
if (!processedIds.has(id)) {
|
|
card.remove();
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateDownloadCard(card, download) {
|
|
// Update status
|
|
const statusEl = card.querySelector('.download-status');
|
|
if (statusEl && statusEl.textContent !== download.status) {
|
|
statusEl.textContent = download.status;
|
|
statusEl.className = `download-status ${download.status}`;
|
|
}
|
|
|
|
// Update progress bar and missing pieces
|
|
const progressContainer = card.querySelector('.progress-container');
|
|
if (progressContainer && download.progress !== undefined) {
|
|
const progressBar = progressContainer.querySelector('.progress-bar');
|
|
const progressText = progressContainer.querySelector('.progress-text');
|
|
const missingText = progressContainer.querySelector('.missing-text');
|
|
|
|
if (progressBar) {
|
|
const downloaded = progressBar.querySelector('.downloaded');
|
|
if (downloaded) {
|
|
downloaded.style.width = download.progress + '%';
|
|
}
|
|
}
|
|
|
|
if (progressText) {
|
|
progressText.textContent = download.progress + '%';
|
|
}
|
|
|
|
if (missingText) {
|
|
const totalMb = parseFloat(download.mb) || parseFloat(download.size);
|
|
const missingMb = parseFloat(download.mbmissing) || 0;
|
|
if (missingMb > 0 && totalMb > 0) {
|
|
missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`;
|
|
} else {
|
|
missingText.textContent = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update speed
|
|
const speedEl = card.querySelector('.detail-item[data-label="Speed"] .detail-value');
|
|
if (speedEl && download.speed !== undefined) {
|
|
speedEl.textContent = download.speed;
|
|
}
|
|
|
|
// Update ETA
|
|
const etaEl = card.querySelector('.detail-item[data-label="ETA"] .detail-value');
|
|
if (etaEl && download.eta !== undefined) {
|
|
etaEl.textContent = download.eta;
|
|
}
|
|
|
|
// Update qBittorrent-specific fields
|
|
if (download.qbittorrent) {
|
|
const seedsEl = card.querySelector('.detail-item[data-label="Seeds"] .detail-value');
|
|
if (seedsEl && download.seeds !== undefined) {
|
|
seedsEl.textContent = download.seeds;
|
|
}
|
|
|
|
const peersEl = card.querySelector('.detail-item[data-label="Peers"] .detail-value');
|
|
if (peersEl && download.peers !== undefined) {
|
|
peersEl.textContent = download.peers;
|
|
}
|
|
|
|
const availabilityEl = card.querySelector('.detail-item[data-label="Availability"] .detail-value');
|
|
if (availabilityEl && download.availability !== undefined) {
|
|
availabilityEl.textContent = `${download.availability}%`;
|
|
}
|
|
}
|
|
}
|
|
|
|
function createDownloadCard(download) {
|
|
const card = document.createElement('div');
|
|
card.className = `download-card ${download.type}`;
|
|
card.dataset.id = download.title;
|
|
|
|
// Cover art
|
|
if (download.coverArt) {
|
|
const coverDiv = document.createElement('div');
|
|
coverDiv.className = 'download-cover';
|
|
const coverImg = document.createElement('img');
|
|
// Proxy cover art through the server so the CSP img-src 'self' rule
|
|
// is satisfied (external poster URLs would be blocked otherwise).
|
|
coverImg.src = download.coverArt
|
|
? '/api/dashboard/cover-art?url=' + encodeURIComponent(download.coverArt)
|
|
: '';
|
|
coverImg.alt = download.movieName || download.seriesName || download.title;
|
|
coverImg.loading = 'lazy';
|
|
coverDiv.appendChild(coverImg);
|
|
card.appendChild(coverDiv);
|
|
}
|
|
|
|
// Info wrapper
|
|
const infoDiv = document.createElement('div');
|
|
infoDiv.className = 'download-info';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'download-header';
|
|
|
|
const type = document.createElement('span');
|
|
type.className = `download-type ${download.type}`;
|
|
if (download.type === 'series') {
|
|
type.textContent = '📺 Series';
|
|
} else if (download.type === 'movie') {
|
|
type.textContent = '🎬 Movie';
|
|
} else if (download.type === 'torrent') {
|
|
const instName = download.instanceName ? ` (${download.instanceName})` : '';
|
|
type.textContent = `📥 Torrent${instName}`;
|
|
} else {
|
|
type.textContent = download.type;
|
|
}
|
|
|
|
const status = document.createElement('span');
|
|
status.className = `download-status ${download.status}`;
|
|
status.textContent = download.status;
|
|
|
|
header.appendChild(type);
|
|
header.appendChild(status);
|
|
|
|
if (download.importIssues && download.importIssues.length > 0) {
|
|
const issueBadge = document.createElement('span');
|
|
issueBadge.className = 'import-issue-badge';
|
|
issueBadge.textContent = 'Import Pending';
|
|
issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n'));
|
|
header.appendChild(issueBadge);
|
|
}
|
|
|
|
const title = document.createElement('h3');
|
|
title.className = 'download-title';
|
|
title.textContent = download.title;
|
|
|
|
infoDiv.appendChild(header);
|
|
infoDiv.appendChild(title);
|
|
|
|
if (download.seriesName) {
|
|
const series = document.createElement('p');
|
|
series.className = 'download-series';
|
|
if (isAdmin && download.arrLink) {
|
|
series.innerHTML = 'Series: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.seriesName) + '</a>';
|
|
} else {
|
|
series.textContent = `Series: ${download.seriesName}`;
|
|
}
|
|
infoDiv.appendChild(series);
|
|
}
|
|
|
|
if (download.movieName) {
|
|
const movie = document.createElement('p');
|
|
movie.className = 'download-movie';
|
|
if (isAdmin && download.arrLink) {
|
|
movie.innerHTML = 'Movie: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.movieName) + '</a>';
|
|
} else {
|
|
movie.textContent = `Movie: ${download.movieName}`;
|
|
}
|
|
infoDiv.appendChild(movie);
|
|
}
|
|
|
|
if (showAll && download.tagBadges && download.tagBadges.length > 0) {
|
|
// In showAll mode: render all tags classified by whether they match an Emby user.
|
|
// Unmatched (no known Emby user) → amber, leftmost.
|
|
// Matched → show Emby display name in accent colour, rightmost.
|
|
const unmatched = download.tagBadges.filter(b => !b.matchedUser);
|
|
const matched = download.tagBadges.filter(b => b.matchedUser);
|
|
for (const b of unmatched) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'download-user-badge unmatched';
|
|
badge.textContent = b.label;
|
|
header.appendChild(badge);
|
|
}
|
|
for (const b of matched) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'download-user-badge';
|
|
badge.textContent = b.matchedUser;
|
|
header.appendChild(badge);
|
|
}
|
|
} else if (download.matchedUserTag) {
|
|
// Normal (non-showAll) view: show only the current user's matched tag
|
|
const matchedBadge = document.createElement('span');
|
|
matchedBadge.className = 'download-user-badge';
|
|
matchedBadge.textContent = download.matchedUserTag;
|
|
header.appendChild(matchedBadge);
|
|
}
|
|
|
|
const details = document.createElement('div');
|
|
details.className = 'download-details';
|
|
|
|
const size = createDetailItem('Size', formatSize(download.size));
|
|
details.appendChild(size);
|
|
|
|
if (download.progress !== undefined) {
|
|
const progressItem = document.createElement('div');
|
|
progressItem.className = 'detail-item progress-item';
|
|
progressItem.dataset.label = 'Progress';
|
|
|
|
const labelSpan = document.createElement('span');
|
|
labelSpan.className = 'detail-label';
|
|
labelSpan.textContent = 'Progress';
|
|
|
|
const valueDiv = document.createElement('div');
|
|
valueDiv.className = 'progress-container';
|
|
|
|
// Progress bar with segments
|
|
const totalMb = parseFloat(download.mb) || parseFloat(download.size);
|
|
const missingMb = parseFloat(download.mbmissing) || 0;
|
|
const downloadedMb = totalMb - missingMb;
|
|
const progressPercent = parseFloat(download.progress) || 0;
|
|
const missingPercent = totalMb > 0 ? (missingMb / totalMb) * 100 : 0;
|
|
|
|
const progressBar = document.createElement('div');
|
|
progressBar.className = 'progress-bar';
|
|
|
|
// Downloaded portion (green)
|
|
if (progressPercent > 0) {
|
|
const downloaded = document.createElement('div');
|
|
downloaded.className = 'progress-segment downloaded';
|
|
downloaded.style.width = progressPercent + '%';
|
|
progressBar.appendChild(downloaded);
|
|
}
|
|
|
|
valueDiv.appendChild(progressBar);
|
|
|
|
// Text showing percentage
|
|
const progressText = document.createElement('span');
|
|
progressText.className = 'progress-text';
|
|
progressText.textContent = download.progress + '%';
|
|
valueDiv.appendChild(progressText);
|
|
|
|
// Missing pieces text
|
|
if (missingMb > 0 && totalMb > 0) {
|
|
const missingText = document.createElement('span');
|
|
missingText.className = 'missing-text';
|
|
missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`;
|
|
valueDiv.appendChild(missingText);
|
|
}
|
|
|
|
progressItem.appendChild(labelSpan);
|
|
progressItem.appendChild(valueDiv);
|
|
details.appendChild(progressItem);
|
|
}
|
|
|
|
if (download.speed) {
|
|
const speed = createDetailItem('Speed', download.speed);
|
|
details.appendChild(speed);
|
|
}
|
|
|
|
if (download.eta) {
|
|
const eta = createDetailItem('ETA', download.eta);
|
|
details.appendChild(eta);
|
|
}
|
|
|
|
// qBittorrent-specific fields
|
|
if (download.qbittorrent) {
|
|
if (download.seeds !== undefined) {
|
|
const seeds = createDetailItem('Seeds', download.seeds);
|
|
details.appendChild(seeds);
|
|
}
|
|
|
|
if (download.peers !== undefined) {
|
|
const peers = createDetailItem('Peers', download.peers);
|
|
details.appendChild(peers);
|
|
}
|
|
|
|
if (download.availability !== undefined) {
|
|
const availability = createDetailItem('Availability', `${download.availability}%`);
|
|
details.appendChild(availability);
|
|
}
|
|
}
|
|
|
|
if (download.completedAt) {
|
|
const completed = createDetailItem('Completed', formatDate(download.completedAt));
|
|
details.appendChild(completed);
|
|
}
|
|
|
|
if (isAdmin && (download.downloadPath || download.targetPath)) {
|
|
const pathsDiv = document.createElement('div');
|
|
pathsDiv.className = 'download-paths';
|
|
if (download.downloadPath) {
|
|
const dlPath = document.createElement('div');
|
|
dlPath.className = 'path-item';
|
|
dlPath.innerHTML = '<span class="path-label">Download:</span> <span class="path-value">' + escapeHtml(download.downloadPath) + '</span>';
|
|
pathsDiv.appendChild(dlPath);
|
|
}
|
|
if (download.targetPath) {
|
|
const tgtPath = document.createElement('div');
|
|
tgtPath.className = 'path-item';
|
|
tgtPath.innerHTML = '<span class="path-label">Target:</span> <span class="path-value">' + escapeHtml(download.targetPath) + '</span>';
|
|
pathsDiv.appendChild(tgtPath);
|
|
}
|
|
details.appendChild(pathsDiv);
|
|
}
|
|
|
|
infoDiv.appendChild(details);
|
|
card.appendChild(infoDiv);
|
|
|
|
return card;
|
|
}
|
|
|
|
function createDetailItem(label, value) {
|
|
const item = document.createElement('div');
|
|
item.className = 'detail-item';
|
|
item.dataset.label = label;
|
|
|
|
const labelSpan = document.createElement('span');
|
|
labelSpan.className = 'detail-label';
|
|
labelSpan.textContent = label;
|
|
|
|
const valueSpan = document.createElement('span');
|
|
valueSpan.className = 'detail-value';
|
|
valueSpan.textContent = value;
|
|
|
|
item.appendChild(labelSpan);
|
|
item.appendChild(valueSpan);
|
|
|
|
return item;
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
let statusRefreshHandle = null;
|
|
const STATUS_REFRESH_MS = 5000;
|
|
|
|
async function toggleStatusPanel() {
|
|
const panel = document.getElementById('status-panel');
|
|
if (panel.style.display !== 'none') {
|
|
panel.style.display = 'none';
|
|
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
|
return;
|
|
}
|
|
panel.style.display = 'block';
|
|
await refreshStatusPanel();
|
|
if (statusRefreshHandle) clearInterval(statusRefreshHandle);
|
|
statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS);
|
|
}
|
|
|
|
function closeStatusPanel() {
|
|
document.getElementById('status-panel').style.display = 'none';
|
|
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
|
}
|
|
|
|
async function refreshStatusPanel() {
|
|
const panel = document.getElementById('status-panel');
|
|
if (!panel || panel.style.display === 'none') return;
|
|
try {
|
|
const res = await fetch('/api/dashboard/status');
|
|
if (!res.ok) throw new Error('Failed to fetch status');
|
|
const data = await res.json();
|
|
renderStatusPanel(data, panel);
|
|
} catch (err) {
|
|
// Don't overwrite panel on transient error during auto-refresh
|
|
if (!panel.innerHTML || panel.innerHTML.includes('status-loading')) {
|
|
panel.innerHTML = '<p class="status-error">Failed to load status.</p>';
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderStatusPanel(data, panel) {
|
|
const s = data.server;
|
|
const hrs = Math.floor(s.uptimeSeconds / 3600);
|
|
const mins = Math.floor((s.uptimeSeconds % 3600) / 60);
|
|
const secs = s.uptimeSeconds % 60;
|
|
const uptime = `${hrs}h ${mins}m ${secs}s`;
|
|
|
|
const totalKB = (data.cache.totalSizeBytes / 1024).toFixed(1);
|
|
|
|
let html = `
|
|
<div class="status-header">
|
|
<h3>Server Status</h3>
|
|
<button class="status-close" onclick="closeStatusPanel()">×</button>
|
|
</div>
|
|
<div class="status-grid">
|
|
<div class="status-card">
|
|
<div class="status-card-title">Server</div>
|
|
<div class="status-row"><span>Uptime</span><span>${uptime}</span></div>
|
|
<div class="status-row"><span>Node</span><span>${escapeHtml(s.nodeVersion)}</span></div>
|
|
<div class="status-row"><span>Memory (RSS)</span><span>${s.memoryUsageMB} MB</span></div>
|
|
<div class="status-row"><span>Heap</span><span>${s.heapUsedMB} / ${s.heapTotalMB} MB</span></div>
|
|
</div>
|
|
<div class="status-card">
|
|
<div class="status-card-title">Data Refresh</div>`;
|
|
|
|
const pollIntervalMs = data.polling.intervalMs;
|
|
const clients = data.clients || [];
|
|
const sseClients = clients.filter(c => c.type === 'sse');
|
|
|
|
if (data.polling.enabled) {
|
|
html += `<div class="status-row"><span>Background poll</span><span>${pollIntervalMs / 1000}s</span></div>`;
|
|
} else {
|
|
html += `<div class="status-row"><span>Background poll</span><span>Disabled</span></div>`;
|
|
}
|
|
|
|
const mode = sseClients.length > 0
|
|
? `<span class="status-fg-badge">SSE push</span>`
|
|
: (data.polling.enabled ? 'Background' : 'On-demand (idle)');
|
|
html += `<div class="status-row"><span>Delivery mode</span><span>${mode}</span></div>`;
|
|
|
|
html += `<div class="status-row"><span>SSE clients</span><span>${sseClients.length}</span></div>`;
|
|
for (const c of sseClients) {
|
|
const age = Math.round((Date.now() - c.connectedAt) / 1000);
|
|
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>connected ${age}s ago</span></div>`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
|
|
// Poll timings card
|
|
const lp = data.polling.lastPoll;
|
|
if (lp) {
|
|
const pollAge = Math.round((Date.now() - new Date(lp.timestamp).getTime()) / 1000);
|
|
html += `
|
|
<div class="status-card status-card-wide">
|
|
<div class="status-card-title">Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)</div>
|
|
<div class="status-timings">`;
|
|
const maxTaskMs = lp.tasks.reduce((max, t) => Math.max(max, t.ms), 1);
|
|
for (const t of lp.tasks) {
|
|
const barWidth = Math.max(2, (t.ms / maxTaskMs) * 100);
|
|
html += `
|
|
<div class="timing-row">
|
|
<span class="timing-label">${escapeHtml(t.label)}</span>
|
|
<div class="timing-bar-bg"><div class="timing-bar" data-w="${barWidth.toFixed(1)}"></div></div>
|
|
<span class="timing-value">${t.ms}ms</span>
|
|
</div>`;
|
|
}
|
|
html += `</div></div>`;
|
|
}
|
|
|
|
// Cache table
|
|
html += `
|
|
<div class="status-card status-card-wide">
|
|
<div class="status-card-title">Cache (${data.cache.entryCount} entries, ${totalKB} KB)</div>
|
|
<table class="status-table">
|
|
<thead><tr><th>Key</th><th>Items</th><th>Size</th><th>TTL</th></tr></thead>
|
|
<tbody>`;
|
|
|
|
for (const e of data.cache.entries) {
|
|
const sizeStr = e.sizeBytes > 1024 ? (e.sizeBytes / 1024).toFixed(1) + ' KB' : e.sizeBytes + ' B';
|
|
const ttlStr = e.expired ? '<span class="status-expired">expired</span>' : (e.ttlRemainingMs / 1000).toFixed(0) + 's';
|
|
const items = e.itemCount !== null ? e.itemCount : '—';
|
|
html += `<tr><td><code>${escapeHtml(e.key)}</code></td><td>${items}</td><td>${sizeStr}</td><td>${ttlStr}</td></tr>`;
|
|
}
|
|
|
|
html += `</tbody></table></div></div>`;
|
|
panel.innerHTML = html;
|
|
// Set bar widths via JS DOM assignment — immune to CSP style-src restrictions
|
|
panel.querySelectorAll('.timing-bar[data-w]').forEach(el => {
|
|
el.style.width = el.dataset.w + '%';
|
|
});
|
|
}
|
|
|
|
function formatSize(size) {
|
|
if (!size) return 'N/A';
|
|
// If already a formatted string (e.g., "21.5 GB"), return as-is
|
|
if (typeof size === 'string') {
|
|
return size;
|
|
}
|
|
// If it's a number (bytes), format it
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(size) / Math.log(1024));
|
|
return Math.round(size / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
|
}
|
|
|
|
function formatDate(dateString) {
|
|
if (!dateString) return 'N/A';
|
|
return new Date(dateString).toLocaleString();
|
|
}
|
|
|
|
function showError(message) {
|
|
const errorDiv = document.getElementById('error-message');
|
|
errorDiv.textContent = message;
|
|
errorDiv.style.display = 'block';
|
|
}
|
|
|
|
function hideError() {
|
|
const errorDiv = document.getElementById('error-message');
|
|
errorDiv.style.display = 'none';
|
|
}
|
|
|
|
function showLoading() {
|
|
const loading = document.getElementById('loading');
|
|
loading.style.display = 'block';
|
|
}
|
|
|
|
function hideLoading() {
|
|
const loading = document.getElementById('loading');
|
|
loading.style.display = 'none';
|
|
}
|