diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index c036f1a..c04ffac 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -1425,6 +1425,63 @@ async def api_upgrade_to_server(): return {"ok": True, "status": "rebuilding"} +# ── Bitcoin IBD sync helper ─────────────────────────────────────── + +BITCOIN_DATADIR = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node" + +# Simple in-process cache: (timestamp, result) +_btc_sync_cache: tuple[float, dict | None] = (0.0, None) +_BTC_SYNC_CACHE_TTL = 5 # seconds + + +def _get_bitcoin_sync_info() -> dict | None: + """Call bitcoin-cli getblockchaininfo and return parsed JSON, or None on error. + + Results are cached for _BTC_SYNC_CACHE_TTL seconds to avoid hammering + bitcoin-cli on every /api/services poll cycle. + """ + global _btc_sync_cache + now = time.monotonic() + cached_at, cached_val = _btc_sync_cache + if now - cached_at < _BTC_SYNC_CACHE_TTL: + return cached_val + + try: + result = subprocess.run( + ["bitcoin-cli", f"-datadir={BITCOIN_DATADIR}", "getblockchaininfo"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + _btc_sync_cache = (now, None) + return None + info = json.loads(result.stdout) + _btc_sync_cache = (now, info) + return info + except Exception: + _btc_sync_cache = (now, None) + return None + + +@app.get("/api/bitcoin/sync") +async def api_bitcoin_sync(): + """Return Bitcoin blockchain sync status directly from bitcoin-cli.""" + loop = asyncio.get_event_loop() + info = await loop.run_in_executor(None, _get_bitcoin_sync_info) + if info is None: + return JSONResponse( + status_code=503, + content={"error": "bitcoin-cli unavailable or bitcoind not running"}, + ) + return { + "blocks": info.get("blocks", 0), + "headers": info.get("headers", 0), + "verificationprogress": info.get("verificationprogress", 0), + "initialblockdownload": info.get("initialblockdownload", False), + } + + @app.get("/api/services") async def api_services(): cfg = load_config() @@ -1486,6 +1543,10 @@ async def api_services(): domain = None # Compute composite health + sync_progress: float | None = None + sync_blocks: int | None = None + sync_headers: int | None = None + sync_ibd: bool | None = None if not enabled: health = "disabled" elif status == "active": @@ -1506,6 +1567,15 @@ async def api_services(): if not domain: has_domain_issues = True health = "needs_attention" if (has_port_issues or has_domain_issues) else "healthy" + # Check Bitcoin IBD state + if unit == "bitcoind.service": + sync = await loop.run_in_executor(None, _get_bitcoin_sync_info) + if sync and sync.get("initialblockdownload"): + health = "syncing" + sync_progress = sync.get("verificationprogress", 0) + sync_blocks = sync.get("blocks", 0) + sync_headers = sync.get("headers", 0) + sync_ibd = True elif status == "inactive": health = "inactive" elif status == "failed": @@ -1513,7 +1583,7 @@ async def api_services(): else: health = status # loading states, etc. - return { + service_data: dict = { "name": entry.get("name", ""), "unit": unit, "type": scope, @@ -1527,6 +1597,12 @@ async def api_services(): "needs_domain": needs_domain, "domain": domain, } + if sync_ibd is not None: + service_data["sync_ibd"] = sync_ibd + service_data["sync_progress"] = sync_progress + service_data["sync_blocks"] = sync_blocks + service_data["sync_headers"] = sync_headers + return service_data results = await asyncio.gather(*[get_status(s) for s in services]) return list(results) @@ -1708,6 +1784,10 @@ async def api_service_detail(unit: str, icon: str | None = None): }) # Compute composite health + sync_progress: float | None = None + sync_blocks: int | None = None + sync_headers: int | None = None + sync_ibd: bool | None = None if not enabled: health = "disabled" elif status == "active": @@ -1719,6 +1799,15 @@ async def api_service_detail(unit: str, icon: str | None = None): elif domain_status and domain_status.get("status") not in ("connected", None): has_domain_issues = True health = "needs_attention" if (has_port_issues or has_domain_issues) else "healthy" + # Check Bitcoin IBD state + if unit == "bitcoind.service": + sync = await loop.run_in_executor(None, _get_bitcoin_sync_info) + if sync and sync.get("initialblockdownload"): + health = "syncing" + sync_progress = sync.get("verificationprogress", 0) + sync_blocks = sync.get("blocks", 0) + sync_headers = sync.get("headers", 0) + sync_ibd = True elif status == "inactive": health = "inactive" elif status == "failed": @@ -1761,7 +1850,7 @@ async def api_service_detail(unit: str, icon: str | None = None): "port_requirements": feat_meta.get("port_requirements", []), } - return { + service_detail: dict = { "name": entry.get("name", ""), "unit": unit, "icon": icon, @@ -1780,6 +1869,12 @@ async def api_service_detail(unit: str, icon: str | None = None): "internal_ip": internal_ip, "feature": feature_entry, } + if sync_ibd is not None: + service_detail["sync_ibd"] = sync_ibd + service_detail["sync_progress"] = sync_progress + service_detail["sync_blocks"] = sync_blocks + service_detail["sync_headers"] = sync_headers + return service_detail @app.get("/api/network") diff --git a/app/sovran_systemsos_web/static/css/tiles.css b/app/sovran_systemsos_web/static/css/tiles.css index df7ba58..613fbc9 100644 --- a/app/sovran_systemsos_web/static/css/tiles.css +++ b/app/sovran_systemsos_web/static/css/tiles.css @@ -85,6 +85,65 @@ .status-dot.failed { background-color: var(--red); } .status-dot.disabled { background-color: var(--grey); } .status-dot.needs-attention { background-color: var(--yellow); } +.status-dot.syncing { background-color: #f5a623; animation: pulse-badge 1.5s infinite; } + +/* ── Bitcoin IBD sync progress bar ──────────────────────────────── */ + +.tile-sync-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + width: 100%; + margin-top: 6px; +} + +.tile-sync-label { + font-size: 0.72rem; + color: #f5a623; + font-weight: 600; + text-align: center; + white-space: nowrap; +} + +.tile-sync-bar-row { + display: flex; + align-items: center; + gap: 5px; + width: 100%; +} + +.tile-sync-bar-track { + flex: 1; + height: 6px; + background-color: var(--border-color); + border-radius: 3px; + overflow: hidden; +} + +.tile-sync-bar-fill { + height: 100%; + background-color: #f5a623; + border-radius: 3px; + transition: width 0.6s ease; + min-width: 2px; +} + +.tile-sync-percent { + font-size: 0.72rem; + font-weight: 700; + color: #f5a623; + white-space: nowrap; + min-width: 2.5em; + text-align: right; +} + +.tile-sync-eta { + font-size: 0.68rem; + color: var(--text-dim); + text-align: center; + white-space: nowrap; +} /* ── Service detail modal sections ───────────────────────────────── */ diff --git a/app/sovran_systemsos_web/static/js/helpers.js b/app/sovran_systemsos_web/static/js/helpers.js index b0b8e99..88774d0 100644 --- a/app/sovran_systemsos_web/static/js/helpers.js +++ b/app/sovran_systemsos_web/static/js/helpers.js @@ -12,6 +12,7 @@ function statusClass(health) { if (health === "inactive") return "inactive"; if (health === "failed") return "failed"; if (health === "disabled") return "disabled"; + if (health === "syncing") return "syncing"; if (STATUS_LOADING_STATES.has(health)) return "loading"; return "unknown"; } @@ -23,6 +24,7 @@ function statusText(health, enabled) { if (health === "active") return "Active"; if (health === "inactive") return "Inactive"; if (health === "failed") return "Failed"; + if (health === "syncing") return "Syncing\u2026"; if (!health || health === "unknown") return "Unknown"; if (STATUS_LOADING_STATES.has(health)) return health; return health; diff --git a/app/sovran_systemsos_web/static/js/tiles.js b/app/sovran_systemsos_web/static/js/tiles.js index 9794990..436cd32 100644 --- a/app/sovran_systemsos_web/static/js/tiles.js +++ b/app/sovran_systemsos_web/static/js/tiles.js @@ -1,5 +1,9 @@ "use strict"; +// ── Bitcoin IBD sync state (for ETA calculation) ────────────────── +// Keyed by tileId: { progress: float, timestamp: ms } +var _btcSyncPrev = {}; + // ── Render: initial build ───────────────────────────────────────── function buildTiles(services, categoryLabels) { @@ -123,6 +127,29 @@ function buildTile(svc) { return tile; } + if (svc.sync_ibd) { + var pct = Math.round((svc.sync_progress || 0) * 100); + var id = tileId(svc); + var eta = _calcBtcEta(id, svc.sync_progress || 0); + tile.innerHTML = + '' + escHtml(svc.name) + '' + + '' + + '
' + escHtml(svc.name) + '
' + + '
' + + '
\u23F3 Syncing Timechain
' + + '
' + + '
' + + '' + pct + '%' + + '
' + + '
' + escHtml(eta) + '
' + + '
'; + tile.style.cursor = "pointer"; + tile.addEventListener("click", function() { + openServiceDetailModal(svc.unit, svc.name, svc.icon); + }); + return tile; + } + tile.innerHTML = '' + escHtml(svc.name) + '
' + escHtml(svc.name) + '
' + st + '
'; tile.style.cursor = "pointer"; @@ -135,6 +162,23 @@ function buildTile(svc) { // ── Render: live update ─────────────────────────────────────────── +// Calculate ETA text for Bitcoin IBD and track progress history. +function _calcBtcEta(id, progress) { + var now = Date.now(); + var prev = _btcSyncPrev[id]; + // Only update the cache when progress has actually advanced + if (!prev || prev.progress < progress) { + _btcSyncPrev[id] = { progress: progress, timestamp: now }; + } + if (!prev || prev.progress >= progress) return "Estimating\u2026"; + var elapsed = (now - prev.timestamp) / 1000; // seconds + if (elapsed <= 0) return "Estimating\u2026"; + var rate = (progress - prev.progress) / elapsed; // progress per second + if (rate <= 0) return "Estimating\u2026"; + var remaining = (1.0 - progress) / rate; + return "\u007E" + formatDuration(remaining) + " remaining"; +} + function updateTiles(services) { _servicesCache = services; for (var i = 0; i < services.length; i++) { @@ -143,12 +187,38 @@ function updateTiles(services) { var id = CSS.escape(tileId(svc)); var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]'); if (!tile) continue; - var sc = statusClass(svc.health || svc.status); - var st = statusText(svc.health || svc.status, svc.enabled); - var dot = tile.querySelector(".status-dot"); - var text = tile.querySelector(".status-text"); - if (dot) dot.className = "status-dot " + sc; - if (text) text.textContent = st; + + if (svc.sync_ibd) { + // If tile was previously normal, rebuild it with the sync layout + if (!tile.querySelector(".tile-sync-container")) { + var newTile = buildTile(svc); + tile.parentNode.replaceChild(newTile, tile); + continue; + } + // Update progress bar values in-place + var pct = Math.round((svc.sync_progress || 0) * 100); + var etaText = _calcBtcEta(tileId(svc), svc.sync_progress || 0); + var fill = tile.querySelector(".tile-sync-bar-fill"); + var pctEl = tile.querySelector(".tile-sync-percent"); + var etaEl = tile.querySelector(".tile-sync-eta"); + if (fill) fill.style.width = pct + "%"; + if (pctEl) pctEl.textContent = pct + "%"; + if (etaEl) etaEl.textContent = etaText; + } else { + // IBD finished or not syncing — if tile had sync layout rebuild it normally + if (tile.querySelector(".tile-sync-container")) { + delete _btcSyncPrev[tileId(svc)]; + var normalTile = buildTile(svc); + tile.parentNode.replaceChild(normalTile, tile); + continue; + } + var sc = statusClass(svc.health || svc.status); + var st = statusText(svc.health || svc.status, svc.enabled); + var dot = tile.querySelector(".status-dot"); + var text = tile.querySelector(".status-text"); + if (dot) dot.className = "status-dot " + sc; + if (text) text.textContent = st; + } } }