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>
552 lines
20 KiB
JavaScript
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">×</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}">↗</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">×</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);
|
|
})();
|