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:
parent
66c823e2fd
commit
00d1af12f0
@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = '/'
|
||||
|
||||
|
||||
@ -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 },
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user