- Ethernet watchdog: background task polls /sys/class/net every 5s, detects cable plug-in on disabled interfaces and auto-enables with DHCP - interface_down endpoint now refuses to disable the only active interface (returns 409), preventing accidental lockout - Frontend shows the error message instead of silently failing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1228 lines
58 KiB
JavaScript
1228 lines
58 KiB
JavaScript
/* Atlus — Settings app */
|
|
(function () {
|
|
'use strict';
|
|
|
|
let container = null;
|
|
let contentEl = null;
|
|
let activeSection = 'general';
|
|
|
|
const SECTIONS = [
|
|
{ id: 'general', label: 'General' },
|
|
{ id: 'applications', label: 'Applications' },
|
|
{ id: 'network', label: 'Network' },
|
|
{ id: 'services', label: 'Services' },
|
|
{ id: 'about', label: 'About' },
|
|
];
|
|
|
|
async function loadSection(section) {
|
|
activeSection = section;
|
|
// Update nav
|
|
container.querySelectorAll('.settings-nav-item').forEach(item => {
|
|
item.classList.toggle('active', item.dataset.section === section);
|
|
});
|
|
|
|
if (section === 'general') await renderGeneral();
|
|
else if (section === 'applications') await renderApplications();
|
|
else if (section === 'network') await renderNetwork();
|
|
else if (section === 'services') await renderServicesConfig();
|
|
else if (section === 'about') await renderAbout();
|
|
}
|
|
|
|
async function renderGeneral() {
|
|
const res = await Atlus.apiFetch('/api/settings');
|
|
const cfg = await res.json();
|
|
const sysRes = await Atlus.apiFetch('/api/settings/system');
|
|
const sys = await sysRes.json();
|
|
|
|
contentEl.innerHTML = `
|
|
<div class="settings-section-title">General</div>
|
|
|
|
<div class="settings-group">
|
|
<div class="settings-group-title">SYSTEM</div>
|
|
<div class="settings-row">
|
|
<div>
|
|
<div class="settings-row-label">Hostname</div>
|
|
<div class="settings-row-desc">System network name</div>
|
|
</div>
|
|
<input class="settings-input" id="setHostname" value="${sys.hostname}">
|
|
</div>
|
|
<div class="settings-row">
|
|
<div>
|
|
<div class="settings-row-label">Timezone</div>
|
|
<div class="settings-row-desc">System timezone</div>
|
|
</div>
|
|
<input class="settings-input" id="setTimezone" value="${cfg.timezone || 'System default'}" placeholder="e.g. America/New_York">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="settings-group">
|
|
<div class="settings-group-title">DISPLAY</div>
|
|
<div class="settings-row">
|
|
<div>
|
|
<div class="settings-row-label">Stats refresh interval</div>
|
|
<div class="settings-row-desc">Seconds between stat updates</div>
|
|
</div>
|
|
<input class="settings-input" id="setStatsInterval" type="number" min="1" max="30" value="${cfg.stats_interval_seconds || 2}" style="width:80px;">
|
|
</div>
|
|
<div class="settings-row">
|
|
<div>
|
|
<div class="settings-row-label">Session timeout</div>
|
|
<div class="settings-row-desc">Minutes before auto-logout</div>
|
|
</div>
|
|
<input class="settings-input" id="setSessionTimeout" type="number" min="5" max="10080" value="${cfg.session_timeout_minutes || 1440}" style="width:80px;">
|
|
</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 class="settings-actions">
|
|
<button class="settings-btn" id="saveGeneral">Save</button>
|
|
</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 () => {
|
|
const hostname = contentEl.querySelector('#setHostname').value.trim();
|
|
const timezone = contentEl.querySelector('#setTimezone').value.trim();
|
|
const interval = parseInt(contentEl.querySelector('#setStatsInterval').value);
|
|
const timeout = parseInt(contentEl.querySelector('#setSessionTimeout').value);
|
|
const updateEnabled = updateEnabledCb.checked;
|
|
const updateInterval = parseInt(updateIntervalInput.value) || 60;
|
|
|
|
// Update hostname if changed
|
|
if (hostname && hostname !== sys.hostname) {
|
|
await Atlus.apiFetch('/api/settings/hostname', {
|
|
method: 'POST',
|
|
body: { hostname },
|
|
});
|
|
}
|
|
|
|
// Update timezone if changed
|
|
if (timezone && timezone !== 'System default') {
|
|
await Atlus.apiFetch('/api/settings/timezone', {
|
|
method: 'POST',
|
|
body: { timezone },
|
|
});
|
|
}
|
|
|
|
// Update config
|
|
await Atlus.apiFetch('/api/settings', {
|
|
method: 'PUT',
|
|
body: {
|
|
stats_interval_seconds: interval,
|
|
session_timeout_minutes: timeout,
|
|
timezone: timezone === 'System default' ? null : timezone,
|
|
update_check_enabled: updateEnabled,
|
|
update_check_interval: updateInterval,
|
|
},
|
|
});
|
|
|
|
alert('Settings saved. Update scanner changes take effect on page reload.');
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Network section — full NetworkManager integration
|
|
// ---------------------------------------------------------------
|
|
|
|
function _signalLabel(signal) {
|
|
if (signal >= 80) return { text: 'Excellent', color: 'var(--status-green)' };
|
|
if (signal >= 60) return { text: 'Good', color: 'var(--status-green)' };
|
|
if (signal >= 40) return { text: 'Fair', color: 'var(--status-amber, #e09a2a)' };
|
|
return { text: 'Weak', color: 'var(--status-red)' };
|
|
}
|
|
|
|
function _signalBars(signal) {
|
|
const filled = Math.ceil(signal / 25);
|
|
return '▂▄▆█'.split('').map((c, i) =>
|
|
`<span style="color:${i < filled ? 'var(--accent)' : 'var(--text-muted)'};">${c}</span>`
|
|
).join('');
|
|
}
|
|
|
|
async function renderNetwork() {
|
|
contentEl.innerHTML = '<div class="settings-section-title">Network</div><div style="padding:16px;color:var(--text-muted);font-family:var(--font-mono);font-size:13px;">Loading…</div>';
|
|
|
|
// Try NetworkManager first
|
|
const statusRes = await Atlus.apiFetch('/api/network/status');
|
|
|
|
if (statusRes.status === 503) {
|
|
// No nmcli — fall back to read-only psutil view
|
|
return await _renderNetworkReadonly();
|
|
}
|
|
|
|
if (!statusRes.ok) {
|
|
contentEl.innerHTML = '<div class="settings-section-title">Network</div><div class="net-error">Failed to load network status</div>';
|
|
return;
|
|
}
|
|
|
|
const status = await statusRes.json();
|
|
const devices = status.devices;
|
|
|
|
let html = '<div class="settings-section-title">Network</div>';
|
|
|
|
// ---- Device overview ----
|
|
html += '<div class="settings-group"><div class="settings-group-title">INTERFACES</div>';
|
|
for (const dev of devices) {
|
|
const isUp = dev.state === 'connected';
|
|
const dotColor = isUp ? 'var(--status-green)' : 'var(--status-red)';
|
|
const ip = dev.ipv4 && dev.ipv4.address ? dev.ipv4.address : '--';
|
|
html += `
|
|
<div class="settings-row">
|
|
<div style="display:flex;align-items:center;gap:10px;">
|
|
<div style="width:8px;height:8px;border-radius:50%;background:${dotColor};flex-shrink:0;"></div>
|
|
<div>
|
|
<div class="settings-row-label">${dev.device}</div>
|
|
<div class="settings-row-desc">${dev.type} · ${dev.connection || 'disconnected'}</div>
|
|
</div>
|
|
</div>
|
|
<span style="font-family:var(--font-mono);font-size:13px;color:var(--text-secondary);">${ip}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
html += '</div>';
|
|
|
|
// ---- Per-ethernet config ----
|
|
const ethDevices = devices.filter(d => d.type === 'ethernet' && d.connection);
|
|
for (const dev of ethDevices) {
|
|
html += `<div class="settings-group" id="ethConfig-${dev.device}">
|
|
<div class="settings-group-title">${dev.device.toUpperCase()} CONFIGURATION</div>
|
|
<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">Loading…</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ---- WiFi section ----
|
|
const wifiDev = devices.find(d => d.type === 'wifi');
|
|
if (wifiDev) {
|
|
html += `
|
|
<div class="settings-group" id="wifiSection">
|
|
<div class="settings-group-title">WI-FI</div>
|
|
<div id="wifiStatus"></div>
|
|
<div style="margin-top:12px;">
|
|
<button class="settings-btn secondary" id="wifiScanBtn">Scan Networks</button>
|
|
</div>
|
|
<div id="wifiNetworkList" class="wifi-network-list"></div>
|
|
<div class="settings-group-title" style="margin-top:16px;">CONNECT TO HIDDEN NETWORK</div>
|
|
<div class="settings-row">
|
|
<div><div class="settings-row-label">SSID</div></div>
|
|
<input class="settings-input" id="hiddenSsid" placeholder="Network name">
|
|
</div>
|
|
<div class="settings-row">
|
|
<div><div class="settings-row-label">Password</div></div>
|
|
<input class="settings-input" id="hiddenPass" type="password" placeholder="Password">
|
|
</div>
|
|
<div class="settings-actions">
|
|
<button class="settings-btn secondary" id="hiddenConnectBtn">Connect</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ---- Unconnected ethernet (no active connection) ----
|
|
const disconnectedEth = devices.filter(d => d.type === 'ethernet' && !d.connection);
|
|
for (const dev of disconnectedEth) {
|
|
html += `
|
|
<div class="settings-group">
|
|
<div class="settings-group-title">${dev.device.toUpperCase()}</div>
|
|
<div class="settings-row">
|
|
<div class="settings-row-label">Status</div>
|
|
<span style="color:var(--status-red);">Disconnected</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
contentEl.innerHTML = html;
|
|
|
|
// ---- Load ethernet configs ----
|
|
for (const dev of ethDevices) {
|
|
_loadEthernetConfig(dev);
|
|
}
|
|
|
|
// ---- WiFi event handlers ----
|
|
if (wifiDev) {
|
|
_renderWifiStatus(wifiDev);
|
|
|
|
contentEl.querySelector('#wifiScanBtn').addEventListener('click', async (e) => {
|
|
const btn = e.target;
|
|
btn.disabled = true;
|
|
btn.textContent = 'Scanning…';
|
|
await _loadWifiNetworks();
|
|
btn.disabled = false;
|
|
btn.textContent = 'Scan Networks';
|
|
});
|
|
|
|
contentEl.querySelector('#hiddenConnectBtn').addEventListener('click', async () => {
|
|
const ssid = contentEl.querySelector('#hiddenSsid').value.trim();
|
|
const pass = contentEl.querySelector('#hiddenPass').value;
|
|
if (!ssid) return;
|
|
await _connectWifi(ssid, pass, true);
|
|
});
|
|
}
|
|
}
|
|
|
|
async function _renderNetworkReadonly() {
|
|
// Fallback: use /api/network/interfaces (ip commands, no NetworkManager)
|
|
let ifacesRes;
|
|
try {
|
|
ifacesRes = await Atlus.apiFetch('/api/network/interfaces');
|
|
} catch (e) {
|
|
ifacesRes = null;
|
|
}
|
|
|
|
if (!ifacesRes || !ifacesRes.ok) {
|
|
// Final fallback: read-only psutil view
|
|
const res = await Atlus.apiFetch('/api/stats');
|
|
const data = await res.json();
|
|
let html = '<div class="settings-section-title">Network</div>';
|
|
const ifaces = data.network.interfaces;
|
|
for (const [name, info] of Object.entries(ifaces)) {
|
|
html += `
|
|
<div class="settings-group">
|
|
<div class="settings-group-title">${name.toUpperCase()}</div>
|
|
<div class="settings-row">
|
|
<div class="settings-row-label">Status</div>
|
|
<span style="color:${info.up ? 'var(--status-green)' : 'var(--status-red)'};">${info.up ? 'Up' : 'Down'}</span>
|
|
</div>
|
|
<div class="settings-row">
|
|
<div class="settings-row-label">IPv4</div>
|
|
<span style="font-family:var(--font-mono);font-size:13px;">${info.ipv4 || '--'}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
contentEl.innerHTML = html;
|
|
return;
|
|
}
|
|
|
|
const ifData = await ifacesRes.json();
|
|
const ifaces = ifData.interfaces || [];
|
|
const dnsServers = ifData.dns || [];
|
|
|
|
let html = '<div class="settings-section-title">Network</div>';
|
|
|
|
// Per-interface cards
|
|
for (const iface of ifaces) {
|
|
const isUp = iface.up;
|
|
const dotColor = isUp ? 'var(--status-green)' : 'var(--status-red)';
|
|
const ipDisplay = iface.ipv4 ? iface.ipv4.split('/')[0] : '--';
|
|
const cidr = iface.ipv4 || '';
|
|
const method = iface.config_method || 'unknown';
|
|
const isStatic = method === 'static';
|
|
|
|
html += `
|
|
<div class="settings-group" id="ifaceGroup-${iface.name}">
|
|
<div class="settings-group-title" style="display:flex;align-items:center;gap:10px;">
|
|
<div style="width:8px;height:8px;border-radius:50%;background:${dotColor};flex-shrink:0;"></div>
|
|
${iface.name.toUpperCase()}
|
|
<span style="font-weight:400;color:var(--text-muted);font-size:9px;letter-spacing:0.5px;">${iface.mac || ''}</span>
|
|
</div>
|
|
<div class="settings-row">
|
|
<div>
|
|
<div class="settings-row-label">Status</div>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:8px;">
|
|
<span style="color:${isUp ? 'var(--status-green)' : 'var(--status-red)'};">${iface.state}</span>
|
|
<button class="settings-btn secondary" style="height:24px;font-size:11px;padding:0 8px;"
|
|
data-iface-toggle="${iface.name}" data-is-up="${isUp}">
|
|
${isUp ? 'Disable' : 'Enable'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="settings-row">
|
|
<div>
|
|
<div class="settings-row-label">IPv4</div>
|
|
</div>
|
|
<span style="font-family:var(--font-mono);font-size:13px;">${ipDisplay}</span>
|
|
</div>
|
|
${iface.gateway ? `
|
|
<div class="settings-row">
|
|
<div><div class="settings-row-label">Gateway</div></div>
|
|
<span style="font-family:var(--font-mono);font-size:13px;">${iface.gateway}</span>
|
|
</div>` : ''}
|
|
<div class="settings-row">
|
|
<div>
|
|
<div class="settings-row-label">IP Configuration</div>
|
|
</div>
|
|
<div class="net-method-radios" data-iface-method="${iface.name}">
|
|
<label class="net-radio-label">
|
|
<input type="radio" name="ifMethod-${iface.name}" value="dhcp" ${!isStatic ? 'checked' : ''}>
|
|
<span>DHCP</span>
|
|
</label>
|
|
<label class="net-radio-label">
|
|
<input type="radio" name="ifMethod-${iface.name}" value="static" ${isStatic ? 'checked' : ''}>
|
|
<span>Static</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
${iface.wireless ? `
|
|
<div class="net-wifi-fields">
|
|
<div class="settings-row">
|
|
<div><div class="settings-row-label">WiFi SSID</div></div>
|
|
<div style="display:flex;align-items:center;gap:8px;">
|
|
<input class="settings-input" id="ifSsid-${iface.name}" value="${iface.config_ssid || ''}" placeholder="Network name">
|
|
<button class="settings-btn secondary" style="height:40px;font-size:11px;padding:0 10px;white-space:nowrap;" data-wifi-scan="${iface.name}">Scan</button>
|
|
</div>
|
|
</div>
|
|
<div id="wifiScanResults-${iface.name}" class="wifi-scan-results hidden"></div>
|
|
<div class="settings-row">
|
|
<div><div class="settings-row-label">WiFi Password</div></div>
|
|
<input class="settings-input" id="ifWifiPass-${iface.name}" type="password" placeholder="Password">
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
<div class="net-static-fields ${isStatic ? '' : 'hidden'}" id="staticFields-${iface.name}">
|
|
<div class="settings-row">
|
|
<div><div class="settings-row-label">IP Address</div><div class="settings-row-desc">CIDR notation (e.g. 192.168.1.100/24)</div></div>
|
|
<input class="settings-input" id="ifAddr-${iface.name}" value="${cidr}" placeholder="192.168.1.100/24">
|
|
</div>
|
|
<div class="settings-row">
|
|
<div><div class="settings-row-label">Gateway</div></div>
|
|
<input class="settings-input" id="ifGw-${iface.name}" value="${iface.gateway || iface.config_gateway || ''}" placeholder="192.168.1.1">
|
|
</div>
|
|
<div class="settings-row">
|
|
<div><div class="settings-row-label">Primary DNS</div></div>
|
|
<input class="settings-input" id="ifDns1-${iface.name}" value="${dnsServers[0] || ''}" placeholder="8.8.8.8">
|
|
</div>
|
|
<div class="settings-row">
|
|
<div><div class="settings-row-label">Secondary DNS</div></div>
|
|
<input class="settings-input" id="ifDns2-${iface.name}" value="${dnsServers[1] || ''}" placeholder="8.8.4.4">
|
|
</div>
|
|
</div>
|
|
<div id="ifError-${iface.name}" class="net-error hidden"></div>
|
|
<div class="settings-actions">
|
|
<button class="settings-btn" data-iface-apply="${iface.name}">Apply</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// DNS section
|
|
html += `
|
|
<div class="settings-group">
|
|
<div class="settings-group-title">DNS</div>
|
|
<div class="settings-row">
|
|
<div class="settings-row-label">Nameservers</div>
|
|
<span style="font-family:var(--font-mono);font-size:13px;">${dnsServers.join(', ') || '--'}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
contentEl.innerHTML = html;
|
|
|
|
// ---- Event handlers ----
|
|
|
|
// Interface up/down toggle
|
|
contentEl.querySelectorAll('[data-iface-toggle]').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const name = btn.dataset.ifaceToggle;
|
|
const isUp = btn.dataset.isUp === 'true';
|
|
const action = isUp ? 'down' : 'up';
|
|
|
|
if (isUp && !confirm(`Bring down ${name}? You may lose connectivity if this is your primary interface.`)) return;
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = isUp ? 'Disabling…' : 'Enabling…';
|
|
try {
|
|
const res = await Atlus.apiFetch(`/api/network/interfaces/${name}/${action}`, { method: 'POST' });
|
|
if (res && !res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
alert(err.detail || 'Failed to change interface state');
|
|
btn.disabled = false;
|
|
btn.textContent = isUp ? 'Disable' : 'Enable';
|
|
return;
|
|
}
|
|
} catch (e) { /* may lose connection */ }
|
|
setTimeout(() => _renderNetworkReadonly(), 2000);
|
|
});
|
|
});
|
|
|
|
// DHCP/Static radio buttons
|
|
contentEl.querySelectorAll('[data-iface-method]').forEach(group => {
|
|
const name = group.dataset.ifaceMethod;
|
|
const staticFields = contentEl.querySelector(`#staticFields-${name}`);
|
|
group.querySelectorAll('input[type="radio"]').forEach(radio => {
|
|
radio.addEventListener('change', () => {
|
|
staticFields.classList.toggle('hidden', radio.value !== 'static');
|
|
});
|
|
});
|
|
});
|
|
|
|
// WiFi scan buttons
|
|
contentEl.querySelectorAll('[data-wifi-scan]').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const name = btn.dataset.wifiScan;
|
|
const resultsEl = contentEl.querySelector(`#wifiScanResults-${name}`);
|
|
btn.disabled = true;
|
|
btn.textContent = 'Scanning…';
|
|
resultsEl.classList.remove('hidden');
|
|
resultsEl.innerHTML = '<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">Scanning…</div>';
|
|
|
|
try {
|
|
const res = await Atlus.apiFetch(`/api/network/wifi/scan/${name}`);
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
resultsEl.innerHTML = `<div class="net-error">Failed to scan: ${err.detail || 'Unknown error'}</div>`;
|
|
} else {
|
|
const data = await res.json();
|
|
const networks = data.networks || [];
|
|
if (networks.length === 0) {
|
|
const errMsg = data.error ? `<br><span style="font-size:11px;">${data.error}</span>` : '';
|
|
resultsEl.innerHTML = `<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">No networks found${errMsg}</div>`;
|
|
} else {
|
|
resultsEl.innerHTML = '';
|
|
for (const net of networks) {
|
|
const sig = _signalLabel(net.signal);
|
|
const item = document.createElement('div');
|
|
item.className = 'wifi-scan-item';
|
|
item.innerHTML = `
|
|
<span class="wifi-scan-ssid">${net.ssid}</span>
|
|
<span style="font-family:var(--font-mono);font-size:11px;color:${sig.color};">${sig.text}</span>
|
|
<span style="font-family:var(--font-mono);font-size:11px;color:var(--text-muted);">${net.security || 'Open'}</span>
|
|
`;
|
|
item.addEventListener('click', () => {
|
|
contentEl.querySelector(`#ifSsid-${name}`).value = net.ssid;
|
|
resultsEl.classList.add('hidden');
|
|
});
|
|
resultsEl.appendChild(item);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
resultsEl.innerHTML = '<div class="net-error">Scan failed</div>';
|
|
}
|
|
btn.disabled = false;
|
|
btn.textContent = 'Scan';
|
|
});
|
|
});
|
|
|
|
// Apply config
|
|
contentEl.querySelectorAll('[data-iface-apply]').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const name = btn.dataset.ifaceApply;
|
|
const errEl = contentEl.querySelector(`#ifError-${name}`);
|
|
errEl.classList.add('hidden');
|
|
|
|
const methodGroup = contentEl.querySelector(`[data-iface-method="${name}"]`);
|
|
const selectedRadio = methodGroup.querySelector('input[type="radio"]:checked');
|
|
const method = selectedRadio ? selectedRadio.value : 'dhcp';
|
|
const isStatic = method === 'static';
|
|
const body = { method };
|
|
|
|
if (isStatic) {
|
|
body.address = contentEl.querySelector(`#ifAddr-${name}`).value.trim();
|
|
body.gateway = contentEl.querySelector(`#ifGw-${name}`).value.trim();
|
|
const dns1 = contentEl.querySelector(`#ifDns1-${name}`).value.trim();
|
|
const dns2 = contentEl.querySelector(`#ifDns2-${name}`).value.trim();
|
|
body.dns = [dns1, dns2].filter(Boolean);
|
|
|
|
if (!body.address || !body.gateway) {
|
|
errEl.textContent = 'IP address and gateway are required for static configuration.';
|
|
errEl.classList.remove('hidden');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Include WiFi credentials if this is a wireless interface
|
|
const ssidInput = contentEl.querySelector(`#ifSsid-${name}`);
|
|
const wifiPassInput = contentEl.querySelector(`#ifWifiPass-${name}`);
|
|
if (ssidInput) {
|
|
const ssid = ssidInput.value.trim();
|
|
if (ssid) body.ssid = ssid;
|
|
if (wifiPassInput) {
|
|
const wifiPass = wifiPassInput.value;
|
|
if (wifiPass) body.wifi_password = wifiPass;
|
|
}
|
|
}
|
|
|
|
if (!confirm(`Apply network changes to ${name}? If this is your primary interface, you may lose access temporarily.`)) return;
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = 'Applying…';
|
|
|
|
try {
|
|
const res = await Atlus.apiFetch(`/api/network/interfaces/${name}/config`, {
|
|
method: 'PUT',
|
|
body: body,
|
|
});
|
|
|
|
if (res && res.ok) {
|
|
btn.textContent = 'Applied ✓';
|
|
setTimeout(() => _renderNetworkReadonly(), 3000);
|
|
} else {
|
|
const err = res ? await res.json().catch(() => ({})) : {};
|
|
errEl.textContent = err.detail || 'Failed to apply configuration.';
|
|
errEl.classList.remove('hidden');
|
|
btn.disabled = false;
|
|
btn.textContent = 'Apply';
|
|
}
|
|
} catch (e) {
|
|
// Connection may be lost if changing the interface we're connected through
|
|
btn.textContent = 'Applied (reconnecting…)';
|
|
setTimeout(() => {
|
|
window.location.reload();
|
|
}, 5000);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function _loadEthernetConfig(dev) {
|
|
const groupEl = contentEl.querySelector(`#ethConfig-${dev.device}`);
|
|
if (!groupEl) return;
|
|
|
|
let connRes;
|
|
try {
|
|
connRes = await Atlus.apiFetch(`/api/network/connection/${encodeURIComponent(dev.connection)}`);
|
|
} catch (e) {
|
|
groupEl.innerHTML += '<div class="net-error">Failed to load configuration</div>';
|
|
return;
|
|
}
|
|
|
|
if (!connRes.ok) {
|
|
groupEl.innerHTML = `<div class="settings-group-title">${dev.device.toUpperCase()} CONFIGURATION</div><div class="net-error">Failed to load configuration</div>`;
|
|
return;
|
|
}
|
|
|
|
const conn = await connRes.json();
|
|
const isStatic = conn.ipv4.method === 'manual';
|
|
const currentAddr = conn.active_ipv4.address || conn.ipv4.addresses || '';
|
|
const currentGw = conn.active_ipv4.gateway || conn.ipv4.gateway || '';
|
|
const currentDns = conn.active_ipv4.dns.length ? conn.active_ipv4.dns : (conn.ipv4.dns ? conn.ipv4.dns.split(',').map(s => s.trim()) : []);
|
|
|
|
groupEl.innerHTML = `
|
|
<div class="settings-group-title">${dev.device.toUpperCase()} CONFIGURATION</div>
|
|
<div class="settings-row">
|
|
<div>
|
|
<div class="settings-row-label">IP Configuration</div>
|
|
</div>
|
|
<div class="net-method-radios" id="methodRadios-${dev.device}">
|
|
<label class="net-radio-label">
|
|
<input type="radio" name="ethMethod-${dev.device}" value="dhcp" ${!isStatic ? 'checked' : ''}>
|
|
<span>DHCP</span>
|
|
</label>
|
|
<label class="net-radio-label">
|
|
<input type="radio" name="ethMethod-${dev.device}" value="static" ${isStatic ? 'checked' : ''}>
|
|
<span>Static</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="net-static-fields ${isStatic ? '' : 'hidden'}" id="staticFields-${dev.device}">
|
|
<div class="settings-row">
|
|
<div><div class="settings-row-label">IP Address</div><div class="settings-row-desc">CIDR notation (e.g. 192.168.1.100/24)</div></div>
|
|
<input class="settings-input" id="ethAddr-${dev.device}" value="${currentAddr}" placeholder="192.168.1.100/24">
|
|
</div>
|
|
<div class="settings-row">
|
|
<div><div class="settings-row-label">Gateway</div></div>
|
|
<input class="settings-input" id="ethGw-${dev.device}" value="${currentGw}" placeholder="192.168.1.1">
|
|
</div>
|
|
<div class="settings-row">
|
|
<div><div class="settings-row-label">Primary DNS</div></div>
|
|
<input class="settings-input" id="ethDns1-${dev.device}" value="${currentDns[0] || ''}" placeholder="8.8.8.8">
|
|
</div>
|
|
<div class="settings-row">
|
|
<div><div class="settings-row-label">Secondary DNS</div></div>
|
|
<input class="settings-input" id="ethDns2-${dev.device}" value="${currentDns[1] || ''}" placeholder="8.8.4.4">
|
|
</div>
|
|
</div>
|
|
<div id="ethError-${dev.device}" class="net-error hidden"></div>
|
|
<div class="settings-actions">
|
|
<button class="settings-btn" id="ethApply-${dev.device}">Apply</button>
|
|
</div>
|
|
`;
|
|
|
|
// Radio handler
|
|
const methodRadios = groupEl.querySelector(`#methodRadios-${dev.device}`);
|
|
const staticFields = groupEl.querySelector(`#staticFields-${dev.device}`);
|
|
methodRadios.querySelectorAll('input[type="radio"]').forEach(radio => {
|
|
radio.addEventListener('change', () => {
|
|
staticFields.classList.toggle('hidden', radio.value !== 'static');
|
|
});
|
|
});
|
|
|
|
// Apply handler
|
|
groupEl.querySelector(`#ethApply-${dev.device}`).addEventListener('click', async () => {
|
|
const errEl = groupEl.querySelector(`#ethError-${dev.device}`);
|
|
errEl.classList.add('hidden');
|
|
|
|
const selectedRadio = methodRadios.querySelector('input[type="radio"]:checked');
|
|
const isNowStatic = selectedRadio && selectedRadio.value === 'static';
|
|
const body = { method: isNowStatic ? 'manual' : 'auto' };
|
|
|
|
if (isNowStatic) {
|
|
body.address = groupEl.querySelector(`#ethAddr-${dev.device}`).value.trim();
|
|
body.gateway = groupEl.querySelector(`#ethGw-${dev.device}`).value.trim();
|
|
const dns1 = groupEl.querySelector(`#ethDns1-${dev.device}`).value.trim();
|
|
const dns2 = groupEl.querySelector(`#ethDns2-${dev.device}`).value.trim();
|
|
body.dns = [dns1, dns2].filter(Boolean);
|
|
|
|
if (!body.address || !body.gateway) {
|
|
errEl.textContent = 'IP address and gateway are required for static configuration.';
|
|
errEl.classList.remove('hidden');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Warn if configuring the interface we're connected through
|
|
if (confirm(`Apply network changes to ${dev.device}? If you are connected through this interface, you may lose access temporarily.`)) {
|
|
const applyBtn = groupEl.querySelector(`#ethApply-${dev.device}`);
|
|
applyBtn.disabled = true;
|
|
applyBtn.textContent = 'Applying…';
|
|
|
|
const res = await Atlus.apiFetch(`/api/network/connection/${encodeURIComponent(dev.connection)}/ipv4`, {
|
|
method: 'PUT',
|
|
body: body,
|
|
});
|
|
|
|
if (res && res.ok) {
|
|
applyBtn.textContent = 'Applied ✓';
|
|
setTimeout(() => renderNetwork(), 3000);
|
|
} else {
|
|
const err = res ? await res.json().catch(() => ({})) : {};
|
|
errEl.textContent = err.detail || 'Failed to apply configuration.';
|
|
errEl.classList.remove('hidden');
|
|
applyBtn.disabled = false;
|
|
applyBtn.textContent = 'Apply';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function _renderWifiStatus(wifiDev) {
|
|
const el = contentEl.querySelector('#wifiStatus');
|
|
if (!el) return;
|
|
|
|
if (wifiDev.state === 'connected' && wifiDev.connection) {
|
|
const ip = wifiDev.ipv4 && wifiDev.ipv4.address ? wifiDev.ipv4.address : '--';
|
|
el.innerHTML = `
|
|
<div class="settings-row">
|
|
<div>
|
|
<div class="settings-row-label">Connected to: ${wifiDev.connection}</div>
|
|
<div class="settings-row-desc">${ip}</div>
|
|
</div>
|
|
<button class="settings-btn secondary" id="wifiDisconnectBtn">Disconnect</button>
|
|
</div>
|
|
`;
|
|
el.querySelector('#wifiDisconnectBtn').addEventListener('click', async () => {
|
|
await Atlus.apiFetch('/api/network/wifi/disconnect', { method: 'POST' });
|
|
setTimeout(() => renderNetwork(), 1500);
|
|
});
|
|
} else {
|
|
el.innerHTML = `
|
|
<div class="settings-row">
|
|
<div class="settings-row-label">Status</div>
|
|
<span style="color:var(--status-red);">Disconnected</span>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
async function _loadWifiNetworks() {
|
|
const listEl = contentEl.querySelector('#wifiNetworkList');
|
|
if (!listEl) return;
|
|
listEl.innerHTML = '<div style="padding:12px;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">Scanning…</div>';
|
|
|
|
const res = await Atlus.apiFetch('/api/network/wifi/list');
|
|
if (!res.ok) {
|
|
listEl.innerHTML = '<div class="net-error">Failed to scan WiFi networks</div>';
|
|
return;
|
|
}
|
|
|
|
const networks = await res.json();
|
|
if (networks.length === 0) {
|
|
listEl.innerHTML = '<div style="padding:12px;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">No networks found</div>';
|
|
return;
|
|
}
|
|
|
|
listEl.innerHTML = '';
|
|
for (const net of networks) {
|
|
const sig = _signalLabel(net.signal);
|
|
const isOpen = !net.security || net.security === '' || net.security === '--';
|
|
const lockIcon = isOpen ? '' : '🔒 ';
|
|
const inUseTag = net.in_use ? ' <span style="color:var(--accent);font-size:11px;">● CONNECTED</span>' : '';
|
|
|
|
const item = document.createElement('div');
|
|
item.className = 'wifi-network-item';
|
|
item.innerHTML = `
|
|
<div class="wifi-network-info">
|
|
<div class="wifi-ssid">${lockIcon}${net.ssid}${inUseTag}</div>
|
|
<div class="wifi-detail">${net.security || 'Open'} · <span style="color:${sig.color};">${sig.text} (${net.signal}%)</span></div>
|
|
</div>
|
|
<div class="wifi-signal">${_signalBars(net.signal)}</div>
|
|
${net.in_use ? '' : '<button class="settings-btn secondary wifi-connect-btn">Connect</button>'}
|
|
`;
|
|
|
|
if (!net.in_use) {
|
|
const connectBtn = item.querySelector('.wifi-connect-btn');
|
|
connectBtn.addEventListener('click', () => {
|
|
if (isOpen) {
|
|
_connectWifi(net.ssid, null, false);
|
|
} else {
|
|
_showWifiPasswordInput(item, net.ssid);
|
|
}
|
|
});
|
|
}
|
|
|
|
listEl.appendChild(item);
|
|
}
|
|
}
|
|
|
|
function _showWifiPasswordInput(itemEl, ssid) {
|
|
// Check if already showing
|
|
if (itemEl.querySelector('.wifi-password-row')) return;
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'wifi-password-row';
|
|
row.innerHTML = `
|
|
<input class="settings-input wifi-pass-input" type="password" placeholder="Password for ${ssid}">
|
|
<button class="settings-btn wifi-pass-connect">Connect</button>
|
|
<button class="settings-btn secondary wifi-pass-cancel">Cancel</button>
|
|
`;
|
|
|
|
row.querySelector('.wifi-pass-connect').addEventListener('click', () => {
|
|
const pass = row.querySelector('.wifi-pass-input').value;
|
|
_connectWifi(ssid, pass, false);
|
|
});
|
|
|
|
row.querySelector('.wifi-pass-cancel').addEventListener('click', () => {
|
|
row.remove();
|
|
});
|
|
|
|
// Enter key support
|
|
row.querySelector('.wifi-pass-input').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
const pass = row.querySelector('.wifi-pass-input').value;
|
|
_connectWifi(ssid, pass, false);
|
|
}
|
|
});
|
|
|
|
itemEl.appendChild(row);
|
|
row.querySelector('.wifi-pass-input').focus();
|
|
}
|
|
|
|
async function _connectWifi(ssid, password, hidden) {
|
|
const body = { ssid, hidden };
|
|
if (password) body.password = password;
|
|
|
|
const listEl = contentEl.querySelector('#wifiNetworkList');
|
|
if (listEl) {
|
|
listEl.innerHTML = `<div style="padding:12px;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">Connecting to ${ssid}…</div>`;
|
|
}
|
|
|
|
const res = await Atlus.apiFetch('/api/network/wifi/connect', {
|
|
method: 'POST',
|
|
body: body,
|
|
});
|
|
|
|
if (res && res.ok) {
|
|
setTimeout(() => renderNetwork(), 2000);
|
|
} else {
|
|
const err = res ? await res.json().catch(() => ({})) : {};
|
|
if (listEl) {
|
|
listEl.innerHTML = `<div class="net-error">Failed to connect: ${err.detail || 'Unknown error'}</div>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Applications section — manage GUI apps + autostart
|
|
// ---------------------------------------------------------------
|
|
|
|
function _slugify(str) {
|
|
return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
}
|
|
|
|
/** Trigger a right-panel apps refresh (calls loadPanelApps if exposed). */
|
|
function _refreshPanel() {
|
|
if (typeof window._atlusLoadPanelApps === 'function') {
|
|
window._atlusLoadPanelApps();
|
|
}
|
|
}
|
|
|
|
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>';
|
|
|
|
// Discover installed apps
|
|
html += `
|
|
<div class="settings-group">
|
|
<div class="settings-group-title">ADD APPLICATION</div>
|
|
<div id="appDiscoverList" class="app-discover-list">
|
|
<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">Loading installed apps…</div>
|
|
</div>
|
|
<div style="margin-top:12px;">
|
|
<button class="settings-btn secondary" id="appAddManualToggle" style="font-size:12px;">+ Add manually</button>
|
|
</div>
|
|
<div id="appAddManualForm" class="app-manual-form hidden">
|
|
<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 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>
|
|
</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 },
|
|
});
|
|
_refreshPanel();
|
|
renderApplications();
|
|
});
|
|
});
|
|
|
|
// Manual form toggle
|
|
contentEl.querySelector('#appAddManualToggle').addEventListener('click', () => {
|
|
const form = contentEl.querySelector('#appAddManualForm');
|
|
form.classList.toggle('hidden');
|
|
});
|
|
|
|
// Manual add
|
|
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;
|
|
}
|
|
|
|
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 },
|
|
});
|
|
|
|
_refreshPanel();
|
|
renderApplications();
|
|
});
|
|
|
|
// Load discovered apps
|
|
_loadDiscoverableApps(guiApps);
|
|
}
|
|
|
|
async function _loadDiscoverableApps(existingApps) {
|
|
const listEl = contentEl.querySelector('#appDiscoverList');
|
|
if (!listEl) return;
|
|
|
|
try {
|
|
const res = await Atlus.apiFetch('/api/display/discover-apps');
|
|
if (!res.ok) {
|
|
listEl.innerHTML = '<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">Could not scan for installed apps</div>';
|
|
return;
|
|
}
|
|
const discovered = await res.json();
|
|
|
|
// Filter out apps already configured
|
|
const existingCmds = new Set(existingApps.map(a => a.command));
|
|
const available = discovered.filter(a => !existingCmds.has(a.command));
|
|
|
|
if (available.length === 0) {
|
|
listEl.innerHTML = '<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">No additional applications found on this system</div>';
|
|
return;
|
|
}
|
|
|
|
listEl.innerHTML = '';
|
|
for (const app of available) {
|
|
const row = document.createElement('div');
|
|
row.className = 'app-discover-item';
|
|
row.innerHTML = `
|
|
<div class="app-config-info">
|
|
<span class="app-config-icon">${_desktopIcon(app.icon)}</span>
|
|
<div class="app-config-details">
|
|
<div class="app-config-name">${app.name}</div>
|
|
<div class="app-config-cmd">${app.command}${app.comment ? ' — ' + app.comment : ''}</div>
|
|
</div>
|
|
</div>
|
|
<button class="settings-btn" style="height:28px;font-size:11px;padding:0 14px;flex-shrink:0;">Add</button>
|
|
`;
|
|
row.querySelector('.settings-btn').addEventListener('click', async () => {
|
|
const newApp = {
|
|
id: _slugify(app.name),
|
|
name: app.name,
|
|
command: app.command,
|
|
icon: _desktopIcon(app.icon),
|
|
args: [],
|
|
target_fps: 10,
|
|
autostart: false,
|
|
};
|
|
const apps = [...existingApps, newApp];
|
|
await Atlus.apiFetch('/api/settings', {
|
|
method: 'PUT',
|
|
body: { gui_apps: apps },
|
|
});
|
|
_refreshPanel();
|
|
renderApplications();
|
|
});
|
|
listEl.appendChild(row);
|
|
}
|
|
} catch (e) {
|
|
listEl.innerHTML = '<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">Failed to scan for apps</div>';
|
|
}
|
|
}
|
|
|
|
function _desktopIcon(icon) {
|
|
// If it's already an emoji or short text, use it directly
|
|
// If it's a path or icon name (from .desktop files), use a default emoji
|
|
if (!icon) return '🖥';
|
|
// Emoji detection: if first char is > U+2000, likely an emoji
|
|
if (icon.codePointAt(0) > 0x2000) return icon;
|
|
// Short text like "A" or icon names like "nextcloud" — use default
|
|
if (icon.length <= 3 && !icon.includes('/')) return icon;
|
|
return '🖥';
|
|
}
|
|
|
|
async function renderServicesConfig() {
|
|
const cfgRes = await Atlus.apiFetch('/api/settings');
|
|
const cfg = await cfgRes.json();
|
|
const panelServices = cfg.panel_services || [];
|
|
|
|
contentEl.innerHTML = `
|
|
<div class="settings-section-title">Services</div>
|
|
<div class="settings-group">
|
|
<div class="settings-group-title">MONITORED SERVICES</div>
|
|
<div class="settings-row-desc" style="margin-bottom:16px;">
|
|
Comma-separated list of systemd unit names to monitor via the Services app.
|
|
</div>
|
|
<textarea class="settings-input" id="setPanelServices" rows="4"
|
|
style="width:100%;height:100px;padding:12px;resize:vertical;"
|
|
placeholder="e.g. nginx.service, docker.service">${panelServices.join(', ')}</textarea>
|
|
</div>
|
|
<div class="settings-actions">
|
|
<button class="settings-btn" id="saveServices">Save</button>
|
|
</div>
|
|
`;
|
|
|
|
contentEl.querySelector('#saveServices').addEventListener('click', async () => {
|
|
const raw = contentEl.querySelector('#setPanelServices').value;
|
|
const units = raw.split(',').map(s => s.trim()).filter(Boolean);
|
|
await Atlus.apiFetch('/api/settings', {
|
|
method: 'PUT',
|
|
body: { panel_services: units },
|
|
});
|
|
alert('Panel services updated.');
|
|
});
|
|
}
|
|
|
|
async function renderAbout() {
|
|
const res = await Atlus.apiFetch('/api/settings/system');
|
|
const sys = await res.json();
|
|
|
|
contentEl.innerHTML = `
|
|
<div class="settings-section-title">About</div>
|
|
<div class="settings-group">
|
|
<div style="text-align:center;margin-bottom:32px;">
|
|
<div style="font-family:var(--font-mono);font-size:48px;font-weight:500;color:var(--accent);margin-bottom:8px;">A</div>
|
|
<div style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted);letter-spacing:4px;">ATLUS v0.1.0</div>
|
|
</div>
|
|
<div class="settings-row">
|
|
<div class="settings-row-label">Hostname</div>
|
|
<span style="font-family:var(--font-mono);font-size:13px;">${sys.hostname}</span>
|
|
</div>
|
|
<div class="settings-row">
|
|
<div class="settings-row-label">Operating System</div>
|
|
<span style="font-family:var(--font-mono);font-size:13px;">${sys.os}</span>
|
|
</div>
|
|
<div class="settings-row">
|
|
<div class="settings-row-label">Kernel</div>
|
|
<span style="font-family:var(--font-mono);font-size:13px;">${sys.kernel}</span>
|
|
</div>
|
|
<div class="settings-row">
|
|
<div class="settings-row-label">Architecture</div>
|
|
<span style="font-family:var(--font-mono);font-size:13px;">${sys.arch}</span>
|
|
</div>
|
|
<div class="settings-row">
|
|
<div class="settings-row-label">Python</div>
|
|
<span style="font-family:var(--font-mono);font-size:13px;">${sys.python}</span>
|
|
</div>
|
|
</div>
|
|
<div style="text-align:center;margin-top:32px;">
|
|
<div style="color:var(--text-muted);font-size:12px;font-family:var(--font-mono);">
|
|
Licensed under GPL-3.0
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
Atlus.registerApp('settings', {
|
|
title: 'Settings',
|
|
|
|
init(el) {
|
|
container = el;
|
|
container.classList.add('app-settings');
|
|
|
|
// Nav
|
|
const nav = document.createElement('div');
|
|
nav.className = 'settings-nav';
|
|
SECTIONS.forEach(s => {
|
|
const item = document.createElement('button');
|
|
item.className = 'settings-nav-item' + (s.id === activeSection ? ' active' : '');
|
|
item.dataset.section = s.id;
|
|
item.textContent = s.label;
|
|
item.addEventListener('click', () => loadSection(s.id));
|
|
nav.appendChild(item);
|
|
});
|
|
container.appendChild(nav);
|
|
|
|
// Content
|
|
contentEl = document.createElement('div');
|
|
contentEl.className = 'settings-content';
|
|
container.appendChild(contentEl);
|
|
|
|
loadSection(activeSection);
|
|
},
|
|
|
|
destroy() {
|
|
container = null;
|
|
contentEl = null;
|
|
activeSection = 'general';
|
|
},
|
|
});
|
|
})();
|