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.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<serde_json::Value> {
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::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(
@@ -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<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
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 = '/'

View File

@@ -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<Record<string, 'healthy' | 'unhealthy' | 'unknown' | 'starting'>> {
async getHealthStatus(): Promise<Record<string, string>> {
return rpcClient.call<Record<string, string>>({
method: 'container-health',
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 url: string
private reconnectTimer: ReturnType<typeof setTimeout> | 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

View File

@@ -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') {

View File

@@ -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<ContainerStatus[]>([])
const healthStatus = ref<Record<string, string>>({})
const loading = ref(false)
const loadingApps = ref<Set<string>>(new Set()) // Track loading state per app
const error = ref<string | null>(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,
}
})

View File

@@ -1,17 +1,17 @@
<template>
<div class="p-6">
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Container Apps</h1>
<p class="text-white/70">Manage containerized applications running on your Archipelago node</p>
<h1 class="text-3xl font-bold text-white mb-2">My Apps</h1>
<p class="text-white/70">Manage your Archipelago applications</p>
</div>
<!-- Loading State -->
<div v-if="store.loading" class="flex items-center justify-center py-12">
<!-- Loading State (initial load) -->
<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>
<!-- 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">
<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" />
@@ -20,146 +20,295 @@
</div>
</div>
<!-- Container List -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Apps Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Bundled Apps -->
<div
v-for="container in store.containers"
:key="container.id"
class="glass-card p-6 hover:bg-white/5 transition-colors cursor-pointer"
@click="$router.push(`/dashboard/containers/${extractAppId(container.name)}`)"
v-for="app in BUNDLED_APPS"
:key="app.id"
class="glass-card p-6 hover:bg-white/5 transition-colors"
>
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-lg font-semibold text-white mb-1">
{{ extractAppName(container.name) }}
</h3>
<p class="text-sm text-white/60">{{ container.image }}</p>
<div class="flex items-center gap-3">
<span class="text-3xl">{{ app.icon }}</span>
<div>
<h3 class="text-lg font-semibold text-white">{{ app.name }}</h3>
<p class="text-sm text-white/60">{{ app.description }}</p>
</div>
</div>
<ContainerStatus
:state="container.state as any"
:health="store.getHealthStatus(extractAppId(container.name)) as any"
/>
</div>
<div class="space-y-2 mb-4">
<div class="flex items-center justify-between text-sm">
<span class="text-white/60">Container ID</span>
<span class="text-white/80 font-mono text-xs">{{ container.id.substring(0, 12) }}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-white/60">Created</span>
<span class="text-white/80 text-xs">{{ formatDate(container.created) }}</span>
<!-- Status Badge -->
<div class="mb-4">
<span
:class="getStatusBadgeClass(app.id)"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium"
>
<!-- Loading spinner -->
<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 class="mb-4">
<ContainerStatus :state="container.state as any" />
</div>
<div class="flex gap-2">
<button
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"
>
Start
</button>
<button
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"
>
Stop
</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>
<!-- Empty State -->
<div v-if="store.containers.length === 0" class="col-span-full 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">
<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>
<h3 class="text-xl font-semibold text-white mb-2">No containers found</h3>
<p class="text-white/60 mb-6">Install your first container app to get started</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>
<!-- Empty state (when no bundled apps - shouldn't happen) -->
<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">
<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>
<h3 class="text-xl font-semibold text-white mb-2">No apps available</h3>
<p class="text-white/60">Check your Archipelago installation</p>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useContainerStore } from '@/stores/container'
import { onMounted, computed } from 'vue'
import { useContainerStore, BUNDLED_APPS, type BundledApp } from '@/stores/container'
import ContainerStatus from '@/components/ContainerStatus.vue'
const store = useContainerStore()
// Get current host for launch URLs
const currentHost = computed(() => window.location.hostname)
onMounted(async () => {
await store.fetchContainers()
await store.fetchHealthStatus()
// Refresh every 30 seconds
// Refresh every 10 seconds
setInterval(async () => {
await store.fetchContainers()
await store.fetchHealthStatus()
}, 30000)
}, 10000)
})
function extractAppId(containerName: string): string {
// Extract app ID from container name like "archipelago-bitcoin-core"
return containerName.replace('archipelago-', '')
}
// Containers that aren't bundled apps
const otherContainers = computed(() => {
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 {
const appId = extractAppId(containerName)
// Convert kebab-case to Title Case
return appId
return containerName
.replace('archipelago-', '')
.replace('-dev', '')
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
function formatDate(dateString: string): string {
try {
const date = new Date(dateString)
return date.toLocaleDateString()
} catch {
return dateString
function getStatusBadgeClass(appId: string): string {
if (store.isAppLoading(appId)) {
return 'bg-yellow-500/20 text-yellow-400'
}
const state = store.getAppState(appId)
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 {
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)
} catch (e) {
console.error('Failed to start container:', e)
}
}
async function handleStop(appId: string) {
async function handleStopContainer(name: string) {
try {
const appId = name.replace('archipelago-', '').replace('-dev', '')
await store.stopContainer(appId)
} catch (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>

View File

@@ -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<boolean>({ 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