Files
eco_os/ecoos_daemon/ts/ui/server.ts
2026-01-08 18:33:14 +00:00

540 lines
16 KiB
TypeScript

/**
* UI Server
*
* HTTP server for the management UI on port 3006
*/
import type { EcoDaemon } from '../daemon/index.ts';
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 });
}
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; }
h1 { font-size: 24px; margin-bottom: 20px; }
.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;
}
</style>
</head>
<body>
<div class="container">
<h1>EcoOS Management</h1>
<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>
<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;">
<h2>Logs</h2>
<div class="logs" id="logs"></div>
</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';
}
function updateStatus(data) {
// 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('');
}
}
}
// Logs
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;
}
}
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;
});
}
// 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 {}
};
</script>
</body>
</html>`;
return new Response(html, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}
}