@@ -0,0 +1,786 @@
#!/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 %a n \t %a d " , " --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 ( )