Auto-commit local changes before build (2026-01-06 15:04:51)

This commit is contained in:
Ivo Oskamp 2026-01-06 15:04:51 +01:00
parent 9a42df3dd3
commit 7bfde72f4d
11 changed files with 121 additions and 270 deletions

View File

@ -1 +1 @@
v20260106-16-reset v20260106-17-jobrun-popup-objects-restore

View File

@ -1,5 +1,6 @@
from .routes_shared import * # noqa: F401,F403 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/<int:run_id>/alerts") @main_bp.route("/api/job-runs/<int:run_id>/alerts")
@login_required @login_required
@ -178,7 +179,7 @@ def api_tickets():
return jsonify({"status": "error", "message": "Forbidden."}), 403 return jsonify({"status": "error", "message": "Forbidden."}), 403
payload = request.get_json(silent=True) or {} 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: try:
run_id = int(payload.get("job_run_id") or 0) run_id = int(payload.get("job_run_id") or 0)
except Exception: except Exception:
@ -194,10 +195,21 @@ def api_tickets():
job = Job.query.get(run.job_id) if run else None job = Job.query.get(run.job_id) if run else None
now = datetime.utcnow() 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 = Ticket(
ticket_code=code, ticket_code=ticket_code,
title=None, title=None,
description=description, description=description,
active_from_date=_to_amsterdam_date(run.run_at) or _to_amsterdam_date(now) or now.date(), 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 @login_required
@roles_required("admin", "operator", "viewer") @roles_required("admin", "operator", "viewer")
def api_ticket_update(ticket_id: int): def api_ticket_update(ticket_id: int):
if get_active_role() not in ("admin", "operator"): # Editing tickets is not allowed. Resolve the old ticket and create a new one instead.
return jsonify({"status": "error", "message": "Forbidden."}), 403 return jsonify({"status": "error", "message": "Ticket editing is disabled. Resolve the old ticket and create a new one."}), 405
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/<int:ticket_id>/resolve", methods=["POST"]) @main_bp.route("/api/tickets/<int:ticket_id>/resolve", methods=["POST"])
@ -420,21 +419,8 @@ def api_remarks():
@login_required @login_required
@roles_required("admin", "operator", "viewer") @roles_required("admin", "operator", "viewer")
def api_remark_update(remark_id: int): def api_remark_update(remark_id: int):
if get_active_role() not in ("admin", "operator"): # Editing remarks is not allowed. Resolve the old remark and create a new one instead.
return jsonify({"status": "error", "message": "Forbidden."}), 403 return jsonify({"status": "error", "message": "Remark editing is disabled. Resolve the old remark and create a new one."}), 405
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/<int:remark_id>/resolve", methods=["POST"]) @main_bp.route("/api/remarks/<int:remark_id>/resolve", methods=["POST"])

View File

@ -1,23 +1,13 @@
from .routes_shared import * # noqa: F401,F403 from .routes_shared import * # noqa: F401,F403
from .routes_shared import _format_datetime from .routes_shared import _format_datetime
@main_bp.route("/remarks/<int:remark_id>", methods=["GET", "POST"]) @main_bp.route("/remarks/<int:remark_id>", methods=["GET"])
@login_required @login_required
@roles_required("admin", "operator", "viewer") @roles_required("admin", "operator", "viewer")
def remark_detail(remark_id: int): def remark_detail(remark_id: int):
remark = Remark.query.get_or_404(remark_id) remark = Remark.query.get_or_404(remark_id)
if request.method == "POST": # Remark editing is disabled. Resolve the old remark and create a new one instead.
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() scopes = RemarkScope.query.filter(RemarkScope.remark_id == remark.id).order_by(RemarkScope.id.asc()).all()

View File

@ -270,23 +270,13 @@ def tickets_page():
) )
@main_bp.route("/tickets/<int:ticket_id>", methods=["GET", "POST"]) @main_bp.route("/tickets/<int:ticket_id>", methods=["GET"])
@login_required @login_required
@roles_required("admin", "operator", "viewer") @roles_required("admin", "operator", "viewer")
def ticket_detail(ticket_id: int): def ticket_detail(ticket_id: int):
ticket = Ticket.query.get_or_404(ticket_id) ticket = Ticket.query.get_or_404(ticket_id)
if request.method == "POST": # Ticket editing is disabled. Resolve the old ticket and create a new one instead.
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
scopes = TicketScope.query.filter(TicketScope.ticket_id == ticket.id).order_by(TicketScope.id.asc()).all() scopes = TicketScope.query.filter(TicketScope.ticket_id == ticket.id).order_by(TicketScope.id.asc()).all()

