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 00000000..3441e3d9 Binary files /dev/null and b/docker/bitcoin-ui/assets/img/app-icons/bitcoin-knots.webp differ diff --git a/docker/bitcoin-ui/index.html b/docker/bitcoin-ui/index.html index 1a17e26a..938750f5 100644 --- a/docker/bitcoin-ui/index.html +++ b/docker/bitcoin-ui/index.html @@ -3,7 +3,7 @@ - Bitcoin Core - Archipelago + Bitcoin Knots - Archipelago