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/dashboard.html b/packages/tracker/meshnet_tracker/dashboard.html index db7abc3..a302228 100644 --- a/packages/tracker/meshnet_tracker/dashboard.html +++ b/packages/tracker/meshnet_tracker/dashboard.html @@ -280,6 +280,41 @@ const copies = v => (v === null || v === undefined) ? "?" : Number(v).toFixed(2) const hp = v => (v === null || v === undefined) ? "?HP" : `${copies(v)}HP`; const short = (s, n=14) => { s = String(s); return s.length > n ? s.slice(0, 6) + "…" + s.slice(-5) : s; }; +function accountDisplayName(account) { + if (!account) return "?"; + const nickname = (account.nickname || "").trim(); + if (nickname) return nickname; + return account.email || account.wallet || account.account_id || "?"; +} + +function buildNodeNameMap(map) { + const byId = new Map(); + for (const node of (map && map.nodes) || []) { + const name = (node.friendly_name || "").trim(); + if (node.node_id && name) byId.set(node.node_id, name); + } + return byId; +} + +function nodeDisplayName(nodeOrId, nameMap) { + if (nodeOrId && typeof nodeOrId === "object") { + const friendly = (nodeOrId.friendly_name || "").trim(); + if (friendly) return friendly; + return short(nodeOrId.node_id || "?"); + } + const friendly = nameMap && nameMap.get(nodeOrId); + return friendly || short(nodeOrId || "?"); +} + +function nodeDisplayCell(node) { + const label = esc(nodeDisplayName(node)); + const friendly = (node.friendly_name || "").trim(); + if (friendly && node.node_id) { + return `${label}`; + } + return label; +} + function modelAliasKey(value) { if (!value) return ""; const text = String(value).trim(); @@ -398,7 +433,7 @@ function renderNodes(map) { html += table(["node", "shard", "tps (1h)", "queue", "served"], group.map(n => { const modelStats = (n.throughput && (n.throughput[n.hf_repo] || n.throughput[n.model])) || {}; return [ - esc(short(n.node_id || "?")), + nodeDisplayCell(n), esc(`${n.shard_start ?? "?"}-${n.shard_end ?? "?"}`), `${esc(tps(modelStats.tokens_per_sec_last_hour))}`, esc(String((n.stats && n.stats.queue_depth) ?? 0)), @@ -475,11 +510,12 @@ function renderStats(stats) { function renderThroughputHtml(stats) { const nodes = (stats && stats.nodes) || {}; + const nameMap = buildNodeNameMap(lastNetworkMap); const rows = []; for (const [nodeId, nodeStats] of Object.entries(nodes)) { for (const [model, s] of Object.entries((nodeStats && nodeStats.models) || {})) { rows.push([ - esc(short(nodeId)), + esc(nodeDisplayName(nodeId, nameMap)), esc(short(model, 24)), `${esc(tps(s.tokens_per_sec_last_hour))}`, `${esc(String(s.sample_count_last_hour ?? 0))}`, @@ -612,6 +648,7 @@ function renderRouting(routing) { return; } const cfg = (routing && routing.config) || {}; + const nameMap = buildNodeNameMap(lastNetworkMap); let html = `
` + `explore share: ${esc(String(cfg.explore_share ?? "?"))} · ` + `traffic ∝ tps^${esc(String(cfg.weight_alpha ?? "?"))} · ` + @@ -621,7 +658,7 @@ function renderRouting(routing) { html += `
${esc(model)} ` + `(epoch ${esc(String(info.epoch ?? 0))} · ${routes.length} route${routes.length === 1 ? "" : "s"})
`; html += table(["route", "tps", "coeff", "share", "samples", "status"], routes.map(r => { - const hops = (r.hops || []).map(h => `${short(h.node_id, 12)}[${h.shard}]`).join(" → "); + const hops = (r.hops || []).map(h => `${nodeDisplayName(h.node_id, nameMap)}[${h.shard}]`).join(" → "); const statusCls = r.status === "proven" ? "ok" : r.status === "stale" ? "warn" : "dim"; const coeff = (r.coefficient === null || r.coefficient === undefined) ? "—" : Number(r.coefficient).toFixed(2) + "×"; @@ -1259,7 +1296,8 @@ function renderAuthForms(errorMsg) { `${label}`; const identityFields = '' + - ''; + '' + + ''; const form = authTab === "login" ? '' + '' + @@ -1281,9 +1319,10 @@ function switchAuthTab(name) { authTab = name; renderAuthForms(); } async function doRegister() { const email = $("auth-email").value.trim(); const wallet = $("auth-wallet").value.trim(); + const nickname = $("auth-nickname").value.trim(); const password = $("auth-password").value; const r = await apiCall("/v1/auth/register", "POST", - { email: email || null, wallet: wallet || null, password }); + { email: email || null, wallet: wallet || null, nickname: nickname || null, password }); if (!r.ok) { renderAuthForms(r.data.error || "registration failed"); return; } setSession(r.data.session_token); await renderAccountPanel(); @@ -1382,6 +1421,13 @@ function renderChatAuthHint() { } } +async function saveNickname() { + const nickname = $("account-nickname").value.trim(); + const r = await apiCall("/v1/account/profile", "POST", { nickname: nickname || null }); + if (!r.ok) alert(r.data.error || "nickname update failed"); + else await renderAccountPanel(); +} + async function renderAccountPanel() { const r = await apiCall("/v1/account"); if (r.status === 404) { // accounts disabled on this tracker @@ -1397,10 +1443,14 @@ async function renderAccountPanel() { const { account, api_keys, balances, total_balance, usage, topup_amount } = r.data; accountApiKeys = Array.isArray(api_keys) ? api_keys.slice() : []; accountUsageRecords = (usage && (usage.records || usage.recent)) || []; - const who = account.email || account.wallet || account.account_id; + const who = accountDisplayName(account); let html = `
${esc(who)} ${esc(account.role)} ` + `
` + + `
` + + `` + + `` + + `
` + `
balance: ` + `${usdt(total_balance)} USDT · requests: ${usage.requests} · ` + `tokens: ${usage.total_tokens} · spent: ${usdt(usage.total_cost)} USDT
`; @@ -1617,7 +1667,7 @@ async function renderAdminPanel() { const rows = (r.data.accounts || []).map(a => { const balance = Object.values(a.balances || {}).reduce((x, y) => x + y, 0); return [ - esc(short(a.email || a.wallet || a.account_id, 24)), + esc(short(accountDisplayName(a), 24)), `${esc(a.role)}`, String((a.api_keys || []).length), `${usdt(balance)}`, diff --git a/packages/tracker/meshnet_tracker/server.py b/packages/tracker/meshnet_tracker/server.py index c433da0..31f8141 100644 --- a/packages/tracker/meshnet_tracker/server.py +++ b/packages/tracker/meshnet_tracker/server.py @@ -570,7 +570,7 @@ class _NodeEntry: "benchmark_tokens_per_sec", "quantization", "managed_assignment", "model_tokens_per_sec", "pending_directives", "last_heartbeat", "tracker_mode", - "relay_addr", "cert_fingerprint", "peer_id", + "relay_addr", "cert_fingerprint", "peer_id", "friendly_name", # heartbeat stats (reported by node, cumulative) "total_requests", "failed_requests", "queue_depth", "proxy_inflight", "uptime_seconds", "current_requests", @@ -606,6 +606,7 @@ class _NodeEntry: relay_addr: str | None = None, cert_fingerprint: str | None = None, peer_id: str | None = None, + friendly_name: str | None = None, ) -> None: self.node_id = node_id self.endpoint = endpoint @@ -632,6 +633,7 @@ class _NodeEntry: self.relay_addr = relay_addr self.cert_fingerprint = cert_fingerprint self.peer_id = peer_id + self.friendly_name = friendly_name self.pending_directives: list[dict] = [] self.last_heartbeat: float = time.monotonic() self.total_requests: int = 0 @@ -2231,6 +2233,22 @@ def _registration_ban_error(contracts: Any | None, wallet_address: str | None) - return None +_MAX_FRIENDLY_NAME_LENGTH = 64 + + +def _normalize_friendly_name(value: object) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise ValueError("friendly_name must be a string") + name = value.strip() + if not name: + return None + if len(name) > _MAX_FRIENDLY_NAME_LENGTH: + raise ValueError(f"friendly_name must be at most {_MAX_FRIENDLY_NAME_LENGTH} characters") + return name + + def _tracker_log( server: "_TrackerHTTPServer", level: str, @@ -2606,6 +2624,9 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): if self.path == "/v1/auth/logout": self._handle_auth_logout() return + if self.path == "/v1/account/profile": + self._handle_account_profile() + return if self.path == "/v1/account/keys": self._handle_account_key_create() return @@ -2923,6 +2944,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): "nodes": [ { "node_id": node.node_id, + "friendly_name": node.friendly_name, "endpoint": node.endpoint, "relay_addr": node.relay_addr, "peer_id": node.peer_id, @@ -4001,6 +4023,11 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): relay_addr = body.get("relay_addr") or None cert_fingerprint = body.get("cert_fingerprint") or None peer_id = body.get("peer_id") or None + try: + friendly_name = _normalize_friendly_name(body.get("friendly_name")) + except ValueError as exc: + self._send_json(400, {"error": str(exc)}) + return node_id = _node_id_for_registration( endpoint, @@ -4035,6 +4062,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): relay_addr=relay_addr, cert_fingerprint=cert_fingerprint, peer_id=peer_id, + friendly_name=friendly_name, ) with server.lock: self._purge_expired_nodes() @@ -4053,6 +4081,12 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): for eid in stale_ids: old = server.registry.pop(eid) stale_entries.append((eid, old)) + if friendly_name is None: + for _eid, old in stale_entries: + if old.friendly_name: + friendly_name = old.friendly_name + break + entry.friendly_name = friendly_name is_topology_change = node_id not in stale_ids or any( (old.shard_start, old.shard_end) != (entry.shard_start, entry.shard_end) for eid, old in stale_entries @@ -4162,6 +4196,12 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): entry.uptime_seconds = float(body["uptime_seconds"]) if "status" in body and body["status"] in ("ready", "loading"): entry.status = body["status"] + if "friendly_name" in body: + try: + entry.friendly_name = _normalize_friendly_name(body.get("friendly_name")) + except ValueError as exc: + self._send_json(400, {"error": str(exc)}) + return directives = list(entry.pending_directives) entry.pending_directives.clear() new_assignment = entry.pending_new_assignment @@ -4443,6 +4483,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): email=body.get("email"), wallet=body.get("wallet"), password=str(body.get("password") or ""), + nickname=body.get("nickname"), ) except ValueError as exc: self._send_json(400, {"error": str(exc)}) @@ -4518,6 +4559,27 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): "topup_amount": server.devnet_topup_amount if server.billing is not None else 0.0, }) + def _handle_account_profile(self): + accounts = self._require_accounts() + if accounts is None: + return + account = self._session_account() + if account is None: + self._send_json(401, {"error": "login required"}) + return + body = self._read_json_body() + if body is None: + return + try: + updated = accounts.update_profile( + account["account_id"], + nickname=body.get("nickname"), + ) + except ValueError as exc: + self._send_json(400, {"error": str(exc)}) + return + self._send_json(200, {"account": updated}) + def _handle_account_key_create(self): accounts = self._require_accounts() if accounts is None: @@ -6076,6 +6138,10 @@ class TrackerServer: shard_end = int(payload["shard_end"]) if payload.get("shard_end") is not None else None except (TypeError, ValueError): return + try: + friendly_name = _normalize_friendly_name(payload.get("friendly_name")) + except ValueError: + friendly_name = None entry = _NodeEntry( node_id=node_id, endpoint=endpoint.rstrip("/"), @@ -6095,6 +6161,7 @@ class TrackerServer: if isinstance(payload.get("downloaded_models"), list) else None ), + friendly_name=_normalize_friendly_name(payload.get("friendly_name")), ) with self._lock: self._registry[node_id] = entry diff --git a/tests/test_accounts.py b/tests/test_accounts.py index 62388ef..b832cca 100644 --- a/tests/test_accounts.py +++ b/tests/test_accounts.py @@ -48,6 +48,36 @@ def test_register_rejects_duplicate_identifiers(): store.register(email="DUP@example.com", password="other-secret") +def test_register_and_update_nickname(): + store = AccountStore() + account = store.register( + email="nick@example.com", + password="secret-123", + nickname=" Alpha ", + ) + assert account["nickname"] == "Alpha" + updated = store.update_profile(account["account_id"], nickname="Beta") + assert updated["nickname"] == "Beta" + cleared = store.update_profile(account["account_id"], nickname=None) + assert cleared["nickname"] is None + + +def test_nickname_replicates_across_stores(): + leader = AccountStore() + follower = AccountStore() + account = leader.register( + email="nick@example.com", + password="secret-123", + nickname="HiveNick", + ) + leader.update_profile(account["account_id"], nickname="Renamed") + events, _ = leader.events_since(0) + follower.apply_events(events) + view = follower.get_account(account["account_id"]) + assert view is not None + assert view["nickname"] == "Renamed" + + def test_login_by_email_or_wallet(): store = AccountStore() account = store.register( @@ -168,6 +198,27 @@ def test_register_login_and_account_view(account_tracker): assert me["usage"]["requests"] == 0 +def test_account_nickname_register_and_profile_update(account_tracker): + url, _ = account_tracker + reg = _call(f"{url}/v1/auth/register", "POST", { + "email": "nick@example.com", + "password": "secret-123", + "nickname": "Operator", + }) + assert reg["account"]["nickname"] == "Operator" + + updated = _call( + f"{url}/v1/account/profile", + "POST", + {"nickname": "Renamed"}, + token=reg["session_token"], + ) + assert updated["account"]["nickname"] == "Renamed" + + me = _call(f"{url}/v1/account", token=reg["session_token"]) + assert me["account"]["nickname"] == "Renamed" + + def test_login_sets_cookie_and_cookie_auth_survives_tracker_restart(tmp_path): accounts_db = str(tmp_path / "accounts.db") tracker = TrackerServer( diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index d082538..35ebe48 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -52,6 +52,34 @@ def test_dashboard_chat_uses_streaming_fetch(): assert 'console.error("chat stream failed", err)' in html +def test_network_map_includes_node_friendly_name(): + tracker = TrackerServer() + port = tracker.start() + try: + body = json.dumps({ + "endpoint": "http://127.0.0.1:9010", + "model": "stub-model", + "shard_start": 0, + "shard_end": 3, + "hardware_profile": {}, + "friendly_name": "Kitchen GPU", + }).encode() + req = urllib.request.Request( + f"http://127.0.0.1:{port}/v1/nodes/register", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + urllib.request.urlopen(req).read() + network = json.loads( + urllib.request.urlopen(f"http://127.0.0.1:{port}/v1/network/map").read() + ) + finally: + tracker.stop() + + assert network["nodes"][0]["friendly_name"] == "Kitchen GPU" + + def test_dashboard_chat_model_selector_shows_health_and_speed(): tracker = TrackerServer() port = tracker.start() @@ -64,6 +92,9 @@ def test_dashboard_chat_model_selector_shows_health_and_speed(): assert "chatModelHealthHp" in html assert "modelServedCopiesFromMap" in html + assert "nodeDisplayName" in html + assert "accountDisplayName" in html + assert "saveNickname" in html assert "chatModelTypicalTps" in html assert "chatModelOptionLabel" in html assert "findRoutingForModel" in html