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>
179 lines
6.8 KiB
JavaScript
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)} ·
|
|
Total recv: ${Atlus.formatBytes(data.network.bytes_recv)}
|
|
</div>
|
|
`;
|
|
html += '</div>';
|
|
el.innerHTML = html;
|
|
});
|
|
},
|
|
|
|
destroy() {},
|
|
});
|
|
})();
|