Really-amin commited on
Commit
d4f1120
·
verified ·
1 Parent(s): 049f4ea

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +279 -2
  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
  }