feat: first commit of new api
This commit is contained in:
@@ -1,18 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"ddbb/pkg/api"
|
||||
"ddbb/pkg/cron"
|
||||
"ddbb/internal/api"
|
||||
// "ddbb/pkg/cron"
|
||||
"log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Set up the cron job
|
||||
cron.ScheduleBackupFromEnv()
|
||||
// cron.ScheduleBackupFromEnv()
|
||||
|
||||
// Run the api endpoint
|
||||
r := api.SetupRouter()
|
||||
err := r.Run(":12345")
|
||||
err := r.Run(":3000")
|
||||
if err != nil {
|
||||
log.Fatalf("API fatal error: %v", err)
|
||||
}
|
||||
|
||||
71
internal/api/api.go
Normal file
71
internal/api/api.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Job struct {
|
||||
Id string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Database string `json:"database"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"-"` // omit from JSON responses
|
||||
DumpFile string `json:"dump_file"`
|
||||
DumpFileModTime time.Time `json:"dump_file_mod_time"`
|
||||
IntegrityStatus string `json:"integrity_status"`
|
||||
IntegrityLastCheckTime time.Time `json:"integrity_last_check_time"`
|
||||
}
|
||||
|
||||
type DumpJobResult struct {
|
||||
Id string
|
||||
TimeStart time.Time
|
||||
TimeEnd time.Time
|
||||
Result string
|
||||
Error error
|
||||
}
|
||||
|
||||
type RestoreJobResult struct {
|
||||
Id string
|
||||
TimeStart time.Time
|
||||
TimeEnd time.Time
|
||||
Result string
|
||||
Error error
|
||||
}
|
||||
|
||||
func listJobs() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dump any database based on Id
|
||||
func triggerDump(c *gin.Context) DumpJobResult {
|
||||
return DumpJobResult{}
|
||||
}
|
||||
|
||||
// Restore any database based on Id
|
||||
func triggerRestore(c *gin.Context) RestoreJobResult {
|
||||
return RestoreJobResult{}
|
||||
}
|
||||
|
||||
// Check any database based on Id
|
||||
func triggerIntegrityCheck(c *gin.Context) Job {
|
||||
return Job{}
|
||||
}
|
||||
|
||||
// API entrypoint
|
||||
func SetupRouter() *gin.Engine {
|
||||
// GIN General setup
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.Default()
|
||||
r.SetTrustedProxies([]string{"127.0.0.1"})
|
||||
|
||||
// GIN endpoints
|
||||
r.GET("/list", listJobs)
|
||||
r.POST("/dump", triggerDump)
|
||||
r.POST("/integrity", triggerIntegrityCheck)
|
||||
r.POST("/restore", triggerRestore)
|
||||
return r
|
||||
}
|
||||
1
internal/backup/backup.go
Normal file
1
internal/backup/backup.go
Normal file
@@ -0,0 +1 @@
|
||||
package backup
|
||||
0
internal/cron/cron.go
Normal file
0
internal/cron/cron.go
Normal file
0
internal/docker/docker.go
Normal file
0
internal/docker/docker.go
Normal file
0
internal/integrity/integrity.go
Normal file
0
internal/integrity/integrity.go
Normal file
0
internal/restore/restore.go
Normal file
0
internal/restore/restore.go
Normal file
123
pkg/api/api.go
123
pkg/api/api.go
@@ -1,123 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"ddbb/pkg/backup"
|
||||
"ddbb/pkg/docker"
|
||||
"ddbb/pkg/restore"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Init backup status
|
||||
var (
|
||||
backupStatus = "Idle"
|
||||
statusMutex sync.Mutex
|
||||
)
|
||||
|
||||
func BackupAllDatabases() {
|
||||
allBackups, _ := docker.GetBackupConfigsFromContainers()
|
||||
for _, job := range allBackups {
|
||||
backup.BackupJob(job)
|
||||
}
|
||||
}
|
||||
|
||||
// Dump any database giving an Id
|
||||
func triggerDump(c *gin.Context) {
|
||||
// Extract backup job name from the form POST data
|
||||
backupId := c.PostForm("id")
|
||||
if backupId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing backup job id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Find the backup job config with the matching name
|
||||
var chosenJob *backup.Config
|
||||
jobs, err := docker.GetBackupConfigsFromContainers()
|
||||
if err != nil {
|
||||
log.Fatalf("Error while parsing docker labels: %v", err)
|
||||
}
|
||||
for i, job := range jobs {
|
||||
if job.Id == backupId {
|
||||
chosenJob = &jobs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if chosenJob == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Backup job not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Run the backup asynchronously
|
||||
go func(cfg backup.Config) {
|
||||
statusMutex.Lock()
|
||||
backupStatus = "Running"
|
||||
statusMutex.Unlock()
|
||||
|
||||
backup.BackupJob(cfg)
|
||||
|
||||
statusMutex.Lock()
|
||||
backupStatus = "Completed"
|
||||
statusMutex.Unlock()
|
||||
}(*chosenJob)
|
||||
|
||||
c.Redirect(http.StatusSeeOther, "/")
|
||||
}
|
||||
|
||||
// Restore any database giving an Id
|
||||
func triggerRestore(c *gin.Context) {
|
||||
// Extract backup job name from the form POST data
|
||||
restoreId := c.PostForm("id")
|
||||
if restoreId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing restore job id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Find the backup job config with the matching name
|
||||
var chosenJob *backup.Config
|
||||
jobs, err := docker.GetBackupConfigsFromContainers()
|
||||
if err != nil {
|
||||
log.Fatalf("Error while parsing docker labels: %v", err)
|
||||
}
|
||||
for i, job := range jobs {
|
||||
if job.Id == restoreId {
|
||||
chosenJob = &jobs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if chosenJob == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Restore job not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Run the backup asynchronously
|
||||
go func(cfg backup.Config) {
|
||||
statusMutex.Lock()
|
||||
backupStatus = "Running"
|
||||
statusMutex.Unlock()
|
||||
|
||||
restore.RestoreJob(cfg)
|
||||
|
||||
statusMutex.Lock()
|
||||
backupStatus = "Completed"
|
||||
statusMutex.Unlock()
|
||||
}(*chosenJob)
|
||||
|
||||
c.Redirect(http.StatusSeeOther, "/")
|
||||
}
|
||||
|
||||
func SetupRouter() *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.Default()
|
||||
r.SetTrustedProxies([]string{"127.0.0.1"})
|
||||
r.LoadHTMLGlob("templates/*")
|
||||
r.POST("/backup", triggerDump)
|
||||
r.POST("/restore", triggerRestore)
|
||||
r.GET("/", ServeBackupPage)
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"ddbb/pkg/backup"
|
||||
"ddbb/pkg/docker"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ServeBackupPage(c *gin.Context) {
|
||||
allBackups, _ := docker.GetBackupConfigsFromContainers()
|
||||
setBackupDumpFiles(allBackups, "/backups/")
|
||||
|
||||
c.HTML(http.StatusOK, "backups.tmpl", gin.H{
|
||||
"Backups": allBackups,
|
||||
})
|
||||
}
|
||||
|
||||
func setBackupDumpFiles(allBackups []backup.Config, dumpDir string) error {
|
||||
for i := range allBackups {
|
||||
host := allBackups[i].Host
|
||||
// Try to find a dump file matching host.* in dumpDir
|
||||
pattern := filepath.Join(dumpDir, host+".*")
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
continue // no dump found for this host
|
||||
}
|
||||
dumpFile := matches[0] // take first match
|
||||
|
||||
fi, err := os.Stat(dumpFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
modTime := fi.ModTime().Format(time.RFC822)
|
||||
allBackups[i].DumpFile = dumpFile
|
||||
allBackups[i].DumpFileModTime = modTime
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Id string
|
||||
Kind string
|
||||
Host string
|
||||
Port string
|
||||
Database string
|
||||
User string
|
||||
Password string
|
||||
DumpFile string
|
||||
DumpFileModTime string
|
||||
}
|
||||
|
||||
type ConfigList struct {
|
||||
Jobs []Config
|
||||
}
|
||||
|
||||
var outFileRoot = "/backups/"
|
||||
|
||||
func BackupJob(job Config) {
|
||||
|
||||
var err error
|
||||
switch kind := job.Kind; kind {
|
||||
case "mariadb":
|
||||
err = backupMariaDB(job)
|
||||
case "postgres":
|
||||
err = backupPostgres(job)
|
||||
case "mongodb":
|
||||
err = backupMongoDB(job)
|
||||
default:
|
||||
log.Printf("Invalid database kind for job %s", job.Id)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func backupMariaDB(cfg Config) error {
|
||||
log.Printf("Starting dump of mariadb database %s on %s", cfg.Database, cfg.Host)
|
||||
|
||||
outFileName := outFileRoot + cfg.Host + ".sql"
|
||||
outFile, err := os.Create(outFileName)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create output file: %v", err)
|
||||
return err
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
cmd := exec.Command(
|
||||
"mariadb-dump",
|
||||
"--ssl-verify-server-cert=FALSE",
|
||||
"-h", cfg.Host,
|
||||
"-u", cfg.User,
|
||||
"-p"+cfg.Password,
|
||||
"-P", cfg.Port,
|
||||
cfg.Database,
|
||||
)
|
||||
cmd.Stdout = outFile
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Fatalf("Mariadb dump failed: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Database backup saved to %s", outFileName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func backupPostgres(cfg Config) error {
|
||||
outFileName := outFileRoot + cfg.Host + ".sql"
|
||||
outFile, err := os.Create(outFileName)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create output file: %v", err)
|
||||
return err
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
// Build pg_dump command
|
||||
cmd := exec.Command(
|
||||
"pg_dump",
|
||||
"--dbname="+fmt.Sprintf("postgresql://%s:%s@%s:%s/%s", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database),
|
||||
)
|
||||
cmd.Stdout = outFile
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
log.Printf("Running pg_dump for database %s", cfg.Database)
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Fatalf("pg_dump failed: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Database backup saved to %s", outFileName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func backupMongoDB(cfg Config) error {
|
||||
outFileName := outFileRoot + cfg.Host + ".gz"
|
||||
|
||||
cmd := exec.Command(
|
||||
"mongodump",
|
||||
"--uri",
|
||||
fmt.Sprintf("mongodb://%s:%s@%s:%s/%s?authSource=admin", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database),
|
||||
"--archive="+outFileName,
|
||||
"--gzip",
|
||||
)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
log.Printf("Running mongodump for database %s with gzip compression to file %s", cfg.Database, outFileName)
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Fatalf("mongodump failed: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("MongoDB backup saved to compressed archive %s", outFileName)
|
||||
return nil
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"ddbb/pkg/api"
|
||||
"github.com/robfig/cron/v3"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
func ScheduleBackupFromEnv() {
|
||||
expr := os.Getenv("BACKUP_CRON")
|
||||
if expr == "" {
|
||||
log.Fatal("Environment variable BACKUP_CRON not set")
|
||||
}
|
||||
|
||||
c := cron.New()
|
||||
|
||||
_, err := c.AddFunc(expr, func() {
|
||||
log.Println("Starting scheduled backup")
|
||||
api.BackupAllDatabases()
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid cron expression in BACKUP_CRON: %v", err)
|
||||
}
|
||||
|
||||
c.Start()
|
||||
// defer c.Stop() on application shutdown if needed
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"ddbb/pkg/backup"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
func GetBackupConfigsFromContainers() ([]backup.Config, error) {
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
containers, err := cli.ContainerList(context.Background(), container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var backups []backup.Config
|
||||
|
||||
for _, container := range containers {
|
||||
if container.Labels["ddbb.enable"] != "true" {
|
||||
continue
|
||||
}
|
||||
|
||||
kind := container.Labels["ddbb.kind"]
|
||||
id := container.ID[:12]
|
||||
|
||||
inspect, err := cli.ContainerInspect(context.Background(), container.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
envMap := map[string]string{}
|
||||
for _, env := range inspect.Config.Env {
|
||||
parts := strings.SplitN(env, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
envMap[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
var host, port, database, user, password string
|
||||
|
||||
// Set hostname
|
||||
if len(container.Names) > 0 {
|
||||
host = strings.TrimPrefix(container.Names[0], "/")
|
||||
} else {
|
||||
// fallback
|
||||
host = id
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case "mariadb":
|
||||
port = "3306"
|
||||
database = envMap["MARIADB_DATABASE"]
|
||||
user = envMap["MARIADB_USER"]
|
||||
password = envMap["MARIADB_PASSWORD"]
|
||||
case "postgres":
|
||||
port = "5432"
|
||||
database = envMap["POSTGRES_DB"]
|
||||
user = envMap["POSTGRES_USER"]
|
||||
password = envMap["POSTGRES_PASSWORD"]
|
||||
case "mongodb":
|
||||
port = "27017"
|
||||
database = envMap["MONGO_INITDB_DATABASE"]
|
||||
user = envMap["MONGO_INITDB_ROOT_USERNAME"]
|
||||
password = envMap["MONGO_INITDB_ROOT_PASSWORD"]
|
||||
default:
|
||||
log.Printf("Kind %s is not supported", kind)
|
||||
}
|
||||
|
||||
backups = append(backups, backup.Config{
|
||||
Id: id,
|
||||
Kind: kind,
|
||||
Host: host,
|
||||
Port: port,
|
||||
Database: database,
|
||||
User: user,
|
||||
Password: password,
|
||||
})
|
||||
}
|
||||
|
||||
return backups, nil
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package restore
|
||||
|
||||
import (
|
||||
"ddbb/pkg/backup"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var dumpFileRoot = "/backups/"
|
||||
|
||||
func RestoreJob(cfg backup.Config) {
|
||||
switch strings.ToLower(cfg.Kind) {
|
||||
case "mariadb":
|
||||
restoreMySQL(cfg)
|
||||
case "postgres":
|
||||
restorePostgres(cfg)
|
||||
case "mongodb":
|
||||
restoreMongo(cfg)
|
||||
default:
|
||||
log.Printf("unsupported database kind: %s", cfg.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func restoreMySQL(cfg backup.Config) error {
|
||||
cmd := exec.Command(
|
||||
"mariadb",
|
||||
"--ssl-verify-server-cert=FALSE",
|
||||
"-h", cfg.Host,
|
||||
"-u", cfg.User,
|
||||
fmt.Sprintf("-p%s", cfg.Password),
|
||||
cfg.Database,
|
||||
)
|
||||
dumpFileName := dumpFileRoot + cfg.Host + ".sql"
|
||||
dumpFile, err := os.Open(dumpFileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dumpFile.Close()
|
||||
cmd.Stdin = dumpFile
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("mysql restore failed: %s, output: %s", err, output)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Restore job with ID %s completed successfully", cfg.Id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func restorePostgres(cfg backup.Config) error {
|
||||
cmd := exec.Command(
|
||||
"psql", "-h", cfg.Host, "-U", cfg.User, "-d", cfg.Database,
|
||||
"-f", "-", // read input from stdin
|
||||
)
|
||||
dumpFileName := dumpFileRoot + cfg.Host + ".sql"
|
||||
dumpFile, err := os.Open(dumpFileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dumpFile.Close()
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", cfg.Password))
|
||||
cmd.Stdin = dumpFile
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("postgres restore failed: %s, output: %s", err, output)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Restore job with ID %s completed successfully", cfg.Id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func restoreMongo(cfg backup.Config) error {
|
||||
dumpFileName := dumpFileRoot + cfg.Host + ".gz"
|
||||
cmd := exec.Command(
|
||||
"mongorestore", "--archive", "--gzip", "--drop", "--archive="+dumpFileName,
|
||||
"--uri",
|
||||
fmt.Sprintf("mongodb://%s:%s@%s:%s/%s?authSource=admin", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database),
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("mongodb restore failed: %s, output: %s", err, output)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Restore job with ID %s completed successfully", cfg.Id)
|
||||
return nil
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
{{define "backups.tmpl"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Databases dump jobs</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Mono&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
.confirm-prompt {
|
||||
margin-top: 8px;
|
||||
font-size: 0.9em;
|
||||
color: #f0f0f0;
|
||||
background: #444;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.confirm-prompt button {
|
||||
margin-left: 6px;
|
||||
background-color: #e14c4c;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 4px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.confirm-prompt button.confirm-no {
|
||||
background-color: #444;
|
||||
}
|
||||
.confirm-prompt button:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
body {
|
||||
font-family: 'Fira Mono', Consolas, 'Courier New', monospace;
|
||||
background-color: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
margin: 20px;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #003e6b;
|
||||
margin-bottom: 30px;
|
||||
font-family: 'Fira Mono', monospace;
|
||||
}
|
||||
table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 8px;
|
||||
width: 90%;
|
||||
margin: auto;
|
||||
background: #252526;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.8);
|
||||
border-radius: 0px;
|
||||
overflow: hidden;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px 20px;
|
||||
text-align: left;
|
||||
font-variant-numeric: tabular-nums; /* consistent number width */
|
||||
}
|
||||
th {
|
||||
background-color: #003e6b;
|
||||
color: #d4d4d4;
|
||||
font-weight: 600;
|
||||
}
|
||||
tr:hover {
|
||||
background-color: #333842;
|
||||
}
|
||||
button {
|
||||
background-color: #007acc;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
font-family: 'Fira Mono', monospace;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #005a9e;
|
||||
}
|
||||
form {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
function confirmRestore(form) {
|
||||
// Check if confirmation prompt already exists
|
||||
if (form.querySelector('.confirm-prompt')) {
|
||||
return true; // allow submit on second confirmation
|
||||
}
|
||||
|
||||
// Create confirmation prompt div
|
||||
const prompt = document.createElement('div');
|
||||
prompt.className = 'confirm-prompt';
|
||||
prompt.innerHTML = `
|
||||
<span>Are you sure? </span>
|
||||
<button type='button' class='confirm-yes'>Yes</button>
|
||||
<button type='button' class='confirm-no'>No</button>
|
||||
`;
|
||||
|
||||
form.appendChild(prompt);
|
||||
|
||||
// Focus to yes button for accessibility
|
||||
prompt.querySelector('.confirm-yes').focus();
|
||||
|
||||
// Disable original submit button
|
||||
form.querySelector('button[type="submit"]').disabled = true;
|
||||
|
||||
// Handle yes/no clicks
|
||||
prompt.querySelector('.confirm-yes').onclick = () => {
|
||||
// Clear prompt and submit form
|
||||
form.removeChild(prompt);
|
||||
form.querySelector('button[type="submit"]').disabled = false;
|
||||
form.submit();
|
||||
};
|
||||
prompt.querySelector('.confirm-no').onclick = () => {
|
||||
// Remove prompt and re-enable button, do not submit
|
||||
form.removeChild(prompt);
|
||||
form.querySelector('button[type="submit"]').disabled = false;
|
||||
};
|
||||
|
||||
return false; // prevent normal submit until confirmed
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Databases dump jobs</h1>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Kind</th>
|
||||
<th>Host</th>
|
||||
<th>Port</th>
|
||||
<th>Database</th>
|
||||
<th>User</th>
|
||||
<th>Dump file</th>
|
||||
<th>Dump date</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Backups}}
|
||||
<tr>
|
||||
<td>{{.Id}}</td>
|
||||
<td>{{.Kind}}</td>
|
||||
<td>{{.Host}}</td>
|
||||
<td>{{.Port}}</td>
|
||||
<td>{{.Database}}</td>
|
||||
<td>{{.User}}</td>
|
||||
<td>{{.DumpFile}}</td>
|
||||
<td>{{.DumpFileModTime}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/backup" style="display:inline;">
|
||||
<input type="hidden" name="id" value="{{.Id}}">
|
||||
<button type="submit">Backup</button>
|
||||
</form>
|
||||
<form method="POST" action="/restore" class="restore-form" onsubmit="return confirmRestore(this);" style="display:inline; margin-left: 8px;">
|
||||
<input type="hidden" name="id" value="{{.Id}}">
|
||||
<button type="submit">Restore</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="7" style="text-align:center;">No backups found</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user