Files
fnos-gitea-sync/GiteaHubSync/app/backend/panel.py
T
cbb 4d6f71af5b feat: GiteaHubSync v1.0.0 — fnOS 原生应用:Gitea→GitHub 镜像同步
- 每次推送到飞牛 Gitea(3000 端口)的代码 10 秒内自动镜像到 GitHub 7 个私有仓库
  (SMB_Android / DCU_APP / GD32E50X_BOOT / DCU_DesktopController / Motor_v2 /
  DCU_ECAT_Master / GD32_BOOT),真镜像语义(删分支/标签同步删)
- 标准 .fpk 原生应用:不进 Docker,依赖 python312,安装后进入应用中心已安装列表
  和桌面启动台,支持启动/停止/重启/卸载,NAS 重启自动恢复
- 控制面板(:8899):自动同步开关、立即同步、仓库状态、同步日志、打开 Gitea
- GitHub 推送走 SSH 22 端口直连(HTTPS 443 本地网络被断),部署私钥随应用本地分发
- 附 fnos-install.js(命令行自动安装)、build.sh(重新构建 fpk)、genicon.py(图标生成)

注意:GitHub 部署私钥(GiteaHubSync/app/ssh/id_ed25519)与打包产物 .fpk
按 .gitignore 规则不进仓库,仅保留在工作目录本地分发。
2026-10-13 19:12:49 +08:00

