(() => { const grid = document.getElementById('grid'); const searchInput = document.getElementById('search'); const statsEl = document.getElementById('stats'); const screenshotAllBtn = document.getElementById('screenshot-all'); const logsModal = document.getElementById('logs-modal'); const logsTitle = document.getElementById('logs-title'); const logsBody = document.getElementById('logs-body'); const logsClose = document.getElementById('logs-close'); let projects = []; let filter = 'all'; let searchTerm = ''; // -- Helpers -- function slugColor(slug) { let hash = 0; for (let i = 0; i < slug.length; i++) hash = slug.charCodeAt(i) + ((hash << 5) - hash); return `hsl(${Math.abs(hash) % 360}, 45%, 25%)`; } function projectHash(p) { return `${p.status || 'stopped'}|${p.apiStatus || 'stopped'}|${p.frontendStatus || ''}|${p._hasScreenshot ? 1 : 0}|${p._screenshotTs || 0}|${p.hasDocs ? 1 : 0}`; } function shouldShow(p) { const status = p.status || 'stopped'; if (filter === 'running' && status !== 'running') return false; if (filter === 'stopped' && status !== 'stopped') return false; if (searchTerm && !p.name.toLowerCase().includes(searchTerm) && !p.slug.toLowerCase().includes(searchTerm)) return false; return true; } // -- Card rendering -- function cardHTML(p) { const status = p.status || 'stopped'; const isRunning = status === 'running'; const isBusy = status === 'starting' || status === 'stopping'; const screenshotSrc = `/screenshots/${p.slug}.png?t=${p._screenshotTs || 0}`; const hasFrontend = !!p.frontend; const imgInner = p._hasScreenshot ? `${p.name}` : `
${p.name[0]}
`; const imgHtml = `
${imgInner}
`; // Status dots: show API + Frontend for frontend projects let statusDots; if (hasFrontend) { const apiSt = p.apiStatus || 'stopped'; const feSt = p.frontendStatus || 'stopped'; statusDots = `
`; } else { statusDots = `
`; } // Open link: frontend port for frontend projects, API port otherwise const openPort = hasFrontend ? p.frontend.port : p.port; // DB badge with relative path const dbBadge = p.dbType ? `${p.dbType}${p.dbName ? ` (${p.dbName})` : ''}` : ''; return ` ${imgHtml}
${statusDots}
${p.name}
:${p.port} ${dbBadge} ${!p.hasEnv ? 'no .env' : ''}
${hasFrontend ? `
:${p.frontend.port} ${p.frontend.dir}
` : ''}
${p.path}
${isRunning ? `` : `` } ${p.hasDocs ? `Docs` : ''} ${isRunning ? `Open` : `` }
`; } // -- Smart DOM update -- const cardHashes = new Map(); function render() { const running = projects.filter(p => p.status === 'running').length; statsEl.textContent = `(${projects.length} projects, ${running} running)`; const visible = projects.filter(shouldShow); const visibleSlugs = new Set(visible.map(p => p.slug)); // Remove cards no longer visible for (const card of [...grid.children]) { if (!visibleSlugs.has(card.dataset.slug)) { card.remove(); cardHashes.delete(card.dataset.slug); } } // Add or update visible cards in order let prevCard = null; for (const p of visible) { const hash = projectHash(p); let card = grid.querySelector(`[data-slug="${p.slug}"]`); if (!card) { // New card card = document.createElement('div'); card.className = 'card'; card.dataset.slug = p.slug; card.innerHTML = cardHTML(p); cardHashes.set(p.slug, hash); if (prevCard && prevCard.nextSibling) { grid.insertBefore(card, prevCard.nextSibling); } else if (prevCard) { grid.appendChild(card); } else if (grid.firstChild) { grid.insertBefore(card, grid.firstChild); } else { grid.appendChild(card); } } else if (cardHashes.get(p.slug) !== hash) { // Changed - update innerHTML only for this card card.innerHTML = cardHTML(p); cardHashes.set(p.slug, hash); } prevCard = card; } } // -- Data fetching -- async function fetchProjects() { try { const res = await fetch('/api/projects'); const data = await res.json(); // Probe screenshots for new projects const checks = data.map(async (p) => { const old = projects.find(o => o.slug === p.slug); if (old) { p._hasScreenshot = old._hasScreenshot; p._screenshotTs = old._screenshotTs || 0; } else { p._hasScreenshot = await imageExists(`/screenshots/${p.slug}.png`); p._screenshotTs = p._hasScreenshot ? Date.now() : 0; } }); await Promise.all(checks); projects = data; render(); } catch (err) { console.error('Failed to fetch projects:', err); } } function imageExists(url) { return new Promise(resolve => { const img = new Image(); img.onload = () => resolve(true); img.onerror = () => resolve(false); img.src = url; }); } // -- Centrifugo real-time updates -- async function connectCentrifugo() { try { const res = await fetch('/api/centrifugo/token'); const { token } = await res.json(); const client = new Centrifuge(`ws://${location.hostname}:8787/connection/websocket`, { token, }); const sub = client.newSubscription('projects-browser'); sub.on('publication', (ctx) => { const msg = ctx.data; if (msg.type === 'status') { const p = projects.find(p => p.slug === msg.slug); if (p) { if (msg.subtype === 'api') { p.apiStatus = msg.status; } else if (msg.subtype === 'frontend') { p.frontendStatus = msg.status; } // Recompute combined status const hasFe = !!p.frontend; if (!hasFe) { p.status = p.apiStatus || msg.status; } else { const apiSt = p.apiStatus || 'stopped'; const feSt = p.frontendStatus || 'stopped'; if (apiSt === 'running' && feSt === 'running') p.status = 'running'; else if (apiSt === 'error' || feSt === 'error') p.status = 'error'; else if (apiSt === 'starting' || feSt === 'starting') p.status = 'starting'; else if (apiSt === 'stopping' || feSt === 'stopping') p.status = 'stopping'; else p.status = 'stopped'; } render(); } } if (msg.type === 'screenshot') { const p = projects.find(p => p.slug === msg.slug); if (p) { p._hasScreenshot = true; p._screenshotTs = Date.now(); render(); } } }); sub.subscribe(); client.on('connected', () => { console.log('Centrifugo connected'); }); client.on('disconnected', (ctx) => { console.log('Centrifugo disconnected:', ctx.reason); }); client.connect(); } catch (err) { console.error('Centrifugo setup failed, falling back to polling:', err); setInterval(fetchProjects, 5000); } } // -- Actions -- window.actions = { async start(slug) { await fetch(`/api/projects/${slug}/start`, { method: 'POST' }); // Status updates come via Centrifugo }, async stop(slug) { await fetch(`/api/projects/${slug}/stop`, { method: 'POST' }); }, async screenshot(slug) { const btn = grid.querySelector(`[data-slug="${slug}"] .btn-screenshot-icon`); if (btn) { btn.disabled = true; btn.textContent = '⏳'; } try { await fetch(`/api/projects/${slug}/screenshot`, { method: 'POST' }); // Screenshot update comes via Centrifugo } catch (err) { console.error('Screenshot failed:', err); } }, async logs(slug) { const res = await fetch(`/api/projects/${slug}/logs`); const data = await res.json(); logsTitle.textContent = `Logs: ${slug}`; // Show both API and frontend logs let text = ''; if (data.api?.length) { text += '--- API ---\n' + data.api.join('\n'); } if (data.frontend?.length) { if (text) text += '\n\n'; text += '--- Frontend ---\n' + data.frontend.join('\n'); } logsBody.textContent = text || '(no logs)'; logsModal.classList.add('open'); } }; // -- Filter buttons -- document.querySelectorAll('.filter-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); filter = btn.dataset.filter; render(); }); }); // Search searchInput.addEventListener('input', () => { searchTerm = searchInput.value.toLowerCase().trim(); render(); }); // Screenshot all screenshotAllBtn.addEventListener('click', async () => { screenshotAllBtn.disabled = true; screenshotAllBtn.textContent = 'Taking screenshots...'; try { await fetch('/api/screenshot-all', { method: 'POST' }); // Updates come via Centrifugo } finally { screenshotAllBtn.disabled = false; screenshotAllBtn.textContent = 'Screenshot All'; } }); // Logs modal close logsClose.addEventListener('click', () => logsModal.classList.remove('open')); logsModal.addEventListener('click', (e) => { if (e.target === logsModal) logsModal.classList.remove('open'); }); // -- Init -- fetchProjects().then(connectCentrifugo); })();