Layout v2: sidebar-first design + background sweep + Cove improvements

- Redesign layout to fixed dark sidebar (220px) with IBM Plex Sans/Mono
  fonts and CSS design tokens; full rewrite of layout.css and base.html
- Move missed-run generation and Autotask ticket polling to a daemon
  background thread on Run Checks page load (throttled per job, 10 min)
- Cove manual import now logs and shows per-skip-reason breakdown
- Cove timestamp parser handles ms/us/ns epochs and .NET /Date(ms)/ strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ivo Oskamp 2026-03-19 15:15:50 +01:00
parent 2ebe7d8aed
commit 89f7506763
8 changed files with 909 additions and 414 deletions

View File

@ -12,6 +12,7 @@ Flow (mirrors the mail Inbox flow):
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
@ -237,17 +238,58 @@ def _status_label(code: Any) -> str:
def _ts_to_dt(value: Any) -> datetime | None: def _ts_to_dt(value: Any) -> datetime | None:
"""Convert a Unix timestamp (int or str) to a naive UTC datetime.""" """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 return None
try: try:
ts = int(value)
if ts <= 0:
return None
return datetime.fromtimestamp(ts, tz=timezone.utc).replace(tzinfo=None) return datetime.fromtimestamp(ts, tz=timezone.utc).replace(tzinfo=None)
except (ValueError, TypeError, OSError): except (ValueError, TypeError, OSError):
return None 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: def _fmt_utc(dt: datetime | None) -> str:
"""Format a naive UTC datetime to readable text for run object messages.""" """Format a naive UTC datetime to readable text for run object messages."""
if not dt: if not dt:
@ -255,7 +297,7 @@ def _fmt_utc(dt: datetime | None) -> str:
return dt.strftime("%Y-%m-%d %H:%M UTC") 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. """Fetch Cove account statistics and update the staging table + JobRuns.
For every account: For every account:
@ -266,7 +308,9 @@ def run_cove_import(settings) -> tuple[int, int, int, int]:
settings: SystemSettings ORM object with cove_* fields. settings: SystemSettings ORM object with cove_* fields.
Returns: 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: Raises:
CoveImportError if the API login fails. CoveImportError if the API login fails.
@ -292,6 +336,7 @@ def run_cove_import(settings) -> tuple[int, int, int, int]:
created = 0 created = 0
skipped = 0 skipped = 0
errors = 0 errors = 0
reason_counts: dict[str, int] = {}
page_size = 250 page_size = 250
start = 0 start = 0
@ -310,13 +355,15 @@ def run_cove_import(settings) -> tuple[int, int, int, int]:
for account in accounts: for account in accounts:
total += 1 total += 1
try: try:
run_created = _process_account(account) reason = _process_account(account)
if run_created: if reason == "created":
created += 1 created += 1
else: else:
skipped += 1 skipped += 1
reason_counts[reason] = reason_counts.get(reason, 0) + 1
except Exception as exc: except Exception as exc:
errors += 1 errors += 1
reason_counts["error"] = reason_counts.get("error", 0) + 1
logger.warning("Cove import: error processing account: %s", exc) logger.warning("Cove import: error processing account: %s", exc)
try: try:
db.session.rollback() db.session.rollback()
@ -334,13 +381,21 @@ def run_cove_import(settings) -> tuple[int, int, int, int]:
except Exception: except Exception:
db.session.rollback() db.session.rollback()
if include_reasons:
return total, created, skipped, errors, reason_counts
return total, created, skipped, errors 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. """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 from .models import CoveAccount, Job, JobRun
@ -349,11 +404,11 @@ def _process_account(account: dict) -> bool:
# AccountId is a top-level field # AccountId is a top-level field
account_id = account.get("AccountId") or account.get("AccountID") account_id = account.get("AccountId") or account.get("AccountID")
if not account_id: if not account_id:
return False return "skip_invalid_account_id"
try: try:
account_id = int(account_id) account_id = int(account_id)
except (ValueError, TypeError): except (ValueError, TypeError):
return False return "skip_invalid_account_id"
# Extract metadata from flat settings # Extract metadata from flat settings
account_name = (flat.get("I1") or "").strip() or None 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 still not linked to a job, nothing more to do (shows up in Cove Accounts page)
if not cove_acc.job_id: if not cove_acc.job_id:
db.session.commit() db.session.commit()
return False return "skip_unlinked"
# Account is linked: create a JobRun if the last session is new # Account is linked: create a JobRun if the last session is new
if not last_run_at: if not last_run_at:
db.session.commit() db.session.commit()
return False return "skip_no_timestamp"
try: run_ts = _extract_unix_ts(last_run_ts_raw)
run_ts = int(last_run_ts_raw or 0) if not run_ts:
except (TypeError, ValueError): # Fallback to parsed datetime to keep dedup stable for non-numeric raw formats.
run_ts = 0 run_ts = int(last_run_at.replace(tzinfo=timezone.utc).timestamp())
# Fetch the linked job # Fetch the linked job
job = Job.query.get(cove_acc.job_id) job = Job.query.get(cove_acc.job_id)
if not job: if not job:
db.session.commit() db.session.commit()
return False return "skip_missing_job"
external_id = f"cove-{account_id}-{run_ts}" 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() existing = JobRun.query.filter_by(job_id=job.id, external_id=external_id).first()
if existing: if existing:
db.session.commit() db.session.commit()
return False return "skip_duplicate"
status = _map_status(last_status_code) status = _map_status(last_status_code)
run_remark = ( 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) _persist_datasource_objects(flat, job.customer_id, job.id, run.id, last_run_at)
db.session.commit() db.session.commit()
return True return "created"
def _persist_datasource_objects( def _persist_datasource_objects(

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import calendar import calendar
import threading
from datetime import date, datetime, time, timedelta, timezone 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} AUTOTASK_TERMINAL_STATUS_IDS = {5}
RUN_CHECKS_SORT_MODES = {"customer", "status"} 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") RUN_CHECKS_STATUS_FILTER_VALUES = ("critical", "missed", "warning", "success_override", "success")
@ -942,9 +996,16 @@ def run_checks_page():
if get_active_role() == "admin": if get_active_role() == "admin":
include_reviewed = request.args.get("include_reviewed", "0") in ("1", "true", "yes", "on") 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: try:
from flask import current_app
app = current_app._get_current_object() # noqa: SLF001 — safe proxy unwrap
settings_start = _get_default_missed_start_date() settings_start = _get_default_missed_start_date()
last_reviewed_rows = ( last_reviewed_rows = (
@ -954,17 +1015,26 @@ def run_checks_page():
) )
last_reviewed_map = {int(jid): (dt if dt else None) for jid, dt in last_reviewed_rows} last_reviewed_map = {int(jid): (dt if dt else None) for jid, dt in last_reviewed_rows}
jobs = ( jobs_all = (
Job.query.outerjoin(Customer) Job.query.outerjoin(Customer)
.filter(Job.archived.is_(False)) .filter(Job.archived.is_(False))
.filter(db.or_(Customer.id.is_(None), Customer.active.is_(True))) .filter(db.or_(Customer.id.is_(None), Customer.active.is_(True)))
.all() .all()
) )
today_local = _to_amsterdam_date(datetime.utcnow()) or datetime.utcnow().date() today_local = _to_amsterdam_date(datetime.utcnow()) or datetime.utcnow().date()
now_utc = datetime.utcnow()
for job in jobs: # Build list of jobs that need a missed-run sweep (throttled).
if _is_hidden_3cx_non_backup(getattr(job, "backup_software", None), getattr(job, "backup_type", None)): 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 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)) last_rev = last_reviewed_map.get(int(job.id))
if last_rev: if last_rev:
start_date = _to_amsterdam_date(last_rev) or settings_start start_date = _to_amsterdam_date(last_rev) or settings_start
@ -972,18 +1042,32 @@ def run_checks_page():
start_date = settings_start start_date = settings_start
if start_date and start_date > today_local: if start_date and start_date > today_local:
continue continue
_ensure_missed_runs_for_job(job, start_date, today_local) jobs_to_sweep.append((int(job.id), start_date, today_local))
except Exception:
# Don't block the page if missed-run generation fails.
pass
# Phase 2 (read-only PSA driven): sync internal ticket resolved state based on PSA ticket status. # Collect Autotask run ids for Phase 2 polling.
# Best-effort: never blocks page load. at_run_ids: list[int] = []
try: try:
run_q = JobRun.query.filter(JobRun.reviewed_at.is_(None), JobRun.autotask_ticket_id.isnot(None)) at_run_ids = [
run_ids = [int(x) for (x,) in run_q.with_entities(JobRun.id).limit(800).all()] int(x)
_poll_autotask_ticket_states_for_runs(run_ids=run_ids) 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: 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()
except Exception:
# Never block the page load.
pass pass
# Aggregated per-job rows # Aggregated per-job rows

View File

@ -1368,13 +1368,20 @@ def settings_cove_run_now():
return redirect(url_for("main.settings", section="integrations")) return redirect(url_for("main.settings", section="integrations"))
try: 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( _log_admin_event(
"cove_import_manual", "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( 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", "success" if errors == 0 else "warning",
) )
except CoveImportError as exc: except CoveImportError as exc:

View File

@ -1,25 +1,434 @@
/* Global layout constraints /* ============================================================
- Consistent content width across all pages Backupchecks Layout v2
- Optimized for 1080p while preventing further widening on higher resolutions Sidebar-first, IBM Plex, token-based design system
*/ ============================================================ */
/* Default pages: use more horizontal space on 1920x1080 */ /* ---- Design tokens ---- */
main.content-container { :root {
width: min(96vw, 1840px); --bc-sidebar-w: 220px;
max-width: 1840px; --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 */ /* ---- Global resets ---- */
main.dashboard-container { *, *::before, *::after { box-sizing: border-box; }
width: min(90vw, 1728px);
max-width: 1728px; 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; 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 { .dl-compact .ellipsis-field {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@ -27,7 +436,6 @@ main.dashboard-container {
white-space: nowrap; white-space: nowrap;
cursor: pointer; cursor: pointer;
} }
.dl-compact .ellipsis-field.is-expanded { .dl-compact .ellipsis-field.is-expanded {
overflow: visible; overflow: visible;
text-overflow: clip; text-overflow: clip;
@ -35,55 +443,46 @@ main.dashboard-container {
cursor: text; cursor: text;
} }
/* Markdown rendering (e.g., changelog page) */ /* Markdown content */
.markdown-content { .markdown-content { overflow-wrap: anywhere; }
overflow-wrap: anywhere; .markdown-content h1,.markdown-content h2,.markdown-content h3,
} .markdown-content h4,.markdown-content h5,.markdown-content h6 {
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin-top: 1.25rem; margin-top: 1.25rem;
margin-bottom: 0.75rem; margin-bottom: .75rem;
} }
.markdown-content p { margin-bottom: .75rem; }
.markdown-content p { .markdown-content ul, .markdown-content ol { margin-bottom: .75rem; }
margin-bottom: 0.75rem;
}
.markdown-content ul,
.markdown-content ol {
margin-bottom: 0.75rem;
}
.markdown-content pre { .markdown-content pre {
padding: 0.75rem; padding: .75rem;
border-radius: 0.5rem; border-radius: .5rem;
background: rgba(0, 0, 0, 0.05); background: rgba(0,0,0,.05);
overflow: auto; overflow: auto;
} }
.markdown-content code { font-size: .95em; }
.markdown-content code { .markdown-content table { width: 100%; margin-bottom: 1rem; }
font-size: 0.95em; .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 { .markdown-content blockquote {
border-left: 0.25rem solid rgba(0, 0, 0, 0.15); border-left: .25rem solid rgba(0,0,0,.15);
padding-left: 0.75rem; padding-left: .75rem;
margin-left: 0; 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 */
} }

View File

@ -4,8 +4,6 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/documentation.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/documentation.css') }}">
{% endblock %} {% endblock %}
{% block main_class %}container-fluid content-container{% endblock %}
{% block content %} {% block content %}
<div class="documentation-container"> <div class="documentation-container">
<div class="row g-0"> <div class="row g-0">

View File

@ -3,12 +3,12 @@
<html lang="en" data-theme-preference="{{ _theme_pref }}"> <html lang="en" data-theme-preference="{{ _theme_pref }}">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Backupchecks</title> <title>{% block title %}Backupchecks{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" <link rel="preconnect" href="https://fonts.googleapis.com" />
rel="stylesheet" <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
/> <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/layout.css') }}" /> <link rel="stylesheet" href="{{ url_for('static', filename='css/layout.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/status-text.css') }}" /> <link rel="stylesheet" href="{{ url_for('static', filename='css/status-text.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/sandbox.css') }}" /> <link rel="stylesheet" href="{{ url_for('static', filename='css/sandbox.css') }}" />
@ -21,191 +21,163 @@
var root = document.documentElement; var root = document.documentElement;
var pref = root.getAttribute('data-theme-preference') || 'auto'; var pref = root.getAttribute('data-theme-preference') || 'auto';
var mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null; var mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
function applyTheme() { function applyTheme() {
var theme = pref; var theme = pref;
if (pref === 'auto') { if (pref === 'auto') { theme = (mq && mq.matches) ? 'dark' : 'light'; }
theme = (mq && mq.matches) ? 'dark' : 'light';
}
root.setAttribute('data-bs-theme', theme); root.setAttribute('data-bs-theme', theme);
root.setAttribute('data-theme', theme);
} }
applyTheme(); applyTheme();
if (mq && typeof mq.addEventListener === 'function') { if (mq && typeof mq.addEventListener === 'function') {
mq.addEventListener('change', function () { mq.addEventListener('change', function () {
if ((root.getAttribute('data-theme-preference') || 'auto') === 'auto') { if ((root.getAttribute('data-theme-preference') || 'auto') === 'auto') { applyTheme(); }
applyTheme();
}
});
} else if (mq && typeof mq.addListener === 'function') {
// Safari fallback
mq.addListener(function () {
if ((root.getAttribute('data-theme-preference') || 'auto') === 'auto') {
applyTheme();
}
}); });
} }
} catch (e) { } catch (e) {}
// no-op
}
})(); })();
</script> </script>
</head> </head>
<body> <body class="bc-body">
{% if system_settings and system_settings.is_sandbox_environment %} {% if system_settings and system_settings.is_sandbox_environment %}
<div class="sandbox-banner"> <div class="sandbox-banner"><span class="sandbox-banner-text">SANDBOX</span></div>
<span class="sandbox-banner-text">SANDBOX</span>
</div>
{% endif %} {% endif %}
<nav class="navbar navbar-expand-lg fixed-top bg-body-tertiary border-bottom">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">Backupchecks</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <!-- SIDEBAR -->
{% if active_role == 'reporter' %} <nav class="bc-sidebar" id="bcSidebar">
<li class="nav-item"> <div class="bc-sidebar-header">
<a class="nav-link" href="{{ url_for('main.reports') }}">Reports</a> <a class="bc-logo" href="{{ url_for('main.dashboard') }}">
</li> <span class="bc-logo-icon">
<li class="nav-item"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<a class="nav-link {% if request.path.startswith('/documentation') %}active{% endif %}" href="{{ url_for('documentation.index') }}"> <rect x="2" y="2" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.9"/>
<span class="nav-icon">📖</span> Documentation <rect x="11" y="2" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.5"/>
<rect x="2" y="11" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.5"/>
<rect x="11" y="11" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.25"/>
</svg>
</span>
<span class="bc-logo-text">Backupchecks</span>
</a> </a>
</li> <button class="bc-sidebar-toggle d-lg-none" id="bcSidebarClose" aria-label="Close menu">
<li class="nav-item"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M2 2l14 14M16 2L2 16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<a class="nav-link" href='{{ url_for("main.changelog_page") }}'>Changelog</a> </button>
</li> </div>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.feedback_page') }}">Feedback</a> <div class="bc-sidebar-search">
</li> <form method="get" action="{{ url_for('main.search_page') }}" autocomplete="off" class="bc-search-form">
{% else %} <svg class="bc-search-icon" width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="6" cy="6" r="4.5" stroke="currentColor" stroke-width="1.5"/><path d="M10 10l2.5 2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.inbox') }}">Inbox</a>
</li>
{% if active_role == 'viewer' %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.customers') }}">Customers</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.jobs') }}">Jobs</a>
</li>
{% endif %}
{% if system_settings and system_settings.cove_enabled and active_role in ('admin', 'operator') %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.cove_accounts') }}">Cove Accounts</a>
</li>
{% endif %}
{% if active_role in ('admin', 'operator') %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.run_checks_page') }}">Run Checks</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.reports') }}">Reports</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="moreMenu" role="button" data-bs-toggle="dropdown" aria-expanded="false">
More
</a>
<ul class="dropdown-menu" aria-labelledby="moreMenu">
<li><a class="dropdown-item" href="{{ url_for('main.daily_jobs') }}">Daily Jobs</a></li>
{% if active_role != 'viewer' %}
<li><a class="dropdown-item" href="{{ url_for('main.customers') }}">Customers</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.jobs') }}">Jobs</a></li>
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('main.tickets_page') }}">Tickets</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.overrides') }}">Overrides</a></li>
<li><a class="dropdown-item {% if request.path.startswith('/documentation') %}active{% endif %}" href="{{ url_for('documentation.index') }}">Documentation</a></li>
<li><a class="dropdown-item" href='{{ url_for("main.changelog_page") }}'>Changelog</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.feedback_page') }}">Feedback</a></li>
</ul>
</li>
{% if active_role == 'admin' %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="adminMenu" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Admin
</a>
<ul class="dropdown-menu" aria-labelledby="adminMenu">
<li><a class="dropdown-item" href="{{ url_for('main.admin_all_mails') }}">All Mail</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.inbox_deleted_mails') }}">Deleted mails</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.archived_jobs') }}">Archived Jobs</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.settings') }}">Settings</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.logging_page') }}">Logging</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.parsers_overview') }}">Parsers</a></li>
</ul>
</li>
{% endif %}
{% endif %}
</ul>
<form method="get" action="{{ url_for('main.search_page') }}" class="d-flex me-3 mb-2 mb-lg-0" role="search" autocomplete="off">
<input <input
class="form-control form-control-sm me-2" class="bc-search-input"
type="search" type="search"
name="q" name="q"
placeholder="Search" placeholder="Search..."
aria-label="Search" aria-label="Search"
value="{{ request.args.get('q','') if request.path == url_for('main.search_page') else '' }}" value="{{ request.args.get('q','') if request.path == url_for('main.search_page') else '' }}"
style="min-width: 220px;"
/> />
<button class="btn btn-outline-secondary btn-sm" type="submit">Search</button>
</form> </form>
<span class="navbar-text me-3"> </div>
<a class="text-decoration-none" href="{{ url_for('main.user_settings') }}">
{{ current_user.username }} ({{ active_role }})
</a>
</span>
<div class="bc-nav-section">
{% if active_role == 'reporter' %}
{{ bc_nav_item('main.reports', 'Reports', icon_bars()) }}
{{ bc_nav_item('documentation.index', 'Documentation', icon_book(), startswith='/documentation') }}
{{ bc_nav_item('main.changelog_page', 'Changelog', icon_clock()) }}
{{ bc_nav_item('main.feedback_page', 'Feedback', icon_chat()) }}
{% else %}
{{ bc_nav_item('main.dashboard', 'Dashboard', icon_grid()) }}
{{ bc_nav_item('main.inbox', 'Inbox', icon_inbox(), badge=inbox_count if inbox_count is defined and inbox_count > 0 else none) }}
{% if active_role in ('admin', 'operator') %}
{{ bc_nav_item('main.run_checks_page', 'Run Checks', icon_check()) }}
{% endif %}
{{ bc_nav_item('main.daily_jobs', 'Daily Jobs', icon_calendar()) }}
{{ bc_nav_item('main.reports', 'Reports', icon_bars()) }}
<div class="bc-nav-divider"></div>
<div class="bc-nav-label">Manage</div>
{% if active_role != 'viewer' %}
{{ bc_nav_item('main.customers', 'Customers', icon_building()) }}
{{ bc_nav_item('main.jobs', 'Jobs', icon_server()) }}
{% else %}
{{ bc_nav_item('main.customers', 'Customers', icon_building()) }}
{{ bc_nav_item('main.jobs', 'Jobs', icon_server()) }}
{% endif %}
{{ bc_nav_item('main.tickets_page', 'Tickets', icon_ticket()) }}
{{ bc_nav_item('main.overrides', 'Overrides', icon_shield()) }}
{% if system_settings and system_settings.cove_enabled and active_role in ('admin', 'operator') %}
{{ bc_nav_item('main.cove_accounts', 'Cove Accounts', icon_cloud()) }}
{% endif %}
<div class="bc-nav-divider"></div>
<div class="bc-nav-label">Info</div>
{{ bc_nav_item('documentation.index', 'Documentation', icon_book(), startswith='/documentation') }}
{{ bc_nav_item('main.changelog_page', 'Changelog', icon_clock()) }}
{{ bc_nav_item('main.feedback_page', 'Feedback', icon_chat()) }}
{% if active_role == 'admin' %}
<div class="bc-nav-divider"></div>
<div class="bc-nav-label">Admin</div>
{{ bc_nav_item('main.admin_all_mails', 'All Mail', icon_mail()) }}
{{ bc_nav_item('main.inbox_deleted_mails', 'Deleted Mail', icon_trash()) }}
{{ bc_nav_item('main.archived_jobs', 'Archived Jobs', icon_archive()) }}
{{ bc_nav_item('main.settings', 'Settings', icon_cog()) }}
{{ bc_nav_item('main.logging_page', 'Logging', icon_log()) }}
{{ bc_nav_item('main.parsers_overview', 'Parsers', icon_code()) }}
{% endif %}
{% endif %}
</div>
<div class="bc-sidebar-footer">
<div class="bc-user-block">
<a class="bc-user-name" href="{{ url_for('main.user_settings') }}">{{ current_user.username }}</a>
<span class="bc-user-role">{{ active_role }}</span>
</div>
<div class="bc-footer-controls">
{% if current_user.is_authenticated and user_roles|length > 1 %} {% if current_user.is_authenticated and user_roles|length > 1 %}
<form method="post" action="{{ url_for('main.set_active_role_route') }}" class="me-2"> <form method="post" action="{{ url_for('main.set_active_role_route') }}" class="bc-role-form">
<select <select class="bc-select" name="active_role" aria-label="Role" onchange="this.form.submit()">
class="form-select form-select-sm"
name="active_role"
aria-label="Role"
onchange="this.form.submit()"
style="min-width: 10rem; width: auto;"
>
{% for r in user_roles %} {% for r in user_roles %}
<option value="{{ r }}" {% if r == active_role %}selected{% endif %}>{{ r|capitalize }}</option> <option value="{{ r }}" {% if r == active_role %}selected{% endif %}>{{ r|capitalize }}</option>
{% endfor %} {% endfor %}
</select> </select>
</form> </form>
{% endif %} {% endif %}
<form method="post" action="{{ url_for('main.set_theme_preference') }}" class="me-2"> <form method="post" action="{{ url_for('main.set_theme_preference') }}" class="bc-theme-form">
<select <select class="bc-select" name="theme" aria-label="Theme" onchange="this.form.submit()">
class="form-select form-select-sm" <option value="light" {% if _theme_pref == 'light' %}selected{% endif %}>☀ Light</option>
name="theme" <option value="dark" {% if _theme_pref == 'dark' %}selected{% endif %}>◑ Dark</option>
aria-label="Theme" <option value="auto" {% if _theme_pref == 'auto' %}selected{% endif %}>◎ Auto</option>
onchange="this.form.submit()"
style="width: auto;"
>
<option value="light" {% if _theme_pref == 'light' %}selected{% endif %}>Light</option>
<option value="dark" {% if _theme_pref == 'dark' %}selected{% endif %}>Dark</option>
<option value="auto" {% if _theme_pref == 'auto' %}selected{% endif %}>Auto</option>
</select> </select>
</form> </form>
<a class="btn btn-outline-secondary" href="{{ url_for('auth.logout') }}">Logout</a> <a class="bc-logout" href="{{ url_for('auth.logout') }}" title="Logout">
{% endif %} <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6 2H3a1 1 0 00-1 1v10a1 1 0 001 1h3M10 11l3-3-3-3M13 8H6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</a>
</div> </div>
</div> </div>
</nav> </nav>
<main class="{% block main_class %}container content-container{% endblock %}" id="main-content"> <!-- Mobile overlay -->
<div class="bc-overlay" id="bcOverlay"></div>
{% endif %}
<!-- MAIN CONTENT -->
<div class="bc-main {% if not current_user.is_authenticated %}bc-main-auth{% endif %}">
{% if current_user.is_authenticated %}
<header class="bc-topbar d-lg-none">
<button class="bc-hamburger" id="bcHamburger" aria-label="Open menu">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M3 5h14M3 10h14M3 15h14" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
</button>
<a class="bc-topbar-logo" href="{{ url_for('main.dashboard') }}">Backupchecks</a>
</header>
{% endif %}
<div class="bc-content {% block content_class %}{% endblock %}">
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
<div class="mb-3"> <div class="bc-alerts mb-4">
{% for category, message in messages %} {% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert"> <div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }} {{ message }}
@ -217,153 +189,120 @@
{% endwith %} {% endwith %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script> <script>
// Dynamic navbar height adjustment
(function () { (function () {
function adjustContentPadding() { // Sidebar mobile toggle
try { var sidebar = document.getElementById('bcSidebar');
var navbar = document.querySelector('.navbar.fixed-top'); var overlay = document.getElementById('bcOverlay');
var mainContent = document.getElementById('main-content'); var hamburger = document.getElementById('bcHamburger');
if (!navbar || !mainContent) return; var closeBtn = document.getElementById('bcSidebarClose');
// Get actual navbar height function openSidebar() {
var navbarHeight = navbar.offsetHeight; if (!sidebar) return;
sidebar.classList.add('is-open');
// Add small buffer (20px) for visual spacing if (overlay) overlay.classList.add('is-visible');
var paddingTop = navbarHeight + 20; document.body.classList.add('sidebar-open');
// Apply padding to main content
mainContent.style.paddingTop = paddingTop + 'px';
} catch (e) {
// Fallback to 80px if something goes wrong
var mainContent = document.getElementById('main-content');
if (mainContent) {
mainContent.style.paddingTop = '80px';
}
}
} }
// Run on page load function closeSidebar() {
if (document.readyState === 'loading') { if (!sidebar) return;
document.addEventListener('DOMContentLoaded', adjustContentPadding); sidebar.classList.remove('is-open');
} else { if (overlay) overlay.classList.remove('is-visible');
adjustContentPadding(); document.body.classList.remove('sidebar-open');
} }
// Run after navbar is fully rendered if (hamburger) hamburger.addEventListener('click', openSidebar);
window.addEventListener('load', adjustContentPadding); if (closeBtn) closeBtn.addEventListener('click', closeSidebar);
if (overlay) overlay.addEventListener('click', closeSidebar);
// Run on window resize // Ellipsis field behavior (preserved from original)
var resizeTimeout;
window.addEventListener('resize', function () {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(adjustContentPadding, 100);
});
// Run when navbar collapse is toggled
var navbarCollapse = document.getElementById('navbarNav');
if (navbarCollapse) {
navbarCollapse.addEventListener('shown.bs.collapse', adjustContentPadding);
navbarCollapse.addEventListener('hidden.bs.collapse', adjustContentPadding);
}
})();
</script>
<script>
(function () {
function isOverflowing(el) { function isOverflowing(el) {
try { try { return el && el.scrollWidth > el.clientWidth; } catch(e) { return false; }
return el && el.scrollWidth > el.clientWidth;
} catch (e) {
return false;
} }
function setEllipsisTitle(el) {
if (!el || el.classList.contains('is-expanded')) return;
var txt = (el.textContent || '').trim();
if (!txt) { el.removeAttribute('title'); return; }
if (isOverflowing(el)) { el.setAttribute('title', txt); } else { el.removeAttribute('title'); }
} }
function collapseExpandedEllipsis(root) { function collapseExpandedEllipsis(root) {
try { try {
if (!root || !root.querySelectorAll) return; if (!root || !root.querySelectorAll) return;
var expanded = root.querySelectorAll('.ellipsis-field.is-expanded'); root.querySelectorAll('.ellipsis-field.is-expanded').forEach(function(el) {
if (!expanded || !expanded.length) return; el.classList.remove('is-expanded'); setEllipsisTitle(el);
expanded.forEach(function (el) {
el.classList.remove('is-expanded');
setEllipsisTitle(el);
}); });
} catch (e) { } catch(e) {}
// no-op
}
} }
function setEllipsisTitle(el) { document.addEventListener('click', function(e) {
if (!el || el.classList.contains('is-expanded')) {
return;
}
var txt = (el.textContent || '').trim();
if (!txt) {
el.removeAttribute('title');
return;
}
if (isOverflowing(el)) {
el.setAttribute('title', txt);
} else {
el.removeAttribute('title');
}
}
document.addEventListener('click', function (e) {
var el = e.target; var el = e.target;
if (!el) return; if (!el || !el.classList || !el.classList.contains('ellipsis-field')) return;
if (!el.classList || !el.classList.contains('ellipsis-field')) return;
// Ignore clicks on interactive children
if (e.target.closest && e.target.closest('a, button, input, select, textarea, label')) return; if (e.target.closest && e.target.closest('a, button, input, select, textarea, label')) return;
el.classList.toggle('is-expanded'); el.classList.toggle('is-expanded');
if (el.classList.contains('is-expanded')) { if (el.classList.contains('is-expanded')) { el.removeAttribute('title'); } else { setEllipsisTitle(el); }
el.removeAttribute('title');
} else {
setEllipsisTitle(el);
}
}); });
document.addEventListener('dblclick', function (e) { document.addEventListener('dblclick', function(e) {
var el = e.target; var el = e.target;
if (!el || !el.classList || !el.classList.contains('ellipsis-field')) return; if (!el || !el.classList || !el.classList.contains('ellipsis-field')) return;
// Expand on double click and select all text el.classList.add('is-expanded'); el.removeAttribute('title');
el.classList.add('is-expanded');
el.removeAttribute('title');
try { try {
var range = document.createRange(); var range = document.createRange(); range.selectNodeContents(el);
range.selectNodeContents(el); var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range);
var sel = window.getSelection(); } catch(err) {}
sel.removeAllRanges();
sel.addRange(range);
} catch (err) {
// no-op
}
}); });
document.addEventListener('mouseover', function (e) { document.addEventListener('mouseover', function(e) {
var el = e.target; var el = e.target;
if (!el || !el.classList || !el.classList.contains('ellipsis-field')) return; if (!el || !el.classList || !el.classList.contains('ellipsis-field')) return;
setEllipsisTitle(el); setEllipsisTitle(el);
}); });
// Ensure expanded fields do not persist between popup/modal openings. document.addEventListener('show.bs.modal', function(e) { collapseExpandedEllipsis(e.target); });
document.addEventListener('show.bs.modal', function (e) { document.addEventListener('hidden.bs.modal', function(e) { collapseExpandedEllipsis(e.target); });
collapseExpandedEllipsis(e.target); document.addEventListener('show.bs.offcanvas', function(e) { collapseExpandedEllipsis(e.target); });
}); document.addEventListener('hidden.bs.offcanvas', function(e) { collapseExpandedEllipsis(e.target); });
document.addEventListener('hidden.bs.modal', function (e) {
collapseExpandedEllipsis(e.target);
});
document.addEventListener('show.bs.offcanvas', function (e) {
collapseExpandedEllipsis(e.target);
});
document.addEventListener('hidden.bs.offcanvas', function (e) {
collapseExpandedEllipsis(e.target);
});
})(); })();
</script> </script>
{% block scripts %}{% endblock %}
</body> </body>
</html> </html>
{# ===== ICON MACROS ===== #}
{% macro icon_grid() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><rect x="1" y="1" width="5.5" height="5.5" rx="1" fill="currentColor"/><rect x="8.5" y="1" width="5.5" height="5.5" rx="1" fill="currentColor" opacity=".5"/><rect x="1" y="8.5" width="5.5" height="5.5" rx="1" fill="currentColor" opacity=".5"/><rect x="8.5" y="8.5" width="5.5" height="5.5" rx="1" fill="currentColor" opacity=".25"/></svg>{% endmacro %}
{% macro icon_inbox() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><path d="M1 9.5l2.5-5h8L14 9.5V13a1 1 0 01-1 1H2a1 1 0 01-1-1V9.5z" stroke="currentColor" stroke-width="1.3"/><path d="M1 9.5h3.5a2 2 0 004 0H14" stroke="currentColor" stroke-width="1.3"/></svg>{% endmacro %}
{% macro icon_check() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><path d="M2.5 7.5l3.5 3.5 6.5-6.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>{% endmacro %}
{% macro icon_calendar() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><rect x="1" y="3" width="13" height="11" rx="1.5" stroke="currentColor" stroke-width="1.3"/><path d="M5 1v4M10 1v4M1 7h13" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>{% endmacro %}
{% macro icon_bars() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><path d="M2 4h11M2 7.5h8M2 11h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>{% endmacro %}
{% macro icon_building() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><rect x="1" y="3" width="9" height="11" rx="1" stroke="currentColor" stroke-width="1.3"/><path d="M10 6h3a1 1 0 011 1v7H10" stroke="currentColor" stroke-width="1.3"/><rect x="3" y="6" width="2" height="2" rx=".5" fill="currentColor"/><rect x="7" y="6" width="2" height="2" rx=".5" fill="currentColor"/><rect x="3" y="10" width="2" height="2" rx=".5" fill="currentColor"/><rect x="7" y="10" width="2" height="2" rx=".5" fill="currentColor"/></svg>{% endmacro %}
{% macro icon_server() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><rect x="1" y="2" width="13" height="4" rx="1" stroke="currentColor" stroke-width="1.3"/><rect x="1" y="9" width="13" height="4" rx="1" stroke="currentColor" stroke-width="1.3"/><circle cx="11.5" cy="4" r=".8" fill="currentColor"/><circle cx="11.5" cy="11" r=".8" fill="currentColor"/></svg>{% endmacro %}
{% macro icon_ticket() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><path d="M1 5a1 1 0 011-1h11a1 1 0 011 1v1.5a2 2 0 000 4V12a1 1 0 01-1 1H2a1 1 0 01-1-1v-1.5a2 2 0 000-4V5z" stroke="currentColor" stroke-width="1.3"/></svg>{% endmacro %}
{% macro icon_shield() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><path d="M7.5 1.5l5 2v4c0 2.5-2 4.5-5 6-3-1.5-5-3.5-5-6v-4l5-2z" stroke="currentColor" stroke-width="1.3"/></svg>{% endmacro %}
{% macro icon_cloud() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><path d="M3.5 10a3.5 3.5 0 01-.5-7 4.5 4.5 0 018.5 1.5A3 3 0 0112.5 10H3.5z" stroke="currentColor" stroke-width="1.3"/></svg>{% endmacro %}
{% macro icon_book() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><path d="M3 2h8a1 1 0 011 1v10a1 1 0 01-1 1H3a1 1 0 01-1-1V3a1 1 0 011-1z" stroke="currentColor" stroke-width="1.3"/><path d="M5 5.5h5M5 8h5M5 10.5h3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>{% endmacro %}
{% macro icon_clock() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><circle cx="7.5" cy="7.5" r="5.5" stroke="currentColor" stroke-width="1.3"/><path d="M7.5 4.5v3l2 1.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>{% endmacro %}
{% macro icon_chat() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><path d="M1 3a1 1 0 011-1h11a1 1 0 011 1v7a1 1 0 01-1 1H5l-3 3V3z" stroke="currentColor" stroke-width="1.3"/></svg>{% endmacro %}
{% macro icon_mail() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><rect x="1" y="3" width="13" height="9" rx="1" stroke="currentColor" stroke-width="1.3"/><path d="M1 4l6.5 5L14 4" stroke="currentColor" stroke-width="1.3"/></svg>{% endmacro %}
{% macro icon_trash() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><path d="M3 4h9M5 4V2.5a.5.5 0 01.5-.5h4a.5.5 0 01.5.5V4M6 7v4M9 7v4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><rect x="2" y="4" width="11" height="9" rx="1" stroke="currentColor" stroke-width="1.3"/></svg>{% endmacro %}
{% macro icon_archive() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><rect x="1" y="2" width="13" height="3" rx="1" stroke="currentColor" stroke-width="1.3"/><path d="M2 5v7a1 1 0 001 1h9a1 1 0 001-1V5" stroke="currentColor" stroke-width="1.3"/><path d="M5.5 8.5h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>{% endmacro %}
{% macro icon_cog() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><circle cx="7.5" cy="7.5" r="2" stroke="currentColor" stroke-width="1.3"/><path d="M7.5 1.5v1.3M7.5 12.2v1.3M1.5 7.5h1.3M12.2 7.5h1.3M3.3 3.3l.9.9M10.8 10.8l.9.9M3.3 11.7l.9-.9M10.8 4.2l.9-.9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>{% endmacro %}
{% macro icon_log() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><rect x="2" y="2" width="11" height="11" rx="1" stroke="currentColor" stroke-width="1.3"/><path d="M5 5h5M5 7.5h5M5 10h3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>{% endmacro %}
{% macro icon_code() %}<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><path d="M4.5 5L1 7.5 4.5 10M10.5 5L14 7.5 10.5 10M8.5 3l-2 9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>{% endmacro %}
{# ===== NAV ITEM MACRO ===== #}
{% macro bc_nav_item(endpoint, label, icon_svg, badge=none, startswith=none) %}
{% set _active = (startswith and request.path.startswith(startswith)) or (not startswith and request.path == url_for(endpoint)) %}
<a class="bc-nav-item {% if _active %}is-active{% endif %}" href="{{ url_for(endpoint) }}">
<span class="bc-nav-icon">{{ icon_svg }}</span>
<span class="bc-nav-label-text">{{ label }}</span>
{% if badge %}<span class="bc-nav-badge">{{ badge }}</span>{% endif %}
</a>
{% endmacro %}

View File

@ -1,5 +1,4 @@
{% extends "layout/base.html" %} {% extends "layout/base.html" %}
{% block main_class %}container dashboard-container{% endblock %}
{% block content %} {% block content %}
<h2 class="mb-4">Dashboard</h2> <h2 class="mb-4">Dashboard</h2>

View File

@ -2,6 +2,20 @@
This file documents all changes made to this project via Claude Code. This file documents all changes made to this project via Claude Code.
## [2026-03-19]
### Changed
- Redesigned application layout to sidebar-first design system (Layout v2):
- `static/css/layout.css` completely rewritten with IBM Plex Sans/Mono fonts, CSS design tokens, and a fixed dark sidebar (`--bc-sidebar-w: 220px`)
- `templates/layout/base.html` updated: Google Fonts preload for IBM Plex, `bc-body` class, sidebar-aware structure, simplified dark-mode JS
- `templates/documentation/base.html` and `templates/main/dashboard.html` aligned to new layout structure
- Missed-run generation and Autotask ticket polling now run in a background daemon thread on Run Checks page load:
- Prevents page-load delay caused by heavy DB operations
- Throttled per job (10-minute minimum interval) to avoid pile-up
- Single `_bg_sweep_lock` guard prevents concurrent sweeps
- Cove import manual run now logs and displays per-skip-reason breakdown (`reasons=` in audit log and flash message)
- Cove timestamp parsing now supports additional formats: epoch milliseconds, epoch microseconds/nanoseconds, and .NET JSON Date strings (`/Date(ms)/`)
## [2026-03-12] ## [2026-03-12]
### Fixed ### Fixed