2026-01-08 18:33:14 +00:00
|
|
|
/**
|
|
|
|
|
* UI Server
|
|
|
|
|
*
|
|
|
|
|
* HTTP server for the management UI on port 3006
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type { EcoDaemon } from '../daemon/index.ts';
|
2026-01-09 09:41:47 +00:00
|
|
|
import { VERSION } from '../version.ts';
|
2026-01-08 18:33:14 +00:00
|
|
|
|
|
|
|
|
export class UIServer {
|
|
|
|
|
private port: number;
|
|
|
|
|
private daemon: EcoDaemon;
|
|
|
|
|
private clients: Set<WebSocket> = new Set();
|
|
|
|
|
|
|
|
|
|
constructor(port: number, daemon: EcoDaemon) {
|
|
|
|
|
this.port = port;
|
|
|
|
|
this.daemon = daemon;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async start(): Promise<void> {
|
|
|
|
|
Deno.serve({ port: this.port, hostname: '0.0.0.0' }, (req) =>
|
|
|
|
|
this.handleRequest(req)
|
|
|
|
|
);
|
|
|
|
|
console.log(`Management UI running on http://0.0.0.0:${this.port}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async handleRequest(req: Request): Promise<Response> {
|
|
|
|
|
const url = new URL(req.url);
|
|
|
|
|
const path = url.pathname;
|
|
|
|
|
|
|
|
|
|
// Handle WebSocket upgrade
|
|
|
|
|
if (path === '/ws') {
|
|
|
|
|
return this.handleWebSocket(req);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// API routes
|
|
|
|
|
if (path.startsWith('/api/')) {
|
|
|
|
|
return this.handleApi(req, path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Static files / UI
|
|
|
|
|
if (path === '/' || path === '/index.html') {
|
|
|
|
|
return this.serveHtml();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new Response('Not Found', { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private handleWebSocket(req: Request): Response {
|
|
|
|
|
const { socket, response } = Deno.upgradeWebSocket(req);
|
|
|
|
|
|
|
|
|
|
socket.onopen = () => {
|
|
|
|
|
this.clients.add(socket);
|
|
|
|
|
console.log('WebSocket client connected');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
socket.onclose = () => {
|
|
|
|
|
this.clients.delete(socket);
|
|
|
|
|
console.log('WebSocket client disconnected');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
socket.onerror = (e) => {
|
|
|
|
|
console.error('WebSocket error:', e);
|
|
|
|
|
this.clients.delete(socket);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
broadcast(data: unknown): void {
|
|
|
|
|
const message = JSON.stringify(data);
|
|
|
|
|
for (const client of this.clients) {
|
|
|
|
|
try {
|
|
|
|
|
client.send(message);
|
|
|
|
|
} catch {
|
|
|
|
|
this.clients.delete(client);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async handleApi(req: Request, path: string): Promise<Response> {
|
|
|
|
|
const headers = {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'Access-Control-Allow-Origin': '*',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (path === '/api/status') {
|
|
|
|
|
const status = await this.daemon.getStatus();
|
|
|
|
|
return new Response(JSON.stringify(status), { headers });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (path === '/api/logs') {
|
|
|
|
|
const logs = this.daemon.getLogs();
|
|
|
|
|
return new Response(JSON.stringify({ logs }), { headers });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (path === '/api/reboot' && req.method === 'POST') {
|
|
|
|
|
const result = await this.daemon.rebootSystem();
|
|
|
|
|
return new Response(JSON.stringify(result), { headers });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (path === '/api/restart-chromium' && req.method === 'POST') {
|
|
|
|
|
const result = await this.daemon.restartChromium();
|
|
|
|
|
return new Response(JSON.stringify(result), { headers });
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 16:55:43 +00:00
|
|
|
if (path === '/api/updates') {
|
|
|
|
|
const updates = await this.daemon.getUpdateInfo();
|
|
|
|
|
return new Response(JSON.stringify(updates), { headers });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (path === '/api/updates/check' && req.method === 'POST') {
|
|
|
|
|
await this.daemon.checkForUpdates();
|
|
|
|
|
const updates = await this.daemon.getUpdateInfo();
|
|
|
|
|
return new Response(JSON.stringify(updates), { headers });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (path === '/api/upgrade' && req.method === 'POST') {
|
|
|
|
|
try {
|
|
|
|
|
const body = await req.json();
|
|
|
|
|
const version = body.version;
|
|
|
|
|
if (!version) {
|
|
|
|
|
return new Response(JSON.stringify({ success: false, message: 'Version required' }), { headers });
|
|
|
|
|
}
|
|
|
|
|
const result = await this.daemon.upgradeToVersion(version);
|
|
|
|
|
return new Response(JSON.stringify(result), { headers });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return new Response(JSON.stringify({ success: false, message: String(error) }), { headers });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 18:14:26 +00:00
|
|
|
if (path === '/api/displays') {
|
|
|
|
|
const displays = await this.daemon.getDisplays();
|
|
|
|
|
return new Response(JSON.stringify({ displays }), { headers });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Display control endpoints: /api/displays/{name}/{action}
|
|
|
|
|
const displayMatch = path.match(/^\/api\/displays\/([^/]+)\/(enable|disable|primary)$/);
|
|
|
|
|
if (displayMatch && req.method === 'POST') {
|
|
|
|
|
const name = decodeURIComponent(displayMatch[1]);
|
|
|
|
|
const action = displayMatch[2];
|
|
|
|
|
|
|
|
|
|
let result;
|
|
|
|
|
if (action === 'enable') {
|
|
|
|
|
result = await this.daemon.setDisplayEnabled(name, true);
|
|
|
|
|
} else if (action === 'disable') {
|
|
|
|
|
result = await this.daemon.setDisplayEnabled(name, false);
|
|
|
|
|
} else if (action === 'primary') {
|
|
|
|
|
result = await this.daemon.setKioskDisplay(name);
|
|
|
|
|
}
|
|
|
|
|
return new Response(JSON.stringify(result), { headers });
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-08 18:33:14 +00:00
|
|
|
return new Response(JSON.stringify({ error: 'Not Found' }), {
|
|
|
|
|
status: 404,
|
|
|
|
|
headers,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private serveHtml(): Response {
|
|
|
|
|
const html = `<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
<title>EcoOS Management</title>
|
|
|
|
|
<style>
|
|
|
|
|
:root {
|
|
|
|
|
--bg: #0a0a0a;
|
|
|
|
|
--card: #141414;
|
|
|
|
|
--border: #2a2a2a;
|
|
|
|
|
--text: #e0e0e0;
|
|
|
|
|
--text-dim: #888;
|
|
|
|
|
--accent: #3b82f6;
|
|
|
|
|
--success: #22c55e;
|
|
|
|
|
--warning: #f59e0b;
|
|
|
|
|
--error: #ef4444;
|
|
|
|
|
}
|
|
|
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
|
body {
|
|
|
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
|
|
|
background: var(--bg);
|
|
|
|
|
color: var(--text);
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
padding: 20px;
|
|
|
|
|
}
|
|
|
|
|
.container { max-width: 1200px; margin: 0 auto; }
|
2026-01-09 09:41:47 +00:00
|
|
|
.header {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
}
|
|
|
|
|
h1 { font-size: 24px; margin: 0; }
|
|
|
|
|
.clock {
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
color: var(--text);
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
}
|
2026-01-08 18:33:14 +00:00
|
|
|
.grid {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
|
|
|
gap: 20px;
|
|
|
|
|
}
|
|
|
|
|
.card {
|
|
|
|
|
background: var(--card);
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
padding: 16px;
|
|
|
|
|
}
|
|
|
|
|
.card h2 {
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
color: var(--text-dim);
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
}
|
|
|
|
|
.stat { margin-bottom: 8px; }
|
|
|
|
|
.stat-label { color: var(--text-dim); font-size: 12px; }
|
|
|
|
|
.stat-value { font-size: 18px; font-weight: 600; }
|
|
|
|
|
.progress-bar {
|
|
|
|
|
background: var(--border);
|
|
|
|
|
height: 6px;
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
margin-top: 4px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
.progress-fill {
|
|
|
|
|
height: 100%;
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
transition: width 0.3s;
|
|
|
|
|
}
|
|
|
|
|
.logs {
|
|
|
|
|
height: 300px;
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
font-family: 'SF Mono', Monaco, monospace;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
background: #0d0d0d;
|
|
|
|
|
padding: 12px;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
}
|
|
|
|
|
.log-entry { white-space: pre-wrap; word-break: break-all; }
|
|
|
|
|
.status-dot {
|
|
|
|
|
display: inline-block;
|
|
|
|
|
width: 8px;
|
|
|
|
|
height: 8px;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
margin-right: 8px;
|
|
|
|
|
}
|
|
|
|
|
.status-dot.running { background: var(--success); }
|
|
|
|
|
.status-dot.stopped { background: var(--error); }
|
|
|
|
|
.network-item {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
padding: 8px 0;
|
|
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
|
}
|
|
|
|
|
.network-item:last-child { border-bottom: none; }
|
|
|
|
|
.btn {
|
|
|
|
|
padding: 10px 16px;
|
|
|
|
|
border: none;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: opacity 0.2s;
|
|
|
|
|
margin-right: 8px;
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
}
|
|
|
|
|
.btn:hover { opacity: 0.85; }
|
|
|
|
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
|
.btn-primary { background: var(--accent); color: white; }
|
|
|
|
|
.btn-danger { background: var(--error); color: white; }
|
|
|
|
|
.device-item {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
padding: 8px 0;
|
|
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
|
}
|
|
|
|
|
.device-item:last-child { border-bottom: none; }
|
|
|
|
|
.device-name { font-weight: 500; }
|
|
|
|
|
.device-type {
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
padding: 2px 6px;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
background: var(--border);
|
|
|
|
|
color: var(--text-dim);
|
|
|
|
|
}
|
|
|
|
|
.device-default {
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
padding: 2px 6px;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
background: var(--success);
|
|
|
|
|
color: white;
|
|
|
|
|
}
|
2026-01-09 14:34:51 +00:00
|
|
|
.tabs {
|
|
|
|
|
display: flex;
|
|
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
}
|
|
|
|
|
.tab {
|
|
|
|
|
padding: 8px 16px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
color: var(--text-dim);
|
|
|
|
|
border-bottom: 2px solid transparent;
|
|
|
|
|
margin-bottom: -1px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
}
|
|
|
|
|
.tab:hover { color: var(--text); }
|
|
|
|
|
.tab.active {
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
border-bottom-color: var(--accent);
|
|
|
|
|
}
|
|
|
|
|
.tab-content { display: none; }
|
|
|
|
|
.tab-content.active { display: block; }
|
2026-01-08 18:33:14 +00:00
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<div class="container">
|
2026-01-09 09:41:47 +00:00
|
|
|
<div class="header">
|
|
|
|
|
<h1>EcoOS Management <span style="font-size: 12px; color: var(--text-dim); font-weight: normal;">v${VERSION}</span></h1>
|
|
|
|
|
<div class="clock" id="clock"></div>
|
|
|
|
|
</div>
|
2026-01-08 18:33:14 +00:00
|
|
|
<div class="grid">
|
|
|
|
|
<div class="card">
|
|
|
|
|
<h2>Services</h2>
|
|
|
|
|
<div class="stat">
|
|
|
|
|
<span class="status-dot" id="sway-status"></span>
|
|
|
|
|
Sway Compositor
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat">
|
|
|
|
|
<span class="status-dot" id="chromium-status"></span>
|
|
|
|
|
Chromium Browser
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card">
|
|
|
|
|
<h2>CPU</h2>
|
|
|
|
|
<div class="stat">
|
|
|
|
|
<div class="stat-label">Model</div>
|
|
|
|
|
<div class="stat-value" id="cpu-model">-</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat">
|
|
|
|
|
<div class="stat-label">Cores</div>
|
|
|
|
|
<div class="stat-value" id="cpu-cores">-</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat">
|
|
|
|
|
<div class="stat-label">Usage</div>
|
|
|
|
|
<div class="stat-value" id="cpu-usage">-</div>
|
|
|
|
|
<div class="progress-bar"><div class="progress-fill" id="cpu-bar"></div></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card">
|
|
|
|
|
<h2>Memory</h2>
|
|
|
|
|
<div class="stat">
|
|
|
|
|
<div class="stat-label">Used / Total</div>
|
|
|
|
|
<div class="stat-value" id="memory-usage">-</div>
|
|
|
|
|
<div class="progress-bar"><div class="progress-fill" id="memory-bar"></div></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card">
|
|
|
|
|
<h2>Network</h2>
|
|
|
|
|
<div id="network-list"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card">
|
|
|
|
|
<h2>Disks</h2>
|
|
|
|
|
<div id="disk-list"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card">
|
|
|
|
|
<h2>System</h2>
|
|
|
|
|
<div class="stat">
|
|
|
|
|
<div class="stat-label">Hostname</div>
|
|
|
|
|
<div class="stat-value" id="hostname">-</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat">
|
|
|
|
|
<div class="stat-label">Uptime</div>
|
|
|
|
|
<div class="stat-value" id="uptime">-</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat">
|
|
|
|
|
<div class="stat-label">GPU</div>
|
|
|
|
|
<div class="stat-value" id="gpu">-</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card">
|
|
|
|
|
<h2>Controls</h2>
|
|
|
|
|
<button class="btn btn-primary" id="btn-restart-chromium" onclick="restartChromium()">
|
|
|
|
|
Restart Browser
|
|
|
|
|
</button>
|
|
|
|
|
<button class="btn btn-danger" id="btn-reboot" onclick="rebootSystem()">
|
|
|
|
|
Reboot System
|
|
|
|
|
</button>
|
|
|
|
|
<div id="control-status" style="margin-top: 8px; font-size: 12px; color: var(--text-dim);"></div>
|
|
|
|
|
</div>
|
2026-01-09 16:55:43 +00:00
|
|
|
<div class="card">
|
|
|
|
|
<h2>Updates</h2>
|
|
|
|
|
<div class="stat">
|
|
|
|
|
<div class="stat-label">Current Version</div>
|
|
|
|
|
<div class="stat-value" id="current-version">-</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="updates-list" style="margin: 12px 0;"></div>
|
|
|
|
|
<div id="auto-upgrade-status" style="font-size: 12px; color: var(--text-dim);"></div>
|
|
|
|
|
<button class="btn btn-primary" onclick="checkForUpdates()" style="margin-top: 8px;">
|
|
|
|
|
Check for Updates
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-01-09 18:14:26 +00:00
|
|
|
<div class="card">
|
|
|
|
|
<h2>Displays</h2>
|
|
|
|
|
<div id="displays-list"></div>
|
|
|
|
|
</div>
|
2026-01-08 18:33:14 +00:00
|
|
|
<div class="card">
|
|
|
|
|
<h2>Input Devices</h2>
|
|
|
|
|
<div id="input-devices-list"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card">
|
|
|
|
|
<h2>Speakers</h2>
|
|
|
|
|
<div id="speakers-list"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card">
|
|
|
|
|
<h2>Microphones</h2>
|
|
|
|
|
<div id="microphones-list"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card" style="grid-column: 1 / -1;">
|
2026-01-09 14:34:51 +00:00
|
|
|
<div class="tabs">
|
|
|
|
|
<div class="tab active" onclick="switchTab('daemon')">Daemon Logs</div>
|
2026-01-09 16:55:43 +00:00
|
|
|
<div class="tab" onclick="switchTab('serial')">System Logs</div>
|
2026-01-09 14:34:51 +00:00
|
|
|
</div>
|
|
|
|
|
<div id="daemon-tab" class="tab-content active">
|
|
|
|
|
<div class="logs" id="logs"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="serial-tab" class="tab-content">
|
|
|
|
|
<div class="logs" id="serial-logs"></div>
|
|
|
|
|
</div>
|
2026-01-08 18:33:14 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<script>
|
|
|
|
|
function formatBytes(bytes) {
|
|
|
|
|
if (bytes === 0) return '0 B';
|
|
|
|
|
const k = 1024;
|
|
|
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatUptime(seconds) {
|
|
|
|
|
const days = Math.floor(seconds / 86400);
|
|
|
|
|
const hours = Math.floor((seconds % 86400) / 3600);
|
|
|
|
|
const mins = Math.floor((seconds % 3600) / 60);
|
|
|
|
|
if (days > 0) return days + 'd ' + hours + 'h ' + mins + 'm';
|
|
|
|
|
if (hours > 0) return hours + 'h ' + mins + 'm';
|
|
|
|
|
return mins + 'm';
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 14:34:51 +00:00
|
|
|
let initialVersion = null;
|
|
|
|
|
|
2026-01-08 18:33:14 +00:00
|
|
|
function updateStatus(data) {
|
2026-01-09 14:34:51 +00:00
|
|
|
// Check for version change and reload if needed
|
|
|
|
|
if (data.version) {
|
|
|
|
|
if (initialVersion === null) {
|
|
|
|
|
initialVersion = data.version;
|
|
|
|
|
} else if (data.version !== initialVersion) {
|
|
|
|
|
console.log('Server version changed from ' + initialVersion + ' to ' + data.version + ', reloading...');
|
|
|
|
|
location.reload();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-08 18:33:14 +00:00
|
|
|
// Services
|
|
|
|
|
document.getElementById('sway-status').className =
|
|
|
|
|
'status-dot ' + (data.sway ? 'running' : 'stopped');
|
|
|
|
|
document.getElementById('chromium-status').className =
|
|
|
|
|
'status-dot ' + (data.chromium ? 'running' : 'stopped');
|
|
|
|
|
|
|
|
|
|
// System info
|
|
|
|
|
if (data.systemInfo) {
|
|
|
|
|
const info = data.systemInfo;
|
|
|
|
|
|
|
|
|
|
// CPU
|
|
|
|
|
if (info.cpu) {
|
|
|
|
|
document.getElementById('cpu-model').textContent = info.cpu.model;
|
|
|
|
|
document.getElementById('cpu-cores').textContent = info.cpu.cores;
|
|
|
|
|
document.getElementById('cpu-usage').textContent = info.cpu.usage + '%';
|
|
|
|
|
document.getElementById('cpu-bar').style.width = info.cpu.usage + '%';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Memory
|
|
|
|
|
if (info.memory) {
|
|
|
|
|
document.getElementById('memory-usage').textContent =
|
|
|
|
|
formatBytes(info.memory.used) + ' / ' + formatBytes(info.memory.total);
|
|
|
|
|
document.getElementById('memory-bar').style.width = info.memory.usagePercent + '%';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Network
|
|
|
|
|
if (info.network) {
|
|
|
|
|
const list = document.getElementById('network-list');
|
|
|
|
|
list.innerHTML = info.network.map(n =>
|
|
|
|
|
'<div class="network-item"><span>' + n.name + '</span><span>' + n.ip + '</span></div>'
|
|
|
|
|
).join('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Disks
|
|
|
|
|
if (info.disks) {
|
|
|
|
|
const list = document.getElementById('disk-list');
|
|
|
|
|
list.innerHTML = info.disks.map(d =>
|
|
|
|
|
'<div class="stat" style="margin-bottom: 12px;">' +
|
|
|
|
|
'<div class="stat-label">' + d.mountpoint + '</div>' +
|
|
|
|
|
'<div class="stat-value">' + formatBytes(d.used) + ' / ' + formatBytes(d.total) + '</div>' +
|
|
|
|
|
'<div class="progress-bar"><div class="progress-fill" style="width: ' + d.usagePercent + '%"></div></div>' +
|
|
|
|
|
'</div>'
|
|
|
|
|
).join('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Hostname
|
|
|
|
|
if (info.hostname) {
|
|
|
|
|
document.getElementById('hostname').textContent = info.hostname;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Uptime
|
|
|
|
|
if (info.uptime !== undefined) {
|
|
|
|
|
document.getElementById('uptime').textContent = formatUptime(info.uptime);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GPU
|
|
|
|
|
if (info.gpu && info.gpu.length > 0) {
|
|
|
|
|
document.getElementById('gpu').textContent = info.gpu.map(g => g.name).join(', ');
|
|
|
|
|
} else {
|
|
|
|
|
document.getElementById('gpu').textContent = 'None detected';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Input Devices
|
|
|
|
|
if (info.inputDevices) {
|
|
|
|
|
const list = document.getElementById('input-devices-list');
|
|
|
|
|
if (info.inputDevices.length === 0) {
|
|
|
|
|
list.innerHTML = '<div style="color: var(--text-dim);">No input devices detected</div>';
|
|
|
|
|
} else {
|
|
|
|
|
list.innerHTML = info.inputDevices.map(d =>
|
|
|
|
|
'<div class="device-item">' +
|
|
|
|
|
'<span class="device-name">' + d.name + '</span>' +
|
|
|
|
|
'<span class="device-type">' + d.type + '</span>' +
|
|
|
|
|
'</div>'
|
|
|
|
|
).join('');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Speakers
|
|
|
|
|
if (info.speakers) {
|
|
|
|
|
const list = document.getElementById('speakers-list');
|
|
|
|
|
if (info.speakers.length === 0) {
|
|
|
|
|
list.innerHTML = '<div style="color: var(--text-dim);">No speakers detected</div>';
|
|
|
|
|
} else {
|
|
|
|
|
list.innerHTML = info.speakers.map(s =>
|
|
|
|
|
'<div class="device-item">' +
|
|
|
|
|
'<span class="device-name">' + s.description + '</span>' +
|
|
|
|
|
(s.isDefault ? '<span class="device-default">Default</span>' : '') +
|
|
|
|
|
'</div>'
|
|
|
|
|
).join('');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Microphones
|
|
|
|
|
if (info.microphones) {
|
|
|
|
|
const list = document.getElementById('microphones-list');
|
|
|
|
|
if (info.microphones.length === 0) {
|
|
|
|
|
list.innerHTML = '<div style="color: var(--text-dim);">No microphones detected</div>';
|
|
|
|
|
} else {
|
|
|
|
|
list.innerHTML = info.microphones.map(m =>
|
|
|
|
|
'<div class="device-item">' +
|
|
|
|
|
'<span class="device-name">' + m.description + '</span>' +
|
|
|
|
|
(m.isDefault ? '<span class="device-default">Default</span>' : '') +
|
|
|
|
|
'</div>'
|
|
|
|
|
).join('');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 14:34:51 +00:00
|
|
|
// Daemon Logs
|
2026-01-08 18:33:14 +00:00
|
|
|
if (data.logs) {
|
|
|
|
|
const logsEl = document.getElementById('logs');
|
|
|
|
|
logsEl.innerHTML = data.logs.map(l =>
|
|
|
|
|
'<div class="log-entry">' + l + '</div>'
|
|
|
|
|
).join('');
|
|
|
|
|
logsEl.scrollTop = logsEl.scrollHeight;
|
|
|
|
|
}
|
2026-01-09 14:34:51 +00:00
|
|
|
|
2026-01-09 16:55:43 +00:00
|
|
|
// System Logs
|
|
|
|
|
if (data.systemLogs) {
|
2026-01-09 14:34:51 +00:00
|
|
|
const serialEl = document.getElementById('serial-logs');
|
2026-01-09 16:55:43 +00:00
|
|
|
if (data.systemLogs.length === 0) {
|
|
|
|
|
serialEl.innerHTML = '<div style="color: var(--text-dim);">No system logs available</div>';
|
2026-01-09 14:34:51 +00:00
|
|
|
} else {
|
2026-01-09 16:55:43 +00:00
|
|
|
serialEl.innerHTML = data.systemLogs.map(l =>
|
2026-01-09 14:34:51 +00:00
|
|
|
'<div class="log-entry">' + l + '</div>'
|
|
|
|
|
).join('');
|
|
|
|
|
serialEl.scrollTop = serialEl.scrollHeight;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function switchTab(tab) {
|
|
|
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
|
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
|
|
|
if (tab === 'daemon') {
|
|
|
|
|
document.querySelector('.tab:first-child').classList.add('active');
|
|
|
|
|
document.getElementById('daemon-tab').classList.add('active');
|
|
|
|
|
} else {
|
|
|
|
|
document.querySelector('.tab:last-child').classList.add('active');
|
|
|
|
|
document.getElementById('serial-tab').classList.add('active');
|
|
|
|
|
}
|
2026-01-08 18:33:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setControlStatus(msg, isError) {
|
|
|
|
|
const el = document.getElementById('control-status');
|
|
|
|
|
el.textContent = msg;
|
|
|
|
|
el.style.color = isError ? 'var(--error)' : 'var(--success)';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function restartChromium() {
|
|
|
|
|
const btn = document.getElementById('btn-restart-chromium');
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
setControlStatus('Restarting browser...', false);
|
|
|
|
|
|
|
|
|
|
fetch('/api/restart-chromium', { method: 'POST' })
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(result => {
|
|
|
|
|
setControlStatus(result.message, !result.success);
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
})
|
|
|
|
|
.catch(err => {
|
|
|
|
|
setControlStatus('Error: ' + err, true);
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rebootSystem() {
|
|
|
|
|
if (!confirm('Are you sure you want to reboot the system?')) return;
|
|
|
|
|
|
|
|
|
|
const btn = document.getElementById('btn-reboot');
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
setControlStatus('Rebooting system...', false);
|
|
|
|
|
|
|
|
|
|
fetch('/api/reboot', { method: 'POST' })
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(result => {
|
|
|
|
|
setControlStatus(result.message, !result.success);
|
|
|
|
|
if (!result.success) btn.disabled = false;
|
|
|
|
|
})
|
|
|
|
|
.catch(err => {
|
|
|
|
|
setControlStatus('Error: ' + err, true);
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 16:55:43 +00:00
|
|
|
function checkForUpdates() {
|
|
|
|
|
fetch('/api/updates/check', { method: 'POST' })
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(updateUpdatesUI)
|
|
|
|
|
.catch(err => console.error('Failed to check updates:', err));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function upgradeToVersion(version) {
|
|
|
|
|
if (!confirm('Upgrade to version ' + version + '? The daemon will restart.')) return;
|
|
|
|
|
|
|
|
|
|
fetch('/api/upgrade', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ version: version })
|
|
|
|
|
})
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(result => {
|
|
|
|
|
if (result.success) {
|
|
|
|
|
document.getElementById('auto-upgrade-status').textContent = result.message;
|
|
|
|
|
} else {
|
|
|
|
|
alert('Upgrade failed: ' + result.message);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(err => alert('Upgrade error: ' + err));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateUpdatesUI(data) {
|
|
|
|
|
document.getElementById('current-version').textContent = 'v' + data.currentVersion;
|
|
|
|
|
|
|
|
|
|
const list = document.getElementById('updates-list');
|
|
|
|
|
const newerReleases = data.releases.filter(r => r.isNewer);
|
|
|
|
|
|
|
|
|
|
if (newerReleases.length === 0) {
|
|
|
|
|
list.innerHTML = '<div style="color: var(--text-dim);">No updates available</div>';
|
|
|
|
|
} else {
|
|
|
|
|
list.innerHTML = newerReleases.map(r =>
|
|
|
|
|
'<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--border);">' +
|
|
|
|
|
'<span>v' + r.version + ' <span style="color: var(--text-dim);">(' + formatAge(r.ageHours) + ')</span></span>' +
|
|
|
|
|
'<button class="btn btn-primary" style="padding: 4px 12px; margin: 0;" onclick="upgradeToVersion(\\'' + r.version + '\\')">Upgrade</button>' +
|
|
|
|
|
'</div>'
|
|
|
|
|
).join('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const autoStatus = document.getElementById('auto-upgrade-status');
|
|
|
|
|
if (data.autoUpgrade.targetVersion) {
|
|
|
|
|
if (data.autoUpgrade.waitingForStability) {
|
|
|
|
|
autoStatus.textContent = 'Auto-upgrade to v' + data.autoUpgrade.targetVersion + ' in ' + data.autoUpgrade.scheduledIn + ' (stability period)';
|
|
|
|
|
} else {
|
|
|
|
|
autoStatus.textContent = 'Auto-upgrade to v' + data.autoUpgrade.targetVersion + ' pending...';
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
autoStatus.textContent = data.lastCheck ? 'Last checked: ' + new Date(data.lastCheck).toLocaleTimeString() : '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatAge(hours) {
|
|
|
|
|
if (hours < 1) return Math.round(hours * 60) + 'm ago';
|
|
|
|
|
if (hours < 24) return Math.round(hours) + 'h ago';
|
|
|
|
|
return Math.round(hours / 24) + 'd ago';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch updates info periodically
|
|
|
|
|
function fetchUpdates() {
|
|
|
|
|
fetch('/api/updates')
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(updateUpdatesUI)
|
|
|
|
|
.catch(err => console.error('Failed to fetch updates:', err));
|
|
|
|
|
}
|
|
|
|
|
fetchUpdates();
|
|
|
|
|
setInterval(fetchUpdates, 60000); // Check every minute
|
|
|
|
|
|
2026-01-09 18:14:26 +00:00
|
|
|
// Display management
|
2026-01-10 20:40:27 +00:00
|
|
|
function renderDisplayItem(d) {
|
|
|
|
|
return '<div class="device-item" style="flex-wrap: wrap; gap: 8px;">' +
|
|
|
|
|
'<div style="flex: 1; min-width: 150px;">' +
|
|
|
|
|
'<div class="device-name">' + d.name + '</div>' +
|
|
|
|
|
'<div style="font-size: 11px; color: var(--text-dim);">' +
|
|
|
|
|
d.width + 'x' + d.height + ' @ ' + d.refreshRate + 'Hz' +
|
|
|
|
|
(d.make !== 'Unknown' ? ' • ' + d.make : '') +
|
|
|
|
|
'</div>' +
|
|
|
|
|
'</div>' +
|
|
|
|
|
'<div style="display: flex; gap: 4px;">' +
|
|
|
|
|
(d.isPrimary
|
|
|
|
|
? '<span class="device-default">Primary</span>'
|
|
|
|
|
: (d.active ? '<button class="btn btn-primary" style="padding: 2px 8px; margin: 0; font-size: 11px;" onclick="setKioskDisplay(\\'' + d.name + '\\')">Set Primary</button>' : '')) +
|
|
|
|
|
'<button class="btn ' + (d.active ? 'btn-danger' : 'btn-primary') + '" style="padding: 2px 8px; margin: 0; font-size: 11px;" onclick="toggleDisplay(\\'' + d.name + '\\', ' + !d.active + ')">' +
|
|
|
|
|
(d.active ? 'Disable' : 'Enable') +
|
|
|
|
|
'</button>' +
|
|
|
|
|
'</div>' +
|
|
|
|
|
'</div>';
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 18:14:26 +00:00
|
|
|
function updateDisplaysUI(data) {
|
|
|
|
|
const list = document.getElementById('displays-list');
|
|
|
|
|
if (!data.displays || data.displays.length === 0) {
|
|
|
|
|
list.innerHTML = '<div style="color: var(--text-dim);">No displays detected</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-10 20:40:27 +00:00
|
|
|
|
|
|
|
|
const enabled = data.displays.filter(d => d.active);
|
|
|
|
|
const disabled = data.displays.filter(d => !d.active);
|
|
|
|
|
|
|
|
|
|
let html = enabled.map(renderDisplayItem).join('');
|
|
|
|
|
|
|
|
|
|
if (disabled.length > 0) {
|
|
|
|
|
html += '<details style="margin-top: 12px;">' +
|
|
|
|
|
'<summary style="cursor: pointer; color: var(--text-dim); font-size: 12px; padding: 4px 0;">Disabled Displays (' + disabled.length + ')</summary>' +
|
|
|
|
|
'<div style="margin-top: 8px;">' +
|
|
|
|
|
disabled.map(renderDisplayItem).join('') +
|
2026-01-09 18:14:26 +00:00
|
|
|
'</div>' +
|
2026-01-10 20:40:27 +00:00
|
|
|
'</details>';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
list.innerHTML = html;
|
2026-01-09 18:14:26 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fetchDisplays() {
|
|
|
|
|
fetch('/api/displays')
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(updateDisplaysUI)
|
|
|
|
|
.catch(err => console.error('Failed to fetch displays:', err));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleDisplay(name, enable) {
|
|
|
|
|
fetch('/api/displays/' + encodeURIComponent(name) + '/' + (enable ? 'enable' : 'disable'), { method: 'POST' })
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(result => {
|
|
|
|
|
if (!result.success) alert(result.message);
|
|
|
|
|
fetchDisplays();
|
|
|
|
|
})
|
|
|
|
|
.catch(err => alert('Error: ' + err));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setKioskDisplay(name) {
|
|
|
|
|
fetch('/api/displays/' + encodeURIComponent(name) + '/primary', { method: 'POST' })
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(result => {
|
|
|
|
|
if (!result.success) alert(result.message);
|
|
|
|
|
fetchDisplays();
|
|
|
|
|
})
|
|
|
|
|
.catch(err => alert('Error: ' + err));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fetchDisplays();
|
|
|
|
|
setInterval(fetchDisplays, 5000); // Refresh every 5 seconds
|
|
|
|
|
|
2026-01-08 18:33:14 +00:00
|
|
|
// Initial fetch
|
|
|
|
|
fetch('/api/status')
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(updateStatus)
|
|
|
|
|
.catch(console.error);
|
|
|
|
|
|
|
|
|
|
// Periodic refresh
|
|
|
|
|
setInterval(() => {
|
|
|
|
|
fetch('/api/status')
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(updateStatus)
|
|
|
|
|
.catch(console.error);
|
|
|
|
|
}, 3000);
|
|
|
|
|
|
|
|
|
|
// WebSocket for live updates
|
|
|
|
|
const ws = new WebSocket('ws://' + location.host + '/ws');
|
|
|
|
|
ws.onmessage = (e) => {
|
|
|
|
|
try {
|
|
|
|
|
updateStatus(JSON.parse(e.data));
|
|
|
|
|
} catch {}
|
|
|
|
|
};
|
2026-01-09 09:41:47 +00:00
|
|
|
|
|
|
|
|
// Clock update
|
|
|
|
|
function updateClock() {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const options = {
|
|
|
|
|
weekday: 'short',
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
month: 'short',
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
second: '2-digit',
|
|
|
|
|
hour12: false
|
|
|
|
|
};
|
|
|
|
|
document.getElementById('clock').textContent = now.toLocaleString('en-US', options);
|
|
|
|
|
}
|
|
|
|
|
updateClock();
|
|
|
|
|
setInterval(updateClock, 1000);
|
2026-01-08 18:33:14 +00:00
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>`;
|
|
|
|
|
|
|
|
|
|
return new Response(html, {
|
|
|
|
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|