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.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 }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 = '/'
|
||||||
|
|
||||||
|
|||||||
@ -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 },
|
||||||
|
})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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') {
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user