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.
This commit is contained in:
parent
00d1af12f0
commit
34fc06726e
@ -1,136 +1,146 @@
|
|||||||
|
---
|
||||||
|
description: Development workflow and deployment practices for Archipelago
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
# Archipelago Development Workflow
|
# 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
|
### Standard Deployment Command
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────┐ 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):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /Users/dorian/Projects/archy/image-recipe
|
./scripts/deploy-to-target.sh --live
|
||||||
./build-auto-installer-iso.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker Desktop is required for:**
|
This command:
|
||||||
- Building the Debian rootfs tarball
|
1. Syncs code from local Mac to remote target
|
||||||
- Creating squashfs overlay modules
|
2. Builds frontend (Vue.js) and backend (Rust)
|
||||||
- Pulling/saving container images for bundling
|
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) |
|
- **Host**: archipelago@192.168.1.228
|
||||||
|-----------|--------------|--------------|---------------|
|
- **OS**: Debian-based server
|
||||||
| Frontend | `neode-ui/` | `~/archy/neode-ui/` | `/opt/archipelago/web-ui/` |
|
- **Container Runtime**: Podman (root context for system services)
|
||||||
| Backend | `core/` | `~/archy/core/` | `/usr/local/bin/archipelago` |
|
- **Web Server**: Nginx
|
||||||
| App manifests | `apps/` | `~/archy/apps/` | `/etc/archipelago/apps/` |
|
- **Backend**: Systemd service (`archipelago.service`) running as root
|
||||||
|
|
||||||
## What You Can Remove from Mac
|
## SSH Key Management
|
||||||
|
|
||||||
**Keep:**
|
The deployment scripts require SSH key authentication. If you encounter `Permission denied` errors:
|
||||||
- Docker Desktop (needed for ISO builds)
|
|
||||||
- Node.js/npm (for local editing/linting)
|
|
||||||
- Cursor IDE
|
|
||||||
|
|
||||||
**Can remove:**
|
1. Ensure SSH key is loaded: `ssh-add -l`
|
||||||
- Any local test containers
|
2. Add key if needed: `ssh-add ~/.ssh/id_ed25519`
|
||||||
- Podman (if installed)
|
3. Enter passphrase when prompted
|
||||||
- Local development servers (test on target instead)
|
|
||||||
|
|
||||||
## Workflow Summary
|
## Development Paths
|
||||||
|
|
||||||
1. **Edit** code in Cursor on Mac
|
### Local (Mac)
|
||||||
2. **Sync** to HP ProDesk with rsync
|
- Project root: `/Users/dorian/Projects/archy`
|
||||||
3. **Test** on target (run dev server or deploy to live)
|
- Frontend: `neode-ui/`
|
||||||
4. **Iterate** until working
|
- Backend: `core/`
|
||||||
5. **Build ISO** on Mac when ready for distribution
|
- Scripts: `scripts/`
|
||||||
6. **Flash & test** ISO on HP ProDesk
|
- 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`
|
||||||
|
|||||||
@ -6,6 +6,7 @@ use futures_util::{SinkExt, StreamExt};
|
|||||||
use hyper::{Method, Request, Response, StatusCode};
|
use hyper::{Method, Request, Response, StatusCode};
|
||||||
use hyper_ws_listener::WsStream;
|
use hyper_ws_listener::WsStream;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
@ -94,11 +95,14 @@ impl ApiHandler {
|
|||||||
debug!("Sent initial data dump at revision {}", initial_msg.rev);
|
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
|
// Send periodic pings to keep connection alive
|
||||||
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
||||||
tokio::pin!(ping_interval);
|
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 {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = ping_interval.tick() => {
|
_ = ping_interval.tick() => {
|
||||||
@ -107,6 +111,28 @@ impl ApiHandler {
|
|||||||
break;
|
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() => {
|
msg = rx.next() => {
|
||||||
match msg {
|
match msg {
|
||||||
Some(Ok(Message::Close(_))) => break,
|
Some(Ok(Message::Close(_))) => break,
|
||||||
|
|||||||
@ -445,15 +445,30 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
|
|
||||||
// Convert package ID to container name (e.g., "bitcoin" -> "archy-bitcoin")
|
// Convert package ID to container name (e.g., "bitcoin" -> "archy-bitcoin")
|
||||||
let container_name = format!("archy-{}", package_id);
|
// But also check if container exists without the prefix
|
||||||
|
let container_name = if let Ok(output) = tokio::process::Command::new("sudo")
|
||||||
// Use docker CLI to start the container
|
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)])
|
||||||
let output = tokio::process::Command::new("docker")
|
|
||||||
.arg("start")
|
|
||||||
.arg(&container_name)
|
|
||||||
.output()
|
.output()
|
||||||
.await
|
.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() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
@ -474,15 +489,29 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
|
|
||||||
// Convert package ID to container name
|
// Convert package ID to container name
|
||||||
let container_name = format!("archy-{}", package_id);
|
let container_name = if let Ok(output) = tokio::process::Command::new("sudo")
|
||||||
|
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)])
|
||||||
// Use docker CLI to stop the container
|
|
||||||
let output = tokio::process::Command::new("docker")
|
|
||||||
.arg("stop")
|
|
||||||
.arg(&container_name)
|
|
||||||
.output()
|
.output()
|
||||||
.await
|
.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() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
@ -503,15 +532,29 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
|
|
||||||
// Convert package ID to container name
|
// Convert package ID to container name
|
||||||
let container_name = format!("archy-{}", package_id);
|
let container_name = if let Ok(output) = tokio::process::Command::new("sudo")
|
||||||
|
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)])
|
||||||
// Use docker CLI to restart the container
|
|
||||||
let output = tokio::process::Command::new("docker")
|
|
||||||
.arg("restart")
|
|
||||||
.arg(&container_name)
|
|
||||||
.output()
|
.output()
|
||||||
.await
|
.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() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
|||||||
@ -23,7 +23,13 @@ impl DockerPackageScanner {
|
|||||||
|
|
||||||
/// Scan Docker containers and convert to package data
|
/// Scan Docker containers and convert to package data
|
||||||
pub async fn scan_containers(&self) -> Result<HashMap<String, PackageDataEntry>> {
|
pub async fn scan_containers(&self) -> Result<HashMap<String, PackageDataEntry>> {
|
||||||
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());
|
debug!("Found {} containers", containers.len());
|
||||||
|
|
||||||
@ -39,22 +45,37 @@ impl DockerPackageScanner {
|
|||||||
"penpot-exporter",
|
"penpot-exporter",
|
||||||
"penpot-valkey",
|
"penpot-valkey",
|
||||||
"penpot-mailcatch",
|
"penpot-mailcatch",
|
||||||
"bitcoin-ui",
|
|
||||||
"lnd-ui",
|
|
||||||
"endurain-db",
|
"endurain-db",
|
||||||
"nextcloud-db",
|
"nextcloud-db",
|
||||||
];
|
];
|
||||||
|
|
||||||
for container in containers {
|
// First pass: collect UI containers
|
||||||
// Only process archy-* containers from docker-compose
|
let mut ui_containers: HashMap<String, String> = HashMap::new();
|
||||||
if !container.name.starts_with("archy-") {
|
for container in &containers {
|
||||||
continue;
|
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)
|
debug!("Found {} UI containers", ui_containers.len());
|
||||||
let app_id = container.name.strip_prefix("archy-")
|
|
||||||
|
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)
|
.unwrap_or(&container.name)
|
||||||
.to_string();
|
.to_string()
|
||||||
|
} else {
|
||||||
|
// Use the container name as-is for manually started containers
|
||||||
|
container.name.clone()
|
||||||
|
};
|
||||||
|
|
||||||
// Skip backend services (databases, APIs, etc.)
|
// Skip backend services (databases, APIs, etc.)
|
||||||
if excluded_services.contains(&app_id.as_str()) {
|
if excluded_services.contains(&app_id.as_str()) {
|
||||||
@ -62,11 +83,25 @@ impl DockerPackageScanner {
|
|||||||
continue;
|
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
|
// Get metadata for this app
|
||||||
let metadata = get_app_metadata(&app_id);
|
let metadata = get_app_metadata(&app_id);
|
||||||
|
|
||||||
// Extract port from container
|
// Check if this app has a separate UI container
|
||||||
let lan_address = extract_lan_address(&container.ports);
|
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
|
// Convert container state to package/service state
|
||||||
let (package_state, service_status) = convert_state(&container.state);
|
let (package_state, service_status) = convert_state(&container.state);
|
||||||
@ -146,11 +181,11 @@ struct AppMetadata {
|
|||||||
|
|
||||||
fn get_app_metadata(app_id: &str) -> AppMetadata {
|
fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||||
match app_id {
|
match app_id {
|
||||||
"bitcoin" => AppMetadata {
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => AppMetadata {
|
||||||
title: "Bitcoin Core".to_string(),
|
title: "Bitcoin Knots".to_string(),
|
||||||
description: "Full Bitcoin node implementation".to_string(),
|
description: "Full Bitcoin node implementation".to_string(),
|
||||||
icon: "/assets/img/app-icons/bitcoin-core.png".to_string(),
|
icon: "/assets/img/app-icons/bitcoin-knots.webp".to_string(),
|
||||||
repo: "https://github.com/bitcoin/bitcoin".to_string(),
|
repo: "https://github.com/bitcoinknots/bitcoin".to_string(),
|
||||||
},
|
},
|
||||||
"btcpay" | "btcpay-server" => AppMetadata {
|
"btcpay" | "btcpay-server" => AppMetadata {
|
||||||
title: "BTCPay Server".to_string(),
|
title: "BTCPay Server".to_string(),
|
||||||
|
|||||||
@ -3,7 +3,7 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
/// The main data model that mirrors the frontend's DataModel type.
|
/// The main data model that mirrors the frontend's DataModel type.
|
||||||
/// This is sent via WebSocket as the initial state and updated via patches.
|
/// 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 {
|
pub struct DataModel {
|
||||||
#[serde(rename = "server-info")]
|
#[serde(rename = "server-info")]
|
||||||
pub server_info: ServerInfo,
|
pub server_info: ServerInfo,
|
||||||
@ -12,7 +12,7 @@ pub struct DataModel {
|
|||||||
pub ui: UIData,
|
pub ui: UIData,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct ServerInfo {
|
pub struct ServerInfo {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
@ -29,7 +29,7 @@ pub struct ServerInfo {
|
|||||||
pub zram_enabled: bool,
|
pub zram_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct StatusInfo {
|
pub struct StatusInfo {
|
||||||
pub restarting: bool,
|
pub restarting: bool,
|
||||||
#[serde(rename = "shutting-down")]
|
#[serde(rename = "shutting-down")]
|
||||||
@ -41,7 +41,7 @@ pub struct StatusInfo {
|
|||||||
pub update_progress: Option<f32>,
|
pub update_progress: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct UIData {
|
pub struct UIData {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
#[serde(rename = "ack-welcome")]
|
#[serde(rename = "ack-welcome")]
|
||||||
@ -50,7 +50,7 @@ pub struct UIData {
|
|||||||
pub theme: String,
|
pub theme: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct UIMarketplaceData {
|
pub struct UIMarketplaceData {
|
||||||
#[serde(rename = "selected-hosts")]
|
#[serde(rename = "selected-hosts")]
|
||||||
pub selected_hosts: Vec<String>,
|
pub selected_hosts: Vec<String>,
|
||||||
@ -58,13 +58,13 @@ pub struct UIMarketplaceData {
|
|||||||
pub known_hosts: HashMap<String, MarketplaceHost>,
|
pub known_hosts: HashMap<String, MarketplaceHost>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct MarketplaceHost {
|
pub struct MarketplaceHost {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub enum PackageState {
|
pub enum PackageState {
|
||||||
Installing,
|
Installing,
|
||||||
@ -83,7 +83,7 @@ pub enum PackageState {
|
|||||||
BackingUp,
|
BackingUp,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct PackageDataEntry {
|
pub struct PackageDataEntry {
|
||||||
pub state: PackageState,
|
pub state: PackageState,
|
||||||
#[serde(rename = "static-files")]
|
#[serde(rename = "static-files")]
|
||||||
@ -94,14 +94,14 @@ pub struct PackageDataEntry {
|
|||||||
pub install_progress: Option<InstallProgress>,
|
pub install_progress: Option<InstallProgress>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct StaticFiles {
|
pub struct StaticFiles {
|
||||||
pub license: String,
|
pub license: String,
|
||||||
pub instructions: String,
|
pub instructions: String,
|
||||||
pub icon: String,
|
pub icon: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct Manifest {
|
pub struct Manifest {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
@ -125,18 +125,18 @@ pub struct Manifest {
|
|||||||
pub interfaces: Option<Interfaces>,
|
pub interfaces: Option<Interfaces>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct Description {
|
pub struct Description {
|
||||||
pub short: String,
|
pub short: String,
|
||||||
pub long: String,
|
pub long: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct Interfaces {
|
pub struct Interfaces {
|
||||||
pub main: Option<MainInterface>,
|
pub main: Option<MainInterface>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct MainInterface {
|
pub struct MainInterface {
|
||||||
pub ui: Option<String>,
|
pub ui: Option<String>,
|
||||||
#[serde(rename = "tor-config")]
|
#[serde(rename = "tor-config")]
|
||||||
@ -145,7 +145,7 @@ pub struct MainInterface {
|
|||||||
pub lan_config: Option<String>,
|
pub lan_config: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct InstalledPackageDataEntry {
|
pub struct InstalledPackageDataEntry {
|
||||||
#[serde(rename = "current-dependents")]
|
#[serde(rename = "current-dependents")]
|
||||||
pub current_dependents: HashMap<String, CurrentDependencyInfo>,
|
pub current_dependents: HashMap<String, CurrentDependencyInfo>,
|
||||||
@ -158,13 +158,13 @@ pub struct InstalledPackageDataEntry {
|
|||||||
pub status: ServiceStatus,
|
pub status: ServiceStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct CurrentDependencyInfo {
|
pub struct CurrentDependencyInfo {
|
||||||
#[serde(rename = "health-checks")]
|
#[serde(rename = "health-checks")]
|
||||||
pub health_checks: Vec<String>,
|
pub health_checks: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct InterfaceAddress {
|
pub struct InterfaceAddress {
|
||||||
#[serde(rename = "tor-address")]
|
#[serde(rename = "tor-address")]
|
||||||
pub tor_address: String,
|
pub tor_address: String,
|
||||||
@ -172,7 +172,7 @@ pub struct InterfaceAddress {
|
|||||||
pub lan_address: Option<String>,
|
pub lan_address: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum ServiceStatus {
|
pub enum ServiceStatus {
|
||||||
Stopped,
|
Stopped,
|
||||||
@ -182,7 +182,7 @@ pub enum ServiceStatus {
|
|||||||
Restarting,
|
Restarting,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct InstallProgress {
|
pub struct InstallProgress {
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
pub downloaded: u64,
|
pub downloaded: u64,
|
||||||
|
|||||||
@ -9,7 +9,7 @@ use std::net::SocketAddr;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tracing::{error, info};
|
use tracing::{debug, error, info};
|
||||||
|
|
||||||
pub struct Server {
|
pub struct Server {
|
||||||
_config: Config,
|
_config: Config,
|
||||||
@ -34,8 +34,8 @@ impl Server {
|
|||||||
error!("Failed to scan Docker containers: {}", e);
|
error!("Failed to scan Docker containers: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Periodic scan every 5 seconds
|
// Periodic scan every 10 seconds (only broadcasts if state changed)
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
let mut interval = tokio::time::interval(Duration::from_secs(10));
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
if let Err(e) = scan_and_update_packages(&scanner, &state).await {
|
if let Err(e) = scan_and_update_packages(&scanner, &state).await {
|
||||||
@ -114,10 +114,19 @@ async fn scan_and_update_packages(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let packages = scanner.scan_containers().await?;
|
let packages = scanner.scan_containers().await?;
|
||||||
|
|
||||||
|
// Only update if we have packages AND they're different from current state
|
||||||
if !packages.is_empty() {
|
if !packages.is_empty() {
|
||||||
let (mut data, _) = state.get_snapshot().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;
|
data.package_data = packages;
|
||||||
state.update_data(data).await;
|
state.update_data(data).await;
|
||||||
|
debug!("📦 Container state changed, broadcasting update");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@ -1,19 +1,22 @@
|
|||||||
use crate::data_model::{DataModel, WebSocketMessage};
|
use crate::data_model::{DataModel, WebSocketMessage};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::{broadcast, RwLock};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
/// Manages the application state and broadcasts updates to WebSocket clients
|
/// Manages the application state and broadcasts updates to WebSocket clients
|
||||||
pub struct StateManager {
|
pub struct StateManager {
|
||||||
data: Arc<RwLock<DataModel>>,
|
data: Arc<RwLock<DataModel>>,
|
||||||
revision: Arc<RwLock<u32>>,
|
revision: Arc<RwLock<u32>>,
|
||||||
|
broadcast_tx: broadcast::Sender<WebSocketMessage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StateManager {
|
impl StateManager {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
|
let (broadcast_tx, _) = broadcast::channel(100);
|
||||||
Self {
|
Self {
|
||||||
data: Arc::new(RwLock::new(DataModel::new())),
|
data: Arc::new(RwLock::new(DataModel::new())),
|
||||||
revision: Arc::new(RwLock::new(0)),
|
revision: Arc::new(RwLock::new(0)),
|
||||||
|
broadcast_tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,18 +27,31 @@ impl StateManager {
|
|||||||
(data, rev)
|
(data, rev)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the data model (will broadcast patches in the future)
|
/// Subscribe to state updates
|
||||||
|
pub fn subscribe(&self) -> broadcast::Receiver<WebSocketMessage> {
|
||||||
|
self.broadcast_tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the data model and broadcast to all connected clients
|
||||||
pub async fn update_data(&self, new_data: DataModel) {
|
pub async fn update_data(&self, new_data: DataModel) {
|
||||||
let mut data = self.data.write().await;
|
let mut data = self.data.write().await;
|
||||||
let mut rev = self.revision.write().await;
|
let mut rev = self.revision.write().await;
|
||||||
|
|
||||||
*data = new_data;
|
*data = new_data.clone();
|
||||||
*rev += 1;
|
*rev += 1;
|
||||||
|
|
||||||
debug!("Data model updated to revision {}", *rev);
|
debug!("Data model updated to revision {}", *rev);
|
||||||
|
|
||||||
// TODO: In the future, compute JSON patches and broadcast to all connected clients
|
// Broadcast full data dump to all connected clients
|
||||||
// For now, clients will need to reconnect to get updates
|
// 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
|
/// Get a WebSocket message with the current state
|
||||||
|
|||||||
@ -55,9 +55,13 @@ pub struct PodmanClient {
|
|||||||
|
|
||||||
impl PodmanClient {
|
impl PodmanClient {
|
||||||
pub fn new(user: String) -> Self {
|
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 {
|
Self {
|
||||||
_user: user,
|
_user: user,
|
||||||
rootless: true,
|
rootless: !is_root,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,25 +319,101 @@ impl PodmanClient {
|
|||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
log::error!("Podman list failed: {}", stderr);
|
||||||
return Err(anyhow::anyhow!("Failed to list containers: {}", stderr));
|
return Err(anyhow::anyhow!("Failed to list containers: {}", stderr));
|
||||||
}
|
}
|
||||||
|
|
||||||
let json = String::from_utf8_lossy(&output.stdout);
|
let json = String::from_utf8_lossy(&output.stdout);
|
||||||
let containers: Vec<serde_json::Value> = serde_json::from_str(&json)
|
log::debug!("Podman JSON output ({} bytes): {}", json.len(),
|
||||||
.context("Failed to parse container list")?;
|
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();
|
let mut result = Vec::new();
|
||||||
|
|
||||||
|
// Try parsing as a JSON array first
|
||||||
|
if let Ok(containers) = serde_json::from_str::<Vec<serde_json::Value>>(&json) {
|
||||||
|
log::debug!("Parsed as JSON array with {} items", containers.len());
|
||||||
for container in containers {
|
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 {
|
result.push(ContainerStatus {
|
||||||
id: container["Id"].as_str().unwrap_or("").to_string(),
|
id: container["Id"].as_str().unwrap_or("").to_string(),
|
||||||
name: container["Names"][0].as_str().unwrap_or("").to_string(),
|
name,
|
||||||
state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")),
|
state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")),
|
||||||
image: container["Image"].as_str().unwrap_or("").to_string(),
|
image: container["Image"].as_str().unwrap_or("").to_string(),
|
||||||
created: container["Created"].as_str().unwrap_or("").to_string(),
|
created: container["Created"].as_str().unwrap_or("").to_string(),
|
||||||
ports: vec![],
|
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::<serde_json::Value>(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)
|
Ok(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
docker/bitcoin-ui/assets/img/app-icons/bitcoin-knots.webp
Normal file
BIN
docker/bitcoin-ui/assets/img/app-icons/bitcoin-knots.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Bitcoin Core - Archipelago</title>
|
<title>Bitcoin Knots - Archipelago</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
@ -140,8 +140,8 @@
|
|||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<div class="logo-gradient-border">
|
<div class="logo-gradient-border">
|
||||||
<img
|
<img
|
||||||
src="/assets/img/app-icons/bitcoin-core.png"
|
src="/assets/img/app-icons/bitcoin-knots.webp"
|
||||||
alt="Bitcoin Core"
|
alt="Bitcoin Knots"
|
||||||
class="w-16 h-16"
|
class="w-16 h-16"
|
||||||
style="object-fit: contain;"
|
style="object-fit: contain;"
|
||||||
/>
|
/>
|
||||||
@ -150,8 +150,8 @@
|
|||||||
|
|
||||||
<!-- Title and Description -->
|
<!-- Title and Description -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Bitcoin Core</h1>
|
<h1 class="text-3xl font-bold text-white mb-2">Bitcoin Knots</h1>
|
||||||
<p class="text-white/70">Full Bitcoin node implementation</p>
|
<p class="text-white/70">Enhanced Bitcoin node implementation</p>
|
||||||
<p class="text-sm text-white/60 mt-2">Regtest mode - Development environment</p>
|
<p class="text-sm text-white/60 mt-2">Regtest mode - Development environment</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
195
image-recipe/INTEGRATION-GUIDE.md
Normal file
195
image-recipe/INTEGRATION-GUIDE.md
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
# Live Server to ISO Build Integration Guide
|
||||||
|
|
||||||
|
This document explains how to keep the ISO build synchronized with the live development server.
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### 1. Develop and Test on Live Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make changes locally
|
||||||
|
vim core/archipelago/src/...
|
||||||
|
|
||||||
|
# Deploy to live server for testing
|
||||||
|
./scripts/deploy-to-target.sh --live
|
||||||
|
|
||||||
|
# Test at http://192.168.1.228
|
||||||
|
# Check logs: ssh archipelago@192.168.1.228 'sudo journalctl -u archipelago -f'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Capture System Changes
|
||||||
|
|
||||||
|
When you make system-level changes on the live server (nginx config, systemd service, etc.):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd image-recipe
|
||||||
|
./sync-from-live.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This automatically captures:
|
||||||
|
- `/etc/systemd/system/archipelago.service` → `configs/archipelago.service`
|
||||||
|
- `/etc/nginx/sites-available/archipelago` → `configs/nginx-archipelago.conf`
|
||||||
|
- `/etc/logrotate.d/archipelago` → `configs/logrotate.conf`
|
||||||
|
|
||||||
|
### 3. Build New ISO
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build backend and frontend
|
||||||
|
./scripts/build-backend.sh
|
||||||
|
./scripts/build-frontend.sh
|
||||||
|
|
||||||
|
# Build ISO with latest changes
|
||||||
|
./build-debian-iso.sh
|
||||||
|
|
||||||
|
# Test in QEMU
|
||||||
|
./test-iso-qemu.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Verify Integration
|
||||||
|
|
||||||
|
The ISO build script should:
|
||||||
|
1. Copy `configs/archipelago.service` to `/etc/systemd/system/`
|
||||||
|
2. Copy `configs/nginx-archipelago.conf` to `/etc/nginx/sites-available/archipelago`
|
||||||
|
3. Create symlink: `/etc/nginx/sites-enabled/archipelago`
|
||||||
|
4. Enable the service: `systemctl enable archipelago`
|
||||||
|
5. Install backend to `/usr/local/bin/archipelago`
|
||||||
|
6. Install frontend to `/opt/archipelago/web-ui/`
|
||||||
|
|
||||||
|
## Critical Configuration Settings
|
||||||
|
|
||||||
|
### Backend Service (archipelago.service)
|
||||||
|
|
||||||
|
**Must-have settings**:
|
||||||
|
```ini
|
||||||
|
[Service]
|
||||||
|
User=root # Required for root Podman access
|
||||||
|
Environment="ARCHIPELAGO_BIND=0.0.0.0:5678" # Backend API port
|
||||||
|
Environment="ARCHIPELAGO_DEV_MODE=true" # Enable container auto-detection
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why root?**: The backend must run as root to access containers started with `sudo podman`. Containers in root Podman context are invisible to rootless Podman.
|
||||||
|
|
||||||
|
### Nginx Configuration (nginx-archipelago.conf)
|
||||||
|
|
||||||
|
**Must-have proxies**:
|
||||||
|
```nginx
|
||||||
|
location /rpc/ {
|
||||||
|
proxy_pass http://127.0.0.1:5678; # Backend RPC endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://127.0.0.1:5678; # WebSocket for real-time updates
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Paths Reference
|
||||||
|
|
||||||
|
### Build Artifacts
|
||||||
|
- `build/backend/archipelago` - Compiled Rust backend
|
||||||
|
- `build/frontend/` - Built Vue.js frontend
|
||||||
|
- `configs/` - System configuration files
|
||||||
|
- `results/` - Built ISO images
|
||||||
|
|
||||||
|
### Live Server Paths
|
||||||
|
- `/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
|
||||||
|
- `/var/lib/archipelago/` - Application data
|
||||||
|
|
||||||
|
### ISO Installation Paths
|
||||||
|
Same as live server (above) - the ISO must replicate the exact file structure.
|
||||||
|
|
||||||
|
## Container Management
|
||||||
|
|
||||||
|
### Root vs Rootless Podman
|
||||||
|
|
||||||
|
**Current approach**: Root Podman
|
||||||
|
- Containers started with: `sudo podman run ...`
|
||||||
|
- Backend runs as: `root` user (in systemd)
|
||||||
|
- Container detection: Works automatically in dev mode
|
||||||
|
|
||||||
|
**Why not rootless?**
|
||||||
|
- Would require `User=archipelago` in systemd service
|
||||||
|
- All containers must be started as `archipelago` user
|
||||||
|
- More complex permission management
|
||||||
|
|
||||||
|
### Container Detection
|
||||||
|
|
||||||
|
The backend automatically detects running containers when:
|
||||||
|
1. `ARCHIPELAGO_DEV_MODE=true` is set
|
||||||
|
2. Backend runs with same privileges as container runtime
|
||||||
|
3. Containers exist in accessible Podman context
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Issue: Containers not detected in ISO
|
||||||
|
|
||||||
|
**Cause**: Backend not running as root, or dev mode disabled
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
1. Check `configs/archipelago.service` has `User=root`
|
||||||
|
2. Check `Environment="ARCHIPELAGO_DEV_MODE=true"` is set
|
||||||
|
3. Rebuild ISO and test
|
||||||
|
|
||||||
|
### Issue: UI not loading
|
||||||
|
|
||||||
|
**Cause**: Nginx config not copied or frontend files missing
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
1. Verify `configs/nginx-archipelago.conf` exists
|
||||||
|
2. Check frontend built to `build/frontend/`
|
||||||
|
3. Verify ISO build script copies these files
|
||||||
|
|
||||||
|
### Issue: Backend won't start
|
||||||
|
|
||||||
|
**Cause**: Binary permissions or missing dependencies
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
1. Check backend binary is executable: `chmod +x /usr/local/bin/archipelago`
|
||||||
|
2. Check dependencies installed (Podman, nginx)
|
||||||
|
3. Review systemd logs: `journalctl -u archipelago`
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
Before releasing an ISO, verify:
|
||||||
|
|
||||||
|
- [ ] Boot ISO in QEMU
|
||||||
|
- [ ] Systemd service starts: `systemctl status archipelago`
|
||||||
|
- [ ] Backend responds: `curl http://localhost:5678/health`
|
||||||
|
- [ ] UI accessible: Open browser to `http://localhost`
|
||||||
|
- [ ] Container detection: `sudo podman run -d --name test nginx` → Shows in UI
|
||||||
|
- [ ] RPC works: Test login and API calls
|
||||||
|
- [ ] WebSocket connects: Check browser console
|
||||||
|
|
||||||
|
## Automated Build Pipeline (Future)
|
||||||
|
|
||||||
|
To automate this workflow:
|
||||||
|
|
||||||
|
1. **CI/CD Integration**
|
||||||
|
- Trigger on main branch commits
|
||||||
|
- Run `sync-from-live.sh` with credentials
|
||||||
|
- Build backend and frontend
|
||||||
|
- Build ISO
|
||||||
|
- Upload to releases
|
||||||
|
|
||||||
|
2. **Version Management**
|
||||||
|
- Tag releases with semantic versions
|
||||||
|
- Include git commit hash in ISO metadata
|
||||||
|
- Track which configs were included
|
||||||
|
|
||||||
|
3. **Testing Automation**
|
||||||
|
- Boot ISO in headless QEMU
|
||||||
|
- Run API tests
|
||||||
|
- Verify container detection
|
||||||
|
- Generate test report
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- Development Workflow Rules: `.cursor/rules/Development-Workflow.mdc`
|
||||||
|
- Build Checklist: `ISO-BUILD-CHECKLIST.md`
|
||||||
|
- Architecture Docs: `.cursor/rules/Architecture.mdc`
|
||||||
|
- Deployment Scripts: `scripts/deploy-to-target.sh`
|
||||||
145
image-recipe/ISO-BUILD-CHECKLIST.md
Normal file
145
image-recipe/ISO-BUILD-CHECKLIST.md
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
# ISO Build Checklist
|
||||||
|
|
||||||
|
This checklist ensures that all changes from the live development server are properly integrated into the ISO build.
|
||||||
|
|
||||||
|
## Pre-Build Steps
|
||||||
|
|
||||||
|
### 1. Sync System Configurations from Live Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd image-recipe
|
||||||
|
./sync-from-live.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This captures:
|
||||||
|
- [ ] Systemd service configuration (`archipelago.service`)
|
||||||
|
- [ ] Nginx configuration (`nginx-archipelago.conf`)
|
||||||
|
- [ ] Logrotate configuration (if exists)
|
||||||
|
- [ ] Any custom scripts in `/opt/archipelago/scripts/`
|
||||||
|
|
||||||
|
### 2. Verify Code Changes
|
||||||
|
|
||||||
|
Ensure all code changes are committed:
|
||||||
|
- [ ] Backend changes in `core/`
|
||||||
|
- [ ] Frontend changes in `neode-ui/`
|
||||||
|
- [ ] Script changes in `scripts/`
|
||||||
|
|
||||||
|
### 3. Build Components
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd image-recipe
|
||||||
|
|
||||||
|
# Build backend
|
||||||
|
./scripts/build-backend.sh
|
||||||
|
|
||||||
|
# Build frontend
|
||||||
|
./scripts/build-frontend.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify builds:
|
||||||
|
- [ ] Backend binary exists: `build/backend/archipelago`
|
||||||
|
- [ ] Frontend files exist: `build/frontend/index.html`
|
||||||
|
|
||||||
|
## Integration Check
|
||||||
|
|
||||||
|
### 4. Update Build Scripts
|
||||||
|
|
||||||
|
Review and update if needed:
|
||||||
|
- [ ] `integrate-archipelago.sh` - Includes all config files
|
||||||
|
- [ ] `build-debian-iso.sh` - Installs to correct paths
|
||||||
|
|
||||||
|
### 5. Critical Configuration Values
|
||||||
|
|
||||||
|
Verify in `configs/archipelago.service`:
|
||||||
|
- [ ] `User=root` (required for Podman root context)
|
||||||
|
- [ ] `Environment="ARCHIPELAGO_DEV_MODE=true"` (enables container detection)
|
||||||
|
- [ ] `Environment="ARCHIPELAGO_BIND=0.0.0.0:5678"`
|
||||||
|
|
||||||
|
Verify in `configs/nginx-archipelago.conf`:
|
||||||
|
- [ ] Root path: `/opt/archipelago/web-ui`
|
||||||
|
- [ ] RPC proxy: `/rpc/` → `http://127.0.0.1:5678`
|
||||||
|
- [ ] WebSocket proxy: `/ws` → `http://127.0.0.1:5678`
|
||||||
|
|
||||||
|
## Build Process
|
||||||
|
|
||||||
|
### 6. Build the ISO
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-debian-iso.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- [ ] ISO created in `results/` directory
|
||||||
|
- [ ] No build errors
|
||||||
|
- [ ] File size reasonable (~500MB - 2GB)
|
||||||
|
|
||||||
|
### 7. Test in QEMU
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./test-iso-qemu.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Test checklist:
|
||||||
|
- [ ] ISO boots successfully
|
||||||
|
- [ ] Backend service starts: `systemctl status archipelago`
|
||||||
|
- [ ] Nginx serves frontend
|
||||||
|
- [ ] Can access UI at `http://localhost:8080` (or mapped port)
|
||||||
|
- [ ] Container detection works: Check logs for "Detected container"
|
||||||
|
|
||||||
|
## Post-Build
|
||||||
|
|
||||||
|
### 8. Write to USB (Optional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./write-usb-dd.sh /dev/diskN
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use Balena Etcher to flash the ISO.
|
||||||
|
|
||||||
|
### 9. Test on Real Hardware
|
||||||
|
|
||||||
|
- [ ] Boot from USB
|
||||||
|
- [ ] Network configuration works
|
||||||
|
- [ ] All services start automatically
|
||||||
|
- [ ] Can access web UI
|
||||||
|
- [ ] Containers are detected and managed
|
||||||
|
|
||||||
|
## Deployment Paths Reference
|
||||||
|
|
||||||
|
The ISO build must install to these paths:
|
||||||
|
|
||||||
|
| Component | Path | Source |
|
||||||
|
|-----------|------|--------|
|
||||||
|
| Backend binary | `/usr/local/bin/archipelago` | `build/backend/archipelago` |
|
||||||
|
| Frontend files | `/opt/archipelago/web-ui/` | `build/frontend/*` |
|
||||||
|
| Systemd service | `/etc/systemd/system/archipelago.service` | `configs/archipelago.service` |
|
||||||
|
| Nginx config | `/etc/nginx/sites-available/archipelago` | `configs/nginx-archipelago.conf` |
|
||||||
|
| Nginx symlink | `/etc/nginx/sites-enabled/archipelago` | Link to sites-available |
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### Backend Not Detecting Containers
|
||||||
|
- Verify service runs as `root` user
|
||||||
|
- Check Podman context: `sudo podman ps` should show containers
|
||||||
|
- Enable dev mode: `ARCHIPELAGO_DEV_MODE=true`
|
||||||
|
|
||||||
|
### UI Not Loading
|
||||||
|
- Check nginx configuration paths
|
||||||
|
- Verify frontend files deployed to `/opt/archipelago/web-ui/`
|
||||||
|
- Check nginx error logs: `/var/log/nginx/error.log`
|
||||||
|
|
||||||
|
### Service Fails to Start
|
||||||
|
- Check binary permissions: Should be executable
|
||||||
|
- Check systemd logs: `journalctl -u archipelago`
|
||||||
|
- Test binary manually: `sudo /usr/local/bin/archipelago`
|
||||||
|
|
||||||
|
## Version Tracking
|
||||||
|
|
||||||
|
When building a new ISO, document:
|
||||||
|
- Date: _______________
|
||||||
|
- Git commit: _______________
|
||||||
|
- Backend version: _______________
|
||||||
|
- Frontend version: _______________
|
||||||
|
- ISO filename: _______________
|
||||||
|
- Tested on hardware: _______________
|
||||||
|
- Issues found: _______________
|
||||||
@ -7,6 +7,14 @@ Build scripts for creating bootable Debian Linux OS images for Archipelago Bitco
|
|||||||
### Build the ISO
|
### Build the ISO
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 1. Sync latest configs from live dev server
|
||||||
|
./sync-from-live.sh
|
||||||
|
|
||||||
|
# 2. Build components
|
||||||
|
./scripts/build-backend.sh
|
||||||
|
./scripts/build-frontend.sh
|
||||||
|
|
||||||
|
# 3. Build the ISO
|
||||||
./build-debian-iso.sh
|
./build-debian-iso.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -21,6 +29,8 @@ This creates a bootable Debian Live ISO with Archipelago pre-installed.
|
|||||||
# Or use Balena Etcher to flash the ISO
|
# Or use Balena Etcher to flash the ISO
|
||||||
```
|
```
|
||||||
|
|
||||||
|
See the **ISO-BUILD-CHECKLIST.md** for a comprehensive build workflow.
|
||||||
|
|
||||||
See the Architecture documentation for detailed system information.
|
See the Architecture documentation for detailed system information.
|
||||||
|
|
||||||
## What's Included
|
## What's Included
|
||||||
|
|||||||
@ -232,22 +232,53 @@ mkdir -p "$ARCH_DIR/scripts"
|
|||||||
echo " Including root filesystem..."
|
echo " Including root filesystem..."
|
||||||
cp "$ROOTFS_TAR" "$ARCH_DIR/rootfs.tar"
|
cp "$ROOTFS_TAR" "$ARCH_DIR/rootfs.tar"
|
||||||
|
|
||||||
# Copy backend binary
|
# Build and copy backend binary
|
||||||
if [ -f "$SCRIPT_DIR/../core/target/release/archipelago" ]; then
|
echo " Building backend binary for Linux x86_64..."
|
||||||
echo " Including backend binary..."
|
BACKEND_DOCKERFILE="$WORK_DIR/Dockerfile.backend"
|
||||||
|
cat > "$BACKEND_DOCKERFILE" <<'BACKENDFILE'
|
||||||
|
FROM rust:1.93-bookworm as builder
|
||||||
|
WORKDIR /build
|
||||||
|
COPY core ./core
|
||||||
|
RUN cd core && cargo build --release --bin archipelago
|
||||||
|
BACKENDFILE
|
||||||
|
|
||||||
|
if docker build --platform linux/amd64 -t archipelago-backend -f "$BACKEND_DOCKERFILE" "$SCRIPT_DIR/.." 2>&1 | tail -20; then
|
||||||
|
echo " Extracting backend binary..."
|
||||||
|
BACKEND_CONTAINER=$(docker create --platform linux/amd64 archipelago-backend)
|
||||||
|
docker cp "$BACKEND_CONTAINER:/build/core/target/release/archipelago" "$ARCH_DIR/bin/" && \
|
||||||
|
echo " ✅ Backend binary included ($(du -h "$ARCH_DIR/bin/archipelago" | cut -f1))"
|
||||||
|
docker rm "$BACKEND_CONTAINER"
|
||||||
|
else
|
||||||
|
echo " ⚠️ Backend build failed - using existing binary if available"
|
||||||
|
if [ -f "$SCRIPT_DIR/../core/target/release/archipelago" ]; then
|
||||||
cp "$SCRIPT_DIR/../core/target/release/archipelago" "$ARCH_DIR/bin/"
|
cp "$SCRIPT_DIR/../core/target/release/archipelago" "$ARCH_DIR/bin/"
|
||||||
|
echo " Using local backend binary (may not be compatible)"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Copy web UI (check both possible locations)
|
# Build and copy web UI
|
||||||
if [ -d "$SCRIPT_DIR/../web/dist/neode-ui" ]; then
|
echo " Building web UI..."
|
||||||
|
cd "$SCRIPT_DIR/../neode-ui"
|
||||||
|
if npm run build 2>&1 | tail -5; then
|
||||||
|
if [ -d "$SCRIPT_DIR/../web/dist/neode-ui" ]; then
|
||||||
echo " Including web UI from web/dist/neode-ui..."
|
echo " Including web UI from web/dist/neode-ui..."
|
||||||
cp -r "$SCRIPT_DIR/../web/dist/neode-ui" "$ARCH_DIR/web-ui"
|
cp -r "$SCRIPT_DIR/../web/dist/neode-ui" "$ARCH_DIR/web-ui"
|
||||||
elif [ -d "$SCRIPT_DIR/../neode-ui/dist" ]; then
|
echo " ✅ Web UI included ($(du -sh "$ARCH_DIR/web-ui" | cut -f1))"
|
||||||
echo " Including web UI from neode-ui/dist..."
|
fi
|
||||||
cp -r "$SCRIPT_DIR/../neode-ui/dist" "$ARCH_DIR/web-ui"
|
|
||||||
else
|
else
|
||||||
echo " ⚠️ Web UI not found - build it first with: cd neode-ui && npm run build"
|
echo " ⚠️ Web UI build failed"
|
||||||
|
# Try to use existing build
|
||||||
|
if [ -d "$SCRIPT_DIR/../web/dist/neode-ui" ]; then
|
||||||
|
echo " Using existing web UI build..."
|
||||||
|
cp -r "$SCRIPT_DIR/../web/dist/neode-ui" "$ARCH_DIR/web-ui"
|
||||||
|
elif [ -d "$SCRIPT_DIR/../neode-ui/dist" ]; then
|
||||||
|
echo " Using neode-ui/dist..."
|
||||||
|
cp -r "$SCRIPT_DIR/../neode-ui/dist" "$ARCH_DIR/web-ui"
|
||||||
|
else
|
||||||
|
echo " ❌ No web UI available"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
# Copy app manifests
|
# Copy app manifests
|
||||||
if [ -d "$SCRIPT_DIR/../apps" ]; then
|
if [ -d "$SCRIPT_DIR/../apps" ]; then
|
||||||
|
|||||||
16
image-recipe/configs/archipelago.service
Normal file
16
image-recipe/configs/archipelago.service
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Archipelago Backend
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Environment="ARCHIPELAGO_BIND=0.0.0.0:5678"
|
||||||
|
Environment="ARCHIPELAGO_DEV_MODE=true"
|
||||||
|
ExecStart=/usr/local/bin/archipelago
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
29
image-recipe/configs/nginx-archipelago.conf
Normal file
29
image-recipe/configs/nginx-archipelago.conf
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /opt/archipelago/web-ui;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Serve static files (Vue.js SPA)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to backend
|
||||||
|
location /rpc/ {
|
||||||
|
proxy_pass http://127.0.0.1:5678;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy WebSocket
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://127.0.0.1:5678;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
image-recipe/sync-from-live.sh
Executable file
76
image-recipe/sync-from-live.sh
Executable file
@ -0,0 +1,76 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Sync configuration files from live server to ISO build
|
||||||
|
#
|
||||||
|
# Usage: ./sync-from-live.sh [target-host]
|
||||||
|
#
|
||||||
|
# This script captures system configuration from the live development
|
||||||
|
# server and saves it to the image-recipe/configs/ directory for
|
||||||
|
# inclusion in future ISO builds.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
TARGET_HOST="${1:-archipelago@192.168.1.228}"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CONFIG_DIR="$SCRIPT_DIR/configs"
|
||||||
|
|
||||||
|
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ Syncing Configurations from Live Server ║"
|
||||||
|
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo "Target: $TARGET_HOST"
|
||||||
|
echo "Output: $CONFIG_DIR"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Ensure configs directory exists
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
|
||||||
|
# Sync systemd service
|
||||||
|
echo "📋 Capturing systemd service..."
|
||||||
|
ssh "$TARGET_HOST" 'sudo cat /etc/systemd/system/archipelago.service' > "$CONFIG_DIR/archipelago.service"
|
||||||
|
echo " ✅ Saved to configs/archipelago.service"
|
||||||
|
|
||||||
|
# Sync nginx configuration
|
||||||
|
echo "📋 Capturing nginx configuration..."
|
||||||
|
ssh "$TARGET_HOST" 'sudo cat /etc/nginx/sites-available/archipelago' > "$CONFIG_DIR/nginx-archipelago.conf"
|
||||||
|
echo " ✅ Saved to configs/nginx-archipelago.conf"
|
||||||
|
|
||||||
|
# Sync logrotate if it exists
|
||||||
|
if ssh "$TARGET_HOST" 'sudo test -f /etc/logrotate.d/archipelago'; then
|
||||||
|
echo "📋 Capturing logrotate configuration..."
|
||||||
|
ssh "$TARGET_HOST" 'sudo cat /etc/logrotate.d/archipelago' > "$CONFIG_DIR/logrotate.conf"
|
||||||
|
echo " ✅ Saved to configs/logrotate.conf"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for custom scripts
|
||||||
|
echo ""
|
||||||
|
echo "📋 Checking for custom scripts..."
|
||||||
|
if ssh "$TARGET_HOST" 'sudo test -d /opt/archipelago/scripts'; then
|
||||||
|
SCRIPT_COUNT=$(ssh "$TARGET_HOST" 'sudo ls /opt/archipelago/scripts/ 2>/dev/null | wc -l' | tr -d ' ')
|
||||||
|
if [ "$SCRIPT_COUNT" -gt 0 ]; then
|
||||||
|
echo " ⚠️ Found $SCRIPT_COUNT script(s) in /opt/archipelago/scripts/"
|
||||||
|
echo " Review and manually sync if needed"
|
||||||
|
ssh "$TARGET_HOST" 'sudo ls -lh /opt/archipelago/scripts/'
|
||||||
|
else
|
||||||
|
echo " ✅ No custom scripts found"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " ✅ No custom scripts directory"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
echo ""
|
||||||
|
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ Sync Complete! ║"
|
||||||
|
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo "Configuration files captured:"
|
||||||
|
ls -lh "$CONFIG_DIR"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1. Review the captured configurations"
|
||||||
|
echo " 2. Build backend: ./scripts/build-backend.sh"
|
||||||
|
echo " 3. Build frontend: ./scripts/build-frontend.sh"
|
||||||
|
echo " 4. Update integration script to use these configs"
|
||||||
|
echo " 5. Build ISO: ./build-debian-iso.sh"
|
||||||
|
echo ""
|
||||||
@ -4,19 +4,54 @@ import type { Update, PatchOperation } from '../types/api'
|
|||||||
import { applyPatch } from 'fast-json-patch'
|
import { applyPatch } from 'fast-json-patch'
|
||||||
|
|
||||||
type WebSocketCallback = (update: Update) => void
|
type WebSocketCallback = (update: Update) => void
|
||||||
|
type ConnectionStateCallback = (connected: boolean) => void
|
||||||
|
|
||||||
export class WebSocketClient {
|
export class WebSocketClient {
|
||||||
private ws: WebSocket | null = null
|
private ws: WebSocket | null = null
|
||||||
private callbacks: Set<WebSocketCallback> = new Set()
|
private callbacks: Set<WebSocketCallback> = new Set()
|
||||||
|
private connectionStateCallbacks: Set<ConnectionStateCallback> = new Set()
|
||||||
private reconnectAttempts = 0
|
private reconnectAttempts = 0
|
||||||
private maxReconnectAttempts = 10
|
private maxReconnectAttempts = 10
|
||||||
private reconnectDelay = 1000
|
private reconnectDelay = 1000
|
||||||
private shouldReconnect = true
|
private shouldReconnect = true
|
||||||
private url: string
|
private url: string
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private visibilityChangeHandler: (() => void) | null = null
|
||||||
|
private onlineHandler: (() => void) | null = null
|
||||||
|
|
||||||
constructor(url: string = '/ws/db') {
|
constructor(url: string = '/ws/db') {
|
||||||
this.url = url
|
this.url = url
|
||||||
|
this.setupBrowserEventHandlers()
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupBrowserEventHandlers(): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
// Handle page visibility changes (tab switching, browser minimizing)
|
||||||
|
this.visibilityChangeHandler = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
console.log('[WebSocket] Page became visible, checking connection...')
|
||||||
|
// Reconnect if connection was lost while tab was hidden
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
console.log('[WebSocket] Connection lost while hidden, reconnecting...')
|
||||||
|
this.connect().catch(err => {
|
||||||
|
console.error('[WebSocket] Failed to reconnect on visibility change:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', this.visibilityChangeHandler)
|
||||||
|
|
||||||
|
// Handle network online/offline events
|
||||||
|
this.onlineHandler = () => {
|
||||||
|
console.log('[WebSocket] Network came online, reconnecting...')
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
this.connect().catch(err => {
|
||||||
|
console.error('[WebSocket] Failed to reconnect when network came online:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('online', this.onlineHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
connect(): Promise<void> {
|
connect(): Promise<void> {
|
||||||
@ -36,9 +71,9 @@ export class WebSocketClient {
|
|||||||
if (this.ws.readyState === WebSocket.OPEN) {
|
if (this.ws.readyState === WebSocket.OPEN) {
|
||||||
clearInterval(checkInterval)
|
clearInterval(checkInterval)
|
||||||
resolve()
|
resolve()
|
||||||
} else if (this.ws.readyState === WebSocket.CLOSED) {
|
} else if (this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
|
||||||
clearInterval(checkInterval)
|
clearInterval(checkInterval)
|
||||||
// Connection failed, will be handled by onclose
|
// Connection failed or closing, will be handled by onclose
|
||||||
reject(new Error('Connection closed during connect'))
|
reject(new Error('Connection closed during connect'))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -57,19 +92,17 @@ export class WebSocketClient {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close existing connection if any (but don't prevent reconnection)
|
// Don't close existing connection if it's still active
|
||||||
if (this.ws) {
|
// Only close if it's in CLOSING or CLOSED state
|
||||||
const oldWs = this.ws
|
if (this.ws && (this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED)) {
|
||||||
this.ws = null
|
this.ws = null
|
||||||
// Temporarily disable reconnection to prevent loop
|
}
|
||||||
const wasReconnecting = this.shouldReconnect
|
|
||||||
this.shouldReconnect = false
|
// If we have an active WebSocket, don't create a new one
|
||||||
oldWs.onclose = null // Remove close handler
|
if (this.ws) {
|
||||||
oldWs.close()
|
console.log('[WebSocket] Connection exists, reusing it')
|
||||||
// Restore reconnection flag after a moment
|
resolve()
|
||||||
setTimeout(() => {
|
return
|
||||||
this.shouldReconnect = wasReconnecting
|
|
||||||
}, 100)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset shouldReconnect flag when explicitly connecting
|
// Reset shouldReconnect flag when explicitly connecting
|
||||||
@ -100,6 +133,7 @@ export class WebSocketClient {
|
|||||||
clearTimeout(connectionTimeout)
|
clearTimeout(connectionTimeout)
|
||||||
this.reconnectAttempts = 0
|
this.reconnectAttempts = 0
|
||||||
console.log('[WebSocket] Connected successfully')
|
console.log('[WebSocket] Connected successfully')
|
||||||
|
this.notifyConnectionState(true)
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,6 +157,9 @@ export class WebSocketClient {
|
|||||||
clearTimeout(connectionTimeout)
|
clearTimeout(connectionTimeout)
|
||||||
console.log('[WebSocket] Closed', { code: event.code, reason: event.reason, wasClean: event.wasClean })
|
console.log('[WebSocket] Closed', { code: event.code, reason: event.reason, wasClean: event.wasClean })
|
||||||
|
|
||||||
|
// Notify connection state changed
|
||||||
|
this.notifyConnectionState(false)
|
||||||
|
|
||||||
// Clear the WebSocket reference
|
// Clear the WebSocket reference
|
||||||
this.ws = null
|
this.ws = null
|
||||||
|
|
||||||
@ -133,12 +170,19 @@ export class WebSocketClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Always try to reconnect unless we've exceeded max attempts
|
// Always try to reconnect unless we've exceeded max attempts
|
||||||
// Code 1001 (Going Away) happens on HMR reloads - reconnect IMMEDIATELY
|
|
||||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||||
// Only code 1001 is HMR, NOT 1006 (1006 is abnormal closure)
|
|
||||||
const isHMR = event.code === 1001
|
const isHMR = event.code === 1001
|
||||||
const delay = isHMR ? 0 : (this.reconnectAttempts === 0 ? 100 : Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 5000))
|
const isNormalClosure = event.code === 1000 || event.code === 1001
|
||||||
console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}, code: ${event.code}, HMR: ${isHMR})`)
|
const isServiceRestart = event.code === 1012
|
||||||
|
|
||||||
|
// Immediate reconnection for HMR, service restarts, and first attempt after abnormal closure
|
||||||
|
const needsImmediateReconnect = isHMR || isServiceRestart || (event.code === 1006 && this.reconnectAttempts === 0)
|
||||||
|
|
||||||
|
const delay = needsImmediateReconnect ? 0 :
|
||||||
|
(this.reconnectAttempts === 0 ? 100 :
|
||||||
|
Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 5000))
|
||||||
|
|
||||||
|
console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}, code: ${event.code})`)
|
||||||
|
|
||||||
// Clear any existing reconnect timer
|
// Clear any existing reconnect timer
|
||||||
if (this.reconnectTimer) {
|
if (this.reconnectTimer) {
|
||||||
@ -152,8 +196,8 @@ export class WebSocketClient {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't increment attempts for HMR disconnects - they're expected
|
// Don't increment attempts for expected disconnects (HMR, normal closure)
|
||||||
if (!isHMR) {
|
if (!isHMR && !isNormalClosure) {
|
||||||
this.reconnectAttempts++
|
this.reconnectAttempts++
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,7 +209,7 @@ export class WebSocketClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (delay === 0) {
|
if (delay === 0) {
|
||||||
// Immediate reconnection for HMR
|
// Immediate reconnection
|
||||||
doReconnect()
|
doReconnect()
|
||||||
} else {
|
} else {
|
||||||
this.reconnectTimer = setTimeout(() => {
|
this.reconnectTimer = setTimeout(() => {
|
||||||
@ -188,6 +232,17 @@ export class WebSocketClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onConnectionStateChange(callback: ConnectionStateCallback): () => void {
|
||||||
|
this.connectionStateCallbacks.add(callback)
|
||||||
|
return () => {
|
||||||
|
this.connectionStateCallbacks.delete(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyConnectionState(connected: boolean): void {
|
||||||
|
this.connectionStateCallbacks.forEach((callback) => callback(connected))
|
||||||
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this.shouldReconnect = false
|
this.shouldReconnect = false
|
||||||
this.reconnectAttempts = 0
|
this.reconnectAttempts = 0
|
||||||
@ -214,6 +269,16 @@ export class WebSocketClient {
|
|||||||
reset(): void {
|
reset(): void {
|
||||||
this.disconnect()
|
this.disconnect()
|
||||||
this.callbacks.clear()
|
this.callbacks.clear()
|
||||||
|
|
||||||
|
// Clean up browser event handlers
|
||||||
|
if (this.visibilityChangeHandler) {
|
||||||
|
document.removeEventListener('visibilitychange', this.visibilityChangeHandler)
|
||||||
|
this.visibilityChangeHandler = null
|
||||||
|
}
|
||||||
|
if (this.onlineHandler) {
|
||||||
|
window.removeEventListener('online', this.onlineHandler)
|
||||||
|
this.onlineHandler = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
|
|||||||
@ -170,6 +170,15 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User is already authenticated (from localStorage on page load)
|
||||||
|
// Make sure WebSocket is connected
|
||||||
|
if (!store.isConnected && !store.isReconnecting) {
|
||||||
|
console.log('[Router] User authenticated but WebSocket not connected, connecting...')
|
||||||
|
store.connectWebSocket().catch((err) => {
|
||||||
|
console.warn('[Router] WebSocket connection failed:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Authenticated user accessing protected route
|
// Authenticated user accessing protected route
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|||||||
@ -74,6 +74,18 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
if (!isWsSubscribed) {
|
if (!isWsSubscribed) {
|
||||||
// Subscribe to updates BEFORE connecting (so we catch initial data)
|
// Subscribe to updates BEFORE connecting (so we catch initial data)
|
||||||
isWsSubscribed = true
|
isWsSubscribed = true
|
||||||
|
|
||||||
|
// Listen for connection state changes
|
||||||
|
wsClient.onConnectionStateChange((connected) => {
|
||||||
|
console.log('[Store] WebSocket connection state changed:', connected)
|
||||||
|
isConnected.value = connected
|
||||||
|
if (!connected) {
|
||||||
|
isReconnecting.value = true
|
||||||
|
} else {
|
||||||
|
isReconnecting.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
wsClient.subscribe((update: any) => {
|
wsClient.subscribe((update: any) => {
|
||||||
// Handle mock backend format: {type: 'initial', data: {...}}
|
// Handle mock backend format: {type: 'initial', data: {...}}
|
||||||
if (update?.type === 'initial' && update?.data) {
|
if (update?.type === 'initial' && update?.data) {
|
||||||
@ -107,14 +119,29 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Now connect (or reconnect if already connected)
|
// Now connect (or reconnect if already connected)
|
||||||
|
// Only attempt to connect if not already connected
|
||||||
|
if (wsClient.isConnected()) {
|
||||||
|
console.log('[Store] WebSocket already connected')
|
||||||
|
isConnected.value = true
|
||||||
|
isReconnecting.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await wsClient.connect()
|
await wsClient.connect()
|
||||||
console.log('[Store] WebSocket connected')
|
console.log('[Store] WebSocket connected')
|
||||||
|
|
||||||
|
// Connection state will be updated via the callback
|
||||||
|
if (wsClient.isConnected()) {
|
||||||
|
isConnected.value = true
|
||||||
|
isReconnecting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Store] WebSocket connection failed:', err)
|
console.error('[Store] WebSocket connection failed:', err)
|
||||||
// Don't mark as disconnected immediately - let reconnection logic handle it
|
// Don't mark as disconnected immediately - let reconnection logic handle it
|
||||||
// The WebSocket client will retry automatically
|
// The WebSocket client will retry automatically
|
||||||
isReconnecting.value = true
|
isReconnecting.value = true
|
||||||
|
isConnected.value = false
|
||||||
// Don't throw - allow app to work without real-time updates
|
// Don't throw - allow app to work without real-time updates
|
||||||
// The WebSocket will reconnect in the background
|
// The WebSocket will reconnect in the background
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,16 +81,38 @@
|
|||||||
<button
|
<button
|
||||||
v-if="pkg.state === 'stopped'"
|
v-if="pkg.state === 'stopped'"
|
||||||
@click.stop="startApp(id as string)"
|
@click.stop="startApp(id as string)"
|
||||||
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors"
|
:disabled="loadingActions[id as string]"
|
||||||
|
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
Start
|
<svg
|
||||||
|
v-if="loadingActions[id as string]"
|
||||||
|
class="animate-spin h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{{ loadingActions[id as string] ? 'Starting...' : 'Start' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="pkg.state === 'running'"
|
v-if="pkg.state === 'running'"
|
||||||
@click.stop="stopApp(id as string)"
|
@click.stop="stopApp(id as string)"
|
||||||
class="flex-1 px-4 py-2 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors"
|
:disabled="loadingActions[id as string]"
|
||||||
|
class="flex-1 px-4 py-2 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
Stop
|
<svg
|
||||||
|
v-if="loadingActions[id as string]"
|
||||||
|
class="animate-spin h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{{ loadingActions[id as string] ? 'Stopping...' : 'Stop' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -152,6 +174,9 @@ import { PackageState } from '../types/api'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
|
||||||
|
// Track loading states for each app action
|
||||||
|
const loadingActions = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
// Use real packages from store - no more dummy apps
|
// Use real packages from store - no more dummy apps
|
||||||
const packages = computed(() => {
|
const packages = computed(() => {
|
||||||
const realPackages = store.packages
|
const realPackages = store.packages
|
||||||
@ -185,38 +210,14 @@ function launchApp(id: string) {
|
|||||||
const isDev = import.meta.env.DEV
|
const isDev = import.meta.env.DEV
|
||||||
const pkg = packages.value[id]
|
const pkg = packages.value[id]
|
||||||
|
|
||||||
// Special handling for Bitcoin Core - open in new tab on port 18445
|
|
||||||
if (id === 'bitcoin') {
|
|
||||||
window.open('http://localhost:18445', '_blank', 'noopener,noreferrer')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special handling for LND - open in new tab on port 8085
|
|
||||||
if (id === 'lnd') {
|
|
||||||
window.open('http://localhost:8085', '_blank', 'noopener,noreferrer')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special handling for Penpot - open in new tab on port 9001
|
|
||||||
if (id === 'penpot' || id === 'penpot-frontend') {
|
|
||||||
window.open('http://localhost:9001', '_blank', 'noopener,noreferrer')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special handling for Morphos - open in new tab on port 8081
|
|
||||||
if (id === 'morphos' || id === 'morphos-server') {
|
|
||||||
window.open('http://localhost:8081', '_blank', 'noopener,noreferrer')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special handling for Nextcloud - open in new tab on port 8086
|
|
||||||
if (id === 'nextcloud') {
|
|
||||||
window.open('http://localhost:8086', '_blank', 'noopener,noreferrer')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the LAN address from the package manifest
|
// Get the LAN address from the package manifest
|
||||||
const lanAddress = pkg?.installed?.['interface-addresses']?.main?.['lan-address']
|
let lanAddress = pkg?.installed?.['interface-addresses']?.main?.['lan-address']
|
||||||
|
|
||||||
|
// Replace localhost with the current hostname (for remote access)
|
||||||
|
if (lanAddress && lanAddress.includes('localhost')) {
|
||||||
|
const currentHost = window.location.hostname
|
||||||
|
lanAddress = lanAddress.replace('localhost', currentHost)
|
||||||
|
}
|
||||||
|
|
||||||
if (lanAddress) {
|
if (lanAddress) {
|
||||||
window.open(lanAddress, '_blank', 'noopener,noreferrer')
|
window.open(lanAddress, '_blank', 'noopener,noreferrer')
|
||||||
@ -236,7 +237,12 @@ function launchApp(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (appUrls[id]) {
|
if (appUrls[id]) {
|
||||||
const url = isDev ? appUrls[id].dev : appUrls[id].prod
|
let url = isDev ? appUrls[id].dev : appUrls[id].prod
|
||||||
|
// Replace localhost with current hostname for remote access
|
||||||
|
if (url.includes('localhost')) {
|
||||||
|
const currentHost = window.location.hostname
|
||||||
|
url = url.replace('localhost', currentHost)
|
||||||
|
}
|
||||||
window.open(url, '_blank', 'noopener,noreferrer')
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -267,18 +273,34 @@ function goToApp(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startApp(id: string) {
|
async function startApp(id: string) {
|
||||||
|
loadingActions.value[id] = true
|
||||||
try {
|
try {
|
||||||
await store.startPackage(id)
|
await store.startPackage(id)
|
||||||
|
// Wait for state update from WebSocket
|
||||||
|
// The loader will be cleared when we receive the updated state
|
||||||
|
// For now, keep a max timeout as fallback
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingActions.value[id] = false
|
||||||
|
}, 5000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to start app:', err)
|
console.error('Failed to start app:', err)
|
||||||
|
loadingActions.value[id] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopApp(id: string) {
|
async function stopApp(id: string) {
|
||||||
|
loadingActions.value[id] = true
|
||||||
try {
|
try {
|
||||||
await store.stopPackage(id)
|
await store.stopPackage(id)
|
||||||
|
// Wait for state update from WebSocket
|
||||||
|
// The loader will be cleared when we receive the updated state
|
||||||
|
// For now, keep a max timeout as fallback
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingActions.value[id] = false
|
||||||
|
}, 5000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to stop app:', err)
|
console.error('Failed to stop app:', err)
|
||||||
|
loadingActions.value[id] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<!-- Bundled Apps -->
|
<!-- Bundled Apps -->
|
||||||
<div
|
<div
|
||||||
v-for="app in BUNDLED_APPS"
|
v-for="app in bundledApps"
|
||||||
:key="app.id"
|
:key="app.id"
|
||||||
class="glass-card p-6 hover:bg-white/5 transition-colors"
|
class="glass-card p-6 hover:bg-white/5 transition-colors"
|
||||||
>
|
>
|
||||||
@ -189,6 +189,9 @@ import ContainerStatus from '@/components/ContainerStatus.vue'
|
|||||||
|
|
||||||
const store = useContainerStore()
|
const store = useContainerStore()
|
||||||
|
|
||||||
|
// Expose BUNDLED_APPS to the template (prevents tree-shaking)
|
||||||
|
const bundledApps = BUNDLED_APPS
|
||||||
|
|
||||||
// Get current host for launch URLs
|
// Get current host for launch URLs
|
||||||
const currentHost = computed(() => window.location.hostname)
|
const currentHost = computed(() => window.location.hostname)
|
||||||
|
|
||||||
@ -205,14 +208,14 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// Containers that aren't bundled apps
|
// Containers that aren't bundled apps
|
||||||
const otherContainers = computed(() => {
|
const otherContainers = computed(() => {
|
||||||
const bundledIds = BUNDLED_APPS.map(a => a.id)
|
const bundledIds = bundledApps.map(a => a.id)
|
||||||
return store.containers.filter(c => {
|
return store.containers.filter(c => {
|
||||||
const name = c.name.toLowerCase()
|
const name = c.name.toLowerCase()
|
||||||
return !bundledIds.some(id => name.includes(id))
|
return !bundledIds.some(id => name.includes(id))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasAnyApps = computed(() => BUNDLED_APPS.length > 0 || store.containers.length > 0)
|
const hasAnyApps = computed(() => bundledApps.length > 0 || store.containers.length > 0)
|
||||||
|
|
||||||
function extractAppName(containerName: string): string {
|
function extractAppName(containerName: string): string {
|
||||||
return containerName
|
return containerName
|
||||||
|
|||||||
24
scripts/check-deployment.sh
Executable file
24
scripts/check-deployment.sh
Executable file
@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Quick script to check what's deployed on the target
|
||||||
|
|
||||||
|
echo "Checking deployed files on target..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "1. Web UI files:"
|
||||||
|
ssh archipelago@192.168.1.228 "ls -lh /opt/archipelago/web-ui/ | head -10"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "2. Backend binary:"
|
||||||
|
ssh archipelago@192.168.1.228 "ls -lh /usr/local/bin/archipelago"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "3. Backend service status:"
|
||||||
|
ssh archipelago@192.168.1.228 "sudo systemctl status archipelago --no-pager | head -15"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "4. Nginx status:"
|
||||||
|
ssh archipelago@192.168.1.228 "sudo systemctl status nginx --no-pager | head -10"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "5. Check one of the JS files for BUNDLED_APPS:"
|
||||||
|
ssh archipelago@192.168.1.228 "grep -l 'BUNDLED_APPS' /opt/archipelago/web-ui/assets/*.js | head -1"
|
||||||
30
scripts/debug-frontend.sh
Executable file
30
scripts/debug-frontend.sh
Executable file
@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Check what's actually in the deployed frontend
|
||||||
|
|
||||||
|
TARGET_HOST="${ARCHIPELAGO_TARGET:-archipelago@192.168.1.228}"
|
||||||
|
|
||||||
|
echo "Checking deployed frontend content..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "1. Search for 'bundledApps' variable in JS:"
|
||||||
|
ssh "$TARGET_HOST" "grep -o 'bundledApps' /opt/archipelago/web-ui/assets/*.js | wc -l"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "2. Search for 'Bitcoin Knots' string:"
|
||||||
|
ssh "$TARGET_HOST" "grep -o 'Bitcoin Knots' /opt/archipelago/web-ui/assets/*.js | head -1"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "3. Search for the v-for loop pattern:"
|
||||||
|
ssh "$TARGET_HOST" "grep -o 'v-for.*bundled' /opt/archipelago/web-ui/assets/*.js | head -1"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "4. List all JS assets (to see if they updated):"
|
||||||
|
ssh "$TARGET_HOST" "ls -lh /opt/archipelago/web-ui/assets/*.js | head -10"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "5. Check index.html timestamp:"
|
||||||
|
ssh "$TARGET_HOST" "stat /opt/archipelago/web-ui/index.html | grep Modify"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "6. Try accessing the API from target:"
|
||||||
|
ssh "$TARGET_HOST" 'curl -s http://localhost:80/ | head -20'
|
||||||
@ -60,9 +60,9 @@ echo " Building frontend..."
|
|||||||
ssh "$TARGET_HOST" "cd $TARGET_DIR/neode-ui && npm install --silent && npm run build" 2>&1 | sed 's/^/ /'
|
ssh "$TARGET_HOST" "cd $TARGET_DIR/neode-ui && npm install --silent && npm run build" 2>&1 | sed 's/^/ /'
|
||||||
|
|
||||||
# Backend (if Rust is installed)
|
# Backend (if Rust is installed)
|
||||||
if ssh "$TARGET_HOST" "command -v cargo" >/dev/null 2>&1; then
|
if ssh "$TARGET_HOST" "source ~/.cargo/env 2>/dev/null && command -v cargo" >/dev/null 2>&1; then
|
||||||
echo " Building backend..."
|
echo " Building backend..."
|
||||||
ssh "$TARGET_HOST" "cd $TARGET_DIR/core && cargo build --release 2>&1" | tail -5 | sed 's/^/ /'
|
ssh "$TARGET_HOST" "source ~/.cargo/env && cd $TARGET_DIR/core && cargo build --release 2>&1" | tail -10 | sed 's/^/ /'
|
||||||
else
|
else
|
||||||
echo " ⚠️ Rust not installed on target, skipping backend build"
|
echo " ⚠️ Rust not installed on target, skipping backend build"
|
||||||
fi
|
fi
|
||||||
@ -71,19 +71,27 @@ if [ "$LIVE" = true ]; then
|
|||||||
echo ""
|
echo ""
|
||||||
echo "🚀 Deploying to live system..."
|
echo "🚀 Deploying to live system..."
|
||||||
|
|
||||||
# Deploy backend
|
# Deploy backend (check if binary exists)
|
||||||
ssh "$TARGET_HOST" "sudo cp $TARGET_DIR/core/target/release/archipelago /usr/local/bin/ 2>/dev/null || true"
|
if ssh "$TARGET_HOST" "[ -f $TARGET_DIR/core/target/release/archipelago ]" 2>/dev/null; then
|
||||||
|
echo " Deploying backend binary..."
|
||||||
|
# Stop service first so we can overwrite the binary
|
||||||
|
ssh "$TARGET_HOST" "sudo systemctl stop archipelago"
|
||||||
|
ssh "$TARGET_HOST" "sudo cp $TARGET_DIR/core/target/release/archipelago /usr/local/bin/"
|
||||||
|
fi
|
||||||
|
|
||||||
# Deploy frontend
|
# Deploy frontend
|
||||||
|
echo " Deploying frontend..."
|
||||||
ssh "$TARGET_HOST" "sudo rm -rf /opt/archipelago/web-ui/*"
|
ssh "$TARGET_HOST" "sudo rm -rf /opt/archipelago/web-ui/*"
|
||||||
ssh "$TARGET_HOST" "sudo cp -r $TARGET_DIR/neode-ui/dist/* /opt/archipelago/web-ui/"
|
ssh "$TARGET_HOST" "sudo cp -r $TARGET_DIR/web/dist/neode-ui/* /opt/archipelago/web-ui/"
|
||||||
ssh "$TARGET_HOST" "sudo chown -R 1000:1000 /opt/archipelago/web-ui"
|
ssh "$TARGET_HOST" "sudo chown -R 1000:1000 /opt/archipelago/web-ui"
|
||||||
|
|
||||||
# Restart services
|
# Restart services
|
||||||
ssh "$TARGET_HOST" "sudo systemctl restart archipelago nginx"
|
echo " Restarting services..."
|
||||||
|
ssh "$TARGET_HOST" "sudo systemctl start archipelago && sudo systemctl restart nginx"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Deployed to live system!"
|
echo "✅ Deployed to live system!"
|
||||||
|
echo " Backend: $(ssh "$TARGET_HOST" 'sudo systemctl is-active archipelago')"
|
||||||
echo " Web UI: http://$(echo $TARGET_HOST | cut -d@ -f2)"
|
echo " Web UI: http://$(echo $TARGET_HOST | cut -d@ -f2)"
|
||||||
else
|
else
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
0
scripts/optimize-debian.sh
Normal file → Executable file
0
scripts/optimize-debian.sh
Normal file → Executable file
24
scripts/test-backend-rpc.sh
Executable file
24
scripts/test-backend-rpc.sh
Executable file
@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Test if the new backend binary has the bundled-app methods
|
||||||
|
|
||||||
|
TARGET_HOST="${ARCHIPELAGO_TARGET:-archipelago@192.168.1.228}"
|
||||||
|
|
||||||
|
echo "Testing backend RPC methods..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "1. Test container-list (should work):"
|
||||||
|
ssh "$TARGET_HOST" 'curl -s http://localhost:5678/rpc/v1 -X POST -H "Content-Type: application/json" -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"container-list\",\"params\":{}}"'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
echo "2. Test bundled-app-start (should not error with 'method not found'):"
|
||||||
|
ssh "$TARGET_HOST" 'curl -s http://localhost:5678/rpc/v1 -X POST -H "Content-Type: application/json" -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"bundled-app-start\",\"params\":{\"app_id\":\"test\",\"image\":\"test\",\"ports\":[],\"volumes\":[]}}"'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
echo "3. Check deployed frontend JS for bundled apps:"
|
||||||
|
ssh "$TARGET_HOST" "grep -o 'bitcoin-knots\\|bitcoinknots' /opt/archipelago/web-ui/assets/*.js 2>/dev/null | head -3"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "4. Service Worker file (should exist):"
|
||||||
|
ssh "$TARGET_HOST" "ls -lh /opt/archipelago/web-ui/sw.js"
|
||||||
30
scripts/verify-deployment.sh
Executable file
30
scripts/verify-deployment.sh
Executable file
@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Verify the deployment is working correctly
|
||||||
|
|
||||||
|
TARGET_HOST="${ARCHIPELAGO_TARGET:-archipelago@192.168.1.228}"
|
||||||
|
|
||||||
|
echo "Checking deployment status..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "1. Backend binary timestamp:"
|
||||||
|
ssh "$TARGET_HOST" "ls -lh /usr/local/bin/archipelago | awk '{print \$6, \$7, \$8, \$9}'"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "2. Backend service status:"
|
||||||
|
ssh "$TARGET_HOST" "sudo systemctl status archipelago --no-pager | head -20"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "3. Test RPC method 'container-list':"
|
||||||
|
ssh "$TARGET_HOST" 'curl -s http://localhost:5678/rpc/v1 -X POST -H "Content-Type: application/json" -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"container-list\",\"params\":{}}" | jq .'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "4. Check if bundled-app-start method exists (should not error):"
|
||||||
|
ssh "$TARGET_HOST" 'curl -s http://localhost:5678/rpc/v1 -X POST -H "Content-Type: application/json" -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"bundled-app-start\",\"params\":{\"app_id\":\"bitcoin-knots\",\"image\":\"test\",\"ports\":[],\"volumes\":[]}}" | jq .'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "5. Frontend files timestamp:"
|
||||||
|
ssh "$TARGET_HOST" "ls -lh /opt/archipelago/web-ui/index.html | awk '{print \$6, \$7, \$8, \$9}'"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "6. Check for BUNDLED_APPS in frontend JS:"
|
||||||
|
ssh "$TARGET_HOST" "grep -o 'bitcoin-knots' /opt/archipelago/web-ui/assets/*.js | head -1"
|
||||||
Loading…
x
Reference in New Issue
Block a user