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 =
+ '' +
+ '