Refresh the Unraid WebGUI
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
d2d572254dc7dd9d8a7489cbd7908fc80869efb506d4ecc01845f513c4cfb08b urbm-2026.07.13.r001-x86_64-1.txz
|
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
237c35b49afd25a77903a6c858b20fad1747dbe0131d35c7934f0ee7f94be1a3 urbm-2026.07.13.r002-x86_64-1.txz
|
||||||
Vendored
+8
-2
@@ -2,13 +2,19 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "urbm">
|
<!ENTITY name "urbm">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.07.13.r001">
|
<!ENTITY version "2026.07.13.r002">
|
||||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
|
<!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 packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
|
||||||
<!ENTITY packageSHA256 "d2d572254dc7dd9d8a7489cbd7908fc80869efb506d4ecc01845f513c4cfb08b">
|
<!ENTITY packageSHA256 "237c35b49afd25a77903a6c858b20fad1747dbe0131d35c7934f0ee7f94be1a3">
|
||||||
]>
|
]>
|
||||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
|
<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>
|
<CHANGES>
|
||||||
|
### 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
|
### 2026.07.13.r001
|
||||||
- Harden restore task validation and make confirmed in-place restores target original paths safely.
|
- 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.
|
- Serialize repository and managed-mount access, block unsafe force-unlock operations, and wait for task cleanup during daemon shutdown.
|
||||||
|
|||||||
+7
-1
@@ -2,13 +2,19 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "urbm">
|
<!ENTITY name "urbm">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.07.13.r001">
|
<!ENTITY version "2026.07.13.r002">
|
||||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
|
<!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 packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
|
||||||
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
|
<!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">
|
<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>
|
<CHANGES>
|
||||||
|
### 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
|
### 2026.07.13.r001
|
||||||
- Harden restore task validation and make confirmed in-place restores target original paths safely.
|
- 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.
|
- Serialize repository and managed-mount access, block unsafe force-unlock operations, and wait for task cleanup during daemon shutdown.
|
||||||
|
|||||||
+19
-1
@@ -8,7 +8,7 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
|
|||||||
---
|
---
|
||||||
<?php
|
<?php
|
||||||
$pluginRoot = '/plugins/urbm';
|
$pluginRoot = '/plugins/urbm';
|
||||||
$urbmVersion = '2026.07.13.r001';
|
$urbmVersion = '2026.07.13.r002';
|
||||||
$urbmAssetVersion = preg_replace('/[^A-Za-z0-9]/', '', $urbmVersion);
|
$urbmAssetVersion = preg_replace('/[^A-Za-z0-9]/', '', $urbmVersion);
|
||||||
?>
|
?>
|
||||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=<?= $urbmAssetVersion ?>">
|
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=<?= $urbmAssetVersion ?>">
|
||||||
@@ -33,6 +33,24 @@ $urbmAssetVersion = preg_replace('/[^A-Za-z0-9]/', '', $urbmVersion);
|
|||||||
<main id="bu-content" aria-live="polite"></main>
|
<main id="bu-content" aria-live="polite"></main>
|
||||||
<div id="bu-toast" role="status" aria-live="polite"></div>
|
<div id="bu-toast" role="status" aria-live="polite"></div>
|
||||||
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
|
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
|
||||||
|
<div id="bu-dialog" class="bu-dialog" role="dialog" aria-modal="true" aria-labelledby="bu-dialog-title" hidden>
|
||||||
|
<div class="bu-dialog-backdrop" data-dialog-cancel></div>
|
||||||
|
<section class="bu-dialog-panel" tabindex="-1">
|
||||||
|
<div class="bu-dialog-icon" aria-hidden="true">!</div>
|
||||||
|
<div>
|
||||||
|
<h2 id="bu-dialog-title"></h2>
|
||||||
|
<div id="bu-dialog-message" class="bu-dialog-message"></div>
|
||||||
|
<label id="bu-dialog-confirm-wrap" class="bu-dialog-confirm" hidden>
|
||||||
|
<span id="bu-dialog-confirm-label"></span>
|
||||||
|
<input id="bu-dialog-confirm-input" autocomplete="off" spellcheck="false">
|
||||||
|
</label>
|
||||||
|
<div class="bu-dialog-actions">
|
||||||
|
<button id="bu-dialog-cancel" class="bu-button" type="button">Abbrechen</button>
|
||||||
|
<button id="bu-dialog-accept" class="bu-button danger" type="button">Bestätigen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
|
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
|
||||||
<script src="<?= $pluginRoot ?>/assets/urbm-<?= rawurlencode($urbmVersion) ?>.js"></script>
|
<script src="<?= $pluginRoot ?>/assets/urbm-<?= rawurlencode($urbmVersion) ?>.js"></script>
|
||||||
|
|||||||
+84
-5
@@ -11,6 +11,7 @@
|
|||||||
--bu-accent-hover: var(--orange-400, #c94a17);
|
--bu-accent-hover: var(--orange-400, #c94a17);
|
||||||
--bu-good: #138a62;
|
--bu-good: #138a62;
|
||||||
--bu-bad: #c93642;
|
--bu-bad: #c93642;
|
||||||
|
--bu-warn: #d46a16;
|
||||||
--bu-focus: rgba(232, 93, 36, .25);
|
--bu-focus: rgba(232, 93, 36, .25);
|
||||||
background: var(--bu-page);
|
background: var(--bu-page);
|
||||||
color: var(--bu-text) !important;
|
color: var(--bu-text) !important;
|
||||||
@@ -20,13 +21,14 @@
|
|||||||
padding: 10px 18px 40px;
|
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-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; }
|
.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; }
|
#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; }
|
.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; }
|
#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.ok { color: var(--bu-good); }
|
||||||
.bu-health.bad { color: var(--bu-bad); }
|
.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-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-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; }
|
#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-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 { 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 line { stroke: var(--bu-border); stroke-width: 1; }
|
||||||
.bu-chart-grid text, .bu-chart-date { fill: var(--bu-muted); font-size: 11px; }
|
.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; }
|
#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-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-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 { align-items: flex-start; min-width: 310px; }
|
||||||
.bu-table-actions .bu-button { flex: 0 0 auto; }
|
.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; }
|
.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-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-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-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-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-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-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; }
|
.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.primary:hover { background: var(--bu-accent-hover) !important; }
|
||||||
#urbm-app .bu-button.danger { color: var(--bu-bad) !important; }
|
#urbm-app .bu-button.danger { color: var(--bu-bad) !important; }
|
||||||
#urbm-app .bu-button:disabled { cursor: wait; opacity: .55; }
|
#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 { 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, .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 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-table-scroll { max-width: 100%; overflow-x: auto; }
|
||||||
.bu-snapshot-table { table-layout: fixed; }
|
.bu-snapshot-table { table-layout: fixed; }
|
||||||
.bu-snapshot-id { width: 90px; }
|
.bu-snapshot-id { width: 90px; }
|
||||||
@@ -230,6 +270,20 @@
|
|||||||
#bu-toast.show { display: block; }
|
#bu-toast.show { display: block; }
|
||||||
#bu-toast.error { border-color: var(--bu-bad); color: var(--bu-bad); }
|
#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 [data-tooltip] { cursor: help; }
|
||||||
#urbm-app button[data-tooltip],
|
#urbm-app button[data-tooltip],
|
||||||
#urbm-app input[data-tooltip],
|
#urbm-app input[data-tooltip],
|
||||||
@@ -269,6 +323,22 @@
|
|||||||
top: -12px;
|
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) {
|
@media (max-width: 700px) {
|
||||||
#urbm-app { padding-left: 8px; padding-right: 8px; }
|
#urbm-app { padding-left: 8px; padding-right: 8px; }
|
||||||
.bu-form { grid-template-columns: 1fr; }
|
.bu-form { grid-template-columns: 1fr; }
|
||||||
@@ -276,11 +346,20 @@
|
|||||||
.bu-retention-grid { grid-template-columns: 1fr; }
|
.bu-retention-grid { grid-template-columns: 1fr; }
|
||||||
.bu-schedule-grid { grid-template-columns: 1fr; }
|
.bu-schedule-grid { grid-template-columns: 1fr; }
|
||||||
.bu-form-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 { grid-template-columns: 1fr; }
|
||||||
.bu-progress-label span { text-align: left; }
|
.bu-progress-label span { text-align: left; }
|
||||||
.bu-header { align-items: flex-start; flex-direction: column; gap: 12px; }
|
.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 { display: block; overflow-x: auto; }
|
||||||
.bu-table-actions { min-width: 240px; }
|
.bu-table-actions { min-width: 240px; }
|
||||||
#bu-tooltip { font-size: 13px; }
|
#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%; }
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-35
@@ -6,6 +6,7 @@
|
|||||||
const health = document.getElementById('bu-health');
|
const health = document.getElementById('bu-health');
|
||||||
const toast = document.getElementById('bu-toast');
|
const toast = document.getElementById('bu-toast');
|
||||||
const tooltip = document.getElementById('bu-tooltip');
|
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 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 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: {} };
|
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' : ''}`;
|
toast.textContent = message; toast.className = `show${error ? ' error' : ''}`;
|
||||||
clearTimeout(notify.timer); notify.timer = setTimeout(() => toast.className = '', 4500);
|
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 = () => {
|
const uuid = () => {
|
||||||
if (window.crypto && typeof window.crypto.randomUUID === 'function') return window.crypto.randomUUID();
|
if (window.crypto && typeof window.crypto.randomUUID === 'function') return window.crypto.randomUUID();
|
||||||
const bytes = new Uint8Array(16);
|
const bytes = new Uint8Array(16);
|
||||||
@@ -197,6 +245,8 @@
|
|||||||
const button = (label, action, kind = '') => `<button class="bu-button ${kind}" type="button" data-action="${esc(action)}"${tipAttr(actionTooltip(action, label))}>${esc(label)}</button>`;
|
const button = (label, action, kind = '') => `<button class="bu-button ${kind}" type="button" data-action="${esc(action)}"${tipAttr(actionTooltip(action, label))}>${esc(label)}</button>`;
|
||||||
const disabledButton = (label, help) => `<button class="bu-button" type="button" disabled${tipAttr(help)}>${esc(label)}</button>`;
|
const disabledButton = (label, help) => `<button class="bu-button" type="button" disabled${tipAttr(help)}>${esc(label)}</button>`;
|
||||||
const actionGroup = (buttons) => `<div class="bu-actions bu-table-actions">${buttons}</div>`;
|
const actionGroup = (buttons) => `<div class="bu-actions bu-table-actions">${buttons}</div>`;
|
||||||
|
const actionMenu = (label, buttons) => `<details class="bu-action-menu"><summary class="bu-button" aria-label="${esc(label)}">Mehr <span aria-hidden="true">⋮</span></summary><div class="bu-action-menu-popover">${buttons}</div></details>`;
|
||||||
|
const filterBox = (placeholder, target) => `<label class="bu-filter"><span aria-hidden="true">⌕</span><input type="search" placeholder="${esc(placeholder)}" aria-label="${esc(placeholder)}" data-filter-table="${esc(target)}"></label>`;
|
||||||
const formatBytes = (value) => {
|
const formatBytes = (value) => {
|
||||||
const units = ['B','KiB','MiB','GiB','TiB']; let size = Number(value)||0; let unit = 0;
|
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++; }
|
while (size >= 1024 && unit < units.length-1) { size /= 1024; unit++; }
|
||||||
@@ -208,6 +258,13 @@
|
|||||||
const minutes=Math.floor(seconds/60); seconds%=60;
|
const minutes=Math.floor(seconds/60); seconds%=60;
|
||||||
return hours?`${hours}h ${minutes}m`:minutes?`${minutes}m ${seconds}s`:`${seconds}s`;
|
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'}];
|
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 * * *') {
|
function parseSchedule(cron = '0 2 * * *') {
|
||||||
const value=String(cron).trim();
|
const value=String(cron).trim();
|
||||||
@@ -404,28 +461,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderDashboard() {
|
function renderDashboard() {
|
||||||
const active = state.runs.filter(r => ['queued','running','paused'].includes(r.status)).length;
|
|
||||||
const orderedRuns = newestRuns(state.runs);
|
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 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 stats=state.repositoryStats;
|
||||||
const statsErrors=stats.filter(item=>item.error);
|
const statsErrors=stats.filter(item=>item.error);
|
||||||
const loading=state.repositoryStatsLoading?'<div class="bu-muted">Repository-Werte werden geladen…</div>':statsErrors.length?`<div class="bu-warning">${statsErrors.map(item=>`${esc(item.repositoryName)}: ${esc(displayMessage(item.error))}`).join('<br>')}</div>`:'';
|
const loading=state.repositoryStatsLoading?'<div class="bu-muted">Repository-Werte werden geladen…</div>':statsErrors.length?`<div class="bu-warning">${statsErrors.map(item=>`${esc(item.repositoryName)}: ${esc(displayMessage(item.error))}`).join('<br>')}</div>`:'';
|
||||||
return `<div class="bu-grid">
|
const needsSetup=!state.config.jobs.length||!state.config.repositories.length;
|
||||||
<section class="bu-card"><div class="bu-muted">Aktive Jobs</div><div class="bu-stat">${state.config.jobs.filter(j => j.enabled).length}</div></section>
|
const latestFailed=orderedRuns[0]?.status==='failed';
|
||||||
<section class="bu-card"><div class="bu-muted">Repositorys</div><div class="bu-stat">${state.config.repositories.length}</div></section>
|
const heroClass=latestFailed?'bad':active?'active':needsSetup?'neutral':'good';
|
||||||
<section class="bu-card"><div class="bu-muted">Wartend / laufend</div><div class="bu-stat">${active}</div></section>
|
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';
|
||||||
<section class="bu-card"><div class="bu-muted">Letzte Fehler</div><div class="bu-stat">${failures.length}</div></section>
|
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 `<section class="bu-overview-hero ${heroClass}"><div class="bu-overview-icon" aria-hidden="true">${latestFailed?'!':active?'↻':needsSetup?'→':'✓'}</div><div class="bu-overview-copy"><span class="bu-eyebrow">Systemstatus</span><h2>${esc(heroTitle)}</h2><p>${esc(heroText)}</p></div><div class="bu-overview-action">${heroAction}</div></section>
|
||||||
|
<div class="bu-grid bu-stat-grid">
|
||||||
|
<section class="bu-card bu-stat-card"><div><div class="bu-muted">Aktive Jobs</div><div class="bu-stat">${state.config.jobs.filter(j => j.enabled).length}</div></div><span class="bu-stat-icon" aria-hidden="true">◷</span></section>
|
||||||
|
<section class="bu-card bu-stat-card"><div><div class="bu-muted">Repositorys</div><div class="bu-stat">${state.config.repositories.length}</div></div><span class="bu-stat-icon" aria-hidden="true">▣</span></section>
|
||||||
|
<section class="bu-card bu-stat-card"><div><div class="bu-muted">Wartend / laufend</div><div class="bu-stat">${active}</div></div><span class="bu-stat-icon" aria-hidden="true">↻</span></section>
|
||||||
|
<section class="bu-card bu-stat-card ${failures.length?'has-alert':''}"><div><div class="bu-muted">Jüngste Fehler</div><div class="bu-stat">${failures.length}</div></div><span class="bu-stat-icon" aria-hidden="true">!</span></section>
|
||||||
</div><div class="bu-chart-grid-layout">
|
</div><div class="bu-chart-grid-layout">
|
||||||
<section class="bu-card"><h2>Repository-Größe</h2><div class="bu-muted">Tatsächlich gespeicherte, deduplizierte Restic-Daten pro Repository</div>${loading}${repositoryChart(stats,'storedBytes',formatBytes,'Noch keine Repository-Daten verfügbar.')}</section>
|
<section class="bu-card"><h2>Repository-Größe</h2><div class="bu-muted">Tatsächlich gespeicherte, deduplizierte Restic-Daten pro Repository</div>${loading}${repositoryChart(stats,'storedBytes',formatBytes,'Noch keine Repository-Daten verfügbar.')}</section>
|
||||||
<section class="bu-card"><h2>Dateien in Snapshots</h2><div class="bu-muted">Gesamte referenzierte Dateianzahl aller Snapshots pro Repository</div>${loading}${repositoryChart(stats,'fileCount',value=>Math.round(value).toLocaleString(),'Noch keine Snapshot-Dateien verfügbar.')}</section>
|
<section class="bu-card"><h2>Dateien in Snapshots</h2><div class="bu-muted">Gesamte referenzierte Dateianzahl aller Snapshots pro Repository</div>${loading}${repositoryChart(stats,'fileCount',value=>Math.round(value).toLocaleString(),'Noch keine Snapshot-Dateien verfügbar.')}</section>
|
||||||
</div><section class="bu-card" style="margin-top:16px"><h2>Letzte Aktivitäten</h2>${runsTable(last)}</section>`;
|
</div><section class="bu-card bu-section-gap"><div class="bu-toolbar"><div><h2>Letzte Aktivitäten</h2><div class="bu-muted">Die sechs jüngsten Vorgänge auf einen Blick.</div></div>${button('Alle anzeigen','go-view:activity')}</div>${runsTable(last)}</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderJobs() {
|
function renderJobs() {
|
||||||
const rows = state.config.jobs.map(j => `<tr><td><strong>${esc(j.name)}</strong><br><span class="bu-muted">${esc(j.id)}</span></td><td>${esc(typeLabels[j.type]||j.type)}</td><td>${esc(j.type==='rsync'?(j.rsync?.target||'Kein Ziel'):repoName(j.repositoryId))}</td><td>${esc(scheduleLabel(j.schedule?.cron))}</td><td>${j.enabled ? status('success') : status('cancelled')}</td><td>${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'))}</td></tr>`).join('');
|
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 `<tr data-search="${esc(`${j.name} ${j.id} ${typeLabels[j.type]||j.type} ${destination}`.toLowerCase())}"><td data-label="Job"><strong>${esc(j.name)}</strong><br><span class="bu-muted">${esc(j.id)}</span></td><td data-label="Typ">${esc(typeLabels[j.type]||j.type)}</td><td data-label="Repository / Ziel">${esc(destination)}</td><td data-label="Zeitplan">${esc(scheduleLabel(j.schedule?.cron))}</td><td data-label="Aktiv">${j.enabled ? status('success') : status('cancelled')}</td><td data-label="Aktionen"><div class="bu-row-actions">${button('Starten',`run-job:${j.id}`,'primary')}${actionMenu(`Weitere Aktionen für ${j.name}`,secondary)}</div></td></tr>`; }).join('');
|
||||||
return `<div class="bu-toolbar"><div><h2>Backup-Jobs</h2><div class="bu-muted">Restic-Backups und direkte Rsync-Kopien werden gemeinsam geplant und überwacht.</div></div>${button('Neuer Job','new-job','primary')}</div>
|
return `<div class="bu-toolbar"><div><h2>Backup-Jobs</h2><div class="bu-muted">Restic-Backups und direkte Rsync-Kopien werden gemeinsam geplant und überwacht.</div></div><div class="bu-actions">${filterBox('Jobs durchsuchen','jobs')}${button('Neuer Job','new-job','primary')}</div></div>
|
||||||
<section class="bu-card">${rows ? `<table class="bu-table"><thead><tr><th>Job</th><th>Typ</th><th>Repository / Ziel</th><th>Zeitplan</th><th>Aktiv</th><th>Aktionen</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">Keine Backup-Jobs konfiguriert.</div>'}</section>`;
|
<section class="bu-card">${rows ? `<table id="bu-table-jobs" class="bu-table bu-responsive-table"><thead><tr><th>Job</th><th>Typ</th><th>Repository / Ziel</th><th>Zeitplan</th><th>Aktiv</th><th>Aktionen</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">Keine Backup-Jobs konfiguriert.</div>'}</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function jobForm(job = {}) {
|
function jobForm(job = {}) {
|
||||||
@@ -435,16 +501,15 @@
|
|||||||
resetSourceTree(sourceRoots);
|
resetSourceTree(sourceRoots);
|
||||||
delete state.directoryTrees.rsyncTarget;
|
delete state.directoryTrees.rsyncTarget;
|
||||||
const options = state.config.repositories.map(r => `<option value="${esc(r.id)}" ${r.id===job.repositoryId?'selected':''}>${esc(r.name)}</option>`).join('');
|
const options = state.config.repositories.map(r => `<option value="${esc(r.id)}" ${r.id===job.repositoryId?'selected':''}>${esc(r.name)}</option>`).join('');
|
||||||
content.innerHTML = `<section class="bu-card"><h2>Backup-Job ${job.id ? 'bearbeiten' : 'erstellen'}</h2><form id="bu-job-form" class="bu-form">
|
content.innerHTML = `<section class="bu-card bu-form-card"><div class="bu-form-heading"><span class="bu-eyebrow">Backup-Plan</span><h2>Backup-Job ${job.id ? 'bearbeiten' : 'erstellen'}</h2><p class="bu-muted">Quelle, Ziel und Zeitplan in einem Sicherungsplan zusammenfassen.</p></div><form id="bu-job-form" class="bu-form">
|
||||||
<input type="hidden" name="id" value="${esc(job.id || '')}"><label>Name<input name="name" required value="${esc(job.name || '')}"></label>
|
<input type="hidden" name="id" value="${esc(job.id || '')}"><fieldset class="wide bu-fieldset"><legend>Allgemein</legend><div class="bu-form-grid"><label>Name<input name="name" required value="${esc(job.name || '')}"></label>
|
||||||
<label>Typ<select name="type">${['share','appdata','docker','vm','flash','rsync'].map(v=>`<option value="${v}" ${v===job.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label>
|
<label>Typ<select name="type">${['share','appdata','docker','vm','flash','rsync'].map(v=>`<option value="${v}" ${v===job.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label><label class="wide bu-inline-check"><input name="enabled" type="checkbox" ${job.enabled !== false?'checked':''}><span>Automatische Ausführung aktiviert</span></label></div></fieldset>
|
||||||
<fieldset id="bu-restic-job-settings" class="wide bu-fieldset"><legend>Restic-Einstellungen</legend><div class="bu-form-grid"><label>Repository<select name="repositoryId">${options}</select></label><label>Kompression<select name="compression"><option value="auto" ${(job.compression||'auto')==='auto'?'selected':''}>Automatisch</option><option value="off" ${job.compression==='off'?'selected':''}>Aus</option><option value="max" ${job.compression==='max'?'selected':''}>Maximal</option></select></label><label>CPU-Kerne für dieses Backup<input name="cpuCores" type="number" min="0" max="256" value="${Number(job.cpuCores||0)}"><span class="bu-muted">0 = alle verfügbaren Kerne</span></label><label>Gleichzeitig gelesene Dateien<input name="readConcurrency" type="number" min="0" max="64" value="${Number(job.readConcurrency||0)}"><span class="bu-muted">0 = Restic-Standard</span></label></div></fieldset>
|
<fieldset id="bu-restic-job-settings" class="wide bu-fieldset"><legend>Restic-Einstellungen</legend><div class="bu-form-grid"><label>Repository<select name="repositoryId">${options}</select></label><label>Kompression<select name="compression"><option value="auto" ${(job.compression||'auto')==='auto'?'selected':''}>Automatisch</option><option value="off" ${job.compression==='off'?'selected':''}>Aus</option><option value="max" ${job.compression==='max'?'selected':''}>Maximal</option></select></label><label>CPU-Kerne für dieses Backup<input name="cpuCores" type="number" min="0" max="256" value="${Number(job.cpuCores||0)}"><span class="bu-muted">0 = alle verfügbaren Kerne</span></label><label>Gleichzeitig gelesene Dateien<input name="readConcurrency" type="number" min="0" max="64" value="${Number(job.readConcurrency||0)}"><span class="bu-muted">0 = Restic-Standard</span></label></div></fieldset>
|
||||||
${scheduleFields(job)}
|
${scheduleFields(job)}
|
||||||
<div id="bu-job-specific" class="wide"></div>
|
<div id="bu-job-specific" class="wide"></div>
|
||||||
<label class="wide">Ausschlussmuster, eines pro Zeile<textarea name="excludes">${esc((job.excludes||[]).join('\n'))}</textarea></label>
|
<details class="wide bu-form-section"><summary>Erweiterte Dateifilter</summary><label>Ausschlussmuster, eines pro Zeile<textarea name="excludes">${esc((job.excludes||[]).join('\n'))}</textarea></label><p class="bu-muted">Optional. Beispielsweise Cache-Verzeichnisse, temporäre Dateien oder große, reproduzierbare Inhalte ausschließen.</p></details>
|
||||||
<fieldset id="bu-retention-settings" class="wide bu-fieldset"><legend>Backup-Aufbewahrung</legend><p class="bu-muted">Snapshots bleiben erhalten, wenn mindestens eine aktivierte Regel zutrifft. Täglich, wöchentlich und monatlich auf 0 setzen, um ausschließlich nach Alter aufzubewahren.</p><div class="bu-retention-grid"><label>Alle Backups behalten für Tage<input name="keepWithinDays" type="number" min="0" value="${job.retention?.keepWithinDays ?? 30}"></label><label>Zusätzliche tägliche Backups<input name="keepDaily" type="number" min="0" value="${job.retention?.daily ?? 0}"></label><label>Zusätzliche wöchentliche Backups<input name="keepWeekly" type="number" min="0" value="${job.retention?.weekly ?? 0}"></label><label>Zusätzliche monatliche Backups<input name="keepMonthly" type="number" min="0" value="${job.retention?.monthly ?? 0}"></label></div></fieldset>
|
<fieldset id="bu-retention-settings" class="wide bu-fieldset"><legend>Backup-Aufbewahrung</legend><p class="bu-muted">Snapshots bleiben erhalten, wenn mindestens eine aktivierte Regel zutrifft. Täglich, wöchentlich und monatlich auf 0 setzen, um ausschließlich nach Alter aufzubewahren.</p><div class="bu-retention-grid"><label>Alle Backups behalten für Tage<input name="keepWithinDays" type="number" min="0" value="${job.retention?.keepWithinDays ?? 30}"></label><label>Zusätzliche tägliche Backups<input name="keepDaily" type="number" min="0" value="${job.retention?.daily ?? 0}"></label><label>Zusätzliche wöchentliche Backups<input name="keepWeekly" type="number" min="0" value="${job.retention?.weekly ?? 0}"></label><label>Zusätzliche monatliche Backups<input name="keepMonthly" type="number" min="0" value="${job.retention?.monthly ?? 0}"></label></div></fieldset>
|
||||||
<label><span>Aktiviert</span><input name="enabled" type="checkbox" ${job.enabled !== false?'checked':''}></label>
|
<div class="wide bu-actions bu-form-actions">${button('Abbrechen','cancel-form')}${button('Job speichern','submit-job','primary')}</div></form></section>`;
|
||||||
<div class="wide bu-actions">${button('Speichern','submit-job','primary')}${button('Abbrechen','cancel-form')}</div></form></section>`;
|
|
||||||
enhanceTooltips(content);
|
enhanceTooltips(content);
|
||||||
renderJobSpecific(job.type || 'share', job);
|
renderJobSpecific(job.type || 'share', job);
|
||||||
}
|
}
|
||||||
@@ -621,11 +686,11 @@
|
|||||||
|
|
||||||
function renderRepositories() {
|
function renderRepositories() {
|
||||||
const rows = state.config.repositories.map(r => {
|
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 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 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');
|
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 `<tr><td><strong>${esc(r.name)}</strong><br><span class="bu-muted">${esc(r.id)}</span></td><td>${esc(typeLabels[r.type]||r.type)}</td><td>${esc(r.location)}${volatileLocation(r)?'<span class="bu-warning">Flüchtiger Pfad: Die Daten gehen beim Neustart verloren. Verwende /mnt/user/...</span>':''}</td><td>${r.mount?.managed?'Durch URBM':'Direkt / extern'}</td><td>${actionGroup(actions)}</td></tr>`;
|
return `<tr data-search="${esc(`${r.name} ${r.id} ${typeLabels[r.type]||r.type} ${r.location}`.toLowerCase())}"><td data-label="Name"><strong>${esc(r.name)}</strong><br><span class="bu-muted">${esc(r.id)}</span></td><td data-label="Typ">${esc(typeLabels[r.type]||r.type)}</td><td data-label="Speicherort" class="bu-path-cell">${esc(r.location)}${volatileLocation(r)?'<span class="bu-warning">Flüchtiger Pfad: Die Daten gehen beim Neustart verloren. Verwende /mnt/user/...</span>':''}</td><td data-label="Einbindung">${r.mount?.managed?'Durch URBM':'Direkt / extern'}</td><td data-label="Aktionen"><div class="bu-row-actions">${button('Testen',`test-repo:${r.id}`,'primary')}${actionMenu(`Weitere Aktionen für ${r.name}`,secondary)}</div></td></tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
return `<div class="bu-toolbar"><div><h2>Repositorys</h2><div class="bu-muted">Lokale, SMB-, NFS- und SFTP-Ziele.</div></div><div class="bu-actions">${button('Vorhandenes einbinden','existing-repo')}${button('Neues Repository','new-repo','primary')}</div></div><section class="bu-card">${rows ? `<table class="bu-table"><thead><tr><th>Name</th><th>Typ</th><th>Speicherort</th><th>Einbindung</th><th>Aktionen</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">Keine Repositorys konfiguriert.</div>'}</section>`;
|
return `<div class="bu-toolbar"><div><h2>Repositorys</h2><div class="bu-muted">Lokale, SMB-, NFS- und SFTP-Ziele.</div></div><div class="bu-actions">${filterBox('Repositorys durchsuchen','repositories')}${button('Vorhandenes einbinden','existing-repo')}${button('Neues Repository','new-repo','primary')}</div></div><section class="bu-card">${rows ? `<table id="bu-table-repositories" class="bu-table bu-responsive-table"><thead><tr><th>Name</th><th>Typ</th><th>Speicherort</th><th>Einbindung</th><th>Aktionen</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">Keine Repositorys konfiguriert.</div>'}</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function repositoryForm(repo = {}, existingRepository = false) {
|
function repositoryForm(repo = {}, existingRepository = false) {
|
||||||
@@ -637,7 +702,7 @@
|
|||||||
<label>Name<input name="name" required value="${esc(repo.name||'')}"></label><label>Typ<select name="type">${['local','smb','nfs','sftp'].map(v=>`<option value="${v}" ${v===repo.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label>
|
<label>Name<input name="name" required value="${esc(repo.name||'')}"></label><label>Typ<select name="type">${['local','smb','nfs','sftp'].map(v=>`<option value="${v}" ${v===repo.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label>
|
||||||
<div id="bu-repository-specific" class="wide"></div>
|
<div id="bu-repository-specific" class="wide"></div>
|
||||||
${repo.id?`<fieldset class="wide bu-fieldset"><legend>Restic-Verschlüsselung</legend><p>Das Repository-Passwort wird getrennt von den Ziel- und Mount-Einstellungen geändert.</p><div class="bu-actions">${button('Repository-Passwort ändern',`change-repo-password:${repo.id}`)}</div></fieldset>`:`<fieldset class="wide bu-fieldset"><legend>Restic-Verschlüsselung</legend><div class="bu-form-grid"><label>Repository-Passwort<input name="password" type="password" autocomplete="new-password" required placeholder="Ein starkes Repository-Passwort eingeben"></label><label>Repository-Passwort wiederholen<input name="passwordConfirm" type="password" autocomplete="new-password" required placeholder="Repository-Passwort erneut eingeben"></label></div><p class="bu-muted">Das Passwort wird von URBM geschützt gespeichert. Bewahre dieses Passwort zusätzlich sicher auf; ohne das Passwort können Backups nicht wiederhergestellt werden.</p></fieldset>`}
|
${repo.id?`<fieldset class="wide bu-fieldset"><legend>Restic-Verschlüsselung</legend><p>Das Repository-Passwort wird getrennt von den Ziel- und Mount-Einstellungen geändert.</p><div class="bu-actions">${button('Repository-Passwort ändern',`change-repo-password:${repo.id}`)}</div></fieldset>`:`<fieldset class="wide bu-fieldset"><legend>Restic-Verschlüsselung</legend><div class="bu-form-grid"><label>Repository-Passwort<input name="password" type="password" autocomplete="new-password" required placeholder="Ein starkes Repository-Passwort eingeben"></label><label>Repository-Passwort wiederholen<input name="passwordConfirm" type="password" autocomplete="new-password" required placeholder="Repository-Passwort erneut eingeben"></label></div><p class="bu-muted">Das Passwort wird von URBM geschützt gespeichert. Bewahre dieses Passwort zusätzlich sicher auf; ohne das Passwort können Backups nicht wiederhergestellt werden.</p></fieldset>`}
|
||||||
<div class="wide bu-actions">${button(existingRepository?'Einbinden und testen':'Speichern','submit-repo','primary')}${button('Abbrechen','cancel-form')}</div></form></section>`;
|
<div class="wide bu-actions bu-form-actions">${button('Abbrechen','cancel-form')}${button(existingRepository?'Einbinden und testen':'Repository speichern','submit-repo','primary')}</div></form></section>`;
|
||||||
enhanceTooltips(content);
|
enhanceTooltips(content);
|
||||||
renderRepositorySpecific(repo.type || 'local', repo);
|
renderRepositorySpecific(repo.type || 'local', repo);
|
||||||
}
|
}
|
||||||
@@ -713,7 +778,7 @@
|
|||||||
|
|
||||||
function renderRestore() {
|
function renderRestore() {
|
||||||
const options = state.config.repositories.map(r=>`<option value="${esc(r.id)}">${esc(r.name)}</option>`).join('');
|
const options = state.config.repositories.map(r=>`<option value="${esc(r.id)}">${esc(r.name)}</option>`).join('');
|
||||||
return `<section class="bu-card"><h2>Wiederherstellen</h2><form id="bu-restore-form" class="bu-form"><label>Repository<select name="repositoryId">${options}</select></label><label>Snapshot-ID<input name="snapshotId" required value="${esc(state.selectedSnapshot||'')}"></label><label class="wide">Wiederherzustellende Pfade, einer pro Zeile<textarea name="includes">${esc((state.restoreIncludes||[]).join('\n'))}</textarea></label><label class="wide">Ziel<input name="target" required value="${esc(state.config.settings.restoreRoot)}/${esc(id('restore'))}"></label><div id="bu-dir-tree-restoreTarget" class="wide"></div><label>Am Originalort wiederherstellen<input name="inPlace" type="checkbox"></label><label>Überschreibungsrisiko bestätigen<input name="confirmed" type="checkbox"></label><div class="wide bu-actions">${button('Wiederherstellung einreihen','submit-restore','primary')}</div></form></section>`;
|
return `<section class="bu-card bu-form-card"><div class="bu-form-heading"><span class="bu-eyebrow">Sicher wiederherstellen</span><h2>Wiederherstellen</h2><p class="bu-muted">Standardmäßig landen Dateien in einem getrennten Staging-Verzeichnis.</p></div><form id="bu-restore-form" class="bu-form"><fieldset class="wide bu-fieldset"><legend>Quelle</legend><div class="bu-form-grid"><label>Repository<select name="repositoryId">${options}</select></label><label>Snapshot-ID<input name="snapshotId" required value="${esc(state.selectedSnapshot||'')}"></label><label class="wide">Wiederherzustellende Pfade, einer pro Zeile<textarea name="includes">${esc((state.restoreIncludes||[]).join('\n'))}</textarea></label></div></fieldset><fieldset class="wide bu-fieldset"><legend>Ziel</legend><label class="wide">Zielverzeichnis<input name="target" required value="${esc(state.config.settings.restoreRoot)}/${esc(id('restore'))}"></label><div id="bu-dir-tree-restoreTarget" class="wide"></div></fieldset><fieldset class="wide bu-fieldset bu-danger-zone"><legend>Originalpfade überschreiben</legend><p class="bu-muted">Nur für eine bewusst geplante In-place-Wiederherstellung aktivieren.</p><div class="bu-form-grid"><label class="bu-inline-check"><input name="inPlace" type="checkbox"><span>Am Originalort wiederherstellen</span></label><label class="bu-inline-check"><input name="confirmed" type="checkbox"><span>Überschreibungsrisiko bestätigen</span></label></div></fieldset><div class="wide bu-actions bu-form-actions">${button('Wiederherstellung einreihen','submit-restore','primary')}</div></form></section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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>`; }
|
||||||
@@ -810,12 +875,13 @@
|
|||||||
if (name === 'cancel-run') { await api(`/v1/runs/${a}/cancel`,{method:'POST'}); return load(); }
|
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 === '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 === '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 === '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 === '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 === '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(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 === '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(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 === '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 === '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 === '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(); }
|
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 === '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 === '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 === '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 === '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 === 'adopt-repo-password') { await submitRepositoryPassword(true); return; }
|
||||||
if (name.startsWith('submit-')) { await submit(name); return; }
|
if (name.startsWith('submit-')) { await submit(name); return; }
|
||||||
} catch (error) { notify(error.message, true); }
|
} 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.');
|
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'}:{};
|
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.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.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&&!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.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)};
|
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();
|
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')||'');
|
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(!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(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(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')})});
|
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);
|
state.config.repositories=state.config.repositories.filter(r=>r.id!==repo.id).concat(repo);
|
||||||
@@ -892,15 +958,25 @@
|
|||||||
const password=String(f.get('password')||'');
|
const password=String(f.get('password')||'');
|
||||||
if(password.length<8) throw new Error('Das Repository-Passwort muss mindestens 8 Zeichen lang sein.');
|
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.');
|
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.';
|
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(!confirm(prompt)) return;
|
if(!accepted) return;
|
||||||
const suffix=adopt?'/password/adopt':'/password';
|
const suffix=adopt?'/password/adopt':'/password';
|
||||||
await api(`/v1/repositories/${encodeURIComponent(f.get('id'))}${suffix}`,{method:'POST',body:JSON.stringify({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();
|
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(); });
|
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 => {
|
content.addEventListener('change', e => {
|
||||||
if (e.target.name === 'type' && e.target.closest('#bu-repo-form')) {
|
if (e.target.name === 'type' && e.target.closest('#bu-repo-form')) {
|
||||||
renderRepositorySpecific(e.target.value);
|
renderRepositorySpecific(e.target.value);
|
||||||
|
|||||||
Reference in New Issue
Block a user