diff --git a/dist/urbm-2026.07.10.r003-x86_64-1.txz.sha256 b/dist/urbm-2026.07.10.r003-x86_64-1.txz.sha256 deleted file mode 100644 index 838a5ba..0000000 --- a/dist/urbm-2026.07.10.r003-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -e08f4f2ee2a9589531051a519eceed485dde2d0b456e4e9bf160dd9796be2a8e urbm-2026.07.10.r003-x86_64-1.txz diff --git a/dist/urbm-2026.07.10.r003-x86_64-1.txz b/dist/urbm-2026.07.10.r004-x86_64-1.txz similarity index 55% rename from dist/urbm-2026.07.10.r003-x86_64-1.txz rename to dist/urbm-2026.07.10.r004-x86_64-1.txz index 5143a69..d3a6cb4 100644 Binary files a/dist/urbm-2026.07.10.r003-x86_64-1.txz and b/dist/urbm-2026.07.10.r004-x86_64-1.txz differ diff --git a/dist/urbm-2026.07.10.r004-x86_64-1.txz.sha256 b/dist/urbm-2026.07.10.r004-x86_64-1.txz.sha256 new file mode 100644 index 0000000..d1f9f2d --- /dev/null +++ b/dist/urbm-2026.07.10.r004-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +70477362baaefac7a0f820a737a7af16efd107828f60a00ed5d9e02a9d2bb316 urbm-2026.07.10.r004-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index f3c875b..1613c6a 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,17 @@ - + - + ]> +### 2026.07.10.r004 +- Add a dedicated Protokolle tab showing the daemon log tail and recent saved run logs. +- Add a read-only `/v1/logs` API endpoint limited to `/var/log/urbm.log` and stored URBM run logs. + ### 2026.07.10.r003 - Stream Restic snapshot file listings instead of buffering the full `restic ls --json` output in memory. - Extend snapshot file-listing timeouts across the daemon and PHP bridge to reduce generic 500 errors on large snapshots. diff --git a/internal/api/server.go b/internal/api/server.go index 5b86908..1f4209d 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -37,6 +37,7 @@ func New(socket string, svc *service.Service, log *slog.Logger) *Server { mux.HandleFunc("DELETE /v1/secrets/{id}", s.deleteSecret) mux.HandleFunc("GET /v1/runs", s.runs) mux.HandleFunc("DELETE /v1/runs", s.clearRuns) + mux.HandleFunc("GET /v1/logs", s.logs) mux.HandleFunc("GET /v1/filesystem/directories", s.directories) mux.HandleFunc("GET /v1/workloads/{kind}", s.workloads) mux.HandleFunc("POST /v1/jobs/{id}/run", s.runJob) @@ -129,6 +130,7 @@ func (s *Server) runs(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200 func (s *Server) clearRuns(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, map[string]int{"cleared": s.service.ClearRunHistory()}) } +func (s *Server) logs(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, s.service.Logs()) } func (s *Server) directories(w http.ResponseWriter, r *http.Request) { items, err := s.service.BrowseDirectories(r.URL.Query().Get("path")) if err != nil { diff --git a/internal/service/service.go b/internal/service/service.go index 44c326f..5c26846 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "context" "crypto/rand" "encoding/hex" @@ -65,6 +66,51 @@ func (s *Service) RunQueue(ctx context.Context) { s.queue.Run(ctx) } func (s *Service) Stop() { s.queue.Stop() } func (s *Service) Config() model.Config { s.mu.RLock(); defer s.mu.RUnlock(); return s.config } func (s *Service) Runs() []model.Run { return s.queue.Snapshot() } +func (s *Service) Logs() map[string]any { + return map[string]any{ + "daemon": tailFile("/var/log/urbm.log", 512*1024, 300), + "runs": runsWithLogs(s.queue.Snapshot(), 25), + } +} + +func runsWithLogs(runs []model.Run, limit int) []model.Run { + result := make([]model.Run, 0, limit) + for index := len(runs) - 1; index >= 0 && len(result) < limit; index-- { + if len(runs[index].LiveLog) == 0 { + continue + } + result = append(result, runs[index]) + } + return result +} + +func tailFile(path string, maxBytes int64, maxLines int) map[string]any { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return map[string]any{"path": path, "lines": []string{}, "error": "Logdatei ist noch nicht vorhanden"} + } + return map[string]any{"path": path, "lines": []string{}, "error": err.Error()} + } + truncatedBytes := 0 + if int64(len(data)) > maxBytes { + truncatedBytes = len(data) - int(maxBytes) + data = data[len(data)-int(maxBytes):] + if index := bytes.IndexByte(data, '\n'); index >= 0 && index+1 < len(data) { + data = data[index+1:] + } + } + rawLines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + if len(rawLines) == 1 && rawLines[0] == "" { + rawLines = []string{} + } + truncatedLines := 0 + if len(rawLines) > maxLines { + truncatedLines = len(rawLines) - maxLines + rawLines = rawLines[len(rawLines)-maxLines:] + } + return map[string]any{"path": path, "lines": rawLines, "truncatedBytes": truncatedBytes, "truncatedLines": truncatedLines} +} func (s *Service) BrowseDirectories(path string) ([]platform.DirectoryEntry, error) { if path == "" || path == "/" { diff --git a/plugin/urbm.plg b/plugin/urbm.plg index f551308..ddbb988 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,17 @@ - + ]> +### 2026.07.10.r004 +- Add a dedicated Protokolle tab showing the daemon log tail and recent saved run logs. +- Add a read-only `/v1/logs` API endpoint limited to `/var/log/urbm.log` and stored URBM run logs. + ### 2026.07.10.r003 - Stream Restic snapshot file listings instead of buffering the full `restic ls --json` output in memory. - Extend snapshot file-listing timeouts across the daemon and PHP bridge to reduce generic 500 errors on large snapshots. diff --git a/webgui/URBM.page b/webgui/URBM.page index 6c96bd6..dcfdd88 100644 --- a/webgui/URBM.page +++ b/webgui/URBM.page @@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore" - +
- -

