diff --git a/containers/backupchecks/src/backend/app/main/routes_api.py b/containers/backupchecks/src/backend/app/main/routes_api.py index 7242eff..393d3ca 100644 --- a/containers/backupchecks/src/backend/app/main/routes_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_api.py @@ -1,5 +1,6 @@ from .routes_shared import * # noqa: F401,F403 -from .routes_shared import _format_datetime, _get_ui_timezone_name, _next_ticket_code, _to_amsterdam_date +from .routes_shared import _format_datetime, _get_ui_timezone_name, _to_amsterdam_date +import re @main_bp.route("/api/job-runs//alerts") @login_required @@ -178,7 +179,7 @@ def api_tickets(): return jsonify({"status": "error", "message": "Forbidden."}), 403 payload = request.get_json(silent=True) or {} - description = (payload.get("description") or "").strip() or None + description = None # Description removed from New ticket UI; use remarks for additional context try: run_id = int(payload.get("job_run_id") or 0) except Exception: @@ -194,10 +195,21 @@ def api_tickets(): job = Job.query.get(run.job_id) if run else None now = datetime.utcnow() - code = _next_ticket_code(now) + ticket_code = (payload.get("ticket_code") or "").strip().upper() + + if not ticket_code: + return jsonify({"status": "error", "message": "ticket_code is required."}), 400 + + # Validate format: TYYYYMMDD.#### + if not re.match(r"^T\d{8}\.\d{4}$", ticket_code): + return jsonify({"status": "error", "message": "Invalid ticket_code format. Expected TYYYYMMDD.####."}), 400 + + # Ensure uniqueness + if Ticket.query.filter_by(ticket_code=ticket_code).first(): + return jsonify({"status": "error", "message": "ticket_code already exists."}), 409 ticket = Ticket( - ticket_code=code, + ticket_code=ticket_code, title=None, description=description, active_from_date=_to_amsterdam_date(run.run_at) or _to_amsterdam_date(now) or now.date(), @@ -250,21 +262,8 @@ def api_tickets(): @login_required @roles_required("admin", "operator", "viewer") def api_ticket_update(ticket_id: int): - if get_active_role() not in ("admin", "operator"): - return jsonify({"status": "error", "message": "Forbidden."}), 403 - - ticket = Ticket.query.get_or_404(ticket_id) - payload = request.get_json(silent=True) or {} - if "description" in payload: - ticket.description = (payload.get("description") or "").strip() or None - - try: - db.session.commit() - except Exception as exc: - db.session.rollback() - return jsonify({"status": "error", "message": str(exc) or "Failed to update ticket."}), 500 - - return jsonify({"status": "ok"}) + # Editing tickets is not allowed. Resolve the old ticket and create a new one instead. + return jsonify({"status": "error", "message": "Ticket editing is disabled. Resolve the old ticket and create a new one."}), 405 @main_bp.route("/api/tickets//resolve", methods=["POST"]) @@ -420,21 +419,8 @@ def api_remarks(): @login_required @roles_required("admin", "operator", "viewer") def api_remark_update(remark_id: int): - if get_active_role() not in ("admin", "operator"): - return jsonify({"status": "error", "message": "Forbidden."}), 403 - - remark = Remark.query.get_or_404(remark_id) - payload = request.get_json(silent=True) or {} - if "body" in payload: - remark.body = (payload.get("body") or "").strip() or "" - - try: - db.session.commit() - except Exception as exc: - db.session.rollback() - return jsonify({"status": "error", "message": str(exc) or "Failed to update remark."}), 500 - - return jsonify({"status": "ok"}) + # Editing remarks is not allowed. Resolve the old remark and create a new one instead. + return jsonify({"status": "error", "message": "Remark editing is disabled. Resolve the old remark and create a new one."}), 405 @main_bp.route("/api/remarks//resolve", methods=["POST"]) @@ -489,4 +475,4 @@ def api_remark_link_run(remark_id: int): db.session.rollback() return jsonify({"status": "error", "message": str(exc) or "Failed to link run."}), 500 - return jsonify({"status": "ok"}) + return jsonify({"status": "ok"}) \ No newline at end of file diff --git a/containers/backupchecks/src/backend/app/main/routes_customers.py b/containers/backupchecks/src/backend/app/main/routes_customers.py index 9e53b7d..f4348ae 100644 --- a/containers/backupchecks/src/backend/app/main/routes_customers.py +++ b/containers/backupchecks/src/backend/app/main/routes_customers.py @@ -100,6 +100,9 @@ def customers_delete(customer_id: int): customer = Customer.query.get_or_404(customer_id) 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.commit() flash("Customer deleted.", "success") diff --git a/containers/backupchecks/src/backend/app/main/routes_daily_jobs.py b/containers/backupchecks/src/backend/app/main/routes_daily_jobs.py index 189d0b5..e1b325b 100644 --- a/containers/backupchecks/src/backend/app/main/routes_daily_jobs.py +++ b/containers/backupchecks/src/backend/app/main/routes_daily_jobs.py @@ -76,6 +76,7 @@ def daily_jobs(): jobs = ( 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()) .all() ) diff --git a/containers/backupchecks/src/backend/app/main/routes_feedback.py b/containers/backupchecks/src/backend/app/main/routes_feedback.py index 4e3e322..2dbc85e 100644 --- a/containers/backupchecks/src/backend/app/main/routes_feedback.py +++ b/containers/backupchecks/src/backend/app/main/routes_feedback.py @@ -172,6 +172,19 @@ def feedback_detail(item_id: int): resolved_by = User.query.get(item.resolved_by_user_id) 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( "main/feedback_detail.html", item=item, @@ -179,8 +192,42 @@ def feedback_detail(item_id: int): resolved_by_name=resolved_by_name, vote_count=int(vote_count), user_voted=bool(user_voted), + replies=replies, + reply_user_map=reply_user_map, ) +@main_bp.route("/feedback//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//vote", methods=["POST"]) @login_required diff --git a/containers/backupchecks/src/backend/app/main/routes_inbox.py b/containers/backupchecks/src/backend/app/main/routes_inbox.py index e3a38b8..5440c9f 100644 --- a/containers/backupchecks/src/backend/app/main/routes_inbox.py +++ b/containers/backupchecks/src/backend/app/main/routes_inbox.py @@ -69,6 +69,8 @@ def inbox(): has_prev=has_prev, has_next=has_next, customers=customer_rows, + can_bulk_delete=(get_active_role() in ("admin", "operator")), + is_admin=(get_active_role() == "admin"), ) @@ -296,6 +298,62 @@ def inbox_message_delete(message_id: int): 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") @login_required @roles_required("admin") diff --git a/containers/backupchecks/src/backend/app/main/routes_jobs.py b/containers/backupchecks/src/backend/app/main/routes_jobs.py index 8c559f6..7d96507 100644 --- a/containers/backupchecks/src/backend/app/main/routes_jobs.py +++ b/containers/backupchecks/src/backend/app/main/routes_jobs.py @@ -16,6 +16,7 @@ def jobs(): # Join with customers for display jobs = ( Job.query + .filter(Job.archived.is_(False)) .outerjoin(Customer, Customer.id == Job.customer_id) .add_columns( Job.id, @@ -55,6 +56,89 @@ def jobs(): ) +@main_bp.route("/jobs//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//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/") @login_required @roles_required("admin", "operator", "viewer") diff --git a/containers/backupchecks/src/backend/app/main/routes_remarks.py b/containers/backupchecks/src/backend/app/main/routes_remarks.py index 2ceab03..fcc2480 100644 --- a/containers/backupchecks/src/backend/app/main/routes_remarks.py +++ b/containers/backupchecks/src/backend/app/main/routes_remarks.py @@ -1,23 +1,13 @@ from .routes_shared import * # noqa: F401,F403 from .routes_shared import _format_datetime -@main_bp.route("/remarks/", methods=["GET", "POST"]) +@main_bp.route("/remarks/", methods=["GET"]) @login_required @roles_required("admin", "operator", "viewer") def remark_detail(remark_id: int): remark = Remark.query.get_or_404(remark_id) - if request.method == "POST": - if get_active_role() not in ("admin", "operator"): - abort(403) - remark.body = (request.form.get("body") or "").strip() or "" - try: - db.session.commit() - flash("Remark updated.", "success") - except Exception as exc: - db.session.rollback() - flash(f"Failed to update remark: {exc}", "danger") - return redirect(url_for("main.remark_detail", remark_id=remark.id)) + # Remark editing is disabled. Resolve the old remark and create a new one instead. scopes = RemarkScope.query.filter(RemarkScope.remark_id == remark.id).order_by(RemarkScope.id.asc()).all() diff --git a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py index 43c4968..8193bce 100644 --- a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py @@ -331,14 +331,17 @@ def build_report_columns_meta(): def build_report_job_filters_meta(): """Build job filter metadata for reporting UIs. - Provides available backup_softwares and backup_types derived from active jobs. + Provides available backup_softwares and backup_types derived from jobs that have runs. """ - # Distinct values across active jobs (exclude known informational jobs). + # Distinct values across jobs with runs (exclude known informational jobs). info_backup_types = {"license key"} + # Distinct values across jobs that actually have at least one run. rows = ( db.session.query(Job.backup_software, Job.backup_type) - .filter(Job.active.is_(True)) + .select_from(Job) + .join(JobRun, JobRun.job_id == Job.id) + .distinct() .all() ) diff --git a/containers/backupchecks/src/backend/app/main/routes_run_checks.py b/containers/backupchecks/src/backend/app/main/routes_run_checks.py index 3571dce..0ec069b 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -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} - jobs = Job.query.all() + jobs = Job.query.filter(Job.archived.is_(False)).all() today_local = _to_amsterdam_date(datetime.utcnow()) or datetime.utcnow().date() for job in jobs: @@ -222,6 +222,7 @@ def run_checks_page(): ) .select_from(Job) .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) diff --git a/containers/backupchecks/src/backend/app/main/routes_shared.py b/containers/backupchecks/src/backend/app/main/routes_shared.py index 9f3dcf4..e73ee9e 100644 --- a/containers/backupchecks/src/backend/app/main/routes_shared.py +++ b/containers/backupchecks/src/backend/app/main/routes_shared.py @@ -50,6 +50,7 @@ from ..models import ( RemarkJobRun, FeedbackItem, FeedbackVote, + FeedbackReply, NewsItem, NewsRead, ReportDefinition, diff --git a/containers/backupchecks/src/backend/app/main/routes_tickets.py b/containers/backupchecks/src/backend/app/main/routes_tickets.py index a1cc6f1..b4465e9 100644 --- a/containers/backupchecks/src/backend/app/main/routes_tickets.py +++ b/containers/backupchecks/src/backend/app/main/routes_tickets.py @@ -270,23 +270,13 @@ def tickets_page(): ) -@main_bp.route("/tickets/", methods=["GET", "POST"]) +@main_bp.route("/tickets/", methods=["GET"]) @login_required @roles_required("admin", "operator", "viewer") def ticket_detail(ticket_id: int): ticket = Ticket.query.get_or_404(ticket_id) - if request.method == "POST": - if get_active_role() not in ("admin", "operator"): - abort(403) - ticket.description = (request.form.get("description") or "").strip() or None - try: - db.session.commit() - flash("Ticket updated.", "success") - except Exception as exc: - db.session.rollback() - flash(f"Failed to update ticket: {exc}", "danger") - return redirect(url_for("main.ticket_detail", ticket_id=ticket.id)) + # Ticket editing is disabled. Resolve the old ticket and create a new one instead. # Scopes scopes = TicketScope.query.filter(TicketScope.ticket_id == ticket.id).order_by(TicketScope.id.asc()).all() diff --git a/containers/backupchecks/src/backend/app/migrations.py b/containers/backupchecks/src/backend/app/migrations.py index 49e304d..589d718 100644 --- a/containers/backupchecks/src/backend/app/migrations.py +++ b/containers/backupchecks/src/backend/app/migrations.py @@ -772,17 +772,59 @@ def run_migrations() -> None: migrate_mail_objects_table() migrate_object_persistence_tables() migrate_feedback_tables() + migrate_feedback_replies_table() migrate_tickets_active_from_date() migrate_remarks_active_from_date() migrate_overrides_match_columns() migrate_job_runs_review_tracking() migrate_job_runs_override_metadata() + migrate_jobs_archiving() migrate_news_tables() migrate_reporting_tables() migrate_reporting_report_config() 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: """Add report_definitions.report_config column if missing. @@ -937,6 +979,40 @@ def migrate_job_runs_review_tracking() -> None: 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: """Ensure tickets.active_from_date exists and is populated. @@ -1096,6 +1172,39 @@ 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( text( 'CREATE INDEX IF NOT EXISTS idx_customer_objects_customer_name ON customer_objects (customer_id, object_name)' diff --git a/containers/backupchecks/src/backend/app/models.py b/containers/backupchecks/src/backend/app/models.py index a66644c..7b0f26a 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -196,6 +196,12 @@ class Job(db.Model): auto_approve = 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) updated_at = db.Column( db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False @@ -488,6 +494,20 @@ 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): __tablename__ = "news_items" diff --git a/containers/backupchecks/src/backend/app/parsers/veeam.py b/containers/backupchecks/src/backend/app/parsers/veeam.py index 71f8fd5..2b54e5e 100644 --- a/containers/backupchecks/src/backend/app/parsers/veeam.py +++ b/containers/backupchecks/src/backend/app/parsers/veeam.py @@ -79,7 +79,9 @@ def _extract_configuration_job_overall_message(html: str) -> Optional[str]: for line in text.split("\n"): # Example: # 26-12-2025 10:00:23 Warning Skipping server certificate backup because encryption is disabled - if re.match(r"^\d{2}-\d{2}-\d{4}\s+\d{2}:\d{2}:\d{2}\s+(Warning|Failed|Error)\b", line): + # 6-1-2026 10:00:16 Warning Skipping credentials backup because encryption is disabled + # Veeam can format dates as either zero-padded (06-01-2026) or non-padded (6-1-2026). + if re.match(r"^\d{1,2}-\d{1,2}-\d{4}\s+\d{2}:\d{2}:\d{2}\s+(Warning|Failed|Error)\b", line): wanted_lines.append(line) if not wanted_lines: @@ -711,6 +713,20 @@ def _strip_m365_combined_suffix(job_name: Optional[str]) -> Optional[str]: 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]]: """Try to parse a Veeam backup report mail. @@ -866,6 +882,10 @@ def try_parse_veeam(msg: MailMessage) -> Tuple[bool, Dict, List[Dict]]: # Do not let retry counters create distinct job names. 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. # Strip it so combined/non-combined mails map to the same job. if (backup_type or "") == "Veeam Backup for Microsoft 365": @@ -885,7 +905,7 @@ def try_parse_veeam(msg: MailMessage) -> Tuple[bool, Dict, List[Dict]]: result: Dict = { "backup_software": "Veeam", "backup_type": backup_type, - "job_name": _strip_retry_suffix(job_name), + "job_name": job_name, "overall_status": status_word, } @@ -912,8 +932,17 @@ def try_parse_veeam(msg: MailMessage) -> Tuple[bool, Dict, List[Dict]]: # Keep detailed overall message for non-success states, and always keep # the "Processing " marker when present (used for overrides/rules). + # Veeam Backup for Microsoft 365 can include a meaningful overall warning/info + # even when the run is reported as Success (e.g. missing application + # permissions/roles). Store it so it becomes visible in details and can be + # used for overrides. + is_m365 = (backup_type or "") == "Veeam Backup for Microsoft 365" if overall_message: - if status_word != "Success" or overall_message.lower().startswith("processing "): + if ( + status_word != "Success" + or overall_message.lower().startswith("processing ") + or is_m365 + ): result["overall_message"] = overall_message return True, result, objects diff --git a/containers/backupchecks/src/templates/layout/base.html b/containers/backupchecks/src/templates/layout/base.html index 5ae1890..a0cc910 100644 --- a/containers/backupchecks/src/templates/layout/base.html +++ b/containers/backupchecks/src/templates/layout/base.html @@ -82,6 +82,11 @@ + {% if active_role == 'admin' %} + + {% endif %} diff --git a/containers/backupchecks/src/templates/main/daily_jobs.html b/containers/backupchecks/src/templates/main/daily_jobs.html index 8a42b15..4fc11a1 100644 --- a/containers/backupchecks/src/templates/main/daily_jobs.html +++ b/containers/backupchecks/src/templates/main/daily_jobs.html @@ -200,9 +200,9 @@
- +
-
+
@@ -356,27 +356,13 @@ '
' + '🎫' + '' + escapeHtml(t.ticket_code || '') + '' + - '' + status + '' + + '' + status + '' + '
' + - (t.description ? ('
' + escapeHtml(t.description) + '
') : '') + '
' + '
' + - '' + '' + '
' + '' + - '' + ''; }); html += ''; @@ -397,22 +383,9 @@ (r.body ? ('
' + escapeHtml(r.body) + '
') : '') + '' + '
' + - '' + '' + '
' + '' + - '' + ''; }); html += ''; @@ -427,8 +400,6 @@ var id = btn.getAttribute('data-id'); if (!action || !id) return; - var wrapper = btn.closest('[data-alert-type]'); - if (action === 'resolve-ticket') { if (!confirm('Mark ticket as resolved?')) return; apiJson('/api/tickets/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'}) @@ -439,59 +410,6 @@ apiJson('/api/remarks/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'}) .then(function () { loadAlerts(currentRunId); }) .catch(function (e) { alert(e.message || 'Failed.'); }); - } else if (action === 'toggle-edit-ticket') { - if (!wrapper) return; - var edit = wrapper.querySelector('[data-edit="ticket"]'); - if (!edit) return; - edit.style.display = (edit.style.display === 'none' || !edit.style.display) ? '' : 'none'; - } else if (action === 'toggle-edit-remark') { - if (!wrapper) return; - var edit2 = wrapper.querySelector('[data-edit="remark"]'); - if (!edit2) return; - edit2.style.display = (edit2.style.display === 'none' || !edit2.style.display) ? '' : 'none'; - } else if (action === 'cancel-edit') { - if (!wrapper) return; - var editAny = wrapper.querySelector('[data-edit]'); - if (editAny) editAny.style.display = 'none'; - } else if (action === 'save-ticket') { - if (!wrapper) return; - var editT = wrapper.querySelector('[data-edit="ticket"]'); - if (!editT) return; - var descEl = editT.querySelector('[data-field="description"]'); - var statusEl = editT.querySelector('[data-field="status"]'); - var descVal = descEl ? descEl.value : ''; - if (statusEl) statusEl.textContent = 'Saving...'; - apiJson('/api/tickets/' + encodeURIComponent(id), { - method: 'PATCH', - body: JSON.stringify({description: descVal}) - }) - .then(function () { loadAlerts(currentRunId); }) - .catch(function (e) { - if (statusEl) statusEl.textContent = e.message || 'Failed.'; - else alert(e.message || 'Failed.'); - }); - } else if (action === 'save-remark') { - if (!wrapper) return; - var editR = wrapper.querySelector('[data-edit="remark"]'); - if (!editR) return; - var bodyEl2 = editR.querySelector('[data-field="body"]'); - var statusEl2 = editR.querySelector('[data-field="status"]'); - var bodyVal2 = bodyEl2 ? bodyEl2.value : ''; - if (!bodyVal2 || !bodyVal2.trim()) { - if (statusEl2) statusEl2.textContent = 'Body is required.'; - else alert('Body is required.'); - return; - } - if (statusEl2) statusEl2.textContent = 'Saving...'; - apiJson('/api/remarks/' + encodeURIComponent(id), { - method: 'PATCH', - body: JSON.stringify({body: bodyVal2}) - }) - .then(function () { loadAlerts(currentRunId); }) - .catch(function (e) { - if (statusEl2) statusEl2.textContent = e.message || 'Failed.'; - else alert(e.message || 'Failed.'); - }); } }); }); @@ -528,8 +446,8 @@ function bindInlineCreateForms() { var btnTicket = document.getElementById('dj_ticket_save'); var btnRemark = document.getElementById('dj_remark_save'); - var tDesc = document.getElementById('dj_ticket_description'); - var tStatus = document.getElementById('dj_ticket_status'); + var tCode = document.getElementById('dj_ticket_code'); +var tStatus = document.getElementById('dj_ticket_status'); var rBody = document.getElementById('dj_remark_body'); var rStatus = document.getElementById('dj_remark_status'); @@ -541,8 +459,8 @@ function setDisabled(disabled) { if (btnTicket) btnTicket.disabled = disabled; if (btnRemark) btnRemark.disabled = disabled; - if (tDesc) tDesc.disabled = disabled; - if (rBody) rBody.disabled = disabled; + if (tCode) tCode.disabled = disabled; +if (rBody) rBody.disabled = disabled; } window.__djSetCreateDisabled = setDisabled; @@ -552,15 +470,25 @@ btnTicket.addEventListener('click', function () { if (!currentRunId) { alert('Select a run first.'); return; } clearStatus(); - var description = tDesc ? tDesc.value : ''; + var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : ''; +if (!ticket_code) { + if (tStatus) tStatus.textContent = 'Ticket number is required.'; + else alert('Ticket number is required.'); + return; + } + if (!/^T\d{8}\.\d{4}$/.test(ticket_code)) { + if (tStatus) tStatus.textContent = 'Invalid ticket number format. Expected TYYYYMMDD.####.'; + else alert('Invalid ticket number format. Expected TYYYYMMDD.####.'); + return; + } if (tStatus) tStatus.textContent = 'Saving...'; apiJson('/api/tickets', { method: 'POST', - body: JSON.stringify({job_run_id: currentRunId, description: description}) + body: JSON.stringify({job_run_id: currentRunId, ticket_code: ticket_code}) }) .then(function () { - if (tDesc) tDesc.value = ''; - if (tStatus) tStatus.textContent = ''; + if (tCode) tCode.value = ''; +if (tStatus) tStatus.textContent = ''; loadAlerts(currentRunId); }) .catch(function (e) { diff --git a/containers/backupchecks/src/templates/main/feedback_detail.html b/containers/backupchecks/src/templates/main/feedback_detail.html index 729e2d7..d6cebf7 100644 --- a/containers/backupchecks/src/templates/main/feedback_detail.html +++ b/containers/backupchecks/src/templates/main/feedback_detail.html @@ -49,6 +49,46 @@ +
+
+
Replies
+ {% if replies %} +
+ {% for r in replies %} +
+
+ {{ reply_user_map.get(r.user_id, '') or ('User #' ~ r.user_id) }} + + {{ r.created_at.strftime('%d-%m-%Y %H:%M:%S') if r.created_at else '' }} + +
+
{{ r.message }}
+
+ {% endfor %} +
+ {% else %} +
No replies yet.
+ {% endif %} +
+
+ +
+
+
Add reply
+ {% if item.status == 'open' %} +
+
+ +
+ +
+ {% else %} +
Replies can only be added while the item is open.
+ {% endif %} +
+
+ +
diff --git a/containers/backupchecks/src/templates/main/inbox.html b/containers/backupchecks/src/templates/main/inbox.html index 95e8c78..760c141 100644 --- a/containers/backupchecks/src/templates/main/inbox.html +++ b/containers/backupchecks/src/templates/main/inbox.html @@ -56,10 +56,26 @@ {{ pager("top", page, total_pages, has_prev, has_next) }} +{% if can_bulk_delete %} +
+
+ +
+
+
+{% endif %} + + +
- +
+ {% if can_bulk_delete %} + + {% endif %} @@ -75,6 +91,11 @@ {% if rows %} {% for row in rows %} + {% if can_bulk_delete %} + + {% endif %} @@ -190,6 +211,125 @@ (function () { var customers = {{ customers|tojson|safe }}; + var table = document.getElementById('inboxTable'); + var selectAll = document.getElementById('inbox_select_all'); + var btnDeleteSelected = document.getElementById('btn_inbox_delete_selected'); + var statusEl = document.getElementById('inbox_status'); + + function getSelectedMessageIds() { + if (!table) return []; + var cbs = table.querySelectorAll('tbody .inbox_row_cb'); + var ids = []; + cbs.forEach(function (cb) { + if (cb.checked) ids.push(parseInt(cb.value, 10)); + }); + return ids.filter(function (x) { return Number.isFinite(x); }); + } + + function refreshRowHighlights() { + if (!table) return; + var cbs = table.querySelectorAll('tbody .inbox_row_cb'); + cbs.forEach(function (cb) { + var tr = cb.closest ? cb.closest('tr') : null; + if (!tr) return; + if (cb.checked) tr.classList.add('table-active'); + else tr.classList.remove('table-active'); + }); + } + + function refreshSelectAll() { + if (!selectAll || !table) return; + var cbs = table.querySelectorAll('tbody .inbox_row_cb'); + var total = cbs.length; + var checked = 0; + cbs.forEach(function (cb) { if (cb.checked) checked++; }); + selectAll.indeterminate = checked > 0 && checked < total; + selectAll.checked = total > 0 && checked === total; + } + + function updateBulkDeleteUi() { + var ids = getSelectedMessageIds(); + refreshRowHighlights(); + if (btnDeleteSelected) btnDeleteSelected.disabled = ids.length === 0; + if (statusEl) statusEl.textContent = ids.length ? (ids.length + ' selected') : ''; + refreshSelectAll(); + } + + function postJson(url, payload) { + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(payload || {}) + }).then(function (r) { + return r.json().then(function (data) { return { ok: r.ok, status: r.status, data: data }; }); + }); + } + + if (selectAll && table) { + function setAllSelection(checked) { + var cbs = table.querySelectorAll('tbody .inbox_row_cb'); + cbs.forEach(function (cb) { cb.checked = !!checked; }); + selectAll.indeterminate = false; + selectAll.checked = !!checked; + setTimeout(function () { + selectAll.indeterminate = false; + selectAll.checked = !!checked; + }, 0); + updateBulkDeleteUi(); + } + + selectAll.addEventListener('click', function (e) { + e.stopPropagation(); + }); + + selectAll.addEventListener('change', function () { + setAllSelection(selectAll.checked); + }); + } + + if (table) { + table.addEventListener('change', function (e) { + var t = e.target; + if (t && t.classList && t.classList.contains('inbox_row_cb')) { + updateBulkDeleteUi(); + } + }); + } + + if (btnDeleteSelected) { + btnDeleteSelected.addEventListener('click', function () { + var ids = getSelectedMessageIds(); + if (!ids.length) return; + + var msg = 'Delete ' + ids.length + ' selected message' + (ids.length === 1 ? '' : 's') + ' from the Inbox?'; + if (!confirm(msg)) return; + + if (statusEl) statusEl.textContent = 'Deleting...'; + + postJson('{{ url_for('main.api_inbox_bulk_delete') }}', { message_ids: ids }) + .then(function (res) { + if (!res.ok || !res.data || res.data.status !== 'ok') { + var err = (res.data && (res.data.message || res.data.error)) ? (res.data.message || res.data.error) : 'Request failed.'; + if (statusEl) statusEl.textContent = err; + alert(err); + return; + } + window.location.reload(); + }) + .catch(function () { + var err = 'Request failed.'; + if (statusEl) statusEl.textContent = err; + alert(err); + }); + }); + } + + // Initialize UI state + updateBulkDeleteUi(); + + + function wrapMailHtml(html) { diff --git a/containers/backupchecks/src/templates/main/job_detail.html b/containers/backupchecks/src/templates/main/job_detail.html index 382a7bc..d97f7c0 100644 --- a/containers/backupchecks/src/templates/main/job_detail.html +++ b/containers/backupchecks/src/templates/main/job_detail.html @@ -48,9 +48,15 @@ {% if can_manage_jobs %} - - - +
+
+ + + +
+ + +
{% endif %}

