initial
This commit is contained in:
19
ecoos_daemon/deno.json
Normal file
19
ecoos_daemon/deno.json
Normal 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
|
||||
}
|
||||
}
|
||||
14
ecoos_daemon/mod.ts
Normal file
14
ecoos_daemon/mod.ts
Normal 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
|
||||
* - Chromium browser in kiosk mode
|
||||
*/
|
||||
|
||||
import { EcoDaemon } from './ts/daemon/index.ts';
|
||||
|
||||
const daemon = new EcoDaemon();
|
||||
await daemon.start();
|
||||
350
ecoos_daemon/ts/daemon/index.ts
Normal file
350
ecoos_daemon/ts/daemon/index.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 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';
|
||||
import { runCommand } from '../utils/command.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 chromiumStatus: 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];
|
||||
}
|
||||
|
||||
async getStatus(): Promise<Record<string, unknown>> {
|
||||
const systemInfo = await this.systemInfo.getInfo();
|
||||
return {
|
||||
sway: this.swayStatus.state === 'running',
|
||||
swayStatus: this.swayStatus,
|
||||
chromium: this.chromiumStatus.state === 'running',
|
||||
chromiumStatus: this.chromiumStatus,
|
||||
systemInfo,
|
||||
logs: this.logs.slice(-50),
|
||||
};
|
||||
}
|
||||
|
||||
async rebootSystem(): Promise<{ success: boolean; message: string }> {
|
||||
this.log('System reboot requested...');
|
||||
try {
|
||||
const result = await runCommand('systemctl', ['reboot']);
|
||||
if (result.success) {
|
||||
return { success: true, message: 'System is rebooting...' };
|
||||
}
|
||||
return { success: false, message: 'Failed to reboot: ' + result.stderr };
|
||||
} catch (error) {
|
||||
this.log(`Reboot failed: ${error}`);
|
||||
return { success: false, message: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async restartChromium(): Promise<{ success: boolean; message: string }> {
|
||||
this.log('Chromium restart requested...');
|
||||
|
||||
if (this.swayStatus.state !== 'running') {
|
||||
return { success: false, message: 'Cannot restart Chromium: Sway is not running' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Stop existing Chromium
|
||||
await this.processManager.stopBrowser();
|
||||
this.chromiumStatus = { state: 'stopped' };
|
||||
|
||||
// Wait a moment before restarting
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Start Chromium again
|
||||
await this.startChromium();
|
||||
this.chromiumStatus = { state: 'running' };
|
||||
this.log('Chromium browser restarted successfully');
|
||||
|
||||
return { success: true, message: 'Chromium restarted successfully' };
|
||||
} catch (error) {
|
||||
this.chromiumStatus = {
|
||||
state: 'failed',
|
||||
error: String(error),
|
||||
lastAttempt: new Date().toISOString()
|
||||
};
|
||||
this.log(`Failed to restart Chromium: ${error}`);
|
||||
return { success: false, message: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
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/Chromium 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 Chromium
|
||||
await this.tryStartSwayAndChromium();
|
||||
} catch (error) {
|
||||
this.log(`Service initialization error: ${error}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private async tryStartSwayAndChromium(): 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 Chromium in kiosk mode
|
||||
await this.startChromiumAfterSway();
|
||||
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 startChromiumAfterSway(): Promise<void> {
|
||||
this.chromiumStatus = { state: 'starting', lastAttempt: new Date().toISOString() };
|
||||
this.log('Starting Chromium browser...');
|
||||
|
||||
try {
|
||||
await this.startChromium();
|
||||
this.chromiumStatus = { state: 'running' };
|
||||
this.log('Chromium browser started');
|
||||
} catch (error) {
|
||||
this.chromiumStatus = {
|
||||
state: 'failed',
|
||||
error: String(error),
|
||||
lastAttempt: new Date().toISOString()
|
||||
};
|
||||
this.log(`Failed to start Chromium: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureSeatd(): Promise<void> {
|
||||
const status = await 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 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 runCommand('mkdir', ['-p', runtimeDir]);
|
||||
await runCommand('chown', [`${this.config.user}:${this.config.user}`, runtimeDir]);
|
||||
await 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 startChromium(): Promise<void> {
|
||||
const uid = await this.getUserUid();
|
||||
const runtimeDir = `/run/user/${uid}`;
|
||||
|
||||
await this.processManager.startBrowser({
|
||||
runtimeDir,
|
||||
waylandDisplay: this.config.waylandDisplay,
|
||||
url: 'http://localhost:' + this.config.uiPort,
|
||||
kiosk: true,
|
||||
});
|
||||
}
|
||||
|
||||
private async getUserUid(): Promise<number> {
|
||||
const result = await 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 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.chromiumStatus = { state: 'stopped' };
|
||||
await this.tryStartSwayAndChromium();
|
||||
}
|
||||
|
||||
// If Sway is running but Chromium died, restart Chromium
|
||||
if (this.swayStatus.state === 'running' && this.chromiumStatus.state === 'running'
|
||||
&& !this.processManager.isBrowserRunning()) {
|
||||
this.log('Chromium process died, attempting restart...');
|
||||
this.chromiumStatus = { state: 'starting', lastAttempt: new Date().toISOString() };
|
||||
try {
|
||||
await this.startChromium();
|
||||
this.chromiumStatus = { state: 'running' };
|
||||
this.log('Chromium browser restarted');
|
||||
} catch (error) {
|
||||
this.chromiumStatus = {
|
||||
state: 'failed',
|
||||
error: String(error),
|
||||
lastAttempt: new Date().toISOString()
|
||||
};
|
||||
this.log(`Failed to restart Chromium: ${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.tryStartSwayAndChromium();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Error in monitoring loop: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
348
ecoos_daemon/ts/daemon/process-manager.ts
Normal file
348
ecoos_daemon/ts/daemon/process-manager.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Process Manager
|
||||
*
|
||||
* Manages spawning and monitoring of Sway and Chromium processes
|
||||
*/
|
||||
|
||||
import { runCommand } from '../utils/command.ts';
|
||||
|
||||
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;
|
||||
kiosk?: boolean;
|
||||
}
|
||||
|
||||
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
|
||||
for_window [app_id="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 runCommand('mkdir', ['-p', configDir]);
|
||||
await runCommand('chown', [`${this.user}:${this.user}`, `/home/${this.user}/.config`]);
|
||||
await runCommand('chown', [`${this.user}:${this.user}`, configDir]);
|
||||
|
||||
// Write config file
|
||||
const configContent = this.generateSwayConfig(config);
|
||||
await Deno.writeTextFile(configPath, configContent);
|
||||
await runCommand('chown', [`${this.user}:${this.user}`, configPath]);
|
||||
|
||||
console.log(`[sway] Config written to ${configPath}`);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
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 Chromium 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`,
|
||||
};
|
||||
|
||||
// Chromium 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, 'chromium');
|
||||
|
||||
// Force fullscreen via swaymsg after Chromium window appears (backup)
|
||||
this.forceFullscreenAfterDelay(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force fullscreen for Chromium window after a delay (backup for kiosk mode)
|
||||
*/
|
||||
private async forceFullscreenAfterDelay(config: { runtimeDir: string; waylandDisplay: string }): Promise<void> {
|
||||
// Wait for Chromium window to appear
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
console.log('[chromium] Forcing fullscreen via swaymsg');
|
||||
|
||||
// Try multiple selectors to ensure we catch the window
|
||||
const selectors = [
|
||||
'[app_id="chromium-browser"]',
|
||||
'[app_id="chromium"]',
|
||||
];
|
||||
|
||||
for (const selector of selectors) {
|
||||
await this.swaymsg(config, `${selector} fullscreen enable`);
|
||||
}
|
||||
|
||||
// Also try to focus the window
|
||||
await this.swaymsg(config, '[app_id="chromium-browser"] focus');
|
||||
}
|
||||
|
||||
isSwayRunning(): boolean {
|
||||
return this.swayProcess !== null;
|
||||
}
|
||||
|
||||
isBrowserRunning(): boolean {
|
||||
return this.browserProcess !== null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 === 'chromium') {
|
||||
this.browserProcess = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
384
ecoos_daemon/ts/daemon/system-info.ts
Normal file
384
ecoos_daemon/ts/daemon/system-info.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* System Information
|
||||
*
|
||||
* Gathers CPU, RAM, disk, network, and GPU information
|
||||
*/
|
||||
|
||||
import { runCommand } from '../utils/command.ts';
|
||||
|
||||
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 InputDevice {
|
||||
name: string;
|
||||
type: 'keyboard' | 'mouse' | 'touchpad' | 'other';
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface AudioDevice {
|
||||
name: string;
|
||||
description: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface SystemInfoData {
|
||||
hostname: string;
|
||||
cpu: CpuInfo;
|
||||
memory: MemoryInfo;
|
||||
disks: DiskInfo[];
|
||||
network: NetworkInterface[];
|
||||
gpu: GpuInfo[];
|
||||
uptime: number;
|
||||
inputDevices: InputDevice[];
|
||||
speakers: AudioDevice[];
|
||||
microphones: AudioDevice[];
|
||||
}
|
||||
|
||||
export class SystemInfo {
|
||||
async getInfo(): Promise<SystemInfoData> {
|
||||
const [hostname, cpu, memory, disks, network, gpu, uptime, inputDevices, speakers, microphones] =
|
||||
await Promise.all([
|
||||
this.getHostname(),
|
||||
this.getCpuInfo(),
|
||||
this.getMemoryInfo(),
|
||||
this.getDiskInfo(),
|
||||
this.getNetworkInfo(),
|
||||
this.getGpuInfo(),
|
||||
this.getUptime(),
|
||||
this.getInputDevices(),
|
||||
this.getSpeakers(),
|
||||
this.getMicrophones(),
|
||||
]);
|
||||
|
||||
return { hostname, cpu, memory, disks, network, gpu, uptime, inputDevices, speakers, microphones };
|
||||
}
|
||||
|
||||
private async getHostname(): Promise<string> {
|
||||
try {
|
||||
const result = await runCommand('hostname', []);
|
||||
if (!result.success) return 'unknown';
|
||||
return result.stdout.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 result = await runCommand('df', ['-B1', '--output=source,target,size,used,avail']);
|
||||
if (!result.success) return [];
|
||||
const lines = result.stdout.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 cmdResult = await runCommand('ip', ['-j', 'addr']);
|
||||
if (!cmdResult.success) return [];
|
||||
const interfaces = JSON.parse(cmdResult.stdout);
|
||||
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 result = await runCommand('lspci', ['-mm']);
|
||||
if (!result.success) return [];
|
||||
const lines = result.stdout.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 getInputDevices(): Promise<InputDevice[]> {
|
||||
try {
|
||||
const content = await Deno.readTextFile('/proc/bus/input/devices');
|
||||
const devices: InputDevice[] = [];
|
||||
const blocks = content.split('\n\n');
|
||||
|
||||
for (const block of blocks) {
|
||||
if (!block.trim()) continue;
|
||||
|
||||
const nameMatch = block.match(/N: Name="(.+)"/);
|
||||
const handlersMatch = block.match(/H: Handlers=(.+)/);
|
||||
|
||||
if (nameMatch && handlersMatch) {
|
||||
const name = nameMatch[1];
|
||||
const handlers = handlersMatch[1];
|
||||
|
||||
// Find event device path
|
||||
const eventMatch = handlers.match(/event\d+/);
|
||||
const path = eventMatch ? `/dev/input/${eventMatch[0]}` : '';
|
||||
|
||||
// Determine device type
|
||||
let type: 'keyboard' | 'mouse' | 'touchpad' | 'other' = 'other';
|
||||
const nameLower = name.toLowerCase();
|
||||
if (handlers.includes('kbd') || nameLower.includes('keyboard')) {
|
||||
type = 'keyboard';
|
||||
} else if (nameLower.includes('touchpad') || nameLower.includes('trackpad')) {
|
||||
type = 'touchpad';
|
||||
} else if (handlers.includes('mouse') || nameLower.includes('mouse')) {
|
||||
type = 'mouse';
|
||||
}
|
||||
|
||||
// Filter out system/virtual devices
|
||||
if (!nameLower.includes('power button') &&
|
||||
!nameLower.includes('sleep button') &&
|
||||
!nameLower.includes('pc speaker') &&
|
||||
path) {
|
||||
devices.push({ name, type, path });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return devices;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getSpeakers(): Promise<AudioDevice[]> {
|
||||
try {
|
||||
const result = await runCommand('pactl', ['-f', 'json', 'list', 'sinks']);
|
||||
if (!result.success) {
|
||||
// Fallback to aplay if pactl not available
|
||||
return this.getSpeakersFromAplay();
|
||||
}
|
||||
|
||||
const sinks = JSON.parse(result.stdout);
|
||||
const defaultResult = await runCommand('pactl', ['get-default-sink']);
|
||||
const defaultSink = defaultResult.success ? defaultResult.stdout.trim() : '';
|
||||
|
||||
return sinks.map((sink: { name: string; description: string }) => ({
|
||||
name: sink.name,
|
||||
description: sink.description || sink.name,
|
||||
isDefault: sink.name === defaultSink,
|
||||
}));
|
||||
} catch {
|
||||
return this.getSpeakersFromAplay();
|
||||
}
|
||||
}
|
||||
|
||||
private async getSpeakersFromAplay(): Promise<AudioDevice[]> {
|
||||
try {
|
||||
const result = await runCommand('aplay', ['-l']);
|
||||
if (!result.success) return [];
|
||||
|
||||
const devices: AudioDevice[] = [];
|
||||
const lines = result.stdout.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/card (\d+): (.+?) \[(.+?)\], device (\d+): (.+?) \[(.+?)\]/);
|
||||
if (match) {
|
||||
devices.push({
|
||||
name: `hw:${match[1]},${match[4]}`,
|
||||
description: `${match[3]} - ${match[6]}`,
|
||||
isDefault: devices.length === 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return devices;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getMicrophones(): Promise<AudioDevice[]> {
|
||||
try {
|
||||
const result = await runCommand('pactl', ['-f', 'json', 'list', 'sources']);
|
||||
if (!result.success) {
|
||||
// Fallback to arecord if pactl not available
|
||||
return this.getMicrophonesFromArecord();
|
||||
}
|
||||
|
||||
const sources = JSON.parse(result.stdout);
|
||||
const defaultResult = await runCommand('pactl', ['get-default-source']);
|
||||
const defaultSource = defaultResult.success ? defaultResult.stdout.trim() : '';
|
||||
|
||||
// Filter out monitor sources (they echo speaker output)
|
||||
return sources
|
||||
.filter((source: { name: string }) => !source.name.includes('.monitor'))
|
||||
.map((source: { name: string; description: string }) => ({
|
||||
name: source.name,
|
||||
description: source.description || source.name,
|
||||
isDefault: source.name === defaultSource,
|
||||
}));
|
||||
} catch {
|
||||
return this.getMicrophonesFromArecord();
|
||||
}
|
||||
}
|
||||
|
||||
private async getMicrophonesFromArecord(): Promise<AudioDevice[]> {
|
||||
try {
|
||||
const result = await runCommand('arecord', ['-l']);
|
||||
if (!result.success) return [];
|
||||
|
||||
const devices: AudioDevice[] = [];
|
||||
const lines = result.stdout.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/card (\d+): (.+?) \[(.+?)\], device (\d+): (.+?) \[(.+?)\]/);
|
||||
if (match) {
|
||||
devices.push({
|
||||
name: `hw:${match[1]},${match[4]}`,
|
||||
description: `${match[3]} - ${match[6]}`,
|
||||
isDefault: devices.length === 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return devices;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
539
ecoos_daemon/ts/ui/server.ts
Normal file
539
ecoos_daemon/ts/ui/server.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
27
ecoos_daemon/ts/utils/command.ts
Normal file
27
ecoos_daemon/ts/utils/command.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Shared command execution utility
|
||||
*/
|
||||
|
||||
export interface CommandResult {
|
||||
success: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export async function runCommand(
|
||||
cmd: string,
|
||||
args: string[]
|
||||
): Promise<CommandResult> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user