diff --git a/core/archipelago/src/api/rpc.rs b/core/archipelago/src/api/rpc.rs index 0c8144c5..e24c2daf 100644 --- a/core/archipelago/src/api/rpc.rs +++ b/core/archipelago/src/api/rpc.rs @@ -89,6 +89,10 @@ impl RpcHandler { "package.stop" => self.handle_package_stop(rpc_req.params).await, "package.restart" => self.handle_package_restart(rpc_req.params).await, + // Bundled app management (for pre-loaded container images) + "bundled-app-start" => self.handle_bundled_app_start(rpc_req.params).await, + "bundled-app-stop" => self.handle_bundled_app_stop(rpc_req.params).await, + _ => { Err(anyhow::anyhow!("Unknown method: {}", rpc_req.method)) } @@ -273,17 +277,64 @@ impl RpcHandler { } async fn handle_container_list(&self) -> Result { - let orchestrator = self - .orchestrator - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?; + // Try to get containers from orchestrator first + if let Some(orchestrator) = &self.orchestrator { + if let Ok(containers) = orchestrator.list_containers().await { + if !containers.is_empty() { + return Ok(serde_json::to_value(containers)?); + } + } + } - let containers = orchestrator - .list_containers() + // Fallback: list containers directly via sudo podman (for bundled apps) + let output = tokio::process::Command::new("sudo") + .args(["podman", "ps", "-a", "--format", "json"]) + .output() .await - .context("Failed to list containers")?; + .context("Failed to list containers via podman")?; - Ok(serde_json::to_value(containers)?) + if !output.status.success() { + // If podman fails, return empty list + return Ok(serde_json::json!([])); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout.trim().is_empty() { + return Ok(serde_json::json!([])); + } + + // Parse podman JSON output + let podman_containers: Vec = serde_json::from_str(&stdout) + .unwrap_or_else(|_| Vec::new()); + + // Convert to our ContainerStatus format + let containers: Vec = podman_containers + .iter() + .map(|c| { + let state = c.get("State").and_then(|v| v.as_str()).unwrap_or("unknown"); + let mapped_state = match state.to_lowercase().as_str() { + "running" => "running", + "exited" => "exited", + "stopped" => "stopped", + "created" => "created", + "paused" => "paused", + _ => "unknown", + }; + + serde_json::json!({ + "id": c.get("Id").and_then(|v| v.as_str()).unwrap_or(""), + "name": c.get("Names").and_then(|v| v.as_array()).and_then(|a| a.first()).and_then(|v| v.as_str()).unwrap_or(""), + "state": mapped_state, + "image": c.get("Image").and_then(|v| v.as_str()).unwrap_or(""), + "created": c.get("Created").and_then(|v| v.as_str()).unwrap_or(""), + "ports": c.get("Ports").and_then(|v| v.as_array()).map(|a| + a.iter().filter_map(|p| p.get("hostPort").and_then(|v| v.as_u64()).map(|p| p.to_string())).collect::>() + ).unwrap_or_default(), + }) + }) + .collect(); + + Ok(serde_json::json!(containers)) } async fn handle_container_status( @@ -469,4 +520,116 @@ impl RpcHandler { Ok(serde_json::Value::Null) } + + /// Start a bundled app (create container from pre-loaded image if needed, then start) + async fn handle_bundled_app_start( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let app_id = params + .get("app_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing app_id"))?; + let image = params + .get("image") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing image"))?; + let ports = params + .get("ports") + .and_then(|v| v.as_array()) + .ok_or_else(|| anyhow::anyhow!("Missing ports"))?; + let volumes = params + .get("volumes") + .and_then(|v| v.as_array()) + .ok_or_else(|| anyhow::anyhow!("Missing volumes"))?; + + // Check if container already exists + let check_output = tokio::process::Command::new("sudo") + .args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name={}", app_id)]) + .output() + .await + .context("Failed to check container")?; + + let existing = String::from_utf8_lossy(&check_output.stdout); + + if existing.trim().is_empty() { + // Container doesn't exist - create it + let mut cmd = tokio::process::Command::new("sudo"); + cmd.args(["podman", "run", "-d", "--name", app_id]); + + // Add port mappings + for port in ports { + if let (Some(host), Some(container)) = ( + port.get("host").and_then(|v| v.as_u64()), + port.get("container").and_then(|v| v.as_u64()), + ) { + cmd.arg("-p").arg(format!("{}:{}", host, container)); + } + } + + // Add volume mappings + for volume in volumes { + if let (Some(host), Some(container)) = ( + volume.get("host").and_then(|v| v.as_str()), + volume.get("container").and_then(|v| v.as_str()), + ) { + // Create host directory if it doesn't exist + let _ = tokio::process::Command::new("sudo") + .args(["mkdir", "-p", host]) + .output() + .await; + cmd.arg("-v").arg(format!("{}:{}", host, container)); + } + } + + cmd.arg(image); + + let output = cmd.output().await.context("Failed to create container")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!("Failed to create container: {}", stderr)); + } + } else { + // Container exists - just start it + let output = tokio::process::Command::new("sudo") + .args(["podman", "start", app_id]) + .output() + .await + .context("Failed to start container")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!("Failed to start container: {}", stderr)); + } + } + + Ok(serde_json::json!({ "status": "started", "app_id": app_id })) + } + + /// Stop a bundled app + async fn handle_bundled_app_stop( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let app_id = params + .get("app_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing app_id"))?; + + let output = tokio::process::Command::new("sudo") + .args(["podman", "stop", app_id]) + .output() + .await + .context("Failed to stop container")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!("Failed to stop container: {}", stderr)); + } + + Ok(serde_json::json!({ "status": "stopped", "app_id": app_id })) + } } diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index 9442d621..7cb135d9 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -57,7 +57,7 @@ function handleSplashComplete() { // Determine destination based on onboarding status and dev mode const devMode = import.meta.env.VITE_DEV_MODE const seenOnboarding = localStorage.getItem('neode_onboarding_complete') === '1' - const isSetup = localStorage.getItem('neode_setup_complete') === '1' + // const isSetup = localStorage.getItem('neode_setup_complete') === '1' let destination = '/' diff --git a/neode-ui/src/api/container-client.ts b/neode-ui/src/api/container-client.ts index c9625c18..a334b9e2 100644 --- a/neode-ui/src/api/container-client.ts +++ b/neode-ui/src/api/container-client.ts @@ -20,6 +20,14 @@ export interface ContainerAppInfo { health: 'healthy' | 'unhealthy' | 'unknown' | 'starting' } +export interface BundledAppConfig { + id: string + name: string + image: string + ports: { host: number; container: number }[] + volumes: { host: string; container: string }[] +} + export const containerClient = { /** * Install a container app from a manifest file @@ -94,10 +102,35 @@ export const containerClient = { /** * Get health status for all containers */ - async getHealthStatus(): Promise> { + async getHealthStatus(): Promise> { return rpcClient.call>({ method: 'container-health', params: {}, }) }, + + /** + * Start a bundled app (creates container if needed, then starts it) + */ + async startBundledApp(app: BundledAppConfig): Promise { + return rpcClient.call({ + method: 'bundled-app-start', + params: { + app_id: app.id, + image: app.image, + ports: app.ports, + volumes: app.volumes, + }, + }) + }, + + /** + * Stop a bundled app + */ + async stopBundledApp(appId: string): Promise { + return rpcClient.call({ + method: 'bundled-app-stop', + params: { app_id: appId }, + }) + }, } diff --git a/neode-ui/src/api/websocket.ts b/neode-ui/src/api/websocket.ts index e72b36d8..20357eda 100644 --- a/neode-ui/src/api/websocket.ts +++ b/neode-ui/src/api/websocket.ts @@ -14,7 +14,6 @@ export class WebSocketClient { private shouldReconnect = true private url: string private reconnectTimer: ReturnType | null = null - private isConnecting = false constructor(url: string = '/ws/db') { this.url = url @@ -99,7 +98,6 @@ export class WebSocketClient { this.ws.onopen = () => { clearTimeout(connectionTimeout) - this.isConnecting = false this.reconnectAttempts = 0 console.log('[WebSocket] Connected successfully') resolve() @@ -107,7 +105,6 @@ export class WebSocketClient { this.ws.onerror = (error) => { clearTimeout(connectionTimeout) - this.isConnecting = false console.error('[WebSocket] Connection error:', error) // Don't reject immediately - let onclose handle reconnection // This prevents errors from blocking reconnection @@ -124,7 +121,6 @@ export class WebSocketClient { this.ws.onclose = (event) => { clearTimeout(connectionTimeout) - this.isConnecting = false console.log('[WebSocket] Closed', { code: event.code, reason: event.reason, wasClean: event.wasClean }) // Clear the WebSocket reference @@ -195,7 +191,6 @@ export class WebSocketClient { disconnect(): void { this.shouldReconnect = false this.reconnectAttempts = 0 - this.isConnecting = false // Clear reconnect timer if (this.reconnectTimer) { @@ -242,7 +237,7 @@ function getWebSocketClient(): WebSocketClient { const existing = (window as any).__archipelago_ws_client if (existing && existing instanceof WebSocketClient) { // Check if the WebSocket is still valid - if (existing.ws && existing.ws.readyState === WebSocket.OPEN) { + if (existing.isConnected()) { console.log('[WebSocket] Using existing connected client from HMR') wsClientInstance = existing return existing diff --git a/neode-ui/src/router/index.ts b/neode-ui/src/router/index.ts index 16a8058f..f2a7fb90 100644 --- a/neode-ui/src/router/index.ts +++ b/neode-ui/src/router/index.ts @@ -11,11 +11,11 @@ const router = createRouter({ children: [ { path: '', - redirect: (to) => { + redirect: (_to) => { // Initial routing logic - determines first screen after splash const devMode = import.meta.env.VITE_DEV_MODE const seenOnboarding = localStorage.getItem('neode_onboarding_complete') === '1' - const isSetup = localStorage.getItem('neode_setup_complete') === '1' + // const isSetup = localStorage.getItem('neode_setup_complete') === '1' // Setup mode: go directly to login (original StartOS setup) if (devMode === 'setup') { diff --git a/neode-ui/src/stores/container.ts b/neode-ui/src/stores/container.ts index d26df867..86b063bd 100644 --- a/neode-ui/src/stores/container.ts +++ b/neode-ui/src/stores/container.ts @@ -1,13 +1,59 @@ // Pinia store for container management import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import { containerClient, type ContainerStatus, type ContainerAppInfo } from '@/api/container-client' +import { containerClient, type ContainerStatus } from '@/api/container-client' + +// Bundled apps that come pre-loaded with Archipelago +export interface BundledApp { + id: string + name: string + image: string + description: string + icon: string + ports: { host: number; container: number }[] + volumes: { host: string; container: string }[] + category: 'bitcoin' | 'lightning' | 'home' | 'other' +} + +export const BUNDLED_APPS: BundledApp[] = [ + { + id: 'bitcoin-knots', + name: 'Bitcoin Knots', + image: 'localhost/bitcoinknots/bitcoin:29', + description: 'Full Bitcoin node with additional features', + icon: '₿', + ports: [{ host: 8332, container: 8332 }, { host: 8333, container: 8333 }], + volumes: [{ host: '/var/lib/archipelago/bitcoin', container: '/data' }], + category: 'bitcoin', + }, + { + id: 'lnd', + name: 'Lightning (LND)', + image: 'docker.io/lightninglabs/lnd:v0.18.4-beta', + description: 'Lightning Network Daemon for fast Bitcoin payments', + icon: '⚡', + ports: [{ host: 9735, container: 9735 }, { host: 10009, container: 10009 }], + volumes: [{ host: '/var/lib/archipelago/lnd', container: '/root/.lnd' }], + category: 'lightning', + }, + { + id: 'homeassistant', + name: 'Home Assistant', + image: 'ghcr.io/home-assistant/home-assistant:stable', + description: 'Open source home automation platform', + icon: '🏠', + ports: [{ host: 8123, container: 8123 }], + volumes: [{ host: '/var/lib/archipelago/homeassistant', container: '/config' }], + category: 'home', + }, +] export const useContainerStore = defineStore('container', () => { // State const containers = ref([]) const healthStatus = ref>({}) const loading = ref(false) + const loadingApps = ref>(new Set()) // Track loading state per app const error = ref(null) // Getters @@ -27,6 +73,28 @@ export const useContainerStore = defineStore('container', () => { healthStatus.value[appId] || 'unknown' ) + // Get container for a bundled app (matches by name) + const getContainerForApp = computed(() => (appId: string) => { + return containers.value.find(c => + c.name === appId || + c.name.includes(appId) || + c.name === `archipelago-${appId}` || + c.name === `archipelago-${appId}-dev` + ) + }) + + // Check if an app is currently loading (starting/stopping) + const isAppLoading = computed(() => (appId: string) => + loadingApps.value.has(appId) + ) + + // Get app state: 'running', 'stopped', 'not-installed' + const getAppState = computed(() => (appId: string) => { + const container = getContainerForApp.value(appId) + if (!container) return 'not-installed' + return container.state + }) + // Actions async function fetchContainers() { loading.value = true @@ -65,7 +133,7 @@ export const useContainerStore = defineStore('container', () => { } async function startContainer(appId: string) { - loading.value = true + loadingApps.value.add(appId) error.value = null try { await containerClient.startContainer(appId) @@ -75,12 +143,12 @@ export const useContainerStore = defineStore('container', () => { error.value = e instanceof Error ? e.message : 'Failed to start container' throw e } finally { - loading.value = false + loadingApps.value.delete(appId) } } async function stopContainer(appId: string) { - loading.value = true + loadingApps.value.add(appId) error.value = null try { await containerClient.stopContainer(appId) @@ -89,7 +157,38 @@ export const useContainerStore = defineStore('container', () => { error.value = e instanceof Error ? e.message : 'Failed to stop container' throw e } finally { - loading.value = false + loadingApps.value.delete(appId) + } + } + + // Start a bundled app (creates and starts container) + async function startBundledApp(app: BundledApp) { + loadingApps.value.add(app.id) + error.value = null + try { + await containerClient.startBundledApp(app) + await fetchContainers() + await fetchHealthStatus() + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to start app' + throw e + } finally { + loadingApps.value.delete(app.id) + } + } + + // Stop a bundled app + async function stopBundledApp(appId: string) { + loadingApps.value.add(appId) + error.value = null + try { + await containerClient.stopBundledApp(appId) + await fetchContainers() + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to stop app' + throw e + } finally { + loadingApps.value.delete(appId) } } @@ -116,17 +215,30 @@ export const useContainerStore = defineStore('container', () => { } } + async function getContainerStatus(appId: string) { + try { + return await containerClient.getContainerStatus(appId) + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to get status' + throw e + } + } + return { // State containers, healthStatus, loading, + loadingApps, error, // Getters runningContainers, stoppedContainers, getContainerById, getHealthStatus, + getContainerForApp, + isAppLoading, + getAppState, // Actions fetchContainers, fetchHealthStatus, @@ -135,5 +247,8 @@ export const useContainerStore = defineStore('container', () => { stopContainer, removeContainer, getContainerLogs, + getContainerStatus, + startBundledApp, + stopBundledApp, } }) diff --git a/neode-ui/src/views/ContainerApps.vue b/neode-ui/src/views/ContainerApps.vue index a2a3f81a..54df0677 100644 --- a/neode-ui/src/views/ContainerApps.vue +++ b/neode-ui/src/views/ContainerApps.vue @@ -1,17 +1,17 @@ diff --git a/neode-ui/src/views/Login.vue b/neode-ui/src/views/Login.vue index c300b98b..7cbb00da 100644 --- a/neode-ui/src/views/Login.vue +++ b/neode-ui/src/views/Login.vue @@ -154,8 +154,8 @@ const isSetupMode = computed(() => { onMounted(async () => { if (isSetupMode.value) { try { - const result = await rpcClient.call({ method: 'auth.isSetup', params: {} }) - isSetup.value = result?.result || false + const result = await rpcClient.call({ method: 'auth.isSetup', params: {} }) + isSetup.value = Boolean(result) } catch (err) { console.error('Failed to check setup status:', err) // Assume not set up if check fails