atlus/frontend/js/apps/services.js
roberts f9743bb29a Initial commit — Atlus web desktop environment for SBCs
Full-stack implementation: FastAPI backend with PAM auth, WebSocket
stats/terminal, and vanilla JS frontend with tiling desktop shell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:53:46 -05:00

179 lines
6.8 KiB
JavaScript

/* Atlus — Service Manager app */
(function () {
'use strict';
let container = null;
let listEl = null;
let searchInput = null;
let allServices = [];
let filterActive = false;
async function loadServices() {
try {
const res = await Atlus.apiFetch('/api/services');
if (!res.ok) return;
allServices = await res.json();
renderServices();
} catch (e) {}
}
function renderServices() {
if (!listEl) return;
const query = searchInput ? searchInput.value.toLowerCase() : '';
let filtered = allServices;
if (query) {
filtered = filtered.filter(s =>
s.name.toLowerCase().includes(query) ||
s.description.toLowerCase().includes(query)
);
}
if (filterActive) {
filtered = filtered.filter(s => s.active === 'active');
}
listEl.innerHTML = '';
// Header
const header = document.createElement('div');
header.className = 'services-header';
header.innerHTML = '<span></span><span>Service</span><span>State</span><span>Sub</span><span></span>';
listEl.appendChild(header);
filtered.forEach(svc => {
const row = document.createElement('div');
row.className = 'service-row';
const isActive = svc.active === 'active';
const stateClass = svc.active === 'active' ? 'active' :
svc.active === 'failed' ? 'failed' : 'inactive';
row.innerHTML = `
<div class="svc-toggle">
<button class="service-toggle ${isActive ? 'on' : ''}" data-unit="${svc.unit}"></button>
</div>
<div>
<div class="svc-name">${svc.name}</div>
<div class="svc-desc">${svc.description}</div>
</div>
<span class="svc-state ${stateClass}">${svc.active}</span>
<span class="svc-sub">${svc.sub}</span>
<div class="svc-actions">
<button class="svc-action-btn" data-unit="${svc.unit}" data-action="restart" title="Restart">↻</button>
</div>
`;
// Toggle handler
const toggle = row.querySelector('.service-toggle');
toggle.addEventListener('click', async () => {
const action = toggle.classList.contains('on') ? 'stop' : 'start';
await Atlus.apiFetch('/api/services/action', {
method: 'POST',
body: { unit: svc.unit, action },
});
loadServices();
});
// Restart handler
const restartBtn = row.querySelector('[data-action="restart"]');
restartBtn.addEventListener('click', async () => {
await Atlus.apiFetch('/api/services/action', {
method: 'POST',
body: { unit: svc.unit, action: 'restart' },
});
loadServices();
});
listEl.appendChild(row);
});
}
Atlus.registerApp('services', {
title: 'Services',
init(el) {
container = el;
container.classList.add('app-services');
// Toolbar
const toolbar = document.createElement('div');
toolbar.className = 'services-toolbar';
searchInput = document.createElement('input');
searchInput.className = 'services-search';
searchInput.type = 'text';
searchInput.placeholder = 'Search services…';
searchInput.addEventListener('input', renderServices);
toolbar.appendChild(searchInput);
const filterBtn = document.createElement('button');
filterBtn.className = 'services-filter-btn';
filterBtn.textContent = 'Active';
filterBtn.addEventListener('click', () => {
filterActive = !filterActive;
filterBtn.classList.toggle('active', filterActive);
renderServices();
});
toolbar.appendChild(filterBtn);
container.appendChild(toolbar);
// List
listEl = document.createElement('div');
listEl.className = 'services-list';
container.appendChild(listEl);
loadServices();
},
destroy() {
container = null;
listEl = null;
searchInput = null;
allServices = [];
filterActive = false;
},
});
// Also register as "network" since it's a similar list-based view
Atlus.registerApp('network', {
title: 'Network',
init(el) {
el.classList.add('app-view');
el.style.padding = '24px';
el.style.overflow = 'auto';
el.innerHTML = '<div style="font-family:var(--font-mono);font-size:13px;color:var(--text-secondary);">Loading network info…</div>';
Atlus.apiFetch('/api/stats').then(res => res.json()).then(data => {
let html = '<div style="font-family:var(--font-mono);font-size:13px;">';
const ifaces = data.network.interfaces;
for (const [name, info] of Object.entries(ifaces)) {
html += `
<div style="margin-bottom:24px;padding:16px;background:var(--bg-titlebar);border:1px solid var(--border-structural);border-radius:var(--radius-md);">
<div style="font-size:16px;font-weight:500;color:var(--text-primary);margin-bottom:12px;">${name}</div>
<div style="display:grid;grid-template-columns:100px 1fr;gap:6px 12px;color:var(--text-secondary);">
<span style="color:var(--text-muted);">Status</span>
<span style="color:${info.up ? 'var(--status-green)' : 'var(--status-red)'};">${info.up ? 'Up' : 'Down'}</span>
<span style="color:var(--text-muted);">IPv4</span>
<span>${info.ipv4 || '--'}</span>
<span style="color:var(--text-muted);">IPv6</span>
<span>${info.ipv6 || '--'}</span>
</div>
</div>
`;
}
html += `
<div style="color:var(--text-muted);margin-top:8px;">
Total sent: ${Atlus.formatBytes(data.network.bytes_sent)} &middot;
Total recv: ${Atlus.formatBytes(data.network.bytes_recv)}
</div>
`;
html += '</div>';
el.innerHTML = html;
});
},
destroy() {},
});
})();