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

File diff suppressed because one or more lines are too long

View File

@@ -78,6 +78,8 @@ export interface SystemInfoData {
}
export class SystemInfo {
private lastCpuStats: { total: number; idle: number } | null = null;
async getInfo(): Promise<SystemInfoData> {
const [hostname, cpu, memory, disks, network, gpu, uptime, inputDevices, speakers, microphones] =
await Promise.all([
@@ -112,13 +114,23 @@ export class SystemInfo {
const modelMatch = cpuinfo.match(/model name\s*:\s*(.+)/);
const coreMatches = cpuinfo.match(/processor\s*:/g);
// Get CPU usage from /proc/stat
// Get CPU usage from /proc/stat (delta between readings)
const stat = await Deno.readTextFile('/proc/stat');
const cpuLine = stat.split('\n')[0];
const values = cpuLine.split(/\s+/).slice(1).map(Number);
const total = values.reduce((a, b) => a + b, 0);
const idle = values[3];
const usage = ((total - idle) / total) * 100;
const idle = values[3] + values[4]; // idle + iowait
let usage = 0;
if (this.lastCpuStats) {
const totalDelta = total - this.lastCpuStats.total;
const idleDelta = idle - this.lastCpuStats.idle;
if (totalDelta > 0) {
usage = ((totalDelta - idleDelta) / totalDelta) * 100;
}
}
this.lastCpuStats = { total, idle };
return {
model: modelMatch ? modelMatch[1] : 'Unknown',

View File

@@ -1 +1 @@
export const VERSION = "0.4.14";
export const VERSION = "0.6.3";

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="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="empty-state">
No displays detected
</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 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>
<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>
`
: ''
</details>
` : ''}
${this.message ? html`
<div class="message-bar ${this.messageError ? 'error' : 'success'}">${this.message}</div>
` : ''}
`;
}
<button
class="btn ${display.active ? 'btn-danger' : 'btn-primary'}"
@click=${() => this.toggleDisplay(display.name, !display.active)}
?disabled=${this.loading}
private renderDisplayCard(display: IDisplayInfo): TemplateResult {
return html`
<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.active ? 'Disable' : 'Enable'}
</button>
${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 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)}
></dees-button>
</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 Logs
>System</div>
</div>
<div class="header-row">
<span class="count">${logs.length} lines</span>
<div
class="auto-scroll ${this.autoScroll ? 'active' : ''}"
@click=${this.toggleAutoScroll}
>
<span class="indicator"></span>
Auto-scroll
</div>
</div>
<div class="logs-content" id="logs-content">
<div class="terminal" id="terminal" @scroll=${this.handleScroll}>
${logs.length === 0
? html`<div class="empty-message">No logs available</div>`
: logs.map(log => html`<div class="log-entry">${log}</div>`)
? html`<div class="empty-logs">No logs</div>`
: logs.map(log => html`<div class="line ${this.getLogLevel(log)}">${log}</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;
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 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>
<div class="service-status">
<span class="status-dot ${this.getStatusClass(chromiumStatus)}"></span>
<span>Chromium Browser</span>
${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>
</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"
<!-- Actions -->
<dees-panel .title=${'Actions'}>
<div class="actions-row">
<dees-button
.type=${'default'}
.text=${'Restart Browser'}
.disabled=${this.loading}
@click=${this.restartChromium}
?disabled=${this.loading || !sway}
>
Restart Browser
</button>
<button
class="btn btn-danger"
></dees-button>
<dees-button
.type=${'default'}
.status=${'error'}
.text=${'Reboot System'}
.disabled=${this.loading}
@click=${this.rebootSystem}
?disabled=${this.loading}
>
Reboot System
</button>
<div class="control-status" id="control-status"></div>
></dees-button>
${this.controlMessage ? html`
<span class="message ${this.controlError ? 'error' : 'success'}">${this.controlMessage}</span>
` : ''}
</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>
></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;
}

View File

@@ -13,15 +13,3 @@ 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();
}

View File

@@ -1,200 +1,410 @@
/**
* Shared styles for EcoOS UI components
* EcoOS UI Design System
* Based on dees-catalog design patterns
*/
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;
/* Colors - dees-catalog theme (HSL) */
--bg: hsl(0 0% 3.9%);
--bg-elevated: hsl(0 0% 7.8%);
--bg-hover: hsl(0 0% 14.9%);
--border: hsl(0 0% 14.9%);
--border-hover: hsl(0 0% 20.9%);
--text: hsl(0 0% 95%);
--text-secondary: hsl(215 20.2% 55.1%);
--text-tertiary: hsl(215 20.2% 45%);
/* Semantic colors */
--accent: hsl(217.2 91.2% 59.8%);
--success: hsl(142.1 76.2% 36.3%);
--warning: hsl(45.4 93.4% 47.5%);
--error: hsl(0 84.2% 60.2%);
/* Typography scale */
--text-xs: 11px;
--text-sm: 12px;
--text-base: 13px;
--text-lg: 15px;
--text-xl: 18px;
--text-2xl: 24px;
display: block;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: var(--ecoos-text);
font-size: var(--text-base);
color: var(--text);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.card {
background: var(--ecoos-card);
border: 1px solid var(--ecoos-border);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
/* Monospace utility */
.mono {
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
}
.card-title {
font-size: 14px;
text-transform: uppercase;
color: var(--ecoos-text-dim);
margin-bottom: 12px;
font-weight: 500;
/* Section - lightweight container */
.section {
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.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 {
.section:last-child {
border-bottom: none;
}
.device-name {
.section-title {
font-size: var(--text-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary);
margin-bottom: 8px;
}
.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;
/* Card - minimal styling */
.card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
border-radius: 4px;
}
.log-entry {
white-space: pre-wrap;
word-break: break-all;
/* Table styling */
.table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.table th {
text-align: left;
font-size: var(--text-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-tertiary);
padding: 6px 8px;
border-bottom: 1px solid var(--border);
}
.table td {
padding: 8px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.table tr:last-child td {
border-bottom: none;
}
.table tr:hover td {
background: var(--bg-hover);
}
.table .mono {
font-size: var(--text-xs);
color: var(--text-secondary);
}
/* Status dot */
.dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-tertiary);
}
.dot.success { background: var(--success); }
.dot.warning { background: var(--warning); }
.dot.error { background: var(--error); }
.dot.accent { background: var(--accent); }
.dot.pulse {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Status with dot and text */
.status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--text-sm);
}
.status.success { color: var(--success); }
.status.warning { color: var(--warning); }
.status.error { color: var(--error); }
/* Progress bar - thin */
.progress {
height: 3px;
background: var(--border);
border-radius: 1.5px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--accent);
transition: width 300ms ease;
}
.progress-bar.success { background: var(--success); }
.progress-bar.warning { background: var(--warning); }
.progress-bar.error { background: var(--error); }
/* Badge - compact */
.badge {
display: inline-flex;
align-items: center;
font-size: var(--text-xs);
font-weight: 500;
padding: 2px 6px;
border-radius: 3px;
background: var(--border);
color: var(--text-secondary);
}
.badge.primary {
background: var(--accent);
color: #fff;
}
.badge.success {
background: rgba(12, 206, 107, 0.15);
color: var(--success);
}
.badge.warning {
background: rgba(245, 166, 35, 0.15);
color: var(--warning);
}
.badge.error {
background: rgba(238, 0, 0, 0.15);
color: var(--error);
}
/* Data row - key value pair */
.data-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
}
.data-row + .data-row {
border-top: 1px solid var(--border);
}
.data-label {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.data-value {
font-family: 'SF Mono', monospace;
font-size: var(--text-sm);
color: var(--text);
}
/* Stat - large value display */
.stat {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: var(--text-xs);
color: var(--text-tertiary);
margin-bottom: 2px;
}
.stat-value {
font-size: var(--text-2xl);
font-weight: 600;
font-family: 'SF Mono', monospace;
letter-spacing: -0.02em;
color: var(--text);
}
.stat-value.sm {
font-size: var(--text-lg);
}
/* Grid layouts */
.grid {
display: grid;
gap: 12px;
}
.grid-2 {
grid-template-columns: repeat(2, 1fr);
}
.grid-3 {
grid-template-columns: repeat(3, 1fr);
}
.grid-auto {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
/* Flex utilities */
.flex {
display: flex;
align-items: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.gap-4 { gap: 4px; }
.gap-6 { gap: 6px; }
.gap-8 { gap: 8px; }
.gap-12 { gap: 12px; }
/* Tabs - underline style */
.tabs {
display: flex;
border-bottom: 1px solid var(--ecoos-border);
margin-bottom: 12px;
gap: 0;
border-bottom: 1px solid var(--border);
}
.tab {
padding: 8px 16px;
padding: 8px 12px;
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-tertiary);
cursor: pointer;
color: var(--ecoos-text-dim);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-size: 12px;
text-transform: uppercase;
transition: color 150ms ease;
}
.tab:hover {
color: var(--ecoos-text);
color: var(--text);
}
.tab.active {
color: var(--ecoos-accent);
border-bottom-color: var(--ecoos-accent);
color: var(--text);
border-bottom-color: var(--text);
}
/* Empty state */
.empty {
padding: 24px;
text-align: center;
color: var(--text-tertiary);
font-size: var(--text-sm);
}
/* Actions row */
.actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
/* Collapsible details */
details summary {
cursor: pointer;
padding: 8px 0;
font-size: var(--text-sm);
color: var(--text-secondary);
list-style: none;
display: flex;
align-items: center;
gap: 6px;
}
details summary::-webkit-details-marker {
display: none;
}
details summary::before {
content: '▶';
font-size: 8px;
transition: transform 150ms ease;
}
details[open] summary::before {
transform: rotate(90deg);
}
details summary:hover {
color: var(--text);
}
.details-content {
padding: 8px 0 8px 14px;
}
/* Alert/Banner - slim */
.banner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
font-size: var(--text-sm);
border-radius: 4px;
background: rgba(0, 112, 243, 0.1);
border: 1px solid rgba(0, 112, 243, 0.2);
color: var(--text);
}
.banner.success {
background: rgba(12, 206, 107, 0.1);
border-color: rgba(12, 206, 107, 0.2);
}
.banner.warning {
background: rgba(245, 166, 35, 0.1);
border-color: rgba(245, 166, 35, 0.2);
}
.banner.error {
background: rgba(238, 0, 0, 0.1);
border-color: rgba(238, 0, 0, 0.2);
}
/* Scrollbar - minimal */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-hover);
}
/* Text utilities */
.text-xs { font-size: var(--text-xs); }
.text-sm { font-size: var(--text-sm); }
.text-base { font-size: var(--text-base); }
.text-lg { font-size: var(--text-lg); }
.text-secondary { color: var(--text-secondary); }
.text-tertiary { color: var(--text-tertiary); }
`;
/**

View File

@@ -1,13 +1,14 @@
{
"name": "@ecobridge/eco-os",
"version": "0.6.0",
"version": "0.6.3",
"private": true,
"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:ui && 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",
"daemon:dev": "cd ecoos_daemon && deno run --allow-all --watch mod.ts",
"daemon:start": "cd ecoos_daemon && deno run --allow-all mod.ts",
"daemon:typecheck": "cd ecoos_daemon && deno check mod.ts",
"daemon:bundle": "cd ecoos_daemon && deno compile --allow-all --output bundle/eco-daemon mod.ts",
"daemon:ui": "cd ecoos_daemon && pnpm run build",
"daemon:bundle": "cd ecoos_daemon && pnpm run build && deno compile --allow-all --output bundle/eco-daemon mod.ts",
"test": "pnpm run test:clean && cd isotest && ./run-test.sh",
"test:screenshot": "cd isotest && ./screenshot.sh",
"test:screenshot:loop": "while true; do pnpm run test:screenshot; sleep 5; done",