Auto-commit local changes before build (2026-01-03 14:57:56)
This commit is contained in:
parent
a5c8f2db3c
commit
5ed3e50288
@ -1 +1 @@
|
|||||||
v20260103-08-reports-stats-endpoint-fix
|
v20260103-09-reports-column-selection-ui
|
||||||
|
|||||||
@ -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},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user