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