implement user triggered backups

This commit is contained in:
2025-07-07 20:59:12 +00:00
parent e932165a98
commit 65c8fbdca8
10 changed files with 407 additions and 10 deletions

View File

@@ -35,6 +35,8 @@ func Run() {
router.GET("/config/nets", routes.HandleGETConfigNetsFragment)
router.GET("/config/devices", routes.HandleGETConfigDevicesFragment)
router.GET("/config/boot", routes.HandleGETConfigBootFragment)
router.GET("/backups", routes.HandleGETBackups)
router.GET("/backups/backups", routes.HandleGETBackupsFragment)
router.GET("/login", routes.HandleGETLogin)
router.GET("/settings", routes.HandleGETSettings)

110
app/routes/backups.go Normal file
View File

@@ -0,0 +1,110 @@
package routes
import (
"fmt"
"log"
"net/http"
"proxmoxaas-dashboard/app/common"
"time"
"github.com/gin-gonic/gin"
"github.com/go-viper/mapstructure/v2"
)
type InstanceBackup struct {
Volid string `json:"volid"`
Notes string `json:"notes"`
Size int64 `json:"size"`
CTime int64 `json:"ctime"`
SizeFormatted string
TimeFormatted string
}
func HandleGETBackups(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil {
vm_path, err := common.ExtractVMPath(c)
if err != nil {
common.HandleNonFatalError(c, err)
}
backups, err := GetInstanceBackups(vm_path, auth)
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance backups: %s", err.Error()))
}
config, err := GetInstanceConfig(vm_path, auth) // only used for the VM's name
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
}
log.Printf("%+v", backups)
c.HTML(http.StatusOK, "html/backups.html", gin.H{
"global": common.Global,
"page": "backups",
"backups": backups,
"config": config,
})
} else {
c.Redirect(http.StatusFound, "/login")
}
}
func HandleGETBackupsFragment(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil { // user should be authed, try to return index with population
vm_path, err := common.ExtractVMPath(c)
if err != nil {
common.HandleNonFatalError(c, err)
}
backups, err := GetInstanceBackups(vm_path, auth)
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance backups: %s", err.Error()))
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/backups-backups.go.tmpl", gin.H{
"backups": backups,
})
c.Status(http.StatusOK)
} else { // return 401
c.Status(http.StatusUnauthorized)
}
}
func GetInstanceBackups(vm common.VMPath, auth common.Auth) ([]InstanceBackup, error) {
backups := []InstanceBackup{}
path := fmt.Sprintf("/cluster/%s/%s/%s/backup", vm.Node, vm.Type, vm.VMID)
ctx := common.RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
body := []any{}
res, code, err := common.RequestGetAPI(path, ctx, &body)
if err != nil {
return backups, err
}
if code != 200 {
return backups, fmt.Errorf("request to %s resulted in %+v", path, res)
}
err = mapstructure.Decode(body, &backups)
if err != nil {
return backups, err
}
for i := range backups {
size, prefix := common.FormatNumber(backups[i].Size, 1024)
backups[i].SizeFormatted = fmt.Sprintf("%.3g %sB", size, prefix)
t := time.Unix(backups[i].CTime, 0)
backups[i].TimeFormatted = t.Format("02-01-06 15:04:05")
}
return backups, nil
}

View File

@@ -0,0 +1,3 @@
{{range $i, $x := .backups}}
{{template "backup-card" $x}}
{{end}}

38
web/html/backups.html Normal file
View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{template "head" .}}
<script src="scripts/backups.js" type="module"></script>
<link rel="modulepreload" href="scripts/utils.js">
<link rel="modulepreload" href="scripts/dialog.js">
<style>
</style>
</head>
<body>
<header>
{{template "header" .}}
</header>
<main>
<h2><a href="index">Instances</a> / {{.config.Name}} / Backups</h2>
<section class="w3-card w3-padding">
<div class="w3-row" style="border-bottom: 1px solid;">
<p class="w3-col l2 m4 s8">Time</p>
<p class="w3-col l6 m6 w3-hide-small">Notes</p>
<p class="w3-col l2 w3-hide-medium w3-hide-small">Size</p>
<p class="w3-col l2 m2 s4">Actions</p>
</div>
<div id="backups-container">
{{range $i, $x := .backups}}
{{template "backup-card" $x}}
{{end}}
</div>
<div class="w3-container w3-center">
{{template "backups-add-backup" .}}
</div>
</section>
<div class="w3-container w3-center">
<a class="w3-button w3-margin" id="exit" href="index">EXIT</a>
</div>
</main>
</body>
</html>

View File

@@ -25,7 +25,7 @@
</header>
<main>
<section>
<h2><a href="index">Instances</a> / {{.config.Name}}</h2>
<h2><a href="index">Instances</a> / {{.config.Name}} / Config</h2>
<form id="config-form">
<fieldset class="w3-card w3-padding">
<legend>Resources</legend>

