This commit is contained in:
2026-01-08 12:28:54 +00:00
commit d2a473c2bd
45 changed files with 3739 additions and 0 deletions

19
ecoos_daemon/deno.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "@ecobridge/ecoos-daemon",
"version": "0.0.1",
"exports": "./mod.ts",
"tasks": {
"dev": "deno run --allow-all --watch mod.ts",
"start": "deno run --allow-all mod.ts",
"bundle": "deno compile --allow-all --output bundle/eco-daemon mod.ts"
},
"imports": {
"@std/http": "jsr:@std/http@^1.0.0",
"@std/fs": "jsr:@std/fs@^1.0.0",
"@std/path": "jsr:@std/path@^1.0.0",
"@std/async": "jsr:@std/async@^1.0.0"
},
"compilerOptions": {
"strict": true
}
}

BIN
ecoos_daemon/eco-daemon Executable file

Binary file not shown.

14
ecoos_daemon/mod.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* EcoOS Daemon
*
* Main entry point for the EcoOS system daemon.
* Runs as root via systemd and orchestrates:
* - Management UI on :3006
* - Sway compositor as ecouser
* - Chrome browser in kiosk mode
*/
import { EcoDaemon } from './ts/daemon/index.ts';
const daemon = new EcoDaemon();
await daemon.start();

View File

