Auto-commit local changes before build (2026-01-03 14:57:56) #22

Merged
ivooskamp merged 1 commits from v20260103-09-reports-column-selection-ui into main 2026-01-06 09:27:34 +01:00
5 changed files with 460 additions and 93 deletions
Showing only changes of commit 5ed3e50288 - Show all commits

View File

@ -1 +1 @@
v20260103-08-reports-stats-endpoint-fix v20260103-09-reports-column-selection-ui

View File

@ -185,47 +185,119 @@ def api_reports_create():
def api_reports_columns(): def api_reports_columns():
"""Return column metadata used by the Reports UI. """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/<id>/data?view=summary
- /api/reports/<id>/data?view=snapshot
""" """
err = _require_reporting_role() err = _require_reporting_role()
if err is not None: if err is not None:
return err return err
# Note: Columns map to fields returned by /api/reports/<id>/data?view=snapshot # Keep the payload stable so report_config can safely store selected keys.
# plus some derived fields the UI can compute client-side.
return { 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": [ "groups": [
{ {
"name": "Job Information", "name": "Job Information",
"items": [ "items": [
{"key": "job_name", "label": "Job name"}, {"key": "job_name", "label": "Job name", "views": ["snapshot"]},
{"key": "backup_software", "label": "Job type"}, {"key": "job_id", "label": "Job ID", "views": ["snapshot"]},
{"key": "backup_type", "label": "Repository / Target"}, {"key": "backup_software", "label": "Job type", "views": ["snapshot"]},
{"key": "customer_name", "label": "Customer"}, {"key": "backup_type", "label": "Repository / Target", "views": ["snapshot"]},
{"key": "customer_name", "label": "Customer", "views": ["snapshot"]},
], ],
}, },
{ {
"name": "Status", "name": "Status",
"items": [ "items": [
{"key": "status", "label": "Last run status"}, {"key": "status", "label": "Last run status", "views": ["snapshot"]},
{"key": "missed", "label": "Missed"}, {"key": "missed", "label": "Missed", "views": ["snapshot", "summary"]},
{"key": "override_applied", "label": "Override applied"}, {"key": "override_applied", "label": "Override applied", "views": ["snapshot"]},
{"key": "ticket_number", "label": "Ticket number", "views": ["snapshot"]},
], ],
}, },
{ {
"name": "Time & Performance", "name": "Time",
"items": [ "items": [
{"key": "run_at", "label": "Start time"}, {"key": "run_at", "label": "Start time", "views": ["snapshot"]},
{"key": "reviewed_at", "label": "Reviewed at"}, {"key": "reviewed_at", "label": "Reviewed at", "views": ["snapshot"]},
], ],
}, },
{ {
"name": "Reliability", "name": "Object",
"items": [ "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},
],
},
],
} }

View File

