Support attaching existing Restic repositories

This commit is contained in:
Mikei386
2026-06-15 12:01:24 +02:00
parent e97dde0ed8
commit 3327f21881
11 changed files with 115 additions and 15 deletions
-1
View File
@@ -1 +0,0 @@
250d5afee9c2939738b74f0f6f719db12d9399068bf9452e08e2b03a67d381ad urbm-2026.06.15.r013-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
2cb10896997b03d476b52275aebe077114c77b14ac796c7ba6e608f85b324536 urbm-2026.06.15.r014-x86_64-1.txz
+7 -2
View File
@@ -2,13 +2,18 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.15.r013"> <!ENTITY version "2026.06.15.r014">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg"> <!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz"> <!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "250d5afee9c2939738b74f0f6f719db12d9399068bf9452e08e2b03a67d381ad"> <!ENTITY packageSHA256 "2cb10896997b03d476b52275aebe077114c77b14ac796c7ba6e608f85b324536">
]> ]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png"> <PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
<CHANGES> <CHANGES>
### 2026.06.15.r014
- Add a dedicated workflow for attaching and immediately testing an existing Restic repository without initialization.
- Detect a missing Restic config at local and mounted paths and suggest direct child directories that contain a repository.
- Explain that an existing repository path must directly contain config, data, index, keys, and snapshots.
### 2026.06.15.r013 ### 2026.06.15.r013
- Replace invalid USB raw-image percentages, file counts, and multi-million-hour ETAs with streaming progress. - Replace invalid USB raw-image percentages, file counts, and multi-million-hour ETAs with streaming progress.
- Show bytes read and transfer rate while the final image size remains unknown until EOF. - Show bytes read and transfer rate while the final image size remains unknown until EOF.
+1 -1
View File
@@ -414,7 +414,7 @@ func resticError(message string, commandErr error) error {
case strings.Contains(lower, "wrong password") || strings.Contains(lower, "no key found"): case strings.Contains(lower, "wrong password") || strings.Contains(lower, "no key found"):
return errors.New("authentication: wrong repository password. Enter the original Restic password under Edit and select Test") return errors.New("authentication: wrong repository password. Enter the original Restic password under Edit and select Test")
case strings.Contains(lower, "repository does not exist") || strings.Contains(lower, "is there a repository at the following location?"): case strings.Contains(lower, "repository does not exist") || strings.Contains(lower, "is there a repository at the following location?"):
return errors.New("repository: destination is not initialized; open Repositories and select Initialize once") return errors.New("repository: Am konfigurierten Ziel wurde keine Restic-config gefunden. Für ein vorhandenes Repository den exakten Repository-Ordner eintragen; nur ein wirklich neues, leeres Ziel initialisieren")
default: default:
message = strings.TrimSpace(strings.TrimPrefix(message, "Fatal:")) message = strings.TrimSpace(strings.TrimPrefix(message, "Fatal:"))
return fmt.Errorf("restic: %s: %w", message, commandErr) return fmt.Errorf("restic: %s: %w", message, commandErr)
+7
View File
@@ -298,6 +298,13 @@ func TestResticErrorExplainsCiphertextVerification(t *testing.T) {
} }
} }
func TestResticErrorExplainsMissingExistingRepositoryPath(t *testing.T) {
err := resticError("Fatal: repository does not exist", fmt.Errorf("exit status 10"))
if !strings.Contains(err.Error(), "exakten Repository-Ordner") || !strings.Contains(err.Error(), "wirklich neues") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRunnerPausesResumesAndCancelsProcessGroup(t *testing.T) { func TestRunnerPausesResumesAndCancelsProcessGroup(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
ticksPath := filepath.Join(dir, "ticks") ticksPath := filepath.Join(dir, "ticks")
+41
View File
@@ -0,0 +1,41 @@
package service
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestValidateExistingRepositoryPathAcceptsRepositoryRoot(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "config"), []byte("restic"), 0600); err != nil {
t.Fatal(err)
}
if err := validateExistingRepositoryPath(root); err != nil {
t.Fatalf("repository root rejected: %v", err)
}
}
func TestValidateExistingRepositoryPathSuggestsDirectChild(t *testing.T) {
root := t.TempDir()
repository := filepath.Join(root, "server-backup")
if err := os.Mkdir(repository, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repository, "config"), []byte("restic"), 0600); err != nil {
t.Fatal(err)
}
err := validateExistingRepositoryPath(root)
if err == nil || !strings.Contains(err.Error(), repository) {
t.Fatalf("missing repository suggestion: %v", err)
}
}
func TestValidateExistingRepositoryPathExplainsMissingConfig(t *testing.T) {
root := t.TempDir()
err := validateExistingRepositoryPath(root)
if err == nil || !strings.Contains(err.Error(), "config, data, index, keys und snapshots") {
t.Fatalf("missing layout guidance: %v", err)
}
}
+33
View File
@@ -262,10 +262,43 @@ func (s *Service) TestRepository(ctx context.Context, repoID string, initialize
if initialize { if initialize {
return s.restic.Init(ctx, mounted.Repository) return s.restic.Init(ctx, mounted.Repository)
} }
if mounted.Repository.Type != model.RepositorySFTP {
if err := validateExistingRepositoryPath(mounted.Repository.Location); err != nil {
return err
}
}
_, err = s.restic.Snapshots(ctx, mounted.Repository) _, err = s.restic.Snapshots(ctx, mounted.Repository)
return err return err
} }
func validateExistingRepositoryPath(location string) error {
configPath := filepath.Join(location, "config")
if info, err := os.Stat(configPath); err == nil && !info.IsDir() {
return nil
}
entries, readErr := os.ReadDir(location)
if readErr != nil {
return fmt.Errorf("repository: Repository-Ordner %q kann nicht gelesen werden: %w", location, readErr)
}
candidates := make([]string, 0, 3)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
candidate := filepath.Join(location, entry.Name())
if info, err := os.Stat(filepath.Join(candidate, "config")); err == nil && !info.IsDir() {
candidates = append(candidates, candidate)
if len(candidates) == 3 {
break
}
}
}
if len(candidates) > 0 {
return fmt.Errorf("repository: Im gewählten Ordner liegt keine Restic-config. Verwende direkt den Repository-Ordner: %s", strings.Join(candidates, ", "))
}
return fmt.Errorf("repository: Im gewählten Ordner %q wurde keine Restic-config gefunden. Ein vorhandenes Repository muss direkt auf den Ordner zeigen, der config, data, index, keys und snapshots enthält", location)
}
func (s *Service) UnlockRepository(ctx context.Context, repoID string) error { func (s *Service) UnlockRepository(ctx context.Context, repoID string) error {
repo, ok := s.repository(repoID) repo, ok := s.repository(repoID)
if !ok { if !ok {
+6 -1
View File
@@ -2,13 +2,18 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.15.r013"> <!ENTITY version "2026.06.15.r014">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg"> <!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz"> <!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&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/URBM/issues" icon="urbm.png"> <PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
<CHANGES> <CHANGES>
### 2026.06.15.r014
- Add a dedicated workflow for attaching and immediately testing an existing Restic repository without initialization.
- Detect a missing Restic config at local and mounted paths and suggest direct child directories that contain a repository.
- Explain that an existing repository path must directly contain config, data, index, keys, and snapshots.
### 2026.06.15.r013 ### 2026.06.15.r013
- Replace invalid USB raw-image percentages, file counts, and multi-million-hour ETAs with streaming progress. - Replace invalid USB raw-image percentages, file counts, and multi-million-hour ETAs with streaming progress.
- Show bytes read and transfer rate while the final image size remains unknown until EOF. - Show bytes read and transfer rate while the final image size remains unknown until EOF.
+4 -4
View File
@@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
<?php <?php
$pluginRoot = '/plugins/urbm'; $pluginRoot = '/plugins/urbm';
?> ?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r013"> <link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r014">
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php"> <div id="urbm-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/urbm.png?v=20260615r013" alt="URBM-Logo"> <img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r014" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version">2026.06.15.r013</span></h1><p>Restic Backup Manager für Unraid</p></div> <div><h1>URBM <span class="bu-version">2026.06.15.r014</span></h1><p>Restic Backup Manager für Unraid</p></div>
</div> </div>
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div> <div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div>
</header> </header>
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div> <div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
</div> </div>
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script> <script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r013.js"></script> <script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r014.js"></script>
+14 -5
View File
@@ -57,6 +57,7 @@
'new-job': 'Öffnet den Assistenten für einen neuen Backup-Job. Beispiel: tägliches Appdata-Backup.', 'new-job': 'Öffnet den Assistenten für einen neuen Backup-Job. Beispiel: tägliches Appdata-Backup.',
'submit-job': 'Prüft und speichert diesen Backup-Job.', 'submit-job': 'Prüft und speichert diesen Backup-Job.',
'new-repo': 'Legt ein neues, isoliertes Restic-Speicherziel an.', 'new-repo': 'Legt ein neues, isoliertes Restic-Speicherziel an.',
'existing-repo': 'Bindet ein bereits initialisiertes Restic-Repository mit seinem ursprünglichen Passwort ein. URBM initialisiert oder überschreibt es nicht.',
'submit-repo': 'Speichert Ziel und Zugangsdaten. Das Repository wird dadurch noch nicht automatisch initialisiert.', 'submit-repo': 'Speichert Ziel und Zugangsdaten. Das Repository wird dadurch noch nicht automatisch initialisiert.',
'init-repo': 'Initialisiert am Ziel einmalig ein neues, leeres Restic-Repository. Bei einem vorhandenen Repository nur das ursprüngliche Passwort eintragen und Test verwenden.', 'init-repo': 'Initialisiert am Ziel einmalig ein neues, leeres Restic-Repository. Bei einem vorhandenen Repository nur das ursprüngliche Passwort eintragen und Test verwenden.',
'unlock-repo': 'Entfernt ausschließlich von Restic als veraltet erkannte Repository-Sperren. Aktive Sperren werden nicht gelöscht.', 'unlock-repo': 'Entfernt ausschließlich von Restic als veraltet erkannte Repository-Sperren. Aktive Sperren werden nicht gelöscht.',
@@ -445,18 +446,19 @@
const actions = initialize+button('Testen',`test-repo:${r.id}`)+button('Entsperren',`unlock-repo:${r.id}`)+button('Snapshots',`repo-snapshots:${r.id}`)+button('Prüfen',`maint:check:${r.id}`)+button('Bereinigen',`maint:prune:${r.id}`)+button('Bearbeiten',`edit-repo:${r.id}`)+button('Löschen',`delete-repo:${r.id}`,'danger'); const actions = initialize+button('Testen',`test-repo:${r.id}`)+button('Entsperren',`unlock-repo:${r.id}`)+button('Snapshots',`repo-snapshots:${r.id}`)+button('Prüfen',`maint:check:${r.id}`)+button('Bereinigen',`maint:prune:${r.id}`)+button('Bearbeiten',`edit-repo:${r.id}`)+button('Löschen',`delete-repo:${r.id}`,'danger');
return `<tr><td><strong>${esc(r.name)}</strong><br><span class="bu-muted">${esc(r.id)}</span></td><td>${esc(typeLabels[r.type]||r.type)}</td><td>${esc(r.location)}${volatileLocation(r)?'<span class="bu-warning">Flüchtiger Pfad: Die Daten gehen beim Neustart verloren. Verwende /mnt/user/...</span>':''}</td><td>${r.mount?.managed?'Durch URBM':'Direkt / extern'}</td><td>${actionGroup(actions)}</td></tr>`; return `<tr><td><strong>${esc(r.name)}</strong><br><span class="bu-muted">${esc(r.id)}</span></td><td>${esc(typeLabels[r.type]||r.type)}</td><td>${esc(r.location)}${volatileLocation(r)?'<span class="bu-warning">Flüchtiger Pfad: Die Daten gehen beim Neustart verloren. Verwende /mnt/user/...</span>':''}</td><td>${r.mount?.managed?'Durch URBM':'Direkt / extern'}</td><td>${actionGroup(actions)}</td></tr>`;
}).join(''); }).join('');
return `<div class="bu-toolbar"><div><h2>Repositorys</h2><div class="bu-muted">Lokale, SMB-, NFS- und SFTP-Ziele.</div></div>${button('Neues Repository','new-repo','primary')}</div><section class="bu-card">${rows ? `<table class="bu-table"><thead><tr><th>Name</th><th>Typ</th><th>Speicherort</th><th>Einbindung</th><th>Aktionen</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">Keine Repositorys konfiguriert.</div>'}</section>`; return `<div class="bu-toolbar"><div><h2>Repositorys</h2><div class="bu-muted">Lokale, SMB-, NFS- und SFTP-Ziele.</div></div><div class="bu-actions">${button('Vorhandenes einbinden','existing-repo')}${button('Neues Repository','new-repo','primary')}</div></div><section class="bu-card">${rows ? `<table class="bu-table"><thead><tr><th>Name</th><th>Typ</th><th>Speicherort</th><th>Einbindung</th><th>Aktionen</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">Keine Repositorys konfiguriert.</div>'}</section>`;
} }
function repositoryForm(repo = {}) { function repositoryForm(repo = {}, existingRepository = false) {
state.repositoryBrowserPath = '/'; state.repositoryDirectories = repositoryRoots; state.repositoryBrowserError = ''; state.repositoryBrowserPath = '/'; state.repositoryDirectories = repositoryRoots; state.repositoryBrowserError = '';
const passwordRef=repo.passwordRef||id('repo-password'); const passwordRef=repo.passwordRef||id('repo-password');
const credentialRef=repo.credentialRef||id('repo-credential'); const credentialRef=repo.credentialRef||id('repo-credential');
content.innerHTML = `<section class="bu-card"><h2>Repository ${repo.id?'bearbeiten':'erstellen'}</h2><form id="bu-repo-form" class="bu-form"><input type="hidden" name="id" value="${esc(repo.id||'')}"><input type="hidden" name="passwordRef" value="${esc(passwordRef)}"><input type="hidden" name="credentialRef" value="${esc(credentialRef)}"><input type="hidden" name="hasCredential" value="${repo.credentialRef?'true':'false'}"> content.innerHTML = `<section class="bu-card"><h2>${repo.id?'Repository bearbeiten':existingRepository?'Vorhandenes Repository einbinden':'Neues Repository erstellen'}</h2><form id="bu-repo-form" class="bu-form"><input type="hidden" name="id" value="${esc(repo.id||'')}"><input type="hidden" name="existingRepository" value="${existingRepository?'true':'false'}"><input type="hidden" name="passwordRef" value="${esc(passwordRef)}"><input type="hidden" name="credentialRef" value="${esc(credentialRef)}"><input type="hidden" name="hasCredential" value="${repo.credentialRef?'true':'false'}">
${existingRepository?'<div class="wide bu-inline-info">Wähle den exakten Ordner des vorhandenen Restic-Repositorys. Darin müssen direkt die Einträge <strong>config</strong>, <strong>data</strong>, <strong>index</strong>, <strong>keys</strong> und <strong>snapshots</strong> liegen. Verwende das ursprüngliche Repository-Passwort. Es wird nichts initialisiert.</div>':''}
<label>Name<input name="name" required value="${esc(repo.name||'')}"></label><label>Typ<select name="type">${['local','smb','nfs','sftp'].map(v=>`<option value="${v}" ${v===repo.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label> <label>Name<input name="name" required value="${esc(repo.name||'')}"></label><label>Typ<select name="type">${['local','smb','nfs','sftp'].map(v=>`<option value="${v}" ${v===repo.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label>
<div id="bu-repository-specific" class="wide"></div> <div id="bu-repository-specific" class="wide"></div>
<fieldset class="wide bu-fieldset"><legend>Restic-Verschlüsselung</legend><div class="bu-form-grid"><label>Repository-Passwort<input name="password" type="password" autocomplete="new-password" ${repo.id?'':'required'} placeholder="${repo.id?'Leer lassen, um das vorhandene Passwort zu behalten':'Ein starkes Repository-Passwort eingeben'}"></label><label>Repository-Passwort wiederholen<input name="passwordConfirm" type="password" autocomplete="new-password" ${repo.id?'':'required'} placeholder="${repo.id?'Bei einer Passwortänderung erneut eingeben':'Repository-Passwort erneut eingeben'}"></label></div><p class="bu-muted">Das Passwort wird von URBM geschützt gespeichert. ${repo.id?'Beide Felder leer lassen, um das vorhandene Passwort zu behalten.':'Bewahre dieses Passwort sicher auf; ohne das Passwort können Backups nicht wiederhergestellt werden.'}</p></fieldset> <fieldset class="wide bu-fieldset"><legend>Restic-Verschlüsselung</legend><div class="bu-form-grid"><label>Repository-Passwort<input name="password" type="password" autocomplete="new-password" ${repo.id?'':'required'} placeholder="${repo.id?'Leer lassen, um das vorhandene Passwort zu behalten':'Ein starkes Repository-Passwort eingeben'}"></label><label>Repository-Passwort wiederholen<input name="passwordConfirm" type="password" autocomplete="new-password" ${repo.id?'':'required'} placeholder="${repo.id?'Bei einer Passwortänderung erneut eingeben':'Repository-Passwort erneut eingeben'}"></label></div><p class="bu-muted">Das Passwort wird von URBM geschützt gespeichert. ${repo.id?'Beide Felder leer lassen, um das vorhandene Passwort zu behalten.':'Bewahre dieses Passwort sicher auf; ohne das Passwort können Backups nicht wiederhergestellt werden.'}</p></fieldset>
<div class="wide bu-actions">${button('Speichern','submit-repo','primary')}${button('Abbrechen','cancel-form')}</div></form></section>`; <div class="wide bu-actions">${button(existingRepository?'Einbinden und testen':'Speichern','submit-repo','primary')}${button('Abbrechen','cancel-form')}</div></form></section>`;
enhanceTooltips(content); enhanceTooltips(content);
renderRepositorySpecific(repo.type || 'local', repo); renderRepositorySpecific(repo.type || 'local', repo);
} }
@@ -578,6 +580,7 @@
if (name === 'new-job') return jobForm(); if (name === 'new-job') return jobForm();
if (name === 'edit-job') return jobForm(state.config.jobs.find(j=>j.id===a)); if (name === 'edit-job') return jobForm(state.config.jobs.find(j=>j.id===a));
if (name === 'new-repo') return repositoryForm(); if (name === 'new-repo') return repositoryForm();
if (name === 'existing-repo') return repositoryForm({}, true);
if (name === 'edit-repo') return repositoryForm(state.config.repositories.find(r=>r.id===a)); if (name === 'edit-repo') return repositoryForm(state.config.repositories.find(r=>r.id===a));
if (name === 'cancel-form') return render(); if (name === 'cancel-form') return render();
if (name === 'refresh') return load(); if (name === 'refresh') return load();
@@ -624,13 +627,14 @@
} }
if (kind === 'submit-repo') { if (kind === 'submit-repo') {
const f=new FormData(document.getElementById('bu-repo-form')); const value=name=>String(f.get(name)||'').trim(); const type=value('type'); const managed=(type==='smb'||type==='nfs')&&f.get('managed')==='on'; const f=new FormData(document.getElementById('bu-repo-form')); const value=name=>String(f.get(name)||'').trim(); const type=value('type'); const managed=(type==='smb'||type==='nfs')&&f.get('managed')==='on';
const existingRepository=value('existingRepository')==='true';
let location=managed?value('mountPoint'):value('location'); let location=managed?value('mountPoint'):value('location');
if(!location) throw new Error(managed?'Ein Einhängepunkt ist erforderlich.':'Ein Repository-Speicherort ist erforderlich.'); if(!location) throw new Error(managed?'Ein Einhängepunkt ist erforderlich.':'Ein Repository-Speicherort ist erforderlich.');
if(managed&&!value('remote')) throw new Error('Für eine verwaltete Einbindung ist eine Netzwerkfreigabe erforderlich.'); if(managed&&!value('remote')) throw new Error('Für eine verwaltete Einbindung ist eine Netzwerkfreigabe erforderlich.');
const usesCredential=type==='sftp'||(type==='smb'&&managed); const credentialRef=usesCredential&&(value('hasCredential')==='true'||Boolean(f.get('credential')))?value('credentialRef'):''; const usesCredential=type==='sftp'||(type==='smb'&&managed); const credentialRef=usesCredential&&(value('hasCredential')==='true'||Boolean(f.get('credential')))?value('credentialRef'):'';
const repo={schemaVersion:1,id:value('id')||id('repo'),name:value('name'),type,location,passwordRef:value('passwordRef'),credentialRef,mount:managed?{managed:true,remote:value('remote'),mountPoint:value('mountPoint')}:null,options:type==='sftp'?{knownHostsPath:value('knownHostsPath')}: {}}; const repo={schemaVersion:1,id:value('id')||id('repo'),name:value('name'),type,location,passwordRef:value('passwordRef'),credentialRef,mount:managed?{managed:true,remote:value('remote'),mountPoint:value('mountPoint')}:null,options:type==='sftp'?{knownHostsPath:value('knownHostsPath')}: {}};
const password=String(f.get('password')||''); const passwordConfirm=String(f.get('passwordConfirm')||''); const password=String(f.get('password')||''); const passwordConfirm=String(f.get('passwordConfirm')||'');
if(!value('id')&&!password) throw new Error('Für ein neues Repository ist ein Passwort erforderlich.'); if(!value('id')&&!password) throw new Error(existingRepository?'Zum Einbinden ist das ursprüngliche Repository-Passwort erforderlich.':'Für ein neues Repository ist ein Passwort erforderlich.');
if(password!==passwordConfirm) throw new Error('Die beiden Repository-Passwörter stimmen nicht überein.'); if(password!==passwordConfirm) throw new Error('Die beiden Repository-Passwörter stimmen nicht überein.');
if (volatileLocation(repo) && !confirm('WARNUNG: Dieses Ziel liegt unter /tmp oder /run und geht nach einem Unraid-Neustart verloren. Trotzdem speichern?')) return; if (volatileLocation(repo) && !confirm('WARNUNG: Dieses Ziel liegt unter /tmp oder /run und geht nach einem Unraid-Neustart verloren. Trotzdem speichern?')) return;
if(password) await api(`/v1/secrets/${encodeURIComponent(repo.passwordRef)}`,{method:'PUT',body:JSON.stringify({type:'restic-password',value:password})}); if(password) await api(`/v1/secrets/${encodeURIComponent(repo.passwordRef)}`,{method:'PUT',body:JSON.stringify({type:'restic-password',value:password})});
@@ -640,8 +644,13 @@
state.config = await api('/v1/config'); state.config = await api('/v1/config');
const saved = state.config.repositories.find(r=>r.id===repo.id); const saved = state.config.repositories.find(r=>r.id===repo.id);
if (!saved || saved.location !== repo.location) throw new Error(`Repository-Speicherort wurde nicht gespeichert. Erwartet: ${repo.location}`); if (!saved || saved.location !== repo.location) throw new Error(`Repository-Speicherort wurde nicht gespeichert. Erwartet: ${repo.location}`);
if(existingRepository) {
await api(`/v1/repositories/${repo.id}/test`,{method:'POST'});
render(); notify(`Vorhandenes Repository erfolgreich eingebunden: ${saved.location}`);
} else {
render(); notify(`Repository gespeichert: ${saved.location}`); render(); notify(`Repository gespeichert: ${saved.location}`);
} }
}
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('Wiederherstellung wurde eingereiht'); 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('Wiederherstellung wurde eingereiht'); 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'),showLiveLog:f.get('showLiveLog')==='on',catchUpWindowHours:Number(f.get('catchUp')),checkCron:f.get('checkCron'),pruneCron:f.get('pruneCron')}; await saveConfig(); 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'),showLiveLog:f.get('showLiveLog')==='on',catchUpWindowHours:Number(f.get('catchUp')),checkCron:f.get('checkCron'),pruneCron:f.get('pruneCron')}; 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('Wähle mindestens ein Ereignis für Unraid-Benachrichtigungen aus.'); state.config.notifications=state.config.notifications.filter(n=>n.type!=='unraid'&&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('Wähle mindestens ein Ereignis für Unraid-Benachrichtigungen aus.'); state.config.notifications=state.config.notifications.filter(n=>n.type!=='unraid'&&n.type!=='ntfy').concat(target); await saveConfig(); render(); }