Add Cove Data Protection full integration
- New models: SystemSettings gets 8 cove_* fields, Job gets cove_account_id, JobRun gets source_type and external_id - Migration migrate_cove_integration() adds all new DB columns and a deduplication index on job_runs.external_id - cove_importer.py: Cove API login, paginated EnumerateAccountStatistics, deduplication via external_id, JobRun creation, per-datasource run_object_links persistence (Files&Folders, VssMsSql, M365, etc.) - cove_importer_service.py: background thread, same pattern as auto_importer_service, respects cove_import_interval_minutes - __init__.py: starts cove_importer thread on app startup - routes_settings.py: Cove form handling (POST), has_cove_password variable, new AJAX route /settings/cove/test-connection - routes_jobs.py: new route /jobs/<id>/set-cove-account, cove_enabled passed to job_detail template - settings.html: Cove card in Integrations tab with AJAX test button - job_detail.html: Cove Integration card with Account ID input Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dde2ccbb5d
commit
2f1cc20263
@ -13,6 +13,7 @@ from .main.routes import main_bp
|
|||||||
from .main.routes_documentation import doc_bp
|
from .main.routes_documentation import doc_bp
|
||||||
from .migrations import run_migrations
|
from .migrations import run_migrations
|
||||||
from .auto_importer_service import start_auto_importer
|
from .auto_importer_service import start_auto_importer
|
||||||
|
from .cove_importer_service import start_cove_importer
|
||||||
|
|
||||||
|
|
||||||
def _get_today_ui_date() -> str:
|
def _get_today_ui_date() -> str:
|
||||||
@ -212,4 +213,7 @@ def create_app():
|
|||||||
# Start automatic mail importer background thread
|
# Start automatic mail importer background thread
|
||||||
start_auto_importer(app)
|
start_auto_importer(app)
|
||||||
|
|
||||||
|
# Start Cove Data Protection importer background thread
|
||||||
|
start_cove_importer(app)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
414
containers/backupchecks/src/backend/app/cove_importer.py
Normal file
414
containers/backupchecks/src/backend/app/cove_importer.py
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
"""Cove Data Protection API importer.
|
||||||
|
|
||||||
|
Fetches backup job run data from the Cove (N-able) API and creates
|
||||||
|
JobRun records in the local database.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from .database import db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
COVE_DEFAULT_URL = "https://api.backup.management/jsonapi"
|
||||||
|
|
||||||
|
# Columns to request from EnumerateAccountStatistics
|
||||||
|
COVE_COLUMNS = [
|
||||||
|
"I1", # Account name
|
||||||
|
"I18", # Account ID
|
||||||
|
"I8", # Customer / partner name
|
||||||
|
"I78", # Backup type / product
|
||||||
|
"D09F00", # Overall last session status
|
||||||
|
"D09F09", # Last session start timestamp
|
||||||
|
"D09F15", # Last session end timestamp
|
||||||
|
"D09F08", # 28-day colorbar
|
||||||
|
# Datasource-specific status (F00) and last session time (F15)
|
||||||
|
"D1F00", "D1F15", # Files & Folders
|
||||||
|
"D10F00", "D10F15", # VssMsSql
|
||||||
|
"D11F00", "D11F15", # VssSharePoint
|
||||||
|
"D19F00", "D19F15", # M365 Exchange
|
||||||
|
"D20F00", "D20F15", # M365 OneDrive
|
||||||
|
"D5F00", "D5F15", # M365 SharePoint
|
||||||
|
"D23F00", "D23F15", # M365 Teams
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mapping from Cove status code to Backupchecks status string
|
||||||
|
STATUS_MAP: dict[int, str] = {
|
||||||
|
1: "Warning", # Running
|
||||||
|
2: "Error", # Failed
|
||||||
|
3: "Error", # Interrupted
|
||||||
|
5: "Success", # Completed
|
||||||
|
6: "Error", # Overdue
|
||||||
|
7: "Warning", # No recent backup
|
||||||
|
8: "Warning", # Idle
|
||||||
|
9: "Warning", # Unknown
|
||||||
|
10: "Error", # Account error
|
||||||
|
11: "Warning", # Quota exceeded
|
||||||
|
12: "Warning", # License issue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Datasource label mapping (prefix → human-readable label)
|
||||||
|
DATASOURCE_LABELS: dict[str, str] = {
|
||||||
|
"D1": "Files & Folders",
|
||||||
|
"D10": "VssMsSql",
|
||||||
|
"D11": "VssSharePoint",
|
||||||
|
"D19": "M365 Exchange",
|
||||||
|
"D20": "M365 OneDrive",
|
||||||
|
"D5": "M365 SharePoint",
|
||||||
|
"D23": "M365 Teams",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CoveImportError(Exception):
|
||||||
|
"""Raised when Cove API interaction fails."""
|
||||||
|
|
||||||
|
|
||||||
|
def _cove_login(url: str, username: str, password: str) -> tuple[str, int]:
|
||||||
|
"""Login to the Cove API and return (visa, partner_id).
|
||||||
|
|
||||||
|
Raises CoveImportError on failure.
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "Login",
|
||||||
|
"id": 1,
|
||||||
|
"params": {
|
||||||
|
"Username": username,
|
||||||
|
"Password": password,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=payload, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise CoveImportError(f"Cove login request failed: {exc}") from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise CoveImportError(f"Cove login response is not valid JSON: {exc}") from exc
|
||||||
|
|
||||||
|
result = data.get("result") or {}
|
||||||
|
if not result:
|
||||||
|
error = data.get("error") or {}
|
||||||
|
raise CoveImportError(f"Cove login failed: {error.get('message', 'unknown error')}")
|
||||||
|
|
||||||
|
visa = result.get("Visa") or ""
|
||||||
|
if not visa:
|
||||||
|
raise CoveImportError("Cove login succeeded but no Visa token returned")
|
||||||
|
|
||||||
|
# Partner/account info may be nested
|
||||||
|
account_info = result.get("Accounts", [{}])[0] if result.get("Accounts") else {}
|
||||||
|
partner_id = (
|
||||||
|
account_info.get("PartnerId")
|
||||||
|
or result.get("PartnerID")
|
||||||
|
or result.get("PartnerId")
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return visa, int(partner_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _cove_enumerate(
|
||||||
|
url: str,
|
||||||
|
visa: str,
|
||||||
|
partner_id: int,
|
||||||
|
start: int,
|
||||||
|
count: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Call EnumerateAccountStatistics and return a list of account dicts.
|
||||||
|
|
||||||
|
Returns empty list when no more results.
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "EnumerateAccountStatistics",
|
||||||
|
"id": 2,
|
||||||
|
"visa": visa,
|
||||||
|
"params": {
|
||||||
|
"Query": {
|
||||||
|
"PartnerId": partner_id,
|
||||||
|
"DisplayColumns": COVE_COLUMNS,
|
||||||
|
"StartRecordNumber": start,
|
||||||
|
"RecordCount": count,
|
||||||
|
"Columns": COVE_COLUMNS,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=payload, timeout=60)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise CoveImportError(f"Cove EnumerateAccountStatistics request failed: {exc}") from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise CoveImportError(f"Cove EnumerateAccountStatistics response is not valid JSON: {exc}") from exc
|
||||||
|
|
||||||
|
result = data.get("result") or {}
|
||||||
|
if not result:
|
||||||
|
error = data.get("error") or {}
|
||||||
|
raise CoveImportError(f"Cove EnumerateAccountStatistics failed: {error.get('message', 'unknown error')}")
|
||||||
|
|
||||||
|
return result.get("result", []) or []
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_settings(account: dict) -> dict:
|
||||||
|
"""Convert the Settings array in an account dict to a flat key→value dict.
|
||||||
|
|
||||||
|
Cove returns settings as a list of {Key, Value} objects.
|
||||||
|
This flattens them so we can do `flat['D09F00']` instead of iterating.
|
||||||
|
"""
|
||||||
|
flat: dict[str, Any] = {}
|
||||||
|
settings_list = account.get("Settings") or []
|
||||||
|
if isinstance(settings_list, list):
|
||||||
|
for item in settings_list:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
key = item.get("Key") or item.get("key")
|
||||||
|
value = item.get("Value") if "Value" in item else item.get("value")
|
||||||
|
if key is not None:
|
||||||
|
flat[str(key)] = value
|
||||||
|
return flat
|
||||||
|
|
||||||
|
|
||||||
|
def _map_status(code: Any) -> str:
|
||||||
|
"""Map a Cove status code (int) to a Backupchecks status string."""
|
||||||
|
if code is None:
|
||||||
|
return "Warning"
|
||||||
|
try:
|
||||||
|
return STATUS_MAP.get(int(code), "Warning")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return "Warning"
|
||||||
|
|
||||||
|
|
||||||
|
def run_cove_import(settings) -> tuple[int, int, int, int]:
|
||||||
|
"""Fetch Cove account statistics and create/update JobRun records.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
settings: SystemSettings ORM object with cove_* fields.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (total_accounts, created_runs, skipped_runs, error_count).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
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
|
||||||
|
username = (getattr(settings, "cove_api_username", None) or "").strip()
|
||||||
|
password = (getattr(settings, "cove_api_password", None) or "").strip()
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
raise CoveImportError("Cove API username or password not configured")
|
||||||
|
|
||||||
|
visa, partner_id = _cove_login(url, username, password)
|
||||||
|
|
||||||
|
# Save partner_id back to settings
|
||||||
|
if partner_id and partner_id != getattr(settings, "cove_partner_id", None):
|
||||||
|
settings.cove_partner_id = partner_id
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
db.session.rollback()
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
created = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
page_size = 250
|
||||||
|
start = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
accounts = _cove_enumerate(url, visa, partner_id, start, page_size)
|
||||||
|
except CoveImportError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise CoveImportError(f"Unexpected error fetching accounts at offset {start}: {exc}") from exc
|
||||||
|
|
||||||
|
if not accounts:
|
||||||
|
break
|
||||||
|
|
||||||
|
for account in accounts:
|
||||||
|
total += 1
|
||||||
|
try:
|
||||||
|
created_this = _process_account(account, settings)
|
||||||
|
if created_this:
|
||||||
|
created += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
except Exception as exc:
|
||||||
|
errors += 1
|
||||||
|
logger.warning("Cove import: error processing account: %s", exc)
|
||||||
|
try:
|
||||||
|
db.session.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if len(accounts) < page_size:
|
||||||
|
break
|
||||||
|
start += page_size
|
||||||
|
|
||||||
|
# Update last import timestamp
|
||||||
|
settings.cove_last_import_at = datetime.utcnow()
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
db.session.rollback()
|
||||||
|
|
||||||
|
return total, created, skipped, errors
|
||||||
|
|
||||||
|
|
||||||
|
def _process_account(account: dict, settings) -> bool:
|
||||||
|
"""Process a single Cove account and create a JobRun if needed.
|
||||||
|
|
||||||
|
Returns True if a new JobRun was created, False if skipped (duplicate or no job match).
|
||||||
|
"""
|
||||||
|
from .models import Job, JobRun
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
flat = _flatten_settings(account)
|
||||||
|
|
||||||
|
# AccountId – try both the top-level field and Settings
|
||||||
|
account_id = account.get("AccountId") or account.get("AccountID") or flat.get("I18")
|
||||||
|
if not account_id:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
account_id = int(account_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Last session end timestamp (D09F15) – used as run_at
|
||||||
|
run_ts_raw = flat.get("D09F15")
|
||||||
|
if not run_ts_raw:
|
||||||
|
# No last run timestamp – skip this account
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_ts = int(run_ts_raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if run_ts <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Deduplication key
|
||||||
|
external_id = f"cove-{account_id}-{run_ts}"
|
||||||
|
|
||||||
|
# Check for duplicate
|
||||||
|
existing = JobRun.query.filter_by(external_id=external_id).first()
|
||||||
|
if existing:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Find corresponding Job
|
||||||
|
job = Job.query.filter_by(cove_account_id=account_id).first()
|
||||||
|
if not job:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Convert Unix timestamp to datetime (UTC)
|
||||||
|
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(
|
||||||
|
job_id=job.id,
|
||||||
|
mail_message_id=None,
|
||||||
|
run_at=run_at,
|
||||||
|
status=status,
|
||||||
|
remark=None,
|
||||||
|
missed=False,
|
||||||
|
override_applied=False,
|
||||||
|
source_type="cove_api",
|
||||||
|
external_id=external_id,
|
||||||
|
)
|
||||||
|
db.session.add(run)
|
||||||
|
db.session.flush() # get run.id
|
||||||
|
|
||||||
|
# Persist per-datasource objects
|
||||||
|
customer_id = job.customer_id
|
||||||
|
if customer_id:
|
||||||
|
_persist_datasource_objects(flat, customer_id, job.id, run.id, run_at)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_datasource_objects(
|
||||||
|
flat: dict,
|
||||||
|
customer_id: int,
|
||||||
|
job_id: int,
|
||||||
|
run_id: int,
|
||||||
|
observed_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
"""Create run_object_links for each active datasource found in the account stats."""
|
||||||
|
engine = db.get_engine()
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for ds_prefix, ds_label in DATASOURCE_LABELS.items():
|
||||||
|
status_key = f"{ds_prefix}F00"
|
||||||
|
status_code = flat.get(status_key)
|
||||||
|
if status_code is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
status = _map_status(status_code)
|
||||||
|
|
||||||
|
# Upsert customer_objects
|
||||||
|
customer_object_id = conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO customer_objects (customer_id, object_name, object_type, first_seen_at, last_seen_at)
|
||||||
|
VALUES (:customer_id, :object_name, :object_type, NOW(), NOW())
|
||||||
|
ON CONFLICT (customer_id, object_name)
|
||||||
|
DO UPDATE SET
|
||||||
|
last_seen_at = NOW(),
|
||||||
|
object_type = COALESCE(EXCLUDED.object_type, customer_objects.object_type)
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"object_name": ds_label,
|
||||||
|
"object_type": "cove_datasource",
|
||||||
|
},
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
# Upsert job_object_links
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO job_object_links (job_id, customer_object_id, first_seen_at, last_seen_at)
|
||||||
|
VALUES (:job_id, :customer_object_id, NOW(), NOW())
|
||||||
|
ON CONFLICT (job_id, customer_object_id)
|
||||||
|
DO UPDATE SET last_seen_at = NOW()
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"job_id": job_id, "customer_object_id": customer_object_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upsert run_object_links
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO run_object_links (run_id, customer_object_id, status, error_message, observed_at)
|
||||||
|
VALUES (:run_id, :customer_object_id, :status, NULL, :observed_at)
|
||||||
|
ON CONFLICT (run_id, customer_object_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
observed_at = EXCLUDED.observed_at
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"run_id": run_id,
|
||||||
|
"customer_object_id": customer_object_id,
|
||||||
|
"status": status,
|
||||||
|
"observed_at": observed_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
101
containers/backupchecks/src/backend/app/cove_importer_service.py
Normal file
101
containers/backupchecks/src/backend/app/cove_importer_service.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
"""Cove Data Protection importer background service.
|
||||||
|
|
||||||
|
Runs a background thread that periodically fetches backup job run data
|
||||||
|
from the Cove API and creates JobRun records in the local database.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .admin_logging import log_admin_event
|
||||||
|
from .cove_importer import CoveImportError, run_cove_import
|
||||||
|
from .models import SystemSettings
|
||||||
|
|
||||||
|
|
||||||
|
_COVE_IMPORTER_THREAD_NAME = "cove_importer"
|
||||||
|
|
||||||
|
|
||||||
|
def start_cove_importer(app) -> None:
|
||||||
|
"""Start the Cove importer background thread.
|
||||||
|
|
||||||
|
The thread checks settings on every loop and only runs imports when
|
||||||
|
enabled and the configured interval has elapsed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Avoid starting multiple threads if create_app() is called more than once.
|
||||||
|
if any(t.name == _COVE_IMPORTER_THREAD_NAME for t in threading.enumerate()):
|
||||||
|
return
|
||||||
|
|
||||||
|
def _worker() -> None:
|
||||||
|
last_run_at: datetime | None = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
with app.app_context():
|
||||||
|
settings = SystemSettings.query.first()
|
||||||
|
if settings is None:
|
||||||
|
time.sleep(10)
|
||||||
|
continue
|
||||||
|
|
||||||
|
enabled = bool(getattr(settings, "cove_import_enabled", False))
|
||||||
|
try:
|
||||||
|
interval_minutes = int(getattr(settings, "cove_import_interval_minutes", 30) or 30)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
interval_minutes = 30
|
||||||
|
if interval_minutes < 1:
|
||||||
|
interval_minutes = 1
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
due = False
|
||||||
|
if enabled:
|
||||||
|
if last_run_at is None:
|
||||||
|
due = True
|
||||||
|
else:
|
||||||
|
due = (now - last_run_at).total_seconds() >= (interval_minutes * 60)
|
||||||
|
|
||||||
|
if not due:
|
||||||
|
time.sleep(5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
total, created, skipped, errors = run_cove_import(settings)
|
||||||
|
except CoveImportError as exc:
|
||||||
|
log_admin_event(
|
||||||
|
"cove_import_error",
|
||||||
|
f"Cove import failed: {exc}",
|
||||||
|
)
|
||||||
|
last_run_at = now
|
||||||
|
time.sleep(5)
|
||||||
|
continue
|
||||||
|
except Exception as exc:
|
||||||
|
log_admin_event(
|
||||||
|
"cove_import_error",
|
||||||
|
f"Unexpected error during Cove import: {exc}",
|
||||||
|
)
|
||||||
|
last_run_at = now
|
||||||
|
time.sleep(5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
log_admin_event(
|
||||||
|
"cove_import",
|
||||||
|
f"Cove import finished. accounts={total}, created={created}, skipped={skipped}, errors={errors}",
|
||||||
|
)
|
||||||
|
last_run_at = now
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# Never let the thread die.
|
||||||
|
try:
|
||||||
|
with app.app_context():
|
||||||
|
log_admin_event(
|
||||||
|
"cove_import_error",
|
||||||
|
"Cove importer thread recovered from an unexpected exception.",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
t = threading.Thread(target=_worker, name=_COVE_IMPORTER_THREAD_NAME, daemon=True)
|
||||||
|
t.start()
|
||||||
@ -187,6 +187,35 @@ def unarchive_job(job_id: int):
|
|||||||
return redirect(url_for("main.archived_jobs"))
|
return redirect(url_for("main.archived_jobs"))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/jobs/<int:job_id>/set-cove-account", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin", "operator")
|
||||||
|
def job_set_cove_account(job_id: int):
|
||||||
|
"""Save or clear the Cove Account ID for this job."""
|
||||||
|
job = Job.query.get_or_404(job_id)
|
||||||
|
account_id_raw = (request.form.get("cove_account_id") or "").strip()
|
||||||
|
if account_id_raw:
|
||||||
|
try:
|
||||||
|
job.cove_account_id = int(account_id_raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
flash("Invalid Cove Account ID – must be a number.", "warning")
|
||||||
|
return redirect(url_for("main.job_detail", job_id=job_id))
|
||||||
|
else:
|
||||||
|
job.cove_account_id = None
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
try:
|
||||||
|
log_admin_event(
|
||||||
|
"job_cove_account_set",
|
||||||
|
f"Set Cove Account ID for job {job.id} to {job.cove_account_id!r}",
|
||||||
|
details=f"job_name={job.job_name}",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
flash("Cove Account ID saved.", "success")
|
||||||
|
return redirect(url_for("main.job_detail", job_id=job_id))
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/jobs/<int:job_id>")
|
@main_bp.route("/jobs/<int:job_id>")
|
||||||
@login_required
|
@login_required
|
||||||
@roles_required("admin", "operator", "viewer")
|
@roles_required("admin", "operator", "viewer")
|
||||||
@ -491,6 +520,11 @@ def job_detail(job_id: int):
|
|||||||
if job.customer_id:
|
if job.customer_id:
|
||||||
customer = Customer.query.get(job.customer_id)
|
customer = Customer.query.get(job.customer_id)
|
||||||
|
|
||||||
|
# Load system settings for Cove integration display
|
||||||
|
from ..models import SystemSettings as _SystemSettings
|
||||||
|
_settings = _SystemSettings.query.first()
|
||||||
|
cove_enabled = bool(getattr(_settings, "cove_enabled", False)) if _settings else False
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"main/job_detail.html",
|
"main/job_detail.html",
|
||||||
job=job,
|
job=job,
|
||||||
@ -507,6 +541,7 @@ def job_detail(job_id: int):
|
|||||||
has_prev=has_prev,
|
has_prev=has_prev,
|
||||||
has_next=has_next,
|
has_next=has_next,
|
||||||
can_manage_jobs=can_manage_jobs,
|
can_manage_jobs=can_manage_jobs,
|
||||||
|
cove_enabled=cove_enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -786,6 +786,7 @@ def settings():
|
|||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
autotask_form_touched = any(str(k).startswith("autotask_") for k in (request.form or {}).keys())
|
autotask_form_touched = any(str(k).startswith("autotask_") for k in (request.form or {}).keys())
|
||||||
|
cove_form_touched = any(str(k).startswith("cove_") for k in (request.form or {}).keys())
|
||||||
import_form_touched = any(str(k).startswith("auto_import_") or str(k).startswith("manual_import_") or str(k).startswith("ingest_eml_") for k in (request.form or {}).keys())
|
import_form_touched = any(str(k).startswith("auto_import_") or str(k).startswith("manual_import_") or str(k).startswith("ingest_eml_") for k in (request.form or {}).keys())
|
||||||
general_form_touched = "ui_timezone" in request.form
|
general_form_touched = "ui_timezone" in request.form
|
||||||
mail_form_touched = any(k in request.form for k in ["graph_tenant_id", "graph_client_id", "graph_mailbox", "incoming_folder", "processed_folder"])
|
mail_form_touched = any(k in request.form for k in ["graph_tenant_id", "graph_client_id", "graph_mailbox", "incoming_folder", "processed_folder"])
|
||||||
@ -908,6 +909,31 @@ def settings():
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Cove Data Protection integration
|
||||||
|
if cove_form_touched:
|
||||||
|
settings.cove_enabled = bool(request.form.get("cove_enabled"))
|
||||||
|
settings.cove_import_enabled = bool(request.form.get("cove_import_enabled"))
|
||||||
|
|
||||||
|
if "cove_api_url" in request.form:
|
||||||
|
settings.cove_api_url = (request.form.get("cove_api_url") or "").strip() or None
|
||||||
|
|
||||||
|
if "cove_api_username" in request.form:
|
||||||
|
settings.cove_api_username = (request.form.get("cove_api_username") or "").strip() or None
|
||||||
|
|
||||||
|
if "cove_api_password" in request.form:
|
||||||
|
pw = (request.form.get("cove_api_password") or "").strip()
|
||||||
|
if pw:
|
||||||
|
settings.cove_api_password = pw
|
||||||
|
|
||||||
|
if "cove_import_interval_minutes" in request.form:
|
||||||
|
try:
|
||||||
|
interval = int(request.form.get("cove_import_interval_minutes") or 30)
|
||||||
|
if interval < 1:
|
||||||
|
interval = 1
|
||||||
|
settings.cove_import_interval_minutes = interval
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
# Daily Jobs
|
# Daily Jobs
|
||||||
if "daily_jobs_start_date" in request.form:
|
if "daily_jobs_start_date" in request.form:
|
||||||
daily_jobs_start_date_str = (request.form.get("daily_jobs_start_date") or "").strip()
|
daily_jobs_start_date_str = (request.form.get("daily_jobs_start_date") or "").strip()
|
||||||
@ -1119,6 +1145,7 @@ def settings():
|
|||||||
|
|
||||||
has_client_secret = bool(settings.graph_client_secret)
|
has_client_secret = bool(settings.graph_client_secret)
|
||||||
has_autotask_password = bool(getattr(settings, "autotask_api_password", None))
|
has_autotask_password = bool(getattr(settings, "autotask_api_password", None))
|
||||||
|
has_cove_password = bool(getattr(settings, "cove_api_password", None))
|
||||||
|
|
||||||
# Common UI timezones (IANA names)
|
# Common UI timezones (IANA names)
|
||||||
tz_options = [
|
tz_options = [
|
||||||
@ -1244,6 +1271,7 @@ def settings():
|
|||||||
free_disk_warning=free_disk_warning,
|
free_disk_warning=free_disk_warning,
|
||||||
has_client_secret=has_client_secret,
|
has_client_secret=has_client_secret,
|
||||||
has_autotask_password=has_autotask_password,
|
has_autotask_password=has_autotask_password,
|
||||||
|
has_cove_password=has_cove_password,
|
||||||
tz_options=tz_options,
|
tz_options=tz_options,
|
||||||
users=users,
|
users=users,
|
||||||
admin_users_count=admin_users_count,
|
admin_users_count=admin_users_count,
|
||||||
@ -1258,6 +1286,44 @@ def settings():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/settings/cove/test-connection", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin")
|
||||||
|
def settings_cove_test_connection():
|
||||||
|
"""Test the Cove Data Protection API connection and return JSON result."""
|
||||||
|
from flask import jsonify
|
||||||
|
from ..cove_importer import CoveImportError, _cove_login, COVE_DEFAULT_URL
|
||||||
|
|
||||||
|
settings = _get_or_create_settings()
|
||||||
|
|
||||||
|
username = (getattr(settings, "cove_api_username", None) or "").strip()
|
||||||
|
password = (getattr(settings, "cove_api_password", None) or "").strip()
|
||||||
|
url = (getattr(settings, "cove_api_url", None) or "").strip() or COVE_DEFAULT_URL
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return jsonify({"ok": False, "message": "Cove API username and password must be saved first."})
|
||||||
|
|
||||||
|
try:
|
||||||
|
visa, partner_id = _cove_login(url, username, password)
|
||||||
|
# Store the partner_id
|
||||||
|
settings.cove_partner_id = partner_id
|
||||||
|
db.session.commit()
|
||||||
|
_log_admin_event(
|
||||||
|
"cove_test_connection",
|
||||||
|
f"Cove connection test succeeded. Partner ID: {partner_id}",
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"partner_id": partner_id,
|
||||||
|
"message": f"Connected – Partner ID: {partner_id}",
|
||||||
|
})
|
||||||
|
except CoveImportError as exc:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({"ok": False, "message": str(exc)})
|
||||||
|
except Exception as exc:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({"ok": False, "message": f"Unexpected error: {exc}"})
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/settings/news/create", methods=["POST"])
|
@main_bp.route("/settings/news/create", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@ -1070,6 +1070,70 @@ 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_integration() -> None:
|
||||||
|
"""Add Cove Data Protection integration columns if missing.
|
||||||
|
|
||||||
|
Adds to system_settings:
|
||||||
|
- cove_enabled (BOOLEAN NOT NULL DEFAULT FALSE)
|
||||||
|
- cove_api_url (VARCHAR(255) NULL)
|
||||||
|
- cove_api_username (VARCHAR(255) NULL)
|
||||||
|
- cove_api_password (VARCHAR(255) NULL)
|
||||||
|
- cove_import_enabled (BOOLEAN NOT NULL DEFAULT FALSE)
|
||||||
|
- cove_import_interval_minutes (INTEGER NOT NULL DEFAULT 30)
|
||||||
|
- cove_partner_id (INTEGER NULL)
|
||||||
|
- cove_last_import_at (TIMESTAMP NULL)
|
||||||
|
|
||||||
|
Adds to jobs:
|
||||||
|
- cove_account_id (INTEGER NULL)
|
||||||
|
|
||||||
|
Adds to job_runs:
|
||||||
|
- source_type (VARCHAR(20) NULL)
|
||||||
|
- external_id (VARCHAR(100) NULL)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
engine = db.get_engine()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[migrations] Could not get engine for Cove integration migration: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
# system_settings columns
|
||||||
|
ss_columns = [
|
||||||
|
("cove_enabled", "BOOLEAN NOT NULL DEFAULT FALSE"),
|
||||||
|
("cove_api_url", "VARCHAR(255) NULL"),
|
||||||
|
("cove_api_username", "VARCHAR(255) NULL"),
|
||||||
|
("cove_api_password", "VARCHAR(255) NULL"),
|
||||||
|
("cove_import_enabled", "BOOLEAN NOT NULL DEFAULT FALSE"),
|
||||||
|
("cove_import_interval_minutes", "INTEGER NOT NULL DEFAULT 30"),
|
||||||
|
("cove_partner_id", "INTEGER NULL"),
|
||||||
|
("cove_last_import_at", "TIMESTAMP NULL"),
|
||||||
|
]
|
||||||
|
for column, ddl in ss_columns:
|
||||||
|
if _column_exists_on_conn(conn, "system_settings", column):
|
||||||
|
continue
|
||||||
|
conn.execute(text(f'ALTER TABLE "system_settings" ADD COLUMN {column} {ddl}'))
|
||||||
|
|
||||||
|
# jobs column
|
||||||
|
if not _column_exists_on_conn(conn, "jobs", "cove_account_id"):
|
||||||
|
conn.execute(text('ALTER TABLE "jobs" ADD COLUMN cove_account_id INTEGER NULL'))
|
||||||
|
|
||||||
|
# job_runs columns
|
||||||
|
if not _column_exists_on_conn(conn, "job_runs", "source_type"):
|
||||||
|
conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN source_type VARCHAR(20) NULL'))
|
||||||
|
if not _column_exists_on_conn(conn, "job_runs", "external_id"):
|
||||||
|
conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN external_id VARCHAR(100) NULL'))
|
||||||
|
|
||||||
|
# Index for deduplication lookups
|
||||||
|
conn.execute(text(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_job_runs_external_id ON "job_runs" (external_id)'
|
||||||
|
))
|
||||||
|
|
||||||
|
print("[migrations] migrate_cove_integration completed.")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[migrations] Failed to migrate Cove integration columns: {exc}")
|
||||||
|
|
||||||
|
|
||||||
def run_migrations() -> None:
|
def run_migrations() -> None:
|
||||||
print("[migrations] Starting migrations...")
|
print("[migrations] Starting migrations...")
|
||||||
migrate_add_username_to_users()
|
migrate_add_username_to_users()
|
||||||
@ -1112,6 +1176,7 @@ def run_migrations() -> None:
|
|||||||
migrate_performance_indexes()
|
migrate_performance_indexes()
|
||||||
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()
|
||||||
print("[migrations] All migrations completed.")
|
print("[migrations] All migrations completed.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -117,6 +117,16 @@ class SystemSettings(db.Model):
|
|||||||
# this is not a production environment.
|
# this is not a production environment.
|
||||||
is_sandbox_environment = db.Column(db.Boolean, nullable=False, default=False)
|
is_sandbox_environment = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
# Cove Data Protection integration settings
|
||||||
|
cove_enabled = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
cove_api_url = db.Column(db.String(255), nullable=True) # default: https://api.backup.management/jsonapi
|
||||||
|
cove_api_username = db.Column(db.String(255), nullable=True)
|
||||||
|
cove_api_password = db.Column(db.String(255), nullable=True)
|
||||||
|
cove_import_enabled = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
cove_import_interval_minutes = db.Column(db.Integer, nullable=False, default=30)
|
||||||
|
cove_partner_id = db.Column(db.Integer, nullable=True) # stored after successful login
|
||||||
|
cove_last_import_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
# Autotask integration settings
|
# Autotask integration settings
|
||||||
autotask_enabled = db.Column(db.Boolean, nullable=False, default=False)
|
autotask_enabled = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
autotask_environment = db.Column(db.String(32), nullable=True) # sandbox | production
|
autotask_environment = db.Column(db.String(32), nullable=True) # sandbox | production
|
||||||
@ -242,6 +252,9 @@ 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_account_id = db.Column(db.Integer, nullable=True) # Cove AccountId mapping
|
||||||
|
|
||||||
# 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.
|
||||||
archived = db.Column(db.Boolean, nullable=False, default=False)
|
archived = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
@ -290,6 +303,10 @@ class JobRun(db.Model):
|
|||||||
reviewed_at = db.Column(db.DateTime, nullable=True)
|
reviewed_at = db.Column(db.DateTime, nullable=True)
|
||||||
reviewed_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
reviewed_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
# Import source tracking
|
||||||
|
source_type = db.Column(db.String(20), nullable=True) # NULL = email (backwards compat), "cove_api"
|
||||||
|
external_id = db.Column(db.String(100), nullable=True) # e.g. "cove-{account_id}-{run_ts}" for deduplication
|
||||||
|
|
||||||
# Autotask integration (Phase 4: ticket creation from Run Checks)
|
# Autotask integration (Phase 4: ticket creation from Run Checks)
|
||||||
autotask_ticket_id = db.Column(db.Integer, nullable=True)
|
autotask_ticket_id = db.Column(db.Integer, nullable=True)
|
||||||
autotask_ticket_number = db.Column(db.String(64), nullable=True)
|
autotask_ticket_number = db.Column(db.String(64), nullable=True)
|
||||||
|
|||||||
@ -59,6 +59,34 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if cove_enabled and can_manage_jobs %}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">Cove Integration</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="{{ url_for('main.job_set_cove_account', job_id=job.id) }}" class="row g-2 align-items-end mb-0">
|
||||||
|
<div class="col-auto">
|
||||||
|
<label for="cove_account_id" class="form-label mb-1">Cove Account ID</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" id="cove_account_id" name="cove_account_id"
|
||||||
|
value="{{ job.cove_account_id or '' }}" placeholder="e.g. 4504627" style="width: 180px;" />
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">Save</button>
|
||||||
|
{% if job.cove_account_id %}
|
||||||
|
<button type="submit" name="cove_account_id" value="" class="btn btn-sm btn-outline-secondary ms-1">Clear</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-auto text-muted small">
|
||||||
|
{% if job.cove_account_id %}
|
||||||
|
Linked to Cove account <strong>{{ job.cove_account_id }}</strong>
|
||||||
|
{% else %}
|
||||||
|
Not linked to a Cove account – runs will not be imported automatically.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<h3 class="mt-4 mb-3">Job history</h3>
|
<h3 class="mt-4 mb-3">Job history</h3>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
|
|||||||
@ -504,6 +504,106 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if section == 'integrations' %}
|
||||||
|
<form method="post" class="mb-4" id="cove-settings-form">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">Cove Data Protection (N-able)</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="cove_enabled" name="cove_enabled" {% if settings.cove_enabled %}checked{% endif %} />
|
||||||
|
<label class="form-check-label" for="cove_enabled">Enable Cove integration</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label for="cove_api_url" class="form-label">API URL</label>
|
||||||
|
<input type="url" class="form-control" id="cove_api_url" name="cove_api_url"
|
||||||
|
value="{{ settings.cove_api_url or '' }}"
|
||||||
|
placeholder="https://api.backup.management/jsonapi" />
|
||||||
|
<div class="form-text">Leave empty to use the default Cove API endpoint.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="cove_api_username" class="form-label">API Username <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="cove_api_username" name="cove_api_username"
|
||||||
|
value="{{ settings.cove_api_username or '' }}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="cove_api_password" class="form-label">API Password {% if not has_cove_password %}<span class="text-danger">*</span>{% endif %}</label>
|
||||||
|
<input type="password" class="form-control" id="cove_api_password" name="cove_api_password"
|
||||||
|
placeholder="{% if has_cove_password %}******** (stored){% else %}enter password{% endif %}" />
|
||||||
|
<div class="form-text">Leave empty to keep the existing password.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="cove_import_enabled" name="cove_import_enabled" {% if settings.cove_import_enabled %}checked{% endif %} />
|
||||||
|
<label class="form-check-label" for="cove_import_enabled">Enable automatic import</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="cove_import_interval_minutes" class="form-label">Import interval (minutes)</label>
|
||||||
|
<input type="number" class="form-control" id="cove_import_interval_minutes" name="cove_import_interval_minutes"
|
||||||
|
value="{{ settings.cove_import_interval_minutes or 30 }}" min="1" max="1440" />
|
||||||
|
<div class="form-text">How often (in minutes) to fetch new data from the Cove API.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||||
|
<div id="cove-test-result" class="small"></div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" id="cove-test-btn">Test connection</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Cove Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if settings.cove_partner_id %}
|
||||||
|
<div class="mt-2 text-muted small">
|
||||||
|
Connected – Partner ID: <strong>{{ settings.cove_partner_id }}</strong>
|
||||||
|
{% if settings.cove_last_import_at %}
|
||||||
|
· Last import: {{ settings.cove_last_import_at|local_datetime }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var btn = document.getElementById('cove-test-btn');
|
||||||
|
var resultDiv = document.getElementById('cove-test-result');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
btn.disabled = true;
|
||||||
|
resultDiv.textContent = 'Testing…';
|
||||||
|
resultDiv.className = 'small text-muted';
|
||||||
|
fetch('{{ url_for("main.settings_cove_test_connection") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRFToken': document.querySelector('meta[name="csrf-token"]') ? document.querySelector('meta[name="csrf-token"]').content : '' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.ok) {
|
||||||
|
resultDiv.textContent = data.message;
|
||||||
|
resultDiv.className = 'small text-success';
|
||||||
|
} else {
|
||||||
|
resultDiv.textContent = data.message;
|
||||||
|
resultDiv.className = 'small text-danger';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
resultDiv.textContent = 'Request failed: ' + err;
|
||||||
|
resultDiv.className = 'small text-danger';
|
||||||
|
})
|
||||||
|
.finally(function () { btn.disabled = false; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if section == 'maintenance' %}
|
{% if section == 'maintenance' %}
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
|
|||||||
@ -5,6 +5,17 @@ This file documents all changes made to this project via Claude Code.
|
|||||||
## [2026-02-23]
|
## [2026-02-23]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Cove Data Protection full integration into Backupchecks:
|
||||||
|
- `app/cove_importer.py` – Cove API client: login, paginated EnumerateAccountStatistics, status mapping, deduplication, per-datasource object persistence
|
||||||
|
- `app/cove_importer_service.py` – background thread that polls Cove API on configurable interval
|
||||||
|
- `SystemSettings` model: 8 new Cove fields (`cove_enabled`, `cove_api_url`, `cove_api_username`, `cove_api_password`, `cove_import_enabled`, `cove_import_interval_minutes`, `cove_partner_id`, `cove_last_import_at`)
|
||||||
|
- `Job` model: `cove_account_id` column to link a job to a Cove account
|
||||||
|
- `JobRun` model: `source_type` (NULL = email, "cove_api") and `external_id` (deduplication key) columns
|
||||||
|
- DB migration `migrate_cove_integration()` for all new columns + deduplication index
|
||||||
|
- Settings > Integrations tab: new Cove section with enable toggle, API URL/username/password, import interval, and Test Connection button (AJAX → JSON response with partner ID)
|
||||||
|
- Job Detail page: Cove Integration card showing Account ID input (only when `cove_enabled`)
|
||||||
|
- Route `POST /settings/cove/test-connection` – verifies Cove credentials and stores partner ID
|
||||||
|
- Route `POST /jobs/<id>/set-cove-account` – saves or clears Cove Account ID on a job
|
||||||
- `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