Add contextual WebGUI tooltips
This commit is contained in:
+168
-2
@@ -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) => `<span class="bu-status ${esc(value)}">${esc(value)}</span>`;
|
||||
const button = (label, action, kind = '') => `<button class="bu-button ${kind}" data-action="${esc(action)}">${esc(label)}</button>`;
|
||||
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) => `<span class="bu-status ${esc(value)}" tabindex="0"${tipAttr(`Status dieses Vorgangs. Beispiel: success = erfolgreich, warning = mit Warnung, failed = fehlgeschlagen, running = läuft.`)}>${esc(value)}</span>`;
|
||||
const button = (label, action, kind = '') => `<button class="bu-button ${kind}" data-action="${esc(action)}"${tipAttr(actionTooltip(action, label))}>${esc(label)}</button>`;
|
||||
|
||||
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 @@
|
||||
<label>Shutdown timeout (seconds)<input name="shutdownSecs" type="number" min="1" value="${job.shutdownTimeoutSeconds || 120}"></label>
|
||||
<label><span>Enabled</span><input name="enabled" type="checkbox" ${job.enabled !== false?'checked':''}></label>
|
||||
<div class="wide bu-actions">${button('Save','submit-job','primary')}${button('Cancel','cancel-form')}</div></form></section>`;
|
||||
enhanceTooltips(content);
|
||||
}
|
||||
|
||||
function renderRepositories() {
|
||||
@@ -92,6 +238,7 @@
|
||||
<label>Credential secret ID<input name="credentialRef" value="${esc(repo.credentialRef||'')}"></label><label>SMB credentials or SFTP private key<textarea name="credential" placeholder="SMB: username=... / password=... SFTP: private key PEM"></textarea></label>
|
||||
<label class="wide">SFTP known_hosts path<input name="knownHostsPath" value="${esc(repo.options?.knownHostsPath||'/root/.ssh/known_hosts')}"></label>
|
||||
<div class="wide bu-actions">${button('Save','submit-repo','primary')}${button('Cancel','cancel-form')}</div></form></section>`;
|
||||
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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user