implement user triggered backups
This commit is contained in:
@@ -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
110
app/routes/backups.go
Normal 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
|
||||
}
|
3
web/html/backups-backups.go.tmpl
Normal file
3
web/html/backups-backups.go.tmpl
Normal file
@@ -0,0 +1,3 @@
|
||||
{{range $i, $x := .backups}}
|
||||
{{template "backup-card" $x}}
|
||||
{{end}}
|
38
web/html/backups.html
Normal file
38
web/html/backups.html
Normal 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>
|
@@ -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>
|
||||
|
1
web/images/actions/instance/backup.svg
Normal file
1
web/images/actions/instance/backup.svg
Normal 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
122
web/scripts/backups.js
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
@@ -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 };
|
||||
}
|
||||
|
101
web/templates/backups.go.tmpl
Normal file
101
web/templates/backups.go.tmpl
Normal 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}}
|
@@ -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">
|
||||
|
Reference in New Issue
Block a user