feat(daemon): add serial console reader and UI tab for serial logs; add version propagation and CI/release workflows

This commit is contained in:
2026-01-09 14:34:51 +00:00
parent 5234411c9d
commit 6dd6ead1c9
8 changed files with 262 additions and 7 deletions

26
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,26 @@
name: CI
on:
push:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Install pnpm
run: npm install -g pnpm
- name: Typecheck
run: pnpm run daemon:typecheck
- name: Bundle
run: pnpm run daemon:bundle

View File

@@ -0,0 +1,108 @@
name: Release
on:
push:
tags: ['v*']
jobs:
release:
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Install pnpm
run: npm install -g pnpm
- name: Get version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
- name: Set version in package.json and version.ts
run: |
npm version ${{ steps.version.outputs.version_number }} --no-git-tag-version --allow-same-version
echo "export const VERSION = \"${{ steps.version.outputs.version_number }}\";" > ecoos_daemon/ts/version.ts
- name: Build daemon binary
run: pnpm run daemon:bundle
- name: Build ISO with Docker
run: |
cp ecoos_daemon/bundle/eco-daemon isobuild/config/includes.chroot/opt/eco/bin/
mkdir -p .nogit/iso
docker build -t ecoos-builder -f isobuild/Dockerfile .
docker run --rm --privileged -v $(pwd)/.nogit/iso:/output ecoos-builder
- name: Prepare release assets
run: |
VERSION="${{ steps.version.outputs.version }}"
mkdir -p dist
cp .nogit/iso/ecoos.iso "dist/ecoos-${VERSION}.iso"
cp ecoos_daemon/bundle/eco-daemon "dist/eco-daemon-${VERSION}"
cd dist && sha256sum * > SHA256SUMS.txt
ls -lh
- name: Delete existing release if exists
run: |
VERSION="${{ steps.version.outputs.version }}"
EXISTING=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/${{ gitea.repository }}/releases/tags/$VERSION" | jq -r '.id // empty')
if [ -n "$EXISTING" ]; then
echo "Deleting existing release $EXISTING"
curl -X DELETE -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/${{ gitea.repository }}/releases/$EXISTING"
sleep 2
fi
- name: Create Release and upload assets
run: |
VERSION="${{ steps.version.outputs.version }}"
# Create release
RELEASE_ID=$(curl -X POST -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"https://code.foss.global/api/v1/repos/${{ gitea.repository }}/releases" \
-d "{
\"tag_name\": \"$VERSION\",
\"name\": \"EcoOS $VERSION\",
\"body\": \"## EcoOS $VERSION\n\n### Assets\n- **ecoos-${VERSION}.iso** - Full bootable ISO image\n- **eco-daemon-${VERSION}** - Daemon binary for in-place upgrades\n\n### Checksums\nSHA256 checksums in SHA256SUMS.txt\",
\"draft\": false,
\"prerelease\": false
}" | jq -r '.id')
echo "Created release with ID: $RELEASE_ID"
# Upload assets
for asset in dist/*; do
filename=$(basename "$asset")
echo "Uploading $filename..."
curl -X POST -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$asset" \
"https://code.foss.global/api/v1/repos/${{ gitea.repository }}/releases/$RELEASE_ID/assets?name=$filename"
done
- name: Cleanup old releases (keep 3 latest)
run: |
echo "Checking for old releases to clean up..."
RELEASES=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/${{ gitea.repository }}/releases" | \
jq -r 'sort_by(.created_at) | reverse | .[3:] | .[].id')
for id in $RELEASES; do
echo "Deleting old release $id"
curl -X DELETE -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/${{ gitea.repository }}/releases/$id"
done
echo "Cleanup complete"

16
changelog.md Normal file
View File

@@ -0,0 +1,16 @@
# Changelog
## 2026-01-09 - 0.2.0 - feat(daemon)
add serial console reader and UI tab for serial logs; add version propagation and CI/release workflows
- Start a background serial reader that reads /dev/ttyS0, retains up to 1000 lines and exposes serial logs via the daemon API
- Add a Serial Console tab in the management UI to view serial logs and a tab switcher; UI will auto-reload when daemon version changes
- Expose VERSION from ecoos_daemon and include it in status responses
- Bump package version to 0.1.3 and update daemon version constant
- Add .gitea workflows for CI (typecheck + bundle) and Release (build daemon, build ISO via Docker, upload releases to Gitea), and add a daemon:typecheck npm script; update test/clean scripts
## 2026-01-09 - 0.1.1 - initial project setup & minor update
Consolidated initial project creation and a follow-up update into the initial release (0.1.1).
- 2026-01-08: initial commit — project scaffold and first files added.
- 2026-01-09: minor update and version bump to 0.1.1 — small edits and housekeeping.

View File

@@ -8,6 +8,7 @@ import { ProcessManager } from './process-manager.ts';
import { SystemInfo } from './system-info.ts';
import { UIServer } from '../ui/server.ts';
import { runCommand } from '../utils/command.ts';
import { VERSION } from '../version.ts';
export interface DaemonConfig {
uiPort: number;
@@ -29,6 +30,7 @@ export class EcoDaemon {
private systemInfo: SystemInfo;
private uiServer: UIServer;
private logs: string[] = [];
private serialLogs: string[] = [];
private swayStatus: ServiceStatus = { state: 'stopped' };
private chromiumStatus: ServiceStatus = { state: 'stopped' };
private manualRestartUntil: number = 0; // Timestamp until which auto-restart is disabled
@@ -62,15 +64,21 @@ export class EcoDaemon {
return [...this.logs];
}
getSerialLogs(): string[] {
return [...this.serialLogs];
}
async getStatus(): Promise<Record<string, unknown>> {
const systemInfo = await this.systemInfo.getInfo();
return {
version: VERSION,
sway: this.swayStatus.state === 'running',
swayStatus: this.swayStatus,
chromium: this.chromiumStatus.state === 'running',
chromiumStatus: this.chromiumStatus,
systemInfo,
logs: this.logs.slice(-50),
serialLogs: this.serialLogs.slice(-50),
};
}
@@ -131,6 +139,9 @@ export class EcoDaemon {
await this.uiServer.start();
this.log('Management UI started successfully');
// Start serial console reader in the background
this.startSerialReader();
// Start the Sway/Chromium initialization in the background
// This allows the UI server to remain responsive even if Sway fails
this.startServicesInBackground();
@@ -302,6 +313,31 @@ export class EcoDaemon {
return parseInt(result.stdout.trim(), 10);
}
private startSerialReader(): 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 decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
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);
}
}
}
} catch (error) {
this.log(`Serial reader not available: ${error}`);
}
})();
}
private async runForever(): Promise<void> {
// Monitor processes and restart if needed
while (true) {

View File

@@ -249,6 +249,27 @@ export class UIServer {
background: var(--success);
color: white;
}
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
margin-bottom: 12px;
}
.tab {
padding: 8px 16px;
cursor: pointer;
color: var(--text-dim);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-size: 12px;
text-transform: uppercase;
}
.tab:hover { color: var(--text); }
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-content { display: none; }
.tab-content.active { display: block; }
</style>
</head>
<body>
@@ -339,8 +360,16 @@ export class UIServer {
<div id="microphones-list"></div>
</div>
<div class="card" style="grid-column: 1 / -1;">
<h2>Logs</h2>
<div class="logs" id="logs"></div>
<div class="tabs">
<div class="tab active" onclick="switchTab('daemon')">Daemon Logs</div>
<div class="tab" onclick="switchTab('serial')">Serial Console</div>
</div>
<div id="daemon-tab" class="tab-content active">
<div class="logs" id="logs"></div>
</div>
<div id="serial-tab" class="tab-content">
<div class="logs" id="serial-logs"></div>
</div>
</div>
</div>
</div>
@@ -362,7 +391,20 @@ export class UIServer {
return mins + 'm';
}
let initialVersion = null;
function updateStatus(data) {
// Check for version change and reload if needed
if (data.version) {
if (initialVersion === null) {
initialVersion = data.version;
} else if (data.version !== initialVersion) {
console.log('Server version changed from ' + initialVersion + ' to ' + data.version + ', reloading...');
location.reload();
return;
}
}
// Services
document.getElementById('sway-status').className =
'status-dot ' + (data.sway ? 'running' : 'stopped');
@@ -471,7 +513,7 @@ export class UIServer {
}
}
// Logs
// Daemon Logs
if (data.logs) {
const logsEl = document.getElementById('logs');
logsEl.innerHTML = data.logs.map(l =>
@@ -479,6 +521,31 @@ export class UIServer {
).join('');
logsEl.scrollTop = logsEl.scrollHeight;
}
// Serial Logs
if (data.serialLogs) {
const serialEl = document.getElementById('serial-logs');
if (data.serialLogs.length === 0) {
serialEl.innerHTML = '<div style="color: var(--text-dim);">No serial data available</div>';
} else {
serialEl.innerHTML = data.serialLogs.map(l =>
'<div class="log-entry">' + l + '</div>'
).join('');
serialEl.scrollTop = serialEl.scrollHeight;
}
}
}
function switchTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
if (tab === 'daemon') {
document.querySelector('.tab:first-child').classList.add('active');
document.getElementById('daemon-tab').classList.add('active');
} else {
document.querySelector('.tab:last-child').classList.add('active');
document.getElementById('serial-tab').classList.add('active');
}
}
function setControlStatus(msg, isError) {

View File

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

View File

@@ -1,16 +1,18 @@
{
"name": "@ecobridge/eco-os",
"version": "0.1.1",
"version": "0.1.3",
"private": true,
"scripts": {
"build": "npm version patch --no-git-tag-version && 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 --rm --privileged -v $(pwd)/.nogit/iso:/output ecoos-builder",
"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",
"test": "cd isotest && ./run-test.sh",
"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",
"test:stop": "cd isotest && ./stop.sh",
"clean": "rm -rf .nogit/iso/*.iso .nogit/vm/*.qcow2 .nogit/screenshots/*"
"test:clean": "pnpm run test:stop && rm -rf .nogit/vm/*.qcow2 .nogit/screenshots/*",
"clean": "rm -rf .nogit/iso/*.iso && pnpm run test:clean"
}
}