Initial commit
This commit is contained in:
103
neode-ui/src/api/container-client.ts
Normal file
103
neode-ui/src/api/container-client.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// Container management API client
|
||||
// Extends RPC client with container-specific methods
|
||||
|
||||
import { rpcClient } from './rpc-client'
|
||||
|
||||
export interface ContainerStatus {
|
||||
id: string
|
||||
name: string
|
||||
state: 'created' | 'running' | 'stopped' | 'exited' | 'paused' | 'unknown'
|
||||
image: string
|
||||
created: string
|
||||
ports: string[]
|
||||
}
|
||||
|
||||
export interface ContainerAppInfo {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
status: ContainerStatus
|
||||
health: 'healthy' | 'unhealthy' | 'unknown' | 'starting'
|
||||
}
|
||||
|
||||
export const containerClient = {
|
||||
/**
|
||||
* Install a container app from a manifest file
|
||||
*/
|
||||
async installApp(manifestPath: string): Promise<string> {
|
||||
return rpcClient.call<string>({
|
||||
method: 'container-install',
|
||||
params: { manifest_path: manifestPath },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a container
|
||||
*/
|
||||
async startContainer(appId: string): Promise<void> {
|
||||
return rpcClient.call<void>({
|
||||
method: 'container-start',
|
||||
params: { app_id: appId },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop a container
|
||||
*/
|
||||
async stopContainer(appId: string): Promise<void> {
|
||||
return rpcClient.call<void>({
|
||||
method: 'container-stop',
|
||||
params: { app_id: appId },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a container
|
||||
*/
|
||||
async removeContainer(appId: string): Promise<void> {
|
||||
return rpcClient.call<void>({
|
||||
method: 'container-remove',
|
||||
params: { app_id: appId },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Get container status
|
||||
*/
|
||||
async getContainerStatus(appId: string): Promise<ContainerStatus> {
|
||||
return rpcClient.call<ContainerStatus>({
|
||||
method: 'container-status',
|
||||
params: { app_id: appId },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Get container logs
|
||||
*/
|
||||
async getContainerLogs(appId: string, lines: number = 100): Promise<string[]> {
|
||||
return rpcClient.call<string[]>({
|
||||
method: 'container-logs',
|
||||
params: { app_id: appId, lines },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* List all containers
|
||||
*/
|
||||
async listContainers(): Promise<ContainerStatus[]> {
|
||||
return rpcClient.call<ContainerStatus[]>({
|
||||
method: 'container-list',
|
||||
params: {},
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Get health status for all containers
|
||||
*/
|
||||
async getHealthStatus(): Promise<Record<string, 'healthy' | 'unhealthy' | 'unknown' | 'starting'>> {
|
||||
return rpcClient.call<Record<string, string>>({
|
||||
method: 'container-health',
|
||||
params: {},
|
||||
})
|
||||
},
|
||||
}
|
||||
116
neode-ui/src/components/ContainerStatus.vue
Normal file
116
neode-ui/src/components/ContainerStatus.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Status Indicator -->
|
||||
<div class="relative">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full transition-colors"
|
||||
:class="statusClass"
|
||||
></div>
|
||||
<div
|
||||
v-if="isRunning"
|
||||
class="absolute inset-0 w-3 h-3 rounded-full animate-ping opacity-75"
|
||||
:class="statusClass"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Status Text -->
|
||||
<span class="text-sm font-medium" :class="textClass">
|
||||
{{ statusText }}
|
||||
</span>
|
||||
|
||||
<!-- Health Badge (if running) -->
|
||||
<span
|
||||
v-if="isRunning && health"
|
||||
class="px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="healthClass"
|
||||
>
|
||||
{{ healthText }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
state: 'created' | 'running' | 'stopped' | 'exited' | 'paused' | 'unknown'
|
||||
health?: 'healthy' | 'unhealthy' | 'unknown' | 'starting'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
health: 'unknown',
|
||||
})
|
||||
|
||||
const isRunning = computed(() => props.state === 'running')
|
||||
|
||||
const statusClass = computed(() => {
|
||||
switch (props.state) {
|
||||
case 'running':
|
||||
return 'bg-green-400'
|
||||
case 'stopped':
|
||||
case 'exited':
|
||||
return 'bg-gray-400'
|
||||
case 'paused':
|
||||
return 'bg-yellow-400'
|
||||
default:
|
||||
return 'bg-red-400'
|
||||
}
|
||||
})
|
||||
|
||||
const textClass = computed(() => {
|
||||
switch (props.state) {
|
||||
case 'running':
|
||||
return 'text-green-400'
|
||||
case 'stopped':
|
||||
case 'exited':
|
||||
return 'text-gray-400'
|
||||
case 'paused':
|
||||
return 'text-yellow-400'
|
||||
default:
|
||||
return 'text-red-400'
|
||||
}
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.state) {
|
||||
case 'running':
|
||||
return 'Running'
|
||||
case 'stopped':
|
||||
return 'Stopped'
|
||||
case 'exited':
|
||||
return 'Exited'
|
||||
case 'paused':
|
||||
return 'Paused'
|
||||
case 'created':
|
||||
return 'Created'
|
||||
default:
|
||||
return 'Unknown'
|
||||
}
|
||||
})
|
||||
|
||||
const healthClass = computed(() => {
|
||||
switch (props.health) {
|
||||
case 'healthy':
|
||||
return 'bg-green-500/20 text-green-400 border border-green-500/30'
|
||||
case 'unhealthy':
|
||||
return 'bg-red-500/20 text-red-400 border border-red-500/30'
|
||||
case 'starting':
|
||||
return 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30'
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-400 border border-gray-500/30'
|
||||
}
|
||||
})
|
||||
|
||||
const healthText = computed(() => {
|
||||
switch (props.health) {
|
||||
case 'healthy':
|
||||
return 'Healthy'
|
||||
case 'unhealthy':
|
||||
return 'Unhealthy'
|
||||
case 'starting':
|
||||
return 'Starting'
|
||||
default:
|
||||
return 'Unknown'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
139
neode-ui/src/stores/container.ts
Normal file
139
neode-ui/src/stores/container.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// Pinia store for container management
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { containerClient, type ContainerStatus, type ContainerAppInfo } from '@/api/container-client'
|
||||
|
||||
export const useContainerStore = defineStore('container', () => {
|
||||
// State
|
||||
const containers = ref<ContainerStatus[]>([])
|
||||
const healthStatus = ref<Record<string, string>>({})
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters
|
||||
const runningContainers = computed(() =>
|
||||
containers.value.filter(c => c.state === 'running')
|
||||
)
|
||||
|
||||
const stoppedContainers = computed(() =>
|
||||
containers.value.filter(c => c.state === 'stopped' || c.state === 'exited')
|
||||
)
|
||||
|
||||
const getContainerById = computed(() => (id: string) =>
|
||||
containers.value.find(c => c.name.includes(id))
|
||||
)
|
||||
|
||||
const getHealthStatus = computed(() => (appId: string) =>
|
||||
healthStatus.value[appId] || 'unknown'
|
||||
)
|
||||
|
||||
// Actions
|
||||
async function fetchContainers() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
containers.value = await containerClient.listContainers()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to fetch containers'
|
||||
console.error('Failed to fetch containers:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHealthStatus() {
|
||||
try {
|
||||
healthStatus.value = await containerClient.getHealthStatus()
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch health status:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function installApp(manifestPath: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const containerName = await containerClient.installApp(manifestPath)
|
||||
await fetchContainers()
|
||||
return containerName
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to install app'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startContainer(appId: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await containerClient.startContainer(appId)
|
||||
await fetchContainers()
|
||||
await fetchHealthStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to start container'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stopContainer(appId: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await containerClient.stopContainer(appId)
|
||||
await fetchContainers()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to stop container'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeContainer(appId: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await containerClient.removeContainer(appId)
|
||||
await fetchContainers()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to remove container'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getContainerLogs(appId: string, lines: number = 100) {
|
||||
try {
|
||||
return await containerClient.getContainerLogs(appId, lines)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to get logs'
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
containers,
|
||||
healthStatus,
|
||||
loading,
|
||||
error,
|
||||
// Getters
|
||||
runningContainers,
|
||||
stoppedContainers,
|
||||
getContainerById,
|
||||
getHealthStatus,
|
||||
// Actions
|
||||
fetchContainers,
|
||||
fetchHealthStatus,
|
||||
installApp,
|
||||
startContainer,
|
||||
stopContainer,
|
||||
removeContainer,
|
||||
getContainerLogs,
|
||||
}
|
||||
})
|
||||
260
neode-ui/src/views/ContainerAppDetails.vue
Normal file
260
neode-ui/src/views/ContainerAppDetails.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<button
|
||||
@click="$router.back()"
|
||||
class="mb-4 flex items-center gap-2 text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ appName }}</h1>
|
||||
<p class="text-white/70">Container details and management</p>
|
||||
</div>
|
||||
<ContainerStatus
|
||||
v-if="container"
|
||||
:state="container.state as any"
|
||||
:health="healthStatus as any"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" 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>
|
||||
|
||||
<div v-else-if="error" class="glass-card p-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" />
|
||||
</svg>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="container" class="space-y-6">
|
||||
<!-- Container Info Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Container Information</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span class="text-sm text-white/60">Container ID</span>
|
||||
<p class="text-white/90 font-mono text-sm mt-1">{{ container.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-white/60">Image</span>
|
||||
<p class="text-white/90 text-sm mt-1">{{ container.image }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-white/60">State</span>
|
||||
<p class="text-white/90 text-sm mt-1 capitalize">{{ container.state }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-white/60">Created</span>
|
||||
<p class="text-white/90 text-sm mt-1">{{ formatDate(container.created) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Actions</h2>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
v-if="container.state !== 'running'"
|
||||
@click="handleStart"
|
||||
:disabled="actionLoading"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
Start Container
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="handleStop"
|
||||
:disabled="actionLoading"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
Stop Container
|
||||
</button>
|
||||
<button
|
||||
@click="handleRestart"
|
||||
:disabled="actionLoading || container.state !== 'running'"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
@click="handleRemove"
|
||||
:disabled="actionLoading"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-red-400/90 hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs Card -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-white">Logs</h2>
|
||||
<button
|
||||
@click="refreshLogs"
|
||||
:disabled="logsLoading"
|
||||
class="px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-black/40 rounded-lg p-4 font-mono text-sm text-white/80 max-h-96 overflow-y-auto">
|
||||
<div v-if="logsLoading" class="text-center py-4 text-white/60">
|
||||
Loading logs...
|
||||
</div>
|
||||
<div v-else-if="logs.length === 0" class="text-center py-4 text-white/60">
|
||||
No logs available
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="(log, index) in logs" :key="index" class="mb-1">
|
||||
{{ log }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useContainerStore } from '@/stores/container'
|
||||
import ContainerStatus from '@/components/ContainerStatus.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useContainerStore()
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
const appName = computed(() => {
|
||||
return appId.value
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
const container = ref<any>(null)
|
||||
const logs = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const logsLoading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const healthStatus = ref<string>('unknown')
|
||||
|
||||
onMounted(async () => {
|
||||
await loadContainer()
|
||||
await loadLogs()
|
||||
await loadHealthStatus()
|
||||
})
|
||||
|
||||
async function loadContainer() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const status = await store.getContainerStatus(appId.value)
|
||||
container.value = status
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load container'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
logsLoading.value = true
|
||||
try {
|
||||
logs.value = await store.getContainerLogs(appId.value, 100)
|
||||
} catch (e) {
|
||||
console.error('Failed to load logs:', e)
|
||||
} finally {
|
||||
logsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHealthStatus() {
|
||||
await store.fetchHealthStatus()
|
||||
healthStatus.value = store.getHealthStatus(appId.value)
|
||||
}
|
||||
|
||||
async function refreshLogs() {
|
||||
await loadLogs()
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await store.startContainer(appId.value)
|
||||
await loadContainer()
|
||||
await loadHealthStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to start container'
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await store.stopContainer(appId.value)
|
||||
await loadContainer()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to stop container'
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestart() {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await store.stopContainer(appId.value)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
await store.startContainer(appId.value)
|
||||
await loadContainer()
|
||||
await loadHealthStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to restart container'
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
if (!confirm(`Are you sure you want to remove ${appName.value}? This will delete the container and all its data.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await store.removeContainer(appId.value)
|
||||
router.push('/dashboard/containers')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to remove container'
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString()
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
</script>
|
||||
165
neode-ui/src/views/ContainerApps.vue
Normal file
165
neode-ui/src/views/ContainerApps.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="store.loading" 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 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" />
|
||||
</svg>
|
||||
<span>{{ store.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container List -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<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)}`)"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="container.state !== 'running'"
|
||||
@click.stop="handleStart(extractAppId(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))"
|
||||
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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useContainerStore } from '@/stores/container'
|
||||
import ContainerStatus from '@/components/ContainerStatus.vue'
|
||||
|
||||
const store = useContainerStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchContainers()
|
||||
await store.fetchHealthStatus()
|
||||
|
||||
// Refresh every 30 seconds
|
||||
setInterval(async () => {
|
||||
await store.fetchContainers()
|
||||
await store.fetchHealthStatus()
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
function extractAppId(containerName: string): string {
|
||||
// Extract app ID from container name like "archipelago-bitcoin-core"
|
||||
return containerName.replace('archipelago-', '')
|
||||
}
|
||||
|
||||
function extractAppName(containerName: string): string {
|
||||
const appId = extractAppId(containerName)
|
||||
// Convert kebab-case to Title Case
|
||||
return appId
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStart(appId: string) {
|
||||
try {
|
||||
await store.startContainer(appId)
|
||||
} catch (e) {
|
||||
console.error('Failed to start container:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop(appId: string) {
|
||||
try {
|
||||
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>
|
||||
Reference in New Issue
Block a user