diff --git a/.last-branch b/.last-branch index b4c458d..c5b9c7a 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260106-17-jobrun-popup-objects-restore +v20260106-18-Reset diff --git a/containers/backupchecks/src/backend/app/main/routes_api.py b/containers/backupchecks/src/backend/app/main/routes_api.py index 393d3ca..7242eff 100644 --- a/containers/backupchecks/src/backend/app/main/routes_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_api.py @@ -1,6 +1,5 @@ from .routes_shared import * # noqa: F401,F403 -from .routes_shared import _format_datetime, _get_ui_timezone_name, _to_amsterdam_date -import re +from .routes_shared import _format_datetime, _get_ui_timezone_name, _next_ticket_code, _to_amsterdam_date @main_bp.route("/api/job-runs//alerts") @login_required @@ -179,7 +178,7 @@ def api_tickets(): return jsonify({"status": "error", "message": "Forbidden."}), 403 payload = request.get_json(silent=True) or {} - description = None # Description removed from New ticket UI; use remarks for additional context + description = (payload.get("description") or "").strip() or None try: run_id = int(payload.get("job_run_id") or 0) except Exception: @@ -195,21 +194,10 @@ def api_tickets(): job = Job.query.get(run.job_id) if run else None now = datetime.utcnow() - ticket_code = (payload.get("ticket_code") or "").strip().upper() - - if not ticket_code: - return jsonify({"status": "error", "message": "ticket_code is required."}), 400 - - # Validate format: TYYYYMMDD.#### - if not re.match(r"^T\d{8}\.\d{4}$", ticket_code): - return jsonify({"status": "error", "message": "Invalid ticket_code format. Expected TYYYYMMDD.####."}), 400 - - # Ensure uniqueness - if Ticket.query.filter_by(ticket_code=ticket_code).first(): - return jsonify({"status": "error", "message": "ticket_code already exists."}), 409 + code = _next_ticket_code(now) ticket = Ticket( - ticket_code=ticket_code, + ticket_code=code, title=None, description=description, active_from_date=_to_amsterdam_date(run.run_at) or _to_amsterdam_date(now) or now.date(), @@ -262,8 +250,21 @@ def api_tickets(): @login_required @roles_required("admin", "operator", "viewer") def api_ticket_update(ticket_id: int): - # Editing tickets is not allowed. Resolve the old ticket and create a new one instead. - return jsonify({"status": "error", "message": "Ticket editing is disabled. Resolve the old ticket and create a new one."}), 405 + if get_active_role() not in ("admin", "operator"): + return jsonify({"status": "error", "message": "Forbidden."}), 403 + + ticket = Ticket.query.get_or_404(ticket_id) + payload = request.get_json(silent=True) or {} + if "description" in payload: + ticket.description = (payload.get("description") or "").strip() or None + + try: + db.session.commit() + except Exception as exc: + db.session.rollback() + return jsonify({"status": "error", "message": str(exc) or "Failed to update ticket."}), 500 + + return jsonify({"status": "ok"}) @main_bp.route("/api/tickets//resolve", methods=["POST"]) @@ -419,8 +420,21 @@ def api_remarks(): @login_required @roles_required("admin", "operator", "viewer") def api_remark_update(remark_id: int): - # Editing remarks is not allowed. Resolve the old remark and create a new one instead. - return jsonify({"status": "error", "message": "Remark editing is disabled. Resolve the old remark and create a new one."}), 405 + if get_active_role() not in ("admin", "operator"): + return jsonify({"status": "error", "message": "Forbidden."}), 403 + + remark = Remark.query.get_or_404(remark_id) + payload = request.get_json(silent=True) or {} + if "body" in payload: + remark.body = (payload.get("body") or "").strip() or "" + + try: + db.session.commit() + except Exception as exc: + db.session.rollback() + return jsonify({"status": "error", "message": str(exc) or "Failed to update remark."}), 500 + + return jsonify({"status": "ok"}) @main_bp.route("/api/remarks//resolve", methods=["POST"]) @@ -475,4 +489,4 @@ def api_remark_link_run(remark_id: int): db.session.rollback() return jsonify({"status": "error", "message": str(exc) or "Failed to link run."}), 500 - return jsonify({"status": "ok"}) \ No newline at end of file + return jsonify({"status": "ok"}) diff --git a/containers/backupchecks/src/backend/app/main/routes_customers.py b/containers/backupchecks/src/backend/app/main/routes_customers.py index f4348ae..9e53b7d 100644 --- a/containers/backupchecks/src/backend/app/main/routes_customers.py +++ b/containers/backupchecks/src/backend/app/main/routes_customers.py @@ -100,9 +100,6 @@ 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 e1b325b..189d0b5 100644 --- a/containers/backupchecks/src/backend/app/main/routes_daily_jobs.py +++ b/containers/backupchecks/src/backend/app/main/routes_daily_jobs.py @@ -76,7 +76,6 @@ 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 2dbc85e..4e3e322 100644 --- a/containers/backupchecks/src/backend/app/main/routes_feedback.py +++ b/containers/backupchecks/src/backend/app/main/routes_feedback.py @@ -172,19 +172,6 @@ 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, @@ -192,42 +179,8 @@ 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 5440c9f..e3a38b8 100644 --- a/containers/backupchecks/src/backend/app/main/routes_inbox.py +++ b/containers/backupchecks/src/backend/app/main/routes_inbox.py @@ -69,8 +69,6 @@ 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"), ) @@ -298,62 +296,6 @@ 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 7d96507..8c559f6 100644 --- a/containers/backupchecks/src/backend/app/main/routes_jobs.py +++ b/containers/backupchecks/src/backend/app/main/routes_jobs.py @@ -16,7 +16,6 @@ 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, @@ -56,89 +55,6 @@ 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 fcc2480..2ceab03 100644 --- a/containers/backupchecks/src/backend/app/main/routes_remarks.py +++ b/containers/backupchecks/src/backend/app/main/routes_remarks.py @@ -1,13 +1,23 @@ from .routes_shared import * # noqa: F401,F403 from .routes_shared import _format_datetime -@main_bp.route("/remarks/", methods=["GET"]) +@main_bp.route("/remarks/", methods=["GET", "POST"]) @login_required @roles_required("admin", "operator", "viewer") def remark_detail(remark_id: int): remark = Remark.query.get_or_404(remark_id) - # Remark editing is disabled. Resolve the old remark and create a new one instead. + if request.method == "POST": + if get_active_role() not in ("admin", "operator"): + abort(403) + remark.body = (request.form.get("body") or "").strip() or "" + try: + db.session.commit() + flash("Remark updated.", "success") + except Exception as exc: + db.session.rollback() + flash(f"Failed to update remark: {exc}", "danger") + return redirect(url_for("main.remark_detail", remark_id=remark.id)) scopes = RemarkScope.query.filter(RemarkScope.remark_id == remark.id).order_by(RemarkScope.id.asc()).all() diff --git a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py index 8193bce..43c4968 100644 --- a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py @@ -331,17 +331,14 @@ 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 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"} - # Distinct values across jobs that actually have at least one run. rows = ( db.session.query(Job.backup_software, Job.backup_type) - .select_from(Job) - .join(JobRun, JobRun.job_id == Job.id) - .distinct() + .filter(Job.active.is_(True)) .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 0ec069b..3571dce 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.filter(Job.archived.is_(False)).all() + jobs = Job.query.all() today_local = _to_amsterdam_date(datetime.utcnow()) or datetime.utcnow().date() for job in jobs: @@ -222,7 +222,6 @@ 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 e73ee9e..9f3dcf4 100644 --- a/containers/backupchecks/src/backend/app/main/routes_shared.py +++ b/containers/backupchecks/src/backend/app/main/routes_shared.py @@ -50,7 +50,6 @@ 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 b4465e9..a1cc6f1 100644 --- a/containers/backupchecks/src/backend/app/main/routes_tickets.py +++ b/containers/backupchecks/src/backend/app/main/routes_tickets.py @@ -270,13 +270,23 @@ def tickets_page(): ) -@main_bp.route("/tickets/", methods=["GET"]) +@main_bp.route("/tickets/", methods=["GET", "POST"]) @login_required @roles_required("admin", "operator", "viewer") def ticket_detail(ticket_id: int): ticket = Ticket.query.get_or_404(ticket_id) - # Ticket editing is disabled. Resolve the old ticket and create a new one instead. + if request.method == "POST": + if get_active_role() not in ("admin", "operator"): + abort(403) + ticket.description = (request.form.get("description") or "").strip() or None + try: + db.session.commit() + flash("Ticket updated.", "success") + except Exception as exc: + db.session.rollback() + flash(f"Failed to update ticket: {exc}", "danger") + return redirect(url_for("main.ticket_detail", ticket_id=ticket.id)) # Scopes scopes = TicketScope.query.filter(TicketScope.ticket_id == ticket.id).order_by(TicketScope.id.asc()).all() diff --git a/containers/backupchecks/src/backend/app/migrations.py b/containers/backupchecks/src/backend/app/migrations.py index 589d718..49e304d 100644 --- a/containers/backupchecks/src/backend/app/migrations.py +++ b/containers/backupchecks/src/backend/app/migrations.py @@ -772,59 +772,17 @@ 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. @@ -979,40 +937,6 @@ 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. @@ -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( 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 7b0f26a..a66644c 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -196,12 +196,6 @@ 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 @@ -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): __tablename__ = "news_items" diff --git a/containers/backupchecks/src/backend/app/parsers/veeam.py b/containers/backupchecks/src/backend/app/parsers/veeam.py index 2b54e5e..71f8fd5 100644 --- a/containers/backupchecks/src/backend/app/parsers/veeam.py +++ b/containers/backupchecks/src/backend/app/parsers/veeam.py @@ -79,9 +79,7 @@ def _extract_configuration_job_overall_message(html: str) -> Optional[str]: for line in text.split("\n"): # Example: # 26-12-2025 10:00:23 Warning Skipping server certificate backup because encryption is disabled - # 6-1-2026 10:00:16 Warning Skipping credentials backup because encryption is disabled - # Veeam can format dates as either zero-padded (06-01-2026) or non-padded (6-1-2026). - if re.match(r"^\d{1,2}-\d{1,2}-\d{4}\s+\d{2}:\d{2}:\d{2}\s+(Warning|Failed|Error)\b", line): + if re.match(r"^\d{2}-\d{2}-\d{4}\s+\d{2}:\d{2}:\d{2}\s+(Warning|Failed|Error)\b", line): wanted_lines.append(line) if not wanted_lines: @@ -713,20 +711,6 @@ 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. @@ -882,10 +866,6 @@ 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": @@ -905,7 +885,7 @@ def try_parse_veeam(msg: MailMessage) -> Tuple[bool, Dict, List[Dict]]: result: Dict = { "backup_software": "Veeam", "backup_type": backup_type, - "job_name": job_name, + "job_name": _strip_retry_suffix(job_name), "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 # the "Processing " marker when present (used for overrides/rules). - # Veeam Backup for Microsoft 365 can include a meaningful overall warning/info - # even when the run is reported as Success (e.g. missing application - # permissions/roles). Store it so it becomes visible in details and can be - # used for overrides. - is_m365 = (backup_type or "") == "Veeam Backup for Microsoft 365" if overall_message: - if ( - status_word != "Success" - or overall_message.lower().startswith("processing ") - or is_m365 - ): + if status_word != "Success" or overall_message.lower().startswith("processing "): result["overall_message"] = overall_message return True, result, objects diff --git a/containers/backupchecks/src/templates/layout/base.html b/containers/backupchecks/src/templates/layout/base.html index a0cc910..5ae1890 100644 --- a/containers/backupchecks/src/templates/layout/base.html +++ b/containers/backupchecks/src/templates/layout/base.html @@ -82,11 +82,6 @@ - {% 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 4fc11a1..8a42b15 100644 --- a/containers/backupchecks/src/templates/main/daily_jobs.html +++ b/containers/backupchecks/src/templates/main/daily_jobs.html @@ -200,9 +200,9 @@
- +
-
+
@@ -356,13 +356,27 @@ '
' + '🎫' + '' + escapeHtml(t.ticket_code || '') + '' + - '' + status + '' + + '' + status + '' + '
' + + (t.description ? ('
' + escapeHtml(t.description) + '
') : '') + '
' + '
' + + '' + '' + '
' + '' + + '' + ''; }); html += ''; @@ -383,9 +397,22 @@ (r.body ? ('
' + escapeHtml(r.body) + '
') : '') + '' + '
' + + '' + '' + '
' + '' + + '' + ''; }); html += ''; @@ -400,6 +427,8 @@ var id = btn.getAttribute('data-id'); if (!action || !id) return; + var wrapper = btn.closest('[data-alert-type]'); + if (action === 'resolve-ticket') { if (!confirm('Mark ticket as resolved?')) return; apiJson('/api/tickets/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'}) @@ -410,6 +439,59 @@ apiJson('/api/remarks/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'}) .then(function () { loadAlerts(currentRunId); }) .catch(function (e) { alert(e.message || 'Failed.'); }); + } else if (action === 'toggle-edit-ticket') { + if (!wrapper) return; + var edit = wrapper.querySelector('[data-edit="ticket"]'); + if (!edit) return; + edit.style.display = (edit.style.display === 'none' || !edit.style.display) ? '' : 'none'; + } else if (action === 'toggle-edit-remark') { + if (!wrapper) return; + var edit2 = wrapper.querySelector('[data-edit="remark"]'); + if (!edit2) return; + edit2.style.display = (edit2.style.display === 'none' || !edit2.style.display) ? '' : 'none'; + } else if (action === 'cancel-edit') { + if (!wrapper) return; + var editAny = wrapper.querySelector('[data-edit]'); + if (editAny) editAny.style.display = 'none'; + } else if (action === 'save-ticket') { + if (!wrapper) return; + var editT = wrapper.querySelector('[data-edit="ticket"]'); + if (!editT) return; + var descEl = editT.querySelector('[data-field="description"]'); + var statusEl = editT.querySelector('[data-field="status"]'); + var descVal = descEl ? descEl.value : ''; + if (statusEl) statusEl.textContent = 'Saving...'; + apiJson('/api/tickets/' + encodeURIComponent(id), { + method: 'PATCH', + body: JSON.stringify({description: descVal}) + }) + .then(function () { loadAlerts(currentRunId); }) + .catch(function (e) { + if (statusEl) statusEl.textContent = e.message || 'Failed.'; + else alert(e.message || 'Failed.'); + }); + } else if (action === 'save-remark') { + if (!wrapper) return; + var editR = wrapper.querySelector('[data-edit="remark"]'); + if (!editR) return; + var bodyEl2 = editR.querySelector('[data-field="body"]'); + var statusEl2 = editR.querySelector('[data-field="status"]'); + var bodyVal2 = bodyEl2 ? bodyEl2.value : ''; + if (!bodyVal2 || !bodyVal2.trim()) { + if (statusEl2) statusEl2.textContent = 'Body is required.'; + else alert('Body is required.'); + return; + } + if (statusEl2) statusEl2.textContent = 'Saving...'; + apiJson('/api/remarks/' + encodeURIComponent(id), { + method: 'PATCH', + body: JSON.stringify({body: bodyVal2}) + }) + .then(function () { loadAlerts(currentRunId); }) + .catch(function (e) { + if (statusEl2) statusEl2.textContent = e.message || 'Failed.'; + else alert(e.message || 'Failed.'); + }); } }); }); @@ -446,8 +528,8 @@ function bindInlineCreateForms() { var btnTicket = document.getElementById('dj_ticket_save'); var btnRemark = document.getElementById('dj_remark_save'); - var tCode = document.getElementById('dj_ticket_code'); -var tStatus = document.getElementById('dj_ticket_status'); + var tDesc = document.getElementById('dj_ticket_description'); + var tStatus = document.getElementById('dj_ticket_status'); var rBody = document.getElementById('dj_remark_body'); var rStatus = document.getElementById('dj_remark_status'); @@ -459,8 +541,8 @@ var tStatus = document.getElementById('dj_ticket_status'); function setDisabled(disabled) { if (btnTicket) btnTicket.disabled = disabled; if (btnRemark) btnRemark.disabled = disabled; - if (tCode) tCode.disabled = disabled; -if (rBody) rBody.disabled = disabled; + if (tDesc) tDesc.disabled = disabled; + if (rBody) rBody.disabled = disabled; } window.__djSetCreateDisabled = setDisabled; @@ -470,25 +552,15 @@ if (rBody) rBody.disabled = disabled; btnTicket.addEventListener('click', function () { if (!currentRunId) { alert('Select a run first.'); return; } clearStatus(); - var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : ''; -if (!ticket_code) { - if (tStatus) tStatus.textContent = 'Ticket number is required.'; - else alert('Ticket number is required.'); - return; - } - if (!/^T\d{8}\.\d{4}$/.test(ticket_code)) { - if (tStatus) tStatus.textContent = 'Invalid ticket number format. Expected TYYYYMMDD.####.'; - else alert('Invalid ticket number format. Expected TYYYYMMDD.####.'); - return; - } + var description = tDesc ? tDesc.value : ''; if (tStatus) tStatus.textContent = 'Saving...'; apiJson('/api/tickets', { method: 'POST', - body: JSON.stringify({job_run_id: currentRunId, ticket_code: ticket_code}) + body: JSON.stringify({job_run_id: currentRunId, description: description}) }) .then(function () { - if (tCode) tCode.value = ''; -if (tStatus) tStatus.textContent = ''; + if (tDesc) tDesc.value = ''; + if (tStatus) tStatus.textContent = ''; loadAlerts(currentRunId); }) .catch(function (e) { diff --git a/containers/backupchecks/src/templates/main/feedback_detail.html b/containers/backupchecks/src/templates/main/feedback_detail.html index d6cebf7..729e2d7 100644 --- a/containers/backupchecks/src/templates/main/feedback_detail.html +++ b/containers/backupchecks/src/templates/main/feedback_detail.html @@ -49,46 +49,6 @@ -
-
-
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 760c141..95e8c78 100644 --- a/containers/backupchecks/src/templates/main/inbox.html +++ b/containers/backupchecks/src/templates/main/inbox.html @@ -56,26 +56,10 @@ {{ pager("top", page, total_pages, has_prev, has_next) }} -{% if can_bulk_delete %} -
-
- -
-
-
-{% endif %} - - -
- +
- {% if can_bulk_delete %} - - {% endif %} @@ -91,11 +75,6 @@ {% if rows %} {% for row in rows %} - {% if can_bulk_delete %} - - {% endif %} @@ -211,125 +190,6 @@ (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 d97f7c0..382a7bc 100644 --- a/containers/backupchecks/src/templates/main/job_detail.html +++ b/containers/backupchecks/src/templates/main/job_detail.html @@ -48,15 +48,9 @@ {% if can_manage_jobs %} -
-
- - - -
- - -
+ + + {% endif %}

Job history

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