Merge branch 'master' of https://git.d-popov.com/popov/neuron-tai
# Conflicts: # packages/tracker/meshnet_tracker/cli.py # packages/tracker/meshnet_tracker/dashboard.html # packages/tracker/meshnet_tracker/server.py # tests/test_dashboard.py
This commit is contained in:
@@ -8,7 +8,8 @@ regular user.
|
||||
Mutations are append-only events with unique ids — the same replication
|
||||
model as ``BillingLedger`` — so accounts and API keys converge across the
|
||||
tracker hive via gossip, and every dashboard can serve registration/login.
|
||||
Sessions are deliberately local to each tracker (bearer tokens in memory).
|
||||
Sessions are local to each tracker and persisted so dashboard cookies survive
|
||||
tracker restarts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -115,6 +116,8 @@ class AccountStore:
|
||||
"account_id": account_id,
|
||||
"expires": time.time() + SESSION_TTL,
|
||||
}
|
||||
self._dirty = True
|
||||
self.save_to_db()
|
||||
return token
|
||||
|
||||
def session_account(self, token: str | None) -> dict | None:
|
||||
@@ -134,7 +137,9 @@ class AccountStore:
|
||||
if not token:
|
||||
return
|
||||
with self._lock:
|
||||
self._sessions.pop(token, None)
|
||||
if self._sessions.pop(token, None) is not None:
|
||||
self._dirty = True
|
||||
self.save_to_db()
|
||||
|
||||
# ---- API keys ----
|
||||
|
||||
@@ -271,6 +276,10 @@ class AccountStore:
|
||||
"CREATE TABLE IF NOT EXISTS account_events "
|
||||
"(event_id TEXT PRIMARY KEY, payload TEXT NOT NULL, ts REAL NOT NULL)"
|
||||
)
|
||||
con.execute(
|
||||
"CREATE TABLE IF NOT EXISTS account_sessions "
|
||||
"(token TEXT PRIMARY KEY, account_id TEXT NOT NULL, expires REAL NOT NULL)"
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
@@ -279,6 +288,10 @@ class AccountStore:
|
||||
rows = con.execute(
|
||||
"SELECT payload FROM account_events ORDER BY ts, event_id"
|
||||
).fetchall()
|
||||
session_rows = con.execute(
|
||||
"SELECT token, account_id, expires FROM account_sessions WHERE expires >= ?",
|
||||
(time.time(),),
|
||||
).fetchall()
|
||||
con.close()
|
||||
with self._lock:
|
||||
for (payload,) in rows:
|
||||
@@ -288,6 +301,11 @@ class AccountStore:
|
||||
continue
|
||||
if event.get("id") not in self._seen_event_ids:
|
||||
self._apply_locked(event)
|
||||
self._sessions = {
|
||||
token: {"account_id": account_id, "expires": float(expires)}
|
||||
for token, account_id, expires in session_rows
|
||||
if account_id in self._accounts
|
||||
}
|
||||
self._dirty = False
|
||||
|
||||
def save_to_db(self) -> None:
|
||||
@@ -297,11 +315,21 @@ class AccountStore:
|
||||
if not self._dirty:
|
||||
return
|
||||
events = list(self._event_log)
|
||||
sessions = [
|
||||
(token, session["account_id"], float(session["expires"]))
|
||||
for token, session in self._sessions.items()
|
||||
if session["expires"] >= time.time()
|
||||
]
|
||||
self._dirty = False
|
||||
con = sqlite3.connect(self._db_path) # type: ignore[arg-type]
|
||||
con.executemany(
|
||||
"INSERT OR IGNORE INTO account_events (event_id, payload, ts) VALUES (?, ?, ?)",
|
||||
[(e["id"], json.dumps(e), float(e.get("ts", 0.0))) for e in events],
|
||||
)
|
||||
con.execute("DELETE FROM account_sessions")
|
||||
con.executemany(
|
||||
"INSERT INTO account_sessions (token, account_id, expires) VALUES (?, ?, ?)",
|
||||
sessions,
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
@@ -9,6 +9,12 @@ from pathlib import Path
|
||||
from .accounts import DEFAULT_ACCOUNTS_DB_PATH
|
||||
from .billing import DEFAULT_BILLING_DB_PATH
|
||||
from .hf_pricing import DEFAULT_HF_PRICING_LOG_DB_PATH
|
||||
from .logging_setup import (
|
||||
DEFAULT_LOG_BACKUP_COUNT,
|
||||
DEFAULT_LOG_DIR,
|
||||
DEFAULT_LOG_MAX_BYTES,
|
||||
configure_tracker_file_logging,
|
||||
)
|
||||
from .routing_stats import RoutingConfig
|
||||
from .server import (
|
||||
DEFAULT_CALLER_CREDIT_USDT,
|
||||
@@ -302,6 +308,34 @@ def main() -> None:
|
||||
metavar="SECONDS",
|
||||
help="Half-life for decaying route throughput observations (default 600)",
|
||||
)
|
||||
common.add_argument(
|
||||
"--log-dir",
|
||||
default=DEFAULT_LOG_DIR,
|
||||
metavar="PATH",
|
||||
help=(
|
||||
"Directory for rotating tracker logs "
|
||||
f"(default: {DEFAULT_LOG_DIR}; files: info.log, warning.log, error.log)"
|
||||
),
|
||||
)
|
||||
common.add_argument(
|
||||
"--log-max-bytes",
|
||||
type=int,
|
||||
default=DEFAULT_LOG_MAX_BYTES,
|
||||
metavar="BYTES",
|
||||
help=f"Rotate each tracker log file after this many bytes (default: {DEFAULT_LOG_MAX_BYTES})",
|
||||
)
|
||||
common.add_argument(
|
||||
"--log-backup-count",
|
||||
type=int,
|
||||
default=DEFAULT_LOG_BACKUP_COUNT,
|
||||
metavar="N",
|
||||
help=f"Number of rotated tracker log files to keep per level (default: {DEFAULT_LOG_BACKUP_COUNT})",
|
||||
)
|
||||
common.add_argument(
|
||||
"--no-file-logs",
|
||||
action="store_true",
|
||||
help="Disable rotating tracker log files and only write to the terminal",
|
||||
)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="meshnet-tracker",
|
||||
@@ -315,6 +349,13 @@ def main() -> None:
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command in {None, "start"}:
|
||||
if not args.no_file_logs:
|
||||
log_dir = configure_tracker_file_logging(
|
||||
args.log_dir,
|
||||
max_bytes=args.log_max_bytes,
|
||||
backup_count=args.log_backup_count,
|
||||
)
|
||||
print(f"meshnet-tracker logs: {log_dir}", flush=True)
|
||||
cluster_peers = [u.strip() for u in args.cluster_peers.split(",") if u.strip()]
|
||||
relay_url = args.relay_url or derive_relay_url_from_public_tracker_url(args.self_url)
|
||||
treasury = None
|
||||
|
||||
@@ -234,6 +234,7 @@
|
||||
<section data-tab="overview" class="wide"><h2>Routing (learned)</h2><div id="routing" class="empty">loading…</div></section>
|
||||
<section data-tab="overview" class="wide"><h2>Call wall</h2><div id="call-wall" class="empty">loading...</div></section>
|
||||
<section data-tab="chat" class="wide chat-section">
|
||||
<h2 style="display:none">Chat / inference</h2>
|
||||
<div class="chat-app">
|
||||
<aside class="chat-sidebar">
|
||||
<button type="button" class="chat-new-btn" onclick="createNewChatSession()">+ New chat</button>
|
||||
@@ -283,7 +284,7 @@ async function fetchJson(path) {
|
||||
const headers = {};
|
||||
const token = localStorage.getItem("meshnet_session");
|
||||
if (token) headers["Authorization"] = "Bearer " + token;
|
||||
const r = await fetch(path, { headers });
|
||||
const r = await fetch(path, { headers, credentials: "same-origin" });
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch { return null; }
|
||||
@@ -1109,6 +1110,7 @@ async function apiCall(path, method, body, bearerToken) {
|
||||
const r = await fetch(path, {
|
||||
method: method || "GET",
|
||||
headers,
|
||||
credentials: "same-origin",
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
@@ -1368,10 +1370,11 @@ async function sendChat() {
|
||||
promptEl.value = "";
|
||||
promptEl.style.height = "auto";
|
||||
chatHistory.push({ role: "user", content: prompt, model: selectedChatModel });
|
||||
const assistantMessage = { role: "assistant", content: "", model: selectedChatModel, streaming: true };
|
||||
chatHistory.push(assistantMessage);
|
||||
renderChatHistory();
|
||||
persistActiveChatSession();
|
||||
renderChatStatus("sending request…");
|
||||
const assistantMsg = { role: "assistant", content: "", model: selectedChatModel, streaming: true };
|
||||
let tokens = 0;
|
||||
let usage = null;
|
||||
const started = Date.now();
|
||||
@@ -1382,6 +1385,7 @@ async function sendChat() {
|
||||
const resp = await fetch("/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers,
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify(body),
|
||||
signal: chatAbortController.signal,
|
||||
});
|
||||
@@ -1396,12 +1400,10 @@ async function sendChat() {
|
||||
const contentType = resp.headers.get("Content-Type") || "";
|
||||
if (!resp.body || !contentType.includes("text/event-stream")) {
|
||||
const data = await resp.json();
|
||||
assistantMsg.content = (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) || "";
|
||||
assistantMessage.content = (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) || "";
|
||||
usage = data.usage || null;
|
||||
tokens = (usage && usage.completion_tokens) || 0;
|
||||
} else {
|
||||
chatHistory.push(assistantMsg);
|
||||
renderChatHistory();
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffered = "";
|
||||
@@ -1425,30 +1427,31 @@ async function sendChat() {
|
||||
const delta = chunk.choices && chunk.choices[0] && chunk.choices[0].delta;
|
||||
const piece = delta && delta.content;
|
||||
if (piece) {
|
||||
assistantMsg.content += piece;
|
||||
assistantMessage.content += piece;
|
||||
tokens += 1;
|
||||
updateStreamingChatBubble(assistantMsg.content);
|
||||
updateStreamingChatBubble(assistantMessage.content);
|
||||
const secs = Math.max((Date.now() - started) / 1000, 0.001);
|
||||
renderChatStatus(`generating… ${tokens} tokens · ${(tokens / secs).toFixed(1)} tok/s`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finalizeAssistantMessage(assistantMsg);
|
||||
finalizeAssistantMessage(assistantMessage);
|
||||
renderChatStatus(usage
|
||||
? `done: ${usage.total_tokens ?? "?"} tokens`
|
||||
: `done: ${tokens} tokens`);
|
||||
} catch (err) {
|
||||
if (err && err.name === "AbortError") {
|
||||
if (assistantMsg.content) {
|
||||
finalizeAssistantMessage(assistantMsg);
|
||||
if (assistantMessage.content) {
|
||||
finalizeAssistantMessage(assistantMessage);
|
||||
renderChatStatus(`stopped after ${tokens} tokens`);
|
||||
} else {
|
||||
removeAssistantMessage(assistantMsg);
|
||||
removeAssistantMessage(assistantMessage);
|
||||
persistActiveChatSession();
|
||||
renderChatStatus("stopped");
|
||||
}
|
||||
} else {
|
||||
removeAssistantMessage(assistantMsg);
|
||||
removeAssistantMessage(assistantMessage);
|
||||
const error = (err && err.message) || "request failed";
|
||||
chatHistory.push({ role: "error", content: error, model: selectedChatModel });
|
||||
renderChatHistory();
|
||||
@@ -1557,7 +1560,7 @@ renderChatModels();
|
||||
renderChatHistory();
|
||||
renderChatAuthHint();
|
||||
setInterval(refresh, 4000);
|
||||
setInterval(() => { if (sessionToken) renderAccountPanel(); }, 8000);
|
||||
setInterval(() => { if (sessionToken || isLoggedIn) renderAccountPanel(); }, 8000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
99
packages/tracker/meshnet_tracker/logging_setup.py
Normal file
99
packages/tracker/meshnet_tracker/logging_setup.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Rotating file logging for the tracker CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import TextIO
|
||||
|
||||
|
||||
DEFAULT_LOG_DIR = "logs/tracker"
|
||||
DEFAULT_LOG_MAX_BYTES = 10 * 1024 * 1024
|
||||
DEFAULT_LOG_BACKUP_COUNT = 5
|
||||
TRACKER_LOGGER_NAME = "meshnet.tracker"
|
||||
|
||||
|
||||
class _ExactLevelFilter(logging.Filter):
|
||||
def __init__(self, level: int) -> None:
|
||||
super().__init__()
|
||||
self._level = level
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
return record.levelno == self._level
|
||||
|
||||
|
||||
class _TeeStream:
|
||||
def __init__(self, stream: TextIO, logger: logging.Logger, level: int) -> None:
|
||||
self._stream = stream
|
||||
self._logger = logger
|
||||
self._level = level
|
||||
self._buffer = ""
|
||||
|
||||
def write(self, text: str) -> int:
|
||||
self._stream.write(text)
|
||||
self._stream.flush()
|
||||
self._buffer += text
|
||||
while "\n" in self._buffer:
|
||||
line, self._buffer = self._buffer.split("\n", 1)
|
||||
line = line.rstrip()
|
||||
if line:
|
||||
self._logger.log(self._level, line)
|
||||
return len(text)
|
||||
|
||||
def flush(self) -> None:
|
||||
self._stream.flush()
|
||||
line = self._buffer.rstrip()
|
||||
if line:
|
||||
self._logger.log(self._level, line)
|
||||
self._buffer = ""
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return self._stream.isatty()
|
||||
|
||||
|
||||
def _make_handler(path: Path, level: int, *, max_bytes: int, backup_count: int) -> RotatingFileHandler:
|
||||
handler = RotatingFileHandler(
|
||||
path,
|
||||
maxBytes=max_bytes,
|
||||
backupCount=backup_count,
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler.setLevel(level)
|
||||
handler.addFilter(_ExactLevelFilter(level))
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||
return handler
|
||||
|
||||
|
||||
def configure_tracker_file_logging(
|
||||
log_dir: str | Path = DEFAULT_LOG_DIR,
|
||||
*,
|
||||
max_bytes: int = DEFAULT_LOG_MAX_BYTES,
|
||||
backup_count: int = DEFAULT_LOG_BACKUP_COUNT,
|
||||
tee_stdio: bool = True,
|
||||
) -> Path:
|
||||
"""Configure rotatable info/warning/error log files and return the directory."""
|
||||
|
||||
path = Path(log_dir).expanduser()
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger = logging.getLogger(TRACKER_LOGGER_NAME)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
logger.handlers.clear()
|
||||
logger.addHandler(_make_handler(path / "info.log", logging.INFO, max_bytes=max_bytes, backup_count=backup_count))
|
||||
logger.addHandler(_make_handler(path / "warning.log", logging.WARNING, max_bytes=max_bytes, backup_count=backup_count))
|
||||
logger.addHandler(_make_handler(path / "error.log", logging.ERROR, max_bytes=max_bytes, backup_count=backup_count))
|
||||
|
||||
if tee_stdio:
|
||||
if not isinstance(sys.stdout, _TeeStream):
|
||||
sys.stdout = _TeeStream(sys.stdout, logger, logging.INFO) # type: ignore[assignment]
|
||||
if not isinstance(sys.stderr, _TeeStream):
|
||||
sys.stderr = _TeeStream(sys.stderr, logger, logging.ERROR) # type: ignore[assignment]
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def tracker_logger() -> logging.Logger:
|
||||
return logging.getLogger(TRACKER_LOGGER_NAME)
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user