diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 5db2959..0a436a5 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -2253,6 +2253,9 @@ _BTC_VERSION_CACHE_TTL = 60 # seconds — version doesn't change at runtime # Cache for ``bitcoind --version`` output (available even before RPC is ready) _btcd_version_cache: tuple[float, str | None] = (0.0, None) +# Cache for ``bitcoin-cli getdeploymentinfo`` output (BIP-110 live status) +_btc_deployment_cache: tuple[float, dict | None] = (0.0, None) + # ── Generic service version detection (NixOS store path) ───────── @@ -2367,6 +2370,119 @@ def _get_bitcoin_version_info() -> dict | None: return None +def _get_bitcoin_deployment_info() -> dict | None: + """Call bitcoin-cli getdeploymentinfo and return parsed JSON, or None on error. + + Results are cached for _BTC_VERSION_CACHE_TTL seconds. Never raises. + """ + global _btc_deployment_cache + now = time.monotonic() + cached_at, cached_val = _btc_deployment_cache + if now - cached_at < _BTC_VERSION_CACHE_TTL: + return cached_val + + try: + result = subprocess.run( + ["bitcoin-cli", f"-datadir={BITCOIN_DATADIR}", "getdeploymentinfo"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + _btc_deployment_cache = (now, None) + return None + info = json.loads(result.stdout) + _btc_deployment_cache = (now, info) + return info + except Exception: + _btc_deployment_cache = (now, None) + return None + + +def _get_bip110_status() -> dict: + """Return a dict describing the live BIP-110 deployment/signaling state. + + The returned struct has four stable keys:: + + { + "supported": bool, # node build is BIP-110-capable + "signaling": bool, # node is actively signaling / locked-in / active + "state": str, # "active" | "locked_in" | "signaling" | + # "not_signaling" | "unsupported" | "unknown" + "source": str, # "getdeploymentinfo" | "subversion" | "none" + } + + Resolution order (authoritative → fallback → honest unknown): + + 1. ``getdeploymentinfo`` (authoritative) — scan the ``deployments`` dict for an + entry whose key (case-insensitive) contains "bip110" or "110". The exact + deployment key name is **not** hard-coded because it may vary across Knots + releases; detection is intentionally generic so that a name change degrades + to "unknown" rather than producing a false result. + + 2. Subversion fallback — if getdeploymentinfo is unavailable or yields no + recognisable BIP-110 entry, inspect the ``subversion`` field from + ``getnetworkinfo``. A case-insensitive match for "bip110" or "uasf" in the + subversion string is treated as "signaling". + + 3. Unknown — if the node is entirely unreachable or neither source is + conclusive, return state="unknown", signaling=False, source="none". + """ + _unknown: dict = {"supported": False, "signaling": False, "state": "unknown", "source": "none"} + + # ── 1. getdeploymentinfo (authoritative) ────────────────────────── + deploy_info = _get_bitcoin_deployment_info() + if deploy_info is not None: + deployments = deploy_info.get("deployments", {}) + if isinstance(deployments, dict): + for key, entry in deployments.items(): + # Generic scan: match key containing "bip110" or "110" + if not ("bip110" in key.lower() or "110" in key.lower()): + continue + if not isinstance(entry, dict): + continue + + # bip9 / bip8 status field + bip9 = entry.get("bip9", {}) or {} + bip8 = entry.get("bip8", {}) or {} + status = ( + bip9.get("status") + or bip8.get("status") + or entry.get("status") + or "" + ).lower() + active = entry.get("active", False) + + if active or status == "active": + return {"supported": True, "signaling": True, "state": "active", "source": "getdeploymentinfo"} + if status == "locked_in": + return {"supported": True, "signaling": True, "state": "locked_in", "source": "getdeploymentinfo"} + if status in ("started", "defined"): + # Check whether the node is currently signaling this period + stats = bip9.get("statistics") or bip8.get("statistics") or {} + signaling = bool(stats.get("signaling", False)) if stats else False + if signaling: + return {"supported": True, "signaling": True, "state": "signaling", "source": "getdeploymentinfo"} + return {"supported": True, "signaling": False, "state": "not_signaling", "source": "getdeploymentinfo"} + if status == "failed": + return {"supported": True, "signaling": False, "state": "not_signaling", "source": "getdeploymentinfo"} + # Entry found but status unrecognised — node supports BIP-110 but state unclear + return {"supported": True, "signaling": False, "state": "unknown", "source": "getdeploymentinfo"} + + # ── 2. Subversion fallback ───────────────────────────────────────── + net_info = _get_bitcoin_version_info() + if net_info is not None: + subversion = net_info.get("subversion", "") or "" + sv_lower = subversion.lower() + if "bip110" in sv_lower or "uasf-bip110" in sv_lower or "uasf" in sv_lower: + return {"supported": True, "signaling": True, "state": "signaling", "source": "subversion"} + # Node is reachable via RPC but no BIP-110 marker found anywhere + return {"supported": False, "signaling": False, "state": "unsupported", "source": "subversion"} + + # ── 3. Node unreachable / RPC not ready ─────────────────────────── + return _unknown + + def _get_bitcoind_version() -> str | None: """Run ``bitcoind --version`` and return the raw version string, or None on error. @@ -2481,6 +2597,19 @@ async def api_bitcoin_version(): } +@app.get("/api/bitcoin/bip110") +async def api_bitcoin_bip110(): + """Return live BIP-110 deployment/signaling status from bitcoin-cli. + + Always returns HTTP 200. When bitcoind is unreachable or the node is mid-IBD + the response will contain ``state = "unknown"`` so the UI can render a neutral + badge rather than an error toast. + """ + loop = asyncio.get_event_loop() + status = await loop.run_in_executor(None, _get_bip110_status) + return status + + @app.get("/api/services") async def api_services(): cfg = load_config() @@ -2661,6 +2790,8 @@ async def api_services(): btc_ver = _format_bitcoin_version(raw_ver, icon=icon) service_data["bitcoin_version"] = btc_ver # backwards compat service_data["version"] = btc_ver + if icon == "bip110": + service_data["bip110"] = await loop.run_in_executor(None, _get_bip110_status) return service_data results = await asyncio.gather(*[get_status(s) for s in services]) @@ -2945,6 +3076,8 @@ async def api_service_detail(unit: str, icon: str | None = None): btc_ver = _format_bitcoin_version(raw_ver, icon=icon) service_detail["bitcoin_version"] = btc_ver # backwards compat service_detail["version"] = btc_ver + if icon == "bip110": + service_detail["bip110"] = await loop.run_in_executor(None, _get_bip110_status) return service_detail diff --git a/app/sovran_systemsos_web/static/css/tiles.css b/app/sovran_systemsos_web/static/css/tiles.css index 32f2278..e8aba2c 100644 --- a/app/sovran_systemsos_web/static/css/tiles.css +++ b/app/sovran_systemsos_web/static/css/tiles.css @@ -155,6 +155,57 @@ white-space: nowrap; } +/* ── BIP-110 status badge (tile + detail modal) ───────────────────── */ + +.tile-bip110-badge { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 0.64rem; + font-weight: 600; + border-radius: 4px; + padding: 2px 6px; + margin-top: 4px; + white-space: nowrap; + letter-spacing: 0.02em; +} + +.tile-bip110-badge--active { + background: rgba(109, 191, 139, 0.18); + color: var(--green); + border: 1px solid rgba(109, 191, 139, 0.3); +} + +.tile-bip110-badge--locked_in { + background: rgba(94, 173, 138, 0.15); + color: var(--accent-color); + border: 1px solid rgba(94, 173, 138, 0.3); +} + +.tile-bip110-badge--signaling { + background: rgba(94, 173, 138, 0.12); + color: var(--accent-color); + border: 1px solid rgba(94, 173, 138, 0.2); +} + +.tile-bip110-badge--not_signaling { + background: rgba(229, 165, 10, 0.12); + color: var(--yellow); + border: 1px solid rgba(229, 165, 10, 0.25); +} + +.tile-bip110-badge--unsupported { + background: rgba(94, 122, 106, 0.12); + color: var(--grey); + border: 1px solid rgba(94, 122, 106, 0.2); +} + +.tile-bip110-badge--unknown { + background: transparent; + color: var(--text-dim); + border: 1px solid var(--border-color); +} + /* ── Service detail modal sections ───────────────────────────────── */ .svc-detail-section { diff --git a/app/sovran_systemsos_web/static/js/service-detail.js b/app/sovran_systemsos_web/static/js/service-detail.js index 29e1531..87918bf 100644 --- a/app/sovran_systemsos_web/static/js/service-detail.js +++ b/app/sovran_systemsos_web/static/js/service-detail.js @@ -107,6 +107,52 @@ async function openServiceDetailModal(unit, name, icon) { '' + ''; + // Section B2: BIP-110 live status (bip110 tile only) + if (icon === 'bip110' && data.bip110) { + var bip110 = data.bip110; + var bip110State = bip110.state || 'unknown'; + var bip110BadgeCls, bip110Label, bip110Tooltip; + switch (bip110State) { + case 'active': + bip110BadgeCls = 'tile-bip110-badge--active'; + bip110Label = 'BIP\u2011110: Active \u2713'; + bip110Tooltip = 'BIP-110 is active on this node'; + break; + case 'locked_in': + bip110BadgeCls = 'tile-bip110-badge--locked_in'; + bip110Label = 'BIP\u2011110: Locked In'; + bip110Tooltip = 'BIP-110 is locked in and will activate shortly'; + break; + case 'signaling': + bip110BadgeCls = 'tile-bip110-badge--signaling'; + bip110Label = 'BIP\u2011110: Signaling'; + bip110Tooltip = 'Node is signaling readiness for BIP-110'; + break; + case 'not_signaling': + bip110BadgeCls = 'tile-bip110-badge--not_signaling'; + bip110Label = 'BIP\u2011110: Not Signaling'; + bip110Tooltip = 'Node supports BIP-110 but is not signaling this period'; + break; + case 'unsupported': + bip110BadgeCls = 'tile-bip110-badge--unsupported'; + bip110Label = 'BIP\u2011110: Not Supported'; + bip110Tooltip = 'This node build does not include BIP-110'; + break; + default: + bip110BadgeCls = 'tile-bip110-badge--unknown'; + bip110Label = 'BIP\u2011110: \u2014'; + bip110Tooltip = 'Status unavailable (node syncing or RPC not ready)'; + } + var bip110Source = bip110.source ? ' (source: ' + escHtml(bip110.source) + ')' : ''; + html += '
' + + '
BIP-110 Deployment Status
' + + '
' + + '' + escHtml(bip110Label) + '' + + bip110Source + + '
' + + '
'; + } + // Section C: Domain diagnostics (domain services) if (data.needs_domain) { var steps = data.domain_check_steps || []; diff --git a/app/sovran_systemsos_web/static/js/tiles.js b/app/sovran_systemsos_web/static/js/tiles.js index d1bac29..7d8b974 100644 --- a/app/sovran_systemsos_web/static/js/tiles.js +++ b/app/sovran_systemsos_web/static/js/tiles.js @@ -4,6 +4,46 @@ // Keyed by tileId: { progress: float, timestamp: ms } var _btcSyncPrev = {}; +// ── BIP-110 badge helper ────────────────────────────────────────── + +function _renderBip110Badge(bip110) { + if (!bip110) return ''; + var state = bip110.state || 'unknown'; + var label, cls, title; + switch (state) { + case 'active': + label = 'BIP\u2011110: Active \u2713'; + cls = 'tile-bip110-badge--active'; + title = 'BIP-110 is active on this node'; + break; + case 'locked_in': + label = 'BIP\u2011110: Locked In'; + cls = 'tile-bip110-badge--locked_in'; + title = 'BIP-110 is locked in and will activate shortly'; + break; + case 'signaling': + label = 'BIP\u2011110: Signaling'; + cls = 'tile-bip110-badge--signaling'; + title = 'Node is signaling readiness for BIP-110'; + break; + case 'not_signaling': + label = 'BIP\u2011110: Not Signaling'; + cls = 'tile-bip110-badge--not_signaling'; + title = 'Node supports BIP-110 but is not signaling this period'; + break; + case 'unsupported': + label = 'BIP\u2011110: Not Supported'; + cls = 'tile-bip110-badge--unsupported'; + title = 'This node build does not include BIP-110'; + break; + default: + label = 'BIP\u2011110: \u2014'; + cls = 'tile-bip110-badge--unknown'; + title = 'Status unavailable (node syncing or RPC not ready)'; + } + return '
' + escHtml(label) + '
'; +} + // ── Render: initial build ───────────────────────────────────────── function buildTiles(services, categoryLabels) { @@ -165,7 +205,8 @@ function buildTile(svc) { var ver = svc.version || svc.bitcoin_version || ''; var versionLabel = ver ? '
' + escHtml(ver) + '
' : ''; - tile.innerHTML = '' + escHtml(svc.name) + '
' + escHtml(svc.name) + '
' + versionLabel + '
' + st + '
'; + var bip110Badge = (svc.icon === 'bip110') ? _renderBip110Badge(svc.bip110) : ''; + tile.innerHTML = '' + escHtml(svc.name) + '
' + escHtml(svc.name) + '
' + versionLabel + bip110Badge + '
' + st + '
'; tile.style.cursor = "pointer"; tile.addEventListener("click", function() { @@ -265,6 +306,31 @@ function updateTiles(services) { } } } + // Update BIP-110 badge for bip110 tiles + if (svc.icon === 'bip110') { + var badgeHtml = _renderBip110Badge(svc.bip110); + var badgeEl = tile.querySelector(".tile-bip110-badge"); + if (badgeEl) { + // Replace existing badge in-place + var tmp = document.createElement("div"); + tmp.innerHTML = badgeHtml; + var newBadge = tmp.firstElementChild; + if (newBadge) { + badgeEl.replaceWith(newBadge); + } else { + badgeEl.remove(); + } + } else if (badgeHtml) { + // Insert badge after version label (or after tile-name if no version) + var anchorEl = tile.querySelector(".tile-version") || tile.querySelector(".tile-name"); + if (anchorEl) { + var tmpDiv = document.createElement("div"); + tmpDiv.innerHTML = badgeHtml; + var newBadgeEl = tmpDiv.firstElementChild; + if (newBadgeEl) anchorEl.insertAdjacentElement("afterend", newBadgeEl); + } + } + } } } }