fix(router/dashboard): 429按打满档位精确冷却+limitName兜底; mark_key_failed尊重显式cooldown(原死参数上限600s); weekly>=100不派单且healthy对齐; /api/usage三来源统一stale标记+前端置灰
This commit is contained in:
@@ -9,9 +9,10 @@ Flask app on :5803. Monitors agents across platforms via:
|
||||
|
||||
Auto-recovery: restarts local Windows agents after 3 consecutive offline checks.
|
||||
"""
|
||||
import random
|
||||
import os, sys, re, json, time, socket, subprocess, logging, urllib.request, sqlite3, shutil, threading
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from flask import Flask, jsonify, request, send_from_directory
|
||||
|
||||
# ---- Paths (platform-agnostic: auto-detect from script location) ----
|
||||
@@ -611,14 +612,21 @@ def _augment_wechat_status(entry):
|
||||
entry["qr_timestamp"] = None
|
||||
entry["session_age_hours"] = 0
|
||||
|
||||
# 1. Fetch QR code (always available)
|
||||
# 二维码【不再自动拉取】:避免 dashboard 每10秒轮询时自动刷新二维码(用户要求改手动获取)。
|
||||
# 只从"手动获取缓存"读取——用户点"获取登录二维码"后由 /api/wechat/status 写入;
|
||||
# 且仅当缓存未过期(二维码约5分钟有效)才返回,过期视为无 → 前端显示"获取登录二维码"按钮。
|
||||
try:
|
||||
req = urllib.request.Request("http://localhost:3001/login?token=mowechat_fixed_token_001")
|
||||
html = urllib.request.urlopen(req, timeout=5).read().decode("utf-8", errors="replace")
|
||||
m = re.search(r'qrcode\.makeCode\("([^"]+)"\)', html)
|
||||
if m:
|
||||
entry["qr_url"] = m.group(1)
|
||||
entry["qr_timestamp"] = now.isoformat()
|
||||
cache_file = str(_GATEWAY_DIR / "temp" / "wechat_qr_cache.json")
|
||||
if os.path.exists(cache_file):
|
||||
with open(cache_file, encoding="utf-8") as f:
|
||||
qc = json.load(f)
|
||||
qurl = qc.get("qr_url")
|
||||
qts = qc.get("ts", "")
|
||||
if qurl and qts:
|
||||
age_sec = (now - datetime.fromisoformat(qts)).total_seconds()
|
||||
if age_sec < 300: # 二维码约5分钟有效
|
||||
entry["qr_url"] = qurl
|
||||
entry["qr_timestamp"] = qts
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1376,6 +1384,27 @@ def api_rdp_toggle():
|
||||
return jsonify({"ok": False, "error": str(e)})
|
||||
|
||||
|
||||
@app.route("/api/rdp/progress")
|
||||
def api_rdp_progress():
|
||||
"""RDP enable 实时进度 — proxy to xmpp_bot on Windows (polls rdp_progress.json)."""
|
||||
try:
|
||||
data = _bridge_post("/rdp", {"action": "progress"}, timeout=8)
|
||||
return jsonify(data)
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "state": "unknown", "message": "progress 查询失败", "error": str(e), "steps": []})
|
||||
|
||||
|
||||
@app.route("/api/rdp/enable_log")
|
||||
def api_rdp_enable_log():
|
||||
"""RDP enable 结构化日志 — proxy to xmpp_bot on Windows (for post-mortem debug)."""
|
||||
try:
|
||||
n = request.args.get("lines", 80, type=int)
|
||||
data = _bridge_post("/rdp", {"action": "enable_log", "lines": n}, timeout=8)
|
||||
return jsonify(data)
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e), "lines": []})
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# OpenCode Go Usage Monitor — 4个账号用量配额监控 (246 本地采集)
|
||||
# See gateway/scripts/specs/usage_monitor.json
|
||||
@@ -1429,6 +1458,23 @@ def api_usage():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 新鲜度统一标记(2026-08-26):三种来源一律按 last_update_iso 算年龄
|
||||
try:
|
||||
_now_dt = datetime.now(timezone.utc)
|
||||
for a in accounts:
|
||||
lu = a.get("last_update_iso")
|
||||
a["age_minutes"] = None
|
||||
a["stale"] = True
|
||||
if lu:
|
||||
dt = datetime.fromisoformat(lu)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
age_min = (_now_dt - dt).total_seconds() / 60.0
|
||||
a["age_minutes"] = int(round(age_min))
|
||||
a["stale"] = age_min > 60
|
||||
except Exception as _e:
|
||||
log.warning("usage: stale-mark failed: %s", _e)
|
||||
|
||||
if not accounts:
|
||||
return jsonify({"ok": False, "error": "no data yet",
|
||||
"last_refresh_iso": None, "accounts": [], "providers": {}})
|
||||
@@ -1462,17 +1508,17 @@ def api_usage_refresh():
|
||||
if proc.returncode != 0 and proc.stderr:
|
||||
log.warning(f"usage_collector stderr:\n{proc.stderr[-1000:]}")
|
||||
|
||||
# 2. Kimi 远程采集(SSH 到 Windows)+ scp 回 246
|
||||
try:
|
||||
_collect_kimi_usage()
|
||||
except Exception as e:
|
||||
log.warning(f"usage: Kimi collection failed: {e}")
|
||||
# 2. Kimi 远程采集(已禁用 by xxm 2026-08-22,CDP弹窗骚扰)
|
||||
# try:
|
||||
# _collect_kimi_usage()
|
||||
# except Exception as e:
|
||||
# log.warning(f"usage: Kimi collection failed: {e}")
|
||||
|
||||
# 3. SenseNova 远程采集(SSH 到 Windows,CDP 刮 console 余量)
|
||||
try:
|
||||
_collect_sensenova_usage()
|
||||
except Exception as e:
|
||||
log.warning(f"usage: SenseNova collection failed: {e}")
|
||||
# 3. SenseNova 远程采集(已禁用 by xxm 2026-08-22)
|
||||
# try:
|
||||
# _collect_sensenova_usage()
|
||||
# except Exception as e:
|
||||
# log.warning(f"usage: SenseNova collection failed: {e}")
|
||||
except Exception as e:
|
||||
log.error(f"usage: collector crash: {e}")
|
||||
finally:
|
||||
@@ -1646,7 +1692,7 @@ def api_keys():
|
||||
|
||||
|
||||
# ── Auto-collect timer (every 5 min, keeps cookies alive) ──
|
||||
_USAGE_AUTO_INTERVAL = 300
|
||||
_USAGE_AUTO_INTERVAL = 1800
|
||||
|
||||
def _start_usage_auto_timer():
|
||||
"""后台线程:OCG 每 5 分钟自动采集;Kimi/SenseNova 每 30 分钟采集(SSH 到 Windows)。"""
|
||||
@@ -1666,22 +1712,16 @@ def _start_usage_auto_timer():
|
||||
if proc.returncode != 0 and proc.stderr:
|
||||
log.warning(f"usage auto-timer stderr:\n{proc.stderr[-800:]}")
|
||||
|
||||
# Kimi/SenseNova:每 30 分钟刷新一次(SSH 到 Windows + CDP,较重)
|
||||
tick += 1
|
||||
if tick % _KIMI_INTERVAL_MULT == 0:
|
||||
try:
|
||||
_collect_kimi_usage()
|
||||
except Exception as e:
|
||||
log.warning(f"usage auto-timer: Kimi failed: {e}")
|
||||
try:
|
||||
_collect_sensenova_usage()
|
||||
except Exception as e:
|
||||
log.warning(f"usage auto-timer: SenseNova failed: {e}")
|
||||
# Kimi/SenseNova auto-timer disabled by xxm 2026-08-22 (CDP弹窗骚扰)
|
||||
# tick += 1
|
||||
# if tick % _KIMI_INTERVAL_MULT == 0:
|
||||
# _collect_kimi_usage()
|
||||
# _collect_sensenova_usage()
|
||||
_usage_collector_running = False
|
||||
except Exception as e:
|
||||
log.error(f"usage auto-timer error: {e}")
|
||||
_usage_collector_running = False
|
||||
time.sleep(_USAGE_AUTO_INTERVAL)
|
||||
time.sleep(_USAGE_AUTO_INTERVAL + random.randint(-240, 240))
|
||||
|
||||
t = threading.Thread(target=_loop, name="usage_auto_timer", daemon=True)
|
||||
t.start()
|
||||
@@ -1861,6 +1901,13 @@ def api_wechat_status():
|
||||
if m:
|
||||
result["qr_url"] = m.group(1)
|
||||
result["qr_timestamp"] = now.isoformat()
|
||||
# 手动获取到二维码 → 写入缓存,dashboard infra status 据此显示(避免自动拉取刷新)
|
||||
try:
|
||||
cache_file = str(_GATEWAY_DIR / "temp" / "wechat_qr_cache.json")
|
||||
with open(cache_file, "w", encoding="utf-8") as _cf:
|
||||
json.dump({"qr_url": m.group(1), "ts": now.isoformat()}, _cf, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
result["message"] = f"QR page fetch failed: {e}"
|
||||
|
||||
@@ -2006,7 +2053,7 @@ def api_wechat_trigger_login():
|
||||
import urllib.request as _ur
|
||||
try:
|
||||
req = _ur.Request(
|
||||
"http://127.0.0.1:3001/api/bot/login",
|
||||
"http://127.0.0.1:3001/api/bot/login?token=mowechat_fixed_token_001",
|
||||
data=b"",
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
|
||||
@@ -45,7 +45,7 @@ ACCOUNTS_FILE = _SCRIPT_DIR / "usage_monitor" / "accounts.json"
|
||||
FAIL_COOLDOWN_SEC = 300 # 5 分钟(key 自身故障)
|
||||
UPSTREAM_COOLDOWN_SEC = 30 # 上游模型级故障(Router.Unavailable)短冷却:换 key 无意义
|
||||
# 用量数据刷新间隔
|
||||
USAGE_REFRESH_INTERVAL = 120 # 2 分钟
|
||||
USAGE_REFRESH_INTERVAL = 1800 # 统一30分钟节奏(2026-08-26)
|
||||
# 最大重试次数(所有 key 耗尽)
|
||||
MAX_RETRIES = 6
|
||||
# 单次上游请求保底超时(秒):上游处理大请求(1MB+)可能要 60-90s+,
|
||||
@@ -154,12 +154,13 @@ def refresh_state():
|
||||
global _keys, _key_usage
|
||||
_keys = load_accounts()
|
||||
_key_usage = load_usage()
|
||||
# 合并 accounts.json 的订阅标记:采集失败时 usage 缺失该信息,
|
||||
# 以 accounts.json 的 renewal/subscribed 为准
|
||||
# 合并 accounts.json 的订阅标记(仅兜底):usage_stats.json 是 CDP 实时检测,
|
||||
# 有该 key 的实时数据时以实时数据为准;只有用量数据缺失该 key 时才回退
|
||||
# 到 accounts.json 的 renewal/subscribed 静态标记(2026-08-20 修复:不再覆盖实时检测)
|
||||
for k in _keys:
|
||||
kid = k["key_id"]
|
||||
if not k.get("subscribed", True):
|
||||
_key_usage.setdefault(kid, {})["subscribed"] = False
|
||||
if kid not in _key_usage and not k.get("subscribed", True):
|
||||
_key_usage[kid] = {"subscribed": False}
|
||||
log.info("state refreshed: %d keys, %d with usage data", len(_keys), len(_key_usage))
|
||||
|
||||
|
||||
@@ -181,6 +182,10 @@ def _key_health_score(kid):
|
||||
if not usage.get("subscribed", True):
|
||||
return 999
|
||||
|
||||
# weekly 打满:沉底与冷却同级,reset 前不派单(2026-08-26)
|
||||
if (usage.get("weekly_pct", 0) or 0) >= 100:
|
||||
return 888
|
||||
|
||||
# 用量采集失败 → 降权但可用(不因采集问题误杀 key)
|
||||
if usage.get("error"):
|
||||
return 500
|
||||
@@ -233,6 +238,8 @@ def pick_key_round_robin():
|
||||
usage = _key_usage.get(kid, {})
|
||||
if not usage.get("subscribed", True):
|
||||
continue
|
||||
if (usage.get("weekly_pct", 0) or 0) >= 100:
|
||||
continue # weekly 打满(2026-08-26)
|
||||
# 选中:更新索引到下一个
|
||||
_rr_index = (idx + 1) % n
|
||||
return k
|
||||
@@ -251,7 +258,11 @@ def mark_key_failed(kid, reason="", cooldown=None):
|
||||
count = _key_fail_count[kid]
|
||||
# 指数退避:base=2s, 每次失败 ×2,上限 600s(10分钟)
|
||||
base = 2
|
||||
cool = min(base * (2 ** (count - 1)), 600)
|
||||
if cooldown and cooldown > 0:
|
||||
# explicit cooldown wins over exponential-backoff cap (2026-08-26)
|
||||
cool = max(int(cooldown), 1)
|
||||
else:
|
||||
cool = min(base * (2 ** (count - 1)), 600)
|
||||
until = now + cool
|
||||
_key_failures[kid] = until
|
||||
if reason:
|
||||
@@ -392,7 +403,9 @@ class RouterHandler(BaseHTTPRequestHandler):
|
||||
"rolling_pct": usage.get("rolling_pct", None),
|
||||
"weekly_pct": usage.get("weekly_pct", None),
|
||||
"monthly_pct": usage.get("monthly_pct", None),
|
||||
"healthy": usage.get("subscribed", True) and (time.time() >= fail_until),
|
||||
"healthy": (usage.get("subscribed", True)
|
||||
and (time.time() >= fail_until)
|
||||
and not ((usage.get("weekly_pct", 0) or 0) >= 100)),
|
||||
"in_cooldown": time.time() < fail_until,
|
||||
"fail_reason": _key_failure_reason.get(kid, ""),
|
||||
"error": usage.get("error", False),
|
||||
@@ -602,7 +615,34 @@ class RouterHandler(BaseHTTPRequestHandler):
|
||||
break
|
||||
mark_key_failed(kid, last_error, cooldown=FAIL_COOLDOWN_SEC)
|
||||
elif status in (402, 429):
|
||||
mark_key_failed(kid, last_error, cooldown=60)
|
||||
# 用 usage_stats 的 reset_in_sec 设定精确冷却时间
|
||||
# 2026-08-26 定稿:只在打满档位取最远 reset;空集合按 limitName 分级兜底。
|
||||
kid_usage = usage_snapshot.get(kid, {})
|
||||
tiers = [kid_usage.get('rolling') or {},
|
||||
kid_usage.get('weekly') or {},
|
||||
kid_usage.get('monthly') or {}]
|
||||
exhausted = [t['reset_in_sec'] for t in tiers
|
||||
if t.get('usage_percent', 0) >= 100 and t.get('reset_in_sec', 0) > 0]
|
||||
limit_name = ''
|
||||
try:
|
||||
eidx = last_error.find("{")
|
||||
if eidx >= 0:
|
||||
meta = (json.loads(last_error[eidx:]).get('error') or {}).get('metadata') or {}
|
||||
limit_name = str(meta.get('limitName', '')).strip().lower()
|
||||
except Exception:
|
||||
limit_name = ''
|
||||
if exhausted:
|
||||
cooldown_sec = max(exhausted) + 1800 # 快照年龄补偿30分钟
|
||||
basis = 'maxed-tiers'
|
||||
elif limit_name in ('weekly', 'monthly'):
|
||||
cooldown_sec = 86400
|
||||
basis = 'limitName:' + limit_name
|
||||
else:
|
||||
cooldown_sec = 3600
|
||||
basis = 'default'
|
||||
log.warning("key %s 429, cooldown %.0fs (%s; limitName=%s)",
|
||||
kid, cooldown_sec, basis, limit_name or 'unknown')
|
||||
mark_key_failed(kid, last_error, cooldown=cooldown_sec)
|
||||
elif status >= 500:
|
||||
# 上游服务端错误(503/500/Internal/Router.Unavailable):
|
||||
# 只是当前 key 的上游实例可能有问题,换下一个 key 重试(不冷却)。
|
||||
@@ -633,7 +673,7 @@ class RouterHandler(BaseHTTPRequestHandler):
|
||||
self._error_response(400, last_error)
|
||||
else:
|
||||
log.error("✗ %s ALL KEYS EXHAUSTED (%.2fs): %s", model_name, elapsed, last_error)
|
||||
self._error_response(502, f"All OCG keys exhausted: {last_error}")
|
||||
self._error_response(429, f"Rate limited, retry later: {last_error}")
|
||||
|
||||
def _forward_request(self, api_key, body, is_stream):
|
||||
"""转发单次请求到指定 key。返回 {ok, status, error}。
|
||||
|
||||
@@ -332,16 +332,10 @@ async function fI(){
|
||||
h+='<span id=wechat-qr-age>\u4e8c\u7ef4\u7801\u7ea65\u5206\u949f\u6709\u6548</span></div>';
|
||||
h+='<button id=wechat-qr-refresh class="btn r" style="margin-top:4px;font-size:14px;padding:3px 10px" onclick="refreshWechatQR()">\u5237\u65b0\u4e8c\u7ef4\u7801</button></div>';
|
||||
h+='<div style=flex:1;min-width:160px;font-size:14px;color:var(--dim)>';
|
||||
h+='<p style="margin:0 0 4px;color:var(--yellow);font-weight:600">\u83ab\u8377\u5fae\u4fe1\u9700\u8981\u626b\u7801\u767b\u5f55</p>';
|
||||
h+='<p style=margin:0;line-height:1.5>'+esc(s.message||'')+'\u3002\u7528\u5fae\u4fe1\u626b\u63cf\u5de6\u4fa7\u4e8c\u7ef4\u7801\u91cd\u65b0\u767b\u5f55\u3002</p>';
|
||||
h+='<p style=margin-top:4px>\u94fe\u63a5: <code style=word-break:break-all;font-size:13px>'+esc(s.qr_url||'')+'</code></p>';
|
||||
h+='<p style="margin:0 0 4px;color:var(--yellow);font-weight:600">莫荷微信需要扫码登录</p>';
|
||||
h+='<p style=margin:0;line-height:1.5>'+esc(s.message||'')+'。用微信扫描左侧二维码重新登录。</p>';
|
||||
h+='<p style=margin-top:4px>链接: <code style=word-break:break-all;font-size:13px>'+esc(s.qr_url||'')+'</code></p>';
|
||||
h+='</div>';
|
||||
setTimeout(function(){
|
||||
var el=document.getElementById('wechat-qr-age');
|
||||
if(el)el.innerHTML='<span style=color:var(--red)>\u4e8c\u7ef4\u7801\u5df2\u8fc7\u671f</span>';
|
||||
var btn=document.getElementById('wechat-qr-refresh');
|
||||
if(btn)btn.textContent='\u5df2\u8fc7\u671f\uff0c\u70b9\u51fb\u5237\u65b0';
|
||||
},300000);
|
||||
}else{
|
||||
h+='<div style=text-align:center;flex-shrink:0>';
|
||||
h+='<button id=wechat-qr-get class="btn s" style="font-size:14px;padding:8px 18px" onclick="getWechatQR()">\u83b7\u53d6\u767b\u5f55\u4e8c\u7ef4\u7801</button>';
|
||||
@@ -384,10 +378,11 @@ async function fI(){
|
||||
var rd=document.getElementById('rdp-section');
|
||||
if(!rd){
|
||||
rd=document.createElement('div');rd.id='rdp-section';rd.className='ps';rd.style.marginTop='16px';
|
||||
rd.innerHTML='<h2>RDP Remote Desktop<span class="help-btn" onclick="showModuleHelp(\'rdp\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'rdp\',\'ai\')" title="AI Spec">§</span></h2><div class=psv><div class=i><span class="d" id=rdp-st></span>RDP <span id=rdp-info></span></div><div class=i><span class="d" id=rdp-tun></span>Tunnel</div></div><div style="margin-top:8px;display:flex;gap:8px;align-items:center"><button class="btn s" id=btn-rdp-on>Enable</button><button class="btn x" id=btn-rdp-off>Disable</button><span id=rdp-status-txt style="font-size:12px;color:var(--dim)">-</span></div>';
|
||||
rd.innerHTML='<h2>RDP Remote Desktop<span class="help-btn" onclick="showModuleHelp(\'rdp\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'rdp\',\'ai\')" title="AI Spec">§</span></h2><div class=psv><div class=i><span class="d" id=rdp-st></span>RDP <span id=rdp-info></span></div><div class=i><span class="d" id=rdp-tun></span>Tunnel</div></div><div style="margin-top:8px;display:flex;gap:8px;align-items:center"><button class="btn s" id=btn-rdp-on>Enable</button><button class="btn x" id=btn-rdp-off>Disable</button><span id=rdp-status-txt style="font-size:12px;color:var(--dim)">-</span><a href="javascript:void(0)" id="rdp-log-link" style="font-size:11px;color:var(--dim);margin-left:auto">日志</a></div><div id="rdp-progress" style="margin-top:10px;display:none;font-size:12px"><div id="rdp-prog-msg" style="color:var(--accent);margin-bottom:6px;font-weight:600"></div><div id="rdp-prog-steps" style="line-height:1.7"></div></div><div id="rdp-logbox" style="margin-top:8px;display:none;font-size:11px;color:var(--dim);background:rgba(0,0,0,.2);padding:8px;border-radius:6px;max-height:180px;overflow:auto;white-space:pre-wrap;font-family:monospace"></div>';
|
||||
ci.appendChild(rd);
|
||||
document.getElementById('btn-rdp-on').onclick=function(){fetch('/api/rdp/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'start'})}).then(function(){fI()}).catch(function(){toast('RDP Fail','err')})};
|
||||
document.getElementById('btn-rdp-on').onclick=function(){fetch('/api/rdp/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'start'})}).then(function(){startRdpProgress()}).catch(function(){toast('RDP Fail','err')})};
|
||||
document.getElementById('btn-rdp-off').onclick=function(){fetch('/api/rdp/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'stop'})}).then(function(){fI()}).catch(function(){toast('RDP Fail','err')})};
|
||||
document.getElementById('rdp-log-link').onclick=function(){var b=document.getElementById('rdp-logbox');if(b.style.display==='none'||!b.style.display){b.style.display='block';loadRdpLog()}else{b.style.display='none'}};
|
||||
}
|
||||
try{var e4=await fetch('/api/rdp').then(function(r){return r.json()});
|
||||
document.getElementById('rdp-st').className='d '+(e4.rdp_enabled?'ok':'stopped');
|
||||
@@ -399,6 +394,42 @@ async function fI(){
|
||||
document.getElementById('rdp-status-txt').textContent=fOn?'Connected':e4.rdp_enabled?'Tunnel pending':'Disconnected';
|
||||
}catch(e4){}
|
||||
|
||||
/* --- RDP enable 实时进度轮询 + 结构化日志 --- */
|
||||
var _rdpProgTimer=null;
|
||||
function startRdpProgress(){
|
||||
var box=document.getElementById('rdp-progress'); if(box) box.style.display='block';
|
||||
if(_rdpProgTimer) clearInterval(_rdpProgTimer);
|
||||
pollRdpProgress();
|
||||
_rdpProgTimer=setInterval(pollRdpProgress,1500);
|
||||
}
|
||||
async function pollRdpProgress(){
|
||||
try{
|
||||
var d=await fetch('/api/rdp/progress').then(function(r){return r.json()});
|
||||
var msg=document.getElementById('rdp-prog-msg');
|
||||
if(msg) msg.textContent=(d.state==='running'?'⏳ ':'')+(d.message||'');
|
||||
var st=document.getElementById('rdp-prog-steps');
|
||||
if(st && d.steps){
|
||||
st.innerHTML=d.steps.map(function(s){
|
||||
var icon=s.status==='ok'?'✓':(s.status==='fail'?'✗':(s.status==='warn'?'⚠':(s.status==='skip'?'—':'…')));
|
||||
var color=s.status==='ok'?'#4caf50':(s.status==='fail'?'#f44336':(s.status==='warn'?'#ff9800':'#9aa'));
|
||||
return '<div style="color:'+color+'">'+icon+' '+s.name+' <span style="color:#778;font-size:11px">'+(s.detail||'')+'</span></div>';
|
||||
}).join('');
|
||||
}
|
||||
if(d.state==='done'||d.state==='failed'){
|
||||
if(_rdpProgTimer){clearInterval(_rdpProgTimer);_rdpProgTimer=null;}
|
||||
if(msg) msg.textContent=(d.state==='done'?'✓ ':'✗ ')+(d.message||'');
|
||||
fI();
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
async function loadRdpLog(){
|
||||
try{
|
||||
var d=await fetch('/api/rdp/enable_log?lines=80').then(function(r){return r.json()});
|
||||
var b=document.getElementById('rdp-logbox');
|
||||
if(b) b.textContent=(d.lines&&d.lines.length)?d.lines.join('\n'):'(暂无日志)';
|
||||
}catch(e){var b=document.getElementById('rdp-logbox');if(b)b.textContent='日志加载失败';}
|
||||
}
|
||||
|
||||
/* --- OpenCode Go Usage section: create once, update state each cycle (no flicker) --- */
|
||||
var us=document.getElementById('usage-section');
|
||||
if(!us){
|
||||
@@ -481,15 +512,17 @@ document.getElementById('rdp-status-txt').textContent=fOn?'Connected':e4.rdp_ena
|
||||
}else if(a.renewal==='cancelled'){
|
||||
statusTags+='<span style="font-size:11px;color:#fb8c00;background:rgba(251,140,0,.1);padding:2px 6px;border-radius:4px;margin-right:4px" title="续订已取消·当前周期仍有效">⏳ 续订取消</span>';
|
||||
}
|
||||
html+=statusTags+'<span style="font-size:11px;color:var(--dim)">'+maxPct+'% max</span>';
|
||||
if(a.stale){statusTags+='<span style="font-size:11px;color:#fb8c00;background:rgba(251,140,0,.12);padding:2px 6px;border-radius:4px;margin-right:4px" title="stale scraped data">⏰ '+(a.age_minutes>=60?(Math.round(a.age_minutes/60*10)/10)+'h ago':((a.age_minutes||0)+'min ago'))+'</span>';}
|
||||
html+=statusTags+(a.stale?'<span style="font-size:11px;color:var(--dim)">stale·'+maxPct+'% max</span>':'<span style="font-size:11px;color:var(--dim)">'+maxPct+'% max</span>');
|
||||
}
|
||||
html+='</div>';
|
||||
if(a.session_expired||a.error){
|
||||
html+='<div style="font-size:12px;color:var(--dim);padding:4px 0">'+esc(a.error||'请重新登录此账号')+'</div>';
|
||||
}else{
|
||||
html+=_renderUsageMetric('5h rolling',a.rolling,barColor);
|
||||
html+=_renderUsageMetric('Weekly',a.weekly,barColor);
|
||||
html+=_renderUsageMetric('Monthly',a.monthly,barColor);
|
||||
var mBar=a.stale?'#5f6368':barColor;
|
||||
html+=_renderUsageMetric('5h rolling',a.rolling,mBar);
|
||||
html+=_renderUsageMetric('Weekly',a.weekly,mBar);
|
||||
html+=_renderUsageMetric('Monthly',a.monthly,mBar);
|
||||
}
|
||||
html+='</div>';}
|
||||
html+='</div>';}}
|
||||
@@ -645,6 +678,14 @@ function inMd(t){return t.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>').repl
|
||||
init();loadAll();setInterval(loadAll,10000);setInterval(fK,15000);
|
||||
|
||||
/* --- WeChat QR: 获取二维码(触发容器 /api/bot/login) + 刷新 --- */
|
||||
function renderWechatQR(qrUrl){
|
||||
var box=document.getElementById('wechat-qr-get-status');
|
||||
if(box) box.innerHTML='<img id=wechat-qr-img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data='+encodeURIComponent(qrUrl)+'" style="border:2px solid var(--green);border-radius:6px">'
|
||||
+'<div style="margin-top:6px;font-size:13px;color:var(--dim)">用微信扫描上方二维码登录(约5分钟有效,过期请点下方"刷新二维码")</div>'
|
||||
+'<button class="btn r" style="margin-top:6px;font-size:13px;padding:3px 10px" onclick="refreshWechatQR()">刷新二维码</button>';
|
||||
var getBtn=document.getElementById('wechat-qr-get');
|
||||
if(getBtn) getBtn.style.display='none';
|
||||
}
|
||||
async function getWechatQR(){
|
||||
var st=document.getElementById('wechat-qr-get-status');
|
||||
var btn=document.getElementById('wechat-qr-get');
|
||||
@@ -660,7 +701,7 @@ async function getWechatQR(){
|
||||
await new Promise(res=>setTimeout(res,2000));
|
||||
try{
|
||||
var rs=await fetch('/api/wechat/status'),sd=await rs.json();
|
||||
if(sd.qr_url){location.reload();return;}
|
||||
if(sd.qr_url){renderWechatQR(sd.qr_url);return;}
|
||||
}catch(e){}
|
||||
}
|
||||
if(st)st.innerHTML='<span style=color:var(--red)>\u4e8c\u7ef4\u7801\u672a\u751f\u6210\uff0c\u8bf7\u91cd\u8bd5</span>';
|
||||
@@ -675,10 +716,17 @@ async function getWechatQR(){
|
||||
}
|
||||
}
|
||||
async function refreshWechatQR(){
|
||||
var st=document.getElementById('wechat-qr-get-status');
|
||||
if(st && !document.getElementById('wechat-qr-img')) st.innerHTML='<span style=color:var(--yellow)>正在刷新二维码...</span>';
|
||||
try{
|
||||
await fetch('/api/wechat/trigger-login',{method:'POST'});
|
||||
// 刷新后等几秒重新拉状态
|
||||
setTimeout(function(){location.reload();},3000);
|
||||
for(var i=0;i<10;i++){
|
||||
await new Promise(res=>setTimeout(res,2000));
|
||||
try{
|
||||
var rs=await fetch('/api/wechat/status'),sd=await rs.json();
|
||||
if(sd.qr_url){ renderWechatQR(sd.qr_url); return; }
|
||||
}catch(e){}
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
</script></body></html>
|
||||
Reference in New Issue
Block a user