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:
parent
06e958f488
commit
23e4906d08
9 changed files with 496 additions and 84 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;"
|
||||||
|
|
|
||||||
|
|
@ -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');
|
const row = document.createElement('div');
|
||||||
row.className = 'panel-service-row';
|
row.className = 'panel-app-row';
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<button class="service-toggle ${isActive ? 'on' : ''}" data-unit="${unit}"></button>
|
<div class="panel-app-dot ${running ? 'active' : ''}"></div>
|
||||||
<span class="service-name">${name}</span>
|
<span class="panel-app-icon">${app.icon || '🖥'}</span>
|
||||||
<button class="service-open" data-unit="${unit}">↗</button>
|
<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>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Click name to open in tab
|
||||||
|
row.querySelector('.panel-app-name').addEventListener('click', () => {
|
||||||
|
openApp('gui-' + app.id);
|
||||||
|
});
|
||||||
|
row.querySelector('.panel-app-icon').addEventListener('click', () => {
|
||||||
|
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');
|
||||||
|
// Navigate to Applications section after a brief delay
|
||||||
|
setTimeout(() => {
|
||||||
|
const navItem = document.querySelector('.settings-nav-item[data-section="applications"]');
|
||||||
|
if (navItem) navItem.click();
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
container.appendChild(addBtn);
|
||||||
|
|
||||||
|
// Action button handlers (launch/stop)
|
||||||
|
container.querySelectorAll('.panel-app-action').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
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',
|
method: 'POST',
|
||||||
body: { unit, action },
|
body: {
|
||||||
|
command: btn.dataset.command,
|
||||||
|
title: btn.dataset.title,
|
||||||
|
args: args,
|
||||||
|
target_fps: parseInt(btn.dataset.fps) || 10,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
loadPanelServices();
|
}
|
||||||
});
|
setTimeout(loadPanelApps, 1500);
|
||||||
});
|
|
||||||
|
|
||||||
// Open handlers
|
|
||||||
container.querySelectorAll('.service-open').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
openApp('services');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
|
|
@ -578,15 +621,34 @@
|
||||||
// Init
|
// Init
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
loadHostname();
|
loadHostname();
|
||||||
loadPanelServices();
|
loadPanelApps();
|
||||||
connectStats();
|
connectStats();
|
||||||
|
|
||||||
|
// Refresh applications panel periodically
|
||||||
|
setInterval(loadPanelApps, 30000);
|
||||||
|
|
||||||
|
// Update scanner — read interval and enabled state from config
|
||||||
|
(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();
|
checkForUpdates();
|
||||||
|
|
||||||
// Refresh services panel periodically
|
|
||||||
setInterval(loadPanelServices, 30000);
|
|
||||||
|
|
||||||
// Check for updates every 60 seconds
|
|
||||||
setInterval(checkForUpdates, 60 * 1000);
|
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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue