diff --git a/packages/node/meshnet_node/cli.py b/packages/node/meshnet_node/cli.py index 6b81c63..af2b7d9 100644 --- a/packages/node/meshnet_node/cli.py +++ b/packages/node/meshnet_node/cli.py @@ -68,6 +68,7 @@ def _run_node(cfg: dict) -> None: tracker_source_disabled=bool(cfg.get("tracker_source_disabled", False)), torch_threads=cfg.get("torch_threads"), torch_interop_threads=cfg.get("torch_interop_threads"), + node_name=cfg.get("node_name"), ) except Exception as exc: print(f"\nERROR: {exc}", file=sys.stderr, flush=True) @@ -157,6 +158,8 @@ def _cmd_default(args) -> int: overrides["host"] = args.host if args.advertise_host: overrides["advertise_host"] = args.advertise_host + if getattr(args, "node_name", None): + overrides["node_name"] = args.node_name if args.route_timeout != 30.0: overrides["route_timeout"] = args.route_timeout if getattr(args, "memory", None) is not None: @@ -246,6 +249,8 @@ def _cmd_start(args) -> int: cfg["wallet_path"] = args.wallet if args.download_dir: cfg["download_dir"] = args.download_dir + if getattr(args, "node_name", None): + cfg["node_name"] = args.node_name # Legacy start: just run without the dashboard (keep original blocking loop) from .startup import run_startup @@ -270,6 +275,7 @@ def _cmd_start(args) -> int: tracker_source_disabled=getattr(args, "tracker_source_disabled", False), torch_threads=getattr(args, "torch_threads", None), torch_interop_threads=getattr(args, "torch_interop_threads", None), + node_name=cfg.get("node_name"), ) except Exception as exc: print(f"ERROR: {exc}", file=sys.stderr, flush=True) @@ -315,6 +321,7 @@ def main() -> None: parser.add_argument("--port", type=int, metavar="N", help="Port to listen on") parser.add_argument("--host", metavar="ADDR", help="Interface to bind (default 0.0.0.0)") parser.add_argument("--advertise-host", metavar="ADDR", help="Host/IP advertised to the tracker") + parser.add_argument("--node-name", metavar="NAME", help="Friendly display name shown on the tracker dashboard") parser.add_argument("--route-timeout", type=float, metavar="SEC", default=30.0, help="Seconds to wait for tracker route lookup (default 30)") parser.add_argument("--memory", type=int, metavar="MB", default=None, @@ -350,6 +357,7 @@ def main() -> None: start_cmd.add_argument("--quantization", choices=["auto", "bfloat16", "int8", "nf4", "bf16"], default="auto") start_cmd.add_argument("--host", default="0.0.0.0") start_cmd.add_argument("--advertise-host") + start_cmd.add_argument("--node-name", help="Friendly display name shown on the tracker dashboard") start_cmd.add_argument("--tracker-mode", action="store_true") start_cmd.add_argument("--tracker-url", default=None) start_cmd.add_argument("--wallet") diff --git a/packages/node/meshnet_node/startup.py b/packages/node/meshnet_node/startup.py index 5f6730e..3b62b1b 100644 --- a/packages/node/meshnet_node/startup.py +++ b/packages/node/meshnet_node/startup.py @@ -610,6 +610,15 @@ def _warn_virtual_network_ip(ip: str | None) -> None: pass +def _registration_display_fields(node_name: str | None) -> dict[str, str]: + if not node_name: + return {} + name = node_name.strip() + if not name: + return {} + return {"friendly_name": name} + + def run_startup( tracker_url: str, port: int = 0, @@ -630,6 +639,7 @@ def run_startup( tracker_source_disabled: bool = False, torch_threads: int | None = None, torch_interop_threads: int | None = None, + node_name: str | None = None, ) -> StubNodeServer | TorchNodeServer: """Execute the full startup sequence and return a running node server. @@ -646,6 +656,7 @@ def run_startup( tracker_url = tracker_url.rstrip("/") relay_url = _discover_relay_url(tracker_url) + display_fields = _registration_display_fields(node_name) if max_loaded_shards < 1: raise ValueError("--max-shards must be at least 1") @@ -871,6 +882,7 @@ def run_startup( ), **registration_capabilities, **relay_fields, + **display_fields, } tracker_node_id = _register_with_tracker( tracker_url, reg_payload, node, _node_start_time, @@ -1019,6 +1031,7 @@ def run_startup( ), **registration_capabilities, **relay_fields, + **display_fields, } tracker_node_id = _register_with_tracker( tracker_url, auto_reg_payload, node, _node_start_time, @@ -1186,6 +1199,7 @@ def run_startup( "model_metadata": model_metadata_for(hf_repo, total_layers, cache_dir=shard_path), **registration_capabilities, **relay_fields, + **display_fields, } tracker_node_id = _register_with_tracker( tracker_url, reg_payload, node, _node_start_time, @@ -1245,6 +1259,7 @@ def run_startup( "managed_assignment": not user_pinned_shard, **registration_capabilities, **relay_fields, + **display_fields, } try: reg_resp = _post_json( diff --git a/packages/tracker/meshnet_tracker/accounts.py b/packages/tracker/meshnet_tracker/accounts.py index 68099c5..23677d4 100644 --- a/packages/tracker/meshnet_tracker/accounts.py +++ b/packages/tracker/meshnet_tracker/accounts.py @@ -27,9 +27,24 @@ DEFAULT_ACCOUNTS_DB_PATH = "accounts.sqlite" SESSION_TTL = 7 * 86400.0 # seconds PBKDF2_ITERATIONS = 200_000 MIN_PASSWORD_LENGTH = 8 +_MAX_NICKNAME_LENGTH = 64 API_KEY_PREFIX = "sk-mesh-" _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +_UNSET = object() + + +def _normalize_nickname(value: object) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise ValueError("nickname must be a string") + nickname = value.strip() + if not nickname: + return None + if len(nickname) > _MAX_NICKNAME_LENGTH: + raise ValueError(f"nickname must be at most {_MAX_NICKNAME_LENGTH} characters") + return nickname def _hash_password(password: str, salt: str) -> str: @@ -64,6 +79,7 @@ class AccountStore: email: str | None = None, wallet: str | None = None, password: str, + nickname: str | None = None, ) -> dict: """Create an account. The first account becomes the admin. @@ -77,6 +93,7 @@ class AccountStore: raise ValueError("invalid email address") if len(password or "") < MIN_PASSWORD_LENGTH: raise ValueError(f"password must be at least {MIN_PASSWORD_LENGTH} characters") + nickname = _normalize_nickname(nickname) with self._lock: for identifier in filter(None, (email, wallet)): if identifier.lower() in self._by_identifier: @@ -91,11 +108,30 @@ class AccountStore: "role": "admin" if not self._accounts else "user", "password_hash": _hash_password(password, salt), "salt": salt, + "nickname": nickname, "ts": time.time(), } self._apply_locked(event) return self._public_view(self._accounts[event["account_id"]]) + def update_profile(self, account_id: str, *, nickname: str | None = _UNSET) -> dict: + """Update display fields for an account. Pass nickname=None to clear.""" + if nickname is not _UNSET: + nickname = _normalize_nickname(nickname) + with self._lock: + if account_id not in self._accounts: + raise ValueError("unknown account") + event = { + "id": f"profile-{uuid.uuid4().hex}", + "type": "update_profile", + "account_id": account_id, + "ts": time.time(), + } + if nickname is not _UNSET: + event["nickname"] = nickname + self._apply_locked(event) + return self._public_view(self._accounts[account_id]) + def verify_login(self, identifier: str, password: str) -> dict | None: """Return the public account view when credentials match, else None.""" with self._lock: @@ -196,6 +232,7 @@ class AccountStore: "account_id": record["account_id"], "email": record.get("email"), "wallet": record.get("wallet"), + "nickname": record.get("nickname"), "role": record["role"], "created_ts": record.get("ts", 0.0), } @@ -249,11 +286,19 @@ class AccountStore: "role": event.get("role", "user"), "password_hash": event["password_hash"], "salt": event["salt"], + "nickname": event.get("nickname"), "ts": float(event.get("ts", 0.0)), } self._accounts[account_id] = record for identifier in filter(None, (record["email"], record["wallet"])): self._by_identifier.setdefault(identifier.lower(), account_id) + elif etype == "update_profile": + account_id = event["account_id"] + record = self._accounts.get(account_id) + if record is None: + return + if "nickname" in event: + record["nickname"] = event.get("nickname") elif etype == "create_key": api_key = event["api_key"] if api_key not in self._revoked_keys: diff --git a/packages/tracker/meshnet_tracker/billing.py b/packages/tracker/meshnet_tracker/billing.py index 0a87865..d3e9442 100644 --- a/packages/tracker/meshnet_tracker/billing.py +++ b/packages/tracker/meshnet_tracker/billing.py @@ -453,13 +453,12 @@ class BillingLedger: with self._lock: return self._node_pending.get(wallet, 0.0) - def usage_for(self, api_keys: list[str], *, recent_limit: int | None = None) -> dict: - """Aggregate charge history for a set of API keys (dashboard view).""" + def usage_totals_for(self, api_keys: list[str]) -> dict: + """Aggregate charge totals without per-request records (dashboard summary).""" keys = set(api_keys) requests = 0 total_tokens = 0 total_cost = 0.0 - records: list[dict] = [] with self._lock: for event in self._event_log: if event.get("type") != "charge" or event.get("api_key") not in keys: @@ -467,6 +466,20 @@ class BillingLedger: requests += 1 total_tokens += int(event.get("total_tokens", 0)) total_cost += float(event.get("cost", 0.0)) + return { + "requests": requests, + "total_tokens": total_tokens, + "total_cost": total_cost, + } + + def usage_for(self, api_keys: list[str], *, recent_limit: int | None = None) -> dict: + """Aggregate charge history for a set of API keys (dashboard view).""" + keys = set(api_keys) + records: list[dict] = [] + with self._lock: + for event in self._event_log: + if event.get("type") != "charge" or event.get("api_key") not in keys: + continue records.append({ "api_key": event["api_key"], "model": event.get("model"), @@ -476,9 +489,9 @@ class BillingLedger: }) recent = records[-recent_limit:] if recent_limit is not None else records return { - "requests": requests, - "total_tokens": total_tokens, - "total_cost": total_cost, + "requests": len(records), + "total_tokens": sum(int(r.get("total_tokens", 0)) for r in records), + "total_cost": sum(float(r.get("cost", 0.0)) for r in records), "records": records, "recent": recent, } diff --git a/packages/tracker/meshnet_tracker/dashboard.html b/packages/tracker/meshnet_tracker/dashboard.html index bf82ea9..ea30ac6 100644 --- a/packages/tracker/meshnet_tracker/dashboard.html +++ b/packages/tracker/meshnet_tracker/dashboard.html @@ -219,6 +219,7 @@

meshnet tracker

+