shell / public /app.js
soxogvv's picture
Upload 4 files
b64654b verified
/* ── State ───────────────────────────────────────────────────────────────── */
let token = sessionStorage.getItem('shellular_token') || null;
let evtSource = null;
let fullOutput = '';
let shellStatus = 'stopped';
let qrRendered = false;
/* ── DOM helpers ─────────────────────────────────────────────────────────── */
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');
/* ── Routing ─────────────────────────────────────────────────────────────── */
function showLogin() {
loginPage.classList.remove('hidden');
dashPage.classList.add('hidden');
keyInput.focus();
}
function showDashboard() {
loginPage.classList.add('hidden');
dashPage.classList.remove('hidden');
connectStream();
ensureShellularRunning(); // always start shellular, whether fresh login or returning session
}
/* ── Password visibility toggle ──────────────────────────────────────────── */
toggleVisBtn.addEventListener('click', () => {
const isPassword = keyInput.type === 'password';
keyInput.type = isPassword ? 'text' : 'password';
eyeOpen.classList.toggle('hidden', isPassword);
eyeClosed.classList.toggle('hidden', !isPassword);
});
/* ── Login ───────────────────────────────────────────────────────────────── */
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');
}
});
/* ── Logout ──────────────────────────────────────────────────────────────── */
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();
});
/* ── SSE stream ──────────────────────────────────────────────────────────── */
function connectStream() {
if (evtSource) { evtSource.close(); }
evtSource = new EventSource(`/api/stream?t=${Date.now()}`, {});
// EventSource doesn't support custom headers, so we pass the token
// as a cookie or query param workaround via a short-lived fetch first.
// Instead, we do: close the native EventSource approach and use fetch-based SSE.
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(); // keep incomplete last chunk
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 { /* ignore parse errors */ }
}
}
} catch {
// Connection dropped β€” retry after 2s
}
setTimeout(startFetchSSE, 2000);
}
/* ── Event handler ───────────────────────────────────────────────────────── */
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');
}
}
/* ── Status display ──────────────────────────────────────────────────────── */
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; // keep showing the spinner while we wait
}
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');
}
// Once shellular is running, fetch the QR data and render it
if (status === 'running' && !qrRendered) {
fetchAndRenderQR();
}
}
/* ── QR state machine ────────────────────────────────────────────────────── */
function setQrState(state) {
qrLoading.classList.toggle('hidden', state !== 'loading');
qrReady.classList.toggle('hidden', state !== 'ready');
qrError.classList.toggle('hidden', state !== 'error');
}
/* ── QR rendering ────────────────────────────────────────────────────────── */
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;
// Show the container FIRST so the div has real dimensions when QRCode renders
setQrState('ready');
// Wipe any previous render
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.';
}
}
/* ── Output processing ───────────────────────────────────────────────────── */
function appendOutput(text) {
if (!text) return;
fullOutput += text;
logPre.textContent = fullOutput;
logPre.scrollTop = logPre.scrollHeight;
// Show manual registration card as soon as rate-limit message appears
if (text.includes('rate-limited') || text.includes('Registration rate-limited')) {
rateLimitCount++;
if (rateLimitCount >= 1) loadManualCard();
}
}
/* ── Controls ────────────────────────────────────────────────────────────── */
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;
}
/* ── Manual registration fallback ───────────────────────────────────────── */
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 { machineId } = await res.json();
const cmd = `curl -s -X POST "https://api.shellular.dev/register" -H "Content-Type: application/json" -d '{"machineId":"${machineId}","platform":"linux"}'`;
manualCurlCmd.textContent = cmd;
machineIdLoaded = true;
manualCard.classList.remove('hidden');
} catch { /* silent */ }
}
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;
// shellular is restarting β€” clear output and show loading
fullOutput = '';
logPre.textContent = '';
qrRendered = false;
setQrState('loading');
} catch (err) {
manualError.textContent = err.message;
manualError.classList.remove('hidden');
} finally {
manualSubmitBtn.disabled = false;
manualSubmitBtn.textContent = 'Connect';
}
});
/* ── First-time setup panel ──────────────────────────────────────────────── */
const setupCard = $('setup-card');
let setupDone = false; // true once panel is shown OR secrets are already seeded
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; } // secrets already saved
// Try to read credentials (shellular must have registered first)
const r2 = await authFetch('/api/shellular/credentials');
if (!r2 || !r2.ok) return; // not ready yet β€” poll will retry
const data = await r2.json();
if (!data.hostId) return;
// Populate and show the panel
$('val-host-id').textContent = data.hostId;
$('val-machine-id').textContent = data.machineId;
$('val-key').textContent = data.keyB64;
setupCard.classList.remove('hidden');
setupDone = true;
} catch { /* retry on next poll */ }
}
// Poll every 4 s so the panel appears as soon as credentials are available,
// regardless of whether the QR has rendered yet.
setInterval(checkSetup, 4000);
// Copy-to-clipboard buttons
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);
});
});
/* ── Kick off shellular automatically after login ────────────────────────── */
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');
}
}
/* ── Init ────────────────────────────────────────────────────────────────── */
// If we have a stored token, try to go straight to the dashboard
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();
}
// Expose for inline onclick in HTML
window.restartShellular = restartShellular;