Initial pve cloud backup app

This commit is contained in:
Codex
2026-07-15 01:00:08 +02:00
commit 7a4c561f8e
37 changed files with 5495 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PVE Cloud Backup</title>
</head>
<body class="bg-slate-950 text-slate-100">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2267
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "pve-cloud-backup-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "vue-tsc --noEmit && vite build --outDir ../static --emptyOutDir",
"dev": "vite"
},
"dependencies": {
"vue": "^3.5.17"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.4",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vite": "^5.4.19",
"vue-tsc": "^3.0.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+688
View File
@@ -0,0 +1,688 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
type Settings = {
setup_complete: boolean
proxmox_node: string
proxmox_storage: string
local_backup_dir: string
rclone_path: string
rclone_remote: string
rclone_remote_path: string
discord_webhook_url: string
allowed_cors_origins: string[]
default_compression: string
default_backup_mode: string
default_retention_type: string
default_retention_value: number
max_concurrent_backups: number
timezone: string
}
type Guest = { vmid: number; name: string; type: string; node: string; status: string }
type Job = {
id: number
guest_vmid: number
guest_name: string
guest_type: string
node: string
enabled: number | boolean
cron_schedule: string
proxmox_storage: string
backup_mode: string
compression: string
retention_type: string
retention_value: number
next_run_at?: string
}
type Backup = {
id: string
job_id?: number
guest_vmid: number
guest_name: string
guest_type: string
node: string
state: string
upid?: string
local_path?: string
remote_path?: string
size_bytes?: number
error_message?: string
started_at: string
pve_completed_at?: string
upload_started_at?: string
completed_at?: string
deleted_at?: string
updated_at?: string
}
const emptySettings: Settings = {
setup_complete: false,
proxmox_node: '',
proxmox_storage: '',
local_backup_dir: '/var/lib/vz/dump',
rclone_path: '/usr/bin/rclone',
rclone_remote: '',
rclone_remote_path: 'pve-cloud-backup',
discord_webhook_url: '',
allowed_cors_origins: ['http://localhost:5173', 'http://127.0.0.1:5173'],
default_compression: 'zstd',
default_backup_mode: 'snapshot',
default_retention_type: 'days',
default_retention_value: 30,
max_concurrent_backups: 1,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
}
const page = ref('Dashboard')
const loading = ref(false)
const error = ref('')
const actionMessage = ref('')
const settings = reactive<Settings>({ ...emptySettings })
const corsText = ref('')
const discover = reactive({ proxmox_node: '', rclone_path: '', rclone_remotes: [] as string[], proxmox_storages: [] as string[] })
const guests = ref<Guest[]>([])
const jobs = ref<Job[]>([])
const backups = ref<Backup[]>([])
const dashboard = ref<any>({})
const selectedBackup = ref<Backup | null>(null)
const deletingJobId = ref<number | null>(null)
const deletingBackupId = ref<string | null>(null)
const nowMs = ref(Date.now())
let clockTimer: number | undefined
const filters = reactive({ guest_vmid: '', state: '' })
const newJob = reactive({
guest: '',
cron_schedule: '0 2 * * *',
enabled: true,
retention_type: 'days',
retention_value: 30,
})
const editingJobId = ref<number | null>(null)
const editJobForm = reactive({
enabled: true,
cron_schedule: '',
proxmox_storage: '',
backup_mode: '',
compression: '',
retention_type: 'days',
retention_value: 1,
})
const pages = computed(() => settings.setup_complete ? ['Dashboard', 'Guests', 'Backup jobs', 'Backup history', 'Backup details', 'Settings'] : ['Setup wizard'])
const timezoneOptions = computed(() => {
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
return Array.from(new Set([settings.timezone, browserTimezone, 'Europe/Berlin', 'UTC', 'America/New_York', 'America/Los_Angeles', 'Asia/Tokyo', 'Australia/Sydney'].filter(Boolean)))
})
const jobCountsByGuest = computed(() => {
const counts = new Map<number, { total: number; enabled: number }>()
for (const job of jobs.value) {
const existing = counts.get(job.guest_vmid) || { total: 0, enabled: 0 }
existing.total += 1
if (Boolean(job.enabled)) existing.enabled += 1
counts.set(job.guest_vmid, existing)
}
return counts
})
async function api<T>(url: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers)
headers.set('Content-Type', 'application/json')
const res = await fetch(url, {
...options,
headers,
})
if (!res.ok) {
const body = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(body.detail || res.statusText)
}
return res.json()
}
function applySettings(data: Settings) {
Object.assign(settings, data)
corsText.value = data.allowed_cors_origins.join('\n')
newJob.retention_type = data.default_retention_type
newJob.retention_value = data.default_retention_value
}
async function loadSettings() {
applySettings(await api<Settings>('/api/settings'))
page.value = settings.setup_complete ? 'Dashboard' : 'Setup wizard'
}
async function loadDiscover() {
Object.assign(discover, await api('/api/discover'))
if (!settings.proxmox_node) settings.proxmox_node = discover.proxmox_node
if (!settings.rclone_path) settings.rclone_path = discover.rclone_path
if (!settings.rclone_remote && discover.rclone_remotes.length) settings.rclone_remote = discover.rclone_remotes[0]
if (!settings.proxmox_storage && discover.proxmox_storages.length) settings.proxmox_storage = discover.proxmox_storages[0]
}
async function saveSettings(complete = settings.setup_complete) {
loading.value = true
error.value = ''
actionMessage.value = ''
try {
const payload = { ...settings, setup_complete: complete, allowed_cors_origins: corsText.value.split(/\n|,/).map(s => s.trim()).filter(Boolean) }
applySettings(await api<Settings>('/api/settings', { method: 'PUT', body: JSON.stringify(payload) }))
if (settings.setup_complete) page.value = 'Dashboard'
await refresh()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function refresh() {
if (!settings.setup_complete) return
const loaders: Promise<any>[] = []
if (page.value === 'Dashboard') loaders.push(api('/api/dashboard').then(v => dashboard.value = v))
if (page.value === 'Guests' || page.value === 'Backup jobs') loaders.push(api<Guest[]>('/api/guests').then(v => guests.value = v).catch(e => error.value = e.message))
if (page.value === 'Backup jobs' || page.value === 'Dashboard' || page.value === 'Guests') loaders.push(api<Job[]>('/api/jobs').then(v => jobs.value = v))
if (page.value === 'Backup history') await loadBackups()
await Promise.all(loaders)
}
async function loadBackups() {
const params = new URLSearchParams()
if (filters.guest_vmid) params.set('guest_vmid', filters.guest_vmid)
if (filters.state) params.set('state', filters.state)
backups.value = await api<Backup[]>(`/api/backups?${params}`)
}
async function createJob() {
const guest = guests.value.find(g => String(g.vmid) === newJob.guest)
if (!guest) {
error.value = 'Select a guest'
return
}
const payload = {
guest_vmid: guest.vmid,
guest_name: guest.name,
guest_type: guest.type,
node: guest.node,
enabled: newJob.enabled,
cron_schedule: newJob.cron_schedule,
proxmox_storage: settings.proxmox_storage,
backup_mode: settings.default_backup_mode,
compression: settings.default_compression,
retention_type: newJob.retention_type,
retention_value: Number(newJob.retention_value),
}
loading.value = true
error.value = ''
actionMessage.value = 'Creating backup job...'
try {
await api('/api/jobs', { method: 'POST', body: JSON.stringify(payload) })
actionMessage.value = ''
await refresh()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function toggleJob(job: Job) {
loading.value = true
error.value = ''
actionMessage.value = `${job.enabled ? 'Disabling' : 'Enabling'} job #${job.id}...`
try {
await api(`/api/jobs/${job.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !Boolean(job.enabled) }) })
actionMessage.value = ''
await refresh()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
function beginEditJob(job: Job) {
editingJobId.value = job.id
editJobForm.enabled = Boolean(job.enabled)
editJobForm.cron_schedule = job.cron_schedule
editJobForm.proxmox_storage = job.proxmox_storage
editJobForm.backup_mode = job.backup_mode
editJobForm.compression = job.compression
editJobForm.retention_type = job.retention_type
editJobForm.retention_value = job.retention_value
}
function cancelEditJob() {
editingJobId.value = null
}
async function saveJob(job: Job) {
error.value = ''
loading.value = true
actionMessage.value = `Saving job #${job.id}...`
try {
await api(`/api/jobs/${job.id}`, {
method: 'PUT',
body: JSON.stringify({
enabled: editJobForm.enabled,
cron_schedule: editJobForm.cron_schedule,
proxmox_storage: editJobForm.proxmox_storage,
backup_mode: editJobForm.backup_mode,
compression: editJobForm.compression,
retention_type: editJobForm.retention_type,
retention_value: Number(editJobForm.retention_value),
}),
})
editingJobId.value = null
actionMessage.value = ''
await refresh()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function deleteJob(job: Job) {
error.value = ''
actionMessage.value = ''
const ok = window.confirm(`Delete job for ${job.guest_name} (${job.guest_vmid}) and delete all known remote backups for this job?`)
if (!ok) return
deletingJobId.value = job.id
loading.value = true
actionMessage.value = `Deleting job #${job.id} and its known backups. This can take a while if remote files are large...`
try {
const result = await api<{ deleted_backups: string[] }>(`/api/jobs/${job.id}`, { method: 'DELETE' })
editingJobId.value = null
actionMessage.value = `Deleted job #${job.id}. Deleted ${result.deleted_backups?.length || 0} backup(s).`
await refresh()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
deletingJobId.value = null
}
}
async function runJob(job: Job) {
loading.value = true
error.value = ''
actionMessage.value = `Queuing job #${job.id}...`
try {
await api(`/api/jobs/${job.id}/run`, { method: 'POST' })
actionMessage.value = ''
page.value = 'Dashboard'
await refresh()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function deleteBackup(backup: Backup) {
error.value = ''
actionMessage.value = ''
const hardPurge = backup.state === 'deleted'
const ok = window.confirm(
hardPurge
? `Nuke deleted backup ${backup.id} from the app database entirely? The remote/local files are already considered deleted.`
: `Delete backup ${backup.id}? If a remote object is recorded, it will be deleted from rclone first.`
)
if (!ok) return
deletingBackupId.value = backup.id
loading.value = true
actionMessage.value = hardPurge
? `Nuking deleted backup ${backup.id} from the app database...`
: `Deleting backup ${backup.id}. This can take a while while rclone removes the remote object...`
try {
const result = await api<Backup | { id: string; purged: boolean }>(`/api/backups/${backup.id}`, { method: 'DELETE' })
if ('purged' in result) {
selectedBackup.value = null
actionMessage.value = `Nuked backup ${backup.id} from the app database.`
page.value = 'Backup history'
} else {
selectedBackup.value = result
actionMessage.value = `Deleted backup ${backup.id}.`
}
await loadBackups()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
deletingBackupId.value = null
}
}
function fmtBytes(value?: number) {
if (!value) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let n = value
let i = 0
while (n > 1024 && i < units.length - 1) { n /= 1024; i++ }
return `${n.toFixed(i ? 1 : 0)} ${units[i]}`
}
function fmtDate(value?: string) {
if (!value) return '—'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
try {
return d.toLocaleString(undefined, { timeZone: settings.timezone || 'UTC' })
} catch {
return d.toLocaleString()
}
}
function countdownTo(value?: string) {
if (!value) return 'not scheduled'
const target = new Date(value).getTime()
if (Number.isNaN(target)) return 'invalid time'
const diff = target - nowMs.value
if (diff <= 0) return 'due now'
const totalSeconds = Math.floor(diff / 1000)
const days = Math.floor(totalSeconds / 86400)
const hours = Math.floor((totalSeconds % 86400) / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
const time = [hours, minutes, seconds].map(v => String(v).padStart(2, '0')).join(':')
return days ? `in ${days}d ${time}` : `in ${time}`
}
function valueOrDash(value?: string | number | null) {
return value === undefined || value === null || value === '' ? '—' : String(value)
}
function stateClass(state?: string) {
if (state === 'completed') return 'border-emerald-800 bg-emerald-950 text-emerald-200'
if (state === 'failed') return 'border-red-800 bg-red-950 text-red-200'
if (state === 'deleted') return 'border-slate-700 bg-slate-800 text-slate-300'
if (state === 'deleting') return 'border-amber-700 bg-amber-950 text-amber-100'
return 'border-cyan-800 bg-cyan-950 text-cyan-200'
}
onMounted(async () => {
clockTimer = window.setInterval(() => {
nowMs.value = Date.now()
}, 1000)
await loadSettings()
await loadDiscover()
await refresh()
})
onUnmounted(() => {
if (clockTimer) window.clearInterval(clockTimer)
})
</script>
<template>
<div class="min-h-screen">
<header class="border-b border-slate-800 bg-slate-950/90">
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-3">
<div>
<h1 class="text-lg font-semibold">PVE Cloud Backup</h1>
<p class="text-xs text-slate-400">Proxmox backups to an existing rclone remote</p>
</div>
<button @click="refresh" :disabled="loading || !settings.setup_complete">Refresh</button>
</div>
</header>
<main class="mx-auto max-w-7xl px-4 py-4">
<nav class="mb-4 flex flex-wrap gap-2">
<button v-for="p in pages" :key="p" @click="page = p; refresh()" :class="page === p ? 'bg-cyan-500' : 'bg-slate-800 hover:bg-slate-700'">{{ p }}</button>
</nav>
<div v-if="actionMessage" class="mb-4 rounded border border-cyan-800 bg-cyan-950 px-3 py-2 text-sm text-cyan-100">{{ actionMessage }}</div>
<div v-if="error" class="mb-4 rounded border border-red-800 bg-red-950 px-3 py-2 text-sm text-red-200">{{ error }}</div>
<datalist id="timezones"><option v-for="tz in timezoneOptions" :key="tz" :value="tz" /></datalist>
<section v-if="page === 'Setup wizard'" class="panel max-w-3xl">
<h2 class="mb-3 text-base font-semibold">Initial setup</h2>
<p v-if="!discover.rclone_remotes.length" class="mb-3 rounded border border-amber-700 bg-amber-950 p-3 text-sm text-amber-100">
No rclone remotes were discovered. Configure rclone separately first. This UI intentionally does not configure rclone.
</p>
<div class="grid gap-3 md:grid-cols-2">
<label><span class="label">Proxmox node</span><input v-model="settings.proxmox_node" class="w-full" /></label>
<label><span class="label">Proxmox storage</span><input v-model="settings.proxmox_storage" list="storages" class="w-full" /></label>
<datalist id="storages"><option v-for="s in discover.proxmox_storages" :key="s" :value="s" /></datalist>
<label><span class="label">Local backup directory</span><input v-model="settings.local_backup_dir" class="w-full" /></label>
<label><span class="label">rclone executable</span><input v-model="settings.rclone_path" class="w-full" /></label>
<label><span class="label">rclone remote</span><select v-model="settings.rclone_remote" class="w-full"><option value="">Select remote</option><option v-for="r in discover.rclone_remotes" :key="r" :value="r">{{ r }}</option></select></label>
<label><span class="label">Remote backup path</span><input v-model="settings.rclone_remote_path" placeholder="pve-cloud-backup" class="w-full" /></label>
<label><span class="label">Timezone</span><input v-model="settings.timezone" list="timezones" class="w-full" placeholder="Europe/Berlin" /></label>
<label><span class="label">Compression</span><select v-model="settings.default_compression" class="w-full"><option>zstd</option><option>gzip</option><option>lzo</option><option>none</option></select></label>
<label><span class="label">Backup mode</span><select v-model="settings.default_backup_mode" class="w-full"><option>snapshot</option><option>suspend</option><option>stop</option></select></label>
<label><span class="label">Retention type</span><select v-model="settings.default_retention_type" class="w-full"><option value="days">Keep days</option><option value="latest">Keep latest</option></select></label>
<label><span class="label">Retention value</span><input v-model.number="settings.default_retention_value" type="number" min="1" class="w-full" /></label>
<label><span class="label">Max concurrent backups</span><input v-model.number="settings.max_concurrent_backups" type="number" min="1" class="w-full" /></label>
<label><span class="label">Discord webhook URL</span><input v-model="settings.discord_webhook_url" class="w-full" /></label>
</div>
<label class="mt-3 block"><span class="label">Allowed CORS origins, one per line</span><textarea v-model="corsText" rows="3" class="w-full"></textarea></label>
<div class="mt-4 flex gap-2">
<button @click="saveSettings(true)" :disabled="loading || !discover.rclone_remotes.length">{{ loading ? 'Saving...' : 'Save setup' }}</button>
<button class="bg-slate-700 hover:bg-slate-600" @click="loadDiscover">Rediscover</button>
</div>
</section>
<section v-else-if="page === 'Dashboard'" class="grid gap-4 lg:grid-cols-3">
<div class="panel"><div class="label">Latest backup</div><div class="text-lg">{{ dashboard.latest_backup?.state || 'none' }}</div><div class="text-sm text-slate-400">{{ dashboard.latest_backup?.guest_name }}</div></div>
<div class="panel"><div class="label">Active tasks</div><div class="text-lg">{{ dashboard.active_tasks?.length || 0 }}</div></div>
<div class="panel"><div class="label">Failed backups</div><div class="text-lg">{{ dashboard.failed_backups || 0 }}</div></div>
<div class="panel"><div class="label">Remote backups</div><div class="text-lg">{{ dashboard.remote_backup_count || 0 }}</div></div>
<div class="panel"><div class="label">Local staging usage</div><div class="text-lg">{{ fmtBytes(dashboard.local_staging_usage?.bytes) }}</div><div class="text-xs text-slate-500">{{ dashboard.local_staging_usage?.path }}</div></div>
<div class="panel lg:col-span-3">
<h2 class="mb-2 font-semibold">Next scheduled jobs</h2>
<table class="w-full text-sm">
<tbody>
<tr v-for="j in dashboard.next_scheduled_jobs || []" :key="j.id" class="border-t border-slate-800">
<td class="py-2">{{ j.guest_name }} ({{ j.guest_vmid }})</td>
<td>{{ j.cron_schedule }}</td>
<td>
<div>{{ fmtDate(j.next_run_at) }}</div>
<div class="text-xs text-cyan-300">{{ countdownTo(j.next_run_at) }}</div>
</td>
</tr>
</tbody>
</table>
<p class="mt-2 text-xs text-slate-500">Schedule timezone: {{ settings.timezone }}</p>
</div>
</section>
<section v-else-if="page === 'Guests'" class="panel">
<h2 class="mb-2 font-semibold">Guests</h2>
<table class="w-full text-sm">
<thead class="text-left text-slate-400"><tr><th>VMID</th><th>Name</th><th>Type</th><th>Node</th><th>Status</th><th>Backup jobs</th></tr></thead>
<tbody>
<tr v-for="g in guests" :key="g.vmid" class="border-t border-slate-800">
<td class="py-2">{{ g.vmid }}</td>
<td>{{ g.name }}</td>
<td>{{ g.type }}</td>
<td>{{ g.node }}</td>
<td>{{ g.status }}</td>
<td>
<span class="rounded border border-slate-700 px-2 py-1 text-xs">
{{ jobCountsByGuest.get(g.vmid)?.total || 0 }} total · {{ jobCountsByGuest.get(g.vmid)?.enabled || 0 }} enabled
</span>
</td>
</tr>
</tbody>
</table>
</section>
<section v-else-if="page === 'Backup jobs'" class="grid gap-4 lg:grid-cols-[1fr_2fr]">
<form class="panel" @submit.prevent="createJob">
<h2 class="mb-3 font-semibold">Create job</h2>
<label class="block mb-2"><span class="label">Guest</span><select v-model="newJob.guest" class="w-full"><option value="">Select</option><option v-for="g in guests" :value="String(g.vmid)" :key="g.vmid">{{ g.name }} ({{ g.vmid }})</option></select></label>
<label class="block mb-2"><span class="label">Cron schedule</span><input v-model="newJob.cron_schedule" class="w-full" /></label>
<label class="block mb-2"><span class="label">Retention</span><div class="flex gap-2"><select v-model="newJob.retention_type"><option value="days">days</option><option value="latest">latest</option></select><input v-model.number="newJob.retention_value" type="number" min="1" class="w-24" /></div></label>
<label class="mb-3 flex items-center gap-2 text-sm"><input type="checkbox" v-model="newJob.enabled" /> Enabled</label>
<button :disabled="loading">{{ loading ? 'Creating...' : 'Create' }}</button>
</form>
<div class="panel">
<h2 class="mb-2 font-semibold">Jobs</h2>
<table class="w-full text-sm">
<thead class="text-left text-slate-400"><tr><th>Guest</th><th>Cron / settings</th><th>Retention</th><th>Next</th><th></th></tr></thead>
<tbody>
<tr v-for="j in jobs" :key="j.id" class="border-t border-slate-800 align-top">
<td class="py-2">{{ j.guest_name }} ({{ j.guest_vmid }})<div class="text-xs text-slate-500">{{ j.guest_type }} on {{ j.node }}</div></td>
<template v-if="editingJobId === j.id">
<td class="space-y-1 py-2">
<div class="rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-300">Editing job #{{ j.id }}</div>
<input v-model="editJobForm.cron_schedule" class="w-full" />
<div class="grid gap-1 md:grid-cols-3">
<input v-model="editJobForm.proxmox_storage" placeholder="storage" />
<select v-model="editJobForm.backup_mode"><option>snapshot</option><option>suspend</option><option>stop</option></select>
<select v-model="editJobForm.compression"><option>zstd</option><option>gzip</option><option>lzo</option><option>none</option></select>
</div>
<label class="flex items-center gap-2 text-xs"><input type="checkbox" v-model="editJobForm.enabled" /> Enabled</label>
</td>
<td class="py-2">
<div class="flex gap-1">
<select v-model="editJobForm.retention_type"><option value="days">days</option><option value="latest">latest</option></select>
<input v-model.number="editJobForm.retention_value" type="number" min="1" class="w-20" />
</div>
</td>
<td class="py-2">
<div>{{ fmtDate(j.next_run_at) }}</div>
<div class="text-xs text-cyan-300">{{ countdownTo(j.next_run_at) }}</div>
</td>
<td class="flex flex-wrap gap-2 py-2">
<button @click="saveJob(j)" :disabled="loading">{{ loading ? 'Saving...' : 'Save' }}</button>
<button class="bg-slate-700" @click="cancelEditJob" :disabled="loading">Cancel</button>
</td>
</template>
<template v-else>
<td class="py-2">{{ j.cron_schedule }}<div class="text-xs text-slate-500">job #{{ j.id }} · {{ j.proxmox_storage }} · {{ j.backup_mode }} · {{ j.compression }} · {{ j.enabled ? 'enabled' : 'disabled' }}</div></td>
<td class="py-2">{{ j.retention_type }} {{ j.retention_value }}</td>
<td class="py-2">
<div>{{ fmtDate(j.next_run_at) }}</div>
<div class="text-xs text-cyan-300">{{ countdownTo(j.next_run_at) }}</div>
</td>
<td class="flex flex-wrap gap-2 py-2">
<button @click="runJob(j)" :disabled="loading">Run</button>
<button class="bg-slate-700" @click="beginEditJob(j)" :disabled="loading">Edit</button>
<button class="bg-slate-700" @click="toggleJob(j)" :disabled="loading">{{ j.enabled ? 'Disable' : 'Enable' }}</button>
<button class="bg-red-700 hover:bg-red-600" @click="deleteJob(j)" :disabled="loading">
{{ deletingJobId === j.id ? 'Deleting...' : 'Delete' }}
</button>
</td>
</template>
</tr>
</tbody>
</table>
</div>
</section>
<section v-else-if="page === 'Backup history'" class="grid gap-4 lg:grid-cols-[2fr_1fr]">
<div class="panel">
<div class="mb-3 flex flex-wrap gap-2">
<input v-model="filters.guest_vmid" placeholder="VMID filter" />
<select v-model="filters.state"><option value="">All states</option><option v-for="s in ['queued','pve_running','local_ready','uploading','remote_ready','local_deleting','completed','failed','deleting','deleted']" :key="s">{{ s }}</option></select>
<button @click="loadBackups">Filter</button>
</div>
<table class="w-full text-sm">
<thead class="text-left text-slate-400"><tr><th>Started</th><th>Guest</th><th>State</th><th>Size</th></tr></thead>
<tbody><tr v-for="b in backups" :key="b.id" @click="selectedBackup = b; page = 'Backup details'" class="cursor-pointer border-t border-slate-800 hover:bg-slate-800"><td class="py-2">{{ b.started_at }}</td><td>{{ b.guest_name }} ({{ b.guest_vmid }})</td><td>{{ b.state }}</td><td>{{ fmtBytes(b.size_bytes) }}</td></tr></tbody>
</table>
</div>
<div class="panel">
<h2 class="mb-2 font-semibold">Backup details</h2>
<div v-if="selectedBackup" class="space-y-2 text-sm">
<div class="flex items-center gap-2">
<span :class="stateClass(selectedBackup.state)" class="rounded border px-2 py-1 text-xs">{{ selectedBackup.state }}</span>
<span class="text-slate-300">{{ selectedBackup.guest_name }} ({{ selectedBackup.guest_vmid }})</span>
</div>
<div class="break-all text-xs text-slate-400">{{ selectedBackup.id }}</div>
<button class="bg-slate-700" @click="page = 'Backup details'">Open details</button>
</div>
<p v-else class="text-sm text-slate-400">Select a backup.</p>
</div>
</section>
<section v-else-if="page === 'Backup details'" class="panel">
<div class="mb-3 flex items-center justify-between gap-3">
<h2 class="font-semibold">Backup details</h2>
<button v-if="selectedBackup" class="bg-red-700 hover:bg-red-600" @click="deleteBackup(selectedBackup)" :disabled="loading">
{{ deletingBackupId === selectedBackup.id ? (selectedBackup.state === 'deleted' ? 'Nuking...' : 'Deleting...') : (selectedBackup.state === 'deleted' ? 'Nuke metadata' : 'Delete backup') }}
</button>
</div>
<p v-if="!selectedBackup" class="text-sm text-slate-400">Select a backup from Backup history.</p>
<div v-else class="space-y-4">
<div class="grid gap-3 md:grid-cols-4">
<div class="rounded border border-slate-800 bg-slate-950 p-3">
<div class="label">State</div>
<span :class="stateClass(selectedBackup.state)" class="mt-1 inline-block rounded border px-2 py-1 text-xs">{{ selectedBackup.state }}</span>
</div>
<div class="rounded border border-slate-800 bg-slate-950 p-3">
<div class="label">Guest</div>
<div>{{ selectedBackup.guest_name }} ({{ selectedBackup.guest_vmid }})</div>
<div class="text-xs text-slate-500">{{ selectedBackup.guest_type }} on {{ selectedBackup.node }}</div>
</div>
<div class="rounded border border-slate-800 bg-slate-950 p-3">
<div class="label">Size</div>
<div>{{ fmtBytes(selectedBackup.size_bytes) }}</div>
</div>
<div class="rounded border border-slate-800 bg-slate-950 p-3">
<div class="label">Job</div>
<div>#{{ valueOrDash(selectedBackup.job_id) }}</div>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="rounded border border-slate-800 bg-slate-950 p-3">
<h3 class="mb-2 text-sm font-semibold">Timing</h3>
<dl class="grid grid-cols-[8rem_1fr] gap-2 text-sm">
<dt class="text-slate-500">Started</dt><dd>{{ fmtDate(selectedBackup.started_at) }}</dd>
<dt class="text-slate-500">PVE done</dt><dd>{{ fmtDate(selectedBackup.pve_completed_at) }}</dd>
<dt class="text-slate-500">Upload start</dt><dd>{{ fmtDate(selectedBackup.upload_started_at) }}</dd>
<dt class="text-slate-500">Completed</dt><dd>{{ fmtDate(selectedBackup.completed_at) }}</dd>
<dt class="text-slate-500">Deleted</dt><dd>{{ fmtDate(selectedBackup.deleted_at) }}</dd>
<dt class="text-slate-500">Updated</dt><dd>{{ fmtDate(selectedBackup.updated_at) }}</dd>
</dl>
</div>
<div class="rounded border border-slate-800 bg-slate-950 p-3">
<h3 class="mb-2 text-sm font-semibold">Identifiers</h3>
<dl class="grid grid-cols-[8rem_1fr] gap-2 text-sm">
<dt class="text-slate-500">Backup ID</dt><dd class="break-all font-mono text-xs">{{ selectedBackup.id }}</dd>
<dt class="text-slate-500">UPID</dt><dd class="break-all font-mono text-xs">{{ valueOrDash(selectedBackup.upid) }}</dd>
</dl>
</div>
</div>
<div class="rounded border border-slate-800 bg-slate-950 p-3">
<h3 class="mb-2 text-sm font-semibold">Storage paths</h3>
<dl class="grid gap-2 text-sm md:grid-cols-[8rem_1fr]">
<dt class="text-slate-500">Remote</dt><dd class="break-all font-mono text-xs">{{ valueOrDash(selectedBackup.remote_path) }}</dd>
<dt class="text-slate-500">Local</dt><dd class="break-all font-mono text-xs">{{ valueOrDash(selectedBackup.local_path) }}</dd>
</dl>
</div>
<div v-if="selectedBackup.error_message" class="rounded border border-red-800 bg-red-950 p-3 text-sm text-red-100">
<h3 class="mb-2 font-semibold">Error</h3>
<div class="whitespace-pre-wrap break-all">{{ selectedBackup.error_message }}</div>
</div>
</div>
</section>
<section v-else-if="page === 'Settings'" class="panel max-w-3xl">
<h2 class="mb-3 font-semibold">Settings</h2>
<div class="grid gap-3 md:grid-cols-2">
<label><span class="label">Proxmox node</span><input v-model="settings.proxmox_node" class="w-full" /></label>
<label><span class="label">Proxmox storage</span><input v-model="settings.proxmox_storage" class="w-full" /></label>
<label><span class="label">Local backup directory</span><input v-model="settings.local_backup_dir" class="w-full" /></label>
<label><span class="label">rclone executable</span><input v-model="settings.rclone_path" class="w-full" /></label>
<label><span class="label">rclone remote</span><input v-model="settings.rclone_remote" class="w-full" /></label>
<label><span class="label">Remote backup path</span><input v-model="settings.rclone_remote_path" class="w-full" /></label>
<label><span class="label">Timezone</span><input v-model="settings.timezone" list="timezones" class="w-full" placeholder="Europe/Berlin" /></label>
<label><span class="label">Discord webhook URL</span><input v-model="settings.discord_webhook_url" class="w-full" /></label>
<label><span class="label">Compression</span><input v-model="settings.default_compression" class="w-full" /></label>
<label><span class="label">Backup mode</span><input v-model="settings.default_backup_mode" class="w-full" /></label>
<label><span class="label">Retention type</span><select v-model="settings.default_retention_type" class="w-full"><option value="days">days</option><option value="latest">latest</option></select></label>
<label><span class="label">Retention value</span><input v-model.number="settings.default_retention_value" type="number" min="1" class="w-full" /></label>
<label><span class="label">Max concurrent backups</span><input v-model.number="settings.max_concurrent_backups" type="number" min="1" class="w-full" /></label>
</div>
<label class="mt-3 block"><span class="label">Allowed CORS origins</span><textarea v-model="corsText" rows="3" class="w-full"></textarea></label>
<button class="mt-4" @click="saveSettings(true)" :disabled="loading">{{ loading ? 'Saving...' : 'Save settings' }}</button>
</section>
</main>
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, any>
export default component
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+21
View File
@@ -0,0 +1,21 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
input,
select,
textarea {
@apply bg-slate-900 border border-slate-700 rounded px-2 py-1 text-sm text-slate-100;
}
button {
@apply rounded px-3 py-1.5 text-sm font-medium bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 disabled:hover:bg-cyan-600;
}
.panel {
@apply rounded-lg border border-slate-800 bg-slate-900/70 p-4;
}
.label {
@apply block text-xs uppercase tracking-wide text-slate-400 mb-1;
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,ts}'],
theme: {
extend: {},
},
plugins: [],
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"jsx": "preserve"
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
})