feat: Business Central backup Docker container with web management panel
Python backup module (Azure AD OAuth2 → BC API v2.0 → GPG AES256 → S3), Flask web UI on port 1890 with SSE live log streaming, dual incremental + full backup schedules, chain restore merging full + incrementals, and per-entity file browser with ZIP download. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends gnupg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY backup_bc.py web.py ./
|
||||||
|
COPY templates/ templates/
|
||||||
|
|
||||||
|
VOLUME ["/data"]
|
||||||
|
ENV BCBAK_DATA=/data
|
||||||
|
ENV PORT=1890
|
||||||
|
EXPOSE 1890
|
||||||
|
|
||||||
|
CMD ["python", "web.py"]
|
||||||
674
backup_bc.py
Normal file
674
backup_bc.py
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Business Central backup module — Azure AD OAuth2 → BC API v2.0 → S3/MinIO."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
import requests
|
||||||
|
from botocore.client import Config
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entity definitions (mirrors bc-export.ps1)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
STANDALONE_ENTITIES = [
|
||||||
|
"accounts", "customers", "vendors", "items", "generalLedgerEntries",
|
||||||
|
"bankAccounts", "employees", "dimensions", "dimensionValues", "currencies",
|
||||||
|
"paymentTerms", "paymentMethods", "journals", "journalLines",
|
||||||
|
"countriesRegions", "companyInformation", "itemCategories",
|
||||||
|
"shipmentMethods", "taxAreas", "taxGroups", "unitsOfMeasure",
|
||||||
|
"timeRegistrationEntries", "contacts", "generalProductPostingGroups",
|
||||||
|
"inventoryPostingGroups", "itemLedgerEntries", "opportunities",
|
||||||
|
"locations", "projects", "irs1099",
|
||||||
|
]
|
||||||
|
|
||||||
|
REPORT_ENTITIES = [
|
||||||
|
"agedAccountsPayable", "agedAccountsReceivable", "balanceSheet",
|
||||||
|
"cashFlowStatement", "incomeStatement", "retainedEarningsStatement",
|
||||||
|
"trialBalance", "customerFinancialDetails", "customerSales",
|
||||||
|
"vendorPurchases",
|
||||||
|
]
|
||||||
|
|
||||||
|
DOCUMENT_ENTITIES = {
|
||||||
|
"salesInvoices": "salesInvoiceLines",
|
||||||
|
"salesOrders": "salesOrderLines",
|
||||||
|
"salesCreditMemos": "salesCreditMemoLines",
|
||||||
|
"purchaseInvoices": "purchaseInvoiceLines",
|
||||||
|
"purchaseOrders": "purchaseOrderLines",
|
||||||
|
"salesQuotes": "salesQuoteLines",
|
||||||
|
"salesShipments": "salesShipmentLines",
|
||||||
|
"purchaseReceipts": "purchaseReceiptLines",
|
||||||
|
"customerPaymentJournals": "customerPayments",
|
||||||
|
"vendorPaymentJournals": "vendorPayments",
|
||||||
|
}
|
||||||
|
|
||||||
|
_DOCUMENT_LINE_ENTITIES = set(DOCUMENT_ENTITIES.values())
|
||||||
|
|
||||||
|
_MAX_RETRIES = 3
|
||||||
|
_RETRY_BACKOFF = [5, 15]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Logging helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _CallbackHandler(logging.Handler):
|
||||||
|
def __init__(self, cb):
|
||||||
|
super().__init__()
|
||||||
|
self._cb = cb
|
||||||
|
|
||||||
|
def emit(self, record):
|
||||||
|
try:
|
||||||
|
self._cb(self.format(record))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _make_logger(log_dir, name, log_callback=None):
|
||||||
|
log_dir = Path(log_dir)
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger = logging.getLogger(f"bc_{name}_{id(log_callback)}")
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
logger.propagate = False
|
||||||
|
logger.handlers.clear()
|
||||||
|
|
||||||
|
fh = logging.FileHandler(log_dir / f"{name}.log")
|
||||||
|
fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||||
|
logger.addHandler(fh)
|
||||||
|
|
||||||
|
if log_callback:
|
||||||
|
ch = _CallbackHandler(log_callback)
|
||||||
|
ch.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||||
|
logger.addHandler(ch)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# BC API client
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class BCClient:
|
||||||
|
def __init__(self, tenant_id, client_id, client_secret, environment,
|
||||||
|
company_name="", timeout=120, logger=None):
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
self.client_id = client_id
|
||||||
|
self.client_secret = client_secret
|
||||||
|
self.environment = environment
|
||||||
|
self.company_name = company_name
|
||||||
|
self.timeout = timeout
|
||||||
|
self.log = logger or logging.getLogger(__name__)
|
||||||
|
self._token = None
|
||||||
|
self._token_expiry = 0.0
|
||||||
|
self._base = (
|
||||||
|
f"https://api.businesscentral.dynamics.com/v2.0"
|
||||||
|
f"/{tenant_id}/{environment}/api/v2.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
def authenticate(self):
|
||||||
|
url = f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token"
|
||||||
|
body = {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
"scope": "https://api.businesscentral.dynamics.com/.default",
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
}
|
||||||
|
for attempt in range(1, _MAX_RETRIES + 1):
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, data=body, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
self._token = data["access_token"]
|
||||||
|
self._token_expiry = time.time() + data.get("expires_in", 3600) - 300
|
||||||
|
self.log.info("Azure AD authentication successful")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
if attempt < _MAX_RETRIES:
|
||||||
|
wait = _RETRY_BACKOFF[attempt - 1]
|
||||||
|
self.log.warning(f"Auth attempt {attempt} failed ({e}), retry in {wait}s")
|
||||||
|
time.sleep(wait)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _get_token(self):
|
||||||
|
if not self._token or time.time() >= self._token_expiry:
|
||||||
|
self.authenticate()
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
def _get(self, url):
|
||||||
|
for attempt in range(1, _MAX_RETRIES + 1):
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self._get_token()}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
resp = requests.get(url, headers=headers, timeout=self.timeout)
|
||||||
|
if resp.status_code == 401 and attempt < _MAX_RETRIES:
|
||||||
|
self._token = None
|
||||||
|
continue
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
status = e.response.status_code if e.response else 0
|
||||||
|
is_retryable = status in (429,) or status >= 500
|
||||||
|
if is_retryable and attempt < _MAX_RETRIES:
|
||||||
|
wait = 30 * attempt if status == 429 else _RETRY_BACKOFF[min(attempt - 1, len(_RETRY_BACKOFF) - 1)]
|
||||||
|
self.log.warning(f" HTTP {status} (attempt {attempt}/{_MAX_RETRIES}), retry in {wait}s")
|
||||||
|
time.sleep(wait)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
except (requests.Timeout, requests.ConnectionError) as e:
|
||||||
|
if attempt < _MAX_RETRIES:
|
||||||
|
wait = _RETRY_BACKOFF[min(attempt - 1, len(_RETRY_BACKOFF) - 1)]
|
||||||
|
self.log.warning(f" Network error (attempt {attempt}/{_MAX_RETRIES}): {e}, retry in {wait}s")
|
||||||
|
time.sleep(wait)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _paginate(self, url):
|
||||||
|
records = []
|
||||||
|
current = url
|
||||||
|
while current:
|
||||||
|
data = self._get(current)
|
||||||
|
if data.get("value"):
|
||||||
|
records.extend(data["value"])
|
||||||
|
current = data.get("@odata.nextLink")
|
||||||
|
return records
|
||||||
|
|
||||||
|
def get_companies(self):
|
||||||
|
companies = self._paginate(f"{self._base}/companies")
|
||||||
|
self.log.info(f"Found {len(companies)} company/companies")
|
||||||
|
return companies
|
||||||
|
|
||||||
|
def fetch_entity(self, company_id, entity, since=None, no_filter=False):
|
||||||
|
url = f"{self._base}/companies({company_id})/{entity}"
|
||||||
|
if since and not no_filter:
|
||||||
|
url += f"?$filter=lastModifiedDateTime gt {since}"
|
||||||
|
try:
|
||||||
|
return self._paginate(url)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning(f" Failed to fetch {entity}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def fetch_document_with_lines(self, company_id, doc_entity, line_entity, since=None):
|
||||||
|
"""Fetch document headers + per-document line items; returns (docs, lines)."""
|
||||||
|
docs, lines = [], []
|
||||||
|
url = f"{self._base}/companies({company_id})/{doc_entity}"
|
||||||
|
if since:
|
||||||
|
url += f"?$filter=lastModifiedDateTime gt {since}"
|
||||||
|
try:
|
||||||
|
current = url
|
||||||
|
while current:
|
||||||
|
page = self._get(current)
|
||||||
|
if not page.get("value"):
|
||||||
|
break
|
||||||
|
for doc in page["value"]:
|
||||||
|
docs.append(doc)
|
||||||
|
doc_id = doc["id"]
|
||||||
|
lines_url = f"{self._base}/companies({company_id})/{doc_entity}({doc_id})/{line_entity}"
|
||||||
|
try:
|
||||||
|
doc_lines = self._paginate(lines_url)
|
||||||
|
lines.extend(doc_lines)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning(f" Lines failed for {doc_entity}/{doc_id}: {e}")
|
||||||
|
if len(docs) % 100 == 0:
|
||||||
|
self.log.info(f" Progress: {len(docs)} docs, {len(lines)} lines")
|
||||||
|
current = page.get("@odata.nextLink")
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning(f" Failed to fetch {doc_entity}: {e}")
|
||||||
|
return docs, lines
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Encryption (GPG symmetric AES256)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _gpg_encrypt(src, dst, passphrase, log):
|
||||||
|
cmd = [
|
||||||
|
"gpg", "--batch", "--yes",
|
||||||
|
"--passphrase-fd", "0",
|
||||||
|
"--pinentry-mode", "loopback",
|
||||||
|
"--symmetric",
|
||||||
|
"--cipher-algo", "AES256",
|
||||||
|
"--compress-algo", "none",
|
||||||
|
"--output", str(dst),
|
||||||
|
str(src),
|
||||||
|
]
|
||||||
|
r = subprocess.run(cmd, input=passphrase.encode(), capture_output=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"GPG encryption failed: {r.stderr.decode()}")
|
||||||
|
log.info(f"Encrypted → {Path(dst).name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _gpg_decrypt(src, dst, passphrase, log):
|
||||||
|
cmd = [
|
||||||
|
"gpg", "--batch", "--yes",
|
||||||
|
"--passphrase-fd", "0",
|
||||||
|
"--pinentry-mode", "loopback",
|
||||||
|
"--decrypt",
|
||||||
|
"--output", str(dst),
|
||||||
|
str(src),
|
||||||
|
]
|
||||||
|
r = subprocess.run(cmd, input=passphrase.encode(), capture_output=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"GPG decryption failed: {r.stderr.decode()}")
|
||||||
|
log.info(f"Decrypted {Path(src).name}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# S3 helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _s3(config):
|
||||||
|
return boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=config["s3_endpoint"],
|
||||||
|
aws_access_key_id=config["s3_access_key"],
|
||||||
|
aws_secret_access_key=config["s3_secret_key"],
|
||||||
|
region_name=config.get("s3_region", "us-east-1"),
|
||||||
|
config=Config(signature_version="s3v4"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Backup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run_backup(config, log_callback=None, force_full=False, last_run_time=None):
|
||||||
|
"""
|
||||||
|
Run a BC backup. Returns dict with status, s3_key, etc.
|
||||||
|
last_run_time: ISO8601 UTC string used as the incremental cutoff.
|
||||||
|
"""
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
log_dir = Path(config.get("log_dir", "/data/logs"))
|
||||||
|
log = _make_logger(log_dir, f"backup_{stamp}", log_callback)
|
||||||
|
|
||||||
|
backup_type = "incremental" if (not force_full and last_run_time) else "full"
|
||||||
|
since = last_run_time if backup_type == "incremental" else None
|
||||||
|
run_start_time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
if backup_type == "incremental":
|
||||||
|
log.info(f"Incremental backup — changes since {since}")
|
||||||
|
else:
|
||||||
|
log.info("Full backup")
|
||||||
|
|
||||||
|
env_name = config.get("bc_environment_name", "Production")
|
||||||
|
archive_base = f"bc_backup_{env_name}_{stamp}_{backup_type}"
|
||||||
|
|
||||||
|
result_base = {
|
||||||
|
"backup_type": backup_type,
|
||||||
|
"run_start_time": run_start_time,
|
||||||
|
"companies": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = BCClient(
|
||||||
|
tenant_id=config["azure_tenant_id"],
|
||||||
|
client_id=config["azure_client_id"],
|
||||||
|
client_secret=config["azure_client_secret"],
|
||||||
|
environment=env_name,
|
||||||
|
company_name=config.get("bc_company_name", ""),
|
||||||
|
logger=log,
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info("=== Step 1: Authenticate to Azure AD ===")
|
||||||
|
client.authenticate()
|
||||||
|
|
||||||
|
log.info("=== Step 2: Fetch companies ===")
|
||||||
|
companies = client.get_companies()
|
||||||
|
if not companies:
|
||||||
|
raise RuntimeError(f"No companies found in environment '{env_name}'")
|
||||||
|
|
||||||
|
company_filter = config.get("bc_company_name", "").strip()
|
||||||
|
if company_filter:
|
||||||
|
companies = [c for c in companies
|
||||||
|
if c.get("name") == company_filter
|
||||||
|
or c.get("displayName") == company_filter]
|
||||||
|
if not companies:
|
||||||
|
raise RuntimeError(f"Company '{company_filter}' not found")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
export_dir = Path(tmp) / archive_base
|
||||||
|
export_dir.mkdir()
|
||||||
|
|
||||||
|
total_records = 0
|
||||||
|
entities_ok = 0
|
||||||
|
entities_error = 0
|
||||||
|
company_names = []
|
||||||
|
|
||||||
|
log.info("=== Step 3: Export data ===")
|
||||||
|
for company in companies:
|
||||||
|
co_name = company.get("name", company.get("id", "unknown"))
|
||||||
|
co_id = company["id"]
|
||||||
|
company_names.append(co_name)
|
||||||
|
log.info(f"--- Company: {co_name} ---")
|
||||||
|
|
||||||
|
safe = "".join(c if c.isalnum() or c in " _-" else "_" for c in co_name)
|
||||||
|
co_dir = export_dir / safe
|
||||||
|
co_dir.mkdir()
|
||||||
|
(co_dir / "_company.json").write_text(json.dumps(company, indent=2))
|
||||||
|
|
||||||
|
for entity in STANDALONE_ENTITIES:
|
||||||
|
log.info(f" Exporting {entity}…")
|
||||||
|
records = client.fetch_entity(co_id, entity, since=since)
|
||||||
|
(co_dir / f"{entity}.json").write_text(json.dumps(records, indent=2))
|
||||||
|
log.info(f" {entity}: {len(records)} records")
|
||||||
|
total_records += len(records)
|
||||||
|
entities_ok += 1
|
||||||
|
|
||||||
|
for entity in REPORT_ENTITIES:
|
||||||
|
log.info(f" Exporting {entity} (report, always full)…")
|
||||||
|
records = client.fetch_entity(co_id, entity, no_filter=True)
|
||||||
|
(co_dir / f"{entity}.json").write_text(json.dumps(records, indent=2))
|
||||||
|
log.info(f" {entity}: {len(records)} records")
|
||||||
|
total_records += len(records)
|
||||||
|
entities_ok += 1
|
||||||
|
|
||||||
|
for doc_entity, line_entity in DOCUMENT_ENTITIES.items():
|
||||||
|
log.info(f" Exporting {doc_entity} (with {line_entity})…")
|
||||||
|
docs, lines = client.fetch_document_with_lines(co_id, doc_entity, line_entity, since=since)
|
||||||
|
(co_dir / f"{doc_entity}.jsonl").write_text(
|
||||||
|
"\n".join(json.dumps(d) for d in docs) + ("\n" if docs else "")
|
||||||
|
)
|
||||||
|
(co_dir / f"{line_entity}.jsonl").write_text(
|
||||||
|
"\n".join(json.dumps(l) for l in lines) + ("\n" if lines else "")
|
||||||
|
)
|
||||||
|
log.info(f" {doc_entity}: {len(docs)} docs, {len(lines)} lines")
|
||||||
|
total_records += len(docs) + len(lines)
|
||||||
|
entities_ok += 1
|
||||||
|
|
||||||
|
if backup_type == "incremental" and total_records == 0:
|
||||||
|
log.info("No changes detected — skipping upload")
|
||||||
|
return {**result_base, "status": "skipped", "total_records": 0,
|
||||||
|
"entities_ok": entities_ok, "entities_error": 0,
|
||||||
|
"companies": company_names, "s3_key": None}
|
||||||
|
|
||||||
|
parent_full_s3_key = config.get("last_full_s3_key") if backup_type == "incremental" else None
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"backup_type": backup_type,
|
||||||
|
"environment": env_name,
|
||||||
|
"companies": company_names,
|
||||||
|
"since_datetime": since,
|
||||||
|
"run_start_time": run_start_time,
|
||||||
|
"total_records": total_records,
|
||||||
|
"parent_full_s3_key": parent_full_s3_key,
|
||||||
|
}
|
||||||
|
(export_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))
|
||||||
|
|
||||||
|
log.info("=== Step 4: Create archive ===")
|
||||||
|
archive_path = Path(tmp) / f"{archive_base}.tar.gz"
|
||||||
|
with tarfile.open(archive_path, "w:gz") as tar:
|
||||||
|
tar.add(export_dir, arcname=archive_base)
|
||||||
|
archive_size_mb = archive_path.stat().st_size / 1024 / 1024
|
||||||
|
log.info(f"Archive: {archive_size_mb:.2f} MB")
|
||||||
|
|
||||||
|
log.info("=== Step 5: Encrypt (GPG AES256) ===")
|
||||||
|
enc_path = Path(tmp) / f"{archive_base}.tar.gz.gpg"
|
||||||
|
_gpg_encrypt(archive_path, enc_path, config["encryption_passphrase"], log)
|
||||||
|
enc_size_mb = enc_path.stat().st_size / 1024 / 1024
|
||||||
|
log.info(f"Encrypted: {enc_size_mb:.2f} MB")
|
||||||
|
|
||||||
|
log.info("=== Step 6: Upload to S3 ===")
|
||||||
|
s3_key = f"backups/{backup_type}/{archive_base}.tar.gz.gpg"
|
||||||
|
s3 = _s3(config)
|
||||||
|
s3.upload_file(
|
||||||
|
str(enc_path),
|
||||||
|
config["s3_bucket"],
|
||||||
|
s3_key,
|
||||||
|
ExtraArgs={"Metadata": {
|
||||||
|
"backup-type": backup_type,
|
||||||
|
"environment": env_name,
|
||||||
|
"timestamp": stamp,
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
log.info(f"Uploaded: s3://{config['s3_bucket']}/{s3_key}")
|
||||||
|
|
||||||
|
log.info("=== Backup completed successfully ===")
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"backup_type": backup_type,
|
||||||
|
"s3_key": s3_key,
|
||||||
|
"parent_full_s3_key": parent_full_s3_key,
|
||||||
|
"total_records": total_records,
|
||||||
|
"entities_ok": entities_ok,
|
||||||
|
"entities_error": entities_error,
|
||||||
|
"companies": company_names,
|
||||||
|
"run_start_time": run_start_time,
|
||||||
|
"archive_size_mb": enc_size_mb,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Backup failed: {e}")
|
||||||
|
return {**result_base, "status": "failed", "error": str(e),
|
||||||
|
"companies": result_base.get("companies", [])}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Restore
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _extract_archive(s3_key, config, passphrase, log):
|
||||||
|
"""Download, decrypt, and extract an archive. Returns path to extracted dir."""
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
enc_path = Path(tmp) / Path(s3_key).name
|
||||||
|
log.info(f"Downloading {Path(s3_key).name}…")
|
||||||
|
_s3(config).download_file(config["s3_bucket"], s3_key, str(enc_path))
|
||||||
|
log.info(f"Downloaded {enc_path.stat().st_size / 1024 / 1024:.2f} MB")
|
||||||
|
|
||||||
|
tar_path = Path(tmp) / enc_path.name.replace(".gpg", "")
|
||||||
|
_gpg_decrypt(enc_path, tar_path, passphrase, log)
|
||||||
|
enc_path.unlink()
|
||||||
|
|
||||||
|
extract_dir = Path(tmp) / "extract"
|
||||||
|
extract_dir.mkdir()
|
||||||
|
with tarfile.open(tar_path, "r:gz") as tar:
|
||||||
|
tar.extractall(extract_dir)
|
||||||
|
tar_path.unlink()
|
||||||
|
return extract_dir, tmp
|
||||||
|
|
||||||
|
|
||||||
|
def restore_backup(s3_key, config, output_dir, log_callback=None, entity_filter=None):
|
||||||
|
"""Download, decrypt, and extract a single backup archive."""
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
log_dir = Path(config.get("log_dir", "/data/logs"))
|
||||||
|
log = _make_logger(log_dir, f"restore_{stamp}", log_callback)
|
||||||
|
output_dir = Path(output_dir)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
passphrase = config["encryption_passphrase"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
extract_dir, tmp = _extract_archive(s3_key, config, passphrase, log)
|
||||||
|
|
||||||
|
filters = [f.strip().lower() for f in entity_filter.split(",") if f.strip()] \
|
||||||
|
if entity_filter else None
|
||||||
|
|
||||||
|
import shutil, tempfile as _tf
|
||||||
|
log.info("Copying files…")
|
||||||
|
for f in extract_dir.rglob("*"):
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
if filters and f.suffix in (".json", ".jsonl"):
|
||||||
|
if not any(fl in f.stem.lower() for fl in filters):
|
||||||
|
if f.name not in ("manifest.json", "_company.json"):
|
||||||
|
continue
|
||||||
|
rel = f.relative_to(extract_dir)
|
||||||
|
dst = output_dir / rel
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(f, dst)
|
||||||
|
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
file_count = (sum(1 for _ in output_dir.rglob("*.json"))
|
||||||
|
+ sum(1 for _ in output_dir.rglob("*.jsonl")))
|
||||||
|
|
||||||
|
manifest = {}
|
||||||
|
for mf in output_dir.rglob("manifest.json"):
|
||||||
|
try:
|
||||||
|
manifest = json.loads(mf.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
|
||||||
|
log.info(f"Restored {file_count} files")
|
||||||
|
return {"status": "success", "file_count": file_count, "manifest": manifest}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Restore failed: {e}")
|
||||||
|
return {"status": "failed", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def chain_restore(s3_keys, config, output_dir, log_callback=None, entity_filter=None):
|
||||||
|
"""
|
||||||
|
Restore a chain of archives (full + incrementals) merged into a complete snapshot.
|
||||||
|
Records from later archives overwrite earlier ones (matched by 'id' or 'number').
|
||||||
|
"""
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
log_dir = Path(config.get("log_dir", "/data/logs"))
|
||||||
|
log = _make_logger(log_dir, f"chain_restore_{stamp}", log_callback)
|
||||||
|
output_dir = Path(output_dir)
|
||||||
|
chain_dir = output_dir / "chain"
|
||||||
|
chain_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
passphrase = config["encryption_passphrase"]
|
||||||
|
|
||||||
|
filters = [f.strip().lower() for f in entity_filter.split(",") if f.strip()] \
|
||||||
|
if entity_filter else None
|
||||||
|
|
||||||
|
# merged[company_safe_name][entity_stem] = {record_id: record_dict}
|
||||||
|
merged: dict[str, dict[str, dict]] = {}
|
||||||
|
last_manifest = {}
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
try:
|
||||||
|
for idx, s3_key in enumerate(s3_keys):
|
||||||
|
log.info(f"[{idx+1}/{len(s3_keys)}] {Path(s3_key).name}")
|
||||||
|
extract_dir, tmp = _extract_archive(s3_key, config, passphrase, log)
|
||||||
|
|
||||||
|
for f in extract_dir.rglob("manifest.json"):
|
||||||
|
try:
|
||||||
|
last_manifest = json.loads(f.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Traverse company dirs inside the archive
|
||||||
|
for archive_subdir in extract_dir.iterdir():
|
||||||
|
if not archive_subdir.is_dir():
|
||||||
|
continue
|
||||||
|
for co_dir in archive_subdir.iterdir():
|
||||||
|
if not co_dir.is_dir():
|
||||||
|
continue
|
||||||
|
co_name = co_dir.name
|
||||||
|
if co_name not in merged:
|
||||||
|
merged[co_name] = {}
|
||||||
|
|
||||||
|
# Copy _company.json straight through
|
||||||
|
company_meta = co_dir / "_company.json"
|
||||||
|
if company_meta.exists():
|
||||||
|
dst = chain_dir / co_name / "_company.json"
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(company_meta, dst)
|
||||||
|
|
||||||
|
for f in co_dir.iterdir():
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
stem = f.stem
|
||||||
|
if filters and stem.lower() not in filters:
|
||||||
|
continue
|
||||||
|
if f.name in ("_company.json",):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if f.suffix == ".json":
|
||||||
|
try:
|
||||||
|
records = json.loads(f.read_text())
|
||||||
|
if not isinstance(records, list):
|
||||||
|
continue
|
||||||
|
if stem not in merged[co_name]:
|
||||||
|
merged[co_name][stem] = {}
|
||||||
|
for r in records:
|
||||||
|
rid = r.get("id") or r.get("number") or json.dumps(r)[:64]
|
||||||
|
merged[co_name][stem][rid] = r
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f" Skip {f.name}: {e}")
|
||||||
|
|
||||||
|
elif f.suffix == ".jsonl":
|
||||||
|
if stem not in merged[co_name]:
|
||||||
|
merged[co_name][stem] = {}
|
||||||
|
for line in f.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
r = json.loads(line)
|
||||||
|
rid = r.get("id") or r.get("number") or line[:64]
|
||||||
|
merged[co_name][stem][rid] = r
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
log.info(f" Merged archive {idx+1}")
|
||||||
|
|
||||||
|
# Write merged output
|
||||||
|
log.info("Writing merged chain output…")
|
||||||
|
file_count = 0
|
||||||
|
for co_name, entities in merged.items():
|
||||||
|
co_chain_dir = chain_dir / co_name
|
||||||
|
co_chain_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for stem, records_by_id in entities.items():
|
||||||
|
records = list(records_by_id.values())
|
||||||
|
if stem in _DOCUMENT_LINE_ENTITIES or stem in DOCUMENT_ENTITIES:
|
||||||
|
out = co_chain_dir / f"{stem}.jsonl"
|
||||||
|
out.write_text("\n".join(json.dumps(r) for r in records) + "\n")
|
||||||
|
else:
|
||||||
|
out = co_chain_dir / f"{stem}.json"
|
||||||
|
out.write_text(json.dumps(records, indent=2))
|
||||||
|
file_count += 1
|
||||||
|
|
||||||
|
chain_manifest = {
|
||||||
|
**last_manifest,
|
||||||
|
"restore_mode": "chain",
|
||||||
|
"chain_length": len(s3_keys),
|
||||||
|
"s3_keys": s3_keys,
|
||||||
|
}
|
||||||
|
(chain_dir / "chain_manifest.json").write_text(json.dumps(chain_manifest, indent=2))
|
||||||
|
|
||||||
|
log.info(f"Chain restore complete: {file_count} entity files")
|
||||||
|
return {"status": "success", "file_count": file_count, "manifest": chain_manifest}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Chain restore failed: {e}")
|
||||||
|
return {"status": "failed", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# List remote backups
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def list_remote_backups(config):
|
||||||
|
s3 = _s3(config)
|
||||||
|
resp = s3.list_objects_v2(Bucket=config["s3_bucket"], Prefix="backups/")
|
||||||
|
objects = resp.get("Contents", [])
|
||||||
|
result = []
|
||||||
|
for obj in objects:
|
||||||
|
key = obj["Key"]
|
||||||
|
if not key.endswith(".gpg"):
|
||||||
|
continue
|
||||||
|
name = Path(key).name
|
||||||
|
backup_type = "incremental" if "_incremental" in name else "full"
|
||||||
|
result.append({
|
||||||
|
"key": key,
|
||||||
|
"name": name,
|
||||||
|
"backup_type": backup_type,
|
||||||
|
"size_mb": round(obj["Size"] / 1024 / 1024, 2),
|
||||||
|
"last_modified": obj["LastModified"].isoformat(),
|
||||||
|
})
|
||||||
|
result.sort(key=lambda x: x["last_modified"], reverse=True)
|
||||||
|
return result
|
||||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
bcbak:
|
||||||
|
build: .
|
||||||
|
container_name: bcbak
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "1890:1890"
|
||||||
|
volumes:
|
||||||
|
- bcbak_data:/data
|
||||||
|
environment:
|
||||||
|
- BCBAK_DATA=/data
|
||||||
|
- PORT=1890
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bcbak_data:
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
boto3>=1.34
|
||||||
|
requests>=2.31
|
||||||
|
flask>=3.0
|
||||||
|
APScheduler>=3.10
|
||||||
278
templates/base.html
Normal file
278
templates/base.html
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>BackupBC{% block title %}{% endblock %}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root { --sidebar-bg: #0f172a; --sidebar-width: 230px; }
|
||||||
|
body { background: #f1f5f9; }
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.sidebar-brand {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.1);
|
||||||
|
}
|
||||||
|
.sidebar-brand .brand-icon {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
background: #6366f1;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
.sidebar .nav-link {
|
||||||
|
color: rgba(255,255,255,.55);
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: .875rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .6rem;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.sidebar .nav-link:hover,
|
||||||
|
.sidebar .nav-link.active {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255,255,255,.1);
|
||||||
|
}
|
||||||
|
.sidebar .nav-link.active { background: rgba(99,102,241,.25); color: #a5b4fc; }
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
margin-left: var(--sidebar-width);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.page-header h4 { font-weight: 700; margin: 0; }
|
||||||
|
|
||||||
|
.stat-card { border: none; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||||
|
.stat-icon {
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-running { background: #f59e0b; color: #fff; }
|
||||||
|
.badge-success { background: #10b981; color: #fff; }
|
||||||
|
.badge-failed { background: #ef4444; color: #fff; }
|
||||||
|
|
||||||
|
.log-terminal {
|
||||||
|
background: #1e1e2e;
|
||||||
|
color: #cdd6f4;
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', 'Courier New', monospace;
|
||||||
|
font-size: .78rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.log-terminal .log-warn { color: #f9e2af; }
|
||||||
|
.log-terminal .log-error { color: #f38ba8; }
|
||||||
|
.log-terminal .log-ok { color: #a6e3a1; }
|
||||||
|
.log-terminal .log-info { color: #89dceb; }
|
||||||
|
|
||||||
|
.table thead th { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: #64748b; font-weight: 600; }
|
||||||
|
.table td { vertical-align: middle; font-size: .875rem; }
|
||||||
|
|
||||||
|
#backupBtn { min-width: 160px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="sidebar">
|
||||||
|
<a href="{{ url_for('dashboard') }}" class="sidebar-brand">
|
||||||
|
<div class="brand-icon"><i class="bi bi-cloud-arrow-up-fill text-white"></i></div>
|
||||||
|
BackupBC
|
||||||
|
</a>
|
||||||
|
<ul class="nav flex-column gap-1">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}" href="{{ url_for('dashboard') }}">
|
||||||
|
<i class="bi bi-speedometer2"></i> Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'history' %}active{% endif %}" href="{{ url_for('history') }}">
|
||||||
|
<i class="bi bi-clock-history"></i> History
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint in ('restore_list','restore_view') %}active{% endif %}" href="{{ url_for('restore_list') }}">
|
||||||
|
<i class="bi bi-arrow-counterclockwise"></i> Restore
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'settings' %}active{% endif %}" href="{{ url_for('settings') }}">
|
||||||
|
<i class="bi bi-gear"></i> Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="mt-auto pt-3" style="border-top:1px solid rgba(255,255,255,.08); font-size:.7rem; color:rgba(255,255,255,.3)">
|
||||||
|
BackupBC v1.0 · Business Central
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-{% if category == 'success' %}check-circle{% else %}exclamation-triangle{% endif %} me-2"></i>
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Backup Modal -->
|
||||||
|
<div class="modal fade" id="backupModal" tabindex="-1" data-bs-backdrop="static">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||||
|
<div class="modal-content border-0 shadow-lg">
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<h5 class="modal-title fw-bold">
|
||||||
|
<i class="bi bi-cloud-upload me-2 text-primary"></i>
|
||||||
|
<span id="modalTitle">Starting Backup…</span>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="modalStatus" class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary" id="modalSpinner" role="status"></div>
|
||||||
|
<span id="modalStatusText" class="text-muted small">Authenticating with Azure AD…</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-terminal" id="logTerminal"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-0 pt-0">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="modalCloseBtn" data-bs-dismiss="modal" disabled>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('history') }}" class="btn btn-primary btn-sm d-none" id="viewHistoryBtn">
|
||||||
|
<i class="bi bi-clock-history me-1"></i>View History
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
let _backupModal = null;
|
||||||
|
|
||||||
|
function runBackupNow(forceFull) {
|
||||||
|
if (forceFull === undefined) forceFull = true;
|
||||||
|
|
||||||
|
const terminal = document.getElementById('logTerminal');
|
||||||
|
const statusText = document.getElementById('modalStatusText');
|
||||||
|
const spinner = document.getElementById('modalSpinner');
|
||||||
|
const closeBtn = document.getElementById('modalCloseBtn');
|
||||||
|
const histBtn = document.getElementById('viewHistoryBtn');
|
||||||
|
const title = document.getElementById('modalTitle');
|
||||||
|
|
||||||
|
terminal.innerHTML = '';
|
||||||
|
spinner.classList.remove('d-none');
|
||||||
|
spinner.className = 'spinner-border spinner-border-sm text-primary';
|
||||||
|
statusText.textContent = 'Authenticating with Azure AD…';
|
||||||
|
statusText.className = 'text-muted small';
|
||||||
|
closeBtn.disabled = true;
|
||||||
|
histBtn.classList.add('d-none');
|
||||||
|
title.textContent = forceFull ? 'Full Backup in Progress' : 'Incremental Backup in Progress';
|
||||||
|
|
||||||
|
if (!_backupModal) _backupModal = new bootstrap.Modal(document.getElementById('backupModal'));
|
||||||
|
_backupModal.show();
|
||||||
|
|
||||||
|
fetch('/api/run', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({force_full: forceFull}),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
statusText.textContent = 'Error: ' + data.error;
|
||||||
|
statusText.className = 'text-danger small fw-semibold';
|
||||||
|
spinner.className = 'd-none';
|
||||||
|
closeBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const runId = data.run_id;
|
||||||
|
statusText.textContent = 'Backup running (run #' + runId + ')…';
|
||||||
|
|
||||||
|
const evtSrc = new EventSource('/api/run/' + runId + '/stream');
|
||||||
|
evtSrc.onmessage = function(e) {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.keepalive) return;
|
||||||
|
if (msg.done) {
|
||||||
|
evtSrc.close();
|
||||||
|
spinner.className = 'd-none';
|
||||||
|
closeBtn.disabled = false;
|
||||||
|
histBtn.classList.remove('d-none');
|
||||||
|
if (msg.status === 'success') {
|
||||||
|
title.textContent = forceFull ? 'Full Backup Complete' : 'Incremental Backup Complete';
|
||||||
|
statusText.innerHTML = '<i class="bi bi-check-circle-fill text-success me-1"></i><span class="text-success fw-semibold">Backup completed successfully!</span>';
|
||||||
|
} else {
|
||||||
|
title.textContent = 'Backup Failed';
|
||||||
|
statusText.innerHTML = '<i class="bi bi-x-circle-fill text-danger me-1"></i><span class="text-danger fw-semibold">Backup failed — see log below.</span>';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.log) appendLog(terminal, msg.log);
|
||||||
|
};
|
||||||
|
evtSrc.onerror = function() {
|
||||||
|
evtSrc.close();
|
||||||
|
spinner.className = 'd-none';
|
||||||
|
closeBtn.disabled = false;
|
||||||
|
appendLog(terminal, 'Connection lost — check History for result.');
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
statusText.textContent = 'Request failed: ' + err.message;
|
||||||
|
statusText.className = 'text-danger small';
|
||||||
|
spinner.className = 'd-none';
|
||||||
|
closeBtn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLog(container, text) {
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.textContent = text;
|
||||||
|
if (/CRITICAL|ERROR/.test(text)) line.className = 'log-error';
|
||||||
|
else if (/WARNING/.test(text)) line.className = 'log-warn';
|
||||||
|
else if (/complete|success|OK/i.test(text)) line.className = 'log-ok';
|
||||||
|
else line.className = 'log-info';
|
||||||
|
container.appendChild(line);
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
163
templates/dashboard.html
Normal file
163
templates/dashboard.html
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %} — Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0"><i class="bi bi-speedometer2 me-2 text-primary"></i>Dashboard</h4>
|
||||||
|
{% if running_id %}
|
||||||
|
<div class="text-warning small mt-1">
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>Backup #{{ running_id }} in progress…
|
||||||
|
<a href="{{ url_for('history') }}" class="ms-1 link-warning">View log</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button type="button" class="btn btn-primary" onclick="runBackupNow(true)" id="backupBtn">
|
||||||
|
<i class="bi bi-cloud-upload-fill me-1"></i>Full Backup
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split"
|
||||||
|
data-bs-toggle="dropdown" aria-expanded="false"></button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="#" onclick="runBackupNow(false); return false;">
|
||||||
|
<i class="bi bi-arrow-repeat me-2 text-success"></i>Incremental Backup
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stat cards -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-6 col-xl-3">
|
||||||
|
<div class="card stat-card h-100">
|
||||||
|
<div class="card-body d-flex align-items-center gap-3">
|
||||||
|
<div class="stat-icon bg-primary bg-opacity-10 text-primary">
|
||||||
|
<i class="bi bi-archive-fill"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-muted small">Total Backups</div>
|
||||||
|
<div class="fw-bold fs-4 lh-1">{{ total }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6 col-xl-3">
|
||||||
|
<div class="card stat-card h-100">
|
||||||
|
<div class="card-body d-flex align-items-center gap-3">
|
||||||
|
<div class="stat-icon bg-success bg-opacity-10 text-success">
|
||||||
|
<i class="bi bi-check-circle-fill"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-muted small">Last Success</div>
|
||||||
|
<div class="fw-semibold lh-1 mt-1">{{ last_ok['started_at'] | fmtdt if last_ok else '—' }}</div>
|
||||||
|
{% if last_ok %}
|
||||||
|
<div class="text-muted" style="font-size:.7rem">{{ last_ok['total_records'] or 0 | int }} records</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6 col-xl-3">
|
||||||
|
<div class="card stat-card h-100">
|
||||||
|
<div class="card-body d-flex align-items-center gap-3">
|
||||||
|
<div class="stat-icon bg-primary bg-opacity-10 text-primary">
|
||||||
|
<i class="bi bi-database-fill-up"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-muted small">Last Full Backup</div>
|
||||||
|
<div class="fw-semibold lh-1 mt-1">{{ last_full['started_at'] | fmtdt if last_full else '—' }}</div>
|
||||||
|
{% if last_full %}
|
||||||
|
<div class="text-muted" style="font-size:.7rem">{{ '%.1f' | format(last_full['archive_size_mb'] or 0) }} MB</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6 col-xl-3">
|
||||||
|
<div class="card stat-card h-100">
|
||||||
|
<div class="card-body d-flex align-items-center gap-3">
|
||||||
|
<div class="stat-icon bg-warning bg-opacity-10 text-warning">
|
||||||
|
<i class="bi bi-alarm-fill"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-muted small">Next Incremental</div>
|
||||||
|
<div class="fw-semibold lh-1 mt-1" style="font-size:.85rem">{{ next_incr or 'Not scheduled' }}</div>
|
||||||
|
<div class="text-muted" style="font-size:.7rem">Full: {{ next_full_run or 'Not scheduled' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent runs -->
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="card-header bg-white border-0 pt-3 pb-2 d-flex justify-content-between align-items-center">
|
||||||
|
<span class="fw-bold"><i class="bi bi-clock-history me-2 text-secondary"></i>Recent Backups</span>
|
||||||
|
<a href="{{ url_for('history') }}" class="btn btn-outline-secondary btn-sm">View All</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Trigger</th>
|
||||||
|
<th>Companies</th>
|
||||||
|
<th>Records</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in recent %}
|
||||||
|
<tr {% if r['status'] == 'failed' %}class="table-danger bg-opacity-25"{% endif %}>
|
||||||
|
<td class="text-muted">{{ r['id'] }}</td>
|
||||||
|
<td>{{ r['started_at'] | fmtdt }}</td>
|
||||||
|
<td>
|
||||||
|
{% if r['backup_type'] == 'full' %}
|
||||||
|
<span class="badge bg-primary"><i class="bi bi-database-fill-up me-1"></i>Full</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-arrow-repeat me-1"></i>Incr</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge rounded-pill {% if r['triggered_by'] == 'manual' %}bg-info{% else %}bg-secondary{% endif %} text-white">
|
||||||
|
{{ r['triggered_by'] }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">
|
||||||
|
{% if r['companies'] %}
|
||||||
|
{{ r['companies'] | replace('["','') | replace('"]','') | replace('","',' · ') }}
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted">{{ r['total_records'] or '—' }}</td>
|
||||||
|
<td>{% if r['archive_size_mb'] %}{{ '%.1f' | format(r['archive_size_mb']) }} MB{% else %}—{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
{% if r['status'] == 'success' %}
|
||||||
|
<span class="badge badge-success"><i class="bi bi-check me-1"></i>OK</span>
|
||||||
|
{% elif r['status'] == 'failed' %}
|
||||||
|
<span class="badge badge-failed"><i class="bi bi-x me-1"></i>Failed</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-running"><i class="bi bi-arrow-repeat me-1"></i>Running</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="text-center text-muted py-5">
|
||||||
|
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
|
||||||
|
No backups yet — click <strong>Full Backup</strong> to start.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
131
templates/history.html
Normal file
131
templates/history.html
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %} — History{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h4><i class="bi bi-clock-history me-2 text-primary"></i>Backup History</h4>
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<span class="text-muted small">{{ total }} total backup{{ 's' if total != 1 }}</span>
|
||||||
|
<button class="btn btn-primary btn-sm px-3" onclick="runBackupNow()">
|
||||||
|
<i class="bi bi-cloud-upload-fill me-1"></i>Backup Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Completed</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Trigger</th>
|
||||||
|
<th>Companies</th>
|
||||||
|
<th>Entities OK</th>
|
||||||
|
<th>Records</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Archive</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in runs %}
|
||||||
|
<tr {% if r['status'] == 'failed' %}class="table-danger bg-opacity-25"{% endif %}>
|
||||||
|
<td class="text-muted">{{ r['id'] }}</td>
|
||||||
|
<td>{{ r['started_at'] | fmtdt }}</td>
|
||||||
|
<td>{{ r['completed_at'] | fmtdt }}</td>
|
||||||
|
<td>{{ r['started_at'] | duration(r['completed_at']) }}</td>
|
||||||
|
<td>
|
||||||
|
{% if r['backup_type'] == 'full' %}
|
||||||
|
<span class="badge bg-primary text-white"><i class="bi bi-database-fill-up me-1"></i>Full</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-success text-white"><i class="bi bi-arrow-repeat me-1"></i>Incr</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge rounded-pill {% if r['triggered_by'] == 'manual' %}bg-info{% else %}bg-secondary{% endif %} text-white">
|
||||||
|
{{ r['triggered_by'] }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">
|
||||||
|
{% if r['companies'] %}
|
||||||
|
{{ r['companies'] | replace('["','') | replace('"]','') | replace('","',' · ') }}
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-success fw-semibold">{{ r['entities_ok'] or '—' }}</td>
|
||||||
|
<td class="text-muted">{{ r['total_records'] or '—' }}</td>
|
||||||
|
<td>{% if r['archive_size_mb'] %}{{ '%.1f' | format(r['archive_size_mb']) }} MB{% else %}—{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
{% if r['status'] == 'success' %}
|
||||||
|
<span class="badge badge-success"><i class="bi bi-check me-1"></i>Success</span>
|
||||||
|
{% elif r['status'] == 'failed' %}
|
||||||
|
<span class="badge badge-failed" title="{{ r['error_message'] or '' }}">
|
||||||
|
<i class="bi bi-x me-1"></i>Failed
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-running"><i class="bi bi-arrow-repeat me-1"></i>Running</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted" style="font-size:.7rem; max-width:180px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">
|
||||||
|
{% if r['s3_key'] %}
|
||||||
|
<span title="{{ r['s3_key'] }}">{{ r['s3_key'].split('/')[-1] }}</span>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if r['s3_key'] and r['status'] == 'success' %}
|
||||||
|
<a href="{{ url_for('restore_list') }}"
|
||||||
|
class="btn btn-outline-warning btn-sm py-0"
|
||||||
|
onclick="event.preventDefault(); sessionStorage.setItem('restoreKey','{{ r['s3_key'] }}'); window.location='{{ url_for('restore_list') }}'">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-1"></i>Restore
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% if r['status'] == 'failed' and r['error_message'] %}
|
||||||
|
<tr class="table-danger">
|
||||||
|
<td colspan="13" class="text-danger small py-1 ps-4">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>{{ r['error_message'] }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="13" class="text-center text-muted py-5">
|
||||||
|
<i class="bi bi-inbox fs-2 d-block mb-2"></i>No backup history yet
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if pages > 1 %}
|
||||||
|
<div class="card-footer bg-white border-0 d-flex justify-content-between align-items-center">
|
||||||
|
<span class="text-muted small">Page {{ page }} of {{ pages }}</span>
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination pagination-sm mb-0">
|
||||||
|
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="?page={{ page - 1 }}">‹ Prev</a>
|
||||||
|
</li>
|
||||||
|
{% for p in range(1, pages + 1) %}
|
||||||
|
{% if p == page or p == 1 or p == pages or (p >= page - 2 and p <= page + 2) %}
|
||||||
|
<li class="page-item {% if p == page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="?page={{ p }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% elif p == page - 3 or p == page + 3 %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<li class="page-item {% if page >= pages %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="?page={{ page + 1 }}">Next ›</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
356
templates/restore_list.html
Normal file
356
templates/restore_list.html
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %} — Restore{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h4><i class="bi bi-arrow-counterclockwise me-2 text-warning"></i>Restore</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Available backups in S3 -->
|
||||||
|
<div class="card stat-card mb-4">
|
||||||
|
<div class="card-header bg-white border-0 pt-3 pb-2">
|
||||||
|
<span class="fw-bold"><i class="bi bi-cloud-download me-2 text-primary"></i>Available Backups</span>
|
||||||
|
<span class="text-muted small ms-2">— click Restore to decrypt and extract locally</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Archive</th>
|
||||||
|
<th>Last Modified</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for b in remote_backups %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if b.get('backup_type') == 'incremental' %}
|
||||||
|
<span class="badge bg-success me-1"><i class="bi bi-arrow-repeat me-1"></i>Incr</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-primary me-1"><i class="bi bi-database-fill-up me-1"></i>Full</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="font-monospace small">{{ b['name'] }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ b['last_modified'][:16].replace('T',' ') }}</td>
|
||||||
|
<td>{{ b['size_mb'] }} MB</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button class="btn btn-warning btn-sm"
|
||||||
|
onclick="startRestore(this)"
|
||||||
|
data-s3key="{{ b['key'] }}"
|
||||||
|
data-type="{{ b.get('backup_type', 'full') }}"
|
||||||
|
data-chain="{{ 'true' if b.get('chain_available') else 'false' }}"
|
||||||
|
data-chain-length="{{ b.get('chain_length', 1) }}">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-1"></i>Restore
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center text-muted py-4">
|
||||||
|
<i class="bi bi-cloud-slash fs-3 d-block mb-2"></i>
|
||||||
|
No backups found — check your S3 settings or run a backup first.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Past restores -->
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="card-header bg-white border-0 pt-3 pb-2">
|
||||||
|
<span class="fw-bold"><i class="bi bi-folder2-open me-2 text-secondary"></i>Previous Restores</span>
|
||||||
|
<span class="text-muted small ms-2">— extracted files ready to browse and download</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Archive</th>
|
||||||
|
<th>Files</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in restores %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">{{ r['id'] }}</td>
|
||||||
|
<td>{{ r['started_at'] | fmtdt }}</td>
|
||||||
|
<td>{{ r['started_at'] | duration(r['completed_at']) }}</td>
|
||||||
|
<td class="font-monospace small" style="max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">
|
||||||
|
{{ r['s3_key'].split('/')[-1] }}
|
||||||
|
</td>
|
||||||
|
<td>{{ r['file_count'] or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if r['status'] == 'success' %}
|
||||||
|
<span class="badge badge-success"><i class="bi bi-check me-1"></i>Ready</span>
|
||||||
|
{% elif r['status'] == 'failed' %}
|
||||||
|
<span class="badge badge-failed" title="{{ r['error_message'] or '' }}">
|
||||||
|
<i class="bi bi-x me-1"></i>Failed
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-running"><i class="bi bi-arrow-repeat me-1"></i>Running</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end d-flex gap-1 justify-content-end">
|
||||||
|
{% if r['status'] == 'success' %}
|
||||||
|
<a href="{{ url_for('restore_view', restore_id=r['id']) }}" class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="bi bi-folder2-open me-1"></i>Browse
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('restore_download_zip', restore_id=r['id']) }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-file-zip me-1"></i>ZIP
|
||||||
|
</a>
|
||||||
|
{% elif r['status'] == 'running' %}
|
||||||
|
<a href="{{ url_for('restore_view', restore_id=r['id']) }}" class="btn btn-outline-warning btn-sm">
|
||||||
|
<i class="bi bi-eye me-1"></i>View
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<button class="btn btn-outline-danger btn-sm" onclick="deleteRestore({{ r['id'] }}, this)"
|
||||||
|
{% if r['status'] == 'running' %}disabled{% endif %}>
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center text-muted py-4">No restores yet</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restore options modal -->
|
||||||
|
<div class="modal fade" id="restoreFilterModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content border-0 shadow-lg">
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<h5 class="modal-title fw-bold">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-2 text-warning"></i>Restore Options
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
|
||||||
|
<!-- Archive type info -->
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3 p-2 rounded bg-light">
|
||||||
|
<span id="restoreTypeBadge" class="badge bg-primary">Full</span>
|
||||||
|
<span id="restoreArchiveName" class="font-monospace small text-muted text-truncate"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chain restore toggle (incrementals only) -->
|
||||||
|
<div id="restoreChainSection" class="mb-3 d-none">
|
||||||
|
<div class="form-check form-switch mb-1">
|
||||||
|
<input class="form-check-input" type="checkbox" id="restoreChainToggle" checked
|
||||||
|
onchange="toggleChainWarning(this.checked)">
|
||||||
|
<label class="form-check-label fw-semibold" for="restoreChainToggle">
|
||||||
|
Chain Restore <span id="restoreChainLabel" class="text-muted fw-normal small"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text mb-2">
|
||||||
|
Downloads the parent full backup + all incrementals up to this point,
|
||||||
|
merged into a complete database snapshot.
|
||||||
|
</div>
|
||||||
|
<div id="restorePartialWarning" class="alert alert-warning border-0 py-2 small d-none">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||||
|
<strong>Partial restore</strong> — only entities changed in this specific incremental will be
|
||||||
|
extracted. Enable Chain Restore above for a complete snapshot.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Entity filter -->
|
||||||
|
<label class="form-label fw-semibold small">
|
||||||
|
Entity filter <span class="fw-normal text-muted">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" class="form-control font-monospace" id="restoreEntityFilter"
|
||||||
|
placeholder="customers, vendors, salesInvoices … (blank = all)">
|
||||||
|
<div class="form-text">Comma-separated entity names to extract.</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-0 pt-0">
|
||||||
|
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button class="btn btn-warning btn-sm" onclick="confirmRestore()">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-1"></i>Start Restore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restore progress modal -->
|
||||||
|
<div class="modal fade" id="restoreModal" tabindex="-1" data-bs-backdrop="static">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||||
|
<div class="modal-content border-0 shadow-lg">
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<h5 class="modal-title fw-bold">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-2 text-warning"></i>
|
||||||
|
<span id="restoreModalTitle">Restore in Progress…</span>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<div class="spinner-border spinner-border-sm text-warning" id="restoreSpinner"></div>
|
||||||
|
<span id="restoreStatusText" class="text-muted small">Downloading from S3…</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-terminal" id="restoreLog"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-0 pt-0">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="restoreCloseBtn"
|
||||||
|
data-bs-dismiss="modal" disabled>Close</button>
|
||||||
|
<a href="#" class="btn btn-warning btn-sm d-none" id="restoreBrowseBtn">
|
||||||
|
<i class="bi bi-folder2-open me-1"></i>Browse Files
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
let _restoreModal = null;
|
||||||
|
let _filterModal = null;
|
||||||
|
let _pendingRestore = {};
|
||||||
|
|
||||||
|
function startRestore(btn) {
|
||||||
|
const s3key = btn.dataset.s3key;
|
||||||
|
const btype = btn.dataset.type || 'full';
|
||||||
|
const chainAvail = btn.dataset.chain === 'true';
|
||||||
|
const chainLength = parseInt(btn.dataset.chainLength || '1');
|
||||||
|
|
||||||
|
_pendingRestore = {s3key, btype, chainAvail, chainLength};
|
||||||
|
|
||||||
|
document.getElementById('restoreEntityFilter').value = '';
|
||||||
|
document.getElementById('restoreArchiveName').textContent = s3key.split('/').pop();
|
||||||
|
|
||||||
|
const badge = document.getElementById('restoreTypeBadge');
|
||||||
|
if (btype === 'incremental') {
|
||||||
|
badge.textContent = 'Incremental';
|
||||||
|
badge.className = 'badge bg-success';
|
||||||
|
} else {
|
||||||
|
badge.textContent = 'Full Backup';
|
||||||
|
badge.className = 'badge bg-primary';
|
||||||
|
}
|
||||||
|
|
||||||
|
const chainSection = document.getElementById('restoreChainSection');
|
||||||
|
if (btype === 'incremental' && chainAvail) {
|
||||||
|
chainSection.classList.remove('d-none');
|
||||||
|
const toggle = document.getElementById('restoreChainToggle');
|
||||||
|
toggle.checked = true;
|
||||||
|
toggle.disabled = false;
|
||||||
|
document.getElementById('restoreChainLabel').textContent =
|
||||||
|
`— ${chainLength} archive${chainLength > 1 ? 's' : ''} (1 full + ${chainLength - 1} incr)`;
|
||||||
|
document.getElementById('restorePartialWarning').classList.add('d-none');
|
||||||
|
} else if (btype === 'incremental' && !chainAvail) {
|
||||||
|
chainSection.classList.remove('d-none');
|
||||||
|
const toggle = document.getElementById('restoreChainToggle');
|
||||||
|
toggle.checked = false;
|
||||||
|
toggle.disabled = true;
|
||||||
|
document.getElementById('restoreChainLabel').textContent = '— not available (run a full backup first)';
|
||||||
|
document.getElementById('restorePartialWarning').classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
chainSection.classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_filterModal) _filterModal = new bootstrap.Modal(document.getElementById('restoreFilterModal'));
|
||||||
|
_filterModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleChainWarning(on) {
|
||||||
|
document.getElementById('restorePartialWarning').classList.toggle('d-none', on);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRestore() {
|
||||||
|
const entityFilter = document.getElementById('restoreEntityFilter').value.trim();
|
||||||
|
const useChain = document.getElementById('restoreChainToggle')?.checked ?? false;
|
||||||
|
if (_filterModal) _filterModal.hide();
|
||||||
|
_doRestore(_pendingRestore.s3key, entityFilter, useChain);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _doRestore(s3Key, entityFilter, useChain) {
|
||||||
|
const terminal = document.getElementById('restoreLog');
|
||||||
|
const statusText = document.getElementById('restoreStatusText');
|
||||||
|
const spinner = document.getElementById('restoreSpinner');
|
||||||
|
const closeBtn = document.getElementById('restoreCloseBtn');
|
||||||
|
const browseBtn = document.getElementById('restoreBrowseBtn');
|
||||||
|
const title = document.getElementById('restoreModalTitle');
|
||||||
|
|
||||||
|
terminal.innerHTML = '';
|
||||||
|
spinner.className = 'spinner-border spinner-border-sm text-warning';
|
||||||
|
statusText.textContent = 'Downloading from S3…';
|
||||||
|
statusText.className = 'text-muted small';
|
||||||
|
closeBtn.disabled = true;
|
||||||
|
browseBtn.classList.add('d-none');
|
||||||
|
title.textContent = 'Restore in Progress…';
|
||||||
|
|
||||||
|
if (!_restoreModal) _restoreModal = new bootstrap.Modal(document.getElementById('restoreModal'));
|
||||||
|
_restoreModal.show();
|
||||||
|
|
||||||
|
fetch('/api/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({s3_key: s3Key, entities: entityFilter || '', chain: !!useChain}),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
statusText.textContent = 'Error: ' + data.error;
|
||||||
|
statusText.className = 'text-danger small fw-semibold';
|
||||||
|
spinner.className = 'd-none';
|
||||||
|
closeBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const restoreId = data.restore_id;
|
||||||
|
statusText.textContent = 'Restore #' + restoreId + ' running…';
|
||||||
|
|
||||||
|
const evtSrc = new EventSource('/api/restore/' + restoreId + '/stream');
|
||||||
|
evtSrc.onmessage = function(e) {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.keepalive) return;
|
||||||
|
if (msg.done) {
|
||||||
|
evtSrc.close();
|
||||||
|
spinner.className = 'd-none';
|
||||||
|
closeBtn.disabled = false;
|
||||||
|
if (msg.status === 'success') {
|
||||||
|
title.textContent = 'Restore Complete';
|
||||||
|
statusText.innerHTML = '<i class="bi bi-check-circle-fill text-success me-1"></i><span class="text-success fw-semibold">Files extracted and ready to browse.</span>';
|
||||||
|
browseBtn.href = '/restore/' + restoreId;
|
||||||
|
browseBtn.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
title.textContent = 'Restore Failed';
|
||||||
|
statusText.innerHTML = '<i class="bi bi-x-circle-fill text-danger me-1"></i><span class="text-danger fw-semibold">Restore failed — see log above.</span>';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.log) appendLog(document.getElementById('restoreLog'), msg.log);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', function() {
|
||||||
|
const key = sessionStorage.getItem('restoreKey');
|
||||||
|
if (key) {
|
||||||
|
sessionStorage.removeItem('restoreKey');
|
||||||
|
_doRestore(key, '', false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function deleteRestore(id, btn) {
|
||||||
|
if (!confirm('Delete this restore and all extracted files?')) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
fetch('/api/restore/' + id, {method: 'DELETE'})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.ok) btn.closest('tr').remove();
|
||||||
|
else { btn.disabled = false; alert('Delete failed: ' + (data.error || 'unknown error')); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
165
templates/restore_view.html
Normal file
165
templates/restore_view.html
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %} — Restore #{{ restore['id'] }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<i class="bi bi-folder2-open me-2 text-warning"></i>Restore #{{ restore['id'] }}
|
||||||
|
</h4>
|
||||||
|
<div class="text-muted small mt-1">
|
||||||
|
{{ restore['s3_key'].split('/')[-1] }} ·
|
||||||
|
Started {{ restore['started_at'] | fmtdt }} ·
|
||||||
|
{{ restore['started_at'] | duration(restore['completed_at']) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% if restore['status'] == 'success' %}
|
||||||
|
<a href="{{ url_for('restore_download_zip', restore_id=restore['id']) }}"
|
||||||
|
class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-file-zip me-1"></i>Download All (ZIP)
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('restore_list') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>All Restores
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if streaming_id %}
|
||||||
|
<!-- Still running — show live log -->
|
||||||
|
<div class="card stat-card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<div class="spinner-border spinner-border-sm text-warning"></div>
|
||||||
|
<span id="liveStatus" class="text-muted">Restore in progress…</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-terminal" id="liveLog"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif restore['status'] == 'failed' %}
|
||||||
|
<div class="alert alert-danger border-0 shadow-sm">
|
||||||
|
<i class="bi bi-x-circle-fill me-2"></i>
|
||||||
|
<strong>Restore failed:</strong> {{ restore['error_message'] or 'Unknown error' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- Success — restore type banner -->
|
||||||
|
{% set rmode = restore_info.get('restore_mode', 'full') %}
|
||||||
|
{% if rmode == 'chain' %}
|
||||||
|
<div class="alert alert-success border-0 shadow-sm mb-4">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>
|
||||||
|
<strong>Chain Restore</strong> — {{ restore_info.get('chain_length', 1) }} archives merged
|
||||||
|
(1 full + {{ restore_info.get('chain_length', 1) - 1 }} incremental).
|
||||||
|
<span class="ms-2 text-success fw-semibold">{{ restore['file_count'] }} entity files — complete snapshot.</span>
|
||||||
|
</div>
|
||||||
|
{% elif rmode == 'incremental' %}
|
||||||
|
<div class="alert alert-warning border-0 shadow-sm mb-4">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
|
<strong>Partial Restore</strong> — this is a single incremental archive.
|
||||||
|
Only <strong>{{ restore['file_count'] }} entity files</strong> with changes in that specific run are present.
|
||||||
|
<span class="ms-2">Use <strong>Chain Restore</strong> from the Restore page for a complete snapshot.</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-success border-0 shadow-sm mb-4">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>
|
||||||
|
<strong>Full Snapshot</strong> — <strong>{{ restore['file_count'] }} entity files</strong> extracted.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if by_company %}
|
||||||
|
<!-- Search box -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" id="entitySearch" class="form-control"
|
||||||
|
placeholder="Filter entities… (e.g. customers, salesInvoices)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="accordion" id="companyAccordion">
|
||||||
|
{% for company in by_company | sort %}
|
||||||
|
{% set entities = by_company[company] %}
|
||||||
|
<div class="accordion-item border-0 mb-2 shadow-sm rounded overflow-hidden">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button fw-semibold" type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#collapse-{{ loop.index }}"
|
||||||
|
aria-expanded="true">
|
||||||
|
<i class="bi bi-building me-2 text-primary"></i>
|
||||||
|
{{ company }}
|
||||||
|
<span class="badge bg-primary bg-opacity-15 text-primary ms-2">
|
||||||
|
{{ entities | length }} entities
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="collapse-{{ loop.index }}" class="accordion-collapse collapse show">
|
||||||
|
<div class="accordion-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover table-sm mb-0 table-files">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Entity</th>
|
||||||
|
<th>Format</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th class="text-end"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for f in entities %}
|
||||||
|
<tr data-entity="{{ f['entity'] }}">
|
||||||
|
<td class="font-monospace">{{ f['entity'] }}</td>
|
||||||
|
<td><span class="badge bg-secondary bg-opacity-15 text-secondary">{{ f['ext'] }}</span></td>
|
||||||
|
<td class="text-muted small">{{ f['size_kb'] }} KB</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a href="{{ url_for('restore_download_file', restore_id=restore['id']) }}?path={{ f['path'] | urlencode }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm py-0">
|
||||||
|
<i class="bi bi-download me-1"></i>Download
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted text-center py-4">No entity files found in this restore.</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% if streaming_id %}
|
||||||
|
<script>
|
||||||
|
const liveLog = document.getElementById('liveLog');
|
||||||
|
const liveStatus = document.getElementById('liveStatus');
|
||||||
|
|
||||||
|
const evtSrc = new EventSource('/api/restore/{{ streaming_id }}/stream');
|
||||||
|
evtSrc.onmessage = function(e) {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.keepalive) return;
|
||||||
|
if (msg.done) {
|
||||||
|
evtSrc.close();
|
||||||
|
liveStatus.textContent = msg.status === 'success' ? 'Restore complete — reloading…' : 'Restore failed.';
|
||||||
|
if (msg.status === 'success') setTimeout(() => location.reload(), 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.log) appendLog(liveLog, msg.log);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if by_company %}
|
||||||
|
<script>
|
||||||
|
document.getElementById('entitySearch').addEventListener('input', function() {
|
||||||
|
const q = this.value.toLowerCase();
|
||||||
|
document.querySelectorAll('.table-files tbody tr').forEach(row => {
|
||||||
|
row.style.display = row.dataset.entity.toLowerCase().includes(q) ? '' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
259
templates/settings.html
Normal file
259
templates/settings.html
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %} — Settings{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h4><i class="bi bi-gear me-2 text-primary"></i>Settings</h4>
|
||||||
|
<button form="settingsForm" type="submit" class="btn btn-primary px-4">
|
||||||
|
<i class="bi bi-floppy me-2"></i>Save Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" id="settingsForm">
|
||||||
|
<div class="row g-4">
|
||||||
|
|
||||||
|
<!-- Azure AD -->
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card stat-card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="fw-bold mb-3 pb-2 border-bottom">
|
||||||
|
<i class="bi bi-microsoft me-2 text-primary"></i>Azure AD Application
|
||||||
|
</h6>
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Create an App Registration in Azure AD with <strong>API.ReadWrite.All</strong>
|
||||||
|
permission on Dynamics 365 Business Central and admin consent granted.
|
||||||
|
</p>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label small fw-semibold">Tenant ID (Directory ID)</label>
|
||||||
|
<input type="text" name="azure_tenant_id" class="form-control font-monospace"
|
||||||
|
value="{{ cfg.get('azure_tenant_id', '') }}"
|
||||||
|
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label small fw-semibold">Client ID (Application ID)</label>
|
||||||
|
<input type="text" name="azure_client_id" class="form-control font-monospace"
|
||||||
|
value="{{ cfg.get('azure_client_id', '') }}"
|
||||||
|
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label small fw-semibold">Client Secret</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" name="azure_client_secret" id="azureSecret" class="form-control"
|
||||||
|
value="{{ cfg.get('azure_client_secret', '') }}"
|
||||||
|
placeholder="Azure AD client secret value" required>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="togglePassword('azureSecret', this)">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Business Central -->
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card stat-card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="fw-bold mb-3 pb-2 border-bottom">
|
||||||
|
<i class="bi bi-building me-2 text-success"></i>Business Central
|
||||||
|
</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label small fw-semibold">Environment Name</label>
|
||||||
|
<input type="text" name="bc_environment_name" class="form-control"
|
||||||
|
value="{{ cfg.get('bc_environment_name', 'Production') }}"
|
||||||
|
placeholder="Production" required>
|
||||||
|
<div class="form-text">Found in BC Admin Center — typically "Production" or "Sandbox".</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label small fw-semibold">
|
||||||
|
Company Name <span class="fw-normal text-muted">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" name="bc_company_name" class="form-control"
|
||||||
|
value="{{ cfg.get('bc_company_name', '') }}"
|
||||||
|
placeholder="Leave blank to back up all companies">
|
||||||
|
<div class="form-text">Filter backup to a specific company. Leave blank for all.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- S3 / MinIO -->
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card stat-card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="fw-bold mb-3 pb-2 border-bottom">
|
||||||
|
<i class="bi bi-hdd-stack-fill me-2 text-warning"></i>S3 / MinIO Storage
|
||||||
|
</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label small fw-semibold">Endpoint URL</label>
|
||||||
|
<input type="url" name="s3_endpoint" class="form-control"
|
||||||
|
value="{{ cfg.get('s3_endpoint', '') }}"
|
||||||
|
placeholder="https://minio.your-domain.com:9000" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label small fw-semibold">Access Key</label>
|
||||||
|
<input type="text" name="s3_access_key" class="form-control"
|
||||||
|
value="{{ cfg.get('s3_access_key', '') }}"
|
||||||
|
placeholder="Access key / username" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label small fw-semibold">Secret Key</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" name="s3_secret_key" id="s3Secret" class="form-control"
|
||||||
|
value="{{ cfg.get('s3_secret_key', '') }}"
|
||||||
|
placeholder="Secret key / password" required>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="togglePassword('s3Secret', this)">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<label class="form-label small fw-semibold">Bucket Name</label>
|
||||||
|
<input type="text" name="s3_bucket" class="form-control"
|
||||||
|
value="{{ cfg.get('s3_bucket', 'bcbak') }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-label small fw-semibold">Region</label>
|
||||||
|
<input type="text" name="s3_region" class="form-control"
|
||||||
|
value="{{ cfg.get('s3_region', 'us-east-1') }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Encryption -->
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="fw-bold mb-3 pb-2 border-bottom">
|
||||||
|
<i class="bi bi-shield-lock-fill me-2 text-success"></i>Encryption
|
||||||
|
</h6>
|
||||||
|
<div class="alert alert-warning border-0 small py-2 mb-3">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
Store this passphrase securely and separately from your backups — losing it means
|
||||||
|
losing access to all encrypted archives. Backups use GPG symmetric AES256.
|
||||||
|
</div>
|
||||||
|
<label class="form-label small fw-semibold">GPG Passphrase</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" name="encryption_passphrase" id="encPassphrase" class="form-control"
|
||||||
|
value="{{ cfg.get('encryption_passphrase', '') }}"
|
||||||
|
placeholder="Strong random passphrase" required>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="togglePassword('encPassphrase', this)">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="generatePassphrase()">
|
||||||
|
<i class="bi bi-shuffle"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">GPG symmetric AES256 · compatible with <code>gpg --decrypt</code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Incremental Schedule -->
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="fw-bold mb-3 pb-2 border-bottom">
|
||||||
|
<i class="bi bi-arrow-repeat me-2 text-success"></i>Incremental Backup Schedule
|
||||||
|
</h6>
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Exports only records modified since the last successful run
|
||||||
|
(<code>lastModifiedDateTime</code> filter). Run frequently to minimize data loss.
|
||||||
|
</p>
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="schedule_incr_enabled" id="incrToggle"
|
||||||
|
{% if cfg.get('schedule_incr_enabled', 'false') == 'true' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label fw-semibold" for="incrToggle">Enable incremental schedule</label>
|
||||||
|
</div>
|
||||||
|
<label class="form-label small fw-semibold">Cron Expression</label>
|
||||||
|
<input type="text" name="schedule_incr_cron" id="incrCron" class="form-control font-monospace"
|
||||||
|
value="{{ cfg.get('schedule_incr_cron', '0 * * * *') }}"
|
||||||
|
placeholder="0 * * * *">
|
||||||
|
<div class="form-text">
|
||||||
|
<code>0 * * * *</code> — every hour <code>0 */4 * * *</code> — every 4 h<br>
|
||||||
|
<a href="https://crontab.guru/" target="_blank" rel="noopener">crontab.guru ↗</a>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 d-flex flex-wrap gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('incrCron','0 * * * *')">Every hour</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('incrCron','0 */2 * * *')">Every 2 h</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('incrCron','0 */4 * * *')">Every 4 h</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('incrCron','0 8-20 * * 1-5')">Business hours</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Full Backup Schedule -->
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="fw-bold mb-3 pb-2 border-bottom">
|
||||||
|
<i class="bi bi-database-fill-up me-2 text-primary"></i>Full Backup Schedule
|
||||||
|
</h6>
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Complete export of all entities and all companies — no date filter applied.
|
||||||
|
Run weekly to provide a chain restore baseline.
|
||||||
|
</p>
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="schedule_full_enabled" id="fullToggle"
|
||||||
|
{% if cfg.get('schedule_full_enabled', 'false') == 'true' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label fw-semibold" for="fullToggle">Enable full backup schedule</label>
|
||||||
|
</div>
|
||||||
|
<label class="form-label small fw-semibold">Cron Expression</label>
|
||||||
|
<input type="text" name="schedule_full_cron" id="fullCron" class="form-control font-monospace"
|
||||||
|
value="{{ cfg.get('schedule_full_cron', '0 1 * * 0') }}"
|
||||||
|
placeholder="0 1 * * 0">
|
||||||
|
<div class="form-text">
|
||||||
|
<code>0 1 * * 0</code> — Sunday 01:00 <code>0 2 1 * *</code> — 1st of month 02:00<br>
|
||||||
|
<a href="https://crontab.guru/" target="_blank" rel="noopener">crontab.guru ↗</a>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 d-flex flex-wrap gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('fullCron','0 1 * * 0')">Weekly Sun 01:00</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('fullCron','0 1 * * 1')">Weekly Mon 01:00</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('fullCron','0 2 1 * *')">Monthly 1st 02:00</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end mt-3 gap-2">
|
||||||
|
<button form="settingsForm" type="submit" class="btn btn-primary px-5">
|
||||||
|
<i class="bi bi-floppy me-2"></i>Save Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function togglePassword(id, btn) {
|
||||||
|
const field = document.getElementById(id);
|
||||||
|
const isPass = field.type === 'password';
|
||||||
|
field.type = isPass ? 'text' : 'password';
|
||||||
|
btn.innerHTML = isPass ? '<i class="bi bi-eye-slash"></i>' : '<i class="bi bi-eye"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePassphrase() {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
|
||||||
|
const arr = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(arr);
|
||||||
|
const pass = Array.from(arr, b => chars[b % chars.length]).join('');
|
||||||
|
const field = document.getElementById('encPassphrase');
|
||||||
|
field.value = pass;
|
||||||
|
field.type = 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCron(fieldId, expr) {
|
||||||
|
document.getElementById(fieldId).value = expr;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
717
web.py
Normal file
717
web.py
Normal file
@@ -0,0 +1,717 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""BackupBC Web Interface — Flask app with APScheduler and live SSE log streaming."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
from flask import (Flask, Response, abort, flash, jsonify, redirect,
|
||||||
|
render_template, request, send_file, url_for)
|
||||||
|
|
||||||
|
from backup_bc import chain_restore, list_remote_backups, restore_backup, run_backup
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Paths
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DATA_DIR = Path(os.environ.get("BCBAK_DATA", Path(__file__).parent / "data"))
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
DB_PATH = DATA_DIR / "bcbak.db"
|
||||||
|
LOG_DIR = DATA_DIR / "logs"
|
||||||
|
RESTORE_DIR = DATA_DIR / "restores"
|
||||||
|
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
RESTORE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# App
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = os.environ.get("SECRET_KEY", os.urandom(24).hex())
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-8s %(message)s")
|
||||||
|
|
||||||
|
scheduler = BackgroundScheduler(timezone="UTC")
|
||||||
|
|
||||||
|
_active: dict[int, dict] = {}
|
||||||
|
_active_lock = threading.Lock()
|
||||||
|
|
||||||
|
_active_restores: dict[int, dict] = {}
|
||||||
|
_restore_lock = threading.Lock()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Database
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_db() -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS runs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
completed_at TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'running',
|
||||||
|
backup_type TEXT NOT NULL DEFAULT 'full',
|
||||||
|
triggered_by TEXT NOT NULL DEFAULT 'scheduled',
|
||||||
|
companies TEXT,
|
||||||
|
entities_ok INTEGER DEFAULT 0,
|
||||||
|
entities_error INTEGER DEFAULT 0,
|
||||||
|
total_records INTEGER DEFAULT 0,
|
||||||
|
archive_size_mb REAL,
|
||||||
|
s3_key TEXT,
|
||||||
|
parent_full_s3_key TEXT,
|
||||||
|
run_start_time TEXT,
|
||||||
|
error_message TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS restores (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
completed_at TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'running',
|
||||||
|
s3_key TEXT NOT NULL,
|
||||||
|
output_dir TEXT,
|
||||||
|
file_count INTEGER DEFAULT 0,
|
||||||
|
is_chain INTEGER DEFAULT 0,
|
||||||
|
chain_length INTEGER DEFAULT 1,
|
||||||
|
error_message TEXT
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
# Migrations for any missing columns
|
||||||
|
runs_cols = {row[1] for row in conn.execute("PRAGMA table_info(runs)").fetchall()}
|
||||||
|
restore_cols = {row[1] for row in conn.execute("PRAGMA table_info(restores)").fetchall()}
|
||||||
|
for col, defn in [
|
||||||
|
("parent_full_s3_key", "TEXT"),
|
||||||
|
("run_start_time", "TEXT"),
|
||||||
|
("total_records", "INTEGER DEFAULT 0"),
|
||||||
|
]:
|
||||||
|
if col not in runs_cols:
|
||||||
|
conn.execute(f"ALTER TABLE runs ADD COLUMN {col} {defn}")
|
||||||
|
for col, defn in [
|
||||||
|
("is_chain", "INTEGER DEFAULT 0"),
|
||||||
|
("chain_length", "INTEGER DEFAULT 1"),
|
||||||
|
]:
|
||||||
|
if col not in restore_cols:
|
||||||
|
conn.execute(f"ALTER TABLE restores ADD COLUMN {col} {defn}")
|
||||||
|
|
||||||
|
|
||||||
|
def cfg_get(key: str, default: str = "") -> str:
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute("SELECT value FROM config WHERE key=?", (key,)).fetchone()
|
||||||
|
return row["value"] if row else default
|
||||||
|
|
||||||
|
|
||||||
|
def cfg_all() -> dict:
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute("SELECT key, value FROM config").fetchall()
|
||||||
|
return {r["key"]: r["value"] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def cfg_set(data: dict) -> None:
|
||||||
|
with get_db() as conn:
|
||||||
|
for k, v in data.items():
|
||||||
|
conn.execute("INSERT OR REPLACE INTO config(key,value) VALUES(?,?)", (k, str(v)))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config builder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _config_for_backup() -> dict:
|
||||||
|
c = cfg_all()
|
||||||
|
return {
|
||||||
|
"azure_tenant_id": c.get("azure_tenant_id", ""),
|
||||||
|
"azure_client_id": c.get("azure_client_id", ""),
|
||||||
|
"azure_client_secret": c.get("azure_client_secret", ""),
|
||||||
|
"bc_environment_name": c.get("bc_environment_name", "Production"),
|
||||||
|
"bc_company_name": c.get("bc_company_name", ""),
|
||||||
|
"s3_endpoint": c.get("s3_endpoint", ""),
|
||||||
|
"s3_access_key": c.get("s3_access_key", ""),
|
||||||
|
"s3_secret_key": c.get("s3_secret_key", ""),
|
||||||
|
"s3_bucket": c.get("s3_bucket", "bcbak"),
|
||||||
|
"s3_region": c.get("s3_region", "us-east-1"),
|
||||||
|
"encryption_passphrase": c.get("encryption_passphrase", ""),
|
||||||
|
"log_dir": str(LOG_DIR),
|
||||||
|
"last_full_s3_key": c.get("last_full_s3_key", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Backup runner
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _launch_backup(triggered_by: str = "scheduled", force_full: bool = False) -> int:
|
||||||
|
with _active_lock:
|
||||||
|
if any(i["status"] == "running" for i in _active.values()):
|
||||||
|
raise RuntimeError("A backup is already in progress")
|
||||||
|
|
||||||
|
backup_type = "full" if force_full else "incremental"
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO runs(started_at,status,backup_type,triggered_by) VALUES(?,?,?,?)",
|
||||||
|
(datetime.datetime.now().isoformat(timespec="seconds"), "running",
|
||||||
|
backup_type, triggered_by),
|
||||||
|
)
|
||||||
|
run_id = cur.lastrowid
|
||||||
|
|
||||||
|
log_q: queue.Queue = queue.Queue()
|
||||||
|
with _active_lock:
|
||||||
|
_active[run_id] = {"queue": log_q, "status": "running"}
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
try:
|
||||||
|
last_run_time = None
|
||||||
|
if not force_full:
|
||||||
|
last_run_time = cfg_get("last_incr_run_time") or None
|
||||||
|
|
||||||
|
result = run_backup(
|
||||||
|
_config_for_backup(),
|
||||||
|
log_callback=lambda m: log_q.put(m),
|
||||||
|
force_full=force_full,
|
||||||
|
last_run_time=last_run_time,
|
||||||
|
)
|
||||||
|
status = result.get("status", "failed")
|
||||||
|
if status == "skipped":
|
||||||
|
status = "success" # skipped = no changes = not a failure
|
||||||
|
|
||||||
|
if status == "success" and result.get("s3_key"):
|
||||||
|
if force_full:
|
||||||
|
cfg_set({"last_full_s3_key": result["s3_key"]})
|
||||||
|
if result.get("run_start_time"):
|
||||||
|
cfg_set({"last_incr_run_time": result["run_start_time"]})
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE runs
|
||||||
|
SET status=?, completed_at=?, companies=?,
|
||||||
|
entities_ok=?, entities_error=?, total_records=?,
|
||||||
|
archive_size_mb=?, s3_key=?, parent_full_s3_key=?,
|
||||||
|
run_start_time=?, error_message=?
|
||||||
|
WHERE id=?""",
|
||||||
|
(status,
|
||||||
|
datetime.datetime.now().isoformat(timespec="seconds"),
|
||||||
|
json.dumps(result.get("companies", [])),
|
||||||
|
result.get("entities_ok", 0),
|
||||||
|
result.get("entities_error", 0),
|
||||||
|
result.get("total_records", 0),
|
||||||
|
result.get("archive_size_mb"),
|
||||||
|
result.get("s3_key"),
|
||||||
|
result.get("parent_full_s3_key"),
|
||||||
|
result.get("run_start_time"),
|
||||||
|
result.get("error"),
|
||||||
|
run_id),
|
||||||
|
)
|
||||||
|
with _active_lock:
|
||||||
|
_active[run_id]["status"] = status
|
||||||
|
except Exception as exc:
|
||||||
|
app.logger.error(f"Backup worker error: {exc}", exc_info=True)
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE runs SET status='failed', completed_at=?, error_message=? WHERE id=?",
|
||||||
|
(datetime.datetime.now().isoformat(timespec="seconds"), str(exc), run_id),
|
||||||
|
)
|
||||||
|
log_q.put(f"CRITICAL: {exc}")
|
||||||
|
with _active_lock:
|
||||||
|
_active[run_id]["status"] = "failed"
|
||||||
|
finally:
|
||||||
|
log_q.put(None)
|
||||||
|
|
||||||
|
t = threading.Thread(target=worker, daemon=True, name=f"backup-{run_id}")
|
||||||
|
t.start()
|
||||||
|
with _active_lock:
|
||||||
|
_active[run_id]["thread"] = t
|
||||||
|
return run_id
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Restore runner
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_restore_chain(target_s3_key: str) -> list[str] | None:
|
||||||
|
"""Return ordered [full_key, incr_1, ..., target_key] or None if unavailable."""
|
||||||
|
with get_db() as conn:
|
||||||
|
target = conn.execute(
|
||||||
|
"SELECT * FROM runs WHERE s3_key=? AND status='success'",
|
||||||
|
(target_s3_key,),
|
||||||
|
).fetchone()
|
||||||
|
if not target or target["backup_type"] == "full":
|
||||||
|
return None
|
||||||
|
parent_key = target["parent_full_s3_key"]
|
||||||
|
if not parent_key:
|
||||||
|
return None
|
||||||
|
full_run = conn.execute(
|
||||||
|
"SELECT started_at FROM runs WHERE s3_key=? AND status='success'",
|
||||||
|
(parent_key,),
|
||||||
|
).fetchone()
|
||||||
|
if not full_run:
|
||||||
|
return None
|
||||||
|
incrementals = conn.execute(
|
||||||
|
"""SELECT s3_key FROM runs
|
||||||
|
WHERE backup_type='incremental' AND parent_full_s3_key=?
|
||||||
|
AND started_at >= ? AND started_at <= ? AND status='success'
|
||||||
|
ORDER BY started_at ASC""",
|
||||||
|
(parent_key, full_run["started_at"], target["started_at"]),
|
||||||
|
).fetchall()
|
||||||
|
return [parent_key] + [r["s3_key"] for r in incrementals]
|
||||||
|
|
||||||
|
|
||||||
|
def _launch_restore(s3_key: str, entity_filter: str | None = None, chain: bool = False) -> int:
|
||||||
|
chain_keys: list[str] | None = None
|
||||||
|
if chain:
|
||||||
|
chain_keys = _get_restore_chain(s3_key)
|
||||||
|
if not chain_keys:
|
||||||
|
chain = False
|
||||||
|
|
||||||
|
is_chain = chain and chain_keys is not None
|
||||||
|
chain_len = len(chain_keys) if chain_keys else 1
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO restores(started_at,status,s3_key,is_chain,chain_length) VALUES(?,?,?,?,?)",
|
||||||
|
(datetime.datetime.now().isoformat(timespec="seconds"), "running",
|
||||||
|
s3_key, int(is_chain), chain_len),
|
||||||
|
)
|
||||||
|
restore_id = cur.lastrowid
|
||||||
|
|
||||||
|
out_dir = RESTORE_DIR / str(restore_id)
|
||||||
|
log_q: queue.Queue = queue.Queue()
|
||||||
|
with _restore_lock:
|
||||||
|
_active_restores[restore_id] = {"queue": log_q, "status": "running"}
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
try:
|
||||||
|
cfg = _config_for_backup()
|
||||||
|
if is_chain:
|
||||||
|
result = chain_restore(chain_keys, cfg, out_dir,
|
||||||
|
log_callback=lambda m: log_q.put(m),
|
||||||
|
entity_filter=entity_filter)
|
||||||
|
else:
|
||||||
|
result = restore_backup(s3_key, cfg, out_dir,
|
||||||
|
log_callback=lambda m: log_q.put(m),
|
||||||
|
entity_filter=entity_filter)
|
||||||
|
status = result["status"]
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE restores
|
||||||
|
SET status=?,completed_at=?,output_dir=?,file_count=?,error_message=?
|
||||||
|
WHERE id=?""",
|
||||||
|
(status,
|
||||||
|
datetime.datetime.now().isoformat(timespec="seconds"),
|
||||||
|
str(out_dir),
|
||||||
|
result.get("file_count", 0),
|
||||||
|
result.get("error"),
|
||||||
|
restore_id),
|
||||||
|
)
|
||||||
|
with _restore_lock:
|
||||||
|
_active_restores[restore_id]["status"] = status
|
||||||
|
except Exception as exc:
|
||||||
|
app.logger.error(f"Restore worker error: {exc}", exc_info=True)
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE restores SET status='failed',completed_at=?,error_message=? WHERE id=?",
|
||||||
|
(datetime.datetime.now().isoformat(timespec="seconds"), str(exc), restore_id),
|
||||||
|
)
|
||||||
|
log_q.put(f"CRITICAL: {exc}")
|
||||||
|
with _restore_lock:
|
||||||
|
_active_restores[restore_id]["status"] = "failed"
|
||||||
|
finally:
|
||||||
|
log_q.put(None)
|
||||||
|
|
||||||
|
t = threading.Thread(target=worker, daemon=True, name=f"restore-{restore_id}")
|
||||||
|
t.start()
|
||||||
|
with _restore_lock:
|
||||||
|
_active_restores[restore_id]["thread"] = t
|
||||||
|
return restore_id
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SSE helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _sse_stream(run_id: int, active_dict: dict, lock: threading.Lock):
|
||||||
|
with lock:
|
||||||
|
info = active_dict.get(run_id)
|
||||||
|
if info is None:
|
||||||
|
yield f"data: {json.dumps({'done': True, 'status': 'unknown'})}\n\n"
|
||||||
|
return
|
||||||
|
log_q = info["queue"]
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = log_q.get(timeout=25)
|
||||||
|
if msg is None:
|
||||||
|
with lock:
|
||||||
|
status = active_dict[run_id]["status"]
|
||||||
|
yield f"data: {json.dumps({'done': True, 'status': status})}\n\n"
|
||||||
|
return
|
||||||
|
yield f"data: {json.dumps({'log': msg})}\n\n"
|
||||||
|
except Exception:
|
||||||
|
yield f"data: {json.dumps({'keepalive': True})}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scheduler
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _cron_trigger(expr: str) -> CronTrigger:
|
||||||
|
parts = expr.strip().split()
|
||||||
|
return CronTrigger(
|
||||||
|
minute=parts[0], hour=parts[1], day=parts[2],
|
||||||
|
month=parts[3], day_of_week=parts[4],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_schedule() -> None:
|
||||||
|
scheduler.remove_all_jobs()
|
||||||
|
if cfg_get("schedule_incr_enabled", "false").lower() == "true":
|
||||||
|
cron = cfg_get("schedule_incr_cron", "0 * * * *").strip()
|
||||||
|
try:
|
||||||
|
scheduler.add_job(_launch_backup, _cron_trigger(cron), id="incr_backup",
|
||||||
|
kwargs={"triggered_by": "scheduled", "force_full": False},
|
||||||
|
misfire_grace_time=3600)
|
||||||
|
app.logger.info(f"Incremental backup scheduled: {cron}")
|
||||||
|
except Exception as exc:
|
||||||
|
app.logger.error(f"Invalid incremental cron '{cron}': {exc}")
|
||||||
|
if cfg_get("schedule_full_enabled", "false").lower() == "true":
|
||||||
|
cron = cfg_get("schedule_full_cron", "0 1 * * 0").strip()
|
||||||
|
try:
|
||||||
|
scheduler.add_job(_launch_backup, _cron_trigger(cron), id="full_backup",
|
||||||
|
kwargs={"triggered_by": "scheduled", "force_full": True},
|
||||||
|
misfire_grace_time=3600)
|
||||||
|
app.logger.info(f"Full backup scheduled: {cron}")
|
||||||
|
except Exception as exc:
|
||||||
|
app.logger.error(f"Invalid full backup cron '{cron}': {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Template filters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.template_filter("duration")
|
||||||
|
def duration_filter(started: str, completed: str | None) -> str:
|
||||||
|
if not started or not completed:
|
||||||
|
return "—"
|
||||||
|
try:
|
||||||
|
secs = int((datetime.datetime.fromisoformat(completed) -
|
||||||
|
datetime.datetime.fromisoformat(started)).total_seconds())
|
||||||
|
return f"{secs // 60}m {secs % 60}s" if secs >= 60 else f"{secs}s"
|
||||||
|
except Exception:
|
||||||
|
return "—"
|
||||||
|
|
||||||
|
|
||||||
|
@app.template_filter("fmtdt")
|
||||||
|
def fmtdt_filter(dt_str: str | None) -> str:
|
||||||
|
return dt_str[:16].replace("T", " ") if dt_str else "—"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes — pages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def dashboard():
|
||||||
|
with get_db() as conn:
|
||||||
|
recent = conn.execute("SELECT * FROM runs ORDER BY started_at DESC LIMIT 5").fetchall()
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0]
|
||||||
|
last_ok = conn.execute("SELECT * FROM runs WHERE status='success' ORDER BY started_at DESC LIMIT 1").fetchone()
|
||||||
|
last_full = conn.execute("SELECT * FROM runs WHERE status='success' AND backup_type='full' ORDER BY started_at DESC LIMIT 1").fetchone()
|
||||||
|
last_failed = conn.execute("SELECT * FROM runs WHERE status='failed' ORDER BY started_at DESC LIMIT 1").fetchone()
|
||||||
|
|
||||||
|
with _active_lock:
|
||||||
|
running_id = next((rid for rid, i in _active.items() if i["status"] == "running"), None)
|
||||||
|
|
||||||
|
job_incr = scheduler.get_job("incr_backup")
|
||||||
|
job_full = scheduler.get_job("full_backup")
|
||||||
|
next_incr = job_incr.next_run_time.strftime("%Y-%m-%d %H:%M UTC") if job_incr and job_incr.next_run_time else None
|
||||||
|
next_full_run = job_full.next_run_time.strftime("%Y-%m-%d %H:%M UTC") if job_full and job_full.next_run_time else None
|
||||||
|
|
||||||
|
return render_template("dashboard.html", recent=recent, total=total,
|
||||||
|
last_ok=last_ok, last_full=last_full, last_failed=last_failed,
|
||||||
|
running_id=running_id, next_incr=next_incr, next_full_run=next_full_run)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/history")
|
||||||
|
def history():
|
||||||
|
page = max(1, request.args.get("page", 1, type=int))
|
||||||
|
per_page = 25
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
with get_db() as conn:
|
||||||
|
runs = conn.execute("SELECT * FROM runs ORDER BY started_at DESC LIMIT ? OFFSET ?",
|
||||||
|
(per_page, offset)).fetchall()
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0]
|
||||||
|
pages = max(1, (total + per_page - 1) // per_page)
|
||||||
|
return render_template("history.html", runs=runs, page=page, pages=pages, total=total)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/restore")
|
||||||
|
def restore_list():
|
||||||
|
with get_db() as conn:
|
||||||
|
restores = conn.execute("SELECT * FROM restores ORDER BY started_at DESC").fetchall()
|
||||||
|
|
||||||
|
remote_backups = []
|
||||||
|
try:
|
||||||
|
remote_backups = list_remote_backups(_config_for_backup())
|
||||||
|
with get_db() as conn:
|
||||||
|
for b in remote_backups:
|
||||||
|
run = conn.execute(
|
||||||
|
"SELECT backup_type, parent_full_s3_key, started_at FROM runs WHERE s3_key=? AND status='success'",
|
||||||
|
(b["key"],),
|
||||||
|
).fetchone()
|
||||||
|
if run:
|
||||||
|
b["backup_type"] = run["backup_type"]
|
||||||
|
parent = run["parent_full_s3_key"]
|
||||||
|
if run["backup_type"] == "incremental" and parent:
|
||||||
|
full_run = conn.execute(
|
||||||
|
"SELECT started_at FROM runs WHERE s3_key=? AND status='success'",
|
||||||
|
(parent,),
|
||||||
|
).fetchone()
|
||||||
|
if full_run:
|
||||||
|
cnt = conn.execute(
|
||||||
|
"""SELECT COUNT(*) FROM runs
|
||||||
|
WHERE backup_type='incremental' AND parent_full_s3_key=?
|
||||||
|
AND started_at>=? AND started_at<=? AND status='success'""",
|
||||||
|
(parent, full_run["started_at"], run["started_at"]),
|
||||||
|
).fetchone()[0]
|
||||||
|
b["chain_available"] = True
|
||||||
|
b["chain_length"] = cnt + 1
|
||||||
|
else:
|
||||||
|
b["chain_available"] = False
|
||||||
|
else:
|
||||||
|
b["chain_available"] = False
|
||||||
|
except Exception as exc:
|
||||||
|
app.logger.warning(f"Could not list remote backups: {exc}")
|
||||||
|
|
||||||
|
return render_template("restore_list.html", restores=restores, remote_backups=remote_backups)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/restore/<int:restore_id>")
|
||||||
|
def restore_view(restore_id: int):
|
||||||
|
with get_db() as conn:
|
||||||
|
restore = conn.execute("SELECT * FROM restores WHERE id=?", (restore_id,)).fetchone()
|
||||||
|
if not restore:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
by_company: dict[str, list] = {}
|
||||||
|
restore_info: dict = {}
|
||||||
|
SKIP = {"manifest.json", "chain_manifest.json", "_company.json"}
|
||||||
|
|
||||||
|
if restore["status"] == "success" and restore["output_dir"]:
|
||||||
|
output_dir = Path(restore["output_dir"])
|
||||||
|
|
||||||
|
# Read manifest
|
||||||
|
chain_manifest = output_dir / "chain" / "chain_manifest.json"
|
||||||
|
if chain_manifest.exists():
|
||||||
|
try:
|
||||||
|
restore_info = json.loads(chain_manifest.read_text())
|
||||||
|
restore_info["restore_mode"] = "chain"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
for mf in output_dir.rglob("manifest.json"):
|
||||||
|
try:
|
||||||
|
restore_info = json.loads(mf.read_text())
|
||||||
|
restore_info["restore_mode"] = restore_info.get("backup_type", "full")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
|
||||||
|
for f in sorted(list(output_dir.rglob("*.json")) + list(output_dir.rglob("*.jsonl"))):
|
||||||
|
if f.name in SKIP:
|
||||||
|
continue
|
||||||
|
rel = f.relative_to(output_dir)
|
||||||
|
parts = rel.parts
|
||||||
|
# chain: chain/{company}/{entity}.json → parts[1]=company
|
||||||
|
# regular: {archive_name}/{company}/{entity}.json → parts[1]=company
|
||||||
|
company = parts[1] if len(parts) >= 3 else "root"
|
||||||
|
by_company.setdefault(company, []).append({
|
||||||
|
"entity": f.stem,
|
||||||
|
"ext": f.suffix,
|
||||||
|
"path": str(rel),
|
||||||
|
"size_kb": round(f.stat().st_size / 1024, 1),
|
||||||
|
})
|
||||||
|
|
||||||
|
with _restore_lock:
|
||||||
|
streaming_id = (restore_id
|
||||||
|
if restore_id in _active_restores
|
||||||
|
and _active_restores[restore_id]["status"] == "running"
|
||||||
|
else None)
|
||||||
|
|
||||||
|
return render_template("restore_view.html", restore=restore, restore_info=restore_info,
|
||||||
|
by_company=by_company, streaming_id=streaming_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/settings", methods=["GET", "POST"])
|
||||||
|
def settings():
|
||||||
|
if request.method == "POST":
|
||||||
|
data = dict(request.form)
|
||||||
|
for checkbox in ("schedule_incr_enabled", "schedule_full_enabled"):
|
||||||
|
data[checkbox] = "true" if checkbox in request.form else "false"
|
||||||
|
cfg_set(data)
|
||||||
|
_apply_schedule()
|
||||||
|
flash("Settings saved.", "success")
|
||||||
|
return redirect(url_for("settings"))
|
||||||
|
return render_template("settings.html", cfg=cfg_all())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes — API (backup)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.route("/api/run", methods=["POST"])
|
||||||
|
def api_run():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
force_full = bool(data.get("force_full", True))
|
||||||
|
try:
|
||||||
|
run_id = _launch_backup(triggered_by="manual", force_full=force_full)
|
||||||
|
return jsonify({"run_id": run_id, "backup_type": "full" if force_full else "incremental"})
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 409
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/run/<int:run_id>/stream")
|
||||||
|
def api_stream(run_id: int):
|
||||||
|
return Response(
|
||||||
|
_sse_stream(run_id, _active, _active_lock),
|
||||||
|
mimetype="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/status")
|
||||||
|
def api_status():
|
||||||
|
with _active_lock:
|
||||||
|
running_id = next((rid for rid, i in _active.items() if i["status"] == "running"), None)
|
||||||
|
return jsonify({"running": running_id is not None, "run_id": running_id})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes — API (restore)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.route("/api/restore/chain-info")
|
||||||
|
def api_restore_chain_info():
|
||||||
|
s3_key = request.args.get("s3_key", "").strip()
|
||||||
|
if not s3_key:
|
||||||
|
return jsonify({"error": "s3_key required"}), 400
|
||||||
|
chain_keys = _get_restore_chain(s3_key)
|
||||||
|
return jsonify({
|
||||||
|
"chain_available": chain_keys is not None,
|
||||||
|
"chain_length": len(chain_keys) if chain_keys else 1,
|
||||||
|
"chain_keys": chain_keys or [],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/restore", methods=["POST"])
|
||||||
|
def api_restore():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
s3_key = data.get("s3_key", "").strip()
|
||||||
|
if not s3_key:
|
||||||
|
return jsonify({"error": "s3_key is required"}), 400
|
||||||
|
entity_filter = data.get("entities", "").strip() or None
|
||||||
|
chain = bool(data.get("chain", False))
|
||||||
|
try:
|
||||||
|
restore_id = _launch_restore(s3_key, entity_filter=entity_filter, chain=chain)
|
||||||
|
return jsonify({"restore_id": restore_id})
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/restore/<int:restore_id>/stream")
|
||||||
|
def api_restore_stream(restore_id: int):
|
||||||
|
return Response(
|
||||||
|
_sse_stream(restore_id, _active_restores, _restore_lock),
|
||||||
|
mimetype="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/restore/<int:restore_id>/download")
|
||||||
|
def restore_download_file(restore_id: int):
|
||||||
|
with get_db() as conn:
|
||||||
|
restore = conn.execute("SELECT * FROM restores WHERE id=?", (restore_id,)).fetchone()
|
||||||
|
if not restore or not restore["output_dir"]:
|
||||||
|
abort(404)
|
||||||
|
rel_path = request.args.get("path", "")
|
||||||
|
if not rel_path:
|
||||||
|
abort(400)
|
||||||
|
output_dir = Path(restore["output_dir"]).resolve()
|
||||||
|
target = (output_dir / rel_path).resolve()
|
||||||
|
if not str(target).startswith(str(output_dir)):
|
||||||
|
abort(403)
|
||||||
|
if not target.exists():
|
||||||
|
abort(404)
|
||||||
|
return send_file(target, as_attachment=True, download_name=target.name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/restore/<int:restore_id>/download-zip")
|
||||||
|
def restore_download_zip(restore_id: int):
|
||||||
|
with get_db() as conn:
|
||||||
|
restore = conn.execute("SELECT * FROM restores WHERE id=?", (restore_id,)).fetchone()
|
||||||
|
if not restore or restore["status"] != "success" or not restore["output_dir"]:
|
||||||
|
abort(404)
|
||||||
|
output_dir = Path(restore["output_dir"])
|
||||||
|
archive_name = Path(restore["s3_key"]).name.replace(".tar.gz.gpg", "") + "_restored.zip"
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
|
||||||
|
for f in sorted(output_dir.rglob("*.json")):
|
||||||
|
zf.write(f, arcname=str(f.relative_to(output_dir)))
|
||||||
|
for f in sorted(output_dir.rglob("*.jsonl")):
|
||||||
|
zf.write(f, arcname=str(f.relative_to(output_dir)))
|
||||||
|
buf.seek(0)
|
||||||
|
return send_file(buf, mimetype="application/zip",
|
||||||
|
as_attachment=True, download_name=archive_name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/restore/<int:restore_id>", methods=["DELETE"])
|
||||||
|
def api_restore_delete(restore_id: int):
|
||||||
|
with get_db() as conn:
|
||||||
|
restore = conn.execute("SELECT * FROM restores WHERE id=?", (restore_id,)).fetchone()
|
||||||
|
if not restore:
|
||||||
|
abort(404)
|
||||||
|
with _restore_lock:
|
||||||
|
if restore_id in _active_restores and _active_restores[restore_id]["status"] == "running":
|
||||||
|
return jsonify({"error": "Restore is still running"}), 409
|
||||||
|
if restore["output_dir"]:
|
||||||
|
out = Path(restore["output_dir"])
|
||||||
|
if out.exists():
|
||||||
|
shutil.rmtree(out, ignore_errors=True)
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute("DELETE FROM restores WHERE id=?", (restore_id,))
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bootstrap
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_db()
|
||||||
|
_apply_schedule()
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
port = int(os.environ.get("PORT", 1890))
|
||||||
|
app.logger.info(f"BackupBC web UI starting on port {port}")
|
||||||
|
app.run(host="0.0.0.0", port=port, debug=False, threaded=True)
|
||||||
Reference in New Issue
Block a user