v20260104-14-reports-stats-total-runs-success-rate-fix #28

Merged
ivooskamp merged 22 commits from v20260104-14-reports-stats-total-runs-success-rate-fix into main 2026-01-13 10:59:39 +01:00
9 changed files with 1985 additions and 224 deletions

View File

@ -1 +1 @@
v20260103-12-reports-columns-selector-init-fix
v20260104-14-reports-stats-total-runs-success-rate-fix

View File

@ -6,3 +6,4 @@ psycopg2-binary==2.9.9
python-dateutil==2.9.0.post0
gunicorn==23.0.0
requests==2.32.3
reportlab==4.2.5

View File

@ -1,6 +1,6 @@
from .routes_shared import * # noqa: F401,F403
from datetime import date, timedelta
from .routes_reporting_api import build_report_columns_meta
from .routes_reporting_api import build_report_columns_meta, build_report_job_filters_meta
def get_default_report_period():
"""Return default report period (last 7 days)."""
@ -21,6 +21,17 @@ def _safe_json_list(value):
return []
def _safe_json_dict(value):
if not value:
return {}
if isinstance(value, dict):
return value
try:
return json.loads(value)
except Exception:
return {}
def _build_report_item(r):
return {
"id": int(r.id),
@ -33,6 +44,7 @@ def _build_report_item(r):
"period_start": r.period_start.isoformat() if getattr(r, "period_start", None) else "",
"period_end": r.period_end.isoformat() if getattr(r, "period_end", None) else "",
"schedule": r.schedule or "",
"report_config": _safe_json_dict(getattr(r, "report_config", None)),
"created_at": r.created_at.isoformat() if getattr(r, "created_at", None) else "",
}
@ -55,6 +67,7 @@ def reports():
"main/reports.html",
initial_reports=items,
columns_meta=build_report_columns_meta(),
job_filters_meta=build_report_job_filters_meta(),
default_period_start=period_start.isoformat(),
default_period_end=period_end.isoformat(),
)
@ -72,4 +85,38 @@ def reports_new():
)
customer_items = [{"id": int(c.id), "name": c.name or ""} for c in customers]
return render_template("main/reports_new.html", initial_customers=customer_items, columns_meta=build_report_columns_meta())
return render_template(
"main/reports_new.html",
initial_customers=customer_items,
columns_meta=build_report_columns_meta(),
job_filters_meta=build_report_job_filters_meta(),
is_edit=False,
initial_report=None,
)
@main_bp.route("/reports/<int:report_id>/edit")
@login_required
def reports_edit(report_id: int):
# Editing reports is limited to the same roles that can create them.
if get_active_role() not in ("admin", "operator", "reporter"):
return abort(403)
r = ReportDefinition.query.get_or_404(report_id)
customers = (
db.session.query(Customer)
.filter(Customer.active.is_(True))
.order_by(Customer.name.asc())
.all()
)
customer_items = [{"id": int(c.id), "name": c.name or ""} for c in customers]
return render_template(
"main/reports_new.html",
initial_customers=customer_items,
columns_meta=build_report_columns_meta(),
job_filters_meta=build_report_job_filters_meta(),
is_edit=True,
initial_report=_build_report_item(r),
)

View File

@ -1371,6 +1371,8 @@ def migrate_reporting_tables() -> None:
id SERIAL PRIMARY KEY,
report_id INTEGER NOT NULL REFERENCES report_definitions(id) ON DELETE CASCADE,
object_name TEXT NOT NULL,
customer_id INTEGER NULL,
customer_name TEXT NULL,
total_runs INTEGER NOT NULL DEFAULT 0,
success_count INTEGER NOT NULL DEFAULT 0,
success_override_count INTEGER NOT NULL DEFAULT 0,
@ -1395,5 +1397,7 @@ def migrate_reporting_tables() -> None:
conn.execute(text("ALTER TABLE report_definitions ADD COLUMN IF NOT EXISTS customer_scope VARCHAR(16) NOT NULL DEFAULT 'all'"))
conn.execute(text("ALTER TABLE report_definitions ADD COLUMN IF NOT EXISTS customer_ids TEXT NULL"))
conn.execute(text("ALTER TABLE report_object_snapshots ADD COLUMN IF NOT EXISTS customer_id INTEGER NULL"))
conn.execute(text("ALTER TABLE report_object_summaries ADD COLUMN IF NOT EXISTS customer_id INTEGER NULL"))
conn.execute(text("ALTER TABLE report_object_summaries ADD COLUMN IF NOT EXISTS customer_name TEXT NULL"))
print("[migrations] reporting tables created/verified.")

View File

@ -536,7 +536,7 @@ class ReportDefinition(db.Model):
# one-time | scheduled
report_type = db.Column(db.String(32), nullable=False, default="one-time")
# csv | pdf (pdf is future)
# csv | html | pdf
output_format = db.Column(db.String(16), nullable=False, default="csv")
# customer scope for report generation
@ -612,6 +612,9 @@ class ReportObjectSummary(db.Model):
report_id = db.Column(db.Integer, db.ForeignKey("report_definitions.id"), nullable=False)
object_name = db.Column(db.Text, nullable=False)
customer_id = db.Column(db.Integer, nullable=True)
customer_name = db.Column(db.Text, nullable=True)
total_runs = db.Column(db.Integer, nullable=False, default=0)
success_count = db.Column(db.Integer, nullable=False, default=0)
success_override_count = db.Column(db.Integer, nullable=False, default=0)

