atlus/frontend/js/atlus.js
roberts 6b407a056b Remove X11/noVNC display feature entirely
The Xvfb + x11vnc + websockify + noVNC approach defeats the purpose
of a native web desktop environment. Removed all related backend
(display.py, routers/display.py), frontend (xdisplay.js/css, panel
tray polling), and installer (X11 deps) code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 22:45:18 -05:00

599 lines
22 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
// =====================================================================
async function checkForUpdates() {
const panel = $('#panelUpdates');
if (!panel) return;
try {
const res = await Atlus.apiFetch('/api/updates/check');
if (!res || !res.ok) {
// Show error state if endpoint fails (503 = git not found, etc.)
panel.classList.remove('hidden');
const status = res ? res.status : 0;
const detail = res ? await res.json().catch(() => ({})) : {};
panel.innerHTML = `
<div class="update-status">
<span class="update-status-dot" style="background:var(--text-muted);"></span>
<div class="update-status-text">
<div class="update-status-title">Updates unavailable</div>
<div class="update-status-hash">${detail.detail || 'Could not check'}</div>
</div>
</div>
`;
return;
}
const data = await res.json();
panel.classList.remove('hidden');
if (data.error) {
showUpdateError(panel, data);
} else if (data.available && data.behind_count > 0) {
showUpdateAvailable(panel, data);
} else {
showUpToDate(panel, data);
}
} catch (e) {
console.warn('Update check failed:', e);
}
}
function showUpToDate(panel, data) {
panel.innerHTML = `
<div class="update-status update-current">
<span class="update-status-dot current"></span>
<div class="update-status-text">
<div class="update-status-title">Up to date</div>
<div class="update-status-hash">${data.local_hash}</div>
</div>
</div>
`;
}
function showUpdateError(panel, data) {
panel.innerHTML = `
<div class="update-status">
<span class="update-status-dot" style="background:var(--status-red);"></span>
<div class="update-status-text">
<div class="update-status-title">Check failed</div>
<div class="update-status-hash">${data.error}</div>
</div>
</div>
`;
}
function showUpdateAvailable(panel, data) {
panel.innerHTML = `
<div class="update-status update-available">
<span class="update-status-dot available"></span>
<div class="update-status-text">
<div class="update-status-title">Update available</div>
<div class="update-status-hash">${data.behind_count} commit${data.behind_count !== 1 ? 's' : ''} behind (${data.remote_hash})</div>
</div>
<button class="update-install-btn" title="Install update">Update</button>
</div>
`;
panel.querySelector('.update-install-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…';
setTimeout(() => attemptReload(0), 4000);
} else {
btn.textContent = '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 (use sendBeacon for reliability)
window.addEventListener('beforeunload', () => {
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,
};
const blob = new Blob([JSON.stringify(state)], { type: 'application/json' });
navigator.sendBeacon('/api/session/state?token=' + TOKEN, blob);
});
// =====================================================================
// Init
// =====================================================================
loadHostname();
loadPanelServices();
connectStats();
checkForUpdates();
// Refresh services panel periodically
setInterval(loadPanelServices, 30000);
// Check for updates every 60 seconds
setInterval(checkForUpdates, 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);
})();