Migrate frontend from monolithic app.js to vanilla ES modules
- Delete React files (App.jsx, main.jsx, App.css) - Create modular vanilla JS structure in client/src/: - state.js (global state object) - api.js (all fetch calls) - sse.js (SSE connection management) - ui/auth.js (authentication UI) - ui/downloads.js (downloads rendering) - ui/history.js (history section) - ui/statusPanel.js (status panel) - ui/webhooks.js (webhook management) - ui/filters.js (download client filter) - ui/theme.js (theme switching) - ui/tabs.js (tab navigation) - utils/format.js (formatting utilities) - utils/storage.js (localStorage helpers) - main.js (DOMContentLoaded bootstrap) - Update vite.config.js for vanilla build outputting to ../public/app.js - Build succeeds: 14 modules, 43.88 kB output
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state, STATUS_REFRESH_MS } from '../state.js';
|
||||
import { refreshStatusPanel as apiRefreshStatusPanel } from '../api.js';
|
||||
import { fetchWebhookStatus } from './webhooks.js';
|
||||
|
||||
export async function toggleStatusPanel() {
|
||||
const panel = document.getElementById('status-panel');
|
||||
const webhooksSection = document.getElementById('webhooks-section');
|
||||
if (panel.style.display !== 'none') {
|
||||
// Close both panels (webhooks is a sibling, hide it too)
|
||||
panel.style.display = 'none';
|
||||
if (webhooksSection) webhooksSection.style.display = 'none';
|
||||
if (state.statusRefreshHandle) { clearInterval(state.statusRefreshHandle); state.statusRefreshHandle = null; }
|
||||
return;
|
||||
}
|
||||
// Open status panel and webhooks section (siblings)
|
||||
panel.style.display = 'block';
|
||||
// Show webhooks section for admin users (collapsed by default)
|
||||
if (webhooksSection && state.isAdmin) {
|
||||
webhooksSection.style.display = 'block';
|
||||
state.webhookSectionExpanded = false;
|
||||
document.getElementById('webhooks-content').style.display = 'none';
|
||||
document.getElementById('webhooks-toggle').classList.remove('expanded');
|
||||
await fetchWebhookStatus();
|
||||
} else if (webhooksSection) {
|
||||
webhooksSection.style.display = 'none';
|
||||
}
|
||||
refreshStatusPanel();
|
||||
if (state.statusRefreshHandle) clearInterval(state.statusRefreshHandle);
|
||||
state.statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS);
|
||||
}
|
||||
|
||||
export function closeStatusPanel() {
|
||||
document.getElementById('status-panel').style.display = 'none';
|
||||
const webhooksSection = document.getElementById('webhooks-section');
|
||||
if (webhooksSection) webhooksSection.style.display = 'none';
|
||||
if (state.statusRefreshHandle) { clearInterval(state.statusRefreshHandle); state.statusRefreshHandle = null; }
|
||||
}
|
||||
|
||||
export async function refreshStatusPanel() {
|
||||
const panel = document.getElementById('status-panel');
|
||||
const contentDiv = document.getElementById('status-content');
|
||||
console.log('[Status] panel found:', !!panel, 'contentDiv found:', !!contentDiv, 'panel display:', panel?.style?.display);
|
||||
if (!panel || panel.style.display === 'none') return;
|
||||
console.log('[Status] Refreshing status panel...');
|
||||
try {
|
||||
const result = await apiRefreshStatusPanel();
|
||||
if (result.success) {
|
||||
console.log('[Status] Got status data, rendering...');
|
||||
renderStatusPanel(result.data, panel);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Status] Error fetching status:', err);
|
||||
// Don't overwrite panel on transient error during auto-refresh
|
||||
if (contentDiv && (!contentDiv.innerHTML || contentDiv.innerHTML.includes('status-loading'))) {
|
||||
contentDiv.innerHTML = '<p class="status-error">Failed to load status: ' + err.message + '</p>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function renderStatusPanel(data, panel) {
|
||||
console.log('[Status] renderStatusPanel called with data:', data ? 'yes' : 'no', 'keys:', data ? Object.keys(data) : 'none');
|
||||
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" id="status-close-btn">×</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>`;
|
||||
|
||||
// Webhook metrics card (admin only)
|
||||
if (state.isAdmin && data.webhooks) {
|
||||
const wh = data.webhooks;
|
||||
const sonarrEnabled = wh.sonarr?.enabled ? '●' : '○';
|
||||
const radarrEnabled = wh.radarr?.enabled ? '●' : '○';
|
||||
const sonarrEvents = wh.sonarr?.eventsReceived || 0;
|
||||
const radarrEvents = wh.radarr?.eventsReceived || 0;
|
||||
const sonarrPolls = wh.sonarr?.pollsSkipped || 0;
|
||||
const radarrPolls = wh.radarr?.pollsSkipped || 0;
|
||||
|
||||
html += `
|
||||
<div class="status-card">
|
||||
<div class="status-card-title">Webhooks</div>
|
||||
<div class="status-row"><span>Sonarr</span><span>${sonarrEnabled} ${wh.sonarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||
<div class="status-row"><span>Radarr</span><span>${radarrEnabled} ${wh.radarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||
<div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents}</span></div>
|
||||
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls}</span></div>
|
||||
</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>`;
|
||||
// Render into status-content div, not the whole panel (preserves webhooks section)
|
||||
const contentDiv = document.getElementById('status-content');
|
||||
const panelCheck = document.getElementById('status-panel');
|
||||
console.log('[Status] contentDiv found:', !!contentDiv, 'panel children:', panelCheck?.children?.length, 'HTML length:', html.length);
|
||||
if (panelCheck) {
|
||||
console.log('[Status] panel innerHTML preview:', panelCheck.innerHTML.substring(0, 200));
|
||||
}
|
||||
if (contentDiv) {
|
||||
contentDiv.innerHTML = html;
|
||||
console.log('[Status] HTML rendered, contentDiv innerHTML length:', contentDiv.innerHTML.length);
|
||||
} else {
|
||||
console.error('[Status] contentDiv not found!');
|
||||
}
|
||||
// Wire close button — addEventListener avoids CSP inline handler restrictions
|
||||
const closeBtn = document.getElementById('status-close-btn');
|
||||
if (closeBtn) closeBtn.addEventListener('click', closeStatusPanel);
|
||||
// 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 escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
Reference in New Issue
Block a user