Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 59 additions & 5 deletions bittensor_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: `~/.bittensor/dashboard`.",
)


def list_prompt(init_var: list, list_type: type, help_text: str) -> list:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()):
Expand Down Expand Up @@ -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'.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -5091,20 +5105,60 @@ 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,
):
"""
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
Expand Down
3 changes: 3 additions & 0 deletions bittensor_cli/src/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ class logging:
record_log = False
logging_dir = "~/.bittensor/miners"

class dashboard:
path = "~/.bittensor/dashboard/"


defaults = Defaults

Expand Down
18 changes: 17 additions & 1 deletion bittensor_cli/src/bittensor/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
143 changes: 106 additions & 37 deletions bittensor_cli/src/commands/view.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import asyncio
import json
import os
import tempfile
import webbrowser
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_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};"

Expand All @@ -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}")
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -319,6 +386,7 @@ def generate_full_page(data: Dict[str, Any]) -> str:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Bittensor CLI Interface</title>
<style>
{get_css_styles()}
Expand Down Expand Up @@ -617,6 +685,7 @@ def generate_main_header(wallet_info: Dict[str, Any], block_number: int) -> str:

return f"""
<div class="header">
<meta charset="UTF-8">
<div class="wallet-info">
<span class="wallet-name">{wallet_info["name"]}</span>
<div class="wallet-address-container" onclick="copyToClipboard('{wallet_info["coldkey"]}', this)">
Expand Down
Loading