let token = sessionStorage.getItem('shellular_token') || null; let evtSource = null; let fullOutput = ''; let shellStatus = 'stopped'; let qrRendered = false; const $ = (id) => document.getElementById(id); const loginPage = $('login-page'); const dashPage = $('dashboard-page'); const loginForm = $('login-form'); const keyInput = $('key-input'); const loginBtn = $('login-btn'); const loginLabel = $('login-label'); const loginSpinner = $('login-spinner'); const loginError = $('login-error'); const toggleVisBtn = $('toggle-vis'); const eyeOpen = $('eye-open'); const eyeClosed = $('eye-closed'); const statusBadge = $('status-badge'); const restartBtn = $('restart-btn'); const logoutBtn = $('logout-btn'); const clearLogBtn = $('clear-log-btn'); const qrLoading = $('qr-loading'); const qrReady = $('qr-ready'); const qrError = $('qr-error'); const qrErrorMsg = $('qr-error-msg'); const qrCanvas = $('qr-canvas'); const logPre = $('log-pre'); function showLogin() { loginPage.classList.remove('hidden'); dashPage.classList.add('hidden'); keyInput.focus(); } function showDashboard() { loginPage.classList.add('hidden'); dashPage.classList.remove('hidden'); connectStream(); ensureShellularRunning(); } toggleVisBtn.addEventListener('click', () => { const isPassword = keyInput.type === 'password'; keyInput.type = isPassword ? 'text' : 'password'; eyeOpen.classList.toggle('hidden', isPassword); eyeClosed.classList.toggle('hidden', !isPassword); }); loginForm.addEventListener('submit', async (e) => { e.preventDefault(); const key = keyInput.value.trim(); if (!key) return; loginBtn.disabled = true; loginLabel.classList.add('hidden'); loginSpinner.classList.remove('hidden'); loginError.classList.add('hidden'); try { const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }), }); const data = await res.json(); if (!res.ok) { throw new Error(data.error || 'Login failed'); } token = data.token; sessionStorage.setItem('shellular_token', token); keyInput.value = ''; showDashboard(); } catch (err) { loginError.textContent = err.message; loginError.classList.remove('hidden'); } finally { loginBtn.disabled = false; loginLabel.classList.remove('hidden'); loginSpinner.classList.add('hidden'); } }); logoutBtn.addEventListener('click', async () => { if (evtSource) { evtSource.close(); evtSource = null; } await fetch('/api/logout', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }).catch(() => {}); token = null; sessionStorage.removeItem('shellular_token'); fullOutput = ''; logPre.textContent = ''; qrPre.textContent = ''; showLogin(); }); function connectStream() { if (evtSource) { evtSource.close(); } evtSource = new EventSource(`/api/stream?t=${Date.now()}`, {}); evtSource.close(); evtSource = null; startFetchSSE(); } async function startFetchSSE() { try { const res = await fetch('/api/stream', { headers: { Authorization: `Bearer ${token}` }, cache: 'no-store', }); if (res.status === 401) { sessionStorage.removeItem('shellular_token'); token = null; showLogin(); return; } if (!res.ok || !res.body) { setTimeout(startFetchSSE, 3000); return; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let partial = ''; while (true) { const { value, done } = await reader.read(); if (done) break; partial += decoder.decode(value, { stream: true }); const parts = partial.split('\n\n'); partial = parts.pop(); for (const part of parts) { const line = part.trim(); if (!line.startsWith('data:')) continue; try { const payload = JSON.parse(line.slice(5).trim()); handleEvent(payload); } catch { } } } } catch { } setTimeout(startFetchSSE, 2000); } function handleEvent(payload) { if (payload.type === 'status') { updateStatus(payload.status); } else if (payload.type === 'output') { appendOutput(payload.text); } else if (payload.type === 'clear') { fullOutput = ''; logPre.textContent = ''; qrRendered = false; setQrState('loading'); } } function updateStatus(status) { shellStatus = status; const labels = { running: 'Running', starting: 'Starting', retrying: 'Retrying…', stopped: 'Stopped', error: 'Error', }; statusBadge.textContent = labels[status] || status; statusBadge.className = `badge badge-${status}`; if (status === 'retrying') { if (!qrRendered) setQrState('loading'); return; } if (status === 'stopped' || status === 'error') { if (!qrRendered) { setQrState('error'); qrErrorMsg.textContent = status === 'error' ? 'Shellular failed to start. Check the output log for details.' : 'Shellular stopped. Click "Try again" to restart.'; } } if (status === 'starting') { if (!qrRendered) setQrState('loading'); } if (status === 'running' && !qrRendered) { fetchAndRenderQR(); } } function setQrState(state) { qrLoading.classList.toggle('hidden', state !== 'loading'); qrReady.classList.toggle('hidden', state !== 'ready'); qrError.classList.toggle('hidden', state !== 'error'); } async function fetchAndRenderQR() { for (let i = 0; i < 8; i++) { await new Promise(r => setTimeout(r, i === 0 ? 1000 : 2500)); try { const res = await authFetch('/api/shellular/qr-data'); if (!res || !res.ok) continue; const json = await res.json(); const qrData = json.qrData; if (!qrData) continue; setQrState('ready'); qrCanvas.innerHTML = ''; new QRCode(qrCanvas, { text: qrData, width: 220, height: 220, colorDark: '#000000', colorLight: '#ffffff', correctLevel: QRCode.CorrectLevel.M, }); qrRendered = true; return; } catch (err) { console.warn('fetchAndRenderQR attempt', i, err); } } if (!qrRendered) { setQrState('error'); qrErrorMsg.textContent = 'Could not render QR. Try restarting.'; } } function appendOutput(text) { if (!text) return; fullOutput += text; logPre.textContent = fullOutput; logPre.scrollTop = logPre.scrollHeight; if (text.includes('rate-limited') || text.includes('Registration rate-limited')) { rateLimitCount++; if (rateLimitCount >= 1) loadManualCard(); } } restartBtn.addEventListener('click', restartShellular); async function restartShellular() { fullOutput = ''; logPre.textContent = ''; qrRendered = false; setQrState('loading'); await authFetch('/api/shellular/restart', 'POST'); } clearLogBtn.addEventListener('click', () => { logPre.textContent = ''; }); async function authFetch(url, method = 'GET') { const res = await fetch(url, { method, headers: { Authorization: `Bearer ${token}` }, }); if (res.status === 401) { token = null; sessionStorage.removeItem('shellular_token'); showLogin(); } return res; } const manualCard = $('manual-reg-card'); const manualCurlCmd = $('manual-curl-cmd'); const manualHostInput = $('manual-host-id'); const manualSubmitBtn = $('manual-submit-btn'); const manualError = $('manual-error'); let rateLimitCount = 0; let machineIdLoaded = false; async function loadManualCard() { if (machineIdLoaded) { manualCard.classList.remove('hidden'); return; } try { const res = await fetch('/api/shellular/machine-id'); const data = await res.json(); const machineId = data.machineId; if (!machineId) { manualCurlCmd.textContent = 'Set the MACHINE_ID_RAW environment variable first, then restart the service.'; manualCard.classList.remove('hidden'); return; } const cmd = `curl -s -X POST "https://api.shellular.dev/host/register" -H "Content-Type: application/json" -H "User-Agent: shellular/0.0.19" -d '{"machineId":"${machineId}","platform":"linux"}'`; manualCurlCmd.textContent = cmd; machineIdLoaded = true; manualCard.classList.remove('hidden'); } catch { } } manualSubmitBtn.addEventListener('click', async () => { const hostId = manualHostInput.value.trim(); if (!hostId) { manualError.textContent = 'Please enter the hostId from the curl response.'; manualError.classList.remove('hidden'); return; } manualError.classList.add('hidden'); manualSubmitBtn.disabled = true; manualSubmitBtn.textContent = 'Connecting…'; try { const r = await fetch('/api/shellular/seed-host', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ hostId }), }); const data = await r.json(); if (!r.ok) throw new Error(data.error || 'Failed'); manualCard.classList.add('hidden'); rateLimitCount = 0; fullOutput = ''; logPre.textContent = ''; qrRendered = false; setQrState('loading'); } catch (err) { manualError.textContent = err.message; manualError.classList.remove('hidden'); } finally { manualSubmitBtn.disabled = false; manualSubmitBtn.textContent = 'Connect'; } }); const setupCard = $('setup-card'); let setupDone = false; async function checkSetup() { if (setupDone || !token) return; try { const r1 = await authFetch('/api/setup-status'); if (!r1 || !r1.ok) return; const { seeded } = await r1.json(); if (seeded) { setupDone = true; return; } const r2 = await authFetch('/api/shellular/credentials'); if (!r2 || !r2.ok) return; const data = await r2.json(); if (!data.hostId) return; const r3 = await fetch('/api/shellular/raw-machine-id'); const { raw } = r3.ok ? await r3.json() : {}; $('val-raw-machine-id').textContent = raw || ''; $('val-host-id').textContent = data.hostId; $('val-machine-id').textContent = data.machineId; $('val-key').textContent = data.keyB64; setupCard.classList.remove('hidden'); setupDone = true; } catch { } } setInterval(checkSetup, 4000); document.addEventListener('click', (e) => { const btn = e.target.closest('.btn-copy'); if (!btn) return; const val = $( btn.dataset.target )?.textContent || ''; navigator.clipboard.writeText(val).then(() => { btn.textContent = 'Copied!'; btn.classList.add('copied'); setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000); }); }); async function ensureShellularRunning() { const res = await authFetch('/api/status'); if (!res) return; const { running } = await res.json(); if (!running) { await authFetch('/api/shellular/start', 'POST'); } } if (token) { fetch('/api/status', { headers: { Authorization: `Bearer ${token}` } }) .then((r) => { if (r.status === 401) { token = null; sessionStorage.removeItem('shellular_token'); showLogin(); } else { showDashboard(); } }) .catch(() => showLogin()); } else { showLogin(); } window.restartShellular = restartShellular;