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:
commit
8e511a111d
@ -1 +1 @@
|
||||
v20260103-12-reports-columns-selector-init-fix
|
||||
v20260104-14-reports-stats-total-runs-success-rate-fix
|
||||
|
||||
@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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),
|
||||
)
|
||||
|
||||
@ -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.")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>';;
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user