View File

@ -40,7 +40,7 @@
{% for item in initial_reports %}
<tr>
<td><strong>{{ item.name }}</strong><div class="text-muted small">{{ item.description }}</div></td>
<td class="text-muted small">{{ item.created_at.replace('T',' ') if item.created_at else '' }}</td>
<td class="text-muted small">{{ item.report_type }}</td>
<td class="text-muted small">
{% if item.period_start or item.period_end %}
{{ item.period_start.replace('T',' ') if item.period_start else '' }} → {{ item.period_end.replace('T',' ') if item.period_end else '' }}
@ -50,9 +50,10 @@
</td>
<td><span class="badge text-bg-light border">{{ item.output_format }}</span></td>
<td class="text-end">
<a class="btn btn-sm btn-outline-secondary" href="{{ url_for('main.reports_edit', report_id=item.id) }}">Edit</a>
<button type="button" class="btn btn-sm btn-outline-primary rep-generate-btn" data-id="{{ item.id }}">Generate</button>
<button type="button" class="btn btn-sm btn-outline-secondary ms-1 rep-view-btn" data-id="{{ item.id }}">View raw</button>
<a class="btn btn-sm btn-outline-success rep-download-btn ms-1" href="/api/reports/{{ item.id }}/export.csv" target="_blank" rel="noopener">Download</a>
<a class="btn btn-sm btn-outline-success rep-download-btn ms-1" href="/api/reports/{{ item.id }}/export?format={{ (item.output_format or 'csv')|lower }}" target="_blank" rel="noopener">Download</a>
{% if active_role in ('admin','operator','reporter') %}
<button type="button" class="btn btn-sm btn-outline-danger rep-delete-btn ms-1" data-id="{{ item.id }}">Delete</button>
{% endif %}
@ -64,7 +65,7 @@
<td colspan="5" class="text-center text-muted py-4">No reports found.</td>
</tr>
{% endif %}
</tbody>>
</tbody>
</table>
</div>
</div>
@ -195,22 +196,58 @@
if (items[j].key === key) return items[j].label || key;
}
}
// Backwards compatibility: object_name was used for job name in older configs.
if (key === 'job_name') {
for (var i2 = 0; i2 < reportColumnsMeta.groups.length; i2++) {
var items2 = reportColumnsMeta.groups[i2].items || [];
for (var j2 = 0; j2 < items2.length; j2++) {
if (items2[j2].key === 'object_name') return items2[j2].label || 'Job name';
}
}
return 'Job name';
}
return key;
}
function uniqPreserveOrder(arr) {
var out = [];
var seen = {};
(arr || []).forEach(function (k) {
var key = String(k);
if (seen[key]) return;
seen[key] = true;
out.push(k);
});
return out;
}
function normalizeCols(arr) {
if (!Array.isArray(arr)) return [];
return uniqPreserveOrder(arr.map(function (k) { return (k === 'object_name') ? 'job_name' : k; }));
}
function defaultColsFor(view) {
if (reportColumnsMeta && reportColumnsMeta.defaults && reportColumnsMeta.defaults[view]) {
return reportColumnsMeta.defaults[view].slice();
return normalizeCols(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'];
if (view === 'snapshot') return ['job_name','customer_name','job_id','status','run_at'];
return ['job_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;
var cols = null;
var hasView = false;
if (cfg.columns && typeof cfg.columns === 'object') {
hasView = Object.prototype.hasOwnProperty.call(cfg.columns, view);
cols = cfg.columns[view];
}
if (hasView && Array.isArray(cols)) {
// If an empty list is saved, keep it empty.
return normalizeCols(cols);
}
if (cols && cols.length) return normalizeCols(cols);
return defaultColsFor(view);
}
@ -271,12 +308,41 @@
}
}
function setRawDownloadLink() {
var btn = qs('rep_raw_download_btn');
if (!btn) return;
if (!rawReportId) {
btn.setAttribute('href', '#');
btn.classList.add('disabled');
return;
}
btn.classList.remove('disabled');
btn.setAttribute('href', '/api/reports/' + rawReportId + '/export?view=' + rawView);
}
function updateRawMeta(total) {
var t = parseInt(total || 0, 10) || 0;
var start = t ? (rawOffset + 1) : 0;
var end = t ? Math.min(rawOffset + rawLimit, t) : 0;
var label = (rawView === 'snapshot') ? 'Snapshot' : 'Summary';
qs('rep_raw_meta').textContent = label + ' · Rows ' + start + '-' + end + ' of ' + t;
qs('rep_raw_prev_btn').disabled = (rawOffset <= 0);
qs('rep_raw_next_btn').disabled = ((rawOffset + rawLimit) >= t);
}
function renderRawTable(view, items) {
var thead = qs('rep_raw_thead');
var tbody = qs('rep_raw_tbody');
var cols = selectedColsFor(view);
if (!cols || !cols.length) {
thead.innerHTML = '<tr><th class="text-muted">No columns selected</th></tr>';
tbody.innerHTML = '<tr><td class="text-muted py-4">No columns selected.</td></tr>';
setRawDownloadLink();
return;
}
function thRow(keys) {
return '<tr>' + keys.map(function (k) { return '<th>' + escapeHtml(colLabel(k)) + '</th>'; }).join('') + '</tr>';
}
@ -298,7 +364,8 @@
return (
'<tr>' +
cols.map(function (k) {
return td(r[k], (k === 'run_at' || k === 'reviewed_at' || k === 'job_id' || k === 'run_id' || k === 'customer_name'));
var val = (k === 'job_name') ? ((r.job_name !== null && r.job_name !== undefined && String(r.job_name).length) ? r.job_name : r.object_name) : r[k];
return td(val, (k === 'run_at' || k === 'reviewed_at' || k === 'job_id' || k === 'run_id' || k === 'customer_name'));
}).join('') +
'</tr>'
);
@ -368,9 +435,10 @@ function loadRawData() {
'<td class="text-muted small">' + period + '</td>' +
'<td><span class="badge text-bg-light border">' + fmt + '</span></td>' +
'<td class="text-end">' +
'<a class="btn btn-sm btn-outline-secondary me-1" href="/reports/' + item.id + '/edit">Edit</a>' +
'<button type="button" class="btn btn-sm btn-outline-primary me-1 rep-generate-btn" data-id="' + item.id + '">Generate</button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary me-1 rep-view-btn" data-id="' + item.id + '">View raw</button>' +
'<a class="btn btn-sm btn-outline-success rep-download-btn" href="/api/reports/' + item.id + '/export.csv" target="_blank" rel="noopener">Download</a>' +
'<a class="btn btn-sm btn-outline-success rep-download-btn" href="/api/reports/' + item.id + '/export?format=' + encodeURIComponent((item.output_format || 'csv').toLowerCase()) + '" target="_blank" rel="noopener">Download</a>' +
(canDeleteReports ? '<button type="button" class="btn btn-sm btn-outline-danger ms-1 rep-delete-btn" data-id="' + item.id + '">Delete</button>' : '') +
'</td>';;

View File

@ -3,8 +3,8 @@
<div class="d-flex flex-wrap align-items-baseline justify-content-between mb-3">
<div>
<h2 class="mb-1">New report</h2>
<div class="text-muted">Create a one-time report definition. Generate output from the Reports overview.</div>
<h2 class="mb-1">{{ 'Edit report' if is_edit else 'New report' }}</h2>
<div class="text-muted">{{ 'Update this report definition. Generate output from the Reports overview.' if is_edit else 'Create a one-time report definition. Generate output from the Reports overview.' }}</div>
</div>
<div class="mt-2 mt-md-0">
<a class="btn btn-outline-secondary" href="{{ url_for('main.reports') }}">Back</a>
@ -30,11 +30,22 @@
<div class="col-12 col-md-6">
<label class="form-label">Output format</label>
<select class="form-select" id="rep_output_format">
<option value="csv" selected>CSV</option>
<option value="pdf" disabled>PDF (coming soon)</option>
<option value="csv">CSV</option>
<option value="html">HTML</option>
<option value="pdf">PDF</option>
</select>
</div>
<div class="col-12 col-md-6">
<label class="form-label">HTML/PDF content</label>
<select class="form-select" id="rep_html_content">
<option value="customers">Customers</option>
<option value="jobs">Jobs</option>
<option value="both">Customers + Jobs</option>
</select>
<div class="form-text">Controls whether the HTML/PDF output shows a customer list, a job list, or both.</div>
</div>
<div class="col-12">
<label class="form-label">Description</label>
<input type="text" class="form-control" id="rep_description" placeholder="Optional description" />
@ -118,10 +129,32 @@
</div>
<div class="col-12">
<div class="alert alert-info mb-0">
Jobs selection is set to <span class="fw-semibold">all jobs for each selected customer</span> in this iteration.
<div class="fw-semibold mb-1">Jobs filter</div>
<div class="text-muted small mb-3">
Filter which jobs are included in the report by backup software and backup type. Informational jobs (e.g. License Key) are always excluded.
</div>
</div>
<div class="col-12 col-lg-6">
<label class="form-label fw-semibold" for="rep_job_backup_software">Backup software</label>
<select class="form-select" id="rep_job_backup_software" multiple size="6">
{% for item in job_filters_meta.backup_softwares %}
<option value="{{ item.key }}">{{ item.label }}</option>
{% endfor %}
</select>
<div class="form-text">Leave empty to include all backup software.</div>
</div>
<div class="col-12 col-lg-6">
<label class="form-label fw-semibold" for="rep_job_backup_type">Backup type</label>
<select class="form-select" id="rep_job_backup_type" multiple size="6">
{% for item in job_filters_meta.backup_types %}
<option value="{{ item.key }}">{{ item.label }}</option>
{% endfor %}
</select>
<div class="form-text">Leave empty to include all backup types (except informational types).</div>
</div>
</div>
@ -133,6 +166,7 @@
<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>
<button type="button" class="btn btn-outline-secondary" id="rep_cols_tab_jobs">Jobs</button>
</div>
<div class="text-muted small" id="rep_cols_hint">Select columns for the summary view.</div>
</div>
@ -157,7 +191,7 @@
<hr class="my-4" />
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-primary" id="rep_create_btn">Create report</button>
<button type="button" class="btn btn-primary" id="rep_create_btn">{{ 'Save changes' if is_edit else 'Create report' }}</button>
<a class="btn btn-outline-secondary" href="{{ url_for('main.reports') }}">Cancel</a>
</div>
</div>
@ -198,7 +232,10 @@
<script>
window.__reportColumnsMeta = {{ columns_meta|tojson }};
window.__jobFiltersMeta = {{ job_filters_meta|tojson }};
window.__initialCustomers = {{ initial_customers|tojson }};
window.__isEdit = {{ 'true' if is_edit else 'false' }};
window.__initialReport = {{ initial_report|tojson }};
window.addEventListener('DOMContentLoaded', function () {
function qs(id) { return document.getElementById(id); }
@ -215,30 +252,260 @@
el.textContent = '';
}
var isEdit = !!window.__isEdit;
var initialReport = window.__initialReport || null;
var editReportId = (initialReport && initialReport.id) ? initialReport.id : null;
// --- HTML/PDF content selector ---
var repHtmlContent = null;
var repHtmlContentTouched = false;
if (isEdit && initialReport && initialReport.report_config && initialReport.report_config.presentation) {
var p = initialReport.report_config.presentation;
if (p && typeof p === 'object') {
repHtmlContent = (p.html_content || '').trim().toLowerCase() || null;
}
}
function defaultHtmlContentForScope(scope) {
if ((scope || '').toLowerCase() === 'single') return 'jobs';
return 'customers';
}
// --- Report content / column selector ---
var repColsView = 'summary';
var repColsMeta = window.__reportColumnsMeta || null;
var repColsSelected = { summary: [], snapshot: [] };
// Use null to indicate "no value configured" so defaults can be applied.
// If the user explicitly saves an empty list, keep it empty.
var repColsSelected = { summary: null, snapshot: null, jobs: null };
if (isEdit && initialReport && initialReport.report_config && initialReport.report_config.columns) {
var cols = initialReport.report_config.columns;
repColsSelected = {
summary: (cols && Object.prototype.hasOwnProperty.call(cols, 'summary') && Array.isArray(cols.summary)) ? cols.summary.slice() : null,
snapshot: (cols && Object.prototype.hasOwnProperty.call(cols, 'snapshot') && Array.isArray(cols.snapshot)) ? cols.snapshot.slice() : null,
jobs: (cols && Object.prototype.hasOwnProperty.call(cols, 'jobs') && Array.isArray(cols.jobs)) ? cols.jobs.slice() : null,
};
}
function uniqPreserveOrder(arr) {
var out = [];
var seen = {};
(arr || []).forEach(function (k) {
var key = String(k);
if (seen[key]) return;
seen[key] = true;
out.push(k);
});
return out;
}
function normalizeJobNameColumns() {
// Merge legacy object_name into job_name (single logical column: "Job name").
if (repColsMeta && repColsMeta.groups) {
repColsMeta.groups.forEach(function (g) {
var items = g.items || [];
var jobItem = null;
var objItem = null;
items.forEach(function (it) {
if (!it) return;
if (it.key === 'job_name') jobItem = it;
if (it.key === 'object_name') objItem = it;
});
if (objItem && !jobItem) {
// convert object_name into job_name
objItem.key = 'job_name';
objItem.label = 'Job name';
jobItem = objItem;
objItem = null;
}
if (jobItem) {
jobItem.label = 'Job name';
// Merge views if object_name existed in the same group
if (objItem && Array.isArray(objItem.views)) {
var merged = (Array.isArray(jobItem.views) ? jobItem.views.slice() : []);
objItem.views.forEach(function (v) {
if (merged.indexOf(v) < 0) merged.push(v);
});
jobItem.views = merged;
}
}
// Remove any remaining object_name items
g.items = (g.items || []).filter(function (it) { return it && it.key !== 'object_name'; });
// Remove duplicate job_name items within the same group
var seenJob = false;
g.items = (g.items || []).filter(function (it) {
if (!it) return false;
if (it.key !== 'job_name') return true;
if (!seenJob) {
seenJob = true;
it.label = 'Job name';
return true;
}
return false;
});
});
// Ensure job_name only appears once across all groups.
var seenGlobalJob = false;
repColsMeta.groups.forEach(function (g2) {
g2.items = (g2.items || []).filter(function (it2) {
if (!it2) return false;
if (it2.key !== 'job_name') return true;
if (!seenGlobalJob) {
seenGlobalJob = true;
it2.label = 'Job name';
return true;
}
return false;
});
});
}
// Defaults: replace object_name -> job_name and de-duplicate
if (repColsMeta && repColsMeta.defaults) {
['summary', 'snapshot', 'jobs'].forEach(function (v) {
var d = repColsMeta.defaults[v];
if (!Array.isArray(d)) return;
repColsMeta.defaults[v] = uniqPreserveOrder(d.map(function (k) { return (k === 'object_name') ? 'job_name' : k; }));
});
}
// Selected: replace object_name -> job_name and de-duplicate
['summary', 'snapshot', 'jobs'].forEach(function (v) {
if (repColsSelected[v] === null || typeof repColsSelected[v] === 'undefined') return;
if (!Array.isArray(repColsSelected[v])) return;
repColsSelected[v] = uniqPreserveOrder(repColsSelected[v].map(function (k) { return (k === 'object_name') ? 'job_name' : k; }));
});
}
// --- Job filters (backup software / backup type) ---
var jobFiltersMeta = window.__jobFiltersMeta || null;
var repJobFiltersSelected = { backup_softwares: [], backup_types: [] };
if (isEdit && initialReport && initialReport.report_config && initialReport.report_config.filters) {
var f = initialReport.report_config.filters;
repJobFiltersSelected = {
backup_softwares: Array.isArray(f.backup_softwares) ? f.backup_softwares.slice() : [],
backup_types: Array.isArray(f.backup_types) ? f.backup_types.slice() : [],
};
}
function getSelectedValues(selectEl) {
var out = [];
if (!selectEl) return out;
for (var i = 0; i < selectEl.options.length; i++) {
var opt = selectEl.options[i];
if (opt && opt.selected) out.push(opt.value);
}
return out;
}
function setSelectedValues(selectEl, values) {
if (!selectEl) return;
var set = {};
(values || []).forEach(function (v) { set[String(v)] = true; });
for (var i = 0; i < selectEl.options.length; i++) {
var opt = selectEl.options[i];
if (!opt) continue;
opt.selected = !!set[String(opt.value)];
}
}
function rebuildBackupTypeOptions(allowedTypes, preserveSelected) {
var sel = qs('rep_job_backup_type');
if (!sel || !jobFiltersMeta || !Array.isArray(jobFiltersMeta.backup_types)) return;
var currentSelected = preserveSelected ? getSelectedValues(sel) : [];
var allowedSet = null;
if (Array.isArray(allowedTypes) && allowedTypes.length) {
allowedSet = {};
allowedTypes.forEach(function (v) { allowedSet[String(v)] = true; });
}
// rebuild options
sel.innerHTML = '';
jobFiltersMeta.backup_types.forEach(function (item) {
if (!item || !item.key) return;
if (String(item.key).toLowerCase() === 'license key') return; // always excluded
if (allowedSet && !allowedSet[String(item.key)]) return;
var opt = document.createElement('option');
opt.value = item.key;
opt.textContent = item.label || item.key;
sel.appendChild(opt);
});
// restore selection (only if still exists)
var finalSelected = [];
currentSelected.forEach(function (v) {
for (var i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === v) finalSelected.push(v);
}
});
setSelectedValues(sel, finalSelected);
}
function onBackupSoftwareChange() {
var swSel = qs('rep_job_backup_software');
var selected = getSelectedValues(swSel);
if (!jobFiltersMeta || !jobFiltersMeta.by_backup_software) {
rebuildBackupTypeOptions(null, true);
return;
}
// union types for selected software(s)
if (!selected.length) {
rebuildBackupTypeOptions(null, true);
return;
}
var union = {};
selected.forEach(function (sw) {
var arr = jobFiltersMeta.by_backup_software[sw];
if (Array.isArray(arr)) {
arr.forEach(function (t) { union[String(t)] = true; });
}
});
var allowed = Object.keys(union);
rebuildBackupTypeOptions(allowed, true);
}
// Initialize job filter selects
(function initJobFilters() {
var swSel = qs('rep_job_backup_software');
if (swSel) {
swSel.addEventListener('change', onBackupSoftwareChange);
setSelectedValues(swSel, repJobFiltersSelected.backup_softwares);
}
onBackupSoftwareChange();
var btSel = qs('rep_job_backup_type');
if (btSel) setSelectedValues(btSel, repJobFiltersSelected.backup_types);
})();
function colsHintText(viewKey) {
return viewKey === 'snapshot'
? 'Select columns for the snapshot view.'
: 'Select columns for the summary view.';
if (viewKey === 'snapshot') return 'Select columns for the snapshot view.';
if (viewKey === 'jobs') return 'Select columns for the jobs view (HTML/PDF).';
return 'Select columns for the summary view.';
}
function setColsView(viewKey) {
repColsView = (viewKey === 'snapshot') ? 'snapshot' : 'summary';
repColsView = (viewKey === 'snapshot' || viewKey === 'jobs') ? viewKey : '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');
}
var c = qs('rep_cols_tab_jobs');
a.classList.toggle('active', repColsView === 'summary');
b.classList.toggle('active', repColsView === 'snapshot');
c.classList.toggle('active', repColsView === 'jobs');
qs('rep_cols_hint').textContent = colsHintText(repColsView);
renderColsAvailable();
@ -265,8 +532,8 @@
function ensureDefaultsFromMeta() {
if (!repColsMeta || !repColsMeta.defaults) return;
['summary', 'snapshot'].forEach(function (v) {
if (!repColsSelected[v] || !repColsSelected[v].length) {
['summary', 'snapshot', 'jobs'].forEach(function (v) {
if (repColsSelected[v] === null || typeof repColsSelected[v] === 'undefined') {
repColsSelected[v] = (repColsMeta.defaults[v] || []).slice();
}
});
@ -439,9 +706,11 @@
if (repColsMeta) {
showColsLoading(false);
clearColsError();
normalizeJobNameColumns();
ensureDefaultsFromMeta();
qs('rep_cols_tab_summary').addEventListener('click', function () { setColsView('summary'); });
qs('rep_cols_tab_snapshot').addEventListener('click', function () { setColsView('snapshot'); });
qs('rep_cols_tab_jobs').addEventListener('click', function () { setColsView('jobs'); });
setColsView('summary');
return;
}
@ -460,11 +729,13 @@
}
repColsMeta = res.json || null;
normalizeJobNameColumns();
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'); });
qs('rep_cols_tab_jobs').addEventListener('click', function () { setColsView('jobs'); });
setColsView('summary');
})
@ -482,6 +753,15 @@
qs(prefix + '_time').value = pad2(d.getUTCHours()) + ':' + pad2(d.getUTCMinutes());
}
function setDateTimeFromIso(prefix, iso) {
var s = (iso || '').trim();
if (!s) return;
var m = s.match(/^(\d{4})-(\d{2})-(\d{2})(?:T|\s)(\d{2}):(\d{2})/);
if (!m) return;
qs(prefix + '_date').value = m[1] + '-' + m[2] + '-' + m[3];
qs(prefix + '_time').value = m[4] + ':' + m[5];
}
function buildIso(dateStr, timeStr, fallbackTime) {
var d = (dateStr || '').trim();
var t = (timeStr || '').trim() || (fallbackTime || '00:00');
@ -527,12 +807,39 @@
var scope = selectedScope();
qs('rep_single_wrap').classList.toggle('d-none', scope !== 'single');
qs('rep_multiple_wrap').classList.toggle('d-none', scope !== 'multiple');
// Set a sensible default for HTML content selection when the user hasn't chosen one yet.
if (!isEdit && !repHtmlContentTouched) {
qs('rep_html_content').value = defaultHtmlContentForScope(scope);
}
}
function applyCustomerSelection() {
if (!isEdit || !initialReport) return;
var scope = (initialReport.customer_scope || 'all');
var ids = initialReport.customer_ids || [];
if (scope === 'single') {
var singleSel = qs('rep_customer_single');
if (singleSel && ids.length === 1) {
singleSel.value = String(ids[0]);
}
} else if (scope === 'multiple') {
var multiSel = qs('rep_customer_multiple');
if (multiSel && Array.isArray(multiSel.options)) {
for (var i = 0; i < multiSel.options.length; i++) {
var opt = multiSel.options[i];
opt.selected = (ids.indexOf(parseInt(opt.value, 10)) >= 0);
}
}
}
}
function loadCustomers() {
var initialCustomers = window.__initialCustomers || null;
if (initialCustomers && Array.isArray(initialCustomers) && initialCustomers.length) {
// Already rendered server-side. Keep the selects usable even if API calls fail.
applyCustomerSelection();
return;
}
qs('rep_customer_single').innerHTML = '<option value="" selected>Loading…</option>';
@ -559,12 +866,42 @@
opt2.textContent = c.name || ('Customer ' + c.id);
qs('rep_customer_multiple').appendChild(opt2);
});
applyCustomerSelection();
})
.catch(function () {
qs('rep_customer_single').innerHTML = '<option value="" selected>Failed to load customers</option>';
});
}
function applyInitialReport() {
if (!isEdit || !initialReport) return;
qs('rep_name').value = initialReport.name || '';
qs('rep_description').value = initialReport.description || '';
qs('rep_output_format').value = (initialReport.output_format || 'csv');
// HTML/PDF content selection (defaults handled below)
if (repHtmlContent) {
qs('rep_html_content').value = repHtmlContent;
}
setDateTimeFromIso('rep_start', initialReport.period_start || '');
setDateTimeFromIso('rep_end', initialReport.period_end || '');
// scope + customer selections
var scope = (initialReport.customer_scope || 'all');
qs('rep_scope_single').checked = (scope === 'single');
qs('rep_scope_multiple').checked = (scope === 'multiple');
qs('rep_scope_all').checked = (scope === 'all');
updateScopeUi();
applyCustomerSelection();
if (!repHtmlContent) {
qs('rep_html_content').value = defaultHtmlContentForScope(scope);
}
}
function validate(payload) {
if (!payload.name) return 'Report name is required.';
if (!payload.period_start || !payload.period_end) return 'Start and end period are required.';
@ -582,6 +919,11 @@
function createReport() {
clearError();
if (isEdit && !editReportId) {
showError('Missing report id.');
return;
}
var scope = selectedScope();
var customerIds = [];
if (scope === 'single') {
@ -605,7 +947,19 @@
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'),
report_config: { columns: repColsSelected, columns_version: 1 }
report_config: {
columns: repColsSelected,
columns_version: 1,
filters: {
backup_softwares: getSelectedValues(qs('rep_job_backup_software')),
backup_types: getSelectedValues(qs('rep_job_backup_type')),
filters_version: 1
},
presentation: {
html_content: (qs('rep_html_content').value || '').trim().toLowerCase(),
presentation_version: 1
}
}
};
if (!payload.description) delete payload.description;
@ -619,10 +973,13 @@
var btn = qs('rep_create_btn');
var oldText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Creating…';
btn.textContent = (isEdit ? 'Saving…' : 'Creating…');
fetch('/api/reports', {
method: 'POST',
var url = isEdit ? ('/api/reports/' + editReportId) : '/api/reports';
var method = isEdit ? 'PUT' : 'POST';
fetch(url, {
method: method,
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
@ -632,7 +989,7 @@
btn.disabled = false;
btn.textContent = oldText;
if (!res.ok) {
showError((res.json && res.json.error) ? res.json.error : 'Create failed.');
showError((res.json && res.json.error) ? res.json.error : (isEdit ? 'Save failed.' : 'Create failed.'));
return;
}
window.location.href = '{{ url_for('main.reports') }}';
@ -640,21 +997,27 @@
.catch(function () {
btn.disabled = false;
btn.textContent = oldText;
showError('Create failed.');
showError(isEdit ? 'Save failed.' : 'Create failed.');
});
}
// Defaults
// Defaults / initial values
if (isEdit) {
applyInitialReport();
} else {
var now = todayUtc();
var end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours(), now.getUTCMinutes(), 0));
var start = new Date(end.getTime() - (7 * 24 * 60 * 60 * 1000));
setDateTime('rep_start', start);
setDateTime('rep_end', end);
}
document.querySelectorAll('input[name="rep_scope"]').forEach(function (r) {
r.addEventListener('change', updateScopeUi);
});
qs('rep_html_content').addEventListener('change', function () { repHtmlContentTouched = true; });
qs('rep_preset_cur_month').addEventListener('click', presetCurrentMonth);
qs('rep_preset_last_month').addEventListener('click', presetLastMonth);
qs('rep_preset_last_month_full').addEventListener('click', presetLastMonthFull);

View File

@ -107,6 +107,205 @@
- Fixed /api/reports/columns to always return the column metadata payload (removed incorrect indentation that prevented a response for authorized users).
- Ensured the “New report” page always initializes the column selector by providing the initial customers payload to the frontend (prevents JS initialization issues that could stop the column lists from rendering).
---
## v20260103-13-reports-edit-and-view-raw-fix
- Added the ability to edit existing reports via a dedicated edit view.
- Reused the report configuration form for both creating and editing reports.
- Fixed the "View raw" functionality so raw report data can be viewed again.
- Resolved an HTML rendering issue causing an extra ">" character above the Name column.
- Improved the reports list to correctly display the report type instead of incorrect metadata.
---
## v20260103-14-reports-percentage-2-decimals
- Limited percentage values in reports to a maximum of two decimal places.
- Applied consistent rounding for percentage fields across:
- Report summary views
- KPI and trend data outputs
- CSV export generation
- Improved readability and consistency of reported percentage values.
---
## v20260103-15-reports-customer-column
- Added **Customer** as a selectable column in report configuration.
- Ensured customer name is included and visible when multiple customers are selected.
- Updated summary and raw data views to correctly display customer information across multi-customer reports.
---
## v20260103-16-reports-snapshot-columns-runid-remark-toggle
- Added `run_id` and `remark` to the Snapshot report column metadata.
- Enabled toggling visibility of `run_id` and `remark` via the column selector.
- Removed forced default visibility so these columns can be fully disabled by the user.
---
## v20260103-17-reports-job-filters-exclude-info-jobs
- Excluded informational jobs (such as License Key and other non-backup jobs) from all report outputs.
- Added configurable filters to reports to select which backup jobs are included.
- Added configurable filters to reports to select which backup types are included.
- Ensured selected job and type filters are consistently applied to snapshot and summary reports.
- Improved report relevance by limiting output to meaningful backup-related data only.
---
## v20260103-18-reports-job-filter-selectbox-size-6-rows
- Updated the report selection UI to make the “Backup software” and “Backup type” select boxes equal in size.
- Both select boxes are now displayed with a fixed height of 6 visible rows for consistent layout and improved usability.
---
## v20260103-19-reports-output-format-html
- Added "HTML" as an output format option next to CSV and PDF in the report definition form.
- Enabled generic report export to support CSV, HTML and PDF outputs.
- Implemented an HTML export preview with a graphical layout (summary cards + charts) to validate report styling.
- Implemented a basic PDF export with summary and a simple success-rate trend chart.
- Updated the Reports overview to use the new generic export endpoint (no longer hardcoded to export.csv).
- Added ReportLab dependency for PDF generation.
---
## v20260103-20-reports-export-html-open-new-tab
- Fixed report export logic so the selected output format is always respected.
- Ensured the export URL explicitly includes the chosen format parameter (CSV, PDF, HTML).
- Changed HTML report handling to open in a new browser tab instead of triggering a file download.
- Enabled proper inline rendering of HTML reports to support graphical preview and layout validation.
---
## v20260104-01-reports-output-format-save-fix
- Fixed an issue where the selected report output format (HTML/PDF) was not persisted when saving or editing a report.
- The report update endpoint now correctly stores the chosen output_format instead of always reverting to CSV.
- Added validation and normalization for output_format values during report creation and update.
- Ensured that selecting HTML as output no longer results in CSV being generated or downloaded.
---
## v20260104-02-reports-html-content-and-success-rate-basis
- Added a configurable report content option for HTML/PDF output: Customers, Jobs, or Customers + Jobs.
- Improved single-customer HTML reports to display job-level details instead of only a customer summary.
- Updated success rate calculation to be based solely on the selected status columns.
- Excluded non-selected statuses (such as Missed runs) from success rate calculations to avoid distorted results.
- Aligned success rate logic consistently across HTML output, PDF summaries, and report statistics.
---
## v20260104-03-reports-html-view-selection-fix
- Fixed HTML report generation to correctly respect the selected report view.
- When "Summary" is selected, the HTML output now renders the summary content instead of the snapshot with individual runs.
- Prevented unintended fallback to Snapshot view when generating HTML reports.
- Improved default behavior for single-customer summary reports to ensure a meaningful summary is displayed.
---
## v20260104-04-reports-html-jobs-table-fix
- Fixed HTML report rendering where only charts were shown and no data rows appeared.
- Restored the jobs table below the charts in HTML reports for single-customer selections.
- Ensured the latest snapshot per job is displayed correctly in the HTML output.
---
## v20260104-05-reports-html-jobs-columns-selection
- Added configurable column selection for the Jobs table in report create/edit views.
- Exposed all Jobs table columns as selectable options instead of using fixed/hardcoded columns.
- Ensured the HTML report Jobs table only renders columns explicitly selected in the report configuration.
- Aligned Jobs table rendering logic with Snapshot and Summary column selection behavior.
---
## v20260104-06-reports-html-jobs-summary-success-rate
- Restored the **Text** column label in the report column selector (previously shown as Object).
- Added job-level metrics to the Jobs view, including total runs, success, warning, failed, missed counts, and success rate.
- Made **success rate per job** selectable as a column in job-based reports.
- Changed the HTML Jobs section from showing the **latest snapshot per job** to an **aggregated summary per job** over the selected reporting period.
- Removed the “Latest snapshot per job” subtitle from HTML reports.
- Added a **per-job success rate chart** to the HTML report to visualize backup performance per job.
- Prevented job status from being presented as a summary value, as it represents a momentary state rather than a stable metric.
---
## v20260104-07-reports-html-export-fix-json
### Fixed
- Resolved Internal Server Error when downloading HTML reports.
- Fixed an `UnboundLocalError` caused by an uninitialized `_json` variable in the HTML export response.
- Replaced usage of `_json` with a consistent `json.dumps(...)` call using the top-level JSON import.
- Ensured stable HTML report export handling across all report download actions.
---
## v20260104-08-reports-html-total-runs-and-jobs-table-fix
- Fixed **Total runs** calculation to only include the selected run statuses. Non-selected statuses (such as missed runs) are no longer counted.
- Aligned **daily trend totals** with the selected status columns to ensure consistency with success rate calculations.
- Fixed **Jobs table column headers** so the correct labels are displayed instead of placeholder keys (e.g. `{jobs_th}`).
- Improved **Jobs table sorting** to follow a logical hierarchy: Customer > Backup software > Backup type > Job name > Object, depending on the selected columns.
---
## v20260104-09-reports-html-total-runs-selection-and-jobs-text-column-remove
- Fixed “Total runs” to only sum the selected status counters (e.g. excluding missed when its not selected), instead of using the raw total that included missed runs.
- Removed the duplicate “Text” column from the Jobs report view by excluding `object_name` from the Jobs column set and Jobs defaults.
---
## v20260104-10-reports-html-export-fix-stats-view-and-total-runs
### Changed
- Fixed an internal server error when downloading HTML reports by correctly passing the selected view to the stats builder and stats API endpoint.
- Updated the Total runs calculation in the HTML summary to only count run statuses that are selected in the active view.
- Prevented non-selected statuses (such as missed) from being included in the Total runs calculation.
---
## v20260104-11-reports-jobs-remove-text-column-option
- Removed the “Text” column from the Jobs column selection in Reports.
- Cleaned up column metadata so the “Text” option no longer appears in the Reports UI.
- Ensured consistency between selectable columns and rendered report output.
---
## v20260104-12-reports-object-name-job-name-columns-fix
- Added the missing `object_name` column to the available report columns and labeled it as "Job name" for Summary/Snapshot/Jobs.
- Fixed column selection behavior so an explicitly saved empty selection stays empty (defaults are only applied when the view has no configured selection).
- Updated the raw data table and HTML export to show a clear “No columns selected” message when all columns are disabled, instead of still rendering default data.
---
## v20260104-13-reports-jobs-deduplicate-job-name-column
- Unified `object_name` and `job_name` into a single logical column labeled "Job name".
- Removed duplicate "Job name" entries from available and selected column lists in Summary, Snapshot, and Jobs views.
- Added migration logic to automatically clean up existing report configurations containing duplicate job name columns.
- Ensured report rendering strictly follows the selected columns; when no columns are selected, no table data is shown.
---
## v20260104-14-reports-stats-total-runs-success-rate-fix
- Fixed incorrect Total runs calculation by only counting selected run statuses.
- Corrected Success rate calculation to be based on Success / (Success + Failed).
- Prevented non-selected statuses (such as Missed) from affecting totals and percentages.
- Ensured consistency between displayed counts and calculated percentages in reports.
================================================================================================================================================