Compare commits
5 Commits
67f4cdc99e
...
dcad276c59
| Author | SHA1 | Date | |
|---|---|---|---|
| dcad276c59 | |||
| 06988d0ff0 | |||
| 69b84153b4 | |||
| df08a7c413 | |||
| 602464189f |
@@ -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,121 @@ 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". 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-bip110"
|
||||
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 that contains "bip110" (case-insensitive).
|
||||
# Deliberately not matching bare "110" to avoid false positives on
|
||||
# unrelated deployments whose names happen to include that digit sequence.
|
||||
if "bip110" not 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 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:
|
||||
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 +2599,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 +2792,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 +3078,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
|
||||
|
||||
|
||||
|
||||
@@ -155,6 +155,69 @@
|
||||
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);
|
||||
}
|
||||
|
||||
.bip110-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bip110-source-label {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Service detail modal sections ───────────────────────────────── */
|
||||
|
||||
.svc-detail-section {
|
||||
|
||||
@@ -60,3 +60,17 @@ async function apiFetch(path, options) {
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
// ── BIP-110 badge state config ────────────────────────────────────
|
||||
// Shared lookup used by tiles.js and service-detail.js.
|
||||
// Keys match the "state" values returned by /api/bitcoin/bip110.
|
||||
|
||||
var BIP110_BADGE_CONFIG = {
|
||||
active: { cls: 'tile-bip110-badge--active', label: 'BIP\u2011110: Active \u2713', title: 'BIP-110 is active on this node' },
|
||||
locked_in: { cls: 'tile-bip110-badge--locked_in', label: 'BIP\u2011110: Locked In', title: 'BIP-110 is locked in and will activate shortly' },
|
||||
signaling: { cls: 'tile-bip110-badge--signaling', label: 'BIP\u2011110: Signaling', title: 'Node is signaling readiness for BIP-110' },
|
||||
not_signaling: { cls: 'tile-bip110-badge--not_signaling',label: 'BIP\u2011110: Not Signaling', title: 'Node supports BIP-110 but is not signaling this period' },
|
||||
unsupported: { cls: 'tile-bip110-badge--unsupported', label: 'BIP\u2011110: Not Supported', title: 'This node build does not include BIP-110' },
|
||||
unknown: { cls: 'tile-bip110-badge--unknown', label: 'BIP\u2011110: \u2014', title: 'Status unavailable (node syncing or RPC not ready)' }
|
||||
};
|
||||
|
||||
@@ -107,6 +107,21 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Section B2: BIP-110 live status (bip110 tile only)
|
||||
if (icon === 'bip110' && data.bip110) {
|
||||
var bip110 = data.bip110;
|
||||
var bip110State = bip110.state || 'unknown';
|
||||
var bip110Cfg = BIP110_BADGE_CONFIG[bip110State] || BIP110_BADGE_CONFIG.unknown;
|
||||
var bip110Source = bip110.source ? ' <span class="bip110-source-label">(source: ' + escHtml(bip110.source) + ')</span>' : '';
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">BIP-110 Deployment Status</div>' +
|
||||
'<div class="bip110-status-row">' +
|
||||
'<span class="tile-bip110-badge ' + bip110Cfg.cls + '" title="' + escHtml(bip110Cfg.title) + '">' + escHtml(bip110Cfg.label) + '</span>' +
|
||||
bip110Source +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Section C: Domain diagnostics (domain services)
|
||||
if (data.needs_domain) {
|
||||
var steps = data.domain_check_steps || [];
|
||||
|
||||
@@ -4,6 +4,21 @@
|
||||
// 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 cfg = BIP110_BADGE_CONFIG[state] || BIP110_BADGE_CONFIG.unknown;
|
||||
return '<div class="tile-bip110-badge ' + cfg.cls + '" title="' + escHtml(cfg.title) + '">' + escHtml(cfg.label) + '</div>';
|
||||
}
|
||||
|
||||
function _firstElementFromHtml(html) {
|
||||
var tmp = document.createElement("div");
|
||||
tmp.innerHTML = html;
|
||||
return tmp.firstElementChild || null;
|
||||
}
|
||||
|
||||
// ── Render: initial build ─────────────────────────────────────────
|
||||
|
||||
function buildTiles(services, categoryLabels) {
|
||||
@@ -165,7 +180,8 @@ function buildTile(svc) {
|
||||
|
||||
var ver = svc.version || svc.bitcoin_version || '';
|
||||
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</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>' + versionLabel + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
||||
var bip110Badge = (svc.icon === 'bip110') ? _renderBip110Badge(svc.bip110) : '';
|
||||
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>' + versionLabel + bip110Badge + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
||||
|
||||
tile.style.cursor = "pointer";
|
||||
tile.addEventListener("click", function() {
|
||||
@@ -265,6 +281,23 @@ 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 newBadge = _firstElementFromHtml(badgeHtml);
|
||||
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 newBadgeEl = _firstElementFromHtml(badgeHtml);
|
||||
if (newBadgeEl) anchorEl.insertAdjacentElement("afterend", newBadgeEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user