Replace panel services with docked apps, add autostart and update scanner settings

Panel: SERVICES section becomes APPLICATIONS — shows configured GUI apps with
status dots, launch/stop controls, and "+ Add" button linking to Settings.

Backend: DisplayManager.autostart_apps() launches autostart-enabled GUI apps on
service startup (always-on desktop session). Lifespan calls it before yield.

Settings: new Applications section for managing GUI apps (add/remove/autostart
toggle). General section gains update scanner interval + enable/disable toggle.
Config adds update_check_enabled and update_check_interval fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
roberts 2026-03-14 23:40:44 -05:00
parent 06e958f488
commit 23e4906d08
9 changed files with 496 additions and 84 deletions

View file

@ -72,6 +72,8 @@ _DEFAULT_CONFIG: dict = {
}, },
"session_timeout_minutes": 1440, # 24 h "session_timeout_minutes": 1440, # 24 h
"stats_interval_seconds": 2, "stats_interval_seconds": 2,
"update_check_enabled": True,
"update_check_interval": 60, # seconds between update checks
} }

View file

@ -6,6 +6,7 @@ Each GUI app window is captured independently and streamed as JPEG frames.
""" """
import asyncio import asyncio
import getpass
import logging import logging
import os import os
import re import re
@ -473,6 +474,39 @@ class DisplayManager:
app.kill() app.kill()
return [a.to_dict() for a in user_apps.values()] return [a.to_dict() for a in user_apps.values()]
async def autostart_apps(self, gui_apps: list[dict]):
"""Launch all apps with autostart=True. Called on service startup."""
if not HAS_DISPLAY_DEPS:
log.info("Display deps not available, skipping autostart")
return
username = getpass.getuser()
started = 0
for app_cfg in gui_apps:
if not app_cfg.get("autostart"):
continue
command = app_cfg.get("command", "")
if not command:
continue
# Skip if already running
if self.get_app_by_command(username, command):
log.debug("Autostart skip (already running): %s", command)
continue
try:
await self.launch_app(
username=username,
command=command,
title=app_cfg.get("name", command),
args=app_cfg.get("args", []),
target_fps=app_cfg.get("target_fps", 10),
)
started += 1
log.info("Autostarted: %s", command)
except Exception as e:
log.warning("Failed to autostart %s: %s", command, e)
if started:
log.info("Autostarted %d application(s)", started)
async def shutdown_all(self): async def shutdown_all(self):
"""Kill all apps and Xvfb displays.""" """Kill all apps and Xvfb displays."""
for username, apps in self._apps.items(): for username, apps in self._apps.items():

View file

@ -10,7 +10,7 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
from backend.auth import authenticate_user, create_token, logout from backend.auth import authenticate_user, create_token, logout
from backend.config import FRONTEND_DIR, HOST, PORT from backend.config import FRONTEND_DIR, HOST, PORT, load_config
from backend.routers import stats, terminal, files, services, processes, settings, network, packages, updates, session, display from backend.routers import stats, terminal, files, services, processes, settings, network, packages, updates, session, display
from backend.sessions import manager as session_manager from backend.sessions import manager as session_manager
from backend.display import display_manager from backend.display import display_manager
@ -30,6 +30,16 @@ log = logging.getLogger("atlus")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
log.info("Atlus starting on %s:%d", HOST, PORT) log.info("Atlus starting on %s:%d", HOST, PORT)
# Autostart configured GUI apps (always-on desktop session)
cfg = load_config()
gui_apps = cfg.get("gui_apps", [])
if gui_apps:
try:
await display_manager.autostart_apps(gui_apps)
except Exception as e:
log.warning("Autostart failed: %s", e)
# Start stats broadcaster # Start stats broadcaster
broadcaster = asyncio.create_task(stats.stats_broadcaster()) broadcaster = asyncio.create_task(stats.stats_broadcaster())

View file

@ -24,6 +24,8 @@ class ConfigUpdate(BaseModel):
gui_apps: Optional[list[dict]] = None gui_apps: Optional[list[dict]] = None
session_timeout_minutes: Optional[int] = None session_timeout_minutes: Optional[int] = None
stats_interval_seconds: Optional[int] = None stats_interval_seconds: Optional[int] = None
update_check_enabled: Optional[bool] = None
update_check_interval: Optional[int] = None
class HostnameRequest(BaseModel): class HostnameRequest(BaseModel):