787 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# ============================================================
# GiteaHubSync — Gitea → GitHub 镜像同步守护进程 + 控制面板
# 纯 Python 标准库。随 fnOS 应用安装,由 cmd/main 以
# setsid nohup 方式拉起;数据目录 = TRIM_PKGVAR(@appdata/GiteaHubSync)
# 页面: / 控制面板(开关/立即同步/快捷入口)
# /status 项目状态(每个仓库的详细同步状态)
# /manage 项目管理(从 Gitea 快捷添加/移除同步项目)
# ============================================================
import atexit
import json
import os
import re
import signal
import subprocess
import threading
import time
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
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"
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 = "http://127.0.0.1:3000/cbb"
GITEA_API = "http://127.0.0.1:3000/api/v1"
GH_USER = "cbb2625274797"
GITEA_UI = os.environ.get("GITEA_URL", "http://192.168.31.10:3000")
GH_URL = f"https://github.com/{GH_USER}?tab=repositories"
DEFAULT_REPOS = ["SMB_Android", "DCU_APP", "GD32E50X_BOOT", "DCU_DesktopController",
"Motor_v2", "DCU_ECAT_Master", "GD32_BOOT"]
INTERVAL = 10
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"
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()
# ---------------- 基础工具 ----------------
def log(msg):
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 git(args, timeout=300):
return subprocess.run(["git"] + 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,返回 (ok, json)"""
try:
r = subprocess.run(["curl", "-sS", "-m", "10", f"{GITEA_API}{path}"],
capture_output=True, text=True, timeout=15)
return r.returncode == 0, json.loads(r.stdout or "{}")
except Exception:
return False, {}
def gitea_repo_exists(name):
ok, d = gitea_api(f"/repos/cbb/{name}")
return ok and isinstance(d, dict) and d.get("full_name") == f"cbb/{name}"
def github_repo_exists(name):
try:
r = git(["ls-remote", f"git@github.com:{GH_USER}/{name}.git", "HEAD"], timeout=30)
return r.returncode == 0
except Exception:
return False
def list_gitea_repos():
ok, d = gitea_api("/users/cbb/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: 非法仓库名"
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}/{name}.git", str(repo)])
if r.returncode != 0:
return "error: 克隆失败 " + (r.stderr or "")[:120]
log(f"{name}: 首次克隆完成")
r = git(["-C", str(repo), "fetch", "--quiet", "--prune", "origin"])
if r.returncode != 0:
return "error: fetch 失败 " + (r.stderr or "")[:120]
cur = fingerprint(repo)
if cur is None:
return "error: 读取 refs 失败"
st = STATE_DIR / name
prev = st.read_text().strip() if st.exists() else ""
if cur == prev:
return "same"
p = git(["-C", str(repo), "push", "--mirror", f"git@github.com:{GH_USER}/{name}.git"])
if p.returncode != 0:
return "error: 推送失败 " + (p.stderr or p.stdout or "")[:120]
STATE_DIR.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" + (f" | {detail}" if detail else ""))
return "changed"
except Exception as e:
return f"error: {e}"
def run_sync(repos=None):
with op_lock:
if state["syncing"]:
return False, "已有同步任务正在进行"
state["syncing"] = True
try:
repos = repos if repos is not None else load_repos()
errs, changed = [], 0
for r in repos:
res = sync_repo(r)
if res.startswith("error"):
errs.append(f"{r} {res}")
stats["total_failures"] += 1
elif res == "changed":
changed += 1
stats["total_pushes"] += 1
state["last_check"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
state["checks"] += 1
stats["total_checks"] += 1
save_stats()
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(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_DIR / 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 global_status():
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"] + INTERVAL - now))
gitea_all = list_gitea_repos()
return {
"enabled": state["enabled"],
"syncing": state["syncing"],
"uptime": fmt_uptime(now - START_TS),
"interval": INTERVAL,
"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": GITEA_UI,
"gitea_online": bool(gitea_all),
"available": [r for r in gitea_all if r not in repos],
}
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}
/* ---------- 窄屏(手机 / 飞牛桌面小窗口):表格转卡片 ---------- */
@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>
</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 ""))
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">开启后每 10 秒检查一次 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(3000端口)</a>
<a class="btn b-gh" 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">同步项目</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?'开':'关');
const p=document.getElementById('pill');
p.textContent=s.enabled?'运行中':'已停止';p.className='pill '+(s.enabled?'on':'off');
document.getElementById('sEnabled').textContent=s.enabled?'开启(10秒/次)':'已停止';
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="sGitea"></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('sGitea').textContent='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>
"""
# ---------------- 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 == "/":
body = (PAGE_HOME.replace("__GITEA__", GITEA_UI)
.replace("__GH__", GH_URL)
.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 == "/api/state":
repos = load_repos()
self._json({"enabled": state["enabled"], "syncing": state["syncing"],
"last_check": state["last_check"], "checks": state["checks"],
"last_result": state["last_result"],
"repo_count": len(repos),
"synced_count": sum(1 for r in repos if (STATE_DIR / r).exists()),
"uptime": fmt_uptime(time.time() - START_TS),
"last_real": last_real_sync()})
elif path == "/api/status":
self._json(global_status())
elif path == "/api/log":
self._json({"lines": tail_log(80)})
else:
self._json({"error": "not found"}, 404)
def do_POST(self):
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/repos/sync":
name = data.get("name", "")
if not valid_name(name):
self._json({"ok": False, "msg": "非法仓库名"})
return
with op_lock:
if state["syncing"]:
self._json({"ok": False, "msg": "已有同步任务正在进行"})
return
state["syncing"] = True
try:
res = sync_repo(name)
state["last_check"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if res.startswith("error"):
stats["total_failures"] += 1
save_stats()
self._json({"ok": False, "msg": f"{name} {res}"})
elif res == "changed":
stats["total_pushes"] += 1
save_stats()
self._json({"ok": True, "msg": f"{name} 已同步到 GitHub"})
else:
self._json({"ok": True, "msg": f"{name} 已是最新,无需同步"})
finally:
state["syncing"] = False
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 上不存在仓库 cbb/{name},请检查名字"})
return
if not github_repo_exists(name):
self._json({"ok": False, "msg": f"GitHub 上还没有 {GH_USER}/{name},请先在 GitHub 建好同名仓库(建议私有)再添加"})
return
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":
stats["total_pushes"] += 1
save_stats()
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_panel_state()
load_stats()
load_repos()
log(f"GiteaHubSync 守护进程启动 (pid {os.getpid()}, 自动同步={'开' if state['enabled'] else '关'}, 项目 {len(load_repos())} 个)")
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()