feat(daemon): add automatic update mechanism (Updater), switch to system journal logs, and expose update controls in the UI

This commit is contained in:
2026-01-09 16:55:43 +00:00
parent 1e86acff55
commit 6a3be55cee
8 changed files with 449 additions and 33 deletions

View File

@@ -6,6 +6,7 @@
import { ProcessManager } from './process-manager.ts';
import { SystemInfo } from './system-info.ts';
import { Updater } from './updater.ts';
import { UIServer } from '../ui/server.ts';
import { runCommand } from '../utils/command.ts';
import { VERSION } from '../version.ts';
@@ -28,12 +29,14 @@ export class EcoDaemon {
private config: DaemonConfig;
private processManager: ProcessManager;
private systemInfo: SystemInfo;
private updater: Updater;
private uiServer: UIServer;
private logs: string[] = [];
private serialLogs: string[] = [];
private systemLogs: string[] = [];
private swayStatus: ServiceStatus = { state: 'stopped' };
private chromiumStatus: ServiceStatus = { state: 'stopped' };
private manualRestartUntil: number = 0; // Timestamp until which auto-restart is disabled
private lastAutoUpgradeCheck: number = 0; // Timestamp of last auto-upgrade check
constructor(config?: Partial<DaemonConfig>) {
this.config = {
@@ -45,6 +48,7 @@ export class EcoDaemon {
this.processManager = new ProcessManager(this.config.user);
this.systemInfo = new SystemInfo();
this.updater = new Updater((msg) => this.log(msg));
this.uiServer = new UIServer(this.config.uiPort, this);
}
@@ -64,8 +68,8 @@ export class EcoDaemon {
return [...this.logs];
}
getSerialLogs(): string[] {
return [...this.serialLogs];
getSystemLogs(): string[] {
return [...this.systemLogs];
}
async getStatus(): Promise<Record<string, unknown>> {
@@ -78,7 +82,7 @@ export class EcoDaemon {
chromiumStatus: this.chromiumStatus,
systemInfo,
logs: this.logs.slice(-50),
serialLogs: this.serialLogs.slice(-50),
systemLogs: this.systemLogs.slice(-50),
};
}
@@ -131,6 +135,18 @@ export class EcoDaemon {
}
}
async getUpdateInfo(): Promise<unknown> {
return this.updater.getUpdateInfo();
}
async checkForUpdates(): Promise<void> {
await this.updater.checkForUpdates();
}
async upgradeToVersion(version: string): Promise<{ success: boolean; message: string }> {
return this.updater.upgradeToVersion(version);
}
async start(): Promise<void> {
this.log('EcoOS Daemon starting...');
@@ -139,8 +155,11 @@ export class EcoDaemon {
await this.uiServer.start();
this.log('Management UI started successfully');
// Start serial console reader in the background
this.startSerialReader();
// Start system journal reader in the background
this.startJournalReader();
// Check for updates on startup
this.updater.checkForUpdates().catch((e) => this.log(`Initial update check failed: ${e}`));
// Start the Sway/Chromium initialization in the background
// This allows the UI server to remain responsive even if Sway fails
@@ -313,12 +332,19 @@ export class EcoDaemon {
return parseInt(result.stdout.trim(), 10);
}
private startSerialReader(): void {
private startJournalReader(): void {
(async () => {
try {
const file = await Deno.open('/dev/ttyS0', { read: true });
this.log('Serial console reader started on /dev/ttyS0');
const reader = file.readable.getReader();
const cmd = new Deno.Command('journalctl', {
args: ['-f', '--no-pager', '-n', '100', '-o', 'short-iso'],
stdout: 'piped',
stderr: 'piped',
});
const process = cmd.spawn();
this.log('System journal reader started');
const reader = process.stdout.getReader();
const decoder = new TextDecoder();
while (true) {
@@ -326,14 +352,14 @@ export class EcoDaemon {
if (done) break;
const text = decoder.decode(value);
for (const line of text.split('\n').filter((l) => l.trim())) {
this.serialLogs.push(line);
if (this.serialLogs.length > 1000) {
this.serialLogs = this.serialLogs.slice(-1000);
this.systemLogs.push(line);
if (this.systemLogs.length > 1000) {
this.systemLogs = this.systemLogs.slice(-1000);
}
}
}
} catch (error) {
this.log(`Serial reader not available: ${error}`);
this.log(`Journal reader not available: ${error}`);
}
})();
}
@@ -383,6 +409,16 @@ export class EcoDaemon {
await this.tryStartSwayAndChromium();
}
}
// Check for auto-upgrades every hour
const now = Date.now();
const oneHour = 60 * 60 * 1000;
if (now - this.lastAutoUpgradeCheck > oneHour) {
this.lastAutoUpgradeCheck = now;
this.updater.checkAutoUpgrade().catch((e) =>
this.log(`Auto-upgrade check failed: ${e}`)
);
}
} catch (error) {
this.log(`Error in monitoring loop: ${error}`);
}

View File

@@ -0,0 +1,270 @@
/**
* Updater
*
* Handles checking for updates, downloading new versions, and performing upgrades
*/
import { VERSION } from '../version.ts';
import { runCommand } from '../utils/command.ts';
export interface Release {
version: string;
tagName: string;
publishedAt: Date;
downloadUrl: string;
isCurrent: boolean;
isNewer: boolean;
ageHours: number;
}
export interface AutoUpgradeStatus {
enabled: boolean;
targetVersion: string | null;
scheduledIn: string | null;
waitingForStability: boolean;
}
export interface UpdateInfo {
currentVersion: string;
releases: Release[];
autoUpgrade: AutoUpgradeStatus;
lastCheck: string | null;
}
interface GiteaRelease {
id: number;
tag_name: string;
name: string;
body: string;
published_at: string;
assets: GiteaAsset[];
}
interface GiteaAsset {
id: number;
name: string;
browser_download_url: string;
size: number;
}
export class Updater {
private repoApiUrl = 'https://code.foss.global/api/v1/repos/ecobridge/eco-os/releases';
private binaryPath = '/opt/eco/bin/eco-daemon';
private releases: Release[] = [];
private lastCheck: Date | null = null;
private logFn: (msg: string) => void;
constructor(logFn: (msg: string) => void) {
this.logFn = logFn;
}
private log(message: string): void {
this.logFn(`[Updater] ${message}`);
}
/**
* Compare semantic versions
* Returns: -1 if a < b, 0 if a == b, 1 if a > b
*/
private compareVersions(a: string, b: string): number {
const partsA = a.replace(/^v/, '').split('.').map(Number);
const partsB = b.replace(/^v/, '').split('.').map(Number);
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const numA = partsA[i] || 0;
const numB = partsB[i] || 0;
if (numA < numB) return -1;
if (numA > numB) return 1;
}
return 0;
}
/**
* Fetch available releases from Gitea
*/
async checkForUpdates(): Promise<Release[]> {
this.log('Checking for updates...');
try {
const response = await fetch(this.repoApiUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const giteaReleases: GiteaRelease[] = await response.json();
const currentVersion = VERSION;
const now = new Date();
this.releases = giteaReleases
.filter((r) => r.tag_name.startsWith('v'))
.map((r) => {
const version = r.tag_name.replace(/^v/, '');
const publishedAt = new Date(r.published_at);
const ageMs = now.getTime() - publishedAt.getTime();
const ageHours = ageMs / (1000 * 60 * 60);
// Find the daemon binary asset
const daemonAsset = r.assets.find((a) =>
a.name.includes('eco-daemon')
);
return {
version,
tagName: r.tag_name,
publishedAt,
downloadUrl: daemonAsset?.browser_download_url || '',
isCurrent: version === currentVersion,
isNewer: this.compareVersions(version, currentVersion) > 0,
ageHours: Math.round(ageHours * 10) / 10,
};
})
.filter((r) => r.downloadUrl) // Only include releases with daemon binary
.sort((a, b) => this.compareVersions(b.version, a.version)); // Newest first
this.lastCheck = now;
this.log(`Found ${this.releases.length} releases, ${this.releases.filter((r) => r.isNewer).length} newer than current`);
return this.releases;
} catch (error) {
this.log(`Failed to check for updates: ${error}`);
return this.releases;
}
}
/**
* Get cached releases (call checkForUpdates first)
*/
getReleases(): Release[] {
return this.releases;
}
/**
* Determine if auto-upgrade should happen and to which version
*/
getAutoUpgradeStatus(): AutoUpgradeStatus {
const newerReleases = this.releases.filter((r) => r.isNewer);
if (newerReleases.length === 0) {
return {
enabled: true,
targetVersion: null,
scheduledIn: null,
waitingForStability: false,
};
}
// Find the latest newer release
const latest = newerReleases[0];
const hoursUntilUpgrade = 24 - latest.ageHours;
if (hoursUntilUpgrade <= 0) {
// Ready to upgrade now
return {
enabled: true,
targetVersion: latest.version,
scheduledIn: 'now',
waitingForStability: false,
};
}
// Still waiting for stability period
const hours = Math.floor(hoursUntilUpgrade);
const minutes = Math.round((hoursUntilUpgrade - hours) * 60);
const scheduledIn = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
return {
enabled: true,
targetVersion: latest.version,
scheduledIn,
waitingForStability: true,
};
}
/**
* Get full update info for API response
*/
getUpdateInfo(): UpdateInfo {
return {
currentVersion: VERSION,
releases: this.releases,
autoUpgrade: this.getAutoUpgradeStatus(),
lastCheck: this.lastCheck?.toISOString() || null,
};
}
/**
* Download and install a specific version
*/
async upgradeToVersion(version: string): Promise<{ success: boolean; message: string }> {
const release = this.releases.find((r) => r.version === version);
if (!release) {
return { success: false, message: `Version ${version} not found` };
}
if (release.isCurrent) {
return { success: false, message: `Already running version ${version}` };
}
this.log(`Starting upgrade to version ${version}...`);
try {
// Download new binary
const tempPath = '/tmp/eco-daemon-new';
this.log(`Downloading from ${release.downloadUrl}...`);
const response = await fetch(release.downloadUrl);
if (!response.ok) {
throw new Error(`Download failed: HTTP ${response.status}`);
}
const data = await response.arrayBuffer();
await Deno.writeFile(tempPath, new Uint8Array(data));
// Verify download
const stat = await Deno.stat(tempPath);
if (stat.size < 1000000) {
// Daemon should be at least 1MB
throw new Error(`Downloaded file too small: ${stat.size} bytes`);
}
this.log(`Downloaded ${stat.size} bytes`);
// Make executable
await Deno.chmod(tempPath, 0o755);
// Replace binary
this.log('Replacing binary...');
await runCommand('mv', [tempPath, this.binaryPath]);
await Deno.chmod(this.binaryPath, 0o755);
// Restart daemon via systemd
this.log('Restarting daemon...');
// Use spawn to avoid waiting for the restart
const restartCmd = new Deno.Command('systemctl', {
args: ['restart', 'eco-daemon'],
stdout: 'null',
stderr: 'null',
});
restartCmd.spawn();
return { success: true, message: `Upgrading to v${version}...` };
} catch (error) {
this.log(`Upgrade failed: ${error}`);
return { success: false, message: String(error) };
}
}
/**
* Check and perform auto-upgrade if conditions are met
*/
async checkAutoUpgrade(): Promise<void> {
await this.checkForUpdates();
const status = this.getAutoUpgradeStatus();
if (status.targetVersion && status.scheduledIn === 'now') {
this.log(`Auto-upgrading to version ${status.targetVersion}...`);
await this.upgradeToVersion(status.targetVersion);
}
}
}

View File

@@ -104,6 +104,31 @@ export class UIServer {
return new Response(JSON.stringify(result), { headers });
}
if (path === '/api/updates') {
const updates = await this.daemon.getUpdateInfo();
return new Response(JSON.stringify(updates), { headers });
}
if (path === '/api/updates/check' && req.method === 'POST') {
await this.daemon.checkForUpdates();
const updates = await this.daemon.getUpdateInfo();
return new Response(JSON.stringify(updates), { headers });
}
if (path === '/api/upgrade' && req.method === 'POST') {
try {
const body = await req.json();
const version = body.version;
if (!version) {
return new Response(JSON.stringify({ success: false, message: 'Version required' }), { headers });
}
const result = await this.daemon.upgradeToVersion(version);
return new Response(JSON.stringify(result), { headers });
} catch (error) {
return new Response(JSON.stringify({ success: false, message: String(error) }), { headers });
}
}
return new Response(JSON.stringify({ error: 'Not Found' }), {
status: 404,
headers,
@@ -347,6 +372,18 @@ export class UIServer {
</button>
<div id="control-status" style="margin-top: 8px; font-size: 12px; color: var(--text-dim);"></div>
</div>
<div class="card">
<h2>Updates</h2>
<div class="stat">
<div class="stat-label">Current Version</div>
<div class="stat-value" id="current-version">-</div>
</div>
<div id="updates-list" style="margin: 12px 0;"></div>
<div id="auto-upgrade-status" style="font-size: 12px; color: var(--text-dim);"></div>
<button class="btn btn-primary" onclick="checkForUpdates()" style="margin-top: 8px;">
Check for Updates
</button>
</div>
<div class="card">
<h2>Input Devices</h2>
<div id="input-devices-list"></div>
@@ -362,7 +399,7 @@ export class UIServer {
<div class="card" style="grid-column: 1 / -1;">
<div class="tabs">
<div class="tab active" onclick="switchTab('daemon')">Daemon Logs</div>
<div class="tab" onclick="switchTab('serial')">Serial Console</div>
<div class="tab" onclick="switchTab('serial')">System Logs</div>
</div>
<div id="daemon-tab" class="tab-content active">
<div class="logs" id="logs"></div>
@@ -522,13 +559,13 @@ export class UIServer {
logsEl.scrollTop = logsEl.scrollHeight;
}
// Serial Logs
if (data.serialLogs) {
// System Logs
if (data.systemLogs) {
const serialEl = document.getElementById('serial-logs');
if (data.serialLogs.length === 0) {
serialEl.innerHTML = '<div style="color: var(--text-dim);">No serial data available</div>';
if (data.systemLogs.length === 0) {
serialEl.innerHTML = '<div style="color: var(--text-dim);">No system logs available</div>';
} else {
serialEl.innerHTML = data.serialLogs.map(l =>
serialEl.innerHTML = data.systemLogs.map(l =>
'<div class="log-entry">' + l + '</div>'
).join('');
serialEl.scrollTop = serialEl.scrollHeight;
@@ -590,6 +627,77 @@ export class UIServer {
});
}
function checkForUpdates() {
fetch('/api/updates/check', { method: 'POST' })
.then(r => r.json())
.then(updateUpdatesUI)
.catch(err => console.error('Failed to check updates:', err));
}
function upgradeToVersion(version) {
if (!confirm('Upgrade to version ' + version + '? The daemon will restart.')) return;
fetch('/api/upgrade', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version: version })
})
.then(r => r.json())
.then(result => {
if (result.success) {
document.getElementById('auto-upgrade-status').textContent = result.message;
} else {
alert('Upgrade failed: ' + result.message);
}
})
.catch(err => alert('Upgrade error: ' + err));
}
function updateUpdatesUI(data) {
document.getElementById('current-version').textContent = 'v' + data.currentVersion;
const list = document.getElementById('updates-list');
const newerReleases = data.releases.filter(r => r.isNewer);
if (newerReleases.length === 0) {
list.innerHTML = '<div style="color: var(--text-dim);">No updates available</div>';
} else {
list.innerHTML = newerReleases.map(r =>
'<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--border);">' +
'<span>v' + r.version + ' <span style="color: var(--text-dim);">(' + formatAge(r.ageHours) + ')</span></span>' +
'<button class="btn btn-primary" style="padding: 4px 12px; margin: 0;" onclick="upgradeToVersion(\\'' + r.version + '\\')">Upgrade</button>' +
'</div>'
).join('');
}
const autoStatus = document.getElementById('auto-upgrade-status');
if (data.autoUpgrade.targetVersion) {
if (data.autoUpgrade.waitingForStability) {
autoStatus.textContent = 'Auto-upgrade to v' + data.autoUpgrade.targetVersion + ' in ' + data.autoUpgrade.scheduledIn + ' (stability period)';
} else {
autoStatus.textContent = 'Auto-upgrade to v' + data.autoUpgrade.targetVersion + ' pending...';
}
} else {
autoStatus.textContent = data.lastCheck ? 'Last checked: ' + new Date(data.lastCheck).toLocaleTimeString() : '';
}
}
function formatAge(hours) {
if (hours < 1) return Math.round(hours * 60) + 'm ago';
if (hours < 24) return Math.round(hours) + 'h ago';
return Math.round(hours / 24) + 'd ago';
}
// Fetch updates info periodically
function fetchUpdates() {
fetch('/api/updates')
.then(r => r.json())
.then(updateUpdatesUI)
.catch(err => console.error('Failed to fetch updates:', err));
}
fetchUpdates();
setInterval(fetchUpdates, 60000); // Check every minute
// Initial fetch
fetch('/api/status')
.then(r => r.json())

View File

@@ -1 +1 @@
export const VERSION = "0.1.3";
export const VERSION = "0.2.3";