diff --git a/dist/backupper-0.1.4-x86_64-1.txz.sha256 b/dist/backupper-0.1.4-x86_64-1.txz.sha256 deleted file mode 100644 index 5de608b..0000000 --- a/dist/backupper-0.1.4-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -6109d2fe2ef3767deab7ffddfb54a32f5e44c02829efe2e65af149ea84ee43c8 backupper-0.1.4-x86_64-1.txz diff --git a/dist/backupper-0.1.4-x86_64-1.txz b/dist/backupper-0.1.5-x86_64-1.txz similarity index 55% rename from dist/backupper-0.1.4-x86_64-1.txz rename to dist/backupper-0.1.5-x86_64-1.txz index b86d569..86248bf 100644 Binary files a/dist/backupper-0.1.4-x86_64-1.txz and b/dist/backupper-0.1.5-x86_64-1.txz differ diff --git a/dist/backupper-0.1.5-x86_64-1.txz.sha256 b/dist/backupper-0.1.5-x86_64-1.txz.sha256 new file mode 100644 index 0000000..a386c39 --- /dev/null +++ b/dist/backupper-0.1.5-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +9f9f8be1af5c6fc9aca24eef9f3d9726ac041132eb64d05811b64ece4342d182 backupper-0.1.5-x86_64-1.txz diff --git a/dist/backupper.plg b/dist/backupper.plg index 64c9222..494ccfa 100644 --- a/dist/backupper.plg +++ b/dist/backupper.plg @@ -2,13 +2,16 @@ - + - + ]> +### 0.1.5 +- Add delayed German tooltips with examples throughout the WebGUI. + ### 0.1.4 - Add Backupper as a first-class top navigation tab. - Use native Unraid theme colors for reliable contrast. diff --git a/plugin/backupper.plg b/plugin/backupper.plg index 1358b81..f536fb3 100644 --- a/plugin/backupper.plg +++ b/plugin/backupper.plg @@ -2,13 +2,16 @@ - + ]> +### 0.1.5 +- Add delayed German tooltips with examples throughout the WebGUI. + ### 0.1.4 - Add Backupper as a first-class top navigation tab. - Use native Unraid theme colors for reliable contrast. diff --git a/webgui/Backupper.page b/webgui/Backupper.page index 63db51c..7c711d3 100644 --- a/webgui/Backupper.page +++ b/webgui/Backupper.page @@ -9,23 +9,24 @@ Tag="backup restic snapshots restore" - +

Backupper

Encrypted Restic backups for Unraid

