Compare commits
5 Commits
| 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)
|
# Cache for ``bitcoind --version`` output (available even before RPC is ready)
|
||||||
_btcd_version_cache: tuple[float, str | None] = (0.0, None)
|
_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) ─────────
|
# ── Generic service version detection (NixOS store path) ─────────
|
||||||
|
|
||||||
@@ -2367,6 +2370,121 @@ def _get_bitcoin_version_info() -> dict | None:
|
|||||||
return 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:
|
def _get_bitcoind_version() -> str | None:
|
||||||
"""Run ``bitcoind --version`` and return the raw version string, or None on error.
|
"""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")
|
@app.get("/api/services")
|
||||||
async def api_services():
|
async def api_services():
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
@@ -2661,6 +2792,8 @@ async def api_services():
|
|||||||
btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
|
btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
|
||||||
service_data["bitcoin_version"] = btc_ver # backwards compat
|
service_data["bitcoin_version"] = btc_ver # backwards compat
|
||||||
service_data["version"] = btc_ver
|
service_data["version"] = btc_ver
|
||||||
|
if icon == "bip110":
|
||||||
|
service_data["bip110"] = await loop.run_in_executor(None, _get_bip110_status)
|
||||||
return service_data
|
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])
|
||||||
@@ -2945,6 +3078,8 @@ async def api_service_detail(unit: str, icon: str | None = None):
|
|||||||
btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
|
btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
|
||||||
service_detail["bitcoin_version"] = btc_ver # backwards compat
|
service_detail["bitcoin_version"] = btc_ver # backwards compat
|
||||||
service_detail["version"] = btc_ver
|
service_detail["version"] = btc_ver
|
||||||
|
if icon == "bip110":
|
||||||
|
service_detail["bip110"] = await loop.run_in_executor(None, _get_bip110_status)
|
||||||
return service_detail
|
return service_detail
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,69 @@
|
|||||||
white-space: nowrap;
|
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 ───────────────────────────────── */
|
/* ── Service detail modal sections ───────────────────────────────── */
|
||||||
|
|
||||||
.svc-detail-section {
|
.svc-detail-section {
|
||||||
|
|||||||
@@ -60,3 +60,17 @@ async function apiFetch(path, options) {
|
|||||||
}
|
}
|
||||||
return res.json();
|
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>' +
|
||||||
'</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)
|
// Section C: Domain diagnostics (domain services)
|
||||||
if (data.needs_domain) {
|
if (data.needs_domain) {
|
||||||
var steps = data.domain_check_steps || [];
|
var steps = data.domain_check_steps || [];
|
||||||
|
|||||||
@@ -4,6 +4,21 @@
|
|||||||
// Keyed by tileId: { progress: float, timestamp: ms }
|
// Keyed by tileId: { progress: float, timestamp: ms }
|
||||||
var _btcSyncPrev = {};
|
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 ─────────────────────────────────────────
|
// ── Render: initial build ─────────────────────────────────────────
|
||||||
|
|
||||||
function buildTiles(services, categoryLabels) {
|
function buildTiles(services, categoryLabels) {
|
||||||
@@ -165,7 +180,8 @@ function buildTile(svc) {
|
|||||||
|
|
||||||
var ver = svc.version || svc.bitcoin_version || '';
|
var ver = svc.version || svc.bitcoin_version || '';
|
||||||
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</div>' : '';
|
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.style.cursor = "pointer";
|
||||||
tile.addEventListener("click", function() {
|
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