diff --git a/dist/backupper-2026.06.14.9-x86_64-1.txz.sha256 b/dist/backupper-2026.06.14.9-x86_64-1.txz.sha256 deleted file mode 100644 index 45af24d..0000000 --- a/dist/backupper-2026.06.14.9-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -8a28603e85c551b4581cbb6568d380600f5aca517d76aa512d2319dac5444014 backupper-2026.06.14.9-x86_64-1.txz diff --git a/dist/backupper-2026.06.14.9-x86_64-1.txz b/dist/backupper-2026.06.14.9.1-x86_64-1.txz similarity index 55% rename from dist/backupper-2026.06.14.9-x86_64-1.txz rename to dist/backupper-2026.06.14.9.1-x86_64-1.txz index 43245dd..0307d25 100644 Binary files a/dist/backupper-2026.06.14.9-x86_64-1.txz and b/dist/backupper-2026.06.14.9.1-x86_64-1.txz differ diff --git a/dist/backupper-2026.06.14.9.1-x86_64-1.txz.sha256 b/dist/backupper-2026.06.14.9.1-x86_64-1.txz.sha256 new file mode 100644 index 0000000..5054ef4 --- /dev/null +++ b/dist/backupper-2026.06.14.9.1-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +67e2f22e189eb4aa8f350d48e23e8a9a618bf5714d9c740ef24e5ee413d045ff backupper-2026.06.14.9.1-x86_64-1.txz diff --git a/dist/backupper.plg b/dist/backupper.plg index c31ee27..8d7226b 100644 --- a/dist/backupper.plg +++ b/dist/backupper.plg @@ -2,13 +2,17 @@ - + - + ]> +### 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 - Replace the job cron input with daily, weekday, selected-day, and time controls. diff --git a/internal/model/model.go b/internal/model/model.go index fa4674a..1c60d9e 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -155,7 +155,7 @@ func DefaultConfig() Config { SchemaVersion: SchemaVersion, Jobs: []Job{}, Repositories: []Repository{}, - Notifications: []NotificationTarget{}, + Notifications: []NotificationTarget{{SchemaVersion: SchemaVersion, ID: "unraid", Name: "Unraid", Type: "unraid", Enabled: true, Events: []string{"success", "warning", "failed"}}}, Settings: Settings{ ResticPath: "/usr/local/libexec/backupper/restic", 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 +} diff --git a/internal/model/validate.go b/internal/model/validate.go index ba3dc0d..1cbc9c1 100644 --- a/internal/model/validate.go +++ b/internal/model/validate.go @@ -3,6 +3,7 @@ package model import ( "errors" "fmt" + "net/url" "path/filepath" "regexp" "strings" @@ -42,12 +43,54 @@ func ValidateConfig(c Config) error { } 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) { return errors.New("restoreRoot must be absolute") } 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 { if j.SchemaVersion != SchemaVersion || !idPattern.MatchString(j.ID) { return errors.New("invalid schemaVersion or id") diff --git a/internal/model/validate_test.go b/internal/model/validate_test.go index 55ecbab..054d7b1 100644 --- a/internal/model/validate_test.go +++ b/internal/model/validate_test.go @@ -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) { c := DefaultConfig() c.Jobs = []Job{{SchemaVersion: 1, ID: "job", Name: "Broken", Type: JobFlash, RepositoryID: "missing", Consistency: Consistency{Mode: "live"}, Compression: "auto"}} diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 27e2b4a..5ee7518 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -3,6 +3,7 @@ package notify import ( "bytes" "context" + "encoding/json" "fmt" "net/http" "net/url" @@ -16,8 +17,10 @@ import ( type SecretGetter interface{ Get(string) (string, error) } type Sender struct { - Secrets SecretGetter - Client *http.Client + Secrets SecretGetter + 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 { @@ -27,25 +30,47 @@ func (s *Sender) Send(ctx context.Context, target model.NotificationTarget, titl switch target.Type { case "unraid": importance := "normal" - if severity == "error" { + if severity == "failed" || severity == "error" { 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() - case "ntfy": - endpoint := strings.TrimRight(target.URL, "/") + "/" + url.PathEscape(target.Topic) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(message)) + path := s.NotifyPath + if path == "" { + path = "/usr/local/emhttp/webGui/scripts/notify" + } + args := []string{"-e", "Backupper", "-s", title, "-d", message, "-i", importance, "-l", "/Tools/Backupper"} + if s.RunCommand != nil { + return s.RunCommand(ctx, path, args...) + } + return exec.CommandContext(ctx, path, args...).Run() + case "gotify": + token, err := s.Secrets.Get(target.TokenRef) if err != nil { return err } - req.Header.Set("Title", title) - req.Header.Set("Tags", "floppy_disk") - if target.TokenRef != "" { - token, err := s.Secrets.Get(target.TokenRef) - if err != nil { - 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 if client == nil { 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() 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 default: diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go new file mode 100644 index 0000000..ba57d06 --- /dev/null +++ b/internal/notify/notify_test.go @@ -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) + } +} diff --git a/internal/service/service.go b/internal/service/service.go index a0594c4..56905a6 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -85,6 +85,7 @@ func (s *Service) LastRun(jobID string) time.Time { } func (s *Service) SaveConfig(config model.Config) error { + config = model.NormalizeConfig(config) for _, job := range config.Jobs { if job.Schedule.Cron != "" { 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 { - event := result.Status - for _, wanted := range job.NotifyOn { - if wanted == event { - go s.notify(job.Name, result) - break - } + if result.Status == "success" || result.Status == "warning" || result.Status == "failed" { + go s.notify(job.Name, result) } return result } @@ -330,7 +327,9 @@ func (s *Service) notify(name string, run model.Run) { for _, target := range s.Config().Notifications { for _, event := range target.Events { 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 } } diff --git a/internal/store/store.go b/internal/store/store.go index 25247dc..e503077 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -35,10 +35,12 @@ func (s *Store) LoadConfig() (model.Config, error) { if err := readJSON(s.configPath(), &c); err != nil { return c, err } + c = model.NormalizeConfig(c) return c, model.ValidateConfig(c) } func (s *Store) SaveConfig(c model.Config) error { + c = model.NormalizeConfig(c) if err := model.ValidateConfig(c); err != nil { return err } diff --git a/plugin/backupper.plg b/plugin/backupper.plg index ad9324d..7a8285f 100644 --- a/plugin/backupper.plg +++ b/plugin/backupper.plg @@ -2,13 +2,17 @@ - + ]> +### 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 - Replace the job cron input with daily, weekday, selected-day, and time controls. diff --git a/webgui/Backupper.page b/webgui/Backupper.page index c90f646..287df2f 100644 --- a/webgui/Backupper.page +++ b/webgui/Backupper.page @@ -9,12 +9,12 @@ Tag="backup restic snapshots restore" - +
- -