@@ -0,0 +1,320 @@
/**
* EcoOS Daemon
*
* Main daemon class that orchestrates system services
*/
import { ProcessManager } from './process-manager.ts';
import { SystemInfo } from './system-info.ts';
import { UIServer } from '../ui/server.ts';
export interface DaemonConfig {
uiPort: number;
user: string;
waylandDisplay: string;
}
export type ServiceState = 'stopped' | 'starting' | 'running' | 'failed';
export interface ServiceStatus {
state: ServiceState;
error?: string;
lastAttempt?: string;
}
export class EcoDaemon {
private config: DaemonConfig;
private processManager: ProcessManager;
private systemInfo: SystemInfo;
private uiServer: UIServer;
private logs: string[] = [];
private swayStatus: ServiceStatus = { state: 'stopped' };
private chromeStatus: ServiceStatus = { state: 'stopped' };
constructor(config?: Partial<DaemonConfig>) {
this.config = {
uiPort: 3006,
user: 'ecouser',
waylandDisplay: 'wayland-1',
...config,
};
this.processManager = new ProcessManager(this.config.user);
this.systemInfo = new SystemInfo();
this.uiServer = new UIServer(this.config.uiPort, this);
}
log(message: string): void {
const timestamp = new Date().toISOString();
const entry = `[${timestamp}] ${message}`;
this.logs.push(entry);
console.log(entry);
// Keep last 1000 log entries
if (this.logs.length > 1000) {
this.logs = this.logs.slice(-1000);
}
}
getLogs(): string[] {
return [...this.logs];
}
getStatus(): Record<string, unknown> {
return {
sway: this.swayStatus.state === 'running',
swayStatus: this.swayStatus,
chrome: this.chromeStatus.state === 'running',
chromeStatus: this.chromeStatus,
systemInfo: this.systemInfo.getInfo(),
logs: this.logs.slice(-50),
};
}
async start(): Promise<void> {
this.log('EcoOS Daemon starting...');
// Start UI server first - this must always succeed and remain responsive
this.log('Starting management UI on port ' + this.config.uiPort);
await this.uiServer.start();
this.log('Management UI started successfully');
// Start the Sway/Chrome initialization in the background
// This allows the UI server to remain responsive even if Sway fails
this.startServicesInBackground();
// Keep the daemon running
await this.runForever();
}
private async startServicesInBackground(): Promise<void> {
// Run service initialization without blocking the main thread
(async () => {
try {
// Ensure seatd is running
this.log('Checking seatd service...');
await this.ensureSeatd();
// Try to start Sway and Chrome
await this.tryStartSwayAndChrome();
} catch (error) {
this.log(`Service initialization error: ${error}`);
}
})();
}
private async tryStartSwayAndChrome(): Promise<void> {
// Try DRM mode first, fall back to headless if it fails
const modes = ['drm', 'headless'] as const;
for (const mode of modes) {
// Stop any existing Sway process
await this.processManager.stopSway();
this.swayStatus = { state: 'starting', lastAttempt: new Date().toISOString() };
this.log(`Trying Sway with ${mode} backend...`);
try {
await this.startSwayWithMode(mode);
} catch (error) {
this.log(`Failed to start Sway with ${mode}: ${error}`);
continue;
}
// Wait for Wayland socket
this.log('Waiting for Wayland socket...');
const waylandReady = await this.waitForWayland();
if (waylandReady) {
this.swayStatus = { state: 'running' };
this.log(`Sway compositor running with ${mode} backend`);
// Start Chrome in kiosk mode
await this.startChromeAfterSway();
return;
} else {
this.log(`Sway ${mode} backend failed - Wayland socket did not appear`);
await this.processManager.stopSway();
}
}
// All modes failed
this.swayStatus = {
state: 'failed',
error: 'All Sway backends failed (tried drm and headless)',
lastAttempt: new Date().toISOString()
};
this.log('All Sway backend modes failed');
}
private async startChromeAfterSway(): Promise<void> {
this.chromeStatus = { state: 'starting', lastAttempt: new Date().toISOString() };
this.log('Starting Chrome browser...');
try {
await this.startChrome();
this.chromeStatus = { state: 'running' };
this.log('Chrome browser started');
} catch (error) {
this.chromeStatus = {
state: 'failed',
error: String(error),
lastAttempt: new Date().toISOString()
};
this.log(`Failed to start Chrome: ${error}`);
}
}
private async ensureSeatd(): Promise<void> {
const status = await this.runCommand('systemctl', ['is-active', 'seatd']);
if (status.success && status.stdout.trim() === 'active') {
this.log('seatd is already running');
return;
}
this.log('Starting seatd service...');
const result = await this.runCommand('systemctl', ['start', 'seatd']);
if (!result.success) {
this.log('Warning: Failed to start seatd: ' + result.stderr);
}
}
private async startSwayWithMode(mode: 'drm' | 'headless'): Promise<void> {
const uid = await this.getUserUid();
// Ensure XDG_RUNTIME_DIR exists
const runtimeDir = `/run/user/${uid}`;
await this.runCommand('mkdir', ['-p', runtimeDir]);
await this.runCommand('chown', [`${this.config.user}:${this.config.user}`, runtimeDir]);
await this.runCommand('chmod', ['700', runtimeDir]);
if (mode === 'drm') {
this.log('Starting Sway with DRM backend (hardware rendering)');
await this.processManager.startSway({
runtimeDir,
backends: 'drm,libinput',
allowSoftwareRendering: true,
headless: false,
});
} else {
this.log('Starting Sway with headless backend (software rendering)');
await this.processManager.startSway({
runtimeDir,
backends: 'headless',
allowSoftwareRendering: true,
headless: true,
headlessOutputs: 1,
renderer: 'pixman',
});
}
}
private async waitForWayland(): Promise<boolean> {
const uid = await this.getUserUid();
const socketPath = `/run/user/${uid}/${this.config.waylandDisplay}`;
// Wait up to 10 seconds for Wayland socket to appear
for (let i = 0; i < 10; i++) {
try {
const stat = await Deno.stat(socketPath);
if (stat.isFile || stat.isSymlink || stat.mode !== undefined) {
this.log('Wayland socket ready: ' + socketPath);
return true;
}
} catch {
// Socket doesn't exist yet
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
return false;
}
private async startChrome(): Promise<void> {
const uid = await this.getUserUid();
const runtimeDir = `/run/user/${uid}`;
await this.processManager.startChrome({
runtimeDir,
waylandDisplay: this.config.waylandDisplay,
url: 'http://localhost:' + this.config.uiPort,
kiosk: true,
});
}
private async getUserUid(): Promise<number> {
const result = await this.runCommand('id', ['-u', this.config.user]);
if (!result.success) {
throw new Error('Failed to get user UID: ' + result.stderr);
}
return parseInt(result.stdout.trim(), 10);
}
private async runCommand(
cmd: string,
args: string[]
): Promise<{ success: boolean; stdout: string; stderr: string }> {
const command = new Deno.Command(cmd, {
args,
stdout: 'piped',
stderr: 'piped',
});
const result = await command.output();
return {
success: result.success,
stdout: new TextDecoder().decode(result.stdout),
stderr: new TextDecoder().decode(result.stderr),
};
}
private async runForever(): Promise<void> {
// Monitor processes and restart if needed
while (true) {
await new Promise((resolve) => setTimeout(resolve, 5000));
try {
// If Sway was running but died, try to restart
if (this.swayStatus.state === 'running' && !this.processManager.isSwayRunning()) {
this.log('Sway process died, attempting restart...');
this.swayStatus = { state: 'stopped' };
this.chromeStatus = { state: 'stopped' };
await this.tryStartSwayAndChrome();
}
// If Sway is running but Chrome died, restart Chrome
if (this.swayStatus.state === 'running' && this.chromeStatus.state === 'running'
&& !this.processManager.isChromeRunning()) {
this.log('Chrome process died, attempting restart...');
this.chromeStatus = { state: 'starting', lastAttempt: new Date().toISOString() };
try {
await this.startChrome();
this.chromeStatus = { state: 'running' };
this.log('Chrome browser restarted');
} catch (error) {
this.chromeStatus = {
state: 'failed',
error: String(error),
lastAttempt: new Date().toISOString()
};
this.log(`Failed to restart Chrome: ${error}`);
}
}
// If Sway failed, retry every 30 seconds
if (this.swayStatus.state === 'failed') {
const lastAttempt = this.swayStatus.lastAttempt
? new Date(this.swayStatus.lastAttempt).getTime()
: 0;
const now = Date.now();
if (now - lastAttempt > 30000) {
this.log('Retrying Sway startup...');
await this.tryStartSwayAndChrome();
}
}
} catch (error) {
this.log(`Error in monitoring loop: ${error}`);
}
}
}
}

View File

@@ -0,0 +1,388 @@
/**
* Process Manager
*
* Manages spawning and monitoring of Sway and Chrome processes
*/
export interface SwayConfig {
runtimeDir: string;
backends: string;
allowSoftwareRendering: boolean;
headless?: boolean;
headlessOutputs?: number;
renderer?: string;
resolution?: string;
}
export interface BrowserConfig {
runtimeDir: string;
waylandDisplay: string;
url: string;
}
export class ProcessManager {
private user: string;
private swayProcess: Deno.ChildProcess | null = null;
private browserProcess: Deno.ChildProcess | null = null;
constructor(user: string) {
this.user = user;
}
/**
* Generate Sway configuration content for kiosk mode
*/
private generateSwayConfig(config: SwayConfig): string {
const resolution = config.resolution || '1920x1080';
return `# EcoOS Sway Configuration (generated by eco-daemon)
# Kiosk mode operation
# Variables
set $mod Mod4
# Output configuration
output * {
bg #000000 solid_color
resolution ${resolution}
}
# For headless backend virtual outputs
output HEADLESS-1 {
resolution ${resolution}
position 0 0
}
# Input configuration
input * {
events enabled
}
# Disable screen blanking for kiosk mode
exec_always swaymsg "output * dpms on"
# No window decorations for fullscreen kiosk
default_border none
default_floating_border none
# Focus follows mouse
focus_follows_mouse yes
# Force all windows fullscreen for kiosk mode
for_window [app_id=".*"] fullscreen enable
# Chrome-specific fullscreen rules
for_window [app_id="google-chrome"] fullscreen enable
for_window [app_id="Google-chrome"] fullscreen enable
for_window [app_id="chrome"] fullscreen enable
for_window [app_id="chromium-browser"] fullscreen enable
for_window [class="Google-chrome"] fullscreen enable
for_window [class="Chromium-browser"] fullscreen enable
`;
}
/**
* Write Sway config to user's config directory
*/
private async writeSwayConfig(config: SwayConfig): Promise<string> {
const configDir = `/home/${this.user}/.config/sway`;
const configPath = `${configDir}/config`;
// Create config directory
await this.runCommand('mkdir', ['-p', configDir]);
await this.runCommand('chown', [`${this.user}:${this.user}`, `/home/${this.user}/.config`]);
await this.runCommand('chown', [`${this.user}:${this.user}`, configDir]);
// Write config file
const configContent = this.generateSwayConfig(config);
await Deno.writeTextFile(configPath, configContent);
await this.runCommand('chown', [`${this.user}:${this.user}`, configPath]);
console.log(`[sway] Config written to ${configPath}`);
return configPath;
}
private async runCommand(
cmd: string,
args: string[]
): Promise<{ success: boolean; stdout: string; stderr: string }> {
const command = new Deno.Command(cmd, {
args,
stdout: 'piped',
stderr: 'piped',
});
const result = await command.output();
return {
success: result.success,
stdout: new TextDecoder().decode(result.stdout),
stderr: new TextDecoder().decode(result.stderr),
};
}
async startSway(config: SwayConfig): Promise<void> {
// Write sway config before starting
const configPath = await this.writeSwayConfig(config);
const env: Record<string, string> = {
XDG_RUNTIME_DIR: config.runtimeDir,
WLR_BACKENDS: config.backends,
};
if (config.allowSoftwareRendering) {
env.WLR_RENDERER_ALLOW_SOFTWARE = '1';
}
// Headless mode configuration
if (config.headless) {
env.WLR_HEADLESS_OUTPUTS = String(config.headlessOutputs || 1);
// Use libinput for input even in headless mode (for VNC/remote input)
env.WLR_LIBINPUT_NO_DEVICES = '1';
}
// Force specific renderer (pixman for software rendering)
if (config.renderer) {
env.WLR_RENDERER = config.renderer;
}
// Build environment string for runuser
const envString = Object.entries(env)
.map(([k, v]) => `${k}=${v}`)
.join(' ');
const command = new Deno.Command('runuser', {
args: ['-u', this.user, '--', 'sh', '-c', `${envString} sway -c ${configPath}`],
stdout: 'piped',
stderr: 'piped',
stdin: 'null',
});
this.swayProcess = command.spawn();
// Log output in background
this.pipeOutput(this.swayProcess, 'sway');
}
/**
* Run a swaymsg command to control Sway
*/
async swaymsg(config: { runtimeDir: string; waylandDisplay: string }, command: string): Promise<boolean> {
const env: Record<string, string> = {
XDG_RUNTIME_DIR: config.runtimeDir,
WAYLAND_DISPLAY: config.waylandDisplay,
};
const envString = Object.entries(env)
.map(([k, v]) => `${k}=${v}`)
.join(' ');
const cmd = new Deno.Command('runuser', {
args: [
'-u',
this.user,
'--',
'sh',
'-c',
`${envString} swaymsg '${command}'`,
],
stdout: 'piped',
stderr: 'piped',
});
try {
const result = await cmd.output();
if (!result.success) {
const stderr = new TextDecoder().decode(result.stderr);
console.error(`[swaymsg] Command failed: ${stderr}`);
}
return result.success;
} catch (error) {
console.error(`[swaymsg] Error: ${error}`);
return false;
}
}
/**
* Start Chrome browser in kiosk mode
*/
async startBrowser(config: BrowserConfig): Promise<void> {
const env: Record<string, string> = {
HOME: `/home/${this.user}`,
XDG_RUNTIME_DIR: config.runtimeDir,
WAYLAND_DISPLAY: config.waylandDisplay,
XDG_CONFIG_HOME: `/home/${this.user}/.config`,
XDG_DATA_HOME: `/home/${this.user}/.local/share`,
};
// Chrome arguments for kiosk mode on Wayland
const browserArgs = [
'--ozone-platform=wayland',
'--enable-features=UseOzonePlatform',
'--kiosk',
'--no-first-run',
'--disable-infobars',
'--disable-session-crashed-bubble',
'--disable-restore-session-state',
'--disable-background-networking',
'--disable-sync',
'--disable-translate',
'--noerrdialogs',
// Required for VM/headless/sandboxed environments
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
// GPU/rendering flags for VM environments
'--disable-gpu',
'--disable-gpu-compositing',
'--disable-gpu-sandbox',
'--disable-software-rasterizer',
'--disable-accelerated-2d-canvas',
'--disable-accelerated-video-decode',
'--use-gl=swiftshader',
'--in-process-gpu',
// Disable features that may cause issues in kiosk mode
'--disable-features=TranslateUI,VizDisplayCompositor',
'--disable-hang-monitor',
'--disable-breakpad',
'--disable-component-update',
config.url,
];
// Build environment string for runuser
const envString = Object.entries(env)
.map(([k, v]) => `${k}=${v}`)
.join(' ');
const command = new Deno.Command('runuser', {
args: [
'-u',
this.user,
'--',
'sh',
'-c',
`${envString} chromium-browser ${browserArgs.join(' ')}`,
],
stdout: 'piped',
stderr: 'piped',
stdin: 'null',
});
this.browserProcess = command.spawn();
// Log output in background
this.pipeOutput(this.browserProcess, 'chrome');
// Force fullscreen via swaymsg after Chrome window appears (backup)
this.forceFullscreenAfterDelay(config);
}
/**
* Force fullscreen for Chrome window after a delay (backup for kiosk mode)
*/
private async forceFullscreenAfterDelay(config: { runtimeDir: string; waylandDisplay: string }): Promise<void> {
// Wait for Chrome window to appear
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log('[chrome] Forcing fullscreen via swaymsg');
// Try multiple selectors to ensure we catch the window
const selectors = [
'[app_id="google-chrome"]',
'[app_id="Google-chrome"]',
'[app_id="chrome"]',
'[app_id="chromium-browser"]',
'[class="Google-chrome"]',
];
for (const selector of selectors) {
await this.swaymsg(config, `${selector} fullscreen enable`);
}
// Also try to focus the window
await this.swaymsg(config, '[app_id="google-chrome"] focus');
}
// Legacy method name for backwards compatibility
async startChrome(config: BrowserConfig & { kiosk?: boolean }): Promise<void> {
return this.startBrowser(config);
}
isSwayRunning(): boolean {
return this.swayProcess !== null;
}
isBrowserRunning(): boolean {
return this.browserProcess !== null;
}
// Legacy method name for backwards compatibility
isChromeRunning(): boolean {
return this.isBrowserRunning();
}
async stopSway(): Promise<void> {
if (this.swayProcess) {
try {
this.swayProcess.kill('SIGTERM');
await this.swayProcess.status;
} catch {
// Process may already be dead
}
this.swayProcess = null;
}
}
async stopBrowser(): Promise<void> {
if (this.browserProcess) {
try {
this.browserProcess.kill('SIGTERM');
await this.browserProcess.status;
} catch {
// Process may already be dead
}
this.browserProcess = null;
}
}
// Legacy method name for backwards compatibility
async stopChrome(): Promise<void> {
return this.stopBrowser();
}
private async pipeOutput(
process: Deno.ChildProcess,
name: string
): Promise<void> {
const decoder = new TextDecoder();
// Pipe stdout
(async () => {
for await (const chunk of process.stdout) {
const text = decoder.decode(chunk);
for (const line of text.split('\n').filter((l) => l.trim())) {
console.log(`[${name}] ${line}`);
}
}
})();
// Pipe stderr
(async () => {
for await (const chunk of process.stderr) {
const text = decoder.decode(chunk);
for (const line of text.split('\n').filter((l) => l.trim())) {
console.error(`[${name}:err] ${line}`);
}
}
})();
// Monitor process exit
process.status.then((status) => {
console.log(`[${name}] Process exited with code ${status.code}`);
if (name === 'sway') {
this.swayProcess = null;
} else if (name === 'chrome') {
this.browserProcess = null;
}
});
}
}

View File

@@ -0,0 +1,231 @@
/**
* System Information
*
* Gathers CPU, RAM, disk, network, and GPU information
*/
export interface CpuInfo {
model: string;
cores: number;
usage: number;
}
export interface MemoryInfo {
total: number;
used: number;
free: number;
usagePercent: number;
}
export interface DiskInfo {
device: string;
mountpoint: string;
total: number;
used: number;
free: number;
usagePercent: number;
}
export interface NetworkInterface {
name: string;
ip: string;
mac: string;
state: 'up' | 'down';
}
export interface GpuInfo {
name: string;
driver: string;
}
export interface SystemInfoData {
hostname: string;
cpu: CpuInfo;
memory: MemoryInfo;
disks: DiskInfo[];
network: NetworkInterface[];
gpu: GpuInfo[];
uptime: number;
}
export class SystemInfo {
async getInfo(): Promise<SystemInfoData> {
const [hostname, cpu, memory, disks, network, gpu, uptime] =
await Promise.all([
this.getHostname(),
this.getCpuInfo(),
this.getMemoryInfo(),
this.getDiskInfo(),
this.getNetworkInfo(),
this.getGpuInfo(),
this.getUptime(),
]);
return { hostname, cpu, memory, disks, network, gpu, uptime };
}
private async getHostname(): Promise<string> {
try {
const result = await this.runCommand('hostname');
return result.trim();
} catch {
return 'unknown';
}
}
private async getCpuInfo(): Promise<CpuInfo> {
try {
const cpuinfo = await Deno.readTextFile('/proc/cpuinfo');
const modelMatch = cpuinfo.match(/model name\s*:\s*(.+)/);
const coreMatches = cpuinfo.match(/processor\s*:/g);
// Get CPU usage from /proc/stat
const stat = await Deno.readTextFile('/proc/stat');
const cpuLine = stat.split('\n')[0];
const values = cpuLine.split(/\s+/).slice(1).map(Number);
const total = values.reduce((a, b) => a + b, 0);
const idle = values[3];
const usage = ((total - idle) / total) * 100;
return {
model: modelMatch ? modelMatch[1] : 'Unknown',
cores: coreMatches ? coreMatches.length : 1,
usage: Math.round(usage * 10) / 10,
};
} catch {
return { model: 'Unknown', cores: 1, usage: 0 };
}
}
private async getMemoryInfo(): Promise<MemoryInfo> {
try {
const meminfo = await Deno.readTextFile('/proc/meminfo');
const totalMatch = meminfo.match(/MemTotal:\s*(\d+)/);
const freeMatch = meminfo.match(/MemAvailable:\s*(\d+)/);
const total = totalMatch ? parseInt(totalMatch[1], 10) * 1024 : 0;
const free = freeMatch ? parseInt(freeMatch[1], 10) * 1024 : 0;
const used = total - free;
return {
total,
used,
free,
usagePercent: total > 0 ? Math.round((used / total) * 1000) / 10 : 0,
};
} catch {
return { total: 0, used: 0, free: 0, usagePercent: 0 };
}
}
private async getDiskInfo(): Promise<DiskInfo[]> {
try {
const output = await this.runCommand('df', ['-B1', '--output=source,target,size,used,avail']);
const lines = output.trim().split('\n').slice(1);
const disks: DiskInfo[] = [];
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 5 && parts[0].startsWith('/dev/')) {
const total = parseInt(parts[2], 10);
const used = parseInt(parts[3], 10);
const free = parseInt(parts[4], 10);
disks.push({
device: parts[0],
mountpoint: parts[1],
total,
used,
free,
usagePercent: total > 0 ? Math.round((used / total) * 1000) / 10 : 0,
});
}
}
return disks;
} catch {
return [];
}
}
private async getNetworkInfo(): Promise<NetworkInterface[]> {
try {
const output = await this.runCommand('ip', ['-j', 'addr']);
const interfaces = JSON.parse(output);
const result: NetworkInterface[] = [];
for (const iface of interfaces) {
if (iface.ifname === 'lo') continue;
let ip = '';
let mac = iface.address || '';
for (const addr of iface.addr_info || []) {
if (addr.family === 'inet') {
ip = addr.local;
break;
}
}
result.push({
name: iface.ifname,
ip: ip || 'No IP',
mac,
state: iface.operstate === 'UP' ? 'up' : 'down',
});
}
return result;
} catch {
return [];
}
}
private async getGpuInfo(): Promise<GpuInfo[]> {
try {
const output = await this.runCommand('lspci', ['-mm']);
const lines = output.split('\n');
const gpus: GpuInfo[] = [];
for (const line of lines) {
if (line.includes('VGA') || line.includes('3D') || line.includes('Display')) {
const parts = line.split('"');
if (parts.length >= 6) {
gpus.push({
name: parts[5] || 'Unknown GPU',
driver: 'unknown',
});
}
}
}
return gpus;
} catch {
return [];
}
}
private async getUptime(): Promise<number> {
try {
const uptime = await Deno.readTextFile('/proc/uptime');
return Math.floor(parseFloat(uptime.split(' ')[0]));
} catch {
return 0;
}
}
private async runCommand(cmd: string, args: string[] = []): Promise<string> {
const command = new Deno.Command(cmd, {
args,
stdout: 'piped',
stderr: 'piped',
});
const result = await command.output();
if (!result.success) {
throw new Error('Command failed');
}
return new TextDecoder().decode(result.stdout);
}
}

View File

@@ -0,0 +1,325 @@
/**
* 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 = 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 });
}
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; }
</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="chrome-status"></span>
Chrome 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" 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 updateStatus(data) {
// Services
document.getElementById('sway-status').className =
'status-dot ' + (data.sway ? 'running' : 'stopped');
document.getElementById('chrome-status').className =
'status-dot ' + (data.chrome ? '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('');
}
}
// 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;
}
}
// 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' },
});
}
}