feat: Add backup page for ZIP downloads
This commit is contained in:
@@ -119,6 +119,12 @@ function toggleWsStats(event: Event) {
|
|||||||
<i class="pi pi-list-check"></i>
|
<i class="pi pi-list-check"></i>
|
||||||
Agent-Aufträge
|
Agent-Aufträge
|
||||||
</router-link>
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link to="/backup">
|
||||||
|
<i class="pi pi-download"></i>
|
||||||
|
Backup
|
||||||
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<router-link to="/cronjobs">
|
<router-link to="/cronjobs">
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ const router = createRouter({
|
|||||||
path: 'agent-tasks-overview',
|
path: 'agent-tasks-overview',
|
||||||
name: 'agent-tasks-overview',
|
name: 'agent-tasks-overview',
|
||||||
component: () => import('../views/AgentTasksOverview.vue')
|
component: () => import('../views/AgentTasksOverview.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'backup',
|
||||||
|
name: 'backup',
|
||||||
|
component: () => import('../views/BackupView.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'cronjobs',
|
path: 'cronjobs',
|
||||||
|
|||||||
332
src/views/BackupView.vue
Normal file
332
src/views/BackupView.vue
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
interface Repo {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
fullName: string
|
||||||
|
description: string | null
|
||||||
|
branch: string
|
||||||
|
url: string
|
||||||
|
updatedAt: string
|
||||||
|
size: number
|
||||||
|
downloadUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const repos = ref<Repo[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const downloading = ref<number | null>(null)
|
||||||
|
|
||||||
|
const totalSize = computed(() => {
|
||||||
|
return repos.value.reduce((sum, r) => sum + r.size, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatSize(kb: number): string {
|
||||||
|
if (kb < 1024) return `${kb} KB`
|
||||||
|
return `${(kb / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleString('de-DE', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRepos() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
repos.value = await api.get('/backup/repos')
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Repos konnten nicht geladen werden', life: 3000 })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadRepo(repo: Repo) {
|
||||||
|
downloading.value = repo.id
|
||||||
|
try {
|
||||||
|
const [owner, repoName] = repo.fullName.split('/')
|
||||||
|
const response = await fetch(
|
||||||
|
`${import.meta.env.VITE_API_URL || 'https://api.ams.kronos-soulution.de'}/api/backup/download/${owner}/${repoName}?branch=${repo.branch}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Download failed')
|
||||||
|
|
||||||
|
const blob = await response.blob()
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${repoName}-${repo.branch}.zip`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
document.body.removeChild(a)
|
||||||
|
|
||||||
|
toast.add({ severity: 'success', summary: 'Download', detail: `${repo.name} heruntergeladen`, life: 3000 })
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Download fehlgeschlagen', life: 3000 })
|
||||||
|
} finally {
|
||||||
|
downloading.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAll() {
|
||||||
|
for (const repo of repos.value) {
|
||||||
|
await downloadRepo(repo)
|
||||||
|
await new Promise(r => setTimeout(r, 500)) // kleine Pause zwischen Downloads
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadRepos)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="backup-view">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2><i class="pi pi-download"></i> Backup</h2>
|
||||||
|
<p class="subtitle">Code-Repositories als ZIP herunterladen</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="refresh-btn" @click="loadRepos" :disabled="loading">
|
||||||
|
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'"></i>
|
||||||
|
</button>
|
||||||
|
<button class="download-all-btn" @click="downloadAll" :disabled="loading || repos.length === 0">
|
||||||
|
<i class="pi pi-cloud-download"></i> Alle herunterladen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-bar">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">{{ repos.length }}</span>
|
||||||
|
<span class="stat-label">Repositories</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">{{ formatSize(totalSize) }}</span>
|
||||||
|
<span class="stat-label">Gesamt</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<i class="pi pi-spin pi-spinner"></i> Lade Repositories...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="repos.length === 0" class="empty-state">
|
||||||
|
<i class="pi pi-inbox"></i>
|
||||||
|
<p>Keine Repositories gefunden</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="repo-list">
|
||||||
|
<div v-for="repo in repos" :key="repo.id" class="repo-card">
|
||||||
|
<div class="repo-info">
|
||||||
|
<div class="repo-header">
|
||||||
|
<a :href="repo.url" target="_blank" class="repo-name">
|
||||||
|
<i class="pi pi-github"></i>
|
||||||
|
{{ repo.fullName }}
|
||||||
|
</a>
|
||||||
|
<span class="repo-branch">
|
||||||
|
<i class="pi pi-code-branch"></i> {{ repo.branch }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="repo.description" class="repo-desc">{{ repo.description }}</p>
|
||||||
|
<div class="repo-meta">
|
||||||
|
<span><i class="pi pi-database"></i> {{ formatSize(repo.size) }}</span>
|
||||||
|
<span><i class="pi pi-clock"></i> {{ formatDate(repo.updatedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="download-btn"
|
||||||
|
@click="downloadRepo(repo)"
|
||||||
|
:disabled="downloading === repo.id"
|
||||||
|
>
|
||||||
|
<i :class="downloading === repo.id ? 'pi pi-spin pi-spinner' : 'pi pi-download'"></i>
|
||||||
|
{{ downloading === repo.id ? 'Lädt...' : 'ZIP' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backup-view {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border: 1px solid rgba(255,255,255,0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn:hover { background: rgba(255,255,255,0.12); color: #fff; }
|
||||||
|
|
||||||
|
.download-all-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-all-btn:hover:not(:disabled) { opacity: 0.9; }
|
||||||
|
.download-all-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.stats-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat { text-align: center; }
|
||||||
|
.stat-value { display: block; font-size: 1.5rem; font-weight: 700; color: #fff; }
|
||||||
|
.stat-label { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
|
||||||
|
|
||||||
|
.loading-state, .empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem;
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i { font-size: 3rem; margin-bottom: 1rem; display: block; }
|
||||||
|
|
||||||
|
.repo-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-card:hover {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border-color: rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-info { flex: 1; }
|
||||||
|
|
||||||
|
.repo-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-name {
|
||||||
|
color: #a5b4fc;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-name:hover { color: #c7d2fe; text-decoration: underline; }
|
||||||
|
|
||||||
|
.repo-branch {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255,255,255,0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-desc {
|
||||||
|
margin: 0.25rem 0 0.5rem;
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
color: rgba(255,255,255,0.4);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-meta span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #4ade80;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(34, 197, 94, 0.25);
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user