Spaces:
Running
Running
Update websocket_hub.py
Browse files- websocket_hub.py +288 -7
websocket_hub.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
"""
|
| 3 |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 4 |
-
β K1RL QUASAR β CENTRAL WEBSOCKET HUB v2.
|
| 5 |
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
|
| 6 |
β β
|
| 7 |
β Architecture role: INGEST β NORMALIZE β BROADCAST β
|
|
@@ -12,9 +12,13 @@
|
|
| 12 |
β β’ Hub NEVER writes back to publishers β
|
| 13 |
β β’ Hub stores latest snapshot per asset (NO history) β
|
| 14 |
β β
|
| 15 |
-
β
|
| 16 |
-
β
|
| 17 |
-
β
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
β β
|
| 19 |
β TRADE API (served natively β no patch script needed): β
|
| 20 |
β GET /api/trades β full open + closed state + stats β
|
|
@@ -22,7 +26,7 @@
|
|
| 22 |
β GET /api/trades/closed β recent closed trades + stats (?limit=N) β
|
| 23 |
β GET /api/health β service health including trade counts β
|
| 24 |
β β
|
| 25 |
-
β VERSION: v2.
|
| 26 |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
"""
|
| 28 |
|
|
@@ -32,6 +36,8 @@ import glob
|
|
| 32 |
import json
|
| 33 |
import logging
|
| 34 |
import os
|
|
|
|
|
|
|
| 35 |
import time
|
| 36 |
from collections import deque
|
| 37 |
from datetime import datetime
|
|
@@ -473,7 +479,7 @@ except Exception:
|
|
| 473 |
app = FastAPI(
|
| 474 |
title="K1RL QUASAR Hub",
|
| 475 |
description="Central WebSocket hub β ingest, normalize, broadcast (one-way)",
|
| 476 |
-
version="2.
|
| 477 |
)
|
| 478 |
app.add_middleware(
|
| 479 |
CORSMiddleware,
|
|
@@ -701,7 +707,7 @@ async def api_health():
|
|
| 701 |
state = _trade_parser.get_state()
|
| 702 |
return JSONResponse({
|
| 703 |
"service": "websocket_hub",
|
| 704 |
-
"version": "v2.
|
| 705 |
"status": "running",
|
| 706 |
"log_files": len(glob.glob(os.path.join(_LOG_DIR, "*.log"))),
|
| 707 |
"trade_open": len(state["open"]),
|
|
@@ -710,6 +716,281 @@ async def api_health():
|
|
| 710 |
})
|
| 711 |
|
| 712 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 713 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 714 |
# SECTION 8 β DASHBOARD UI ROUTES
|
| 715 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
"""
|
| 3 |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 4 |
+
β K1RL QUASAR β CENTRAL WEBSOCKET HUB v2.2-ranker-logs β
|
| 5 |
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
|
| 6 |
β β
|
| 7 |
β Architecture role: INGEST β NORMALIZE β BROADCAST β
|
|
|
|
| 12 |
β β’ Hub NEVER writes back to publishers β
|
| 13 |
β β’ Hub stores latest snapshot per asset (NO history) β
|
| 14 |
β β
|
| 15 |
+
β RANKER LOGS API (FIX v2.2 β moved here from hub_dashboard_service port 8052): β
|
| 16 |
+
β GET /api/ranker/logs/recent β recent log entries (?limit=N&category=X) β
|
| 17 |
+
β GET /api/ranker/logs/stats β log statistics β
|
| 18 |
+
β GET /api/ranker/logs/asset/X β logs for asset X β
|
| 19 |
+
β GET /api/ranker/logs/level/X β logs by level β
|
| 20 |
+
β GET /api/ranker/logs/export β download JSON β
|
| 21 |
+
β GET /api/ranker/logs/debug β file discovery diagnostics β
|
| 22 |
β β
|
| 23 |
β TRADE API (served natively β no patch script needed): β
|
| 24 |
β GET /api/trades β full open + closed state + stats β
|
|
|
|
| 26 |
β GET /api/trades/closed β recent closed trades + stats (?limit=N) β
|
| 27 |
β GET /api/health β service health including trade counts β
|
| 28 |
β β
|
| 29 |
+
β VERSION: v2.2-ranker-logs | 2026-04-04 β
|
| 30 |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
"""
|
| 32 |
|
|
|
|
| 36 |
import json
|
| 37 |
import logging
|
| 38 |
import os
|
| 39 |
+
import re
|
| 40 |
+
import threading
|
| 41 |
import time
|
| 42 |
from collections import deque
|
| 43 |
from datetime import datetime
|
|
|
|
| 479 |
app = FastAPI(
|
| 480 |
title="K1RL QUASAR Hub",
|
| 481 |
description="Central WebSocket hub β ingest, normalize, broadcast (one-way)",
|
| 482 |
+
version="2.2.0",
|
| 483 |
)
|
| 484 |
app.add_middleware(
|
| 485 |
CORSMiddleware,
|
|
|
|
| 707 |
state = _trade_parser.get_state()
|
| 708 |
return JSONResponse({
|
| 709 |
"service": "websocket_hub",
|
| 710 |
+
"version": "v2.2-ranker-logs",
|
| 711 |
"status": "running",
|
| 712 |
"log_files": len(glob.glob(os.path.join(_LOG_DIR, "*.log"))),
|
| 713 |
"trade_open": len(state["open"]),
|
|
|
|
| 716 |
})
|
| 717 |
|
| 718 |
|
| 719 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 720 |
+
# SECTION 7b β RANKER LOGS API (FIX: moved here so routes live on port 7860)
|
| 721 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 722 |
+
#
|
| 723 |
+
# ROOT CAUSE of HTTP 404 on /api/ranker/logs/*:
|
| 724 |
+
# - hub_dashboard_service.py (Flask) runs on port 8052 β NOT publicly accessible on HF Spaces
|
| 725 |
+
# - websocket_hub.py (FastAPI/uvicorn) runs on port 7860 β the ONLY public port
|
| 726 |
+
# - The browser fetches /api/ranker/logs/recent β hits port 7860 β no route β 404
|
| 727 |
+
#
|
| 728 |
+
# FIX: FileBasedLoggerAdapter + all /api/ranker/logs/* routes added directly here.
|
| 729 |
+
# The ranker writes logs to ./ranker_logs (= /app/ranker_logs). This adapter reads
|
| 730 |
+
# those files directly β no dependency on hub_dashboard_service or in-memory ranker.
|
| 731 |
+
|
| 732 |
+
_TRAINING_RE_HUB = re.compile(
|
| 733 |
+
r'step=(\d+)\s*\|\s*loss=([\d.]+)\s*\|\s*lr=([\d.eE+\-]+)\s*\|\s*assets=(\d+)'
|
| 734 |
+
)
|
| 735 |
+
_JSON_BLOB_RE_HUB = re.compile(r'(\{.*\})\s*$')
|
| 736 |
+
|
| 737 |
+
|
| 738 |
+
def _enrich_training_entry(entry: dict) -> dict:
|
| 739 |
+
"""Attach parsed `data` dict to TRAINING entries so dashboard KPI cards populate."""
|
| 740 |
+
if entry.get("category", "").upper() != "TRAINING":
|
| 741 |
+
return entry
|
| 742 |
+
if entry.get("data"):
|
| 743 |
+
return entry
|
| 744 |
+
msg = entry.get("message", "")
|
| 745 |
+
m = _TRAINING_RE_HUB.search(msg)
|
| 746 |
+
if m:
|
| 747 |
+
entry["data"] = {
|
| 748 |
+
"step": int(m.group(1)),
|
| 749 |
+
"loss": float(m.group(2)),
|
| 750 |
+
"lr": float(m.group(3)),
|
| 751 |
+
"asset_count": int(m.group(4)),
|
| 752 |
+
}
|
| 753 |
+
return entry
|
| 754 |
+
jm = _JSON_BLOB_RE_HUB.search(msg)
|
| 755 |
+
if jm:
|
| 756 |
+
try:
|
| 757 |
+
blob = json.loads(jm.group(1))
|
| 758 |
+
if "step" in blob:
|
| 759 |
+
entry["data"] = {
|
| 760 |
+
"step": blob.get("step", 0),
|
| 761 |
+
"loss": blob.get("loss", 0.0),
|
| 762 |
+
"lr": blob.get("lr", 0.0),
|
| 763 |
+
"asset_count": blob.get("asset_count", blob.get("assets", 0)),
|
| 764 |
+
}
|
| 765 |
+
except (ValueError, KeyError):
|
| 766 |
+
pass
|
| 767 |
+
return entry
|
| 768 |
+
|
| 769 |
+
|
| 770 |
+
class FileBasedLoggerAdapter:
|
| 771 |
+
"""
|
| 772 |
+
Reads ranker log files from disk and exposes the RankerLogger interface
|
| 773 |
+
expected by the /api/ranker/logs/* endpoints.
|
| 774 |
+
No in-memory ranker process required.
|
| 775 |
+
"""
|
| 776 |
+
_CAT_RE = re.compile(r'\|\s*(INFO|DEBUG|WARNING|ERROR|CRITICAL)\s*\|\s*([A-Z_]+)\s*\|')
|
| 777 |
+
_ASSET_RE = re.compile(r'\|\s*(?:TRADE|SIGNAL)\s*\|\s*(\w+)\s*\|')
|
| 778 |
+
_TS_RE = re.compile(r'\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]')
|
| 779 |
+
|
| 780 |
+
def __init__(self, log_dir: str):
|
| 781 |
+
self._log_dir = log_dir
|
| 782 |
+
self._lock = threading.RLock()
|
| 783 |
+
|
| 784 |
+
def _find_files(self) -> list:
|
| 785 |
+
candidate_dirs = [
|
| 786 |
+
self._log_dir,
|
| 787 |
+
"/app/ranker_logs",
|
| 788 |
+
str(Path(__file__).parent / "ranker_logs"),
|
| 789 |
+
"./ranker_logs",
|
| 790 |
+
"/home/user/ranker_logs",
|
| 791 |
+
"/tmp/ranker_logs",
|
| 792 |
+
]
|
| 793 |
+
all_files: list = []
|
| 794 |
+
seen: set = set()
|
| 795 |
+
for d in candidate_dirs:
|
| 796 |
+
for f in sorted(glob.glob(str(Path(d) / "*.log*"))):
|
| 797 |
+
if f not in seen:
|
| 798 |
+
seen.add(f)
|
| 799 |
+
all_files.append(f)
|
| 800 |
+
return all_files
|
| 801 |
+
|
| 802 |
+
def _read_lines(self, n_tail: int = 500) -> list:
|
| 803 |
+
files = self._find_files()
|
| 804 |
+
raw: list = []
|
| 805 |
+
for fpath in files[-3:]:
|
| 806 |
+
try:
|
| 807 |
+
with open(fpath, "r", encoding="utf-8", errors="replace") as f:
|
| 808 |
+
raw.extend(f.readlines()[-n_tail:])
|
| 809 |
+
except OSError:
|
| 810 |
+
pass
|
| 811 |
+
raw.reverse() # newest first
|
| 812 |
+
return raw
|
| 813 |
+
|
| 814 |
+
def _line_to_entry(self, line: str) -> Optional[dict]:
|
| 815 |
+
ts_m = self._TS_RE.search(line)
|
| 816 |
+
if not ts_m:
|
| 817 |
+
return None
|
| 818 |
+
cat_m = self._CAT_RE.search(line)
|
| 819 |
+
level = cat_m.group(1) if cat_m else "INFO"
|
| 820 |
+
cat = cat_m.group(2).strip() if cat_m else ""
|
| 821 |
+
ast_m = self._ASSET_RE.search(line)
|
| 822 |
+
asset = ast_m.group(1) if ast_m else None
|
| 823 |
+
return {
|
| 824 |
+
"timestamp": ts_m.group(1),
|
| 825 |
+
"level": level,
|
| 826 |
+
"category": cat,
|
| 827 |
+
"message": line.strip(),
|
| 828 |
+
"asset": asset,
|
| 829 |
+
"data": None,
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
def get_recent(self, n: int = 50, category: Optional[str] = None) -> list:
|
| 833 |
+
entries: list = []
|
| 834 |
+
for line in self._read_lines(n_tail=max(n * 3, 200)):
|
| 835 |
+
e = self._line_to_entry(line)
|
| 836 |
+
if e is None:
|
| 837 |
+
continue
|
| 838 |
+
if category and category.upper() not in line.upper():
|
| 839 |
+
continue
|
| 840 |
+
entries.append(e)
|
| 841 |
+
if len(entries) >= n:
|
| 842 |
+
break
|
| 843 |
+
return entries
|
| 844 |
+
|
| 845 |
+
def get_by_asset(self, asset: str, n: int = 30) -> list:
|
| 846 |
+
entries: list = []
|
| 847 |
+
for line in self._read_lines(n_tail=500):
|
| 848 |
+
if asset.upper() not in line.upper():
|
| 849 |
+
continue
|
| 850 |
+
e = self._line_to_entry(line)
|
| 851 |
+
if e:
|
| 852 |
+
entries.append(e)
|
| 853 |
+
if len(entries) >= n:
|
| 854 |
+
break
|
| 855 |
+
return entries
|
| 856 |
+
|
| 857 |
+
def get_by_level(self, level: str, n: int = 50) -> list:
|
| 858 |
+
entries: list = []
|
| 859 |
+
for line in self._read_lines(n_tail=500):
|
| 860 |
+
e = self._line_to_entry(line)
|
| 861 |
+
if e and e["level"].upper() == level.upper():
|
| 862 |
+
entries.append(e)
|
| 863 |
+
if len(entries) >= n:
|
| 864 |
+
break
|
| 865 |
+
return entries
|
| 866 |
+
|
| 867 |
+
def get_stats(self) -> dict:
|
| 868 |
+
by_category: dict = {}
|
| 869 |
+
by_level: dict = {}
|
| 870 |
+
by_asset: dict = {}
|
| 871 |
+
errors: dict = {}
|
| 872 |
+
total: int = 0
|
| 873 |
+
for line in self._read_lines(n_tail=2000):
|
| 874 |
+
e = self._line_to_entry(line)
|
| 875 |
+
if not e:
|
| 876 |
+
continue
|
| 877 |
+
total += 1
|
| 878 |
+
by_level[e["level"]] = by_level.get(e["level"], 0) + 1
|
| 879 |
+
by_category[e["category"]] = by_category.get(e["category"], 0) + 1
|
| 880 |
+
if e["asset"]:
|
| 881 |
+
by_asset[e["asset"]] = by_asset.get(e["asset"], 0) + 1
|
| 882 |
+
if e["level"] in ("ERROR", "CRITICAL"):
|
| 883 |
+
errors[e["category"]] = errors.get(e["category"], 0) + 1
|
| 884 |
+
return {
|
| 885 |
+
"total_events": total,
|
| 886 |
+
"by_level": by_level,
|
| 887 |
+
"by_category": by_category,
|
| 888 |
+
"by_asset": by_asset,
|
| 889 |
+
"errors": errors,
|
| 890 |
+
"buffer_size": total,
|
| 891 |
+
"buffer_capacity": total,
|
| 892 |
+
}
|
| 893 |
+
|
| 894 |
+
def export_json(self, filepath: str, n: int = 500) -> None:
|
| 895 |
+
entries = self.get_recent(n)
|
| 896 |
+
with open(filepath, "w") as f:
|
| 897 |
+
json.dump({
|
| 898 |
+
"export_time": datetime.utcnow().isoformat(),
|
| 899 |
+
"count": len(entries),
|
| 900 |
+
"logs": entries,
|
| 901 |
+
}, f, indent=2)
|
| 902 |
+
|
| 903 |
+
def clear_buffer(self) -> None:
|
| 904 |
+
pass # file-based β nothing to clear
|
| 905 |
+
|
| 906 |
+
|
| 907 |
+
# Singleton adapter β reads from the same /app/ranker_logs the ranker writes to
|
| 908 |
+
_log_adapter = FileBasedLoggerAdapter(log_dir=_LOG_DIR)
|
| 909 |
+
|
| 910 |
+
|
| 911 |
+
@app.get("/api/ranker/logs/recent")
|
| 912 |
+
async def api_ranker_logs_recent(limit: int = 50, category: Optional[str] = None):
|
| 913 |
+
"""GET /api/ranker/logs/recent?limit=80&category=TRAINING"""
|
| 914 |
+
try:
|
| 915 |
+
entries = _log_adapter.get_recent(n=limit, category=category)
|
| 916 |
+
entries = [_enrich_training_entry(e) for e in entries]
|
| 917 |
+
return JSONResponse({
|
| 918 |
+
"logs": entries,
|
| 919 |
+
"count": len(entries),
|
| 920 |
+
"stats": _log_adapter.get_stats(),
|
| 921 |
+
})
|
| 922 |
+
except Exception as exc:
|
| 923 |
+
logger.exception(f"[api_ranker_logs_recent] {exc}")
|
| 924 |
+
return JSONResponse({"logs": [], "count": 0, "error": str(exc)}, status_code=200)
|
| 925 |
+
|
| 926 |
+
|
| 927 |
+
@app.get("/api/ranker/logs/stats")
|
| 928 |
+
async def api_ranker_logs_stats():
|
| 929 |
+
"""GET /api/ranker/logs/stats"""
|
| 930 |
+
try:
|
| 931 |
+
return JSONResponse(_log_adapter.get_stats())
|
| 932 |
+
except Exception as exc:
|
| 933 |
+
return JSONResponse({"error": str(exc)}, status_code=500)
|
| 934 |
+
|
| 935 |
+
|
| 936 |
+
@app.get("/api/ranker/logs/asset/{asset}")
|
| 937 |
+
async def api_ranker_logs_asset(asset: str, limit: int = 30):
|
| 938 |
+
"""GET /api/ranker/logs/asset/V75?limit=30"""
|
| 939 |
+
try:
|
| 940 |
+
entries = _log_adapter.get_by_asset(asset, n=limit)
|
| 941 |
+
return JSONResponse({"asset": asset, "logs": entries, "count": len(entries)})
|
| 942 |
+
except Exception as exc:
|
| 943 |
+
return JSONResponse({"asset": asset, "logs": [], "count": 0, "error": str(exc)})
|
| 944 |
+
|
| 945 |
+
|
| 946 |
+
@app.get("/api/ranker/logs/level/{level}")
|
| 947 |
+
async def api_ranker_logs_level(level: str, limit: int = 50):
|
| 948 |
+
"""GET /api/ranker/logs/level/ERROR?limit=50"""
|
| 949 |
+
try:
|
| 950 |
+
entries = _log_adapter.get_by_level(level, n=limit)
|
| 951 |
+
return JSONResponse({"level": level.upper(), "logs": entries, "count": len(entries)})
|
| 952 |
+
except Exception as exc:
|
| 953 |
+
return JSONResponse({"level": level.upper(), "logs": [], "count": 0, "error": str(exc)})
|
| 954 |
+
|
| 955 |
+
|
| 956 |
+
@app.get("/api/ranker/logs/export")
|
| 957 |
+
async def api_ranker_logs_export(limit: int = 500):
|
| 958 |
+
"""GET /api/ranker/logs/export β download JSON"""
|
| 959 |
+
from fastapi.responses import FileResponse as _FileResponse
|
| 960 |
+
try:
|
| 961 |
+
export_path = "/tmp/ranker_logs_export.json"
|
| 962 |
+
_log_adapter.export_json(export_path, n=limit)
|
| 963 |
+
return _FileResponse(
|
| 964 |
+
export_path,
|
| 965 |
+
media_type="application/json",
|
| 966 |
+
filename="ranker_logs_export.json",
|
| 967 |
+
)
|
| 968 |
+
except Exception as exc:
|
| 969 |
+
return JSONResponse({"error": str(exc)}, status_code=500)
|
| 970 |
+
|
| 971 |
+
|
| 972 |
+
@app.post("/api/ranker/logs/clear")
|
| 973 |
+
async def api_ranker_logs_clear():
|
| 974 |
+
"""POST /api/ranker/logs/clear"""
|
| 975 |
+
try:
|
| 976 |
+
_log_adapter.clear_buffer()
|
| 977 |
+
return JSONResponse({"status": "cleared"})
|
| 978 |
+
except Exception as exc:
|
| 979 |
+
return JSONResponse({"error": str(exc)}, status_code=500)
|
| 980 |
+
|
| 981 |
+
|
| 982 |
+
@app.get("/api/ranker/logs/debug")
|
| 983 |
+
async def api_ranker_logs_debug():
|
| 984 |
+
"""GET /api/ranker/logs/debug β show which log files are found"""
|
| 985 |
+
files = _log_adapter._find_files()
|
| 986 |
+
return JSONResponse({
|
| 987 |
+
"log_dir": _LOG_DIR,
|
| 988 |
+
"files_found": files,
|
| 989 |
+
"file_count": len(files),
|
| 990 |
+
"stats": _log_adapter.get_stats(),
|
| 991 |
+
})
|
| 992 |
+
|
| 993 |
+
|
| 994 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 995 |
# SECTION 8 β DASHBOARD UI ROUTES
|
| 996 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|