1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165
| cat > /usr/local/bin/sub2api-polling-panel <<'EOF'
from http.server import BaseHTTPRequestHandler, HTTPServer import html import json import subprocess import urllib.parse
HOST = "127.0.0.1" PORT = 6781 TIMER = "rebalance-sub2api-openai-accounts.timer" REBALANCE = "/usr/local/bin/rebalance-sub2api-openai-accounts"
def run(cmd, check=False): p = subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) if check and p.returncode != 0: raise RuntimeError(p.stderr.strip() or p.stdout.strip() or f"command failed: {cmd}") return p
def psql(sql): p = run(["docker", "exec", "sub2api-postgres", "psql", "-U", "sub2api", "-d", "sub2api", "-At", "-F", "\t", "-c", sql]) if p.returncode != 0: return [] return [line.split("\t") for line in p.stdout.splitlines() if line.strip()]
def status(): enabled = run(["systemctl", "is-enabled", TIMER]).stdout.strip() == "enabled" active = run(["systemctl", "is-active", TIMER]).stdout.strip() == "active" timers = run(["systemctl", "list-timers", TIMER, "--no-pager"]).stdout advanced = psql("select value from settings where key='openai_advanced_scheduler_enabled';") priority_rows = psql("select priority::text, count(*)::text from accounts where platform='openai' and deleted_at is null group by priority order by priority;") health_rows = psql(""" select count(*)::text, count(*) filter (where status = 'active' and schedulable = true and rate_limited_at is null and rate_limit_reset_at is null and overload_until is null and temp_unschedulable_until is null and coalesce(error_message, '') = '' and coalesce((extra->>'codex_5h_used_percent')::numeric,0) < 100 and coalesce((extra->>'codex_7d_used_percent')::numeric,0) < 100)::text, count(*) filter (where rate_limited_at is not null or rate_limit_reset_at is not null or coalesce((extra->>'codex_5h_used_percent')::numeric,0) >= 100 or coalesce((extra->>'codex_7d_used_percent')::numeric,0) >= 100)::text, count(*) filter (where overload_until is not null or temp_unschedulable_until is not null)::text, count(*) filter (where status <> 'active' or schedulable = false or coalesce(error_message, '') <> '')::text from accounts where platform='openai' and deleted_at is null; """) issue_rows = psql(""" select id::text, name, status, schedulable::text, case when coalesce((extra->>'codex_7d_used_percent')::numeric,0) >= 100 then '7d 额度已用完' when coalesce((extra->>'codex_5h_used_percent')::numeric,0) >= 100 then '5h 额度已用完' when rate_limited_at is not null or rate_limit_reset_at is not null then '限流/疑似额度耗尽' when overload_until is not null then '过载暂停' when temp_unschedulable_until is not null then '临时不可调度' when status <> 'active' then '状态异常' when schedulable = false then '不可调度' when coalesce(error_message, '') <> '' then '错误' else '正常' end, coalesce(extra->>'codex_5h_used_percent', '0') || '%', coalesce(extra->>'codex_7d_used_percent', '0') || '%', coalesce(extra->>'codex_7d_reset_at', extra->>'codex_5h_reset_at', to_char(rate_limit_reset_at, 'YYYY-MM-DD HH24:MI:SS'), ''), coalesce(left(nullif(error_message, ''), 180), '') from accounts where platform='openai' and deleted_at is null and (status <> 'active' or schedulable=false or rate_limited_at is not null or rate_limit_reset_at is not null or overload_until is not null or temp_unschedulable_until is not null or coalesce(error_message, '') <> '' or coalesce((extra->>'codex_5h_used_percent')::numeric,0) >= 100 or coalesce((extra->>'codex_7d_used_percent')::numeric,0) >= 100) order by id limit 80; """) top_rows = psql(""" select a.id::text, a.name, a.priority::text, a.status, a.schedulable::text, coalesce(x.req_count, 0)::text, coalesce(x.token_count, 0)::text, coalesce(to_char(a.last_used_at, 'YYYY-MM-DD HH24:MI:SS'), '') from accounts a left join ( select account_id, count(*) as req_count, sum(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_creation_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(image_output_tokens,0)) as token_count from usage_logs where created_at >= now() - interval '3 days' group by account_id ) x on x.account_id = a.id where a.platform='openai' and a.deleted_at is null order by a.priority asc, coalesce(x.req_count,0) asc, coalesce(x.token_count,0) asc, a.last_used_at asc nulls first, a.id asc limit 30; """) return { "enabled": enabled, "active": active, "advanced_scheduler": advanced[0][0] if advanced else "missing", "timer": timers, "priority_rows": priority_rows, "health": health_rows[0] if health_rows else ["0", "0", "0", "0", "0"], "issue_rows": issue_rows, "top_rows": top_rows, }
CSS = ":root{font-family:Inter,Segoe UI,Arial,sans-serif;color:#1f2937;background:#f5f7fb}body{margin:0}.wrap{max-width:1180px;margin:0 auto;padding:28px}.bar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:22px}.title{font-size:24px;font-weight:700}.sub{color:#667085;margin-top:5px}.status{display:flex;gap:10px;align-items:center;font-weight:650}.dot{width:11px;height:11px;border-radius:50%;background:#d92d20}.dot.on{background:#039855}.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.cards{display:grid;grid-template-columns:repeat(5,1fr);gap:10px}.metric{background:#fff;border:1px solid #d9e1ec;border-radius:8px;padding:12px}.metric b{display:block;font-size:22px}.metric span{font-size:12px;color:#667085}.bad b{color:#b42318}.warn b{color:#b54708}.ok b{color:#027a48}.panel{background:white;border:1px solid #d9e1ec;border-radius:8px;padding:16px;box-shadow:0 1px 2px rgba(16,24,40,.05)}.actions{display:flex;flex-wrap:wrap;gap:10px}.btn{border:1px solid #b8c3d1;background:#fff;color:#1f2937;padding:9px 13px;border-radius:6px;cursor:pointer;font-weight:650}.btn.primary{background:#175cd3;color:white;border-color:#175cd3}.btn.danger{background:#b42318;color:white;border-color:#b42318}table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:8px;border-bottom:1px solid #edf1f7;vertical-align:top}th{color:#475467;background:#f8fafc}.mono{font-family:Consolas,ui-monospace,monospace;white-space:pre-wrap;font-size:12px;background:#0f172a;color:#e5e7eb;border-radius:6px;padding:12px;overflow:auto;max-height:220px}.note{font-size:13px;color:#667085;margin-top:10px}.empty{color:#027a48;font-weight:650;padding:8px 0}@media(max-width:900px){.grid,.cards{grid-template-columns:1fr}.bar{align-items:flex-start;flex-direction:column}}"
def rows_html(rows): return "".join("<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in r) + "</tr>" for r in rows)
def page(message=""): s = status() state_text = "已开启" if s["enabled"] and s["active"] else "已关闭" dot = "dot on" if s["enabled"] and s["active"] else "dot" priority = "".join(f"<tr><td>{html.escape(r[0])}</td><td>{html.escape(r[1])}</td></tr>" for r in s["priority_rows"]) total, healthy, limited, paused, abnormal = s["health"] issue_body = rows_html(s["issue_rows"]) if s["issue_rows"] else "<tr><td colspan='9'><div class='empty'>当前没有检测到限流、额度耗尽或异常账号</div></td></tr>" top = rows_html(s["top_rows"]) msg = f"<div class='panel'>{html.escape(message)}</div>" if message else "" return f"""<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>sub2api 轮询控制</title><style>{CSS}</style></head><body><div class='wrap'><div class='bar'><div><div class='title'>sub2api 账号轮询控制</div><div class='sub'>近 3 天低用量账号优先,每 10 分钟自动重算</div></div><div class='status'><span class='{dot}'></span>{state_text}</div></div>{msg}<div class='panel'><form class='actions' method='post'><button class='btn primary' name='action' value='enable'>开启轮询</button><button class='btn danger' name='action' value='disable'>关闭轮询</button><button class='btn' name='action' value='rebalance'>立即重算</button><button class='btn' name='action' value='refresh'>刷新</button></form><div class='note'>高级调度开关:{html.escape(s['advanced_scheduler'])}</div></div><div class='cards' style='margin-top:16px'><div class='metric'><b>{total}</b><span>OpenAI 总账号</span></div><div class='metric ok'><b>{healthy}</b><span>正常可调度</span></div><div class='metric warn'><b>{limited}</b><span>限流/疑似额度耗尽</span></div><div class='metric warn'><b>{paused}</b><span>暂停/临时不可调度</span></div><div class='metric bad'><b>{abnormal}</b><span>错误/不可用</span></div></div><div class='grid' style='margin-top:16px'><div class='panel'><h3>优先级分布</h3><table><thead><tr><th>priority</th><th>账号数</th></tr></thead><tbody>{priority}</tbody></table></div><div class='panel'><h3>Systemd Timer</h3><div class='mono'>{html.escape(s['timer'])}</div></div></div><div class='panel' style='margin-top:16px'><h3>额度/限流状态</h3><table><thead><tr><th>ID</th><th>账号</th><th>状态</th><th>可调度</th><th>判断</th><th>5h 使用率</th><th>7d 使用率</th><th>重置时间</th><th>错误信息</th></tr></thead><tbody>{issue_body}</tbody></table></div><div class='panel' style='margin-top:16px'><h3>当前优先使用的账号</h3><table><thead><tr><th>ID</th><th>账号</th><th>priority</th><th>状态</th><th>可调度</th><th>近3天请求</th><th>近3天Token</th><th>最后使用</th></tr></thead><tbody>{top}</tbody></table></div></div></body></html>"""
class Handler(BaseHTTPRequestHandler): def do_GET(self): if urllib.parse.urlparse(self.path).path == "/api/status": data = json.dumps(status(), ensure_ascii=False).encode() self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) return body = page().encode() self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body)
def do_POST(self): length = int(self.headers.get("Content-Length", "0") or 0) raw = self.rfile.read(length).decode() action = urllib.parse.parse_qs(raw).get("action", [""])[0] message = "已刷新" if action == "enable": run(["systemctl", "enable", "--now", TIMER], check=True) run(["docker", "exec", "sub2api-postgres", "psql", "-U", "sub2api", "-d", "sub2api", "-c", "insert into settings (key,value,updated_at) values ('openai_advanced_scheduler_enabled','true',now()) on conflict (key) do update set value='true', updated_at=now();"], check=True) message = "轮询已开启" elif action == "disable": run(["systemctl", "disable", "--now", TIMER], check=False) run(["docker", "exec", "sub2api-postgres", "psql", "-U", "sub2api", "-d", "sub2api", "-c", "insert into settings (key,value,updated_at) values ('openai_advanced_scheduler_enabled','false',now()) on conflict (key) do update set value='false', updated_at=now(); update accounts set priority=1, updated_at=now() where platform='openai' and deleted_at is null; update account_groups ag set priority=1 from accounts a where a.id=ag.account_id and a.platform='openai' and a.deleted_at is null;"], check=True) message = "轮询已关闭,账号优先级已恢复为 1" elif action == "rebalance": run([REBALANCE, "3"], check=True) message = "已立即重算近 3 天低用量优先级" body = page(message).encode() self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body)
def log_message(self, fmt, *args): return
if __name__ == "__main__": HTTPServer((HOST, PORT), Handler).serve_forever() EOF
chmod +x /usr/local/bin/sub2api-polling-panel
|