v20260106-18-Reset #52

Merged
ivooskamp merged 2 commits from v20260106-18-Reset into main 2026-01-13 11:20:25 +01:00
25 changed files with 280 additions and 687 deletions
Showing only changes of commit 39b4ec6064 - Show all commits

View File

@ -1 +1 @@
v20260106-17-jobrun-popup-objects-restore v20260106-18-Reset

View File

@ -1,6 +1,5 @@
from .routes_shared import * # noqa: F401,F403 from .routes_shared import * # noqa: F401,F403
from .routes_shared import _format_datetime, _get_ui_timezone_name, _to_amsterdam_date from .routes_shared import _format_datetime, _get_ui_timezone_name, _next_ticket_code, _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
@ -179,7 +178,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 = None # Description removed from New ticket UI; use remarks for additional context description = (payload.get("description") or "").strip() or None
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:
@ -195,21 +194,10 @@ 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()
ticket_code = (payload.get("ticket_code") or "").strip().upper() code = _next_ticket_code(now)
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=ticket_code, ticket_code=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(),
@ -262,8 +250,21 @@ 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):
# Editing tickets is not allowed. Resolve the old ticket and create a new one instead. if get_active_role() not in ("admin", "operator"):
return jsonify({"status": "error", "message": "Ticket editing is disabled. Resolve the old ticket and create a new one."}), 405 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/<int:ticket_id>/resolve", methods=["POST"]) @main_bp.route("/api/tickets/<int:ticket_id>/resolve", methods=["POST"])
@ -419,8 +420,21 @@ 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):
# Editing remarks is not allowed. Resolve the old remark and create a new one instead. if get_active_role() not in ("admin", "operator"):
return jsonify({"status": "error", "message": "Remark editing is disabled. Resolve the old remark and create a new one."}), 405 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/<int:remark_id>/resolve", methods=["POST"]) @main_bp.route("/api/remarks/<int:remark_id>/resolve", methods=["POST"])

View File

@ -100,9 +100,6 @@ def customers_delete(customer_id: int):
customer = Customer.query.get_or_404(customer_id) customer = Customer.query.get_or_404(customer_id)
try: try:
# Prevent FK violations on older schemas and keep jobs for historical reporting.
# Jobs are not deleted when removing a customer; they are simply unlinked.
Job.query.filter_by(customer_id=customer.id).update({"customer_id": None})
db.session.delete(customer) db.session.delete(customer)
db.session.commit() db.session.commit()
flash("Customer deleted.", "success") flash("Customer deleted.", "success")

View File

@ -76,7 +76,6 @@ def daily_jobs():
jobs = ( jobs = (
Job.query.join(Customer, isouter=True) Job.query.join(Customer, isouter=True)
.filter(Job.archived.is_(False))
.order_by(Customer.name.asc().nullslast(), Job.backup_software.asc(), Job.backup_type.asc(), Job.job_name.asc()) .order_by(Customer.name.asc().nullslast(), Job.backup_software.asc(), Job.backup_type.asc(), Job.job_name.asc())
.all() .all()
) )

View File

