node and account names

This commit is contained in:
Dobromir Popov
2026-07-08 21:33:42 +03:00
parent 52629d7762
commit 1e44e8e578
7 changed files with 275 additions and 8 deletions

View File

@@ -68,6 +68,7 @@ def _run_node(cfg: dict) -> None:
tracker_source_disabled=bool(cfg.get("tracker_source_disabled", False)), tracker_source_disabled=bool(cfg.get("tracker_source_disabled", False)),
torch_threads=cfg.get("torch_threads"), torch_threads=cfg.get("torch_threads"),
torch_interop_threads=cfg.get("torch_interop_threads"), torch_interop_threads=cfg.get("torch_interop_threads"),
node_name=cfg.get("node_name"),
) )
except Exception as exc: except Exception as exc:
print(f"\nERROR: {exc}", file=sys.stderr, flush=True) print(f"\nERROR: {exc}", file=sys.stderr, flush=True)
@@ -157,6 +158,8 @@ def _cmd_default(args) -> int:
overrides["host"] = args.host overrides["host"] = args.host
if args.advertise_host: if args.advertise_host:
overrides["advertise_host"] = 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: if args.route_timeout != 30.0:
overrides["route_timeout"] = args.route_timeout overrides["route_timeout"] = args.route_timeout
if getattr(args, "memory", None) is not None: if getattr(args, "memory", None) is not None:
@@ -246,6 +249,8 @@ def _cmd_start(args) -> int:
cfg["wallet_path"] = args.wallet cfg["wallet_path"] = args.wallet
if args.download_dir: if args.download_dir:
cfg["download_dir"] = 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) # Legacy start: just run without the dashboard (keep original blocking loop)
from .startup import run_startup from .startup import run_startup
@@ -270,6 +275,7 @@ def _cmd_start(args) -> int:
tracker_source_disabled=getattr(args, "tracker_source_disabled", False), tracker_source_disabled=getattr(args, "tracker_source_disabled", False),
torch_threads=getattr(args, "torch_threads", None), torch_threads=getattr(args, "torch_threads", None),
torch_interop_threads=getattr(args, "torch_interop_threads", None), torch_interop_threads=getattr(args, "torch_interop_threads", None),
node_name=cfg.get("node_name"),
) )
except Exception as exc: except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr, flush=True) 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("--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("--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("--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, parser.add_argument("--route-timeout", type=float, metavar="SEC", default=30.0,
help="Seconds to wait for tracker route lookup (default 30)") help="Seconds to wait for tracker route lookup (default 30)")
parser.add_argument("--memory", type=int, metavar="MB", default=None, 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("--quantization", choices=["auto", "bfloat16", "int8", "nf4", "bf16"], default="auto")
start_cmd.add_argument("--host", default="0.0.0.0") start_cmd.add_argument("--host", default="0.0.0.0")
start_cmd.add_argument("--advertise-host") 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-mode", action="store_true")
start_cmd.add_argument("--tracker-url", default=None) start_cmd.add_argument("--tracker-url", default=None)
start_cmd.add_argument("--wallet") start_cmd.add_argument("--wallet")

View File

@@ -610,6 +610,15 @@ def _warn_virtual_network_ip(ip: str | None) -> None:
pass 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( def run_startup(
tracker_url: str, tracker_url: str,
port: int = 0, port: int = 0,
@@ -630,6 +639,7 @@ def run_startup(
tracker_source_disabled: bool = False, tracker_source_disabled: bool = False,
torch_threads: int | None = None, torch_threads: int | None = None,
torch_interop_threads: int | None = None, torch_interop_threads: int | None = None,
node_name: str | None = None,
) -> StubNodeServer | TorchNodeServer: ) -> StubNodeServer | TorchNodeServer:
"""Execute the full startup sequence and return a running node server. """Execute the full startup sequence and return a running node server.
@@ -646,6 +656,7 @@ def run_startup(
tracker_url = tracker_url.rstrip("/") tracker_url = tracker_url.rstrip("/")
relay_url = _discover_relay_url(tracker_url) relay_url = _discover_relay_url(tracker_url)
display_fields = _registration_display_fields(node_name)
if max_loaded_shards < 1: if max_loaded_shards < 1:
raise ValueError("--max-shards must be at least 1") raise ValueError("--max-shards must be at least 1")
@@ -871,6 +882,7 @@ def run_startup(
), ),
**registration_capabilities, **registration_capabilities,
**relay_fields, **relay_fields,
**display_fields,
} }
tracker_node_id = _register_with_tracker( tracker_node_id = _register_with_tracker(
tracker_url, reg_payload, node, _node_start_time, tracker_url, reg_payload, node, _node_start_time,
@@ -1019,6 +1031,7 @@ def run_startup(
), ),
**registration_capabilities, **registration_capabilities,
**relay_fields, **relay_fields,
**display_fields,
} }
tracker_node_id = _register_with_tracker( tracker_node_id = _register_with_tracker(
tracker_url, auto_reg_payload, node, _node_start_time, 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), "model_metadata": model_metadata_for(hf_repo, total_layers, cache_dir=shard_path),
**registration_capabilities, **registration_capabilities,
**relay_fields, **relay_fields,
**display_fields,
} }
tracker_node_id = _register_with_tracker( tracker_node_id = _register_with_tracker(
tracker_url, reg_payload, node, _node_start_time, tracker_url, reg_payload, node, _node_start_time,
@@ -1245,6 +1259,7 @@ def run_startup(
"managed_assignment": not user_pinned_shard, "managed_assignment": not user_pinned_shard,
**registration_capabilities, **registration_capabilities,
**relay_fields, **relay_fields,
**display_fields,
} }
try: try:
reg_resp = _post_json( reg_resp = _post_json(

View File

@@ -27,9 +27,24 @@ DEFAULT_ACCOUNTS_DB_PATH = "accounts.sqlite"
SESSION_TTL = 7 * 86400.0 # seconds SESSION_TTL = 7 * 86400.0 # seconds
PBKDF2_ITERATIONS = 200_000 PBKDF2_ITERATIONS = 200_000
MIN_PASSWORD_LENGTH = 8 MIN_PASSWORD_LENGTH = 8
_MAX_NICKNAME_LENGTH = 64
API_KEY_PREFIX = "sk-mesh-" API_KEY_PREFIX = "sk-mesh-"
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") _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: def _hash_password(password: str, salt: str) -> str:
@@ -64,6 +79,7 @@ class AccountStore:
email: str | None = None, email: str | None = None,
wallet: str | None = None, wallet: str | None = None,
password: str, password: str,
nickname: str | None = None,
) -> dict: ) -> dict:
"""Create an account. The first account becomes the admin. """Create an account. The first account becomes the admin.
@@ -77,6 +93,7 @@ class AccountStore:
raise ValueError("invalid email address") raise ValueError("invalid email address")
if len(password or "") < MIN_PASSWORD_LENGTH: if len(password or "") < MIN_PASSWORD_LENGTH:
raise ValueError(f"password must be at least {MIN_PASSWORD_LENGTH} characters") raise ValueError(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
nickname = _normalize_nickname(nickname)
with self._lock: with self._lock:
for identifier in filter(None, (email, wallet)): for identifier in filter(None, (email, wallet)):
if identifier.lower() in self._by_identifier: if identifier.lower() in self._by_identifier:
@@ -91,11 +108,30 @@ class AccountStore:
"role": "admin" if not self._accounts else "user", "role": "admin" if not self._accounts else "user",
"password_hash": _hash_password(password, salt), "password_hash": _hash_password(password, salt),
"salt": salt, "salt": salt,
"nickname": nickname,
"ts": time.time(), "ts": time.time(),
} }
self._apply_locked(event) self._apply_locked(event)
return self._public_view(self._accounts[event["account_id"]]) 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: def verify_login(self, identifier: str, password: str) -> dict | None:
"""Return the public account view when credentials match, else None.""" """Return the public account view when credentials match, else None."""
with self._lock: with self._lock:
@@ -196,6 +232,7 @@ class AccountStore:
"account_id": record["account_id"], "account_id": record["account_id"],
"email": record.get("email"), "email": record.get("email"),
"wallet": record.get("wallet"), "wallet": record.get("wallet"),
"nickname": record.get("nickname"),
"role": record["role"], "role": record["role"],
"created_ts": record.get("ts", 0.0), "created_ts": record.get("ts", 0.0),
} }
@@ -249,11 +286,19 @@ class AccountStore:
"role": event.get("role", "user"), "role": event.get("role", "user"),
"password_hash": event["password_hash"], "password_hash": event["password_hash"],
"salt": event["salt"], "salt": event["salt"],
"nickname": event.get("nickname"),
"ts": float(event.get("ts", 0.0)), "ts": float(event.get("ts", 0.0)),
} }
self._accounts[account_id] = record self._accounts[account_id] = record
for identifier in filter(None, (record["email"], record["wallet"])): for identifier in filter(None, (record["email"], record["wallet"])):
self._by_identifier.setdefault(identifier.lower(), account_id) 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": elif etype == "create_key":
api_key = event["api_key"] api_key = event["api_key"]
if api_key not in self._revoked_keys: if api_key not in self._revoked_keys:

