From c209db7de54c5706f47d099c1fe6fdac97986014 Mon Sep 17 00:00:00 2001 From: ibraheem-opentensor Date: Tue, 4 Mar 2025 14:52:59 -0800 Subject: [PATCH 01/15] Adds save func, uses html, adds wry option, adds save dir --- bittensor_cli/cli.py | 64 +++++++++++- bittensor_cli/src/__init__.py | 3 + bittensor_cli/src/bittensor/utils.py | 18 +++- bittensor_cli/src/commands/view.py | 143 ++++++++++++++++++++------- 4 files changed, 185 insertions(+), 43 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 6619e948b..604209f27 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -246,6 +246,15 @@ class Options: "--allow-partial/--not-partial", help="Enable or disable partial stake mode (default: disabled).", ) + dashboard_path = typer.Option( + None, + "--dashboard-path", + "--dashboard_path", + "--dash_path", + "--dash.path", + "--dashboard.path", + help="Path to save the dashboard HTML file. For example: `/Users/btuser/.bittensor/dashboard`.", + ) def list_prompt(init_var: list, list_type: type, help_text: str) -> list: @@ -525,6 +534,7 @@ def __init__(self): "rate_tolerance": None, "safe_staking": True, "allow_partial_stake": False, + "dashboard_path": None, # Commenting this out as this needs to get updated # "metagraph_cols": { # "UID": True, @@ -1119,6 +1129,7 @@ def set_config( "--partial/--no-partial", "--allow/--not-allow", ), + dashboard_path: Optional[str] = Options.dashboard_path, ): """ Sets or updates configuration values in the BTCLI config file. @@ -1148,6 +1159,7 @@ def set_config( "rate_tolerance": rate_tolerance, "safe_staking": safe_staking, "allow_partial_stake": allow_partial_stake, + "dashboard_path": dashboard_path, } bools = ["use_cache", "safe_staking", "allow_partial_stake"] if all(v is None for v in args.values()): @@ -1257,6 +1269,7 @@ def del_config( "--allow/--not-allow", ), all_items: bool = typer.Option(False, "--all"), + dashboard_path: Optional[str] = Options.dashboard_path, ): """ Clears the fields in the config file and sets them to 'None'. @@ -1288,6 +1301,7 @@ def del_config( "rate_tolerance": rate_tolerance, "safe_staking": safe_staking, "allow_partial_stake": allow_partial_stake, + "dashboard_path": dashboard_path, } # If no specific argument is provided, iterate over all @@ -5091,6 +5105,19 @@ def view_dashboard( wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, + coldkey_ss58: Optional[str] = typer.Option( + None, + "--coldkey-ss58", + "--ss58", + help="Coldkey SS58 address to view dashboard for", + ), + use_wry: bool = typer.Option( + False, "--use-wry", help="Use PyWry instead of browser window" + ), + save_file: bool = typer.Option( + False, "--save-file", "--save", help="Save the dashboard HTML file" + ), + dashboard_path: Optional[str] = Options.dashboard_path, quiet: bool = Options.quiet, verbose: bool = Options.verbose, ): @@ -5098,13 +5125,40 @@ def view_dashboard( Display html dashboard with subnets list, stake, and neuron information. """ self.verbosity_handler(quiet, verbose) - if is_linux(): + if use_wry and is_linux(): print_linux_dependency_message() - wallet = self.wallet_ask( - wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME, WO.PATH] - ) + + if use_wry and save_file: + print_error("Cannot save file when using PyWry.") + raise typer.Exit() + + if save_file: + if not dashboard_path: + dashboard_path = Prompt.ask( + "Enter the [blue]path[/blue] where the dashboard HTML file will be saved", + default=self.config.get("dashboard_path") + or defaults.dashboard.path, + ) + + if coldkey_ss58: + if not is_valid_ss58_address(coldkey_ss58): + print_error(f"Invalid SS58 address: {coldkey_ss58}") + raise typer.Exit() + wallet = None + else: + 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)) + view.display_network_dashboard( + wallet=wallet, + subtensor=self.initialize_chain(network), + use_wry=use_wry, + save_file=save_file, + dashboard_path=dashboard_path, + coldkey_ss58=coldkey_ss58, + ) ) @staticmethod diff --git a/bittensor_cli/src/__init__.py b/bittensor_cli/src/__init__.py index d990aada9..2b879097f 100644 --- a/bittensor_cli/src/__init__.py +++ b/bittensor_cli/src/__init__.py @@ -138,6 +138,9 @@ class logging: record_log = False logging_dir = "~/.bittensor/miners" + class dashboard: + path = "~/.bittensor/dashboard/" + defaults = Defaults diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index 04a4bafa7..aba0f6622 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -45,17 +45,33 @@ def __init__(self, hotkey_ss58=None): self.ss58_address = hotkey_ss58 +class _Coldkeypub: + def __init__(self, coldkey_ss58=None): + self.ss58_address = coldkey_ss58 + + class WalletLike: - def __init__(self, name=None, hotkey_ss58=None, hotkey_str=None): + def __init__( + self, + name=None, + hotkey_ss58=None, + hotkey_str=None, + coldkeypub_ss58=None, + ): self.name = name self.hotkey_ss58 = hotkey_ss58 self.hotkey_str = hotkey_str self._hotkey = _Hotkey(hotkey_ss58) + self._coldkeypub = _Coldkeypub(coldkeypub_ss58) @property def hotkey(self): return self._hotkey + @property + def coldkeypub(self): + return self._coldkeypub + def print_console(message: str, colour: str, title: str, console: Console): console.print( diff --git a/bittensor_cli/src/commands/view.py b/bittensor_cli/src/commands/view.py index d6eb0263a..4108bdefa 100644 --- a/bittensor_cli/src/commands/view.py +++ b/bittensor_cli/src/commands/view.py @@ -1,5 +1,8 @@ import asyncio import json +import os +import tempfile +import webbrowser import netaddr from dataclasses import asdict, is_dataclass from typing import Any, Dict, List @@ -7,8 +10,9 @@ 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_cli.src.bittensor.utils import console, WalletLike from bittensor_wallet import Wallet +from bittensor_cli.src import defaults root_symbol_html = f"&#x{ord('τ'):X};" @@ -29,42 +33,80 @@ def default(self, obj): async def display_network_dashboard( wallet: Wallet, subtensor: "SubtensorInterface", - prompt: bool = True, + use_wry: bool = False, + save_file: bool = False, + dashboard_path: str = None, + coldkey_ss58: str = None, ) -> bool: """ Generate and display the HTML interface. """ + if coldkey_ss58: + wallet = WalletLike(coldkeypub_ss58=coldkey_ss58, name=coldkey_ss58[:7]) 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 + if use_wry: + 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 + else: + if save_file: + dir_path = os.path.expanduser(dashboard_path) + else: + dir_path = os.path.expanduser(defaults.dashboard.path) + if not os.path.exists(dir_path): + os.makedirs(dir_path) + + with tempfile.NamedTemporaryFile( + delete=not save_file, + suffix=".html", + mode="w", + dir=dir_path, + prefix=f"{wallet.name}_{subnet_data['block_number']}_", + ) as f: + f.write(html_content) + temp_path = f.name + file_url = f"file://{os.path.abspath(temp_path)}" + + if not save_file: + with console.status( + "[dark_sea_green3]Loading dashboard...[/dark_sea_green3]", + spinner="material", + ): + webbrowser.open(file_url) + await asyncio.sleep(10) + return True + + console.print("[green]Dashboard View opened in your browser[/green]") + console.print(f"[yellow]The HTML file is saved at: {temp_path}[/yellow]") + webbrowser.open(file_url) + return True except Exception as e: print(f"Error: {e}") @@ -76,21 +118,33 @@ def int_to_ip(int_val: int) -> str: return str(netaddr.IPAddress(int_val)) -def get_hotkey_identity( +def get_identity( hotkey_ss58: str, identities: dict, old_identities: dict, trucate_length: int = 4, + return_bool: bool = False, + lookup_hk: bool = True, ) -> 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): + if lookup_hk: + if hk_identity := identities["hotkeys"].get(hotkey_ss58): + return hk_identity.get("identity", {}).get("name", "") or hk_identity.get( + "display", "~" + ) + else: + if ck_identity := identities["coldkeys"].get(hotkey_ss58): + return ck_identity.get("identity", {}).get("name", "") or ck_identity.get( + "display", "~" + ) + + if old_identity := old_identities.get(hotkey_ss58): return old_identity.display else: - return f"{hotkey_ss58[:trucate_length]}...{hotkey_ss58[-trucate_length:]}" + if return_bool: + return False + else: + return f"{hotkey_ss58[:trucate_length]}...{hotkey_ss58[-trucate_length:]}" async def fetch_subnet_data( @@ -164,7 +218,7 @@ def process_subnet_data(raw_data: Dict[str, Any]) -> Dict[str, Any]: stake_dict.setdefault(stake.netuid, []).append( { "hotkey": stake.hotkey_ss58, - "hotkey_identity": get_hotkey_identity( + "hotkey_identity": get_identity( stake.hotkey_ss58, ck_hk_identities, old_identities ), "amount": stake.stake.tao, @@ -228,7 +282,7 @@ def process_subnet_data(raw_data: Dict[str, Any]) -> Dict[str, Any]: # Add identities for hotkey in meta_info.hotkeys: - identity = get_hotkey_identity( + identity = get_identity( hotkey, ck_hk_identities, old_identities, trucate_length=2 ) metagraph_info["updated_identities"].append(identity) @@ -278,9 +332,22 @@ def process_subnet_data(raw_data: Dict[str, Any]) -> Dict[str, Any]: } ) subnets.sort(key=lambda x: x["market_cap"], reverse=True) + + wallet_identity = get_identity( + wallet.coldkeypub.ss58_address, + ck_hk_identities, + old_identities, + return_bool=True, + lookup_hk=False, + ) + if not wallet_identity: + wallet_identity = wallet.name + else: + wallet_identity = f"{wallet_identity} ({wallet.name})" + return { "wallet_info": { - "name": wallet.name, + "name": wallet_identity, "balance": balance.tao, "coldkey": wallet.coldkeypub.ss58_address, "total_ideal_stake_value": total_ideal_stake_value.tao, @@ -319,6 +386,7 @@ def generate_full_page(data: Dict[str, Any]) -> str: + Bittensor CLI Interface