atlus/frontend/js/atlus.js
roberts 220a5234cd Add persistent/roaming desktop sessions
PTY terminals now survive browser refresh and close. Session manager
owns PTY lifecycle independently of WebSocket connections, with
background readers storing scrollback for replay on reconnect. Desktop
state (open apps, active app, terminal tabs) persists server-side and
restores automatically on login. Auth tokens moved to localStorage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 21:41:28 -05:00

552 lines
20 KiB
JavaScript

/* Atlus — Core shell: app switching, WebSocket stats, panel updates */
(function () {
'use strict';
// =====================================================================
// Auth guard
// =====================================================================
const TOKEN = localStorage.getItem('atlus_token');
const USER = localStorage.getItem('atlus_user');
if (!TOKEN) {
window.location.href = '/';
return;
}
// =====================================================================
// Globals
// =====================================================================
window.Atlus = {
token: TOKEN,
user: USER,
apps: {}, // registered app modules { id: { init, destroy, title } }
openApps: [], // ordered list of open app ids
activeApp: null, // currently focused app id
layout: 'single', // 'single' | 'split'
secondaryApp: null,
/** Authenticated fetch wrapper */
async apiFetch(url, opts = {}) {
opts.headers = Object.assign({ 'Authorization': `Bearer ${TOKEN}` }, opts.headers || {});
if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(opts.body);
}
const res = await fetch(url, opts);
if (res.status === 401) {
localStorage.removeItem('atlus_token');
localStorage.removeItem('atlus_user');
window.location.href = '/';
return;
}
return res;
},
/** Create an authenticated WebSocket URL */
wsUrl(path) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const sep = path.includes('?') ? '&' : '?';
return `${proto}//${location.host}${path}${sep}token=${TOKEN}`;
},
/** Register an app module */
registerApp(id, module) {
this.apps[id] = module;
},
/** Format bytes to human readable */
formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
},
};
// =====================================================================
// DOM refs
// =====================================================================
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
const stageTabs = $('#stageTabs');
const paneA = $('#paneContentA');
const paneB = $('#paneContentB');
const paneTitleA = $('#paneTitleA');
const paneTitleB = $('#paneTitleB');
const paneBEl = $('#paneB');
const welcomeScreen = $('#welcomeScreen');
// =====================================================================
// App switching
// =====================================================================
function openApp(appId) {
const app = Atlus.apps[appId];
if (!app) return;
// Already open — just focus
if (Atlus.openApps.includes(appId)) {
focusApp(appId);
return;
}
Atlus.openApps.push(appId);
addTab(appId, app.title || appId);
// Create app container
const container = document.createElement('div');
container.className = 'app-view';
container.id = `app-${appId}`;
container.style.display = 'none';
paneA.appendChild(container);
// Initialize app
if (app.init) app.init(container);
focusApp(appId);
saveDesktopState();
}
function focusApp(appId) {
if (welcomeScreen) welcomeScreen.style.display = 'none';
// Hide all apps in pane A
paneA.querySelectorAll('.app-view').forEach(el => el.style.display = 'none');
// Show target
const target = $(`#app-${appId}`);
if (target) target.style.display = 'flex';
// Update tabs
stageTabs.querySelectorAll('.stage-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.app === appId);
});
// Update dock
$$('.dock-item[data-app]').forEach(item => {
item.classList.toggle('active', item.dataset.app === appId);
});
// Update titlebar
const app = Atlus.apps[appId];
paneTitleA.textContent = app ? app.title : appId;
Atlus.activeApp = appId;
// Notify app it got focus
if (app && app.onFocus) app.onFocus();
saveDesktopState();
}
function closeApp(appId) {
const app = Atlus.apps[appId];
if (app && app.destroy) app.destroy();
// Remove DOM
const container = $(`#app-${appId}`);
if (container) container.remove();
// Remove from open list
Atlus.openApps = Atlus.openApps.filter(id => id !== appId);
// Remove tab
const tab = stageTabs.querySelector(`.stage-tab[data-app="${appId}"]`);
if (tab) tab.remove();
// Update dock
const dockItem = $(`.dock-item[data-app="${appId}"]`);
if (dockItem) dockItem.classList.remove('active');
// Focus next app or show welcome
if (Atlus.activeApp === appId) {
if (Atlus.openApps.length > 0) {
focusApp(Atlus.openApps[Atlus.openApps.length - 1]);
} else {
Atlus.activeApp = null;
paneTitleA.textContent = '';
if (welcomeScreen) welcomeScreen.style.display = 'flex';
}
}
saveDesktopState();
}
function addTab(appId, title) {
const tab = document.createElement('button');
tab.className = 'stage-tab';
tab.dataset.app = appId;
tab.innerHTML = `<span>${title}</span><span class="tab-close">&times;</span>`;
tab.addEventListener('click', (e) => {
if (e.target.classList.contains('tab-close')) {
closeApp(appId);
} else {
focusApp(appId);
}
});
stageTabs.appendChild(tab);
}
// =====================================================================
// Dock clicks
// =====================================================================
$$('.dock-item[data-app]').forEach(item => {
item.addEventListener('click', () => openApp(item.dataset.app));
});
// Layout toggle
$$('.layout-btn').forEach(btn => {
btn.addEventListener('click', () => {
const layout = btn.dataset.layout;
Atlus.layout = layout;
$$('.layout-btn').forEach(b => b.classList.toggle('active', b.dataset.layout === layout));
paneBEl.classList.toggle('hidden', layout === 'single');
});
});
// =====================================================================
// System menu
// =====================================================================
const systemMenu = $('#systemMenu');
const logoBtn = $('.dock-logo');
logoBtn.addEventListener('click', () => {
systemMenu.classList.toggle('hidden');
});
systemMenu.addEventListener('click', async (e) => {
const action = e.target.dataset.action;
if (!action) {
// Clicked backdrop
if (e.target === systemMenu) systemMenu.classList.add('hidden');
return;
}
systemMenu.classList.add('hidden');
if (action === 'logout') {
await Atlus.apiFetch('/api/auth/logout', { method: 'POST' });
localStorage.removeItem('atlus_token');
localStorage.removeItem('atlus_user');
window.location.href = '/';
}
});
// =====================================================================
// Panel — Clock
// =====================================================================
function updateClock() {
const now = new Date();
$('#panelDate').textContent = now.toLocaleDateString('en-US', {
weekday: 'short', month: 'short', day: 'numeric'
});
$('#panelTime').textContent = now.toLocaleTimeString('en-US', {
hour: '2-digit', minute: '2-digit', hour12: false
});
}
updateClock();
setInterval(updateClock, 1000);
// =====================================================================
// Panel — WebSocket stats
// =====================================================================
let statsWs = null;
function connectStats() {
statsWs = new WebSocket(Atlus.wsUrl('/api/stats/ws'));
statsWs.onmessage = (e) => {
const data = JSON.parse(e.data);
updatePanel(data);
};
statsWs.onclose = () => {
setTimeout(connectStats, 3000);
};
statsWs.onerror = () => {
statsWs.close();
};
}
function updatePanel(data) {
// CPU
const cpuPct = Math.round(data.cpu_percent);
$('#statCpu').textContent = cpuPct + '%';
updateBar($('#statCpuBar'), cpuPct);
// Memory
const memPct = Math.round(data.memory.percent);
const memUsed = Atlus.formatBytes(data.memory.used);
const memTotal = Atlus.formatBytes(data.memory.total);
$('#statMem').textContent = `${memUsed} / ${memTotal}`;
updateBar($('#statMemBar'), memPct);
// Disk
const diskPct = Math.round(data.disk.percent);
$('#statDisk').textContent = diskPct + '%';
updateBar($('#statDiskBar'), diskPct);
// Temp
if (data.cpu_temp !== null) {
const temp = Math.round(data.cpu_temp);
$('#statTemp').textContent = temp + '\u00B0C';
// Temp bar: 0-85°C range
const tempPct = Math.min(100, Math.round((temp / 85) * 100));
updateBar($('#statTempBar'), tempPct);
}
// Network
const netContainer = $('#panelNetwork');
netContainer.innerHTML = '';
const ifaces = data.network.interfaces;
for (const [name, info] of Object.entries(ifaces)) {
const item = document.createElement('div');
item.className = 'panel-net-item';
item.innerHTML = `
<span class="panel-net-dot ${info.up ? 'up' : 'down'}"></span>
<span class="panel-net-name">${name}</span>
<span class="panel-net-ip">${info.ipv4 || '--'}</span>
`;
netContainer.appendChild(item);
}
}
function updateBar(barEl, percent) {
barEl.style.width = percent + '%';
barEl.className = 'stat-bar-fill';
if (percent >= 90) barEl.classList.add('crit');
else if (percent >= 70) barEl.classList.add('warn');
}
// =====================================================================
// Panel — Hostname
// =====================================================================
async function loadHostname() {
try {
const res = await Atlus.apiFetch('/api/settings/system');
if (res.ok) {
const data = await res.json();
$('#panelHostname').textContent = data.hostname;
}
} catch (e) { /* ignore */ }
}
// =====================================================================
// Panel — Services
// =====================================================================
async function loadPanelServices() {
try {
const cfgRes = await Atlus.apiFetch('/api/settings');
if (!cfgRes.ok) return;
const cfg = await cfgRes.json();
const panelUnits = cfg.panel_services || [];
if (panelUnits.length === 0) {
$('#panelServices').innerHTML = '<div style="color:var(--text-muted);font-size:12px;font-family:var(--font-mono);padding:4px 0;">No services pinned</div>';
return;
}
const container = $('#panelServices');
container.innerHTML = '';
for (const unit of panelUnits) {
const res = await Atlus.apiFetch(`/api/services/${unit}`);
if (!res.ok) continue;
const svc = await res.json();
const isActive = svc.active === 'active';
const name = svc.name || unit.replace('.service', '');
const row = document.createElement('div');
row.className = 'panel-service-row';
row.innerHTML = `
<button class="service-toggle ${isActive ? 'on' : ''}" data-unit="${unit}"></button>
<span class="service-name">${name}</span>
<button class="service-open" data-unit="${unit}">&nearr;</button>
`;
container.appendChild(row);
}
// Toggle handlers
container.querySelectorAll('.service-toggle').forEach(btn => {
btn.addEventListener('click', async () => {
const unit = btn.dataset.unit;
const action = btn.classList.contains('on') ? 'stop' : 'start';
await Atlus.apiFetch('/api/services/action', {
method: 'POST',
body: { unit, action },
});
loadPanelServices();
});
});
// Open handlers
container.querySelectorAll('.service-open').forEach(btn => {
btn.addEventListener('click', () => {
openApp('services');
});
});
} catch (e) { /* ignore */ }
}
// =====================================================================
// Panel — Update checker
// =====================================================================
let updateDismissed = false;
async function checkForUpdates() {
if (updateDismissed) return;
try {
const res = await Atlus.apiFetch('/api/updates/check');
if (!res.ok) return;
const data = await res.json();
const panel = $('#panelUpdates');
if (!panel) return;
if (data.available && data.behind_count > 0) {
showUpdateToast(panel, data);
} else {
panel.classList.add('hidden');
}
} catch (e) { /* ignore */ }
}
function showUpdateToast(panel, data) {
panel.classList.remove('hidden');
panel.innerHTML = `
<div class="update-toast">
<button class="update-dismiss" title="Dismiss">&times;</button>
<div class="update-toast-title">Update available</div>
<div class="update-toast-info">${data.behind_count} commit${data.behind_count !== 1 ? 's' : ''} behind (${data.remote_hash})</div>
<button class="update-toast-btn">Install Update</button>
</div>
`;
panel.querySelector('.update-dismiss').addEventListener('click', () => {
panel.classList.add('hidden');
updateDismissed = true;
});
panel.querySelector('.update-toast-btn').addEventListener('click', async (e) => {
const btn = e.target;
btn.disabled = true;
btn.textContent = 'Updating…';
try {
const res = await Atlus.apiFetch('/api/updates/apply', { method: 'POST' });
if (res.ok) {
btn.textContent = 'Restarting…';
// Server will restart — try to reload after a delay
setTimeout(() => attemptReload(0), 4000);
} else {
btn.textContent = 'Update failed';
btn.disabled = false;
}
} catch (e) {
// Connection lost = server restarting
btn.textContent = 'Restarting…';
setTimeout(() => attemptReload(0), 4000);
}
});
}
function attemptReload(attempt) {
if (attempt > 10) return; // Give up after ~30s
fetch('/desktop', { method: 'HEAD' })
.then(() => window.location.reload())
.catch(() => setTimeout(() => attemptReload(attempt + 1), 3000));
}
// =====================================================================
// Session persistence — save/restore desktop state
// =====================================================================
let _saveTimer = null;
function saveDesktopState() {
// Debounce — save at most once per second
if (_saveTimer) clearTimeout(_saveTimer);
_saveTimer = setTimeout(_doSaveState, 1000);
}
function _doSaveState() {
const termApp = Atlus.apps.terminal;
const terminalTabs = (termApp && termApp.getTerminalIds)
? termApp.getTerminalIds()
: [];
const state = {
open_apps: Atlus.openApps.slice(),
active_app: Atlus.activeApp,
terminal_tabs: terminalTabs,
};
Atlus.apiFetch('/api/session/state', {
method: 'PUT',
body: state,
}).catch(() => {}); // best-effort
}
async function restoreSession() {
try {
const res = await Atlus.apiFetch('/api/session');
if (!res || !res.ok) return;
const session = await res.json();
const state = session.desktop_state || {};
const openApps = state.open_apps || [];
const activeApp = state.active_app;
const serverTerminals = session.terminals || {};
// Store terminal info for the terminal app to use when it inits
Atlus._restoredTerminals = Object.values(serverTerminals);
Atlus._restoredTerminalTabs = state.terminal_tabs || [];
// Re-open apps in saved order
for (const appId of openApps) {
if (Atlus.apps[appId]) {
openApp(appId);
}
}
// Focus the previously active app
if (activeApp && Atlus.openApps.includes(activeApp)) {
focusApp(activeApp);
}
} catch (e) {
// First visit or server error — no session to restore
}
}
// Save state on visibility change (tab switch / minimize)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
_doSaveState(); // immediate save on hide
}
});
// Save state before unload
window.addEventListener('beforeunload', () => {
_doSaveState();
});
// =====================================================================
// Init
// =====================================================================
loadHostname();
loadPanelServices();
connectStats();
checkForUpdates();
// Refresh services panel periodically
setInterval(loadPanelServices, 30000);
// Check for updates every 30 minutes
setInterval(checkForUpdates, 30 * 60 * 1000);
// Expose for app modules
window.Atlus.openApp = openApp;
window.Atlus.closeApp = closeApp;
window.Atlus.focusApp = focusApp;
window.Atlus.saveDesktopState = saveDesktopState;
// Restore previous session (after a brief delay to let app modules register)
setTimeout(restoreSession, 100);
})();