View File

@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="backup" viewBox="1 1 22 22" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path fill-rule="evenodd" clip-rule="evenodd" d="M18.172 1a2 2 0 011.414.586l2.828 2.828A2 2 0 0123 5.828V20a3 3 0 01-3 3H4a3 3 0 01-3-3V4a3 3 0 013-3h14.172zM4 3a1 1 0 00-1 1v16a1 1 0 001 1h1v-6a3 3 0 013-3h8a3 3 0 013 3v6h1a1 1 0 001-1V6.828a2 2 0 00-.586-1.414l-1.828-1.828A2 2 0 0017.172 3H17v2a3 3 0 01-3 3h-4a3 3 0 01-3-3V3H4zm13 18v-6a1 1 0 00-1-1H8a1 1 0 00-1 1v6h10zM9 3h6v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V3z" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 631 B

122
web/scripts/backups.js Normal file
View File

@@ -0,0 +1,122 @@
import { requestAPI, getURIData, setAppearance, requestDash } from "./utils.js";
import { alert, dialog } from "./dialog.js";
window.addEventListener("DOMContentLoaded", init);
let node;
let type;
let vmid;
async function init () {
setAppearance();
const uriData = getURIData();
node = uriData.node;
type = uriData.type;
vmid = uriData.vmid;
document.querySelector("#backup-add").addEventListener("click", handleBackupAddButton);
}
class BackupCard extends HTMLElement {
shadowRoot = null;
constructor () {
super();
const internals = this.attachInternals();
this.shadowRoot = internals.shadowRoot;
const editButton = this.shadowRoot.querySelector("#edit-btn");
if (editButton.classList.contains("clickable")) {
editButton.onclick = this.handleEditButton.bind(this);
editButton.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.editButton();
}
};
}
const deleteButton = this.shadowRoot.querySelector("#delete-btn");
if (deleteButton.classList.contains("clickable")) {
deleteButton.onclick = this.handleDeleteButton.bind(this);
deleteButton.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.handleDeleteButton();
}
};
}
}
get volid () {
return this.dataset.volid;
}
async handleEditButton () {
const template = this.shadowRoot.querySelector("#edit-dialog");
dialog(template, async (result, form) => {
if (result === "confirm") {
const body = {
volid: this.volid,
notes: form.get("notes")
};
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup/notes`, "POST", body);
if (result.status !== 200) {
alert(`Attempted to edit backup but got: ${result.error}`);
}
refreshBackups();
}
});
}
async handleDeleteButton () {
const template = this.shadowRoot.querySelector("#delete-dialog");
dialog(template, async (result, form) => {
if (result === "confirm") {
const body = {
volid: this.volid
};
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup`, "DELETE", body);
if (result.status !== 200) {
alert(`Attempted to delete backup but got: ${result.error}`);
}
refreshBackups();
}
});
}
}
customElements.define("backup-card", BackupCard);
async function getBackupsFragment () {
return await requestDash(`/backups/backups?node=${node}&type=${type}&vmid=${vmid}`, "GET");
}
async function refreshBackups () {
let backups = await getBackupsFragment();
if (backups.status !== 200) {
alert("Error fetching backups.");
}
else {
backups = backups.data;
const container = document.querySelector("#backups-container");
container.setHTMLUnsafe(backups);
}
}
async function handleBackupAddButton () {
const template = document.querySelector("#create-backup-dialog");
dialog(template, async (result, form) => {
if (result === "confirm") {
const body = {
notes: form.get("notes")
};
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup`, "POST", body);
if (result.status !== 200) {
alert(`Attempted to create backup but got: ${result.error}`);
}
refreshBackups();
}
});
}

View File

@@ -81,7 +81,11 @@ async function request (url, content) {
const response = await fetch(url, content);
const contentType = response.headers.get("Content-Type");
let data = null;
if (contentType.includes("application/json")) {
if (contentType === null) {
data = {};
}
else if (contentType.includes("application/json")) {
data = await response.json();
data.status = response.status;
}
@@ -94,8 +98,9 @@ async function request (url, content) {
data.status = response.status;
}
else {
data = response;
data = {};
}
if (!response.ok) {
return { status: response.status, error: data ? data.error : response.status };
}

View File

@@ -0,0 +1,101 @@
{{define "backup-card"}}
<backup-card data-volid="{{.Volid}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
<link rel="stylesheet" href="css/style.css">
<style>
* {
margin: 0;
}
a {
height: 1em;
width: 1em;
margin: 0px;
padding: 0px;
}
svg {
height: 1em;
width: 1em;
}
</style>
<div class="w3-row" style="margin-top: 1em; margin-bottom: 1em;">
<p class="w3-col l2 m4 s8">{{.TimeFormatted}}</p>
<p class="w3-col l6 m6 w3-hide-small">{{.Notes}}</p>
<p class="w3-col l2 w3-hide-medium w3-hide-small">{{.SizeFormatted}}</p>
<div class="w3-col l2 m2 s4 flex row nowrap" style="height: 1lh;">
<svg id="edit-btn" class="clickable" aria-label="change notes"><use href="images/actions/instance/config-active.svg#symb"></svg>
<svg id="delete-btn" class="clickable" aria-label="delete backup" role="button" tabindex=0><use href="images/actions/instance/delete-active.svg#symb"></svg>
</div>
</div>
<template id="edit-dialog">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Edit Backup
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto;" id="form">
<label for="rate">Notes</label>
<textarea id="notes" name="notes" class="w3-input w3-border">{{.Notes}}</textarea>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
<template id="delete-dialog">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Delete Backup
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<p>
Are you sure you want to <strong>delete</strong> the backup made at {{.TimeFormatted}}?
</p>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template>
</backup-card>
{{end}}
{{define "backups-add-backup"}}
<button type="button" id="backup-add" class="w3-button" aria-label="Create Backup">
<span class="large" style="margin: 0;">Create Backup</span>
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Create Backup"><use href="images/actions/network/add.svg#symb"></use></svg>
</button>
<template id="create-backup-dialog">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Create Backup
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto;" id="form">
<label for="rate">Notes</label>
<textarea id="notes" name="notes" class="w3-input w3-border">{{.Notes}}</textarea>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
{{end}}

View File

@@ -8,6 +8,16 @@
* {
margin: 0;
}
a {
height: 1em;
width: 1em;
margin: 0px;
padding: 0px;
}
svg {
height: 1em;
width: 1em;
}
</style>
<div class="w3-row" style="margin-top: 1em; margin-bottom: 1em;">
<hr class="w3-show-small w3-hide-medium w3-hide-large" style="margin: 0; margin-bottom: 1em;">
@@ -42,6 +52,9 @@
{{if and (eq .NodeStatus "online") (eq .Status "running")}}
<svg id="power-btn" class="clickable" aria-label="shutdown instance" role="button" tabindex=0><use href="images/actions/instance/stop.svg#symb"></svg>
<svg id="configure-btn" aria-disabled="true" role="none"><use href="images/actions/instance/config-inactive.svg#symb"></svg>
<a href="{{.BackupsPath}}">
<svg id="backup-btn" class="clickable" aria-label="manage backups"><use href="images/actions/instance/backup.svg#symb"></svg>
</a>
<a href="{{.ConsolePath}}" target="_blank">
<svg id="console-btn" class="clickable" aria-label="open console"><use href="images/actions/instance/console-active.svg#symb"></svg>
</a>
@@ -51,11 +64,17 @@
<a href="{{.ConfigPath}}">
<svg id="configure-btn" class="clickable" aria-label="change configuration"><use href="images/actions/instance/config-active.svg#symb"></svg>
</a>
<a href="{{.BackupsPath}}">
<svg id="backup-btn" class="clickable" aria-label="manage backups"><use href="images/actions/instance/backup.svg#symb"></svg>
</a>
<svg id="console-btn" aria-disabled="true" role="none"><use href="images/actions/instance/console-inactive.svg#symb"></svg>
<svg id="delete-btn" class="clickable" aria-label="delete instance" role="button" tabindex=0><use href="images/actions/instance/delete-active.svg#symb"></svg>
{{else if and (eq .NodeStatus "online") (eq .Status "loading")}}
<svg id="power-btn" aria-disabled="true" role="none"><use href="images/actions/instance/loading.svg#symb"></svg>
<svg id="configure-btn" aria-disabled="true" role="none"><use href="images/actions/instance/config-inactive.svg#symb"></svg>
<a href="{{.BackupsPath}}">
<svg id="backup-btn" class="clickable" aria-label="manage backups"><use href="images/actions/instance/backup.svg#symb"></svg>
</a>
<svg id="console-btn" aria-disabled="true" role="none"><use href="images/actions/instance/console-inactive.svg#symb"></svg>
<svg id="delete-btn" aria-disabled="true" role="none"><use href="images/actions/instance/delete-inactive.svg#symb"></svg>
{{else}}
@@ -77,14 +96,12 @@
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<p>
{{if eq .Status "running"}}
Are you sure you want to <strong>stop</strong> {{.VMID}}?
<p>Are you sure you want to <strong>stop</strong> {{.VMID}}?</p>
{{else if eq .Status "stopped"}}
Are you sure you want to <strong>start</strong> {{.VMID}}?
<p>Are you sure you want to <strong>start</strong> {{.VMID}}?</p>
{{else}}
{{end}}
</p>
</form>
</div>
<div id="controls" class="w3-center w3-container">
@@ -103,9 +120,7 @@
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<p>
Are you sure you want to <strong>delete</strong> {{.VMID}}
</p>
<p>Are you sure you want to <strong>delete</strong> {{.VMID}}?</p>
</form>
</div>
<div id="controls" class="w3-center w3-container">