Add Cove Accounts inbox-style flow for linking accounts to jobs

- CoveAccount staging model: all Cove accounts upserted from API;
  unmatched accounts visible on /cove/accounts before job linking
- cove_importer.py: always upserts accounts, creates JobRuns only for
  accounts with a linked job (deduplication via external_id)
- routes_cove.py: GET /cove/accounts, POST link/unlink routes
- cove_accounts.html: inbox-style page with Bootstrap modals for
  creating new jobs or linking to existing ones
- Nav bar: Cove Accounts link for admin/operator when cove_enabled
- DB migration: migrate_cove_accounts_table() for staging table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ivo Oskamp 2026-02-23 10:40:54 +01:00
parent c045240001
commit 9b19283c97
8 changed files with 597 additions and 54 deletions

View File

@ -1,7 +1,13 @@
"""Cove Data Protection API importer. """Cove Data Protection API importer.
Fetches backup job run data from the Cove (N-able) API and creates Fetches backup job run data from the Cove (N-able) API.
JobRun records in the local database.
Flow (mirrors the mail Inbox flow):
1. All Cove accounts are upserted into the `cove_accounts` staging table.
2. Accounts without a linked job appear on the Cove Accounts page where
an admin can create or link a job (same as approving a mail from Inbox).
3. For accounts that have a linked job, a JobRun is created per new session
(deduplicated via external_id).
""" """
from __future__ import annotations from __future__ import annotations
@ -20,12 +26,12 @@ COVE_DEFAULT_URL = "https://api.backup.management/jsonapi"
# Columns to request from EnumerateAccountStatistics # Columns to request from EnumerateAccountStatistics
COVE_COLUMNS = [ COVE_COLUMNS = [
"I1", # Account name "I1", # Account/device name
"I18", # Account ID "I18", # Computer name
"I8", # Customer / partner name "I8", # Customer / partner name
"I78", # Backup type / product "I78", # Active datasource label
"D09F00", # Overall last session status "D09F00", # Overall last session status
"D09F09", # Last session start timestamp "D09F09", # Last successful session timestamp
"D09F15", # Last session end timestamp "D09F15", # Last session end timestamp
"D09F08", # 28-day colorbar "D09F08", # 28-day colorbar
# Datasource-specific status (F00) and last session time (F15) # Datasource-specific status (F00) and last session time (F15)
@ -40,20 +46,20 @@ COVE_COLUMNS = [
# Mapping from Cove status code to Backupchecks status string # Mapping from Cove status code to Backupchecks status string
STATUS_MAP: dict[int, str] = { STATUS_MAP: dict[int, str] = {
1: "Warning", # Running 1: "Warning", # In process
2: "Error", # Failed 2: "Error", # Failed
3: "Error", # Interrupted 3: "Error", # Aborted
5: "Success", # Completed 5: "Success", # Completed
6: "Error", # Overdue 6: "Error", # Interrupted
7: "Warning", # No recent backup 7: "Warning", # NotStarted
8: "Warning", # Idle 8: "Warning", # CompletedWithErrors
9: "Warning", # Unknown 9: "Warning", # InProgressWithFaults
10: "Error", # Account error 10: "Error", # OverQuota
11: "Warning", # Quota exceeded 11: "Warning", # NoSelection
12: "Warning", # License issue 12: "Warning", # Restarted
} }
# Datasource label mapping (prefix → human-readable label) # Datasource label mapping (column prefix → human-readable label)
DATASOURCE_LABELS: dict[str, str] = { DATASOURCE_LABELS: dict[str, str] = {
"D1": "Files & Folders", "D1": "Files & Folders",
"D10": "VssMsSql", "D10": "VssMsSql",
@ -167,7 +173,7 @@ def _cove_enumerate(
if result is None: if result is None:
return [] return []
# Unwrap possible nested result (same as test script) # Unwrap possible nested result
if isinstance(result, dict) and "result" in result: if isinstance(result, dict) and "result" in result:
result = result["result"] result = result["result"]
@ -184,7 +190,6 @@ def _flatten_settings(account: dict) -> dict:
Cove returns settings as a list of single-key dicts, e.g.: Cove returns settings as a list of single-key dicts, e.g.:
[{"D09F00": "5"}, {"I1": "device name"}, ...] [{"D09F00": "5"}, {"I1": "device name"}, ...]
This flattens them so we can do `flat['D09F00']` instead of iterating.
""" """
flat: dict[str, Any] = {} flat: dict[str, Any] = {}
settings_list = account.get("Settings") or [] settings_list = account.get("Settings") or []
@ -205,8 +210,25 @@ def _map_status(code: Any) -> str:
return "Warning" return "Warning"
def _ts_to_dt(value: Any) -> datetime | None:
"""Convert a Unix timestamp (int or str) to a naive UTC datetime."""
if value is None:
return None
try:
ts = int(value)
if ts <= 0:
return None
return datetime.fromtimestamp(ts, tz=timezone.utc).replace(tzinfo=None)
except (ValueError, TypeError, OSError):
return None
def run_cove_import(settings) -> tuple[int, int, int, int]: def run_cove_import(settings) -> tuple[int, int, int, int]:
"""Fetch Cove account statistics and create/update JobRun records. """Fetch Cove account statistics and update the staging table + JobRuns.
For every account:
- Upsert into cove_accounts (always)
- If the account has a linked job create a JobRun if not already seen
Args: Args:
settings: SystemSettings ORM object with cove_* fields. settings: SystemSettings ORM object with cove_* fields.
@ -217,8 +239,6 @@ def run_cove_import(settings) -> tuple[int, int, int, int]:
Raises: Raises:
CoveImportError if the API login fails. CoveImportError if the API login fails.
""" """
from .models import Job, JobRun # lazy import to avoid circular refs
url = (getattr(settings, "cove_api_url", None) or "").strip() or COVE_DEFAULT_URL url = (getattr(settings, "cove_api_url", None) or "").strip() or COVE_DEFAULT_URL
username = (getattr(settings, "cove_api_username", None) or "").strip() username = (getattr(settings, "cove_api_username", None) or "").strip()
password = (getattr(settings, "cove_api_password", None) or "").strip() password = (getattr(settings, "cove_api_password", None) or "").strip()
@ -258,8 +278,8 @@ def run_cove_import(settings) -> tuple[int, int, int, int]:
for account in accounts: for account in accounts:
total += 1 total += 1
try: try:
created_this = _process_account(account, settings) run_created = _process_account(account)
if created_this: if run_created:
created += 1 created += 1
else: else:
skipped += 1 skipped += 1
@ -285,17 +305,16 @@ def run_cove_import(settings) -> tuple[int, int, int, int]:
return total, created, skipped, errors return total, created, skipped, errors
def _process_account(account: dict, settings) -> bool: def _process_account(account: dict) -> bool:
"""Process a single Cove account and create a JobRun if needed. """Upsert a Cove account into the staging table and create a JobRun if linked.
Returns True if a new JobRun was created, False if skipped (duplicate or no job match). Returns True if a new JobRun was created, False otherwise.
""" """
from .models import Job, JobRun from .models import CoveAccount, JobRun
from sqlalchemy import text
flat = _flatten_settings(account) flat = _flatten_settings(account)
# AccountId is a top-level field (not in Settings) # AccountId is a top-level field
account_id = account.get("AccountId") or account.get("AccountID") account_id = account.get("AccountId") or account.get("AccountID")
if not account_id: if not account_id:
return False return False
@ -304,45 +323,69 @@ def _process_account(account: dict, settings) -> bool:
except (ValueError, TypeError): except (ValueError, TypeError):
return False return False
# Last session end timestamp (D09F15) used as run_at # Extract metadata from flat settings
run_ts_raw = flat.get("D09F15") account_name = (flat.get("I1") or "").strip() or None
if not run_ts_raw: computer_name = (flat.get("I18") or "").strip() or None
# No last run timestamp skip this account customer_name = (flat.get("I8") or "").strip() or None
return False datasource_types = (flat.get("I78") or "").strip() or None
last_run_at = _ts_to_dt(flat.get("D09F15"))
colorbar_28d = (flat.get("D09F08") or "").strip() or None
try: try:
run_ts = int(run_ts_raw) last_status_code = int(flat["D09F00"]) if flat.get("D09F00") is not None else None
except (ValueError, TypeError): except (ValueError, TypeError):
last_status_code = None
# Upsert into cove_accounts staging table
cove_acc = CoveAccount.query.filter_by(account_id=account_id).first()
if cove_acc is None:
cove_acc = CoveAccount(
account_id=account_id,
first_seen_at=datetime.utcnow(),
)
db.session.add(cove_acc)
cove_acc.account_name = account_name
cove_acc.computer_name = computer_name
cove_acc.customer_name = customer_name
cove_acc.datasource_types = datasource_types
cove_acc.last_status_code = last_status_code
cove_acc.last_run_at = last_run_at
cove_acc.colorbar_28d = colorbar_28d
cove_acc.last_seen_at = datetime.utcnow()
db.session.flush() # ensure cove_acc.id is set
# If not linked to a job yet, nothing more to do (shows up in Cove Accounts page)
if not cove_acc.job_id:
db.session.commit()
return False return False
if run_ts <= 0: # Account is linked: create a JobRun if the last session is new
if not last_run_at:
db.session.commit()
return False return False
# Deduplication key run_ts = int(flat.get("D09F15", 0))
external_id = f"cove-{account_id}-{run_ts}" external_id = f"cove-{account_id}-{run_ts}"
# Check for duplicate
existing = JobRun.query.filter_by(external_id=external_id).first() existing = JobRun.query.filter_by(external_id=external_id).first()
if existing: if existing:
db.session.commit()
return False return False
# Find corresponding Job # Fetch the linked job
job = Job.query.filter_by(cove_account_id=account_id).first() from .models import Job
job = Job.query.get(cove_acc.job_id)
if not job: if not job:
db.session.commit()
return False return False
# Convert Unix timestamp to datetime (UTC) status = _map_status(last_status_code)
run_at = datetime.fromtimestamp(run_ts, tz=timezone.utc).replace(tzinfo=None)
# Map overall status
status_code = flat.get("D09F00")
status = _map_status(status_code)
# Create JobRun
run = JobRun( run = JobRun(
job_id=job.id, job_id=job.id,
mail_message_id=None, mail_message_id=None,
run_at=run_at, run_at=last_run_at,
status=status, status=status,
remark=None, remark=None,
missed=False, missed=False,
@ -354,9 +397,8 @@ def _process_account(account: dict, settings) -> bool:
db.session.flush() # get run.id db.session.flush() # get run.id
# Persist per-datasource objects # Persist per-datasource objects
customer_id = job.customer_id if job.customer_id:
if customer_id: _persist_datasource_objects(flat, job.customer_id, job.id, run.id, last_run_at)
_persist_datasource_objects(flat, customer_id, job.id, run.id, run_at)
db.session.commit() db.session.commit()
return True return True

View File

@ -27,5 +27,6 @@ from . import routes_api # noqa: F401
from . import routes_reporting_api # noqa: F401 from . import routes_reporting_api # noqa: F401
from . import routes_user_settings # noqa: F401 from . import routes_user_settings # noqa: F401
from . import routes_search # noqa: F401 from . import routes_search # noqa: F401
from . import routes_cove # noqa: F401
__all__ = ["main_bp", "roles_required"] __all__ = ["main_bp", "roles_required"]

View File

@ -0,0 +1,179 @@
"""Cove Data Protection account review routes.
Mirrors the Inbox flow for mail messages:
/cove/accounts list all Cove accounts (unmatched first)
/cove/accounts/<id>/link link an account to an existing or new job
/cove/accounts/<id>/unlink remove the job link
"""
from .routes_shared import * # noqa: F401,F403
from .routes_shared import _log_admin_event
from ..models import CoveAccount, Customer, Job, SystemSettings
@main_bp.route("/cove/accounts")
@login_required
@roles_required("admin", "operator")
def cove_accounts():
settings = SystemSettings.query.first()
if not settings or not getattr(settings, "cove_enabled", False):
flash("Cove integration is not enabled.", "warning")
return redirect(url_for("main.settings", section="integrations"))
# Unmatched accounts (no job linked) shown first, like Inbox items
unmatched = (
CoveAccount.query
.filter(CoveAccount.job_id.is_(None))
.order_by(CoveAccount.customer_name.asc().nullslast(), CoveAccount.account_name.asc())
.all()
)
# Matched accounts
matched = (
CoveAccount.query
.filter(CoveAccount.job_id.isnot(None))
.order_by(CoveAccount.customer_name.asc().nullslast(), CoveAccount.account_name.asc())
.all()
)
customers = Customer.query.filter_by(active=True).order_by(Customer.name.asc()).all()
jobs = Job.query.filter_by(archived=False).order_by(Job.job_name.asc()).all()
return render_template(
"main/cove_accounts.html",
unmatched=unmatched,
matched=matched,
customers=customers,
jobs=jobs,
settings=settings,
STATUS_LABELS={
1: "In process", 2: "Failed", 3: "Aborted", 5: "Completed",
6: "Interrupted", 7: "Not started", 8: "Completed with errors",
9: "In progress with faults", 10: "Over quota",
11: "No selection", 12: "Restarted",
},
STATUS_CLASS={
1: "warning", 2: "danger", 3: "danger", 5: "success",
6: "danger", 7: "secondary", 8: "warning", 9: "warning",
10: "danger", 11: "warning", 12: "warning",
},
)
@main_bp.route("/cove/accounts/<int:cove_account_db_id>/link", methods=["POST"])
@login_required
@roles_required("admin", "operator")
def cove_account_link(cove_account_db_id: int):
"""Link a Cove account to a job (create a new one or select existing)."""
cove_acc = CoveAccount.query.get_or_404(cove_account_db_id)
action = (request.form.get("action") or "").strip() # "create" or "link"
if action == "create":
# Create a new job from the Cove account data
customer_id_raw = (request.form.get("customer_id") or "").strip()
if not customer_id_raw:
flash("Please select a customer.", "danger")
return redirect(url_for("main.cove_accounts"))
try:
customer_id = int(customer_id_raw)
except ValueError:
flash("Invalid customer selection.", "danger")
return redirect(url_for("main.cove_accounts"))
customer = Customer.query.get(customer_id)
if not customer:
flash("Customer not found.", "danger")
return redirect(url_for("main.cove_accounts"))
job_name = (request.form.get("job_name") or cove_acc.account_name or "").strip()
backup_type = (request.form.get("backup_type") or cove_acc.datasource_types or "Backup").strip()
job = Job(
customer_id=customer.id,
backup_software="Cove Data Protection",
backup_type=backup_type,
job_name=job_name,
cove_account_id=cove_acc.account_id,
active=True,
auto_approve=True,
)
db.session.add(job)
db.session.flush()
cove_acc.job_id = job.id
db.session.commit()
_log_admin_event(
"cove_account_linked",
f"Created job {job.id} and linked Cove account {cove_acc.account_id} ({cove_acc.account_name})",
details=f"customer={customer.name}, job_name={job_name}",
)
flash(
f"Job '{job_name}' created for customer '{customer.name}'. "
"Runs will appear after the next Cove import.",
"success",
)
elif action == "link":
# Link to an existing job
job_id_raw = (request.form.get("job_id") or "").strip()
if not job_id_raw:
flash("Please select a job.", "danger")
return redirect(url_for("main.cove_accounts"))
try:
job_id = int(job_id_raw)
except ValueError:
flash("Invalid job selection.", "danger")
return redirect(url_for("main.cove_accounts"))
job = Job.query.get(job_id)
if not job:
flash("Job not found.", "danger")
return redirect(url_for("main.cove_accounts"))
job.cove_account_id = cove_acc.account_id
cove_acc.job_id = job.id
db.session.commit()
_log_admin_event(
"cove_account_linked",
f"Linked Cove account {cove_acc.account_id} ({cove_acc.account_name}) to existing job {job.id}",
details=f"job_name={job.job_name}",
)
flash(
f"Cove account linked to job '{job.job_name}'. "
"Runs will appear after the next Cove import.",
"success",
)
else:
flash("Unknown action.", "warning")
return redirect(url_for("main.cove_accounts"))
@main_bp.route("/cove/accounts/<int:cove_account_db_id>/unlink", methods=["POST"])
@login_required
@roles_required("admin", "operator")
def cove_account_unlink(cove_account_db_id: int):
"""Remove the job link from a Cove account (puts it back in the unmatched list)."""
cove_acc = CoveAccount.query.get_or_404(cove_account_db_id)
old_job_id = cove_acc.job_id
if old_job_id:
job = Job.query.get(old_job_id)
if job and job.cove_account_id == cove_acc.account_id:
job.cove_account_id = None
cove_acc.job_id = None
db.session.commit()
_log_admin_event(
"cove_account_unlinked",
f"Unlinked Cove account {cove_acc.account_id} ({cove_acc.account_name}) from job {old_job_id}",
)
flash("Cove account unlinked.", "success")
return redirect(url_for("main.cove_accounts"))

View File

@ -1070,6 +1070,47 @@ def migrate_rename_admin_logs_to_audit_logs() -> None:
print("[migrations] audit_logs table will be created by db.create_all()") print("[migrations] audit_logs table will be created by db.create_all()")
def migrate_cove_accounts_table() -> None:
"""Create the cove_accounts staging table if it does not exist.
This table stores all accounts returned by Cove EnumerateAccountStatistics.
Unlinked accounts (job_id IS NULL) appear in the Cove Accounts review page.
"""
try:
engine = db.get_engine()
except Exception as exc:
print(f"[migrations] Could not get engine for cove_accounts migration: {exc}")
return
try:
with engine.begin() as conn:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS cove_accounts (
id SERIAL PRIMARY KEY,
account_id INTEGER NOT NULL UNIQUE,
account_name VARCHAR(512) NULL,
computer_name VARCHAR(512) NULL,
customer_name VARCHAR(255) NULL,
datasource_types VARCHAR(255) NULL,
last_status_code INTEGER NULL,
last_run_at TIMESTAMP NULL,
colorbar_28d VARCHAR(64) NULL,
job_id INTEGER NULL REFERENCES jobs(id) ON DELETE SET NULL,
first_seen_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMP NOT NULL DEFAULT NOW()
)
"""))
conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_cove_accounts_account_id ON cove_accounts (account_id)"
))
conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_cove_accounts_job_id ON cove_accounts (job_id)"
))
print("[migrations] migrate_cove_accounts_table completed.")
except Exception as exc:
print(f"[migrations] Failed to migrate cove_accounts table: {exc}")
def migrate_cove_integration() -> None: def migrate_cove_integration() -> None:
"""Add Cove Data Protection integration columns if missing. """Add Cove Data Protection integration columns if missing.
@ -1177,6 +1218,7 @@ def run_migrations() -> None:
migrate_system_settings_require_daily_dashboard_visit() migrate_system_settings_require_daily_dashboard_visit()
migrate_rename_admin_logs_to_audit_logs() migrate_rename_admin_logs_to_audit_logs()
migrate_cove_integration() migrate_cove_integration()
migrate_cove_accounts_table()
print("[migrations] All migrations completed.") print("[migrations] All migrations completed.")

View File

@ -252,8 +252,8 @@ class Job(db.Model):
auto_approve = db.Column(db.Boolean, nullable=False, default=True) auto_approve = db.Column(db.Boolean, nullable=False, default=True)
active = db.Column(db.Boolean, nullable=False, default=True) active = db.Column(db.Boolean, nullable=False, default=True)
# Cove Data Protection integration # Cove Data Protection integration (legacy: account ID stored directly on job)
cove_account_id = db.Column(db.Integer, nullable=True) # Cove AccountId mapping cove_account_id = db.Column(db.Integer, nullable=True) # kept for backwards compat
# Archived jobs are excluded from Daily Jobs and Run Checks. # Archived jobs are excluded from Daily Jobs and Run Checks.
# JobRuns remain in the database and are still included in reporting. # JobRuns remain in the database and are still included in reporting.
@ -331,6 +331,41 @@ class JobRun(db.Model):
autotask_ticket_created_by = db.relationship("User", foreign_keys=[autotask_ticket_created_by_user_id]) autotask_ticket_created_by = db.relationship("User", foreign_keys=[autotask_ticket_created_by_user_id])
class CoveAccount(db.Model):
"""Staging table for Cove Data Protection accounts.
All accounts returned by EnumerateAccountStatistics are upserted here.
Unlinked accounts (job_id IS NULL) appear in the Cove Accounts page
where an admin can create or link a job the same flow as the mail Inbox.
Once linked, the importer creates JobRuns for each new session.
"""
__tablename__ = "cove_accounts"
id = db.Column(db.Integer, primary_key=True)
# Cove account identifier (unique, from AccountId field)
account_id = db.Column(db.Integer, nullable=False, unique=True)
# Account/device info from Cove columns
account_name = db.Column(db.String(512), nullable=True) # I1 device/backup name
computer_name = db.Column(db.String(512), nullable=True) # I18 computer name
customer_name = db.Column(db.String(255), nullable=True) # I8 Cove customer/partner name
datasource_types = db.Column(db.String(255), nullable=True) # I78 active datasource label
# Last known status
last_status_code = db.Column(db.Integer, nullable=True) # D09F00
last_run_at = db.Column(db.DateTime, nullable=True) # D09F15 (converted from Unix ts)
colorbar_28d = db.Column(db.String(64), nullable=True) # D09F08
# Link to a Backupchecks job (NULL = unmatched, needs review)
job_id = db.Column(db.Integer, db.ForeignKey("jobs.id"), nullable=True)
first_seen_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
last_seen_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
job = db.relationship("Job", backref=db.backref("cove_account", uselist=False))
class JobRunReviewEvent(db.Model): class JobRunReviewEvent(db.Model):
__tablename__ = "job_run_review_events" __tablename__ = "job_run_review_events"

View File

@ -95,6 +95,11 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('main.inbox') }}">Inbox</a> <a class="nav-link" href="{{ url_for('main.inbox') }}">Inbox</a>
</li> </li>
{% if system_settings and system_settings.cove_enabled and active_role in ('admin', 'operator') %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.cove_accounts') }}">Cove Accounts</a>
</li>
{% endif %}
{% if active_role == 'admin' %} {% if active_role == 'admin' %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('main.admin_all_mails') }}">All Mail</a> <a class="nav-link" href="{{ url_for('main.admin_all_mails') }}">All Mail</a>

View File

@ -0,0 +1,231 @@
{% extends "layout/base.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">Cove Accounts</h2>
<div class="d-flex gap-2">
{% if settings.cove_partner_id %}
<form method="post" action="{{ url_for('main.settings_cove_run_now') }}" class="mb-0">
<button type="submit" class="btn btn-sm btn-outline-primary">Run import now</button>
</form>
{% endif %}
<a href="{{ url_for('main.settings', section='integrations') }}" class="btn btn-sm btn-outline-secondary">Cove settings</a>
</div>
</div>
{% if settings.cove_last_import_at %}
<p class="text-muted small mb-3">Last import: {{ settings.cove_last_import_at|local_datetime }}</p>
{% else %}
<p class="text-muted small mb-3">No import has run yet. Click <strong>Run import now</strong> to fetch Cove accounts.</p>
{% endif %}
{# ── Unmatched accounts (need a job) ─────────────────────────────────────── #}
{% if unmatched %}
<h4 class="mb-2">Unmatched <span class="badge bg-warning text-dark">{{ unmatched|length }}</span></h4>
<p class="text-muted small mb-3">These accounts have no linked job yet. Create a new job or link to an existing one.</p>
<div class="table-responsive mb-4">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>Account name</th>
<th>Computer</th>
<th>Customer (Cove)</th>
<th>Datasource</th>
<th>Last status</th>
<th>Last run</th>
<th>First seen</th>
<th></th>
</tr>
</thead>
<tbody>
{% for acc in unmatched %}
<tr>
<td>{{ acc.account_name or '—' }}</td>
<td class="text-muted small">{{ acc.computer_name or '—' }}</td>
<td>{{ acc.customer_name or '—' }}</td>
<td class="text-muted small">{{ acc.datasource_types or '—' }}</td>
<td>
{% if acc.last_status_code is not none %}
<span class="badge bg-{{ STATUS_CLASS.get(acc.last_status_code, 'secondary') }}">
{{ STATUS_LABELS.get(acc.last_status_code, acc.last_status_code) }}
</span>
{% else %}—{% endif %}
</td>
<td class="text-muted small">{{ acc.last_run_at|local_datetime if acc.last_run_at else '—' }}</td>
<td class="text-muted small">{{ acc.first_seen_at|local_datetime }}</td>
<td>
<button class="btn btn-sm btn-primary"
data-bs-toggle="modal"
data-bs-target="#link-modal-{{ acc.id }}">
Link / Create job
</button>
</td>
</tr>
{# Link modal #}
<div class="modal fade" id="link-modal-{{ acc.id }}" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Link: {{ acc.account_name or acc.account_id }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-3">
Cove account <strong>{{ acc.account_id }}</strong>
customer: <strong>{{ acc.customer_name or '?' }}</strong>
</p>
<ul class="nav nav-tabs mb-3" id="tab-{{ acc.id }}" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab"
data-bs-target="#create-{{ acc.id }}" type="button">
Create new job
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab"
data-bs-target="#existing-{{ acc.id }}" type="button">
Link to existing job
</button>
</li>
</ul>
<div class="tab-content">
{# Tab 1: Create new job #}
<div class="tab-pane fade show active" id="create-{{ acc.id }}">
<form method="post" action="{{ url_for('main.cove_account_link', cove_account_db_id=acc.id) }}">
<input type="hidden" name="action" value="create" />
<div class="mb-3">
<label class="form-label">Customer <span class="text-danger">*</span></label>
<select class="form-select" name="customer_id" required>
<option value="">Select customer…</option>
{% for c in customers %}
<option value="{{ c.id }}"
{% if acc.customer_name and acc.customer_name.lower() == c.name.lower() %}selected{% endif %}>
{{ c.name }}
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Job name</label>
<input type="text" class="form-control" name="job_name"
value="{{ acc.account_name or '' }}" />
<div class="form-text">Defaults to the Cove account name.</div>
</div>
<div class="mb-3">
<label class="form-label">Backup type</label>
<input type="text" class="form-control" name="backup_type"
value="{{ acc.datasource_types or 'Backup' }}" />
<div class="form-text">From Cove datasource info.</div>
</div>
<div class="d-flex justify-content-end gap-2">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Create job &amp; link</button>
</div>
</form>
</div>
{# Tab 2: Link to existing job #}
<div class="tab-pane fade" id="existing-{{ acc.id }}">
<form method="post" action="{{ url_for('main.cove_account_link', cove_account_db_id=acc.id) }}">
<input type="hidden" name="action" value="link" />
<div class="mb-3">
<label class="form-label">Job <span class="text-danger">*</span></label>
<select class="form-select" name="job_id" required>
<option value="">Select job…</option>
{% for j in jobs %}
<option value="{{ j.id }}">
{{ j.customer.name ~ ' ' if j.customer else '' }}{{ j.backup_software }} / {{ j.job_name }}
</option>
{% endfor %}
</select>
</div>
<div class="d-flex justify-content-end gap-2">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Link to job</button>
</div>
</form>
</div>
</div>{# /tab-content #}
</div>
</div>
</div>
</div>{# /modal #}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-success mb-4">
<strong>All accounts matched.</strong>
{% if not settings.cove_last_import_at %}
Run an import first to see Cove accounts here.
{% else %}
No unmatched Cove accounts.
{% endif %}
</div>
{% endif %}
{# ── Matched accounts ────────────────────────────────────────────────────── #}
{% if matched %}
<h4 class="mb-2">Linked <span class="badge bg-success">{{ matched|length }}</span></h4>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>Account name</th>
<th>Customer (Cove)</th>
<th>Datasource</th>
<th>Last status</th>
<th>Last run</th>
<th>Linked job</th>
<th></th>
</tr>
</thead>
<tbody>
{% for acc in matched %}
<tr>
<td>{{ acc.account_name or '—' }}</td>
<td>{{ acc.customer_name or '—' }}</td>
<td class="text-muted small">{{ acc.datasource_types or '—' }}</td>
<td>
{% if acc.last_status_code is not none %}
<span class="badge bg-{{ STATUS_CLASS.get(acc.last_status_code, 'secondary') }}">
{{ STATUS_LABELS.get(acc.last_status_code, acc.last_status_code) }}
</span>
{% else %}—{% endif %}
</td>
<td class="text-muted small">{{ acc.last_run_at|local_datetime if acc.last_run_at else '—' }}</td>
<td>
{% if acc.job %}
<a href="{{ url_for('main.job_detail', job_id=acc.job.id) }}">
{{ acc.job.customer.name ~ ' ' if acc.job.customer else '' }}{{ acc.job.job_name }}
</a>
{% else %}—{% endif %}
</td>
<td>
<form method="post"
action="{{ url_for('main.cove_account_unlink', cove_account_db_id=acc.id) }}"
onsubmit="return confirm('Remove link between this Cove account and the job?');"
class="mb-0">
<button type="submit" class="btn btn-sm btn-outline-secondary">Unlink</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if not unmatched and not matched %}
<div class="alert alert-info">
No Cove accounts found. Run an import first via the button above or via Settings → Integrations → Cove.
</div>
{% endif %}
{% endblock %}

View File

@ -17,6 +17,14 @@ This file documents all changes made to this project via Claude Code.
- Route `POST /settings/cove/test-connection` verifies Cove credentials and stores partner ID - Route `POST /settings/cove/test-connection` verifies Cove credentials and stores partner ID
- Route `POST /settings/cove/run-now` manually trigger a Cove import from the Settings page - Route `POST /settings/cove/run-now` manually trigger a Cove import from the Settings page
- Route `POST /jobs/<id>/set-cove-account` saves or clears Cove Account ID on a job - Route `POST /jobs/<id>/set-cove-account` saves or clears Cove Account ID on a job
- Cove Accounts inbox-style flow:
- `CoveAccount` model (staging table): stores all Cove accounts from API, with optional `job_id` link
- DB migration `migrate_cove_accounts_table()` creates `cove_accounts` table with indexes
- `cove_importer.py` updated: always upserts all accounts into staging table; JobRuns only created for accounts with a linked job
- `routes_cove.py` new routes: `GET /cove/accounts`, `POST /cove/accounts/<id>/link`, `POST /cove/accounts/<id>/unlink`
- `cove_accounts.html` inbox-style page: unmatched accounts shown first with "Link / Create job" modals (two tabs: create new job or link to existing), matched accounts listed below with Unlink button
- Nav bar: "Cove Accounts" link added for admin/operator roles when `cove_enabled`
- Route `POST /settings/cove/run-now` triggers manual import (button also shown on Cove Accounts page)
- `cove_api_test.py` standalone Python test script to verify Cove Data Protection API column codes - `cove_api_test.py` standalone Python test script to verify Cove Data Protection API column codes
- Tests D9Fxx (Total), D10Fxx (VssMsSql), D11Fxx (VssSharePoint), and D1Fxx (Files&Folders) - Tests D9Fxx (Total), D10Fxx (VssMsSql), D11Fxx (VssSharePoint), and D1Fxx (Files&Folders)
- Displays backup status (F00), timestamps (F09/F15/F18), error counts (F06) per account - Displays backup status (F00), timestamps (F09/F15/F18), error counts (F06) per account