View File

@@ -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 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; }; 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 `<span title="${esc(node.node_id)}">${label}</span>`;
}
return label;
}
function modelAliasKey(value) { function modelAliasKey(value) {
if (!value) return ""; if (!value) return "";
const text = String(value).trim(); const text = String(value).trim();
@@ -398,7 +433,7 @@ function renderNodes(map) {
html += table(["node", "shard", "tps (1h)", "queue", "served"], group.map(n => { html += table(["node", "shard", "tps (1h)", "queue", "served"], group.map(n => {
const modelStats = (n.throughput && (n.throughput[n.hf_repo] || n.throughput[n.model])) || {}; const modelStats = (n.throughput && (n.throughput[n.hf_repo] || n.throughput[n.model])) || {};
return [ return [
esc(short(n.node_id || "?")), nodeDisplayCell(n),
esc(`${n.shard_start ?? "?"}-${n.shard_end ?? "?"}`), esc(`${n.shard_start ?? "?"}-${n.shard_end ?? "?"}`),
`<span class="num">${esc(tps(modelStats.tokens_per_sec_last_hour))}</span>`, `<span class="num">${esc(tps(modelStats.tokens_per_sec_last_hour))}</span>`,
esc(String((n.stats && n.stats.queue_depth) ?? 0)), esc(String((n.stats && n.stats.queue_depth) ?? 0)),
@@ -475,11 +510,12 @@ function renderStats(stats) {
function renderThroughputHtml(stats) { function renderThroughputHtml(stats) {
const nodes = (stats && stats.nodes) || {}; const nodes = (stats && stats.nodes) || {};
const nameMap = buildNodeNameMap(lastNetworkMap);
const rows = []; const rows = [];
for (const [nodeId, nodeStats] of Object.entries(nodes)) { for (const [nodeId, nodeStats] of Object.entries(nodes)) {
for (const [model, s] of Object.entries((nodeStats && nodeStats.models) || {})) { for (const [model, s] of Object.entries((nodeStats && nodeStats.models) || {})) {
rows.push([ rows.push([
esc(short(nodeId)), esc(nodeDisplayName(nodeId, nameMap)),
esc(short(model, 24)), esc(short(model, 24)),
`<span class="num">${esc(tps(s.tokens_per_sec_last_hour))}</span>`, `<span class="num">${esc(tps(s.tokens_per_sec_last_hour))}</span>`,
`<span class="num">${esc(String(s.sample_count_last_hour ?? 0))}</span>`, `<span class="num">${esc(String(s.sample_count_last_hour ?? 0))}</span>`,
@@ -612,6 +648,7 @@ function renderRouting(routing) {
return; return;
} }
const cfg = (routing && routing.config) || {}; const cfg = (routing && routing.config) || {};
const nameMap = buildNodeNameMap(lastNetworkMap);
let html = `<div class="dim" style="margin-bottom:6px">` + let html = `<div class="dim" style="margin-bottom:6px">` +
`explore share: <b>${esc(String(cfg.explore_share ?? "?"))}</b> · ` + `explore share: <b>${esc(String(cfg.explore_share ?? "?"))}</b> · ` +
`traffic ∝ tps^<b>${esc(String(cfg.weight_alpha ?? "?"))}</b> · ` + `traffic ∝ tps^<b>${esc(String(cfg.weight_alpha ?? "?"))}</b> · ` +
@@ -621,7 +658,7 @@ function renderRouting(routing) {
html += `<div style="margin-top:6px"><b>${esc(model)}</b> ` + html += `<div style="margin-top:6px"><b>${esc(model)}</b> ` +
`<span class="dim">(epoch ${esc(String(info.epoch ?? 0))} · ${routes.length} route${routes.length === 1 ? "" : "s"})</span></div>`; `<span class="dim">(epoch ${esc(String(info.epoch ?? 0))} · ${routes.length} route${routes.length === 1 ? "" : "s"})</span></div>`;
html += table(["route", "tps", "coeff", "share", "samples", "status"], routes.map(r => { 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 statusCls = r.status === "proven" ? "ok" : r.status === "stale" ? "warn" : "dim";
const coeff = (r.coefficient === null || r.coefficient === undefined) const coeff = (r.coefficient === null || r.coefficient === undefined)
? "—" : Number(r.coefficient).toFixed(2) + "×"; ? "—" : Number(r.coefficient).toFixed(2) + "×";
@@ -1259,7 +1296,8 @@ function renderAuthForms(errorMsg) {
`<a class="${authTab === name ? "active" : ""}" onclick="switchAuthTab('${name}')">${label}</a>`; `<a class="${authTab === name ? "active" : ""}" onclick="switchAuthTab('${name}')">${label}</a>`;
const identityFields = const identityFields =
'<input id="auth-email" type="email" placeholder="email (or leave blank)">' + '<input id="auth-email" type="email" placeholder="email (or leave blank)">' +
'<input id="auth-wallet" type="text" placeholder="wallet address (or leave blank)">'; '<input id="auth-wallet" type="text" placeholder="wallet address (or leave blank)">' +
'<input id="auth-nickname" type="text" placeholder="nickname (optional)">';
const form = authTab === "login" const form = authTab === "login"
? '<input id="auth-identifier" type="text" placeholder="email or wallet address">' + ? '<input id="auth-identifier" type="text" placeholder="email or wallet address">' +
'<input id="auth-password" type="password" placeholder="password">' + '<input id="auth-password" type="password" placeholder="password">' +
@@ -1281,9 +1319,10 @@ function switchAuthTab(name) { authTab = name; renderAuthForms(); }
async function doRegister() { async function doRegister() {
const email = $("auth-email").value.trim(); const email = $("auth-email").value.trim();
const wallet = $("auth-wallet").value.trim(); const wallet = $("auth-wallet").value.trim();
const nickname = $("auth-nickname").value.trim();
const password = $("auth-password").value; const password = $("auth-password").value;
const r = await apiCall("/v1/auth/register", "POST", 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; } if (!r.ok) { renderAuthForms(r.data.error || "registration failed"); return; }
setSession(r.data.session_token); setSession(r.data.session_token);
await renderAccountPanel(); 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() { async function renderAccountPanel() {
const r = await apiCall("/v1/account"); const r = await apiCall("/v1/account");
if (r.status === 404) { // accounts disabled on this tracker 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; const { account, api_keys, balances, total_balance, usage, topup_amount } = r.data;
accountApiKeys = Array.isArray(api_keys) ? api_keys.slice() : []; accountApiKeys = Array.isArray(api_keys) ? api_keys.slice() : [];
accountUsageRecords = (usage && (usage.records || usage.recent)) || []; accountUsageRecords = (usage && (usage.records || usage.recent)) || [];
const who = account.email || account.wallet || account.account_id; const who = accountDisplayName(account);
let html = let html =
`<div><b>${esc(who)}</b> <span class="pill">${esc(account.role)}</span> ` + `<div><b>${esc(who)}</b> <span class="pill">${esc(account.role)}</span> ` +
`<button class="small" style="float:right" onclick="doLogout()">log out</button></div>` + `<button class="small" style="float:right" onclick="doLogout()">log out</button></div>` +
`<div style="margin:6px 0;display:flex;gap:8px;align-items:center;flex-wrap:wrap">` +
`<label class="dim" style="margin:0">nickname</label>` +
`<input id="account-nickname" type="text" value="${esc(account.nickname || "")}" placeholder="display name" style="min-width:180px">` +
`<button class="small" type="button" onclick="saveNickname()">save</button></div>` +
`<div style="margin:6px 0">balance: <b class="${total_balance > 0 ? "ok" : "bad"}">` + `<div style="margin:6px 0">balance: <b class="${total_balance > 0 ? "ok" : "bad"}">` +
`${usdt(total_balance)}</b> USDT · requests: <b>${usage.requests}</b> · ` + `${usdt(total_balance)}</b> USDT · requests: <b>${usage.requests}</b> · ` +
`tokens: <b>${usage.total_tokens}</b> · spent: <b>${usdt(usage.total_cost)}</b> USDT</div>`; `tokens: <b>${usage.total_tokens}</b> · spent: <b>${usdt(usage.total_cost)}</b> USDT</div>`;
@@ -1617,7 +1667,7 @@ async function renderAdminPanel() {
const rows = (r.data.accounts || []).map(a => { const rows = (r.data.accounts || []).map(a => {
const balance = Object.values(a.balances || {}).reduce((x, y) => x + y, 0); const balance = Object.values(a.balances || {}).reduce((x, y) => x + y, 0);
return [ return [
esc(short(a.email || a.wallet || a.account_id, 24)), esc(short(accountDisplayName(a), 24)),
`<span class="pill">${esc(a.role)}</span>`, `<span class="pill">${esc(a.role)}</span>`,
String((a.api_keys || []).length), String((a.api_keys || []).length),
`<span class="num ${balance > 0 ? "ok" : "bad"}">${usdt(balance)}</span>`, `<span class="num ${balance > 0 ? "ok" : "bad"}">${usdt(balance)}</span>`,

View File

@@ -570,7 +570,7 @@ class _NodeEntry:
"benchmark_tokens_per_sec", "quantization", "managed_assignment", "benchmark_tokens_per_sec", "quantization", "managed_assignment",
"model_tokens_per_sec", "model_tokens_per_sec",
"pending_directives", "last_heartbeat", "tracker_mode", "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) # heartbeat stats (reported by node, cumulative)
"total_requests", "failed_requests", "queue_depth", "proxy_inflight", "uptime_seconds", "total_requests", "failed_requests", "queue_depth", "proxy_inflight", "uptime_seconds",
"current_requests", "current_requests",
@@ -606,6 +606,7 @@ class _NodeEntry:
relay_addr: str | None = None, relay_addr: str | None = None,
cert_fingerprint: str | None = None, cert_fingerprint: str | None = None,
peer_id: str | None = None, peer_id: str | None = None,
friendly_name: str | None = None,
) -> None: ) -> None:
self.node_id = node_id self.node_id = node_id
self.endpoint = endpoint self.endpoint = endpoint
@@ -632,6 +633,7 @@ class _NodeEntry:
self.relay_addr = relay_addr self.relay_addr = relay_addr
self.cert_fingerprint = cert_fingerprint self.cert_fingerprint = cert_fingerprint
self.peer_id = peer_id self.peer_id = peer_id
self.friendly_name = friendly_name
self.pending_directives: list[dict] = [] self.pending_directives: list[dict] = []
self.last_heartbeat: float = time.monotonic() self.last_heartbeat: float = time.monotonic()
self.total_requests: int = 0 self.total_requests: int = 0
@@ -2231,6 +2233,22 @@ def _registration_ban_error(contracts: Any | None, wallet_address: str | None) -
return 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( def _tracker_log(
server: "_TrackerHTTPServer", server: "_TrackerHTTPServer",
level: str, level: str,
@@ -2606,6 +2624,9 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
if self.path == "/v1/auth/logout": if self.path == "/v1/auth/logout":
self._handle_auth_logout() self._handle_auth_logout()
return return
if self.path == "/v1/account/profile":
self._handle_account_profile()
return
if self.path == "/v1/account/keys": if self.path == "/v1/account/keys":
self._handle_account_key_create() self._handle_account_key_create()
return return
@@ -2923,6 +2944,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
"nodes": [ "nodes": [
{ {
"node_id": node.node_id, "node_id": node.node_id,
"friendly_name": node.friendly_name,
"endpoint": node.endpoint, "endpoint": node.endpoint,
"relay_addr": node.relay_addr, "relay_addr": node.relay_addr,
"peer_id": node.peer_id, "peer_id": node.peer_id,
@@ -4001,6 +4023,11 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
relay_addr = body.get("relay_addr") or None relay_addr = body.get("relay_addr") or None
cert_fingerprint = body.get("cert_fingerprint") or None cert_fingerprint = body.get("cert_fingerprint") or None
peer_id = body.get("peer_id") 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( node_id = _node_id_for_registration(
endpoint, endpoint,
@@ -4035,6 +4062,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
relay_addr=relay_addr, relay_addr=relay_addr,
cert_fingerprint=cert_fingerprint, cert_fingerprint=cert_fingerprint,
peer_id=peer_id, peer_id=peer_id,
friendly_name=friendly_name,
) )
with server.lock: with server.lock:
self._purge_expired_nodes() self._purge_expired_nodes()
@@ -4053,6 +4081,12 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
for eid in stale_ids: for eid in stale_ids:
old = server.registry.pop(eid) old = server.registry.pop(eid)
stale_entries.append((eid, old)) 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( is_topology_change = node_id not in stale_ids or any(
(old.shard_start, old.shard_end) != (entry.shard_start, entry.shard_end) (old.shard_start, old.shard_end) != (entry.shard_start, entry.shard_end)
for eid, old in stale_entries for eid, old in stale_entries
@@ -4162,6 +4196,12 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
entry.uptime_seconds = float(body["uptime_seconds"]) entry.uptime_seconds = float(body["uptime_seconds"])
if "status" in body and body["status"] in ("ready", "loading"): if "status" in body and body["status"] in ("ready", "loading"):
entry.status = body["status"] 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) directives = list(entry.pending_directives)
entry.pending_directives.clear() entry.pending_directives.clear()
new_assignment = entry.pending_new_assignment new_assignment = entry.pending_new_assignment
@@ -4443,6 +4483,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
email=body.get("email"), email=body.get("email"),
wallet=body.get("wallet"), wallet=body.get("wallet"),
password=str(body.get("password") or ""), password=str(body.get("password") or ""),
nickname=body.get("nickname"),
) )
except ValueError as exc: except ValueError as exc:
self._send_json(400, {"error": str(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, "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): def _handle_account_key_create(self):
accounts = self._require_accounts() accounts = self._require_accounts()
if accounts is None: 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 shard_end = int(payload["shard_end"]) if payload.get("shard_end") is not None else None
except (TypeError, ValueError): except (TypeError, ValueError):
return return
try:
friendly_name = _normalize_friendly_name(payload.get("friendly_name"))
except ValueError:
friendly_name = None
entry = _NodeEntry( entry = _NodeEntry(
node_id=node_id, node_id=node_id,
endpoint=endpoint.rstrip("/"), endpoint=endpoint.rstrip("/"),
@@ -6095,6 +6161,7 @@ class TrackerServer:
if isinstance(payload.get("downloaded_models"), list) if isinstance(payload.get("downloaded_models"), list)
else None else None
), ),
friendly_name=_normalize_friendly_name(payload.get("friendly_name")),
) )
with self._lock: with self._lock:
self._registry[node_id] = entry self._registry[node_id] = entry

View File

@@ -48,6 +48,36 @@ def test_register_rejects_duplicate_identifiers():
store.register(email="DUP@example.com", password="other-secret") 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(): def test_login_by_email_or_wallet():
store = AccountStore() store = AccountStore()
account = store.register( account = store.register(
@@ -168,6 +198,27 @@ def test_register_login_and_account_view(account_tracker):
assert me["usage"]["requests"] == 0 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): def test_login_sets_cookie_and_cookie_auth_survives_tracker_restart(tmp_path):
accounts_db = str(tmp_path / "accounts.db") accounts_db = str(tmp_path / "accounts.db")
tracker = TrackerServer( tracker = TrackerServer(

View File

@@ -52,6 +52,34 @@ def test_dashboard_chat_uses_streaming_fetch():
assert 'console.error("chat stream failed", err)' in html 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(): def test_dashboard_chat_model_selector_shows_health_and_speed():
tracker = TrackerServer() tracker = TrackerServer()
port = tracker.start() port = tracker.start()
@@ -64,6 +92,9 @@ def test_dashboard_chat_model_selector_shows_health_and_speed():
assert "chatModelHealthHp" in html assert "chatModelHealthHp" in html
assert "modelServedCopiesFromMap" in html assert "modelServedCopiesFromMap" in html
assert "nodeDisplayName" in html
assert "accountDisplayName" in html
assert "saveNickname" in html
assert "chatModelTypicalTps" in html assert "chatModelTypicalTps" in html
assert "chatModelOptionLabel" in html assert "chatModelOptionLabel" in html
assert "findRoutingForModel" in html assert "findRoutingForModel" in html