512 lines
17 KiB
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>
|