Backupper 2026.06.14.9

Encrypted Restic backups for Unraid

+ +

Backupper 2026.06.14.9.1

Encrypted Restic backups for Unraid

Connecting...
@@ -25,11 +25,11 @@ $pluginRoot = '/plugins/backupper'; - +
- + diff --git a/webgui/assets/backupper.css b/webgui/assets/backupper.css index 73a22fb..046c39a 100644 --- a/webgui/assets/backupper.css +++ b/webgui/assets/backupper.css @@ -96,6 +96,9 @@ #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; } .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 { appearance: none; diff --git a/webgui/assets/backupper.js b/webgui/assets/backupper.js index c722267..0dae0e7 100644 --- a/webgui/assets/backupper.js +++ b/webgui/assets/backupper.js @@ -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.', 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.', - url: 'Adresse des ntfy-Servers. Öffentliches Beispiel: https://ntfy.sh. Für einen eigenen Server z. B. https://ntfy.example.de.', - 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 ntfy-Token. Beispiel: ntfy-token-main.', - token: 'Optionales Bearer-Token deines ntfy-Servers. Leer lassen, wenn das Topic keine Anmeldung benötigt.', + url: 'Basisadresse deines Gotify-Servers. Beispiel: https://gotify.example.de oder http://192.168.1.20:8080.', + tokenRef: 'Interne ID für das verschlüsselt gespeicherte Gotify-App-Token. Beispiel: gotify-token-main.', + token: 'App-Token aus Gotify unter Apps. Das Token wird verschlüsselt gespeichert und nicht erneut angezeigt.', + events: 'Legt fest, bei welchen Ergebnissen dieser Kanal informiert. Mindestens ein Ereignis muss aktiv sein.', }; const actionHelp = { @@ -62,7 +62,8 @@ '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-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.', 'cancel-form': 'Verwirft ungespeicherte Eingaben und kehrt zur Übersicht zurück.', '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.', 'Activity': 'Globale Warteschlange und Historie aller Backupper-Aufgaben.', '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.', }; container.querySelectorAll('h2, h3').forEach(heading => setTooltip(heading, sectionHelp[heading.textContent.trim()])); @@ -410,9 +412,12 @@ function renderSettings() { 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])=>``).join(''); return `

Settings

${button('Save settings','submit-settings','primary')}
-

ntfy notification

${button('Save notification','submit-notify','primary')}
`; +

Unraid notifications

Uses Unraid's notification system and all agents configured under Settings > Notification Settings.

Notify on
${eventBoxes(unraid)}
${button('Save Unraid notifications','submit-unraid-notify','primary')}
+

Gotify notification

Create an application in Gotify and enter its application token here.

Notify on
${eventBoxes(gotify)}
${button('Save Gotify notification','submit-gotify-notify','primary')}
`; } 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); 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'); - 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(); } 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-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(); }); @@ -510,7 +516,7 @@ const form = e.target.closest('form'); if (!form) return; 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); }); root.addEventListener('mouseover', e => {