backupchecks/containers/backupchecks/src/templates/main/reports.html

474 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 %}