@ -172,19 +172,6 @@ def feedback_detail(item_id: int):
resolved_by = User.query.get(item.resolved_by_user_id) resolved_by = User.query.get(item.resolved_by_user_id)
resolved_by_name = resolved_by.username if resolved_by else "" resolved_by_name = resolved_by.username if resolved_by else ""
replies = (
FeedbackReply.query.filter(FeedbackReply.feedback_item_id == item.id)
.order_by(FeedbackReply.created_at.asc())
.all()
)
reply_user_ids = sorted({int(r.user_id) for r in replies})
reply_users = (
User.query.filter(User.id.in_(reply_user_ids)).all() if reply_user_ids else []
)
reply_user_map = {int(u.id): (u.username or "") for u in reply_users}
return render_template( return render_template(
"main/feedback_detail.html", "main/feedback_detail.html",
item=item, item=item,
@ -192,42 +179,8 @@ def feedback_detail(item_id: int):
resolved_by_name=resolved_by_name, resolved_by_name=resolved_by_name,
vote_count=int(vote_count), vote_count=int(vote_count),
user_voted=bool(user_voted), user_voted=bool(user_voted),
replies=replies,
reply_user_map=reply_user_map,
) )
@main_bp.route("/feedback/<int:item_id>/reply", methods=["POST"])
@login_required
@roles_required("admin", "operator", "viewer")
def feedback_reply(item_id: int):
item = FeedbackItem.query.get_or_404(item_id)
if item.deleted_at is not None:
abort(404)
if (item.status or "").strip().lower() != "open":
flash("Only open feedback items can be replied to.", "warning")
return redirect(url_for("main.feedback_detail", item_id=item.id))
message = (request.form.get("message") or "").strip()
if not message:
flash("Reply message is required.", "danger")
return redirect(url_for("main.feedback_detail", item_id=item.id))
reply = FeedbackReply(
feedback_item_id=int(item.id),
user_id=int(current_user.id),
message=message,
created_at=datetime.utcnow(),
)
db.session.add(reply)
db.session.commit()
flash("Reply added.", "success")
return redirect(url_for("main.feedback_detail", item_id=item.id))
@main_bp.route("/feedback/<int:item_id>/vote", methods=["POST"]) @main_bp.route("/feedback/<int:item_id>/vote", methods=["POST"])
@login_required @login_required

View File

@ -69,8 +69,6 @@ def inbox():
has_prev=has_prev, has_prev=has_prev,
has_next=has_next, has_next=has_next,
customers=customer_rows, customers=customer_rows,
can_bulk_delete=(get_active_role() in ("admin", "operator")),
is_admin=(get_active_role() == "admin"),
) )
@ -298,62 +296,6 @@ def inbox_message_delete(message_id: int):
return redirect(url_for("main.inbox")) return redirect(url_for("main.inbox"))
@main_bp.post("/api/inbox/delete")
@login_required
@roles_required("admin", "operator")
def api_inbox_bulk_delete():
"""Bulk delete inbox messages (soft delete -> move to Deleted)."""
data = request.get_json(silent=True) or {}
message_ids = data.get("message_ids") or []
try:
message_ids = [int(x) for x in message_ids]
except Exception:
return jsonify({"status": "error", "message": "Invalid message_ids."}), 400
if not message_ids:
return jsonify({"status": "ok", "updated": 0, "skipped": 0, "missing": 0})
msgs = MailMessage.query.filter(MailMessage.id.in_(message_ids)).all()
msg_map = {int(m.id): m for m in msgs}
now = datetime.utcnow()
updated = 0
skipped = 0
missing = 0
for mid in message_ids:
msg = msg_map.get(int(mid))
if not msg:
missing += 1
continue
if getattr(msg, "location", "inbox") != "inbox":
skipped += 1
continue
if hasattr(msg, "location"):
msg.location = "deleted"
if hasattr(msg, "deleted_at"):
msg.deleted_at = now
if hasattr(msg, "deleted_by_user_id"):
msg.deleted_by_user_id = current_user.id
updated += 1
try:
db.session.commit()
except Exception as exc:
db.session.rollback()
_log_admin_event("inbox_bulk_delete_error", f"Failed to bulk delete inbox messages {message_ids}: {exc}")
return jsonify({"status": "error", "message": "Database error while deleting messages."}), 500
_log_admin_event("inbox_bulk_delete", f"Deleted inbox messages: {message_ids}")
return jsonify({"status": "ok", "updated": updated, "skipped": skipped, "missing": missing})
@main_bp.route("/inbox/deleted") @main_bp.route("/inbox/deleted")
@login_required @login_required
@roles_required("admin") @roles_required("admin")

View File

