Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b87d63121 | |||
| 140ce716f2 | |||
| b9b7f2b4a3 | |||
| aedcc3f875 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
isobuild/output/
|
isobuild/output/
|
||||||
*.iso
|
*.iso
|
||||||
|
|||||||
17
changelog.md
17
changelog.md
@@ -1,5 +1,22 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-01-12 - 0.6.0 - feat(ecoos-daemon)
|
||||||
|
integrate a bundled daemon web UI with components, interfaces, styles, bundling config, and server support
|
||||||
|
|
||||||
|
- Adds a new TypeScript UI bundle package (@ecobridge/ecoos-daemon-ui) and build config (npmextra.json) to produce a bundled /app.js
|
||||||
|
- Implements web components: ecoos-app, ecoos-overview, ecoos-devices, ecoos-displays, ecoos-updates, ecoos-logs and shared styles/utilities
|
||||||
|
- Introduces TypeScript interfaces for status, display and updates under ts_interfaces for API contracts
|
||||||
|
- Server integration: UIServer now serves the bundled JS at /app.js and includes the app version in the HTML title
|
||||||
|
- Updates root package.json (adds dependencies key) and .gitignore to ignore node_modules
|
||||||
|
|
||||||
|
## 2026-01-10 - 0.5.0 - feat(ui,isotest)
|
||||||
|
Group disabled displays into a collapsible section and refactor display item rendering; start a background screenshot loop during isotest and improve test-run cleanup
|
||||||
|
|
||||||
|
- Refactored display rendering: introduced renderDisplayItem() and simplified updateDisplaysUI() to separate enabled/disabled displays
|
||||||
|
- Disabled displays are collapsed under a <details> summary showing count ("Disabled Displays (N)")
|
||||||
|
- Added a background screenshot loop in isotest/run-test.sh that runs screenshot.sh every 5 seconds and records SCREENSHOT_LOOP_PID
|
||||||
|
- Improved cleanup in isotest/run-test.sh to kill SCREENSHOT_LOOP_PID and ENABLE_PID if they are running
|
||||||
|
|
||||||
## 2026-01-10 - 0.4.15 - fix(isotest)
|
## 2026-01-10 - 0.4.15 - fix(isotest)
|
||||||
Improve robustness of SPICE display enabler: add logging, wait-for-port and URI parsing, retries and reconnection logic, stabilization delay before configuring, and verification/retry of monitor configuration
|
Improve robustness of SPICE display enabler: add logging, wait-for-port and URI parsing, retries and reconnection logic, stabilization delay before configuring, and verification/retry of monitor configuration
|
||||||
|
|
||||||
|
|||||||
13
ecoos_daemon/npmextra.json
Normal file
13
ecoos_daemon/npmextra.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"@git.zone/tsbundle": {
|
||||||
|
"bundles": [
|
||||||
|
{
|
||||||
|
"from": "./ts_web/index.ts",
|
||||||
|
"to": "./ts/daemon/bundledui.ts",
|
||||||
|
"bundler": "esbuild",
|
||||||
|
"outputMode": "base64ts",
|
||||||
|
"production": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
17
ecoos_daemon/package.json
Normal file
17
ecoos_daemon/package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@ecobridge/ecoos-daemon-ui",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsbundle"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@design.estate/dees-catalog": "^3.34.1",
|
||||||
|
"@design.estate/dees-element": "^2.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@git.zone/tsbundle": "^2.8.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
4528
ecoos_daemon/pnpm-lock.yaml
generated
Normal file
4528
ecoos_daemon/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
ecoos_daemon/ts/daemon/bundledui.ts
Normal file
7
ecoos_daemon/ts/daemon/bundledui.ts
Normal file
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import type { EcoDaemon } from '../daemon/index.ts';
|
import type { EcoDaemon } from '../daemon/index.ts';
|
||||||
import { VERSION } from '../version.ts';
|
import { VERSION } from '../version.ts';
|
||||||
|
import { files as bundledFiles } from '../daemon/bundledui.ts';
|
||||||
|
|
||||||
export class UIServer {
|
export class UIServer {
|
||||||
private port: number;
|
private port: number;
|
||||||
@@ -43,6 +44,11 @@ export class UIServer {
|
|||||||
return this.serveHtml();
|
return this.serveHtml();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bundled JavaScript
|
||||||
|
if (path === '/app.js') {
|
||||||
|
return this.serveAppJs();
|
||||||
|
}
|
||||||
|
|
||||||
return new Response('Not Found', { status: 404 });
|
return new Response('Not Found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,671 +163,48 @@ export class UIServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private serveHtml(): Response {
|
private serveHtml(): Response {
|
||||||
const html = `<!DOCTYPE html>
|
const html = `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>EcoOS Management</title>
|
<title>EcoOS Management v${VERSION}</title>
|
||||||
<style>
|
<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; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body {
|
html, body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
width: 100%;
|
||||||
background: var(--bg);
|
height: 100%;
|
||||||
color: var(--text);
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.container { max-width: 1200px; margin: 0 auto; }
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
h1 { font-size: 24px; margin: 0; }
|
|
||||||
.clock {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.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;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.progress-fill {
|
ecoos-app {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
height: 100%;
|
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;
|
|
||||||
}
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.tab {
|
|
||||||
padding: 8px 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-dim);
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
margin-bottom: -1px;
|
|
||||||
font-size: 12px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.tab:hover { color: var(--text); }
|
|
||||||
.tab.active {
|
|
||||||
color: var(--accent);
|
|
||||||
border-bottom-color: var(--accent);
|
|
||||||
}
|
|
||||||
.tab-content { display: none; }
|
|
||||||
.tab-content.active { display: block; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<ecoos-app></ecoos-app>
|
||||||
<div class="header">
|
<script type="module" src="/app.js"></script>
|
||||||
<h1>EcoOS Management <span style="font-size: 12px; color: var(--text-dim); font-weight: normal;">v${VERSION}</span></h1>
|
|
||||||
<div class="clock" id="clock"></div>
|
|
||||||
</div>
|
|
||||||
<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>Updates</h2>
|
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-label">Current Version</div>
|
|
||||||
<div class="stat-value" id="current-version">-</div>
|
|
||||||
</div>
|
|
||||||
<div id="updates-list" style="margin: 12px 0;"></div>
|
|
||||||
<div id="auto-upgrade-status" style="font-size: 12px; color: var(--text-dim);"></div>
|
|
||||||
<button class="btn btn-primary" onclick="checkForUpdates()" style="margin-top: 8px;">
|
|
||||||
Check for Updates
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h2>Displays</h2>
|
|
||||||
<div id="displays-list"></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;">
|
|
||||||
<div class="tabs">
|
|
||||||
<div class="tab active" onclick="switchTab('daemon')">Daemon Logs</div>
|
|
||||||
<div class="tab" onclick="switchTab('serial')">System Logs</div>
|
|
||||||
</div>
|
|
||||||
<div id="daemon-tab" class="tab-content active">
|
|
||||||
<div class="logs" id="logs"></div>
|
|
||||||
</div>
|
|
||||||
<div id="serial-tab" class="tab-content">
|
|
||||||
<div class="logs" id="serial-logs"></div>
|
|
||||||
</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';
|
|
||||||
}
|
|
||||||
|
|
||||||
let initialVersion = null;
|
|
||||||
|
|
||||||
function updateStatus(data) {
|
|
||||||
// Check for version change and reload if needed
|
|
||||||
if (data.version) {
|
|
||||||
if (initialVersion === null) {
|
|
||||||
initialVersion = data.version;
|
|
||||||
} else if (data.version !== initialVersion) {
|
|
||||||
console.log('Server version changed from ' + initialVersion + ' to ' + data.version + ', reloading...');
|
|
||||||
location.reload();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Daemon 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// System Logs
|
|
||||||
if (data.systemLogs) {
|
|
||||||
const serialEl = document.getElementById('serial-logs');
|
|
||||||
if (data.systemLogs.length === 0) {
|
|
||||||
serialEl.innerHTML = '<div style="color: var(--text-dim);">No system logs available</div>';
|
|
||||||
} else {
|
|
||||||
serialEl.innerHTML = data.systemLogs.map(l =>
|
|
||||||
'<div class="log-entry">' + l + '</div>'
|
|
||||||
).join('');
|
|
||||||
serialEl.scrollTop = serialEl.scrollHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchTab(tab) {
|
|
||||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
||||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
||||||
if (tab === 'daemon') {
|
|
||||||
document.querySelector('.tab:first-child').classList.add('active');
|
|
||||||
document.getElementById('daemon-tab').classList.add('active');
|
|
||||||
} else {
|
|
||||||
document.querySelector('.tab:last-child').classList.add('active');
|
|
||||||
document.getElementById('serial-tab').classList.add('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkForUpdates() {
|
|
||||||
fetch('/api/updates/check', { method: 'POST' })
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(updateUpdatesUI)
|
|
||||||
.catch(err => console.error('Failed to check updates:', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
function upgradeToVersion(version) {
|
|
||||||
if (!confirm('Upgrade to version ' + version + '? The daemon will restart.')) return;
|
|
||||||
|
|
||||||
fetch('/api/upgrade', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ version: version })
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(result => {
|
|
||||||
if (result.success) {
|
|
||||||
document.getElementById('auto-upgrade-status').textContent = result.message;
|
|
||||||
} else {
|
|
||||||
alert('Upgrade failed: ' + result.message);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => alert('Upgrade error: ' + err));
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUpdatesUI(data) {
|
|
||||||
document.getElementById('current-version').textContent = 'v' + data.currentVersion;
|
|
||||||
|
|
||||||
const list = document.getElementById('updates-list');
|
|
||||||
const newerReleases = data.releases.filter(r => r.isNewer);
|
|
||||||
|
|
||||||
if (newerReleases.length === 0) {
|
|
||||||
list.innerHTML = '<div style="color: var(--text-dim);">No updates available</div>';
|
|
||||||
} else {
|
|
||||||
list.innerHTML = newerReleases.map(r =>
|
|
||||||
'<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--border);">' +
|
|
||||||
'<span>v' + r.version + ' <span style="color: var(--text-dim);">(' + formatAge(r.ageHours) + ')</span></span>' +
|
|
||||||
'<button class="btn btn-primary" style="padding: 4px 12px; margin: 0;" onclick="upgradeToVersion(\\'' + r.version + '\\')">Upgrade</button>' +
|
|
||||||
'</div>'
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoStatus = document.getElementById('auto-upgrade-status');
|
|
||||||
if (data.autoUpgrade.targetVersion) {
|
|
||||||
if (data.autoUpgrade.waitingForStability) {
|
|
||||||
autoStatus.textContent = 'Auto-upgrade to v' + data.autoUpgrade.targetVersion + ' in ' + data.autoUpgrade.scheduledIn + ' (stability period)';
|
|
||||||
} else {
|
|
||||||
autoStatus.textContent = 'Auto-upgrade to v' + data.autoUpgrade.targetVersion + ' pending...';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
autoStatus.textContent = data.lastCheck ? 'Last checked: ' + new Date(data.lastCheck).toLocaleTimeString() : '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAge(hours) {
|
|
||||||
if (hours < 1) return Math.round(hours * 60) + 'm ago';
|
|
||||||
if (hours < 24) return Math.round(hours) + 'h ago';
|
|
||||||
return Math.round(hours / 24) + 'd ago';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch updates info periodically
|
|
||||||
function fetchUpdates() {
|
|
||||||
fetch('/api/updates')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(updateUpdatesUI)
|
|
||||||
.catch(err => console.error('Failed to fetch updates:', err));
|
|
||||||
}
|
|
||||||
fetchUpdates();
|
|
||||||
setInterval(fetchUpdates, 60000); // Check every minute
|
|
||||||
|
|
||||||
// Display management
|
|
||||||
function updateDisplaysUI(data) {
|
|
||||||
const list = document.getElementById('displays-list');
|
|
||||||
if (!data.displays || data.displays.length === 0) {
|
|
||||||
list.innerHTML = '<div style="color: var(--text-dim);">No displays detected</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
list.innerHTML = data.displays.map(d =>
|
|
||||||
'<div class="device-item" style="flex-wrap: wrap; gap: 8px;">' +
|
|
||||||
'<div style="flex: 1; min-width: 150px;">' +
|
|
||||||
'<div class="device-name">' + d.name + '</div>' +
|
|
||||||
'<div style="font-size: 11px; color: var(--text-dim);">' +
|
|
||||||
d.width + 'x' + d.height + ' @ ' + d.refreshRate + 'Hz' +
|
|
||||||
(d.make !== 'Unknown' ? ' • ' + d.make : '') +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div style="display: flex; gap: 4px;">' +
|
|
||||||
(d.isPrimary
|
|
||||||
? '<span class="device-default">Primary</span>'
|
|
||||||
: '<button class="btn btn-primary" style="padding: 2px 8px; margin: 0; font-size: 11px;" onclick="setKioskDisplay(\\'' + d.name + '\\')">Set Primary</button>') +
|
|
||||||
'<button class="btn ' + (d.active ? 'btn-danger' : 'btn-primary') + '" style="padding: 2px 8px; margin: 0; font-size: 11px;" onclick="toggleDisplay(\\'' + d.name + '\\', ' + !d.active + ')">' +
|
|
||||||
(d.active ? 'Disable' : 'Enable') +
|
|
||||||
'</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>'
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchDisplays() {
|
|
||||||
fetch('/api/displays')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(updateDisplaysUI)
|
|
||||||
.catch(err => console.error('Failed to fetch displays:', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleDisplay(name, enable) {
|
|
||||||
fetch('/api/displays/' + encodeURIComponent(name) + '/' + (enable ? 'enable' : 'disable'), { method: 'POST' })
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(result => {
|
|
||||||
if (!result.success) alert(result.message);
|
|
||||||
fetchDisplays();
|
|
||||||
})
|
|
||||||
.catch(err => alert('Error: ' + err));
|
|
||||||
}
|
|
||||||
|
|
||||||
function setKioskDisplay(name) {
|
|
||||||
fetch('/api/displays/' + encodeURIComponent(name) + '/primary', { method: 'POST' })
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(result => {
|
|
||||||
if (!result.success) alert(result.message);
|
|
||||||
fetchDisplays();
|
|
||||||
})
|
|
||||||
.catch(err => alert('Error: ' + err));
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchDisplays();
|
|
||||||
setInterval(fetchDisplays, 5000); // Refresh every 5 seconds
|
|
||||||
|
|
||||||
// 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 {}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clock update
|
|
||||||
function updateClock() {
|
|
||||||
const now = new Date();
|
|
||||||
const options = {
|
|
||||||
weekday: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
hour12: false
|
|
||||||
};
|
|
||||||
document.getElementById('clock').textContent = now.toLocaleString('en-US', options);
|
|
||||||
}
|
|
||||||
updateClock();
|
|
||||||
setInterval(updateClock, 1000);
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
|
|||||||
24
ecoos_daemon/ts_interfaces/display.ts
Normal file
24
ecoos_daemon/ts_interfaces/display.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Display interfaces - API contracts for display management
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface IDisplayInfo {
|
||||||
|
name: string; // e.g., "DP-1", "HDMI-A-1", "HEADLESS-1"
|
||||||
|
make: string; // Manufacturer
|
||||||
|
model: string; // Model name
|
||||||
|
serial: string; // Serial number
|
||||||
|
active: boolean; // Currently enabled
|
||||||
|
width: number; // Resolution width
|
||||||
|
height: number; // Resolution height
|
||||||
|
refreshRate: number; // Hz
|
||||||
|
isPrimary: boolean; // Has the focused window (kiosk)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDisplaysResponse {
|
||||||
|
displays: IDisplayInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDisplayActionResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
7
ecoos_daemon/ts_interfaces/index.ts
Normal file
7
ecoos_daemon/ts_interfaces/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Re-export all interfaces
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './status.ts';
|
||||||
|
export * from './display.ts';
|
||||||
|
export * from './updates.ts';
|
||||||
81
ecoos_daemon/ts_interfaces/status.ts
Normal file
81
ecoos_daemon/ts_interfaces/status.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Status interfaces - API contracts for system status data
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TServiceState = 'stopped' | 'starting' | 'running' | 'failed';
|
||||||
|
|
||||||
|
export interface IServiceStatus {
|
||||||
|
state: TServiceState;
|
||||||
|
error?: string;
|
||||||
|
lastAttempt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICpuInfo {
|
||||||
|
model: string;
|
||||||
|
cores: number;
|
||||||
|
usage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMemoryInfo {
|
||||||
|
total: number;
|
||||||
|
used: number;
|
||||||
|
free: number;
|
||||||
|
usagePercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDiskInfo {
|
||||||
|
device: string;
|
||||||
|
mountpoint: string;
|
||||||
|
total: number;
|
||||||
|
used: number;
|
||||||
|
free: number;
|
||||||
|
usagePercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INetworkInterface {
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
mac: string;
|
||||||
|
state: 'up' | 'down';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IGpuInfo {
|
||||||
|
name: string;
|
||||||
|
driver: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IInputDevice {
|
||||||
|
name: string;
|
||||||
|
type: 'keyboard' | 'mouse' | 'touchpad' | 'other';
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAudioDevice {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISystemInfo {
|
||||||
|
hostname: string;
|
||||||
|
cpu: ICpuInfo;
|
||||||
|
memory: IMemoryInfo;
|
||||||
|
disks: IDiskInfo[];
|
||||||
|
network: INetworkInterface[];
|
||||||
|
gpu: IGpuInfo[];
|
||||||
|
uptime: number;
|
||||||
|
inputDevices: IInputDevice[];
|
||||||
|
speakers: IAudioDevice[];
|
||||||
|
microphones: IAudioDevice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IStatus {
|
||||||
|
version: string;
|
||||||
|
sway: boolean;
|
||||||
|
swayStatus: IServiceStatus;
|
||||||
|
chromium: boolean;
|
||||||
|
chromiumStatus: IServiceStatus;
|
||||||
|
systemInfo: ISystemInfo;
|
||||||
|
logs: string[];
|
||||||
|
systemLogs: string[];
|
||||||
|
}
|
||||||
32
ecoos_daemon/ts_interfaces/updates.ts
Normal file
32
ecoos_daemon/ts_interfaces/updates.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Update interfaces - API contracts for update/upgrade system
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface IRelease {
|
||||||
|
version: string;
|
||||||
|
tagName: string;
|
||||||
|
publishedAt: Date;
|
||||||
|
downloadUrl: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
isNewer: boolean;
|
||||||
|
ageHours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAutoUpgradeStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
targetVersion: string | null;
|
||||||
|
scheduledIn: string | null;
|
||||||
|
waitingForStability: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IUpdateInfo {
|
||||||
|
currentVersion: string;
|
||||||
|
releases: IRelease[];
|
||||||
|
autoUpgrade: IAutoUpgradeStatus;
|
||||||
|
lastCheck: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IUpgradeResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
265
ecoos_daemon/ts_web/elements/ecoos-app.ts
Normal file
265
ecoos_daemon/ts_web/elements/ecoos-app.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* EcoOS App - Main application component
|
||||||
|
* Uses dees-simple-appdash as the dashboard shell
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
html,
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
state,
|
||||||
|
css,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
import { DeesSimpleAppdash, type IView } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
import type { IStatus } from '../../ts_interfaces/status.js';
|
||||||
|
import type { IDisplayInfo } from '../../ts_interfaces/display.js';
|
||||||
|
import type { IUpdateInfo } from '../../ts_interfaces/updates.js';
|
||||||
|
|
||||||
|
import { EcoosOverview } from './ecoos-overview.js';
|
||||||
|
import { EcoosDevices } from './ecoos-devices.js';
|
||||||
|
import { EcoosDisplays } from './ecoos-displays.js';
|
||||||
|
import { EcoosUpdates } from './ecoos-updates.js';
|
||||||
|
import { EcoosLogs } from './ecoos-logs.js';
|
||||||
|
|
||||||
|
@customElement('ecoos-app')
|
||||||
|
export class EcoosApp extends DeesElement {
|
||||||
|
@state()
|
||||||
|
private accessor status: IStatus | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor displays: IDisplayInfo[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor updateInfo: IUpdateInfo | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor initialVersion: string | null = null;
|
||||||
|
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private statusInterval: number | null = null;
|
||||||
|
private displaysInterval: number | null = null;
|
||||||
|
private updatesInterval: number | null = null;
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
dees-simple-appdash {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
private viewTabs: IView[] = [
|
||||||
|
{
|
||||||
|
name: 'Overview',
|
||||||
|
iconName: 'lucide:layoutGrid',
|
||||||
|
element: EcoosOverview,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Devices',
|
||||||
|
iconName: 'lucide:cpu',
|
||||||
|
element: EcoosDevices,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Displays',
|
||||||
|
iconName: 'lucide:monitor',
|
||||||
|
element: EcoosDisplays,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Updates',
|
||||||
|
iconName: 'lucide:download',
|
||||||
|
element: EcoosUpdates,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Logs',
|
||||||
|
iconName: 'lucide:scrollText',
|
||||||
|
element: EcoosLogs,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.startPolling();
|
||||||
|
this.connectWebSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this.stopPolling();
|
||||||
|
this.disconnectWebSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<dees-simple-appdash
|
||||||
|
name="EcoOS Management"
|
||||||
|
.viewTabs=${this.viewTabs}
|
||||||
|
@view-select=${this.handleViewSelect}
|
||||||
|
></dees-simple-appdash>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updated(changedProperties: Map<string, unknown>): void {
|
||||||
|
super.updated(changedProperties);
|
||||||
|
|
||||||
|
// Pass data to view components when they're rendered
|
||||||
|
this.updateViewData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateViewData(): void {
|
||||||
|
// Find and update the active view component
|
||||||
|
const appdash = this.shadowRoot?.querySelector('dees-simple-appdash');
|
||||||
|
if (!appdash) return;
|
||||||
|
|
||||||
|
// Get the current view content
|
||||||
|
const overview = appdash.shadowRoot?.querySelector('ecoos-overview') as EcoosOverview;
|
||||||
|
const devices = appdash.shadowRoot?.querySelector('ecoos-devices') as EcoosDevices;
|
||||||
|
const displays = appdash.shadowRoot?.querySelector('ecoos-displays') as EcoosDisplays;
|
||||||
|
const updates = appdash.shadowRoot?.querySelector('ecoos-updates') as EcoosUpdates;
|
||||||
|
const logs = appdash.shadowRoot?.querySelector('ecoos-logs') as EcoosLogs;
|
||||||
|
|
||||||
|
if (overview && this.status) {
|
||||||
|
overview.status = this.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (devices && this.status?.systemInfo) {
|
||||||
|
devices.systemInfo = this.status.systemInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displays) {
|
||||||
|
displays.displays = this.displays;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates && this.updateInfo) {
|
||||||
|
updates.updateInfo = this.updateInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logs && this.status) {
|
||||||
|
logs.daemonLogs = this.status.logs || [];
|
||||||
|
logs.systemLogs = this.status.systemLogs || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleViewSelect(event: CustomEvent): void {
|
||||||
|
console.log('View selected:', event.detail.view.name);
|
||||||
|
// Trigger a data update for the new view
|
||||||
|
setTimeout(() => this.updateViewData(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startPolling(): void {
|
||||||
|
// Initial fetches
|
||||||
|
this.fetchStatus();
|
||||||
|
this.fetchDisplays();
|
||||||
|
this.fetchUpdates();
|
||||||
|
|
||||||
|
// Periodic polling
|
||||||
|
this.statusInterval = window.setInterval(() => this.fetchStatus(), 3000);
|
||||||
|
this.displaysInterval = window.setInterval(() => this.fetchDisplays(), 5000);
|
||||||
|
this.updatesInterval = window.setInterval(() => this.fetchUpdates(), 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopPolling(): void {
|
||||||
|
if (this.statusInterval) {
|
||||||
|
clearInterval(this.statusInterval);
|
||||||
|
this.statusInterval = null;
|
||||||
|
}
|
||||||
|
if (this.displaysInterval) {
|
||||||
|
clearInterval(this.displaysInterval);
|
||||||
|
this.displaysInterval = null;
|
||||||
|
}
|
||||||
|
if (this.updatesInterval) {
|
||||||
|
clearInterval(this.updatesInterval);
|
||||||
|
this.updatesInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private connectWebSocket(): void {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||||
|
|
||||||
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data) as IStatus;
|
||||||
|
this.handleStatusUpdate(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('WebSocket message parse error:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
console.log('WebSocket disconnected, reconnecting in 3s...');
|
||||||
|
setTimeout(() => this.connectWebSocket(), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private disconnectWebSocket(): void {
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleStatusUpdate(data: IStatus): void {
|
||||||
|
// Check for version change and reload if needed
|
||||||
|
if (data.version) {
|
||||||
|
if (this.initialVersion === null) {
|
||||||
|
this.initialVersion = data.version;
|
||||||
|
} else if (data.version !== this.initialVersion) {
|
||||||
|
console.log(`Version changed from ${this.initialVersion} to ${data.version}, reloading...`);
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.status = data;
|
||||||
|
this.updateViewData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchStatus(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/status');
|
||||||
|
const data = await response.json() as IStatus;
|
||||||
|
this.handleStatusUpdate(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchDisplays(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/displays');
|
||||||
|
const data = await response.json();
|
||||||
|
this.displays = data.displays || [];
|
||||||
|
this.updateViewData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch displays:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchUpdates(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/updates');
|
||||||
|
const data = await response.json() as IUpdateInfo;
|
||||||
|
this.updateInfo = data;
|
||||||
|
this.updateViewData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch updates:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
130
ecoos_daemon/ts_web/elements/ecoos-devices.ts
Normal file
130
ecoos_daemon/ts_web/elements/ecoos-devices.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* EcoOS Devices View
|
||||||
|
* Shows network interfaces, disks, input devices, speakers, and microphones
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
html,
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
property,
|
||||||
|
css,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
import { sharedStyles, formatBytes } from '../styles/shared.js';
|
||||||
|
import type { ISystemInfo } from '../../ts_interfaces/status.js';
|
||||||
|
|
||||||
|
@customElement('ecoos-devices')
|
||||||
|
export class EcoosDevices extends DeesElement {
|
||||||
|
@property({ type: Object })
|
||||||
|
public accessor systemInfo: ISystemInfo | null = null;
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
sharedStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--ecoos-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-item {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
render(): TemplateResult {
|
||||||
|
if (!this.systemInfo) {
|
||||||
|
return html`<div>Loading...</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="grid">
|
||||||
|
<!-- Network Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Network</div>
|
||||||
|
${this.systemInfo.network?.length
|
||||||
|
? this.systemInfo.network.map(n => html`
|
||||||
|
<div class="network-item">
|
||||||
|
<span>${n.name}</span>
|
||||||
|
<span>${n.ip}</span>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
: html`<div style="color: var(--ecoos-text-dim)">No interfaces detected</div>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Disks Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Disks</div>
|
||||||
|
${this.systemInfo.disks?.length
|
||||||
|
? this.systemInfo.disks.map(d => html`
|
||||||
|
<div class="disk-item">
|
||||||
|
<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>
|
||||||
|
`)
|
||||||
|
: html`<div style="color: var(--ecoos-text-dim)">No disks detected</div>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input Devices Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Input Devices</div>
|
||||||
|
${this.systemInfo.inputDevices?.length
|
||||||
|
? this.systemInfo.inputDevices.map(d => html`
|
||||||
|
<div class="device-item">
|
||||||
|
<span class="device-name">${d.name}</span>
|
||||||
|
<span class="device-type">${d.type}</span>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
: html`<div style="color: var(--ecoos-text-dim)">No input devices detected</div>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Speakers Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Speakers</div>
|
||||||
|
${this.systemInfo.speakers?.length
|
||||||
|
? this.systemInfo.speakers.map(s => html`
|
||||||
|
<div class="device-item">
|
||||||
|
<span class="device-name">${s.description}</span>
|
||||||
|
${s.isDefault ? html`<span class="device-default">Default</span>` : ''}
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
: html`<div style="color: var(--ecoos-text-dim)">No speakers detected</div>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Microphones Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Microphones</div>
|
||||||
|
${this.systemInfo.microphones?.length
|
||||||
|
? this.systemInfo.microphones.map(m => html`
|
||||||
|
<div class="device-item">
|
||||||
|
<span class="device-name">${m.description}</span>
|
||||||
|
${m.isDefault ? html`<span class="device-default">Default</span>` : ''}
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
: html`<div style="color: var(--ecoos-text-dim)">No microphones detected</div>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
202
ecoos_daemon/ts_web/elements/ecoos-displays.ts
Normal file
202
ecoos_daemon/ts_web/elements/ecoos-displays.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* EcoOS Displays View
|
||||||
|
* Display management with enable/disable/primary controls
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
html,
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
property,
|
||||||
|
state,
|
||||||
|
css,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
import { sharedStyles } from '../styles/shared.js';
|
||||||
|
import type { IDisplayInfo } from '../../ts_interfaces/display.js';
|
||||||
|
|
||||||
|
@customElement('ecoos-displays')
|
||||||
|
export class EcoosDisplays extends DeesElement {
|
||||||
|
@property({ type: Array })
|
||||||
|
public accessor displays: IDisplayInfo[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor loading: boolean = false;
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
sharedStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-item {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--ecoos-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-details {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ecoos-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-actions .btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ecoos-text-dim);
|
||||||
|
margin: 16px 0 8px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header:hover {
|
||||||
|
color: var(--ecoos-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed-content.expanded {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
render(): TemplateResult {
|
||||||
|
const enabledDisplays = this.displays.filter(d => d.active);
|
||||||
|
const disabledDisplays = this.displays.filter(d => !d.active);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Displays</div>
|
||||||
|
|
||||||
|
${this.displays.length === 0
|
||||||
|
? html`<div style="color: var(--ecoos-text-dim)">No displays detected</div>`
|
||||||
|
: html`
|
||||||
|
<!-- Enabled Displays -->
|
||||||
|
${enabledDisplays.map(d => this.renderDisplayItem(d))}
|
||||||
|
|
||||||
|
<!-- Disabled Displays -->
|
||||||
|
${disabledDisplays.length > 0 ? html`
|
||||||
|
<details style="margin-top: 12px;">
|
||||||
|
<summary class="section-header">
|
||||||
|
Disabled Displays (${disabledDisplays.length})
|
||||||
|
</summary>
|
||||||
|
<div style="margin-top: 8px;">
|
||||||
|
${disabledDisplays.map(d => this.renderDisplayItem(d))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
` : ''}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderDisplayItem(display: IDisplayInfo): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="display-item">
|
||||||
|
<div class="display-info">
|
||||||
|
<div class="display-name">${display.name}</div>
|
||||||
|
<div class="display-details">
|
||||||
|
${display.width}x${display.height} @ ${display.refreshRate}Hz
|
||||||
|
${display.make !== 'Unknown' ? ` • ${display.make}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="display-actions">
|
||||||
|
${display.isPrimary
|
||||||
|
? html`<span class="device-default">Primary</span>`
|
||||||
|
: display.active
|
||||||
|
? html`
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click=${() => this.setKioskDisplay(display.name)}
|
||||||
|
?disabled=${this.loading}
|
||||||
|
>
|
||||||
|
Set Primary
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
class="btn ${display.active ? 'btn-danger' : 'btn-primary'}"
|
||||||
|
@click=${() => this.toggleDisplay(display.name, !display.active)}
|
||||||
|
?disabled=${this.loading}
|
||||||
|
>
|
||||||
|
${display.active ? 'Disable' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toggleDisplay(name: string, enable: boolean): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const action = enable ? 'enable' : 'disable';
|
||||||
|
const response = await fetch(`/api/displays/${encodeURIComponent(name)}/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.message);
|
||||||
|
}
|
||||||
|
this.dispatchEvent(new CustomEvent('refresh-displays'));
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${error}`);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setKioskDisplay(name: string): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/displays/${encodeURIComponent(name)}/primary`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.message);
|
||||||
|
}
|
||||||
|
this.dispatchEvent(new CustomEvent('refresh-displays'));
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${error}`);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
127
ecoos_daemon/ts_web/elements/ecoos-logs.ts
Normal file
127
ecoos_daemon/ts_web/elements/ecoos-logs.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* EcoOS Logs View
|
||||||
|
* Tabbed log viewer for daemon and system logs with auto-scroll
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
html,
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
property,
|
||||||
|
state,
|
||||||
|
css,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
import { sharedStyles } from '../styles/shared.js';
|
||||||
|
|
||||||
|
@customElement('ecoos-logs')
|
||||||
|
export class EcoosLogs extends DeesElement {
|
||||||
|
@property({ type: Array })
|
||||||
|
public accessor daemonLogs: string[] = [];
|
||||||
|
|
||||||
|
@property({ type: Array })
|
||||||
|
public accessor systemLogs: string[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor activeTab: 'daemon' | 'system' = 'daemon';
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
sharedStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-container {
|
||||||
|
background: var(--ecoos-card);
|
||||||
|
border: 1px solid var(--ecoos-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--ecoos-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-content {
|
||||||
|
height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
background: #0d0d0d;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message {
|
||||||
|
color: var(--ecoos-text-dim);
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
render(): TemplateResult {
|
||||||
|
const logs = this.activeTab === 'daemon' ? this.daemonLogs : this.systemLogs;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="logs-container">
|
||||||
|
<div class="logs-header">
|
||||||
|
<div class="tabs">
|
||||||
|
<div
|
||||||
|
class="tab ${this.activeTab === 'daemon' ? 'active' : ''}"
|
||||||
|
@click=${() => this.switchTab('daemon')}
|
||||||
|
>
|
||||||
|
Daemon Logs
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="tab ${this.activeTab === 'system' ? 'active' : ''}"
|
||||||
|
@click=${() => this.switchTab('system')}
|
||||||
|
>
|
||||||
|
System Logs
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="logs-content" id="logs-content">
|
||||||
|
${logs.length === 0
|
||||||
|
? html`<div class="empty-message">No logs available</div>`
|
||||||
|
: logs.map(log => html`<div class="log-entry">${log}</div>`)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private switchTab(tab: 'daemon' | 'system'): void {
|
||||||
|
this.activeTab = tab;
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
updated(changedProperties: Map<string, unknown>): void {
|
||||||
|
super.updated(changedProperties);
|
||||||
|
|
||||||
|
// Auto-scroll when logs change
|
||||||
|
if (changedProperties.has('daemonLogs') || changedProperties.has('systemLogs')) {
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollToBottom(): void {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const content = this.shadowRoot?.getElementById('logs-content');
|
||||||
|
if (content) {
|
||||||
|
content.scrollTop = content.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
193
ecoos_daemon/ts_web/elements/ecoos-overview.ts
Normal file
193
ecoos_daemon/ts_web/elements/ecoos-overview.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* EcoOS Overview View
|
||||||
|
* Shows services status, CPU, memory, system info, and controls
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
html,
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
property,
|
||||||
|
css,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
import { sharedStyles, formatBytes, formatUptime } from '../styles/shared.js';
|
||||||
|
import type { IStatus, IServiceStatus } from '../../ts_interfaces/status.js';
|
||||||
|
|
||||||
|
@customElement('ecoos-overview')
|
||||||
|
export class EcoosOverview extends DeesElement {
|
||||||
|
@property({ type: Object })
|
||||||
|
public accessor status: IStatus | null = null;
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
public accessor loading: boolean = false;
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
sharedStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-section {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-status {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ecoos-text-dim);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
render(): TemplateResult {
|
||||||
|
if (!this.status) {
|
||||||
|
return html`<div>Loading...</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { systemInfo, sway, chromium, swayStatus, chromiumStatus } = this.status;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="grid">
|
||||||
|
<!-- Services Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Services</div>
|
||||||
|
<div class="service-status">
|
||||||
|
<span class="status-dot ${this.getStatusClass(swayStatus)}"></span>
|
||||||
|
<span>Sway Compositor</span>
|
||||||
|
</div>
|
||||||
|
<div class="service-status">
|
||||||
|
<span class="status-dot ${this.getStatusClass(chromiumStatus)}"></span>
|
||||||
|
<span>Chromium Browser</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CPU Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">CPU</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Model</div>
|
||||||
|
<div class="stat-value">${systemInfo?.cpu?.model || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Cores</div>
|
||||||
|
<div class="stat-value">${systemInfo?.cpu?.cores || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Usage</div>
|
||||||
|
<div class="stat-value">${systemInfo?.cpu?.usage || 0}%</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: ${systemInfo?.cpu?.usage || 0}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Memory Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Memory</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Used / Total</div>
|
||||||
|
<div class="stat-value">
|
||||||
|
${formatBytes(systemInfo?.memory?.used || 0)} / ${formatBytes(systemInfo?.memory?.total || 0)}
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: ${systemInfo?.memory?.usagePercent || 0}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">System</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Hostname</div>
|
||||||
|
<div class="stat-value">${systemInfo?.hostname || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Uptime</div>
|
||||||
|
<div class="stat-value">${formatUptime(systemInfo?.uptime || 0)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">GPU</div>
|
||||||
|
<div class="stat-value">
|
||||||
|
${systemInfo?.gpu?.length ? systemInfo.gpu.map(g => g.name).join(', ') : 'None detected'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Controls</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click=${this.restartChromium}
|
||||||
|
?disabled=${this.loading || !sway}
|
||||||
|
>
|
||||||
|
Restart Browser
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
@click=${this.rebootSystem}
|
||||||
|
?disabled=${this.loading}
|
||||||
|
>
|
||||||
|
Reboot System
|
||||||
|
</button>
|
||||||
|
<div class="control-status" id="control-status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStatusClass(status: IServiceStatus): string {
|
||||||
|
switch (status?.state) {
|
||||||
|
case 'running': return 'running';
|
||||||
|
case 'starting': return 'starting';
|
||||||
|
default: return 'stopped';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restartChromium(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/restart-chromium', { method: 'POST' });
|
||||||
|
const result = await response.json();
|
||||||
|
this.showControlStatus(result.message, !result.success);
|
||||||
|
} catch (error) {
|
||||||
|
this.showControlStatus(`Error: ${error}`, true);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async rebootSystem(): Promise<void> {
|
||||||
|
if (!confirm('Are you sure you want to reboot the system?')) return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/reboot', { method: 'POST' });
|
||||||
|
const result = await response.json();
|
||||||
|
this.showControlStatus(result.message, !result.success);
|
||||||
|
} catch (error) {
|
||||||
|
this.showControlStatus(`Error: ${error}`, true);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private showControlStatus(message: string, isError: boolean): void {
|
||||||
|
const el = this.shadowRoot?.getElementById('control-status');
|
||||||
|
if (el) {
|
||||||
|
el.textContent = message;
|
||||||
|
el.style.color = isError ? 'var(--ecoos-error)' : 'var(--ecoos-success)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
185
ecoos_daemon/ts_web/elements/ecoos-updates.ts
Normal file
185
ecoos_daemon/ts_web/elements/ecoos-updates.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* EcoOS Updates View
|
||||||
|
* Version info, available updates, and upgrade controls
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
html,
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
property,
|
||||||
|
state,
|
||||||
|
css,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
import { sharedStyles, formatAge } from '../styles/shared.js';
|
||||||
|
import type { IUpdateInfo, IRelease } from '../../ts_interfaces/updates.js';
|
||||||
|
|
||||||
|
@customElement('ecoos-updates')
|
||||||
|
export class EcoosUpdates extends DeesElement {
|
||||||
|
@property({ type: Object })
|
||||||
|
public accessor updateInfo: IUpdateInfo | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor loading: boolean = false;
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
sharedStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--ecoos-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-version {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-age {
|
||||||
|
color: var(--ecoos-text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-item .btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-upgrade-status {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ecoos-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
render(): TemplateResult {
|
||||||
|
if (!this.updateInfo) {
|
||||||
|
return html`<div>Loading...</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newerReleases = this.updateInfo.releases.filter(r => r.isNewer);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Updates</div>
|
||||||
|
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Current Version</div>
|
||||||
|
<div class="stat-value">v${this.updateInfo.currentVersion}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 16px 0;">
|
||||||
|
${newerReleases.length === 0
|
||||||
|
? html`<div style="color: var(--ecoos-text-dim)">No updates available</div>`
|
||||||
|
: newerReleases.map(r => this.renderRelease(r))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auto-upgrade-status">
|
||||||
|
${this.renderAutoUpgradeStatus()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click=${this.checkForUpdates}
|
||||||
|
?disabled=${this.loading}
|
||||||
|
>
|
||||||
|
Check for Updates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderRelease(release: IRelease): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="release-item">
|
||||||
|
<span>
|
||||||
|
<span class="release-version">v${release.version}</span>
|
||||||
|
<span class="release-age">(${formatAge(release.ageHours)})</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click=${() => this.upgradeToVersion(release.version)}
|
||||||
|
?disabled=${this.loading}
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderAutoUpgradeStatus(): TemplateResult {
|
||||||
|
const { autoUpgrade, lastCheck } = this.updateInfo!;
|
||||||
|
|
||||||
|
if (autoUpgrade.targetVersion) {
|
||||||
|
if (autoUpgrade.waitingForStability) {
|
||||||
|
return html`Auto-upgrade to v${autoUpgrade.targetVersion} in ${autoUpgrade.scheduledIn} (stability period)`;
|
||||||
|
}
|
||||||
|
return html`Auto-upgrade to v${autoUpgrade.targetVersion} pending...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastCheck) {
|
||||||
|
return html`Last checked: ${new Date(lastCheck).toLocaleTimeString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkForUpdates(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/updates/check', { method: 'POST' });
|
||||||
|
const result = await response.json();
|
||||||
|
this.updateInfo = result;
|
||||||
|
this.dispatchEvent(new CustomEvent('updates-checked', { detail: result }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check updates:', error);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upgradeToVersion(version: string): Promise<void> {
|
||||||
|
if (!confirm(`Upgrade to version ${version}? The daemon will restart.`)) return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/upgrade', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ version }),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
this.dispatchEvent(new CustomEvent('upgrade-started', { detail: result }));
|
||||||
|
} else {
|
||||||
|
alert(`Upgrade failed: ${result.message}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Upgrade error: ${error}`);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
ecoos_daemon/ts_web/index.ts
Normal file
27
ecoos_daemon/ts_web/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* EcoOS Daemon UI Entry Point
|
||||||
|
* Bundles all components for the daemon UI
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Import all components to ensure they're registered
|
||||||
|
import './elements/ecoos-app.js';
|
||||||
|
import './elements/ecoos-overview.js';
|
||||||
|
import './elements/ecoos-devices.js';
|
||||||
|
import './elements/ecoos-displays.js';
|
||||||
|
import './elements/ecoos-updates.js';
|
||||||
|
import './elements/ecoos-logs.js';
|
||||||
|
|
||||||
|
// Export the main app component
|
||||||
|
export { EcoosApp } from './elements/ecoos-app.js';
|
||||||
|
|
||||||
|
// Create and mount the app when DOM is ready
|
||||||
|
function init() {
|
||||||
|
const app = document.createElement('ecoos-app');
|
||||||
|
document.body.appendChild(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
230
ecoos_daemon/ts_web/styles/shared.ts
Normal file
230
ecoos_daemon/ts_web/styles/shared.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* Shared styles for EcoOS UI components
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { css } from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
export const sharedStyles = css`
|
||||||
|
:host {
|
||||||
|
--ecoos-bg: #0a0a0a;
|
||||||
|
--ecoos-card: #141414;
|
||||||
|
--ecoos-border: #2a2a2a;
|
||||||
|
--ecoos-text: #e0e0e0;
|
||||||
|
--ecoos-text-dim: #888;
|
||||||
|
--ecoos-accent: #3b82f6;
|
||||||
|
--ecoos-success: #22c55e;
|
||||||
|
--ecoos-warning: #f59e0b;
|
||||||
|
--ecoos-error: #ef4444;
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
color: var(--ecoos-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--ecoos-card);
|
||||||
|
border: 1px solid var(--ecoos-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ecoos-text-dim);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--ecoos-text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
background: var(--ecoos-border);
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--ecoos-accent);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.running {
|
||||||
|
background: var(--ecoos-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.stopped {
|
||||||
|
background: var(--ecoos-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.starting {
|
||||||
|
background: var(--ecoos-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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(--ecoos-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--ecoos-error);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--ecoos-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(--ecoos-border);
|
||||||
|
color: var(--ecoos-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-default {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--ecoos-success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs {
|
||||||
|
height: 400px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--ecoos-border);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ecoos-text-dim);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--ecoos-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--ecoos-accent);
|
||||||
|
border-bottom-color: var(--ecoos-accent);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes to human readable string
|
||||||
|
*/
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format uptime seconds to human readable string
|
||||||
|
*/
|
||||||
|
export function formatUptime(seconds: number): string {
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format age in hours to human readable string
|
||||||
|
*/
|
||||||
|
export function formatAge(hours: number): string {
|
||||||
|
if (hours < 1) return `${Math.round(hours * 60)}m ago`;
|
||||||
|
if (hours < 24) return `${Math.round(hours)}h ago`;
|
||||||
|
return `${Math.round(hours / 24)}d ago`;
|
||||||
|
}
|
||||||
@@ -63,6 +63,12 @@ fi
|
|||||||
cleanup() {
|
cleanup() {
|
||||||
echo ""
|
echo ""
|
||||||
echo "Shutting down..."
|
echo "Shutting down..."
|
||||||
|
if [ -n "$SCREENSHOT_LOOP_PID" ] && kill -0 "$SCREENSHOT_LOOP_PID" 2>/dev/null; then
|
||||||
|
kill "$SCREENSHOT_LOOP_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [ -n "$ENABLE_PID" ] && kill -0 "$ENABLE_PID" 2>/dev/null; then
|
||||||
|
kill "$ENABLE_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
if [ -n "$VIEWER_PID" ] && kill -0 "$VIEWER_PID" 2>/dev/null; then
|
if [ -n "$VIEWER_PID" ] && kill -0 "$VIEWER_PID" 2>/dev/null; then
|
||||||
kill "$VIEWER_PID" 2>/dev/null || true
|
kill "$VIEWER_PID" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
@@ -212,11 +218,15 @@ if [ -f "$SCRIPT_DIR/enable-displays.py" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Tips:"
|
echo "Tips:"
|
||||||
echo " - pnpm run test:screenshot - Take screenshot"
|
|
||||||
echo " - http://localhost:3006 - Management UI"
|
echo " - http://localhost:3006 - Management UI"
|
||||||
echo " - socat - UNIX-CONNECT:.nogit/vm/serial.sock - Serial console (login: ecouser/ecouser)"
|
echo " - socat - UNIX-CONNECT:.nogit/vm/serial.sock - Serial console (login: ecouser/ecouser)"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# Start screenshot loop in background (takes screenshots every 5 seconds)
|
||||||
|
echo "Starting screenshot loop..."
|
||||||
|
(while true; do "$SCRIPT_DIR/screenshot.sh" 2>/dev/null; sleep 5; done) &
|
||||||
|
SCREENSHOT_LOOP_PID=$!
|
||||||
|
|
||||||
if [ "$AUTO_MODE" = true ]; then
|
if [ "$AUTO_MODE" = true ]; then
|
||||||
echo "=== Auto mode: waiting for display setup ==="
|
echo "=== Auto mode: waiting for display setup ==="
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ecobridge/eco-os",
|
"name": "@ecobridge/eco-os",
|
||||||
"version": "0.4.15",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "[ -z \"$CI\" ] && npm version patch --no-git-tag-version || true && node -e \"const v=require('./package.json').version; require('fs').writeFileSync('ecoos_daemon/ts/version.ts', 'export const VERSION = \\\"'+v+'\\\";\\n');\" && pnpm run daemon:bundle && cp ecoos_daemon/bundle/eco-daemon isobuild/config/includes.chroot/opt/eco/bin/ && mkdir -p .nogit/iso && docker build --no-cache -t ecoos-builder -f isobuild/Dockerfile . && docker run --privileged --name ecoos-build ecoos-builder && docker cp ecoos-build:/output/ecoos.iso .nogit/iso/ecoos.iso && docker rm ecoos-build",
|
"build": "[ -z \"$CI\" ] && npm version patch --no-git-tag-version || true && node -e \"const v=require('./package.json').version; require('fs').writeFileSync('ecoos_daemon/ts/version.ts', 'export const VERSION = \\\"'+v+'\\\";\\n');\" && pnpm run daemon:bundle && cp ecoos_daemon/bundle/eco-daemon isobuild/config/includes.chroot/opt/eco/bin/ && mkdir -p .nogit/iso && docker build --no-cache -t ecoos-builder -f isobuild/Dockerfile . && docker run --privileged --name ecoos-build ecoos-builder && docker cp ecoos-build:/output/ecoos.iso .nogit/iso/ecoos.iso && docker rm ecoos-build",
|
||||||
@@ -14,5 +14,6 @@
|
|||||||
"test:stop": "cd isotest && ./stop.sh",
|
"test:stop": "cd isotest && ./stop.sh",
|
||||||
"test:clean": "pnpm run test:stop && rm -rf .nogit/vm/*.qcow2 .nogit/screenshots/*",
|
"test:clean": "pnpm run test:stop && rm -rf .nogit/vm/*.qcow2 .nogit/screenshots/*",
|
||||||
"clean": "rm -rf .nogit/iso/*.iso && pnpm run test:clean"
|
"clean": "rm -rf .nogit/iso/*.iso && pnpm run test:clean"
|
||||||
}
|
},
|
||||||
|
"dependencies": {}
|
||||||
}
|
}
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
Normal file
9
pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.: {}
|
||||||
Reference in New Issue
Block a user