Merge pull request #68 from naturallaw777/copilot/add-bitcoin-ibd-sync-indicator

feat: Bitcoin IBD sync progress bar in active Bitcoin tile
This commit is contained in:
Sovran_Systems
2026-04-05 00:45:49 -05:00
committed by GitHub
4 changed files with 234 additions and 8 deletions

View File

@@ -1425,6 +1425,63 @@ async def api_upgrade_to_server():
return {"ok": True, "status": "rebuilding"} 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") @app.get("/api/services")
async def api_services(): async def api_services():
cfg = load_config() cfg = load_config()
@@ -1486,6 +1543,10 @@ async def api_services():
domain = None domain = None
# Compute composite health # 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: if not enabled:
health = "disabled" health = "disabled"
elif status == "active": elif status == "active":
@@ -1506,6 +1567,15 @@ async def api_services():
if not domain: if not domain:
has_domain_issues = True has_domain_issues = True
health = "needs_attention" if (has_port_issues or has_domain_issues) else "healthy" 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": elif status == "inactive":
health = "inactive" health = "inactive"
elif status == "failed": elif status == "failed":
@@ -1513,7 +1583,7 @@ async def api_services():
else: else:
health = status # loading states, etc. health = status # loading states, etc.
return { service_data: dict = {
"name": entry.get("name", ""), "name": entry.get("name", ""),
"unit": unit, "unit": unit,
"type": scope, "type": scope,
@@ -1527,6 +1597,12 @@ async def api_services():
"needs_domain": needs_domain, "needs_domain": needs_domain,
"domain": 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]) results = await asyncio.gather(*[get_status(s) for s in services])
return list(results) return list(results)
@@ -1708,6 +1784,10 @@ async def api_service_detail(unit: str, icon: str | None = None):
}) })
# Compute composite health # 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: if not enabled:
health = "disabled" health = "disabled"
elif status == "active": 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): elif domain_status and domain_status.get("status") not in ("connected", None):
has_domain_issues = True has_domain_issues = True
health = "needs_attention" if (has_port_issues or has_domain_issues) else "healthy" 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": elif status == "inactive":
health = "inactive" health = "inactive"
elif status == "failed": 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", []), "port_requirements": feat_meta.get("port_requirements", []),
} }
return { service_detail: dict = {
"name": entry.get("name", ""), "name": entry.get("name", ""),
"unit": unit, "unit": unit,
"icon": icon, "icon": icon,
@@ -1780,6 +1869,12 @@ async def api_service_detail(unit: str, icon: str | None = None):
"internal_ip": internal_ip, "internal_ip": internal_ip,
"feature": feature_entry, "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") @app.get("/api/network")

View File

@@ -85,6 +85,65 @@
.status-dot.failed { background-color: var(--red); } .status-dot.failed { background-color: var(--red); }
.status-dot.disabled { background-color: var(--grey); } .status-dot.disabled { background-color: var(--grey); }
.status-dot.needs-attention { background-color: var(--yellow); } .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 ───────────────────────────────── */ /* ── Service detail modal sections ───────────────────────────────── */

View File

@@ -12,6 +12,7 @@ function statusClass(health) {
if (health === "inactive") return "inactive"; if (health === "inactive") return "inactive";
if (health === "failed") return "failed"; if (health === "failed") return "failed";
if (health === "disabled") return "disabled"; if (health === "disabled") return "disabled";
if (health === "syncing") return "syncing";
if (STATUS_LOADING_STATES.has(health)) return "loading"; if (STATUS_LOADING_STATES.has(health)) return "loading";
return "unknown"; return "unknown";
} }
@@ -23,6 +24,7 @@ function statusText(health, enabled) {
if (health === "active") return "Active"; if (health === "active") return "Active";
if (health === "inactive") return "Inactive"; if (health === "inactive") return "Inactive";
if (health === "failed") return "Failed"; if (health === "failed") return "Failed";
if (health === "syncing") return "Syncing\u2026";
if (!health || health === "unknown") return "Unknown"; if (!health || health === "unknown") return "Unknown";
if (STATUS_LOADING_STATES.has(health)) return health; if (STATUS_LOADING_STATES.has(health)) return health;
return health; return health;

View File

@@ -1,5 +1,9 @@
"use strict"; "use strict";
// ── Bitcoin IBD sync state (for ETA calculation) ──────────────────
// Keyed by tileId: { progress: float, timestamp: ms }
var _btcSyncPrev = {};
// ── Render: initial build ───────────────────────────────────────── // ── Render: initial build ─────────────────────────────────────────
function buildTiles(services, categoryLabels) { function buildTiles(services, categoryLabels) {
@@ -123,6 +127,29 @@ function buildTile(svc) {
return tile; 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 =
'<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'">' +
'<div class="tile-icon-fallback" style="display:none">?</div>' +
'<div class="tile-name">' + escHtml(svc.name) + '</div>' +
'<div class="tile-sync-container">' +
'<div class="tile-sync-label">\u23F3 Syncing Timechain</div>' +
'<div class="tile-sync-bar-row">' +
'<div class="tile-sync-bar-track"><div class="tile-sync-bar-fill" style="width:' + pct + '%"></div></div>' +
'<span class="tile-sync-percent">' + pct + '%</span>' +
'</div>' +
'<div class="tile-sync-eta">' + escHtml(eta) + '</div>' +
'</div>';
tile.style.cursor = "pointer";
tile.addEventListener("click", function() {
openServiceDetailModal(svc.unit, svc.name, svc.icon);
});
return tile;
}
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div><div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>'; tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div><div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
tile.style.cursor = "pointer"; tile.style.cursor = "pointer";
@@ -135,6 +162,23 @@ function buildTile(svc) {
// ── Render: live update ─────────────────────────────────────────── // ── 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) { function updateTiles(services) {
_servicesCache = services; _servicesCache = services;
for (var i = 0; i < services.length; i++) { for (var i = 0; i < services.length; i++) {
@@ -143,12 +187,38 @@ function updateTiles(services) {
var id = CSS.escape(tileId(svc)); var id = CSS.escape(tileId(svc));
var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]'); var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]');
if (!tile) continue; if (!tile) continue;
var sc = statusClass(svc.health || svc.status);
var st = statusText(svc.health || svc.status, svc.enabled); if (svc.sync_ibd) {
var dot = tile.querySelector(".status-dot"); // If tile was previously normal, rebuild it with the sync layout
var text = tile.querySelector(".status-text"); if (!tile.querySelector(".tile-sync-container")) {
if (dot) dot.className = "status-dot " + sc; var newTile = buildTile(svc);
if (text) text.textContent = st; 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;
}
} }
} }