@ -16,7 +16,6 @@ def jobs():
# Join with customers for display # Join with customers for display
jobs = ( jobs = (
Job.query Job.query
.filter(Job.archived.is_(False))
.outerjoin(Customer, Customer.id == Job.customer_id) .outerjoin(Customer, Customer.id == Job.customer_id)
.add_columns( .add_columns(
Job.id, Job.id,
@ -56,89 +55,6 @@ def jobs():
) )
@main_bp.route("/jobs/<int:job_id>/archive", methods=["POST"])
@login_required
@roles_required("admin", "operator")
def archive_job(job_id: int):
job = Job.query.get_or_404(job_id)
if job.archived:
flash("Job is already archived.", "info")
return redirect(url_for("main.jobs"))
job.archived = True
job.archived_at = datetime.utcnow()
job.archived_by_user_id = current_user.id
db.session.commit()
try:
log_admin_event("job_archived", f"Archived job {job.id}", details=f"job_name={job.job_name}")
except Exception:
pass
flash("Job archived.", "success")
return redirect(url_for("main.jobs"))
@main_bp.route("/archived-jobs")
@login_required
@roles_required("admin")
def archived_jobs():
rows = (
Job.query
.filter(Job.archived.is_(True))
.outerjoin(Customer, Customer.id == Job.customer_id)
.add_columns(
Job.id,
Job.backup_software,
Job.backup_type,
Job.job_name,
Job.archived_at,
Customer.name.label("customer_name"),
)
.order_by(Customer.name.asc().nullslast(), Job.backup_software.asc(), Job.backup_type.asc(), Job.job_name.asc())
.all()
)
out = []
for row in rows:
out.append(
{
"id": row.id,
"customer_name": getattr(row, "customer_name", "") or "",
"backup_software": row.backup_software or "",
"backup_type": row.backup_type or "",
"job_name": row.job_name or "",
"archived_at": _format_datetime(row.archived_at),
}
)
return render_template("main/archived_jobs.html", jobs=out)
@main_bp.route("/jobs/<int:job_id>/unarchive", methods=["POST"])
@login_required
@roles_required("admin")
def unarchive_job(job_id: int):
job = Job.query.get_or_404(job_id)
if not job.archived:
flash("Job is not archived.", "info")
return redirect(url_for("main.archived_jobs"))
job.archived = False
job.archived_at = None
job.archived_by_user_id = None
db.session.commit()
try:
log_admin_event("job_unarchived", f"Unarchived job {job.id}", details=f"job_name={job.job_name}")
except Exception:
pass
flash("Job restored.", "success")
return redirect(url_for("main.archived_jobs"))
@main_bp.route("/jobs/<int:job_id>") @main_bp.route("/jobs/<int:job_id>")
@login_required @login_required
@roles_required("admin", "operator", "viewer") @roles_required("admin", "operator", "viewer")

View File

@ -1,13 +1,23 @@
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"]) @main_bp.route("/remarks/<int:remark_id>", methods=["GET", "POST"])
@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)
# 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() scopes = RemarkScope.query.filter(RemarkScope.remark_id == remark.id).order_by(RemarkScope.id.asc()).all()

View File

@ -331,17 +331,14 @@ def build_report_columns_meta():
def build_report_job_filters_meta(): def build_report_job_filters_meta():
"""Build job filter metadata for reporting UIs. """Build job filter metadata for reporting UIs.
Provides available backup_softwares and backup_types derived from jobs that have runs. Provides available backup_softwares and backup_types derived from active jobs.
""" """
# Distinct values across jobs with runs (exclude known informational jobs). # Distinct values across active jobs (exclude known informational jobs).
info_backup_types = {"license key"} info_backup_types = {"license key"}
# Distinct values across jobs that actually have at least one run.
rows = ( rows = (
db.session.query(Job.backup_software, Job.backup_type) db.session.query(Job.backup_software, Job.backup_type)
.select_from(Job) .filter(Job.active.is_(True))
.join(JobRun, JobRun.job_id == Job.id)
.distinct()
.all() .all()
) )

View File

@ -195,7 +195,7 @@ def run_checks_page():
) )
last_reviewed_map = {int(jid): (dt if dt else None) for jid, dt in last_reviewed_rows} last_reviewed_map = {int(jid): (dt if dt else None) for jid, dt in last_reviewed_rows}
jobs = Job.query.filter(Job.archived.is_(False)).all() jobs = Job.query.all()
today_local = _to_amsterdam_date(datetime.utcnow()) or datetime.utcnow().date() today_local = _to_amsterdam_date(datetime.utcnow()) or datetime.utcnow().date()
for job in jobs: for job in jobs:
@ -222,7 +222,6 @@ def run_checks_page():
) )
.select_from(Job) .select_from(Job)
.outerjoin(Customer, Customer.id == Job.customer_id) .outerjoin(Customer, Customer.id == Job.customer_id)
.filter(Job.archived.is_(False))
) )
# Runs to show in the overview: unreviewed (or all if admin toggle enabled) # Runs to show in the overview: unreviewed (or all if admin toggle enabled)

