Upload 2 files
Browse files- app.py +279 -2
- index.html +528 -0
app.py
CHANGED
|
@@ -9,7 +9,7 @@ from fastapi.responses import HTMLResponse, FileResponse, Response
|
|
| 9 |
from fastapi.staticfiles import StaticFiles
|
| 10 |
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
from pydantic import BaseModel
|
| 12 |
-
from typing import List, Dict, Optional
|
| 13 |
import asyncio
|
| 14 |
import aiohttp
|
| 15 |
import random
|
|
@@ -19,7 +19,9 @@ from datetime import datetime, timedelta
|
|
| 19 |
import uvicorn
|
| 20 |
from collections import defaultdict
|
| 21 |
import os
|
| 22 |
-
from urllib.parse import urljoin
|
|
|
|
|
|
|
| 23 |
|
| 24 |
from database import Database
|
| 25 |
from config import config as global_config
|
|
@@ -39,6 +41,24 @@ class PoolMemberAdd(BaseModel):
|
|
| 39 |
priority: int = 1
|
| 40 |
weight: int = 1
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
logger = logging.getLogger("crypto_monitor")
|
| 43 |
|
| 44 |
|
|
@@ -75,6 +95,53 @@ if not trusted_hosts:
|
|
| 75 |
trusted_hosts = ["*"]
|
| 76 |
app.add_middleware(TrustedHostMiddleware, allowed_hosts=trusted_hosts)
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
# WebSocket Manager
|
| 79 |
class ConnectionManager:
|
| 80 |
def __init__(self):
|
|
@@ -321,6 +388,75 @@ def provider_slug(name: str) -> str:
|
|
| 321 |
return name.lower().replace(" ", "_")
|
| 322 |
|
| 323 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
def assemble_providers() -> List[Dict]:
|
| 325 |
providers: List[Dict] = []
|
| 326 |
seen = set()
|
|
@@ -364,6 +500,24 @@ def assemble_providers() -> List[Dict]:
|
|
| 364 |
"timeout_ms": cfg.timeout_ms
|
| 365 |
})
|
| 366 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
return providers
|
| 368 |
|
| 369 |
# Cache for API responses
|
|
@@ -794,6 +948,52 @@ async def providers():
|
|
| 794 |
data = await get_provider_stats()
|
| 795 |
return data
|
| 796 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 797 |
@app.get("/api/status")
|
| 798 |
async def status():
|
| 799 |
"""Get system status for dashboard"""
|
|
@@ -1150,6 +1350,34 @@ async def api_logs(type: str = "all"):
|
|
| 1150 |
return logs
|
| 1151 |
|
| 1152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1153 |
@app.get("/api/alerts")
|
| 1154 |
async def api_alerts():
|
| 1155 |
"""Expose active/unacknowledged alerts for the alerts tab"""
|
|
@@ -1255,6 +1483,16 @@ async def _fetch_hf_registry(kind: str = "models", query: str = "crypto", limit:
|
|
| 1255 |
]
|
| 1256 |
|
| 1257 |
# Update cache
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1258 |
if kind == "models":
|
| 1259 |
HF_MODELS = items
|
| 1260 |
else:
|
|
@@ -1281,6 +1519,45 @@ async def hf_registry(type: str = "models"):
|
|
| 1281 |
return data
|
| 1282 |
|
| 1283 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1284 |
@app.get("/api/hf/search")
|
| 1285 |
async def hf_search(q: str = "", kind: str = "models"):
|
| 1286 |
"""Search over the HF registry."""
|
|
|
|
| 9 |
from fastapi.staticfiles import StaticFiles
|
| 10 |
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
from pydantic import BaseModel
|
| 12 |
+
from typing import List, Dict, Optional, Literal
|
| 13 |
import asyncio
|
| 14 |
import aiohttp
|
| 15 |
import random
|
|
|
|
| 19 |
import uvicorn
|
| 20 |
from collections import defaultdict
|
| 21 |
import os
|
| 22 |
+
from urllib.parse import urljoin, unquote
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from threading import Lock
|
| 25 |
|
| 26 |
from database import Database
|
| 27 |
from config import config as global_config
|
|
|
|
| 41 |
priority: int = 1
|
| 42 |
weight: int = 1
|
| 43 |
|
| 44 |
+
class ProviderCreateRequest(BaseModel):
|
| 45 |
+
name: str
|
| 46 |
+
category: str
|
| 47 |
+
endpoint_url: str
|
| 48 |
+
requires_key: bool = False
|
| 49 |
+
api_key: Optional[str] = None
|
| 50 |
+
rate_limit: Optional[str] = None
|
| 51 |
+
timeout_ms: int = 10000
|
| 52 |
+
health_check_endpoint: Optional[str] = None
|
| 53 |
+
notes: Optional[str] = None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class HFRegistryItemCreate(BaseModel):
|
| 57 |
+
id: str
|
| 58 |
+
kind: Literal["model", "dataset"]
|
| 59 |
+
description: Optional[str] = None
|
| 60 |
+
downloads: Optional[int] = None
|
| 61 |
+
likes: Optional[int] = None
|
| 62 |
logger = logging.getLogger("crypto_monitor")
|
| 63 |
|
| 64 |
|
|
|
|
| 95 |
trusted_hosts = ["*"]
|
| 96 |
app.add_middleware(TrustedHostMiddleware, allowed_hosts=trusted_hosts)
|
| 97 |
|
| 98 |
+
|
| 99 |
+
CUSTOM_REGISTRY_PATH = Path("data/custom_registry.json")
|
| 100 |
+
_registry_lock = Lock()
|
| 101 |
+
_custom_registry: Dict[str, List[Dict]] = {
|
| 102 |
+
"providers": [],
|
| 103 |
+
"hf_models": [],
|
| 104 |
+
"hf_datasets": []
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _load_custom_registry() -> Dict[str, List[Dict]]:
|
| 109 |
+
if not CUSTOM_REGISTRY_PATH.exists():
|
| 110 |
+
return {
|
| 111 |
+
"providers": [],
|
| 112 |
+
"hf_models": [],
|
| 113 |
+
"hf_datasets": []
|
| 114 |
+
}
|
| 115 |
+
try:
|
| 116 |
+
with CUSTOM_REGISTRY_PATH.open("r", encoding="utf-8") as f:
|
| 117 |
+
data = json.load(f)
|
| 118 |
+
return {
|
| 119 |
+
"providers": data.get("providers", []),
|
| 120 |
+
"hf_models": data.get("hf_models", []),
|
| 121 |
+
"hf_datasets": data.get("hf_datasets", []),
|
| 122 |
+
}
|
| 123 |
+
except Exception:
|
| 124 |
+
return {
|
| 125 |
+
"providers": [],
|
| 126 |
+
"hf_models": [],
|
| 127 |
+
"hf_datasets": []
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _save_custom_registry() -> None:
|
| 132 |
+
CUSTOM_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 133 |
+
with CUSTOM_REGISTRY_PATH.open("w", encoding="utf-8") as f:
|
| 134 |
+
json.dump(_custom_registry, f, ensure_ascii=False, indent=2)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _refresh_custom_registry() -> None:
|
| 138 |
+
global _custom_registry
|
| 139 |
+
with _registry_lock:
|
| 140 |
+
_custom_registry = _load_custom_registry()
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
_refresh_custom_registry()
|
| 144 |
+
|
| 145 |
# WebSocket Manager
|
| 146 |
class ConnectionManager:
|
| 147 |
def __init__(self):
|
|
|
|
| 388 |
return name.lower().replace(" ", "_")
|
| 389 |
|
| 390 |
|
| 391 |
+
def _get_custom_providers() -> List[Dict]:
|
| 392 |
+
with _registry_lock:
|
| 393 |
+
return [dict(provider) for provider in _custom_registry.get("providers", [])]
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def _add_custom_provider(payload: Dict) -> Dict:
|
| 397 |
+
slug = provider_slug(payload["name"])
|
| 398 |
+
with _registry_lock:
|
| 399 |
+
existing = _custom_registry.setdefault("providers", [])
|
| 400 |
+
if any(provider_slug(item.get("name", "")) == slug for item in existing):
|
| 401 |
+
raise ValueError("Provider already exists")
|
| 402 |
+
existing.append(payload)
|
| 403 |
+
_save_custom_registry()
|
| 404 |
+
return payload
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def _remove_custom_provider(slug: str) -> bool:
|
| 408 |
+
removed = False
|
| 409 |
+
with _registry_lock:
|
| 410 |
+
providers = _custom_registry.setdefault("providers", [])
|
| 411 |
+
new_list = []
|
| 412 |
+
for item in providers:
|
| 413 |
+
if provider_slug(item.get("name", "")) == slug:
|
| 414 |
+
removed = True
|
| 415 |
+
continue
|
| 416 |
+
new_list.append(item)
|
| 417 |
+
if removed:
|
| 418 |
+
_custom_registry["providers"] = new_list
|
| 419 |
+
_save_custom_registry()
|
| 420 |
+
return removed
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def _get_custom_hf(kind: Literal["models", "datasets"]) -> List[Dict]:
|
| 424 |
+
key = "hf_models" if kind == "models" else "hf_datasets"
|
| 425 |
+
with _registry_lock:
|
| 426 |
+
return [dict(item) for item in _custom_registry.get(key, [])]
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def _add_custom_hf_item(kind: Literal["models", "datasets"], payload: Dict) -> Dict:
|
| 430 |
+
key = "hf_models" if kind == "models" else "hf_datasets"
|
| 431 |
+
identifier = payload.get("id") or payload.get("name")
|
| 432 |
+
if not identifier:
|
| 433 |
+
raise ValueError("id is required")
|
| 434 |
+
with _registry_lock:
|
| 435 |
+
collection = _custom_registry.setdefault(key, [])
|
| 436 |
+
if any((item.get("id") or item.get("name")) == identifier for item in collection):
|
| 437 |
+
raise ValueError("Item already exists")
|
| 438 |
+
collection.append(payload)
|
| 439 |
+
_save_custom_registry()
|
| 440 |
+
return payload
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def _remove_custom_hf_item(kind: Literal["models", "datasets"], identifier: str) -> bool:
|
| 444 |
+
key = "hf_models" if kind == "models" else "hf_datasets"
|
| 445 |
+
removed = False
|
| 446 |
+
with _registry_lock:
|
| 447 |
+
collection = _custom_registry.setdefault(key, [])
|
| 448 |
+
filtered = []
|
| 449 |
+
for item in collection:
|
| 450 |
+
if (item.get("id") or item.get("name")) == identifier:
|
| 451 |
+
removed = True
|
| 452 |
+
continue
|
| 453 |
+
filtered.append(item)
|
| 454 |
+
if removed:
|
| 455 |
+
_custom_registry[key] = filtered
|
| 456 |
+
_save_custom_registry()
|
| 457 |
+
return removed
|
| 458 |
+
|
| 459 |
+
|
| 460 |
def assemble_providers() -> List[Dict]:
|
| 461 |
providers: List[Dict] = []
|
| 462 |
seen = set()
|
|
|
|
| 500 |
"timeout_ms": cfg.timeout_ms
|
| 501 |
})
|
| 502 |
|
| 503 |
+
for custom in _get_custom_providers():
|
| 504 |
+
slug = provider_slug(custom.get("name", ""))
|
| 505 |
+
if not slug or slug in seen:
|
| 506 |
+
continue
|
| 507 |
+
providers.append({
|
| 508 |
+
"name": custom.get("name"),
|
| 509 |
+
"category": custom.get("category", "custom"),
|
| 510 |
+
"base_url": custom.get("base_url") or custom.get("endpoint_url"),
|
| 511 |
+
"endpoints": custom.get("endpoints", {}),
|
| 512 |
+
"health_endpoint": custom.get("health_endpoint") or custom.get("base_url"),
|
| 513 |
+
"requires_key": custom.get("requires_key", False),
|
| 514 |
+
"api_key": custom.get("api_key"),
|
| 515 |
+
"timeout_ms": custom.get("timeout_ms", 10000),
|
| 516 |
+
"rate_limit": custom.get("rate_limit"),
|
| 517 |
+
"notes": custom.get("notes"),
|
| 518 |
+
})
|
| 519 |
+
seen.add(slug)
|
| 520 |
+
|
| 521 |
return providers
|
| 522 |
|
| 523 |
# Cache for API responses
|
|
|
|
| 948 |
data = await get_provider_stats()
|
| 949 |
return data
|
| 950 |
|
| 951 |
+
|
| 952 |
+
@app.get("/api/providers/custom")
|
| 953 |
+
async def providers_custom():
|
| 954 |
+
"""Return custom providers registered through the UI."""
|
| 955 |
+
return _get_custom_providers()
|
| 956 |
+
|
| 957 |
+
|
| 958 |
+
@app.post("/api/providers", status_code=201)
|
| 959 |
+
async def create_provider(request: ProviderCreateRequest):
|
| 960 |
+
"""Create a custom provider entry."""
|
| 961 |
+
name = request.name.strip()
|
| 962 |
+
if not name:
|
| 963 |
+
raise HTTPException(status_code=400, detail="name is required")
|
| 964 |
+
category = request.category.strip() or "custom"
|
| 965 |
+
endpoint_url = request.endpoint_url.strip()
|
| 966 |
+
if not endpoint_url:
|
| 967 |
+
raise HTTPException(status_code=400, detail="endpoint_url is required")
|
| 968 |
+
|
| 969 |
+
payload = {
|
| 970 |
+
"name": name,
|
| 971 |
+
"category": category,
|
| 972 |
+
"base_url": endpoint_url,
|
| 973 |
+
"endpoint_url": endpoint_url,
|
| 974 |
+
"health_endpoint": request.health_check_endpoint.strip() if request.health_check_endpoint else endpoint_url,
|
| 975 |
+
"requires_key": request.requires_key,
|
| 976 |
+
"api_key": request.api_key.strip() if request.api_key else None,
|
| 977 |
+
"timeout_ms": request.timeout_ms,
|
| 978 |
+
"rate_limit": request.rate_limit.strip() if request.rate_limit else None,
|
| 979 |
+
"notes": request.notes.strip() if request.notes else None,
|
| 980 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 981 |
+
}
|
| 982 |
+
try:
|
| 983 |
+
created = _add_custom_provider(payload)
|
| 984 |
+
except ValueError as exc:
|
| 985 |
+
raise HTTPException(status_code=400, detail=str(exc))
|
| 986 |
+
|
| 987 |
+
return {"message": "Provider registered", "provider": created}
|
| 988 |
+
|
| 989 |
+
|
| 990 |
+
@app.delete("/api/providers/{slug}", status_code=204)
|
| 991 |
+
async def delete_provider(slug: str):
|
| 992 |
+
"""Delete a custom provider by slug."""
|
| 993 |
+
if not _remove_custom_provider(slug):
|
| 994 |
+
raise HTTPException(status_code=404, detail="Provider not found")
|
| 995 |
+
return Response(status_code=204)
|
| 996 |
+
|
| 997 |
@app.get("/api/status")
|
| 998 |
async def status():
|
| 999 |
"""Get system status for dashboard"""
|
|
|
|
| 1350 |
return logs
|
| 1351 |
|
| 1352 |
|
| 1353 |
+
@app.get("/api/logs/summary")
|
| 1354 |
+
async def api_logs_summary(hours: int = 24):
|
| 1355 |
+
"""Provide aggregated log summary for dashboard widgets."""
|
| 1356 |
+
rows = db.get_recent_status(hours=hours, limit=500)
|
| 1357 |
+
by_status: Dict[str, int] = defaultdict(int)
|
| 1358 |
+
by_provider: Dict[str, int] = defaultdict(int)
|
| 1359 |
+
last_error = None
|
| 1360 |
+
for row in rows:
|
| 1361 |
+
status = (row.get("status") or "unknown").lower()
|
| 1362 |
+
provider = row.get("provider_name") or "System"
|
| 1363 |
+
by_status[status] += 1
|
| 1364 |
+
by_provider[provider] += 1
|
| 1365 |
+
if status != "online":
|
| 1366 |
+
last_error = last_error or {
|
| 1367 |
+
"provider": provider,
|
| 1368 |
+
"status": status,
|
| 1369 |
+
"timestamp": row.get("timestamp") or row.get("created_at"),
|
| 1370 |
+
"message": row.get("error_message") or row.get("status_code"),
|
| 1371 |
+
}
|
| 1372 |
+
return {
|
| 1373 |
+
"total": len(rows),
|
| 1374 |
+
"by_status": dict(by_status),
|
| 1375 |
+
"by_provider": dict(sorted(by_provider.items(), key=lambda item: item[1], reverse=True)[:8]),
|
| 1376 |
+
"last_error": last_error,
|
| 1377 |
+
"hours": hours,
|
| 1378 |
+
}
|
| 1379 |
+
|
| 1380 |
+
|
| 1381 |
@app.get("/api/alerts")
|
| 1382 |
async def api_alerts():
|
| 1383 |
"""Expose active/unacknowledged alerts for the alerts tab"""
|
|
|
|
| 1483 |
]
|
| 1484 |
|
| 1485 |
# Update cache
|
| 1486 |
+
custom_items = _get_custom_hf("models" if kind == "models" else "datasets")
|
| 1487 |
+
if custom_items:
|
| 1488 |
+
seen_ids = {item.get("id") or item.get("name") for item in items}
|
| 1489 |
+
for custom in custom_items:
|
| 1490 |
+
identifier = custom.get("id") or custom.get("name")
|
| 1491 |
+
if identifier in seen_ids:
|
| 1492 |
+
continue
|
| 1493 |
+
items.append(custom)
|
| 1494 |
+
seen_ids.add(identifier)
|
| 1495 |
+
|
| 1496 |
if kind == "models":
|
| 1497 |
HF_MODELS = items
|
| 1498 |
else:
|
|
|
|
| 1519 |
return data
|
| 1520 |
|
| 1521 |
|
| 1522 |
+
@app.get("/api/hf/custom")
|
| 1523 |
+
async def hf_custom_registry():
|
| 1524 |
+
"""Return custom Hugging Face registry entries."""
|
| 1525 |
+
return {
|
| 1526 |
+
"models": _get_custom_hf("models"),
|
| 1527 |
+
"datasets": _get_custom_hf("datasets"),
|
| 1528 |
+
}
|
| 1529 |
+
|
| 1530 |
+
|
| 1531 |
+
@app.post("/api/hf/custom", status_code=201)
|
| 1532 |
+
async def hf_register_custom(item: HFRegistryItemCreate):
|
| 1533 |
+
"""Register a custom Hugging Face model or dataset."""
|
| 1534 |
+
payload = {
|
| 1535 |
+
"id": item.id.strip(),
|
| 1536 |
+
"description": item.description.strip() if item.description else "",
|
| 1537 |
+
"downloads": item.downloads or 0,
|
| 1538 |
+
"likes": item.likes or 0,
|
| 1539 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 1540 |
+
}
|
| 1541 |
+
target_kind: Literal["models", "datasets"] = "models" if item.kind == "model" else "datasets"
|
| 1542 |
+
try:
|
| 1543 |
+
created = _add_custom_hf_item(target_kind, payload)
|
| 1544 |
+
except ValueError as exc:
|
| 1545 |
+
raise HTTPException(status_code=400, detail=str(exc))
|
| 1546 |
+
return {"message": "Item added", "item": created}
|
| 1547 |
+
|
| 1548 |
+
|
| 1549 |
+
@app.delete("/api/hf/custom/{kind}/{identifier}", status_code=204)
|
| 1550 |
+
async def hf_delete_custom(kind: str, identifier: str):
|
| 1551 |
+
"""Remove a custom HF model or dataset."""
|
| 1552 |
+
kind = kind.lower()
|
| 1553 |
+
if kind not in {"model", "dataset"}:
|
| 1554 |
+
raise HTTPException(status_code=400, detail="kind must be 'model' or 'dataset'")
|
| 1555 |
+
decoded = unquote(identifier)
|
| 1556 |
+
if not _remove_custom_hf_item("models" if kind == "model" else "datasets", decoded):
|
| 1557 |
+
raise HTTPException(status_code=404, detail="Item not found")
|
| 1558 |
+
return Response(status_code=204)
|
| 1559 |
+
|
| 1560 |
+
|
| 1561 |
@app.get("/api/hf/search")
|
| 1562 |
async def hf_search(q: str = "", kind: str = "models"):
|
| 1563 |
"""Search over the HF registry."""
|
index.html
CHANGED
|
@@ -649,6 +649,111 @@
|
|
| 649 |
position: relative;
|
| 650 |
}
|
| 651 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 652 |
.field input {
|
| 653 |
width: 100%;
|
| 654 |
border-radius: 999px;
|
|
@@ -903,6 +1008,10 @@
|
|
| 903 |
<span class="icon">🤗</span>
|
| 904 |
<span>Hugging Face</span>
|
| 905 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 906 |
</div>
|
| 907 |
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
|
| 908 |
<span class="label-mono" id="label-last-refresh">Last refresh: —</span>
|
|
@@ -1009,6 +1118,12 @@
|
|
| 1009 |
let autoRefreshTimer = null;
|
| 1010 |
let ws = null;
|
| 1011 |
let wsConnected = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1012 |
|
| 1013 |
const panelLeftTitle = document.getElementById('panel-left-title');
|
| 1014 |
const panelLeftSubtitle = document.getElementById('panel-left-subtitle');
|
|
@@ -1091,6 +1206,28 @@
|
|
| 1091 |
throw lastError || new Error('All endpoints failed');
|
| 1092 |
}
|
| 1093 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1094 |
function formatNumber(num) {
|
| 1095 |
if (num === null || num === undefined || isNaN(num)) return '—';
|
| 1096 |
if (Math.abs(num) >= 1e9) return (num / 1e9).toFixed(1) + 'B';
|
|
@@ -1104,6 +1241,10 @@
|
|
| 1104 |
return num.toFixed(digits) + '%';
|
| 1105 |
}
|
| 1106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1107 |
function setActiveTab(tab) {
|
| 1108 |
currentTab = tab;
|
| 1109 |
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
@@ -1171,6 +1312,14 @@
|
|
| 1171 |
panelRightSubtitle.textContent = 'جستجو، رفرش registry و تست اتصال.';
|
| 1172 |
loadHFRegistry();
|
| 1173 |
renderHFRight();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1174 |
}
|
| 1175 |
}
|
| 1176 |
|
|
@@ -1588,6 +1737,346 @@
|
|
| 1588 |
}
|
| 1589 |
}
|
| 1590 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1591 |
function renderOverviewRight() {
|
| 1592 |
panelRightContent.innerHTML = `
|
| 1593 |
<div class="grid-2" style="margin-bottom:10px;">
|
|
@@ -1615,7 +2104,14 @@
|
|
| 1615 |
<div class="panel-subtitle">
|
| 1616 |
برای بررسی دقیقتر وضعیت، میتوانید تبهای Providers، Logs و Alerts را باز کنید.
|
| 1617 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1618 |
`;
|
|
|
|
| 1619 |
}
|
| 1620 |
|
| 1621 |
function renderProvidersFilters() {
|
|
@@ -1683,6 +2179,9 @@
|
|
| 1683 |
<div class="panel-subtitle">
|
| 1684 |
فیلترها فقط روی endpoint /api/logs اعمال میشوند و دادهها از دیتابیس واقعی خوانده میشوند.
|
| 1685 |
</div>
|
|
|
|
|
|
|
|
|
|
| 1686 |
`;
|
| 1687 |
panelRightContent.querySelectorAll('[data-log-type]').forEach(btn => {
|
| 1688 |
btn.addEventListener('click', () => {
|
|
@@ -1692,6 +2191,29 @@
|
|
| 1692 |
loadLogs(type);
|
| 1693 |
});
|
| 1694 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1695 |
}
|
| 1696 |
|
| 1697 |
function renderAlertsRight() {
|
|
@@ -1863,6 +2385,12 @@
|
|
| 1863 |
const data = JSON.parse(event.data);
|
| 1864 |
if (data.type === 'alert') {
|
| 1865 |
showToast('Alert: ' + (data.provider || 'provider'), data.message || '');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1866 |
}
|
| 1867 |
} catch (e) {
|
| 1868 |
}
|
|
|
|
| 649 |
position: relative;
|
| 650 |
}
|
| 651 |
|
| 652 |
+
.form-card {
|
| 653 |
+
border-radius: var(--radius-md);
|
| 654 |
+
border: 1px solid rgba(148, 163, 184, 0.35);
|
| 655 |
+
background: rgba(15, 23, 42, 0.92);
|
| 656 |
+
padding: 12px;
|
| 657 |
+
display: flex;
|
| 658 |
+
flex-direction: column;
|
| 659 |
+
gap: 10px;
|
| 660 |
+
margin-bottom: 12px;
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
.form-card label {
|
| 664 |
+
font-size: 11px;
|
| 665 |
+
color: var(--muted);
|
| 666 |
+
display: flex;
|
| 667 |
+
flex-direction: column;
|
| 668 |
+
gap: 4px;
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
.form-card input,
|
| 672 |
+
.form-card textarea,
|
| 673 |
+
.form-card select {
|
| 674 |
+
border-radius: var(--radius-pill);
|
| 675 |
+
border: 1px solid rgba(148, 163, 184, 0.45);
|
| 676 |
+
background: rgba(15, 23, 42, 0.96);
|
| 677 |
+
padding: 6px 11px;
|
| 678 |
+
color: var(--text);
|
| 679 |
+
font-size: 11px;
|
| 680 |
+
outline: none;
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
.form-card textarea {
|
| 684 |
+
min-height: 60px;
|
| 685 |
+
resize: vertical;
|
| 686 |
+
border-radius: 16px;
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
.form-actions {
|
| 690 |
+
display: flex;
|
| 691 |
+
gap: 8px;
|
| 692 |
+
flex-wrap: wrap;
|
| 693 |
+
justify-content: flex-end;
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
.muted-text {
|
| 697 |
+
font-size: 11px;
|
| 698 |
+
color: var(--muted);
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
.list-card {
|
| 702 |
+
border-radius: var(--radius-md);
|
| 703 |
+
border: 1px solid rgba(148, 163, 184, 0.25);
|
| 704 |
+
background: rgba(15, 23, 42, 0.88);
|
| 705 |
+
padding: 10px 12px;
|
| 706 |
+
margin-bottom: 8px;
|
| 707 |
+
display: flex;
|
| 708 |
+
flex-direction: column;
|
| 709 |
+
gap: 6px;
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
.list-card header {
|
| 713 |
+
display: flex;
|
| 714 |
+
justify-content: space-between;
|
| 715 |
+
align-items: center;
|
| 716 |
+
gap: 8px;
|
| 717 |
+
font-size: 12px;
|
| 718 |
+
color: var(--text);
|
| 719 |
+
font-weight: 600;
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
.list-card .meta {
|
| 723 |
+
font-size: 10px;
|
| 724 |
+
color: var(--muted);
|
| 725 |
+
display: flex;
|
| 726 |
+
gap: 6px;
|
| 727 |
+
flex-wrap: wrap;
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
.small-btn {
|
| 731 |
+
border-radius: var(--radius-pill);
|
| 732 |
+
border: 1px solid rgba(148, 163, 184, 0.5);
|
| 733 |
+
background: transparent;
|
| 734 |
+
color: var(--muted);
|
| 735 |
+
font-size: 10px;
|
| 736 |
+
padding: 4px 8px;
|
| 737 |
+
cursor: pointer;
|
| 738 |
+
transition: var(--transition-fast);
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
.small-btn:hover {
|
| 742 |
+
color: var(--text);
|
| 743 |
+
border-color: rgba(148, 163, 184, 0.8);
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
.status-chip {
|
| 747 |
+
display: inline-flex;
|
| 748 |
+
align-items: center;
|
| 749 |
+
gap: 4px;
|
| 750 |
+
padding: 2px 8px;
|
| 751 |
+
border-radius: var(--radius-pill);
|
| 752 |
+
font-size: 10px;
|
| 753 |
+
border: 1px solid rgba(148, 163, 184, 0.4);
|
| 754 |
+
background: rgba(15, 23, 42, 0.92);
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
.field input {
|
| 758 |
width: 100%;
|
| 759 |
border-radius: 999px;
|
|
|
|
| 1008 |
<span class="icon">🤗</span>
|
| 1009 |
<span>Hugging Face</span>
|
| 1010 |
</button>
|
| 1011 |
+
<button class="tab-btn" data-tab="manage">
|
| 1012 |
+
<span class="icon">⚙️</span>
|
| 1013 |
+
<span>Manage</span>
|
| 1014 |
+
</button>
|
| 1015 |
</div>
|
| 1016 |
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
|
| 1017 |
<span class="label-mono" id="label-last-refresh">Last refresh: —</span>
|
|
|
|
| 1118 |
let autoRefreshTimer = null;
|
| 1119 |
let ws = null;
|
| 1120 |
let wsConnected = false;
|
| 1121 |
+
let customProviders = [];
|
| 1122 |
+
let customHFModels = [];
|
| 1123 |
+
let customHFDatasets = [];
|
| 1124 |
+
let logsSummaryCache = null;
|
| 1125 |
+
let latestMarketUpdate = null;
|
| 1126 |
+
let latestSentimentUpdate = null;
|
| 1127 |
|
| 1128 |
const panelLeftTitle = document.getElementById('panel-left-title');
|
| 1129 |
const panelLeftSubtitle = document.getElementById('panel-left-subtitle');
|
|
|
|
| 1206 |
throw lastError || new Error('All endpoints failed');
|
| 1207 |
}
|
| 1208 |
|
| 1209 |
+
async function sendJson(path, payload, method = 'POST') {
|
| 1210 |
+
const url = config.apiBase ? config.apiBase + path : path;
|
| 1211 |
+
const options = {
|
| 1212 |
+
method,
|
| 1213 |
+
headers: {
|
| 1214 |
+
'Content-Type': 'application/json'
|
| 1215 |
+
}
|
| 1216 |
+
};
|
| 1217 |
+
if (method !== 'GET' && payload !== undefined && payload !== null) {
|
| 1218 |
+
options.body = JSON.stringify(payload);
|
| 1219 |
+
}
|
| 1220 |
+
const resp = await fetch(url, options);
|
| 1221 |
+
if (!resp.ok) {
|
| 1222 |
+
const text = await resp.text();
|
| 1223 |
+
throw new Error(text || ('HTTP ' + resp.status));
|
| 1224 |
+
}
|
| 1225 |
+
if (resp.status === 204) {
|
| 1226 |
+
return null;
|
| 1227 |
+
}
|
| 1228 |
+
return await resp.json();
|
| 1229 |
+
}
|
| 1230 |
+
|
| 1231 |
function formatNumber(num) {
|
| 1232 |
if (num === null || num === undefined || isNaN(num)) return '—';
|
| 1233 |
if (Math.abs(num) >= 1e9) return (num / 1e9).toFixed(1) + 'B';
|
|
|
|
| 1241 |
return num.toFixed(digits) + '%';
|
| 1242 |
}
|
| 1243 |
|
| 1244 |
+
function slugify(name) {
|
| 1245 |
+
return (name || '').toLowerCase().replace(/\s+/g, '_');
|
| 1246 |
+
}
|
| 1247 |
+
|
| 1248 |
function setActiveTab(tab) {
|
| 1249 |
currentTab = tab;
|
| 1250 |
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
|
|
| 1312 |
panelRightSubtitle.textContent = 'جستجو، رفرش registry و تست اتصال.';
|
| 1313 |
loadHFRegistry();
|
| 1314 |
renderHFRight();
|
| 1315 |
+
} else if (tab === 'manage') {
|
| 1316 |
+
panelLeftTitle.textContent = 'Resource Manager';
|
| 1317 |
+
panelLeftSubtitle.textContent = 'مدیریت providerهای سفارشی، مدلها و لاگهای سیستم.';
|
| 1318 |
+
panelRightIcon.textContent = '⚙️';
|
| 1319 |
+
panelRightTitle.textContent = 'Add & Configure';
|
| 1320 |
+
panelRightSubtitle.textContent = 'افزودن provider جدید، ثبت مدل Hugging Face و مشاهده خلاصه لاگها.';
|
| 1321 |
+
renderManageRight();
|
| 1322 |
+
loadManage();
|
| 1323 |
}
|
| 1324 |
}
|
| 1325 |
|
|
|
|
| 1737 |
}
|
| 1738 |
}
|
| 1739 |
|
| 1740 |
+
async function loadManage() {
|
| 1741 |
+
panelLeftContent.innerHTML = '<div class="empty-state"><span class="loader"></span> در حال بارگذاری دادههای مدیریت…</div>';
|
| 1742 |
+
try {
|
| 1743 |
+
const [providers, hfCustom, logsSummary] = await Promise.all([
|
| 1744 |
+
fetchJsonWithFallback(['/api/providers/custom']),
|
| 1745 |
+
fetchJsonWithFallback(['/api/hf/custom']),
|
| 1746 |
+
fetchJsonWithFallback(['/api/logs/summary']).catch(() => null)
|
| 1747 |
+
]);
|
| 1748 |
+
customProviders = Array.isArray(providers) ? providers : [];
|
| 1749 |
+
customHFModels = hfCustom && Array.isArray(hfCustom.models) ? hfCustom.models : [];
|
| 1750 |
+
customHFDatasets = hfCustom && Array.isArray(hfCustom.datasets) ? hfCustom.datasets : [];
|
| 1751 |
+
logsSummaryCache = logsSummary;
|
| 1752 |
+
renderManageLeft();
|
| 1753 |
+
} catch (err) {
|
| 1754 |
+
console.error('manage load error', err);
|
| 1755 |
+
panelLeftContent.innerHTML = '<div class="empty-state">خطا در دریافت اطلاعات مدیریت منابع.</div>';
|
| 1756 |
+
} finally {
|
| 1757 |
+
updateLastRefresh();
|
| 1758 |
+
}
|
| 1759 |
+
}
|
| 1760 |
+
|
| 1761 |
+
function renderManageLeft() {
|
| 1762 |
+
const providerItems = customProviders.length
|
| 1763 |
+
? customProviders.map(item => {
|
| 1764 |
+
const rate = item.rate_limit ? `<span class="status-chip">⏱️ ${item.rate_limit}</span>` : '';
|
| 1765 |
+
const notes = item.notes ? `<div class="muted-text">${item.notes}</div>` : '';
|
| 1766 |
+
return `
|
| 1767 |
+
<div class="list-card">
|
| 1768 |
+
<header>
|
| 1769 |
+
<span>${item.name}</span>
|
| 1770 |
+
<div class="meta">
|
| 1771 |
+
<span class="pill-mini">${item.category || 'custom'}</span>
|
| 1772 |
+
${rate}
|
| 1773 |
+
</div>
|
| 1774 |
+
</header>
|
| 1775 |
+
<div class="muted-text">Endpoint: <span class="label-mono">${item.base_url || item.endpoint_url}</span></div>
|
| 1776 |
+
<div class="meta">
|
| 1777 |
+
<span>Health: ${item.health_endpoint || '—'}</span>
|
| 1778 |
+
<span>Timeout: ${item.timeout_ms || 10000} ms</span>
|
| 1779 |
+
<span>${item.requires_key ? '🔐 requires key' : '🔓 no key'}</span>
|
| 1780 |
+
</div>
|
| 1781 |
+
${notes}
|
| 1782 |
+
<div class="form-actions">
|
| 1783 |
+
<button class="small-btn" data-remove-provider="${slugify(item.name)}">حذف</button>
|
| 1784 |
+
</div>
|
| 1785 |
+
</div>
|
| 1786 |
+
`;
|
| 1787 |
+
}).join('')
|
| 1788 |
+
: '<div class="empty-state">هنوز هیچ provider سفارشی اضافه نشده است.</div>';
|
| 1789 |
+
|
| 1790 |
+
const hfModelItems = customHFModels.length
|
| 1791 |
+
? customHFModels.map(item => `
|
| 1792 |
+
<div class="list-card">
|
| 1793 |
+
<header>
|
| 1794 |
+
<span>${item.id}</span>
|
| 1795 |
+
<button class="small-btn" data-remove-hf="model::${encodeURIComponent(item.id)}">حذف</button>
|
| 1796 |
+
</header>
|
| 1797 |
+
<div class="muted-text">${item.description || 'بدون توضیح'}</div>
|
| 1798 |
+
<div class="meta">
|
| 1799 |
+
<span>⬇ ${formatNumber(item.downloads || 0)}</span>
|
| 1800 |
+
<span>❤️ ${formatNumber(item.likes || 0)}</span>
|
| 1801 |
+
</div>
|
| 1802 |
+
</div>
|
| 1803 |
+
`).join('')
|
| 1804 |
+
: '<div class="empty-state">مدل اختصاصی ثبت نشده است.</div>';
|
| 1805 |
+
|
| 1806 |
+
const hfDatasetItems = customHFDatasets.length
|
| 1807 |
+
? customHFDatasets.map(item => `
|
| 1808 |
+
<div class="list-card">
|
| 1809 |
+
<header>
|
| 1810 |
+
<span>${item.id}</span>
|
| 1811 |
+
<button class="small-btn" data-remove-hf="dataset::${encodeURIComponent(item.id)}">حذف</button>
|
| 1812 |
+
</header>
|
| 1813 |
+
<div class="muted-text">${item.description || 'بدون توضیح'}</div>
|
| 1814 |
+
<div class="meta">
|
| 1815 |
+
<span>⬇ ${formatNumber(item.downloads || 0)}</span>
|
| 1816 |
+
<span>❤️ ${formatNumber(item.likes || 0)}</span>
|
| 1817 |
+
</div>
|
| 1818 |
+
</div>
|
| 1819 |
+
`).join('')
|
| 1820 |
+
: '<div class="empty-state">دیتاست اختصاصی ثبت نشده است.</div>';
|
| 1821 |
+
|
| 1822 |
+
const logsSummaryHtml = logsSummaryCache
|
| 1823 |
+
? `
|
| 1824 |
+
<div class="list-card">
|
| 1825 |
+
<header>
|
| 1826 |
+
<span>خلاصه لاگها (${logsSummaryCache.total})</span>
|
| 1827 |
+
</header>
|
| 1828 |
+
<div class="meta">
|
| 1829 |
+
${Object.entries(logsSummaryCache.by_status || {})
|
| 1830 |
+
.map(([status, count]) => `<span class="status-chip">${status}: ${count}</span>`).join(' ')}
|
| 1831 |
+
</div>
|
| 1832 |
+
${logsSummaryCache.last_error ? `
|
| 1833 |
+
<div class="muted-text" style="margin-top:6px;">
|
| 1834 |
+
آخرین خطا: ${logsSummaryCache.last_error.provider} (${logsSummaryCache.last_error.status}) – ${logsSummaryCache.last_error.message || 'بدون پیام'}
|
| 1835 |
+
</div>` : '<div class="muted-text" style="margin-top:6px;">در بازه اخیر خطایی ثبت نشده است.</div>'}
|
| 1836 |
+
</div>
|
| 1837 |
+
`
|
| 1838 |
+
: '<div class="list-card"><div class="muted-text">خلاصه لاگها در دسترس نیست.</div></div>';
|
| 1839 |
+
|
| 1840 |
+
panelLeftContent.innerHTML = `
|
| 1841 |
+
<div class="panel-subtitle" style="margin-bottom:6px;">Providerهای سفارشی</div>
|
| 1842 |
+
${providerItems}
|
| 1843 |
+
<div class="panel-subtitle" style="margin:12px 0 6px;">مدلهای Hugging Face</div>
|
| 1844 |
+
${hfModelItems}
|
| 1845 |
+
<div class="panel-subtitle" style="margin:12px 0 6px;">دیتاستهای Hugging Face</div>
|
| 1846 |
+
${hfDatasetItems}
|
| 1847 |
+
<div class="panel-subtitle" style="margin:12px 0 6px;">وضعیت لاگها</div>
|
| 1848 |
+
${logsSummaryHtml}
|
| 1849 |
+
`;
|
| 1850 |
+
|
| 1851 |
+
panelLeftContent.querySelectorAll('[data-remove-provider]').forEach(btn => {
|
| 1852 |
+
btn.addEventListener('click', async () => {
|
| 1853 |
+
const slug = btn.getAttribute('data-remove-provider');
|
| 1854 |
+
if (!slug) return;
|
| 1855 |
+
try {
|
| 1856 |
+
await sendJson(`/api/providers/${slug}`, null, 'DELETE');
|
| 1857 |
+
showToast('حذف provider', 'Provider با موفقیت حذف شد.');
|
| 1858 |
+
await loadManage();
|
| 1859 |
+
} catch (err) {
|
| 1860 |
+
console.error('remove provider error', err);
|
| 1861 |
+
showToast('خطا', 'حذف provider انجام نشد.');
|
| 1862 |
+
}
|
| 1863 |
+
});
|
| 1864 |
+
});
|
| 1865 |
+
|
| 1866 |
+
panelLeftContent.querySelectorAll('[data-remove-hf]').forEach(btn => {
|
| 1867 |
+
btn.addEventListener('click', async () => {
|
| 1868 |
+
const payload = btn.getAttribute('data-remove-hf');
|
| 1869 |
+
if (!payload) return;
|
| 1870 |
+
const [kind, identifier] = payload.split('::');
|
| 1871 |
+
try {
|
| 1872 |
+
await sendJson(`/api/hf/custom/${kind}/${identifier}`, null, 'DELETE');
|
| 1873 |
+
showToast('حذف آیتم HF', 'آیتم با موفقیت حذف شد.');
|
| 1874 |
+
await loadManage();
|
| 1875 |
+
} catch (err) {
|
| 1876 |
+
console.error('remove hf error', err);
|
| 1877 |
+
showToast('خطا', 'حذف آیتم انجام نشد.');
|
| 1878 |
+
}
|
| 1879 |
+
});
|
| 1880 |
+
});
|
| 1881 |
+
|
| 1882 |
+
renderManageLogsSummary();
|
| 1883 |
+
}
|
| 1884 |
+
|
| 1885 |
+
function renderManageRight() {
|
| 1886 |
+
panelRightContent.innerHTML = `
|
| 1887 |
+
<form class="form-card" id="form-provider">
|
| 1888 |
+
<div class="muted-text">افزودن provider جدید به صورت سفارشی. پس از ثبت، در تب Providers و health قابل مشاهده خواهد بود.</div>
|
| 1889 |
+
<label>
|
| 1890 |
+
نام provider
|
| 1891 |
+
<input name="name" required placeholder="مثلاً My API" />
|
| 1892 |
+
</label>
|
| 1893 |
+
<label>
|
| 1894 |
+
دستهبندی
|
| 1895 |
+
<input name="category" placeholder="مثلاً market_data" />
|
| 1896 |
+
</label>
|
| 1897 |
+
<label>
|
| 1898 |
+
Endpoint اصلی
|
| 1899 |
+
<input name="endpoint_url" required placeholder="https://api.example.com" />
|
| 1900 |
+
</label>
|
| 1901 |
+
<label>
|
| 1902 |
+
Health endpoint (اختیاری)
|
| 1903 |
+
<input name="health_check_endpoint" placeholder="https://api.example.com/health" />
|
| 1904 |
+
</label>
|
| 1905 |
+
<label style="display:flex; align-items:center; gap:6px;">
|
| 1906 |
+
<input type="checkbox" name="requires_key" style="width:auto;" />
|
| 1907 |
+
نیاز به API key
|
| 1908 |
+
</label>
|
| 1909 |
+
<label>
|
| 1910 |
+
API key (اختیاری)
|
| 1911 |
+
<input name="api_key" placeholder="فقط برای نمایش ماسکشده ذخیره میشود" />
|
| 1912 |
+
</label>
|
| 1913 |
+
<label>
|
| 1914 |
+
Rate limit
|
| 1915 |
+
<input name="rate_limit" placeholder="مثلاً 50/min" />
|
| 1916 |
+
</label>
|
| 1917 |
+
<label>
|
| 1918 |
+
Timeout (ms)
|
| 1919 |
+
<input name="timeout_ms" type="number" min="1000" step="500" value="10000" />
|
| 1920 |
+
</label>
|
| 1921 |
+
<label>
|
| 1922 |
+
توضیحات
|
| 1923 |
+
<textarea name="notes" placeholder="یادداشت یا توضیح اضافی"></textarea>
|
| 1924 |
+
</label>
|
| 1925 |
+
<div class="form-actions">
|
| 1926 |
+
<button class="btn-primary btn" type="submit">ذخیره provider</button>
|
| 1927 |
+
</div>
|
| 1928 |
+
</form>
|
| 1929 |
+
|
| 1930 |
+
<form class="form-card" id="form-hf">
|
| 1931 |
+
<div class="muted-text">ثبت مدل یا دیتاست Hugging Face برای نمایش سریع در داشبورد.</div>
|
| 1932 |
+
<label>
|
| 1933 |
+
نوع
|
| 1934 |
+
<select name="kind">
|
| 1935 |
+
<option value="model">Model</option>
|
| 1936 |
+
<option value="dataset">Dataset</option>
|
| 1937 |
+
</select>
|
| 1938 |
+
</label>
|
| 1939 |
+
<label>
|
| 1940 |
+
شناسه (ID)
|
| 1941 |
+
<input name="id" required placeholder="مثلاً username/model-name" />
|
| 1942 |
+
</label>
|
| 1943 |
+
<label>
|
| 1944 |
+
توضیح
|
| 1945 |
+
<textarea name="description" placeholder="توضیح کوتاه"></textarea>
|
| 1946 |
+
</label>
|
| 1947 |
+
<label>
|
| 1948 |
+
تعداد دانلود (اختیاری)
|
| 1949 |
+
<input name="downloads" type="number" min="0" placeholder="مثلاً 1200" />
|
| 1950 |
+
</label>
|
| 1951 |
+
<label>
|
| 1952 |
+
تعداد لایک (اختیاری)
|
| 1953 |
+
<input name="likes" type="number" min="0" placeholder="مثلاً 42" />
|
| 1954 |
+
</label>
|
| 1955 |
+
<div class="form-actions">
|
| 1956 |
+
<button class="btn" type="submit">افزودن آیتم HF</button>
|
| 1957 |
+
</div>
|
| 1958 |
+
</form>
|
| 1959 |
+
|
| 1960 |
+
<div class="form-card">
|
| 1961 |
+
<div class="muted-text">خلاصهٔ آخرین لاگها برای بررسی سریع وضعیت سیستم.</div>
|
| 1962 |
+
<div id="manage-logs-summary"></div>
|
| 1963 |
+
</div>
|
| 1964 |
+
`;
|
| 1965 |
+
|
| 1966 |
+
const providerForm = document.getElementById('form-provider');
|
| 1967 |
+
providerForm.addEventListener('submit', submitProviderForm);
|
| 1968 |
+
|
| 1969 |
+
const hfForm = document.getElementById('form-hf');
|
| 1970 |
+
hfForm.addEventListener('submit', submitHFForm);
|
| 1971 |
+
|
| 1972 |
+
renderManageLogsSummary();
|
| 1973 |
+
}
|
| 1974 |
+
|
| 1975 |
+
function renderManageLogsSummary() {
|
| 1976 |
+
const container = document.getElementById('manage-logs-summary');
|
| 1977 |
+
if (!container) return;
|
| 1978 |
+
if (!logsSummaryCache) {
|
| 1979 |
+
container.innerHTML = '<div class="muted-text">اطلاعاتی از لاگها موجود نیست.</div>';
|
| 1980 |
+
return;
|
| 1981 |
+
}
|
| 1982 |
+
const statusChips = Object.entries(logsSummaryCache.by_status || {})
|
| 1983 |
+
.map(([status, count]) => `<span class="status-chip">${status}: ${count}</span>`).join(' ');
|
| 1984 |
+
const providerList = Object.entries(logsSummaryCache.by_provider || {})
|
| 1985 |
+
.map(([provider, count]) => `<div class="muted-text">${provider}: ${count}</div>`).join('');
|
| 1986 |
+
container.innerHTML = `
|
| 1987 |
+
<div class="meta" style="margin-bottom:6px;">${statusChips}</div>
|
| 1988 |
+
<div style="font-size:11px; color:var(--muted); margin-bottom:6px;">پراکندگی بر اساس provider:</div>
|
| 1989 |
+
<div>${providerList || '<div class="muted-text">دادهای وجود ندارد.</div>'}</div>
|
| 1990 |
+
`;
|
| 1991 |
+
}
|
| 1992 |
+
|
| 1993 |
+
async function submitProviderForm(event) {
|
| 1994 |
+
event.preventDefault();
|
| 1995 |
+
const form = event.currentTarget;
|
| 1996 |
+
const data = new FormData(form);
|
| 1997 |
+
const payload = {
|
| 1998 |
+
name: data.get('name') || '',
|
| 1999 |
+
category: data.get('category') || 'custom',
|
| 2000 |
+
endpoint_url: data.get('endpoint_url') || '',
|
| 2001 |
+
health_check_endpoint: data.get('health_check_endpoint') || null,
|
| 2002 |
+
requires_key: data.get('requires_key') === 'on',
|
| 2003 |
+
api_key: data.get('api_key') || null,
|
| 2004 |
+
rate_limit: data.get('rate_limit') || null,
|
| 2005 |
+
timeout_ms: Number(data.get('timeout_ms') || 10000),
|
| 2006 |
+
notes: data.get('notes') || null
|
| 2007 |
+
};
|
| 2008 |
+
try {
|
| 2009 |
+
await sendJson('/api/providers', payload, 'POST');
|
| 2010 |
+
showToast('ثبت provider', 'Provider جدید اضافه شد.');
|
| 2011 |
+
form.reset();
|
| 2012 |
+
await loadManage();
|
| 2013 |
+
} catch (err) {
|
| 2014 |
+
console.error('create provider error', err);
|
| 2015 |
+
showToast('خطا', 'ذخیره provider انجام نشد.');
|
| 2016 |
+
}
|
| 2017 |
+
}
|
| 2018 |
+
|
| 2019 |
+
async function submitHFForm(event) {
|
| 2020 |
+
event.preventDefault();
|
| 2021 |
+
const form = event.currentTarget;
|
| 2022 |
+
const data = new FormData(form);
|
| 2023 |
+
const payload = {
|
| 2024 |
+
kind: data.get('kind') || 'model',
|
| 2025 |
+
id: (data.get('id') || '').trim(),
|
| 2026 |
+
description: data.get('description') || null,
|
| 2027 |
+
downloads: data.get('downloads') ? Number(data.get('downloads')) : null,
|
| 2028 |
+
likes: data.get('likes') ? Number(data.get('likes')) : null
|
| 2029 |
+
};
|
| 2030 |
+
if (!payload.id) {
|
| 2031 |
+
showToast('خطا', 'شناسه مدل/دیتاست الزامی است.');
|
| 2032 |
+
return;
|
| 2033 |
+
}
|
| 2034 |
+
try {
|
| 2035 |
+
await sendJson('/api/hf/custom', payload, 'POST');
|
| 2036 |
+
showToast('ثبت Hugging Face', 'آیتم جدید اضافه شد.');
|
| 2037 |
+
form.reset();
|
| 2038 |
+
await loadManage();
|
| 2039 |
+
} catch (err) {
|
| 2040 |
+
console.error('create hf item error', err);
|
| 2041 |
+
showToast('خطا', 'ذخیره آیتم انجام نشد.');
|
| 2042 |
+
}
|
| 2043 |
+
}
|
| 2044 |
+
|
| 2045 |
+
function updateLiveWidgets() {
|
| 2046 |
+
const marketWidget = document.getElementById('live-market-widget');
|
| 2047 |
+
if (marketWidget) {
|
| 2048 |
+
if (latestMarketUpdate && Array.isArray(latestMarketUpdate.data) && latestMarketUpdate.data.length) {
|
| 2049 |
+
const items = latestMarketUpdate.data.slice(0, 3).map(item => `
|
| 2050 |
+
<div class="meta" style="justify-content: space-between;">
|
| 2051 |
+
<span>${item.name || item.symbol}</span>
|
| 2052 |
+
<span>${formatNumber(item.price || item.current_price || 0)} USD</span>
|
| 2053 |
+
</div>
|
| 2054 |
+
<div class="muted-text">24h: ${item.change_24h !== undefined ? formatPercent(item.change_24h, 2) : '—'} | Rank: ${item.rank || '—'}</div>
|
| 2055 |
+
`).join('');
|
| 2056 |
+
marketWidget.innerHTML = `
|
| 2057 |
+
<div class="muted-text" style="margin-bottom:6px;">${new Date(latestMarketUpdate.timestamp || Date.now()).toLocaleTimeString('fa-IR')}</div>
|
| 2058 |
+
${items}
|
| 2059 |
+
`;
|
| 2060 |
+
} else {
|
| 2061 |
+
marketWidget.innerHTML = '<div class="muted-text">دادهای برای نمایش وجود ندارد.</div>';
|
| 2062 |
+
}
|
| 2063 |
+
}
|
| 2064 |
+
|
| 2065 |
+
const sentimentWidget = document.getElementById('live-sentiment-widget');
|
| 2066 |
+
if (sentimentWidget) {
|
| 2067 |
+
if (latestSentimentUpdate && latestSentimentUpdate.data) {
|
| 2068 |
+
const data = latestSentimentUpdate.data;
|
| 2069 |
+
sentimentWidget.innerHTML = `
|
| 2070 |
+
<div class="muted-text" style="margin-bottom:6px;">${new Date(latestSentimentUpdate.timestamp || Date.now()).toLocaleTimeString('fa-IR')}</div>
|
| 2071 |
+
<div class="status-chip">شاخص: ${data.value}</div>
|
| 2072 |
+
<div class="muted-text">توصیف: ${data.classification || '—'}</div>
|
| 2073 |
+
`;
|
| 2074 |
+
} else {
|
| 2075 |
+
sentimentWidget.innerHTML = '<div class="muted-text">دادهای برای نمایش وجود ندارد.</div>';
|
| 2076 |
+
}
|
| 2077 |
+
}
|
| 2078 |
+
}
|
| 2079 |
+
|
| 2080 |
function renderOverviewRight() {
|
| 2081 |
panelRightContent.innerHTML = `
|
| 2082 |
<div class="grid-2" style="margin-bottom:10px;">
|
|
|
|
| 2104 |
<div class="panel-subtitle">
|
| 2105 |
برای بررسی دقیقتر وضعیت، میتوانید تبهای Providers، Logs و Alerts را باز کنید.
|
| 2106 |
</div>
|
| 2107 |
+
<div class="form-card" id="live-market-widget">
|
| 2108 |
+
<div class="muted-text">آخرین بروزرسانی لحظهای بازار پس از اتصال WebSocket نمایش داده میشود.</div>
|
| 2109 |
+
</div>
|
| 2110 |
+
<div class="form-card" id="live-sentiment-widget">
|
| 2111 |
+
<div class="muted-text">آخرین احساس بازار (Sentiment) از فید زنده در این بخش نمایش داده میشود.</div>
|
| 2112 |
+
</div>
|
| 2113 |
`;
|
| 2114 |
+
updateLiveWidgets();
|
| 2115 |
}
|
| 2116 |
|
| 2117 |
function renderProvidersFilters() {
|
|
|
|
| 2179 |
<div class="panel-subtitle">
|
| 2180 |
فیلترها فقط روی endpoint /api/logs اعمال میشوند و دادهها از دیتابیس واقعی خوانده میشوند.
|
| 2181 |
</div>
|
| 2182 |
+
<div style="margin-top:10px;" id="logs-summary-box" class="muted-text">
|
| 2183 |
+
در حال بارگذاری خلاصه لاگها…
|
| 2184 |
+
</div>
|
| 2185 |
`;
|
| 2186 |
panelRightContent.querySelectorAll('[data-log-type]').forEach(btn => {
|
| 2187 |
btn.addEventListener('click', () => {
|
|
|
|
| 2191 |
loadLogs(type);
|
| 2192 |
});
|
| 2193 |
});
|
| 2194 |
+
loadLogsSummary();
|
| 2195 |
+
}
|
| 2196 |
+
|
| 2197 |
+
async function loadLogsSummary() {
|
| 2198 |
+
const box = document.getElementById('logs-summary-box');
|
| 2199 |
+
if (!box) return;
|
| 2200 |
+
try {
|
| 2201 |
+
const summary = await fetchJsonWithFallback(['/api/logs/summary']);
|
| 2202 |
+
logsSummaryCache = summary;
|
| 2203 |
+
const statusChips = Object.entries(summary.by_status || {})
|
| 2204 |
+
.map(([status, count]) => `<span class="status-chip">${status}: ${count}</span>`).join(' ');
|
| 2205 |
+
const lastError = summary.last_error
|
| 2206 |
+
? `<div class="muted-text" style="margin-top:6px;">آخرین خطا برای ${summary.last_error.provider}: ${summary.last_error.message || 'No message'}</div>`
|
| 2207 |
+
: '<div class="muted-text" style="margin-top:6px;">در بازه اخیر خطایی ثبت نشده است.</div>';
|
| 2208 |
+
box.innerHTML = `
|
| 2209 |
+
<div class="muted-text">جمع کل لاگها: ${summary.total}</div>
|
| 2210 |
+
<div class="meta" style="margin:6px 0;">${statusChips}</div>
|
| 2211 |
+
${lastError}
|
| 2212 |
+
`;
|
| 2213 |
+
} catch (err) {
|
| 2214 |
+
console.error('log summary error', err);
|
| 2215 |
+
box.textContent = 'خطا در بارگذاری خلاصه لاگها.';
|
| 2216 |
+
}
|
| 2217 |
}
|
| 2218 |
|
| 2219 |
function renderAlertsRight() {
|
|
|
|
| 2385 |
const data = JSON.parse(event.data);
|
| 2386 |
if (data.type === 'alert') {
|
| 2387 |
showToast('Alert: ' + (data.provider || 'provider'), data.message || '');
|
| 2388 |
+
} else if (data.type === 'market_update') {
|
| 2389 |
+
latestMarketUpdate = data;
|
| 2390 |
+
updateLiveWidgets();
|
| 2391 |
+
} else if (data.type === 'sentiment_update') {
|
| 2392 |
+
latestSentimentUpdate = data;
|
| 2393 |
+
updateLiveWidgets();
|
| 2394 |
}
|
| 2395 |
} catch (e) {
|
| 2396 |
}
|