feat(ecoos-daemon): integrate a bundled daemon web UI with components, interfaces, styles, bundling config, and server support
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user