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

680 lines
26 KiB
HTML

{% extends "layout/base.html" %}
<style>
/* Inbox popup: wider + internal scroll areas */
.modal-xxl { max-width: 98vw; }
@media (min-width: 1400px) { .modal-xxl { max-width: 1400px; } }
#msg_body_container_iframe { height: 55vh; }
#msg_objects_container { max-height: 25vh; overflow: auto; }
</style>
{# Pager macro must be defined before it is used #}
{% macro pager(position, page, total_pages, has_prev, has_next) -%}
<div class="d-flex justify-content-between align-items-center my-2">
<div>
{% if has_prev %}
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('main.inbox', page=page-1) }}">Previous</a>
{% else %}
<button class="btn btn-outline-secondary btn-sm" disabled>Previous</button>
{% endif %}
{% if has_next %}
<a class="btn btn-outline-secondary btn-sm ms-2" href="{{ url_for('main.inbox', page=page+1) }}">Next</a>
{% else %}
<button class="btn btn-outline-secondary btn-sm ms-2" disabled>Next</button>
{% endif %}
</div>
{% if current_user.is_authenticated and active_role in ["admin", "operator"] %}
<form method="POST" action="{{ url_for('main.inbox_reparse_all') }}" class="me-3 mb-0">
<button type="submit" class="btn btn-outline-secondary btn-sm">Re-parse all</button>
</form>
{% endif %}
<div class="d-flex align-items-center">
<span class="me-2">Page {{ page }} of {{ total_pages }}</span>
<form method="get" class="d-flex align-items-center mb-0">
<label for="page_{{ position }}" class="form-label me-1 mb-0">Go to:</label>
<input
type="number"
min="1"
max="{{ total_pages }}"
class="form-control form-control-sm me-1"
id="page_{{ position }}"
name="page"
value="{{ page }}"
style="width: 5rem;"
/>
<button type="submit" class="btn btn-primary btn-sm">Go</button>
</form>
</div>
</div>
{%- endmacro %}
{% block content %}
<h2 class="mb-3">Inbox</h2>
{{ pager("top", page, total_pages, has_prev, has_next) }}
{% if can_bulk_delete %}
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-danger" id="btn_inbox_delete_selected" disabled>Delete selected</button>
</div>
</div>
<div class="small text-muted mb-2" id="inbox_status"></div>
{% endif %}
<div class="table-responsive">
<table class="table table-sm table-hover align-middle" id="inboxTable">
<thead class="table-light">
<tr>
{% if can_bulk_delete %}
<th scope="col" style="width: 34px;">
<input class="form-check-input" type="checkbox" id="inbox_select_all" />
</th>
{% endif %}
<th scope="col">From</th>
<th scope="col">Subject</th>
<th scope="col">Date / time</th>
<th scope="col">Backup</th>
<th scope="col">Type</th>
<th scope="col">Job name</th>
<th scope="col">Overall</th>
<th scope="col">Parsed</th>
<th scope="col">EML</th>
</tr>
</thead>
<tbody>
{% if rows %}
{% for row in rows %}
<tr class="inbox-row" data-message-id="{{ row.id }}" style="cursor: pointer;">
{% if can_bulk_delete %}
<td onclick="event.stopPropagation();">
<input class="form-check-input inbox_row_cb" type="checkbox" value="{{ row.id }}" />
</td>
{% endif %}
<td>{{ row.from_address }}</td>
<td>{{ row.subject }}</td>
<td>{{ row.received_at }}</td>
<td>{{ row.backup_software }}</td>
<td>{{ row.backup_type }}</td>
<td>{{ row.job_name }}</td>
<td>{{ row.overall_status }}</td>
<td>{{ row.parsed_at }}</td>
<td>
{% if row.has_eml %}
<a class="eml-download" href="{{ url_for('main.inbox_message_eml', message_id=row.id) }}">EML</a>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="10" class="text-center text-muted py-3">
No messages found.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{{ pager("bottom", page, total_pages, has_prev, has_next) }}
<!-- Inline popup modal for message details -->
<div class="modal fade" id="inboxMessageModal" tabindex="-1" aria-labelledby="inboxMessageModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable modal-xxl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="inboxMessageModalLabel">Message details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-3">
<dl class="row mb-0 dl-compact">
<dt class="col-4">From</dt>
<dd class="col-8 ellipsis-field" id="msg_from"></dd>
<dt class="col-4">Backup</dt>
<dd class="col-8 ellipsis-field" id="msg_backup"></dd>
<dt class="col-4">Type</dt>
<dd class="col-8 ellipsis-field" id="msg_type"></dd>
<dt class="col-4">Job</dt>
<dd class="col-8 ellipsis-field" id="msg_job"></dd>
<dt class="col-4">Overall</dt>
<dd class="col-8 ellipsis-field" id="msg_overall"></dd>
<dt class="col-4">Customer</dt>
<dd class="col-8">
{% if current_user.is_authenticated and active_role in ["admin", "operator"] %}
<input id="msg_customer_input" class="form-control form-control-sm" list="customerList" placeholder="Select customer" autocomplete="off" />
<datalist id="customerList">
{% for c in customers %}
<option value="{{ c.name }}"></option>
{% endfor %}
</datalist>
{% else %}
<span id="msg_customer_display" class="ellipsis-field"></span>
{% endif %}
</dd>
<dt class="col-4">Received</dt>
<dd class="col-8 ellipsis-field" id="msg_received"></dd>
<dt class="col-4">Parsed</dt>
<dd class="col-8 ellipsis-field" id="msg_parsed"></dd>
</dl>
</div>
<div class="col-md-9">
<div class="mb-2">
<h6 class="mb-1">Details</h6>
<div id="msg_overall_message" class="border rounded p-2" style="white-space: pre-wrap; max-height: 20vh; overflow: auto;"></div>
</div>
<div class="border rounded p-2 p-0" style="overflow:hidden;">
<iframe id="msg_body_container_iframe" class="w-100" style="height:55vh; border:0; background:transparent;" sandbox="allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"></iframe>
</div>
<div class="mt-3">
<div id="msg_objects_container">
<!-- Parsed objects will be rendered here -->
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
{% if current_user.is_authenticated and active_role in ["admin", "operator"] %}
<form id="inboxApproveForm" method="POST" action="" class="me-auto mb-0">
<input type="hidden" id="msg_customer_id" name="customer_id" value="" />
<button type="submit" class="btn btn-primary" id="inboxApproveBtn">Approve job</button>
<button type="button" class="btn btn-outline-primary ms-2 d-none" id="vspcMapCompaniesBtn">Map companies</button>
</form>
<form id="inboxDeleteForm" method="POST" action="" class="mb-0">
<button type="submit" class="btn btn-outline-danger" onclick="return confirm('Delete this message from the Inbox?');">Delete</button>
</form>
{% endif %}
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- VSPC company mapping modal (for multi-company summary emails) -->
<div class="modal fade" id="vspcCompanyMapModal" tabindex="-1" aria-labelledby="vspcCompanyMapModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="vspcCompanyMapModalLabel">Map companies to customers</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="vspcCompanyMapForm" method="POST" action="">
<div class="modal-body">
<p class="mb-2">This message contains multiple companies. Map each company to a customer to approve.</p>
<datalist id="vspcCustomerList">
{% for c in customers %}
<option value="{{ c.name }}"></option>
{% endfor %}
</datalist>
<div class="table-responsive" style="max-height:55vh; overflow-y:auto;">
<table class="table table-sm align-middle">
<thead>
<tr>
<th style="width: 40%;">Company</th>
<th style="width: 60%;">Customer</th>
</tr>
</thead>
<tbody id="vspcCompanyMapTbody">
<!-- rows injected by JS -->
</tbody>
</table>
</div>
<input type="hidden" id="vspc_company_mappings_json" name="company_mappings_json" value="" />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Approve mapped companies</button>
</div>
</form>
</div>
</div>
</div>
<script>
(function () {
var customers = {{ customers|tojson|safe }};
var table = document.getElementById('inboxTable');
var selectAll = document.getElementById('inbox_select_all');
var btnDeleteSelected = document.getElementById('btn_inbox_delete_selected');
var statusEl = document.getElementById('inbox_status');
function getSelectedMessageIds() {
if (!table) return [];
var cbs = table.querySelectorAll('tbody .inbox_row_cb');
var ids = [];
cbs.forEach(function (cb) {
if (cb.checked) ids.push(parseInt(cb.value, 10));
});
return ids.filter(function (x) { return Number.isFinite(x); });
}
function refreshRowHighlights() {
if (!table) return;
var cbs = table.querySelectorAll('tbody .inbox_row_cb');
cbs.forEach(function (cb) {
var tr = cb.closest ? cb.closest('tr') : null;
if (!tr) return;
if (cb.checked) tr.classList.add('table-active');
else tr.classList.remove('table-active');
});
}
function refreshSelectAll() {
if (!selectAll || !table) return;
var cbs = table.querySelectorAll('tbody .inbox_row_cb');
var total = cbs.length;
var checked = 0;
cbs.forEach(function (cb) { if (cb.checked) checked++; });
selectAll.indeterminate = checked > 0 && checked < total;
selectAll.checked = total > 0 && checked === total;
}
function updateBulkDeleteUi() {
var ids = getSelectedMessageIds();
refreshRowHighlights();
if (btnDeleteSelected) btnDeleteSelected.disabled = ids.length === 0;
if (statusEl) statusEl.textContent = ids.length ? (ids.length + ' selected') : '';
refreshSelectAll();
}
function postJson(url, payload) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(payload || {})
}).then(function (r) {
return r.json().then(function (data) { return { ok: r.ok, status: r.status, data: data }; });
});
}
if (selectAll && table) {
function setAllSelection(checked) {
var cbs = table.querySelectorAll('tbody .inbox_row_cb');
cbs.forEach(function (cb) { cb.checked = !!checked; });
selectAll.indeterminate = false;
selectAll.checked = !!checked;
setTimeout(function () {
selectAll.indeterminate = false;
selectAll.checked = !!checked;
}, 0);
updateBulkDeleteUi();
}
selectAll.addEventListener('click', function (e) {
e.stopPropagation();
});
selectAll.addEventListener('change', function () {
setAllSelection(selectAll.checked);
});
}
if (table) {
table.addEventListener('change', function (e) {
var t = e.target;
if (t && t.classList && t.classList.contains('inbox_row_cb')) {
updateBulkDeleteUi();
}
});
}
if (btnDeleteSelected) {
btnDeleteSelected.addEventListener('click', function () {
var ids = getSelectedMessageIds();
if (!ids.length) return;
var msg = 'Delete ' + ids.length + ' selected message' + (ids.length === 1 ? '' : 's') + ' from the Inbox?';
if (!confirm(msg)) return;
if (statusEl) statusEl.textContent = 'Deleting...';
postJson('{{ url_for('main.api_inbox_bulk_delete') }}', { message_ids: ids })
.then(function (res) {
if (!res.ok || !res.data || res.data.status !== 'ok') {
var err = (res.data && (res.data.message || res.data.error)) ? (res.data.message || res.data.error) : 'Request failed.';
if (statusEl) statusEl.textContent = err;
alert(err);
return;
}
window.location.reload();
})
.catch(function () {
var err = 'Request failed.';
if (statusEl) statusEl.textContent = err;
alert(err);
});
});
}
// Initialize UI state
updateBulkDeleteUi();
function wrapMailHtml(html) {
html = html || "";
var trimmed = (typeof html === "string") ? html.trim() : "";
// If the content already looks like a full HTML document (common for report attachments),
// do not wrap it again.
if (trimmed.toLowerCase().indexOf("<!doctype") === 0 || trimmed.toLowerCase().indexOf("<html") === 0) {
return trimmed;
}
// Ensure we render the mail HTML with its own CSS, isolated from the site styling.
return (
"<!doctype html><html><head><meta charset=\"utf-8\">" +
"<base target=\"_blank\">" +
"</head><body style=\"margin:0; padding:8px;\">" +
html +
"</body></html>"
);
}
function findCustomerIdByName(name) {
if (!name) return null;
for (var i = 0; i < customers.length; i++) {
if (customers[i].name === name) return customers[i].id;
}
return null;
}
function renderObjects(objects) {
var container = document.getElementById("msg_objects_container");
if (!container) return;
if (!objects || !objects.length) {
container.innerHTML = "<p class=\"text-muted mb-0\">No objects parsed for this message.</p>";
return;
}
function objectSeverityRank(o) {
var st = String((o && o.status) || "").trim().toLowerCase();
var err = String((o && o.error_message) || "").trim();
if (st === "error" || st === "failed" || st === "failure" || err) return 0;
if (st === "warning") return 1;
return 2;
}
function sortObjects(list) {
return (list || []).slice().sort(function (a, b) {
var ra = objectSeverityRank(a);
var rb = objectSeverityRank(b);
if (ra !== rb) return ra - rb;
var na = String((a && a.name) || "").toLowerCase();
var nb = String((b && b.name) || "").toLowerCase();
if (na < nb) return -1;
if (na > nb) return 1;
var ta = String((a && a.type) || "").toLowerCase();
var tb = String((b && b.type) || "").toLowerCase();
if (ta < tb) return -1;
if (ta > tb) return 1;
return 0;
});
}
var sorted = sortObjects(objects);
var html = "<div class=\"table-responsive\"><table class=\"table table-sm table-bordered mb-0\">";
html += "<thead><tr><th>Object</th><th>Type</th><th>Status</th><th>Error</th></tr></thead><tbody>";
for (var i = 0; i < sorted.length; i++) {
var o = sorted[i] || {};
html += "<tr>";
html += "<td>" + (o.name || "") + "</td>";
html += "<td>" + (o.type || "") + "</td>";
html += "<td>" + (o.status || "") + "</td>";
html += "<td>" + (o.error_message || "") + "</td>";
html += "</tr>";
}
html += "</tbody></table></div>";
container.innerHTML = html;
}
function attachHandlers() {
var emlLinks = document.querySelectorAll("a.eml-download");
emlLinks.forEach(function (a) {
a.addEventListener("click", function (ev) {
ev.stopPropagation();
});
});
var rows = document.querySelectorAll("tr.inbox-row");
var modalEl = document.getElementById("inboxMessageModal");
if (!modalEl) return;
var modal = new bootstrap.Modal(modalEl);
rows.forEach(function (row) {
row.addEventListener("click", function () {
var id = row.getAttribute("data-message-id");
if (!id) return;
fetch("{{ url_for('main.inbox_message_detail', message_id=0) }}".replace("0", id))
.then(function (resp) {
if (!resp.ok) throw new Error("Failed to load message details");
return resp.json();
})
.then(function (data) {
if (data.status !== "ok") throw new Error("Unexpected response");
var meta = data.meta || {};
document.getElementById("inboxMessageModalLabel").textContent = meta.subject || "Message details";
document.getElementById("msg_from").textContent = meta.from_address || "";
document.getElementById("msg_backup").textContent = meta.backup_software || "";
document.getElementById("msg_type").textContent = meta.backup_type || "";
document.getElementById("msg_job").textContent = meta.job_name || "";
document.getElementById("msg_overall").textContent = meta.overall_status || "";
document.getElementById("msg_overall_message").textContent = meta.overall_message || "";
document.getElementById("msg_received").textContent = meta.received_at || "";
document.getElementById("msg_parsed").textContent = meta.parsed_at || "";
var bodyFrame = document.getElementById("msg_body_container_iframe");
if (bodyFrame) bodyFrame.srcdoc = wrapMailHtml(data.body_html || "");
renderObjects(data.objects || []);
// VSPC multi-company mapping support (Active alarms summary)
(function () {
var mapBtn = document.getElementById("vspcMapCompaniesBtn");
var approveBtn = document.getElementById("inboxApproveBtn");
if (!mapBtn) return;
// reset
mapBtn.classList.add("d-none");
if (approveBtn) approveBtn.classList.remove("d-none");
var ciReset = document.getElementById("msg_customer_input");
if (ciReset) {
ciReset.removeAttribute("disabled");
ciReset.placeholder = "Select customer";
}
var bsw = String(meta.backup_software || "").trim();
var btype = String(meta.backup_type || "").trim();
var jname = String(meta.job_name || "").trim();
if (bsw !== "Veeam" || btype !== "Service Provider Console" || jname !== "Active alarms summary") {
return;
}
var companies = (data.vspc_companies || meta.vspc_companies || []);
var defaults = (data.vspc_company_defaults || {});
if (!Array.isArray(companies)) companies = [];
// Fallback for older stored messages where companies were embedded in object names.
if (!companies.length) {
var objs = data.objects || [];
var seen = {};
objs.forEach(function (o) {
var name = String((o && o.name) || "");
var ix = name.indexOf(" | ");
if (ix > 0) {
var c = name.substring(0, ix).trim();
if (c && !seen[c]) { seen[c] = true; companies.push(c); }
}
});
}
if (!companies.length) return;
// Show mapping button; hide regular approve
mapBtn.classList.remove("d-none");
if (approveBtn) approveBtn.classList.add("d-none");
var ci = document.getElementById("msg_customer_input");
if (ci) {
ci.value = "";
ci.setAttribute("disabled", "disabled");
ci.placeholder = "Use \"Map companies\"";
}
mapBtn.onclick = function () {
var tbody = document.getElementById("vspcCompanyMapTbody");
var form = document.getElementById("vspcCompanyMapForm");
if (!tbody || !form) return;
// set form action
form.action = "{{ url_for('main.inbox_message_approve_vspc_companies', message_id=0) }}".replace("0", String(meta.id || id));
// build rows
tbody.innerHTML = "";
companies.forEach(function (company) {
var tr = document.createElement("tr");
var tdC = document.createElement("td");
tdC.textContent = company;
tr.appendChild(tdC);
var tdS = document.createElement("td");
var inp = document.createElement("input");
inp.type = "text";
inp.className = "form-control form-control-sm";
inp.setAttribute("list", "vspcCustomerList");
inp.setAttribute("data-company", company);
inp.placeholder = "Select customer";
// Prefill with existing mapping when available.
try {
var d = defaults && defaults[company];
if (d && d.customer_name) {
inp.value = String(d.customer_name);
}
} catch (e) {}
tdS.appendChild(inp);
tr.appendChild(tdS);
tbody.appendChild(tr);
});
// clear hidden field
var hidden = document.getElementById("vspc_company_mappings_json");
if (hidden) hidden.value = "";
var mapModalEl = document.getElementById("vspcCompanyMapModal");
if (mapModalEl && window.bootstrap) {
var mm = bootstrap.Modal.getOrCreateInstance(mapModalEl);
mm.show();
}
};
// Attach submit handler once
var mapForm = document.getElementById("vspcCompanyMapForm");
if (mapForm && !mapForm.getAttribute("data-bound")) {
mapForm.setAttribute("data-bound", "1");
mapForm.addEventListener("submit", function (ev) {
var rows = document.querySelectorAll("#vspcCompanyMapTbody input[data-company]");
var mappings = [];
rows.forEach(function (inp) {
var company = inp.getAttribute("data-company") || "";
var cname = String(inp.value || "").trim();
if (!company || !cname) return;
var cid = findCustomerIdByName(cname);
if (!cid) return;
mappings.push({ company: company, customer_id: cid });
});
var hidden = document.getElementById("vspc_company_mappings_json");
if (hidden) hidden.value = JSON.stringify(mappings);
});
}
})();
var customerName = meta.customer_name || "";
var approveForm = document.getElementById("inboxApproveForm");
{% if current_user.is_authenticated and active_role in ["admin", "operator"] %}
var customerInput = document.getElementById("msg_customer_input");
var customerIdField = document.getElementById("msg_customer_id");
if (customerInput) customerInput.value = customerName;
if (customerIdField) {
var existingId = findCustomerIdByName(customerName);
customerIdField.value = existingId !== null ? String(existingId) : "";
}
if (approveForm) {
approveForm.action = "{{ url_for('main.inbox_message_approve', message_id=0) }}".replace("0", id);
approveForm.onsubmit = function (ev) {
if (!customerInput || !customerIdField) return;
var cid = findCustomerIdByName(customerInput.value);
if (!cid) {
ev.preventDefault();
alert("Please select an existing customer name from the list.");
return false;
}
customerIdField.value = String(cid);
};
}
var deleteForm = document.getElementById("inboxDeleteForm");
if (deleteForm) {
deleteForm.action = "{{ url_for('main.inbox_message_delete', message_id=0) }}".replace("0", id);
}
{% else %}
var customerDisplay = document.getElementById("msg_customer_display");
if (customerDisplay) customerDisplay.textContent = customerName || "";
if (approveForm) approveForm.style.display = "none";
var deleteForm = document.getElementById("inboxDeleteForm");
if (deleteForm) deleteForm.style.display = "none";
{% endif %}
modal.show();
})
.catch(function (err) {
console.error(err);
});
});
});
}
document.addEventListener("DOMContentLoaded", attachHandlers);
})();
</script>
{% endblock %}