diff --git a/containers/backupchecks/src/backend/app/cloud_connect_importer.py b/containers/backupchecks/src/backend/app/cloud_connect_importer.py index 2da6871..eaf5c58 100644 --- a/containers/backupchecks/src/backend/app/cloud_connect_importer.py +++ b/containers/backupchecks/src/backend/app/cloud_connect_importer.py @@ -224,20 +224,22 @@ def upsert_cloud_connect_report(mail_message_id: int, html_body: str) -> dict: counters = {"total": len(rows), "linked": 0, "unlinked": 0, "created": 0, "skipped": 0} for row in rows: - user = row["user"] - section = row["section"] + user = row["user"] + section = row["section"] + repo_name = row["repo_name"] or "" # never None — part of unique key - # Upsert the staging record — keyed on (user, section). - acc = CloudConnectAccount.query.filter_by(user=user, section=section).first() + # Upsert the staging record — keyed on (user, section, repo_name). + acc = CloudConnectAccount.query.filter_by(user=user, section=section, repo_name=repo_name).first() if acc is None: acc = CloudConnectAccount( user=user, section=section, + repo_name=repo_name, first_seen_at=now, ) db.session.add(acc) - acc.repo_name = row["repo_name"] + acc.repo_name = repo_name acc.repo_type = row["repo_type"] acc.num_items = row["num_items"] acc.total_quota = row["total_quota"] @@ -262,7 +264,8 @@ def upsert_cloud_connect_report(mail_message_id: int, html_body: str) -> dict: continue # Deduplicate: one run per job per report date. - external_id = f"vcc-{user}-{section}-{report_date}".lower().replace(" ", "_") + repo_slug = repo_name.lower().replace(" ", "_") + external_id = f"vcc-{user}-{section}-{repo_slug}-{report_date}".lower().replace(" ", "_") existing = JobRun.query.filter_by(job_id=job.id, external_id=external_id).first() if existing: diff --git a/containers/backupchecks/src/backend/app/main/routes_cloud_connect.py b/containers/backupchecks/src/backend/app/main/routes_cloud_connect.py index f4bb517..857bc73 100644 --- a/containers/backupchecks/src/backend/app/main/routes_cloud_connect.py +++ b/containers/backupchecks/src/backend/app/main/routes_cloud_connect.py @@ -40,7 +40,9 @@ def cloud_connect_accounts(): acc.derived_backup_type = ( "Cloud Connect Agent" if acc.section == "Agent" else "Cloud Connect Backup" ) - acc.derived_job_name = acc.user + # Use repo_name as the suggested job name if set (distinguishes multiple repos + # per user); fall back to user name when repo_name is absent. + acc.derived_job_name = acc.repo_name.strip() if acc.repo_name and acc.repo_name.strip() else acc.user return render_template( "main/cloud_connect_accounts.html", @@ -149,7 +151,7 @@ def cloud_connect_account_link(cc_account_db_id: int): customer = Customer.query.get_or_404(customer_id) - job_name = acc.user.strip() + job_name = (acc.repo_name.strip() if acc.repo_name and acc.repo_name.strip() else acc.user.strip()) backup_type = "Cloud Connect Agent" if acc.section == "Agent" else "Cloud Connect Backup" job = Job( diff --git a/containers/backupchecks/src/backend/app/migrations.py b/containers/backupchecks/src/backend/app/migrations.py index 83e631f..6bb3ae7 100644 --- a/containers/backupchecks/src/backend/app/migrations.py +++ b/containers/backupchecks/src/backend/app/migrations.py @@ -1312,6 +1312,60 @@ def migrate_cloud_connect_accounts_table() -> None: print(f"[migrations] Failed to migrate cloud_connect_accounts table: {exc}") +def migrate_cc_accounts_repo_unique_key() -> None: + """Extend the cloud_connect_accounts unique key to include repo_name. + + Old key: (user, section) + New key: (user, section, repo_name) + + This allows a single user to have multiple repository entries in the Cloud Connect + daily report (e.g. both a Veeam Cloud Connect Repository and an Immutable repository), + each linked to a separate Backupchecks job. + """ + try: + engine = db.get_engine() + except Exception as exc: + print(f"[migrations] Could not get engine for cc repo key migration: {exc}") + return + + try: + with engine.begin() as conn: + # Make repo_name NOT NULL with default '' (required for unique constraint). + conn.execute(text( + "UPDATE cloud_connect_accounts SET repo_name = '' WHERE repo_name IS NULL" + )) + conn.execute(text( + "ALTER TABLE cloud_connect_accounts ALTER COLUMN repo_name SET NOT NULL" + )) + conn.execute(text( + "ALTER TABLE cloud_connect_accounts ALTER COLUMN repo_name SET DEFAULT ''" + )) + + # Drop old (user, section) constraint if it still exists. + conn.execute(text( + "ALTER TABLE cloud_connect_accounts " + "DROP CONSTRAINT IF EXISTS uq_cloud_connect_accounts_user_section" + )) + + # Add new (user, section, repo_name) constraint if not already present. + conn.execute(text(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'uq_cloud_connect_accounts_user_section_repo' + ) THEN + ALTER TABLE cloud_connect_accounts + ADD CONSTRAINT uq_cloud_connect_accounts_user_section_repo + UNIQUE ("user", section, repo_name); + END IF; + END $$; + """)) + print("[migrations] migrate_cc_accounts_repo_unique_key completed.") + except Exception as exc: + print(f"[migrations] Failed migrate_cc_accounts_repo_unique_key: {exc}") + + def run_migrations() -> None: print("[migrations] Starting migrations...") migrate_add_username_to_users() @@ -1358,6 +1412,7 @@ def run_migrations() -> None: migrate_cove_integration() migrate_cove_accounts_table() migrate_cloud_connect_accounts_table() + migrate_cc_accounts_repo_unique_key() migrate_entra_sso_settings() print("[migrations] All migrations completed.") diff --git a/containers/backupchecks/src/backend/app/models.py b/containers/backupchecks/src/backend/app/models.py index 41bd859..83d2f64 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -399,7 +399,7 @@ class CloudConnectAccount(db.Model): user = db.Column(db.String(255), nullable=False) section = db.Column(db.String(32), nullable=False) - repo_name = db.Column(db.String(512), nullable=True) + repo_name = db.Column(db.String(512), nullable=False, default="") repo_type = db.Column(db.String(255), nullable=True) num_items = db.Column(db.String(64), nullable=True) total_quota = db.Column(db.String(32), nullable=True) @@ -418,7 +418,7 @@ class CloudConnectAccount(db.Model): job = db.relationship("Job", backref=db.backref("cloud_connect_account", uselist=False)) __table_args__ = ( - db.UniqueConstraint("user", "section", name="uq_cloud_connect_accounts_user_section"), + db.UniqueConstraint("user", "section", "repo_name", name="uq_cloud_connect_accounts_user_section_repo"), ) diff --git a/containers/backupchecks/src/templates/main/cloud_connect_accounts.html b/containers/backupchecks/src/templates/main/cloud_connect_accounts.html index 2893ee6..22611fe 100644 --- a/containers/backupchecks/src/templates/main/cloud_connect_accounts.html +++ b/containers/backupchecks/src/templates/main/cloud_connect_accounts.html @@ -33,7 +33,8 @@ data-id="{{ acc.id }}" data-user="{{ acc.user | e }}" data-section="{{ acc.section | e }}" - data-backup-type="{{ acc.derived_backup_type | e }}"> + data-backup-type="{{ acc.derived_backup_type | e }}" + data-job-name="{{ acc.derived_job_name | e }}"> {{ acc.user }} {{ acc.section }} {{ acc.repo_name or '—' }}
{{ acc.repo_type or '' }} @@ -236,11 +237,12 @@ var user = row.getAttribute('data-user'); var section = row.getAttribute('data-section'); var backupType = row.getAttribute('data-backup-type'); + var jobName = row.getAttribute('data-job-name') || user; var linkUrl = linkUrlTpl.replace('0', id); document.getElementById('ccLinkModalTitle').textContent = user + ' (' + section + ')'; document.getElementById('ccDisplayBackupType').textContent = backupType; - document.getElementById('ccDisplayJobName').textContent = user; + document.getElementById('ccDisplayJobName').textContent = jobName; var customerInput = document.getElementById('ccCustomerInput'); var customerIdField = document.getElementById('ccCustomerId'); diff --git a/docs/changelog-claude.md b/docs/changelog-claude.md index 2b4a93b..0882af0 100644 --- a/docs/changelog-claude.md +++ b/docs/changelog-claude.md @@ -5,6 +5,13 @@ This file documents all changes made to this project via Claude Code. ## [2026-03-20] ### Fixed +- Cloud Connect accounts: users with multiple repositories (e.g. Veeam Cloud Connect Repository + Cloud Connect Immutable) now get a separate staging account entry per repository instead of overwriting each other: + - `CloudConnectAccount` unique key changed from `(user, section)` to `(user, section, repo_name)` + - Migration `migrate_cc_accounts_repo_unique_key`: drops old constraint, makes `repo_name` NOT NULL (default `''`), adds new constraint + - Importer: upserts on `(user, section, repo_name)`; `external_id` now includes repo slug so each repo gets its own `JobRun` + - Job creation suggestion uses `repo_name` as the default job name (falls back to user when repo_name is empty) + - Cloud Connect accounts page: `data-job-name` attribute on row, modal reads it correctly + - Cloud Connect runs in job detail page popup now show a structured CC summary instead of the raw report email with all tenants: - `routes_inbox.py` (`inbox_message_detail`): accepts optional `?run_id=` parameter; when the run has `source_type = "cloud_connect"`, returns `cloud_connect_summary` dict and per-run objects from `run_object_links` instead of MailObjects - `job_detail.html`: passes `run_id` to the detail API; if `cloud_connect_summary` is returned, shows the CC summary panel, collapses the raw email (accessible via "show" toggle), and shows only the single per-run repository object