Add Unraid and Gotify notifications

This commit is contained in:
Mikei386
2026-06-14 13:50:02 +02:00
parent b7fe6ac86f
commit 1af23831f4
15 changed files with 232 additions and 44 deletions
-1
View File
@@ -1 +0,0 @@
8a28603e85c551b4581cbb6568d380600f5aca517d76aa512d2319dac5444014 backupper-2026.06.14.9-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
67e2f22e189eb4aa8f350d48e23e8a9a618bf5714d9c740ef24e5ee413d045ff backupper-2026.06.14.9.1-x86_64-1.txz
+6 -2
View File
@@ -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
View File
@@ -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
}
+43
View File
@@ -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")
+20
View File
@@ -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
View File
@@ -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:
+63
View File
@@ -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)
}
}
+5 -6
View File
@@ -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
} }
} }
+2
View File
@@ -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
} }
+5 -1
View File
@@ -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.
+5 -5
View File
@@ -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>
+3
View File
@@ -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
View File
@@ -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 &gt; 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 => {