Merge pull request 'v20260104-14-reports-stats-total-runs-success-rate-fix' (#28) from v20260104-14-reports-stats-total-runs-success-rate-fix into main

Reviewed-on: #28
This commit is contained in:
Ivo Oskamp 2026-01-13 10:59:39 +01:00
commit 8e511a111d
8 changed files with 1786 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 python-dateutil==2.9.0.post0
gunicorn==23.0.0 gunicorn==23.0.0
requests==2.32.3 requests==2.32.3
reportlab==4.2.5

View File

@ -1,6 +1,6 @@
from .routes_shared import * # noqa: F401,F403 from .routes_shared import * # noqa: F401,F403
from datetime import date, timedelta 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(): def get_default_report_period():
"""Return default report period (last 7 days).""" """Return default report period (last 7 days)."""
@ -21,6 +21,17 @@ def _safe_json_list(value):
return [] 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): def _build_report_item(r):
return { return {
"id": int(r.id), "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_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 "", "period_end": r.period_end.isoformat() if getattr(r, "period_end", None) else "",
"schedule": r.schedule or "", "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 "", "created_at": r.created_at.isoformat() if getattr(r, "created_at", None) else "",
} }
@ -55,6 +67,7 @@ def reports():
"main/reports.html", "main/reports.html",
initial_reports=items, initial_reports=items,
columns_meta=build_report_columns_meta(), columns_meta=build_report_columns_meta(),
job_filters_meta=build_report_job_filters_meta(),
default_period_start=period_start.isoformat(), default_period_start=period_start.isoformat(),
default_period_end=period_end.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] 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, id SERIAL PRIMARY KEY,
report_id INTEGER NOT NULL REFERENCES report_definitions(id) ON DELETE CASCADE, report_id INTEGER NOT NULL REFERENCES report_definitions(id) ON DELETE CASCADE,
object_name TEXT NOT NULL, object_name TEXT NOT NULL,
customer_id INTEGER NULL,
customer_name TEXT NULL,
total_runs INTEGER NOT NULL DEFAULT 0, total_runs INTEGER NOT NULL DEFAULT 0,
success_count INTEGER NOT NULL DEFAULT 0, success_count INTEGER NOT NULL DEFAULT 0,
success_override_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_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_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_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.") print("[migrations] reporting tables created/verified.")

View File

@ -536,7 +536,7 @@ class ReportDefinition(db.Model):
# one-time | scheduled # one-time | scheduled
report_type = db.Column(db.String(32), nullable=False, default="one-time") 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") output_format = db.Column(db.String(16), nullable=False, default="csv")
# customer scope for report generation # 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) report_id = db.Column(db.Integer, db.ForeignKey("report_definitions.id"), nullable=False)
object_name = db.Column(db.Text, 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) total_runs = db.Column(db.Integer, nullable=False, default=0)
success_count = 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) success_override_count = db.Column(db.Integer, nullable=False, default=0)

View File

