Initial pve cloud backup app
This commit is contained in:
@@ -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>
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user