540 lines
16 KiB
TypeScript
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' },
|
|
});
|
|
}
|
|
}
|