@ -40,7 +40,7 @@
{% for item in initial_reports %} {% for item in initial_reports %}
<tr> <tr>
<td><strong>{{ item.name }}</strong><div class="text-muted small">{{ item.description }}</div></td> <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"> <td class="text-muted small">
{% if item.period_start or item.period_end %} {% 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 '' }} {{ 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>
<td><span class="badge text-bg-light border">{{ item.output_format }}</span></td> <td><span class="badge text-bg-light border">{{ item.output_format }}</span></td>
<td class="text-end"> <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-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> <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') %} {% 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> <button type="button" class="btn btn-sm btn-outline-danger rep-delete-btn ms-1" data-id="{{ item.id }}">Delete</button>
{% endif %} {% endif %}
@ -64,7 +65,7 @@
<td colspan="5" class="text-center text-muted py-4">No reports found.</td> <td colspan="5" class="text-center text-muted py-4">No reports found.</td>
</tr> </tr>
{% endif %} {% endif %}
</tbody>> </tbody>
</table> </table>
</div> </div>
</div> </div>
@ -195,22 +196,58 @@
if (items[j].key === key) return items[j].label || key; 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; 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) { function defaultColsFor(view) {
if (reportColumnsMeta && reportColumnsMeta.defaults && reportColumnsMeta.defaults[view]) { if (reportColumnsMeta && reportColumnsMeta.defaults && reportColumnsMeta.defaults[view]) {
return reportColumnsMeta.defaults[view].slice(); return normalizeCols(reportColumnsMeta.defaults[view].slice());
} }
// hard fallback // hard fallback
if (view === 'snapshot') return ['object_name','customer_name','job_id','job_name','status','run_at']; if (view === 'snapshot') return ['job_name','customer_name','job_id','status','run_at'];
return ['object_name','total_runs','success_count','warning_count','failed_count','missed_count','success_rate']; return ['job_name','total_runs','success_count','warning_count','failed_count','missed_count','success_rate'];
} }
function selectedColsFor(view) { function selectedColsFor(view) {
var cfg = rawReportConfig || {}; var cfg = rawReportConfig || {};
var cols = (cfg.columns && cfg.columns[view]) ? cfg.columns[view] : null; var cols = null;
if (cols && cols.length) return cols; 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); 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) { function renderRawTable(view, items) {
var thead = qs('rep_raw_thead'); var thead = qs('rep_raw_thead');
var tbody = qs('rep_raw_tbody'); var tbody = qs('rep_raw_tbody');
var cols = selectedColsFor(view); 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) { function thRow(keys) {
return '<tr>' + keys.map(function (k) { return '<th>' + escapeHtml(colLabel(k)) + '</th>'; }).join('') + '</tr>'; return '<tr>' + keys.map(function (k) { return '<th>' + escapeHtml(colLabel(k)) + '</th>'; }).join('') + '</tr>';
} }
@ -298,7 +364,8 @@
return ( return (
'<tr>' + '<tr>' +
cols.map(function (k) { 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('') + }).join('') +
'</tr>' '</tr>'
); );
@ -368,9 +435,10 @@ function loadRawData() {
'<td class="text-muted small">' + period + '</td>' + '<td class="text-muted small">' + period + '</td>' +
'<td><span class="badge text-bg-light border">' + fmt + '</span></td>' + '<td><span class="badge text-bg-light border">' + fmt + '</span></td>' +
'<td class="text-end">' + '<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-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>' + '<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>' : '') + (canDeleteReports ? '<button type="button" class="btn btn-sm btn-outline-danger ms-1 rep-delete-btn" data-id="' + item.id + '">Delete</button>' : '') +
'</td>';; '</td>';;

View File

@ -3,8 +3,8 @@
<div class="d-flex flex-wrap align-items-baseline justify-content-between mb-3"> <div class="d-flex flex-wrap align-items-baseline justify-content-between mb-3">
<div> <div>
<h2 class="mb-1">New report</h2> <h2 class="mb-1">{{ 'Edit report' if is_edit else 'New report' }}</h2>
<div class="text-muted">Create a one-time report definition. Generate output from the Reports overview.</div> <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>
<div class="mt-2 mt-md-0"> <div class="mt-2 mt-md-0">
<a class="btn btn-outline-secondary" href="{{ url_for('main.reports') }}">Back</a> <a class="btn btn-outline-secondary" href="{{ url_for('main.reports') }}">Back</a>
@ -30,11 +30,22 @@
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label class="form-label">Output format</label> <label class="form-label">Output format</label>
<select class="form-select" id="rep_output_format"> <select class="form-select" id="rep_output_format">
<option value="csv" selected>CSV</option> <option value="csv">CSV</option>
<option value="pdf" disabled>PDF (coming soon)</option> <option value="html">HTML</option>
<option value="pdf">PDF</option>
</select> </select>
</div> </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"> <div class="col-12">
<label class="form-label">Description</label> <label class="form-label">Description</label>
<input type="text" class="form-control" id="rep_description" placeholder="Optional description" /> <input type="text" class="form-control" id="rep_description" placeholder="Optional description" />
@ -118,10 +129,32 @@
</div> </div>
<div class="col-12"> <div class="col-12">
<div class="alert alert-info mb-0"> <div class="fw-semibold mb-1">Jobs filter</div>
Jobs selection is set to <span class="fw-semibold">all jobs for each selected customer</span> in this iteration. <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> </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> </div>
@ -133,6 +166,7 @@
<div class="btn-group" role="group" aria-label="Report content view selector"> <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 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_snapshot">Snapshot</button>
<button type="button" class="btn btn-outline-secondary" id="rep_cols_tab_jobs">Jobs</button>
</div> </div>
<div class="text-muted small" id="rep_cols_hint">Select columns for the summary view.</div> <div class="text-muted small" id="rep_cols_hint">Select columns for the summary view.</div>
</div> </div>
@ -157,7 +191,7 @@
<hr class="my-4" /> <hr class="my-4" />
<div class="d-flex flex-wrap gap-2"> <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> <a class="btn btn-outline-secondary" href="{{ url_for('main.reports') }}">Cancel</a>
</div> </div>
</div> </div>
@ -198,7 +232,10 @@
<script> <script>
window.__reportColumnsMeta = {{ columns_meta|tojson }}; window.__reportColumnsMeta = {{ columns_meta|tojson }};
window.__jobFiltersMeta = {{ job_filters_meta|tojson }};
window.__initialCustomers = {{ initial_customers|tojson }}; window.__initialCustomers = {{ initial_customers|tojson }};
window.__isEdit = {{ 'true' if is_edit else 'false' }};
window.__initialReport = {{ initial_report|tojson }};
window.addEventListener('DOMContentLoaded', function () { window.addEventListener('DOMContentLoaded', function () {
function qs(id) { return document.getElementById(id); } function qs(id) { return document.getElementById(id); }
@ -215,30 +252,260 @@
el.textContent = ''; 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 --- // --- Report content / column selector ---
var repColsView = 'summary'; var repColsView = 'summary';
var repColsMeta = window.__reportColumnsMeta || null; 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) { function colsHintText(viewKey) {
return viewKey === 'snapshot' if (viewKey === 'snapshot') return 'Select columns for the snapshot view.';
? 'Select columns for the snapshot view.' if (viewKey === 'jobs') return 'Select columns for the jobs view (HTML/PDF).';
: 'Select columns for the summary view.'; return 'Select columns for the summary view.';
} }
function setColsView(viewKey) { function setColsView(viewKey) {
repColsView = (viewKey === 'snapshot') ? 'snapshot' : 'summary'; repColsView = (viewKey === 'snapshot' || viewKey === 'jobs') ? viewKey : 'summary';
var a = qs('rep_cols_tab_summary'); var a = qs('rep_cols_tab_summary');
var b = qs('rep_cols_tab_snapshot'); var b = qs('rep_cols_tab_snapshot');
if (repColsView === 'summary') { var c = qs('rep_cols_tab_jobs');
a.classList.add('active'); a.classList.toggle('active', repColsView === 'summary');
b.classList.remove('active'); b.classList.toggle('active', repColsView === 'snapshot');
} else { c.classList.toggle('active', repColsView === 'jobs');
b.classList.add('active');
a.classList.remove('active');
}
qs('rep_cols_hint').textContent = colsHintText(repColsView); qs('rep_cols_hint').textContent = colsHintText(repColsView);
renderColsAvailable(); renderColsAvailable();
@ -265,8 +532,8 @@
function ensureDefaultsFromMeta() { function ensureDefaultsFromMeta() {
if (!repColsMeta || !repColsMeta.defaults) return; if (!repColsMeta || !repColsMeta.defaults) return;
['summary', 'snapshot'].forEach(function (v) { ['summary', 'snapshot', 'jobs'].forEach(function (v) {
if (!repColsSelected[v] || !repColsSelected[v].length) { if (repColsSelected[v] === null || typeof repColsSelected[v] === 'undefined') {
repColsSelected[v] = (repColsMeta.defaults[v] || []).slice(); repColsSelected[v] = (repColsMeta.defaults[v] || []).slice();
} }
}); });
@ -439,9 +706,11 @@
if (repColsMeta) { if (repColsMeta) {
showColsLoading(false); showColsLoading(false);
clearColsError(); clearColsError();
normalizeJobNameColumns();
ensureDefaultsFromMeta(); ensureDefaultsFromMeta();
qs('rep_cols_tab_summary').addEventListener('click', function () { setColsView('summary'); }); qs('rep_cols_tab_summary').addEventListener('click', function () { setColsView('summary'); });
qs('rep_cols_tab_snapshot').addEventListener('click', function () { setColsView('snapshot'); }); qs('rep_cols_tab_snapshot').addEventListener('click', function () { setColsView('snapshot'); });
qs('rep_cols_tab_jobs').addEventListener('click', function () { setColsView('jobs'); });
setColsView('summary'); setColsView('summary');
return; return;
} }
@ -460,11 +729,13 @@
} }
repColsMeta = res.json || null; repColsMeta = res.json || null;
normalizeJobNameColumns();
ensureDefaultsFromMeta(); ensureDefaultsFromMeta();
// bind tabs once metadata is ready // bind tabs once metadata is ready
qs('rep_cols_tab_summary').addEventListener('click', function () { setColsView('summary'); }); qs('rep_cols_tab_summary').addEventListener('click', function () { setColsView('summary'); });
qs('rep_cols_tab_snapshot').addEventListener('click', function () { setColsView('snapshot'); }); qs('rep_cols_tab_snapshot').addEventListener('click', function () { setColsView('snapshot'); });
qs('rep_cols_tab_jobs').addEventListener('click', function () { setColsView('jobs'); });
setColsView('summary'); setColsView('summary');
}) })
@ -482,6 +753,15 @@
qs(prefix + '_time').value = pad2(d.getUTCHours()) + ':' + pad2(d.getUTCMinutes()); 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) { function buildIso(dateStr, timeStr, fallbackTime) {
var d = (dateStr || '').trim(); var d = (dateStr || '').trim();
var t = (timeStr || '').trim() || (fallbackTime || '00:00'); var t = (timeStr || '').trim() || (fallbackTime || '00:00');
@ -527,12 +807,39 @@
var scope = selectedScope(); var scope = selectedScope();
qs('rep_single_wrap').classList.toggle('d-none', scope !== 'single'); qs('rep_single_wrap').classList.toggle('d-none', scope !== 'single');
qs('rep_multiple_wrap').classList.toggle('d-none', scope !== 'multiple'); 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() { function loadCustomers() {
var initialCustomers = window.__initialCustomers || null; var initialCustomers = window.__initialCustomers || null;
if (initialCustomers && Array.isArray(initialCustomers) && initialCustomers.length) { if (initialCustomers && Array.isArray(initialCustomers) && initialCustomers.length) {
// Already rendered server-side. Keep the selects usable even if API calls fail. // Already rendered server-side. Keep the selects usable even if API calls fail.
applyCustomerSelection();
return; return;
} }
qs('rep_customer_single').innerHTML = '<option value="" selected>Loading…</option>'; qs('rep_customer_single').innerHTML = '<option value="" selected>Loading…</option>';
@ -559,12 +866,42 @@
opt2.textContent = c.name || ('Customer ' + c.id); opt2.textContent = c.name || ('Customer ' + c.id);
qs('rep_customer_multiple').appendChild(opt2); qs('rep_customer_multiple').appendChild(opt2);
}); });
applyCustomerSelection();
}) })
.catch(function () { .catch(function () {
qs('rep_customer_single').innerHTML = '<option value="" selected>Failed to load customers</option>'; 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) { function validate(payload) {
if (!payload.name) return 'Report name is required.'; if (!payload.name) return 'Report name is required.';
if (!payload.period_start || !payload.period_end) return 'Start and end period are required.'; if (!payload.period_start || !payload.period_end) return 'Start and end period are required.';
@ -582,6 +919,11 @@
function createReport() { function createReport() {
clearError(); clearError();
if (isEdit && !editReportId) {
showError('Missing report id.');
return;
}
var scope = selectedScope(); var scope = selectedScope();
var customerIds = []; var customerIds = [];
if (scope === 'single') { if (scope === 'single') {
@ -605,7 +947,19 @@
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 } 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; if (!payload.description) delete payload.description;
@ -619,10 +973,13 @@
var btn = qs('rep_create_btn'); var btn = qs('rep_create_btn');
var oldText = btn.textContent; var oldText = btn.textContent;
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Creating…'; btn.textContent = (isEdit ? 'Saving…' : 'Creating…');
fetch('/api/reports', { var url = isEdit ? ('/api/reports/' + editReportId) : '/api/reports';
method: 'POST', var method = isEdit ? 'PUT' : 'POST';
fetch(url, {
method: method,
credentials: 'same-origin', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)
@ -632,7 +989,7 @@
btn.disabled = false; btn.disabled = false;
btn.textContent = oldText; btn.textContent = oldText;
if (!res.ok) { 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; return;
} }
window.location.href = '{{ url_for('main.reports') }}'; window.location.href = '{{ url_for('main.reports') }}';
@ -640,21 +997,27 @@
.catch(function () { .catch(function () {
btn.disabled = false; btn.disabled = false;
btn.textContent = oldText; btn.textContent = oldText;
showError('Create failed.'); showError(isEdit ? 'Save failed.' : 'Create failed.');
}); });
} }
// Defaults // Defaults / initial values
var now = todayUtc(); if (isEdit) {
var end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours(), now.getUTCMinutes(), 0)); applyInitialReport();
var start = new Date(end.getTime() - (7 * 24 * 60 * 60 * 1000)); } else {
setDateTime('rep_start', start); var now = todayUtc();
setDateTime('rep_end', end); 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) { document.querySelectorAll('input[name="rep_scope"]').forEach(function (r) {
r.addEventListener('change', updateScopeUi); r.addEventListener('change', updateScopeUi);
}); });
qs('rep_html_content').addEventListener('change', function () { repHtmlContentTouched = true; });
qs('rep_preset_cur_month').addEventListener('click', presetCurrentMonth); qs('rep_preset_cur_month').addEventListener('click', presetCurrentMonth);
qs('rep_preset_last_month').addEventListener('click', presetLastMonth); qs('rep_preset_last_month').addEventListener('click', presetLastMonth);
qs('rep_preset_last_month_full').addEventListener('click', presetLastMonthFull); qs('rep_preset_last_month_full').addEventListener('click', presetLastMonthFull);