diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py
index 10814682c..6619e948b 100755
--- a/bittensor_cli/cli.py
+++ b/bittensor_cli/cli.py
@@ -32,7 +32,7 @@
from bittensor_cli.src.bittensor import utils
from bittensor_cli.src.bittensor.balances import Balance
from async_substrate_interface.errors import SubstrateRequestException
-from bittensor_cli.src.commands import sudo, wallets
+from bittensor_cli.src.commands import sudo, wallets, view
from bittensor_cli.src.commands import weights as weights_cmds
from bittensor_cli.src.commands.subnets import price, subnets
from bittensor_cli.src.commands.stake import (
@@ -193,8 +193,7 @@ class Options:
)
wait_for_finalization = typer.Option(
True,
- help="If `True`, waits until the transaction is finalized "
- "on the blockchain.",
+ help="If `True`, waits until the transaction is finalized on the blockchain.",
)
prompt = typer.Option(
True,
@@ -513,6 +512,7 @@ class CLIManager:
subnets_app: typer.Typer
weights_app: typer.Typer
utils_app = typer.Typer(epilog=_epilog)
+ view_app: typer.Typer
asyncio_runner = asyncio
def __init__(self):
@@ -562,6 +562,7 @@ def __init__(self):
self.sudo_app = typer.Typer(epilog=_epilog)
self.subnets_app = typer.Typer(epilog=_epilog)
self.weights_app = typer.Typer(epilog=_epilog)
+ self.view_app = typer.Typer(epilog=_epilog)
# config alias
self.app.add_typer(
@@ -639,6 +640,14 @@ def __init__(self):
self.utils_app, name="utils", no_args_is_help=True, hidden=True
)
+ # view app
+ self.app.add_typer(
+ self.view_app,
+ name="view",
+ short_help="HTML view commands",
+ no_args_is_help=True,
+ )
+
# config commands
self.config_app.command("set")(self.set_config)
self.config_app.command("get")(self.get_config)
@@ -806,6 +815,11 @@ def __init__(self):
"commit", rich_help_panel=HELP_PANELS["WEIGHTS"]["COMMIT_REVEAL"]
)(self.weights_commit)
+ # view commands
+ self.view_app.command(
+ "dashboard", rich_help_panel=HELP_PANELS["VIEW"]["DASHBOARD"]
+ )(self.view_dashboard)
+
# Sub command aliases
# Weights
self.wallet_app.command(
@@ -1336,7 +1350,7 @@ def get_config(self):
if value in Constants.networks:
value = value + f" ({Constants.network_map[value]})"
if key == "rate_tolerance":
- value = f"{value} ({value*100}%)" if value is not None else "None"
+ value = f"{value} ({value * 100}%)" if value is not None else "None"
elif key in deprecated_configs:
continue
@@ -1365,19 +1379,19 @@ def ask_rate_tolerance(
"""
if rate_tolerance is not None:
console.print(
- f"[dim][blue]Rate tolerance[/blue]: [bold cyan]{rate_tolerance} ({rate_tolerance*100}%)[/bold cyan]."
+ f"[dim][blue]Rate tolerance[/blue]: [bold cyan]{rate_tolerance} ({rate_tolerance * 100}%)[/bold cyan]."
)
return rate_tolerance
elif self.config.get("rate_tolerance") is not None:
config_slippage = self.config["rate_tolerance"]
console.print(
- f"[dim][blue]Rate tolerance[/blue]: [bold cyan]{config_slippage} ({config_slippage*100}%)[/bold cyan] (from config)."
+ f"[dim][blue]Rate tolerance[/blue]: [bold cyan]{config_slippage} ({config_slippage * 100}%)[/bold cyan] (from config)."
)
return config_slippage
else:
console.print(
"[dim][blue]Rate tolerance[/blue]: "
- + f"[bold cyan]{defaults.rate_tolerance} ({defaults.rate_tolerance*100}%)[/bold cyan] "
+ + f"[bold cyan]{defaults.rate_tolerance} ({defaults.rate_tolerance * 100}%)[/bold cyan] "
+ "by default. Set this using "
+ "[dark_sea_green3 italic]`btcli config set`[/dark_sea_green3 italic] "
+ "or "
@@ -5071,6 +5085,28 @@ def weights_commit(
)
)
+ def view_dashboard(
+ self,
+ network: Optional[list[str]] = Options.network,
+ wallet_name: str = Options.wallet_name,
+ wallet_path: str = Options.wallet_path,
+ wallet_hotkey: str = Options.wallet_hotkey,
+ quiet: bool = Options.quiet,
+ verbose: bool = Options.verbose,
+ ):
+ """
+ Display html dashboard with subnets list, stake, and neuron information.
+ """
+ self.verbosity_handler(quiet, verbose)
+ if is_linux():
+ print_linux_dependency_message()
+ wallet = self.wallet_ask(
+ wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME, WO.PATH]
+ )
+ return self._run_command(
+ view.display_network_dashboard(wallet, self.initialize_chain(network))
+ )
+
@staticmethod
@utils_app.command("convert")
def convert(
diff --git a/bittensor_cli/src/__init__.py b/bittensor_cli/src/__init__.py
index e01563096..d990aada9 100644
--- a/bittensor_cli/src/__init__.py
+++ b/bittensor_cli/src/__init__.py
@@ -713,6 +713,9 @@ class WalletValidationTypes(Enum):
"IDENTITY": "Subnet Identity Management",
},
"WEIGHTS": {"COMMIT_REVEAL": "Commit / Reveal"},
+ "VIEW": {
+ "DASHBOARD": "Network Dashboard",
+ },
}
COLOR_PALETTE = {
diff --git a/bittensor_cli/src/bittensor/balances.py b/bittensor_cli/src/bittensor/balances.py
index b83001634..34711f46b 100644
--- a/bittensor_cli/src/bittensor/balances.py
+++ b/bittensor_cli/src/bittensor/balances.py
@@ -297,17 +297,16 @@ def set_unit(self, netuid: int):
return self
-def fixed_to_float(fixed: dict) -> float:
- # Currently this is stored as a U64F64
+def fixed_to_float(fixed, frac_bits: int = 64, total_bits: int = 128) -> float:
+ # By default, this is a U64F64
# which is 64 bits of integer and 64 bits of fractional
- # uint_bits = 64
- frac_bits = 64
data: int = fixed["bits"]
- # Shift bits to extract integer part (assuming 64 bits for integer part)
- integer_part = data >> frac_bits
+ # Logical and to get the fractional part; remaining is the integer part
fractional_part = data & (2**frac_bits - 1)
+ # Shift to get the integer part from the remaining bits
+ integer_part = data >> (total_bits - frac_bits)
frac_float = fractional_part / (2**frac_bits)
diff --git a/bittensor_cli/src/bittensor/chain_data.py b/bittensor_cli/src/bittensor/chain_data.py
index 9d71c675e..628821aea 100644
--- a/bittensor_cli/src/bittensor/chain_data.py
+++ b/bittensor_cli/src/bittensor/chain_data.py
@@ -6,11 +6,12 @@
import netaddr
from scalecodec.utils.ss58 import ss58_encode
-from bittensor_cli.src.bittensor.balances import Balance
+from bittensor_cli.src.bittensor.balances import Balance, fixed_to_float
from bittensor_cli.src.bittensor.networking import int_to_ip
from bittensor_cli.src.bittensor.utils import (
SS58_FORMAT,
- u16_normalized_float,
+ u16_normalized_float as u16tf,
+ u64_normalized_float as u64tf,
decode_account_id,
)
@@ -57,6 +58,31 @@ def process_stake_data(stake_data, netuid):
return decoded_stake_data
+def _tbwu(val: int, netuid: Optional[int] = 0) -> Balance:
+ """Returns a Balance object from a value and unit."""
+ return Balance.from_rao(val).set_unit(netuid)
+
+
+def _chr_str(codes: tuple[int]) -> str:
+ """Converts a tuple of integer Unicode code points into a string."""
+ return "".join(map(chr, codes))
+
+
+def process_nested(data: Union[tuple, dict], chr_transform):
+ """Processes nested data structures by applying a transformation function to their elements."""
+ if isinstance(data, (list, tuple)):
+ if len(data) > 0 and isinstance(data[0], dict):
+ return [
+ {k: chr_transform(v) for k, v in item.items()}
+ if item is not None
+ else None
+ for item in data
+ ]
+ return {}
+ elif isinstance(data, dict):
+ return {k: chr_transform(v) for k, v in data.items()}
+
+
@dataclass
class AxonInfo:
version: int
@@ -312,13 +338,13 @@ def _fix_decoded(cls, decoded: Any) -> "NeuronInfo":
stake=total_stake,
stake_dict=stake_dict,
total_stake=total_stake,
- rank=u16_normalized_float(decoded.get("rank")),
+ rank=u16tf(decoded.get("rank")),
emission=decoded.get("emission") / 1e9,
- incentive=u16_normalized_float(decoded.get("incentive")),
- consensus=u16_normalized_float(decoded.get("consensus")),
- trust=u16_normalized_float(decoded.get("trust")),
- validator_trust=u16_normalized_float(decoded.get("validator_trust")),
- dividends=u16_normalized_float(decoded.get("dividends")),
+ incentive=u16tf(decoded.get("incentive")),
+ consensus=u16tf(decoded.get("consensus")),
+ trust=u16tf(decoded.get("trust")),
+ validator_trust=u16tf(decoded.get("validator_trust")),
+ dividends=u16tf(decoded.get("dividends")),
last_update=decoded.get("last_update"),
validator_permit=decoded.get("validator_permit"),
weights=[[e[0], e[1]] for e in decoded.get("weights")],
@@ -426,22 +452,22 @@ def _fix_decoded(cls, decoded: Union[dict, "NeuronInfoLite"]) -> "NeuronInfoLite
coldkey=coldkey,
),
coldkey=coldkey,
- consensus=u16_normalized_float(consensus),
- dividends=u16_normalized_float(dividends),
+ consensus=u16tf(consensus),
+ dividends=u16tf(dividends),
emission=emission / 1e9,
hotkey=hotkey,
- incentive=u16_normalized_float(incentive),
+ incentive=u16tf(incentive),
last_update=last_update,
netuid=netuid,
pruning_score=pruning_score,
- rank=u16_normalized_float(rank),
+ rank=u16tf(rank),
stake_dict=stake_dict,
stake=stake,
total_stake=stake,
- trust=u16_normalized_float(trust),
+ trust=u16tf(trust),
uid=uid,
validator_permit=validator_permit,
- validator_trust=u16_normalized_float(validator_trust),
+ validator_trust=u16tf(validator_trust),
)
return neuron
@@ -492,7 +518,7 @@ def _fix_decoded(cls, decoded: "DelegateInfo") -> "DelegateInfo":
total_stake=total_stake,
nominators=nominators,
owner_ss58=owner,
- take=u16_normalized_float(decoded.get("take")),
+ take=u16tf(decoded.get("take")),
validator_permits=decoded.get("validator_permits"),
registrations=decoded.get("registrations"),
return_per_1000=Balance.from_rao(decoded.get("return_per_1000")),
@@ -528,7 +554,7 @@ def _fix_decoded(cls, decoded: Any) -> "DelegateInfoLite":
if decoded_take == 65535:
fixed_take = None
else:
- fixed_take = u16_normalized_float(decoded_take)
+ fixed_take = u16tf(decoded_take)
return cls(
hotkey_ss58=ss58_encode(decoded.get("delegate_ss58"), SS58_FORMAT),
@@ -581,7 +607,7 @@ def _fix_decoded(cls, decoded: "SubnetInfo") -> "SubnetInfo":
tempo=decoded.get("tempo"),
modality=decoded.get("network_modality"),
connection_requirements={
- str(int(netuid)): u16_normalized_float(int(req))
+ str(int(netuid)): u16tf(int(req))
for (netuid, req) in decoded.get("network_connect")
},
emission_value=decoded.get("emission_value"),
@@ -844,19 +870,17 @@ def _fix_decoded(cls, decoded: Any) -> "SubnetState":
coldkeys=[decode_account_id(val) for val in decoded.get("coldkeys")],
active=decoded.get("active"),
validator_permit=decoded.get("validator_permit"),
- pruning_score=[
- u16_normalized_float(val) for val in decoded.get("pruning_score")
- ],
+ pruning_score=[u16tf(val) for val in decoded.get("pruning_score")],
last_update=decoded.get("last_update"),
emission=[
Balance.from_rao(val).set_unit(netuid)
for val in decoded.get("emission")
],
- dividends=[u16_normalized_float(val) for val in decoded.get("dividends")],
- incentives=[u16_normalized_float(val) for val in decoded.get("incentives")],
- consensus=[u16_normalized_float(val) for val in decoded.get("consensus")],
- trust=[u16_normalized_float(val) for val in decoded.get("trust")],
- rank=[u16_normalized_float(val) for val in decoded.get("rank")],
+ dividends=[u16tf(val) for val in decoded.get("dividends")],
+ incentives=[u16tf(val) for val in decoded.get("incentives")],
+ consensus=[u16tf(val) for val in decoded.get("consensus")],
+ trust=[u16tf(val) for val in decoded.get("trust")],
+ rank=[u16tf(val) for val in decoded.get("rank")],
block_at_registration=decoded.get("block_at_registration"),
alpha_stake=[
Balance.from_rao(val).set_unit(netuid)
@@ -871,3 +895,241 @@ def _fix_decoded(cls, decoded: Any) -> "SubnetState":
],
emission_history=decoded.get("emission_history"),
)
+
+
+@dataclass
+class ChainIdentity(InfoBase):
+ """Dataclass for chain identity information."""
+
+ name: str
+ url: str
+ github: str
+ image: str
+ discord: str
+ description: str
+ additional: str
+
+ @classmethod
+ def _from_dict(cls, decoded: dict) -> "ChainIdentity":
+ """Returns a ChainIdentity object from decoded chain data."""
+ return cls(
+ name=decoded["name"],
+ url=decoded["url"],
+ github=decoded["github_repo"],
+ image=decoded["image"],
+ discord=decoded["discord"],
+ description=decoded["description"],
+ additional=decoded["additional"],
+ )
+
+
+@dataclass
+class MetagraphInfo(InfoBase):
+ # Subnet index
+ netuid: int
+
+ # Name and symbol
+ name: str
+ symbol: str
+ identity: Optional[SubnetIdentity]
+ network_registered_at: int
+
+ # Keys for owner.
+ owner_hotkey: str # hotkey
+ owner_coldkey: str # coldkey
+
+ # Tempo terms.
+ block: int # block at call.
+ tempo: int # epoch tempo
+ last_step: int
+ blocks_since_last_step: int
+
+ # Subnet emission terms
+ subnet_emission: Balance # subnet emission via tao
+ alpha_in: Balance # amount of alpha in reserve
+ alpha_out: Balance # amount of alpha outstanding
+ tao_in: Balance # amount of tao injected per block
+ alpha_out_emission: Balance # amount injected in alpha reserves per block
+ alpha_in_emission: Balance # amount injected outstanding per block
+ tao_in_emission: Balance # amount of tao injected per block
+ pending_alpha_emission: Balance # pending alpha to be distributed
+ pending_root_emission: Balance # pending tao for root divs to be distributed
+ subnet_volume: Balance # volume of the subnet in TAO
+ moving_price: Balance # subnet moving price.
+
+ # Hparams for epoch
+ rho: int # subnet rho param
+ kappa: float # subnet kappa param
+
+ # Validator params
+ min_allowed_weights: float # min allowed weights per val
+ max_weights_limit: float # max allowed weights per val
+ weights_version: int # allowed weights version
+ weights_rate_limit: int # rate limit on weights.
+ activity_cutoff: int # validator weights cut off period in blocks
+ max_validators: int # max allowed validators.
+
+ # Registration
+ num_uids: int
+ max_uids: int
+ burn: Balance # current burn cost.
+ difficulty: float # current difficulty.
+ registration_allowed: bool # allows registrations.
+ pow_registration_allowed: bool # pow registration enabled.
+ immunity_period: int # subnet miner immunity period
+ min_difficulty: float # min pow difficulty
+ max_difficulty: float # max pow difficulty
+ min_burn: Balance # min tao burn
+ max_burn: Balance # max tao burn
+ adjustment_alpha: float # adjustment speed for registration params.
+ adjustment_interval: int # pow and burn adjustment interval
+ target_regs_per_interval: int # target registrations per interval
+ max_regs_per_block: int # max registrations per block.
+ serving_rate_limit: int # axon serving rate limit
+
+ # CR
+ commit_reveal_weights_enabled: bool # Is CR enabled.
+ commit_reveal_period: int # Commit reveal interval
+
+ # Bonds
+ liquid_alpha_enabled: bool # Bonds liquid enabled.
+ alpha_high: float # Alpha param high
+ alpha_low: float # Alpha param low
+ bonds_moving_avg: float # Bonds moving avg
+
+ # Metagraph info.
+ hotkeys: list[str] # hotkey per UID
+ coldkeys: list[str] # coldkey per UID
+ identities: list[Optional[ChainIdentity]] # coldkeys identities
+ axons: list[AxonInfo] # UID axons.
+ active: list[bool] # Active per UID
+ validator_permit: list[bool] # Val permit per UID
+ pruning_score: list[float] # Pruning per UID
+ last_update: list[int] # Last update per UID
+ emission: list[Balance] # Emission per UID
+ dividends: list[float] # Dividends per UID
+ incentives: list[float] # Mining incentives per UID
+ consensus: list[float] # Consensus per UID
+ trust: list[float] # Trust per UID
+ rank: list[float] # Rank per UID
+ block_at_registration: list[int] # Reg block per UID
+ alpha_stake: list[Balance] # Alpha staked per UID
+ tao_stake: list[Balance] # TAO staked per UID
+ total_stake: list[Balance] # Total stake per UID
+
+ # Dividend break down.
+ tao_dividends_per_hotkey: list[
+ tuple[str, Balance]
+ ] # List of dividend payouts in tao via root.
+ alpha_dividends_per_hotkey: list[
+ tuple[str, Balance]
+ ] # List of dividend payout in alpha via subnet.
+
+ @classmethod
+ def _fix_decoded(cls, decoded: dict) -> "MetagraphInfo":
+ """Returns a MetagraphInfo object from decoded chain data."""
+ # Subnet index
+ _netuid = decoded["netuid"]
+
+ # Name and symbol
+ decoded.update({"name": bytes(decoded.get("name")).decode()})
+ decoded.update({"symbol": bytes(decoded.get("symbol")).decode()})
+ for key in ["identities", "identity"]:
+ raw_data = decoded.get(key)
+ processed = process_nested(raw_data, _chr_str)
+ decoded.update({key: processed})
+
+ return cls(
+ # Subnet index
+ netuid=_netuid,
+ # Name and symbol
+ name=decoded["name"],
+ symbol=decoded["symbol"],
+ identity=decoded["identity"],
+ network_registered_at=decoded["network_registered_at"],
+ # Keys for owner.
+ owner_hotkey=decoded["owner_hotkey"],
+ owner_coldkey=decoded["owner_coldkey"],
+ # Tempo terms.
+ block=decoded["block"],
+ tempo=decoded["tempo"],
+ last_step=decoded["last_step"],
+ blocks_since_last_step=decoded["blocks_since_last_step"],
+ # Subnet emission terms
+ subnet_emission=_tbwu(decoded["subnet_emission"]),
+ alpha_in=_tbwu(decoded["alpha_in"], _netuid),
+ alpha_out=_tbwu(decoded["alpha_out"], _netuid),
+ tao_in=_tbwu(decoded["tao_in"]),
+ alpha_out_emission=_tbwu(decoded["alpha_out_emission"], _netuid),
+ alpha_in_emission=_tbwu(decoded["alpha_in_emission"], _netuid),
+ tao_in_emission=_tbwu(decoded["tao_in_emission"]),
+ pending_alpha_emission=_tbwu(decoded["pending_alpha_emission"], _netuid),
+ pending_root_emission=_tbwu(decoded["pending_root_emission"]),
+ subnet_volume=_tbwu(decoded["subnet_volume"], _netuid),
+ moving_price=Balance.from_tao(
+ fixed_to_float(decoded.get("moving_price"), 32)
+ ),
+ # Hparams for epoch
+ rho=decoded["rho"],
+ kappa=decoded["kappa"],
+ # Validator params
+ min_allowed_weights=u16tf(decoded["min_allowed_weights"]),
+ max_weights_limit=u16tf(decoded["max_weights_limit"]),
+ weights_version=decoded["weights_version"],
+ weights_rate_limit=decoded["weights_rate_limit"],
+ activity_cutoff=decoded["activity_cutoff"],
+ max_validators=decoded["max_validators"],
+ # Registration
+ num_uids=decoded["num_uids"],
+ max_uids=decoded["max_uids"],
+ burn=_tbwu(decoded["burn"]),
+ difficulty=u64tf(decoded["difficulty"]),
+ registration_allowed=decoded["registration_allowed"],
+ pow_registration_allowed=decoded["pow_registration_allowed"],
+ immunity_period=decoded["immunity_period"],
+ min_difficulty=u64tf(decoded["min_difficulty"]),
+ max_difficulty=u64tf(decoded["max_difficulty"]),
+ min_burn=_tbwu(decoded["min_burn"]),
+ max_burn=_tbwu(decoded["max_burn"]),
+ adjustment_alpha=u64tf(decoded["adjustment_alpha"]),
+ adjustment_interval=decoded["adjustment_interval"],
+ target_regs_per_interval=decoded["target_regs_per_interval"],
+ max_regs_per_block=decoded["max_regs_per_block"],
+ serving_rate_limit=decoded["serving_rate_limit"],
+ # CR
+ commit_reveal_weights_enabled=decoded["commit_reveal_weights_enabled"],
+ commit_reveal_period=decoded["commit_reveal_period"],
+ # Bonds
+ liquid_alpha_enabled=decoded["liquid_alpha_enabled"],
+ alpha_high=u16tf(decoded["alpha_high"]),
+ alpha_low=u16tf(decoded["alpha_low"]),
+ bonds_moving_avg=u64tf(decoded["bonds_moving_avg"]),
+ # Metagraph info.
+ hotkeys=[decode_account_id(ck) for ck in decoded.get("hotkeys", [])],
+ coldkeys=[decode_account_id(hk) for hk in decoded.get("coldkeys", [])],
+ identities=decoded["identities"],
+ axons=decoded.get("axons", []),
+ active=decoded["active"],
+ validator_permit=decoded["validator_permit"],
+ pruning_score=[u16tf(ps) for ps in decoded.get("pruning_score", [])],
+ last_update=decoded["last_update"],
+ emission=[_tbwu(em, _netuid) for em in decoded.get("emission", [])],
+ dividends=[u16tf(dv) for dv in decoded.get("dividends", [])],
+ incentives=[u16tf(ic) for ic in decoded.get("incentives", [])],
+ consensus=[u16tf(cs) for cs in decoded.get("consensus", [])],
+ trust=[u16tf(tr) for tr in decoded.get("trust", [])],
+ rank=[u16tf(rk) for rk in decoded.get("rank", [])],
+ block_at_registration=decoded["block_at_registration"],
+ alpha_stake=[_tbwu(ast, _netuid) for ast in decoded["alpha_stake"]],
+ tao_stake=[_tbwu(ts) for ts in decoded["tao_stake"]],
+ total_stake=[_tbwu(ts, _netuid) for ts in decoded["total_stake"]],
+ # Dividend break down
+ tao_dividends_per_hotkey=[
+ (decode_account_id(alpha[0]), _tbwu(alpha[1]))
+ for alpha in decoded["tao_dividends_per_hotkey"]
+ ],
+ alpha_dividends_per_hotkey=[
+ (decode_account_id(adphk[0]), _tbwu(adphk[1], _netuid))
+ for adphk in decoded["alpha_dividends_per_hotkey"]
+ ],
+ )
diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py
index c72d3d809..59dd2fcb7 100644
--- a/bittensor_cli/src/bittensor/subtensor_interface.py
+++ b/bittensor_cli/src/bittensor/subtensor_interface.py
@@ -20,6 +20,7 @@
decode_hex_identity,
DynamicInfo,
SubnetState,
+ MetagraphInfo,
)
from bittensor_cli.src import DelegatesDetails
from bittensor_cli.src.bittensor.balances import Balance, fixed_to_float
@@ -1252,6 +1253,38 @@ async def get_stake_for_coldkey_and_hotkey_on_netuid(
else:
return Balance.from_rao(_result).set_unit(int(netuid))
+ async def get_metagraph_info(
+ self, netuid: int, block_hash: Optional[str] = None
+ ) -> Optional[MetagraphInfo]:
+ hex_bytes_result = await self.query_runtime_api(
+ runtime_api="SubnetInfoRuntimeApi",
+ method="get_metagraph",
+ params=[netuid],
+ block_hash=block_hash,
+ )
+
+ if hex_bytes_result is None:
+ return None
+
+ try:
+ bytes_result = bytes.fromhex(hex_bytes_result[2:])
+ except ValueError:
+ bytes_result = bytes.fromhex(hex_bytes_result)
+
+ return MetagraphInfo.from_any(bytes_result)
+
+ async def get_all_metagraphs_info(
+ self, block_hash: Optional[str] = None
+ ) -> list[MetagraphInfo]:
+ hex_bytes_result = await self.query_runtime_api(
+ runtime_api="SubnetInfoRuntimeApi",
+ method="get_all_metagraphs",
+ params=[],
+ block_hash=block_hash,
+ )
+
+ return MetagraphInfo.list_from_any(hex_bytes_result)
+
async def multi_get_stake_for_coldkey_and_hotkey_on_netuid(
self,
hotkey_ss58s: list[str],
diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py
index a1a4f1ae4..04a4bafa7 100644
--- a/bittensor_cli/src/bittensor/utils.py
+++ b/bittensor_cli/src/bittensor/utils.py
@@ -1279,14 +1279,19 @@ def print_linux_dependency_message():
"""Prints the WebKit dependency message for Linux systems."""
console.print("[red]This command requires WebKit dependencies on Linux.[/red]")
console.print(
- "\nPlease install the required packages using one of the following commands based on your distribution:"
+ "\nPlease make sure these packages are installed on your system for PyWry to work:"
)
console.print("\nArch Linux / Manjaro:")
console.print("[green]sudo pacman -S webkit2gtk[/green]")
console.print("\nDebian / Ubuntu:")
console.print("[green]sudo apt install libwebkit2gtk-4.0-dev[/green]")
+ console.print("\nNote for Ubuntu 24.04+ & Debian 13+:")
+ console.print(
+ "You may need to add the following line to your `/etc/apt/sources.list` file:"
+ )
+ console.print("[green]http://gb.archive.ubuntu.com/ubuntu jammy main[/green]")
console.print("\nFedora / CentOS / AlmaLinux:")
- console.print("[green]sudo dnf install gtk3-devel webkit2gtk3-devel[/green]")
+ console.print("[green]sudo dnf install gtk3-devel webkit2gtk3-devel[/green]\n\n")
def is_linux():
diff --git a/bittensor_cli/src/commands/view.py b/bittensor_cli/src/commands/view.py
new file mode 100644
index 000000000..d6eb0263a
--- /dev/null
+++ b/bittensor_cli/src/commands/view.py
@@ -0,0 +1,2876 @@
+import asyncio
+import json
+import netaddr
+from dataclasses import asdict, is_dataclass
+from typing import Any, Dict, List
+from pywry import PyWry
+
+from bittensor_cli.src.bittensor.balances import Balance
+from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface
+from bittensor_cli.src.bittensor.utils import console
+from bittensor_wallet import Wallet
+
+root_symbol_html = f"{ord('τ'):X};"
+
+
+class Encoder(json.JSONEncoder):
+ """JSON encoder for serializing dataclasses and balances"""
+
+ def default(self, obj):
+ if is_dataclass(obj):
+ return asdict(obj)
+
+ elif isinstance(obj, Balance):
+ return obj.tao
+
+ return super().default(obj)
+
+
+async def display_network_dashboard(
+ wallet: Wallet,
+ subtensor: "SubtensorInterface",
+ prompt: bool = True,
+) -> bool:
+ """
+ Generate and display the HTML interface.
+ """
+ try:
+ with console.status("[dark_sea_green3]Fetching data...", spinner="earth"):
+ _subnet_data = await fetch_subnet_data(wallet, subtensor)
+ subnet_data = process_subnet_data(_subnet_data)
+ html_content = generate_full_page(subnet_data)
+
+ console.print(
+ "[dark_sea_green3]Opening dashboard in a window. Press Ctrl+C to close.[/dark_sea_green3]"
+ )
+ window = PyWry()
+ window.send_html(
+ html=html_content,
+ title="Bittensor View",
+ width=1200,
+ height=800,
+ )
+ window.start()
+ await asyncio.sleep(10)
+ try:
+ while True:
+ if _has_exited(window):
+ break
+ await asyncio.sleep(1)
+ except KeyboardInterrupt:
+ console.print("\n[yellow]Closing Bittensor View...[/yellow]")
+ finally:
+ if not _has_exited(window):
+ try:
+ window.close()
+ except Exception:
+ pass
+
+ except Exception as e:
+ print(f"Error: {e}")
+ return False
+
+
+def int_to_ip(int_val: int) -> str:
+ """Maps to an ip string"""
+ return str(netaddr.IPAddress(int_val))
+
+
+def get_hotkey_identity(
+ hotkey_ss58: str,
+ identities: dict,
+ old_identities: dict,
+ trucate_length: int = 4,
+) -> str:
+ """Fetch identity of hotkey from both sources"""
+ if hk_identity := identities["hotkeys"].get(hotkey_ss58):
+ return hk_identity.get("identity", {}).get("name", "") or hk_identity.get(
+ "display", "~"
+ )
+ elif old_identity := old_identities.get(hotkey_ss58):
+ return old_identity.display
+ else:
+ return f"{hotkey_ss58[:trucate_length]}...{hotkey_ss58[-trucate_length:]}"
+
+
+async def fetch_subnet_data(
+ wallet: Wallet, subtensor: "SubtensorInterface"
+) -> Dict[str, Any]:
+ """
+ Fetch subnet data from the network.
+ """
+ block_hash = await subtensor.substrate.get_chain_head()
+
+ (
+ balance,
+ stake_info,
+ metagraphs_info,
+ subnets_info,
+ ck_hk_identities,
+ old_identities,
+ block_number,
+ ) = await asyncio.gather(
+ subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash=block_hash),
+ subtensor.get_stake_for_coldkey(
+ wallet.coldkeypub.ss58_address, block_hash=block_hash
+ ),
+ subtensor.get_all_metagraphs_info(block_hash=block_hash),
+ subtensor.all_subnets(block_hash=block_hash),
+ subtensor.fetch_coldkey_hotkey_identities(block_hash=block_hash),
+ subtensor.get_delegate_identities(block_hash=block_hash),
+ subtensor.substrate.get_block_number(block_hash=block_hash),
+ )
+
+ return {
+ "balance": balance,
+ "stake_info": stake_info,
+ "metagraphs_info": metagraphs_info,
+ "subnets_info": subnets_info,
+ "ck_hk_identities": ck_hk_identities,
+ "old_identities": old_identities,
+ "wallet": wallet,
+ "block_number": block_number,
+ }
+
+
+def process_subnet_data(raw_data: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Process and prepare subnet data.
+ """
+ balance = raw_data["balance"]
+ stake_info = raw_data["stake_info"]
+ metagraphs_info = raw_data["metagraphs_info"]
+ subnets_info = raw_data["subnets_info"]
+ ck_hk_identities = raw_data["ck_hk_identities"]
+ old_identities = raw_data["old_identities"]
+ wallet = raw_data["wallet"]
+ block_number = raw_data["block_number"]
+
+ pool_info = {info.netuid: info for info in subnets_info}
+
+ total_ideal_stake_value = Balance.from_tao(0)
+ total_slippage_value = Balance.from_tao(0)
+
+ # Process stake
+ stake_dict: Dict[int, List[Dict[str, Any]]] = {}
+ for stake in stake_info:
+ if stake.stake.tao > 0:
+ slippage_value, _, slippage_percentage = pool_info[
+ stake.netuid
+ ].alpha_to_tao_with_slippage(stake.stake)
+ ideal_value = pool_info[stake.netuid].alpha_to_tao(stake.stake)
+ total_ideal_stake_value += ideal_value
+ total_slippage_value += slippage_value
+ stake_dict.setdefault(stake.netuid, []).append(
+ {
+ "hotkey": stake.hotkey_ss58,
+ "hotkey_identity": get_hotkey_identity(
+ stake.hotkey_ss58, ck_hk_identities, old_identities
+ ),
+ "amount": stake.stake.tao,
+ "emission": stake.emission.tao,
+ "is_registered": stake.is_registered,
+ "tao_emission": stake.tao_emission.tao,
+ "ideal_value": ideal_value.tao,
+ "slippage_value": slippage_value.tao,
+ "slippage_percentage": slippage_percentage,
+ }
+ )
+
+ # Process metagraph
+ subnets = []
+ for meta_info in metagraphs_info:
+ subnet_stakes = stake_dict.get(meta_info.netuid, [])
+ metagraph_info = {
+ "netuid": meta_info.netuid,
+ "name": meta_info.name,
+ "symbol": meta_info.symbol,
+ "alpha_in": 0 if meta_info.netuid == 0 else meta_info.alpha_in.tao,
+ "alpha_out": meta_info.alpha_out.tao,
+ "tao_in": 0 if meta_info.netuid == 0 else meta_info.tao_in.tao,
+ "tao_in_emission": meta_info.tao_in_emission.tao,
+ "num_uids": meta_info.num_uids,
+ "max_uids": meta_info.max_uids,
+ "moving_price": meta_info.moving_price.tao,
+ "blocks_since_last_step": "~"
+ if meta_info.netuid == 0
+ else meta_info.blocks_since_last_step,
+ "tempo": "~" if meta_info.netuid == 0 else meta_info.tempo,
+ "registration_allowed": meta_info.registration_allowed,
+ "commit_reveal_weights_enabled": meta_info.commit_reveal_weights_enabled,
+ "hotkeys": meta_info.hotkeys,
+ "coldkeys": meta_info.coldkeys,
+ "updated_identities": [],
+ "processed_axons": [],
+ "rank": meta_info.rank,
+ "trust": meta_info.trust,
+ "consensus": meta_info.consensus,
+ "incentives": meta_info.incentives,
+ "dividends": meta_info.dividends,
+ "active": meta_info.active,
+ "validator_permit": meta_info.validator_permit,
+ "pruning_score": meta_info.pruning_score,
+ "last_update": meta_info.last_update,
+ "block_at_registration": meta_info.block_at_registration,
+ }
+
+ # Process axon data and convert IPs
+ for axon in meta_info.axons:
+ if axon:
+ processed_axon = {
+ "ip": int_to_ip(axon["ip"]) if axon["ip"] else "N/A",
+ "port": axon["port"],
+ "ip_type": axon["ip_type"],
+ }
+ metagraph_info["processed_axons"].append(processed_axon)
+ else:
+ metagraph_info["processed_axons"].append(None)
+
+ # Add identities
+ for hotkey in meta_info.hotkeys:
+ identity = get_hotkey_identity(
+ hotkey, ck_hk_identities, old_identities, trucate_length=2
+ )
+ metagraph_info["updated_identities"].append(identity)
+
+ # Balance conversion
+ for field in [
+ "emission",
+ "alpha_stake",
+ "tao_stake",
+ "total_stake",
+ ]:
+ if hasattr(meta_info, field):
+ raw_data = getattr(meta_info, field)
+ if isinstance(raw_data, list):
+ metagraph_info[field] = [
+ x.tao if hasattr(x, "tao") else x for x in raw_data
+ ]
+ else:
+ metagraph_info[field] = raw_data
+
+ # Calculate price
+ price = (
+ 1
+ if metagraph_info["netuid"] == 0
+ else metagraph_info["tao_in"] / metagraph_info["alpha_in"]
+ if metagraph_info["alpha_in"] > 0
+ else 0
+ )
+
+ # Package it all up
+ symbol_html = f"{ord(meta_info.symbol):X};"
+ subnets.append(
+ {
+ "netuid": meta_info.netuid,
+ "name": meta_info.name,
+ "symbol": symbol_html,
+ "price": price,
+ "market_cap": float(
+ (metagraph_info["alpha_in"] + metagraph_info["alpha_out"]) * price
+ )
+ if price
+ else 0,
+ "emission": metagraph_info["tao_in_emission"],
+ "total_stake": metagraph_info["alpha_out"],
+ "your_stakes": subnet_stakes,
+ "metagraph_info": metagraph_info,
+ }
+ )
+ subnets.sort(key=lambda x: x["market_cap"], reverse=True)
+ return {
+ "wallet_info": {
+ "name": wallet.name,
+ "balance": balance.tao,
+ "coldkey": wallet.coldkeypub.ss58_address,
+ "total_ideal_stake_value": total_ideal_stake_value.tao,
+ "total_slippage_value": total_slippage_value.tao,
+ },
+ "subnets": subnets,
+ "block_number": block_number,
+ }
+
+
+def _has_exited(handler) -> bool:
+ """Check if PyWry process has cleanly exited with returncode 0."""
+ return (
+ hasattr(handler, "runner")
+ and handler.runner is not None
+ and handler.runner.returncode == 0
+ )
+
+
+def generate_full_page(data: Dict[str, Any]) -> str:
+ """
+ Generate full HTML content for the interface.
+ """
+ serializable_data = {
+ "wallet_info": data["wallet_info"],
+ "subnets": data["subnets"],
+ }
+ wallet_info_json = json.dumps(
+ serializable_data["wallet_info"], cls=Encoder
+ ).replace("'", "'")
+ subnets_json = json.dumps(serializable_data["subnets"], cls=Encoder).replace(
+ "'", "'"
+ )
+
+ return f"""
+
+
+
+ Bittensor CLI Interface
+
+
+
+
+
+
+
+
+
+
Btcli View
+ Beta
+
+
+
+
+
+
+ {generate_main_header(data["wallet_info"], data["block_number"])}
+ {generate_main_filters()}
+ {generate_subnets_table(data["subnets"])}
+
+
+
+
+ {generate_subnet_details_header()}
+ {generate_subnet_metrics()}
+ {generate_neuron_details()}
+
+
+
+
+
+ """
+
+
+def generate_subnet_details_header() -> str:
+ """
+ Generates the header section for the subnet details page,
+ including the back button, toggle controls, title, and network visualization.
+ """
+ return """
+
+ """
+
+
+def generate_subnet_metrics() -> str:
+ """
+ Generates the metrics section for the subnet details page,
+ including market metrics and the stakes table.
+ """
+ return """
+
+
+
+
+
+
+
+
+
+
+ Hotkey
+ Amount
+ Value
+ Value (w/ slippage)
+ Alpha emission
+ Tao emission
+ Registered
+ Actions
+
+
+
+
+
+
+
+
+ """
+
+
+def generate_neuron_details() -> str:
+ """
+ Generates the neuron detail container, which is hidden by default.
+ This section shows detailed information for a selected neuron.
+ """
+ return """
+
+
+
+
+
+
+
+ Hotkey:
+
+
+
+ Coldkey:
+
+
+
+
+
+
+
+ """
+
+
+def generate_main_header(wallet_info: Dict[str, Any], block_number: int) -> str:
+ truncated_coldkey = f"{wallet_info['coldkey'][:6]}...{wallet_info['coldkey'][-6:]}"
+
+ # Calculate slippage percentage
+ ideal_value = wallet_info["total_ideal_stake_value"]
+ slippage_value = wallet_info["total_slippage_value"]
+ slippage_percentage = (
+ ((ideal_value - slippage_value) / ideal_value * 100) if ideal_value > 0 else 0
+ )
+
+ return f"""
+
+ """
+
+
+def generate_main_filters() -> str:
+ return """
+
+
+ """
+
+
+def generate_subnets_table(subnets: List[Dict[str, Any]]) -> str:
+ rows = []
+ for subnet in subnets:
+ total_your_stake = sum(stake["amount"] for stake in subnet["your_stakes"])
+ stake_status = (
+ 'Staked '
+ if total_your_stake > 0
+ else 'Not Staked '
+ )
+ rows.append(f"""
+
+ {subnet["netuid"]} - {subnet["name"]}
+
+
+
+
+ {stake_status}
+
+ """)
+ return f"""
+
+
+
+
+ Subnet
+ Price
+ Market Cap
+ Your Stake
+ Emission
+ Status
+
+
+
+ {"".join(rows)}
+
+
+
+ """
+
+
+def generate_subnet_details_html() -> str:
+ return """
+
+
+
+
+
+ Price
+
+
+
+ Market Cap
+
+
+
+ Emission Rate
+
+
+
+ Your Total Stake
+
+
+
+
+
+
+ """
+
+
+def get_css_styles() -> str:
+ """Get CSS styles for the interface."""
+ return """
+ /* ===================== Base Styles & Typography ===================== */
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Noto+Sans:wght@400;500;600&display=swap');
+
+ body {
+ font-family: 'Inter', 'Noto Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Arial Unicode MS', sans-serif;
+ margin: 0;
+ padding: 24px;
+ background: #000000;
+ color: #ffffff;
+ }
+
+ input, button, select {
+ font-family: inherit;
+ font-feature-settings: normal;
+ }
+
+ /* ===================== Main Page Header ===================== */
+ .header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 24px;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 12px;
+ margin-bottom: 24px;
+ backdrop-filter: blur(10px);
+ }
+
+ .wallet-info {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ .wallet-name {
+ font-size: 1.1em;
+ font-weight: 500;
+ color: #FF9900;
+ }
+
+ .wallet-address-container {
+ position: relative;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .wallet-address {
+ font-size: 0.9em;
+ color: rgba(255, 255, 255, 0.5);
+ font-family: monospace;
+ transition: color 0.2s ease;
+ }
+
+ .wallet-address-container:hover .wallet-address {
+ color: rgba(255, 255, 255, 0.8);
+ }
+
+ .copy-indicator {
+ background: rgba(255, 153, 0, 0.1);
+ color: rgba(255, 153, 0, 0.8);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 0.7em;
+ transition: all 0.2s ease;
+ opacity: 0;
+ }
+
+ .wallet-address-container:hover .copy-indicator {
+ opacity: 1;
+ background: rgba(255, 153, 0, 0.2);
+ }
+
+ .wallet-address-container.copied .copy-indicator {
+ opacity: 1;
+ background: rgba(255, 153, 0, 0.3);
+ color: #FF9900;
+ }
+
+ .stake-metrics {
+ display: flex;
+ gap: 24px;
+ align-items: center;
+ }
+
+ .stake-metric {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 2px;
+ position: relative;
+ padding: 8px 16px;
+ border-radius: 8px;
+ transition: all 0.2s ease;
+ }
+
+ .stake-metric:hover {
+ background: rgba(255, 153, 0, 0.05);
+ }
+
+ .stake-metric .metric-label {
+ font-size: 0.8em;
+ color: rgba(255, 255, 255, 0.6);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+
+ .stake-metric .metric-value {
+ font-size: 1.1em;
+ font-weight: 500;
+ color: #FF9900;
+ font-feature-settings: "tnum";
+ font-variant-numeric: tabular-nums;
+ }
+
+ .slippage-value {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .slippage-detail {
+ font-size: 0.8em;
+ color: rgba(255, 255, 255, 0.5);
+ }
+
+ /* ===================== Main Page Filters ===================== */
+ .filters-section {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin: 24px 0;
+ gap: 16px;
+ }
+
+ .search-box input {
+ padding: 10px 16px;
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.03);
+ color: rgba(255, 255, 255, 0.7);
+ width: 240px;
+ font-size: 0.9em;
+ transition: all 0.2s ease;
+ }
+ .search-box input::placeholder {
+ color: rgba(255, 255, 255, 0.4);
+ }
+
+ .search-box input:focus {
+ outline: none;
+ border-color: rgba(255, 153, 0, 0.5);
+ background: rgba(255, 255, 255, 0.06);
+ color: rgba(255, 255, 255, 0.9);
+ }
+
+ .filter-toggles {
+ display: flex;
+ gap: 16px;
+ }
+
+ .filter-toggles label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 0.9em;
+ cursor: pointer;
+ user-select: none;
+ }
+
+ /* Checkbox styling for both main page and subnet page */
+ .filter-toggles input[type="checkbox"],
+ .toggle-label input[type="checkbox"] {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ width: 18px;
+ height: 18px;
+ border: 2px solid rgba(255, 153, 0, 0.3);
+ border-radius: 4px;
+ background: rgba(0, 0, 0, 0.2);
+ cursor: pointer;
+ position: relative;
+ transition: all 0.2s ease;
+ }
+
+ .filter-toggles input[type="checkbox"]:hover,
+ .toggle-label input[type="checkbox"]:hover {
+ border-color: #FF9900;
+ }
+
+ .filter-toggles input[type="checkbox"]:checked,
+ .toggle-label input[type="checkbox"]:checked {
+ background: #FF9900;
+ border-color: #FF9900;
+ }
+
+ .filter-toggles input[type="checkbox"]:checked::after,
+ .toggle-label input[type="checkbox"]:checked::after {
+ content: '';
+ position: absolute;
+ left: 5px;
+ top: 2px;
+ width: 4px;
+ height: 8px;
+ border: solid #000;
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+ }
+
+ .filter-toggles label:hover,
+ .toggle-label:hover {
+ color: rgba(255, 255, 255, 0.9);
+ }
+ .disabled-label {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ .add-stake-button {
+ padding: 10px 20px;
+ font-size: 0.8rem;
+ }
+ .export-csv-button {
+ padding: 10px 20px;
+ font-size: 0.8rem;
+ }
+ .button-group {
+ display: flex;
+ gap: 8px;
+ }
+
+ /* ===================== Main Page Subnet Table ===================== */
+ .subnets-table-container {
+ background: rgba(255, 255, 255, 0.02);
+ border-radius: 12px;
+ overflow: hidden;
+ }
+
+ .subnets-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.95em;
+ }
+
+ .subnets-table th {
+ background: rgba(255, 255, 255, 0.05);
+ font-weight: 500;
+ text-align: left;
+ padding: 16px;
+ color: rgba(255, 255, 255, 0.7);
+ }
+
+ .subnets-table td {
+ padding: 14px 16px;
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
+ }
+
+ .subnet-row {
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+ }
+
+ .subnet-row:hover {
+ background: rgba(255, 255, 255, 0.05);
+ }
+
+ .subnet-name {
+ color: #ffffff;
+ font-weight: 500;
+ font-size: 0.95em;
+ }
+
+ .price, .market-cap, .your-stake, .emission {
+ font-family: 'Inter', monospace;
+ font-size: 1.0em;
+ font-feature-settings: "tnum";
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0.01em;
+ white-space: nowrap;
+ }
+
+ .stake-status {
+ font-size: 0.85em;
+ padding: 4px 8px;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.05);
+ }
+
+ .stake-status.staked {
+ background: rgba(255, 153, 0, 0.1);
+ color: #FF9900;
+ }
+
+ .subnets-table th.sortable {
+ cursor: pointer;
+ position: relative;
+ padding-right: 20px;
+ }
+
+ .subnets-table th.sortable:hover {
+ color: #FF9900;
+ }
+
+ .subnets-table th[data-sort] {
+ color: #FF9900;
+ }
+
+ /* ===================== Subnet Tiles View ===================== */
+ .subnet-tiles-container {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 1rem;
+ padding: 1rem;
+ }
+
+ .subnet-tile {
+ width: clamp(75px, 6vw, 600px);
+ height: clamp(75px, 6vw, 600px);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ position: relative;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ overflow: hidden;
+ font-size: clamp(0.6rem, 1vw, 1.4rem);
+ }
+
+ .tile-netuid {
+ position: absolute;
+ top: 0.4em;
+ left: 0.4em;
+ font-size: 0.7em;
+ color: rgba(255, 255, 255, 0.6);
+ }
+
+ .tile-symbol {
+ font-size: 1.6em;
+ margin-bottom: 0.4em;
+ color: #FF9900;
+ }
+
+ .tile-name {
+ display: block;
+ width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: 1em;
+ text-align: center;
+ color: rgba(255, 255, 255, 0.9);
+ margin: 0 0.4em;
+ }
+
+ .tile-market-cap {
+ font-size: 0.9em;
+ color: rgba(255, 255, 255, 0.5);
+ margin-top: 2px;
+ }
+
+ .subnet-tile:hover {
+ transform: translateY(-2px);
+ box-shadow:
+ 0 0 12px rgba(255, 153, 0, 0.6),
+ 0 0 24px rgba(255, 153, 0, 0.3);
+ background: rgba(255, 255, 255, 0.08);
+ }
+
+ .subnet-tile.staked {
+ border: 1px solid rgba(255, 153, 0, 0.3);
+ }
+
+ .subnet-tile.staked::before {
+ content: '';
+ position: absolute;
+ top: 0.4em;
+ right: 0.4em;
+ width: 0.5em;
+ height: 0.5em;
+ border-radius: 50%;
+ background: #FF9900;
+ }
+
+ /* ===================== Subnet Detail Page Header ===================== */
+ .subnet-header {
+ padding: 16px;
+ border-radius: 12px;
+ margin-bottom: 0px;
+ }
+
+ .subnet-header h2 {
+ margin: 0;
+ font-size: 1.3em;
+ }
+
+ .subnet-price {
+ font-size: 1.3em;
+ color: #FF9900;
+ }
+
+ .subnet-title-row {
+ display: grid;
+ grid-template-columns: 300px 1fr 300px;
+ align-items: start;
+ margin: 0;
+ position: relative;
+ min-height: 60px;
+ }
+
+ .title-price {
+ grid-column: 1;
+ padding-top: 0;
+ margin-top: -10px;
+ }
+
+ .header-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ margin-bottom: 16px;
+ }
+
+ .toggle-group {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ align-items: flex-end;
+ }
+
+ .toggle-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 0.9em;
+ cursor: pointer;
+ user-select: none;
+ }
+
+ .back-button {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.8);
+ padding: 8px 16px;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 0.9em;
+ transition: all 0.2s ease;
+ margin-bottom: 16px;
+ }
+
+ .back-button:hover {
+ background: rgba(255, 255, 255, 0.1);
+ border-color: rgba(255, 255, 255, 0.2);
+ }
+
+ /* ===================== Network Visualization ===================== */
+ .network-visualization-container {
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+ top: -50px;
+ width: 700px;
+ height: 80px;
+ z-index: 1;
+ }
+
+ .network-visualization {
+ width: 700px;
+ height: 80px;
+ position: relative;
+ }
+
+ #network-canvas {
+ background: transparent;
+ position: relative;
+ z-index: 1;
+ }
+
+ /* Gradient behind visualization */
+ .network-visualization::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 100%;
+ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.95) 0%, rgba(0, 0, 0, 0.8) 100%);
+ z-index: 0;
+ pointer-events: none;
+ }
+
+ /* ===================== Subnet Detail Metrics ===================== */
+ .network-metrics {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 12px;
+ margin: 0;
+ margin-top: 16px;
+ }
+
+ /* Base card styles - applied to both network and metric cards */
+ .network-card, .metric-card {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ padding: 12px 16px;
+ min-height: 50px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 4px;
+ }
+
+ /* Separate styling for moving price value */
+ #network-moving-price {
+ color: #FF9900;
+ }
+
+ .metrics-section {
+ margin-top: 0px;
+ margin-bottom: 16px;
+ }
+
+ .metrics-group {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 12px;
+ margin: 0;
+ margin-top: 2px;
+ }
+
+ .market-metrics .metric-card {
+ background: rgba(255, 255, 255, 0.05);
+ min-height: 70px;
+ }
+
+ .metric-label {
+ font-size: 0.85em;
+ color: rgba(255, 255, 255, 0.7);
+ margin: 0;
+ }
+
+ .metric-value {
+ font-size: 1.2em;
+ line-height: 1.3;
+ margin: 0;
+ }
+
+ /* Add status colors */
+ .registration-status {
+ color: #2ECC71;
+ }
+
+ .registration-status.closed {
+ color: #ff4444; /* Red color for closed status */
+ }
+
+ .cr-status {
+ color: #2ECC71;
+ }
+
+ .cr-status.disabled {
+ color: #ff4444; /* Red color for disabled status */
+ }
+
+ /* ===================== Stakes Table ===================== */
+ .stakes-container {
+ margin-top: 24px;
+ padding: 0 24px;
+ }
+
+ .stakes-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+ }
+
+ .stakes-header h3 {
+ font-size: 1.2em;
+ color: #ffffff;
+ margin: 0;
+ }
+
+ .stakes-table-container {
+ background: rgba(255, 255, 255, 0.02);
+ border-radius: 12px;
+ overflow: hidden;
+ margin-bottom: 24px;
+ width: 100%;
+ }
+
+ .stakes-table {
+ width: 100%;
+ border-collapse: collapse;
+ }
+
+ .stakes-table th {
+ background: rgba(255, 255, 255, 0.05);
+ padding: 16px;
+ text-align: left;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.7);
+ }
+
+ .stakes-table td {
+ padding: 16px;
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
+ }
+
+ .stakes-table tr {
+ transition: background-color 0.2s ease;
+ }
+
+ .stakes-table tr:nth-child(even) {
+ background: rgba(255, 255, 255, 0.02);
+ }
+
+ .stakes-table tr:hover {
+ background: transparent;
+ }
+
+ .no-stakes-row td {
+ text-align: center;
+ padding: 32px;
+ color: rgba(255, 255, 255, 0.5);
+ }
+
+ /* Table styles consistency */
+ .stakes-table th, .network-table th {
+ background: rgba(255, 255, 255, 0.05);
+ padding: 16px;
+ text-align: left;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.7);
+ transition: color 0.2s ease;
+ }
+
+ /* Sortable columns */
+ .stakes-table th.sortable, .network-table th.sortable {
+ cursor: pointer;
+ }
+
+ /* Active sort column - only change color */
+ .stakes-table th.sortable[data-sort], .network-table th.sortable[data-sort] {
+ color: #FF9900;
+ }
+
+ /* Hover effects - only change color */
+ .stakes-table th.sortable:hover, .network-table th.sortable:hover {
+ color: #FF9900;
+ }
+
+ /* Remove hover background from table rows */
+ .stakes-table tr:hover {
+ background: transparent;
+ }
+
+ /* ===================== Network Table ===================== */
+ .network-table-container {
+ margin-top: 60px;
+ position: relative;
+ z-index: 2;
+ background: rgba(0, 0, 0, 0.8);
+ }
+
+ .network-table {
+ width: 100%;
+ border-collapse: collapse;
+ table-layout: fixed;
+ }
+
+ .network-table th {
+ background: rgba(255, 255, 255, 0.05);
+ padding: 16px;
+ text-align: left;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.7);
+ }
+
+ .network-table td {
+ padding: 16px;
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
+ }
+
+ .network-table tr {
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+ }
+
+ .network-table tr:hover {
+ background-color: rgba(255, 255, 255, 0.05);
+ }
+
+ .network-table tr:nth-child(even) {
+ background-color: rgba(255, 255, 255, 0.02);
+ }
+
+ .network-table tr:nth-child(even):hover {
+ background-color: rgba(255, 255, 255, 0.05);
+ }
+
+ .network-search-container {
+ display: flex;
+ align-items: center;
+ margin-bottom: 16px;
+ padding: 0 16px;
+ }
+
+ .network-search {
+ width: 100%;
+ padding: 12px 16px;
+ border: 1px solid rgba(255, 153, 0, 0.2);
+ border-radius: 8px;
+ background: rgba(0, 0, 0, 0.2);
+ color: #ffffff;
+ font-size: 0.95em;
+ transition: all 0.2s ease;
+ }
+
+ .network-search:focus {
+ outline: none;
+ border-color: rgba(255, 153, 0, 0.5);
+ background: rgba(0, 0, 0, 0.3);
+ caret-color: #FF9900;
+ }
+
+ .network-search::placeholder {
+ color: rgba(255, 255, 255, 0.3);
+ }
+
+ /* ===================== Cell Styles & Formatting ===================== */
+ .hotkey-cell {
+ max-width: 200px;
+ position: relative;
+ }
+
+ .hotkey-container {
+ position: relative;
+ display: inline-block;
+ max-width: 100%;
+ }
+
+ .hotkey-identity, .truncated-address {
+ color: rgba(255, 255, 255, 0.8);
+ display: inline-block;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .copy-button {
+ position: absolute;
+ top: -20px; /* Position above the text */
+ right: 0;
+ background: rgba(255, 153, 0, 0.1);
+ color: rgba(255, 255, 255, 0.6);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 0.7em;
+ cursor: pointer;
+ opacity: 0;
+ transition: all 0.2s ease;
+ transform: translateY(5px);
+ }
+
+ .hotkey-container:hover .copy-button {
+ opacity: 1;
+ transform: translateY(0);
+ }
+
+ .copy-button:hover {
+ background: rgba(255, 153, 0, 0.2);
+ color: #FF9900;
+ }
+
+ .address-cell {
+ max-width: 150px;
+ position: relative;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .address-container {
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+ position: relative;
+ }
+
+ .address-container:hover::after {
+ content: 'Click to copy';
+ position: absolute;
+ right: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ background: rgba(255, 153, 0, 0.1);
+ color: #FF9900;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 0.8em;
+ opacity: 0.8;
+ }
+
+ .truncated-address {
+ font-family: monospace;
+ color: rgba(255, 255, 255, 0.8);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .truncated-address:hover {
+ color: #FF9900;
+ }
+
+ .registered-yes {
+ color: #FF9900;
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ .registered-no {
+ color: #ff4444;
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ .manage-button {
+ background: rgba(255, 153, 0, 0.1);
+ border: 1px solid rgba(255, 153, 0, 0.2);
+ color: #FF9900;
+ padding: 6px 12px;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ }
+
+ .manage-button:hover {
+ background: rgba(255, 153, 0, 0.2);
+ transform: translateY(-1px);
+ }
+
+ .hotkey-identity {
+ display: inline-block;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: #FF9900;
+ }
+
+ .identity-cell {
+ max-width: 700px;
+ font-size: 0.90em;
+ letter-spacing: -0.2px;
+ color: #FF9900;
+ }
+
+ .per-day {
+ font-size: 0.75em;
+ opacity: 0.7;
+ margin-left: 4px;
+ }
+
+ /* ===================== Neuron Detail Panel ===================== */
+ #neuron-detail-container {
+ background: rgba(255, 255, 255, 0.02);
+ border-radius: 12px;
+ padding: 16px;
+ margin-top: 16px;
+ }
+
+ .neuron-detail-header {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin-bottom: 16px;
+ }
+
+ .neuron-detail-content {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+
+ .neuron-info-top {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .neuron-keys {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 0.9em;
+ color: rgba(255, 255, 255, 0.6);
+ font-size: 1em;
+ color: rgba(255, 255, 255, 0.7);
+ }
+
+ .neuron-cards-container {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .neuron-metrics-row {
+ display: grid;
+ grid-template-columns: repeat(6, 1fr);
+ gap: 12px;
+ margin: 0;
+ }
+
+ .neuron-metrics-row.last-row {
+ grid-template-columns: repeat(3, 1fr);
+ }
+
+ /* IP Info styling */
+ #neuron-ipinfo {
+ font-size: 0.85em;
+ line-height: 1.4;
+ white-space: nowrap;
+ }
+
+ #neuron-ipinfo .no-connection {
+ color: #ff4444;
+ font-weight: 500;
+ }
+
+ /* Adjust metric card for IP info to accommodate multiple lines */
+ .neuron-cards-container .metric-card:has(#neuron-ipinfo) {
+ min-height: 85px;
+ }
+
+ /* ===================== Subnet Page Color Overrides ===================== */
+ /* Subnet page specific style */
+ .subnet-page .metric-card-title,
+ .subnet-page .network-card-title {
+ color: rgba(255, 255, 255, 0.7);
+ }
+
+ .subnet-page .metric-card .metric-value,
+ .subnet-page .metric-value {
+ color: white;
+ }
+
+ /* Green values */
+ .subnet-page .validator-true,
+ .subnet-page .active-yes,
+ .subnet-page .registration-open,
+ .subnet-page .cr-enabled,
+ .subnet-page .ip-info {
+ color: #FF9900;
+ }
+
+ /* Red values */
+ .subnet-page .validator-false,
+ .subnet-page .active-no,
+ .subnet-page .registration-closed,
+ .subnet-page .cr-disabled,
+ .subnet-page .ip-na {
+ color: #ff4444;
+ }
+
+ /* Keep symbols green in subnet page */
+ .subnet-page .symbol {
+ color: #FF9900;
+ }
+
+ /* ===================== Responsive Styles ===================== */
+ @media (max-width: 1200px) {
+ .stakes-table {
+ display: block;
+ overflow-x: auto;
+ }
+
+ .network-metrics {
+ grid-template-columns: repeat(3, 1fr);
+ }
+ }
+
+ @media (min-width: 1201px) {
+ .network-metrics {
+ grid-template-columns: repeat(5, 1fr);
+ }
+ }
+ /* ===== Splash Screen ===== */
+ #splash-screen {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ background: #000000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 999999;
+ opacity: 1;
+ transition: opacity 1s ease;
+ }
+
+ #splash-screen.fade-out {
+ opacity: 0;
+ }
+
+ .splash-content {
+ text-align: center;
+ color: #FF9900;
+ opacity: 0;
+ animation: fadeIn 1.2s ease forwards;
+ }
+ @keyframes fadeIn {
+ 0% {
+ opacity: 0;
+ transform: scale(0.97);
+ }
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ }
+
+ /* Title & text styling */
+ .title-row {
+ display: flex;
+ align-items: baseline;
+ gap: 1rem;
+ }
+
+ .splash-title {
+ font-size: 2.4rem;
+ margin: 0;
+ padding: 0;
+ font-weight: 600;
+ color: #FF9900;
+ }
+
+ .beta-text {
+ font-size: 0.9rem;
+ color: #FF9900;
+ background: rgba(255, 153, 0, 0.1);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-weight: 500;
+ }
+
+
+ """
+
+
+def get_javascript() -> str:
+ return """
+ /* ===================== Global Variables ===================== */
+ const root_symbol_html = 'τ';
+ let verboseNumbers = false;
+
+ /* ===================== Clipboard Functions ===================== */
+ /**
+ * Copies text to clipboard and shows visual feedback
+ * @param {string} text The text to copy
+ * @param {HTMLElement} element Optional element to show feedback on
+ */
+ function copyToClipboard(text, element) {
+ navigator.clipboard.writeText(text)
+ .then(() => {
+ const targetElement = element || (event && event.target);
+
+ if (targetElement) {
+ const copyIndicator = targetElement.querySelector('.copy-indicator');
+
+ if (copyIndicator) {
+ const originalText = copyIndicator.textContent;
+ copyIndicator.textContent = 'Copied!';
+ copyIndicator.style.color = '#FF9900';
+
+ setTimeout(() => {
+ copyIndicator.textContent = originalText;
+ copyIndicator.style.color = '';
+ }, 1000);
+ } else {
+ const originalText = targetElement.textContent;
+ targetElement.textContent = 'Copied!';
+ targetElement.style.color = '#FF9900';
+
+ setTimeout(() => {
+ targetElement.textContent = originalText;
+ targetElement.style.color = '';
+ }, 1000);
+ }
+ }
+ })
+ .catch(err => {
+ console.error('Failed to copy:', err);
+ });
+ }
+
+
+ /* ===================== Initialization and DOMContentLoaded Handler ===================== */
+ document.addEventListener('DOMContentLoaded', function() {
+ try {
+ const initialDataElement = document.getElementById('initial-data');
+ if (!initialDataElement) {
+ throw new Error('Initial data element (#initial-data) not found.');
+ }
+ window.initialData = {
+ wallet_info: JSON.parse(initialDataElement.getAttribute('data-wallet-info')),
+ subnets: JSON.parse(initialDataElement.getAttribute('data-subnets'))
+ };
+ } catch (error) {
+ console.error('Error loading initial data:', error);
+ }
+
+ // Return to the main list of subnets.
+ const backButton = document.querySelector('.back-button');
+ if (backButton) {
+ backButton.addEventListener('click', function() {
+ // First check if neuron details are visible and close them if needed
+ const neuronDetails = document.getElementById('neuron-detail-container');
+ if (neuronDetails && neuronDetails.style.display !== 'none') {
+ closeNeuronDetails();
+ return; // Stop here, don't go back to main page yet
+ }
+
+ // Otherwise go back to main subnet list
+ document.getElementById('main-content').style.display = 'block';
+ document.getElementById('subnet-page').style.display = 'none';
+ });
+ }
+
+
+ // Splash screen logic
+ const splash = document.getElementById('splash-screen');
+ const mainContent = document.getElementById('main-content');
+ mainContent.style.display = 'none';
+
+ setTimeout(() => {
+ splash.classList.add('fade-out');
+ splash.addEventListener('transitionend', () => {
+ splash.style.display = 'none';
+ mainContent.style.display = 'block';
+ }, { once: true });
+ }, 2000);
+
+ initializeFormattedNumbers();
+
+ // Keep main page's "verbose" checkbox and the Subnet page's "verbose" checkbox in sync
+ const mainVerboseCheckbox = document.getElementById('show-verbose');
+ const subnetVerboseCheckbox = document.getElementById('verbose-toggle');
+ if (mainVerboseCheckbox && subnetVerboseCheckbox) {
+ mainVerboseCheckbox.addEventListener('change', function() {
+ subnetVerboseCheckbox.checked = this.checked;
+ toggleVerboseNumbers();
+ });
+ subnetVerboseCheckbox.addEventListener('change', function() {
+ mainVerboseCheckbox.checked = this.checked;
+ toggleVerboseNumbers();
+ });
+ }
+
+ // Initialize tile view as default
+ const tilesContainer = document.getElementById('subnet-tiles-container');
+ const tableContainer = document.querySelector('.subnets-table-container');
+
+ // Generate and show tiles
+ generateSubnetTiles();
+ tilesContainer.style.display = 'flex';
+ tableContainer.style.display = 'none';
+ });
+
+ /* ===================== Main Page Functions ===================== */
+ /**
+ * Sort the main Subnets table by the specified column index.
+ * Toggles ascending/descending on each click.
+ * @param {number} columnIndex Index of the column to sort.
+ */
+ function sortMainTable(columnIndex) {
+ const table = document.querySelector('.subnets-table');
+ const headers = table.querySelectorAll('th');
+ const header = headers[columnIndex];
+
+ // Determine new sort direction
+ let isDescending = header.getAttribute('data-sort') !== 'desc';
+
+ // Clear sort markers on all columns, then set the new one
+ headers.forEach(th => { th.removeAttribute('data-sort'); });
+ header.setAttribute('data-sort', isDescending ? 'desc' : 'asc');
+
+ // Sort rows based on numeric value (or netuid in col 0)
+ const tbody = table.querySelector('tbody');
+ const rows = Array.from(tbody.querySelectorAll('tr'));
+ rows.sort((rowA, rowB) => {
+ const cellA = rowA.cells[columnIndex];
+ const cellB = rowB.cells[columnIndex];
+
+ // Special handling for the first column with netuid in data-value
+ if (columnIndex === 0) {
+ const netuidA = parseInt(cellA.getAttribute('data-value'), 10);
+ const netuidB = parseInt(cellB.getAttribute('data-value'), 10);
+ return isDescending ? (netuidB - netuidA) : (netuidA - netuidB);
+ }
+
+ // Otherwise parse float from data-value
+ const valueA = parseFloat(cellA.getAttribute('data-value')) || 0;
+ const valueB = parseFloat(cellB.getAttribute('data-value')) || 0;
+ return isDescending ? (valueB - valueA) : (valueA - valueB);
+ });
+
+ // Re-inject rows in sorted order
+ tbody.innerHTML = '';
+ rows.forEach(row => tbody.appendChild(row));
+ }
+
+ /**
+ * Filters the main Subnets table rows based on user search and "Show Only Staked" checkbox.
+ */
+ function filterSubnets() {
+ const searchText = document.getElementById('subnet-search').value.toLowerCase();
+ const showStaked = document.getElementById('show-staked').checked;
+ const showTiles = document.getElementById('show-tiles').checked;
+
+ // Filter table rows
+ const rows = document.querySelectorAll('.subnet-row');
+ rows.forEach(row => {
+ const name = row.querySelector('.subnet-name').textContent.toLowerCase();
+ const stakeStatus = row.querySelector('.stake-status').textContent; // "Staked" or "Not Staked"
+
+ let isVisible = name.includes(searchText);
+ if (showStaked) {
+ // If "Show only Staked" is checked, the row must have "Staked" to be visible
+ isVisible = isVisible && (stakeStatus === 'Staked');
+ }
+ row.style.display = isVisible ? '' : 'none';
+ });
+
+ // Filter tiles if they're being shown
+ if (showTiles) {
+ const tiles = document.querySelectorAll('.subnet-tile');
+ tiles.forEach(tile => {
+ const name = tile.querySelector('.tile-name').textContent.toLowerCase();
+ const netuid = tile.querySelector('.tile-netuid').textContent;
+ const isStaked = tile.classList.contains('staked');
+
+ let isVisible = name.includes(searchText) || netuid.includes(searchText);
+ if (showStaked) {
+ isVisible = isVisible && isStaked;
+ }
+ tile.style.display = isVisible ? '' : 'none';
+ });
+ }
+ }
+
+
+ /* ===================== Subnet Detail Page Functions ===================== */
+ /**
+ * Displays the Subnet page (detailed view) for the selected netuid.
+ * Hides the main content and populates all the metrics / stakes / network table.
+ * @param {number} netuid The netuid of the subnet to show in detail.
+ */
+ function showSubnetPage(netuid) {
+ try {
+ window.currentSubnet = netuid;
+ window.scrollTo(0, 0);
+
+ const subnet = window.initialData.subnets.find(s => s.netuid === parseInt(netuid, 10));
+ if (!subnet) {
+ throw new Error(`Subnet not found for netuid: ${netuid}`);
+ }
+ window.currentSubnetSymbol = subnet.symbol;
+
+ // Insert the "metagraph" table beneath the "stakes" table in the hidden container
+ const networkTableHTML = `
+
+
+
+
+
+
+
+ Name
+ Stake Weight
+ Stake ${subnet.symbol}
+ Stake ${root_symbol_html}
+ Dividends
+ Incentive
+ Emissions /day
+ Hotkey
+ Coldkey
+
+
+
+ ${generateNetworkTableRows(subnet.metagraph_info)}
+
+
+
+ `;
+
+ // Show/hide main content vs. subnet detail
+ document.getElementById('main-content').style.display = 'none';
+ document.getElementById('subnet-page').style.display = 'block';
+
+ document.querySelector('#subnet-title').textContent = `${subnet.netuid} - ${subnet.name}`;
+ document.querySelector('#subnet-price').innerHTML = formatNumber(subnet.price, subnet.symbol);
+ document.querySelector('#subnet-market-cap').innerHTML = formatNumber(subnet.market_cap, root_symbol_html);
+ document.querySelector('#subnet-total-stake').innerHTML= formatNumber(subnet.total_stake, subnet.symbol);
+ document.querySelector('#subnet-emission').innerHTML = formatNumber(subnet.emission, root_symbol_html);
+
+
+ const metagraphInfo = subnet.metagraph_info;
+ document.querySelector('#network-alpha-in').innerHTML = formatNumber(metagraphInfo.alpha_in, subnet.symbol);
+ document.querySelector('#network-tau-in').innerHTML = formatNumber(metagraphInfo.tao_in, root_symbol_html);
+ document.querySelector('#network-moving-price').innerHTML = formatNumber(metagraphInfo.moving_price, subnet.symbol);
+
+ // Registration status
+ const registrationElement = document.querySelector('#network-registration');
+ registrationElement.textContent = metagraphInfo.registration_allowed ? 'Open' : 'Closed';
+ registrationElement.classList.toggle('closed', !metagraphInfo.registration_allowed);
+
+ // Commit-Reveal Weight status
+ const crElement = document.querySelector('#network-cr');
+ crElement.textContent = metagraphInfo.commit_reveal_weights_enabled ? 'Enabled' : 'Disabled';
+ crElement.classList.toggle('disabled', !metagraphInfo.commit_reveal_weights_enabled);
+
+ // Blocks since last step, out of tempo
+ document.querySelector('#network-blocks-since-step').innerHTML =
+ `${metagraphInfo.blocks_since_last_step}/${metagraphInfo.tempo}`;
+
+ // Number of neurons vs. max
+ document.querySelector('#network-neurons').innerHTML =
+ `${metagraphInfo.num_uids}/${metagraphInfo.max_uids}`;
+
+ // Update "Your Stakes" table
+ const stakesTableBody = document.querySelector('#stakes-table-body');
+ stakesTableBody.innerHTML = '';
+ if (subnet.your_stakes && subnet.your_stakes.length > 0) {
+ subnet.your_stakes.forEach(stake => {
+ const row = document.createElement('tr');
+ row.innerHTML = `
+
+
+ ${stake.hotkey_identity}
+
+ copy
+
+
+ ${formatNumber(stake.amount, subnet.symbol)}
+ ${formatNumber(stake.ideal_value, root_symbol_html)}
+ ${formatNumber(stake.slippage_value, root_symbol_html)} (${stake.slippage_percentage.toFixed(2)}%)
+ ${formatNumber(stake.emission, subnet.symbol + '/day')}
+ ${formatNumber(stake.tao_emission, root_symbol_html + '/day')}
+
+
+ ${stake.is_registered ? 'Yes' : 'No'}
+
+
+
+ Coming soon
+
+ `;
+ stakesTableBody.appendChild(row);
+ });
+ } else {
+ // If no user stake in this subnet
+ stakesTableBody.innerHTML = `
+
+ No stakes found for this subnet
+
+ `;
+ }
+
+ // Remove any previously injected network table then add the new one
+ const existingNetworkTable = document.querySelector('.network-table-container');
+ if (existingNetworkTable) {
+ existingNetworkTable.remove();
+ }
+ document.querySelector('.stakes-table-container').insertAdjacentHTML('afterend', networkTableHTML);
+
+ // Format the new numbers
+ initializeFormattedNumbers();
+
+ // Initialize connectivity visualization (the dots / lines "animation")
+ setTimeout(() => { initNetworkVisualization(); }, 100);
+
+ // Toggle whether we are showing the "Your Stakes" or "Metagraph" table
+ toggleStakeView();
+
+ // Initialize sorting on newly injected table columns
+ initializeSorting();
+
+ // Auto-sort by Stake descending on the network table for convenience
+ setTimeout(() => {
+ const networkTable = document.querySelector('.network-table');
+ if (networkTable) {
+ const stakeColumn = networkTable.querySelector('th:nth-child(2)');
+ if (stakeColumn) {
+ sortTable(networkTable, 1, stakeColumn, true);
+ stakeColumn.setAttribute('data-sort', 'desc');
+ }
+ }
+ }, 100);
+
+ console.log('Subnet page updated successfully');
+ } catch (error) {
+ console.error('Error updating subnet page:', error);
+ }
+ }
+
+ /**
+ * Generates the rows for the "Neurons" table (shown when the user unchecks "Show Stakes").
+ * Each row, when clicked, calls showNeuronDetails(i).
+ * @param {Object} metagraphInfo The "metagraph_info" of the subnet that holds hotkeys, etc.
+ */
+ function generateNetworkTableRows(metagraphInfo) {
+ const rows = [];
+ console.log('Generating network table rows with data:', metagraphInfo);
+
+ for (let i = 0; i < metagraphInfo.hotkeys.length; i++) {
+ // Subnet symbol is used to show token vs. root stake
+ const subnet = window.initialData.subnets.find(s => s.netuid === window.currentSubnet);
+ const subnetSymbol = subnet ? subnet.symbol : '';
+
+ // Possibly show hotkey/coldkey truncated for readability
+ const truncatedHotkey = truncateAddress(metagraphInfo.hotkeys[i]);
+ const truncatedColdkey = truncateAddress(metagraphInfo.coldkeys[i]);
+ const identityName = metagraphInfo.updated_identities[i] || '~';
+
+ // Root stake is being scaled by 0.18 arbitrarily here
+ const adjustedRootStake = metagraphInfo.tao_stake[i] * 0.18;
+
+ rows.push(`
+
+ ${identityName}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${truncatedHotkey}
+ copy
+
+
+
+
+ ${truncatedColdkey}
+ copy
+
+
+
+ `);
+ }
+ return rows.join('');
+ }
+
+ /**
+ * Handles toggling between the "Your Stakes" view and the "Neurons" view on the Subnet page.
+ * The "Show Stakes" checkbox (#stake-toggle) controls which table is visible.
+ */
+ function toggleStakeView() {
+ const showStakes = document.getElementById('stake-toggle').checked;
+ const stakesTable = document.querySelector('.stakes-table-container');
+ const networkTable = document.querySelector('.network-table-container');
+ const sectionHeader = document.querySelector('.view-header');
+ const neuronDetails = document.getElementById('neuron-detail-container');
+ const addStakeButton = document.querySelector('.add-stake-button');
+ const exportCsvButton = document.querySelector('.export-csv-button');
+ const stakesHeader = document.querySelector('.stakes-header');
+
+ // First, close neuron details if they're open
+ if (neuronDetails && neuronDetails.style.display !== 'none') {
+ neuronDetails.style.display = 'none';
+ }
+
+ // Always show the section header and stakes header when toggling views
+ if (sectionHeader) sectionHeader.style.display = 'block';
+ if (stakesHeader) stakesHeader.style.display = 'flex';
+
+ if (showStakes) {
+ // Show the Stakes table, hide the Neurons table
+ stakesTable.style.display = 'block';
+ networkTable.style.display = 'none';
+ sectionHeader.textContent = 'Your Stakes';
+ if (addStakeButton) {
+ addStakeButton.style.display = 'none';
+ }
+ if (exportCsvButton) {
+ exportCsvButton.style.display = 'none';
+ }
+ } else {
+ // Show the Neurons table, hide the Stakes table
+ stakesTable.style.display = 'none';
+ networkTable.style.display = 'block';
+ sectionHeader.textContent = 'Metagraph';
+ if (addStakeButton) {
+ addStakeButton.style.display = 'block';
+ }
+ if (exportCsvButton) {
+ exportCsvButton.style.display = 'block';
+ }
+ }
+ }
+
+ /**
+ * Called when you click a row in the "Neurons" table, to display more detail about that neuron.
+ * This hides the "Neurons" table and shows the #neuron-detail-container.
+ * @param {number} rowIndex The index of the neuron in the arrays (hotkeys, coldkeys, etc.)
+ */
+ function showNeuronDetails(rowIndex) {
+ try {
+ // Hide the network table & stakes table
+ const networkTable = document.querySelector('.network-table-container');
+ if (networkTable) networkTable.style.display = 'none';
+ const stakesTable = document.querySelector('.stakes-table-container');
+ if (stakesTable) stakesTable.style.display = 'none';
+
+ // Hide the stakes header with the action buttons
+ const stakesHeader = document.querySelector('.stakes-header');
+ if (stakesHeader) stakesHeader.style.display = 'none';
+
+ // Hide the view header that says "Neurons"
+ const viewHeader = document.querySelector('.view-header');
+ if (viewHeader) viewHeader.style.display = 'none';
+
+ // Show the neuron detail panel
+ const detailContainer = document.getElementById('neuron-detail-container');
+ if (detailContainer) detailContainer.style.display = 'block';
+
+ // Pull out the current subnet
+ const subnet = window.initialData.subnets.find(s => s.netuid === window.currentSubnet);
+ if (!subnet) {
+ console.error('No subnet data for netuid:', window.currentSubnet);
+ return;
+ }
+
+ const metagraphInfo = subnet.metagraph_info;
+ const subnetSymbol = subnet.symbol || '';
+
+ // Pull axon data, for IP info
+ const axonData = metagraphInfo.processed_axons ? metagraphInfo.processed_axons[rowIndex] : null;
+ let ipInfoString;
+
+ // Update IP info card - hide header if IP info is present
+ const ipInfoCard = document.getElementById('neuron-ipinfo').closest('.metric-card');
+ if (axonData && axonData.ip !== 'N/A') {
+ // If we have valid IP info, hide the "IP Info" label
+ if (ipInfoCard && ipInfoCard.querySelector('.metric-label')) {
+ ipInfoCard.querySelector('.metric-label').style.display = 'none';
+ }
+ // Format IP info with green labels
+ ipInfoString = `IP: ${axonData.ip} ` +
+ `Port: ${axonData.port} ` +
+ `Type: ${axonData.ip_type}`;
+ } else {
+ // If no IP info, show the label
+ if (ipInfoCard && ipInfoCard.querySelector('.metric-label')) {
+ ipInfoCard.querySelector('.metric-label').style.display = 'block';
+ }
+ ipInfoString = 'N/A ';
+ }
+
+ // Basic identity and hotkey/coldkey info
+ const name = metagraphInfo.updated_identities[rowIndex] || '~';
+ const hotkey = metagraphInfo.hotkeys[rowIndex];
+ const coldkey = metagraphInfo.coldkeys[rowIndex];
+ const rank = metagraphInfo.rank ? metagraphInfo.rank[rowIndex] : 0;
+ const trust = metagraphInfo.trust ? metagraphInfo.trust[rowIndex] : 0;
+ const pruning = metagraphInfo.pruning_score ? metagraphInfo.pruning_score[rowIndex] : 0;
+ const vPermit = metagraphInfo.validator_permit ? metagraphInfo.validator_permit[rowIndex] : false;
+ const lastUpd = metagraphInfo.last_update ? metagraphInfo.last_update[rowIndex] : 0;
+ const consensus = metagraphInfo.consensus ? metagraphInfo.consensus[rowIndex] : 0;
+ const regBlock = metagraphInfo.block_at_registration ? metagraphInfo.block_at_registration[rowIndex] : 0;
+ const active = metagraphInfo.active ? metagraphInfo.active[rowIndex] : false;
+
+ // Update UI fields
+ document.getElementById('neuron-name').textContent = name;
+ document.getElementById('neuron-name').style.color = '#FF9900';
+
+ document.getElementById('neuron-hotkey').textContent = hotkey;
+ document.getElementById('neuron-coldkey').textContent = coldkey;
+ document.getElementById('neuron-trust').textContent = trust.toFixed(4);
+ document.getElementById('neuron-pruning-score').textContent = pruning.toFixed(4);
+
+ // Validator
+ const validatorElem = document.getElementById('neuron-validator-permit');
+ if (vPermit) {
+ validatorElem.style.color = '#2ECC71';
+ validatorElem.textContent = 'True';
+ } else {
+ validatorElem.style.color = '#ff4444';
+ validatorElem.textContent = 'False';
+ }
+
+ document.getElementById('neuron-last-update').textContent = lastUpd;
+ document.getElementById('neuron-consensus').textContent = consensus.toFixed(4);
+ document.getElementById('neuron-reg-block').textContent = regBlock;
+ document.getElementById('neuron-ipinfo').innerHTML = ipInfoString;
+
+ const activeElem = document.getElementById('neuron-active');
+ if (active) {
+ activeElem.style.color = '#2ECC71';
+ activeElem.textContent = 'Yes';
+ } else {
+ activeElem.style.color = '#ff4444';
+ activeElem.textContent = 'No';
+ }
+
+ // Add stake data ("total_stake", "alpha_stake", "tao_stake")
+ document.getElementById('neuron-stake-total').setAttribute(
+ 'data-value', metagraphInfo.total_stake[rowIndex]
+ );
+ document.getElementById('neuron-stake-total').setAttribute(
+ 'data-symbol', subnetSymbol
+ );
+
+ document.getElementById('neuron-stake-token').setAttribute(
+ 'data-value', metagraphInfo.alpha_stake[rowIndex]
+ );
+ document.getElementById('neuron-stake-token').setAttribute(
+ 'data-symbol', subnetSymbol
+ );
+
+ // Multiply tao_stake by 0.18
+ const originalStakeRoot = metagraphInfo.tao_stake[rowIndex];
+ const calculatedStakeRoot = originalStakeRoot * 0.18;
+
+ document.getElementById('neuron-stake-root').setAttribute(
+ 'data-value', calculatedStakeRoot
+ );
+ document.getElementById('neuron-stake-root').setAttribute(
+ 'data-symbol', root_symbol_html
+ );
+ // Also set the inner text right away, so we show a correct format on load
+ document.getElementById('neuron-stake-root').innerHTML =
+ formatNumber(calculatedStakeRoot, root_symbol_html);
+
+ // Dividends, Incentive
+ document.getElementById('neuron-dividends').setAttribute(
+ 'data-value', metagraphInfo.dividends[rowIndex]
+ );
+ document.getElementById('neuron-dividends').setAttribute('data-symbol', '');
+
+ document.getElementById('neuron-incentive').setAttribute(
+ 'data-value', metagraphInfo.incentives[rowIndex]
+ );
+ document.getElementById('neuron-incentive').setAttribute('data-symbol', '');
+
+ // Emissions
+ document.getElementById('neuron-emissions').setAttribute(
+ 'data-value', metagraphInfo.emission[rowIndex]
+ );
+ document.getElementById('neuron-emissions').setAttribute('data-symbol', subnetSymbol);
+
+ // Rank
+ document.getElementById('neuron-rank').textContent = rank.toFixed(4);
+
+ // Re-run formatting so the newly updated data-values appear in numeric form
+ initializeFormattedNumbers();
+ } catch (err) {
+ console.error('Error showing neuron details:', err);
+ }
+ }
+
+ /**
+ * Closes the neuron detail panel and goes back to whichever table was selected ("Stakes" or "Metagraph").
+ */
+ function closeNeuronDetails() {
+ // Hide neuron details
+ const detailContainer = document.getElementById('neuron-detail-container');
+ if (detailContainer) detailContainer.style.display = 'none';
+
+ // Show the stakes header with action buttons
+ const stakesHeader = document.querySelector('.stakes-header');
+ if (stakesHeader) stakesHeader.style.display = 'flex';
+
+ // Show the view header again
+ const viewHeader = document.querySelector('.view-header');
+ if (viewHeader) viewHeader.style.display = 'block';
+
+ // Show the appropriate table based on toggle state
+ const showStakes = document.getElementById('stake-toggle').checked;
+ const stakesTable = document.querySelector('.stakes-table-container');
+ const networkTable = document.querySelector('.network-table-container');
+
+ if (showStakes) {
+ stakesTable.style.display = 'block';
+ networkTable.style.display = 'none';
+
+ // Hide action buttons when showing stakes
+ const addStakeButton = document.querySelector('.add-stake-button');
+ const exportCsvButton = document.querySelector('.export-csv-button');
+ if (addStakeButton) addStakeButton.style.display = 'none';
+ if (exportCsvButton) exportCsvButton.style.display = 'none';
+ } else {
+ stakesTable.style.display = 'none';
+ networkTable.style.display = 'block';
+
+ // Show action buttons when showing metagraph
+ const addStakeButton = document.querySelector('.add-stake-button');
+ const exportCsvButton = document.querySelector('.export-csv-button');
+ if (addStakeButton) addStakeButton.style.display = 'block';
+ if (exportCsvButton) exportCsvButton.style.display = 'block';
+ }
+ }
+
+
+ /* ===================== Number Formatting Functions ===================== */
+ /**
+ * Toggles the numeric display between "verbose" and "short" notations
+ * across all .formatted-number elements on the page.
+ */
+ function toggleVerboseNumbers() {
+ // We read from the main or subnet checkboxes
+ verboseNumbers =
+ document.getElementById('verbose-toggle')?.checked ||
+ document.getElementById('show-verbose')?.checked ||
+ false;
+
+ // Reformat all visible .formatted-number elements
+ document.querySelectorAll('.formatted-number').forEach(element => {
+ const value = parseFloat(element.dataset.value);
+ const symbol = element.dataset.symbol;
+ element.innerHTML = formatNumber(value, symbol);
+ });
+
+ // If we're currently on the Subnet detail page, update those numbers too
+ if (document.getElementById('subnet-page').style.display !== 'none') {
+ updateAllNumbers();
+ }
+ }
+
+ /**
+ * Scans all .formatted-number elements and replaces their text with
+ * the properly formatted version (short or verbose).
+ */
+ function initializeFormattedNumbers() {
+ document.querySelectorAll('.formatted-number').forEach(element => {
+ const value = parseFloat(element.dataset.value);
+ const symbol = element.dataset.symbol;
+ element.innerHTML = formatNumber(value, symbol);
+ });
+ }
+
+ /**
+ * Called by toggleVerboseNumbers() to reformat key metrics on the Subnet page
+ * that might not be directly wrapped in .formatted-number but need to be updated anyway.
+ */
+ function updateAllNumbers() {
+ try {
+ const subnet = window.initialData.subnets.find(s => s.netuid === window.currentSubnet);
+ if (!subnet) {
+ console.error('Could not find subnet data for netuid:', window.currentSubnet);
+ return;
+ }
+ // Reformat a few items in the Subnet detail header
+ document.querySelector('#subnet-market-cap').innerHTML =
+ formatNumber(subnet.market_cap, root_symbol_html);
+ document.querySelector('#subnet-total-stake').innerHTML =
+ formatNumber(subnet.total_stake, subnet.symbol);
+ document.querySelector('#subnet-emission').innerHTML =
+ formatNumber(subnet.emission, root_symbol_html);
+
+ // Reformat the Metagraph table data
+ const netinfo = subnet.metagraph_info;
+ document.querySelector('#network-alpha-in').innerHTML =
+ formatNumber(netinfo.alpha_in, subnet.symbol);
+ document.querySelector('#network-tau-in').innerHTML =
+ formatNumber(netinfo.tao_in, root_symbol_html);
+
+ // Reformat items in "Your Stakes" table
+ document.querySelectorAll('#stakes-table-body .formatted-number').forEach(element => {
+ const value = parseFloat(element.dataset.value);
+ const symbol = element.dataset.symbol;
+ element.innerHTML = formatNumber(value, symbol);
+ });
+ } catch (error) {
+ console.error('Error updating numbers:', error);
+ }
+ }
+
+ /**
+ * Format a numeric value into either:
+ * - a short format (e.g. 1.23k, 3.45m) if verboseNumbers==false
+ * - a more precise format (1,234.5678) if verboseNumbers==true
+ * @param {number} num The numeric value to format.
+ * @param {string} symbol A short suffix or currency symbol (e.g. 'τ') that we append.
+ */
+ function formatNumber(num, symbol = '') {
+ if (num === undefined || num === null || isNaN(num)) {
+ return '0.00 ' + `${symbol} `;
+ }
+ num = parseFloat(num);
+ if (num === 0) {
+ return '0.00 ' + `${symbol} `;
+ }
+
+ // If user requested verbose
+ if (verboseNumbers) {
+ return num.toLocaleString('en-US', {
+ minimumFractionDigits: 4,
+ maximumFractionDigits: 4
+ }) + ' ' + `${symbol} `;
+ }
+
+ // Otherwise show short scale for large numbers
+ const absNum = Math.abs(num);
+ if (absNum >= 1000) {
+ const suffixes = ['', 'k', 'm', 'b', 't'];
+ const magnitude = Math.min(4, Math.floor(Math.log10(absNum) / 3));
+ const scaledNum = num / Math.pow(10, magnitude * 3);
+ return scaledNum.toFixed(2) + suffixes[magnitude] + ' ' +
+ `${symbol} `;
+ } else {
+ // For small numbers <1000, just show 4 decimals
+ return num.toFixed(4) + ' ' + `${symbol} `;
+ }
+ }
+
+ /**
+ * Truncates a string address into the format "ABC..XYZ" for a bit more readability
+ * @param {string} address
+ * @returns {string} truncated address form
+ */
+ function truncateAddress(address) {
+ if (!address || address.length <= 7) {
+ return address; // no need to truncate if very short
+ }
+ return `${address.substring(0, 3)}..${address.substring(address.length - 3)}`;
+ }
+
+ /**
+ * Format a number in compact notation (K, M, B) for tile display
+ */
+ function formatTileNumbers(num) {
+ if (num >= 1000000000) {
+ return (num / 1000000000).toFixed(1) + 'B';
+ } else if (num >= 1000000) {
+ return (num / 1000000).toFixed(1) + 'M';
+ } else if (num >= 1000) {
+ return (num / 1000).toFixed(1) + 'K';
+ } else {
+ return num.toFixed(1);
+ }
+ }
+
+
+ /* ===================== Table Sorting and Filtering Functions ===================== */
+ /**
+ * Switches the Metagraph or Stakes table from sorting ascending to descending on a column, and vice versa.
+ * @param {HTMLTableElement} table The table element itself
+ * @param {number} columnIndex The column index to sort by
+ * @param {HTMLTableHeaderCellElement} header The element clicked
+ * @param {boolean} forceDescending If true and no existing sort marker, will do a descending sort by default
+ */
+ function sortTable(table, columnIndex, header, forceDescending = false) {
+ const tbody = table.querySelector('tbody');
+ const rows = Array.from(tbody.querySelectorAll('tr'));
+
+ // If forcing descending and the header has no 'data-sort', default to 'desc'
+ let isDescending;
+ if (forceDescending && !header.hasAttribute('data-sort')) {
+ isDescending = true;
+ } else {
+ isDescending = header.getAttribute('data-sort') !== 'desc';
+ }
+
+ // Clear data-sort from all headers in the table
+ table.querySelectorAll('th').forEach(th => {
+ th.removeAttribute('data-sort');
+ });
+ // Mark the clicked header with new direction
+ header.setAttribute('data-sort', isDescending ? 'desc' : 'asc');
+
+ // Sort numerically
+ rows.sort((rowA, rowB) => {
+ const cellA = rowA.cells[columnIndex];
+ const cellB = rowB.cells[columnIndex];
+
+ // Attempt to parse float from data-value or fallback to textContent
+ let valueA = parseFloat(cellA.getAttribute('data-value')) ||
+ parseFloat(cellA.textContent.replace(/[^\\d.-]/g, '')) ||
+ 0;
+ let valueB = parseFloat(cellB.getAttribute('data-value')) ||
+ parseFloat(cellB.textContent.replace(/[^\\d.-]/g, '')) ||
+ 0;
+
+ return isDescending ? (valueB - valueA) : (valueA - valueB);
+ });
+
+ // Reinsert sorted rows
+ tbody.innerHTML = '';
+ rows.forEach(row => tbody.appendChild(row));
+ }
+
+ /**
+ * Adds sortable behavior to certain columns in the "stakes-table" or "network-table".
+ * Called after these tables are created in showSubnetPage().
+ */
+ function initializeSorting() {
+ const networkTable = document.querySelector('.network-table');
+ if (networkTable) {
+ initializeTableSorting(networkTable);
+ }
+ const stakesTable = document.querySelector('.stakes-table');
+ if (stakesTable) {
+ initializeTableSorting(stakesTable);
+ }
+ }
+
+ /**
+ * Helper function that attaches sort handlers to appropriate columns in a table.
+ * @param {HTMLTableElement} table The table element to set up sorting for.
+ */
+ function initializeTableSorting(table) {
+ const headers = table.querySelectorAll('th');
+ headers.forEach((header, index) => {
+ // We only want some columns to be sortable, as in original code
+ if (table.classList.contains('stakes-table') && index >= 1 && index <= 5) {
+ header.classList.add('sortable');
+ header.addEventListener('click', () => {
+ sortTable(table, index, header, true);
+ });
+ } else if (table.classList.contains('network-table') && index < 6) {
+ header.classList.add('sortable');
+ header.addEventListener('click', () => {
+ sortTable(table, index, header, true);
+ });
+ }
+ });
+ }
+
+ /**
+ * Filters rows in the Metagraph table by name, hotkey, or coldkey.
+ * Invoked by the oninput event of the #network-search field.
+ * @param {string} searchValue The substring typed by the user.
+ */
+ function filterNetworkTable(searchValue) {
+ const searchTerm = searchValue.toLowerCase().trim();
+ const rows = document.querySelectorAll('.network-table tbody tr');
+
+ rows.forEach(row => {
+ const nameCell = row.querySelector('.identity-cell');
+ const hotkeyContainer = row.querySelector('.hotkey-container[data-full-address]');
+ const coldkeyContainer = row.querySelectorAll('.hotkey-container[data-full-address]')[1];
+
+ const name = nameCell ? nameCell.textContent.toLowerCase() : '';
+ const hotkey = hotkeyContainer ? hotkeyContainer.getAttribute('data-full-address').toLowerCase() : '';
+ const coldkey= coldkeyContainer ? coldkeyContainer.getAttribute('data-full-address').toLowerCase() : '';
+
+ const matches = (name.includes(searchTerm) || hotkey.includes(searchTerm) || coldkey.includes(searchTerm));
+ row.style.display = matches ? '' : 'none';
+ });
+ }
+
+
+ /* ===================== Network Visualization Functions ===================== */
+ /**
+ * Initializes the network visualization on the canvas element.
+ */
+ function initNetworkVisualization() {
+ try {
+ const canvas = document.getElementById('network-canvas');
+ if (!canvas) {
+ console.error('Canvas element (#network-canvas) not found');
+ return;
+ }
+ const ctx = canvas.getContext('2d');
+
+ const subnet = window.initialData.subnets.find(s => s.netuid === window.currentSubnet);
+ if (!subnet) {
+ console.error('Could not find subnet data for netuid:', window.currentSubnet);
+ return;
+ }
+ const numNeurons = subnet.metagraph_info.num_uids;
+ const nodes = [];
+
+ // Randomly place nodes, each with a small velocity
+ for (let i = 0; i < numNeurons; i++) {
+ nodes.push({
+ x: Math.random() * canvas.width,
+ y: Math.random() * canvas.height,
+ radius: 2,
+ vx: (Math.random() - 0.5) * 0.5,
+ vy: (Math.random() - 0.5) * 0.5
+ });
+ }
+
+ // Animation loop
+ function animate() {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ ctx.beginPath();
+ ctx.strokeStyle = 'rgba(255, 153, 0, 0.2)';
+ for (let i = 0; i < nodes.length; i++) {
+ for (let j = i + 1; j < nodes.length; j++) {
+ const dx = nodes[i].x - nodes[j].x;
+ const dy = nodes[i].y - nodes[j].y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+ if (distance < 30) {
+ ctx.moveTo(nodes[i].x, nodes[i].y);
+ ctx.lineTo(nodes[j].x, nodes[j].y);
+ }
+ }
+ }
+ ctx.stroke();
+
+ nodes.forEach(node => {
+ node.x += node.vx;
+ node.y += node.vy;
+
+ // Bounce them off the edges
+ if (node.x <= 0 || node.x >= canvas.width) node.vx *= -1;
+ if (node.y <= 0 || node.y >= canvas.height) node.vy *= -1;
+
+ ctx.beginPath();
+ ctx.fillStyle = '#FF9900';
+ ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2);
+ ctx.fill();
+ });
+
+ requestAnimationFrame(animate);
+ }
+ animate();
+ } catch (error) {
+ console.error('Error in network visualization:', error);
+ }
+ }
+
+
+ /* ===================== Tile View Functions ===================== */
+ /**
+ * Toggles between the tile view and table view of subnets.
+ */
+ function toggleTileView() {
+ const showTiles = document.getElementById('show-tiles').checked;
+ const tilesContainer = document.getElementById('subnet-tiles-container');
+ const tableContainer = document.querySelector('.subnets-table-container');
+
+ if (showTiles) {
+ // Show tiles, hide table
+ tilesContainer.style.display = 'flex';
+ tableContainer.style.display = 'none';
+
+ // Generate tiles if they don't exist yet
+ if (tilesContainer.children.length === 0) {
+ generateSubnetTiles();
+ }
+
+ // Apply current filters to the tiles
+ filterSubnets();
+ } else {
+ // Show table, hide tiles
+ tilesContainer.style.display = 'none';
+ tableContainer.style.display = 'block';
+ }
+ }
+
+ /**
+ * Generates the subnet tiles based on the initialData.
+ */
+ function generateSubnetTiles() {
+ const tilesContainer = document.getElementById('subnet-tiles-container');
+ tilesContainer.innerHTML = ''; // Clear existing tiles
+
+ // Sort subnets by market cap (descending)
+ const sortedSubnets = [...window.initialData.subnets].sort((a, b) => b.market_cap - a.market_cap);
+
+ sortedSubnets.forEach(subnet => {
+ const isStaked = subnet.your_stakes && subnet.your_stakes.length > 0;
+ const marketCapFormatted = formatTileNumbers(subnet.market_cap);
+
+ const tile = document.createElement('div');
+ tile.className = `subnet-tile ${isStaked ? 'staked' : ''}`;
+ tile.onclick = () => showSubnetPage(subnet.netuid);
+
+ // Calculate background intensity based on market cap relative to max
+ const maxMarketCap = sortedSubnets[0].market_cap;
+ const intensity = Math.max(5, Math.min(15, 5 + (subnet.market_cap / maxMarketCap) * 10));
+
+ tile.innerHTML = `
+ ${subnet.netuid}
+ ${subnet.symbol}
+ ${subnet.name}
+ ${marketCapFormatted} ${root_symbol_html}
+ `;
+
+ // Set background intensity
+ tile.style.background = `rgba(255, 255, 255, 0.0${intensity.toFixed(0)})`;
+
+ tilesContainer.appendChild(tile);
+ });
+ }
+ """