c435413563
支持 300+ 台服务器按分类浏览与批量管理,修复分页「全部」与排序;门控 Gate3 强制本地 API。 Co-authored-by: Cursor <cursoragent@cursor.com>
211 lines
7.0 KiB
Python
211 lines
7.0 KiB
Python
#!/usr/bin/env python3
|
||
"""Import servers from Excel (.xls) via Nexus API (name + IP columns).
|
||
|
||
Usage:
|
||
NEXUS_TEST_BASE=https://api.synaglobal.vip \\
|
||
NEXUS_TEST_ADMIN_USER=admin \\
|
||
NEXUS_TEST_ADMIN_PASSWORD='***' \\
|
||
python scripts/import_servers_from_xls.py /path/to/servers.xls
|
||
|
||
Options:
|
||
--dry-run Parse only, do not call API
|
||
--batch Use /add-by-ips-batch (requires server with name-tab support)
|
||
--chunk N Batch chunk size (default 200)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
|
||
def _load_test_credentials() -> tuple[str, str, str]:
|
||
sys.path.insert(0, str(ROOT / "tests"))
|
||
import test_api # noqa: E402
|
||
|
||
test_api._load_credentials_from_env_file()
|
||
base = os.environ.get("NEXUS_TEST_BASE", "http://127.0.0.1:8600").rstrip("/")
|
||
user = os.environ.get("NEXUS_TEST_ADMIN_USER", "admin")
|
||
password = os.environ.get("NEXUS_TEST_ADMIN_PASSWORD", "admin")
|
||
return base, user, password
|
||
|
||
|
||
def _api_json(method: str, url: str, token: str | None = None, body: dict | None = None, timeout: int = 300):
|
||
data = None
|
||
headers = {"Content-Type": "application/json"}
|
||
if token:
|
||
headers["Authorization"] = f"Bearer {token}"
|
||
if body is not None:
|
||
data = json.dumps(body).encode()
|
||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||
raw = resp.read()
|
||
return resp.status, json.loads(raw) if raw else {}
|
||
except urllib.error.HTTPError as e:
|
||
raw = e.read()
|
||
try:
|
||
payload = json.loads(raw) if raw else {"detail": e.reason}
|
||
except json.JSONDecodeError:
|
||
payload = {"detail": raw.decode(errors="replace")}
|
||
return e.code, payload
|
||
|
||
|
||
def _login(base: str, user: str, password: str) -> str:
|
||
code, data = _api_json("POST", f"{base}/api/auth/login", body={"username": user, "password": password})
|
||
if code != 200 or not data.get("access_token"):
|
||
detail = data.get("detail", data)
|
||
raise SystemExit(f"登录失败 ({code}): {detail}")
|
||
return data["access_token"]
|
||
|
||
|
||
def _read_xls(path: Path) -> list[tuple[str, str]]:
|
||
try:
|
||
import xlrd
|
||
except ImportError as e:
|
||
raise SystemExit("需要 xlrd: pip install xlrd") from e
|
||
|
||
wb = xlrd.open_workbook(str(path))
|
||
sh = wb.sheet_by_index(0)
|
||
rows: list[tuple[str, str]] = []
|
||
for r in range(sh.nrows):
|
||
if sh.ncols < 2:
|
||
continue
|
||
name = str(sh.cell_value(r, 0)).strip()
|
||
ip = str(sh.cell_value(r, 1)).strip()
|
||
if not ip:
|
||
continue
|
||
rows.append((name or ip, ip))
|
||
return rows
|
||
|
||
|
||
def _dedupe_rows(rows: list[tuple[str, str]]) -> tuple[list[tuple[str, str]], int]:
|
||
seen: set[str] = set()
|
||
out: list[tuple[str, str]] = []
|
||
removed = 0
|
||
for name, ip in rows:
|
||
key = ip.casefold()
|
||
if key in seen:
|
||
removed += 1
|
||
continue
|
||
seen.add(key)
|
||
out.append((name, ip))
|
||
return out, removed
|
||
|
||
|
||
def _import_single(base: str, token: str, rows: list[tuple[str, str]], port: int, agent_port: int) -> dict:
|
||
stats = {"created": 0, "pending": 0, "skipped": 0, "errors": 0}
|
||
total = len(rows)
|
||
for i, (name, ip) in enumerate(rows, 1):
|
||
code, data = _api_json(
|
||
"POST",
|
||
f"{base}/api/servers/add-by-ip",
|
||
token=token,
|
||
body={
|
||
"domain": ip,
|
||
"name": name,
|
||
"port": port,
|
||
"agent_port": agent_port,
|
||
},
|
||
timeout=300,
|
||
)
|
||
if code == 200 and data.get("success"):
|
||
stats["created"] += 1
|
||
status = "created"
|
||
elif code == 409:
|
||
stats["skipped"] += 1
|
||
status = "skipped"
|
||
elif code == 200 and not data.get("success"):
|
||
stats["pending"] += 1
|
||
status = "pending"
|
||
else:
|
||
stats["errors"] += 1
|
||
status = f"error:{code}"
|
||
print(f"[{i}/{total}] {status} {name} ({ip})", flush=True)
|
||
return stats
|
||
|
||
|
||
def _import_batch(
|
||
base: str,
|
||
token: str,
|
||
rows: list[tuple[str, str]],
|
||
port: int,
|
||
agent_port: int,
|
||
chunk: int,
|
||
) -> dict:
|
||
stats = {"created": 0, "pending": 0, "skipped": 0, "errors": 0}
|
||
for start in range(0, len(rows), chunk):
|
||
part = rows[start : start + chunk]
|
||
text = "\n".join(f"{name}\t{ip}" for name, ip in part)
|
||
code, data = _api_json(
|
||
"POST",
|
||
f"{base}/api/servers/add-by-ips-batch",
|
||
token=token,
|
||
body={"text": text, "port": port, "agent_port": agent_port},
|
||
timeout=3600,
|
||
)
|
||
if code != 200:
|
||
stats["errors"] += len(part)
|
||
print(f"batch {start // chunk + 1} failed ({code}): {data.get('detail', data)}", flush=True)
|
||
continue
|
||
stats["created"] += int(data.get("created", 0))
|
||
stats["pending"] += int(data.get("pending", 0))
|
||
stats["skipped"] += int(data.get("skipped", 0))
|
||
print(
|
||
f"batch {start // chunk + 1}: created={data.get('created')} "
|
||
f"pending={data.get('pending')} skipped={data.get('skipped')}",
|
||
flush=True,
|
||
)
|
||
return stats
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description="Import servers from Excel via Nexus API")
|
||
parser.add_argument("xls", type=Path, help="Excel file (.xls), column A=name, B=IP")
|
||
parser.add_argument("--dry-run", action="store_true", help="Parse only")
|
||
parser.add_argument("--batch", action="store_true", help="Use batch API instead of single add-by-ip")
|
||
parser.add_argument("--chunk", type=int, default=200, help="Batch chunk size")
|
||
parser.add_argument("--port", type=int, default=22)
|
||
parser.add_argument("--agent-port", type=int, default=8601)
|
||
args = parser.parse_args()
|
||
|
||
if not args.xls.is_file():
|
||
raise SystemExit(f"文件不存在: {args.xls}")
|
||
|
||
rows = _read_xls(args.xls)
|
||
rows, dup_removed = _dedupe_rows(rows)
|
||
print(f"读取 {args.xls.name}: {len(rows)} 台(去重忽略 {dup_removed} 行重复 IP)")
|
||
if args.dry_run:
|
||
for name, ip in rows[:5]:
|
||
print(f" {name}\t{ip}")
|
||
if len(rows) > 5:
|
||
print(f" ... 共 {len(rows)} 行")
|
||
return
|
||
|
||
base, user, password = _load_test_credentials()
|
||
print(f"目标 API: {base} 用户: {user}")
|
||
token = _login(base, user, password)
|
||
started = time.time()
|
||
if args.batch:
|
||
stats = _import_batch(base, token, rows, args.port, args.agent_port, args.chunk)
|
||
else:
|
||
stats = _import_single(base, token, rows, args.port, args.agent_port)
|
||
elapsed = int(time.time() - started)
|
||
print(
|
||
f"完成 ({elapsed}s): 成功 {stats['created']},待连接 {stats['pending']},"
|
||
f"跳过 {stats['skipped']},错误 {stats['errors']}"
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|