diff --git a/app/app.go b/app/app.go index 6968f19..4f0a2b3 100644 --- a/app/app.go +++ b/app/app.go @@ -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) diff --git a/app/routes/backups.go b/app/routes/backups.go new file mode 100644 index 0000000..6db9f9e --- /dev/null +++ b/app/routes/backups.go @@ -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 +} diff --git a/web/html/backups-backups.go.tmpl b/web/html/backups-backups.go.tmpl new file mode 100644 index 0000000..f2667b6 --- /dev/null +++ b/web/html/backups-backups.go.tmpl @@ -0,0 +1,3 @@ +{{range $i, $x := .backups}} + {{template "backup-card" $x}} +{{end}} \ No newline at end of file diff --git a/web/html/backups.html b/web/html/backups.html new file mode 100644 index 0000000..eb89376 --- /dev/null +++ b/web/html/backups.html @@ -0,0 +1,38 @@ + + + + {{template "head" .}} + + + + + + +
+ {{template "header" .}} +
+
+

Instances / {{.config.Name}} / Backups

+
+
+

Time

+

Notes

+

Size

+

Actions

+
+
+ {{range $i, $x := .backups}} + {{template "backup-card" $x}} + {{end}} +
+
+ {{template "backups-add-backup" .}} +
+
+
+ EXIT +
+
+ + \ No newline at end of file diff --git a/web/html/config.html b/web/html/config.html index a47e223..a816e1b 100644 --- a/web/html/config.html +++ b/web/html/config.html @@ -25,7 +25,7 @@
-

Instances / {{.config.Name}}

+

Instances / {{.config.Name}} / Config

Resources diff --git a/web/images/actions/instance/backup.svg b/web/images/actions/instance/backup.svg new file mode 100644 index 0000000..9839af3 --- /dev/null +++ b/web/images/actions/instance/backup.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/scripts/backups.js b/web/scripts/backups.js new file mode 100644 index 0000000..6b7d194 --- /dev/null +++ b/web/scripts/backups.js @@ -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(); + } + }); +} diff --git a/web/scripts/utils.js b/web/scripts/utils.js index 2fa1670..05af804 100644 --- a/web/scripts/utils.js +++ b/web/scripts/utils.js @@ -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 }; } diff --git a/web/templates/backups.go.tmpl b/web/templates/backups.go.tmpl new file mode 100644 index 0000000..c912904 --- /dev/null +++ b/web/templates/backups.go.tmpl @@ -0,0 +1,101 @@ +{{define "backup-card"}} + + + +{{end}} + +{{define "backups-add-backup"}} + + +{{end}} \ No newline at end of file diff --git a/web/templates/instance-card.go.tmpl b/web/templates/instance-card.go.tmpl index 7d23661..58523e1 100644 --- a/web/templates/instance-card.go.tmpl +++ b/web/templates/instance-card.go.tmpl @@ -8,6 +8,16 @@ * { margin: 0; } + a { + height: 1em; + width: 1em; + margin: 0px; + padding: 0px; + } + svg { + height: 1em; + width: 1em; + }

@@ -42,6 +52,9 @@ {{if and (eq .NodeStatus "online") (eq .Status "running")}} + + + @@ -51,11 +64,17 @@ + + + {{else if and (eq .NodeStatus "online") (eq .Status "loading")}} + + + {{else}} @@ -77,14 +96,12 @@

-

{{if eq .Status "running"}} - Are you sure you want to stop {{.VMID}}? +

Are you sure you want to stop {{.VMID}}?

{{else if eq .Status "stopped"}} - Are you sure you want to start {{.VMID}}? +

Are you sure you want to start {{.VMID}}?

{{else}} {{end}} -

@@ -103,9 +120,7 @@

-

- Are you sure you want to delete {{.VMID}} -

+

Are you sure you want to delete {{.VMID}}?