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:
2026-06-26 20:09:32 +02:00
parent 025eb3896c
commit b651eca285
11 changed files with 2781 additions and 0 deletions

18
Dockerfile Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
boto3>=1.34
requests>=2.31
flask>=3.0
APScheduler>=3.10

278
templates/base.html Normal file
View 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
View 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
View 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
View 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
View 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
View 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 &nbsp; <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 &nbsp; <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
View 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)