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-12 01:51:22 +00:00
|
|
|
import { files as bundledFiles } from '../daemon/bundledui.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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-12 01:51:22 +00:00
|
|
|
// Bundled JavaScript
|
|
|
|
|
if (path === '/app.js') {
|
|
|
|
|
return this.serveAppJs();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-08 18:33:14 +00:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-12 01:51:22 +00:00
|
|
|
private serveAppJs(): Response {
|
|
|
|
|
// Find the bundle.js file in the bundled content
|
|
|
|
|
const bundleFile = bundledFiles.find(f => f.path === 'bundle.js');
|
|
|
|
|
if (!bundleFile) {
|
|
|
|
|
return new Response('Bundle not found', { status: 500 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Decode base64 content
|
|
|
|
|
const jsContent = atob(bundleFile.contentBase64);
|
|
|
|
|
|
|
|
|
|
return new Response(jsContent, {
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/javascript; charset=utf-8',
|
|
|
|
|
'Cache-Control': 'no-cache',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-08 18:33:14 +00:00
|
|
|
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">
|
2026-01-12 01:51:22 +00:00
|
|
|
<title>EcoOS Management v${VERSION}</title>
|
2026-01-08 18:33:14 +00:00
|
|
|
<style>
|
|
|
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
2026-01-12 01:51:22 +00:00
|
|
|
html, body {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
2026-01-08 18:33:14 +00:00
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
2026-01-12 01:51:22 +00:00
|
|
|
ecoos-app {
|
|
|
|
|
display: block;
|
|
|
|
|
width: 100%;
|
2026-01-08 18:33:14 +00:00
|
|
|
height: 100%;
|
2026-01-09 14:34:51 +00:00
|
|
|
}
|
2026-01-08 18:33:14 +00:00
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
2026-01-12 01:51:22 +00:00
|
|
|
<ecoos-app></ecoos-app>
|
|
|
|
|
<script type="module" src="/app.js"></script>
|
2026-01-08 18:33:14 +00:00
|
|
|
</body>
|
|
|
|
|
</html>`;
|
|
|
|
|
|
|
|
|
|
return new Response(html, {
|
|
|
|
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|