View File

@ -50,7 +50,6 @@ from ..models import (
RemarkJobRun, RemarkJobRun,
FeedbackItem, FeedbackItem,
FeedbackVote, FeedbackVote,
FeedbackReply,
NewsItem, NewsItem,
NewsRead, NewsRead,
ReportDefinition, ReportDefinition,

View File

@ -270,13 +270,23 @@ def tickets_page():
) )
@main_bp.route("/tickets/<int:ticket_id>", methods=["GET"]) @main_bp.route("/tickets/<int:ticket_id>", methods=["GET", "POST"])
@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)
# 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
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

@ -772,59 +772,17 @@ def run_migrations() -> None:
migrate_mail_objects_table() migrate_mail_objects_table()
migrate_object_persistence_tables() migrate_object_persistence_tables()
migrate_feedback_tables() migrate_feedback_tables()
migrate_feedback_replies_table()
migrate_tickets_active_from_date() migrate_tickets_active_from_date()
migrate_remarks_active_from_date() migrate_remarks_active_from_date()
migrate_overrides_match_columns() migrate_overrides_match_columns()
migrate_job_runs_review_tracking() migrate_job_runs_review_tracking()
migrate_job_runs_override_metadata() migrate_job_runs_override_metadata()
migrate_jobs_archiving()
migrate_news_tables() migrate_news_tables()
migrate_reporting_tables() migrate_reporting_tables()
migrate_reporting_report_config() migrate_reporting_report_config()
print("[migrations] All migrations completed.") print("[migrations] All migrations completed.")
def migrate_jobs_archiving() -> None:
"""Add archiving columns to jobs if missing.
Columns:
- jobs.archived (BOOLEAN NOT NULL DEFAULT FALSE)
- jobs.archived_at (TIMESTAMP NULL)
- jobs.archived_by_user_id (INTEGER NULL)
"""
table = "jobs"
try:
engine = db.get_engine()
except Exception as exc:
print(f"[migrations] Could not get engine for jobs archiving migration: {exc}")
return
inspector = inspect(engine)
try:
existing_columns = {col["name"] for col in inspector.get_columns(table)}
except Exception as exc:
print(f"[migrations] {table} table not found for jobs archiving migration, skipping: {exc}")
return
with engine.begin() as conn:
if "archived" not in existing_columns:
print('[migrations] Adding jobs.archived column...')
conn.execute(text('ALTER TABLE "jobs" ADD COLUMN archived BOOLEAN NOT NULL DEFAULT FALSE'))
if "archived_at" not in existing_columns:
print('[migrations] Adding jobs.archived_at column...')
conn.execute(text('ALTER TABLE "jobs" ADD COLUMN archived_at TIMESTAMP'))
if "archived_by_user_id" not in existing_columns:
print('[migrations] Adding jobs.archived_by_user_id column...')
conn.execute(text('ALTER TABLE "jobs" ADD COLUMN archived_by_user_id INTEGER'))
print("[migrations] migrate_jobs_archiving completed.")
def migrate_reporting_report_config() -> None: def migrate_reporting_report_config() -> None:
"""Add report_definitions.report_config column if missing. """Add report_definitions.report_config column if missing.
@ -979,40 +937,6 @@ def migrate_job_runs_review_tracking() -> None:
print("[migrations] migrate_job_runs_review_tracking completed.") print("[migrations] migrate_job_runs_review_tracking completed.")
def migrate_feedback_replies_table() -> None:
"""Ensure feedback reply table exists.
Table:
- feedback_replies (messages on open feedback items)
"""
engine = db.get_engine()
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS feedback_replies (
id SERIAL PRIMARY KEY,
feedback_item_id INTEGER NOT NULL REFERENCES feedback_items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
message TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_feedback_replies_item_created_at
ON feedback_replies (feedback_item_id, created_at);
"""
)
)
print("[migrations] Feedback replies table ensured.")
def migrate_tickets_active_from_date() -> None: def migrate_tickets_active_from_date() -> None:
"""Ensure tickets.active_from_date exists and is populated. """Ensure tickets.active_from_date exists and is populated.
@ -1172,39 +1096,6 @@ def migrate_object_persistence_tables() -> None:
''' '''
) )
) )
# Ensure existing installations also have ON DELETE CASCADE on customer_objects.customer_id.
# Older schemas created the FK without cascade, which blocks deleting customers.
conn.execute(
text(
'''
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.table_constraints tc
WHERE tc.table_name = 'customer_objects'
AND tc.constraint_type = 'FOREIGN KEY'
AND tc.constraint_name = 'customer_objects_customer_id_fkey'
) THEN
ALTER TABLE customer_objects
DROP CONSTRAINT customer_objects_customer_id_fkey;
END IF;
-- Recreate with cascade (idempotent via the drop above)
ALTER TABLE customer_objects
ADD CONSTRAINT customer_objects_customer_id_fkey
FOREIGN KEY (customer_id)
REFERENCES customers(id)
ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN
-- Constraint already exists with the correct name.
NULL;
END $$;
'''
)
)
conn.execute( conn.execute(
text( text(
'CREATE INDEX IF NOT EXISTS idx_customer_objects_customer_name ON customer_objects (customer_id, object_name)' 'CREATE INDEX IF NOT EXISTS idx_customer_objects_customer_name ON customer_objects (customer_id, object_name)'

View File

@ -196,12 +196,6 @@ class Job(db.Model):
auto_approve = db.Column(db.Boolean, nullable=False, default=True) auto_approve = db.Column(db.Boolean, nullable=False, default=True)
active = db.Column(db.Boolean, nullable=False, default=True) active = db.Column(db.Boolean, nullable=False, default=True)
# Archived jobs are excluded from Daily Jobs and Run Checks.
# JobRuns remain in the database and are still included in reporting.
archived = db.Column(db.Boolean, nullable=False, default=False)
archived_at = db.Column(db.DateTime, nullable=True)
archived_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column( updated_at = db.Column(
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
@ -494,20 +488,6 @@ class FeedbackVote(db.Model):
) )
class FeedbackReply(db.Model):
__tablename__ = "feedback_replies"
id = db.Column(db.Integer, primary_key=True)
feedback_item_id = db.Column(
db.Integer, db.ForeignKey("feedback_items.id", ondelete="CASCADE"), nullable=False
)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
message = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
class NewsItem(db.Model): class NewsItem(db.Model):
__tablename__ = "news_items" __tablename__ = "news_items"

View File

@ -79,9 +79,7 @@ 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
# 6-1-2026 10:00:16 Warning Skipping credentials 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):
# 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:
@ -713,20 +711,6 @@ def _strip_m365_combined_suffix(job_name: Optional[str]) -> Optional[str]:
return cleaned or None return cleaned or None
def _strip_full_suffix(job_name: Optional[str]) -> Optional[str]:
"""Remove a trailing "(Full)" suffix from a Veeam job name.
Some Veeam installations create separate emails where the job name is
suffixed with "(Full)" (e.g. "Backup VM DC01 (Full)"). Those should be
treated as the same logical job as the non-suffixed name.
"""
if not job_name:
return job_name
cleaned = re.sub(r"\s*\(\s*Full\s*\)\s*$", "", job_name, flags=re.IGNORECASE).strip()
return cleaned or None
def try_parse_veeam(msg: MailMessage) -> Tuple[bool, Dict, List[Dict]]: def try_parse_veeam(msg: MailMessage) -> Tuple[bool, Dict, List[Dict]]:
"""Try to parse a Veeam backup report mail. """Try to parse a Veeam backup report mail.
@ -882,10 +866,6 @@ def try_parse_veeam(msg: MailMessage) -> Tuple[bool, Dict, List[Dict]]:
# Do not let retry counters create distinct job names. # Do not let retry counters create distinct job names.
job_name = _strip_retry_suffix(job_name) job_name = _strip_retry_suffix(job_name)
# Veeam can append a "(Full)" suffix to the job name in some reports.
# Strip it so full/non-full mails map to the same logical job.
job_name = _strip_full_suffix(job_name)
# Veeam Backup for Microsoft 365 reports can add a "(Combined)" suffix. # Veeam Backup for Microsoft 365 reports can add a "(Combined)" suffix.
# Strip it so combined/non-combined mails map to the same job. # Strip it so combined/non-combined mails map to the same job.
if (backup_type or "") == "Veeam Backup for Microsoft 365": if (backup_type or "") == "Veeam Backup for Microsoft 365":
@ -905,7 +885,7 @@ def try_parse_veeam(msg: MailMessage) -> Tuple[bool, Dict, List[Dict]]:
result: Dict = { result: Dict = {
"backup_software": "Veeam", "backup_software": "Veeam",
"backup_type": backup_type, "backup_type": backup_type,
"job_name": job_name, "job_name": _strip_retry_suffix(job_name),
"overall_status": status_word, "overall_status": status_word,
} }
@ -932,17 +912,8 @@ 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 ( if status_word != "Success" or overall_message.lower().startswith("processing "):
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

@ -82,11 +82,6 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('main.jobs') }}">Jobs</a> <a class="nav-link" href="{{ url_for('main.jobs') }}">Jobs</a>
</li> </li>
{% if active_role == 'admin' %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.archived_jobs') }}">Archived Jobs</a>
</li>
{% endif %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('main.daily_jobs') }}">Daily Jobs</a> <a class="nav-link" href="{{ url_for('main.daily_jobs') }}">Daily Jobs</a>
</li> </li>

View File

@ -200,7 +200,7 @@
<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">
<input class="form-control form-control-sm" id="dj_ticket_code" type="text" placeholder="Ticket number (e.g., T20260106.0001)" /> <textarea class="form-control form-control-sm" id="dj_ticket_description" rows="2" placeholder="Description (optional)"></textarea>
</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>
@ -358,11 +358,25 @@
'<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>';
@ -383,9 +397,22 @@
(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>';
@ -400,6 +427,8 @@
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: '{}'})
@ -410,6 +439,59 @@
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.');
});
} }
}); });
}); });
@ -446,7 +528,7 @@
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 tCode = document.getElementById('dj_ticket_code'); var tDesc = document.getElementById('dj_ticket_description');
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');
@ -459,7 +541,7 @@ var tStatus = document.getElementById('dj_ticket_status');
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 (tCode) tCode.disabled = disabled; if (tDesc) tDesc.disabled = disabled;
if (rBody) rBody.disabled = disabled; if (rBody) rBody.disabled = disabled;
} }
@ -470,24 +552,14 @@ if (rBody) rBody.disabled = disabled;
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 ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : ''; var description = tDesc ? tDesc.value : '';
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, ticket_code: ticket_code}) body: JSON.stringify({job_run_id: currentRunId, description: description})
}) })
.then(function () { .then(function () {
if (tCode) tCode.value = ''; if (tDesc) tDesc.value = '';
if (tStatus) tStatus.textContent = ''; if (tStatus) tStatus.textContent = '';
loadAlerts(currentRunId); loadAlerts(currentRunId);
}) })

View File

@ -49,46 +49,6 @@
</div> </div>
</div> </div>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title mb-3">Replies</h5>
{% if replies %}
<div class="list-group list-group-flush">
{% for r in replies %}
<div class="list-group-item px-0">
<div class="d-flex justify-content-between align-items-start">
<strong>{{ reply_user_map.get(r.user_id, '') or ('User #' ~ r.user_id) }}</strong>
<span class="text-muted" style="font-size: 0.85rem;">
{{ r.created_at.strftime('%d-%m-%Y %H:%M:%S') if r.created_at else '' }}
</span>
</div>
<div style="white-space: pre-wrap;">{{ r.message }}</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-muted">No replies yet.</div>
{% endif %}
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title mb-3">Add reply</h5>
{% if item.status == 'open' %}
<form method="post" action="{{ url_for('main.feedback_reply', item_id=item.id) }}">
<div class="mb-2">
<textarea class="form-control" name="message" rows="4" required></textarea>
</div>
<button type="submit" class="btn btn-primary">Post reply</button>
</form>
{% else %}
<div class="text-muted">Replies can only be added while the item is open.</div>
{% endif %}
</div>
</div>
<div class="col-12 col-lg-4"> <div class="col-12 col-lg-4">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">

View File

@ -56,26 +56,10 @@
{{ pager("top", page, total_pages, has_prev, has_next) }} {{ 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"> <div class="table-responsive">
<table class="table table-sm table-hover align-middle" id="inboxTable"> <table class="table table-sm table-hover align-middle">
<thead class="table-light"> <thead class="table-light">
<tr> <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">From</th>
<th scope="col">Subject</th> <th scope="col">Subject</th>
<th scope="col">Date / time</th> <th scope="col">Date / time</th>
@ -91,11 +75,6 @@
{% if rows %} {% if rows %}
{% for row in rows %} {% for row in rows %}
<tr class="inbox-row" data-message-id="{{ row.id }}" style="cursor: pointer;"> <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.from_address }}</td>
<td>{{ row.subject }}</td> <td>{{ row.subject }}</td>
<td>{{ row.received_at }}</td> <td>{{ row.received_at }}</td>
@ -211,125 +190,6 @@
(function () { (function () {
var customers = {{ customers|tojson|safe }}; 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) { function wrapMailHtml(html) {

View File

@ -48,15 +48,9 @@
</div> </div>
{% if can_manage_jobs %} {% if can_manage_jobs %}
<div class="d-flex flex-wrap gap-2 mb-3"> <form method="post" action="{{ url_for('main.job_delete', job_id=job.id) }}" class="mb-3" onsubmit="return confirm('Are you sure you want to delete this job? Related mails will be returned to the Inbox.');">
<form method="post" action="{{ url_for('main.archive_job', job_id=job.id) }}" class="mb-0" onsubmit="return confirm('Archive this job? No new runs are expected and it will be removed from Daily Jobs and Run Checks.');">
<button type="submit" class="btn btn-outline-secondary">Archive</button>
</form>
<form method="post" action="{{ url_for('main.job_delete', job_id=job.id) }}" class="mb-0" onsubmit="return confirm('Are you sure you want to delete this job? Related mails will be returned to the Inbox.');">
<button type="submit" class="btn btn-outline-danger">Delete job</button> <button type="submit" class="btn btn-outline-danger">Delete job</button>
</form> </form>
</div>
{% endif %} {% endif %}
<h3 class="mt-4 mb-3">Job history</h3> <h3 class="mt-4 mb-3">Job history</h3>
@ -283,36 +277,16 @@
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 < sorted.length; i++) { for (var i = 0; i < objects.length; i++) {
var o = sorted[i] || {}; var o = objects[i] || {};
html += "<tr>"; html += "<tr>";
html += "<td>" + escapeHtml(o.name || "") + "</td>"; html += "<td>" + (o.name || "") + "</td>";
html += "<td>" + escapeHtml(o.type || "") + "</td>"; html += "<td>" + (o.type || "") + "</td>";
var d = statusDotClass(o.status); var d = statusDotClass(o.status);
html += "<td class=\"status-text " + statusClass(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>";
(d ? ("<span class=\"status-dot " + d + " me-2\" aria-hidden=\"true\"></span>") : "") + html += "<td>" + (o.error_message || "") + "</td>";
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

@ -15,7 +15,7 @@
<tbody> <tbody>
{% if jobs %} {% if jobs %}
{% for j in jobs %} {% for j in jobs %}
<tr class="job-row" data-href="{{ url_for('main.job_detail', job_id=j.id) }}" style="cursor: pointer;"> <tr style="cursor: pointer;" onclick="window.location='{{ url_for('main.job_detail', job_id=j.id) }}'">
<td>{{ j.customer_name }}</td> <td>{{ j.customer_name }}</td>
<td>{{ j.backup_software }}</td> <td>{{ j.backup_software }}</td>
<td>{{ j.backup_type }}</td> <td>{{ j.backup_type }}</td>
@ -32,24 +32,4 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<script>
(function () {
function onRowClick(e) {
// Don't navigate when clicking interactive elements inside the row.
if (e.target.closest('a, button, input, select, textarea, label, form')) {
return;
}
var href = this.getAttribute('data-href');
if (href) {
window.location.href = href;
}
}
document.querySelectorAll('tr.job-row[data-href]').forEach(function (row) {
row.addEventListener('click', onRowClick);
});
})();
</script>
{% endblock %} {% endblock %}

View File

@ -16,19 +16,19 @@
{% endif %} {% endif %}
</div> </div>
<div class="row g-3"> <div class="col-12"> <form method="post" class="row g-3"> <div class="col-12">
<label class="form-label">Body</label> <label class="form-label">Body</label>
<div class="form-control-plaintext border rounded p-2" style="min-height: 7rem; white-space: pre-wrap;">{{ remark.body or '' }}</div> <textarea class="form-control" name="body" rows="6">{{ remark.body or '' }}</textarea>
</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 %}
</div> </form>
</div> </div>
</div> </div>

View File

@ -205,7 +205,7 @@
<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">
<input class="form-control form-control-sm" id="rcm_ticket_code" type="text" placeholder="Ticket number (e.g., T20260106.0001)" /> <textarea class="form-control form-control-sm" id="rcm_ticket_description" rows="2" placeholder="Description (optional)"></textarea>
</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>
@ -669,11 +669,25 @@ 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>';
@ -682,21 +696,34 @@ 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 status = r.resolved_at ? 'Resolved' : 'Active'; var status2 = 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') + '">' + status + '</span>' + '<span class="ms-2 badge ' + (r.resolved_at ? 'bg-secondary' : 'bg-warning text-dark') + '">' + status2 + '</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>';
@ -710,6 +737,8 @@ 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: '{}'})
@ -720,6 +749,58 @@ 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.');
});
} }
}); });
}); });
@ -744,7 +825,7 @@ 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 tCode = document.getElementById('rcm_ticket_code'); var tDesc = document.getElementById('rcm_ticket_description');
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');
@ -757,7 +838,7 @@ var tStatus = document.getElementById('rcm_ticket_status');
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 (tCode) tCode.disabled = disabled; if (tDesc) tDesc.disabled = disabled;
if (rBody) rBody.disabled = disabled; if (rBody) rBody.disabled = disabled;
} }
@ -768,24 +849,14 @@ if (rBody) rBody.disabled = disabled;
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 ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : ''; var description = tDesc ? tDesc.value : '';
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, ticket_code: ticket_code}) body: JSON.stringify({job_run_id: currentRunId, description: description})
}) })
.then(function () { .then(function () {
if (tCode) tCode.value = ''; if (tDesc) tDesc.value = '';
if (tStatus) tStatus.textContent = ''; if (tStatus) tStatus.textContent = '';
loadAlerts(currentRunId); loadAlerts(currentRunId);
}) })

View File

@ -17,16 +17,19 @@
{% endif %} {% endif %}
</div> </div>
<div class="row g-3"> <form method="post" class="row g-3"> <div class="col-12">
<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 %}
</div> </form>
</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 / job" /> <input class="form-control" id="flt_q" name="q" value="{{ q }}" placeholder="ticket code / description / 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</a> <a class="btn btn-sm btn-outline-primary" href="{{ url_for('main.ticket_detail', ticket_id=t.id) }}">View / Edit</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</a> <a class="btn btn-sm btn-outline-primary" href="{{ url_for('main.remark_detail', remark_id=r.id) }}">View / Edit</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 %}