@ -174,6 +174,55 @@
var canDeleteReports = {{ 'true' if active_role in ('admin','operator','reporter') else 'false' }}; 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 qs(id) { return document.getElementById(id); }
function fmtPeriod(item) { function fmtPeriod(item) {
@ -224,94 +273,39 @@
var thead = qs('rep_raw_thead'); var thead = qs('rep_raw_thead');
var tbody = qs('rep_raw_tbody'); var tbody = qs('rep_raw_tbody');
function thRow(cols) { var cols = selectedColsFor(view);
return '<tr>' + cols.map(function (c) { return '<th>' + escapeHtml(c) + '</th>'; }).join('') + '</tr>';
function thRow(keys) {
return '<tr>' + keys.map(function (k) { return '<th>' + escapeHtml(colLabel(k)) + '</th>'; }).join('') + '</tr>';
} }
if (view === 'snapshot') { thead.innerHTML = thRow(cols);
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 = '<tr><td colspan="13" class="text-center text-muted py-4">No snapshot rows found.</td></tr>';
return;
}
tbody.innerHTML = items.map(function (r) {
return (
'<tr>' +
'<td class="text-nowrap">' + escapeHtml(r.object_name || '') + '</td>' +
'<td class="text-nowrap">' + escapeHtml(r.customer_name || '') + '</td>' +
'<td class="text-nowrap">' + escapeHtml(String(r.job_id || '')) + '</td>' +
'<td>' + escapeHtml(r.job_name || '') + '</td>' +
'<td class="text-nowrap">' + escapeHtml(r.backup_software || '') + '</td>' +
'<td class="text-nowrap">' + escapeHtml(r.backup_type || '') + '</td>' +
'<td class="text-nowrap">' + escapeHtml(String(r.run_id || '')) + '</td>' +
'<td class="text-nowrap">' + escapeHtml((r.run_at || '').replace('T', ' ')) + '</td>' +
'<td class="text-nowrap">' + escapeHtml(r.status || '') + '</td>' +
'<td class="text-nowrap">' + (r.missed ? '1' : '0') + '</td>' +
'<td class="text-nowrap">' + (r.override_applied ? '1' : '0') + '</td>' +
'<td class="text-nowrap">' + escapeHtml((r.reviewed_at || '').replace('T', ' ')) + '</td>' +
'<td>' + escapeHtml(r.remark || '') + '</td>' +
'</tr>'
);
}).join('');
return;
}
thead.innerHTML = thRow([
'Object', 'Total', 'Success', 'Success (override)', 'Warning', 'Failed', 'Missed', 'Success rate (%)'
]);
if (!items || !items.length) { if (!items || !items.length) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted py-4">No summary rows found.</td></tr>'; tbody.innerHTML = '<tr><td colspan="' + String(cols.length || 1) + '" class="text-center text-muted py-4">No rows found.</td></tr>';
updateDownloadLink();
return; return;
} }
function td(val, nowrap) {
var c = nowrap ? ' class="text-nowrap"' : '';
return '<td' + c + '>' + escapeHtml(fmtCellValue(val)) + '</td>';
}
tbody.innerHTML = items.map(function (r) { tbody.innerHTML = items.map(function (r) {
return ( return (
'<tr>' + '<tr>' +
'<td class="text-nowrap">' + escapeHtml(r.object_name || '') + '</td>' + cols.map(function (k) {
'<td class="text-nowrap">' + escapeHtml(String(r.total_runs || 0)) + '</td>' + return td(r[k], (k === 'run_at' || k === 'reviewed_at' || k === 'job_id' || k === 'run_id' || k === 'customer_name'));
'<td class="text-nowrap">' + escapeHtml(String(r.success_count || 0)) + '</td>' + }).join('') +
'<td class="text-nowrap">' + escapeHtml(String(r.success_override_count || 0)) + '</td>' +
'<td class="text-nowrap">' + escapeHtml(String(r.warning_count || 0)) + '</td>' +
'<td class="text-nowrap">' + escapeHtml(String(r.failed_count || 0)) + '</td>' +
'<td class="text-nowrap">' + escapeHtml(String(r.missed_count || 0)) + '</td>' +
'<td class="text-nowrap">' + escapeHtml(String(r.success_rate || 0)) + '</td>' +
'</tr>' '</tr>'
); );
}).join(''); }).join('');
updateDownloadLink();
} }
function updateRawMeta(total) { function loadRawData() {
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() {
if (!rawReportId) return; if (!rawReportId) return;
setRawViewButtons(); setRawViewButtons();
setRawDownloadLink(); setRawDownloadLink();
@ -332,6 +326,12 @@
function openRawModal(id) { function openRawModal(id) {
rawReportId = 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; rawOffset = 0;
rawView = rawView || 'summary'; rawView = rawView || 'summary';
qs('rep_raw_title').textContent = 'Raw data (Report #' + id + ')'; qs('rep_raw_title').textContent = 'Raw data (Report #' + id + ')';
@ -340,6 +340,7 @@
} }
function renderTable(items) { function renderTable(items) {
reportsItems = items || [];
var body = qs('rep_table_body'); var body = qs('rep_table_body');
if (!items || !items.length) { if (!items || !items.length) {
body.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">No reports defined yet.</td></tr>'; body.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">No reports defined yet.</td></tr>';
@ -428,7 +429,7 @@
rawModal.hide(); rawModal.hide();
rawReportId = null; rawReportId = null;
} }
loadReports(); loadReportColumnsMeta().then(function () { loadReports(); });
}) })
.catch(function () { .catch(function () {
if (btnEl) { btnEl.disabled = false; btnEl.textContent = oldText; } if (btnEl) { btnEl.disabled = false; btnEl.textContent = oldText; }
@ -493,7 +494,7 @@
loadRawData(); loadRawData();
}); });
loadReports(); loadReportColumnsMeta().then(function () { loadReports(); });
}); });
</script> </script>

View File

@ -124,6 +124,36 @@
</div> </div>
</div> </div>
<div class="fw-semibold mb-1">Report content</div>
<div class="text-muted small mb-3">Choose which columns are included in the report and define their order. (Applies to online table views.)</div>
<div class="border rounded p-3">
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between mb-3">
<div class="btn-group" role="group" aria-label="Report content view selector">
<button type="button" class="btn btn-outline-secondary active" id="rep_cols_tab_summary">Summary</button>
<button type="button" class="btn btn-outline-secondary" id="rep_cols_tab_snapshot">Snapshot</button>
</div>
<div class="text-muted small" id="rep_cols_hint">Select columns for the summary view.</div>
</div>
<div class="alert alert-info py-2 px-3 mb-3 d-none" id="rep_cols_loading">Loading available columns…</div>
<div class="alert alert-danger py-2 px-3 mb-3 d-none" id="rep_cols_error"></div>
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="fw-semibold mb-2">Available columns</div>
<div id="rep_cols_available"></div>
</div>
<div class="col-12 col-lg-6">
<div class="fw-semibold mb-2">Selected columns (drag to reorder)</div>
<div class="text-muted small mb-2">Tip: disabled items are coming soon.</div>
<ul class="list-group" id="rep_cols_selected"></ul>
</div>
</div>
</div>
<hr class="my-4" /> <hr class="my-4" />
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
@ -183,6 +213,255 @@
el.textContent = ''; 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 = '<div class="text-muted">No column metadata available.</div>';
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 = '<li class="list-group-item text-muted">No columns selected.</li>';
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 pad2(n) { return (n < 10 ? '0' : '') + String(n); }
function setDateTime(prefix, d) { function setDateTime(prefix, d) {
@ -311,7 +590,8 @@
customer_scope: scope, customer_scope: scope,
customer_ids: customerIds, customer_ids: customerIds,
period_start: buildIso(qs('rep_start_date').value, qs('rep_start_time').value, '00:00'), 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; if (!payload.description) delete payload.description;
@ -368,6 +648,9 @@
updateScopeUi(); updateScopeUi();
loadCustomers(); loadCustomers();
// init column selector
loadReportColumns();
}); });
</script> </script>

View File

@ -70,6 +70,17 @@
- Removed the redundant `/api/reports/<report_id>/stats` route definition to ensure the endpoint is registered only once. - Removed the redundant `/api/reports/<report_id>/stats` route definition to ensure the endpoint is registered only once.
- Restored proper Gunicorn boot sequence by resolving Flask endpoint name collision. - 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 ## v0.1.15