Job history

@@ -268,7 +274,7 @@ "" ); } -function renderObjects(objects) { + function renderObjects(objects) { var container = document.getElementById("run_msg_objects_container"); if (!container) return; @@ -277,16 +283,36 @@ function renderObjects(objects) { return; } + // Sort: objects with an error_message first (alphabetically by name), then the rest (also by name). + var sorted = (objects || []).slice().sort(function (a, b) { + a = a || {}; + b = b || {}; + var aHasErr = !!(a.error_message && a.error_message.toString().trim()); + var bHasErr = !!(b.error_message && b.error_message.toString().trim()); + if (aHasErr !== bHasErr) return aHasErr ? -1 : 1; + + var an = (a.name || "").toString().toLowerCase(); + var bn = (b.name || "").toString().toLowerCase(); + if (an < bn) return -1; + if (an > bn) return 1; + return 0; + }); + var html = "
+ + From Subject Date / time
+ + {{ row.from_address }} {{ row.subject }} {{ row.received_at }}
"; html += ""; - for (var i = 0; i < objects.length; i++) { - var o = objects[i] || {}; + for (var i = 0; i < sorted.length; i++) { + var o = sorted[i] || {}; html += ""; - html += ""; - html += ""; + html += ""; + html += ""; + var d = statusDotClass(o.status); - html += ""; - html += ""; + html += ""; + + html += ""; html += ""; } html += "
ObjectTypeStatusError
" + (o.name || "") + "" + (o.type || "") + "" + escapeHtml(o.name || "") + "" + escapeHtml(o.type || "") + "" + (d ? ('') : '') + escapeHtml(o.status || "") + "" + (o.error_message || "") + "" + + (d ? ("") : "") + + escapeHtml(o.status || "") + + "" + escapeHtml(o.error_message || "") + "
"; @@ -383,4 +409,4 @@ function renderObjects(objects) { })(); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/containers/backupchecks/src/templates/main/jobs.html b/containers/backupchecks/src/templates/main/jobs.html index 1d7ddde..8240d12 100644 --- a/containers/backupchecks/src/templates/main/jobs.html +++ b/containers/backupchecks/src/templates/main/jobs.html @@ -15,7 +15,7 @@ {% if jobs %} {% for j in jobs %} - + {{ j.customer_name }} {{ j.backup_software }} {{ j.backup_type }} @@ -32,4 +32,24 @@
+ + {% endblock %} diff --git a/containers/backupchecks/src/templates/main/remark_detail.html b/containers/backupchecks/src/templates/main/remark_detail.html index 3509fbf..696d846 100644 --- a/containers/backupchecks/src/templates/main/remark_detail.html +++ b/containers/backupchecks/src/templates/main/remark_detail.html @@ -16,19 +16,19 @@ {% endif %}
-
+
- +
{{ remark.body or '' }}
{% if active_role in ['admin','operator'] %}
- + {% if not remark.resolved_at %} {% endif %}
{% endif %} - +
diff --git a/containers/backupchecks/src/templates/main/run_checks.html b/containers/backupchecks/src/templates/main/run_checks.html index 56d0e82..171b4ca 100644 --- a/containers/backupchecks/src/templates/main/run_checks.html +++ b/containers/backupchecks/src/templates/main/run_checks.html @@ -205,9 +205,9 @@
- -
-
+ + +
@@ -669,25 +669,11 @@ table.addEventListener('change', function (e) { '' + escapeHtml(t.ticket_code || '') + '' + '' + status + '' + '
' + - (t.description ? ('
' + escapeHtml(t.description) + '
') : '') + '' + '
' + - '' + '' + '
' + '' + - '' + ''; }); html += ''; @@ -696,34 +682,21 @@ table.addEventListener('change', function (e) { if (remarks.length) { html += '
Remarks
'; remarks.forEach(function (r) { - var status2 = r.resolved_at ? 'Resolved' : 'Active'; + var status = r.resolved_at ? 'Resolved' : 'Active'; html += '
' + '
' + '
' + '
' + '💬' + 'Remark' + - '' + status2 + '' + + '' + status + '' + '
' + (r.body ? ('
' + escapeHtml(r.body) + '
') : '') + '
' + '
' + - '' + '' + '
' + '
' + - '' + '
'; }); html += '
'; @@ -737,8 +710,6 @@ table.addEventListener('change', function (e) { var action = btn.getAttribute('data-action'); var id = btn.getAttribute('data-id'); if (!action || !id) return; - var wrapper = btn.closest('[data-alert-type]'); - if (action === 'resolve-ticket') { if (!confirm('Mark ticket as resolved?')) return; apiJson('/api/tickets/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'}) @@ -749,58 +720,6 @@ table.addEventListener('change', function (e) { apiJson('/api/remarks/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'}) .then(function () { loadAlerts(currentRunId); }) .catch(function (e) { alert(e.message || 'Failed.'); }); - } else if (action === 'toggle-edit-ticket') { - if (!wrapper) return; - var edit = wrapper.querySelector('[data-edit="ticket"]'); - if (!edit) return; - edit.style.display = (edit.style.display === 'none' || !edit.style.display) ? '' : 'none'; - } else if (action === 'toggle-edit-remark') { - if (!wrapper) return; - var edit2 = wrapper.querySelector('[data-edit="remark"]'); - if (!edit2) return; - edit2.style.display = (edit2.style.display === 'none' || !edit2.style.display) ? '' : 'none'; - } else if (action === 'cancel-edit') { - if (!wrapper) return; - var editAny = wrapper.querySelector('[data-edit]'); - if (editAny) editAny.style.display = 'none'; - } else if (action === 'save-ticket') { - if (!wrapper) return; - var editT = wrapper.querySelector('[data-edit="ticket"]'); - if (!editT) return; - var descEl = editT.querySelector('[data-field="description"]'); - var statusEl2 = editT.querySelector('[data-field="status"]'); - var descVal = descEl ? descEl.value : ''; - if (statusEl2) statusEl2.textContent = 'Saving...'; - apiJson('/api/tickets/' + encodeURIComponent(id), { - method: 'PATCH', - body: JSON.stringify({description: descVal}) - }) - .then(function () { loadAlerts(currentRunId); }) - .catch(function (e) { - if (statusEl2) statusEl2.textContent = e.message || 'Failed.'; - else alert(e.message || 'Failed.'); - }); - } else if (action === 'save-remark') { - if (!wrapper) return; - var editR = wrapper.querySelector('[data-edit="remark"]'); - if (!editR) return; - var bodyEl = editR.querySelector('[data-field="body"]'); - var statusEl3 = editR.querySelector('[data-field="status"]'); - var bodyVal = bodyEl ? bodyEl.value : ''; - if (!bodyVal || !bodyVal.trim()) { - if (statusEl3) statusEl3.textContent = 'Body is required.'; - return; - } - if (statusEl3) statusEl3.textContent = 'Saving...'; - apiJson('/api/remarks/' + encodeURIComponent(id), { - method: 'PATCH', - body: JSON.stringify({body: bodyVal}) - }) - .then(function () { loadAlerts(currentRunId); }) - .catch(function (e) { - if (statusEl3) statusEl3.textContent = e.message || 'Failed.'; - else alert(e.message || 'Failed.'); - }); } }); }); @@ -825,8 +744,8 @@ table.addEventListener('change', function (e) { function bindInlineCreateForms() { var btnTicket = document.getElementById('rcm_ticket_save'); var btnRemark = document.getElementById('rcm_remark_save'); - var tDesc = document.getElementById('rcm_ticket_description'); - var tStatus = document.getElementById('rcm_ticket_status'); + var tCode = document.getElementById('rcm_ticket_code'); +var tStatus = document.getElementById('rcm_ticket_status'); var rBody = document.getElementById('rcm_remark_body'); var rStatus = document.getElementById('rcm_remark_status'); @@ -838,8 +757,8 @@ table.addEventListener('change', function (e) { function setDisabled(disabled) { if (btnTicket) btnTicket.disabled = disabled; if (btnRemark) btnRemark.disabled = disabled; - if (tDesc) tDesc.disabled = disabled; - if (rBody) rBody.disabled = disabled; + if (tCode) tCode.disabled = disabled; +if (rBody) rBody.disabled = disabled; } window.__rcmSetCreateDisabled = setDisabled; @@ -849,15 +768,25 @@ table.addEventListener('change', function (e) { btnTicket.addEventListener('click', function () { if (!currentRunId) { alert('Select a run first.'); return; } clearStatus(); - var description = tDesc ? tDesc.value : ''; + var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : ''; +if (!ticket_code) { + if (tStatus) tStatus.textContent = 'Ticket number is required.'; + else alert('Ticket number is required.'); + return; + } + if (!/^T\d{8}\.\d{4}$/.test(ticket_code)) { + if (tStatus) tStatus.textContent = 'Invalid ticket number format. Expected TYYYYMMDD.####.'; + else alert('Invalid ticket number format. Expected TYYYYMMDD.####.'); + return; + } if (tStatus) tStatus.textContent = 'Saving...'; apiJson('/api/tickets', { method: 'POST', - body: JSON.stringify({job_run_id: currentRunId, description: description}) + body: JSON.stringify({job_run_id: currentRunId, ticket_code: ticket_code}) }) .then(function () { - if (tDesc) tDesc.value = ''; - if (tStatus) tStatus.textContent = ''; + if (tCode) tCode.value = ''; +if (tStatus) tStatus.textContent = ''; loadAlerts(currentRunId); }) .catch(function (e) { diff --git a/containers/backupchecks/src/templates/main/ticket_detail.html b/containers/backupchecks/src/templates/main/ticket_detail.html index 74b3ae5..32cd071 100644 --- a/containers/backupchecks/src/templates/main/ticket_detail.html +++ b/containers/backupchecks/src/templates/main/ticket_detail.html @@ -17,19 +17,16 @@ {% endif %} -
- - -
+
{% if active_role in ['admin','operator'] %}
- + {% if not ticket.resolved_at %} {% endif %}
{% endif %} - +
diff --git a/containers/backupchecks/src/templates/main/tickets.html b/containers/backupchecks/src/templates/main/tickets.html index 61d6b4e..33de980 100644 --- a/containers/backupchecks/src/templates/main/tickets.html +++ b/containers/backupchecks/src/templates/main/tickets.html @@ -45,7 +45,7 @@
- +
@@ -89,7 +89,7 @@ {{ t.start_date }} {{ t.resolved_at }} - View / Edit + View {% if t.active and t.job_id %} Job page {% endif %} @@ -144,7 +144,7 @@ {{ r.start_date }} {{ r.resolved_at }} - View / Edit + View {% if r.active and r.job_id %} Job page {% endif %}