Implement bundled app management in RPC and UI

- Added new RPC methods for starting and stopping bundled apps, allowing management of pre-loaded container images.
- Enhanced container listing logic to include a fallback to Podman for bundled apps.
- Updated the UI to display bundled apps with their respective statuses, including start and stop functionality.
- Introduced a new Pinia store structure to manage loading states and app statuses for bundled applications.
- Refactored existing components to improve user experience and streamline app management.
This commit is contained in:
Dorian 2026-02-01 06:04:36 +00:00
parent 66c823e2fd
commit 00d1af12f0
8 changed files with 561 additions and 106 deletions

View File

@ -89,6 +89,10 @@ impl RpcHandler {
"package.stop" => self.handle_package_stop(rpc_req.params).await, "package.stop" => self.handle_package_stop(rpc_req.params).await,
"package.restart" => self.handle_package_restart(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)) Err(anyhow::anyhow!("Unknown method: {}", rpc_req.method))
} }
@ -273,17 +277,64 @@ impl RpcHandler {
} }
async fn handle_container_list(&self) -> Result<serde_json::Value> { async fn handle_container_list(&self) -> Result<serde_json::Value> {
let orchestrator = self // Try to get containers from orchestrator first
.orchestrator if let Some(orchestrator) = &self.orchestrator {
.as_ref() if let Ok(containers) = orchestrator.list_containers().await {
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?; if !containers.is_empty() {
return Ok(serde_json::to_value(containers)?);
}
}
}
let containers = orchestrator // Fallback: list containers directly via sudo podman (for bundled apps)
.list_containers() let output = tokio::process::Command::new("sudo")
.args(["podman", "ps", "-a", "--format", "json"])
.output()
.await .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::Value> = serde_json::from_str(&stdout)
.unwrap_or_else(|_| Vec::new());
// Convert to our ContainerStatus format
let containers: Vec<serde_json::Value> = 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::<Vec<_>>()
).unwrap_or_default(),
})
})
.collect();
Ok(serde_json::json!(containers))
} }
async fn handle_container_status( async fn handle_container_status(
@ -469,4 +520,116 @@ impl RpcHandler {
Ok(serde_json::Value::Null) 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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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 }))
}
} }

View File

