Feat/incremental sync #1

Merged
Lago merged 7 commits from feat/incremental-sync into main 2026-06-12 10:06:42 +02:00
6 changed files with 1050 additions and 46 deletions
Showing only changes of commit 7a388653ed - Show all commits
+2
View File
@@ -9,6 +9,8 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
EXPOSE 8081 8082
HEALTHCHECK --interval=60s --timeout=10s --start-period=5s --retries=3 \ 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 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 image: lagortinez/baikal-sync:latest
container_name: baikal-sync container_name: baikal-sync
restart: always restart: always
ports:
- "8081:8081"
- "8082:8082"
environment: environment:
- ICS_URL=${ICS_URL} - ICS_URL=${ICS_URL}
- BAIKAL_URL=${BAIKAL_URL} - BAIKAL_URL=${BAIKAL_URL}
+73
View File
@@ -1,10 +1,83 @@
import json import json
import threading import threading
import time
from collections import deque
from datetime import datetime, timezone from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
from io import StringIO 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: class HealthServer:
def __init__(self, port: int = 8081): def __init__(self, port: int = 8081):
self.port = port 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 state import SyncState
from diff import parse_ics_events, compute_diff, parse_ics_events_with_data from diff import parse_ics_events, compute_diff, parse_ics_events_with_data
from apply import apply_adds, apply_updates, apply_deletes 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__) logger = logging.getLogger(__name__)
shutdown_event = threading.Event() shutdown_event = threading.Event()
@@ -51,8 +52,23 @@ def find_calendar(client, config):
return None 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() 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...") logger.info("Starting sync cycle...")
try: try:
@@ -68,17 +84,35 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool:
if remote_etag and cached_etag == remote_etag: if remote_etag and cached_etag == remote_etag:
logger.info("No changes detected (ETag match). Skipping sync.") 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 return True
dl_start = time.time()
response = requests.get(config.ics_url, headers=HEADERS, timeout=30) response = requests.get(config.ics_url, headers=HEADERS, timeout=30)
response.raise_for_status() response.raise_for_status()
ics_text = response.text 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() ics_hash = hashlib.sha256(ics_text.encode("utf-8")).hexdigest()
if cached_hash == ics_hash: if cached_hash == ics_hash:
logger.info("No changes detected (hash match). Skipping sync.") logger.info("No changes detected (hash match). Skipping sync.")
if remote_etag: if remote_etag:
state.set_ics_cache(ics_hash, 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 return True
state.set_ics_cache(ics_hash, remote_etag) 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: if not to_add and not to_update and not to_delete:
logger.info("Calendar is already in sync.") logger.info("Calendar is already in sync.")
duration = time.time() - start_time duration = time.time() - start_time
health.update_status( skipped = True
datetime.now(timezone.utc), msg = "already in sync"
duration, session.record(True, duration, 0, 0, 0, True, ics_latency_ms, msg, ics_download_size)
True,
len(ics_uids),
)
for uid, h in ics_uids.items(): for uid, h in ics_uids.items():
state.upsert_event(uid, h) 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 return True
if is_first_run and not to_delete: if is_first_run and not to_delete:
logger.info( 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), len(to_add),
) )
for uid, h in ics_uids.items(): for uid, h in ics_uids.items():
state.upsert_event(uid, h) state.upsert_event(uid, h)
duration = time.time() - start_time duration = time.time() - start_time
health.update_status( msg = f"first run, registered {len(to_add)} events"
datetime.now(timezone.utc), session.record(True, duration, len(to_add), 0, 0, False, ics_latency_ms, msg, ics_download_size)
duration, health.update_status(datetime.now(timezone.utc), duration, True, len(ics_uids))
True, dashboard.set_event_count(len(ics_uids))
len(ics_uids), dashboard.set_syncing(False)
)
return True return True
logger.info( logger.info(
@@ -145,12 +178,10 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool:
if not calendar: if not calendar:
logger.error("Failed to find calendar") logger.error("Failed to find calendar")
duration = time.time() - start_time duration = time.time() - start_time
health.update_status( msg = "calendar not found"
datetime.now(timezone.utc), session.record(False, duration, 0, 0, 0, False, ics_latency_ms, msg, ics_download_size)
duration, health.update_status(datetime.now(timezone.utc), duration, False, 0)
False, dashboard.set_syncing(False)
0,
)
return False return False
snapshot = state.snapshot() snapshot = state.snapshot()
@@ -162,19 +193,26 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool:
try: try:
logger.info("Phase 1: Adding %d events...", len(add_events)) logger.info("Phase 1: Adding %d events...", len(add_events))
s_a, e_a = 0, 0
if add_events: if add_events:
s, e = apply_adds(calendar, add_events) s_a, e_a = apply_adds(calendar, add_events)
logger.info("Added %d/%d events (%d errors)", s, len(add_events), e) 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)) logger.info("Phase 2: Updating %d events...", len(update_events))
s_u, e_u = 0, 0
if update_events: if update_events:
s, e = apply_updates(calendar, update_events) s_u, e_u = apply_updates(calendar, update_events)
logger.info("Updated %d/%d events (%d errors)", s, len(update_events), e) 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)) logger.info("Phase 3: Deleting %d events...", len(delete_uids))
s_d, e_d = 0, 0
if delete_uids: if delete_uids:
s, e = apply_deletes(calendar, delete_uids) s_d, e_d = apply_deletes(calendar, delete_uids)
logger.info("Deleted %d/%d events (%d errors)", s, len(delete_uids), e) 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(): for uid, h in ics_uids.items():
state.upsert_event(uid, h) state.upsert_event(uid, h)
@@ -184,36 +222,31 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool:
total = len(ics_uids) total = len(ics_uids)
duration = time.time() - start_time 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) logger.info("Sync completed in %.1fs. Total events: %d", duration, total)
health.update_status( health.update_status(datetime.now(timezone.utc), duration, True, total)
datetime.now(timezone.utc), dashboard.set_event_count(total)
duration, dashboard.set_syncing(False)
True,
total,
)
return True return True
except Exception as exc: except Exception as exc:
logger.error("Sync failed: %s. Rolling back state.", exc) logger.error("Sync failed: %s. Rolling back state.", exc)
state.restore_snapshot(snapshot) state.restore_snapshot(snapshot)
duration = time.time() - start_time duration = time.time() - start_time
health.update_status( msg = str(exc)[:80]
datetime.now(timezone.utc), session.record(False, duration, 0, 0, 0, False, ics_latency_ms, msg, ics_download_size)
duration, health.update_status(datetime.now(timezone.utc), duration, False, 0)
False, dashboard.set_syncing(False)
0,
)
return False return False
except Exception as exc: except Exception as exc:
logger.error("Sync error: %s", exc) logger.error("Sync error: %s", exc)
duration = time.time() - start_time duration = time.time() - start_time
health.update_status( msg = str(exc)[:80]
datetime.now(timezone.utc), session.record(False, duration, 0, 0, 0, False, ics_latency_ms, msg, ics_download_size)
duration, health.update_status(datetime.now(timezone.utc), duration, False, 0)
False, dashboard.set_syncing(False)
0,
)
return False return False
@@ -233,6 +266,19 @@ def main():
health.start() health.start()
logger.info("Health endpoint on :8081") 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 backoff = 0
def handle_signal(signum, frame): def handle_signal(signum, frame):
@@ -243,7 +289,7 @@ def main():
signal.signal(signal.SIGINT, handle_signal) signal.signal(signal.SIGINT, handle_signal)
while not shutdown_event.is_set(): while not shutdown_event.is_set():
success = sync_once(state, health, config) success = sync_once(state, health, session, config, dashboard)
if success: if success:
backoff = 0 backoff = 0
@@ -253,11 +299,14 @@ def main():
sleep_time = backoff * 60 sleep_time = backoff * 60
logger.info("Sync failed. Backing off %d minutes...", backoff) 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) logger.info("Next sync in %d seconds...", sleep_time)
shutdown_event.wait(sleep_time) shutdown_event.wait(sleep_time)
state.close() state.close()
health.stop() health.stop()
dashboard.stop()
logger.info("Shutdown complete.") logger.info("Shutdown complete.")