Add log viewer
This commit is contained in:
@@ -1 +0,0 @@
|
||||
e08f4f2ee2a9589531051a519eceed485dde2d0b456e4e9bf160dd9796be2a8e urbm-2026.07.10.r003-x86_64-1.txz
|
||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
70477362baaefac7a0f820a737a7af16efd107828f60a00ed5d9e02a9d2bb316 urbm-2026.07.10.r004-x86_64-1.txz
|
||||
Vendored
+6
-2
@@ -2,13 +2,17 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "urbm">
|
||||
<!ENTITY author "Michael Roll">
|
||||
<!ENTITY version "2026.07.10.r003">
|
||||
<!ENTITY version "2026.07.10.r004">
|
||||
<!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 packageSHA256 "e08f4f2ee2a9589531051a519eceed485dde2d0b456e4e9bf160dd9796be2a8e">
|
||||
<!ENTITY packageSHA256 "70477362baaefac7a0f820a737a7af16efd107828f60a00ed5d9e02a9d2bb316">
|
||||
]>
|
||||
<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>
|
||||
### 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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 == "/" {
|
||||
|
||||
+5
-1
@@ -2,13 +2,17 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "urbm">
|
||||
<!ENTITY author "Michael Roll">
|
||||
<!ENTITY version "2026.07.10.r003">
|
||||
<!ENTITY version "2026.07.10.r004">
|
||||
<!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 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">
|
||||
<CHANGES>
|
||||
### 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.
|
||||
|
||||
+5
-4
@@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
|
||||
<?php
|
||||
$pluginRoot = '/plugins/urbm';
|
||||
?>
|
||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260710r003">
|
||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260710r004">
|
||||
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php">
|
||||
<header class="bu-header">
|
||||
<div class="bu-brand">
|
||||
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260710r003" alt="URBM-Logo">
|
||||
<div><h1>URBM <span class="bu-version">2026.07.10.r003</span></h1><p>Restic Backup Manager für Unraid</p></div>
|
||||
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260710r004" alt="URBM-Logo">
|
||||
<div><h1>URBM <span class="bu-version">2026.07.10.r004</span></h1><p>Restic Backup Manager für Unraid</p></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>
|
||||
</header>
|
||||
@@ -25,6 +25,7 @@ $pluginRoot = '/plugins/urbm';
|
||||
<button data-view="snapshots" data-tooltip="Zeigt vorhandene Sicherungsstände und deren Dateien. Beispiel: Snapshot vom gestrigen Backup öffnen.">Snapshots</button>
|
||||
<button data-view="restore" data-tooltip="Stellt Dateien oder Ordner aus einem Snapshot wieder her. Standardmäßig in ein separates Staging-Verzeichnis.">Wiederherstellen</button>
|
||||
<button data-view="activity" data-tooltip="Zeigt wartende, laufende und abgeschlossene Backup-, Restore- und Wartungsaufgaben.">Aktivitäten</button>
|
||||
<button data-view="logs" data-tooltip="Zeigt das URBM-Daemon-Protokoll und gespeicherte Laufprotokolle zur Fehlersuche.">Protokolle</button>
|
||||
<button data-view="settings" data-tooltip="Globale Einstellungen sowie native Unraid- und Gotify-Benachrichtigungen konfigurieren.">Einstellungen</button>
|
||||
</nav>
|
||||
<main id="bu-content" aria-live="polite"></main>
|
||||
@@ -32,4 +33,4 @@ $pluginRoot = '/plugins/urbm';
|
||||
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
|
||||
</div>
|
||||
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
|
||||
<script src="<?= $pluginRoot ?>/assets/urbm-2026.07.10.r003.js"></script>
|
||||
<script src="<?= $pluginRoot ?>/assets/urbm-2026.07.10.r004.js"></script>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+19
-3
@@ -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 `<div class="bu-toolbar"><div><h2>Aktivitäten</h2><div class="bu-muted">Laufende Aufgaben werden automatisch alle zwei Sekunden aktualisiert.</div></div><div class="bu-actions">${button('Aktualisieren','refresh','primary')}${button('Abgeschlossene Aktivitäten löschen','clear-activity','danger')}</div></div><section class="bu-card">${runsTable(newestRuns(state.runs))}</section>`; }
|
||||
function renderLogs() {
|
||||
const daemon=state.logs?.daemon||{};
|
||||
const daemonLines=daemon.lines||[];
|
||||
const daemonHint=daemon.error?`<div class="bu-inline-error">${esc(daemon.error)}</div>`:`<div class="bu-muted">${esc(daemon.path||'/var/log/urbm.log')}${daemon.truncatedLines?` · ${daemon.truncatedLines} ältere Zeilen ausgeblendet`:''}${daemon.truncatedBytes?` · Anfang wegen Größe gekürzt`:''}</div>`;
|
||||
const runLogs=(state.logs?.runs||[]).map(run=>`<details class="bu-live-log" open><summary>${esc(taskLabels[run.taskType]||run.taskType)} · ${esc(runTarget(run))} · ${esc(statusLabels[run.status]||run.status)} · ${esc(new Date(run.createdAt).toLocaleString())}</summary><pre>${esc((run.liveLog||[]).join('\n'))}</pre></details>`).join('');
|
||||
return `<div class="bu-toolbar"><div><h2>Protokolle</h2><div class="bu-muted">Nur lesbare Diagnoseansicht. Secrets und Passwörter werden von URBM nicht geloggt.</div></div><div class="bu-actions">${button('Aktualisieren','refresh-logs','primary')}</div></div>${state.logsLoading?'<section class="bu-card"><div class="bu-empty">Protokolle werden geladen…</div></section>':state.logsError?`<section class="bu-card"><div class="bu-inline-error">${esc(state.logsError)}</div></section>`:`<section class="bu-card"><h3>Daemon-Protokoll</h3>${daemonHint}<pre class="bu-log-pre">${esc(daemonLines.join('\n'))}</pre></section><section class="bu-card" style="margin-top:16px"><h3>Laufprotokolle</h3>${runLogs||'<div class="bu-empty">Keine gespeicherten Laufprotokolle vorhanden.</div>'}</section>`}`;
|
||||
}
|
||||
function progressView(run) {
|
||||
if(run.status==='queued') return '<span class="bu-muted">Wartet in der Warteschlange</span>';
|
||||
if(!['backup','rsync','restore'].includes(run.taskType)&&!run.progressPercent) return '<span class="bu-muted">Kein Prozentwert verfügbar</span>';
|
||||
@@ -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')) {
|
||||
|
||||
Reference in New Issue
Block a user