@ -57,7 +57,7 @@ function handleSplashComplete() {
// Determine destination based on onboarding status and dev mode // Determine destination based on onboarding status and dev mode
const devMode = import.meta.env.VITE_DEV_MODE const devMode = import.meta.env.VITE_DEV_MODE
const seenOnboarding = localStorage.getItem('neode_onboarding_complete') === '1' 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 = '/' let destination = '/'

View File

@ -20,6 +20,14 @@ export interface ContainerAppInfo {
health: 'healthy' | 'unhealthy' | 'unknown' | 'starting' 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 = { export const containerClient = {
/** /**
* Install a container app from a manifest file * Install a container app from a manifest file
@ -94,10 +102,35 @@ export const containerClient = {
/** /**
* Get health status for all containers * Get health status for all containers
*/ */
async getHealthStatus(): Promise<Record<string, 'healthy' | 'unhealthy' | 'unknown' | 'starting'>> { async getHealthStatus(): Promise<Record<string, string>> {
return rpcClient.call<Record<string, string>>({ return rpcClient.call<Record<string, string>>({
method: 'container-health', method: 'container-health',
params: {}, params: {},
}) })
}, },
/**
* Start a bundled app (creates container if needed, then starts it)
*/
async startBundledApp(app: BundledAppConfig): Promise<void> {
return rpcClient.call<void>({
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<void> {
return rpcClient.call<void>({
method: 'bundled-app-stop',
params: { app_id: appId },
})
},
} }

View File

@ -14,7 +14,6 @@ export class WebSocketClient {
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 isConnecting = false
constructor(url: string = '/ws/db') { constructor(url: string = '/ws/db') {
this.url = url this.url = url
@ -99,7 +98,6 @@ export class WebSocketClient {
this.ws.onopen = () => { this.ws.onopen = () => {
clearTimeout(connectionTimeout) clearTimeout(connectionTimeout)
this.isConnecting = false
this.reconnectAttempts = 0 this.reconnectAttempts = 0
console.log('[WebSocket] Connected successfully') console.log('[WebSocket] Connected successfully')
resolve() resolve()
@ -107,7 +105,6 @@ export class WebSocketClient {
this.ws.onerror = (error) => { this.ws.onerror = (error) => {
clearTimeout(connectionTimeout) clearTimeout(connectionTimeout)
this.isConnecting = false
console.error('[WebSocket] Connection error:', error) console.error('[WebSocket] Connection error:', error)
// Don't reject immediately - let onclose handle reconnection // Don't reject immediately - let onclose handle reconnection
// This prevents errors from blocking reconnection // This prevents errors from blocking reconnection
@ -124,7 +121,6 @@ export class WebSocketClient {
this.ws.onclose = (event) => { this.ws.onclose = (event) => {
clearTimeout(connectionTimeout) clearTimeout(connectionTimeout)
this.isConnecting = false
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 })
// Clear the WebSocket reference // Clear the WebSocket reference
@ -195,7 +191,6 @@ export class WebSocketClient {
disconnect(): void { disconnect(): void {
this.shouldReconnect = false this.shouldReconnect = false
this.reconnectAttempts = 0 this.reconnectAttempts = 0
this.isConnecting = false
// Clear reconnect timer // Clear reconnect timer
if (this.reconnectTimer) { if (this.reconnectTimer) {
@ -242,7 +237,7 @@ function getWebSocketClient(): WebSocketClient {
const existing = (window as any).__archipelago_ws_client const existing = (window as any).__archipelago_ws_client
if (existing && existing instanceof WebSocketClient) { if (existing && existing instanceof WebSocketClient) {
// Check if the WebSocket is still valid // 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') console.log('[WebSocket] Using existing connected client from HMR')
wsClientInstance = existing wsClientInstance = existing
return existing return existing

View File

@ -11,11 +11,11 @@ const router = createRouter({
children: [ children: [
{ {
path: '', path: '',
redirect: (to) => { redirect: (_to) => {
// Initial routing logic - determines first screen after splash // Initial routing logic - determines first screen after splash
const devMode = import.meta.env.VITE_DEV_MODE const devMode = import.meta.env.VITE_DEV_MODE
const seenOnboarding = localStorage.getItem('neode_onboarding_complete') === '1' 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) // Setup mode: go directly to login (original StartOS setup)
if (devMode === 'setup') { if (devMode === 'setup') {

View File

@ -1,13 +1,59 @@
// Pinia store for container management // Pinia store for container management
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' 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', () => { export const useContainerStore = defineStore('container', () => {
// State // State
const containers = ref<ContainerStatus[]>([]) const containers = ref<ContainerStatus[]>([])
const healthStatus = ref<Record<string, string>>({}) const healthStatus = ref<Record<string, string>>({})
const loading = ref(false) const loading = ref(false)
const loadingApps = ref<Set<string>>(new Set()) // Track loading state per app
const error = ref<string | null>(null) const error = ref<string | null>(null)
// Getters // Getters
@ -27,6 +73,28 @@ export const useContainerStore = defineStore('container', () => {
healthStatus.value[appId] || 'unknown' 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 // Actions
async function fetchContainers() { async function fetchContainers() {
loading.value = true loading.value = true
@ -65,7 +133,7 @@ export const useContainerStore = defineStore('container', () => {
} }
async function startContainer(appId: string) { async function startContainer(appId: string) {
loading.value = true loadingApps.value.add(appId)
error.value = null error.value = null
try { try {
await containerClient.startContainer(appId) await containerClient.startContainer(appId)
@ -75,12 +143,12 @@ export const useContainerStore = defineStore('container', () => {
error.value = e instanceof Error ? e.message : 'Failed to start container' error.value = e instanceof Error ? e.message : 'Failed to start container'
throw e throw e
} finally { } finally {
loading.value = false loadingApps.value.delete(appId)
} }
} }
async function stopContainer(appId: string) { async function stopContainer(appId: string) {
loading.value = true loadingApps.value.add(appId)
error.value = null error.value = null
try { try {
await containerClient.stopContainer(appId) await containerClient.stopContainer(appId)
@ -89,7 +157,38 @@ export const useContainerStore = defineStore('container', () => {
error.value = e instanceof Error ? e.message : 'Failed to stop container' error.value = e instanceof Error ? e.message : 'Failed to stop container'
throw e throw e
} finally { } 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 { return {
// State // State
containers, containers,
healthStatus, healthStatus,
loading, loading,
loadingApps,
error, error,
// Getters // Getters
runningContainers, runningContainers,
stoppedContainers, stoppedContainers,
getContainerById, getContainerById,
getHealthStatus, getHealthStatus,
getContainerForApp,
isAppLoading,
getAppState,
// Actions // Actions
fetchContainers, fetchContainers,
fetchHealthStatus, fetchHealthStatus,
@ -135,5 +247,8 @@ export const useContainerStore = defineStore('container', () => {
stopContainer, stopContainer,
removeContainer, removeContainer,
getContainerLogs, getContainerLogs,
getContainerStatus,
startBundledApp,
stopBundledApp,
} }
}) })

View File

@ -1,17 +1,17 @@
<template> <template>
<div class="p-6"> <div class="p-6">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Container Apps</h1> <h1 class="text-3xl font-bold text-white mb-2">My Apps</h1>
<p class="text-white/70">Manage containerized applications running on your Archipelago node</p> <p class="text-white/70">Manage your Archipelago applications</p>
</div> </div>
<!-- Loading State --> <!-- Loading State (initial load) -->
<div v-if="store.loading" class="flex items-center justify-center py-12"> <div v-if="store.loading && !hasAnyApps" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-white/60"></div> <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-white/60"></div>
</div> </div>
<!-- Error State --> <!-- Error State -->
<div v-else-if="store.error" class="glass-card p-6 mb-6"> <div v-if="store.error" class="glass-card p-6 mb-6">
<div class="flex items-center gap-3 text-red-400"> <div class="flex items-center gap-3 text-red-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
@ -20,146 +20,295 @@
</div> </div>
</div> </div>
<!-- Container List --> <!-- Apps Grid -->
<div v-else 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 -->
<div <div
v-for="container in store.containers" v-for="app in BUNDLED_APPS"
:key="container.id" :key="app.id"
class="glass-card p-6 hover:bg-white/5 transition-colors cursor-pointer" class="glass-card p-6 hover:bg-white/5 transition-colors"
@click="$router.push(`/dashboard/containers/${extractAppId(container.name)}`)"
> >
<div class="flex items-start justify-between mb-4"> <div class="flex items-start justify-between mb-4">
<div class="flex-1"> <div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-white mb-1"> <span class="text-3xl">{{ app.icon }}</span>
{{ extractAppName(container.name) }} <div>
</h3> <h3 class="text-lg font-semibold text-white">{{ app.name }}</h3>
<p class="text-sm text-white/60">{{ container.image }}</p> <p class="text-sm text-white/60">{{ app.description }}</p>
</div>
</div> </div>
<ContainerStatus
:state="container.state as any"
:health="store.getHealthStatus(extractAppId(container.name)) as any"
/>
</div> </div>
<div class="space-y-2 mb-4"> <!-- Status Badge -->
<div class="flex items-center justify-between text-sm"> <div class="mb-4">
<span class="text-white/60">Container ID</span> <span
<span class="text-white/80 font-mono text-xs">{{ container.id.substring(0, 12) }}</span> :class="getStatusBadgeClass(app.id)"
</div> class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium"
<div class="flex items-center justify-between text-sm"> >
<span class="text-white/60">Created</span> <!-- Loading spinner -->
<span class="text-white/80 text-xs">{{ formatDate(container.created) }}</span> <svg
v-if="store.isAppLoading(app.id)"
class="w-3 h-3 animate-spin"
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>
<!-- Status dot -->
<span
v-else
:class="getStatusDotClass(app.id)"
class="w-2 h-2 rounded-full"
></span>
{{ getStatusText(app.id) }}
</span>
</div>
<!-- Port info -->
<div class="text-sm text-white/50 mb-4">
<span v-if="store.getAppState(app.id) === 'running'">
Port{{ app.ports.length > 1 ? 's' : '' }}:
{{ app.ports.map(p => p.host).join(', ') }}
</span>
<span v-else>
{{ app.image.split('/').pop() }}
</span>
</div>
<!-- Action Buttons -->
<div class="flex gap-2">
<!-- Not installed: Start button -->
<button
v-if="store.getAppState(app.id) === 'not-installed'"
@click="handleStartApp(app)"
:disabled="store.isAppLoading(app.id)"
class="flex-1 px-4 py-2 bg-green-600 hover:bg-green-500 disabled:bg-green-800 disabled:cursor-not-allowed rounded text-sm font-medium text-white transition-colors flex items-center justify-center gap-2"
>
<svg v-if="store.isAppLoading(app.id)" class="w-4 h-4 animate-spin" 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>{{ store.isAppLoading(app.id) ? 'Starting...' : 'Start' }}</span>
</button>
<!-- Stopped: Start button -->
<button
v-else-if="store.getAppState(app.id) === 'stopped' || store.getAppState(app.id) === 'exited'"
@click="handleStartApp(app)"
:disabled="store.isAppLoading(app.id)"
class="flex-1 px-4 py-2 bg-green-600 hover:bg-green-500 disabled:bg-green-800 disabled:cursor-not-allowed rounded text-sm font-medium text-white transition-colors flex items-center justify-center gap-2"
>
<svg v-if="store.isAppLoading(app.id)" class="w-4 h-4 animate-spin" 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>{{ store.isAppLoading(app.id) ? 'Starting...' : 'Start' }}</span>
</button>
<!-- Running: Stop and Launch buttons -->
<template v-else-if="store.getAppState(app.id) === 'running'">
<button
@click="handleStopApp(app.id)"
:disabled="store.isAppLoading(app.id)"
class="flex-1 px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
<svg v-if="store.isAppLoading(app.id)" class="w-4 h-4 animate-spin" 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>{{ store.isAppLoading(app.id) ? 'Stopping...' : 'Stop' }}</span>
</button>
<a
:href="getLaunchUrl(app)"
target="_blank"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded text-sm font-medium text-white transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Launch
</a>
</template>
</div>
</div>
<!-- Other containers (not bundled) -->
<div
v-for="container in otherContainers"
:key="container.id"
class="glass-card p-6 hover:bg-white/5 transition-colors"
>
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<span class="text-3xl">📦</span>
<div>
<h3 class="text-lg font-semibold text-white">{{ extractAppName(container.name) }}</h3>
<p class="text-sm text-white/60">{{ container.image }}</p>
</div>
</div> </div>
</div> </div>
<div class="mb-4">
<ContainerStatus :state="container.state as any" />
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
v-if="container.state !== 'running'" v-if="container.state !== 'running'"
@click.stop="handleStart(extractAppId(container.name))" @click="handleStartContainer(container.name)"
class="flex-1 px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors" class="flex-1 px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors"
> >
Start Start
</button> </button>
<button <button
v-else v-else
@click.stop="handleStop(extractAppId(container.name))" @click="handleStopContainer(container.name)"
class="flex-1 px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors" class="flex-1 px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors"
> >
Stop Stop
</button> </button>
<button
@click.stop="handleRemove(extractAppId(container.name))"
class="px-4 py-2 glass-button rounded text-sm font-medium text-red-400/90 hover:text-red-400 transition-colors"
>
Remove
</button>
</div> </div>
</div> </div>
</div>
<!-- Empty State --> <!-- Empty state (when no bundled apps - shouldn't happen) -->
<div v-if="store.containers.length === 0" class="col-span-full glass-card p-12 text-center"> <div v-if="!hasAnyApps && !store.loading" class="glass-card p-12 text-center">
<svg class="w-16 h-16 mx-auto mb-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-16 h-16 mx-auto mb-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg> </svg>
<h3 class="text-xl font-semibold text-white mb-2">No containers found</h3> <h3 class="text-xl font-semibold text-white mb-2">No apps available</h3>
<p class="text-white/60 mb-6">Install your first container app to get started</p> <p class="text-white/60">Check your Archipelago installation</p>
<button
@click="$router.push('/dashboard/marketplace')"
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors"
>
Browse Marketplace
</button>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { onMounted, computed } from 'vue'
import { useContainerStore } from '@/stores/container' import { useContainerStore, BUNDLED_APPS, type BundledApp } from '@/stores/container'
import ContainerStatus from '@/components/ContainerStatus.vue' import ContainerStatus from '@/components/ContainerStatus.vue'
const store = useContainerStore() const store = useContainerStore()
// Get current host for launch URLs
const currentHost = computed(() => window.location.hostname)
onMounted(async () => { onMounted(async () => {
await store.fetchContainers() await store.fetchContainers()
await store.fetchHealthStatus() await store.fetchHealthStatus()
// Refresh every 30 seconds // Refresh every 10 seconds
setInterval(async () => { setInterval(async () => {
await store.fetchContainers() await store.fetchContainers()
await store.fetchHealthStatus() await store.fetchHealthStatus()
}, 30000) }, 10000)
}) })
function extractAppId(containerName: string): string { // Containers that aren't bundled apps
// Extract app ID from container name like "archipelago-bitcoin-core" const otherContainers = computed(() => {
return containerName.replace('archipelago-', '') const bundledIds = BUNDLED_APPS.map(a => a.id)
} return store.containers.filter(c => {
const name = c.name.toLowerCase()
return !bundledIds.some(id => name.includes(id))
})
})
const hasAnyApps = computed(() => BUNDLED_APPS.length > 0 || store.containers.length > 0)
function extractAppName(containerName: string): string { function extractAppName(containerName: string): string {
const appId = extractAppId(containerName) return containerName
// Convert kebab-case to Title Case .replace('archipelago-', '')
return appId .replace('-dev', '')
.split('-') .split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1)) .map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ') .join(' ')
} }
function formatDate(dateString: string): string { function getStatusBadgeClass(appId: string): string {
try { if (store.isAppLoading(appId)) {
const date = new Date(dateString) return 'bg-yellow-500/20 text-yellow-400'
return date.toLocaleDateString() }
} catch { const state = store.getAppState(appId)
return dateString switch (state) {
case 'running':
return 'bg-green-500/20 text-green-400'
case 'stopped':
case 'exited':
return 'bg-gray-500/20 text-gray-400'
case 'not-installed':
default:
return 'bg-blue-500/20 text-blue-400'
} }
} }
async function handleStart(appId: string) { function getStatusDotClass(appId: string): string {
const state = store.getAppState(appId)
switch (state) {
case 'running':
return 'bg-green-400'
case 'stopped':
case 'exited':
return 'bg-gray-400'
case 'not-installed':
default:
return 'bg-blue-400'
}
}
function getStatusText(appId: string): string {
if (store.isAppLoading(appId)) {
return 'Loading...'
}
const state = store.getAppState(appId)
switch (state) {
case 'running':
return 'Running'
case 'stopped':
case 'exited':
return 'Stopped'
case 'not-installed':
return 'Ready to Start'
default:
return state
}
}
function getLaunchUrl(app: BundledApp): string {
const port = app.ports[0]?.host
if (!port) return '#'
return `http://${currentHost.value}:${port}`
}
async function handleStartApp(app: BundledApp) {
try { try {
await store.startBundledApp(app)
} catch (e) {
console.error('Failed to start app:', e)
}
}
async function handleStopApp(appId: string) {
try {
await store.stopBundledApp(appId)
} catch (e) {
console.error('Failed to stop app:', e)
}
}
async function handleStartContainer(name: string) {
try {
const appId = name.replace('archipelago-', '').replace('-dev', '')
await store.startContainer(appId) await store.startContainer(appId)
} catch (e) { } catch (e) {
console.error('Failed to start container:', e) console.error('Failed to start container:', e)
} }
} }
async function handleStop(appId: string) { async function handleStopContainer(name: string) {
try { try {
const appId = name.replace('archipelago-', '').replace('-dev', '')
await store.stopContainer(appId) await store.stopContainer(appId)
} catch (e) { } catch (e) {
console.error('Failed to stop container:', e) console.error('Failed to stop container:', e)
} }
} }
async function handleRemove(appId: string) {
if (!confirm(`Are you sure you want to remove ${appId}? This will delete the container.`)) {
return
}
try {
await store.removeContainer(appId)
} catch (e) {
console.error('Failed to remove container:', e)
}
}
</script> </script>

View File

@ -154,8 +154,8 @@ const isSetupMode = computed(() => {
onMounted(async () => { onMounted(async () => {
if (isSetupMode.value) { if (isSetupMode.value) {
try { try {
const result = await rpcClient.call({ method: 'auth.isSetup', params: {} }) const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {} })
isSetup.value = result?.result || false isSetup.value = Boolean(result)
} catch (err) { } catch (err) {
console.error('Failed to check setup status:', err) console.error('Failed to check setup status:', err)
// Assume not set up if check fails // Assume not set up if check fails