feat(daemon): add automatic update mechanism (Updater), switch to system journal logs, and expose update controls in the UI
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
|
||||
270
ecoos_daemon/ts/daemon/updater.ts
Normal file
270
ecoos_daemon/ts/daemon/updater.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "0.1.3";
|
||||
export const VERSION = "0.2.3";
|
||||
|
||||
Reference in New Issue
Block a user