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:
@@ -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")
|
||||||
|
|||||||
@@ -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 ───────────────────────────────── */
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,6 +187,31 @@ 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;
|
||||||
|
|
||||||
|
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 sc = statusClass(svc.health || svc.status);
|
||||||
var st = statusText(svc.health || svc.status, svc.enabled);
|
var st = statusText(svc.health || svc.status, svc.enabled);
|
||||||
var dot = tile.querySelector(".status-dot");
|
var dot = tile.querySelector(".status-dot");
|
||||||
@@ -150,6 +219,7 @@ function updateTiles(services) {
|
|||||||
if (dot) dot.className = "status-dot " + sc;
|
if (dot) dot.className = "status-dot " + sc;
|
||||||
if (text) text.textContent = st;
|
if (text) text.textContent = st;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Service polling ───────────────────────────────────────────────
|
// ── Service polling ───────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user