View file

@ -289,6 +289,74 @@
white-space: nowrap; white-space: nowrap;
} }
/* Applications management */
.app-config-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--border-structural);
}
.app-config-info {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.app-config-icon {
font-size: 20px;
flex-shrink: 0;
}
.app-config-details {
min-width: 0;
}
.app-config-name {
font-family: var(--font-ui);
font-size: 14px;
color: var(--text-primary);
}
.app-config-cmd {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-config-controls {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.app-autostart-label {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
user-select: none;
}
.app-autostart-label input[type="checkbox"] {
width: 14px;
height: 14px;
accent-color: var(--accent);
cursor: pointer;
}
/* WiFi network list */ /* WiFi network list */
.wifi-network-list { .wifi-network-list {
max-height: 360px; max-height: 360px;

View file

@ -136,54 +136,41 @@
.stat-bar-fill.warn { background: var(--status-amber); } .stat-bar-fill.warn { background: var(--status-amber); }
.stat-bar-fill.crit { background: var(--status-red); } .stat-bar-fill.crit { background: var(--status-red); }
/* Services */ /* Applications (docked GUI apps) */
.panel-service-list { .panel-app-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
} }
.panel-service-row { .panel-app-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
min-height: 44px; min-height: 38px;
padding: 6px 0; padding: 4px 0;
} }
.service-toggle { .panel-app-dot {
position: relative; width: 8px;
width: 36px; height: 8px;
height: 20px; border-radius: 50%;
background: var(--border-component); background: var(--border-component);
border-radius: 10px;
border: none;
cursor: pointer;
transition: background var(--transition-fast);
flex-shrink: 0; flex-shrink: 0;
transition: background var(--transition-fast);
} }
.service-toggle.on { .panel-app-dot.active {
background: var(--status-green); background: var(--status-green);
} }
.service-toggle::after { .panel-app-icon {
content: ''; font-size: 14px;
position: absolute; flex-shrink: 0;
top: 2px; cursor: pointer;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
transition: transform var(--transition-fast);
} }
.service-toggle.on::after { .panel-app-name {
transform: translateX(16px);
}
.service-name {
flex: 1; flex: 1;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
@ -191,9 +178,14 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
cursor: pointer;
} }
.service-open { .panel-app-name:hover {
color: var(--accent);
}
.panel-app-action {
width: 28px; width: 28px;
height: 28px; height: 28px;
background: none; background: none;
@ -204,15 +196,47 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 14px; font-size: 12px;
flex-shrink: 0; flex-shrink: 0;
} }
.service-open:hover { .panel-app-action:hover {
background: var(--accent-hover); background: var(--accent-hover);
color: var(--accent); color: var(--accent);
} }
.panel-app-action.stop:hover {
color: var(--status-red);
}
.panel-app-action:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.panel-app-add {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 32px;
margin-top: 8px;
background: none;
border: 1px dashed var(--border-structural);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
transition: all var(--transition-fast);
}
.panel-app-add:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-hover);
}
/* Update status */ /* Update status */
.panel-updates { .panel-updates {
transition: all var(--transition-base); transition: all var(--transition-base);

View file

@ -172,10 +172,10 @@
</div> </div>
</div> </div>
<!-- Services --> <!-- Applications -->
<div class="panel-section panel-services"> <div class="panel-section panel-apps">
<div class="panel-section-title">SERVICES</div> <div class="panel-section-title">APPLICATIONS</div>
<div id="panelServices" class="panel-service-list"></div> <div id="panelApps" class="panel-app-list"></div>
</div> </div>
</aside> </aside>

View file

@ -8,6 +8,7 @@
const SECTIONS = [ const SECTIONS = [
{ id: 'general', label: 'General' }, { id: 'general', label: 'General' },
{ id: 'applications', label: 'Applications' },
{ id: 'network', label: 'Network' }, { id: 'network', label: 'Network' },
{ id: 'services', label: 'Services' }, { id: 'services', label: 'Services' },
{ id: 'about', label: 'About' }, { id: 'about', label: 'About' },
@ -21,6 +22,7 @@
}); });
if (section === 'general') await renderGeneral(); if (section === 'general') await renderGeneral();
else if (section === 'applications') await renderApplications();
else if (section === 'network') await renderNetwork(); else if (section === 'network') await renderNetwork();
else if (section === 'services') await renderServicesConfig(); else if (section === 'services') await renderServicesConfig();
else if (section === 'about') await renderAbout(); else if (section === 'about') await renderAbout();
@ -69,6 +71,19 @@
</div> </div>
<input class="settings-input" id="setSessionTimeout" type="number" min="5" max="10080" value="${cfg.session_timeout_minutes || 1440}" style="width:80px;"> <input class="settings-input" id="setSessionTimeout" type="number" min="5" max="10080" value="${cfg.session_timeout_minutes || 1440}" style="width:80px;">
</div> </div>
<div class="settings-row">
<div>
<div class="settings-row-label">Update scanner</div>
<div class="settings-row-desc">Seconds between update checks</div>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<input class="settings-input" id="setUpdateInterval" type="number" min="10" max="86400" value="${cfg.update_check_interval || 60}" style="width:80px;" ${cfg.update_check_enabled === false ? 'disabled' : ''}>
<label class="app-autostart-label">
<input type="checkbox" id="setUpdateEnabled" ${cfg.update_check_enabled !== false ? 'checked' : ''}>
<span>Enabled</span>
</label>
</div>
</div>
</div> </div>
<div class="settings-actions"> <div class="settings-actions">
@ -76,11 +91,20 @@
</div> </div>
`; `;
// Update scanner toggle — enable/disable the interval input
const updateEnabledCb = contentEl.querySelector('#setUpdateEnabled');
const updateIntervalInput = contentEl.querySelector('#setUpdateInterval');
updateEnabledCb.addEventListener('change', () => {
updateIntervalInput.disabled = !updateEnabledCb.checked;
});
contentEl.querySelector('#saveGeneral').addEventListener('click', async () => { contentEl.querySelector('#saveGeneral').addEventListener('click', async () => {
const hostname = contentEl.querySelector('#setHostname').value.trim(); const hostname = contentEl.querySelector('#setHostname').value.trim();
const timezone = contentEl.querySelector('#setTimezone').value.trim(); const timezone = contentEl.querySelector('#setTimezone').value.trim();
const interval = parseInt(contentEl.querySelector('#setStatsInterval').value); const interval = parseInt(contentEl.querySelector('#setStatsInterval').value);
const timeout = parseInt(contentEl.querySelector('#setSessionTimeout').value); const timeout = parseInt(contentEl.querySelector('#setSessionTimeout').value);
const updateEnabled = updateEnabledCb.checked;
const updateInterval = parseInt(updateIntervalInput.value) || 60;
// Update hostname if changed // Update hostname if changed
if (hostname && hostname !== sys.hostname) { if (hostname && hostname !== sys.hostname) {
@ -105,10 +129,12 @@
stats_interval_seconds: interval, stats_interval_seconds: interval,
session_timeout_minutes: timeout, session_timeout_minutes: timeout,
timezone: timezone === 'System default' ? null : timezone, timezone: timezone === 'System default' ? null : timezone,
update_check_enabled: updateEnabled,
update_check_interval: updateInterval,
}, },
}); });
alert('Settings saved.'); alert('Settings saved. Update scanner changes take effect on page reload.');
}); });
} }
@ -804,17 +830,201 @@
} }
} }
// ---------------------------------------------------------------
// Applications section — manage GUI apps + autostart
// ---------------------------------------------------------------
function _slugify(str) {
return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
async function renderApplications() {
const cfgRes = await Atlus.apiFetch('/api/settings');
const cfg = await cfgRes.json();
const guiApps = cfg.gui_apps || [];
// Check display availability
let runningApps = [];
let displayAvailable = false;
try {
const statusRes = await Atlus.apiFetch('/api/display/status');
if (statusRes.ok) {
const status = await statusRes.json();
displayAvailable = status.available;
runningApps = status.apps || [];
}
} catch (e) { /* ignore */ }
let html = '<div class="settings-section-title">Applications</div>';
// Display deps status
if (!displayAvailable) {
html += `<div class="settings-group">
<div class="net-error" style="margin-bottom:16px;">
Display dependencies not available. Install xvfb, xdotool, and imagemagick to enable GUI app support.
</div>
</div>`;
}
// Configured apps list
html += '<div class="settings-group"><div class="settings-group-title">CONFIGURED APPLICATIONS</div>';
if (guiApps.length === 0) {
html += '<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">No applications configured. Add one below.</div>';
} else {
for (let i = 0; i < guiApps.length; i++) {
const app = guiApps[i];
const running = runningApps.find(r => r.command === app.command && r.alive);
const statusColor = running ? 'var(--status-green)' : 'var(--text-muted)';
const statusText = running ? 'Running' : 'Stopped';
html += `
<div class="app-config-row">
<div class="app-config-info">
<span class="app-config-icon">${app.icon || '🖥'}</span>
<div class="app-config-details">
<div class="app-config-name">${app.name || app.command}</div>
<div class="app-config-cmd">${app.command}${app.args && app.args.length ? ' ' + app.args.join(' ') : ''}</div>
</div>
</div>
<div class="app-config-controls">
<span style="font-family:var(--font-mono);font-size:11px;color:${statusColor};">${statusText}</span>
<label class="app-autostart-label" title="Autostart on boot">
<input type="checkbox" class="app-autostart-cb" data-app-idx="${i}" ${app.autostart ? 'checked' : ''}>
<span>Auto</span>
</label>
<button class="settings-btn secondary" style="height:28px;font-size:11px;padding:0 10px;" data-remove-app="${i}">Remove</button>
</div>
</div>
`;
}
}
html += '</div>';
// Add app form
html += `
<div class="settings-group">
<div class="settings-group-title">ADD APPLICATION</div>
<div class="settings-row">
<div><div class="settings-row-label">Name</div><div class="settings-row-desc">Display name for the app</div></div>
<input class="settings-input" id="appAddName" placeholder="e.g. Nextcloud">
</div>
<div class="settings-row">
<div><div class="settings-row-label">Command</div><div class="settings-row-desc">Executable name (must be in PATH)</div></div>
<input class="settings-input" id="appAddCommand" placeholder="e.g. nextcloud">
</div>
<div class="settings-row">
<div><div class="settings-row-label">Icon</div><div class="settings-row-desc">Emoji icon for dock and panel</div></div>
<input class="settings-input" id="appAddIcon" value="🖥" style="width:80px;text-align:center;">
</div>
<div class="settings-row">
<div><div class="settings-row-label">Arguments</div><div class="settings-row-desc">Optional, space-separated</div></div>
<input class="settings-input" id="appAddArgs" placeholder="--arg1 value">
</div>
<div class="settings-row">
<div><div class="settings-row-label">Target FPS</div><div class="settings-row-desc">Frame rate for streaming</div></div>
<input class="settings-input" id="appAddFps" type="number" min="1" max="30" value="10" style="width:80px;">
</div>
<div class="settings-row">
<div><div class="settings-row-label">Autostart</div><div class="settings-row-desc">Launch automatically when Atlus starts</div></div>
<label class="app-autostart-label">
<input type="checkbox" id="appAddAutostart">
<span>Enable</span>
</label>
</div>
<div class="settings-actions">
<button class="settings-btn" id="appAddSave">Add Application</button>
</div>
</div>
`;
contentEl.innerHTML = html;
// ---- Event handlers ----
// Autostart toggle
contentEl.querySelectorAll('.app-autostart-cb').forEach(cb => {
cb.addEventListener('change', async () => {
const idx = parseInt(cb.dataset.appIdx);
const apps = [...guiApps];
if (apps[idx]) {
apps[idx] = { ...apps[idx], autostart: cb.checked };
await Atlus.apiFetch('/api/settings', {
method: 'PUT',
body: { gui_apps: apps },
});
}
});
});
// Remove app
contentEl.querySelectorAll('[data-remove-app]').forEach(btn => {
btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.removeApp);
const app = guiApps[idx];
if (!confirm(`Remove ${app.name || app.command} from applications?`)) return;
const apps = guiApps.filter((_, i) => i !== idx);
await Atlus.apiFetch('/api/settings', {
method: 'PUT',
body: { gui_apps: apps },
});
renderApplications();
});
});
// Add app
contentEl.querySelector('#appAddSave').addEventListener('click', async () => {
const name = contentEl.querySelector('#appAddName').value.trim();
const command = contentEl.querySelector('#appAddCommand').value.trim();
const icon = contentEl.querySelector('#appAddIcon').value.trim() || '🖥';
const argsRaw = contentEl.querySelector('#appAddArgs').value.trim();
const fps = parseInt(contentEl.querySelector('#appAddFps').value) || 10;
const autostart = contentEl.querySelector('#appAddAutostart').checked;
if (!name || !command) {
alert('Name and command are required.');
return;
}
// Check for duplicate command
if (guiApps.some(a => a.command === command)) {
alert(`An application with command "${command}" already exists.`);
return;
}
const newApp = {
id: _slugify(name),
name: name,
command: command,
icon: icon,
args: argsRaw ? argsRaw.split(' ').filter(Boolean) : [],
target_fps: fps,
autostart: autostart,
};
const apps = [...guiApps, newApp];
await Atlus.apiFetch('/api/settings', {
method: 'PUT',
body: { gui_apps: apps },
});
alert(`${name} added. It will appear in the dock and panel.`);
renderApplications();
});
}
async function renderServicesConfig() { async function renderServicesConfig() {
const cfgRes = await Atlus.apiFetch('/api/settings'); const cfgRes = await Atlus.apiFetch('/api/settings');
const cfg = await cfgRes.json(); const cfg = await cfgRes.json();
const panelServices = cfg.panel_services || []; const panelServices = cfg.panel_services || [];
contentEl.innerHTML = ` contentEl.innerHTML = `
<div class="settings-section-title">Panel Services</div> <div class="settings-section-title">Services</div>
<div class="settings-group"> <div class="settings-group">
<div class="settings-group-title">PINNED TO RIGHT PANEL</div> <div class="settings-group-title">MONITORED SERVICES</div>
<div class="settings-row-desc" style="margin-bottom:16px;"> <div class="settings-row-desc" style="margin-bottom:16px;">
Comma-separated list of systemd unit names to show in the right panel. Comma-separated list of systemd unit names to monitor via the Services app.
</div> </div>
<textarea class="settings-input" id="setPanelServices" rows="4" <textarea class="settings-input" id="setPanelServices" rows="4"
style="width:100%;height:100px;padding:12px;resize:vertical;" style="width:100%;height:100px;padding:12px;resize:vertical;"

View file

@ -332,57 +332,100 @@
} }
// ===================================================================== // =====================================================================
// Panel — Services // Panel — Applications (docked GUI apps)
// ===================================================================== // =====================================================================
async function loadPanelServices() { async function loadPanelApps() {
const container = $('#panelApps');
if (!container) return;
try { try {
const cfgRes = await Atlus.apiFetch('/api/settings'); const cfgRes = await Atlus.apiFetch('/api/settings');
if (!cfgRes.ok) return; if (!cfgRes.ok) return;
const cfg = await cfgRes.json(); const cfg = await cfgRes.json();
const panelUnits = cfg.panel_services || []; const guiApps = cfg.gui_apps || [];
if (panelUnits.length === 0) { // Get running apps (may 503 if display deps unavailable)
$('#panelServices').innerHTML = '<div style="color:var(--text-muted);font-size:12px;font-family:var(--font-mono);padding:4px 0;">No services pinned</div>'; let runningApps = [];
return; try {
} const runRes = await Atlus.apiFetch('/api/display/apps');
if (runRes.ok) runningApps = await runRes.json();
} catch (e) { /* ignore */ }
const container = $('#panelServices');
container.innerHTML = ''; container.innerHTML = '';
for (const unit of panelUnits) { if (guiApps.length === 0) {
const res = await Atlus.apiFetch(`/api/services/${unit}`); container.innerHTML = '<div style="color:var(--text-muted);font-size:12px;font-family:var(--font-mono);padding:4px 0;">No apps configured</div>';
if (!res.ok) continue; } else {
const svc = await res.json(); for (const app of guiApps) {
const isActive = svc.active === 'active'; const running = runningApps.find(r => r.command === app.command && r.alive);
const name = svc.name || unit.replace('.service', ''); const row = document.createElement('div');
row.className = 'panel-app-row';
row.innerHTML = `
<div class="panel-app-dot ${running ? 'active' : ''}"></div>
<span class="panel-app-icon">${app.icon || '🖥'}</span>
<span class="panel-app-name">${app.name || app.command}</span>
<button class="panel-app-action ${running ? 'stop' : 'start'}"
data-command="${app.command}" data-app-id="${running ? running.app_id : ''}"
data-gui-id="${app.id}" data-title="${app.name || app.command}"
data-args="${(app.args || []).join(' ')}" data-fps="${app.target_fps || 10}"
title="${running ? 'Stop' : 'Launch'}">
${running ? '■' : '▶'}
</button>
`;
const row = document.createElement('div'); // Click name to open in tab
row.className = 'panel-service-row'; row.querySelector('.panel-app-name').addEventListener('click', () => {
row.innerHTML = ` openApp('gui-' + app.id);
<button class="service-toggle ${isActive ? 'on' : ''}" data-unit="${unit}"></button> });
<span class="service-name">${name}</span> row.querySelector('.panel-app-icon').addEventListener('click', () => {
<button class="service-open" data-unit="${unit}">&nearr;</button> openApp('gui-' + app.id);
`; });
container.appendChild(row);
container.appendChild(row);
}
} }
// Toggle handlers // Add App button
container.querySelectorAll('.service-toggle').forEach(btn => { const addBtn = document.createElement('button');
btn.addEventListener('click', async () => { addBtn.className = 'panel-app-add';
const unit = btn.dataset.unit; addBtn.textContent = '+ Add';
const action = btn.classList.contains('on') ? 'stop' : 'start'; addBtn.addEventListener('click', () => {
await Atlus.apiFetch('/api/services/action', { openApp('settings');
method: 'POST', // Navigate to Applications section after a brief delay
body: { unit, action }, setTimeout(() => {
}); const navItem = document.querySelector('.settings-nav-item[data-section="applications"]');
loadPanelServices(); if (navItem) navItem.click();
}); }, 200);
}); });
container.appendChild(addBtn);
// Open handlers // Action button handlers (launch/stop)
container.querySelectorAll('.service-open').forEach(btn => { container.querySelectorAll('.panel-app-action').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', async (e) => {
openApp('services'); e.stopPropagation();
const isRunning = btn.classList.contains('stop');
btn.disabled = true;
if (isRunning) {
// Stop the app
const appId = btn.dataset.appId;
if (appId) {
await Atlus.apiFetch(`/api/display/apps/${appId}`, { method: 'DELETE' });
}
} else {
// Launch the app
const args = btn.dataset.args ? btn.dataset.args.split(' ').filter(Boolean) : [];
await Atlus.apiFetch('/api/display/apps', {
method: 'POST',
body: {
command: btn.dataset.command,
title: btn.dataset.title,
args: args,
target_fps: parseInt(btn.dataset.fps) || 10,
},
});
}
setTimeout(loadPanelApps, 1500);
}); });
}); });
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
@ -578,15 +621,34 @@
// Init // Init
// ===================================================================== // =====================================================================
loadHostname(); loadHostname();
loadPanelServices(); loadPanelApps();
connectStats(); connectStats();
checkForUpdates();
// Refresh services panel periodically // Refresh applications panel periodically
setInterval(loadPanelServices, 30000); setInterval(loadPanelApps, 30000);
// Check for updates every 60 seconds // Update scanner — read interval and enabled state from config
setInterval(checkForUpdates, 60 * 1000); (async function initUpdateChecker() {
try {
const cfgRes = await Atlus.apiFetch('/api/settings');
if (cfgRes.ok) {
const cfg = await cfgRes.json();
const enabled = cfg.update_check_enabled !== false;
const intervalSec = cfg.update_check_interval || 60;
if (enabled) {
checkForUpdates();
setInterval(checkForUpdates, intervalSec * 1000);
}
} else {
// Fallback: check every 60s
checkForUpdates();
setInterval(checkForUpdates, 60 * 1000);
}
} catch (e) {
checkForUpdates();
setInterval(checkForUpdates, 60 * 1000);
}
})();
// Expose for app modules // Expose for app modules
window.Atlus.openApp = openApp; window.Atlus.openApp = openApp;