- run_sync 改线程池并行:每轮最多 N 个项目同时克隆/推送,项目多时 显著缩短单轮耗时(实测 6 个仓库 3 并发 3 倍加速) - 按仓库防重入:同一仓库同时只允许一个同步,后到者提示 「该仓库正在同步中,请稍候」,避免并发 git 操作损坏镜像 - 状态页单仓库「同步」按钮不再被整轮同步全局拦截(旧版报 「已有同步任务正在进行」),与进行中的整轮真正并行 - 新增「同时同步项目数」设置(1~8),设置页可调,立即生效 - 统计计数与日志写入加锁,修正并行下的计数漂移与日志交错风险 - 首页/状态页显示当前并行数;manifest 1.2.0 → 1.3.0
1652 lines
77 KiB
Python
1652 lines
77 KiB
Python
#!/usr/bin/env python3
|
||
# ============================================================
|
||
# GiteaHubSync — Gitea → GitHub 镜像同步守护进程 + 控制面板
|
||
# 纯 Python 标准库。随 fnOS 应用安装,由 cmd/main 以
|
||
# setsid nohup 方式拉起;数据目录 = TRIM_PKGVAR(@appdata/GiteaHubSync)
|
||
# 页面: / 控制面板(开关/立即同步/快捷入口)
|
||
# /status 项目状态(每个仓库的详细同步状态)
|
||
# /manage 项目管理(从 Gitea 快捷添加/移除同步项目)
|
||
# /settings 设置(GitHub 多账号/切换用户、Gitea 用户名、自动创建仓库)
|
||
# 多账号: accounts.json 保存多个 GitHub 账号(用户名 + Personal Access Token),
|
||
# 可随时切换「当前账号」;每个账号独立记录同步进度。带 Token 的账号走
|
||
# HTTPS 推送并支持自动创建 GitHub 仓库;无 Token 的旧账号走 SSH 私钥推送。
|
||
# ============================================================
|
||
import atexit
|
||
import json
|
||
import os
|
||
import re
|
||
import signal
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from datetime import datetime
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from pathlib import Path
|
||
from urllib.parse import quote, urlsplit
|
||
|
||
APP_DIR = Path(__file__).resolve().parent.parent
|
||
DATA_DIR = Path(os.environ.get("GHS_DATA_DIR", "/vol1/@appdata/GiteaHubSync"))
|
||
REPO_DIR = DATA_DIR / "repos"
|
||
STATE_DIR = DATA_DIR / ".last-pushed"
|
||
LOG_FILE = DATA_DIR / "sync.log"
|
||
PID_FILE = DATA_DIR / "daemon.pid"
|
||
PANEL_STATE = DATA_DIR / "state.json"
|
||
REPOS_FILE = DATA_DIR / "repos.json"
|
||
STATS_FILE = DATA_DIR / "stats.json"
|
||
ACCOUNTS_FILE = DATA_DIR / "accounts.json" # GitHub 多账号(含 Token,权限 600)
|
||
SETTINGS_FILE = DATA_DIR / "settings.json" # Gitea 用户名/密码、自动创建开关
|
||
GITCREDS_FILE = DATA_DIR / ".gitcreds" # git credential store(Gitea 密码,权限 600)
|
||
SSH_KEY = APP_DIR / "ssh" / "id_ed25519"
|
||
if (DATA_DIR / "id_ed25519").exists():
|
||
SSH_KEY = DATA_DIR / "id_ed25519"
|
||
KNOWN_HOSTS = DATA_DIR / "known_hosts"
|
||
GITEA_URL_ENV = os.environ.get("GITEA_URL") # “打开 Gitea”按钮地址覆盖,一般不设
|
||
DEFAULT_GITEA_HOST = "http://127.0.0.1:3000" # 同步引擎访问的 Gitea 地址(设置页可改)
|
||
DEFAULT_REPOS = ["SMB_Android", "DCU_APP", "GD32E50X_BOOT", "DCU_DesktopController",
|
||
"Motor_v2", "DCU_ECAT_Master", "GD32_BOOT"]
|
||
LEGACY_GH_USER = "cbb2625274797" # 旧版硬编码账号,仅用于首次运行的迁移
|
||
LEGACY_GITEA_USER = "cbb" # 旧版硬编码的 Gitea 用户名,仅作默认值
|
||
DEFAULT_INTERVAL = 10 # 同步间隔默认值(秒)
|
||
MIN_INTERVAL = 5 # 最小同步间隔(秒)
|
||
MAX_INTERVAL = 86400 # 最大同步间隔(秒,1 天)
|
||
DEFAULT_WORKERS = 3 # 同时同步的项目数默认值
|
||
MIN_WORKERS = 1
|
||
MAX_WORKERS = 8
|
||
PANEL_PORT = int(os.environ.get("PANEL_PORT", "8899"))
|
||
START_TS = time.time()
|
||
NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,100}$")
|
||
|
||
ENV = dict(os.environ)
|
||
ENV["GIT_SSH_COMMAND"] = (f"ssh -i {SSH_KEY} -o IdentitiesOnly=yes "
|
||
f"-o StrictHostKeyChecking=accept-new "
|
||
f"-o UserKnownHostsFile={KNOWN_HOSTS}")
|
||
ENV["GIT_TERMINAL_PROMPT"] = "0"
|
||
|
||
# ---------------- 应用设置(settings.json) ----------------
|
||
cfg = {"gitea_user": LEGACY_GITEA_USER, "gitea_pass": "", "auto_create": True,
|
||
"interval": DEFAULT_INTERVAL, "gitea_host": DEFAULT_GITEA_HOST,
|
||
"max_workers": DEFAULT_WORKERS}
|
||
|
||
|
||
def norm_gitea_host(h):
|
||
"""规范化 Gitea 地址:补 http://、去尾部斜杠;不合法返回 None"""
|
||
h = str(h or "").strip().rstrip("/")
|
||
if not h:
|
||
return None
|
||
if "://" not in h:
|
||
h = "http://" + h
|
||
try:
|
||
p = urlsplit(h)
|
||
host = p.hostname or ""
|
||
if p.scheme in ("http", "https") and host \
|
||
and not p.path.strip("/") and not p.query and not p.fragment \
|
||
and re.fullmatch(r"[A-Za-z0-9.\-:\[\]]+", host):
|
||
return h
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def gitea_host():
|
||
return cfg.get("gitea_host") or DEFAULT_GITEA_HOST
|
||
|
||
|
||
def gitea_ui(host_header=""):
|
||
"""「打开 Gitea」按钮地址:优先环境变量;否则用设置里的地址,
|
||
但把 127.0.0.1/localhost 替换成访问面板的主机名(浏览器打不开本机回环)"""
|
||
if GITEA_URL_ENV:
|
||
return GITEA_URL_ENV
|
||
base = gitea_host()
|
||
try:
|
||
p = urlsplit(base)
|
||
host = (host_header or "").split(":")[0].strip()
|
||
if host and p.hostname in ("127.0.0.1", "localhost", "0.0.0.0", "::1", ""):
|
||
return f"{p.scheme}://{host}" + (f":{p.port}" if p.port else "")
|
||
except Exception:
|
||
pass
|
||
return base
|
||
|
||
|
||
def sync_interval():
|
||
"""当前同步间隔(秒),始终返回合法值"""
|
||
try:
|
||
iv = int(cfg.get("interval", DEFAULT_INTERVAL))
|
||
except Exception:
|
||
iv = DEFAULT_INTERVAL
|
||
return min(MAX_INTERVAL, max(MIN_INTERVAL, iv))
|
||
|
||
|
||
def valid_interval(v):
|
||
try:
|
||
return MIN_INTERVAL <= int(v) <= MAX_INTERVAL
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def sync_workers():
|
||
"""同时同步的项目数,始终返回合法值"""
|
||
try:
|
||
n = int(cfg.get("max_workers", DEFAULT_WORKERS))
|
||
except Exception:
|
||
n = DEFAULT_WORKERS
|
||
return min(MAX_WORKERS, max(MIN_WORKERS, n))
|
||
|
||
|
||
def valid_workers(v):
|
||
try:
|
||
return MIN_WORKERS <= int(v) <= MAX_WORKERS
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def load_settings():
|
||
try:
|
||
d = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
|
||
if isinstance(d.get("gitea_user"), str) and valid_name(d["gitea_user"]):
|
||
cfg["gitea_user"] = d["gitea_user"]
|
||
cfg["gitea_pass"] = str(d.get("gitea_pass") or "")
|
||
cfg["auto_create"] = bool(d.get("auto_create", True))
|
||
if valid_interval(d.get("interval")):
|
||
cfg["interval"] = int(d["interval"])
|
||
if valid_workers(d.get("max_workers")):
|
||
cfg["max_workers"] = int(d["max_workers"])
|
||
h = norm_gitea_host(d.get("gitea_host"))
|
||
if h:
|
||
cfg["gitea_host"] = h
|
||
except Exception:
|
||
pass
|
||
write_gitcreds()
|
||
|
||
|
||
def save_settings():
|
||
try:
|
||
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
SETTINGS_FILE.write_text(json.dumps(cfg, ensure_ascii=False, indent=1),
|
||
encoding="utf-8")
|
||
os.chmod(SETTINGS_FILE, 0o600) # 含 Gitea 密码,仅属主可读
|
||
except Exception as e:
|
||
log(f"保存 settings.json 失败: {e}")
|
||
write_gitcreds()
|
||
|
||
|
||
def gitea_base():
|
||
"""Gitea 上当前用户的仓库前缀(地址可在设置页修改)"""
|
||
return f"{gitea_host()}/{cfg['gitea_user']}"
|
||
|
||
|
||
# ---------------- GitHub 多账号(accounts.json) ----------------
|
||
accounts = [] # [{id,label,gh_user,token,private,created}]
|
||
active_id = None
|
||
|
||
|
||
def load_accounts():
|
||
"""加载账号;首次运行从旧版迁移:生成「默认(SSH 私钥)」账号,行为与旧版一致"""
|
||
global accounts, active_id
|
||
try:
|
||
d = json.loads(ACCOUNTS_FILE.read_text(encoding="utf-8"))
|
||
accounts = [a for a in d.get("accounts", []) if a.get("id") and a.get("gh_user")]
|
||
active_id = d.get("active")
|
||
except Exception:
|
||
accounts = []
|
||
if not accounts:
|
||
accounts = [{"id": "default", "label": "默认(SSH 私钥)",
|
||
"gh_user": LEGACY_GH_USER, "token": "", "private": True,
|
||
"created": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}]
|
||
active_id = "default"
|
||
migrate_flat_state()
|
||
save_accounts()
|
||
if active_id not in [a["id"] for a in accounts]:
|
||
active_id = accounts[0]["id"]
|
||
save_accounts()
|
||
|
||
|
||
def save_accounts():
|
||
try:
|
||
ACCOUNTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
ACCOUNTS_FILE.write_text(
|
||
json.dumps({"active": active_id, "accounts": accounts},
|
||
ensure_ascii=False, indent=1), encoding="utf-8")
|
||
os.chmod(ACCOUNTS_FILE, 0o600) # 含 Token,仅属主可读
|
||
except Exception as e:
|
||
log(f"保存 accounts.json 失败: {e}")
|
||
|
||
|
||
def active_account():
|
||
for a in accounts:
|
||
if a["id"] == active_id:
|
||
return a
|
||
return accounts[0] if accounts else None
|
||
|
||
|
||
def find_account(aid):
|
||
for a in accounts:
|
||
if a["id"] == aid:
|
||
return a
|
||
return None
|
||
|
||
|
||
def migrate_flat_state():
|
||
"""旧版同步状态平铺在 STATE_DIR/<name>;新版按账号分目录 STATE_DIR/<account>/<name>"""
|
||
try:
|
||
legacy_dir = STATE_DIR / "default"
|
||
legacy_dir.mkdir(parents=True, exist_ok=True)
|
||
for f in STATE_DIR.iterdir():
|
||
if f.is_file() and valid_name(f.name):
|
||
f.rename(legacy_dir / f.name)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def state_path(acc, name):
|
||
return STATE_DIR / ((acc or {}).get("id") or "default") / name
|
||
|
||
|
||
def mask_token(tok):
|
||
if not tok:
|
||
return ""
|
||
if len(tok) <= 8:
|
||
return "****"
|
||
return tok[:7] + "****" + tok[-4:]
|
||
|
||
|
||
def scrub(s, acc):
|
||
"""日志/报错脱敏:GitHub Token 和 Gitea 密码不进日志"""
|
||
if not s:
|
||
return s
|
||
if acc and acc.get("token"):
|
||
s = s.replace(acc["token"], "***")
|
||
if cfg.get("gitea_pass"):
|
||
s = s.replace(cfg["gitea_pass"], "***")
|
||
return s
|
||
|
||
state = {"enabled": True, "syncing": False, "last_check": None, "checks": 0,
|
||
"last_result": "尚未运行", "loop_last": None}
|
||
stats = {"total_checks": 0, "total_pushes": 0, "total_failures": 0}
|
||
op_lock = threading.Lock() # 整轮同步互斥(同一轮只跑一次)
|
||
stats_lock = threading.Lock() # 保护 stats 计数
|
||
log_lock = threading.Lock() # 保护日志文件写入
|
||
repo_busy_lock = threading.Lock() # 保护 repo_in_progress
|
||
repo_in_progress = set() # 正在同步的仓库名(按仓库防重入)
|
||
|
||
|
||
# ---------------- 基础工具 ----------------
|
||
def log(msg):
|
||
with log_lock:
|
||
try:
|
||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||
f.write(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def bump_stats(**kw):
|
||
"""线程安全地累加统计计数"""
|
||
with stats_lock:
|
||
for k, v in kw.items():
|
||
stats[k] = stats.get(k, 0) + v
|
||
save_stats()
|
||
|
||
|
||
def write_gitcreds():
|
||
"""把 Gitea 凭据写成 git credential store 文件(600),供 clone/fetch 读取;
|
||
这样镜像仓库的 remote 地址里不保存密码。清除密码时删除该文件。"""
|
||
try:
|
||
if cfg.get("gitea_pass"):
|
||
p = urlsplit(gitea_host())
|
||
netloc = p.hostname + (f":{p.port}" if p.port else "")
|
||
user = quote(str(cfg["gitea_user"]), safe="")
|
||
pw = quote(str(cfg["gitea_pass"]), safe="")
|
||
GITCREDS_FILE.write_text(f"{p.scheme}://{user}:{pw}@{netloc}\n",
|
||
encoding="utf-8")
|
||
os.chmod(GITCREDS_FILE, 0o600)
|
||
elif GITCREDS_FILE.exists():
|
||
GITCREDS_FILE.unlink()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def repoint_mirror_remotes():
|
||
"""Gitea 地址变更后,把已有镜像仓库的 origin 指向新地址"""
|
||
n = 0
|
||
try:
|
||
for repo in REPO_DIR.glob("*.git"):
|
||
r = git(["-C", str(repo), "remote", "set-url", "origin",
|
||
f"{gitea_base()}/{repo.name[:-4]}.git"], timeout=20)
|
||
if r.returncode == 0:
|
||
n += 1
|
||
except Exception as e:
|
||
log(f"更新镜像 remote 失败: {e}")
|
||
return n
|
||
|
||
|
||
def git_creds_args():
|
||
if cfg.get("gitea_pass") and GITCREDS_FILE.exists():
|
||
return ["-c", f"credential.helper=store --file={GITCREDS_FILE}"]
|
||
return []
|
||
|
||
|
||
def git(args, timeout=300):
|
||
return subprocess.run(["git"] + git_creds_args() + args, capture_output=True,
|
||
text=True, env=ENV, timeout=timeout)
|
||
|
||
|
||
def load_stats():
|
||
global stats
|
||
try:
|
||
stats.update(json.loads(STATS_FILE.read_text(encoding="utf-8")))
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def save_stats():
|
||
try:
|
||
STATS_FILE.write_text(json.dumps(stats), encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def load_repos():
|
||
"""同步项目列表(repos.json),首次运行播种默认 7 个"""
|
||
try:
|
||
data = json.loads(REPOS_FILE.read_text(encoding="utf-8"))
|
||
repos = [r for r in data.get("repos", []) if valid_name(r)]
|
||
if repos:
|
||
return repos
|
||
except Exception:
|
||
pass
|
||
save_repos(DEFAULT_REPOS)
|
||
return list(DEFAULT_REPOS)
|
||
|
||
|
||
def save_repos(repos):
|
||
try:
|
||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
REPOS_FILE.write_text(json.dumps({"repos": repos}, ensure_ascii=False, indent=1),
|
||
encoding="utf-8")
|
||
except Exception as e:
|
||
log(f"保存 repos.json 失败: {e}")
|
||
|
||
|
||
def valid_name(name):
|
||
return bool(name) and bool(NAME_RE.match(name)) and name not in (".", "..")
|
||
|
||
|
||
def gitea_api(path):
|
||
"""访问本地 Gitea API(配置了密码则带 Basic Auth,可读私有仓库),返回 (ok, json)"""
|
||
try:
|
||
cmd = ["curl", "-sS", "-m", "10"]
|
||
if cfg.get("gitea_pass"):
|
||
cmd += ["-u", f"{cfg['gitea_user']}:{cfg['gitea_pass']}"]
|
||
cmd.append(f"{gitea_host()}/api/v1{path}")
|
||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||
return r.returncode == 0, json.loads(r.stdout or "{}")
|
||
except Exception:
|
||
return False, {}
|
||
|
||
|
||
def gitea_probe(host=None):
|
||
"""探测 Gitea 地址连通性,返回 (ok, msg)"""
|
||
host = host or gitea_host()
|
||
try:
|
||
r = subprocess.run(["curl", "-sS", "-m", "8", "-w", "\n%{http_code}",
|
||
f"{host}/api/v1/version"],
|
||
capture_output=True, text=True, timeout=12)
|
||
out, _, code = r.stdout.rpartition("\n")
|
||
st = int(code) if code.strip().isdigit() else 0
|
||
if 200 <= st < 300:
|
||
try:
|
||
ver = json.loads(out or "{}").get("version", "?")
|
||
except Exception:
|
||
ver = "?"
|
||
return True, f"连接成功,Gitea 版本 {ver}"
|
||
return False, f"地址连不上(HTTP {st or '超时'}),请检查后重试"
|
||
except Exception as e:
|
||
return False, f"地址连不上:{e}"
|
||
|
||
|
||
def gitea_repo_exists(name):
|
||
ok, d = gitea_api(f"/repos/{cfg['gitea_user']}/{name}")
|
||
return ok and isinstance(d, dict) and d.get("full_name") == f"{cfg['gitea_user']}/{name}"
|
||
|
||
|
||
def gh_api(acc, path, method="GET", body=None, timeout=15):
|
||
"""调用 GitHub API(需 Token)。返回 (ok, http_status, json)"""
|
||
if not acc or not acc.get("token"):
|
||
return False, 0, {}
|
||
cmd = ["curl", "-sS", "-m", str(timeout), "-X", method,
|
||
"-H", f"Authorization: token {acc['token']}",
|
||
"-H", "Accept: application/vnd.github+json",
|
||
"-H", "Content-Type: application/json", "-w", "\n%{http_code}"]
|
||
if body is not None:
|
||
cmd += ["-d", json.dumps(body, ensure_ascii=False)]
|
||
cmd.append(f"https://api.github.com{path}")
|
||
try:
|
||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 5)
|
||
out, _, code = r.stdout.rpartition("\n")
|
||
status = int(code) if code.strip().isdigit() else 0
|
||
try:
|
||
d = json.loads(out or "{}")
|
||
except Exception:
|
||
d = {}
|
||
return (200 <= status < 300), status, d
|
||
except Exception:
|
||
return False, 0, {}
|
||
|
||
|
||
def gh_verify_token(token):
|
||
"""验证 Token 有效性,返回 (ok, login, msg)"""
|
||
token = (token or "").strip()
|
||
if not token:
|
||
return False, "", "Token 不能为空"
|
||
try:
|
||
r = subprocess.run(
|
||
["curl", "-sS", "-m", "15", "-w", "\n%{http_code}",
|
||
"-H", f"Authorization: token {token}",
|
||
"-H", "Accept: application/vnd.github+json",
|
||
"https://api.github.com/user"],
|
||
capture_output=True, text=True, timeout=20)
|
||
out, _, code = r.stdout.rpartition("\n")
|
||
status = int(code) if code.strip().isdigit() else 0
|
||
if 200 <= status < 300:
|
||
d = json.loads(out or "{}")
|
||
return True, (d.get("login") or ""), ""
|
||
if status == 401:
|
||
return False, "", "Token 无效或已过期(请重新生成)"
|
||
return False, "", f"GitHub API 返回 HTTP {status}"
|
||
except Exception as e:
|
||
return False, "", f"网络错误: {e}"
|
||
|
||
|
||
def gh_push_url(acc, name):
|
||
"""当前账号的 GitHub 推送地址:有 Token 走 HTTPS,否则回退 SSH 私钥"""
|
||
if acc.get("token"):
|
||
return f"https://x-access-token:{acc['token']}@github.com/{acc['gh_user']}/{name}.git"
|
||
return f"git@github.com:{acc['gh_user']}/{name}.git"
|
||
|
||
|
||
def github_repo_exists(acc, name):
|
||
if acc.get("token"):
|
||
ok, status, d = gh_api(acc, f"/repos/{acc['gh_user']}/{name}")
|
||
return ok and status == 200 and isinstance(d, dict) \
|
||
and d.get("full_name") == f"{acc['gh_user']}/{name}"
|
||
try:
|
||
r = git(["ls-remote", gh_push_url(acc, name), "HEAD"], timeout=30)
|
||
return r.returncode == 0
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def github_create_repo(acc, name):
|
||
"""在 GitHub 自动创建仓库(需 Token)。返回 (ok, msg)"""
|
||
if not acc.get("token"):
|
||
return False, "当前账号未配置 Token,无法自动创建(请到设置页添加带 Token 的 GitHub 账号)"
|
||
ok, status, d = gh_api(acc, "/user/repos", "POST", {
|
||
"name": name, "private": bool(acc.get("private", True)),
|
||
"description": f"镜像自飞牛 Gitea 仓库 {name}(GiteaHubSync 自动创建)"})
|
||
if ok and status == 201:
|
||
return True, "已创建"
|
||
if status == 422 and isinstance(d, dict) and "errors" in d:
|
||
return True, "已存在" # 重名等情况,按已存在继续推送
|
||
msg = d.get("message") if isinstance(d, dict) else str(d)
|
||
return False, f"HTTP {status} {msg}"
|
||
|
||
|
||
def list_gitea_repos():
|
||
ok, d = gitea_api(f"/users/{cfg['gitea_user']}/repos?limit=100")
|
||
if ok and isinstance(d, list):
|
||
return [x.get("name") for x in d if x.get("name")]
|
||
return []
|
||
|
||
|
||
# ---------------- 同步逻辑 ----------------
|
||
def fingerprint(repo):
|
||
r = git(["-C", str(repo), "for-each-ref", "--format=%(refname) %(objectname)"])
|
||
if r.returncode != 0:
|
||
return None
|
||
import hashlib
|
||
return hashlib.sha1(r.stdout.encode()).hexdigest()
|
||
|
||
|
||
def sync_repo(name):
|
||
"""返回 'changed' / 'same' / 'error:...';同一仓库并发调用时后到者直接返回"""
|
||
if not valid_name(name):
|
||
return "error: 非法仓库名"
|
||
acc = active_account()
|
||
if not acc:
|
||
return "error: 没有可用的 GitHub 账号,请到设置页添加"
|
||
with repo_busy_lock:
|
||
if name in repo_in_progress:
|
||
return "error: 该仓库正在同步中,请稍候"
|
||
repo_in_progress.add(name)
|
||
try:
|
||
return _sync_repo_locked(name, acc)
|
||
finally:
|
||
with repo_busy_lock:
|
||
repo_in_progress.discard(name)
|
||
|
||
|
||
def _sync_repo_locked(name, acc):
|
||
repo = REPO_DIR / f"{name}.git"
|
||
try:
|
||
if not repo.exists():
|
||
REPO_DIR.mkdir(parents=True, exist_ok=True)
|
||
r = git(["clone", "--quiet", "--mirror", f"{gitea_base()}/{name}.git", str(repo)])
|
||
if r.returncode != 0:
|
||
return "error: 克隆失败 " + scrub((r.stderr or "")[:120], acc)
|
||
log(f"{name}: 首次克隆完成")
|
||
r = git(["-C", str(repo), "fetch", "--quiet", "--prune", "origin"])
|
||
if r.returncode != 0:
|
||
return "error: fetch 失败 " + scrub((r.stderr or "")[:120], acc)
|
||
cur = fingerprint(repo)
|
||
if cur is None:
|
||
return "error: 读取 refs 失败"
|
||
st = state_path(acc, name)
|
||
prev = st.read_text().strip() if st.exists() else ""
|
||
if cur == prev:
|
||
return "same"
|
||
# 推送前确保 GitHub 仓库存在;不存在时按设置自动创建
|
||
if not github_repo_exists(acc, name):
|
||
if acc.get("token") and cfg.get("auto_create", True):
|
||
ok, msg = github_create_repo(acc, name)
|
||
if not ok:
|
||
return f"error: 自动创建 GitHub 仓库失败: {msg}"
|
||
log(f"{name}: GitHub 仓库不存在,已自动创建"
|
||
f"({'私有' if acc.get('private', True) else '公开'})")
|
||
else:
|
||
hint = ("当前账号无 Token 或未开启自动创建,请到设置页配置"
|
||
if not acc.get("token") else
|
||
"自动创建已关闭,请到设置页开启或在 GitHub 手动创建")
|
||
return f"error: GitHub 上不存在 {acc['gh_user']}/{name},{hint}"
|
||
p = git(["-C", str(repo), "push", "--mirror", gh_push_url(acc, name)])
|
||
if p.returncode != 0:
|
||
return "error: 推送失败 " + scrub((p.stderr or p.stdout or "")[:120], acc)
|
||
st.parent.mkdir(parents=True, exist_ok=True)
|
||
st.write_text(cur)
|
||
detail = " ".join(l.strip() for l in p.stdout.splitlines()
|
||
if any(k in l for k in ("new branch", "new tag", "deleted", "forced", "->")))
|
||
log(f"{name}: 已同步到 GitHub({acc['gh_user']})"
|
||
+ (f" | {scrub(detail, acc)}" if detail else ""))
|
||
return "changed"
|
||
except Exception as e:
|
||
return f"error: {scrub(str(e), acc)}"
|
||
|
||
|
||
def run_sync(repos=None):
|
||
"""跑一轮同步:多个项目按设置并行(默认 3 个同时)"""
|
||
with op_lock:
|
||
if state["syncing"]:
|
||
return False, "已有同步任务正在进行"
|
||
state["syncing"] = True
|
||
try:
|
||
repos = repos if repos is not None else load_repos()
|
||
results = {}
|
||
workers = sync_workers()
|
||
|
||
def worker(name):
|
||
try:
|
||
results[name] = sync_repo(name)
|
||
except Exception as e:
|
||
results[name] = f"error: {e}"
|
||
|
||
if repos:
|
||
with ThreadPoolExecutor(max_workers=workers,
|
||
thread_name_prefix="ghs-sync") as ex:
|
||
# map 会阻塞到全部完成;异常已在 worker 内捕获
|
||
list(ex.map(worker, repos))
|
||
errs, changed = [], 0
|
||
for r in repos:
|
||
res = results.get(r, "error: 未执行")
|
||
if res.startswith("error"):
|
||
errs.append(f"{r} {res}")
|
||
elif res == "changed":
|
||
changed += 1
|
||
if errs:
|
||
bump_stats(total_failures=len(errs))
|
||
if changed:
|
||
bump_stats(total_pushes=changed)
|
||
state["last_check"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
state["checks"] += 1
|
||
bump_stats(total_checks=1)
|
||
if errs:
|
||
state["last_result"] = "失败: " + "; ".join(errs)[:200]
|
||
return False, state["last_result"]
|
||
state["last_result"] = "成功"
|
||
return True, ("成功" if changed == 0 else f"成功(同步了 {changed} 个仓库)")
|
||
except Exception as e:
|
||
state["last_result"] = f"异常: {e}"
|
||
return False, state["last_result"]
|
||
finally:
|
||
state["syncing"] = False
|
||
|
||
|
||
def loop():
|
||
while True:
|
||
state["loop_last"] = time.time()
|
||
if state["enabled"]:
|
||
run_sync()
|
||
time.sleep(sync_interval())
|
||
|
||
|
||
def load_panel_state():
|
||
try:
|
||
state["enabled"] = bool(json.loads(PANEL_STATE.read_text(encoding="utf-8")).get("enabled", True))
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def save_panel_state():
|
||
try:
|
||
PANEL_STATE.write_text(json.dumps({"enabled": state["enabled"]}, ensure_ascii=False),
|
||
encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ---------------- 状态详情 ----------------
|
||
def repo_detail(name):
|
||
repo = REPO_DIR / f"{name}.git"
|
||
st = state_path(active_account(), name)
|
||
d = {"name": name, "cloned": repo.exists(), "branches": 0, "tags": 0,
|
||
"head": None, "last_commit": None, "size_mb": 0.0,
|
||
"status": "未同步", "last_push": None}
|
||
if st.exists():
|
||
d["status"] = "已同步"
|
||
d["last_push"] = datetime.fromtimestamp(st.stat().st_mtime).strftime("%m-%d %H:%M:%S")
|
||
if not repo.exists():
|
||
return d
|
||
try:
|
||
r = git(["-C", str(repo), "for-each-ref", "--format=%(refname)"], timeout=20)
|
||
if r.returncode == 0:
|
||
refs = r.stdout.split()
|
||
d["branches"] = sum(1 for x in refs if x.startswith("refs/heads/"))
|
||
d["tags"] = sum(1 for x in refs if x.startswith("refs/tags/"))
|
||
h = git(["-C", str(repo), "symbolic-ref", "--quiet", "HEAD"], timeout=10)
|
||
if h.returncode == 0 and h.stdout.strip():
|
||
d["head"] = h.stdout.strip().replace("refs/heads/", "")
|
||
if d["head"]:
|
||
c = git(["-C", str(repo), "log", "-1",
|
||
"--format=%h\t%s\t%an\t%ad", "--date=format:%m-%d %H:%M", d["head"]],
|
||
timeout=10)
|
||
if c.returncode == 0 and c.stdout.strip():
|
||
p = c.stdout.strip().split("\t", 3)
|
||
d["last_commit"] = {"hash": p[0],
|
||
"msg": p[1] if len(p) > 1 else "",
|
||
"author": p[2] if len(p) > 2 else "",
|
||
"time": p[3] if len(p) > 3 else ""}
|
||
d["size_mb"] = round(sum(f.stat().st_size for f in repo.rglob("*") if f.is_file()) / 1048576, 1)
|
||
except Exception:
|
||
pass
|
||
return d
|
||
|
||
|
||
def fmt_uptime(sec):
|
||
sec = int(sec)
|
||
d, rem = divmod(sec, 86400)
|
||
h, rem = divmod(rem, 3600)
|
||
m, s = divmod(rem, 60)
|
||
return (f"{d}天 " if d else "") + f"{h:02d}:{m:02d}:{s:02d}"
|
||
|
||
|
||
def account_public(acc):
|
||
"""账号信息(脱敏后可下发到前端)"""
|
||
if not acc:
|
||
return None
|
||
return {"id": acc["id"], "label": acc.get("label") or acc["gh_user"],
|
||
"gh_user": acc["gh_user"], "token": mask_token(acc.get("token")),
|
||
"has_token": bool(acc.get("token")),
|
||
"private": bool(acc.get("private", True)),
|
||
"method": "Token(HTTPS)" if acc.get("token") else "SSH 私钥"}
|
||
|
||
|
||
def global_status(ui_url=None):
|
||
repos = load_repos()
|
||
details = [repo_detail(r) for r in repos]
|
||
now = time.time()
|
||
next_in = None
|
||
if state["enabled"] and state["loop_last"]:
|
||
next_in = max(0, int(state["loop_last"] + sync_interval() - now))
|
||
gitea_all = list_gitea_repos()
|
||
acc = active_account()
|
||
return {
|
||
"enabled": state["enabled"],
|
||
"syncing": state["syncing"],
|
||
"uptime": fmt_uptime(now - START_TS),
|
||
"interval": sync_interval(),
|
||
"max_workers": sync_workers(),
|
||
"last_check": state["last_check"],
|
||
"next_in": next_in,
|
||
"checks": state["checks"],
|
||
"stats": stats,
|
||
"last_result": state["last_result"],
|
||
"repo_count": len(repos),
|
||
"synced_count": sum(1 for x in details if x["status"] == "已同步"),
|
||
"total_size_mb": round(sum(x["size_mb"] for x in details), 1),
|
||
"repos": details,
|
||
"gitea_url": ui_url or gitea_ui(),
|
||
"gitea_user": cfg["gitea_user"],
|
||
"gitea_online": bool(gitea_all),
|
||
"available": [r for r in gitea_all if r not in repos],
|
||
"account": account_public(acc),
|
||
"auto_create": bool(cfg.get("auto_create", True)),
|
||
"gh_url": f"https://github.com/{acc['gh_user']}?tab=repositories" if acc else "",
|
||
}
|
||
|
||
|
||
def tail_log(n=80):
|
||
try:
|
||
return LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()[-n:]
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
# ---------------- 页面 ----------------
|
||
CSS = """
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;background:#f0f2f5;color:#1f2329;min-height:100vh}
|
||
.wrap{width:100%;padding:20px 20px 60px}
|
||
.top{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:14px}
|
||
.top h1{font-size:20px;display:flex;align-items:center;gap:8px;min-width:0}
|
||
.pill{font-size:12px;padding:4px 12px;border-radius:999px;font-weight:600;white-space:nowrap;flex:none}
|
||
.pill.on{background:#e3f5e9;color:#0a7d34}.pill.off{background:#fee;color:#c0392b}
|
||
nav{display:flex;gap:6px;margin-bottom:16px;background:#fff;border-radius:12px;padding:6px;box-shadow:0 1px 4px rgba(0,0,0,.06);flex-wrap:wrap}
|
||
nav a{padding:8px 16px;border-radius:8px;text-decoration:none;color:#646a73;font-size:14px;font-weight:600;white-space:nowrap}
|
||
nav a.active{background:#e8f0fe;color:#1f6feb}
|
||
.card{background:#fff;border-radius:14px;padding:18px;margin-bottom:14px;box-shadow:0 1px 4px rgba(0,0,0,.06);min-width:0}
|
||
.row{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:14px}
|
||
.switchbox{display:flex;align-items:center;gap:14px;min-width:0}
|
||
.switch{position:relative;width:64px;height:34px;flex:none}
|
||
.switch input{opacity:0;width:0;height:0}
|
||
.slider{position:absolute;inset:0;background:#cbd2d9;border-radius:34px;cursor:pointer;transition:.25s}
|
||
.slider:before{content:"";position:absolute;width:26px;height:26px;left:4px;top:4px;background:#fff;border-radius:50%;transition:.25s;box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
||
input:checked+.slider{background:#0a7d34}
|
||
input:checked+.slider:before{transform:translateX(30px)}
|
||
.swlabel{font-size:16px;font-weight:600}.swsub{font-size:12px;color:#646a73;margin-top:2px}
|
||
.btns{display:flex;gap:10px;flex-wrap:wrap}
|
||
button,a.btn{border:none;border-radius:10px;padding:11px 18px;font-size:14px;font-weight:600;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:6px;transition:.15s;white-space:nowrap}
|
||
button:active,a.btn:active{transform:scale(.97)}
|
||
.b-sync{background:#0a7d34;color:#fff}.b-sync:hover{background:#086b2c}
|
||
.b-gitea{background:#1f6feb;color:#fff}.b-gitea:hover{background:#1a5fd0}
|
||
.b-gh{background:#24292f;color:#fff}.b-gh:hover{background:#000}
|
||
.b-add{background:#7c3aed;color:#fff}.b-add:hover{background:#6d28d9}
|
||
.b-del{background:#fee2e2;color:#b91c1c;padding:6px 12px;font-size:12px}
|
||
.b-del:hover{background:#fecaca}
|
||
.b-mini{background:#eef2ff;color:#4338ca;padding:6px 12px;font-size:12px}
|
||
button:disabled{background:#cbd2d9;color:#fff;cursor:not-allowed}
|
||
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px}
|
||
.stat{background:#f6f8fa;border-radius:10px;padding:12px 14px;min-width:0}
|
||
.stat .k{font-size:12px;color:#646a73}
|
||
.stat .v{font-size:15px;font-weight:700;margin-top:4px;word-break:break-word}
|
||
.tablewrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
|
||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||
th,td{padding:9px 8px;text-align:left;border-bottom:1px solid #eef0f3;vertical-align:top}
|
||
th{color:#646a73;font-weight:600;font-size:12px;white-space:nowrap}
|
||
td{word-break:break-word}
|
||
td.ok{color:#0a7d34;font-weight:600;white-space:nowrap}
|
||
td.warn{color:#b45309;font-weight:600;white-space:nowrap}
|
||
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
|
||
.commit{color:#646a73;font-size:12px;margin-top:2px;line-height:1.5}
|
||
h2{font-size:15px;margin-bottom:12px;color:#1f2329}
|
||
pre{background:#0d1117;color:#c9d1d9;border-radius:10px;padding:14px;font-size:12px;line-height:1.6;max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all}
|
||
.tip{font-size:12px;color:#646a73;margin-top:10px;line-height:1.6}
|
||
.addrow{display:flex;gap:10px;flex-wrap:wrap}
|
||
.addrow input{flex:1;min-width:180px;border:1px solid #d0d5dd;border-radius:10px;padding:11px 14px;font-size:14px;outline:none}
|
||
.addrow input:focus{border-color:#7c3aed}
|
||
.avail{display:flex;gap:8px;flex-wrap:wrap;margin-top:6px}
|
||
.chip{border:1px dashed #7c3aed;color:#7c3aed;border-radius:999px;padding:6px 14px;font-size:13px;cursor:pointer;background:#faf5ff;white-space:nowrap}
|
||
.chip:hover{background:#f3e8ff}
|
||
.msg{margin-top:10px;font-size:13px;padding:10px 14px;border-radius:8px;display:none;line-height:1.6;word-break:break-word}
|
||
.msg.err{display:block;background:#fee;color:#b91c1c}
|
||
.msg.ok{display:block;background:#e3f5e9;color:#0a7d34}
|
||
input[type=checkbox]{accent-color:#7c3aed;cursor:pointer}
|
||
.addrow input[type=password]{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.5px}
|
||
td .pill{font-size:12px}
|
||
/* ---------- 设置页 ---------- */
|
||
h2 .h2sub{font-size:12px;font-weight:400;color:#8a919e;margin-left:10px}
|
||
.banner{border-radius:10px;padding:12px 16px;font-size:13px;line-height:1.75;margin-bottom:14px;display:flex;gap:10px;align-items:flex-start}
|
||
.banner.warn{background:#fffaeb;border:1px solid #fec84b;color:#92400e}
|
||
.banner .ic{font-size:16px;line-height:1.5;flex:none}
|
||
.badge{display:inline-block;font-size:12px;font-weight:600;padding:3px 10px;border-radius:999px;white-space:nowrap;line-height:1.5}
|
||
.badge.b-ok{background:#e3f5e9;color:#0a7d34}
|
||
.badge.b-warn{background:#fef0c7;color:#b45309}
|
||
.badge.b-mute{background:#f0f2f5;color:#646a73}
|
||
.badge.b-info{background:#e8f0fe;color:#1f6feb}
|
||
.flabel{display:block;font-size:13px;font-weight:600;color:#374151;margin:16px 0 6px}
|
||
.flabel:first-child{margin-top:0}
|
||
.flabel .sub{font-weight:400;color:#8a919e;font-size:12px;margin-left:8px}
|
||
.chk{display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;padding:4px 0}
|
||
.chk input{width:18px;height:18px}
|
||
.optbox{background:#f6f8fa;border-radius:10px;padding:6px 16px;margin-top:16px}
|
||
.optrow{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:12px 0}
|
||
.optrow+.optrow{border-top:1px solid #eef0f3}
|
||
.opttext{flex:1;min-width:200px}
|
||
.opttext .swlabel{font-size:14px;font-weight:600}
|
||
.opttext .swsub{font-size:12px;color:#646a73;margin-top:2px;line-height:1.6}
|
||
.numin{flex:none;width:110px;border:1px solid #d0d5dd;border-radius:10px;padding:10px 12px;font-size:14px;outline:none;text-align:center}
|
||
.numin:focus{border-color:#7c3aed}
|
||
table.hover tbody tr:hover{background:#f8fafc}
|
||
td.ops{white-space:nowrap}
|
||
td.ops button,td.ops .pill{margin:2px 4px 2px 0}
|
||
/* ---------- 窄屏(手机 / 飞牛桌面小窗口):表格转卡片 ---------- */
|
||
@media (max-width:760px){
|
||
.wrap{padding:14px 10px 40px}
|
||
.top h1{font-size:16px}
|
||
nav{gap:4px}
|
||
nav a{padding:8px 12px;font-size:13px;flex:1;text-align:center}
|
||
.stats{grid-template-columns:repeat(2,1fr);gap:8px}
|
||
.stat{padding:10px}
|
||
.stat .v{font-size:13px}
|
||
.card{padding:14px;border-radius:12px}
|
||
button,a.btn{padding:10px 14px;font-size:13px}
|
||
.btns{width:100%}
|
||
.btns button,.btns a.btn{flex:1;justify-content:center}
|
||
.addrow{flex-direction:column}
|
||
.addrow input{min-width:0;width:100%}
|
||
.addrow button{width:100%;justify-content:center}
|
||
table.resp{min-width:0}
|
||
table.resp thead{display:none}
|
||
table.resp tbody tr{display:block;border:1px solid #e5e8ec;border-radius:12px;margin-bottom:10px;padding:10px 12px;background:#fbfcfd}
|
||
table.resp tbody td{display:flex;justify-content:space-between;align-items:baseline;gap:6px 14px;border:none;padding:5px 0;flex-wrap:wrap}
|
||
table.resp tbody td::before{content:attr(data-label);color:#646a73;font-size:12px;font-weight:600;flex:none}
|
||
table.resp tbody td .val{flex:1;text-align:right;min-width:0;word-break:break-word}
|
||
table.resp tbody td:has(button){justify-content:flex-end}
|
||
table.resp tbody td:has(button)::before{margin-right:auto}
|
||
pre{font-size:11px;padding:10px}
|
||
}
|
||
"""
|
||
|
||
NAV = """<nav>
|
||
<a href="./" __A__>🎛 控制面板</a>
|
||
<a href="status" __B__>📊 项目状态</a>
|
||
<a href="manage" __C__>🧩 项目管理</a>
|
||
<a href="settings" __D__>⚙️ 设置</a>
|
||
</nav>"""
|
||
|
||
|
||
def page(title, body, active):
|
||
nav = (NAV.replace("__A__", 'class="active"' if active == "home" else "")
|
||
.replace("__B__", 'class="active"' if active == "status" else "")
|
||
.replace("__C__", 'class="active"' if active == "manage" else "")
|
||
.replace("__D__", 'class="active"' if active == "settings" else ""))
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>{title} · GiteaHubSync</title>
|
||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔄</text></svg>">
|
||
<style>{CSS}</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<div class="top"><h1>🔄 {title}</h1><span id="pill" class="pill on">运行中</span></div>
|
||
{nav}
|
||
{body}
|
||
</div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
PAGE_HOME = """
|
||
<div class="card">
|
||
<div class="row">
|
||
<div class="switchbox">
|
||
<label class="switch"><input type="checkbox" id="sw" onchange="toggle()"><span class="slider"></span></label>
|
||
<div>
|
||
<div class="swlabel" id="swlabel">自动同步:开</div>
|
||
<div class="swsub" id="swsub">开启后定期检查 Gitea,有新提交自动推送到 GitHub</div>
|
||
</div>
|
||
</div>
|
||
<div class="btns">
|
||
<button class="b-sync" id="btnSync" onclick="syncNow()">⚡ 立即同步一次</button>
|
||
<a class="btn b-gitea" href="__GITEA__" target="_blank">🗂️ 打开 Gitea</a>
|
||
<a class="btn b-gh" id="btnGh" href="__GH__" target="_blank">🐙 GitHub 仓库列表</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="stats">
|
||
<div class="stat"><div class="k">自动同步</div><div class="v" id="sEnabled">—</div></div>
|
||
<div class="stat"><div class="k">GitHub 目标账号</div><div class="v" id="sAccount">—</div></div>
|
||
<div class="stat"><div class="k">同步项目</div><div class="v" id="sRepos">—</div></div>
|
||
<div class="stat"><div class="k">守护运行时长</div><div class="v" id="sUptime">—</div></div>
|
||
<div class="stat"><div class="k">最近实际同步</div><div class="v" id="sLast">—</div></div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>同步日志(最近 80 行)</h2>
|
||
<pre id="log">加载中…</pre>
|
||
<div class="tip">日志文件:__LOGPATH__ · 面板每 3 秒自动刷新</div>
|
||
</div>
|
||
<script>
|
||
async function jget(u){const r=await fetch(u);return r.json()}
|
||
async function refresh(){
|
||
try{
|
||
const s=await jget('api/state');
|
||
document.getElementById('sw').checked=s.enabled;
|
||
document.getElementById('swlabel').textContent='自动同步:'+(s.enabled?'开':'关');
|
||
document.getElementById('swsub').textContent='开启后每 '+(s.interval||10)+' 秒检查一次 Gitea,'+(s.max_workers||1)+' 个项目并行同步,有新提交自动推送到 GitHub(间隔/并发可在设置页调整)';
|
||
const p=document.getElementById('pill');
|
||
p.textContent=s.enabled?'运行中':'已停止';p.className='pill '+(s.enabled?'on':'off');
|
||
document.getElementById('sEnabled').textContent=s.enabled?('开启('+(s.interval||10)+'秒/次)'):'已停止';
|
||
document.getElementById('sAccount').textContent=s.account?(s.account.gh_user+'('+s.account.method+')'):'未配置';
|
||
document.getElementById('sRepos').textContent=s.repo_count+' 个(已同步 '+s.synced_count+')';
|
||
document.getElementById('sUptime').textContent=s.uptime;
|
||
document.getElementById('sLast').textContent=s.last_real;
|
||
const l=await jget('api/log');
|
||
document.getElementById('log').textContent=l.lines.join('\\n')||'(暂无日志)';
|
||
}catch(e){document.getElementById('log').textContent='刷新失败: '+e}
|
||
}
|
||
async function toggle(){await fetch('api/toggle',{method:'POST'});refresh();}
|
||
async function syncNow(){
|
||
const b=document.getElementById('btnSync');b.disabled=true;b.textContent='⏳ 同步中…';
|
||
try{const r=await (await fetch('api/sync',{method:'POST'})).json();
|
||
if(!r.ok)alert('同步结果: '+(r.msg||JSON.stringify(r)));
|
||
}catch(e){alert('同步请求失败: '+e)}
|
||
finally{b.disabled=false;b.textContent='⚡ 立即同步一次';refresh();}
|
||
}
|
||
refresh();setInterval(refresh,3000);
|
||
</script>
|
||
"""
|
||
|
||
PAGE_STATUS = """
|
||
<div class="card">
|
||
<div class="stats">
|
||
<div class="stat"><div class="k">同步项目</div><div class="v" id="sCount">—</div></div>
|
||
<div class="stat"><div class="k">已同步</div><div class="v" id="sSynced">—</div></div>
|
||
<div class="stat"><div class="k">守护运行时长</div><div class="v" id="sUptime">—</div></div>
|
||
<div class="stat"><div class="k">距下次检查</div><div class="v" id="sNext">—</div></div>
|
||
<div class="stat"><div class="k">累计检查轮数</div><div class="v" id="sChecks">—</div></div>
|
||
<div class="stat"><div class="k">累计实际同步</div><div class="v" id="sPushes">—</div></div>
|
||
<div class="stat"><div class="k">累计失败次数</div><div class="v" id="sFails">—</div></div>
|
||
<div class="stat"><div class="k">镜像总占用</div><div class="v" id="sSize">—</div></div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="row">
|
||
<h2 style="margin:0">项目明细</h2>
|
||
<span class="tip" id="sTarget"></span>
|
||
</div>
|
||
<div style="overflow-x:auto">
|
||
<table class="resp">
|
||
<thead><tr><th>项目</th><th>状态</th><th>默认分支</th><th>分支/标签</th><th>最近推送同步</th><th>最新提交</th><th>大小</th><th></th></tr></thead>
|
||
<tbody id="tb"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
async function jget(u){const r=await fetch(u);return r.json()}
|
||
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||
async function refresh(){
|
||
try{
|
||
const s=await jget('api/status');
|
||
const p=document.getElementById('pill');
|
||
p.textContent=s.enabled?'运行中':'已停止';p.className='pill '+(s.enabled?'on':'off');
|
||
document.getElementById('sCount').textContent=s.repo_count+' 个';
|
||
document.getElementById('sSynced').textContent=s.synced_count+' / '+s.repo_count;
|
||
document.getElementById('sUptime').textContent=s.uptime;
|
||
document.getElementById('sNext').textContent=s.enabled?(s.next_in==null?'—':s.next_in+' 秒'):'已暂停';
|
||
document.getElementById('sChecks').textContent=s.stats.total_checks;
|
||
document.getElementById('sPushes').textContent=s.stats.total_pushes;
|
||
document.getElementById('sFails').textContent=s.stats.total_failures;
|
||
document.getElementById('sSize').textContent=s.total_size_mb+' MB';
|
||
document.getElementById('sTarget').textContent='同步目标: '+(s.account?s.account.gh_user+'('+s.account.method+')':'未配置')+' · '+(s.max_workers||1)+' 个并行 · Gitea '+(s.gitea_online?'在线 ✓':'离线 ✗');
|
||
document.getElementById('tb').innerHTML=s.repos.map(r=>{
|
||
const st=r.status==='已同步'?'<td class="ok">✓ 已同步</td>':'<td class="warn">⏳ 未同步</td>';
|
||
const c=r.last_commit?`<div class="commit">${esc(r.last_commit.hash)} ${esc(r.last_commit.msg)} · ${esc(r.last_commit.author)} · ${esc(r.last_commit.time)}</div>`:'<div class="commit">—</div>';
|
||
return `<tr><td data-label="项目"><b>${esc(r.name)}</b></td><td data-label="状态" class="${r.status==='已同步'?'ok':'warn'}">${r.status==='已同步'?'✓ 已同步':'⏳ 未同步'}</td><td data-label="默认分支">${esc(r.head||'—')}</td><td data-label="分支/标签">${r.branches} / ${r.tags}</td><td data-label="最近推送同步">${r.last_push||'—'}</td><td data-label="最新提交">${c}</td><td data-label="大小">${r.size_mb} MB</td><td data-label="操作"><button class="b-mini" onclick="syncOne('${esc(r.name)}')">同步</button></td></tr>`;
|
||
}).join('')||'<tr><td colspan="8" style="color:#646a73">还没有同步项目,去<a href="manage">项目管理</a>添加</td></tr>';
|
||
}catch(e){document.getElementById('tb').innerHTML='<tr><td colspan="8">刷新失败: '+e+'</td></tr>'}
|
||
}
|
||
async function syncOne(n){
|
||
const r=await (await fetch('api/repos/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n})})).json();
|
||
if(!r.ok)alert(n+': '+(r.msg||JSON.stringify(r)));
|
||
refresh();
|
||
}
|
||
refresh();setInterval(refresh,5000);
|
||
</script>
|
||
"""
|
||
|
||
PAGE_MANAGE = """
|
||
<div class="card">
|
||
<h2>快捷添加项目</h2>
|
||
<div class="addrow">
|
||
<input id="repoName" placeholder="输入 Gitea 上的仓库名,如 ESP32_COMM" onkeydown="if(event.key==='Enter')addRepo()">
|
||
<button class="b-add" id="btnAdd" onclick="addRepo()">➕ 添加并同步</button>
|
||
</div>
|
||
<div class="tip">下面可直接点击你 Gitea 上还未同步的仓库(GitHub 上不存在同名仓库时会按设置自动创建):</div>
|
||
<div class="avail" id="avail"></div>
|
||
<div class="msg" id="msg"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>当前同步项目(<span id="cnt">0</span>)</h2>
|
||
<table class="resp">
|
||
<thead><tr><th>项目</th><th>状态</th><th>最近推送同步</th><th>操作</th></tr></thead>
|
||
<tbody id="tb"></tbody>
|
||
</table>
|
||
<div class="tip">移除只是停止同步,GitHub 上的仓库和本地镜像数据都会保留,以后可以随时加回来。</div>
|
||
</div>
|
||
<script>
|
||
async function jget(u){const r=await fetch(u);return r.json()}
|
||
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||
function showMsg(t,ok){const m=document.getElementById('msg');m.textContent=t;m.className='msg '+(ok?'ok':'err');}
|
||
async function refresh(){
|
||
try{
|
||
const s=await jget('api/status');
|
||
document.getElementById('cnt').textContent=s.repo_count;
|
||
document.getElementById('avail').innerHTML=(s.available||[]).map(n=>
|
||
`<span class="chip" onclick="addRepo('${esc(n)}')">+ ${esc(n)}</span>`).join('')||'<span class="tip">Gitea 上的仓库都已加入同步</span>';
|
||
document.getElementById('tb').innerHTML=s.repos.map(r=>
|
||
`<tr><td data-label="项目"><b>${esc(r.name)}</b></td><td data-label="状态" class="${r.status==='已同步'?'ok':'warn'}">${r.status}</td><td data-label="最近推送同步">${r.last_push||'—'}</td><td data-label="操作"><button class="b-del" onclick="delRepo('${esc(r.name)}')">移除</button></td></tr>`).join('')
|
||
||'<tr><td colspan="4" style="color:#646a73">暂无项目</td></tr>';
|
||
}catch(e){showMsg('刷新失败: '+e,false)}
|
||
}
|
||
async function addRepo(n){
|
||
const name=(n||document.getElementById('repoName').value||'').trim();
|
||
if(!name){showMsg('请输入仓库名',false);return;}
|
||
const b=document.getElementById('btnAdd');b.disabled=true;b.textContent='⏳ 处理中…';
|
||
try{
|
||
const r=await (await fetch('api/repos/add',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})})).json();
|
||
showMsg(r.msg||JSON.stringify(r),!!r.ok);
|
||
if(r.ok)document.getElementById('repoName').value='';
|
||
}catch(e){showMsg('请求失败: '+e,false)}
|
||
finally{b.disabled=false;b.textContent='➕ 添加并同步';refresh();}
|
||
}
|
||
async function delRepo(n){
|
||
if(!confirm('确定把 '+n+' 从同步列表移除?\\n(GitHub 仓库和本地数据保留)'))return;
|
||
const r=await (await fetch('api/repos/remove',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n})})).json();
|
||
showMsg(r.msg||JSON.stringify(r),!!r.ok);
|
||
refresh();
|
||
}
|
||
refresh();setInterval(refresh,5000);
|
||
</script>
|
||
"""
|
||
|
||
|
||
PAGE_SETTINGS = """
|
||
<div class="card">
|
||
<h2>GitHub 账号<span class="h2sub">推送目标 · 可添加多个,随时切换</span></h2>
|
||
<div id="accBanner"></div>
|
||
<div class="tablewrap">
|
||
<table class="resp hover">
|
||
<colgroup><col style="width:15%"><col style="width:21%"><col style="width:15%"><col style="width:14%"><col style="width:12%"><col style="width:23%"></colgroup>
|
||
<thead><tr><th>备注</th><th>GitHub 用户名</th><th>Token</th><th>推送方式</th><th>自动建库</th><th>操作</th></tr></thead>
|
||
<tbody id="tbAcc"></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="tip">切换账号后,所有项目改推到该账号名下;每个账号独立记录同步进度,切回原账号不会重复推送。</div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>添加 / 更新 GitHub 账号<span class="h2sub">Token 验证通过后自动识别用户名</span></h2>
|
||
<label class="flabel">备注名<span class="sub">方便自己识别,可留空</span></label>
|
||
<div class="addrow"><input id="accLabel" placeholder="如:我的主账号"></div>
|
||
<label class="flabel">GitHub 用户名<span class="sub">留空则按 Token 自动识别</span></label>
|
||
<div class="addrow"><input id="accUser" placeholder="如 cbb2625274797"></div>
|
||
<label class="flabel">Personal Access Token</label>
|
||
<div class="addrow"><input id="accToken" type="password" placeholder="ghp_ 开头"></div>
|
||
<label class="flabel">自动创建的仓库设为</label>
|
||
<div class="addrow" style="align-items:center">
|
||
<label class="chk"><input type="checkbox" id="accPrivate" checked> 私有(推荐)</label>
|
||
</div>
|
||
<div class="btns" style="margin-top:14px">
|
||
<button class="b-add" id="btnAccAdd" onclick="addAccount()">✓ 验证并添加</button>
|
||
</div>
|
||
<div class="msg" id="msgAcc"></div>
|
||
<div class="tip">
|
||
Token 获取:GitHub → Settings → Developer settings → Personal access tokens →
|
||
<b>Tokens (classic)</b> → Generate new token,勾选 <b>repo</b> 权限(即可自动创建仓库并推送)。<br>
|
||
链接:<a href="https://github.com/settings/tokens" target="_blank">github.com/settings/tokens</a> ·
|
||
Token 只保存在 NAS 本地(accounts.json,权限 600),不会上传到任何地方。
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>Gitea 与同步设置</h2>
|
||
<label class="flabel">Gitea 地址<span class="sub">同步引擎访问的地址,默认 http://127.0.0.1:3000</span></label>
|
||
<div class="addrow">
|
||
<input id="giteaHost" placeholder="http://127.0.0.1:3000">
|
||
<button class="b-mini" id="btnGitea" onclick="saveGitea(false)">保存</button>
|
||
</div>
|
||
<label class="flabel">Gitea 用户名<span class="sub">面板按此用户名列出你可同步的仓库</span></label>
|
||
<div class="addrow">
|
||
<input id="giteaUser" placeholder="cbb">
|
||
<button class="b-mini" onclick="saveGitea(false)">保存</button>
|
||
</div>
|
||
<label class="flabel">Gitea 密码<span class="sub">仅仓库为私有时需要;留空保存可清除</span></label>
|
||
<div class="addrow">
|
||
<input id="giteaPass" type="password" placeholder="未设置">
|
||
<button class="b-mini" onclick="saveGitea(true)">保存</button>
|
||
</div>
|
||
<div class="tip" id="giteaUrlTip"></div>
|
||
<div class="optbox">
|
||
<div class="optrow">
|
||
<label class="switch"><input type="checkbox" id="swAuto" onchange="saveAuto()"><span class="slider"></span></label>
|
||
<div class="opttext">
|
||
<div class="swlabel">GitHub 上不存在同名仓库时自动创建</div>
|
||
<div class="swsub">需要当前账号已配置 Token;关闭后添加不存在的仓库会提示手动创建</div>
|
||
</div>
|
||
</div>
|
||
<div class="optrow">
|
||
<input id="syncInterval" type="number" min="5" max="86400" step="1" class="numin">
|
||
<button class="b-mini" id="btnInterval" onclick="saveInterval()">保存间隔</button>
|
||
<div class="opttext">
|
||
<div class="swlabel">同步间隔(秒)</div>
|
||
<div class="swsub" id="intervalTip"></div>
|
||
</div>
|
||
</div>
|
||
<div class="optrow">
|
||
<input id="syncWorkers" type="number" min="1" max="8" step="1" class="numin">
|
||
<button class="b-mini" id="btnWorkers" onclick="saveWorkers()">保存并发</button>
|
||
<div class="opttext">
|
||
<div class="swlabel">同时同步项目数</div>
|
||
<div class="swsub" id="workersTip"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="msg" id="msgCfg"></div>
|
||
</div>
|
||
<script>
|
||
async function jget(u){const r=await fetch(u);return r.json()}
|
||
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML}
|
||
function showMsg(id,t,ok){const m=document.getElementById(id);m.textContent=t;m.className='msg '+(ok?'ok':'err');}
|
||
async function refresh(){
|
||
try{
|
||
const s=await jget('api/settings');
|
||
document.getElementById('giteaHost').value=s.gitea_host||'http://127.0.0.1:3000';
|
||
document.getElementById('giteaUser').value=s.gitea_user||'';
|
||
document.getElementById('giteaPass').placeholder=s.gitea_pass_set
|
||
?'Gitea 密码(已设置;留空保存可清除)':'Gitea 密码(私有仓库必填)';
|
||
document.getElementById('giteaUrlTip').textContent='「打开 Gitea」按钮地址:'+(s.gitea_url||'—')+'(随访问地址自动适配);密码只保存在 NAS 本地,仅用于读取私有仓库。';
|
||
document.getElementById('swAuto').checked=!!s.auto_create;
|
||
const iv=document.getElementById('syncInterval');
|
||
iv.value=s.interval||10;iv.min=s.min_interval||5;iv.max=s.max_interval||86400;
|
||
document.getElementById('intervalTip').textContent='每隔 '+fmtInterval(s.interval||10)+' 检查一次 Gitea 是否有新提交('+(s.min_interval||5)+'~'+(s.max_interval||86400)+' 秒),改完立即生效';
|
||
const wk=document.getElementById('syncWorkers');
|
||
wk.value=s.max_workers||3;wk.min=s.min_workers||1;wk.max=s.max_workers_limit||8;
|
||
document.getElementById('workersTip').textContent='每轮最多 '+(s.max_workers||3)+' 个项目同时克隆/推送(1~'+(s.max_workers_limit||8)+'),项目多时能显著加快;网络或 NAS 负载高就调小';
|
||
const cur=(s.accounts||[]).find(a=>a.id===s.active);
|
||
// 当前账号无 Token 时,顶部提示为什么不能自动建库、怎么解锁
|
||
document.getElementById('accBanner').innerHTML=(cur&&!cur.has_token)
|
||
?`<div class="banner warn"><span class="ic">⚠️</span><div><b>当前账号「${esc(cur.label)}」通过 SSH 私钥推送,暂时无法自动创建仓库。</b>
|
||
<br>GitHub 的创建仓库接口只认 Token 认证,SSH 私钥只能推送<b>已存在</b>的仓库——这是 GitHub 的限制,不是软件没做。
|
||
<br>给这个账号配上 Token 即可解锁(推送也会改走 HTTPS,更稳更快):
|
||
<button class="b-mini" style="margin-left:6px" onclick="upgradeAcc('${esc(cur.id)}','${esc(cur.gh_user)}')">去添加 Token</button></div></div>`
|
||
:'';
|
||
document.getElementById('tbAcc').innerHTML=(s.accounts||[]).map(a=>{
|
||
const isCur=a.id===s.active;
|
||
const ops=isCur
|
||
? '<span class="pill on">当前使用</span>'
|
||
: `<button class="b-mini" onclick="switchAcc('${esc(a.id)}')">切换到此账号</button>`;
|
||
const verify=a.has_token?`<button class="b-mini" onclick="verifyAcc('${esc(a.id)}')">验证</button>`:'';
|
||
const upgrade=a.has_token?'':`<button class="b-mini" onclick="upgradeAcc('${esc(a.id)}','${esc(a.gh_user)}')">添加 Token</button>`;
|
||
const del=(a.has_token||(s.accounts||[]).length>1)
|
||
? `<button class="b-del" onclick="delAcc('${esc(a.id)}')">删除</button>`:'';
|
||
const method=a.has_token
|
||
?'<span class="badge b-ok">Token · HTTPS</span>'
|
||
:'<span class="badge b-mute">SSH 私钥</span>';
|
||
const create=a.has_token
|
||
?`<span class="badge ${a.private?'b-ok':'b-info'}">${a.private?'私有':'公开'}</span>`
|
||
:'<span class="badge b-warn" title="GitHub 创建仓库的接口只能用 Token 认证;添加 Token 后即可自动创建">需 Token</span>';
|
||
return `<tr><td data-label="备注"><b>${esc(a.label)}</b></td><td data-label="用户名" class="mono">${esc(a.gh_user)}</td>`
|
||
+`<td data-label="Token" class="mono">${esc(a.token||'(未配置)')}</td>`
|
||
+`<td data-label="推送方式">${method}</td>`
|
||
+`<td data-label="自动建库">${create}</td>`
|
||
+`<td data-label="操作" class="ops">${ops} ${verify} ${upgrade} ${del}</td></tr>`;
|
||
}).join('')||'<tr><td colspan="6" style="color:#646a73">暂无账号</td></tr>';
|
||
}catch(e){showMsg('msgAcc','刷新失败: '+e,false)}
|
||
}
|
||
function upgradeAcc(id,ghUser){
|
||
// 预填「添加/更新账号」表单并滚动过去,引导给旧账号补 Token
|
||
document.getElementById('accLabel').value='';
|
||
document.getElementById('accUser').value=ghUser||'';
|
||
document.getElementById('accToken').value='';
|
||
document.getElementById('accToken').focus();
|
||
document.getElementById('accToken').scrollIntoView({behavior:'smooth',block:'center'});
|
||
showMsg('msgAcc','已为你填好用户名 '+ghUser+',把 Token 粘贴进去点「验证并添加」即可(会更新原账号)',true);
|
||
}
|
||
async function addAccount(){
|
||
const label=document.getElementById('accLabel').value.trim();
|
||
const gh_user=document.getElementById('accUser').value.trim();
|
||
const token=document.getElementById('accToken').value.trim();
|
||
const priv=document.getElementById('accPrivate').checked;
|
||
if(!token){showMsg('msgAcc','请填写 Personal Access Token',false);return;}
|
||
const b=document.getElementById('btnAccAdd');b.disabled=true;b.textContent='⏳ 验证中…';
|
||
try{
|
||
const r=await (await fetch('api/accounts/add',{method:'POST',headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({label,gh_user,token,private:priv})})).json();
|
||
showMsg('msgAcc',r.msg||JSON.stringify(r),!!r.ok);
|
||
if(r.ok){document.getElementById('accLabel').value='';document.getElementById('accUser').value='';document.getElementById('accToken').value='';}
|
||
}catch(e){showMsg('msgAcc','请求失败: '+e,false)}
|
||
finally{b.disabled=false;b.textContent='✓ 验证并添加';refresh();}
|
||
}
|
||
async function switchAcc(id){
|
||
const r=await (await fetch('api/accounts/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})})).json();
|
||
showMsg('msgAcc',r.msg||JSON.stringify(r),!!r.ok);refresh();
|
||
}
|
||
async function verifyAcc(id){
|
||
const r=await (await fetch('api/accounts/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})})).json();
|
||
showMsg('msgAcc',r.msg||JSON.stringify(r),!!r.ok);refresh();
|
||
}
|
||
async function delAcc(id){
|
||
if(!confirm('确定删除该 GitHub 账号?\\n(只从本应用移除,GitHub 上的仓库不受影响)'))return;
|
||
const r=await (await fetch('api/accounts/remove',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})})).json();
|
||
showMsg('msgAcc',r.msg||JSON.stringify(r),!!r.ok);refresh();
|
||
}
|
||
async function saveGitea(withPass){
|
||
// withPass=false 时不发送密码字段,避免改地址/用户名时误清密码
|
||
const body={gitea_host:document.getElementById('giteaHost').value.trim(),
|
||
gitea_user:document.getElementById('giteaUser').value.trim()};
|
||
if(withPass)body.gitea_pass=document.getElementById('giteaPass').value;
|
||
const r=await (await fetch('api/settings/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json();
|
||
showMsg('msgCfg',r.msg||JSON.stringify(r),!!r.ok);
|
||
if(withPass)document.getElementById('giteaPass').value='';
|
||
refresh();
|
||
}
|
||
async function saveAuto(){
|
||
const auto_create=document.getElementById('swAuto').checked;
|
||
const r=await (await fetch('api/settings/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({auto_create})})).json();
|
||
showMsg('msgCfg',r.msg||JSON.stringify(r),!!r.ok);refresh();
|
||
}
|
||
function fmtInterval(s){
|
||
s=parseInt(s,10)||0;
|
||
if(s>=3600)return (s/3600)+' 小时';
|
||
if(s>=60)return Math.round(s/6)/10+' 分钟';
|
||
return s+' 秒';
|
||
}
|
||
async function saveInterval(){
|
||
const v=parseInt(document.getElementById('syncInterval').value,10);
|
||
const b=document.getElementById('btnInterval');b.disabled=true;b.textContent='⏳ 保存中…';
|
||
try{
|
||
const r=await (await fetch('api/settings/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({interval:v})})).json();
|
||
showMsg('msgCfg',r.msg||JSON.stringify(r),!!r.ok);
|
||
}catch(e){showMsg('msgCfg','请求失败: '+e,false)}
|
||
finally{b.disabled=false;b.textContent='保存间隔';refresh();}
|
||
}
|
||
async function saveWorkers(){
|
||
const v=parseInt(document.getElementById('syncWorkers').value,10);
|
||
const b=document.getElementById('btnWorkers');b.disabled=true;b.textContent='⏳ 保存中…';
|
||
try{
|
||
const r=await (await fetch('api/settings/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({max_workers:v})})).json();
|
||
showMsg('msgCfg',r.msg||JSON.stringify(r),!!r.ok);
|
||
}catch(e){showMsg('msgCfg','请求失败: '+e,false)}
|
||
finally{b.disabled=false;b.textContent='保存并发';refresh();}
|
||
}
|
||
refresh();
|
||
</script>
|
||
"""
|
||
|
||
|
||
# ---------------- HTTP 服务 ----------------
|
||
class Handler(BaseHTTPRequestHandler):
|
||
def log_message(self, *a):
|
||
pass
|
||
|
||
def _json(self, obj, code=200):
|
||
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||
self.send_response(code)
|
||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
def _html(self, html):
|
||
body = html.encode("utf-8")
|
||
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 _body(self):
|
||
try:
|
||
n = int(self.headers.get("Content-Length", 0) or 0)
|
||
return json.loads(self.rfile.read(n) or b"{}")
|
||
except Exception:
|
||
return {}
|
||
|
||
def do_GET(self):
|
||
path = self.path.split("?")[0].rstrip("/") or "/"
|
||
if path == "/":
|
||
acc = active_account()
|
||
body = (PAGE_HOME.replace("__GITEA__", gitea_ui(self.headers.get("Host")))
|
||
.replace("__GH__", f"https://github.com/{acc['gh_user']}?tab=repositories" if acc else "")
|
||
.replace("__LOGPATH__", str(LOG_FILE)))
|
||
self._html(page("控制面板", body, "home"))
|
||
elif path == "/status":
|
||
self._html(page("项目状态", PAGE_STATUS, "status"))
|
||
elif path == "/manage":
|
||
self._html(page("项目管理", PAGE_MANAGE, "manage"))
|
||
elif path == "/settings":
|
||
self._html(page("设置", PAGE_SETTINGS, "settings"))
|
||
elif path == "/api/state":
|
||
repos = load_repos()
|
||
acc = active_account()
|
||
self._json({"enabled": state["enabled"], "syncing": state["syncing"],
|
||
"last_check": state["last_check"], "checks": state["checks"],
|
||
"last_result": state["last_result"],
|
||
"interval": sync_interval(),
|
||
"max_workers": sync_workers(),
|
||
"repo_count": len(repos),
|
||
"synced_count": sum(1 for r in repos if state_path(acc, r).exists()),
|
||
"uptime": fmt_uptime(time.time() - START_TS),
|
||
"account": account_public(acc),
|
||
"last_real": last_real_sync()})
|
||
elif path == "/api/status":
|
||
self._json(global_status())
|
||
elif path == "/api/settings":
|
||
self._json({"gitea_user": cfg["gitea_user"],
|
||
"gitea_pass": mask_token(cfg.get("gitea_pass")),
|
||
"gitea_pass_set": bool(cfg.get("gitea_pass")),
|
||
"auto_create": bool(cfg.get("auto_create", True)),
|
||
"interval": sync_interval(),
|
||
"min_interval": MIN_INTERVAL,
|
||
"max_interval": MAX_INTERVAL,
|
||
"max_workers": sync_workers(),
|
||
"min_workers": MIN_WORKERS,
|
||
"max_workers_limit": MAX_WORKERS,
|
||
"gitea_host": gitea_host(),
|
||
"gitea_url": gitea_ui(self.headers.get("Host")),
|
||
"active": active_id,
|
||
"accounts": [account_public(a) for a in accounts]})
|
||
elif path == "/api/log":
|
||
self._json({"lines": tail_log(80)})
|
||
else:
|
||
self._json({"error": "not found"}, 404)
|
||
|
||
def do_POST(self):
|
||
global active_id
|
||
path = self.path.split("?")[0].rstrip("/") or "/"
|
||
data = self._body()
|
||
if path == "/api/toggle":
|
||
state["enabled"] = not state["enabled"]
|
||
save_panel_state()
|
||
self._json({"ok": True, "enabled": state["enabled"]})
|
||
elif path == "/api/sync":
|
||
ok, msg = run_sync()
|
||
self._json({"ok": ok, "msg": msg})
|
||
elif path == "/api/settings/save":
|
||
new_user = new_pass = new_host = None
|
||
if "gitea_user" in data:
|
||
u = (data.get("gitea_user") or "").strip()
|
||
if not valid_name(u):
|
||
self._json({"ok": False, "msg": "Gitea 用户名不合法(仅限字母/数字/._-)"})
|
||
return
|
||
new_user = u
|
||
if "gitea_pass" in data:
|
||
new_pass = str(data.get("gitea_pass") or "") # 空串 = 清除密码
|
||
if "gitea_host" in data:
|
||
h = norm_gitea_host(data.get("gitea_host"))
|
||
if not h:
|
||
self._json({"ok": False, "msg": "Gitea 地址不合法,应类似 http://127.0.0.1:3000"})
|
||
return
|
||
new_host = h
|
||
if "interval" in data:
|
||
if not valid_interval(data.get("interval")):
|
||
self._json({"ok": False, "msg": f"同步间隔须为 {MIN_INTERVAL} ~ {MAX_INTERVAL} 之间的整数(秒)"})
|
||
return
|
||
if "max_workers" in data:
|
||
if not valid_workers(data.get("max_workers")):
|
||
self._json({"ok": False, "msg": f"同时同步项目数须为 {MIN_WORKERS} ~ {MAX_WORKERS} 之间的整数"})
|
||
return
|
||
# 预校验:新地址先探连通性;涉及账号的改动再用新凭据调一次 /user 验证
|
||
trial = dict(cfg)
|
||
if new_user is not None:
|
||
trial["gitea_user"] = new_user
|
||
if new_pass is not None:
|
||
trial["gitea_pass"] = new_pass
|
||
if new_host is not None:
|
||
trial["gitea_host"] = new_host
|
||
ok, msg = gitea_probe(new_host)
|
||
if not ok:
|
||
self._json({"ok": False, "msg": msg})
|
||
return
|
||
if (new_user is not None or new_pass is not None) and trial["gitea_pass"]:
|
||
try:
|
||
r = subprocess.run(
|
||
["curl", "-sS", "-m", "10", "-w", "\n%{http_code}",
|
||
"-u", f"{trial['gitea_user']}:{trial['gitea_pass']}",
|
||
f"{trial['gitea_host']}/api/v1/user"],
|
||
capture_output=True, text=True, timeout=15)
|
||
out, _, code = r.stdout.rpartition("\n")
|
||
st = int(code) if code.strip().isdigit() else 0
|
||
if not (200 <= st < 300):
|
||
self._json({"ok": False, "msg": f"Gitea 账号验证失败(HTTP {st}):请检查用户名/密码"})
|
||
return
|
||
login = (json.loads(out or "{}") or {}).get("login")
|
||
if login and login != trial["gitea_user"]:
|
||
self._json({"ok": False, "msg": f"该密码对应的 Gitea 用户是 {login},与填写的 {trial['gitea_user']} 不一致"})
|
||
return
|
||
except Exception as e:
|
||
self._json({"ok": False, "msg": f"Gitea 账号验证出错: {e}"})
|
||
return
|
||
host_changed = new_host is not None and new_host != gitea_host()
|
||
if new_user is not None:
|
||
cfg["gitea_user"] = new_user
|
||
if new_pass is not None:
|
||
cfg["gitea_pass"] = new_pass
|
||
log("Gitea 密码已" + ("更新" if new_pass else "清除"))
|
||
if new_host is not None:
|
||
cfg["gitea_host"] = new_host
|
||
if "auto_create" in data:
|
||
cfg["auto_create"] = bool(data.get("auto_create"))
|
||
if "interval" in data:
|
||
old_iv = sync_interval()
|
||
cfg["interval"] = int(data.get("interval"))
|
||
if cfg["interval"] != old_iv:
|
||
log(f"同步间隔已调整: {old_iv}s -> {cfg['interval']}s")
|
||
if "max_workers" in data:
|
||
old_w = sync_workers()
|
||
cfg["max_workers"] = int(data.get("max_workers"))
|
||
if cfg["max_workers"] != old_w:
|
||
log(f"同时同步项目数已调整: {old_w} -> {cfg['max_workers']}")
|
||
save_settings()
|
||
notes = []
|
||
if new_host is not None:
|
||
ok, msg = gitea_probe()
|
||
notes.append(f"Gitea 地址已设为 {new_host}({msg})")
|
||
if host_changed:
|
||
n = repoint_mirror_remotes()
|
||
log(f"Gitea 地址变更: {n} 个镜像仓库的 origin 已更新")
|
||
notes.append(f"{n} 个已有镜像的拉取地址已更新")
|
||
if cfg.get("gitea_pass"):
|
||
notes.append("Gitea 密码验证通过 ✓,可同步私有仓库")
|
||
else:
|
||
ok, d = gitea_api(f"/users/{cfg['gitea_user']}")
|
||
if ok and isinstance(d, dict) and d.get("login"):
|
||
notes.append("未配置 Gitea 密码,只能同步公开仓库;仓库为私有时请填写密码")
|
||
else:
|
||
notes.append(f"注意:在新地址上找不到用户 {cfg['gitea_user']},请检查")
|
||
self._json({"ok": True, "msg": "已保存(" + ";".join(notes) + ")"})
|
||
elif path == "/api/accounts/add":
|
||
token = (data.get("token") or "").strip()
|
||
label = (data.get("label") or "").strip()
|
||
gh_user = (data.get("gh_user") or "").strip()
|
||
private = bool(data.get("private", True))
|
||
if not token:
|
||
self._json({"ok": False, "msg": "Token 不能为空"})
|
||
return
|
||
ok, login, err = gh_verify_token(token)
|
||
if not ok:
|
||
self._json({"ok": False, "msg": f"Token 验证失败: {err}"})
|
||
return
|
||
if gh_user and gh_user != login:
|
||
self._json({"ok": False, "msg": f"Token 对应的账号是 {login},与填写的 {gh_user} 不一致"})
|
||
return
|
||
existing = next((a for a in accounts if a["gh_user"] == login), None)
|
||
if existing:
|
||
existing["token"] = token
|
||
existing["private"] = private
|
||
if label:
|
||
existing["label"] = label
|
||
active_id = existing["id"]
|
||
save_accounts()
|
||
log(f"GitHub 账号 {login} 的 Token 已更新")
|
||
self._json({"ok": True, "msg": f"账号 {login} 已存在,Token 已更新并切换为当前账号"})
|
||
return
|
||
acc = {"id": uuid.uuid4().hex[:8], "label": label or login,
|
||
"gh_user": login, "token": token, "private": private,
|
||
"created": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
||
accounts.append(acc)
|
||
# 旧版默认账号(无 Token)存在时,添加第一个 Token 账号后自动切过去
|
||
cur = active_account()
|
||
if (not cur) or (not cur.get("token")):
|
||
active_id = acc["id"]
|
||
save_accounts()
|
||
log(f"新增 GitHub 账号 {login}({'私有' if private else '公开'}仓库)")
|
||
self._json({"ok": True, "msg": f"账号 {login} 验证通过,已添加"
|
||
+ ("并切换为当前账号" if active_id == acc["id"] else "")})
|
||
elif path == "/api/accounts/switch":
|
||
aid = (data.get("id") or "").strip()
|
||
if not find_account(aid):
|
||
self._json({"ok": False, "msg": "账号不存在"})
|
||
return
|
||
active_id = aid
|
||
save_accounts()
|
||
acc = find_account(aid)
|
||
log(f"已切换 GitHub 账号: {acc['label']}({acc['gh_user']})")
|
||
self._json({"ok": True, "msg": f"已切换到 {acc['label']}({acc['gh_user']})"})
|
||
elif path == "/api/accounts/remove":
|
||
aid = (data.get("id") or "").strip()
|
||
acc = find_account(aid)
|
||
if not acc:
|
||
self._json({"ok": False, "msg": "账号不存在"})
|
||
return
|
||
if len(accounts) <= 1:
|
||
self._json({"ok": False, "msg": "至少保留一个账号"})
|
||
return
|
||
accounts.remove(acc)
|
||
if active_id == aid:
|
||
active_id = accounts[0]["id"]
|
||
save_accounts()
|
||
log(f"已移除 GitHub 账号 {acc['gh_user']}")
|
||
self._json({"ok": True, "msg": f"账号 {acc['gh_user']} 已移除(GitHub 仓库不受影响)"})
|
||
elif path == "/api/accounts/verify":
|
||
aid = (data.get("id") or "").strip()
|
||
acc = find_account(aid)
|
||
if not acc:
|
||
self._json({"ok": False, "msg": "账号不存在"})
|
||
return
|
||
if not acc.get("token"):
|
||
self._json({"ok": True, "msg": f"{acc['gh_user']}:SSH 私钥推送(无需 Token)"})
|
||
return
|
||
ok, login, err = gh_verify_token(acc["token"])
|
||
if ok:
|
||
self._json({"ok": True, "msg": f"Token 有效 ✓({login})"})
|
||
else:
|
||
self._json({"ok": False, "msg": f"Token 校验失败: {err}"})
|
||
elif path == "/api/repos/sync":
|
||
name = data.get("name", "")
|
||
if not valid_name(name):
|
||
self._json({"ok": False, "msg": "非法仓库名"})
|
||
return
|
||
# 不再被整轮同步全局拦截:只禁止同一仓库重复同步(sync_repo 内部按仓库防重入),
|
||
# 与正在进行的整轮同步互不阻塞,实现多项目同时同步
|
||
res = sync_repo(name)
|
||
state["last_check"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
if res.startswith("error"):
|
||
bump_stats(total_failures=1)
|
||
self._json({"ok": False, "msg": f"{name} {res}"})
|
||
elif res == "changed":
|
||
bump_stats(total_pushes=1)
|
||
self._json({"ok": True, "msg": f"{name} 已同步到 GitHub"})
|
||
else:
|
||
self._json({"ok": True, "msg": f"{name} 已是最新,无需同步"})
|
||
elif path == "/api/repos/add":
|
||
name = (data.get("name") or "").strip()
|
||
if not valid_name(name):
|
||
self._json({"ok": False, "msg": "仓库名不合法(仅限字母/数字/._-)"})
|
||
return
|
||
repos = load_repos()
|
||
if name in repos:
|
||
self._json({"ok": False, "msg": f"{name} 已在同步列表中"})
|
||
return
|
||
if not gitea_repo_exists(name):
|
||
self._json({"ok": False, "msg": f"Gitea 上不存在仓库 {cfg['gitea_user']}/{name},请检查名字"})
|
||
return
|
||
acc = active_account()
|
||
# GitHub 上不存在同名仓库时,按设置自动创建(需 Token)
|
||
if not github_repo_exists(acc, name):
|
||
if not (acc.get("token") and cfg.get("auto_create", True)):
|
||
self._json({"ok": False, "msg": f"GitHub 上不存在 {acc['gh_user']}/{name},且当前账号无法自动创建(需在设置页添加带 Token 的账号并开启自动创建)"})
|
||
return
|
||
ok, msg = github_create_repo(acc, name)
|
||
if not ok:
|
||
self._json({"ok": False, "msg": f"自动创建 GitHub 仓库失败: {msg}"})
|
||
return
|
||
log(f"{name}: GitHub 仓库不存在,添加时已自动创建({'私有' if acc.get('private', True) else '公开'})")
|
||
repos.append(name)
|
||
save_repos(repos)
|
||
log(f"{name}: 已加入同步列表")
|
||
res = sync_repo(name)
|
||
if res.startswith("error"):
|
||
self._json({"ok": False, "msg": f"已加入列表,但首次同步失败:{res}"})
|
||
elif res == "changed":
|
||
bump_stats(total_pushes=1)
|
||
self._json({"ok": True, "msg": f"{name} 添加成功,已完成首次同步"})
|
||
else:
|
||
self._json({"ok": True, "msg": f"{name} 添加成功,内容已是最新"})
|
||
elif path == "/api/repos/remove":
|
||
name = (data.get("name") or "").strip()
|
||
repos = load_repos()
|
||
if name not in repos:
|
||
self._json({"ok": False, "msg": f"{name} 不在同步列表中"})
|
||
return
|
||
repos = [r for r in repos if r != name]
|
||
save_repos(repos)
|
||
log(f"{name}: 已从同步列表移除")
|
||
self._json({"ok": True, "msg": f"{name} 已移除(GitHub 仓库与本地数据保留)"})
|
||
else:
|
||
self._json({"error": "not found"}, 404)
|
||
|
||
|
||
def last_real_sync():
|
||
try:
|
||
for line in reversed(LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()):
|
||
if "已同步到 GitHub" in line:
|
||
return line.strip()
|
||
except Exception:
|
||
pass
|
||
return "尚无记录"
|
||
|
||
|
||
def write_pidfile():
|
||
try:
|
||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
PID_FILE.write_text(str(os.getpid()))
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def main():
|
||
signal.signal(signal.SIGHUP, signal.SIG_IGN)
|
||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
(DATA_DIR / "logs").mkdir(exist_ok=True)
|
||
# fnOS 解包可能把密钥权限放宽,ssh 会拒绝;守护进程即属主,启动时自愈
|
||
try:
|
||
if SSH_KEY.exists():
|
||
os.chmod(SSH_KEY, 0o600)
|
||
except Exception:
|
||
pass
|
||
write_pidfile()
|
||
atexit.register(lambda: PID_FILE.exists() and PID_FILE.unlink())
|
||
load_settings()
|
||
load_accounts()
|
||
load_panel_state()
|
||
load_stats()
|
||
load_repos()
|
||
acc = active_account()
|
||
log(f"GiteaHubSync 守护进程启动 (pid {os.getpid()}, 自动同步={'开' if state['enabled'] else '关'}, "
|
||
f"项目 {len(load_repos())} 个, GitHub 账号={acc['label'] if acc else '无'}"
|
||
f"({acc['gh_user'] if acc else '-'}/{'Token' if acc and acc.get('token') else 'SSH'}), "
|
||
f"Gitea 用户={cfg['gitea_user']}, 地址={gitea_host()}, 自动创建={'开' if cfg.get('auto_create') else '关'})")
|
||
threading.Thread(target=loop, daemon=True).start()
|
||
server = ThreadingHTTPServer(("0.0.0.0", PANEL_PORT), Handler)
|
||
log(f"控制面板监听 :{PANEL_PORT}")
|
||
server.serve_forever()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|