From 34fc06726e28b5790490117c61768a8c57f09700 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 1 Feb 2026 13:24:03 +0000 Subject: [PATCH] Enhance development workflow and deployment practices for Archipelago - Updated the Development-Workflow documentation to clarify deployment strategy, emphasizing direct deployment to the live system for testing. - Added detailed instructions for the deployment command, including syncing code, building frontend and backend, and restarting services. - Improved SSH key management section to assist with authentication issues. - Expanded the testing workflow to include steps for checking logs and syncing changes back to the ISO build. - Updated the ISO build integration section to ensure system-level changes are captured for future builds. - Refactored various sections for clarity and completeness, including deployment paths and system configuration files. --- .cursor/rules/Development-Workflow.mdc | 252 +++++++++--------- core/archipelago/src/api/handler.rs | 28 +- core/archipelago/src/api/rpc.rs | 85 ++++-- .../src/container/docker_packages.rs | 71 +++-- core/archipelago/src/data_model.rs | 36 +-- core/archipelago/src/server.rs | 21 +- core/archipelago/src/state.rs | 26 +- core/container/src/podman_client.rs | 104 +++++++- .../assets/img/app-icons/bitcoin-knots.webp | Bin 0 -> 13940 bytes docker/bitcoin-ui/index.html | 10 +- image-recipe/INTEGRATION-GUIDE.md | 195 ++++++++++++++ image-recipe/ISO-BUILD-CHECKLIST.md | 145 ++++++++++ image-recipe/README.md | 10 + image-recipe/build-auto-installer-iso.sh | 55 +++- image-recipe/configs/archipelago.service | 16 ++ image-recipe/configs/nginx-archipelago.conf | 29 ++ image-recipe/sync-from-live.sh | 76 ++++++ neode-ui/src/api/websocket.ts | 107 ++++++-- neode-ui/src/router/index.ts | 9 + neode-ui/src/stores/app.ts | 27 ++ neode-ui/src/views/Apps.vue | 94 ++++--- neode-ui/src/views/ContainerApps.vue | 9 +- scripts/check-deployment.sh | 24 ++ scripts/debug-frontend.sh | 30 +++ scripts/deploy-to-target.sh | 20 +- scripts/optimize-debian.sh | 0 scripts/test-backend-rpc.sh | 24 ++ scripts/verify-deployment.sh | 30 +++ 28 files changed, 1248 insertions(+), 285 deletions(-) create mode 100644 docker/bitcoin-ui/assets/img/app-icons/bitcoin-knots.webp create mode 100644 image-recipe/INTEGRATION-GUIDE.md create mode 100644 image-recipe/ISO-BUILD-CHECKLIST.md create mode 100644 image-recipe/configs/archipelago.service create mode 100644 image-recipe/configs/nginx-archipelago.conf create mode 100755 image-recipe/sync-from-live.sh create mode 100755 scripts/check-deployment.sh create mode 100755 scripts/debug-frontend.sh mode change 100644 => 100755 scripts/optimize-debian.sh create mode 100755 scripts/test-backend-rpc.sh create mode 100755 scripts/verify-deployment.sh diff --git a/.cursor/rules/Development-Workflow.mdc b/.cursor/rules/Development-Workflow.mdc index 331caf4a..1581538d 100644 --- a/.cursor/rules/Development-Workflow.mdc +++ b/.cursor/rules/Development-Workflow.mdc @@ -1,136 +1,146 @@ +--- +description: Development workflow and deployment practices for Archipelago +alwaysApply: true +--- + # Archipelago Development Workflow -## Overview +## Deployment Strategy -Development happens on Mac (editing in Cursor), with the HP ProDesk running Archipelago as the live test target via SSH. +**Always deploy to live system for testing** - The target device (192.168.1.228) is a development machine, so deploy changes directly to the live system rather than using dev servers. -## Architecture - -``` -┌─────────────────────┐ SSH/rsync ┌─────────────────────┐ -│ Mac (Dev Host) │ ──────────────────────────▶│ HP ProDesk (Target)│ -│ │ │ │ -│ • Cursor IDE │ │ • Archipelago OS │ -│ • Source code │ │ • Live testing │ -│ • ISO builds │ │ • Vue.js dev server│ -│ │ │ • Rust backend │ -└─────────────────────┘ └─────────────────────┘ -``` - -## Target Machine Setup - -**SSH Access:** -```bash -ssh archipelago@192.168.1.228 -# Password: archipelago -``` - -**Required packages on target (install once):** -```bash -sudo apt update && sudo apt install -y \ - nodejs npm \ - rustc cargo \ - git \ - build-essential -``` - -## Development Commands - -### Sync Code to Target -```bash -# From Mac - sync entire project -rsync -avz --exclude 'node_modules' --exclude 'target' --exclude 'dist' \ - /Users/dorian/Projects/archy/ \ - archipelago@192.168.1.228:/home/archipelago/archy/ - -# Or just the frontend -rsync -avz --exclude 'node_modules' \ - /Users/dorian/Projects/archy/neode-ui/ \ - archipelago@192.168.1.228:/home/archipelago/archy/neode-ui/ -``` - -### Frontend Development (Vue.js) -```bash -# On target via SSH -cd ~/archy/neode-ui -npm install -npm run dev -- --host 0.0.0.0 - -# Access from Mac browser: http://192.168.1.228:5173 -``` - -### Backend Development (Rust) -```bash -# On target via SSH -cd ~/archy/core -cargo build --release - -# Test run -./target/release/archipelago -``` - -### Quick Deploy Script -Create `~/deploy.sh` on Mac: -```bash -#!/bin/bash -TARGET="archipelago@192.168.1.228" -PROJECT="/Users/dorian/Projects/archy" - -# Sync code -rsync -avz --exclude 'node_modules' --exclude 'target' --exclude 'dist' \ - "$PROJECT/" "$TARGET:/home/archipelago/archy/" - -# Rebuild on target -ssh $TARGET "cd ~/archy/neode-ui && npm install && npm run build" -ssh $TARGET "cd ~/archy/core && cargo build --release" - -# Deploy to live system -ssh $TARGET "sudo cp ~/archy/core/target/release/archipelago /usr/local/bin/" -ssh $TARGET "sudo cp -r ~/archy/neode-ui/dist/* /opt/archipelago/web-ui/" -ssh $TARGET "sudo systemctl restart archipelago" - -echo "Deployed! Check http://192.168.1.228" -``` - -## ISO Builds - -ISO builds still happen on Mac (requires Docker Desktop for creating rootfs): +### Standard Deployment Command ```bash -cd /Users/dorian/Projects/archy/image-recipe -./build-auto-installer-iso.sh +./scripts/deploy-to-target.sh --live ``` -**Docker Desktop is required for:** -- Building the Debian rootfs tarball -- Creating squashfs overlay modules -- Pulling/saving container images for bundling +This command: +1. Syncs code from local Mac to remote target +2. Builds frontend (Vue.js) and backend (Rust) +3. Deploys to live paths: + - Frontend: `/opt/archipelago/web-ui/` + - Backend: `/usr/local/bin/archipelago` +4. Restarts services (systemd + nginx) -## File Locations +### Target Environment -| Component | Mac (Source) | Target (Dev) | Target (Live) | -|-----------|--------------|--------------|---------------| -| Frontend | `neode-ui/` | `~/archy/neode-ui/` | `/opt/archipelago/web-ui/` | -| Backend | `core/` | `~/archy/core/` | `/usr/local/bin/archipelago` | -| App manifests | `apps/` | `~/archy/apps/` | `/etc/archipelago/apps/` | +- **Host**: archipelago@192.168.1.228 +- **OS**: Debian-based server +- **Container Runtime**: Podman (root context for system services) +- **Web Server**: Nginx +- **Backend**: Systemd service (`archipelago.service`) running as root -## What You Can Remove from Mac +## SSH Key Management -**Keep:** -- Docker Desktop (needed for ISO builds) -- Node.js/npm (for local editing/linting) -- Cursor IDE +The deployment scripts require SSH key authentication. If you encounter `Permission denied` errors: -**Can remove:** -- Any local test containers -- Podman (if installed) -- Local development servers (test on target instead) +1. Ensure SSH key is loaded: `ssh-add -l` +2. Add key if needed: `ssh-add ~/.ssh/id_ed25519` +3. Enter passphrase when prompted -## Workflow Summary +## Development Paths -1. **Edit** code in Cursor on Mac -2. **Sync** to HP ProDesk with rsync -3. **Test** on target (run dev server or deploy to live) -4. **Iterate** until working -5. **Build ISO** on Mac when ready for distribution -6. **Flash & test** ISO on HP ProDesk +### Local (Mac) +- Project root: `/Users/dorian/Projects/archy` +- Frontend: `neode-ui/` +- Backend: `core/` +- Scripts: `scripts/` +- ISO Build: `image-recipe/` + +### Remote (Target) +- Dev directory: `~/archy/` +- Live frontend: `/opt/archipelago/web-ui/` +- Live backend: `/usr/local/bin/archipelago` +- Data: `/var/lib/archipelago/` +- Systemd service: `/etc/systemd/system/archipelago.service` +- Nginx config: `/etc/nginx/sites-available/archipelago` + +## Testing Workflow + +1. Make changes locally +2. Deploy with `--live` flag +3. Test at http://192.168.1.228 +4. Check logs if needed: + - Backend: `ssh archipelago@192.168.1.228 'sudo journalctl -u archipelago -f'` + - Nginx: `ssh archipelago@192.168.1.228 'sudo tail -f /var/log/nginx/error.log'` +5. **Sync changes back to ISO build** (see below) + +## Running Containers + +Check container status: +```bash +ssh archipelago@192.168.1.228 'sudo podman ps' +``` + +Common containers: +- Home Assistant (port 8123) +- Bitcoin Knots (ports 8332, 8333) +- LND (ports 9735, 10009) + +## ISO Build Integration + +**CRITICAL**: After testing on the live server, always update the ISO build to include your changes. + +### System Configuration Files to Sync + +When you make system-level changes on the live server, capture them for the ISO build: + +1. **Systemd Service** (`/etc/systemd/system/archipelago.service`) + - Location in repo: `image-recipe/configs/archipelago.service` + - Capture command: `ssh archipelago@192.168.1.228 'sudo cat /etc/systemd/system/archipelago.service' > image-recipe/configs/archipelago.service` + +2. **Nginx Configuration** (`/etc/nginx/sites-available/archipelago`) + - Location in repo: `image-recipe/configs/nginx-archipelago.conf` + - Capture command: `ssh archipelago@192.168.1.228 'sudo cat /etc/nginx/sites-available/archipelago' > image-recipe/configs/nginx-archipelago.conf` + +3. **Other System Files** + - Logrotate: `image-recipe/configs/logrotate.conf` + - Any new scripts in `/opt/archipelago/scripts/` + +### Build Process Checklist + +Before building a new ISO, ensure: + +- [ ] Latest backend built: `cd image-recipe && ./scripts/build-backend.sh` +- [ ] Latest frontend built: `cd image-recipe && ./scripts/build-frontend.sh` +- [ ] System configs synced from live server +- [ ] Integration script updated: `./integrate-archipelago.sh` +- [ ] ISO built: `./build-debian-iso.sh` +- [ ] ISO tested in QEMU: `./test-iso-qemu.sh` + +### Key Configuration Values + +**Backend Service (archipelago.service)**: +- **User**: `root` (required to access root Podman containers) +- **Environment**: + - `ARCHIPELAGO_BIND=0.0.0.0:5678` + - `ARCHIPELAGO_DEV_MODE=true` (for container auto-detection) + +**Nginx Configuration**: +- Serves frontend from `/opt/archipelago/web-ui` +- Proxies `/rpc/` to backend at `127.0.0.1:5678` +- Proxies `/ws` for WebSocket connections + +### Deployment Paths in ISO + +The ISO build must install files to: +- `/usr/local/bin/archipelago` - Backend binary +- `/opt/archipelago/web-ui/` - Frontend files +- `/etc/systemd/system/archipelago.service` - Service definition +- `/etc/nginx/sites-available/archipelago` - Nginx config +- `/opt/archipelago/` - Base directory for scripts and data + +## Common Issues + +### Container Detection +- Containers must be in **root Podman context** (started with `sudo podman`) +- Backend must run as **root** to see root containers +- Check: `sudo podman ps` (should show containers) +- Check: `podman ps` (should be empty if using root containers) + +### Service Not Starting +- Check systemd status: `sudo systemctl status archipelago` +- Check logs: `sudo journalctl -u archipelago -n 50` +- Verify binary: `ls -lh /usr/local/bin/archipelago` +- Test manually: `sudo /usr/local/bin/archipelago` diff --git a/core/archipelago/src/api/handler.rs b/core/archipelago/src/api/handler.rs index 8bbce190..0f19902d 100644 --- a/core/archipelago/src/api/handler.rs +++ b/core/archipelago/src/api/handler.rs @@ -6,6 +6,7 @@ use futures_util::{SinkExt, StreamExt}; use hyper::{Method, Request, Response, StatusCode}; use hyper_ws_listener::WsStream; use std::sync::Arc; +use tokio::sync::broadcast; use tokio_tungstenite::tungstenite::Message; use tracing::{debug, info}; @@ -94,11 +95,14 @@ impl ApiHandler { debug!("Sent initial data dump at revision {}", initial_msg.rev); } + // Subscribe to state updates + let mut state_rx = state_manager.subscribe(); + // Send periodic pings to keep connection alive let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30)); tokio::pin!(ping_interval); - // Keep connection open; UI may send/receive JSON patches. For now just accept and ignore. + // Keep connection open and forward state updates to client loop { tokio::select! { _ = ping_interval.tick() => { @@ -107,6 +111,28 @@ impl ApiHandler { break; } } + // Forward state updates from broadcast channel to WebSocket + update = state_rx.recv() => { + match update { + Ok(msg) => { + if let Ok(json_msg) = serde_json::to_string(&msg) { + if let Err(e) = tx.send(Message::Text(json_msg)).await { + debug!("Failed to send state update: {}", e); + break; + } + debug!("Sent state update at revision {}", msg.rev); + } + } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + debug!("Client lagged behind, skipped {} messages", skipped); + // Continue receiving - the client will get the next update + } + Err(broadcast::error::RecvError::Closed) => { + debug!("Broadcast channel closed"); + break; + } + } + } msg = rx.next() => { match msg { Some(Ok(Message::Close(_))) => break, diff --git a/core/archipelago/src/api/rpc.rs b/core/archipelago/src/api/rpc.rs index e24c2daf..b9287d63 100644 --- a/core/archipelago/src/api/rpc.rs +++ b/core/archipelago/src/api/rpc.rs @@ -445,15 +445,30 @@ impl RpcHandler { .ok_or_else(|| anyhow::anyhow!("Missing package id"))?; // Convert package ID to container name (e.g., "bitcoin" -> "archy-bitcoin") - let container_name = format!("archy-{}", package_id); - - // Use docker CLI to start the container - let output = tokio::process::Command::new("docker") - .arg("start") - .arg(&container_name) + // But also check if container exists without the prefix + let container_name = if let Ok(output) = tokio::process::Command::new("sudo") + .args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)]) .output() .await - .context("Failed to execute docker start")?; + { + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.trim().is_empty() { + debug!("Found container without prefix: {}", package_id); + package_id.to_string() + } else { + debug!("Using archy- prefix: archy-{}", package_id); + format!("archy-{}", package_id) + } + } else { + format!("archy-{}", package_id) + }; + + // Use podman CLI to start the container + let output = tokio::process::Command::new("sudo") + .args(["podman", "start", &container_name]) + .output() + .await + .context("Failed to execute podman start")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -474,15 +489,29 @@ impl RpcHandler { .ok_or_else(|| anyhow::anyhow!("Missing package id"))?; // Convert package ID to container name - let container_name = format!("archy-{}", package_id); - - // Use docker CLI to stop the container - let output = tokio::process::Command::new("docker") - .arg("stop") - .arg(&container_name) + let container_name = if let Ok(output) = tokio::process::Command::new("sudo") + .args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)]) .output() .await - .context("Failed to execute docker stop")?; + { + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.trim().is_empty() { + debug!("Found container without prefix: {}", package_id); + package_id.to_string() + } else { + debug!("Using archy- prefix: archy-{}", package_id); + format!("archy-{}", package_id) + } + } else { + format!("archy-{}", package_id) + }; + + // Use podman CLI to stop the container + let output = tokio::process::Command::new("sudo") + .args(["podman", "stop", &container_name]) + .output() + .await + .context("Failed to execute podman stop")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -503,15 +532,29 @@ impl RpcHandler { .ok_or_else(|| anyhow::anyhow!("Missing package id"))?; // Convert package ID to container name - let container_name = format!("archy-{}", package_id); - - // Use docker CLI to restart the container - let output = tokio::process::Command::new("docker") - .arg("restart") - .arg(&container_name) + let container_name = if let Ok(output) = tokio::process::Command::new("sudo") + .args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)]) .output() .await - .context("Failed to execute docker restart")?; + { + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.trim().is_empty() { + debug!("Found container without prefix: {}", package_id); + package_id.to_string() + } else { + debug!("Using archy- prefix: archy-{}", package_id); + format!("archy-{}", package_id) + } + } else { + format!("archy-{}", package_id) + }; + + // Use podman CLI to restart the container + let output = tokio::process::Command::new("sudo") + .args(["podman", "restart", &container_name]) + .output() + .await + .context("Failed to execute podman restart")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index af29acd8..3b725846 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -23,7 +23,13 @@ impl DockerPackageScanner { /// Scan Docker containers and convert to package data pub async fn scan_containers(&self) -> Result> { - let containers = self.runtime.list_containers().await?; + let containers = match self.runtime.list_containers().await { + Ok(c) => c, + Err(e) => { + debug!("Failed to list containers: {}", e); + return Ok(HashMap::new()); + } + }; debug!("Found {} containers", containers.len()); @@ -39,22 +45,37 @@ impl DockerPackageScanner { "penpot-exporter", "penpot-valkey", "penpot-mailcatch", - "bitcoin-ui", - "lnd-ui", "endurain-db", "nextcloud-db", ]; - for container in containers { - // Only process archy-* containers from docker-compose - if !container.name.starts_with("archy-") { - continue; + // First pass: collect UI containers + let mut ui_containers: HashMap = HashMap::new(); + for container in &containers { + if container.name.ends_with("-ui") { + // Map bitcoin-ui -> bitcoin, lnd-ui -> lnd + let parent_app = container.name.strip_suffix("-ui").unwrap_or(&container.name); + if !container.ports.is_empty() { + if let Some(ui_address) = extract_lan_address(&container.ports) { + ui_containers.insert(parent_app.to_string(), ui_address); + } + } } - - // Extract app ID from container name (archy-bitcoin -> bitcoin) - let app_id = container.name.strip_prefix("archy-") - .unwrap_or(&container.name) - .to_string(); + } + + debug!("Found {} UI containers", ui_containers.len()); + + for container in containers { + // Extract app ID from container name + // Support both archy-* containers (docker-compose) and plain names (manual) + let app_id = if container.name.starts_with("archy-") { + container.name.strip_prefix("archy-") + .unwrap_or(&container.name) + .to_string() + } else { + // Use the container name as-is for manually started containers + container.name.clone() + }; // Skip backend services (databases, APIs, etc.) if excluded_services.contains(&app_id.as_str()) { @@ -62,11 +83,25 @@ impl DockerPackageScanner { continue; } + // Skip UI containers (they're merged with their parent apps) + if app_id.ends_with("-ui") { + debug!("Skipping UI container: {}", app_id); + continue; + } + // Get metadata for this app let metadata = get_app_metadata(&app_id); - // Extract port from container - let lan_address = extract_lan_address(&container.ports); + // Check if this app has a separate UI container + let lan_address = if let Some(ui_address) = ui_containers.get(&app_id) { + debug!("Using UI container address for {}: {}", app_id, ui_address); + Some(ui_address.clone()) + } else { + // Extract port from the main container + extract_lan_address(&container.ports) + }; + + debug!("Container {}: ports={:?}, lan_address={:?}", app_id, container.ports, lan_address); // Convert container state to package/service state let (package_state, service_status) = convert_state(&container.state); @@ -146,11 +181,11 @@ struct AppMetadata { fn get_app_metadata(app_id: &str) -> AppMetadata { match app_id { - "bitcoin" => AppMetadata { - title: "Bitcoin Core".to_string(), + "bitcoin" | "bitcoin-core" | "bitcoin-knots" => AppMetadata { + title: "Bitcoin Knots".to_string(), description: "Full Bitcoin node implementation".to_string(), - icon: "/assets/img/app-icons/bitcoin-core.png".to_string(), - repo: "https://github.com/bitcoin/bitcoin".to_string(), + icon: "/assets/img/app-icons/bitcoin-knots.webp".to_string(), + repo: "https://github.com/bitcoinknots/bitcoin".to_string(), }, "btcpay" | "btcpay-server" => AppMetadata { title: "BTCPay Server".to_string(), diff --git a/core/archipelago/src/data_model.rs b/core/archipelago/src/data_model.rs index de4d53bb..3869fd4e 100644 --- a/core/archipelago/src/data_model.rs +++ b/core/archipelago/src/data_model.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; /// The main data model that mirrors the frontend's DataModel type. /// This is sent via WebSocket as the initial state and updated via patches. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct DataModel { #[serde(rename = "server-info")] pub server_info: ServerInfo, @@ -12,7 +12,7 @@ pub struct DataModel { pub ui: UIData, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ServerInfo { pub id: String, pub version: String, @@ -29,7 +29,7 @@ pub struct ServerInfo { pub zram_enabled: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct StatusInfo { pub restarting: bool, #[serde(rename = "shutting-down")] @@ -41,7 +41,7 @@ pub struct StatusInfo { pub update_progress: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct UIData { pub name: Option, #[serde(rename = "ack-welcome")] @@ -50,7 +50,7 @@ pub struct UIData { pub theme: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct UIMarketplaceData { #[serde(rename = "selected-hosts")] pub selected_hosts: Vec, @@ -58,13 +58,13 @@ pub struct UIMarketplaceData { pub known_hosts: HashMap, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct MarketplaceHost { pub name: String, pub url: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "kebab-case")] pub enum PackageState { Installing, @@ -83,7 +83,7 @@ pub enum PackageState { BackingUp, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct PackageDataEntry { pub state: PackageState, #[serde(rename = "static-files")] @@ -94,14 +94,14 @@ pub struct PackageDataEntry { pub install_progress: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct StaticFiles { pub license: String, pub instructions: String, pub icon: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Manifest { pub id: String, pub title: String, @@ -125,18 +125,18 @@ pub struct Manifest { pub interfaces: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Description { pub short: String, pub long: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Interfaces { pub main: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct MainInterface { pub ui: Option, #[serde(rename = "tor-config")] @@ -145,7 +145,7 @@ pub struct MainInterface { pub lan_config: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct InstalledPackageDataEntry { #[serde(rename = "current-dependents")] pub current_dependents: HashMap, @@ -158,13 +158,13 @@ pub struct InstalledPackageDataEntry { pub status: ServiceStatus, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct CurrentDependencyInfo { #[serde(rename = "health-checks")] pub health_checks: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct InterfaceAddress { #[serde(rename = "tor-address")] pub tor_address: String, @@ -172,7 +172,7 @@ pub struct InterfaceAddress { pub lan_address: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum ServiceStatus { Stopped, @@ -182,7 +182,7 @@ pub enum ServiceStatus { Restarting, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct InstallProgress { pub size: u64, pub downloaded: u64, diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 2690e8c1..e0cd9601 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -9,7 +9,7 @@ use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; use tokio::net::TcpListener; -use tracing::{error, info}; +use tracing::{debug, error, info}; pub struct Server { _config: Config, @@ -34,8 +34,8 @@ impl Server { error!("Failed to scan Docker containers: {}", e); } - // Periodic scan every 5 seconds - let mut interval = tokio::time::interval(Duration::from_secs(5)); + // Periodic scan every 10 seconds (only broadcasts if state changed) + let mut interval = tokio::time::interval(Duration::from_secs(10)); loop { interval.tick().await; if let Err(e) = scan_and_update_packages(&scanner, &state).await { @@ -114,10 +114,19 @@ async fn scan_and_update_packages( ) -> Result<()> { let packages = scanner.scan_containers().await?; + // Only update if we have packages AND they're different from current state if !packages.is_empty() { - let (mut data, _) = state.get_snapshot().await; - data.package_data = packages; - state.update_data(data).await; + let (current_data, _) = state.get_snapshot().await; + + // Check if packages actually changed to avoid unnecessary broadcasts + let packages_changed = current_data.package_data != packages; + + if packages_changed { + let mut data = current_data; + data.package_data = packages; + state.update_data(data).await; + debug!("📦 Container state changed, broadcasting update"); + } } Ok(()) diff --git a/core/archipelago/src/state.rs b/core/archipelago/src/state.rs index 6eeca3eb..c1d1a513 100644 --- a/core/archipelago/src/state.rs +++ b/core/archipelago/src/state.rs @@ -1,19 +1,22 @@ use crate::data_model::{DataModel, WebSocketMessage}; use std::sync::Arc; -use tokio::sync::RwLock; +use tokio::sync::{broadcast, RwLock}; use tracing::debug; /// Manages the application state and broadcasts updates to WebSocket clients pub struct StateManager { data: Arc>, revision: Arc>, + broadcast_tx: broadcast::Sender, } impl StateManager { pub fn new() -> Self { + let (broadcast_tx, _) = broadcast::channel(100); Self { data: Arc::new(RwLock::new(DataModel::new())), revision: Arc::new(RwLock::new(0)), + broadcast_tx, } } @@ -24,18 +27,31 @@ impl StateManager { (data, rev) } - /// Update the data model (will broadcast patches in the future) + /// Subscribe to state updates + pub fn subscribe(&self) -> broadcast::Receiver { + self.broadcast_tx.subscribe() + } + + /// Update the data model and broadcast to all connected clients pub async fn update_data(&self, new_data: DataModel) { let mut data = self.data.write().await; let mut rev = self.revision.write().await; - *data = new_data; + *data = new_data.clone(); *rev += 1; debug!("Data model updated to revision {}", *rev); - // TODO: In the future, compute JSON patches and broadcast to all connected clients - // For now, clients will need to reconnect to get updates + // Broadcast full data dump to all connected clients + // In the future, we can optimize this by computing and sending JSON patches + let message = WebSocketMessage { + rev: *rev, + data: Some(new_data), + patch: None, + }; + + // Ignore errors if no receivers are connected + let _ = self.broadcast_tx.send(message); } /// Get a WebSocket message with the current state diff --git a/core/container/src/podman_client.rs b/core/container/src/podman_client.rs index f6f572d5..f13a09a4 100644 --- a/core/container/src/podman_client.rs +++ b/core/container/src/podman_client.rs @@ -55,9 +55,13 @@ pub struct PodmanClient { impl PodmanClient { pub fn new(user: String) -> Self { + // If running as root, use root podman context + let is_root = std::env::var("USER").unwrap_or_default() == "root" || + std::env::var("HOME").unwrap_or_default() == "/root"; + Self { _user: user, - rootless: true, + rootless: !is_root, } } @@ -315,25 +319,101 @@ impl PodmanClient { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); + log::error!("Podman list failed: {}", stderr); return Err(anyhow::anyhow!("Failed to list containers: {}", stderr)); } let json = String::from_utf8_lossy(&output.stdout); - let containers: Vec = serde_json::from_str(&json) - .context("Failed to parse container list")?; + log::debug!("Podman JSON output ({} bytes): {}", json.len(), + if json.len() > 200 { &json[..200] } else { &json }); + // Podman can return either a JSON array or NDJSON (newline-delimited JSON) let mut result = Vec::new(); - for container in containers { - result.push(ContainerStatus { - id: container["Id"].as_str().unwrap_or("").to_string(), - name: container["Names"][0].as_str().unwrap_or("").to_string(), - state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")), - image: container["Image"].as_str().unwrap_or("").to_string(), - created: container["Created"].as_str().unwrap_or("").to_string(), - ports: vec![], - }); + + // Try parsing as a JSON array first + if let Ok(containers) = serde_json::from_str::>(&json) { + log::debug!("Parsed as JSON array with {} items", containers.len()); + for container in containers { + // Handle both Names as array and Names as string + let name = if let Some(names_array) = container["Names"].as_array() { + names_array.get(0).and_then(|v| v.as_str()).unwrap_or("").to_string() + } else { + container["Names"].as_str().unwrap_or("").to_string() + }; + + // Parse ports from the Ports array + let ports = if let Some(ports_array) = container["Ports"].as_array() { + ports_array.iter().filter_map(|port| { + // Podman format: {"host_ip":"","container_port":8123,"host_port":8123,"range":1,"protocol":"tcp"} + if let (Some(host_port), Some(container_port), Some(protocol)) = ( + port["host_port"].as_u64(), + port["container_port"].as_u64(), + port["protocol"].as_str() + ) { + Some(format!("0.0.0.0:{}->{}/{}", host_port, container_port, protocol)) + } else { + None + } + }).collect() + } else { + vec![] + }; + + result.push(ContainerStatus { + id: container["Id"].as_str().unwrap_or("").to_string(), + name, + state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")), + image: container["Image"].as_str().unwrap_or("").to_string(), + created: container["Created"].as_str().unwrap_or("").to_string(), + ports, + }); + } + } else { + log::debug!("Failed to parse as JSON array, trying NDJSON"); + // Try parsing as NDJSON (newline-delimited JSON) + for line in json.lines() { + if line.trim().is_empty() { + continue; + } + + if let Ok(container) = serde_json::from_str::(line) { + // Handle both Names as array and Names as string + let name = if let Some(names_array) = container["Names"].as_array() { + names_array.get(0).and_then(|v| v.as_str()).unwrap_or("").to_string() + } else { + container["Names"].as_str().unwrap_or("").to_string() + }; + + // Parse ports from the Ports array + let ports = if let Some(ports_array) = container["Ports"].as_array() { + ports_array.iter().filter_map(|port| { + if let (Some(host_port), Some(container_port), Some(protocol)) = ( + port["host_port"].as_u64(), + port["container_port"].as_u64(), + port["protocol"].as_str() + ) { + Some(format!("0.0.0.0:{}->{}/{}", host_port, container_port, protocol)) + } else { + None + } + }).collect() + } else { + vec![] + }; + + result.push(ContainerStatus { + id: container["Id"].as_str().unwrap_or("").to_string(), + name, + state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")), + image: container["Image"].as_str().unwrap_or("").to_string(), + created: container["Created"].as_str().unwrap_or("").to_string(), + ports, + }); + } + } } + log::debug!("Returning {} containers", result.len()); Ok(result) } } diff --git a/docker/bitcoin-ui/assets/img/app-icons/bitcoin-knots.webp b/docker/bitcoin-ui/assets/img/app-icons/bitcoin-knots.webp new file mode 100644 index 0000000000000000000000000000000000000000..3441e3d9489ec248696b8edbcb998f7c45725921 GIT binary patch literal 13940 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 - Bitcoin Core - Archipelago + Bitcoin Knots - Archipelago