diff --git a/.last-branch b/.last-branch index aa78fb9..1083736 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260106-15-jobrun-popup-objects-sort +v20260106-16-reset diff --git a/containers/backupchecks/src/backend/app/main/routes_api.py b/containers/backupchecks/src/backend/app/main/routes_api.py index 393d3ca..7242eff 100644 --- a/containers/backupchecks/src/backend/app/main/routes_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_api.py @@ -1,6 +1,5 @@ from .routes_shared import * # noqa: F401,F403 -from .routes_shared import _format_datetime, _get_ui_timezone_name, _to_amsterdam_date -import re +from .routes_shared import _format_datetime, _get_ui_timezone_name, _next_ticket_code, _to_amsterdam_date @main_bp.route("/api/job-runs//alerts") @login_required @@ -179,7 +178,7 @@ def api_tickets(): return jsonify({"status": "error", "message": "Forbidden."}), 403 payload = request.get_json(silent=True) or {} - description = None # Description removed from New ticket UI; use remarks for additional context + description = (payload.get("description") or "").strip() or None try: run_id = int(payload.get("job_run_id") or 0) except Exception: @@ -195,21 +194,10 @@ def api_tickets(): job = Job.query.get(run.job_id) if run else None now = datetime.utcnow() - 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 + code = _next_ticket_code(now) ticket = Ticket( - ticket_code=ticket_code, + ticket_code=code, title=None, description=description, active_from_date=_to_amsterdam_date(run.run_at) or _to_amsterdam_date(now) or now.date(), @@ -262,8 +250,21 @@ def api_tickets(): @login_required @roles_required("admin", "operator", "viewer") def api_ticket_update(ticket_id: int): - # 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 + 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"}) @main_bp.route("/api/tickets//resolve", methods=["POST"]) @@ -419,8 +420,21 @@ def api_remarks(): @login_required @roles_required("admin", "operator", "viewer") def api_remark_update(remark_id: int): - # 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 + 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"}) @main_bp.route("/api/remarks//resolve", methods=["POST"]) @@ -475,4 +489,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"}) \ No newline at end of file + return jsonify({"status": "ok"}) diff --git a/containers/backupchecks/src/backend/app/main/routes_remarks.py b/containers/backupchecks/src/backend/app/main/routes_remarks.py index fcc2480..2ceab03 100644 --- a/containers/backupchecks/src/backend/app/main/routes_remarks.py +++ b/containers/backupchecks/src/backend/app/main/routes_remarks.py @@ -1,13 +1,23 @@ from .routes_shared import * # noqa: F401,F403 from .routes_shared import _format_datetime -@main_bp.route("/remarks/", methods=["GET"]) +@main_bp.route("/remarks/", methods=["GET", "POST"]) @login_required @roles_required("admin", "operator", "viewer") def remark_detail(remark_id: int): remark = Remark.query.get_or_404(remark_id) - # Remark editing is disabled. Resolve the old remark and create a new one instead. + 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)) 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 b4465e9..a1cc6f1 100644 --- a/containers/backupchecks/src/backend/app/main/routes_tickets.py +++ b/containers/backupchecks/src/backend/app/main/routes_tickets.py @@ -270,13 +270,23 @@ def tickets_page(): ) -@main_bp.route("/tickets/", methods=["GET"]) +@main_bp.route("/tickets/", methods=["GET", "POST"]) @login_required @roles_required("admin", "operator", "viewer") def ticket_detail(ticket_id: int): ticket = Ticket.query.get_or_404(ticket_id) - # Ticket editing is disabled. Resolve the old ticket and create a new one instead. + 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)) # 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 2b54e5e..ee628ef 100644 --- a/containers/backupchecks/src/backend/app/parsers/veeam.py +++ b/containers/backupchecks/src/backend/app/parsers/veeam.py @@ -79,9 +79,7 @@ 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 - # 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): + if re.match(r"^\d{2}-\d{2}-\d{4}\s+\d{2}:\d{2}:\d{2}\s+(Warning|Failed|Error)\b", line): wanted_lines.append(line) if not wanted_lines: @@ -932,17 +930,8 @@ 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 ") - or is_m365 - ): + if status_word != "Success" or overall_message.lower().startswith("processing "): 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 4fc11a1..8a42b15 100644 --- a/containers/backupchecks/src/templates/main/daily_jobs.html +++ b/containers/backupchecks/src/templates/main/daily_jobs.html @@ -200,9 +200,9 @@
- +
-
+
@@ -356,13 +356,27 @@ '
' + '🎫' + '' + escapeHtml(t.ticket_code || '') + '' + - '' + status + '' + + '' + status + '' + '
' + + (t.description ? ('
' + escapeHtml(t.description) + '
') : '') + '
' + '
' + + '' + '' + '
' + '' + + '' + ''; }); html += ''; @@ -383,9 +397,22 @@ (r.body ? ('
' + escapeHtml(r.body) + '
') : '') + '' + '
' + + '' + '' + '
' + '' + + '' + ''; }); html += ''; @@ -400,6 +427,8 @@ 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: '{}'}) @@ -410,6 +439,59 @@ 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.'); + }); } }); }); @@ -446,8 +528,8 @@ function bindInlineCreateForms() { var btnTicket = document.getElementById('dj_ticket_save'); var btnRemark = document.getElementById('dj_remark_save'); - var tCode = document.getElementById('dj_ticket_code'); -var tStatus = document.getElementById('dj_ticket_status'); + var tDesc = document.getElementById('dj_ticket_description'); + var tStatus = document.getElementById('dj_ticket_status'); var rBody = document.getElementById('dj_remark_body'); var rStatus = document.getElementById('dj_remark_status'); @@ -459,8 +541,8 @@ var tStatus = document.getElementById('dj_ticket_status'); function setDisabled(disabled) { if (btnTicket) btnTicket.disabled = disabled; if (btnRemark) btnRemark.disabled = disabled; - if (tCode) tCode.disabled = disabled; -if (rBody) rBody.disabled = disabled; + if (tDesc) tDesc.disabled = disabled; + if (rBody) rBody.disabled = disabled; } window.__djSetCreateDisabled = setDisabled; @@ -470,25 +552,15 @@ if (rBody) rBody.disabled = disabled; btnTicket.addEventListener('click', function () { if (!currentRunId) { alert('Select a run first.'); return; } clearStatus(); - 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; - } + var description = tDesc ? tDesc.value : ''; if (tStatus) tStatus.textContent = 'Saving...'; apiJson('/api/tickets', { method: 'POST', - body: JSON.stringify({job_run_id: currentRunId, ticket_code: ticket_code}) + body: JSON.stringify({job_run_id: currentRunId, description: description}) }) .then(function () { - if (tCode) tCode.value = ''; -if (tStatus) tStatus.textContent = ''; + if (tDesc) tDesc.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 7897f93..a7e062f 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,36 +283,16 @@ 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 < sorted.length; i++) { - var o = sorted[i] || {}; + for (var i = 0; i < objects.length; i++) { + var o = objects[i] || {}; html += ""; - html += ""; - html += ""; - + html += ""; + html += ""; var d = statusDotClass(o.status); - html += ""; - - html += ""; + html += ""; + html += ""; html += ""; } html += "
ObjectTypeStatusError
" + escapeHtml(o.name || "") + "" + escapeHtml(o.type || "") + "" + (o.name || "") + "" + (o.type || "") + "" + - (d ? ("") : "") + - escapeHtml(o.status || "") + - "" + escapeHtml(o.error_message || "") + "" + (d ? ('') : '') + escapeHtml(o.status || "") + "" + (o.error_message || "") + "
"; diff --git a/containers/backupchecks/src/templates/main/remark_detail.html b/containers/backupchecks/src/templates/main/remark_detail.html index 696d846..3509fbf 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 171b4ca..56d0e82 100644 --- a/containers/backupchecks/src/templates/main/run_checks.html +++ b/containers/backupchecks/src/templates/main/run_checks.html @@ -205,9 +205,9 @@
- -
-
+ + +
@@ -669,11 +669,25 @@ table.addEventListener('change', function (e) { '' + escapeHtml(t.ticket_code || '') + '' + '' + status + '' + '
' + + (t.description ? ('
' + escapeHtml(t.description) + '
') : '') + '' + '
' + + '' + '' + '
' + '' + + '' + ''; }); html += ''; @@ -682,21 +696,34 @@ table.addEventListener('change', function (e) { if (remarks.length) { html += '
Remarks
'; remarks.forEach(function (r) { - var status = r.resolved_at ? 'Resolved' : 'Active'; + var status2 = r.resolved_at ? 'Resolved' : 'Active'; html += '
' + '
' + '
' + '
' + '💬' + 'Remark' + - '' + status + '' + + '' + status2 + '' + '
' + (r.body ? ('
' + escapeHtml(r.body) + '
') : '') + '
' + '
' + + '' + '' + '
' + '
' + + '' + '
'; }); html += '
'; @@ -710,6 +737,8 @@ 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: '{}'}) @@ -720,6 +749,58 @@ 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.'); + }); } }); }); @@ -744,8 +825,8 @@ table.addEventListener('change', function (e) { function bindInlineCreateForms() { var btnTicket = document.getElementById('rcm_ticket_save'); var btnRemark = document.getElementById('rcm_remark_save'); - var tCode = document.getElementById('rcm_ticket_code'); -var tStatus = document.getElementById('rcm_ticket_status'); + var tDesc = document.getElementById('rcm_ticket_description'); + var tStatus = document.getElementById('rcm_ticket_status'); var rBody = document.getElementById('rcm_remark_body'); var rStatus = document.getElementById('rcm_remark_status'); @@ -757,8 +838,8 @@ var tStatus = document.getElementById('rcm_ticket_status'); function setDisabled(disabled) { if (btnTicket) btnTicket.disabled = disabled; if (btnRemark) btnRemark.disabled = disabled; - if (tCode) tCode.disabled = disabled; -if (rBody) rBody.disabled = disabled; + if (tDesc) tDesc.disabled = disabled; + if (rBody) rBody.disabled = disabled; } window.__rcmSetCreateDisabled = setDisabled; @@ -768,25 +849,15 @@ if (rBody) rBody.disabled = disabled; btnTicket.addEventListener('click', function () { if (!currentRunId) { alert('Select a run first.'); return; } clearStatus(); - 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; - } + var description = tDesc ? tDesc.value : ''; if (tStatus) tStatus.textContent = 'Saving...'; apiJson('/api/tickets', { method: 'POST', - body: JSON.stringify({job_run_id: currentRunId, ticket_code: ticket_code}) + body: JSON.stringify({job_run_id: currentRunId, description: description}) }) .then(function () { - if (tCode) tCode.value = ''; -if (tStatus) tStatus.textContent = ''; + if (tDesc) tDesc.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 32cd071..74b3ae5 100644 --- a/containers/backupchecks/src/templates/main/ticket_detail.html +++ b/containers/backupchecks/src/templates/main/ticket_detail.html @@ -17,16 +17,19 @@ {% 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 33de980..61d6b4e 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 + View / Edit {% if t.active and t.job_id %} Job page {% endif %} @@ -144,7 +144,7 @@ {{ r.start_date }} {{ r.resolved_at }} - View + View / Edit {% if r.active and r.job_id %} Job page {% endif %}