diff --git a/.last-branch b/.last-branch index 1083736..b4c458d 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260106-16-reset +v20260106-17-jobrun-popup-objects-restore diff --git a/containers/backupchecks/src/backend/app/main/routes_api.py b/containers/backupchecks/src/backend/app/main/routes_api.py index 7242eff..393d3ca 100644 --- a/containers/backupchecks/src/backend/app/main/routes_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_api.py @@ -1,5 +1,6 @@ from .routes_shared import * # noqa: F401,F403 -from .routes_shared import _format_datetime, _get_ui_timezone_name, _next_ticket_code, _to_amsterdam_date +from .routes_shared import _format_datetime, _get_ui_timezone_name, _to_amsterdam_date +import re @main_bp.route("/api/job-runs//alerts") @login_required @@ -178,7 +179,7 @@ def api_tickets(): return jsonify({"status": "error", "message": "Forbidden."}), 403 payload = request.get_json(silent=True) or {} - description = (payload.get("description") or "").strip() or None + description = None # Description removed from New ticket UI; use remarks for additional context try: run_id = int(payload.get("job_run_id") or 0) except Exception: @@ -194,10 +195,21 @@ def api_tickets(): job = Job.query.get(run.job_id) if run else None now = datetime.utcnow() - code = _next_ticket_code(now) + ticket_code = (payload.get("ticket_code") or "").strip().upper() + + if not ticket_code: + return jsonify({"status": "error", "message": "ticket_code is required."}), 400 + + # Validate format: TYYYYMMDD.#### + if not re.match(r"^T\d{8}\.\d{4}$", ticket_code): + return jsonify({"status": "error", "message": "Invalid ticket_code format. Expected TYYYYMMDD.####."}), 400 + + # Ensure uniqueness + if Ticket.query.filter_by(ticket_code=ticket_code).first(): + return jsonify({"status": "error", "message": "ticket_code already exists."}), 409 ticket = Ticket( - ticket_code=code, + ticket_code=ticket_code, title=None, description=description, active_from_date=_to_amsterdam_date(run.run_at) or _to_amsterdam_date(now) or now.date(), @@ -250,21 +262,8 @@ def api_tickets(): @login_required @roles_required("admin", "operator", "viewer") def api_ticket_update(ticket_id: int): - if get_active_role() not in ("admin", "operator"): - return jsonify({"status": "error", "message": "Forbidden."}), 403 - - ticket = Ticket.query.get_or_404(ticket_id) - payload = request.get_json(silent=True) or {} - if "description" in payload: - ticket.description = (payload.get("description") or "").strip() or None - - try: - db.session.commit() - except Exception as exc: - db.session.rollback() - return jsonify({"status": "error", "message": str(exc) or "Failed to update ticket."}), 500 - - return jsonify({"status": "ok"}) + # Editing tickets is not allowed. Resolve the old ticket and create a new one instead. + return jsonify({"status": "error", "message": "Ticket editing is disabled. Resolve the old ticket and create a new one."}), 405 @main_bp.route("/api/tickets//resolve", methods=["POST"]) @@ -420,21 +419,8 @@ def api_remarks(): @login_required @roles_required("admin", "operator", "viewer") def api_remark_update(remark_id: int): - if get_active_role() not in ("admin", "operator"): - return jsonify({"status": "error", "message": "Forbidden."}), 403 - - remark = Remark.query.get_or_404(remark_id) - payload = request.get_json(silent=True) or {} - if "body" in payload: - remark.body = (payload.get("body") or "").strip() or "" - - try: - db.session.commit() - except Exception as exc: - db.session.rollback() - return jsonify({"status": "error", "message": str(exc) or "Failed to update remark."}), 500 - - return jsonify({"status": "ok"}) + # Editing remarks is not allowed. Resolve the old remark and create a new one instead. + return jsonify({"status": "error", "message": "Remark editing is disabled. Resolve the old remark and create a new one."}), 405 @main_bp.route("/api/remarks//resolve", methods=["POST"]) @@ -489,4 +475,4 @@ def api_remark_link_run(remark_id: int): db.session.rollback() return jsonify({"status": "error", "message": str(exc) or "Failed to link run."}), 500 - return jsonify({"status": "ok"}) + return jsonify({"status": "ok"}) \ No newline at end of file diff --git a/containers/backupchecks/src/backend/app/main/routes_remarks.py b/containers/backupchecks/src/backend/app/main/routes_remarks.py index 2ceab03..fcc2480 100644 --- a/containers/backupchecks/src/backend/app/main/routes_remarks.py +++ b/containers/backupchecks/src/backend/app/main/routes_remarks.py @@ -1,23 +1,13 @@ from .routes_shared import * # noqa: F401,F403 from .routes_shared import _format_datetime -@main_bp.route("/remarks/", methods=["GET", "POST"]) +@main_bp.route("/remarks/", methods=["GET"]) @login_required @roles_required("admin", "operator", "viewer") def remark_detail(remark_id: int): remark = Remark.query.get_or_404(remark_id) - if request.method == "POST": - if get_active_role() not in ("admin", "operator"): - abort(403) - remark.body = (request.form.get("body") or "").strip() or "" - try: - db.session.commit() - flash("Remark updated.", "success") - except Exception as exc: - db.session.rollback() - flash(f"Failed to update remark: {exc}", "danger") - return redirect(url_for("main.remark_detail", remark_id=remark.id)) + # Remark editing is disabled. Resolve the old remark and create a new one instead. scopes = RemarkScope.query.filter(RemarkScope.remark_id == remark.id).order_by(RemarkScope.id.asc()).all() diff --git a/containers/backupchecks/src/backend/app/main/routes_tickets.py b/containers/backupchecks/src/backend/app/main/routes_tickets.py index a1cc6f1..b4465e9 100644 --- a/containers/backupchecks/src/backend/app/main/routes_tickets.py +++ b/containers/backupchecks/src/backend/app/main/routes_tickets.py @@ -270,23 +270,13 @@ def tickets_page(): ) -@main_bp.route("/tickets/", methods=["GET", "POST"]) +@main_bp.route("/tickets/", methods=["GET"]) @login_required @roles_required("admin", "operator", "viewer") def ticket_detail(ticket_id: int): ticket = Ticket.query.get_or_404(ticket_id) - if request.method == "POST": - if get_active_role() not in ("admin", "operator"): - abort(403) - ticket.description = (request.form.get("description") or "").strip() or None - try: - db.session.commit() - flash("Ticket updated.", "success") - except Exception as exc: - db.session.rollback() - flash(f"Failed to update ticket: {exc}", "danger") - return redirect(url_for("main.ticket_detail", ticket_id=ticket.id)) + # Ticket editing is disabled. Resolve the old ticket and create a new one instead. # Scopes scopes = TicketScope.query.filter(TicketScope.ticket_id == ticket.id).order_by(TicketScope.id.asc()).all() diff --git a/containers/backupchecks/src/backend/app/parsers/veeam.py b/containers/backupchecks/src/backend/app/parsers/veeam.py index ee628ef..2b54e5e 100644 --- a/containers/backupchecks/src/backend/app/parsers/veeam.py +++ b/containers/backupchecks/src/backend/app/parsers/veeam.py @@ -79,7 +79,9 @@ def _extract_configuration_job_overall_message(html: str) -> Optional[str]: for line in text.split("\n"): # Example: # 26-12-2025 10:00:23 Warning Skipping server certificate backup because encryption is disabled - if re.match(r"^\d{2}-\d{2}-\d{4}\s+\d{2}:\d{2}:\d{2}\s+(Warning|Failed|Error)\b", line): + # 6-1-2026 10:00:16 Warning Skipping credentials backup because encryption is disabled + # Veeam can format dates as either zero-padded (06-01-2026) or non-padded (6-1-2026). + if re.match(r"^\d{1,2}-\d{1,2}-\d{4}\s+\d{2}:\d{2}:\d{2}\s+(Warning|Failed|Error)\b", line): wanted_lines.append(line) if not wanted_lines: @@ -930,8 +932,17 @@ def try_parse_veeam(msg: MailMessage) -> Tuple[bool, Dict, List[Dict]]: # Keep detailed overall message for non-success states, and always keep # the "Processing " marker when present (used for overrides/rules). + # Veeam Backup for Microsoft 365 can include a meaningful overall warning/info + # even when the run is reported as Success (e.g. missing application + # permissions/roles). Store it so it becomes visible in details and can be + # used for overrides. + is_m365 = (backup_type or "") == "Veeam Backup for Microsoft 365" if overall_message: - if status_word != "Success" or overall_message.lower().startswith("processing "): + if ( + status_word != "Success" + or overall_message.lower().startswith("processing ") + or is_m365 + ): result["overall_message"] = overall_message return True, result, objects diff --git a/containers/backupchecks/src/templates/main/daily_jobs.html b/containers/backupchecks/src/templates/main/daily_jobs.html index 8a42b15..4fc11a1 100644 --- a/containers/backupchecks/src/templates/main/daily_jobs.html +++ b/containers/backupchecks/src/templates/main/daily_jobs.html @@ -200,9 +200,9 @@
- +
-
+
@@ -356,27 +356,13 @@ '
' + '🎫' + '' + escapeHtml(t.ticket_code || '') + '' + - '' + status + '' + + '' + status + '' + '
' + - (t.description ? ('
' + escapeHtml(t.description) + '
') : '') + '
' + '
' + - '' + '' + '
' + '' + - '' + ''; }); html += ''; @@ -397,22 +383,9 @@ (r.body ? ('
' + escapeHtml(r.body) + '
') : '') + '' + '
' + - '' + '' + '
' + '' + - '' + ''; }); html += ''; @@ -427,8 +400,6 @@ var id = btn.getAttribute('data-id'); if (!action || !id) return; - var wrapper = btn.closest('[data-alert-type]'); - if (action === 'resolve-ticket') { if (!confirm('Mark ticket as resolved?')) return; apiJson('/api/tickets/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'}) @@ -439,59 +410,6 @@ apiJson('/api/remarks/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'}) .then(function () { loadAlerts(currentRunId); }) .catch(function (e) { alert(e.message || 'Failed.'); }); - } else if (action === 'toggle-edit-ticket') { - if (!wrapper) return; - var edit = wrapper.querySelector('[data-edit="ticket"]'); - if (!edit) return; - edit.style.display = (edit.style.display === 'none' || !edit.style.display) ? '' : 'none'; - } else if (action === 'toggle-edit-remark') { - if (!wrapper) return; - var edit2 = wrapper.querySelector('[data-edit="remark"]'); - if (!edit2) return; - edit2.style.display = (edit2.style.display === 'none' || !edit2.style.display) ? '' : 'none'; - } else if (action === 'cancel-edit') { - if (!wrapper) return; - var editAny = wrapper.querySelector('[data-edit]'); - if (editAny) editAny.style.display = 'none'; - } else if (action === 'save-ticket') { - if (!wrapper) return; - var editT = wrapper.querySelector('[data-edit="ticket"]'); - if (!editT) return; - var descEl = editT.querySelector('[data-field="description"]'); - var statusEl = editT.querySelector('[data-field="status"]'); - var descVal = descEl ? descEl.value : ''; - if (statusEl) statusEl.textContent = 'Saving...'; - apiJson('/api/tickets/' + encodeURIComponent(id), { - method: 'PATCH', - body: JSON.stringify({description: descVal}) - }) - .then(function () { loadAlerts(currentRunId); }) - .catch(function (e) { - if (statusEl) statusEl.textContent = e.message || 'Failed.'; - else alert(e.message || 'Failed.'); - }); - } else if (action === 'save-remark') { - if (!wrapper) return; - var editR = wrapper.querySelector('[data-edit="remark"]'); - if (!editR) return; - var bodyEl2 = editR.querySelector('[data-field="body"]'); - var statusEl2 = editR.querySelector('[data-field="status"]'); - var bodyVal2 = bodyEl2 ? bodyEl2.value : ''; - if (!bodyVal2 || !bodyVal2.trim()) { - if (statusEl2) statusEl2.textContent = 'Body is required.'; - else alert('Body is required.'); - return; - } - if (statusEl2) statusEl2.textContent = 'Saving...'; - apiJson('/api/remarks/' + encodeURIComponent(id), { - method: 'PATCH', - body: JSON.stringify({body: bodyVal2}) - }) - .then(function () { loadAlerts(currentRunId); }) - .catch(function (e) { - if (statusEl2) statusEl2.textContent = e.message || 'Failed.'; - else alert(e.message || 'Failed.'); - }); } }); }); @@ -528,8 +446,8 @@ function bindInlineCreateForms() { var btnTicket = document.getElementById('dj_ticket_save'); var btnRemark = document.getElementById('dj_remark_save'); - var tDesc = document.getElementById('dj_ticket_description'); - var tStatus = document.getElementById('dj_ticket_status'); + var tCode = document.getElementById('dj_ticket_code'); +var tStatus = document.getElementById('dj_ticket_status'); var rBody = document.getElementById('dj_remark_body'); var rStatus = document.getElementById('dj_remark_status'); @@ -541,8 +459,8 @@ function setDisabled(disabled) { if (btnTicket) btnTicket.disabled = disabled; if (btnRemark) btnRemark.disabled = disabled; - if (tDesc) tDesc.disabled = disabled; - if (rBody) rBody.disabled = disabled; + if (tCode) tCode.disabled = disabled; +if (rBody) rBody.disabled = disabled; } window.__djSetCreateDisabled = setDisabled; @@ -552,15 +470,25 @@ btnTicket.addEventListener('click', function () { if (!currentRunId) { alert('Select a run first.'); return; } clearStatus(); - var description = tDesc ? tDesc.value : ''; + var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : ''; +if (!ticket_code) { + if (tStatus) tStatus.textContent = 'Ticket number is required.'; + else alert('Ticket number is required.'); + return; + } + if (!/^T\d{8}\.\d{4}$/.test(ticket_code)) { + if (tStatus) tStatus.textContent = 'Invalid ticket number format. Expected TYYYYMMDD.####.'; + else alert('Invalid ticket number format. Expected TYYYYMMDD.####.'); + return; + } if (tStatus) tStatus.textContent = 'Saving...'; apiJson('/api/tickets', { method: 'POST', - body: JSON.stringify({job_run_id: currentRunId, description: description}) + body: JSON.stringify({job_run_id: currentRunId, ticket_code: ticket_code}) }) .then(function () { - if (tDesc) tDesc.value = ''; - if (tStatus) tStatus.textContent = ''; + if (tCode) tCode.value = ''; +if (tStatus) tStatus.textContent = ''; loadAlerts(currentRunId); }) .catch(function (e) { diff --git a/containers/backupchecks/src/templates/main/job_detail.html b/containers/backupchecks/src/templates/main/job_detail.html index a7e062f..d97f7c0 100644 --- a/containers/backupchecks/src/templates/main/job_detail.html +++ b/containers/backupchecks/src/templates/main/job_detail.html @@ -274,7 +274,7 @@ "" ); } -function renderObjects(objects) { + function renderObjects(objects) { var container = document.getElementById("run_msg_objects_container"); if (!container) return; @@ -283,16 +283,36 @@ function renderObjects(objects) { return; } + // Sort: objects with an error_message first (alphabetically by name), then the rest (also by name). + var sorted = (objects || []).slice().sort(function (a, b) { + a = a || {}; + b = b || {}; + var aHasErr = !!(a.error_message && a.error_message.toString().trim()); + var bHasErr = !!(b.error_message && b.error_message.toString().trim()); + if (aHasErr !== bHasErr) return aHasErr ? -1 : 1; + + var an = (a.name || "").toString().toLowerCase(); + var bn = (b.name || "").toString().toLowerCase(); + if (an < bn) return -1; + if (an > bn) return 1; + return 0; + }); + var html = "
"; html += ""; - for (var i = 0; i < objects.length; i++) { - var o = objects[i] || {}; + for (var i = 0; i < sorted.length; i++) { + var o = sorted[i] || {}; html += ""; - html += ""; - html += ""; + html += ""; + html += ""; + var d = statusDotClass(o.status); - html += ""; - html += ""; + html += ""; + + html += ""; html += ""; } html += "
ObjectTypeStatusError
" + (o.name || "") + "" + (o.type || "") + "" + escapeHtml(o.name || "") + "" + escapeHtml(o.type || "") + "" + (d ? ('') : '') + escapeHtml(o.status || "") + "" + (o.error_message || "") + "" + + (d ? ("") : "") + + escapeHtml(o.status || "") + + "" + escapeHtml(o.error_message || "") + "
"; @@ -389,4 +409,4 @@ function renderObjects(objects) { })(); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/containers/backupchecks/src/templates/main/remark_detail.html b/containers/backupchecks/src/templates/main/remark_detail.html index 3509fbf..696d846 100644 --- a/containers/backupchecks/src/templates/main/remark_detail.html +++ b/containers/backupchecks/src/templates/main/remark_detail.html @@ -16,19 +16,19 @@ {% endif %} -
+
- +
{{ remark.body or '' }}
{% if active_role in ['admin','operator'] %}
- + {% if not remark.resolved_at %} {% endif %}
{% endif %} - +
diff --git a/containers/backupchecks/src/templates/main/run_checks.html b/containers/backupchecks/src/templates/main/run_checks.html index 56d0e82..171b4ca 100644 --- a/containers/backupchecks/src/templates/main/run_checks.html +++ b/containers/backupchecks/src/templates/main/run_checks.html @@ -205,9 +205,9 @@
- -
-
+ + +
@@ -669,25 +669,11 @@ table.addEventListener('change', function (e) { '' + escapeHtml(t.ticket_code || '') + '' + '' + status + '' + '
' + - (t.description ? ('
' + escapeHtml(t.description) + '
') : '') + '' + '
' + - '' + '' + '
' + '' + - '' + ''; }); html += ''; @@ -696,34 +682,21 @@ table.addEventListener('change', function (e) { if (remarks.length) { html += '
Remarks
'; remarks.forEach(function (r) { - var status2 = r.resolved_at ? 'Resolved' : 'Active'; + var status = r.resolved_at ? 'Resolved' : 'Active'; html += '
' + '
' + '
' + '
' + '💬' + 'Remark' + - '' + status2 + '' + + '' + status + '' + '
' + (r.body ? ('
' + escapeHtml(r.body) + '
') : '') + '
' + '
' + - '' + '' + '
' + '
' + - '' + '
'; }); html += '
'; @@ -737,8 +710,6 @@ table.addEventListener('change', function (e) { var action = btn.getAttribute('data-action'); var id = btn.getAttribute('data-id'); if (!action || !id) return; - var wrapper = btn.closest('[data-alert-type]'); - if (action === 'resolve-ticket') { if (!confirm('Mark ticket as resolved?')) return; apiJson('/api/tickets/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'}) @@ -749,58 +720,6 @@ table.addEventListener('change', function (e) { apiJson('/api/remarks/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'}) .then(function () { loadAlerts(currentRunId); }) .catch(function (e) { alert(e.message || 'Failed.'); }); - } else if (action === 'toggle-edit-ticket') { - if (!wrapper) return; - var edit = wrapper.querySelector('[data-edit="ticket"]'); - if (!edit) return; - edit.style.display = (edit.style.display === 'none' || !edit.style.display) ? '' : 'none'; - } else if (action === 'toggle-edit-remark') { - if (!wrapper) return; - var edit2 = wrapper.querySelector('[data-edit="remark"]'); - if (!edit2) return; - edit2.style.display = (edit2.style.display === 'none' || !edit2.style.display) ? '' : 'none'; - } else if (action === 'cancel-edit') { - if (!wrapper) return; - var editAny = wrapper.querySelector('[data-edit]'); - if (editAny) editAny.style.display = 'none'; - } else if (action === 'save-ticket') { - if (!wrapper) return; - var editT = wrapper.querySelector('[data-edit="ticket"]'); - if (!editT) return; - var descEl = editT.querySelector('[data-field="description"]'); - var statusEl2 = editT.querySelector('[data-field="status"]'); - var descVal = descEl ? descEl.value : ''; - if (statusEl2) statusEl2.textContent = 'Saving...'; - apiJson('/api/tickets/' + encodeURIComponent(id), { - method: 'PATCH', - body: JSON.stringify({description: descVal}) - }) - .then(function () { loadAlerts(currentRunId); }) - .catch(function (e) { - if (statusEl2) statusEl2.textContent = e.message || 'Failed.'; - else alert(e.message || 'Failed.'); - }); - } else if (action === 'save-remark') { - if (!wrapper) return; - var editR = wrapper.querySelector('[data-edit="remark"]'); - if (!editR) return; - var bodyEl = editR.querySelector('[data-field="body"]'); - var statusEl3 = editR.querySelector('[data-field="status"]'); - var bodyVal = bodyEl ? bodyEl.value : ''; - if (!bodyVal || !bodyVal.trim()) { - if (statusEl3) statusEl3.textContent = 'Body is required.'; - return; - } - if (statusEl3) statusEl3.textContent = 'Saving...'; - apiJson('/api/remarks/' + encodeURIComponent(id), { - method: 'PATCH', - body: JSON.stringify({body: bodyVal}) - }) - .then(function () { loadAlerts(currentRunId); }) - .catch(function (e) { - if (statusEl3) statusEl3.textContent = e.message || 'Failed.'; - else alert(e.message || 'Failed.'); - }); } }); }); @@ -825,8 +744,8 @@ table.addEventListener('change', function (e) { function bindInlineCreateForms() { var btnTicket = document.getElementById('rcm_ticket_save'); var btnRemark = document.getElementById('rcm_remark_save'); - var tDesc = document.getElementById('rcm_ticket_description'); - var tStatus = document.getElementById('rcm_ticket_status'); + var tCode = document.getElementById('rcm_ticket_code'); +var tStatus = document.getElementById('rcm_ticket_status'); var rBody = document.getElementById('rcm_remark_body'); var rStatus = document.getElementById('rcm_remark_status'); @@ -838,8 +757,8 @@ table.addEventListener('change', function (e) { function setDisabled(disabled) { if (btnTicket) btnTicket.disabled = disabled; if (btnRemark) btnRemark.disabled = disabled; - if (tDesc) tDesc.disabled = disabled; - if (rBody) rBody.disabled = disabled; + if (tCode) tCode.disabled = disabled; +if (rBody) rBody.disabled = disabled; } window.__rcmSetCreateDisabled = setDisabled; @@ -849,15 +768,25 @@ table.addEventListener('change', function (e) { btnTicket.addEventListener('click', function () { if (!currentRunId) { alert('Select a run first.'); return; } clearStatus(); - var description = tDesc ? tDesc.value : ''; + var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : ''; +if (!ticket_code) { + if (tStatus) tStatus.textContent = 'Ticket number is required.'; + else alert('Ticket number is required.'); + return; + } + if (!/^T\d{8}\.\d{4}$/.test(ticket_code)) { + if (tStatus) tStatus.textContent = 'Invalid ticket number format. Expected TYYYYMMDD.####.'; + else alert('Invalid ticket number format. Expected TYYYYMMDD.####.'); + return; + } if (tStatus) tStatus.textContent = 'Saving...'; apiJson('/api/tickets', { method: 'POST', - body: JSON.stringify({job_run_id: currentRunId, description: description}) + body: JSON.stringify({job_run_id: currentRunId, ticket_code: ticket_code}) }) .then(function () { - if (tDesc) tDesc.value = ''; - if (tStatus) tStatus.textContent = ''; + if (tCode) tCode.value = ''; +if (tStatus) tStatus.textContent = ''; loadAlerts(currentRunId); }) .catch(function (e) { diff --git a/containers/backupchecks/src/templates/main/ticket_detail.html b/containers/backupchecks/src/templates/main/ticket_detail.html index 74b3ae5..32cd071 100644 --- a/containers/backupchecks/src/templates/main/ticket_detail.html +++ b/containers/backupchecks/src/templates/main/ticket_detail.html @@ -17,19 +17,16 @@ {% endif %} -
- - -
+
{% if active_role in ['admin','operator'] %}
- + {% if not ticket.resolved_at %} {% endif %}
{% endif %} - +
diff --git a/containers/backupchecks/src/templates/main/tickets.html b/containers/backupchecks/src/templates/main/tickets.html index 61d6b4e..33de980 100644 --- a/containers/backupchecks/src/templates/main/tickets.html +++ b/containers/backupchecks/src/templates/main/tickets.html @@ -45,7 +45,7 @@
- +
@@ -89,7 +89,7 @@ {{ t.start_date }} {{ t.resolved_at }} - View / Edit + View {% if t.active and t.job_id %} Job page {% endif %} @@ -144,7 +144,7 @@ {{ r.start_date }} {{ r.resolved_at }} - View / Edit + View {% if r.active and r.job_id %} Job page {% endif %}