admin_panel/frontend/src/pages/admin/AppDetail.vue

512 lines
17 KiB
Vue

<template>
<div class="flex flex-col h-full">
<LayoutHeader>
<template #left>
<div class="flex items-center gap-3">
<Button variant="ghost" @click="$router.push({ name: 'Apps' })">
<FeatherIcon name="arrow-left" class="h-4 w-4" />
</Button>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center">
<FeatherIcon name="package" class="h-5 w-5 text-blue-600" />
</div>
<div>
<h1 class="text-xl font-semibold text-ink-gray-9">
{{ app?.name || route.params.appId }}
</h1>
<p class="text-sm text-ink-gray-5">
{{ app?.currentVersion || '-' }}
</p>
</div>
</div>
</div>
</template>
<template #right>
<Button variant="subtle" @click="refreshData" :loading="appDetail.loading">
<template #prefix>
<FeatherIcon name="refresh-cw" class="h-4 w-4" />
</template>
Refresh
</Button>
<Button variant="solid" @click="showBuildModal = true">
<template #prefix>
<FeatherIcon name="play" class="h-4 w-4" />
</template>
Build Image
</Button>
</template>
</LayoutHeader>
<div class="flex-1 overflow-auto">
<!-- Loading State -->
<div v-if="appDetail.loading" class="flex items-center justify-center h-64">
<Spinner class="h-8 w-8" />
</div>
<template v-else>
<!-- Tabs -->
<div class="border-b px-5">
<nav class="flex gap-2">
<button
v-for="tab in tabs"
:key="tab.id"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="
activeTab === tab.id
? 'border-b-2 border-ink-gray-9 text-ink-gray-9'
: 'text-ink-gray-5 hover:text-ink-gray-7'
"
@click="activeTab = tab.id"
>
{{ tab.label }}
</button>
</nav>
</div>
<div class="p-5">
<!-- Overview Tab -->
<div v-if="activeTab === 'overview'" class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label="Versions"
:value="app?.versions?.length || 0"
iconName="tag"
color="blue"
/>
<StatCard
label="Latest Version"
:value="app?.currentVersion || '-'"
iconName="git-branch"
color="green"
/>
<StatCard
label="Size"
:value="app?.size || '-'"
iconName="hard-drive"
color="purple"
/>
<StatCard
label="Status"
:value="app?.status || '-'"
iconName="activity"
color="gray"
/>
</div>
<div class="rounded-lg border p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-medium text-ink-gray-9">
Description
</h3>
<Button variant="subtle" size="sm" @click="showEditDescModal = true">
<template #prefix>
<FeatherIcon name="edit-2" class="h-4 w-4" />
</template>
Edit
</Button>
</div>
<p class="text-sm text-ink-gray-5">
{{ app?.description || 'No description available' }}
</p>
</div>
</div>
<!-- Versions Tab -->
<div v-else-if="activeTab === 'versions'">
<div v-if="app?.versions?.length" class="rounded-lg border overflow-hidden">
<table class="min-w-full divide-y">
<thead class="bg-surface-gray-2">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Version</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Alias</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Fingerprint</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Created</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Size</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y">
<tr v-for="version in app.versions" :key="version.version">
<td class="px-6 py-4">
<span class="text-sm font-medium text-ink-gray-9">{{ version.version }}</span>
</td>
<td class="px-6 py-4 text-sm text-ink-gray-7 font-mono">{{ version.alias }}</td>
<td class="px-6 py-4 text-sm text-ink-gray-7 font-mono">{{ version.fingerprint }}</td>
<td class="px-6 py-4 text-sm text-ink-gray-7">{{ version.released }}</td>
<td class="px-6 py-4 text-sm text-ink-gray-7">{{ version.size }}</td>
<td class="px-6 py-4">
<Badge
:variant="{ current: 'success', active: 'info', deprecated: 'warning' }[version.status] || 'subtle'"
:label="version.status"
/>
</td>
<td class="px-6 py-4">
<Button
variant="ghost"
size="sm"
@click="deleteVersion(version)"
:loading="deletingVersion === version.fingerprint"
>
<FeatherIcon name="trash-2" class="h-4 w-4 text-red-500" />
</Button>
</td>
</tr>
</tbody>
</table>
</div>
<EmptyState
v-else
icon="tag"
title="No versions"
description="Build your first image version using the Build Image button."
/>
</div>
<!-- Configuration Tab -->
<div v-else-if="activeTab === 'configuration'">
<div class="rounded-lg border overflow-hidden">
<div class="flex items-center justify-between p-4 border-b">
<h3 class="text-base font-medium text-ink-gray-9">
Distrobuilder Configuration
</h3>
<div class="flex gap-2">
<Button
v-if="!editConfig"
variant="subtle"
size="sm"
@click="startEditing"
>
<template #prefix>
<FeatherIcon name="edit-2" class="h-4 w-4" />
</template>
Edit
</Button>
<template v-else>
<Button variant="subtle" size="sm" @click="cancelEditing">
Cancel
</Button>
<Button
variant="solid"
size="sm"
@click="saveConfig"
:loading="savingConfig"
>
Save
</Button>
</template>
</div>
</div>
<div class="p-4">
<pre
v-if="!editConfig"
class="bg-gray-900 text-gray-100 p-4 rounded-lg text-sm overflow-x-auto font-mono max-h-[600px] overflow-y-auto"
>{{ app?.yaml_content || '# No configuration available' }}</pre>
<textarea
v-else
v-model="configContent"
class="w-full h-[600px] bg-gray-900 text-gray-100 p-4 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
></textarea>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- Build Image Modal -->
<Dialog v-model="showBuildModal" :options="{ title: 'Build Image' }">
<template #body-content>
<div class="space-y-4">
<FormControl
v-model="buildVersion"
label="Version Tag"
type="text"
placeholder="v1.0.0"
:description="'Leave empty for unversioned build'"
/>
<FormControl
v-model="buildRelease"
label="Release"
type="text"
placeholder="edge"
description="Distribution release (e.g. edge, 3.21, 3.23, 24.04)"
/>
<FormControl
v-model="targetServer"
label="Target Server"
type="select"
:options="targetServerOptions"
description="Select the image server where the built image will be imported"
/>
<FormControl
v-if="!buildResult"
v-model="doasPassword"
label="Password (doas)"
type="password"
placeholder="Required for distrobuilder"
/>
<div v-if="buildResult" class="mt-4">
<label class="block text-sm font-medium text-ink-gray-7 mb-2">Build Output</label>
<div class="bg-gray-900 text-gray-100 p-4 rounded-lg text-xs font-mono max-h-64 overflow-y-auto">
<div
v-for="(log, index) in buildResult.logs"
:key="index"
:class="{
'text-green-400': log.includes('✓'),
'text-red-400': log.includes('✗') || log.includes('Error'),
'text-yellow-400': log.includes('==='),
}"
>
{{ log }}
</div>
</div>
</div>
</div>
</template>
<template #actions>
<Button variant="subtle" @click="closeBuildModal">
{{ buildResult ? 'Close' : $t('common.cancel') }}
</Button>
<Button
v-if="!buildResult"
variant="solid"
@click="buildImage"
:loading="building"
:disabled="!doasPassword"
>
<template #prefix>
<FeatherIcon name="play" class="h-4 w-4" />
</template>
Start Build
</Button>
</template>
</Dialog>
<!-- Edit Description Modal -->
<Dialog v-model="showEditDescModal" :options="{ title: 'Edit Description' }">
<template #body-content>
<FormControl
v-model="editDescription"
label="Description"
type="textarea"
placeholder="Describe this app template..."
/>
</template>
<template #actions>
<Button variant="subtle" @click="showEditDescModal = false">
{{ $t('common.cancel') }}
</Button>
<Button variant="solid" @click="saveDescription" :loading="savingDesc">
Save
</Button>
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { createResource, call, Button, Badge, FormControl, Dialog, Spinner, FeatherIcon } from 'frappe-ui'
import LayoutHeader from '@/components/Common/LayoutHeader.vue'
import StatCard from '@/components/Common/StatCard.vue'
import EmptyState from '@/components/Common/EmptyState.vue'
const route = useRoute()
const activeTab = ref('overview')
const editConfig = ref(false)
const configContent = ref('')
const savingConfig = ref(false)
const showBuildModal = ref(false)
const buildVersion = ref('')
const buildRelease = ref('edge')
const targetServer = ref('')
const doasPassword = ref('')
const building = ref(false)
const buildResult = ref(null)
const showEditDescModal = ref(false)
const editDescription = ref('')
const savingDesc = ref(false)
const deletingVersion = ref(null)
const tabs = [
{ id: 'overview', label: 'Overview' },
{ id: 'versions', label: 'Versions' },
{ id: 'configuration', label: 'Configuration' },
]
const appDetail = createResource({
url: 'admin_panel.api.apps.get_app_detail',
params: { app_name: route.params.appId },
auto: true,
})
const imageServers = createResource({
url: 'admin_panel.api.servers.get_image_servers',
auto: true,
})
const app = computed(() => appDetail.data)
const imageServersList = computed(() => imageServers.data || [])
const targetServerOptions = computed(() => {
const servers = imageServersList.value
return servers.map(s => ({
label: `${s.name}${s.status === 'online' ? '' : ' (' + s.status + ')'}`,
value: s.name,
}))
})
// Auto-select first image server
watch(imageServersList, (servers) => {
if (servers.length && !targetServer.value) {
targetServer.value = servers[0].name
}
}, { immediate: true })
watch(() => app.value?.description, (newDesc) => {
editDescription.value = newDesc || ''
})
function refreshData() {
appDetail.reload()
}
function startEditing() {
configContent.value = app.value?.yaml_content || ''
editConfig.value = true
}
function cancelEditing() {
editConfig.value = false
configContent.value = ''
}
async function saveConfig() {
savingConfig.value = true
try {
const result = await call('admin_panel.api.apps.save_app_config', {
app_name: route.params.appId,
yaml_content: configContent.value,
})
if (result.success) {
console.log(result.message)
editConfig.value = false
appDetail.reload()
} else {
console.error(result.error || 'Failed to save configuration')
}
} catch (error) {
console.error(error.message || 'Failed to save configuration')
} finally {
savingConfig.value = false
}
}
async function buildImage() {
building.value = true
buildResult.value = null
try {
const result = await call('admin_panel.api.apps.build_image', {
app_name: route.params.appId,
version: buildVersion.value,
target_server: targetServer.value,
release: buildRelease.value || 'edge',
doas_password: doasPassword.value,
})
buildResult.value = result
if (result.success) {
console.log(result.message)
appDetail.reload()
} else {
console.error(result.error || 'Build failed')
}
} catch (error) {
buildResult.value = {
success: false,
error: error.message,
logs: [`Error: ${error.message}`],
}
console.error(error.message || 'Build failed')
} finally {
building.value = false
}
}
function closeBuildModal() {
showBuildModal.value = false
buildResult.value = null
buildVersion.value = ''
buildRelease.value = 'edge'
doasPassword.value = ''
targetServer.value = imageServersList.value.length ? imageServersList.value[0].name : ''
}
async function saveDescription() {
savingDesc.value = true
try {
const result = await call('admin_panel.api.apps.update_app_meta', {
app_name: route.params.appId,
description: editDescription.value,
})
if (result.success) {
console.log(result.message)
showEditDescModal.value = false
appDetail.reload()
} else {
console.error(result.error || 'Failed to update description')
}
} catch (error) {
console.error(error.message || 'Failed to update description')
} finally {
savingDesc.value = false
}
}
async function deleteVersion(version) {
if (!version.fingerprint || version.fingerprint === '-') {
console.error('Cannot delete: invalid fingerprint')
return
}
// Get full fingerprint from versions API
deletingVersion.value = version.fingerprint
try {
// First get the full fingerprint
const versions = await call('admin_panel.api.apps.get_app_versions', {
app_name: route.params.appId,
})
const fullVersion = versions.find(v => v.fingerprint.startsWith(version.fingerprint))
if (!fullVersion) {
console.error('Version not found')
return
}
const result = await call('admin_panel.api.apps.delete_image', {
fingerprint: fullVersion.fingerprint,
})
if (result.success) {
console.log('Image deleted successfully')
appDetail.reload()
} else {
console.error(result.error || 'Failed to delete image')
}
} catch (error) {
console.error(error.message || 'Failed to delete image')
} finally {
deletingVersion.value = null
}
}
</script>