diff --git a/containers/backupchecks/src/backend/app/cove_importer.py b/containers/backupchecks/src/backend/app/cove_importer.py index ccc3107..8b339e7 100644 --- a/containers/backupchecks/src/backend/app/cove_importer.py +++ b/containers/backupchecks/src/backend/app/cove_importer.py @@ -12,6 +12,7 @@ Flow (mirrors the mail Inbox flow): from __future__ import annotations import logging +import re from datetime import datetime, timezone from typing import Any @@ -237,17 +238,58 @@ def _status_label(code: Any) -> str: def _ts_to_dt(value: Any) -> datetime | None: """Convert a Unix timestamp (int or str) to a naive UTC datetime.""" - if value is None: + ts = _extract_unix_ts(value) + if ts is None: return None try: - ts = int(value) - if ts <= 0: - return None return datetime.fromtimestamp(ts, tz=timezone.utc).replace(tzinfo=None) except (ValueError, TypeError, OSError): return None +def _extract_unix_ts(value: Any) -> int | None: + """Extract Unix epoch seconds from Cove timestamp variants. + + Supports: + - plain epoch seconds ("1735689600") + - epoch milliseconds ("1735689600000") + - .NET JSON date style ("/Date(1735689600000)/") + """ + if value is None: + return None + + # Plain int/float values + if isinstance(value, (int, float)): + ts = int(value) + else: + s = str(value).strip() + if not s: + return None + + # Handle /Date(1735689600000)/ (optionally with timezone suffix) + m = re.search(r"/Date\(([-]?\d+)(?:[+-]\d{4})?\)/", s) + if m: + s = m.group(1) + + try: + ts = int(s) + except (ValueError, TypeError): + return None + + if ts <= 0: + return None + + # Convert ms/us/ns to seconds when needed + if ts > 10_000_000_000_000_000: # ns + ts = ts // 1_000_000_000 + elif ts > 10_000_000_000_000: # us + ts = ts // 1_000_000 + elif ts > 10_000_000_000: # ms + ts = ts // 1_000 + + return ts if ts > 0 else None + + def _fmt_utc(dt: datetime | None) -> str: """Format a naive UTC datetime to readable text for run object messages.""" if not dt: @@ -255,7 +297,7 @@ def _fmt_utc(dt: datetime | None) -> str: return dt.strftime("%Y-%m-%d %H:%M UTC") -def run_cove_import(settings) -> tuple[int, int, int, int]: +def run_cove_import(settings, include_reasons: bool = False): """Fetch Cove account statistics and update the staging table + JobRuns. For every account: @@ -266,7 +308,9 @@ def run_cove_import(settings) -> tuple[int, int, int, int]: settings: SystemSettings ORM object with cove_* fields. Returns: - Tuple of (total_accounts, created_runs, skipped_runs, error_count). + By default: tuple(total_accounts, created_runs, skipped_runs, error_count). + When include_reasons=True: + tuple(total_accounts, created_runs, skipped_runs, error_count, reason_counts). Raises: CoveImportError if the API login fails. @@ -292,6 +336,7 @@ def run_cove_import(settings) -> tuple[int, int, int, int]: created = 0 skipped = 0 errors = 0 + reason_counts: dict[str, int] = {} page_size = 250 start = 0 @@ -310,13 +355,15 @@ def run_cove_import(settings) -> tuple[int, int, int, int]: for account in accounts: total += 1 try: - run_created = _process_account(account) - if run_created: + reason = _process_account(account) + if reason == "created": created += 1 else: skipped += 1 + reason_counts[reason] = reason_counts.get(reason, 0) + 1 except Exception as exc: errors += 1 + reason_counts["error"] = reason_counts.get("error", 0) + 1 logger.warning("Cove import: error processing account: %s", exc) try: db.session.rollback() @@ -334,13 +381,21 @@ def run_cove_import(settings) -> tuple[int, int, int, int]: except Exception: db.session.rollback() + if include_reasons: + return total, created, skipped, errors, reason_counts return total, created, skipped, errors -def _process_account(account: dict) -> bool: +def _process_account(account: dict) -> str: """Upsert a Cove account into the staging table and create a JobRun if linked. - Returns True if a new JobRun was created, False otherwise. + Returns a result code: + - "created" + - "skip_invalid_account_id" + - "skip_unlinked" + - "skip_no_timestamp" + - "skip_missing_job" + - "skip_duplicate" """ from .models import CoveAccount, Job, JobRun @@ -349,11 +404,11 @@ def _process_account(account: dict) -> bool: # AccountId is a top-level field account_id = account.get("AccountId") or account.get("AccountID") if not account_id: - return False + return "skip_invalid_account_id" try: account_id = int(account_id) except (ValueError, TypeError): - return False + return "skip_invalid_account_id" # Extract metadata from flat settings account_name = (flat.get("I1") or "").strip() or None @@ -409,23 +464,23 @@ def _process_account(account: dict) -> bool: # If still not linked to a job, nothing more to do (shows up in Cove Accounts page) if not cove_acc.job_id: db.session.commit() - return False + return "skip_unlinked" # Account is linked: create a JobRun if the last session is new if not last_run_at: db.session.commit() - return False + return "skip_no_timestamp" - try: - run_ts = int(last_run_ts_raw or 0) - except (TypeError, ValueError): - run_ts = 0 + run_ts = _extract_unix_ts(last_run_ts_raw) + if not run_ts: + # Fallback to parsed datetime to keep dedup stable for non-numeric raw formats. + run_ts = int(last_run_at.replace(tzinfo=timezone.utc).timestamp()) # Fetch the linked job job = Job.query.get(cove_acc.job_id) if not job: db.session.commit() - return False + return "skip_missing_job" external_id = f"cove-{account_id}-{run_ts}" @@ -435,7 +490,7 @@ def _process_account(account: dict) -> bool: existing = JobRun.query.filter_by(job_id=job.id, external_id=external_id).first() if existing: db.session.commit() - return False + return "skip_duplicate" status = _map_status(last_status_code) run_remark = ( @@ -465,7 +520,7 @@ def _process_account(account: dict) -> bool: _persist_datasource_objects(flat, job.customer_id, job.id, run.id, last_run_at) db.session.commit() - return True + return "created" def _persist_datasource_objects( diff --git a/containers/backupchecks/src/backend/app/main/routes_run_checks.py b/containers/backupchecks/src/backend/app/main/routes_run_checks.py index 1e45f4f..e94b4a9 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -1,6 +1,7 @@ from __future__ import annotations import calendar +import threading from datetime import date, datetime, time, timedelta, timezone @@ -43,6 +44,59 @@ from ..ticketing_utils import link_open_internal_tickets_to_run AUTOTASK_TERMINAL_STATUS_IDS = {5} RUN_CHECKS_SORT_MODES = {"customer", "status"} + +# --------------------------------------------------------------------------- +# Background task helpers +# --------------------------------------------------------------------------- + +# Throttle: track when we last ran missed-run generation per job. +# Key: job_id (int), Value: datetime of last run (UTC) +_missed_run_last_ran: dict[int, datetime] = {} +_missed_run_lock = threading.Lock() +_MISSED_RUN_THROTTLE = timedelta(minutes=10) + +# Guard: only one background sweep at a time +_bg_sweep_lock = threading.Lock() + + +def _run_background_sweep(app, job_ids_and_dates: list[tuple[int, date, date]], run_ids_for_at: list[int]) -> None: + """Heavy operations executed in a daemon thread so the page loads immediately. + + - Missed-run generation for eligible jobs (throttled per job). + - Autotask ticket state polling. + """ + if not _bg_sweep_lock.acquire(blocking=False): + # A sweep is already running; skip to avoid pile-up. + return + + try: + with app.app_context(): + # 1. Missed-run generation + try: + for job_id, start_date, end_date in job_ids_and_dates: + try: + job = Job.query.get(job_id) + if job is None: + continue + _ensure_missed_runs_for_job(job, start_date, end_date) + with _missed_run_lock: + _missed_run_last_ran[job_id] = datetime.utcnow() + except Exception: + try: + db.session.rollback() + except Exception: + pass + except Exception: + pass + + # 2. Autotask ticket state polling + try: + if run_ids_for_at: + _poll_autotask_ticket_states_for_runs(run_ids=run_ids_for_at) + except Exception: + pass + finally: + _bg_sweep_lock.release() RUN_CHECKS_STATUS_FILTER_VALUES = ("critical", "missed", "warning", "success_override", "success") @@ -942,9 +996,16 @@ def run_checks_page(): if get_active_role() == "admin": include_reviewed = request.args.get("include_reviewed", "0") in ("1", "true", "yes", "on") - # Generate missed runs since the last review per job so they show up in Run Checks. - # This is intentionally best-effort; any errors should not block page load. + # --------------------------------------------------------------------------- + # Background sweep: missed-run generation + Autotask ticket polling. + # Both operations are dispatched to a daemon thread so this page loads + # immediately. Results will be visible on the *next* page load. + # Missed-run generation is throttled per job (max once per 10 minutes). + # --------------------------------------------------------------------------- try: + from flask import current_app + app = current_app._get_current_object() # noqa: SLF001 — safe proxy unwrap + settings_start = _get_default_missed_start_date() last_reviewed_rows = ( @@ -954,36 +1015,59 @@ def run_checks_page(): ) last_reviewed_map = {int(jid): (dt if dt else None) for jid, dt in last_reviewed_rows} - jobs = ( + jobs_all = ( Job.query.outerjoin(Customer) .filter(Job.archived.is_(False)) .filter(db.or_(Customer.id.is_(None), Customer.active.is_(True))) .all() ) today_local = _to_amsterdam_date(datetime.utcnow()) or datetime.utcnow().date() + now_utc = datetime.utcnow() - for job in jobs: - if _is_hidden_3cx_non_backup(getattr(job, "backup_software", None), getattr(job, "backup_type", None)): - continue - last_rev = last_reviewed_map.get(int(job.id)) - if last_rev: - start_date = _to_amsterdam_date(last_rev) or settings_start - else: - start_date = settings_start - if start_date and start_date > today_local: - continue - _ensure_missed_runs_for_job(job, start_date, today_local) - except Exception: - # Don't block the page if missed-run generation fails. - pass + # Build list of jobs that need a missed-run sweep (throttled). + jobs_to_sweep: list[tuple[int, date, date]] = [] + with _missed_run_lock: + for job in jobs_all: + if _is_hidden_3cx_non_backup( + getattr(job, "backup_software", None), getattr(job, "backup_type", None) + ): + continue + last_ran = _missed_run_last_ran.get(int(job.id)) + if last_ran and (now_utc - last_ran) < _MISSED_RUN_THROTTLE: + continue # ran recently enough + last_rev = last_reviewed_map.get(int(job.id)) + if last_rev: + start_date = _to_amsterdam_date(last_rev) or settings_start + else: + start_date = settings_start + if start_date and start_date > today_local: + continue + jobs_to_sweep.append((int(job.id), start_date, today_local)) + + # Collect Autotask run ids for Phase 2 polling. + at_run_ids: list[int] = [] + try: + at_run_ids = [ + int(x) + for (x,) in JobRun.query + .filter(JobRun.reviewed_at.is_(None), JobRun.autotask_ticket_id.isnot(None)) + .with_entities(JobRun.id) + .limit(800) + .all() + ] + except Exception: + at_run_ids = [] + + if jobs_to_sweep or at_run_ids: + t = threading.Thread( + target=_run_background_sweep, + args=(app, jobs_to_sweep, at_run_ids), + daemon=True, + ) + t.start() - # Phase 2 (read-only PSA driven): sync internal ticket resolved state based on PSA ticket status. - # Best-effort: never blocks page load. - try: - run_q = JobRun.query.filter(JobRun.reviewed_at.is_(None), JobRun.autotask_ticket_id.isnot(None)) - run_ids = [int(x) for (x,) in run_q.with_entities(JobRun.id).limit(800).all()] - _poll_autotask_ticket_states_for_runs(run_ids=run_ids) except Exception: + # Never block the page load. pass # Aggregated per-job rows diff --git a/containers/backupchecks/src/backend/app/main/routes_settings.py b/containers/backupchecks/src/backend/app/main/routes_settings.py index 9d1bef5..1ec3dd8 100644 --- a/containers/backupchecks/src/backend/app/main/routes_settings.py +++ b/containers/backupchecks/src/backend/app/main/routes_settings.py @@ -1368,13 +1368,20 @@ def settings_cove_run_now(): return redirect(url_for("main.settings", section="integrations")) try: - total, created, skipped, errors = run_cove_import(settings) + total, created, skipped, errors, reasons = run_cove_import(settings, include_reasons=True) + reason_text = ", ".join(f"{k}={v}" for k, v in sorted(reasons.items())) or "none" _log_admin_event( "cove_import_manual", - f"Manual Cove import finished. accounts={total}, created={created}, skipped={skipped}, errors={errors}", + ( + "Manual Cove import finished. " + f"accounts={total}, created={created}, skipped={skipped}, errors={errors}, reasons={reason_text}" + ), ) flash( - f"Cove import finished. Accounts: {total}, new runs: {created}, skipped: {skipped}, errors: {errors}.", + ( + f"Cove import finished. Accounts: {total}, new runs: {created}, " + f"skipped: {skipped}, errors: {errors}. Skip reasons: {reason_text}." + ), "success" if errors == 0 else "warning", ) except CoveImportError as exc: diff --git a/containers/backupchecks/src/static/css/layout.css b/containers/backupchecks/src/static/css/layout.css index e1afe54..71acf72 100644 --- a/containers/backupchecks/src/static/css/layout.css +++ b/containers/backupchecks/src/static/css/layout.css @@ -1,25 +1,434 @@ -/* Global layout constraints - - Consistent content width across all pages - - Optimized for 1080p while preventing further widening on higher resolutions -*/ +/* ============================================================ + Backupchecks — Layout v2 + Sidebar-first, IBM Plex, token-based design system + ============================================================ */ -/* Default pages: use more horizontal space on 1920x1080 */ -main.content-container { - width: min(96vw, 1840px); - max-width: 1840px; +/* ---- Design tokens ---- */ +:root { + --bc-sidebar-w: 220px; + --bc-sidebar-bg: #0f1117; + --bc-sidebar-border: rgba(255,255,255,0.07); + --bc-sidebar-text: rgba(255,255,255,0.65); + --bc-sidebar-text-hover: rgba(255,255,255,0.95); + --bc-sidebar-active-bg: rgba(255,255,255,0.08); + --bc-sidebar-active-text: #fff; + --bc-sidebar-label-color: rgba(255,255,255,0.3); + --bc-logo-color: #fff; + --bc-accent: #3b82f6; + --bc-accent-dim: rgba(59,130,246,0.15); + --bc-main-bg: #f5f6f8; + --bc-main-bg-dark: #181c24; + --bc-content-max: 1720px; + --bc-radius: 8px; + --bc-font: 'IBM Plex Sans', system-ui, sans-serif; + --bc-mono: 'IBM Plex Mono', 'Cascadia Code', monospace; + --bc-transition: 150ms ease; } -/* Dashboard: keep the original width */ -main.dashboard-container { - width: min(90vw, 1728px); - max-width: 1728px; +/* ---- Global resets ---- */ +*, *::before, *::after { box-sizing: border-box; } + +body.bc-body { + font-family: var(--bc-font); + font-size: 14px; + line-height: 1.55; + margin: 0; + padding: 0; + display: flex; + min-height: 100vh; } -/* Prevent long detail values (e.g., email addresses) from overlapping other fields */ -.dl-compact dt { +/* ============================================================ + SIDEBAR + ============================================================ */ +.bc-sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: var(--bc-sidebar-w); + background: var(--bc-sidebar-bg); + border-right: 1px solid var(--bc-sidebar-border); + display: flex; + flex-direction: column; + z-index: 200; + overflow: hidden; +} + +/* ---- Header / Logo ---- */ +.bc-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 14px; + height: 52px; + flex-shrink: 0; + border-bottom: 1px solid var(--bc-sidebar-border); +} + +.bc-logo { + display: flex; + align-items: center; + gap: 9px; + text-decoration: none; + color: var(--bc-logo-color); +} + +.bc-logo-icon { display: flex; align-items: center; color: var(--bc-accent); flex-shrink: 0; } + +.bc-logo-text { + font-size: 13.5px; + font-weight: 600; + letter-spacing: -.01em; white-space: nowrap; } +.bc-sidebar-toggle { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--bc-sidebar-text); + cursor: pointer; + padding: 4px; + border-radius: 4px; +} + +.bc-sidebar-toggle:hover { color: var(--bc-sidebar-text-hover); } + +/* ---- Search ---- */ +.bc-sidebar-search { + padding: 10px 12px; + border-bottom: 1px solid var(--bc-sidebar-border); + flex-shrink: 0; +} + +.bc-search-form { + position: relative; + display: flex; + align-items: center; +} + +.bc-search-icon { + position: absolute; + left: 9px; + color: var(--bc-sidebar-label-color); + pointer-events: none; + flex-shrink: 0; +} + +.bc-search-input { + width: 100%; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + color: rgba(255,255,255,0.85); + font-size: 12.5px; + font-family: var(--bc-font); + padding: 5px 9px 5px 29px; + outline: none; + transition: border-color var(--bc-transition); +} + +.bc-search-input::placeholder { color: var(--bc-sidebar-label-color); } +.bc-search-input:focus { border-color: rgba(59,130,246,0.5); background: rgba(255,255,255,0.08); } + +/* ---- Nav ---- */ +.bc-nav-section { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 8px 0; + scrollbar-width: thin; + scrollbar-color: rgba(255,255,255,0.1) transparent; +} + +.bc-nav-section::-webkit-scrollbar { width: 4px; } +.bc-nav-section::-webkit-scrollbar-track { background: transparent; } +.bc-nav-section::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; } + +.bc-nav-item { + display: flex; + align-items: center; + gap: 9px; + padding: 6px 12px; + margin: 1px 8px; + border-radius: 6px; + text-decoration: none; + color: var(--bc-sidebar-text); + font-size: 13px; + font-weight: 500; + transition: background var(--bc-transition), color var(--bc-transition); + white-space: nowrap; +} + +.bc-nav-item:hover { + background: rgba(255,255,255,0.06); + color: var(--bc-sidebar-text-hover); +} + +.bc-nav-item.is-active { + background: var(--bc-sidebar-active-bg); + color: var(--bc-sidebar-active-text); +} + +.bc-nav-icon { display: flex; align-items: center; flex-shrink: 0; } +.bc-nav-label-text { flex: 1; overflow: hidden; text-overflow: ellipsis; } + +.bc-nav-badge { + background: var(--bc-accent); + color: #fff; + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 99px; + min-width: 18px; + text-align: center; + flex-shrink: 0; +} + +.bc-nav-divider { + height: 1px; + background: var(--bc-sidebar-border); + margin: 6px 12px; +} + +.bc-nav-label { + font-size: 10.5px; + font-weight: 600; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--bc-sidebar-label-color); + padding: 4px 20px 2px; +} + +/* ---- Footer ---- */ +.bc-sidebar-footer { + border-top: 1px solid var(--bc-sidebar-border); + padding: 10px 12px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.bc-user-block { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.bc-user-name { + font-size: 12.5px; + font-weight: 500; + color: rgba(255,255,255,0.75); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bc-user-name:hover { color: #fff; } + +.bc-user-role { + font-size: 10.5px; + color: var(--bc-sidebar-label-color); + background: rgba(255,255,255,0.06); + border-radius: 4px; + padding: 1px 6px; + white-space: nowrap; + flex-shrink: 0; +} + +.bc-footer-controls { + display: flex; + align-items: center; + gap: 6px; +} + +.bc-select { + flex: 1; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 5px; + color: rgba(255,255,255,0.75); + font-size: 11.5px; + font-family: var(--bc-font); + padding: 3px 6px; + outline: none; + cursor: pointer; + min-width: 0; +} + +.bc-select:focus { border-color: rgba(59,130,246,0.5); } + +.bc-logout { + display: flex; + align-items: center; + color: var(--bc-sidebar-text); + flex-shrink: 0; + padding: 4px; + border-radius: 4px; + transition: color var(--bc-transition); +} + +.bc-logout:hover { color: #ef4444; } + +/* ============================================================ + MAIN CONTENT AREA + ============================================================ */ +.bc-main { + flex: 1; + margin-left: var(--bc-sidebar-w); + min-width: 0; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.bc-main-auth { + margin-left: 0; +} + +/* ---- Topbar (mobile only) ---- */ +.bc-topbar { + position: sticky; + top: 0; + z-index: 100; + height: 48px; + display: flex; + align-items: center; + gap: 12px; + padding: 0 16px; + border-bottom: 1px solid var(--bs-border-color); + background: var(--bs-body-bg); +} + +.bc-hamburger { + background: none; + border: none; + color: var(--bs-body-color); + cursor: pointer; + display: flex; + align-items: center; + padding: 4px; + border-radius: 4px; +} + +.bc-topbar-logo { + font-size: 14px; + font-weight: 600; + text-decoration: none; + color: var(--bs-body-color); +} + +/* ---- Content ---- */ +.bc-content { + flex: 1; + padding: 28px 32px; + max-width: var(--bc-content-max); + width: 100%; +} + +/* ---- Overlay (mobile) ---- */ +.bc-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + z-index: 150; +} + +.bc-overlay.is-visible { display: block; } + +/* ============================================================ + AUTH PAGES (login, setup) + ============================================================ */ +.bc-main-auth .bc-content { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 32px 16px; +} + +/* ============================================================ + MOBILE SIDEBAR + ============================================================ */ +@media (max-width: 991px) { + .bc-sidebar { + transform: translateX(-100%); + transition: transform 220ms cubic-bezier(.4,0,.2,1); + } + + .bc-sidebar.is-open { + transform: translateX(0); + box-shadow: 4px 0 32px rgba(0,0,0,0.3); + } + + .bc-main { + margin-left: 0; + } +} + +/* ============================================================ + DARK THEME overrides + ============================================================ */ +[data-bs-theme="dark"] .bc-main { + background: var(--bc-main-bg-dark); +} + +[data-bs-theme="light"] .bc-main { + background: var(--bc-main-bg); +} + +/* ============================================================ + CONTENT TYPOGRAPHY & UTILITIES + ============================================================ */ + +/* Page headings */ +.bc-content h2 { + font-size: 20px; + font-weight: 600; + margin-bottom: 20px; + letter-spacing: -.02em; +} + +.bc-content h3 { + font-size: 16px; + font-weight: 600; +} + +/* Tables */ +.table { + font-size: 13.5px; +} + +.table thead th { + font-size: 11.5px; + font-weight: 600; + letter-spacing: .04em; + text-transform: uppercase; + opacity: .6; + border-bottom-width: 1px; + white-space: nowrap; +} + +/* Cards */ +.card { + border-radius: var(--bc-radius); +} + +/* Mono values (log lines, codes, etc.) */ +code, kbd, pre, .bc-mono { + font-family: var(--bc-mono); + font-size: 12.5px; +} + +/* Compact definition lists */ +.dl-compact dt { white-space: nowrap; } .dl-compact .ellipsis-field { min-width: 0; overflow: hidden; @@ -27,7 +436,6 @@ main.dashboard-container { white-space: nowrap; cursor: pointer; } - .dl-compact .ellipsis-field.is-expanded { overflow: visible; text-overflow: clip; @@ -35,55 +443,46 @@ main.dashboard-container { cursor: text; } -/* Markdown rendering (e.g., changelog page) */ -.markdown-content { - overflow-wrap: anywhere; -} - -.markdown-content h1, -.markdown-content h2, -.markdown-content h3, -.markdown-content h4, -.markdown-content h5, -.markdown-content h6 { +/* Markdown content */ +.markdown-content { overflow-wrap: anywhere; } +.markdown-content h1,.markdown-content h2,.markdown-content h3, +.markdown-content h4,.markdown-content h5,.markdown-content h6 { margin-top: 1.25rem; - margin-bottom: 0.75rem; + margin-bottom: .75rem; } - -.markdown-content p { - margin-bottom: 0.75rem; -} - -.markdown-content ul, -.markdown-content ol { - margin-bottom: 0.75rem; -} - +.markdown-content p { margin-bottom: .75rem; } +.markdown-content ul, .markdown-content ol { margin-bottom: .75rem; } .markdown-content pre { - padding: 0.75rem; - border-radius: 0.5rem; - background: rgba(0, 0, 0, 0.05); + padding: .75rem; + border-radius: .5rem; + background: rgba(0,0,0,.05); overflow: auto; } - -.markdown-content code { - font-size: 0.95em; +.markdown-content code { font-size: .95em; } +.markdown-content table { width: 100%; margin-bottom: 1rem; } +.markdown-content table th, .markdown-content table td { + padding: .5rem; + border-top: 1px solid rgba(0,0,0,.15); } - -.markdown-content table { - width: 100%; - margin-bottom: 1rem; -} - -.markdown-content table th, -.markdown-content table td { - padding: 0.5rem; - border-top: 1px solid rgba(0, 0, 0, 0.15); -} - .markdown-content blockquote { - border-left: 0.25rem solid rgba(0, 0, 0, 0.15); - padding-left: 0.75rem; + border-left: .25rem solid rgba(0,0,0,.15); + padding-left: .75rem; margin-left: 0; - color: rgba(0, 0, 0, 0.7); + color: rgba(0,0,0,.7); +} + +/* Alerts */ +.bc-alerts { max-width: 800px; } + +/* Info block (was used in run_checks) */ +.info-block { + font-size: 13.5px; + color: var(--bs-secondary-color); + max-width: 860px; +} + +/* Legacy compatibility: content-container / dashboard-container */ +main.content-container, +main.dashboard-container { + /* Replaced by .bc-content — these are no-ops now but kept for safety */ } diff --git a/containers/backupchecks/src/templates/documentation/base.html b/containers/backupchecks/src/templates/documentation/base.html index d26b043..7cb7010 100644 --- a/containers/backupchecks/src/templates/documentation/base.html +++ b/containers/backupchecks/src/templates/documentation/base.html @@ -4,8 +4,6 @@ {% endblock %} -{% block main_class %}container-fluid content-container{% endblock %} - {% block content %}
diff --git a/containers/backupchecks/src/templates/layout/base.html b/containers/backupchecks/src/templates/layout/base.html index 7bdcd11..918e63b 100644 --- a/containers/backupchecks/src/templates/layout/base.html +++ b/containers/backupchecks/src/templates/layout/base.html @@ -3,12 +3,12 @@ - Backupchecks + {% block title %}Backupchecks{% endblock %} - + + + + @@ -21,349 +21,288 @@ var root = document.documentElement; var pref = root.getAttribute('data-theme-preference') || 'auto'; var mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null; - function applyTheme() { var theme = pref; - if (pref === 'auto') { - theme = (mq && mq.matches) ? 'dark' : 'light'; - } + if (pref === 'auto') { theme = (mq && mq.matches) ? 'dark' : 'light'; } root.setAttribute('data-bs-theme', theme); + root.setAttribute('data-theme', theme); } - applyTheme(); - if (mq && typeof mq.addEventListener === 'function') { mq.addEventListener('change', function () { - if ((root.getAttribute('data-theme-preference') || 'auto') === 'auto') { - applyTheme(); - } - }); - } else if (mq && typeof mq.addListener === 'function') { - // Safari fallback - mq.addListener(function () { - if ((root.getAttribute('data-theme-preference') || 'auto') === 'auto') { - applyTheme(); - } + if ((root.getAttribute('data-theme-preference') || 'auto') === 'auto') { applyTheme(); } }); } - } catch (e) { - // no-op - } + } catch (e) {} })(); - + + {% if system_settings and system_settings.is_sandbox_environment %} -
- SANDBOX -
+
SANDBOX
{% endif %} -