diff --git a/dist/urbm-2026.07.13.r001-x86_64-1.txz.sha256 b/dist/urbm-2026.07.13.r001-x86_64-1.txz.sha256 deleted file mode 100644 index 8944814..0000000 --- a/dist/urbm-2026.07.13.r001-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -d2d572254dc7dd9d8a7489cbd7908fc80869efb506d4ecc01845f513c4cfb08b urbm-2026.07.13.r001-x86_64-1.txz diff --git a/dist/urbm-2026.07.13.r001-x86_64-1.txz b/dist/urbm-2026.07.13.r002-x86_64-1.txz similarity index 56% rename from dist/urbm-2026.07.13.r001-x86_64-1.txz rename to dist/urbm-2026.07.13.r002-x86_64-1.txz index ce4c67a..3ee0423 100644 Binary files a/dist/urbm-2026.07.13.r001-x86_64-1.txz and b/dist/urbm-2026.07.13.r002-x86_64-1.txz differ diff --git a/dist/urbm-2026.07.13.r002-x86_64-1.txz.sha256 b/dist/urbm-2026.07.13.r002-x86_64-1.txz.sha256 new file mode 100644 index 0000000..5713c78 --- /dev/null +++ b/dist/urbm-2026.07.13.r002-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +237c35b49afd25a77903a6c858b20fad1747dbe0131d35c7934f0ee7f94be1a3 urbm-2026.07.13.r002-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index b2a3151..3dc379b 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,19 @@ - + - + ]> +### 2026.07.13.r002 +- Redesign the dashboard around actionable health, active work, recent failures, and the latest successful backup. +- Add searchable responsive job and repository lists with compact secondary-action menus. +- Structure backup and restore forms more clearly and keep their primary actions visible while scrolling. +- Replace browser confirmations with accessible in-app dialogs and typed confirmation for emergency operations. + ### 2026.07.13.r001 - Harden restore task validation and make confirmed in-place restores target original paths safely. - Serialize repository and managed-mount access, block unsafe force-unlock operations, and wait for task cleanup during daemon shutdown. diff --git a/plugin/urbm.plg b/plugin/urbm.plg index 153d598..d2efd4d 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,19 @@ - + ]> +### 2026.07.13.r002 +- Redesign the dashboard around actionable health, active work, recent failures, and the latest successful backup. +- Add searchable responsive job and repository lists with compact secondary-action menus. +- Structure backup and restore forms more clearly and keep their primary actions visible while scrolling. +- Replace browser confirmations with accessible in-app dialogs and typed confirmation for emergency operations. + ### 2026.07.13.r001 - Harden restore task validation and make confirmed in-place restores target original paths safely. - Serialize repository and managed-mount access, block unsafe force-unlock operations, and wait for task cleanup during daemon shutdown. diff --git a/webgui/URBM.page b/webgui/URBM.page index aa66c91..c507bb0 100644 --- a/webgui/URBM.page +++ b/webgui/URBM.page @@ -8,7 +8,7 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore" --- @@ -33,6 +33,24 @@ $urbmAssetVersion = preg_replace('/[^A-Za-z0-9]/', '', $urbmVersion);
+ diff --git a/webgui/assets/urbm.css b/webgui/assets/urbm.css index 6e2692a..1d3cc21 100644 --- a/webgui/assets/urbm.css +++ b/webgui/assets/urbm.css @@ -11,6 +11,7 @@ --bu-accent-hover: var(--orange-400, #c94a17); --bu-good: #138a62; --bu-bad: #c93642; + --bu-warn: #d46a16; --bu-focus: rgba(232, 93, 36, .25); background: var(--bu-page); color: var(--bu-text) !important; @@ -20,13 +21,14 @@ padding: 10px 18px 40px; } -.bu-header { align-items: center; display: flex; justify-content: space-between; margin: 6px 0 22px; } +.bu-header { align-items: center; display: flex; justify-content: space-between; margin: 6px 0 22px; padding: 8px 2px; } .bu-brand { align-items: center; display: flex; gap: 14px; min-width: 0; } .bu-logo { display: block; flex: 0 0 auto; height: 58px; object-fit: contain; width: 58px; } #urbm-app .bu-header h1 { color: var(--bu-text) !important; font-size: 30px; font-weight: 700; line-height: 1.2; margin: 0; } .bu-version { border: 1px solid var(--bu-border); border-radius: 999px; display: inline-block; font-size: 12px; font-weight: 600; padding: 2px 7px; vertical-align: middle; } #urbm-app .bu-header p { color: var(--bu-muted) !important; font-size: 16px; margin: 5px 0 0; opacity: .78; } -.bu-health { background: var(--bu-panel); border: 1px solid var(--bu-border); border-radius: 999px; color: var(--bu-text); font-weight: 600; padding: 9px 15px; } +.bu-health { align-items: center; background: var(--bu-panel); border: 1px solid var(--bu-border); border-radius: 999px; color: var(--bu-text); display: inline-flex; font-weight: 600; gap: 8px; padding: 9px 15px; } +.bu-health::before { background: currentColor; border-radius: 50%; box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 12%, transparent); content: ''; height: 8px; width: 8px; } .bu-health.ok { color: var(--bu-good); } .bu-health.bad { color: var(--bu-bad); } @@ -57,9 +59,25 @@ .bu-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); } .bu-chart-grid-layout { display: grid; gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 16px; } -.bu-card { background: var(--bu-panel); border: 1px solid var(--bu-border); border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.12); color: var(--bu-text); padding: 18px; } +.bu-card { background: var(--bu-panel); border: 1px solid color-mix(in srgb, var(--bu-border) 78%, transparent); border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,.08); color: var(--bu-text); padding: 20px; } #urbm-app .bu-card h2, #urbm-app .bu-card h3 { color: var(--bu-text) !important; font-weight: 700; margin-top: 0; } .bu-stat { color: var(--bu-text); font-size: 32px; font-weight: 700; } +.bu-section-gap { margin-top: 16px; } +.bu-eyebrow { color: var(--bu-accent); display: block; font-size: 12px; font-weight: 800; letter-spacing: .11em; margin-bottom: 4px; text-transform: uppercase; } +.bu-overview-hero { align-items: center; background: linear-gradient(115deg, color-mix(in srgb, var(--bu-good) 11%, var(--bu-panel)), var(--bu-panel)); border: 1px solid color-mix(in srgb, var(--bu-good) 55%, var(--bu-border)); border-radius: 12px; display: grid; gap: 16px; grid-template-columns: auto minmax(0, 1fr) auto; margin-bottom: 16px; overflow: hidden; padding: 22px 24px; position: relative; } +.bu-overview-hero.active { background: linear-gradient(115deg, color-mix(in srgb, var(--bu-accent) 12%, var(--bu-panel)), var(--bu-panel)); border-color: color-mix(in srgb, var(--bu-accent) 60%, var(--bu-border)); } +.bu-overview-hero.bad { background: linear-gradient(115deg, color-mix(in srgb, var(--bu-bad) 11%, var(--bu-panel)), var(--bu-panel)); border-color: color-mix(in srgb, var(--bu-bad) 58%, var(--bu-border)); } +.bu-overview-hero.neutral { background: linear-gradient(115deg, color-mix(in srgb, var(--bu-border) 12%, var(--bu-panel)), var(--bu-panel)); } +.bu-overview-icon { align-items: center; background: var(--bu-good); border-radius: 50%; color: #fff; display: flex; font-size: 22px; font-weight: 800; height: 48px; justify-content: center; width: 48px; } +.bu-overview-hero.active .bu-overview-icon { background: var(--bu-accent); } +.bu-overview-hero.bad .bu-overview-icon { background: var(--bu-bad); } +.bu-overview-hero.neutral .bu-overview-icon { background: var(--bu-border-strong); } +#urbm-app .bu-overview-copy h2 { color: var(--bu-text) !important; font-size: 22px; margin: 0; } +.bu-overview-copy p { color: var(--bu-muted); margin: 4px 0 0; } +.bu-stat-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.bu-stat-card { align-items: center; display: flex; justify-content: space-between; min-height: 88px; } +.bu-stat-icon { align-items: center; background: var(--bu-panel-alt); border-radius: 9px; color: var(--bu-muted); display: flex; font-size: 20px; height: 42px; justify-content: center; width: 42px; } +.bu-stat-card.has-alert .bu-stat, .bu-stat-card.has-alert .bu-stat-icon { color: var(--bu-bad); } .bu-chart { display: block; height: 240px; margin-top: 12px; overflow: visible; width: 100%; } .bu-chart-grid line { stroke: var(--bu-border); stroke-width: 1; } .bu-chart-grid text, .bu-chart-date { fill: var(--bu-muted); font-size: 11px; } @@ -72,6 +90,7 @@ #urbm-app .bu-muted { color: var(--bu-muted) !important; opacity: .72; } .bu-toolbar { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-bottom: 14px; } .bu-actions { display: flex; flex-wrap: wrap; gap: 7px; } +.bu-row-actions { align-items: center; display: flex; gap: 7px; justify-content: flex-end; } .bu-table-actions { align-items: flex-start; min-width: 310px; } .bu-table-actions .bu-button { flex: 0 0 auto; } .bu-warning { color: var(--bu-bad) !important; display: block; font-size: 13px; font-weight: 700; margin-top: 5px; } @@ -112,8 +131,17 @@ .bu-workload-options { display: grid; gap: 15px; grid-template-columns: repeat(2, minmax(220px, 1fr)); margin-top: 15px; } .bu-manual-source { display: grid; gap: 7px; margin-top: 14px; } .bu-info-card { background: var(--bu-panel-alt); border: 1px solid var(--bu-border); border-radius: 6px; padding: 16px; } -.bu-fieldset { border: 1px solid var(--bu-border); border-radius: 6px; padding: 14px; } +.bu-fieldset { border: 1px solid color-mix(in srgb, var(--bu-border) 78%, transparent); border-radius: 8px; padding: 16px; } .bu-fieldset legend { color: var(--bu-text); font-size: 17px; font-weight: 700; padding: 0 7px; } +.bu-danger-zone { background: color-mix(in srgb, var(--bu-bad) 5%, var(--bu-panel)); border-color: color-mix(in srgb, var(--bu-bad) 55%, var(--bu-border)); } +.bu-form-heading { border-bottom: 1px solid var(--bu-border); margin: -2px 0 20px; padding-bottom: 15px; } +.bu-form-heading h2 { margin-bottom: 4px; } +.bu-form-heading p { margin: 0; } +.bu-form-section { border: 1px solid var(--bu-border); border-radius: 8px; padding: 0; } +.bu-form-section summary { cursor: pointer; font-size: 16px; font-weight: 700; padding: 14px 16px; } +.bu-form-section[open] summary { border-bottom: 1px solid var(--bu-border); } +.bu-form-section > :not(summary) { margin-left: 16px; margin-right: 16px; } +.bu-form-actions { align-items: center; background: color-mix(in srgb, var(--bu-panel) 92%, transparent); border-top: 1px solid var(--bu-border); bottom: 0; justify-content: flex-end; margin: 4px -20px -20px; padding: 14px 20px; position: sticky; z-index: 5; } .bu-retention-grid { display: grid; gap: 15px; grid-template-columns: repeat(4, minmax(150px, 1fr)); } .bu-schedule-grid { display: grid; gap: 15px; grid-template-columns: minmax(240px, 2fr) minmax(150px, 1fr); } .bu-weekdays { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; } @@ -168,10 +196,22 @@ #urbm-app .bu-button.primary:hover { background: var(--bu-accent-hover) !important; } #urbm-app .bu-button.danger { color: var(--bu-bad) !important; } #urbm-app .bu-button:disabled { cursor: wait; opacity: .55; } +.bu-action-menu { position: relative; } +#urbm-app .bu-action-menu > summary { align-items: center; display: inline-flex; gap: 8px; list-style: none; } +.bu-action-menu > summary::-webkit-details-marker { display: none; } +.bu-action-menu-popover { background: var(--bu-panel); border: 1px solid var(--bu-border-strong); border-radius: 8px; box-shadow: 0 12px 32px rgba(0,0,0,.22); display: grid; min-width: 220px; padding: 6px; position: absolute; right: 0; top: calc(100% + 6px); z-index: 30; } +#urbm-app .bu-action-menu-popover .bu-button { background: transparent !important; border-color: transparent !important; justify-content: flex-start; text-align: left; width: 100%; } +#urbm-app .bu-action-menu-popover .bu-button:hover { background: var(--bu-panel-alt) !important; border-color: transparent !important; } +#urbm-app .bu-action-menu-popover .bu-button.danger { border-top: 1px solid var(--bu-border) !important; border-radius: 0 !important; margin-top: 4px !important; } +.bu-filter { align-items: center; background: var(--bu-input); border: 1px solid var(--bu-border-strong); border-radius: 6px; display: flex; gap: 7px; min-width: 230px; padding: 0 10px; } +#urbm-app .bu-filter input { background: transparent !important; border: 0 !important; box-shadow: none !important; color: var(--bu-text) !important; min-height: 38px; min-width: 0; outline: 0; padding: 7px 0 !important; width: 100%; } .bu-table { border-collapse: collapse; color: var(--bu-text); width: 100%; } .bu-table th, .bu-table td { border-bottom: 1px solid var(--bu-border); color: var(--bu-text); padding: 12px 8px; text-align: left; vertical-align: top; } .bu-table th { color: var(--bu-text); font-size: 13px; font-weight: 700; letter-spacing: .03em; opacity: .76; text-transform: uppercase; } +.bu-table tbody tr { transition: background-color .15s ease; } +.bu-table tbody tr:not(.bu-log-row):hover { background: color-mix(in srgb, var(--bu-accent) 4%, transparent); } +.bu-path-cell { max-width: 420px; overflow-wrap: anywhere; } .bu-table-scroll { max-width: 100%; overflow-x: auto; } .bu-snapshot-table { table-layout: fixed; } .bu-snapshot-id { width: 90px; } @@ -230,6 +270,20 @@ #bu-toast.show { display: block; } #bu-toast.error { border-color: var(--bu-bad); color: var(--bu-bad); } +.bu-dialog[hidden] { display: none; } +.bu-dialog { align-items: center; display: flex; inset: 0; justify-content: center; padding: 20px; position: fixed; z-index: 100001; } +.bu-dialog-backdrop { background: rgba(10,14,18,.62); inset: 0; position: absolute; } +.bu-dialog-panel { background: var(--bu-panel); border: 1px solid var(--bu-border-strong); border-radius: 12px; box-shadow: 0 24px 70px rgba(0,0,0,.38); display: grid; gap: 16px; grid-template-columns: auto minmax(0,1fr); max-width: 560px; padding: 24px; position: relative; width: 100%; } +.bu-dialog-icon { align-items: center; background: color-mix(in srgb, var(--bu-bad) 13%, transparent); border-radius: 50%; color: var(--bu-bad); display: flex; font-size: 22px; font-weight: 800; height: 46px; justify-content: center; width: 46px; } +.bu-dialog-icon.safe { background: color-mix(in srgb, var(--bu-good) 13%, transparent); color: var(--bu-good); } +#urbm-app .bu-dialog-panel h2 { color: var(--bu-text) !important; font-size: 21px; margin: 0 0 8px; } +.bu-dialog-message { color: var(--bu-muted); line-height: 1.55; white-space: pre-line; } +.bu-dialog-confirm { display: grid; font-weight: 700; gap: 7px; margin-top: 16px; } +.bu-dialog-confirm[hidden] { display: none; } +#urbm-app .bu-dialog-confirm input { background: var(--bu-input) !important; border: 1px solid var(--bu-border-strong) !important; border-radius: 5px !important; color: var(--bu-text) !important; font: inherit; min-height: 40px; padding: 9px 10px !important; } +.bu-dialog-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; } +body.bu-dialog-open { overflow: hidden; } + #urbm-app [data-tooltip] { cursor: help; } #urbm-app button[data-tooltip], #urbm-app input[data-tooltip], @@ -269,6 +323,22 @@ top: -12px; } +@media (max-width: 1000px) { + .bu-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .bu-chart-grid-layout { grid-template-columns: 1fr; } +} + +@media (max-width: 820px) { + .bu-responsive-table thead { display: none; } + .bu-responsive-table, .bu-responsive-table tbody, .bu-responsive-table tr, .bu-responsive-table td { display: block; width: 100%; } + .bu-responsive-table tr { border: 1px solid var(--bu-border); border-radius: 8px; margin-bottom: 12px; padding: 6px 12px; } + .bu-responsive-table td { align-items: start; border-bottom: 1px solid color-mix(in srgb, var(--bu-border) 55%, transparent); display: grid; gap: 12px; grid-template-columns: minmax(105px, 32%) minmax(0, 1fr); padding: 10px 0; } + .bu-responsive-table td::before { color: var(--bu-muted); content: attr(data-label); font-size: 12px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; } + .bu-responsive-table td:last-child { border-bottom: 0; } + .bu-row-actions { justify-content: flex-start; } + .bu-action-menu-popover { left: 0; right: auto; } +} + @media (max-width: 700px) { #urbm-app { padding-left: 8px; padding-right: 8px; } .bu-form { grid-template-columns: 1fr; } @@ -276,11 +346,20 @@ .bu-retention-grid { grid-template-columns: 1fr; } .bu-schedule-grid { grid-template-columns: 1fr; } .bu-form-grid { grid-template-columns: 1fr; } - .bu-chart-grid-layout { grid-template-columns: 1fr; } + .bu-stat-grid { grid-template-columns: 1fr 1fr; } .bu-progress-label { grid-template-columns: 1fr; } .bu-progress-label span { text-align: left; } .bu-header { align-items: flex-start; flex-direction: column; gap: 12px; } + .bu-overview-hero { align-items: start; grid-template-columns: auto minmax(0,1fr); padding: 18px; } + .bu-overview-action { grid-column: 1 / -1; width: 100%; } + .bu-overview-action .bu-button { width: 100%; } + .bu-toolbar { align-items: stretch; } + .bu-toolbar > .bu-actions { width: 100%; } + .bu-filter { flex: 1 1 100%; } .bu-table { display: block; overflow-x: auto; } .bu-table-actions { min-width: 240px; } #bu-tooltip { font-size: 13px; } + .bu-dialog-panel { grid-template-columns: 1fr; padding: 20px; } + .bu-dialog-actions { flex-direction: column-reverse; } + .bu-dialog-actions .bu-button { width: 100%; } } diff --git a/webgui/assets/urbm.js b/webgui/assets/urbm.js index 0fa54af..3991df1 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -6,6 +6,7 @@ const health = document.getElementById('bu-health'); const toast = document.getElementById('bu-toast'); const tooltip = document.getElementById('bu-tooltip'); + const dialog = document.getElementById('bu-dialog'); 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: [], logs: null, logsLoading: false, logsError: '', repositoryStats: [], repositoryStatsLoading: false, view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], snapshotTree: null, snapshotTreeMeta: null, snapshotTreeChildren: {}, snapshotTreeLoading: false, snapshotTreeNodeLoading: {}, snapshotTreeErrors: {}, snapshotTreeError: '', snapshotTreeExpanded: {}, restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceTreeRoots: sourceRoots, sourceTreeChildren: {}, sourceTreeExpanded: {}, sourceTreeLoading: {}, sourceTreeErrors: {}, directoryTrees: {}, liveLogOpen: {} }; @@ -122,6 +123,53 @@ toast.textContent = message; toast.className = `show${error ? ' error' : ''}`; clearTimeout(notify.timer); notify.timer = setTimeout(() => toast.className = '', 4500); }; + function askConfirm({title='Aktion bestätigen', message='', accept='Bestätigen', danger=true, phrase=''}) { + if (!dialog) return Promise.resolve(window.confirm(`${title}\n\n${message}`)); + const titleNode=dialog.querySelector('#bu-dialog-title'); + const messageNode=dialog.querySelector('#bu-dialog-message'); + const acceptButton=dialog.querySelector('#bu-dialog-accept'); + const dialogIcon=dialog.querySelector('.bu-dialog-icon'); + const cancelButton=dialog.querySelector('#bu-dialog-cancel'); + const confirmWrap=dialog.querySelector('#bu-dialog-confirm-wrap'); + const confirmLabel=dialog.querySelector('#bu-dialog-confirm-label'); + const confirmInput=dialog.querySelector('#bu-dialog-confirm-input'); + titleNode.textContent=title; + messageNode.textContent=message; + acceptButton.textContent=accept; + acceptButton.classList.toggle('danger',danger); + acceptButton.classList.toggle('primary',!danger); + dialogIcon.textContent=danger?'!':'✓'; + dialogIcon.classList.toggle('safe',!danger); + confirmWrap.hidden=!phrase; + confirmLabel.textContent=phrase?`Zur Bestätigung „${phrase}“ eingeben.`:''; + confirmInput.value=''; + acceptButton.disabled=Boolean(phrase); + dialog.hidden=false; + document.body.classList.add('bu-dialog-open'); + requestAnimationFrame(()=>(phrase?confirmInput:cancelButton).focus()); + return new Promise(resolve=>{ + const finish=value=>{ + dialog.hidden=true; + document.body.classList.remove('bu-dialog-open'); + dialog.removeEventListener('click',onClick); + dialog.removeEventListener('keydown',onKey); + confirmInput.removeEventListener('input',onInput); + resolve(value); + }; + const onInput=()=>{ acceptButton.disabled=Boolean(phrase)&&confirmInput.value.trim()!==phrase; }; + const onClick=event=>{ + if(event.target.closest('#bu-dialog-accept')) finish(true); + else if(event.target.closest('#bu-dialog-cancel')||event.target.matches('[data-dialog-cancel]')) finish(false); + }; + const onKey=event=>{ + if(event.key==='Escape') finish(false); + if(event.key==='Enter'&&!acceptButton.disabled&&event.target!==cancelButton) finish(true); + }; + dialog.addEventListener('click',onClick); + dialog.addEventListener('keydown',onKey); + confirmInput.addEventListener('input',onInput); + }); + } const uuid = () => { if (window.crypto && typeof window.crypto.randomUUID === 'function') return window.crypto.randomUUID(); const bytes = new Uint8Array(16); @@ -197,6 +245,8 @@ const button = (label, action, kind = '') => ``; const disabledButton = (label, help) => ``; const actionGroup = (buttons) => `
${buttons}
`; + const actionMenu = (label, buttons) => `
Mehr
${buttons}
`; + const filterBox = (placeholder, target) => ``; const formatBytes = (value) => { const units = ['B','KiB','MiB','GiB','TiB']; let size = Number(value)||0; let unit = 0; while (size >= 1024 && unit < units.length-1) { size /= 1024; unit++; } @@ -208,6 +258,13 @@ const minutes=Math.floor(seconds/60); seconds%=60; return hours?`${hours}h ${minutes}m`:minutes?`${minutes}m ${seconds}s`:`${seconds}s`; }; + const relativeTime = value => { + const time=new Date(value||0).getTime(); + if(!time) return 'noch nie'; + const seconds=Math.round((Date.now()-time)/1000); const future=seconds<0; const absolute=Math.abs(seconds); + const [amount,unit]=absolute<60?[absolute,'Sek.']:absolute<3600?[Math.round(absolute/60),'Min.']:absolute<86400?[Math.round(absolute/3600),'Std.']:[Math.round(absolute/86400),'Tagen']; + return future?`in ${amount} ${unit}`:`vor ${amount} ${unit}`; + }; const weekDays = [{value:'1',short:'Mo',name:'Montag'},{value:'2',short:'Di',name:'Dienstag'},{value:'3',short:'Mi',name:'Mittwoch'},{value:'4',short:'Do',name:'Donnerstag'},{value:'5',short:'Fr',name:'Freitag'},{value:'6',short:'Sa',name:'Samstag'},{value:'0',short:'So',name:'Sonntag'}]; function parseSchedule(cron = '0 2 * * *') { const value=String(cron).trim(); @@ -404,28 +461,37 @@ } function renderDashboard() { - const active = state.runs.filter(r => ['queued','running','paused'].includes(r.status)).length; const orderedRuns = newestRuns(state.runs); + const activeRuns = orderedRuns.filter(r => ['queued','running','paused'].includes(r.status)); + const active = activeRuns.length; const failures = orderedRuns.filter(r => r.status === 'failed').slice(0, 5); - const last = orderedRuns.slice(0, 8); + const lastSuccess=orderedRuns.find(r=>r.status==='success'); + const last = orderedRuns.slice(0, 6); const stats=state.repositoryStats; const statsErrors=stats.filter(item=>item.error); const loading=state.repositoryStatsLoading?'
Repository-Werte werden geladen…
':statsErrors.length?`
${statsErrors.map(item=>`${esc(item.repositoryName)}: ${esc(displayMessage(item.error))}`).join('
')}
`:''; - return `
-
Aktive Jobs
${state.config.jobs.filter(j => j.enabled).length}
-
Repositorys
${state.config.repositories.length}
-
Wartend / laufend
${active}
-
Letzte Fehler
${failures.length}
+ const needsSetup=!state.config.jobs.length||!state.config.repositories.length; + const latestFailed=orderedRuns[0]?.status==='failed'; + const heroClass=latestFailed?'bad':active?'active':needsSetup?'neutral':'good'; + const heroTitle=latestFailed?'Ein Vorgang benötigt Aufmerksamkeit':active?`${active} ${active===1?'Vorgang ist':'Vorgänge sind'} aktiv`:needsSetup?'URBM fertig einrichten':'Backups sind im grünen Bereich'; + const heroText=latestFailed?displayMessage(orderedRuns[0].message):active?`${taskLabels[activeRuns[0].taskType]||activeRuns[0].taskType}: ${runTarget(activeRuns[0])}`:needsSetup?'Lege mindestens ein Repository und einen Backup-Job an.':lastSuccess?`Letzter Erfolg ${relativeTime(lastSuccess.finishedAt||lastSuccess.createdAt)} · ${runTarget(lastSuccess)}`:'Noch kein Backup-Lauf vorhanden.'; + const heroAction=latestFailed||active?button('Aktivitäten öffnen','go-view:activity','primary'):needsSetup?button('Einrichtung fortsetzen',`go-view:${state.config.repositories.length?'jobs':'repositories'}`,'primary'):button('Backup-Jobs öffnen','go-view:jobs'); + return `
Systemstatus

${esc(heroTitle)}

${esc(heroText)}

${heroAction}
+
+
Aktive Jobs
${state.config.jobs.filter(j => j.enabled).length}
+
Repositorys
${state.config.repositories.length}
+
Wartend / laufend
${active}
+
Jüngste Fehler
${failures.length}

Repository-Größe

Tatsächlich gespeicherte, deduplizierte Restic-Daten pro Repository
${loading}${repositoryChart(stats,'storedBytes',formatBytes,'Noch keine Repository-Daten verfügbar.')}

Dateien in Snapshots

Gesamte referenzierte Dateianzahl aller Snapshots pro Repository
${loading}${repositoryChart(stats,'fileCount',value=>Math.round(value).toLocaleString(),'Noch keine Snapshot-Dateien verfügbar.')}
-

Letzte Aktivitäten

${runsTable(last)}
`; +

Letzte Aktivitäten

Die sechs jüngsten Vorgänge auf einen Blick.
${button('Alle anzeigen','go-view:activity')}
${runsTable(last)}
`; } function renderJobs() { - const rows = state.config.jobs.map(j => `${esc(j.name)}
${esc(j.id)}${esc(typeLabels[j.type]||j.type)}${esc(j.type==='rsync'?(j.rsync?.target||'Kein Ziel'):repoName(j.repositoryId))}${esc(scheduleLabel(j.schedule?.cron))}${j.enabled ? status('success') : status('cancelled')}${actionGroup(button('Starten',`run-job:${j.id}`,'primary')+button('Bearbeiten',`edit-job:${j.id}`)+button('Duplizieren',`duplicate-job:${j.id}`)+button('Löschen',`delete-job:${j.id}`,'danger'))}`).join(''); - return `

Backup-Jobs

Restic-Backups und direkte Rsync-Kopien werden gemeinsam geplant und überwacht.
${button('Neuer Job','new-job','primary')}
-
${rows ? `${rows}
JobTypRepository / ZielZeitplanAktivAktionen
` : '
Keine Backup-Jobs konfiguriert.
'}
`; + const rows = state.config.jobs.map(j => { const destination=j.type==='rsync'?(j.rsync?.target||'Kein Ziel'):repoName(j.repositoryId); const secondary=button('Bearbeiten',`edit-job:${j.id}`)+button('Duplizieren',`duplicate-job:${j.id}`)+button('Löschen',`delete-job:${j.id}`,'danger'); return `${esc(j.name)}
${esc(j.id)}${esc(typeLabels[j.type]||j.type)}${esc(destination)}${esc(scheduleLabel(j.schedule?.cron))}${j.enabled ? status('success') : status('cancelled')}
${button('Starten',`run-job:${j.id}`,'primary')}${actionMenu(`Weitere Aktionen für ${j.name}`,secondary)}
`; }).join(''); + return `

Backup-Jobs

Restic-Backups und direkte Rsync-Kopien werden gemeinsam geplant und überwacht.
${filterBox('Jobs durchsuchen','jobs')}${button('Neuer Job','new-job','primary')}
+
${rows ? `${rows}
JobTypRepository / ZielZeitplanAktivAktionen
` : '
Keine Backup-Jobs konfiguriert.
'}
`; } function jobForm(job = {}) { @@ -435,16 +501,15 @@ resetSourceTree(sourceRoots); delete state.directoryTrees.rsyncTarget; const options = state.config.repositories.map(r => ``).join(''); - content.innerHTML = `

Backup-Job ${job.id ? 'bearbeiten' : 'erstellen'}

- - + content.innerHTML = `
Backup-Plan

Backup-Job ${job.id ? 'bearbeiten' : 'erstellen'}

Quelle, Ziel und Zeitplan in einem Sicherungsplan zusammenfassen.

+
Allgemein
+
Restic-Einstellungen
${scheduleFields(job)}
- +
Erweiterte Dateifilter

Optional. Beispielsweise Cache-Verzeichnisse, temporäre Dateien oder große, reproduzierbare Inhalte ausschließen.

Backup-Aufbewahrung

Snapshots bleiben erhalten, wenn mindestens eine aktivierte Regel zutrifft. Täglich, wöchentlich und monatlich auf 0 setzen, um ausschließlich nach Alter aufzubewahren.

- -
${button('Speichern','submit-job','primary')}${button('Abbrechen','cancel-form')}
`; +
${button('Abbrechen','cancel-form')}${button('Job speichern','submit-job','primary')}
`; enhanceTooltips(content); renderJobSpecific(job.type || 'share', job); } @@ -621,11 +686,11 @@ function renderRepositories() { const rows = state.config.repositories.map(r => { - const initialize = volatileLocation(r) ? disabledButton('Initialisieren','Dieses Ziel liegt im flüchtigen RAM. Zuerst über Bearbeiten einen dauerhaften Pfad wie /mnt/user/Backups/HomeServer eintragen.') : button('Initialisieren',`init-repo:${r.id}`,'primary'); - const actions = initialize+button('Testen',`test-repo:${r.id}`)+button('Passwort ändern',`change-repo-password:${r.id}`)+button('Entsperren',`unlock-repo:${r.id}`)+button('Notfall entsperren',`force-unlock-repo:${r.id}`,'danger')+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 `${esc(r.name)}
${esc(r.id)}${esc(typeLabels[r.type]||r.type)}${esc(r.location)}${volatileLocation(r)?'Flüchtiger Pfad: Die Daten gehen beim Neustart verloren. Verwende /mnt/user/...':''}${r.mount?.managed?'Durch URBM':'Direkt / extern'}${actionGroup(actions)}`; + const initialize = volatileLocation(r) ? disabledButton('Initialisieren','Dieses Ziel liegt im flüchtigen RAM. Zuerst über Bearbeiten einen dauerhaften Pfad wie /mnt/user/Backups/HomeServer eintragen.') : button('Initialisieren',`init-repo:${r.id}`); + const secondary = button('Snapshots',`repo-snapshots:${r.id}`)+button('Bearbeiten',`edit-repo:${r.id}`)+button('Passwort ändern',`change-repo-password:${r.id}`)+initialize+button('Prüfen',`maint:check:${r.id}`)+button('Bereinigen',`maint:prune:${r.id}`)+button('Entsperren',`unlock-repo:${r.id}`)+button('Notfall entsperren',`force-unlock-repo:${r.id}`,'danger')+button('Löschen',`delete-repo:${r.id}`,'danger'); + return `${esc(r.name)}
${esc(r.id)}${esc(typeLabels[r.type]||r.type)}${esc(r.location)}${volatileLocation(r)?'Flüchtiger Pfad: Die Daten gehen beim Neustart verloren. Verwende /mnt/user/...':''}${r.mount?.managed?'Durch URBM':'Direkt / extern'}
${button('Testen',`test-repo:${r.id}`,'primary')}${actionMenu(`Weitere Aktionen für ${r.name}`,secondary)}
`; }).join(''); - return `

Repositorys

Lokale, SMB-, NFS- und SFTP-Ziele.
${button('Vorhandenes einbinden','existing-repo')}${button('Neues Repository','new-repo','primary')}
${rows ? `${rows}
NameTypSpeicherortEinbindungAktionen
` : '
Keine Repositorys konfiguriert.
'}
`; + return `

Repositorys

Lokale, SMB-, NFS- und SFTP-Ziele.
${filterBox('Repositorys durchsuchen','repositories')}${button('Vorhandenes einbinden','existing-repo')}${button('Neues Repository','new-repo','primary')}
${rows ? `${rows}
NameTypSpeicherortEinbindungAktionen
` : '
Keine Repositorys konfiguriert.
'}
`; } function repositoryForm(repo = {}, existingRepository = false) { @@ -637,7 +702,7 @@
${repo.id?`
Restic-Verschlüsselung

Das Repository-Passwort wird getrennt von den Ziel- und Mount-Einstellungen geändert.

${button('Repository-Passwort ändern',`change-repo-password:${repo.id}`)}
`:`
Restic-Verschlüsselung

Das Passwort wird von URBM geschützt gespeichert. Bewahre dieses Passwort zusätzlich sicher auf; ohne das Passwort können Backups nicht wiederhergestellt werden.

`} -
${button(existingRepository?'Einbinden und testen':'Speichern','submit-repo','primary')}${button('Abbrechen','cancel-form')}
`; +
${button('Abbrechen','cancel-form')}${button(existingRepository?'Einbinden und testen':'Repository speichern','submit-repo','primary')}
`; enhanceTooltips(content); renderRepositorySpecific(repo.type || 'local', repo); } @@ -713,7 +778,7 @@ function renderRestore() { const options = state.config.repositories.map(r=>``).join(''); - return `

Wiederherstellen

${button('Wiederherstellung einreihen','submit-restore','primary')}
`; + return `
Sicher wiederherstellen

Wiederherstellen

Standardmäßig landen Dateien in einem getrennten Staging-Verzeichnis.

Quelle
Ziel
Originalpfade überschreiben

Nur für eine bewusst geplante In-place-Wiederherstellung aktivieren.

${button('Wiederherstellung einreihen','submit-restore','primary')}
`; } 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))}
`; } @@ -810,12 +875,13 @@ if (name === 'cancel-run') { await api(`/v1/runs/${a}/cancel`,{method:'POST'}); return load(); } 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 === 'go-view') { state.view=a; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x.dataset.view===a)); render(); if(a==='dashboard') loadRepositoryStats(); if(a==='logs'&&!state.logs&&!state.logsLoading) loadLogs(); return; } + if (name === 'clear-activity') { if(await askConfirm({title:'Aktivitätsverlauf löschen?',message:'Alle abgeschlossenen Aktivitäten und ihre gespeicherten Laufprotokolle werden entfernt. Laufende und wartende Aufgaben bleiben erhalten.',accept:'Verlauf löschen'})) { 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; } - if (name === 'force-unlock-repo') { if(confirm('NOTFALL-ENTSPERRUNG\n\nDiese Aktion entfernt ALLE Restic-Sperren dieses Repositorys.\n\nNur fortfahren, wenn wirklich kein Backup, Restore, Check oder Prune mehr läuft - auch nicht auf einem anderen Server, der dieses Repository nutzt. Bei einem laufenden Vorgang kann das Repository beschädigt oder der laufende Vorgang fehlschlagen.\n\nAlle Sperren jetzt entfernen?')) { await api(`/v1/repositories/${a}/force-unlock`,{method:'POST'}); notify('Alle Repository-Sperren wurden entfernt'); } return; } + if (name === 'init-repo') { if(await askConfirm({title:'Neues Repository initialisieren?',message:'Am konfigurierten Ziel wird ein neues, leeres Restic-Repository angelegt. Verwende diese Aktion nicht für ein bereits vorhandenes Repository.',accept:'Initialisieren',danger:false})) { await api(`/v1/repositories/${a}/init`,{method:'POST'}); notify('Repository erfolgreich initialisiert'); } return; } + if (name === 'unlock-repo') { if(await askConfirm({title:'Verwaiste Sperren entfernen?',message:'Restic entfernt nur als veraltet erkannte Sperren. Diese Aktion ist für abgebrochene Prozesse oder einen Neustart gedacht.',accept:'Entsperren',danger:false})) { await api(`/v1/repositories/${a}/unlock`,{method:'POST'}); notify('Verwaiste Repository-Sperren wurden entfernt'); } return; } + if (name === 'force-unlock-repo') { if(await askConfirm({title:'Notfall-Entsperrung',message:'Alle Restic-Sperren werden entfernt. Fahre nur fort, wenn auf keinem Server ein Backup, Restore, Check oder Prune auf dieses Repository zugreift. Ein aktiver Vorgang kann sonst fehlschlagen oder Daten beschädigen.',accept:'Alle Sperren entfernen',phrase:'ENTSPERREN'})) { await api(`/v1/repositories/${a}/force-unlock`,{method:'POST'}); notify('Alle Repository-Sperren wurden entfernt'); } return; } if (name === 'maint') { await api(`/v1/repositories/${b}/${a}`,{method:'POST'}); notify(`${a==='check'?'Prüfung':'Bereinigung'} wurde eingereiht`); return load(); } if (name === 'repo-snapshots') { state.view='snapshots'; document.querySelector('[data-view="snapshots"]').click(); render(); setTimeout(()=>{document.getElementById('bu-snapshot-repo').value=a; handle('load-snapshots');},0); return; } if (name === 'load-snapshots') { const repo=document.getElementById('bu-snapshot-repo').value; state.snapshots=newestSnapshots(await api(`/v1/repositories/${repo}/snapshots`)); state.snapshotRepo=repo; return render(); } @@ -828,9 +894,9 @@ if (name === 'toggle-snapshot-tree') { const path=decodeURIComponent(a); if(state.snapshotTreeExpanded[path]) { state.snapshotTreeExpanded[path]=false; return renderPreservingScroll(); } state.snapshotTreeExpanded[path]=true; renderPreservingScroll(); if(Object.prototype.hasOwnProperty.call(state.snapshotTreeChildren,path)&&!state.snapshotTreeErrors[path]) return; state.snapshotTreeNodeLoading[path]=true; state.snapshotTreeErrors[path]=''; renderPreservingScroll(); try { await loadSnapshotChildren(path); } catch(error) { state.snapshotTreeErrors[path]=`Snapshot-Ordner konnte nicht geladen werden: ${error.message}`; } finally { state.snapshotTreeNodeLoading[path]=false; return renderPreservingScroll(); } } if (name === 'retry-snapshot-tree') { const path=decodeURIComponent(a); delete state.snapshotTreeChildren[path]; state.snapshotTreeErrors[path]=''; state.snapshotTreeExpanded[path]=false; return handle(`toggle-snapshot-tree:${encodeURIComponent(path)}`); } if (name === 'restore-selected') { if(!state.restoreIncludes.length) throw new Error('Wähle mindestens eine Datei oder einen Ordner aus.'); state.view='restore'; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x.dataset.view==='restore')); return render(); } - if (name === 'delete-job') { if(confirm('Diesen Job löschen?')) { state.config.jobs=state.config.jobs.filter(j=>j.id!==a); await saveConfig(); render(); } return; } + if (name === 'delete-job') { const job=state.config.jobs.find(j=>j.id===a); if(await askConfirm({title:'Backup-Job löschen?',message:`„${job?.name||a}“ wird aus der Konfiguration entfernt. Bereits vorhandene Snapshots bleiben erhalten.`,accept:'Job löschen'})) { state.config.jobs=state.config.jobs.filter(j=>j.id!==a); await saveConfig(); render(); } return; } if (name === 'duplicate-job') { const source=state.config.jobs.find(j=>j.id===a); const copy=structuredClone(source); copy.id=id('job'); copy.name += ' Kopie'; copy.enabled=false; state.config.jobs.push(copy); await saveConfig(); return render(); } - if (name === 'delete-repo') { if(state.config.jobs.some(j=>j.repositoryId===a)) throw new Error('Das Repository wird noch von einem Job verwendet.'); if(confirm('Dieses Repository aus der URBM-Konfiguration löschen? Die Backup-Daten am Ziel bleiben erhalten.')) { state.config.repositories=state.config.repositories.filter(r=>r.id!==a); await saveConfig(); render(); } return; } + if (name === 'delete-repo') { if(state.config.jobs.some(j=>j.repositoryId===a)) throw new Error('Das Repository wird noch von einem Job verwendet.'); const repo=state.config.repositories.find(r=>r.id===a); if(await askConfirm({title:'Repository entfernen?',message:`„${repo?.name||a}“ wird nur aus URBM entfernt. Die Backup-Daten am Speicherziel werden nicht gelöscht.`,accept:'Repository entfernen'})) { state.config.repositories=state.config.repositories.filter(r=>r.id!==a); await saveConfig(); render(); } return; } if (name === 'adopt-repo-password') { await submitRepositoryPassword(true); return; } if (name.startsWith('submit-')) { await submit(name); return; } } catch (error) { notify(error.message, true); } @@ -847,8 +913,8 @@ if(type!=='rsync'&&!retention.keepWithinDays&&!retention.daily&&!retention.weekly&&!retention.monthly) throw new Error('Die Aufbewahrung benötigt mindestens eine Alters- oder Kalenderregel.'); const rsync=type==='rsync'?{target:String(f.get('rsyncTarget')||'').trim(),overwrite:f.get('rsyncOverwrite')==='on',delete:f.get('rsyncDelete')==='on',preservePermissions:f.get('rsyncPermissions')==='on',preserveOwner:f.get('rsyncOwner')==='on',preserveGroup:f.get('rsyncGroup')==='on',preserveTimes:f.get('rsyncTimes')==='on',preserveLinks:f.get('rsyncLinks')==='on',preserveAcls:f.get('rsyncACLs')==='on',preserveXattrs:f.get('rsyncXattrs')==='on',checksum:f.get('rsyncChecksum')==='on',dryRun:f.get('rsyncDryRun')==='on',skipSpaceCheck:f.get('rsyncSkipSpaceCheck')==='on'}:{}; if(type==='rsync'&&!rsync.target) throw new Error('Wähle einen Zielordner für die Rsync-Kopie.'); - if(type==='rsync'&&rsync.delete&&!confirm('Rsync darf Dateien am Ziel löschen, die in der Quelle nicht mehr vorhanden sind. Diese Option kann Daten am Ziel entfernen. Wirklich speichern?')) return; - if(type==='rsync'&&rsync.skipSpaceCheck&&!confirm('Die Kapazitätsprüfung wirklich abschalten? Der Rsync-Lauf kann dann das Ziel vollständig füllen und mit einem Schreibfehler abbrechen.')) return; + if(type==='rsync'&&rsync.delete&&!await askConfirm({title:'Rsync-Löschungen aktivieren?',message:'Dateien am Ziel, die in der Quelle nicht mehr vorhanden sind, dürfen beim Lauf gelöscht werden.',accept:'Löschungen erlauben'})) return; + if(type==='rsync'&&rsync.skipSpaceCheck&&!await askConfirm({title:'Kapazitätsprüfung abschalten?',message:'Der Rsync-Lauf kann das Ziel vollständig füllen und anschließend mit einem Schreibfehler abbrechen.',accept:'Prüfung abschalten'})) return; const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:type==='rsync'?'':f.get('repositoryId'),sources,schedule:{cron:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:type==='rsync'?'':f.get('compression'),cpuCores:type==='rsync'?0:Number(f.get('cpuCores')||0),readConcurrency:type==='rsync'?0:Number(f.get('readConcurrency')||0),excludes:lines(f.get('excludes')),tags:existing?.tags||[],flashImage:type==='flash'&&f.get('flashImage')==='on',retention:type==='rsync'?{}:retention,rsync,notifyOn:existing?.notifyOn||['success','warning','failed'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)}; state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render(); } @@ -863,7 +929,7 @@ const password=String(f.get('password')||''); const passwordConfirm=String(f.get('passwordConfirm')||''); 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 (volatileLocation(repo) && !confirm('WARNUNG: Dieses Ziel liegt unter /tmp oder /run und geht nach einem Unraid-Neustart verloren. Trotzdem speichern?')) return; + if (volatileLocation(repo) && !await askConfirm({title:'Flüchtiges Speicherziel',message:'Dieses Ziel liegt unter /tmp oder /run. Sämtliche dort gespeicherten Daten gehen bei einem Unraid-Neustart verloren.',accept:'Trotzdem speichern',phrase:'FLÜCHTIG'})) return; if(password) await api(`/v1/secrets/${encodeURIComponent(repo.passwordRef)}`,{method:'PUT',body:JSON.stringify({type:'restic-password',value:password})}); if(f.get('credential')) await api(`/v1/secrets/${encodeURIComponent(repo.credentialRef)}`,{method:'PUT',body:JSON.stringify({type:type==='sftp'?'sftp-private-key':'smb-credential',value:f.get('credential')})}); state.config.repositories=state.config.repositories.filter(r=>r.id!==repo.id).concat(repo); @@ -892,15 +958,25 @@ const password=String(f.get('password')||''); if(password.length<8) throw new Error('Das Repository-Passwort muss mindestens 8 Zeichen lang sein.'); if(password!==String(f.get('passwordConfirm')||'')) throw new Error('Die beiden Repository-Passwörter stimmen nicht überein.'); - const prompt=adopt?'Dieses bereits gültige Passwort prüfen und nur auf diesem URBM-Server übernehmen?':'Repository-Passwort jetzt wirklich ändern?\n\nDas bisherige Passwort funktioniert danach für den bisherigen Restic-Key nicht mehr. Weitere Server müssen anschließend auf das neue Passwort umgestellt werden.'; - if(!confirm(prompt)) return; + const accepted=await askConfirm(adopt?{title:'Passwort übernehmen?',message:'Das bereits gültige Passwort wird geprüft und nur auf diesem URBM-Server gespeichert.',accept:'Prüfen und übernehmen',danger:false}:{title:'Repository-Passwort ändern?',message:'Der bisherige Restic-Key wird entfernt. Weitere Server müssen anschließend auf das neue Passwort umgestellt werden.',accept:'Passwort ändern'}); + if(!accepted) return; const suffix=adopt?'/password/adopt':'/password'; await api(`/v1/repositories/${encodeURIComponent(f.get('id'))}${suffix}`,{method:'POST',body:JSON.stringify({password})}); 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(); 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('click', e => { const target=e.target.closest('[data-action]'); if(!target)return; e.preventDefault(); target.closest('.bu-action-menu')?.removeAttribute('open'); handle(target.dataset.action,target); }); + content.addEventListener('input', e => { + const target=e.target.closest('[data-filter-table]'); + if(!target) return; + const query=target.value.trim().toLowerCase(); + const table=document.getElementById(`bu-table-${target.dataset.filterTable}`); + table?.querySelectorAll('tbody tr[data-search]').forEach(row=>{ row.hidden=Boolean(query)&&!row.dataset.search.includes(query); }); + }); + document.addEventListener('click', e=>{ + document.querySelectorAll('.bu-action-menu[open]').forEach(menu=>{ if(!menu.contains(e.target)) menu.removeAttribute('open'); }); + }); content.addEventListener('change', e => { if (e.target.name === 'type' && e.target.closest('#bu-repo-form')) { renderRepositorySpecific(e.target.value);