From 5ed3e5028890a339e8f5564ecd87b1ff53fff2c1 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Sat, 3 Jan 2026 14:57:56 +0100 Subject: [PATCH] Auto-commit local changes before build (2026-01-03 14:57:56) --- .last-branch | 2 +- .../backend/app/main/routes_reporting_api.py | 104 ++++++- .../src/templates/main/reports.html | 151 +++++----- .../src/templates/main/reports_new.html | 285 +++++++++++++++++- docs/changelog.md | 11 + 5 files changed, 460 insertions(+), 93 deletions(-) diff --git a/.last-branch b/.last-branch index f9f63f4..7818126 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260103-08-reports-stats-endpoint-fix +v20260103-09-reports-column-selection-ui diff --git a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py index 58037da..8ee5b24 100644 --- a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py @@ -185,47 +185,119 @@ def api_reports_create(): def api_reports_columns(): """Return column metadata used by the Reports UI. - This is a UI helper endpoint so the frontend can render grouped column selectors. + The UI uses this endpoint for: + - grouped column selectors in the "New report" wizard + - translating column keys into human-readable labels + - sensible default columns per view + + Note: Column keys map to fields returned by: + - /api/reports//data?view=summary + - /api/reports//data?view=snapshot """ err = _require_reporting_role() if err is not None: return err - # Note: Columns map to fields returned by /api/reports//data?view=snapshot - # plus some derived fields the UI can compute client-side. + # Keep the payload stable so report_config can safely store selected keys. return { + "views": [ + {"key": "summary", "label": "Summary"}, + {"key": "snapshot", "label": "Snapshot"}, + ], + "defaults": { + "summary": [ + "object_name", + "total_runs", + "success_count", + "warning_count", + "failed_count", + "missed_count", + "success_rate", + ], + "snapshot": [ + "object_name", + "customer_name", + "job_id", + "job_name", + "backup_software", + "backup_type", + "status", + "missed", + "override_applied", + "run_id", + "run_at", + "ticket_number", + "remark", + "reviewed_at", + ], + }, "groups": [ { "name": "Job Information", "items": [ - {"key": "job_name", "label": "Job name"}, - {"key": "backup_software", "label": "Job type"}, - {"key": "backup_type", "label": "Repository / Target"}, - {"key": "customer_name", "label": "Customer"}, + {"key": "job_name", "label": "Job name", "views": ["snapshot"]}, + {"key": "job_id", "label": "Job ID", "views": ["snapshot"]}, + {"key": "backup_software", "label": "Job type", "views": ["snapshot"]}, + {"key": "backup_type", "label": "Repository / Target", "views": ["snapshot"]}, + {"key": "customer_name", "label": "Customer", "views": ["snapshot"]}, ], }, { "name": "Status", "items": [ - {"key": "status", "label": "Last run status"}, - {"key": "missed", "label": "Missed"}, - {"key": "override_applied", "label": "Override applied"}, + {"key": "status", "label": "Last run status", "views": ["snapshot"]}, + {"key": "missed", "label": "Missed", "views": ["snapshot", "summary"]}, + {"key": "override_applied", "label": "Override applied", "views": ["snapshot"]}, + {"key": "ticket_number", "label": "Ticket number", "views": ["snapshot"]}, ], }, { - "name": "Time & Performance", + "name": "Time", "items": [ - {"key": "run_at", "label": "Start time"}, - {"key": "reviewed_at", "label": "Reviewed at"}, + {"key": "run_at", "label": "Start time", "views": ["snapshot"]}, + {"key": "reviewed_at", "label": "Reviewed at", "views": ["snapshot"]}, ], }, { - "name": "Reliability", + "name": "Object", "items": [ - {"key": "remark", "label": "Remark"}, + {"key": "object_name", "label": "Object name", "views": ["snapshot", "summary"]}, ], }, - ] + { + "name": "Summary counts", + "items": [ + {"key": "total_runs", "label": "Total runs", "views": ["summary"]}, + {"key": "success_count", "label": "Success", "views": ["summary"]}, + {"key": "warning_count", "label": "Warnings", "views": ["summary"]}, + {"key": "failed_count", "label": "Failed", "views": ["summary"]}, + {"key": "missed_count", "label": "Missed", "views": ["summary"]}, + {"key": "success_rate", "label": "Success rate (%)", "views": ["summary"]}, + ], + }, + { + "name": "Reliability (coming soon)", + "items": [ + {"key": "consecutive_failures", "label": "Consecutive failures", "views": ["summary", "snapshot"], "enabled": False}, + {"key": "last_success_at", "label": "Last successful run", "views": ["summary", "snapshot"], "enabled": False}, + ], + }, + { + "name": "Performance (coming soon)", + "items": [ + {"key": "duration_sec", "label": "Duration", "views": ["snapshot"], "enabled": False}, + {"key": "avg_duration_sec", "label": "Average duration", "views": ["summary"], "enabled": False}, + ], + }, + { + "name": "Data (coming soon)", + "items": [ + {"key": "data_processed", "label": "Data processed", "views": ["snapshot"], "enabled": False}, + {"key": "data_size", "label": "Data size", "views": ["snapshot"], "enabled": False}, + {"key": "change_rate", "label": "Change rate", "views": ["summary"], "enabled": False}, + ], + }, + ], } diff --git a/containers/backupchecks/src/templates/main/reports.html b/containers/backupchecks/src/templates/main/reports.html index 2bb1d55..1a273d4 100644 --- a/containers/backupchecks/src/templates/main/reports.html +++ b/containers/backupchecks/src/templates/main/reports.html @@ -174,6 +174,55 @@ var canDeleteReports = {{ 'true' if active_role in ('admin','operator','reporter') else 'false' }}; + var reportsItems = []; + var reportColumnsMeta = null; + var rawReportConfig = null; + + function loadReportColumnsMeta() { + return fetch('/api/reports/columns', { credentials: 'same-origin' }) + .then(function (r) { return r.json(); }) + .then(function (j) { reportColumnsMeta = j || null; }) + .catch(function () { reportColumnsMeta = null; }); + } + + function colLabel(key) { + if (!reportColumnsMeta || !reportColumnsMeta.groups) return key; + for (var i = 0; i < reportColumnsMeta.groups.length; i++) { + var items = reportColumnsMeta.groups[i].items || []; + for (var j = 0; j < items.length; j++) { + if (items[j].key === key) return items[j].label || key; + } + } + return key; + } + + function defaultColsFor(view) { + if (reportColumnsMeta && reportColumnsMeta.defaults && reportColumnsMeta.defaults[view]) { + return reportColumnsMeta.defaults[view].slice(); + } + // hard fallback + if (view === 'snapshot') return ['object_name','customer_name','job_id','job_name','status','run_at']; + return ['object_name','total_runs','success_count','warning_count','failed_count','missed_count','success_rate']; + } + + function selectedColsFor(view) { + var cfg = rawReportConfig || {}; + var cols = (cfg.columns && cfg.columns[view]) ? cfg.columns[view] : null; + if (cols && cols.length) return cols; + return defaultColsFor(view); + } + + function fmtCellValue(v) { + if (v === null || v === undefined) return ''; + if (typeof v === 'boolean') return v ? 'Yes' : 'No'; + var s = String(v); + // basic ISO datetime prettify + if (s.indexOf('T') >= 0 && s.indexOf(':') >= 0 && s.indexOf('-') >= 0) { + return s.replace('T', ' '); + } + return s; + } + function qs(id) { return document.getElementById(id); } function fmtPeriod(item) { @@ -224,94 +273,39 @@ var thead = qs('rep_raw_thead'); var tbody = qs('rep_raw_tbody'); - function thRow(cols) { - return '' + cols.map(function (c) { return '' + escapeHtml(c) + ''; }).join('') + ''; + var cols = selectedColsFor(view); + + function thRow(keys) { + return '' + keys.map(function (k) { return '' + escapeHtml(colLabel(k)) + ''; }).join('') + ''; } - if (view === 'snapshot') { - thead.innerHTML = thRow([ - 'Object', 'Customer', 'Job ID', 'Job Name', 'Backup software', 'Backup type', - 'Run ID', 'Run at (UTC)', 'Status', 'Missed', 'Override', 'Reviewed at', 'Remark' - ]); - - if (!items || !items.length) { - tbody.innerHTML = 'No snapshot rows found.'; - return; - } - - tbody.innerHTML = items.map(function (r) { - return ( - '' + - '' + escapeHtml(r.object_name || '') + '' + - '' + escapeHtml(r.customer_name || '') + '' + - '' + escapeHtml(String(r.job_id || '')) + '' + - '' + escapeHtml(r.job_name || '') + '' + - '' + escapeHtml(r.backup_software || '') + '' + - '' + escapeHtml(r.backup_type || '') + '' + - '' + escapeHtml(String(r.run_id || '')) + '' + - '' + escapeHtml((r.run_at || '').replace('T', ' ')) + '' + - '' + escapeHtml(r.status || '') + '' + - '' + (r.missed ? '1' : '0') + '' + - '' + (r.override_applied ? '1' : '0') + '' + - '' + escapeHtml((r.reviewed_at || '').replace('T', ' ')) + '' + - '' + escapeHtml(r.remark || '') + '' + - '' - ); - }).join(''); - - return; - } - - thead.innerHTML = thRow([ - 'Object', 'Total', 'Success', 'Success (override)', 'Warning', 'Failed', 'Missed', 'Success rate (%)' - ]); + thead.innerHTML = thRow(cols); if (!items || !items.length) { - tbody.innerHTML = 'No summary rows found.'; + tbody.innerHTML = 'No rows found.'; + updateDownloadLink(); return; } + function td(val, nowrap) { + var c = nowrap ? ' class="text-nowrap"' : ''; + return '' + escapeHtml(fmtCellValue(val)) + ''; + } + tbody.innerHTML = items.map(function (r) { return ( '' + - '' + escapeHtml(r.object_name || '') + '' + - '' + escapeHtml(String(r.total_runs || 0)) + '' + - '' + escapeHtml(String(r.success_count || 0)) + '' + - '' + escapeHtml(String(r.success_override_count || 0)) + '' + - '' + escapeHtml(String(r.warning_count || 0)) + '' + - '' + escapeHtml(String(r.failed_count || 0)) + '' + - '' + escapeHtml(String(r.missed_count || 0)) + '' + - '' + escapeHtml(String(r.success_rate || 0)) + '' + + cols.map(function (k) { + return td(r[k], (k === 'run_at' || k === 'reviewed_at' || k === 'job_id' || k === 'run_id' || k === 'customer_name')); + }).join('') + '' ); }).join(''); + + updateDownloadLink(); } - function updateRawMeta(total) { - var a = rawOffset + 1; - var b = Math.min(rawOffset + rawLimit, total); - if (!total) { - qs('rep_raw_meta').textContent = '0 rows'; - } else { - qs('rep_raw_meta').textContent = a + '–' + b + ' of ' + total; - } - - qs('rep_raw_prev_btn').disabled = rawOffset <= 0; - qs('rep_raw_next_btn').disabled = (rawOffset + rawLimit) >= total; - } - - function setRawDownloadLink() { - if (!rawReportId) { - qs('rep_raw_download_btn').setAttribute('href', '#'); - qs('rep_raw_download_btn').classList.add('disabled'); - return; - } - - qs('rep_raw_download_btn').classList.remove('disabled'); - qs('rep_raw_download_btn').setAttribute('href', '/api/reports/' + rawReportId + '/export.csv?view=' + rawView); - } - - function loadRawData() { +function loadRawData() { if (!rawReportId) return; setRawViewButtons(); setRawDownloadLink(); @@ -332,6 +326,12 @@ function openRawModal(id) { rawReportId = id; + rawReportConfig = null; + var rid = String(id || ''); + for (var i = 0; i < (reportsItems || []).length; i++) { + if (String(reportsItems[i].id) === rid) { rawReportConfig = (reportsItems[i].report_config || null); break; } + } + rawOffset = 0; rawView = rawView || 'summary'; qs('rep_raw_title').textContent = 'Raw data (Report #' + id + ')'; @@ -340,6 +340,7 @@ } function renderTable(items) { + reportsItems = items || []; var body = qs('rep_table_body'); if (!items || !items.length) { body.innerHTML = 'No reports defined yet.'; @@ -428,7 +429,7 @@ rawModal.hide(); rawReportId = null; } - loadReports(); + loadReportColumnsMeta().then(function () { loadReports(); }); }) .catch(function () { if (btnEl) { btnEl.disabled = false; btnEl.textContent = oldText; } @@ -493,7 +494,7 @@ loadRawData(); }); - loadReports(); + loadReportColumnsMeta().then(function () { loadReports(); }); }); diff --git a/containers/backupchecks/src/templates/main/reports_new.html b/containers/backupchecks/src/templates/main/reports_new.html index 3ce76ac..e84706e 100644 --- a/containers/backupchecks/src/templates/main/reports_new.html +++ b/containers/backupchecks/src/templates/main/reports_new.html @@ -124,6 +124,36 @@ + +
Report content
+
Choose which columns are included in the report and define their order. (Applies to online table views.)
+ +
+
+
+ + +
+
Select columns for the summary view.
+
+ +
Loading available columns…
+
+ +
+
+
Available columns
+
+
+ +
+
Selected columns (drag to reorder)
+
Tip: disabled items are coming soon.
+
    +
    +
    +
    +
    @@ -183,6 +213,255 @@ el.textContent = ''; } + + // --- Report content / column selector --- + var repColsView = 'summary'; + var repColsMeta = null; + var repColsSelected = { summary: [], snapshot: [] }; + + function colsHintText(viewKey) { + return viewKey === 'snapshot' + ? 'Select columns for the snapshot view.' + : 'Select columns for the summary view.'; + } + + function setColsView(viewKey) { + repColsView = (viewKey === 'snapshot') ? 'snapshot' : 'summary'; + + var a = qs('rep_cols_tab_summary'); + var b = qs('rep_cols_tab_snapshot'); + if (repColsView === 'summary') { + a.classList.add('active'); + b.classList.remove('active'); + } else { + b.classList.add('active'); + a.classList.remove('active'); + } + qs('rep_cols_hint').textContent = colsHintText(repColsView); + + renderColsAvailable(); + renderColsSelected(); + } + + function showColsError(msg) { + var el = qs('rep_cols_error'); + el.textContent = msg || 'Failed to load columns.'; + el.classList.remove('d-none'); + } + + function clearColsError() { + var el = qs('rep_cols_error'); + el.classList.add('d-none'); + el.textContent = ''; + } + + function showColsLoading(on) { + var el = qs('rep_cols_loading'); + if (on) el.classList.remove('d-none'); + else el.classList.add('d-none'); + } + + function ensureDefaultsFromMeta() { + if (!repColsMeta || !repColsMeta.defaults) return; + ['summary', 'snapshot'].forEach(function (v) { + if (!repColsSelected[v] || !repColsSelected[v].length) { + repColsSelected[v] = (repColsMeta.defaults[v] || []).slice(); + } + }); + } + + function renderColsAvailable() { + var host = qs('rep_cols_available'); + if (!host) return; + host.innerHTML = ''; + + if (!repColsMeta || !repColsMeta.groups) { + host.innerHTML = '
    No column metadata available.
    '; + return; + } + + function isSelected(key) { + return (repColsSelected[repColsView] || []).indexOf(key) >= 0; + } + + function toggleKey(key, checked) { + var arr = repColsSelected[repColsView] || []; + var idx = arr.indexOf(key); + if (checked && idx < 0) arr.push(key); + if (!checked && idx >= 0) arr.splice(idx, 1); + repColsSelected[repColsView] = arr; + renderColsSelected(); + } + + repColsMeta.groups.forEach(function (g) { + var items = (g.items || []).filter(function (it) { + var v = it.views || []; + return v.indexOf(repColsView) >= 0; + }); + if (!items.length) return; + + var groupEl = document.createElement('div'); + groupEl.className = 'mb-3'; + + var title = document.createElement('div'); + title.className = 'fw-semibold mb-2'; + title.textContent = g.name || 'Columns'; + groupEl.appendChild(title); + + items.forEach(function (it) { + var enabled = (it.enabled !== false); + var id = 'rep_col_' + repColsView + '_' + (it.key || '').replace(/[^a-zA-Z0-9_]/g, '_'); + + var wrap = document.createElement('div'); + wrap.className = 'form-check'; + + var cb = document.createElement('input'); + cb.className = 'form-check-input'; + cb.type = 'checkbox'; + cb.id = id; + cb.disabled = !enabled; + cb.checked = isSelected(it.key); + + cb.addEventListener('change', function () { + toggleKey(it.key, cb.checked); + }); + + var lbl = document.createElement('label'); + lbl.className = 'form-check-label'; + lbl.setAttribute('for', id); + lbl.textContent = it.label || it.key || ''; + + if (!enabled) { + lbl.classList.add('text-muted'); + lbl.textContent = (lbl.textContent || '') + ' (coming soon)'; + } + + wrap.appendChild(cb); + wrap.appendChild(lbl); + groupEl.appendChild(wrap); + }); + + host.appendChild(groupEl); + }); + } + + function renderColsSelected() { + var host = qs('rep_cols_selected'); + if (!host) return; + host.innerHTML = ''; + + var arr = repColsSelected[repColsView] || []; + if (!arr.length) { + host.innerHTML = '
  • No columns selected.
  • '; + return; + } + + function labelForKey(key) { + if (!repColsMeta || !repColsMeta.groups) return key; + for (var i = 0; i < repColsMeta.groups.length; i++) { + var items = repColsMeta.groups[i].items || []; + for (var j = 0; j < items.length; j++) { + if (items[j].key === key) return items[j].label || key; + } + } + return key; + } + + function removeKey(key) { + var i = arr.indexOf(key); + if (i >= 0) arr.splice(i, 1); + repColsSelected[repColsView] = arr; + renderColsAvailable(); + renderColsSelected(); + } + + arr.forEach(function (key) { + var li = document.createElement('li'); + li.className = 'list-group-item d-flex align-items-center justify-content-between gap-2'; + li.setAttribute('draggable', 'true'); + li.dataset.key = key; + + var left = document.createElement('div'); + left.className = 'd-flex align-items-center gap-2'; + var handle = document.createElement('span'); + handle.className = 'text-muted'; + handle.textContent = '↕'; + var txt = document.createElement('span'); + txt.textContent = labelForKey(key); + + left.appendChild(handle); + left.appendChild(txt); + + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-sm btn-outline-danger'; + btn.textContent = 'Remove'; + btn.addEventListener('click', function () { removeKey(key); }); + + li.appendChild(left); + li.appendChild(btn); + + li.addEventListener('dragstart', function (ev) { + ev.dataTransfer.setData('text/plain', key); + ev.dataTransfer.effectAllowed = 'move'; + }); + + li.addEventListener('dragover', function (ev) { + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'move'; + }); + + li.addEventListener('drop', function (ev) { + ev.preventDefault(); + var draggedKey = ev.dataTransfer.getData('text/plain'); + if (!draggedKey || draggedKey === key) return; + + var from = arr.indexOf(draggedKey); + var to = arr.indexOf(key); + if (from < 0 || to < 0) return; + + arr.splice(from, 1); + arr.splice(to, 0, draggedKey); + repColsSelected[repColsView] = arr; + renderColsSelected(); + }); + + host.appendChild(li); + }); + } + + function loadReportColumns() { + var area = document.getElementById('rep_cols_available'); + if (!area) return; + + showColsLoading(true); + clearColsError(); + + fetch('/api/reports/columns', { credentials: 'same-origin' }) + .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, json: j }; }); }) + .then(function (res) { + showColsLoading(false); + if (!res.ok) { + showColsError((res.json && res.json.error) ? res.json.error : 'Failed to load columns.'); + return; + } + + repColsMeta = res.json || null; + ensureDefaultsFromMeta(); + + // bind tabs once metadata is ready + qs('rep_cols_tab_summary').addEventListener('click', function () { setColsView('summary'); }); + qs('rep_cols_tab_snapshot').addEventListener('click', function () { setColsView('snapshot'); }); + + setColsView('summary'); + }) + .catch(function () { + showColsLoading(false); + showColsError('Failed to load columns.'); + }); + } + + function pad2(n) { return (n < 10 ? '0' : '') + String(n); } function setDateTime(prefix, d) { @@ -311,7 +590,8 @@ customer_scope: scope, customer_ids: customerIds, period_start: buildIso(qs('rep_start_date').value, qs('rep_start_time').value, '00:00'), - period_end: buildIso(qs('rep_end_date').value, qs('rep_end_time').value, '23:59') + period_end: buildIso(qs('rep_end_date').value, qs('rep_end_time').value, '23:59'), + report_config: { columns: repColsSelected, columns_version: 1 } }; if (!payload.description) delete payload.description; @@ -368,6 +648,9 @@ updateScopeUi(); loadCustomers(); + + // init column selector + loadReportColumns(); }); diff --git a/docs/changelog.md b/docs/changelog.md index e201701..62084c1 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -70,6 +70,17 @@ - Removed the redundant `/api/reports//stats` route definition to ensure the endpoint is registered only once. - Restored proper Gunicorn boot sequence by resolving Flask endpoint name collision. +--- + +## v20260103-09-reports-column-selection-ui + +- Added extended report configuration options when creating a report, allowing users to select which columns are included. +- Introduced grouped column selection for Summary and Snapshot views, aligned with the reporting proposal. +- Implemented drag-and-drop ordering for selected report columns. +- Persisted selected columns per report so configurations are reused consistently. +- Updated report rendering to dynamically display data based on the configured columns instead of fixed defaults. +- Extended reports columns metadata endpoint to support configurable and future report metrics. + ================================================================================================================================================ ## v0.1.15