-
Connecting...
+
Connecting...
+
- + diff --git a/webgui/assets/backupper.css b/webgui/assets/backupper.css index 8e7efd5..5a4ed12 100644 --- a/webgui/assets/backupper.css +++ b/webgui/assets/backupper.css @@ -120,9 +120,49 @@ #bu-toast.show { display: block; } #bu-toast.error { border-color: var(--bu-bad); color: var(--bu-bad); } +#backupper-app [data-tooltip] { cursor: help; } +#backupper-app button[data-tooltip], +#backupper-app input[data-tooltip], +#backupper-app select[data-tooltip], +#backupper-app textarea[data-tooltip] { cursor: pointer; } +#bu-tooltip { + background: #15191d; + border: 1px solid #59636d; + border-radius: 7px; + box-shadow: 0 8px 26px rgba(0,0,0,.35); + color: #ffffff; + display: none; + font-size: 14px; + font-weight: 400; + line-height: 1.45; + max-width: min(380px, calc(100vw - 28px)); + padding: 10px 12px; + pointer-events: none; + position: fixed; + text-align: left; + white-space: normal; + z-index: 100000; +} +#bu-tooltip.visible { display: block; } +#bu-tooltip::after { + border: 6px solid transparent; + border-top-color: #15191d; + bottom: -12px; + content: ""; + left: var(--bu-tooltip-arrow, 24px); + position: absolute; +} +#bu-tooltip.below::after { + border-bottom-color: #15191d; + border-top-color: transparent; + bottom: auto; + top: -12px; +} + @media (max-width: 700px) { #backupper-app { padding-left: 8px; padding-right: 8px; } .bu-form { grid-template-columns: 1fr; } .bu-header { align-items: flex-start; flex-direction: column; gap: 12px; } .bu-table { display: block; overflow-x: auto; } + #bu-tooltip { font-size: 13px; } } diff --git a/webgui/assets/backupper.js b/webgui/assets/backupper.js index c626d20..deb45fb 100644 --- a/webgui/assets/backupper.js +++ b/webgui/assets/backupper.js @@ -5,8 +5,61 @@ const content = document.getElementById('bu-content'); const health = document.getElementById('bu-health'); const toast = document.getElementById('bu-toast'); + const tooltip = document.getElementById('bu-tooltip'); const state = { config: null, runs: [], view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [] }; + const fieldHelp = { + name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.', + type: 'Art der zu sichernden Daten. Beispiel: appdata für Containerdaten, vm für virtuelle Maschinen oder flash für den Unraid-USB-Stick.', + repositoryId: 'Speicherziel für diesen Vorgang. Jeder Backup-Job verwendet ein eigenes Restic-Repository.', + cron: 'Zeitplan im Cron-Format: Minute Stunde Tag Monat Wochentag. Beispiel: 0 2 * * * startet täglich um 02:00 Uhr; 30 3 * * 0 jeden Sonntag um 03:30 Uhr.', + consistency: 'Live sichert ohne Unterbrechung, kann aber nur crash-konsistent sein. Controlled stop beendet Container oder VM vor dem Backup und startet sie danach wieder.', + compression: 'Restic-Kompression. auto ist empfohlen; off spart CPU; max spart eher Speicher, benötigt aber mehr CPU-Zeit.', + sources: 'Eine Quelle pro Zeile. Pfad-Beispiel: /mnt/user/Media. Docker- oder VM-ID mit @ voranstellen, z. B. @HomeAssistant.', + excludes: 'Muster für Dateien, die nicht gesichert werden. Ein Muster pro Zeile. Beispiele: *.tmp, cache/** oder /mnt/user/appdata/plex/Cache/**.', + shutdownSecs: 'Maximale Wartezeit für einen kontrollierten Stopp. Beispiel: 120 wartet bis zu zwei Minuten; danach wird das Backup ohne Force-Stop abgebrochen.', + enabled: 'Aktiviert automatische geplante Läufe. Deaktivierte Jobs können weiterhin gespeichert, aber nicht automatisch ausgeführt werden.', + location: 'Restic-Ziel. Lokal: /mnt/user/backups/job1. SFTP: user@server:/backup/job1. Bei SMB/NFS mit Mount wird hier der Mount-Pfad verwendet.', + passwordRef: 'Interne ID des verschlüsselt gespeicherten Restic-Passworts. Beispiel: repo-password-appdata. Keine Leerzeichen verwenden.', + password: 'Passwort zur Restic-Verschlüsselung. Beispiel: eine lange zufällige Passphrase. Leer lassen, wenn ein vorhandenes Passwort unverändert bleiben soll.', + managed: 'Wenn aktiv, mountet Backupper SMB/NFS vor dem Job selbst. Wenn aus, muss der angegebene Pfad bereits durch Unraid oder Unassigned Devices gemountet sein.', + remote: 'Adresse der Netzwerkfreigabe. SMB-Beispiel: //192.168.1.20/backup. NFS-Beispiel: 192.168.1.20:/volume1/backup.', + mountPoint: 'Lokaler Einhängepunkt auf Unraid. Beispiel: /mnt/remotes/backup-nas. Erlaubt sind Pfade unter /mnt/remotes oder /mnt/disks.', + credentialRef: 'Interne ID für SMB-Zugangsdaten oder einen SFTP-Schlüssel. Beispiel: nas-login oder sftp-backup-key.', + credential: 'SMB-Beispiel: username=backup und password=geheim auf getrennten Zeilen. Bei SFTP hier den kompletten privaten SSH-Schlüssel einfügen.', + knownHostsPath: 'Datei mit dem geprüften SSH-Hostschlüssel. Beispiel: /root/.ssh/known_hosts. StrictHostKeyChecking ist immer aktiv.', + snapshotId: 'Eindeutige ID des Sicherungsstands. Beispiel: 7f3a91cd. Sie kann im Bereich Snapshots über Browse ausgewählt werden.', + includes: 'Nur diese Pfade werden wiederhergestellt, einer pro Zeile. Beispiel: /mnt/user/Documents/Rechnung.pdf oder /mnt/user/Documents.', + target: 'Zielverzeichnis der Wiederherstellung. Sicheres Beispiel: /mnt/user/backupper-restores/restore-123. Standardmäßig wird nicht direkt über Originaldaten geschrieben.', + inPlace: 'Erlaubt Wiederherstellung direkt an einen produktiven Zielpfad. Nur aktivieren, wenn vorhandene Dateien bewusst ersetzt werden sollen.', + confirmed: 'Zusätzliche Bestätigung für einen In-place-Restore. Ohne diese Bestätigung wird ein direkter Restore blockiert.', + resticPath: 'Pfad zur vom Plugin bereitgestellten Restic-Datei. Standard: /usr/local/libexec/backupper/restic. Normalerweise nicht ändern.', + restoreRoot: 'Basisverzeichnis für sichere Staging-Restores. Beispiel: /mnt/user/backupper-restores.', + persistentLogDir: 'Optionales Verzeichnis für dauerhafte Detailprotokolle. Beispiel: /mnt/user/system/backupper-logs. Leer lassen, um Flash-Schreibzugriffe zu vermeiden.', + 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.', + }; + + const actionHelp = { + '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.', + 'new-repo': 'Legt ein neues, isoliertes Restic-Speicherziel an.', + 'submit-repo': 'Speichert Ziel und Zugangsdaten. Das Repository wird dadurch noch nicht automatisch initialisiert.', + 'load-snapshots': 'Lädt alle Sicherungsstände des ausgewählten Repositorys.', + '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.', + '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.', + }; + const esc = (value) => String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); const api = async (path, options = {}) => { const response = await fetch(`${root.dataset.api}?path=${encodeURIComponent(path)}`, { @@ -25,8 +78,99 @@ }; const id = (prefix) => `${prefix}-${crypto.randomUUID()}`; const repoName = (repoId) => state.config.repositories.find(r => r.id === repoId)?.name || repoId; - const status = (value) => `${esc(value)}`; - const button = (label, action, kind = '') => ``; + const tipAttr = (text) => text ? ` data-tooltip="${esc(text)}"` : ''; + const actionTooltip = (action, label) => { + const [name] = action.split(':'); + if (actionHelp[name]) return actionHelp[name]; + const dynamic = { + 'run-job': 'Startet diesen Backup-Job sofort. Beispiel: manuelles Backup vor einem Unraid-Update.', + 'edit-job': 'Öffnet die Einstellungen dieses Backup-Jobs zum Bearbeiten.', + 'duplicate-job': 'Erstellt eine deaktivierte Kopie als Vorlage. Danach Name, Repository und Quellen anpassen.', + 'delete-job': 'Löscht die Job-Konfiguration. Vorhandene Restic-Snapshots im Repository bleiben erhalten.', + 'test-repo': 'Prüft Erreichbarkeit, Passwort und Restic-Zugriff, ohne ein Backup zu starten.', + 'repo-snapshots': 'Wechselt zu den Sicherungsständen dieses Repositorys.', + 'maint': action.includes(':check:') ? 'Prüft Struktur und Integrität des Restic-Repositorys.' : 'Entfernt nach Retention nicht mehr benötigte Datenblöcke und gibt Speicher frei.', + 'edit-repo': 'Öffnet Ziel-, Mount- und Zugangseinstellungen dieses Repositorys.', + 'delete-repo': 'Löscht nur die Backupper-Konfiguration. Die Backupdaten am Speicherziel werden nicht gelöscht.', + 'browse': 'Öffnet den Dateibaum dieses Snapshots, um einzelne Dateien oder Ordner auszuwählen.', + 'open-dir': 'Öffnet dieses Verzeichnis innerhalb des schreibgeschützten Snapshots.', + 'cancel-run': 'Bricht die laufende oder wartende Aufgabe ab. Cleanup und Neustart gestoppter Workloads werden trotzdem versucht.', + }; + return dynamic[name] || `${label} ausführen.`; + }; + const status = (value) => `${esc(value)}`; + const button = (label, action, kind = '') => ``; + + function setTooltip(element, text) { + if (!element || !text || element.dataset.tooltip) return; + element.dataset.tooltip = text; + if (!['INPUT','SELECT','TEXTAREA','BUTTON'].includes(element.tagName) && !element.hasAttribute('tabindex')) element.tabIndex = 0; + } + + function enhanceTooltips(container = root) { + container.querySelectorAll('.bu-form [name]').forEach(control => { + const help = fieldHelp[control.name]; + if (help) { + setTooltip(control, help); + setTooltip(control.closest('label'), help); + } + }); + const dashboardHelp = { + 'Enabled jobs': 'Anzahl aktivierter Backup-Jobs, die nach Zeitplan laufen dürfen. Beispiel: 3 bedeutet drei aktive Sicherungspläne.', + 'Repositories': 'Anzahl konfigurierter Restic-Speicherziele. Im MVP gehört jedes Repository genau zu einem Job.', + 'Queued / running': 'Aufgaben in der Warteschlange oder in Ausführung. Backupper führt immer nur eine Aufgabe gleichzeitig aus.', + 'Recent failures': 'Fehlgeschlagene Läufe in der jüngsten Historie. Details stehen unter Activity.', + }; + container.querySelectorAll('.bu-card .bu-muted').forEach(element => setTooltip(element.closest('.bu-card') || element, dashboardHelp[element.textContent.trim()])); + const headerHelp = { + Job: 'Name und interne ID des Backup-Jobs.', Type: 'Art der gesicherten Daten.', Repository: 'Verwendetes Restic-Speicherziel.', Schedule: 'Automatischer Cron-Zeitplan.', Enabled: 'Zeigt, ob der Job automatisch laufen darf.', Actions: 'Verfügbare Aktionen für diesen Eintrag.', Name: 'Anzeigename und interne ID.', Location: 'Pfad oder Adresse des Restic-Ziels.', Mount: 'Zeigt, ob Backupper das Netzwerkziel selbst mountet.', Snapshot: 'Eindeutiger Sicherungsstand.', Time: 'Erstellungszeitpunkt des Snapshots.', Host: 'Hostname des Systems, das den Snapshot erstellt hat.', Paths: 'Im Snapshot enthaltene Quellpfade.', Task: 'Art und Ziel der Aufgabe.', Status: 'Aktueller oder endgültiger Laufstatus.', Created: 'Zeitpunkt, an dem die Aufgabe eingereiht wurde.', Message: 'Ergebnis oder Fehlerbeschreibung.', Size: 'Gespeicherte Dateigröße im Snapshot.' + }; + container.querySelectorAll('.bu-table th').forEach(th => setTooltip(th, headerHelp[th.textContent.trim()] || 'Informationen zu dieser Tabellenspalte.')); + const snapshotSelect = container.querySelector('#bu-snapshot-repo'); + setTooltip(snapshotSelect, 'Repository auswählen, dessen Snapshots geladen werden. Beispiel: Appdata Backup NAS.'); + container.querySelectorAll('.bu-file-select').forEach(box => setTooltip(box, `Für den Restore auswählen. Beispielpfad: ${box.value}`)); + const sectionHelp = { + 'Backup Jobs': 'Sicherungspläne verbinden Quellen, Zeitplan und genau ein Restic-Repository.', + 'Repositories': 'Speicherziele und verschlüsselte Zugangsdaten für Restic.', + 'Snapshots': 'Unveränderliche Sicherungsstände, die durchsucht und wiederhergestellt werden können.', + '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.', + 'Recent activity': 'Die zuletzt gestarteten Backup-, Restore- und Wartungsvorgänge.', + }; + container.querySelectorAll('h2, h3').forEach(heading => setTooltip(heading, sectionHelp[heading.textContent.trim()])); + } + + let tooltipTimer = null; + let tooltipTarget = null; + function hideTooltip() { + clearTimeout(tooltipTimer); tooltipTimer = null; tooltipTarget = null; + tooltip.classList.remove('visible', 'below'); tooltip.setAttribute('aria-hidden', 'true'); + } + function placeTooltip(target) { + const rect = target.getBoundingClientRect(); + tooltip.classList.add('visible'); + tooltip.style.left = '0px'; tooltip.style.top = '0px'; + const box = tooltip.getBoundingClientRect(); + const gap = 10; + let left = Math.max(10, Math.min(rect.left + rect.width / 2 - box.width / 2, window.innerWidth - box.width - 10)); + let top = rect.top - box.height - gap; + const below = top < 8; + if (below) top = Math.min(window.innerHeight - box.height - 8, rect.bottom + gap); + tooltip.classList.toggle('below', below); + tooltip.style.setProperty('--bu-tooltip-arrow', `${Math.max(14, Math.min(box.width - 20, rect.left + rect.width / 2 - left - 6))}px`); + tooltip.style.left = `${left}px`; tooltip.style.top = `${top}px`; + } + function showTooltip(target, delay = 1200) { + const text = target?.dataset?.tooltip; + if (!text) return; + clearTimeout(tooltipTimer); tooltipTarget = target; + tooltipTimer = setTimeout(() => { + if (tooltipTarget !== target || !document.contains(target)) return; + tooltip.textContent = text; tooltip.setAttribute('aria-hidden', 'false'); placeTooltip(target); + }, delay); + } async function load() { try { @@ -42,6 +186,7 @@ function render() { const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, settings:renderSettings}; content.innerHTML = views[state.view](); + enhanceTooltips(content); } function renderDashboard() { @@ -76,6 +221,7 @@
${button('Save','submit-job','primary')}${button('Cancel','cancel-form')}
`; + enhanceTooltips(content); } function renderRepositories() { @@ -92,6 +238,7 @@
${button('Save','submit-repo','primary')}${button('Cancel','cancel-form')}
`; + enhanceTooltips(content); } function renderSnapshots() { @@ -172,6 +319,25 @@ 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(); }); content.addEventListener('click', e => { const target=e.target.closest('[data-action]'); if(!target)return; e.preventDefault(); handle(target.dataset.action,target); }); + root.addEventListener('mouseover', e => { + const target = e.target.closest('[data-tooltip]'); + if (!target || target.contains(e.relatedTarget)) return; + showTooltip(target, 1200); + }); + root.addEventListener('mouseout', e => { + const target = e.target.closest('[data-tooltip]'); + if (!target || target.contains(e.relatedTarget)) return; + hideTooltip(); + }); + root.addEventListener('focusin', e => { + const target = e.target.closest('[data-tooltip]'); + if (target) showTooltip(target, 0); + }); + root.addEventListener('focusout', hideTooltip); + root.addEventListener('keydown', e => { if (e.key === 'Escape') hideTooltip(); }); + window.addEventListener('scroll', hideTooltip, true); + window.addEventListener('resize', hideTooltip); + enhanceTooltips(root); setInterval(async()=>{ if(!state.config)return; try { state.runs=await api('/v1/runs'); if(['dashboard','activity'].includes(state.view))render(); }catch(_){ } },10000); load(); })();