474 lines
18 KiB
HTML
474 lines
18 KiB
HTML
{% extends "layout/base.html" %}
|
||
{% block content %}
|
||
|
||
<div class="d-flex flex-wrap align-items-baseline justify-content-between mb-3">
|
||
<div>
|
||
<h2 class="mb-1">Reports</h2>
|
||
<div class="text-muted">Create report definitions and generate raw output for testing.</div>
|
||
</div>
|
||
<div class="mt-2 mt-md-0">
|
||
<a class="btn btn-primary" id="rep_new_btn" href="{{ url_for('main.reports_new') }}">New report</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row g-3">
|
||
<div class="col-12 col-xl-8">
|
||
<div class="card">
|
||
<div class="card-header d-flex align-items-center justify-content-between">
|
||
<div>
|
||
<div class="fw-semibold">Report definitions</div>
|
||
<div class="text-muted small">One-time reports are supported. Scheduling is a placeholder for now.</div>
|
||
</div>
|
||
<div class="d-flex align-items-center gap-2">
|
||
<button type="button" class="btn btn-sm btn-outline-secondary" id="rep_refresh_btn">Refresh</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<div class="table-responsive">
|
||
<table class="table table-hover mb-0 align-middle">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th style="width: 30%;">Name</th>
|
||
<th style="width: 18%;">Type</th>
|
||
<th style="width: 22%;">Period</th>
|
||
<th style="width: 12%;">Format</th>
|
||
<th style="width: 18%;" class="text-end">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="rep_table_body">
|
||
<tr>
|
||
<td colspan="5" class="text-center text-muted py-4">Loading…</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="card-footer text-muted small">
|
||
Tip: Generate a report first, then preview the raw data or download the CSV export.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-12 col-xl-4">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<div class="fw-semibold">Scheduling (placeholder)</div>
|
||
<div class="text-muted small">This is a preview of the future scheduling UI.</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="mb-3">
|
||
<label class="form-label">Delivery method</label>
|
||
<select class="form-select" disabled>
|
||
<option selected>Email</option>
|
||
<option>Download only</option>
|
||
</select>
|
||
<div class="form-text">Coming soon.</div>
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<label class="form-label">Frequency</label>
|
||
<select class="form-select" disabled>
|
||
<option selected>Daily</option>
|
||
<option>Weekly</option>
|
||
<option>Monthly</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<label class="form-label">Recipients</label>
|
||
<input type="text" class="form-control" disabled placeholder="user@example.com, team@example.com" />
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<label class="form-label">Next run</label>
|
||
<div class="input-group">
|
||
<input type="text" class="form-control" disabled placeholder="Not scheduled" />
|
||
<span class="input-group-text">UTC</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="alert alert-info mb-0">
|
||
Scheduling is not active yet. These controls are disabled on purpose.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Raw data modal -->
|
||
<div class="modal fade" id="rep_raw_modal" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="rep_raw_title">Raw data</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
|
||
<div class="modal-body">
|
||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||
<div class="btn-group" role="group" aria-label="Raw view selector">
|
||
<button type="button" class="btn btn-outline-primary" id="rep_raw_view_summary">Summary</button>
|
||
<button type="button" class="btn btn-outline-primary" id="rep_raw_view_snapshot">Snapshot</button>
|
||
</div>
|
||
<div class="d-flex align-items-center gap-2">
|
||
<span class="text-muted small" id="rep_raw_meta">-</span>
|
||
<div class="btn-group" role="group" aria-label="Raw pagination">
|
||
<button type="button" class="btn btn-outline-secondary" id="rep_raw_prev_btn">Prev</button>
|
||
<button type="button" class="btn btn-outline-secondary" id="rep_raw_next_btn">Next</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="table-responsive">
|
||
<table class="table table-sm table-hover align-middle mb-0" id="rep_raw_table">
|
||
<thead class="table-light" id="rep_raw_thead"></thead>
|
||
<tbody id="rep_raw_tbody">
|
||
<tr><td class="text-center text-muted py-4">Select a report to view raw data.</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<a class="btn btn-outline-success" id="rep_raw_download_btn" href="#" target="_blank">Download CSV</a>
|
||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
window.addEventListener('DOMContentLoaded', function () {
|
||
var rawModalEl = document.getElementById('rep_raw_modal');
|
||
var rawModal = window.bootstrap ? new bootstrap.Modal(rawModalEl) : null;
|
||
|
||
var rawReportId = null;
|
||
var rawView = 'summary';
|
||
var rawLimit = 100;
|
||
var rawOffset = 0;
|
||
|
||
var canDeleteReports = {{ 'true' if active_role in ('admin','operator','reporter') else 'false' }};
|
||
|
||
function qs(id) { return document.getElementById(id); }
|
||
|
||
function fmtPeriod(item) {
|
||
var a = (item.period_start || '').replace('T', ' ');
|
||
var b = (item.period_end || '').replace('T', ' ');
|
||
if (!a && !b) return '-';
|
||
return a + ' → ' + b;
|
||
}
|
||
|
||
function badgeForType(item) {
|
||
var t = (item.report_type || '').toLowerCase();
|
||
if (t === 'scheduled') return '<span class="badge text-bg-warning">Scheduled</span>';
|
||
return '<span class="badge text-bg-secondary">One-time</span>';
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return (s || '').replace(/[&<>"']/g, function (c) {
|
||
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
|
||
});
|
||
}
|
||
|
||
function setTableLoading(msg) {
|
||
var body = qs('rep_table_body');
|
||
body.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">' + escapeHtml(msg || 'Loading…') + '</td></tr>';
|
||
}
|
||
|
||
function setRawLoading(msg) {
|
||
qs('rep_raw_thead').innerHTML = '';
|
||
qs('rep_raw_tbody').innerHTML = '<tr><td class="text-center text-muted py-4">' + escapeHtml(msg || 'Loading…') + '</td></tr>';
|
||
qs('rep_raw_meta').textContent = '-';
|
||
qs('rep_raw_prev_btn').disabled = true;
|
||
qs('rep_raw_next_btn').disabled = true;
|
||
}
|
||
|
||
function setRawViewButtons() {
|
||
var a = qs('rep_raw_view_summary');
|
||
var b = qs('rep_raw_view_snapshot');
|
||
if (rawView === 'snapshot') {
|
||
b.classList.add('active');
|
||
a.classList.remove('active');
|
||
} else {
|
||
a.classList.add('active');
|
||
b.classList.remove('active');
|
||
}
|
||
}
|
||
|
||
function renderRawTable(view, items) {
|
||
var thead = qs('rep_raw_thead');
|
||
var tbody = qs('rep_raw_tbody');
|
||
|
||
function thRow(cols) {
|
||
return '<tr>' + cols.map(function (c) { return '<th>' + escapeHtml(c) + '</th>'; }).join('') + '</tr>';
|
||
}
|
||
|
||
if (view === 'snapshot') {
|
||
thead.innerHTML = thRow([
|
||
'Object', 'Customer', 'Job ID', 'Job Name', 'Backup software', 'Backup type',
|
||
'Run ID', 'Run at (UTC)', 'Status', 'Missed', 'Override', 'Reviewed at', 'Remark'
|
||
]);
|
||
|
||
if (!items || !items.length) {
|
||
tbody.innerHTML = '<tr><td colspan="13" class="text-center text-muted py-4">No snapshot rows found.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = items.map(function (r) {
|
||
return (
|
||
'<tr>' +
|
||
'<td class="text-nowrap">' + escapeHtml(r.object_name || '') + '</td>' +
|
||
'<td class="text-nowrap">' + escapeHtml(r.customer_name || '') + '</td>' +
|
||
'<td class="text-nowrap">' + escapeHtml(String(r.job_id || '')) + '</td>' +
|
||
'<td>' + escapeHtml(r.job_name || '') + '</td>' +
|
||
'<td class="text-nowrap">' + escapeHtml(r.backup_software || '') + '</td>' +
|
||
'<td class="text-nowrap">' + escapeHtml(r.backup_type || '') + '</td>' +
|
||
'<td class="text-nowrap">' + escapeHtml(String(r.run_id || '')) + '</td>' +
|
||
'<td class="text-nowrap">' + escapeHtml((r.run_at || '').replace('T', ' ')) + '</td>' +
|
||
'<td class="text-nowrap">' + escapeHtml(r.status || '') + '</td>' +
|
||
'<td class="text-nowrap">' + (r.missed ? '1' : '0') + '</td>' +
|
||
'<td class="text-nowrap">' + (r.override_applied ? '1' : '0') + '</td>' +
|
||
'<td class="text-nowrap">' + escapeHtml((r.reviewed_at || '').replace('T', ' ')) + '</td>' +
|
||
'<td>' + escapeHtml(r.remark || '') + '</td>' +
|
||
'</tr>'
|
||
);
|
||
}).join('');
|
||
|
||
return;
|
||
}
|
||
|
||
thead.innerHTML = thRow([
|
||
'Object', 'Total', 'Success', 'Success (override)', 'Warning', 'Failed', 'Missed', 'Success rate (%)'
|
||
]);
|
||
|
||
if (!items || !items.length) {
|
||
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted py-4">No summary rows found.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = items.map(function (r) {
|
||
return (
|
||
'<tr>' +
|
||
'<td class="text-nowrap">' + escapeHtml(r.object_name || '') + '</td>' +
|
||
'<td class="text-nowrap">' + escapeHtml(String(r.total_runs || 0)) + '</td>' +
|
||
'<td class="text-nowrap">' + escapeHtml(String(r.success_count || 0)) + '</td>' +
|
||
'<td class="text-nowrap">' + escapeHtml(String(r.success_override_count || 0)) + '</td>' +
|
||
'<td class="text-nowrap">' + escapeHtml(String(r.warning_count || 0)) + '</td>' +
|
||
'<td class="text-nowrap">' + escapeHtml(String(r.failed_count || 0)) + '</td>' +
|
||
'<td class="text-nowrap">' + escapeHtml(String(r.missed_count || 0)) + '</td>' +
|
||
'<td class="text-nowrap">' + escapeHtml(String(r.success_rate || 0)) + '</td>' +
|
||
'</tr>'
|
||
);
|
||
}).join('');
|
||
}
|
||
|
||
function updateRawMeta(total) {
|
||
var a = rawOffset + 1;
|
||
var b = Math.min(rawOffset + rawLimit, total);
|
||
if (!total) {
|
||
qs('rep_raw_meta').textContent = '0 rows';
|
||
} else {
|
||
qs('rep_raw_meta').textContent = a + '–' + b + ' of ' + total;
|
||
}
|
||
|
||
qs('rep_raw_prev_btn').disabled = rawOffset <= 0;
|
||
qs('rep_raw_next_btn').disabled = (rawOffset + rawLimit) >= total;
|
||
}
|
||
|
||
function setRawDownloadLink() {
|
||
if (!rawReportId) {
|
||
qs('rep_raw_download_btn').setAttribute('href', '#');
|
||
qs('rep_raw_download_btn').classList.add('disabled');
|
||
return;
|
||
}
|
||
|
||
qs('rep_raw_download_btn').classList.remove('disabled');
|
||
qs('rep_raw_download_btn').setAttribute('href', '/api/reports/' + rawReportId + '/export.csv?view=' + rawView);
|
||
}
|
||
|
||
function loadRawData() {
|
||
if (!rawReportId) return;
|
||
setRawViewButtons();
|
||
setRawDownloadLink();
|
||
setRawLoading('Loading…');
|
||
|
||
fetch('/api/reports/' + rawReportId + '/data?view=' + rawView + '&limit=' + rawLimit + '&offset=' + rawOffset, { credentials: 'same-origin' })
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
var items = (data && data.items) ? data.items : [];
|
||
var total = (data && data.total) ? data.total : 0;
|
||
renderRawTable(rawView, items);
|
||
updateRawMeta(total);
|
||
})
|
||
.catch(function () {
|
||
setRawLoading('Failed to load raw data. Generate the report first.');
|
||
});
|
||
}
|
||
|
||
function openRawModal(id) {
|
||
rawReportId = id;
|
||
rawOffset = 0;
|
||
rawView = rawView || 'summary';
|
||
qs('rep_raw_title').textContent = 'Raw data (Report #' + id + ')';
|
||
loadRawData();
|
||
rawModal.show();
|
||
}
|
||
|
||
function renderTable(items) {
|
||
var body = qs('rep_table_body');
|
||
if (!items || !items.length) {
|
||
body.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">No reports defined yet.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
body.innerHTML = '';
|
||
items.forEach(function (item) {
|
||
var tr = document.createElement('tr');
|
||
|
||
var name = escapeHtml(item.name || 'Report');
|
||
var desc = escapeHtml(item.description || '');
|
||
var typeBadge = badgeForType(item);
|
||
var period = escapeHtml(fmtPeriod(item));
|
||
var fmt = escapeHtml((item.output_format || 'csv').toUpperCase());
|
||
|
||
tr.innerHTML =
|
||
'<td>' +
|
||
'<div class="fw-semibold">' + name + '</div>' +
|
||
(desc ? '<div class="text-muted small">' + desc + '</div>' : '') +
|
||
'</td>' +
|
||
'<td>' + typeBadge + '</td>' +
|
||
'<td class="text-muted small">' + period + '</td>' +
|
||
'<td><span class="badge text-bg-light border">' + fmt + '</span></td>' +
|
||
'<td class="text-end">' +
|
||
'<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>' +
|
||
(canDeleteReports ? '<button type="button" class="btn btn-sm btn-outline-danger ms-1 rep-delete-btn" data-id="' + item.id + '">Delete</button>' : '') +
|
||
'</td>';;
|
||
|
||
body.appendChild(tr);
|
||
});
|
||
|
||
body.querySelectorAll('.rep-generate-btn').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
var id = btn.getAttribute('data-id');
|
||
generateReport(id, btn);
|
||
});
|
||
});
|
||
|
||
body.querySelectorAll('.rep-view-btn').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
var id = btn.getAttribute('data-id');
|
||
openRawModal(id);
|
||
});
|
||
});
|
||
}
|
||
|
||
body.querySelectorAll('.rep-delete-btn').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
var id = btn.getAttribute('data-id');
|
||
deleteReport(id, btn);
|
||
});
|
||
});
|
||
function loadReports() {
|
||
setTableLoading('Loading…');
|
||
fetch('/api/reports', { credentials: 'same-origin' })
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
renderTable((data && data.items) ? data.items : []);
|
||
})
|
||
.catch(function () {
|
||
setTableLoading('Failed to load reports.');
|
||
});
|
||
}
|
||
|
||
function deleteReport(id, btnEl) {
|
||
if (!id) return;
|
||
if (!confirm('Delete this report definition? This cannot be undone.')) return;
|
||
var oldText = btnEl ? btnEl.textContent : '';
|
||
if (btnEl) { btnEl.disabled = true; btnEl.textContent = 'Deleting…'; }
|
||
|
||
fetch('/api/reports/' + id, { method: 'DELETE', credentials: 'same-origin' })
|
||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, json: j }; }); })
|
||
.then(function (res) {
|
||
if (btnEl) { btnEl.disabled = false; btnEl.textContent = oldText; }
|
||
if (!res.ok) {
|
||
alert((res.json && res.json.error) ? res.json.error : 'Delete failed.');
|
||
return;
|
||
}
|
||
// If the raw modal is open for this report, close it.
|
||
if (rawReportId && String(rawReportId) === String(id) && rawModal) {
|
||
rawModal.hide();
|
||
rawReportId = null;
|
||
}
|
||
loadReports();
|
||
})
|
||
.catch(function () {
|
||
if (btnEl) { btnEl.disabled = false; btnEl.textContent = oldText; }
|
||
alert('Delete failed.');
|
||
});
|
||
}
|
||
|
||
function generateReport(id, btnEl) {
|
||
if (!id) return;
|
||
var oldText = btnEl.textContent;
|
||
btnEl.disabled = true;
|
||
btnEl.textContent = 'Generating…';
|
||
|
||
fetch('/api/reports/' + id + '/generate', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({})
|
||
})
|
||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, json: j }; }); })
|
||
.then(function (res) {
|
||
btnEl.disabled = false;
|
||
btnEl.textContent = oldText;
|
||
if (!res.ok) {
|
||
alert((res.json && res.json.error) ? res.json.error : 'Generate failed.');
|
||
return;
|
||
}
|
||
if (res.json && (res.json.snapshot_rows !== undefined || res.json.summary_rows !== undefined)) {
|
||
alert('Report generated. Snapshots: ' + (res.json.snapshot_rows || 0) + ', Summary: ' + (res.json.summary_rows || 0));
|
||
} else {
|
||
alert('Report generated.');
|
||
}
|
||
})
|
||
.catch(function () {
|
||
btnEl.disabled = false;
|
||
btnEl.textContent = oldText;
|
||
alert('Generate failed.');
|
||
});
|
||
}
|
||
|
||
qs('rep_refresh_btn').addEventListener('click', loadReports);
|
||
|
||
qs('rep_raw_view_summary').addEventListener('click', function () {
|
||
rawView = 'summary';
|
||
rawOffset = 0;
|
||
loadRawData();
|
||
});
|
||
|
||
qs('rep_raw_view_snapshot').addEventListener('click', function () {
|
||
rawView = 'snapshot';
|
||
rawOffset = 0;
|
||
loadRawData();
|
||
});
|
||
|
||
qs('rep_raw_prev_btn').addEventListener('click', function () {
|
||
rawOffset = Math.max(0, rawOffset - rawLimit);
|
||
loadRawData();
|
||
});
|
||
|
||
qs('rep_raw_next_btn').addEventListener('click', function () {
|
||
rawOffset = rawOffset + rawLimit;
|
||
loadRawData();
|
||
});
|
||
|
||
loadReports();
|
||
});
|
||
</script>
|
||
|
||
|
||
{% endblock %} |