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:
parent
2ebe7d8aed
commit
89f7506763
@ -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(
|
||||
|
||||
@ -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,17 +1015,26 @@ 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)):
|
||||
# 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
|
||||
@ -972,18 +1042,32 @@ def run_checks_page():
|
||||
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
|
||||
jobs_to_sweep.append((int(job.id), start_date, today_local))
|
||||
|
||||
# Phase 2 (read-only PSA driven): sync internal ticket resolved state based on PSA ticket status.
|
||||
# Best-effort: never blocks page load.
|
||||
# Collect Autotask run ids for Phase 2 polling.
|
||||
at_run_ids: list[int] = []
|
||||
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)
|
||||
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()
|
||||
|
||||
except Exception:
|
||||
# Never block the page load.
|
||||
pass
|
||||
|
||||
# Aggregated per-job rows
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 */
|
||||
}
|
||||
|
||||
@ -4,8 +4,6 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/documentation.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block main_class %}container-fluid content-container{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="documentation-container">
|
||||
<div class="row g-0">
|
||||
|
||||
@ -3,12 +3,12 @@
|
||||
<html lang="en" data-theme-preference="{{ _theme_pref }}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Backupchecks</title>
|
||||
<title>{% block title %}Backupchecks{% endblock %}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<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/status-text.css') }}" />
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/sandbox.css') }}" />
|
||||
@ -21,191 +21,163 @@
|
||||
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) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<body class="bc-body">
|
||||
|
||||
{% if system_settings and system_settings.is_sandbox_environment %}
|
||||
<div class="sandbox-banner">
|
||||
<span class="sandbox-banner-text">SANDBOX</span>
|
||||
</div>
|
||||
<div class="sandbox-banner"><span class="sandbox-banner-text">SANDBOX</span></div>
|
||||
{% 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 %}
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
{% if active_role == 'reporter' %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.reports') }}">Reports</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.path.startswith('/documentation') %}active{% endif %}" href="{{ url_for('documentation.index') }}">
|
||||
<span class="nav-icon">📖</span> Documentation
|
||||
<!-- SIDEBAR -->
|
||||
<nav class="bc-sidebar" id="bcSidebar">
|
||||
<div class="bc-sidebar-header">
|
||||
<a class="bc-logo" href="{{ url_for('main.dashboard') }}">
|
||||
<span class="bc-logo-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.9"/>
|
||||
<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>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href='{{ url_for("main.changelog_page") }}'>Changelog</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.feedback_page') }}">Feedback</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<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">
|
||||
<button class="bc-sidebar-toggle d-lg-none" id="bcSidebarClose" aria-label="Close menu">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bc-sidebar-search">
|
||||
<form method="get" action="{{ url_for('main.search_page') }}" autocomplete="off" class="bc-search-form">
|
||||
<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>
|
||||
<input
|
||||
class="form-control form-control-sm me-2"
|
||||
class="bc-search-input"
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder="Search"
|
||||
placeholder="Search..."
|
||||
aria-label="Search"
|
||||
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>
|
||||
<span class="navbar-text me-3">
|
||||
<a class="text-decoration-none" href="{{ url_for('main.user_settings') }}">
|
||||
{{ current_user.username }} ({{ active_role }})
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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 %}
|
||||
<form method="post" action="{{ url_for('main.set_active_role_route') }}" class="me-2">
|
||||
<select
|
||||
class="form-select form-select-sm"
|
||||
name="active_role"
|
||||
aria-label="Role"
|
||||
onchange="this.form.submit()"
|
||||
style="min-width: 10rem; width: auto;"
|
||||
>
|
||||
<form method="post" action="{{ url_for('main.set_active_role_route') }}" class="bc-role-form">
|
||||
<select class="bc-select" name="active_role" aria-label="Role" onchange="this.form.submit()">
|
||||
{% for r in user_roles %}
|
||||
<option value="{{ r }}" {% if r == active_role %}selected{% endif %}>{{ r|capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('main.set_theme_preference') }}" class="me-2">
|
||||
<select
|
||||
class="form-select form-select-sm"
|
||||
name="theme"
|
||||
aria-label="Theme"
|
||||
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>
|
||||
<form method="post" action="{{ url_for('main.set_theme_preference') }}" class="bc-theme-form">
|
||||
<select class="bc-select" name="theme" aria-label="Theme" onchange="this.form.submit()">
|
||||
<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>
|
||||
</form>
|
||||
<a class="btn btn-outline-secondary" href="{{ url_for('auth.logout') }}">Logout</a>
|
||||
{% endif %}
|
||||
<a class="bc-logout" href="{{ url_for('auth.logout') }}" title="Logout">
|
||||
<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>
|
||||
</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) %}
|
||||
{% if messages %}
|
||||
<div class="mb-3">
|
||||
<div class="bc-alerts mb-4">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
@ -217,153 +189,120 @@
|
||||
{% endwith %}
|
||||
|
||||
{% 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>
|
||||
// Dynamic navbar height adjustment
|
||||
(function () {
|
||||
function adjustContentPadding() {
|
||||
try {
|
||||
var navbar = document.querySelector('.navbar.fixed-top');
|
||||
var mainContent = document.getElementById('main-content');
|
||||
if (!navbar || !mainContent) return;
|
||||
// Sidebar mobile toggle
|
||||
var sidebar = document.getElementById('bcSidebar');
|
||||
var overlay = document.getElementById('bcOverlay');
|
||||
var hamburger = document.getElementById('bcHamburger');
|
||||
var closeBtn = document.getElementById('bcSidebarClose');
|
||||
|
||||
// Get actual navbar height
|
||||
var navbarHeight = navbar.offsetHeight;
|
||||
|
||||
// Add small buffer (20px) for visual spacing
|
||||
var paddingTop = navbarHeight + 20;
|
||||
|
||||
// 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';
|
||||
}
|
||||
}
|
||||
function openSidebar() {
|
||||
if (!sidebar) return;
|
||||
sidebar.classList.add('is-open');
|
||||
if (overlay) overlay.classList.add('is-visible');
|
||||
document.body.classList.add('sidebar-open');
|
||||
}
|
||||
|
||||
// Run on page load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', adjustContentPadding);
|
||||
} else {
|
||||
adjustContentPadding();
|
||||
function closeSidebar() {
|
||||
if (!sidebar) return;
|
||||
sidebar.classList.remove('is-open');
|
||||
if (overlay) overlay.classList.remove('is-visible');
|
||||
document.body.classList.remove('sidebar-open');
|
||||
}
|
||||
|
||||
// Run after navbar is fully rendered
|
||||
window.addEventListener('load', adjustContentPadding);
|
||||
if (hamburger) hamburger.addEventListener('click', openSidebar);
|
||||
if (closeBtn) closeBtn.addEventListener('click', closeSidebar);
|
||||
if (overlay) overlay.addEventListener('click', closeSidebar);
|
||||
|
||||
// Run on window resize
|
||||
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 () {
|
||||
// Ellipsis field behavior (preserved from original)
|
||||
function isOverflowing(el) {
|
||||
try {
|
||||
return el && el.scrollWidth > el.clientWidth;
|
||||
} catch (e) {
|
||||
return false;
|
||||
try { 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) {
|
||||
try {
|
||||
if (!root || !root.querySelectorAll) return;
|
||||
var expanded = root.querySelectorAll('.ellipsis-field.is-expanded');
|
||||
if (!expanded || !expanded.length) return;
|
||||
expanded.forEach(function (el) {
|
||||
el.classList.remove('is-expanded');
|
||||
setEllipsisTitle(el);
|
||||
root.querySelectorAll('.ellipsis-field.is-expanded').forEach(function(el) {
|
||||
el.classList.remove('is-expanded'); setEllipsisTitle(el);
|
||||
});
|
||||
} catch (e) {
|
||||
// no-op
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
document.addEventListener('click', function(e) {
|
||||
var el = e.target;
|
||||
if (!el) return;
|
||||
if (!el.classList || !el.classList.contains('ellipsis-field')) return;
|
||||
// Ignore clicks on interactive children
|
||||
if (!el || !el.classList || !el.classList.contains('ellipsis-field')) return;
|
||||
if (e.target.closest && e.target.closest('a, button, input, select, textarea, label')) return;
|
||||
el.classList.toggle('is-expanded');
|
||||
if (el.classList.contains('is-expanded')) {
|
||||
el.removeAttribute('title');
|
||||
} else {
|
||||
setEllipsisTitle(el);
|
||||
}
|
||||
if (el.classList.contains('is-expanded')) { el.removeAttribute('title'); } else { setEllipsisTitle(el); }
|
||||
});
|
||||
|
||||
document.addEventListener('dblclick', function (e) {
|
||||
document.addEventListener('dblclick', function(e) {
|
||||
var el = e.target;
|
||||
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 {
|
||||
var range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
var sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} catch (err) {
|
||||
// no-op
|
||||
}
|
||||
var range = document.createRange(); range.selectNodeContents(el);
|
||||
var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range);
|
||||
} catch(err) {}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseover', function (e) {
|
||||
document.addEventListener('mouseover', function(e) {
|
||||
var el = e.target;
|
||||
if (!el || !el.classList || !el.classList.contains('ellipsis-field')) return;
|
||||
setEllipsisTitle(el);
|
||||
});
|
||||
|
||||
// Ensure expanded fields do not persist between popup/modal openings.
|
||||
document.addEventListener('show.bs.modal', 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);
|
||||
});
|
||||
document.addEventListener('show.bs.modal', 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>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</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 %}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
{% extends "layout/base.html" %}
|
||||
{% block main_class %}container dashboard-container{% endblock %}
|
||||
{% block content %}
|
||||
<h2 class="mb-4">Dashboard</h2>
|
||||
|
||||
|
||||
@ -2,6 +2,20 @@
|
||||
|
||||
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]
|
||||
|
||||
### Fixed
|
||||
|
||||
Loading…
Reference in New Issue
Block a user