This commit is contained in:
2026-01-12 14:34:56 +00:00
parent 2b87d63121
commit 2d4846cfed
12 changed files with 1173 additions and 566 deletions

View File

@@ -1,6 +1,6 @@
/**
* EcoOS Devices View
* Shows network interfaces, disks, input devices, speakers, and microphones
* Card-based view for network, storage, input, and audio devices
*/
import {
@@ -11,6 +11,7 @@ import {
css,
type TemplateResult,
} from '@design.estate/dees-element';
import { DeesPanel, DeesBadge } from '@design.estate/dees-catalog';
import { sharedStyles, formatBytes } from '../styles/shared.js';
import type { ISystemInfo } from '../../ts_interfaces/status.js';
@@ -25,106 +26,192 @@ export class EcoosDevices extends DeesElement {
css`
:host {
display: block;
padding: 20px;
padding: 16px;
}
.network-item {
.cards-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
@media (max-width: 768px) {
.cards-grid {
grid-template-columns: 1fr;
}
}
.device-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--ecoos-border);
border-bottom: 1px solid var(--border);
gap: 12px;
}
.network-item:last-child {
.device-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.disk-item {
margin-bottom: 12px;
.device-row:first-child {
padding-top: 0;
}
.device-name {
font-weight: 500;
font-size: var(--text-sm);
flex-shrink: 0;
}
.device-info {
flex: 1;
text-align: right;
font-family: 'SF Mono', monospace;
font-size: var(--text-xs);
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-secondary {
font-size: var(--text-xs);
color: var(--text-tertiary);
margin-top: 2px;
}
.progress-mini {
width: 60px;
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
flex-shrink: 0;
}
.progress-mini-bar {
height: 100%;
background: var(--accent);
transition: width 300ms ease;
}
.progress-mini-bar.warning {
background: var(--warning);
}
.progress-mini-bar.error {
background: var(--error);
}
.usage-info {
display: flex;
align-items: center;
gap: 8px;
}
.usage-text {
font-size: var(--text-xs);
color: var(--text-secondary);
font-family: 'SF Mono', monospace;
}
.empty-text {
font-size: var(--text-sm);
color: var(--text-tertiary);
padding: 8px 0;
}
`,
];
render(): TemplateResult {
if (!this.systemInfo) {
return html`<div>Loading...</div>`;
return html`<div class="empty">Loading...</div>`;
}
return html`
<div class="grid">
<!-- Network Card -->
<div class="card">
<div class="card-title">Network</div>
<div class="cards-grid">
<!-- Network -->
<dees-panel .title=${'Network'}>
${this.systemInfo.network?.length
? this.systemInfo.network.map(n => html`
<div class="network-item">
<span>${n.name}</span>
<span>${n.ip}</span>
<div class="device-row">
<span class="device-name">${n.name}</span>
<span class="device-info">${n.ip || '—'}</span>
<dees-badge .type=${n.state === 'up' ? 'success' : 'error'}>${n.state}</dees-badge>
</div>
`)
: html`<div style="color: var(--ecoos-text-dim)">No interfaces detected</div>`
: html`<div class="empty-text">No network interfaces</div>`
}
</div>
</dees-panel>
<!-- Disks Card -->
<div class="card">
<div class="card-title">Disks</div>
<!-- Storage -->
<dees-panel .title=${'Storage'}>
${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 class="device-row">
<div>
<div class="device-name">${d.mountpoint}</div>
<div class="device-secondary">${d.device}</div>
</div>
<div class="usage-info">
<span class="usage-text">${formatBytes(d.used)} / ${formatBytes(d.total)}</span>
<div class="progress-mini">
<div class="progress-mini-bar ${this.getUsageClass(d.usagePercent || 0)}" style="width: ${d.usagePercent || 0}%"></div>
</div>
</div>
</div>
`)
: html`<div style="color: var(--ecoos-text-dim)">No disks detected</div>`
: html`<div class="empty-text">No disks</div>`
}
</div>
</dees-panel>
<!-- Input Devices Card -->
<div class="card">
<div class="card-title">Input Devices</div>
<!-- Input Devices -->
<dees-panel .title=${'Input Devices'}>
${this.systemInfo.inputDevices?.length
? this.systemInfo.inputDevices.map(d => html`
<div class="device-item">
<div class="device-row">
<span class="device-name">${d.name}</span>
<span class="device-type">${d.type}</span>
<dees-badge .type=${'default'}>${d.type}</dees-badge>
</div>
`)
: html`<div style="color: var(--ecoos-text-dim)">No input devices detected</div>`
: html`<div class="empty-text">No input devices</div>`
}
</div>
</dees-panel>
<!-- Speakers Card -->
<div class="card">
<div class="card-title">Speakers</div>
<!-- Audio Output -->
<dees-panel .title=${'Audio Output'}>
${this.systemInfo.speakers?.length
? this.systemInfo.speakers.map(s => html`
<div class="device-item">
<div class="device-row">
<span class="device-name">${s.description}</span>
${s.isDefault ? html`<span class="device-default">Default</span>` : ''}
${s.isDefault ? html`<dees-badge .type=${'success'}>Default</dees-badge>` : ''}
</div>
`)
: html`<div style="color: var(--ecoos-text-dim)">No speakers detected</div>`
: html`<div class="empty-text">No speakers</div>`
}
</div>
</dees-panel>
<!-- Microphones Card -->
<div class="card">
<div class="card-title">Microphones</div>
<!-- Audio Input -->
<dees-panel .title=${'Audio Input'}>
${this.systemInfo.microphones?.length
? this.systemInfo.microphones.map(m => html`
<div class="device-item">
<div class="device-row">
<span class="device-name">${m.description}</span>
${m.isDefault ? html`<span class="device-default">Default</span>` : ''}
${m.isDefault ? html`<dees-badge .type=${'success'}>Default</dees-badge>` : ''}
</div>
`)
: html`<div style="color: var(--ecoos-text-dim)">No microphones detected</div>`
: html`<div class="empty-text">No microphones</div>`
}
</div>
</dees-panel>
</div>
`;
}
private getUsageClass(usage: number): string {
if (usage > 90) return 'error';
if (usage > 75) return 'warning';
return '';
}
}

View File

@@ -1,6 +1,6 @@
/**
* EcoOS Displays View
* Display management with enable/disable/primary controls
* Card-based display management
*/
import {
@@ -12,6 +12,7 @@ import {
css,
type TemplateResult,
} from '@design.estate/dees-element';
import { DeesButton, DeesPanel, DeesBadge } from '@design.estate/dees-catalog';
import { sharedStyles } from '../styles/shared.js';
import type { IDisplayInfo } from '../../ts_interfaces/display.js';
@@ -24,73 +25,96 @@ export class EcoosDisplays extends DeesElement {
@state()
private accessor loading: boolean = false;
@state()
private accessor message: string = '';
@state()
private accessor messageError: boolean = false;
public static styles = [
sharedStyles,
css`
:host {
display: block;
padding: 20px;
padding: 16px;
}
.display-item {
.display-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.display-card {
opacity: 1;
transition: opacity 0.2s ease;
}
.display-card.disabled {
opacity: 0.5;
}
.display-meta {
font-size: var(--text-xs);
color: var(--text-tertiary);
margin-bottom: 12px;
}
.badge-row {
margin-bottom: 12px;
}
.actions-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 0;
border-bottom: 1px solid var(--ecoos-border);
flex-wrap: wrap;
}
.display-item:last-child {
border-bottom: none;
.message-bar {
margin-top: 16px;
padding: 8px 12px;
border-radius: 6px;
font-size: var(--text-sm);
}
.display-info {
flex: 1;
min-width: 150px;
.message-bar.success {
background: hsla(142.1, 76.2%, 36.3%, 0.15);
color: var(--success);
}
.display-name {
font-weight: 500;
font-size: 14px;
.message-bar.error {
background: hsla(0, 84.2%, 60.2%, 0.15);
color: var(--error);
}
.display-details {
font-size: 11px;
color: var(--ecoos-text-dim);
.empty-state {
text-align: center;
padding: 32px;
color: var(--text-tertiary);
}
.display-actions {
display: flex;
gap: 4px;
align-items: center;
.disabled-section {
margin-top: 16px;
}
.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;
.disabled-header {
font-size: var(--text-sm);
color: var(--text-tertiary);
margin-bottom: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
}
.section-header:hover {
color: var(--ecoos-text);
.disabled-header::before {
content: '▶';
font-size: 8px;
transition: transform 150ms ease;
}
.collapsed-content {
display: none;
}
.collapsed-content.expanded {
display: block;
.disabled-header.open::before {
transform: rotate(90deg);
}
`,
];
@@ -99,102 +123,109 @@ export class EcoosDisplays extends DeesElement {
const enabledDisplays = this.displays.filter(d => d.active);
const disabledDisplays = this.displays.filter(d => !d.active);
if (this.displays.length === 0) {
return html`
<div class="empty-state">
No displays detected
</div>
`;
}
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 class="display-grid">
${enabledDisplays.map(d => this.renderDisplayCard(d))}
</div>
${disabledDisplays.length > 0 ? html`
<details class="disabled-section">
<summary class="disabled-header">
Disabled Displays (${disabledDisplays.length})
</summary>
<div class="display-grid" style="margin-top: 12px;">
${disabledDisplays.map(d => this.renderDisplayCard(d))}
</div>
</details>
` : ''}
${this.message ? html`
<div class="message-bar ${this.messageError ? 'error' : 'success'}">${this.message}</div>
` : ''}
`;
}
private renderDisplayItem(display: IDisplayInfo): TemplateResult {
private renderDisplayCard(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}` : ''}
<dees-panel
class="display-card ${display.active ? '' : 'disabled'}"
.title=${display.name}
.subtitle=${`${display.width}×${display.height} @ ${display.refreshRate}Hz`}
.variant=${display.active ? 'default' : 'ghost'}
>
${display.make && display.make !== 'Unknown' ? html`
<div class="display-meta">${display.make}${display.model ? ` ${display.model}` : ''}</div>
` : ''}
${display.isPrimary ? html`
<div class="badge-row">
<dees-badge .type=${'primary'}>Primary</dees-badge>
</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'}"
` : ''}
<div class="actions-row">
${display.active && !display.isPrimary ? html`
<dees-button
.type=${'default'}
.text=${'Set Primary'}
.disabled=${this.loading}
@click=${() => this.setPrimary(display.name)}
></dees-button>
` : ''}
<dees-button
.type=${'default'}
.status=${display.active ? 'error' : 'success'}
.text=${display.active ? 'Disable' : 'Enable'}
.disabled=${this.loading}
@click=${() => this.toggleDisplay(display.name, !display.active)}
?disabled=${this.loading}
>
${display.active ? 'Disable' : 'Enable'}
</button>
></dees-button>
</div>
</div>
</dees-panel>
`;
}
private async toggleDisplay(name: string, enable: boolean): Promise<void> {
this.loading = true;
this.message = '';
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.message = result.message;
this.messageError = !result.success;
this.dispatchEvent(new CustomEvent('refresh-displays'));
} catch (error) {
alert(`Error: ${error}`);
this.message = `Error: ${error}`;
this.messageError = true;
} finally {
this.loading = false;
}
}
private async setKioskDisplay(name: string): Promise<void> {
private async setPrimary(name: string): Promise<void> {
this.loading = true;
this.message = '';
try {
const response = await fetch(`/api/displays/${encodeURIComponent(name)}/primary`, {
method: 'POST',
});
const result = await response.json();
if (!result.success) {
alert(result.message);
}
this.message = result.message;
this.messageError = !result.success;
this.dispatchEvent(new CustomEvent('refresh-displays'));
} catch (error) {
alert(`Error: ${error}`);
this.message = `Error: ${error}`;
this.messageError = true;
} finally {
this.loading = false;
}

View File

@@ -1,6 +1,6 @@
/**
* EcoOS Logs View
* Tabbed log viewer for daemon and system logs with auto-scroll
* Panel-wrapped terminal-style log viewer
*/
import {
@@ -12,6 +12,7 @@ import {
css,
type TemplateResult,
} from '@design.estate/dees-element';
import { DeesPanel } from '@design.estate/dees-catalog';
import { sharedStyles } from '../styles/shared.js';
@@ -26,46 +27,125 @@ export class EcoosLogs extends DeesElement {
@state()
private accessor activeTab: 'daemon' | 'system' = 'daemon';
@state()
private accessor autoScroll: boolean = true;
public static styles = [
sharedStyles,
css`
:host {
display: block;
padding: 20px;
padding: 16px;
}
.logs-container {
background: var(--ecoos-card);
border: 1px solid var(--ecoos-border);
border-radius: 8px;
overflow: hidden;
.container {
display: flex;
flex-direction: column;
height: calc(100vh - 140px);
min-height: 300px;
}
.logs-header {
padding: 12px 16px;
border-bottom: 1px solid var(--ecoos-border);
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
margin-bottom: 12px;
}
.logs-content {
height: 500px;
overflow-y: auto;
font-family: 'SF Mono', Monaco, monospace;
font-size: 12px;
line-height: 1.6;
background: #0d0d0d;
.tab {
padding: 8px 16px;
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-tertiary);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 150ms ease;
}
.tab:hover {
color: var(--text);
}
.tab.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.count {
font-size: var(--text-xs);
color: var(--text-tertiary);
font-family: 'SF Mono', monospace;
}
.auto-scroll {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--text-xs);
color: var(--text-tertiary);
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background 150ms ease;
}
.auto-scroll:hover {
background: var(--bg-hover);
}
.auto-scroll.active {
color: var(--accent);
}
.auto-scroll .indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-tertiary);
}
.auto-scroll.active .indicator {
background: var(--accent);
}
.terminal {
flex: 1;
background: hsl(0 0% 2%);
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 11px;
line-height: 1.5;
padding: 12px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 6px;
}
.log-entry {
.line {
white-space: pre-wrap;
word-break: break-all;
padding: 2px 0;
padding: 1px 0;
color: var(--text-secondary);
}
.empty-message {
color: var(--ecoos-text-dim);
padding: 20px;
text-align: center;
.line.error {
color: var(--error);
}
.line.warning {
color: var(--warning);
}
.empty-logs {
color: var(--text-tertiary);
font-style: italic;
}
`,
];
@@ -74,53 +154,88 @@ export class EcoosLogs extends DeesElement {
const logs = this.activeTab === 'daemon' ? this.daemonLogs : this.systemLogs;
return html`
<div class="logs-container">
<div class="logs-header">
<dees-panel .title=${'Logs'}>
<div class="container">
<div class="tabs">
<div
class="tab ${this.activeTab === 'daemon' ? 'active' : ''}"
@click=${() => this.switchTab('daemon')}
>
Daemon Logs
</div>
>Daemon</div>
<div
class="tab ${this.activeTab === 'system' ? 'active' : ''}"
@click=${() => this.switchTab('system')}
>System</div>
</div>
<div class="header-row">
<span class="count">${logs.length} lines</span>
<div
class="auto-scroll ${this.autoScroll ? 'active' : ''}"
@click=${this.toggleAutoScroll}
>
System Logs
<span class="indicator"></span>
Auto-scroll
</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 class="terminal" id="terminal" @scroll=${this.handleScroll}>
${logs.length === 0
? html`<div class="empty-logs">No logs</div>`
: logs.map(log => html`<div class="line ${this.getLogLevel(log)}">${log}</div>`)
}
</div>
</div>
</div>
</dees-panel>
`;
}
private getLogLevel(log: string): string {
const lower = log.toLowerCase();
if (lower.includes('error') || lower.includes('fail') || lower.includes('fatal')) {
return 'error';
}
if (lower.includes('warn')) {
return 'warning';
}
return '';
}
private switchTab(tab: 'daemon' | 'system'): void {
this.activeTab = tab;
this.scrollToBottom();
if (this.autoScroll) {
this.scrollToBottom();
}
}
private toggleAutoScroll(): void {
this.autoScroll = !this.autoScroll;
if (this.autoScroll) {
this.scrollToBottom();
}
}
private handleScroll(): void {
const terminal = this.shadowRoot?.getElementById('terminal');
if (terminal) {
const isAtBottom = terminal.scrollHeight - terminal.scrollTop <= terminal.clientHeight + 50;
if (!isAtBottom && this.autoScroll) {
this.autoScroll = false;
}
}
}
updated(changedProperties: Map<string, unknown>): void {
super.updated(changedProperties);
// Auto-scroll when logs change
if (changedProperties.has('daemonLogs') || changedProperties.has('systemLogs')) {
if ((changedProperties.has('daemonLogs') || changedProperties.has('systemLogs')) && this.autoScroll) {
this.scrollToBottom();
}
}
private scrollToBottom(): void {
requestAnimationFrame(() => {
const content = this.shadowRoot?.getElementById('logs-content');
if (content) {
content.scrollTop = content.scrollHeight;
const terminal = this.shadowRoot?.getElementById('terminal');
if (terminal) {
terminal.scrollTop = terminal.scrollHeight;
}
});
}

View File

@@ -1,6 +1,6 @@
/**
* EcoOS Overview View
* Shows services status, CPU, memory, system info, and controls
* Dashboard with stats grid, service panels, and system info
*/
import {
@@ -11,6 +11,13 @@ import {
css,
type TemplateResult,
} from '@design.estate/dees-element';
import {
DeesButton,
DeesPanel,
DeesStatsgrid,
DeesBadge,
type IStatsTile,
} from '@design.estate/dees-catalog';
import { sharedStyles, formatBytes, formatUptime } from '../styles/shared.js';
import type { IStatus, IServiceStatus } from '../../ts_interfaces/status.js';
@@ -23,171 +30,275 @@ export class EcoosOverview extends DeesElement {
@property({ type: Boolean })
public accessor loading: boolean = false;
@property({ type: String })
public accessor controlMessage: string = '';
@property({ type: Boolean })
public accessor controlError: boolean = false;
public static styles = [
sharedStyles,
css`
:host {
display: block;
padding: 20px;
padding: 16px;
}
.service-status {
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
.cards-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
@media (max-width: 768px) {
.cards-row {
grid-template-columns: 1fr;
}
}
.service-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.controls-section {
margin-top: 16px;
.service-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.control-status {
margin-top: 8px;
font-size: 12px;
color: var(--ecoos-text-dim);
.service-row:first-child {
padding-top: 0;
}
.service-name {
font-weight: 500;
font-size: var(--text-sm);
}
.service-error {
font-size: var(--text-xs);
color: var(--error);
margin-top: 2px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.info-label {
font-size: var(--text-xs);
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.info-value {
font-size: var(--text-sm);
font-family: 'SF Mono', monospace;
}
.actions-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.message {
font-size: var(--text-xs);
padding: 4px 8px;
border-radius: 4px;
}
.message.success {
background: hsla(142.1, 76.2%, 36.3%, 0.15);
color: var(--success);
}
.message.error {
background: hsla(0, 84.2%, 60.2%, 0.15);
color: var(--error);
}
`,
];
render(): TemplateResult {
if (!this.status) {
return html`<div>Loading...</div>`;
return html`<div class="empty">Loading...</div>`;
}
const { systemInfo, sway, chromium, swayStatus, chromiumStatus } = this.status;
const { systemInfo, swayStatus, chromiumStatus } = this.status;
const cpuUsage = systemInfo?.cpu?.usage || 0;
const memUsage = systemInfo?.memory?.usagePercent || 0;
const statsTiles: IStatsTile[] = [
{
id: 'cpu',
title: 'CPU',
value: Math.round(cpuUsage),
type: 'percentage',
icon: 'lucide:cpu',
description: `${systemInfo?.cpu?.cores || 0} cores`,
},
{
id: 'memory',
title: 'Memory',
value: Math.round(memUsage),
type: 'percentage',
icon: 'lucide:database',
description: `${formatBytes(systemInfo?.memory?.used || 0)} / ${formatBytes(systemInfo?.memory?.total || 0)}`,
},
{
id: 'uptime',
title: 'Uptime',
value: formatUptime(systemInfo?.uptime || 0),
type: 'text',
icon: 'lucide:clock',
},
];
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 class="page">
<!-- Stats Grid -->
<dees-statsgrid
.tiles=${statsTiles}
.minTileWidth=${200}
.gap=${16}
></dees-statsgrid>
<!-- Services & System Info -->
<div class="cards-row">
<dees-panel .title=${'Services'}>
<div class="service-row">
<div>
<div class="service-name">Sway Compositor</div>
${swayStatus?.error ? html`<div class="service-error">${swayStatus.error}</div>` : ''}
</div>
${this.renderStatusBadge(swayStatus)}
</div>
<div class="service-row">
<div>
<div class="service-name">Chromium Browser</div>
${chromiumStatus?.error ? html`<div class="service-error">${chromiumStatus.error}</div>` : ''}
</div>
${this.renderStatusBadge(chromiumStatus)}
</div>
</dees-panel>
<dees-panel .title=${'System'}>
<div class="info-grid">
<div class="info-item">
<span class="info-label">Hostname</span>
<span class="info-value">${systemInfo?.hostname || '—'}</span>
</div>
<div class="info-item">
<span class="info-label">CPU Model</span>
<span class="info-value">${this.truncate(systemInfo?.cpu?.model || '—', 20)}</span>
</div>
<div class="info-item" style="grid-column: span 2;">
<span class="info-label">GPU</span>
<span class="info-value">${systemInfo?.gpu?.length ? systemInfo.gpu.map(g => g.name).join(', ') : 'None'}</span>
</div>
</div>
</dees-panel>
</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>
<!-- Actions -->
<dees-panel .title=${'Actions'}>
<div class="actions-row">
<dees-button
.type=${'default'}
.text=${'Restart Browser'}
.disabled=${this.loading}
@click=${this.restartChromium}
></dees-button>
<dees-button
.type=${'default'}
.status=${'error'}
.text=${'Reboot System'}
.disabled=${this.loading}
@click=${this.rebootSystem}
></dees-button>
${this.controlMessage ? html`
<span class="message ${this.controlError ? 'error' : 'success'}">${this.controlMessage}</span>
` : ''}
</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>
</dees-panel>
</div>
`;
}
private getStatusClass(status: IServiceStatus): string {
switch (status?.state) {
case 'running': return 'running';
case 'starting': return 'starting';
default: return 'stopped';
private renderStatusBadge(status: IServiceStatus): TemplateResult {
const state = status?.state || 'stopped';
let badgeType: 'default' | 'success' | 'warning' | 'error' = 'default';
let label = 'Stopped';
if (state === 'running') {
badgeType = 'success';
label = 'Running';
} else if (state === 'starting') {
badgeType = 'warning';
label = 'Starting';
} else if (state === 'failed') {
badgeType = 'error';
label = 'Failed';
}
return html`<dees-badge .type=${badgeType}>${label}</dees-badge>`;
}
private truncate(str: string, len: number): string {
return str.length > len ? str.substring(0, len) + '...' : str;
}
private async restartChromium(): Promise<void> {
this.loading = true;
this.controlMessage = '';
try {
const response = await fetch('/api/restart-chromium', { method: 'POST' });
const result = await response.json();
this.showControlStatus(result.message, !result.success);
this.controlMessage = result.message;
this.controlError = !result.success;
} catch (error) {
this.showControlStatus(`Error: ${error}`, true);
this.controlMessage = `Error: ${error}`;
this.controlError = true;
} finally {
this.loading = false;
}
}
private async rebootSystem(): Promise<void> {
if (!confirm('Are you sure you want to reboot the system?')) return;
if (!confirm('Are you sure you want to reboot?')) return;
this.loading = true;
this.controlMessage = '';
try {
const response = await fetch('/api/reboot', { method: 'POST' });
const result = await response.json();
this.showControlStatus(result.message, !result.success);
this.controlMessage = result.message;
this.controlError = !result.success;
} catch (error) {
this.showControlStatus(`Error: ${error}`, true);
this.controlMessage = `Error: ${error}`;
this.controlError = 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)';
}
}
}

View File

@@ -1,6 +1,6 @@
/**
* EcoOS Updates View
* Version info, available updates, and upgrade controls
* Card-based update management
*/
import {
@@ -12,9 +12,10 @@ import {
css,
type TemplateResult,
} from '@design.estate/dees-element';
import { DeesButton, DeesPanel, DeesBadge } from '@design.estate/dees-catalog';
import { sharedStyles, formatAge } from '../styles/shared.js';
import type { IUpdateInfo, IRelease } from '../../ts_interfaces/updates.js';
import type { IUpdateInfo } from '../../ts_interfaces/updates.js';
@customElement('ecoos-updates')
export class EcoosUpdates extends DeesElement {
@@ -24,130 +25,174 @@ export class EcoosUpdates extends DeesElement {
@state()
private accessor loading: boolean = false;
@state()
private accessor message: string = '';
@state()
private accessor messageError: boolean = false;
public static styles = [
sharedStyles,
css`
:host {
display: block;
padding: 20px;
padding: 16px;
}
.release-item {
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
.version-display {
font-size: var(--text-2xl);
font-weight: 600;
font-family: 'SF Mono', monospace;
margin-bottom: 8px;
}
.last-check {
font-size: var(--text-xs);
color: var(--text-tertiary);
margin-bottom: 12px;
}
.banner-upgrade {
background: hsla(217.2, 91.2%, 59.8%, 0.1);
border-color: hsla(217.2, 91.2%, 59.8%, 0.3);
}
.banner-content {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--text-sm);
}
.banner-content strong {
font-family: 'SF Mono', monospace;
}
.update-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--ecoos-border);
padding: 10px 0;
border-bottom: 1px solid var(--border);
gap: 12px;
}
.release-item:last-child {
.update-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.release-version {
.update-row:first-child {
padding-top: 0;
}
.update-version {
font-family: 'SF Mono', monospace;
font-size: var(--text-sm);
font-weight: 500;
}
.release-age {
color: var(--ecoos-text-dim);
font-size: 12px;
.update-age {
font-size: var(--text-xs);
color: var(--text-tertiary);
flex: 1;
}
.release-item .btn {
padding: 4px 12px;
margin: 0;
font-size: 11px;
.empty-text {
font-size: var(--text-sm);
color: var(--text-tertiary);
padding: 8px 0;
}
.auto-upgrade-status {
margin-top: 16px;
font-size: 12px;
color: var(--ecoos-text-dim);
.message-bar {
padding: 8px 12px;
border-radius: 6px;
font-size: var(--text-sm);
}
.actions {
margin-top: 16px;
.message-bar.success {
background: hsla(142.1, 76.2%, 36.3%, 0.15);
color: var(--success);
}
.message-bar.error {
background: hsla(0, 84.2%, 60.2%, 0.15);
color: var(--error);
}
`,
];
render(): TemplateResult {
if (!this.updateInfo) {
return html`<div>Loading...</div>`;
return html`<div class="empty">Loading...</div>`;
}
const newerReleases = this.updateInfo.releases.filter(r => r.isNewer);
const { autoUpgrade, lastCheck } = this.updateInfo;
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"
<div class="page">
<!-- Current Version -->
<dees-panel .title=${'Current Version'}>
<div class="version-display">v${this.updateInfo.currentVersion}</div>
${lastCheck ? html`<div class="last-check">Last check: ${new Date(lastCheck).toLocaleString()}</div>` : ''}
<dees-button
.type=${'default'}
.text=${this.loading ? 'Checking...' : 'Check for Updates'}
.disabled=${this.loading}
@click=${this.checkForUpdates}
?disabled=${this.loading}
>
Check for Updates
</button>
</div>
></dees-button>
</dees-panel>
<!-- Auto-upgrade Banner -->
${autoUpgrade?.targetVersion ? html`
<dees-panel .variant=${'outline'} class="banner-upgrade">
<div class="banner-content">
${autoUpgrade.waitingForStability
? html`Auto-upgrade to <strong>v${autoUpgrade.targetVersion}</strong> in ${autoUpgrade.scheduledIn}`
: html`Auto-upgrade to <strong>v${autoUpgrade.targetVersion}</strong> pending`
}
</div>
</dees-panel>
` : ''}
<!-- Available Updates -->
<dees-panel .title=${'Available Updates'}>
${newerReleases.length === 0
? html`<div class="empty-text">You're up to date</div>`
: newerReleases.map(r => html`
<div class="update-row">
<span class="update-version">v${r.version}</span>
<span class="update-age">${formatAge(r.ageHours)}</span>
<dees-button
.type=${'default'}
.status=${'success'}
.text=${'Upgrade'}
.disabled=${this.loading}
@click=${() => this.upgradeToVersion(r.version)}
></dees-button>
</div>
`)
}
</dees-panel>
<!-- Message -->
${this.message ? html`
<div class="message-bar ${this.messageError ? 'error' : 'success'}">${this.message}</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;
this.message = '';
try {
const response = await fetch('/api/updates/check', { method: 'POST' });
const result = await response.json();
@@ -155,15 +200,18 @@ export class EcoosUpdates extends DeesElement {
this.dispatchEvent(new CustomEvent('updates-checked', { detail: result }));
} catch (error) {
console.error('Failed to check updates:', error);
this.message = `Failed: ${error}`;
this.messageError = true;
} finally {
this.loading = false;
}
}
private async upgradeToVersion(version: string): Promise<void> {
if (!confirm(`Upgrade to version ${version}? The daemon will restart.`)) return;
if (!confirm(`Upgrade to v${version}?`)) return;
this.loading = true;
this.message = '';
try {
const response = await fetch('/api/upgrade', {
method: 'POST',
@@ -172,12 +220,16 @@ export class EcoosUpdates extends DeesElement {
});
const result = await response.json();
if (result.success) {
this.message = result.message;
this.messageError = false;
this.dispatchEvent(new CustomEvent('upgrade-started', { detail: result }));
} else {
alert(`Upgrade failed: ${result.message}`);
this.message = `Failed: ${result.message}`;
this.messageError = true;
}
} catch (error) {
alert(`Upgrade error: ${error}`);
this.message = `Error: ${error}`;
this.messageError = true;
} finally {
this.loading = false;
}