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