View File

@ -79,7 +79,9 @@ def _extract_configuration_job_overall_message(html: str) -> Optional[str]:
for line in text.split("\n"): for line in text.split("\n"):
# Example: # Example:
# 26-12-2025 10:00:23 Warning Skipping server certificate backup because encryption is disabled # 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) wanted_lines.append(line)
if not wanted_lines: 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 # Keep detailed overall message for non-success states, and always keep
# the "Processing <object>" marker when present (used for overrides/rules). # the "Processing <object>" 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 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 result["overall_message"] = overall_message
return True, result, objects return True, result, objects

View File

@ -200,9 +200,9 @@
<button type="button" class="btn btn-sm btn-outline-primary" id="dj_ticket_save">Add</button> <button type="button" class="btn btn-sm btn-outline-primary" id="dj_ticket_save">Add</button>
</div> </div>
<div class="mt-2"> <div class="mt-2">
<textarea class="form-control form-control-sm" id="dj_ticket_description" rows="2" placeholder="Description (optional)"></textarea> <input class="form-control form-control-sm" id="dj_ticket_code" type="text" placeholder="Ticket number (e.g., T20260106.0001)" />
</div> </div>
<div class="mt-2 small text-muted" id="dj_ticket_status"></div> <div class="mt-2 small text-muted" id="dj_ticket_status"></div>
</div> </div>
</div> </div>
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
@ -358,25 +358,11 @@
'<span class="fw-semibold">' + escapeHtml(t.ticket_code || '') + '</span>' + '<span class="fw-semibold">' + escapeHtml(t.ticket_code || '') + '</span>' +
'<span class="ms-2 badge ' + (t.resolved_at ? 'bg-secondary' : 'bg-warning text-dark') + '">' + status + '</span>' + '<span class="ms-2 badge ' + (t.resolved_at ? 'bg-secondary' : 'bg-warning text-dark') + '">' + status + '</span>' +
'</div>' + '</div>' +
(t.description ? ('<div class="small text-muted mt-1">' + escapeHtml(t.description) + '</div>') : '') +
'</div>' + '</div>' +
'<div class="d-flex gap-1 flex-shrink-0">' + '<div class="d-flex gap-1 flex-shrink-0">' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-action="toggle-edit-ticket" data-id="' + t.id + '" ' + (t.resolved_at ? 'disabled' : '') + '>Edit</button>' +
'<button type="button" class="btn btn-sm btn-outline-success" data-action="resolve-ticket" data-id="' + t.id + '" ' + (t.resolved_at ? 'disabled' : '') + '>Resolve</button>' + '<button type="button" class="btn btn-sm btn-outline-success" data-action="resolve-ticket" data-id="' + t.id + '" ' + (t.resolved_at ? 'disabled' : '') + '>Resolve</button>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div class="mt-2" data-edit="ticket" style="display:none;">' +
'<div class="row g-2">' +
'<div class="col-12">' +
'<textarea class="form-control form-control-sm" data-field="description" rows="2" placeholder="Description (optional)">' + escapeHtml(t.description || '') + '</textarea>' +
'</div>' +
'<div class="col-12 d-flex gap-2">' +
'<button type="button" class="btn btn-sm btn-primary" data-action="save-ticket" data-id="' + t.id + '">Save</button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-action="cancel-edit" data-id="' + t.id + '">Cancel</button>' +
'<div class="small text-muted align-self-center" data-field="status"></div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>'; '</div>';
}); });
html += '</div></div>'; html += '</div></div>';
@ -397,22 +383,9 @@
(r.body ? ('<div class="small text-muted mt-1">' + escapeHtml(r.body) + '</div>') : '') + (r.body ? ('<div class="small text-muted mt-1">' + escapeHtml(r.body) + '</div>') : '') +
'</div>' + '</div>' +
'<div class="d-flex gap-1 flex-shrink-0">' + '<div class="d-flex gap-1 flex-shrink-0">' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-action="toggle-edit-remark" data-id="' + r.id + '" ' + (r.resolved_at ? 'disabled' : '') + '>Edit</button>' +
'<button type="button" class="btn btn-sm btn-outline-success" data-action="resolve-remark" data-id="' + r.id + '" ' + (r.resolved_at ? 'disabled' : '') + '>Resolve</button>' + '<button type="button" class="btn btn-sm btn-outline-success" data-action="resolve-remark" data-id="' + r.id + '" ' + (r.resolved_at ? 'disabled' : '') + '>Resolve</button>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div class="mt-2" data-edit="remark" style="display:none;">' +
'<div class="row g-2">' +
'<div class="col-12">' +
'<textarea class="form-control form-control-sm" data-field="body" rows="2" placeholder="Body (required)">' + escapeHtml(r.body || '') + '</textarea>' +
'</div>' +
'<div class="col-12 d-flex gap-2">' +
'<button type="button" class="btn btn-sm btn-primary" data-action="save-remark" data-id="' + r.id + '">Save</button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-action="cancel-edit" data-id="' + r.id + '">Cancel</button>' +
'<div class="small text-muted align-self-center" data-field="status"></div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>'; '</div>';
}); });
html += '</div></div>'; html += '</div></div>';
@ -427,8 +400,6 @@
var id = btn.getAttribute('data-id'); var id = btn.getAttribute('data-id');
if (!action || !id) return; if (!action || !id) return;
var wrapper = btn.closest('[data-alert-type]');
if (action === 'resolve-ticket') { if (action === 'resolve-ticket') {
if (!confirm('Mark ticket as resolved?')) return; if (!confirm('Mark ticket as resolved?')) return;
apiJson('/api/tickets/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'}) apiJson('/api/tickets/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'})
@ -439,59 +410,6 @@
apiJson('/api/remarks/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'}) apiJson('/api/remarks/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'})
.then(function () { loadAlerts(currentRunId); }) .then(function () { loadAlerts(currentRunId); })
.catch(function (e) { alert(e.message || 'Failed.'); }); .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() { function bindInlineCreateForms() {
var btnTicket = document.getElementById('dj_ticket_save'); var btnTicket = document.getElementById('dj_ticket_save');
var btnRemark = document.getElementById('dj_remark_save'); var btnRemark = document.getElementById('dj_remark_save');
var tDesc = document.getElementById('dj_ticket_description'); var tCode = document.getElementById('dj_ticket_code');
var tStatus = document.getElementById('dj_ticket_status'); var tStatus = document.getElementById('dj_ticket_status');
var rBody = document.getElementById('dj_remark_body'); var rBody = document.getElementById('dj_remark_body');
var rStatus = document.getElementById('dj_remark_status'); var rStatus = document.getElementById('dj_remark_status');
@ -541,8 +459,8 @@
function setDisabled(disabled) { function setDisabled(disabled) {
if (btnTicket) btnTicket.disabled = disabled; if (btnTicket) btnTicket.disabled = disabled;
if (btnRemark) btnRemark.disabled = disabled; if (btnRemark) btnRemark.disabled = disabled;
if (tDesc) tDesc.disabled = disabled; if (tCode) tCode.disabled = disabled;
if (rBody) rBody.disabled = disabled; if (rBody) rBody.disabled = disabled;
} }
window.__djSetCreateDisabled = setDisabled; window.__djSetCreateDisabled = setDisabled;
@ -552,15 +470,25 @@
btnTicket.addEventListener('click', function () { btnTicket.addEventListener('click', function () {
if (!currentRunId) { alert('Select a run first.'); return; } if (!currentRunId) { alert('Select a run first.'); return; }
clearStatus(); 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...'; if (tStatus) tStatus.textContent = 'Saving...';
apiJson('/api/tickets', { apiJson('/api/tickets', {
method: 'POST', method: 'POST',
body: JSON.stringify({job_run_id: currentRunId, description: description}) body: JSON.stringify({job_run_id: currentRunId, ticket_code: ticket_code})
}) })
.then(function () { .then(function () {
if (tDesc) tDesc.value = ''; if (tCode) tCode.value = '';
if (tStatus) tStatus.textContent = ''; if (tStatus) tStatus.textContent = '';
loadAlerts(currentRunId); loadAlerts(currentRunId);
}) })
.catch(function (e) { .catch(function (e) {

View File

@ -274,7 +274,7 @@
"</body></html>" "</body></html>"
); );
} }
function renderObjects(objects) { function renderObjects(objects) {
var container = document.getElementById("run_msg_objects_container"); var container = document.getElementById("run_msg_objects_container");
if (!container) return; if (!container) return;
@ -283,16 +283,36 @@ function renderObjects(objects) {
return; 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 = "<div class=\"table-responsive\"><table class=\"table table-sm table-bordered mb-0\">"; 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>"; html += "<thead><tr><th>Object</th><th>Type</th><th>Status</th><th>Error</th></tr></thead><tbody>";
for (var i = 0; i < objects.length; i++) { for (var i = 0; i < sorted.length; i++) {
var o = objects[i] || {}; var o = sorted[i] || {};
html += "<tr>"; html += "<tr>";
html += "<td>" + (o.name || "") + "</td>"; html += "<td>" + escapeHtml(o.name || "") + "</td>";
html += "<td>" + (o.type || "") + "</td>"; html += "<td>" + escapeHtml(o.type || "") + "</td>";
var d = statusDotClass(o.status); var d = statusDotClass(o.status);
html += "<td class=\"status-text " + statusClass(o.status) + "\">" + (d ? ('<span class=\\\"status-dot ' + d + ' me-2\\\" aria-hidden=\\\"true\\\"></span>') : '') + escapeHtml(o.status || "") + "</td>"; html += "<td class=\"status-text " + statusClass(o.status) + "\">" +
html += "<td>" + (o.error_message || "") + "</td>"; (d ? ("<span class=\"status-dot " + d + " me-2\" aria-hidden=\"true\"></span>") : "") +
escapeHtml(o.status || "") +
"</td>";
html += "<td>" + escapeHtml(o.error_message || "") + "</td>";
html += "</tr>"; html += "</tr>";
} }
html += "</tbody></table></div>"; html += "</tbody></table></div>";

View File

@ -16,19 +16,19 @@
{% endif %} {% endif %}
</div> </div>
<form method="post" class="row g-3"> <div class="col-12"> <div class="row g-3"> <div class="col-12">
<label class="form-label">Body</label> <label class="form-label">Body</label>
<textarea class="form-control" name="body" rows="6">{{ remark.body or '' }}</textarea> <div class="form-control-plaintext border rounded p-2" style="min-height: 7rem; white-space: pre-wrap;">{{ remark.body or '' }}</div>
</div> </div>
{% if active_role in ['admin','operator'] %} {% if active_role in ['admin','operator'] %}
<div class="col-12"> <div class="col-12">
<button class="btn btn-primary" type="submit">Save</button>
{% if not remark.resolved_at %} {% if not remark.resolved_at %}
<button class="btn btn-outline-success" type="button" onclick="if(confirm('Mark remark as resolved?')){fetch('{{ url_for('main.api_remark_resolve', remark_id=remark.id) }}',{method:'POST'}).then(()=>location.reload());}">Resolve</button> <button class="btn btn-outline-success" type="button" onclick="if(confirm('Mark remark as resolved?')){fetch('{{ url_for('main.api_remark_resolve', remark_id=remark.id) }}',{method:'POST'}).then(()=>location.reload());}">Resolve</button>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
</form> </div>
</div> </div>
</div> </div>

View File

@ -205,9 +205,9 @@
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_ticket_save">Add</button> <button type="button" class="btn btn-sm btn-outline-primary" id="rcm_ticket_save">Add</button>
</div> </div>
<div class="mt-2"> <div class="mt-2">
<textarea class="form-control form-control-sm" id="rcm_ticket_description" rows="2" placeholder="Description (optional)"></textarea> <input class="form-control form-control-sm" id="rcm_ticket_code" type="text" placeholder="Ticket number (e.g., T20260106.0001)" />
</div> </div>
<div class="mt-2 small text-muted" id="rcm_ticket_status"></div> <div class="mt-2 small text-muted" id="rcm_ticket_status"></div>
</div> </div>
</div> </div>
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
@ -669,25 +669,11 @@ table.addEventListener('change', function (e) {
'<span class="fw-semibold">' + escapeHtml(t.ticket_code || '') + '</span>' + '<span class="fw-semibold">' + escapeHtml(t.ticket_code || '') + '</span>' +
'<span class="ms-2 badge ' + (t.resolved_at ? 'bg-secondary' : 'bg-warning text-dark') + '">' + status + '</span>' + '<span class="ms-2 badge ' + (t.resolved_at ? 'bg-secondary' : 'bg-warning text-dark') + '">' + status + '</span>' +
'</div>' + '</div>' +
(t.description ? ('<div class="small text-muted mt-1">' + escapeHtml(t.description) + '</div>') : '') +
'</div>' + '</div>' +
'<div class="d-flex gap-1 flex-shrink-0">' + '<div class="d-flex gap-1 flex-shrink-0">' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-action="toggle-edit-ticket" data-id="' + t.id + '" ' + (t.resolved_at ? 'disabled' : '') + '>Edit</button>' +
'<button type="button" class="btn btn-sm btn-outline-success" data-action="resolve-ticket" data-id="' + t.id + '" ' + (t.resolved_at ? 'disabled' : '') + '>Resolve</button>' + '<button type="button" class="btn btn-sm btn-outline-success" data-action="resolve-ticket" data-id="' + t.id + '" ' + (t.resolved_at ? 'disabled' : '') + '>Resolve</button>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div class="mt-2" data-edit="ticket" style="display:none;">' +
'<div class="row g-2">' +
'<div class="col-12">' +
'<textarea class="form-control form-control-sm" data-field="description" rows="2" placeholder="Description (optional)">' + escapeHtml(t.description || '') + '</textarea>' +
'</div>' +
'<div class="col-12 d-flex gap-2">' +
'<button type="button" class="btn btn-sm btn-primary" data-action="save-ticket" data-id="' + t.id + '">Save</button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-action="cancel-edit" data-id="' + t.id + '">Cancel</button>' +
'<div class="small text-muted align-self-center" data-field="status"></div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>'; '</div>';
}); });
html += '</div></div>'; html += '</div></div>';
@ -696,34 +682,21 @@ table.addEventListener('change', function (e) {
if (remarks.length) { if (remarks.length) {
html += '<div class="mb-2"><strong>Remarks</strong><div class="mt-1">'; html += '<div class="mb-2"><strong>Remarks</strong><div class="mt-1">';
remarks.forEach(function (r) { remarks.forEach(function (r) {
var status2 = r.resolved_at ? 'Resolved' : 'Active'; var status = r.resolved_at ? 'Resolved' : 'Active';
html += '<div class="mb-2 border rounded p-2" data-alert-type="remark" data-id="' + r.id + '">' + html += '<div class="mb-2 border rounded p-2" data-alert-type="remark" data-id="' + r.id + '">' +
'<div class="d-flex align-items-start justify-content-between gap-2">' + '<div class="d-flex align-items-start justify-content-between gap-2">' +
'<div class="flex-grow-1 min-w-0">' + '<div class="flex-grow-1 min-w-0">' +
'<div class="text-truncate">' + '<div class="text-truncate">' +
'<span class="me-1" title="Remark">💬</span>' + '<span class="me-1" title="Remark">💬</span>' +
'<span class="fw-semibold">Remark</span>' + '<span class="fw-semibold">Remark</span>' +
'<span class="ms-2 badge ' + (r.resolved_at ? 'bg-secondary' : 'bg-warning text-dark') + '">' + status2 + '</span>' + '<span class="ms-2 badge ' + (r.resolved_at ? 'bg-secondary' : 'bg-warning text-dark') + '">' + status + '</span>' +
'</div>' + '</div>' +
(r.body ? ('<div class="small text-muted mt-1">' + escapeHtml(r.body) + '</div>') : '') + (r.body ? ('<div class="small text-muted mt-1">' + escapeHtml(r.body) + '</div>') : '') +
'</div>' + '</div>' +
'<div class="d-flex gap-1 flex-shrink-0">' + '<div class="d-flex gap-1 flex-shrink-0">' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-action="toggle-edit-remark" data-id="' + r.id + '" ' + (r.resolved_at ? 'disabled' : '') + '>Edit</button>' +
'<button type="button" class="btn btn-sm btn-outline-success" data-action="resolve-remark" data-id="' + r.id + '" ' + (r.resolved_at ? 'disabled' : '') + '>Resolve</button>' + '<button type="button" class="btn btn-sm btn-outline-success" data-action="resolve-remark" data-id="' + r.id + '" ' + (r.resolved_at ? 'disabled' : '') + '>Resolve</button>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div class="mt-2" data-edit="remark" style="display:none;">' +
'<div class="row g-2">' +
'<div class="col-12">' +
'<textarea class="form-control form-control-sm" data-field="body" rows="2" placeholder="Body (required)">' + escapeHtml(r.body || '') + '</textarea>' +
'</div>' +
'<div class="col-12 d-flex gap-2">' +
'<button type="button" class="btn btn-sm btn-primary" data-action="save-remark" data-id="' + r.id + '">Save</button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-action="cancel-edit" data-id="' + r.id + '">Cancel</button>' +
'<div class="small text-muted align-self-center" data-field="status"></div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>'; '</div>';
}); });
html += '</div></div>'; html += '</div></div>';
@ -737,8 +710,6 @@ table.addEventListener('change', function (e) {
var action = btn.getAttribute('data-action'); var action = btn.getAttribute('data-action');
var id = btn.getAttribute('data-id'); var id = btn.getAttribute('data-id');
if (!action || !id) return; if (!action || !id) return;
var wrapper = btn.closest('[data-alert-type]');
if (action === 'resolve-ticket') { if (action === 'resolve-ticket') {
if (!confirm('Mark ticket as resolved?')) return; if (!confirm('Mark ticket as resolved?')) return;
apiJson('/api/tickets/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'}) 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: '{}'}) apiJson('/api/remarks/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'})
.then(function () { loadAlerts(currentRunId); }) .then(function () { loadAlerts(currentRunId); })
.catch(function (e) { alert(e.message || 'Failed.'); }); .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() { function bindInlineCreateForms() {
var btnTicket = document.getElementById('rcm_ticket_save'); var btnTicket = document.getElementById('rcm_ticket_save');
var btnRemark = document.getElementById('rcm_remark_save'); var btnRemark = document.getElementById('rcm_remark_save');
var tDesc = document.getElementById('rcm_ticket_description'); var tCode = document.getElementById('rcm_ticket_code');
var tStatus = document.getElementById('rcm_ticket_status'); var tStatus = document.getElementById('rcm_ticket_status');
var rBody = document.getElementById('rcm_remark_body'); var rBody = document.getElementById('rcm_remark_body');
var rStatus = document.getElementById('rcm_remark_status'); var rStatus = document.getElementById('rcm_remark_status');
@ -838,8 +757,8 @@ table.addEventListener('change', function (e) {
function setDisabled(disabled) { function setDisabled(disabled) {
if (btnTicket) btnTicket.disabled = disabled; if (btnTicket) btnTicket.disabled = disabled;
if (btnRemark) btnRemark.disabled = disabled; if (btnRemark) btnRemark.disabled = disabled;
if (tDesc) tDesc.disabled = disabled; if (tCode) tCode.disabled = disabled;
if (rBody) rBody.disabled = disabled; if (rBody) rBody.disabled = disabled;
} }
window.__rcmSetCreateDisabled = setDisabled; window.__rcmSetCreateDisabled = setDisabled;
@ -849,15 +768,25 @@ table.addEventListener('change', function (e) {
btnTicket.addEventListener('click', function () { btnTicket.addEventListener('click', function () {
if (!currentRunId) { alert('Select a run first.'); return; } if (!currentRunId) { alert('Select a run first.'); return; }
clearStatus(); 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...'; if (tStatus) tStatus.textContent = 'Saving...';
apiJson('/api/tickets', { apiJson('/api/tickets', {
method: 'POST', method: 'POST',
body: JSON.stringify({job_run_id: currentRunId, description: description}) body: JSON.stringify({job_run_id: currentRunId, ticket_code: ticket_code})
}) })
.then(function () { .then(function () {
if (tDesc) tDesc.value = ''; if (tCode) tCode.value = '';
if (tStatus) tStatus.textContent = ''; if (tStatus) tStatus.textContent = '';
loadAlerts(currentRunId); loadAlerts(currentRunId);
}) })
.catch(function (e) { .catch(function (e) {

View File

@ -17,19 +17,16 @@
{% endif %} {% endif %}
</div> </div>
<form method="post" class="row g-3"> <div class="col-12"> <div class="row g-3">
<label class="form-label">Description</label>
<textarea class="form-control" name="description" rows="5">{{ ticket.description or '' }}</textarea>
</div>
{% if active_role in ['admin','operator'] %} {% if active_role in ['admin','operator'] %}
<div class="col-12"> <div class="col-12">
<button class="btn btn-primary" type="submit">Save</button>
{% if not ticket.resolved_at %} {% if not ticket.resolved_at %}
<button class="btn btn-outline-success" type="button" onclick="if(confirm('Mark ticket as resolved?')){fetch('{{ url_for('main.api_ticket_resolve', ticket_id=ticket.id) }}',{method:'POST'}).then(()=>location.reload());}">Resolve</button> <button class="btn btn-outline-success" type="button" onclick="if(confirm('Mark ticket as resolved?')){fetch('{{ url_for('main.api_ticket_resolve', ticket_id=ticket.id) }}',{method:'POST'}).then(()=>location.reload());}">Resolve</button>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
</form> </div>
</div> </div>
</div> </div>

View File

@ -45,7 +45,7 @@
<div class="col-auto" style="min-width: 260px;"> <div class="col-auto" style="min-width: 260px;">
<label class="form-label" for="flt_q">Search</label> <label class="form-label" for="flt_q">Search</label>
<input class="form-control" id="flt_q" name="q" value="{{ q }}" placeholder="ticket code / description / job" /> <input class="form-control" id="flt_q" name="q" value="{{ q }}" placeholder="ticket code / job" />
</div> </div>
<div class="col-auto"> <div class="col-auto">
@ -89,7 +89,7 @@
<td class="text-nowrap">{{ t.start_date }}</td> <td class="text-nowrap">{{ t.start_date }}</td>
<td class="text-nowrap">{{ t.resolved_at }}</td> <td class="text-nowrap">{{ t.resolved_at }}</td>
<td class="text-nowrap"> <td class="text-nowrap">
<a class="btn btn-sm btn-outline-primary" href="{{ url_for('main.ticket_detail', ticket_id=t.id) }}">View / Edit</a> <a class="btn btn-sm btn-outline-primary" href="{{ url_for('main.ticket_detail', ticket_id=t.id) }}">View</a>
{% if t.active and t.job_id %} {% if t.active and t.job_id %}
<a class="btn btn-sm btn-outline-secondary ms-1" href="{{ url_for('main.job_detail', job_id=t.job_id) }}">Job page</a> <a class="btn btn-sm btn-outline-secondary ms-1" href="{{ url_for('main.job_detail', job_id=t.job_id) }}">Job page</a>
{% endif %} {% endif %}
@ -144,7 +144,7 @@
<td class="text-nowrap">{{ r.start_date }}</td> <td class="text-nowrap">{{ r.start_date }}</td>
<td class="text-nowrap">{{ r.resolved_at }}</td> <td class="text-nowrap">{{ r.resolved_at }}</td>
<td class="text-nowrap"> <td class="text-nowrap">
<a class="btn btn-sm btn-outline-primary" href="{{ url_for('main.remark_detail', remark_id=r.id) }}">View / Edit</a> <a class="btn btn-sm btn-outline-primary" href="{{ url_for('main.remark_detail', remark_id=r.id) }}">View</a>
{% if r.active and r.job_id %} {% if r.active and r.job_id %}
<a class="btn btn-sm btn-outline-secondary ms-1" href="{{ url_for('main.job_detail', job_id=r.job_id) }}">Job page</a> <a class="btn btn-sm btn-outline-secondary ms-1" href="{{ url_for('main.job_detail', job_id=r.job_id) }}">Job page</a>
{% endif %} {% endif %}