feat: dashboard UI with real-time stats, light/dark mode, session metrics

This commit is contained in:
2026-06-12 08:11:24 +02:00
parent 20247c92c2
commit 7a388653ed
6 changed files with 1050 additions and 46 deletions
+2
View File
@@ -9,6 +9,8 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8081 8082
HEALTHCHECK --interval=60s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8081/health')" || exit 1
+146
View File
@@ -0,0 +1,146 @@
import json
import os
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
from health import HealthServer, SyncSession
class DashboardServer:
def __init__(self, port: int = 8082, health: HealthServer = None, session: SyncSession = None):
self.port = port
self.health = health
self.session = session
self.server = None
self.thread = None
self._lock = threading.Lock()
self._syncing = False
self._next_sync_in = 0
self._event_count = 0
self._backoff_min = 0
self._config = {}
self._base_dir = os.path.dirname(os.path.abspath(__file__))
def start(self):
handler = self._make_handler()
self.server = HTTPServer(("0.0.0.0", self.port), handler)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
def stop(self):
if self.server:
self.server.shutdown()
self.server = None
self.thread = None
def update_config(self, config_dict: dict):
with self._lock:
self._config = dict(config_dict)
def set_syncing(self, syncing: bool):
with self._lock:
self._syncing = syncing
def set_next_sync_in(self, seconds: int):
with self._lock:
self._next_sync_in = seconds
def set_event_count(self, n: int):
with self._lock:
self._event_count = n
def set_backoff_min(self, n: int):
with self._lock:
self._backoff_min = n
def _get_status(self):
with self._lock:
syncing = self._syncing
next_sync_in = self._next_sync_in
event_count = self._event_count
backoff_min = self._backoff_min
config = dict(self._config)
last_sync = None
duration = 0.0
last_success = None
if self.health:
with self.health.lock:
last_sync = self.health.last_sync
duration = self.health.last_sync_duration
last_success = self.health.last_sync_success
status = "idle"
if syncing:
status = "syncing"
elif last_success is False:
status = "error"
elif backoff_min > 0:
status = "backoff"
session_data = {}
history = []
if self.session:
session_data = self.session.get_status()
history = session_data.get("history", [])
ics_latency = 0.0
if self.session and self.session.non_skip_count > 0:
ics_latency = round(self.session.total_latency_ms / self.session.non_skip_count)
return {
"status": status,
"last_sync": last_sync.isoformat() if last_sync else None,
"duration": duration,
"ics_latency_ms": ics_latency,
"event_count": event_count,
"next_sync_in": next_sync_in,
"session": session_data,
"history": history,
"config": config,
}
def _make_handler(self):
server_self = self
base_dir = server_self._base_dir
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/api/status":
self._handle_api(server_self)
elif self.path == "/":
self._handle_dashboard(base_dir)
else:
self.send_response(404)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"error": "not found"}).encode())
def _handle_api(self, srv):
data = srv._get_status()
body = json.dumps(data).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
def _handle_dashboard(self, base):
html_path = os.path.join(base, "static", "dashboard.html")
try:
with open(html_path, "r", encoding="utf-8") as f:
content = f.read()
body = content.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(body)
except FileNotFoundError:
self.send_response(404)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"error": "dashboard not found"}).encode())
def log_message(self, format, *args):
pass
return Handler
+3
View File
@@ -3,6 +3,9 @@ services:
image: lagortinez/baikal-sync:latest
container_name: baikal-sync
restart: always
ports:
- "8081:8081"
- "8082:8082"
environment:
- ICS_URL=${ICS_URL}
- BAIKAL_URL=${BAIKAL_URL}
+73
View File
@@ -1,10 +1,83 @@
import json
import threading
import time
from collections import deque
from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler
from io import StringIO
class SyncSession:
def __init__(self):
self._lock = threading.Lock()
self.start_time = time.time()
self.syncs_total = 0
self.syncs_failed = 0
self.syncs_skipped = 0
self.total_added = 0
self.total_updated = 0
self.total_deleted = 0
self.total_duration = 0.0
self.total_latency_ms = 0.0
self.non_skip_count = 0
self.bandwidth_bytes = 0
self.bandwidth_saved_bytes = 0
self.history = deque(maxlen=50)
def record(self, ok: bool, duration: float, added: int, updated: int, deleted: int, skipped: bool, ics_latency_ms: float, msg: str, ics_download_size: int = 0) -> None:
with self._lock:
self.syncs_total += 1
ts = time.strftime("%H:%M:%S", time.gmtime())
if not ok:
self.syncs_failed += 1
if skipped:
self.syncs_skipped += 1
self.total_added += added
self.total_updated += updated
self.total_deleted += deleted
if not skipped:
self.non_skip_count += 1
self.total_duration += duration
self.total_latency_ms += ics_latency_ms
if ics_download_size:
self.bandwidth_bytes += ics_download_size
self.history.append({
"time": ts,
"ok": ok,
"duration": round(duration, 2),
"added": added,
"updated": updated,
"deleted": deleted,
"skipped": skipped,
"latency_ms": round(ics_latency_ms),
"msg": msg,
})
def get_status(self) -> dict:
with self._lock:
uptime = time.time() - self.start_time
avg_dur = round(self.total_duration / self.non_skip_count, 2) if self.non_skip_count > 0 else 0
avg_lat = round(self.total_latency_ms / self.non_skip_count) if self.non_skip_count > 0 else 0
return {
"uptime_sec": round(uptime),
"syncs_total": self.syncs_total,
"syncs_failed": self.syncs_failed,
"syncs_skipped": self.syncs_skipped,
"total_added": self.total_added,
"total_updated": self.total_updated,
"total_deleted": self.total_deleted,
"avg_duration": avg_dur,
"avg_latency_ms": avg_lat,
"bandwidth_bytes": self.bandwidth_bytes,
"bandwidth_saved_bytes": self.bandwidth_saved_bytes,
"history": list(self.history),
}
def add_saved_bandwidth(self, bytes_saved: int) -> None:
with self._lock:
self.bandwidth_saved_bytes += bytes_saved
class HealthServer:
def __init__(self, port: int = 8081):
self.port = port
+731
View File
@@ -0,0 +1,731 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Baikal Sync Dashboard</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--transition: 0.3s ease;
--green: #00b894;
--blue: #74b9ff;
--yellow: #fdcb6e;
--red: #e17055;
--purple: #a29bfe;
}
[data-theme="dark"] {
--bg: #0f1117;
--card: #1a1d27;
--border: #2e3345;
--text: #e4e6f0;
--text-secondary: #8b90a0;
--shadow: 0 2px 12px rgba(0,0,0,0.3);
--chart-bg: #12141c;
}
[data-theme="light"] {
--bg: #f5f7fa;
--card: #ffffff;
--border: #e2e8f0;
--text: #1a1d27;
--text-secondary: #64748b;
--shadow: 0 2px 12px rgba(0,0,0,0.08);
--chart-bg: #f0f3f8;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
transition: background var(--transition), color var(--transition);
min-height: 100vh;
}
.container { max-width: 1200px; margin: 0 auto; padding: 24px 16px; }
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28px;
}
.logo { display: flex; align-items: center; gap: 10px; font-size: 1.5rem; font-weight: 700; }
.status-dot {
width: 12px; height: 12px; border-radius: 50%; background: var(--green);
animation: pulse 2s ease-in-out infinite;
}
.status-dot.error { background: var(--red); }
.status-dot.syncing { background: var(--blue); animation-duration: 0.6s; }
.status-dot.backoff { background: var(--yellow); }
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(0,184,148,0.5); }
50% { box-shadow: 0 0 0 8px rgba(0,184,148,0); }
}
.theme-toggle {
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
width: 40px; height: 40px; cursor: pointer; display: flex;
align-items: center; justify-content: center; font-size: 1.2rem;
transition: background var(--transition), border-color var(--transition);
}
.theme-toggle:hover { border-color: var(--text-secondary); }
.card {
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
padding: 20px; transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
box-shadow: var(--shadow);
}
.status-bar { margin-bottom: 24px; }
.status-main {
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 12px; margin-bottom: 16px;
}
.status-text { font-size: 1.35rem; font-weight: 600; }
.status-meta { display: flex; gap: 20px; flex-wrap: wrap; color: var(--text-secondary); font-size: 0.9rem; }
.countdown-bar { margin-top: 12px; }
.countdown-label { font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 6px; display: flex; justify-content: space-between; }
.progress-track {
height: 6px; background: var(--border); border-radius: 3px; overflow: hidden;
}
.progress-fill {
height: 100%; background: var(--green); border-radius: 3px;
transition: width 1s linear;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px; margin-bottom: 24px;
}
.stat-card { padding: 18px; }
.stat-label { font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.stat-value { font-size: 1.7rem; font-weight: 700; }
.stat-sub { font-size: 0.8rem; color: var(--text-secondary); margin-top: 4px; }
.stat-card.green .stat-value { color: var(--green); }
.stat-card.blue .stat-value { color: var(--blue); }
.stat-card.yellow .stat-value { color: var(--yellow); }
.stat-card.red .stat-value { color: var(--red); }
.stat-card.purple .stat-value { color: var(--purple); }
.section-card { margin-bottom: 24px; }
.section-title { font-size: 1.05rem; font-weight: 600; margin-bottom: 16px; }
.stacked-bar-container { margin-bottom: 20px; }
.stacked-bar {
height: 32px; border-radius: 6px; overflow: hidden; display: flex;
background: var(--border);
}
.stacked-segment { height: 100%; transition: width 0.5s ease; min-width: 0; }
.legend {
display: flex; gap: 16px; flex-wrap: wrap; margin-top: 10px;
font-size: 0.85rem; color: var(--text-secondary);
}
.legend-item { display: flex; align-items: center; gap: 6px; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
.toggle-group {
display: flex; gap: 4px; margin-bottom: 16px;
background: var(--border); border-radius: 8px; padding: 3px; width: fit-content;
}
.toggle-btn {
background: transparent; border: none; color: var(--text-secondary);
padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.85rem;
transition: all var(--transition);
}
.toggle-btn.active { background: var(--card); color: var(--text); font-weight: 600; }
.chart-area {
display: flex; align-items: flex-end; gap: 2px; height: 120px;
background: var(--chart-bg); border-radius: 8px; padding: 8px; overflow: hidden;
}
.chart-bar {
flex: 1; border-radius: 2px 2px 0 0; transition: height 0.3s ease, background 0.3s ease;
min-width: 3px; position: relative;
}
.chart-bar.ok { background: var(--green); }
.chart-bar.fail { background: var(--red); }
.chart-labels {
display: flex; justify-content: space-between; margin-top: 4px;
font-size: 0.7rem; color: var(--text-secondary);
}
.config-section { margin-bottom: 24px; }
.copy-btn {
background: var(--green); color: #fff; border: none; border-radius: 8px;
padding: 8px 18px; cursor: pointer; font-size: 0.9rem; font-weight: 600;
transition: opacity var(--transition); margin-bottom: 16px;
}
.copy-btn:hover { opacity: 0.85; }
.copy-btn.copied { background: var(--blue); }
.config-table { width: 100%; border-collapse: collapse; }
.config-table td {
padding: 10px 12px; border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
.config-table tr:last-child td { border-bottom: none; }
.config-table td:first-child {
color: var(--text-secondary); font-weight: 500; width: 140px;
white-space: nowrap;
}
.config-table td:last-child { word-break: break-all; }
.password-cell { display: flex; align-items: center; gap: 8px; }
.password-text { flex: 1; }
.eye-btn {
background: none; border: none; cursor: pointer; font-size: 1.1rem;
opacity: 0.6; transition: opacity var(--transition); padding: 4px;
}
.eye-btn:hover { opacity: 1; }
.history-section { margin-bottom: 24px; }
.timeline { display: flex; flex-direction: column; gap: 6px; max-height: 400px; overflow-y: auto; }
.timeline-entry {
display: flex; align-items: center; gap: 10px; padding: 10px 14px;
border-radius: 8px; background: var(--chart-bg); font-size: 0.88rem;
transition: background var(--transition);
}
.entry-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.entry-dot.ok { background: var(--green); }
.entry-dot.fail { background: var(--red); }
.entry-time { color: var(--text-secondary); font-variant-numeric: tabular-nums; min-width: 60px; }
.entry-msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.badge {
padding: 2px 8px; border-radius: 4px; font-size: 0.78rem; font-weight: 600;
white-space: nowrap;
}
.badge.added { background: rgba(0,184,148,0.15); color: var(--green); }
.badge.updated { background: rgba(116,185,255,0.15); color: var(--blue); }
.badge.skipped { background: rgba(139,144,160,0.15); color: var(--text-secondary); }
.badge.error { background: rgba(225,112,85,0.15); color: var(--red); }
.empty-state {
text-align: center; padding: 40px 20px; color: var(--text-secondary);
font-size: 0.95rem;
}
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.status-main { flex-direction: column; align-items: flex-start; }
.status-meta { flex-direction: column; gap: 4px; }
}
@media (max-width: 480px) {
.stats-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="logo">
<span class="status-dot" id="headerDot"></span>
Baikal Sync
</div>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle theme">🌙</button>
</header>
<div class="card status-bar" id="statusBar">
<div class="empty-state">Loading…</div>
</div>
<div class="stats-grid" id="statsGrid">
<div class="card stat-card green"><div class="stat-label">Total Events</div><div class="stat-value" id="statEvents"></div></div>
<div class="card stat-card blue"><div class="stat-label">Total Added</div><div class="stat-value" id="statAdded"></div></div>
<div class="card stat-card purple"><div class="stat-label">Avg Events/Sync</div><div class="stat-value" id="statAvgEvents"></div></div>
<div class="card stat-card blue"><div class="stat-label">Syncs Total</div><div class="stat-value" id="statSyncs"></div><div class="stat-sub" id="statSyncsSub"></div></div>
<div class="card stat-card yellow"><div class="stat-label">Avg Sync Time</div><div class="stat-value" id="statAvgDuration"></div></div>
<div class="card stat-card purple"><div class="stat-label">Avg Latency</div><div class="stat-value" id="statAvgLatency"></div></div>
<div class="card stat-card green"><div class="stat-label">Bandwidth Saved</div><div class="stat-value" id="statBandwidth"></div></div>
<div class="card stat-card" id="backoffCard"><div class="stat-label">Backoff</div><div class="stat-value" id="statBackoff"></div></div>
</div>
<div class="card section-card">
<div class="section-title">Session Breakdown</div>
<div class="stacked-bar-container">
<div class="stacked-bar" id="stackedBar"></div>
<div class="legend" id="stackedLegend"></div>
</div>
<div class="toggle-group" id="chartToggle">
<button class="toggle-btn active" data-metric="duration">Duration</button>
<button class="toggle-btn" data-metric="events">Events</button>
<button class="toggle-btn" data-metric="latency">Latency</button>
</div>
<div class="chart-area" id="chartArea"></div>
<div class="chart-labels" id="chartLabels"></div>
</div>
<div class="card config-section">
<div class="section-title">Configuration</div>
<button class="copy-btn" id="copyBtn">Copy .env</button>
<table class="config-table" id="configTable">
<tbody id="configBody"></tbody>
</table>
</div>
<div class="card history-section">
<div class="section-title">Sync History</div>
<div class="timeline" id="timeline">
<div class="empty-state">No sync history yet.</div>
</div>
</div>
</div>
<script>
(function () {
"use strict";
const state = {
data: null,
chartMetric: "duration",
passwordVisible: false,
pollTimer: null,
countdownTimer: null,
lastPoll: null
};
const $ = (sel) => document.querySelector(sel);
function fmtBytes(b) {
if (b == null || b === 0) return "0 B";
if (b >= 1048576) return (b / 1048576).toFixed(2) + " MB";
return (b / 1024).toFixed(1) + " KB";
}
function fmtUptime(sec) {
if (sec == null) return "—";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
return h + "h " + m + "m";
}
function fmtDuration(s) {
if (s == null) return "—";
if (s < 1) return (s * 1000).toFixed(0) + "ms";
return s.toFixed(2) + "s";
}
function fmtTime(iso) {
if (!iso) return "Never";
try {
const d = new Date(iso);
return d.toLocaleTimeString();
} catch { return iso; }
}
function fmtCountdown(sec) {
if (sec == null || sec < 0) return "00:00";
const m = Math.floor(sec / 60);
const s = sec % 60;
return String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
}
function truncate(str, max) {
if (!str) return "";
max = max || 60;
return str.length > max ? str.slice(0, 30) + "…" : str;
}
function maskPassword(p) {
if (!p) return "";
return "•".repeat(Math.min(p.length, 20));
}
function getStatusLabel(s) {
switch (s) {
case "syncing": return "Syncing…";
case "error": return "Error";
case "backoff": return "Backing off";
case "idle":
default: return "Up to Date";
}
}
function getSyncStatusClass(s) {
switch (s) {
case "syncing": return "syncing";
case "error": return "error";
case "backoff": return "backoff";
default: return "";
}
}
/* ── Theme ── */
function initTheme() {
const saved = localStorage.getItem("baikal-theme");
const theme = saved || "dark";
document.documentElement.setAttribute("data-theme", theme);
$("#themeToggle").textContent = theme === "dark" ? "☀️" : "🌙";
}
function toggleTheme() {
const current = document.documentElement.getAttribute("data-theme");
const next = current === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("baikal-theme", next);
$("#themeToggle").textContent = next === "dark" ? "☀️" : "🌙";
}
/* ── Render Status Bar ── */
function renderStatusBar(d) {
const cls = getSyncStatusClass(d.status);
$("#headerDot").className = "status-dot" + (cls ? " " + cls : "");
let statusText = "";
if (d.status === "backoff" && d.next_sync_in != null) {
const mins = Math.ceil(d.next_sync_in / 60);
statusText = "Backing off — " + mins + " min remaining";
} else {
statusText = "Sync " + getStatusLabel(d.status);
}
const progressPct = d.next_sync_in != null && d.config && d.config.sync_frequency
? Math.max(0, Math.min(100, ((d.config.sync_frequency * 60 - d.next_sync_in) / (d.config.sync_frequency * 60)) * 100))
: 0;
$("#statusBar").innerHTML =
'<div class="status-main">' +
'<div class="status-text">' + statusText + "</div>" +
'<div class="status-meta">' +
'<span>Last sync: ' + fmtTime(d.last_sync) + "</span>" +
(d.duration != null ? '<span>Duration: ' + fmtDuration(d.duration) + "</span>" : "") +
(d.ics_latency_ms != null ? '<span>Latency: ' + d.ics_latency_ms + "ms</span>" : "") +
'<span>Uptime: ' + fmtUptime(d.session.uptime_sec) + "</span>" +
"</div>" +
"</div>" +
'<div class="countdown-bar">' +
'<div class="countdown-label">' +
'<span>Next sync</span><span id="countdown">' + fmtCountdown(d.next_sync_in) + "</span>" +
"</div>" +
'<div class="progress-track"><div class="progress-fill" id="progressFill" style="width:' + progressPct + '%"></div></div>' +
"</div>";
}
/* ── Render Stats ── */
function renderStats(d) {
const s = d.session;
$("#statEvents").textContent = d.event_count != null ? d.event_count : "—";
$("#statAdded").textContent = s.total_added != null ? s.total_added : "—";
const totalOps = (s.total_added || 0) + (s.total_updated || 0);
const avgEvents = s.syncs_total > 0 ? (totalOps / s.syncs_total).toFixed(2) : "—";
$("#statAvgEvents").textContent = avgEvents;
$("#statSyncs").textContent = s.syncs_total || 0;
const skipPct = s.syncs_total > 0 ? ((s.syncs_skipped || 0) / s.syncs_total * 100).toFixed(0) : "0";
$("#statSyncsSub").textContent = (s.syncs_failed || 0) + " failed · " + skipPct + "% skipped";
$("#statAvgDuration").textContent = fmtDuration(s.avg_duration);
$("#statAvgLatency").textContent = s.avg_latency_ms != null ? s.avg_latency_ms + " ms" : "—";
$("#statBandwidth").textContent = fmtBytes(s.bandwidth_saved_bytes);
const bc = $("#backoffCard");
if (d.status === "backoff") {
bc.className = "card stat-card yellow";
const mins = Math.ceil((d.next_sync_in || 0) / 60);
$("#statBackoff").textContent = mins + " min";
} else {
bc.className = "card stat-card green";
$("#statBackoff").textContent = "None";
}
}
/* ── Render Stacked Bar ── */
function renderStackedBar(d) {
const s = d.session;
const added = s.total_added || 0;
const updated = s.total_updated || 0;
const deleted = s.total_deleted || 0;
const skipped = s.syncs_skipped || 0;
const total = added + updated + deleted + skipped || 1;
const w1 = (added / total * 100).toFixed(2);
const w2 = (updated / total * 100).toFixed(2);
const w3 = (deleted / total * 100).toFixed(2);
const w4 = (skipped / total * 100).toFixed(2);
$("#stackedBar").innerHTML =
'<div class="stacked-segment" style="width:' + w1 + '%;background:var(--green)"></div>' +
'<div class="stacked-segment" style="width:' + w2 + '%;background:var(--blue)"></div>' +
'<div class="stacked-segment" style="width:' + w3 + '%;background:var(--purple)"></div>' +
'<div class="stacked-segment" style="width:' + w4 + '%;background:var(--text-secondary)"></div>';
$("#stackedLegend").innerHTML =
'<div class="legend-item"><span class="legend-dot" style="background:var(--green)"></span>Added: ' + added + "</div>" +
'<div class="legend-item"><span class="legend-dot" style="background:var(--blue)"></span>Updated: ' + updated + "</div>" +
'<div class="legend-item"><span class="legend-dot" style="background:var(--purple)"></span>Deleted: " + deleted + "</div>" +
'<div class="legend-item"><span class="legend-dot" style="background:var(--text-secondary)"></span>Skipped: ' + skipped + "</div>";
}
/* ── Render Chart ── */
function renderChart(d) {
const history = d.session.history || [];
const metric = state.chartMetric;
const sliced = history.slice(-50);
if (!sliced.length) {
$("#chartArea").innerHTML = "";
$("#chartLabels").innerHTML = "";
return;
}
let key = "";
if (metric === "duration") key = "duration";
else if (metric === "events") {
const vals = sliced.map(function (e) { return (e.added || 0) + (e.updated || 0) + (e.deleted || 0); });
const maxVal = Math.max(1, ...vals);
const bars = sliced.map(function (e, i) {
const v = (e.added || 0) + (e.updated || 0) + (e.deleted || 0);
const h = Math.max(2, (v / maxVal) * 100);
return '<div class="chart-bar ' + (e.ok ? "ok" : "fail") + '" style="height:' + h + '%" title="' + v + ' events"></div>';
}).join("");
$("#chartArea").innerHTML = bars;
const first = sliced[0];
const last = sliced[sliced.length - 1];
$("#chartLabels").innerHTML = '<span>' + (first.time || "") + "</span><span>" + (last.time || "") + "</span>";
return;
} else { key = "latency_ms"; }
const vals = sliced.map(function (e) { return e[key] || 0; });
const maxVal = Math.max(1, ...vals);
const bars = sliced.map(function (e) {
const v = e[key] || 0;
const h = Math.max(2, (v / maxVal) * 100);
const tip = metric === "duration" ? (v * 1000).toFixed(0) + "ms" : v + "ms";
return '<div class="chart-bar ' + (e.ok ? "ok" : "fail") + '" style="height:' + h + '%" title="' + tip + '"></div>';
}).join("");
$("#chartArea").innerHTML = bars;
const first = sliced[0];
const last = sliced[sliced.length - 1];
$("#chartLabels").innerHTML = '<span>' + (first.time || "") + "</span><span>" + (last.time || "") + "</span>";
}
/* ── Render Config ── */
function renderConfig(d) {
const c = d.config || {};
const rows = [
["ICS URL", truncate(c.ics_url || "")],
["Baikal URL", truncate(c.baikal_url || "")],
["User", c.baikal_user || ""],
["Password", "password", c.baikal_pass || ""],
["Frequency", c.sync_frequency ? c.sync_frequency + " minutes" : "—"],
["Calendar ID", c.calendar_id || "—"]
];
let html = "";
rows.forEach(function (r) {
if (r[1] === "password") {
const pw = state.passwordVisible ? r[2] : maskPassword(r[2]);
html += '<tr><td>' + r[0] + "</td><td>" +
'<div class="password-cell">' +
'<span class="password-text">' + pw + "</span>" +
'<button class="eye-btn" id="eyeBtn">' + (state.passwordVisible ? "🙈" : "👁️") + "</button>" +
"</div></td></tr>";
} else {
html += '<tr><td>' + r[0] + "</td><td>" + r[1] + "</td></tr>";
}
});
$("#configBody").innerHTML = html;
if ($("#eyeBtn")) {
$("#eyeBtn").addEventListener("click", function () {
state.passwordVisible = !state.passwordVisible;
renderConfig(state.data);
});
}
}
/* ── Render History ── */
function renderHistory(d) {
const history = d.session.history || [];
const sliced = history.slice(-50).reverse();
if (!sliced.length) {
$("#timeline").innerHTML = '<div class="empty-state">No sync history yet.</div>';
return;
}
let html = "";
sliced.forEach(function (entry) {
const ok = entry.ok !== false;
const badge = getBadge(entry);
html +=
'<div class="timeline-entry">' +
'<span class="entry-dot ' + (ok ? "ok" : "fail") + '"></span>' +
'<span class="entry-time">' + (entry.time || "") + "</span>" +
'<span class="entry-msg">' + escapeHtml(entry.msg || (ok ? "OK" : "Error")) + "</span>" +
badge +
"</div>";
});
$("#timeline").innerHTML = html;
}
function getBadge(e) {
const added = e.added || 0;
const updated = e.updated || 0;
const deleted = e.deleted || 0;
if (!e.ok) return '<span class="badge error">error</span>';
if (e.skipped || (added === 0 && updated === 0 && deleted === 0)) return '<span class="badge skipped">skipped</span>';
const parts = [];
if (added) parts.push("+" + added);
if (updated) parts.push("~" + updated);
if (deleted) parts.push("" + deleted);
return '<span class="badge ' + (added ? "added" : "updated") + '">' + parts.join(" / ") + "</span>";
}
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
/* ── Copy .env ── */
function copyEnv() {
const c = state.data.config;
if (!c) return;
const lines = [
"ICS_URL=" + (c.ics_url || ""),
"BAIKAL_URL=" + (c.baikal_url || ""),
"BAIKAL_USER=" + (c.baikal_user || ""),
"BAIKAL_PASS=" + (c.baikal_pass || ""),
"SYNC_FREQUENCY=" + (c.sync_frequency || ""),
"CALENDAR_ID=" + (c.calendar_id || "")
].join("\n");
navigator.clipboard.writeText(lines).then(function () {
const btn = $("#copyBtn");
btn.textContent = "Copied!";
btn.classList.add("copied");
setTimeout(function () {
btn.textContent = "Copy .env";
btn.classList.remove("copied");
}, 2000);
}).catch(function () {
const ta = document.createElement("textarea");
ta.value = lines;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
const btn = $("#copyBtn");
btn.textContent = "Copied!";
btn.classList.add("copied");
setTimeout(function () {
btn.textContent = "Copy .env";
btn.classList.remove("copied");
}, 2000);
});
}
/* ── Polling ── */
function updateCountdown() {
if (!state.data) return;
const el = $("#countdown");
const fill = $("#progressFill");
if (!el || !fill) return;
let remaining = (state.data.next_sync_in || 0) - 1;
if (remaining < 0) remaining = 0;
el.textContent = fmtCountdown(remaining);
const freq = state.data.config ? state.data.config.sync_frequency * 60 : 600;
const pct = freq > 0 ? Math.max(0, Math.min(100, ((freq - remaining) / freq) * 100)) : 100;
fill.style.width = pct + "%";
state.data.next_sync_in = remaining;
}
async function poll() {
try {
const res = await fetch("/api/status");
if (!res.ok) throw new Error("HTTP " + res.status);
state.data = await res.json();
state.lastPoll = Date.now();
renderStatusBar(state.data);
renderStats(state.data);
renderStackedBar(state.data);
renderChart(state.data);
renderConfig(state.data);
renderHistory(state.data);
} catch (err) {
console.error("Poll error:", err);
}
}
/* ── Init ── */
function init() {
initTheme();
$("#themeToggle").addEventListener("click", toggleTheme);
$("#copyBtn").addEventListener("click", copyEnv);
document.getElementById("chartToggle").addEventListener("click", function (e) {
const btn = e.target.closest(".toggle-btn");
if (!btn) return;
state.chartMetric = btn.getAttribute("data-metric");
document.querySelectorAll("#chartToggle .toggle-btn").forEach(function (b) { b.classList.remove("active"); });
btn.classList.add("active");
if (state.data) renderChart(state.data);
});
poll();
state.pollTimer = setInterval(poll, 10000);
state.countdownTimer = setInterval(updateCountdown, 1000);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
</script>
</body>
</html>
+95 -46
View File
@@ -12,7 +12,8 @@ from config import validate, HEADERS, Config
from state import SyncState
from diff import parse_ics_events, compute_diff, parse_ics_events_with_data
from apply import apply_adds, apply_updates, apply_deletes
from health import HealthServer
from health import HealthServer, SyncSession
from dashboard import DashboardServer
logger = logging.getLogger(__name__)
shutdown_event = threading.Event()
@@ -51,8 +52,23 @@ def find_calendar(client, config):
return None
def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool:
def sync_once(
state: SyncState,
health: HealthServer,
session: SyncSession,
config: Config,
dashboard: DashboardServer,
) -> bool:
start_time = time.time()
ics_latency_ms = 0
added = 0
updated = 0
deleted = 0
skipped = False
ics_download_size = 0
msg = ""
dashboard.set_syncing(True)
logger.info("Starting sync cycle...")
try:
@@ -68,17 +84,35 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool:
if remote_etag and cached_etag == remote_etag:
logger.info("No changes detected (ETag match). Skipping sync.")
duration = time.time() - start_time
skipped = True
msg = "no changes (ETag)"
session.record(True, duration, 0, 0, 0, True, 0, msg)
health.update_status(datetime.now(timezone.utc), duration, True, 0)
dashboard.set_syncing(False)
return True
dl_start = time.time()
response = requests.get(config.ics_url, headers=HEADERS, timeout=30)
response.raise_for_status()
ics_text = response.text
dl_end = time.time()
ics_latency_ms = round((dl_end - dl_start) * 1000)
ics_download_size = len(ics_text)
ics_hash = hashlib.sha256(ics_text.encode("utf-8")).hexdigest()
if cached_hash == ics_hash:
logger.info("No changes detected (hash match). Skipping sync.")
if remote_etag:
state.set_ics_cache(ics_hash, remote_etag)
duration = time.time() - start_time
skipped = True
msg = "no changes (hash)"
session.record(True, duration, 0, 0, 0, True, ics_latency_ms, msg, ics_download_size)
if ics_download_size:
pass
health.update_status(datetime.now(timezone.utc), duration, True, 0)
dashboard.set_syncing(False)
return True
state.set_ics_cache(ics_hash, remote_etag)
@@ -101,30 +135,29 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool:
if not to_add and not to_update and not to_delete:
logger.info("Calendar is already in sync.")
duration = time.time() - start_time
health.update_status(
datetime.now(timezone.utc),
duration,
True,
len(ics_uids),
)
skipped = True
msg = "already in sync"
session.record(True, duration, 0, 0, 0, True, ics_latency_ms, msg, ics_download_size)
for uid, h in ics_uids.items():
state.upsert_event(uid, h)
health.update_status(datetime.now(timezone.utc), duration, True, len(ics_uids))
dashboard.set_event_count(len(ics_uids))
dashboard.set_syncing(False)
return True
if is_first_run and not to_delete:
logger.info(
"First run detected: %d events in ICS. Registering state without re-adding to calendar.",
"First run detected: %d events in ICS. Registering state without re-adding.",
len(to_add),
)
for uid, h in ics_uids.items():
state.upsert_event(uid, h)
duration = time.time() - start_time
health.update_status(
datetime.now(timezone.utc),
duration,
True,
len(ics_uids),
)
msg = f"first run, registered {len(to_add)} events"
session.record(True, duration, len(to_add), 0, 0, False, ics_latency_ms, msg, ics_download_size)
health.update_status(datetime.now(timezone.utc), duration, True, len(ics_uids))
dashboard.set_event_count(len(ics_uids))
dashboard.set_syncing(False)
return True
logger.info(
@@ -145,12 +178,10 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool:
if not calendar:
logger.error("Failed to find calendar")
duration = time.time() - start_time
health.update_status(
datetime.now(timezone.utc),
duration,
False,
0,
)
msg = "calendar not found"
session.record(False, duration, 0, 0, 0, False, ics_latency_ms, msg, ics_download_size)
health.update_status(datetime.now(timezone.utc), duration, False, 0)
dashboard.set_syncing(False)
return False
snapshot = state.snapshot()
@@ -162,19 +193,26 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool:
try:
logger.info("Phase 1: Adding %d events...", len(add_events))
s_a, e_a = 0, 0
if add_events:
s, e = apply_adds(calendar, add_events)
logger.info("Added %d/%d events (%d errors)", s, len(add_events), e)
s_a, e_a = apply_adds(calendar, add_events)
logger.info("Added %d/%d events (%d errors)", s_a, len(add_events), e_a)
logger.info("Phase 2: Updating %d events...", len(update_events))
s_u, e_u = 0, 0
if update_events:
s, e = apply_updates(calendar, update_events)
logger.info("Updated %d/%d events (%d errors)", s, len(update_events), e)
s_u, e_u = apply_updates(calendar, update_events)
logger.info("Updated %d/%d events (%d errors)", s_u, len(update_events), e_u)
logger.info("Phase 3: Deleting %d events...", len(delete_uids))
s_d, e_d = 0, 0
if delete_uids:
s, e = apply_deletes(calendar, delete_uids)
logger.info("Deleted %d/%d events (%d errors)", s, len(delete_uids), e)
s_d, e_d = apply_deletes(calendar, delete_uids)
logger.info("Deleted %d/%d events (%d errors)", s_d, len(delete_uids), e_d)
added = s_a
updated = s_u
deleted = s_d
for uid, h in ics_uids.items():
state.upsert_event(uid, h)
@@ -184,36 +222,31 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool:
total = len(ics_uids)
duration = time.time() - start_time
msg = f"+{added} / ~{updated} / -{deleted}"
session.record(True, duration, added, updated, deleted, False, ics_latency_ms, msg, ics_download_size)
logger.info("Sync completed in %.1fs. Total events: %d", duration, total)
health.update_status(
datetime.now(timezone.utc),
duration,
True,
total,
)
health.update_status(datetime.now(timezone.utc), duration, True, total)
dashboard.set_event_count(total)
dashboard.set_syncing(False)
return True
except Exception as exc:
logger.error("Sync failed: %s. Rolling back state.", exc)
state.restore_snapshot(snapshot)
duration = time.time() - start_time
health.update_status(
datetime.now(timezone.utc),
duration,
False,
0,
)
msg = str(exc)[:80]
session.record(False, duration, 0, 0, 0, False, ics_latency_ms, msg, ics_download_size)
health.update_status(datetime.now(timezone.utc), duration, False, 0)
dashboard.set_syncing(False)
return False
except Exception as exc:
logger.error("Sync error: %s", exc)
duration = time.time() - start_time
health.update_status(
datetime.now(timezone.utc),
duration,
False,
0,
)
msg = str(exc)[:80]
session.record(False, duration, 0, 0, 0, False, ics_latency_ms, msg, ics_download_size)
health.update_status(datetime.now(timezone.utc), duration, False, 0)
dashboard.set_syncing(False)
return False
@@ -233,6 +266,19 @@ def main():
health.start()
logger.info("Health endpoint on :8081")
session = SyncSession()
dashboard = DashboardServer(8082, health, session)
dashboard.update_config({
"ics_url": config.ics_url,
"baikal_url": config.baikal_url,
"baikal_user": config.baikal_user,
"baikal_pass": config.baikal_pass,
"sync_frequency": config.sync_frequency,
"calendar_id": os.environ.get("CALENDAR_ID", ""),
})
dashboard.start()
logger.info("Dashboard on :8082")
backoff = 0
def handle_signal(signum, frame):
@@ -243,7 +289,7 @@ def main():
signal.signal(signal.SIGINT, handle_signal)
while not shutdown_event.is_set():
success = sync_once(state, health, config)
success = sync_once(state, health, session, config, dashboard)
if success:
backoff = 0
@@ -253,11 +299,14 @@ def main():
sleep_time = backoff * 60
logger.info("Sync failed. Backing off %d minutes...", backoff)
dashboard.set_next_sync_in(sleep_time)
dashboard.set_backoff_min(backoff)
logger.info("Next sync in %d seconds...", sleep_time)
shutdown_event.wait(sleep_time)
state.close()
health.stop()
dashboard.stop()
logger.info("Shutdown complete.")