Add Unraid and Gotify notifications
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
8a28603e85c551b4581cbb6568d380600f5aca517d76aa512d2319dac5444014 backupper-2026.06.14.9-x86_64-1.txz
|
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
67e2f22e189eb4aa8f350d48e23e8a9a618bf5714d9c740ef24e5ee413d045ff backupper-2026.06.14.9.1-x86_64-1.txz
|
||||||
Vendored
+6
-2
@@ -2,13 +2,17 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "backupper">
|
<!ENTITY name "backupper">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.06.14.9">
|
<!ENTITY version "2026.06.14.9.1">
|
||||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
|
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
|
||||||
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
|
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
|
||||||
<!ENTITY packageSHA256 "8a28603e85c551b4581cbb6568d380600f5aca517d76aa512d2319dac5444014">
|
<!ENTITY packageSHA256 "67e2f22e189eb4aa8f350d48e23e8a9a618bf5714d9c740ef24e5ee413d045ff">
|
||||||
]>
|
]>
|
||||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues" icon="backupper.png">
|
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues" icon="backupper.png">
|
||||||
<CHANGES>
|
<CHANGES>
|
||||||
|
### 2026.06.14.9.1
|
||||||
|
- Add native Unraid notifications and replace ntfy with Gotify.
|
||||||
|
- Allow success, warning, and failure events to be selected per notification channel.
|
||||||
|
|
||||||
### 2026.06.14.9
|
### 2026.06.14.9
|
||||||
- Replace the job cron input with daily, weekday, selected-day, and time controls.
|
- Replace the job cron input with daily, weekday, selected-day, and time controls.
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -155,7 +155,7 @@ func DefaultConfig() Config {
|
|||||||
SchemaVersion: SchemaVersion,
|
SchemaVersion: SchemaVersion,
|
||||||
Jobs: []Job{},
|
Jobs: []Job{},
|
||||||
Repositories: []Repository{},
|
Repositories: []Repository{},
|
||||||
Notifications: []NotificationTarget{},
|
Notifications: []NotificationTarget{{SchemaVersion: SchemaVersion, ID: "unraid", Name: "Unraid", Type: "unraid", Enabled: true, Events: []string{"success", "warning", "failed"}}},
|
||||||
Settings: Settings{
|
Settings: Settings{
|
||||||
ResticPath: "/usr/local/libexec/backupper/restic",
|
ResticPath: "/usr/local/libexec/backupper/restic",
|
||||||
RestoreRoot: "/mnt/user/backupper-restores",
|
RestoreRoot: "/mnt/user/backupper-restores",
|
||||||
@@ -165,3 +165,22 @@ func DefaultConfig() Config {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NormalizeConfig(c Config) Config {
|
||||||
|
notifications := make([]NotificationTarget, 0, len(c.Notifications)+1)
|
||||||
|
hasUnraid := false
|
||||||
|
for _, target := range c.Notifications {
|
||||||
|
if target.Type == "ntfy" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if target.Type == "unraid" {
|
||||||
|
hasUnraid = true
|
||||||
|
}
|
||||||
|
notifications = append(notifications, target)
|
||||||
|
}
|
||||||
|
if !hasUnraid {
|
||||||
|
notifications = append(notifications, NotificationTarget{SchemaVersion: SchemaVersion, ID: "unraid", Name: "Unraid", Type: "unraid", Enabled: true, Events: []string{"success", "warning", "failed"}})
|
||||||
|
}
|
||||||
|
c.Notifications = notifications
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package model
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -42,12 +43,54 @@ func ValidateConfig(c Config) error {
|
|||||||
}
|
}
|
||||||
repositoryOwners[job.RepositoryID] = job.ID
|
repositoryOwners[job.RepositoryID] = job.ID
|
||||||
}
|
}
|
||||||
|
notifications := make(map[string]struct{}, len(c.Notifications))
|
||||||
|
for _, target := range c.Notifications {
|
||||||
|
if err := ValidateNotification(target); err != nil {
|
||||||
|
return fmt.Errorf("notification %q: %w", target.ID, err)
|
||||||
|
}
|
||||||
|
if _, exists := notifications[target.ID]; exists {
|
||||||
|
return fmt.Errorf("duplicate notification id %q", target.ID)
|
||||||
|
}
|
||||||
|
notifications[target.ID] = struct{}{}
|
||||||
|
}
|
||||||
if !filepath.IsAbs(c.Settings.RestoreRoot) {
|
if !filepath.IsAbs(c.Settings.RestoreRoot) {
|
||||||
return errors.New("restoreRoot must be absolute")
|
return errors.New("restoreRoot must be absolute")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ValidateNotification(target NotificationTarget) error {
|
||||||
|
if target.SchemaVersion != SchemaVersion || !idPattern.MatchString(target.ID) {
|
||||||
|
return errors.New("invalid schemaVersion or id")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(target.Name) == "" {
|
||||||
|
return errors.New("name is required")
|
||||||
|
}
|
||||||
|
switch target.Type {
|
||||||
|
case "unraid":
|
||||||
|
case "gotify":
|
||||||
|
parsed, err := url.Parse(target.URL)
|
||||||
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||||
|
return errors.New("Gotify URL must be an absolute HTTP or HTTPS URL")
|
||||||
|
}
|
||||||
|
if !idPattern.MatchString(target.TokenRef) {
|
||||||
|
return errors.New("Gotify tokenRef must be a valid secret ID")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported notification type %q", target.Type)
|
||||||
|
}
|
||||||
|
allowed := map[string]bool{"success": true, "warning": true, "failed": true}
|
||||||
|
if target.Enabled && len(target.Events) == 0 {
|
||||||
|
return errors.New("enabled notification requires at least one event")
|
||||||
|
}
|
||||||
|
for _, event := range target.Events {
|
||||||
|
if !allowed[event] {
|
||||||
|
return fmt.Errorf("unsupported event %q", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func ValidateJob(j Job) error {
|
func ValidateJob(j Job) error {
|
||||||
if j.SchemaVersion != SchemaVersion || !idPattern.MatchString(j.ID) {
|
if j.SchemaVersion != SchemaVersion || !idPattern.MatchString(j.ID) {
|
||||||
return errors.New("invalid schemaVersion or id")
|
return errors.New("invalid schemaVersion or id")
|
||||||
|
|||||||
@@ -15,6 +15,26 @@ func TestValidateConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeConfigReplacesNtfyAndAddsUnraid(t *testing.T) {
|
||||||
|
c := DefaultConfig()
|
||||||
|
c.Notifications = []NotificationTarget{{SchemaVersion: 1, ID: "old-ntfy", Name: "ntfy", Type: "ntfy", Enabled: true}}
|
||||||
|
c = NormalizeConfig(c)
|
||||||
|
if len(c.Notifications) != 1 || c.Notifications[0].Type != "unraid" || !c.Notifications[0].Enabled {
|
||||||
|
t.Fatalf("notifications = %#v", c.Notifications)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGotifyNotification(t *testing.T) {
|
||||||
|
target := NotificationTarget{SchemaVersion: 1, ID: "gotify", Name: "Gotify", Type: "gotify", Enabled: true, URL: "https://gotify.example.test", TokenRef: "gotify-token", Events: []string{"failed"}}
|
||||||
|
if err := ValidateNotification(target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target.URL = "javascript:alert(1)"
|
||||||
|
if err := ValidateNotification(target); err == nil {
|
||||||
|
t.Fatal("unsafe Gotify URL accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRepositoryIsolation(t *testing.T) {
|
func TestRepositoryIsolation(t *testing.T) {
|
||||||
c := DefaultConfig()
|
c := DefaultConfig()
|
||||||
c.Jobs = []Job{{SchemaVersion: 1, ID: "job", Name: "Broken", Type: JobFlash, RepositoryID: "missing", Consistency: Consistency{Mode: "live"}, Compression: "auto"}}
|
c.Jobs = []Job{{SchemaVersion: 1, ID: "job", Name: "Broken", Type: JobFlash, RepositoryID: "missing", Consistency: Consistency{Mode: "live"}, Compression: "auto"}}
|
||||||
|
|||||||
+37
-12
@@ -3,6 +3,7 @@ package notify
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -18,6 +19,8 @@ type SecretGetter interface{ Get(string) (string, error) }
|
|||||||
type Sender struct {
|
type Sender struct {
|
||||||
Secrets SecretGetter
|
Secrets SecretGetter
|
||||||
Client *http.Client
|
Client *http.Client
|
||||||
|
NotifyPath string
|
||||||
|
RunCommand func(context.Context, string, ...string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Sender) Send(ctx context.Context, target model.NotificationTarget, title, message, severity string) error {
|
func (s *Sender) Send(ctx context.Context, target model.NotificationTarget, title, message, severity string) error {
|
||||||
@@ -27,25 +30,47 @@ func (s *Sender) Send(ctx context.Context, target model.NotificationTarget, titl
|
|||||||
switch target.Type {
|
switch target.Type {
|
||||||
case "unraid":
|
case "unraid":
|
||||||
importance := "normal"
|
importance := "normal"
|
||||||
if severity == "error" {
|
if severity == "failed" || severity == "error" {
|
||||||
importance = "alert"
|
importance = "alert"
|
||||||
|
} else if severity == "warning" {
|
||||||
|
importance = "warning"
|
||||||
}
|
}
|
||||||
return exec.CommandContext(ctx, "/usr/local/emhttp/webGui/scripts/notify", "-e", "Backupper", "-s", title, "-d", message, "-i", importance).Run()
|
path := s.NotifyPath
|
||||||
case "ntfy":
|
if path == "" {
|
||||||
endpoint := strings.TrimRight(target.URL, "/") + "/" + url.PathEscape(target.Topic)
|
path = "/usr/local/emhttp/webGui/scripts/notify"
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(message))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
req.Header.Set("Title", title)
|
args := []string{"-e", "Backupper", "-s", title, "-d", message, "-i", importance, "-l", "/Tools/Backupper"}
|
||||||
req.Header.Set("Tags", "floppy_disk")
|
if s.RunCommand != nil {
|
||||||
if target.TokenRef != "" {
|
return s.RunCommand(ctx, path, args...)
|
||||||
|
}
|
||||||
|
return exec.CommandContext(ctx, path, args...).Run()
|
||||||
|
case "gotify":
|
||||||
token, err := s.Secrets.Get(target.TokenRef)
|
token, err := s.Secrets.Get(target.TokenRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
endpoint, err := url.Parse(strings.TrimRight(target.URL, "/") + "/message")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
query := endpoint.Query()
|
||||||
|
query.Set("token", token)
|
||||||
|
endpoint.RawQuery = query.Encode()
|
||||||
|
priority := 2
|
||||||
|
if severity == "warning" {
|
||||||
|
priority = 5
|
||||||
|
} else if severity == "failed" || severity == "error" {
|
||||||
|
priority = 8
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(map[string]any{"title": title, "message": message, "priority": priority})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
client := s.Client
|
client := s.Client
|
||||||
if client == nil {
|
if client == nil {
|
||||||
client = &http.Client{Timeout: 15 * time.Second}
|
client = &http.Client{Timeout: 15 * time.Second}
|
||||||
@@ -56,7 +81,7 @@ func (s *Sender) Send(ctx context.Context, target model.NotificationTarget, titl
|
|||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
return fmt.Errorf("ntfy returned %s", response.Status)
|
return fmt.Errorf("Gotify returned %s", response.Status)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/backupper-unraid/backupper/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testSecrets map[string]string
|
||||||
|
|
||||||
|
func (s testSecrets) Get(id string) (string, error) { return s[id], nil }
|
||||||
|
|
||||||
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
|
||||||
|
|
||||||
|
func TestSendGotify(t *testing.T) {
|
||||||
|
var body map[string]any
|
||||||
|
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Path != "/message" || r.URL.Query().Get("token") != "secret-token" {
|
||||||
|
t.Errorf("unexpected request URL %s", r.URL.String())
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Body: io.NopCloser(strings.NewReader("{}")), Header: make(http.Header)}, nil
|
||||||
|
})}
|
||||||
|
|
||||||
|
sender := &Sender{Secrets: testSecrets{"gotify-token": "secret-token"}, Client: client}
|
||||||
|
target := model.NotificationTarget{Enabled: true, Type: "gotify", URL: "https://gotify.example.test", TokenRef: "gotify-token"}
|
||||||
|
if err := sender.Send(context.Background(), target, "Backup failed", "repository unavailable", "failed"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body["title"] != "Backup failed" || body["message"] != "repository unavailable" || body["priority"] != float64(8) {
|
||||||
|
t.Fatalf("unexpected Gotify payload %#v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendUnraid(t *testing.T) {
|
||||||
|
var command string
|
||||||
|
var args []string
|
||||||
|
sender := &Sender{RunCommand: func(_ context.Context, name string, values ...string) error {
|
||||||
|
command, args = name, values
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
target := model.NotificationTarget{Enabled: true, Type: "unraid"}
|
||||||
|
if err := sender.Send(context.Background(), target, "Backup warning", "completed with warnings", "warning"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if command != "/usr/local/emhttp/webGui/scripts/notify" {
|
||||||
|
t.Fatalf("unexpected command %q", command)
|
||||||
|
}
|
||||||
|
want := []string{"-e", "Backupper", "-s", "Backup warning", "-d", "completed with warnings", "-i", "warning", "-l", "/Tools/Backupper"}
|
||||||
|
if !reflect.DeepEqual(args, want) {
|
||||||
|
t.Fatalf("arguments = %#v, want %#v", args, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,7 @@ func (s *Service) LastRun(jobID string) time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) SaveConfig(config model.Config) error {
|
func (s *Service) SaveConfig(config model.Config) error {
|
||||||
|
config = model.NormalizeConfig(config)
|
||||||
for _, job := range config.Jobs {
|
for _, job := range config.Jobs {
|
||||||
if job.Schedule.Cron != "" {
|
if job.Schedule.Cron != "" {
|
||||||
if _, err := scheduler.Parse(job.Schedule.Cron); err != nil {
|
if _, err := scheduler.Parse(job.Schedule.Cron); err != nil {
|
||||||
@@ -316,12 +317,8 @@ func (s *Service) executeRestore(ctx context.Context, run model.Run) model.Run {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) finishJob(run model.Run, job model.Job, result model.Run) model.Run {
|
func (s *Service) finishJob(run model.Run, job model.Job, result model.Run) model.Run {
|
||||||
event := result.Status
|
if result.Status == "success" || result.Status == "warning" || result.Status == "failed" {
|
||||||
for _, wanted := range job.NotifyOn {
|
|
||||||
if wanted == event {
|
|
||||||
go s.notify(job.Name, result)
|
go s.notify(job.Name, result)
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -330,7 +327,9 @@ func (s *Service) notify(name string, run model.Run) {
|
|||||||
for _, target := range s.Config().Notifications {
|
for _, target := range s.Config().Notifications {
|
||||||
for _, event := range target.Events {
|
for _, event := range target.Events {
|
||||||
if event == run.Status {
|
if event == run.Status {
|
||||||
_ = s.notifier.Send(context.Background(), target, "Backupper: "+name, run.Message, run.Status)
|
if err := s.notifier.Send(context.Background(), target, "Backupper: "+name, run.Message, run.Status); err != nil {
|
||||||
|
s.log.Warn("send notification failed", "target", target.ID, "type", target.Type, "error", err)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,10 +35,12 @@ func (s *Store) LoadConfig() (model.Config, error) {
|
|||||||
if err := readJSON(s.configPath(), &c); err != nil {
|
if err := readJSON(s.configPath(), &c); err != nil {
|
||||||
return c, err
|
return c, err
|
||||||
}
|
}
|
||||||
|
c = model.NormalizeConfig(c)
|
||||||
return c, model.ValidateConfig(c)
|
return c, model.ValidateConfig(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SaveConfig(c model.Config) error {
|
func (s *Store) SaveConfig(c model.Config) error {
|
||||||
|
c = model.NormalizeConfig(c)
|
||||||
if err := model.ValidateConfig(c); err != nil {
|
if err := model.ValidateConfig(c); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,17 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "backupper">
|
<!ENTITY name "backupper">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.06.14.9">
|
<!ENTITY version "2026.06.14.9.1">
|
||||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
|
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
|
||||||
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
|
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
|
||||||
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
|
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
|
||||||
]>
|
]>
|
||||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues" icon="backupper.png">
|
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues" icon="backupper.png">
|
||||||
<CHANGES>
|
<CHANGES>
|
||||||
|
### 2026.06.14.9.1
|
||||||
|
- Add native Unraid notifications and replace ntfy with Gotify.
|
||||||
|
- Allow success, warning, and failure events to be selected per notification channel.
|
||||||
|
|
||||||
### 2026.06.14.9
|
### 2026.06.14.9
|
||||||
- Replace the job cron input with daily, weekday, selected-day, and time controls.
|
- Replace the job cron input with daily, weekday, selected-day, and time controls.
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ Tag="backup restic snapshots restore"
|
|||||||
<?php
|
<?php
|
||||||
$pluginRoot = '/plugins/backupper';
|
$pluginRoot = '/plugins/backupper';
|
||||||
?>
|
?>
|
||||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=202606149">
|
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=2026061491">
|
||||||
<div id="backupper-app" data-api="<?= $pluginRoot ?>/api.php">
|
<div id="backupper-app" data-api="<?= $pluginRoot ?>/api.php">
|
||||||
<header class="bu-header">
|
<header class="bu-header">
|
||||||
<div class="bu-brand">
|
<div class="bu-brand">
|
||||||
<img class="bu-logo" src="<?= $pluginRoot ?>/images/backupper.png?v=202606149" alt="Backupper logo">
|
<img class="bu-logo" src="<?= $pluginRoot ?>/images/backupper.png?v=2026061491" alt="Backupper logo">
|
||||||
<div><h1>Backupper <span class="bu-version">2026.06.14.9</span></h1><p>Encrypted Restic backups for Unraid</p></div>
|
<div><h1>Backupper <span class="bu-version">2026.06.14.9.1</span></h1><p>Encrypted Restic backups for Unraid</p></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die Backupper-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Connecting...</div>
|
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die Backupper-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Connecting...</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -25,11 +25,11 @@ $pluginRoot = '/plugins/backupper';
|
|||||||
<button data-view="snapshots" data-tooltip="Zeigt vorhandene Sicherungsstände und deren Dateien. Beispiel: Snapshot vom gestrigen Backup öffnen.">Snapshots</button>
|
<button data-view="snapshots" data-tooltip="Zeigt vorhandene Sicherungsstände und deren Dateien. Beispiel: Snapshot vom gestrigen Backup öffnen.">Snapshots</button>
|
||||||
<button data-view="restore" data-tooltip="Stellt Dateien oder Ordner aus einem Snapshot wieder her. Standardmäßig in ein separates Staging-Verzeichnis.">Restore</button>
|
<button data-view="restore" data-tooltip="Stellt Dateien oder Ordner aus einem Snapshot wieder her. Standardmäßig in ein separates Staging-Verzeichnis.">Restore</button>
|
||||||
<button data-view="activity" data-tooltip="Zeigt wartende, laufende und abgeschlossene Backup-, Restore- und Wartungsaufgaben.">Activity</button>
|
<button data-view="activity" data-tooltip="Zeigt wartende, laufende und abgeschlossene Backup-, Restore- und Wartungsaufgaben.">Activity</button>
|
||||||
<button data-view="settings" data-tooltip="Globale Einstellungen und ntfy-Benachrichtigungen konfigurieren.">Settings</button>
|
<button data-view="settings" data-tooltip="Globale Einstellungen sowie native Unraid- und Gotify-Benachrichtigungen konfigurieren.">Settings</button>
|
||||||
</nav>
|
</nav>
|
||||||
<main id="bu-content" aria-live="polite"></main>
|
<main id="bu-content" aria-live="polite"></main>
|
||||||
<div id="bu-toast" role="status" aria-live="polite"></div>
|
<div id="bu-toast" role="status" aria-live="polite"></div>
|
||||||
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
|
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
<script>window.BACKUPPER_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
|
<script>window.BACKUPPER_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
|
||||||
<script src="<?= $pluginRoot ?>/assets/backupper-2026.06.14.9.js"></script>
|
<script src="<?= $pluginRoot ?>/assets/backupper-2026.06.14.9.1.js"></script>
|
||||||
|
|||||||
@@ -96,6 +96,9 @@
|
|||||||
#backupper-app .bu-weekdays input { opacity: 0; pointer-events: none; position: absolute; }
|
#backupper-app .bu-weekdays input { opacity: 0; pointer-events: none; position: absolute; }
|
||||||
#backupper-app .bu-weekdays input:checked + span { background: var(--bu-accent) !important; border-color: var(--bu-accent) !important; color: #fff !important; }
|
#backupper-app .bu-weekdays input:checked + span { background: var(--bu-accent) !important; border-color: var(--bu-accent) !important; color: #fff !important; }
|
||||||
.bu-custom-cron { margin-top: 14px; }
|
.bu-custom-cron { margin-top: 14px; }
|
||||||
|
.bu-event-options { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||||
|
#backupper-app .bu-event-options label { align-items: center; display: flex; gap: 7px; grid-template-columns: auto auto; }
|
||||||
|
#backupper-app .bu-event-options input[type="checkbox"] { margin: 0; min-height: 20px; width: 20px; }
|
||||||
|
|
||||||
#backupper-app .bu-button {
|
#backupper-app .bu-button {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
|||||||
+17
-11
@@ -46,10 +46,10 @@
|
|||||||
catchUp: 'Wie viele Stunden nach einem verpassten Termin ein Job beim Boot nachgeholt werden darf. Beispiel: 24 holt einen innerhalb des letzten Tages verpassten Lauf nach.',
|
catchUp: 'Wie viele Stunden nach einem verpassten Termin ein Job beim Boot nachgeholt werden darf. Beispiel: 24 holt einen innerhalb des letzten Tages verpassten Lauf nach.',
|
||||||
checkCron: 'Zeitplan für vollständige Repository-Prüfungen. Beispiel: 0 3 1 * * prüft am ersten Tag jedes Monats um 03:00 Uhr.',
|
checkCron: 'Zeitplan für vollständige Repository-Prüfungen. Beispiel: 0 3 1 * * prüft am ersten Tag jedes Monats um 03:00 Uhr.',
|
||||||
pruneCron: 'Zeitplan zum Freigeben nicht mehr benötigter Restic-Daten. Beispiel: 0 4 * * 0 startet sonntags um 04:00 Uhr.',
|
pruneCron: 'Zeitplan zum Freigeben nicht mehr benötigter Restic-Daten. Beispiel: 0 4 * * 0 startet sonntags um 04:00 Uhr.',
|
||||||
url: 'Adresse des ntfy-Servers. Öffentliches Beispiel: https://ntfy.sh. Für einen eigenen Server z. B. https://ntfy.example.de.',
|
url: 'Basisadresse deines Gotify-Servers. Beispiel: https://gotify.example.de oder http://192.168.1.20:8080.',
|
||||||
topic: 'ntfy-Thema, an das Meldungen gesendet werden. Beispiel: unraid-backup-7f42. Verwende bei ntfy.sh einen schwer erratbaren Namen.',
|
tokenRef: 'Interne ID für das verschlüsselt gespeicherte Gotify-App-Token. Beispiel: gotify-token-main.',
|
||||||
tokenRef: 'Interne ID für das verschlüsselt gespeicherte ntfy-Token. Beispiel: ntfy-token-main.',
|
token: 'App-Token aus Gotify unter Apps. Das Token wird verschlüsselt gespeichert und nicht erneut angezeigt.',
|
||||||
token: 'Optionales Bearer-Token deines ntfy-Servers. Leer lassen, wenn das Topic keine Anmeldung benötigt.',
|
events: 'Legt fest, bei welchen Ergebnissen dieser Kanal informiert. Mindestens ein Ereignis muss aktiv sein.',
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionHelp = {
|
const actionHelp = {
|
||||||
@@ -62,7 +62,8 @@
|
|||||||
'restore-selected': 'Übernimmt die markierten Dateien oder Ordner in den Restore-Assistenten.',
|
'restore-selected': 'Übernimmt die markierten Dateien oder Ordner in den Restore-Assistenten.',
|
||||||
'submit-restore': 'Stellt den Restore in die globale Warteschlange; Backups und Restores laufen nacheinander.',
|
'submit-restore': 'Stellt den Restore in die globale Warteschlange; Backups und Restores laufen nacheinander.',
|
||||||
'submit-settings': 'Speichert die globalen Backupper-Einstellungen.',
|
'submit-settings': 'Speichert die globalen Backupper-Einstellungen.',
|
||||||
'submit-notify': 'Speichert den ntfy-Kanal und das Token verschlüsselt.',
|
'submit-unraid-notify': 'Speichert native Unraid-Benachrichtigungen. Die Zustellung erfolgt über die in Unraid eingerichteten Notification Agents.',
|
||||||
|
'submit-gotify-notify': 'Speichert den Gotify-Kanal und das App-Token verschlüsselt.',
|
||||||
'refresh': 'Lädt Laufstatus und Historie erneut vom Backupper-Daemon.',
|
'refresh': 'Lädt Laufstatus und Historie erneut vom Backupper-Daemon.',
|
||||||
'cancel-form': 'Verwirft ungespeicherte Eingaben und kehrt zur Übersicht zurück.',
|
'cancel-form': 'Verwirft ungespeicherte Eingaben und kehrt zur Übersicht zurück.',
|
||||||
'browser-up': 'Öffnet im Snapshot-Browser das übergeordnete Verzeichnis.',
|
'browser-up': 'Öffnet im Snapshot-Browser das übergeordnete Verzeichnis.',
|
||||||
@@ -205,7 +206,8 @@
|
|||||||
'Restore': 'Wiederherstellung ausgewählter Dateien oder Ordner. Staging ist die sichere Standardmethode.',
|
'Restore': 'Wiederherstellung ausgewählter Dateien oder Ordner. Staging ist die sichere Standardmethode.',
|
||||||
'Activity': 'Globale Warteschlange und Historie aller Backupper-Aufgaben.',
|
'Activity': 'Globale Warteschlange und Historie aller Backupper-Aufgaben.',
|
||||||
'Settings': 'Globale Pfade sowie Zeitpläne für Check, Prune und Nachholläufe.',
|
'Settings': 'Globale Pfade sowie Zeitpläne für Check, Prune und Nachholläufe.',
|
||||||
'ntfy notification': 'Optionaler Push-Kanal für Erfolg, Warnung und Fehler.',
|
'Unraid notifications': 'Native Meldungen im Unraid-Webinterface und über alle unter Einstellungen > Benachrichtigungen aktivierten Agenten.',
|
||||||
|
'Gotify notification': 'Optionaler direkter Push-Kanal zu deinem eigenen Gotify-Server.',
|
||||||
'Recent activity': 'Die zuletzt gestarteten Backup-, Restore- und Wartungsvorgänge.',
|
'Recent activity': 'Die zuletzt gestarteten Backup-, Restore- und Wartungsvorgänge.',
|
||||||
};
|
};
|
||||||
container.querySelectorAll('h2, h3').forEach(heading => setTooltip(heading, sectionHelp[heading.textContent.trim()]));
|
container.querySelectorAll('h2, h3').forEach(heading => setTooltip(heading, sectionHelp[heading.textContent.trim()]));
|
||||||
@@ -410,9 +412,12 @@
|
|||||||
|
|
||||||
function renderSettings() {
|
function renderSettings() {
|
||||||
const s = state.config.settings;
|
const s = state.config.settings;
|
||||||
const ntfy = state.config.notifications.find(n=>n.type==='ntfy') || {};
|
const unraid = state.config.notifications.find(n=>n.type==='unraid') || {id:'unraid',enabled:true,events:['success','warning','failed']};
|
||||||
|
const gotify = state.config.notifications.find(n=>n.type==='gotify') || {id:'gotify',enabled:false,events:['success','warning','failed']};
|
||||||
|
const eventBoxes = target => [['success','Success'],['warning','Warning'],['failed','Failed']].map(([value,label])=>`<label><input name="events" type="checkbox" value="${value}" ${(target.events||[]).includes(value)?'checked':''}><span>${label}</span></label>`).join('');
|
||||||
return `<section class="bu-card"><h2>Settings</h2><form id="bu-settings-form" class="bu-form"><label class="wide">Restic binary<input name="resticPath" value="${esc(s.resticPath)}"></label><label class="wide">Restore staging root<input name="restoreRoot" value="${esc(s.restoreRoot)}"></label><label class="wide">Persistent log directory<input name="persistentLogDir" value="${esc(s.persistentLogDir||'')}"></label><label>Catch-up window (hours)<input name="catchUp" type="number" value="${s.catchUpWindowHours||24}"></label><label>Monthly check cron<input name="checkCron" value="${esc(s.checkCron)}"></label><label>Prune cron<input name="pruneCron" value="${esc(s.pruneCron)}"></label><div class="wide bu-actions">${button('Save settings','submit-settings','primary')}</div></form></section>
|
return `<section class="bu-card"><h2>Settings</h2><form id="bu-settings-form" class="bu-form"><label class="wide">Restic binary<input name="resticPath" value="${esc(s.resticPath)}"></label><label class="wide">Restore staging root<input name="restoreRoot" value="${esc(s.restoreRoot)}"></label><label class="wide">Persistent log directory<input name="persistentLogDir" value="${esc(s.persistentLogDir||'')}"></label><label>Catch-up window (hours)<input name="catchUp" type="number" value="${s.catchUpWindowHours||24}"></label><label>Monthly check cron<input name="checkCron" value="${esc(s.checkCron)}"></label><label>Prune cron<input name="pruneCron" value="${esc(s.pruneCron)}"></label><div class="wide bu-actions">${button('Save settings','submit-settings','primary')}</div></form></section>
|
||||||
<section class="bu-card" style="margin-top:16px"><h2>ntfy notification</h2><form id="bu-notify-form" class="bu-form"><input type="hidden" name="id" value="${esc(ntfy.id||id('ntfy'))}"><label>Server URL<input name="url" value="${esc(ntfy.url||'https://ntfy.sh')}"></label><label>Topic<input name="topic" value="${esc(ntfy.topic||'')}"></label><label>Token secret ID<input name="tokenRef" value="${esc(ntfy.tokenRef||id('ntfy-token'))}"></label><label>Token<input name="token" type="password"></label><label>Enabled<input name="enabled" type="checkbox" ${ntfy.enabled?'checked':''}></label><div class="wide bu-actions">${button('Save notification','submit-notify','primary')}</div></form></section>`;
|
<section class="bu-card" style="margin-top:16px"><h2>Unraid notifications</h2><p class="bu-muted">Uses Unraid's notification system and all agents configured under Settings > Notification Settings.</p><form id="bu-unraid-notify-form" class="bu-form"><input type="hidden" name="id" value="${esc(unraid.id)}"><label><span>Enabled</span><input name="enabled" type="checkbox" ${unraid.enabled?'checked':''}></label><fieldset class="wide bu-fieldset"><legend>Notify on</legend><div class="bu-event-options">${eventBoxes(unraid)}</div></fieldset><div class="wide bu-actions">${button('Save Unraid notifications','submit-unraid-notify','primary')}</div></form></section>
|
||||||
|
<section class="bu-card" style="margin-top:16px"><h2>Gotify notification</h2><p class="bu-muted">Create an application in Gotify and enter its application token here.</p><form id="bu-gotify-notify-form" class="bu-form"><input type="hidden" name="id" value="${esc(gotify.id||'gotify')}"><label class="wide">Server URL<input name="url" type="url" placeholder="https://gotify.example.de" value="${esc(gotify.url||'')}"></label><label>Token secret ID<input name="tokenRef" value="${esc(gotify.tokenRef||'gotify-token')}"></label><label>Application token<input name="token" type="password" autocomplete="new-password"></label><label><span>Enabled</span><input name="enabled" type="checkbox" ${gotify.enabled?'checked':''}></label><fieldset class="wide bu-fieldset"><legend>Notify on</legend><div class="bu-event-options">${eventBoxes(gotify)}</div></fieldset><div class="wide bu-actions">${button('Save Gotify notification','submit-gotify-notify','primary')}</div></form></section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
@@ -462,7 +467,7 @@
|
|||||||
else sources=[...state.backupSelections.map(path=>({path})),...lines(f.get('sources')||'').map(path=>({path}))].filter((item,index,all)=>all.findIndex(other=>other.path===item.path)===index);
|
else sources=[...state.backupSelections.map(path=>({path})),...lines(f.get('sources')||'').map(path=>({path}))].filter((item,index,all)=>all.findIndex(other=>other.path===item.path)===index);
|
||||||
const retention={keepWithinDays:Number(f.get('keepWithinDays')),daily:Number(f.get('keepDaily')),weekly:Number(f.get('keepWeekly')),monthly:Number(f.get('keepMonthly'))};
|
const retention={keepWithinDays:Number(f.get('keepWithinDays')),daily:Number(f.get('keepDaily')),weekly:Number(f.get('keepWeekly')),monthly:Number(f.get('keepMonthly'))};
|
||||||
if(!retention.keepWithinDays&&!retention.daily&&!retention.weekly&&!retention.monthly) throw new Error('Retention must keep at least one age or calendar policy');
|
if(!retention.keepWithinDays&&!retention.daily&&!retention.weekly&&!retention.monthly) throw new Error('Retention must keep at least one age or calendar policy');
|
||||||
const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:f.get('repositoryId'),sources,schedule:{cron:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:f.get('compression'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],retention,notifyOn:existing?.notifyOn||['failed','warning'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)};
|
const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:f.get('repositoryId'),sources,schedule:{cron:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:f.get('compression'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],retention,notifyOn:existing?.notifyOn||['success','warning','failed'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)};
|
||||||
state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render();
|
state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render();
|
||||||
}
|
}
|
||||||
if (kind === 'submit-repo') {
|
if (kind === 'submit-repo') {
|
||||||
@@ -479,7 +484,8 @@
|
|||||||
}
|
}
|
||||||
if (kind === 'submit-restore') { const f=new FormData(document.getElementById('bu-restore-form')); await api('/v1/restores',{method:'POST',body:JSON.stringify({schemaVersion:1,id:'',repositoryId:f.get('repositoryId'),snapshotId:f.get('snapshotId'),includes:lines(f.get('includes')),target:f.get('target'),inPlace:f.get('inPlace')==='on',confirmed:f.get('confirmed')==='on'})}); notify('Restore queued'); state.view='activity'; render(); }
|
if (kind === 'submit-restore') { const f=new FormData(document.getElementById('bu-restore-form')); await api('/v1/restores',{method:'POST',body:JSON.stringify({schemaVersion:1,id:'',repositoryId:f.get('repositoryId'),snapshotId:f.get('snapshotId'),includes:lines(f.get('includes')),target:f.get('target'),inPlace:f.get('inPlace')==='on',confirmed:f.get('confirmed')==='on'})}); notify('Restore queued'); state.view='activity'; render(); }
|
||||||
if (kind === 'submit-settings') { const f=new FormData(document.getElementById('bu-settings-form')); state.config.settings={resticPath:f.get('resticPath'),restoreRoot:f.get('restoreRoot'),persistentLogDir:f.get('persistentLogDir'),catchUpWindowHours:Number(f.get('catchUp')),checkCron:f.get('checkCron'),pruneCron:f.get('pruneCron')}; await saveConfig(); }
|
if (kind === 'submit-settings') { const f=new FormData(document.getElementById('bu-settings-form')); state.config.settings={resticPath:f.get('resticPath'),restoreRoot:f.get('restoreRoot'),persistentLogDir:f.get('persistentLogDir'),catchUpWindowHours:Number(f.get('catchUp')),checkCron:f.get('checkCron'),pruneCron:f.get('pruneCron')}; await saveConfig(); }
|
||||||
if (kind === 'submit-notify') { const f=new FormData(document.getElementById('bu-notify-form')); const target={schemaVersion:1,id:f.get('id'),name:'ntfy',type:'ntfy',enabled:f.get('enabled')==='on',url:f.get('url'),topic:f.get('topic'),tokenRef:f.get('tokenRef'),events:['failed','warning','success']}; if(f.get('token')) await api(`/v1/secrets/${encodeURIComponent(target.tokenRef)}`,{method:'PUT',body:JSON.stringify({type:'notification-token',value:f.get('token')})}); state.config.notifications=state.config.notifications.filter(n=>n.type!=='ntfy').concat(target); await saveConfig(); render(); }
|
if (kind === 'submit-unraid-notify') { const f=new FormData(document.getElementById('bu-unraid-notify-form')); const target={schemaVersion:1,id:f.get('id')||'unraid',name:'Unraid',type:'unraid',enabled:f.get('enabled')==='on',events:f.getAll('events')}; if(target.enabled&&!target.events.length) throw new Error('Select at least one event for Unraid notifications'); state.config.notifications=state.config.notifications.filter(n=>n.type!=='unraid'&&n.type!=='ntfy').concat(target); await saveConfig(); render(); }
|
||||||
|
if (kind === 'submit-gotify-notify') { const f=new FormData(document.getElementById('bu-gotify-notify-form')); const target={schemaVersion:1,id:f.get('id')||'gotify',name:'Gotify',type:'gotify',enabled:f.get('enabled')==='on',url:String(f.get('url')||'').trim(),tokenRef:String(f.get('tokenRef')||'').trim(),events:f.getAll('events')}; if(target.enabled&&(!target.url||!target.tokenRef)) throw new Error('Gotify server URL and token secret ID are required'); if(target.enabled&&!target.events.length) throw new Error('Select at least one event for Gotify notifications'); if(f.get('token')) await api(`/v1/secrets/${encodeURIComponent(target.tokenRef)}`,{method:'PUT',body:JSON.stringify({type:'gotify-token',value:f.get('token')})}); state.config.notifications=state.config.notifications.filter(n=>n.type!=='gotify'&&n.type!=='ntfy'); if(target.enabled||target.url) state.config.notifications.push(target); await saveConfig(); render(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector('.bu-tabs').addEventListener('click', e => { const button=e.target.closest('[data-view]'); if(!button)return; state.view=button.dataset.view; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x===button)); render(); });
|
document.querySelector('.bu-tabs').addEventListener('click', e => { const button=e.target.closest('[data-view]'); if(!button)return; state.view=button.dataset.view; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x===button)); render(); });
|
||||||
@@ -510,7 +516,7 @@
|
|||||||
const form = e.target.closest('form');
|
const form = e.target.closest('form');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const action = form.id === 'bu-repo-form' ? 'submit-repo' : form.id === 'bu-job-form' ? 'submit-job' : form.id === 'bu-restore-form' ? 'submit-restore' : form.id === 'bu-settings-form' ? 'submit-settings' : form.id === 'bu-notify-form' ? 'submit-notify' : '';
|
const action = form.id === 'bu-repo-form' ? 'submit-repo' : form.id === 'bu-job-form' ? 'submit-job' : form.id === 'bu-restore-form' ? 'submit-restore' : form.id === 'bu-settings-form' ? 'submit-settings' : form.id === 'bu-unraid-notify-form' ? 'submit-unraid-notify' : form.id === 'bu-gotify-notify-form' ? 'submit-gotify-notify' : '';
|
||||||
if (action) handle(action);
|
if (action) handle(action);
|
||||||
});
|
});
|
||||||
root.addEventListener('mouseover', e => {
|
root.addEventListener('mouseover', e => {
|
||||||
|
|||||||
Reference in New Issue
Block a user