URBM 2026.07.10.r003

Restic Backup Manager für Unraid

+ +

URBM 2026.07.10.r004

Restic Backup Manager für Unraid

Verbindung wird hergestellt...
@@ -25,6 +25,7 @@ $pluginRoot = '/plugins/urbm'; +
@@ -32,4 +33,4 @@ $pluginRoot = '/plugins/urbm';
- + diff --git a/webgui/assets/urbm.css b/webgui/assets/urbm.css index b4af72c..6e2692a 100644 --- a/webgui/assets/urbm.css +++ b/webgui/assets/urbm.css @@ -138,6 +138,7 @@ .bu-live-log { background: #11161a; border: 1px solid var(--bu-border); border-radius: 6px; color: #e8edf1; margin: 2px 0 8px; padding: 9px 12px; } .bu-live-log summary { cursor: pointer; font-weight: 700; } .bu-live-log pre { color: #e8edf1; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5; margin: 10px 0 0; max-height: 320px; overflow: auto; white-space: pre-wrap; word-break: break-word; } +.bu-log-pre { background: #11161a; border: 1px solid var(--bu-border); border-radius: 6px; color: #e8edf1; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5; margin: 10px 0 0; max-height: 520px; overflow: auto; padding: 12px; white-space: pre-wrap; word-break: break-word; } @keyframes bu-progress-scan { from { transform: translateX(-20%); } to { transform: translateX(250%); } } .bu-form-grid { display: grid; gap: 15px; grid-template-columns: repeat(2, minmax(220px, 1fr)); } .bu-managed-mount, .bu-external-mount { margin-top: 14px; } diff --git a/webgui/assets/urbm.js b/webgui/assets/urbm.js index 367f753..804c235 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -8,7 +8,7 @@ const tooltip = document.getElementById('bu-tooltip'); const sourceRoots = ['/mnt/user','/mnt/disks','/mnt/remotes','/boot'].map(path=>({name:path,path})); const repositoryRoots = ['/mnt/user','/mnt/disks','/mnt/remotes'].map(path=>({name:path,path})); - const state = { config: null, runs: [], repositoryStats: [], repositoryStatsLoading: false, view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], snapshotTree: null, snapshotTreeLoading: false, snapshotTreeError: '', snapshotTreeExpanded: {}, restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceTreeRoots: sourceRoots, sourceTreeChildren: {}, sourceTreeExpanded: {}, sourceTreeLoading: {}, sourceTreeErrors: {}, directoryTrees: {}, liveLogOpen: {} }; + const state = { config: null, runs: [], logs: null, logsLoading: false, logsError: '', repositoryStats: [], repositoryStatsLoading: false, view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], snapshotTree: null, snapshotTreeLoading: false, snapshotTreeError: '', snapshotTreeExpanded: {}, restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceTreeRoots: sourceRoots, sourceTreeChildren: {}, sourceTreeExpanded: {}, sourceTreeLoading: {}, sourceTreeErrors: {}, directoryTrees: {}, liveLogOpen: {} }; const fieldHelp = { name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.', @@ -72,6 +72,7 @@ '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 URBM-Daemon.', + 'refresh-logs': 'Lädt Daemon-Protokoll und gespeicherte Laufprotokolle erneut.', 'clear-activity': 'Löscht alle abgeschlossenen Aktivitätseinträge und deren gespeicherte Laufprotokolle. Laufende und wartende Aufgaben bleiben erhalten.', 'cancel-form': 'Verwirft ungespeicherte Eingaben und kehrt zur Übersicht zurück.', 'toggle-source-tree': 'Klappt die direkten Unterordner dieses Ordners auf oder zu. Inhalte werden erst beim ersten Öffnen geladen.', @@ -359,7 +360,7 @@ } function render() { - const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, settings:renderSettings}; + const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, logs:renderLogs, settings:renderSettings}; content.innerHTML = views[state.view](); enhanceTooltips(content); content.querySelectorAll('.bu-file-select[data-partial="true"]').forEach(box=>{box.indeterminate=true;box.setAttribute('aria-checked','mixed');}); @@ -373,6 +374,7 @@ if(!state.directoryTrees.persistentLogDir) initDirectoryTree('persistentLogDir','Protokollverzeichnis auswählen','#bu-settings-form [name="persistentLogDir"]'); else renderDirectoryTree('persistentLogDir'); } + if(state.view==='logs'&&!state.logs&&!state.logsLoading&&!state.logsError) loadLogs(); } function captureScrollState(root=document) { @@ -726,6 +728,13 @@ } function renderActivity() { return `

Aktivitäten

Laufende Aufgaben werden automatisch alle zwei Sekunden aktualisiert.
${button('Aktualisieren','refresh','primary')}${button('Abgeschlossene Aktivitäten löschen','clear-activity','danger')}
${runsTable(newestRuns(state.runs))}
`; } + function renderLogs() { + const daemon=state.logs?.daemon||{}; + const daemonLines=daemon.lines||[]; + const daemonHint=daemon.error?`
${esc(daemon.error)}
`:`
${esc(daemon.path||'/var/log/urbm.log')}${daemon.truncatedLines?` · ${daemon.truncatedLines} ältere Zeilen ausgeblendet`:''}${daemon.truncatedBytes?` · Anfang wegen Größe gekürzt`:''}
`; + const runLogs=(state.logs?.runs||[]).map(run=>`
${esc(taskLabels[run.taskType]||run.taskType)} · ${esc(runTarget(run))} · ${esc(statusLabels[run.status]||run.status)} · ${esc(new Date(run.createdAt).toLocaleString())}
${esc((run.liveLog||[]).join('\n'))}
`).join(''); + return `

Protokolle

Nur lesbare Diagnoseansicht. Secrets und Passwörter werden von URBM nicht geloggt.
${button('Aktualisieren','refresh-logs','primary')}
${state.logsLoading?'
Protokolle werden geladen…
':state.logsError?`
${esc(state.logsError)}
`:`

Daemon-Protokoll

${daemonHint}
${esc(daemonLines.join('\n'))}

Laufprotokolle

${runLogs||'
Keine gespeicherten Laufprotokolle vorhanden.
'}
`}`; + } function progressView(run) { if(run.status==='queued') return 'Wartet in der Warteschlange'; if(!['backup','rsync','restore'].includes(run.taskType)&&!run.progressPercent) return 'Kein Prozentwert verfügbar'; @@ -772,6 +781,12 @@ notify('Konfiguration gespeichert'); return state.config; } + async function loadLogs() { + state.logsLoading=true; state.logsError=''; render(); + try { state.logs=await api('/v1/logs'); } + catch(error) { state.logsError=error.message; } + finally { state.logsLoading=false; render(); } + } function renderPreservingScroll() { const scrollSnapshot=captureScrollState(content); render(); @@ -799,6 +814,7 @@ if (name === 'pause-run') { await api(`/v1/runs/${a}/pause`,{method:'POST'}); notify('Vorgang pausiert'); return load(); } if (name === 'resume-run') { await api(`/v1/runs/${a}/resume`,{method:'POST'}); notify('Vorgang wird fortgesetzt'); return load(); } if (name === 'clear-activity') { if(confirm('Alle abgeschlossenen Aktivitäten und deren gespeicherte Laufprotokolle löschen?\n\nLaufende und wartende Aufgaben bleiben erhalten.')) { const result=await api('/v1/runs',{method:'DELETE'}); notify(result.cleared?`${result.cleared} abgeschlossene Aktivitäten gelöscht`:'Keine abgeschlossenen Aktivitäten vorhanden'); return load(); } return; } + if (name === 'refresh-logs') { state.logs=null; return loadLogs(); } if (name === 'test-repo') { await api(`/v1/repositories/${a}/test`,{method:'POST'}); return notify('Repository-Verbindung erfolgreich getestet'); } if (name === 'init-repo') { if(confirm('Hier ein NEUES Restic-Repository erstellen?\n\nNur fortfahren, wenn dieses Ziel noch kein Restic-Repository enthält. Bei einem vorhandenen Repository das ursprüngliche Passwort unter Bearbeiten eintragen und Testen verwenden.')) { await api(`/v1/repositories/${a}/init`,{method:'POST'}); notify('Repository erfolgreich initialisiert'); } return; } if (name === 'unlock-repo') { if(confirm('Verwaiste Restic-Sperren dieses Repositorys entfernen?\n\nRestic entfernt dabei nur als veraltet erkannte Locks. Verwende diese Aktion nach einem Neustart oder abgebrochenen Restic-Prozess.')) { await api(`/v1/repositories/${a}/unlock`,{method:'POST'}); notify('Verwaiste Repository-Sperren wurden entfernt'); } return; } @@ -885,7 +901,7 @@ notify(adopt?'Repository-Passwort wurde geprüft und auf diesem Server übernommen':'Repository-Passwort wurde sicher geändert'); 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(); if(state.view==='dashboard')loadRepositoryStats(); }); + 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(); if(state.view==='dashboard')loadRepositoryStats(); if(state.view==='logs'&&!state.logs&&!state.logsLoading)loadLogs(); }); content.addEventListener('click', e => { const target=e.target.closest('[data-action]'); if(!target)return; e.preventDefault(); handle(target.dataset.action,target); }); content.addEventListener('change', e => { if (e.target.name === 'type' && e.target.closest('#bu-repo-form')) {