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:
parent
c045240001
commit
9b19283c97
@ -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
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
179
containers/backupchecks/src/backend/app/main/routes_cove.py
Normal file
179
containers/backupchecks/src/backend/app/main/routes_cove.py
Normal 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"))
|
||||||
@ -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.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
231
containers/backupchecks/src/templates/main/cove_accounts.html
Normal file
231
containers/backupchecks/src/templates/main/cove_accounts.html
Normal 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 & 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 %}
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user