停止/删除改为纯本地操作,页面不再查询 Gitea

停止只在本平台标记已取消、删除只删本地记录,均不再调用 GitHub/Gitea 的查询或取消接口,操作秒响应;构建状态完全以工作流结束回调为准,未收到回调的在途任务由纯本地 6 小时超时兜底。

移除上一版的 Gitea 网页登录取消、网页账密配置项、runs 批量状态查询及两后端 get_run/cancel_run 接口,消除页面加载时的远程等待。
This commit is contained in:
naeeo
2026-09-30 09:20:28 +08:00
parent c8f41a34c0
commit 6e5754fb59
7 changed files with 49 additions and 541 deletions
-18
View File
@@ -169,24 +169,6 @@ SETTING_DEFS = [
'sensitive': True, 'sensitive': True,
'help': '在 Gitea 个人设置中生成的 API 令牌,需对构建仓库授予 Actions 读写权限。页面仅显示保存状态,不会回显明文。', 'help': '在 Gitea 个人设置中生成的 API 令牌,需对构建仓库授予 Actions 读写权限。页面仅显示保存状态,不会回显明文。',
}, },
{
'key': 'GITEA_WEB_USERNAME',
'env': 'GITEA_WEB_USERNAME',
'default': '',
'label': 'Gitea 网页登录账号(可选)',
'group': 'gitea',
'sensitive': False,
'help': '仅 Gitea 1.27 及更早版本需要:这些版本没有取消运行的 API,填账号后本平台可模拟网页登录一键停止构建。留空则停止时给出运行页链接手动 Cancel;升级到 Gitea 1.28+ 后无需填写。建议使用专用账号并授予构建仓库写权限,该账号不能启用两步验证。',
},
{
'key': 'GITEA_WEB_PASSWORD',
'env': 'GITEA_WEB_PASSWORD',
'default': '',
'label': 'Gitea 网页登录密码(可选)',
'group': 'gitea',
'sensitive': True,
'help': '与上面的网页登录账号配套,仅用于 Gitea 1.27 及更早版本的一键停止。页面仅显示保存状态,不会回显明文。',
},
{ {
'key': 'GITEA_PROXY', 'key': 'GITEA_PROXY',
'env': 'GITEA_PROXY', 'env': 'GITEA_PROXY',
-23
View File
@@ -18,13 +18,6 @@ class DispatchResult:
html_url: str = '' # 平台上的运行详情页地址 html_url: str = '' # 平台上的运行详情页地址
@dataclass
class RunInfo:
finished: bool # 是否已进入终态
status: str = '' # 终态结论:success/failure/cancelled/skipped;未结束为进行中状态
html_url: str = ''
# rdgen 目标平台 -> 工作流文件名(两平台同名) # rdgen 目标平台 -> 工作流文件名(两平台同名)
WORKFLOW_FILES = { WORKFLOW_FILES = {
'windows': 'generator-windows.yml', 'windows': 'generator-windows.yml',
@@ -57,22 +50,6 @@ class BuildBackend:
"""触发工作流。inputs 为完整输入字典(GitHub/Gitea 均为 {ref, inputs})。""" """触发工作流。inputs 为完整输入字典(GitHub/Gitea 均为 {ref, inputs})。"""
raise NotImplementedError raise NotImplementedError
def get_run(self, run_id, *, timeout=12):
"""查询单次运行;网络异常或非 200 返回 None(调用方按「仍在进行」处理)。"""
raise NotImplementedError
def get_runs_batch(self, run_ids, *, timeout=12):
"""批量查询多个运行,返回 {str(run_id): RunInfo}。
列表页一次刷新多个在途任务时使用;平台不支持或查询失败返回 None,
调用方退化为逐个 get_run。返回字典中缺失的 id 由调用方逐个兜底。
"""
return None
def cancel_run(self, run_id, *, timeout=15):
"""请求平台取消一次运行;成功无返回,已结束时抛 BackendError(kind='finished')。"""
raise NotImplementedError
def log_url(self, run_id) -> str: def log_url(self, run_id) -> str:
raise NotImplementedError raise NotImplementedError
+4 -305
View File
@@ -4,31 +4,13 @@ API 与 GitHub Actions 几乎同形(已对照 Gitea 官方 Swagger 核实)
- 触发:POST /api/v1/repos/{owner}/{repo}/actions/workflows/{file}/dispatches - 触发:POST /api/v1/repos/{owner}/{repo}/actions/workflows/{file}/dispatches
?return_run_details=true,body {ref, inputs},200 返回 ?return_run_details=true,body {ref, inputs},200 返回
{workflow_run_id, html_url, run_url},不传该参数为 204; {workflow_run_id, html_url, run_url},不传该参数为 204;
- 状态:GET /api/v1/repos/{owner}/{repo}/actions/runs/{id},
status/conclusion 字段仿 GitHub;
- 鉴权:Authorization: token <PAT>。 - 鉴权:Authorization: token <PAT>。
注意:本平台不主动查询/取消 Gitea 上的运行。构建状态完全以工作流结束时的
回调(update_github_run)为准,停止/删除只作用于本平台本地记录。
""" """
import re
from .. import app_settings, ghnet from .. import app_settings, ghnet
from .base import BackendError, DispatchResult, RunInfo, WORKFLOW_FILES, BuildBackend from .base import BackendError, DispatchResult, WORKFLOW_FILES, BuildBackend
# Gitea 未结束状态(blocked=等待审批,waiting=等待 runner)
_GITEA_ACTIVE = {'queued', 'in_progress', 'waiting', 'blocked', 'requested', 'pending', 'running'}
# Gitea 原生终态(部分版本 run.status 直接返回结论,不走 completed+conclusion)
_GITEA_TERMINAL = {'success', 'failure', 'cancelled', 'skipped', 'timed_out', 'stopped', 'dead'}
# Gitea 结论 -> rdgen 内部状态
_CONCLUSION_MAP = {
'success': 'success',
'failure': 'failure',
'cancelled': 'cancelled',
'skipped': 'skipped',
'timed_out': 'timed_out',
'stopped': 'cancelled',
'dead': 'failure',
}
# 作业出现这些结论时,整个运行最终必然失败(cleanup 等收尾作业除外)
_JOB_BAD_CONCLUSIONS = {'failure', 'cancelled', 'timed_out', 'dead'}
class GiteaBackend(BuildBackend): class GiteaBackend(BuildBackend):
@@ -148,289 +130,6 @@ class GiteaBackend(BuildBackend):
print(f'查询 Gitea 最新运行失败:{e}') print(f'查询 Gitea 最新运行失败:{e}')
return None return None
# ---- 状态查询 ----
def get_run(self, run_id, *, timeout=12):
url = self._api(f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}')
token = app_settings.get_value('GITEA_TOKEN')
proxy = self._proxy()
try:
resp = ghnet.get(url, token=token, timeout=timeout, flavor='gitea', proxy=proxy)
except Exception as e:
print(f'查询 Gitea 状态出错:{e}')
return None
if resp.status_code != 200:
return None
data = resp.json()
html_url = data.get('html_url') or self.log_url(run_id)
status = (data.get('status') or '').lower()
conclusion = (data.get('conclusion') or '').lower()
if status == 'completed':
return RunInfo(
finished=True,
status=_CONCLUSION_MAP.get(conclusion, 'failure'),
html_url=html_url,
)
# 部分 Gitea 版本直接在 status 返回终态(无 completed 包裹)
if status in _GITEA_TERMINAL:
return RunInfo(
finished=True,
status=_CONCLUSION_MAP.get(status, 'failure'),
html_url=html_url,
)
# 仅在途运行才需要 jobs:识别「前置作业失败但父运行长期挂起」的 Gitea 已知行为
jobs_url = self._api(
f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}/jobs'
)
try:
jobs_resp = ghnet.get(
jobs_url, token=token, timeout=timeout, flavor='gitea', proxy=proxy)
except Exception as e:
print(f'查询 Gitea 作业状态出错:{e}')
jobs_resp = None
if jobs_resp is not None:
bad = self._bad_job_conclusion(jobs_resp)
if bad:
return RunInfo(finished=True, status=bad, html_url=html_url)
# blocked(等待审批)等也算进行中
return RunInfo(finished=False, status=status or 'in_progress', html_url=html_url)
@staticmethod
def _bad_job_conclusion(resp):
"""从 jobs 响应中判定终态:有非 cleanup 作业失败/取消/超时时返回对应状态,否则 None。"""
try:
if resp.status_code != 200:
return None
data = resp.json()
jobs = data.get('jobs', data if isinstance(data, list) else [])
for job in jobs:
name = (job.get('name') or '').lower()
if name.startswith('cleanup'):
continue
conclusion = (job.get('conclusion') or '').lower()
if conclusion in _JOB_BAD_CONCLUSIONS:
return _CONCLUSION_MAP.get(conclusion, 'failure')
except Exception as e:
print(f'解析 Gitea 作业状态出错:{e}')
return None
# ---- 状态批量查询(列表页用,一次请求取回全部运行) ----
def get_runs_batch(self, run_ids, *, timeout=12):
"""一次 runs 列表请求批量同步状态,避免每个在途任务各发两个请求。
返回 {str(run_id): RunInfo};列表拿不到时返回 None 让调用方退化逐个查询。
列表中仍在途的运行,再查 jobs 以识别「前置作业失败但父运行挂起」的僵死;
列表中不存在的 id(已删除或超出最近窗口)不放进返回字典。
"""
want = {str(i) for i in run_ids if i}
if not want:
return {}
url = self._api(
f'/repos/{self._owner()}/{self._repo()}/actions/runs?limit=50'
)
try:
resp = ghnet.get(
url, token=app_settings.get_value('GITEA_TOKEN'),
timeout=timeout, flavor='gitea', proxy=self._proxy(),
)
except Exception as e:
print(f'批量查询 Gitea 状态出错:{e}')
return None
if resp.status_code != 200:
return None
try:
data = resp.json()
items = data.get('workflow_runs') if isinstance(data, dict) else data
except ValueError:
return None
if not items:
return {}
result = {}
active = [] # (id, html_url)
for item in items:
rid = item.get('id')
if rid is None or str(rid) not in want:
continue
html_url = item.get('html_url') or self.log_url(rid)
status = (item.get('status') or '').lower()
conclusion = (item.get('conclusion') or '').lower()
if status == 'completed' or status in _GITEA_TERMINAL:
result[str(rid)] = RunInfo(
finished=True,
status=_CONCLUSION_MAP.get(conclusion or status, 'failure'),
html_url=html_url,
)
else:
active.append((rid, html_url))
# 在途运行查 jobs 识别 Gitea 的作业级失败挂起(通常在途任务很少)
if active:
token = app_settings.get_value('GITEA_TOKEN')
proxy = self._proxy()
owner_repo = f'{self._owner()}/{self._repo()}'
def _probe(rid_html):
rid, html_url = rid_html
jobs_url = self._api(
f'/repos/{owner_repo}/actions/runs/{rid}/jobs'
)
try:
jobs_resp = ghnet.get(
jobs_url, token=token, timeout=timeout,
flavor='gitea', proxy=proxy,
)
except Exception:
return str(rid), RunInfo(False, 'in_progress', html_url)
bad = self._bad_job_conclusion(jobs_resp)
if bad:
return str(rid), RunInfo(True, bad, html_url)
return str(rid), RunInfo(False, 'in_progress', html_url)
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=min(8, len(active))) as pool:
for key, info in pool.map(_probe, active):
result[key] = info
return result
# ---- 取消运行 ----
def cancel_run(self, run_id, *, timeout=15):
"""请求平台取消运行。
- 2xx:已接受取消;
- 409:运行已结束(调用方应改为拉取真实状态);
- 运行在平台上已不存在(如已被手工删除):kind='missing';
- 旧版 Gitea(≤1.27)无取消 API:配置了 Web 账密则模拟网页登录取消,
否则 kind='unsupported',引导用户去运行页手动 Cancel。
"""
url = self._api(f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}/cancel')
try:
resp = ghnet.post(
url, token=app_settings.get_value('GITEA_TOKEN'),
timeout=timeout, flavor='gitea', proxy=self._proxy(),
)
except Exception as e:
raise BackendError(f'无法连接 Gitea 构建服务:{e}', kind='network')
if 200 <= resp.status_code < 300:
return
if resp.status_code == 409:
raise BackendError('该构建运行已经结束,无法取消。', kind='finished')
if resp.status_code == 404:
exists = self._run_exists(run_id, timeout=timeout)
if exists is False:
raise BackendError(
'该运行在 Gitea 上已不存在(可能已被删除)。', kind='missing')
# 运行存在但 cancel 端点 404:Gitea ≤ 1.27 没有取消 API
if self._cancel_via_web(run_id, timeout=timeout):
return
raise BackendError(
'当前 Gitea 版本(1.27 及更早)未开放取消运行的 API。'
'可在后台「Gitea 连接」填写 Gitea 网页登录账号以启用一键停止,'
'或在 Gitea 的运行页面手动点击 Cancel 停止;升级到 Gitea 1.28+ 后可直接一键停止。',
kind='unsupported',
)
detail = ''
try:
detail = (resp.text or '')[:200]
except Exception:
pass
raise BackendError(f'Gitea 拒绝了取消请求(HTTP {resp.status_code}):{detail}')
def _run_exists(self, run_id, *, timeout=10):
"""GET run:存在 True / 404 False / 无法确定 None。"""
url = self._api(f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}')
try:
resp = ghnet.get(
url, token=app_settings.get_value('GITEA_TOKEN'),
timeout=timeout, flavor='gitea', proxy=self._proxy(),
)
except Exception:
return None
if resp.status_code == 200:
return True
if resp.status_code == 404:
return False
return None
def _cancel_via_web(self, run_id, *, timeout=20):
"""Gitea ≤1.27 无取消 API 时,用网页会话方式取消(需后台配置 Web 账密)。
模拟浏览器:登录拿会话 → 打开运行页拿 CSRF → POST 网页取消路由。
注意:部分 1.27 实例的登录页本身不输出 CSRF(登录 POST 豁免),
但取消操作需要登录后页面里的令牌;未配置账密、登录失败
(含开启两步验证)时返回 False。
"""
import requests
username = app_settings.get_value('GITEA_WEB_USERNAME').strip()
password = app_settings.get_value('GITEA_WEB_PASSWORD')
if not username or not password:
return False
server = self._server()
owner, repo = self._owner(), self._repo()
proxies = ghnet.proxies(self._proxy())
sess = requests.Session()
sess.proxies.update(proxies)
ua = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 rdgen'
try:
# 1. 登录页(建立会话;CSRF 若页面有则带上,没有也无妨——该实例登录豁免)
r = sess.get(f'{server}/user/login', timeout=timeout,
headers={'User-Agent': ua}, allow_redirects=True)
csrf = self._extract_csrf(r.text)
login_data = {'user_name': username, 'password': password}
login_headers = {'User-Agent': ua, 'Referer': f'{server}/user/login'}
if csrf:
login_data['_csrf'] = csrf
login_headers['X-Csrf-Token'] = csrf
# 2. 提交登录(Gitea 用 user_name 字段;成功后 302 离开 /user/login)
r = sess.post(
f'{server}/user/login', data=login_data,
headers=login_headers, timeout=timeout, allow_redirects=True,
)
if '/user/login' in r.url or 'name="password"' in r.text:
print('Gitea 网页取消:登录失败(账号密码错误或启用了两步验证)')
return False
# 3. 打开运行页取 CSRF(登录后页面才输出令牌)
run_page = f'{server}/{owner}/{repo}/actions/runs/{run_id}'
r = sess.get(run_page, timeout=timeout, headers={'User-Agent': ua})
if r.status_code != 200:
print(f'Gitea 网页取消:运行页 HTTP {r.status_code}(账号可能无该仓库访问权限)')
return False
csrf = self._extract_csrf(r.text)
if not csrf:
print('Gitea 网页取消:登录后运行页仍未找到 CSRF 令牌(账号可能无该仓库写权限)')
return False
# 4. POST 网页取消路由(302/303 回运行页即成功)
r = sess.post(
f'{run_page}/cancel',
data={'_csrf': csrf},
headers={'User-Agent': ua, 'Referer': run_page,
'X-Csrf-Token': csrf},
timeout=timeout, allow_redirects=False,
)
if r.status_code in (200, 302, 303):
return True
print(f'Gitea 网页取消:取消请求被拒绝 HTTP {r.status_code}')
return False
except Exception as e:
print(f'Gitea 网页取消出错:{e}')
return False
@staticmethod
def _extract_csrf(html):
"""从 Gitea 页面提取 CSRF:优先 meta,其次隐藏域,最后前端 JSON 配置。"""
if not html:
return None
for pattern in (
r'name="_csrf"\s+content="([^"]+)"',
r'content="([^"]+)"\s+name="_csrf"',
r'name="_csrf"\s+value="([^"]+)"',
r'"(?:csrfToken|_csrf)"\s*:\s*"([^"]+)"',
):
m = re.search(pattern, html)
if m:
return m.group(1)
return None
# ---- 日志链接 ---- # ---- 日志链接 ----
def log_url(self, run_id): def log_url(self, run_id):
if not run_id: if not run_id:
+6 -39
View File
@@ -1,12 +1,13 @@
"""GitHub Actions 构建后端(迁自 views.py / web_views.py 的原有逻辑,行为保持一致)。""" """GitHub Actions 构建后端(迁自 views.py / web_views.py 的原有逻辑,行为保持一致)。
注意:本平台不主动查询/取消 GitHub 上的运行。构建状态完全以工作流结束时的
回调(update_github_run)为准,停止/删除只作用于本平台本地记录。
"""
from .. import app_settings, ghnet from .. import app_settings, ghnet
from .base import BackendError, DispatchResult, RunInfo, WORKFLOW_FILES, BuildBackend from .base import BackendError, DispatchResult, WORKFLOW_FILES, BuildBackend
API = 'https://api.github.com' API = 'https://api.github.com'
# 未进入 completed 的状态一律视为进行中
_GITHUB_ACTIVE = {'queued', 'in_progress', 'waiting', 'requested', 'pending'}
class GitHubBackend(BuildBackend): class GitHubBackend(BuildBackend):
name = 'github' name = 'github'
@@ -56,40 +57,6 @@ class GitHubBackend(BuildBackend):
pass pass
return DispatchResult(run_id=data.get('workflow_run_id'), html_url=data.get('html_url') or '') return DispatchResult(run_id=data.get('workflow_run_id'), html_url=data.get('html_url') or '')
# ---- 状态查询 ----
def get_run(self, run_id, *, timeout=12):
user = app_settings.get_value('GHUSER')
repo = app_settings.get_value('REPONAME')
url = f'{API}/repos/{user}/{repo}/actions/runs/{run_id}'
try:
resp = ghnet.get(url, token=app_settings.get_value('GHBEARER'), timeout=timeout)
except Exception as e:
print(f'查询 GitHub 状态出错:{e}')
return None
if resp.status_code != 200:
return None
data = resp.json()
if data.get('status') == 'completed':
return RunInfo(finished=True, status=data.get('conclusion') or 'failure',
html_url=data.get('html_url') or self.log_url(run_id))
return RunInfo(finished=False, status=data.get('status') or 'in_progress',
html_url=data.get('html_url') or self.log_url(run_id))
# ---- 取消运行 ----
def cancel_run(self, run_id, *, timeout=15):
user = app_settings.get_value('GHUSER')
repo = app_settings.get_value('REPONAME')
url = f'{API}/repos/{user}/{repo}/actions/runs/{run_id}/cancel'
try:
resp = ghnet.post(url, token=app_settings.get_value('GHBEARER'), timeout=timeout)
except Exception as e:
raise BackendError(f'无法连接 GitHub 构建服务:{e}', kind='network')
if 200 <= resp.status_code < 300:
return
if resp.status_code == 409:
raise BackendError('该构建运行已经结束,无法取消。', kind='finished')
raise BackendError(f'GitHub 拒绝了取消请求(HTTP {resp.status_code})。')
# ---- 日志链接 ---- # ---- 日志链接 ----
def log_url(self, run_id): def log_url(self, run_id):
if not run_id: if not run_id:
-12
View File
@@ -307,18 +307,6 @@ class GitHubSettingsForm(forms.Form):
required=False, required=False,
) )
clear_gitea_token = forms.BooleanField(label="清除后台保存的令牌", required=False) clear_gitea_token = forms.BooleanField(label="清除后台保存的令牌", required=False)
gitea_web_username = forms.CharField(
label="Gitea 网页登录账号(可选)",
max_length=100,
required=False,
widget=forms.TextInput(attrs={'placeholder': '仅 Gitea 1.27 及更早版本一键停止时需要'}),
)
gitea_web_password = forms.CharField(
label="Gitea 网页登录密码(可选)",
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '已保存时留空表示不修改'}),
required=False,
)
clear_gitea_web_password = forms.BooleanField(label="清除后台保存的网页登录密码", required=False)
gitea_proxy = forms.CharField( gitea_proxy = forms.CharField(
label="Gitea 访问代理", label="Gitea 访问代理",
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '内网直连通常留空,如 http://host:port'}), widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '内网直连通常留空,如 http://host:port'}),
+19 -98
View File
@@ -2,7 +2,6 @@ import io
import json import json
import os import os
import re import re
import time
import uuid import uuid
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
@@ -33,114 +32,35 @@ from .models import GithubRun, STATUS_LABELS, STATUS_BADGE
# 终态状态集合 # 终态状态集合
TERMINAL_STATUSES = ('success', 'failure', 'cancelled', 'timed_out', 'skipped') TERMINAL_STATUSES = ('success', 'failure', 'cancelled', 'timed_out', 'skipped')
# 正常构建约 30~45 分钟;超过该时长平台仍显示进行中的,视为 runner 离线/作业僵死 # 正常构建约 30~45 分钟;超过该时长仍在进行中的,视为 runner 离线/作业僵死。
# 状态以构建脚本结束回调为准,页面不再向构建平台主动查询;超时兜底只做本地判断。
STALE_BUILD_HOURS = 6 STALE_BUILD_HOURS = 6
# 列表页状态同步节流:同一在途任务两次主动查询的最小间隔,避免每次刷页面都串行请求平台
REFRESH_THROTTLE_SECONDS = 20
# 列表/仪表盘同步用短超时,平台抖动时不能拖垮页面
REFRESH_TIMEOUT = 8
_refresh_stamps = {}
def _refresh_due(run_pk): def mark_stale_builds(gh_runs=None):
now = time.monotonic() """纯本地兜底:在途任务超过 STALE_BUILD_HOURS 未收到结束回调的,标记为超时。
ts = _refresh_stamps.get(run_pk, 0)
if now - ts >= REFRESH_THROTTLE_SECONDS:
# 发起前先占位,避免并发请求堆积
_refresh_stamps[run_pk] = now
return True
return False
不产生任何网络请求。传入任务列表时只处理这些任务;不传则扫描全表。
def refresh_active_run(gh_run, *, force=False, timeout=REFRESH_TIMEOUT):
"""向构建平台同步一次任务状态,状态落库后返回 True;网络异常或仍在进行则 False。
同时兜底两种平台侧的僵死:
- Gitea 前置作业失败后可复用工作流父作业/无匹配标签的作业长期挂起;
- 自建 runner 离线导致运行一直 in_progress(超过 STALE_BUILD_HOURS 判超时)。
force=False 时按 REFRESH_THROTTLE_SECONDS 节流(列表/仪表盘用);
force=True 跳过节流立即查询(详情页、停止操作后用),但同样刷新节流戳,
使紧接着的列表打开不再重复请求。
""" """
if gh_run.is_finished() or not gh_run.github_run_id: cutoff = timezone.now() - timedelta(hours=STALE_BUILD_HOURS)
return False qs = GithubRun.objects.exclude(status__in=TERMINAL_STATUSES)
if not force and not _refresh_due(gh_run.pk): if gh_runs is not None:
return False ids = [r.pk for r in gh_runs if not r.is_finished()]
_refresh_stamps[gh_run.pk] = time.monotonic() qs = qs.filter(pk__in=ids)
backend = build_backends.get_backend(gh_run.backend or 'github') qs.filter(created_at__lt=cutoff).update(
try: status='timed_out', updated_at=timezone.now())
info = backend.get_run(gh_run.github_run_id, timeout=timeout)
except Exception as e:
print(f"查询构建状态出错:{e}")
return False
if info is None:
return False
return _apply_run_info(gh_run, info)
def _apply_run_info(gh_run, info): def mark_run_stale_if_needed(gh_run):
"""把平台返回的状态落库;终态与 6 小时僵死兜底均在此。返回是否有变化。""" """单个任务的纯本地超时兜底(详情/状态接口用)。"""
new_status = info.status if info.finished else None if not gh_run.is_finished() and gh_run.created_at < \
if new_status is None and timezone.now() - gh_run.created_at > timedelta(hours=STALE_BUILD_HOURS): timezone.now() - timedelta(hours=STALE_BUILD_HOURS):
new_status = 'timed_out' gh_run.status = 'timed_out'
if new_status and new_status != gh_run.status:
gh_run.status = new_status
gh_run.save(update_fields=['status', 'updated_at']) gh_run.save(update_fields=['status', 'updated_at'])
return True return True
return False return False
def refresh_active_runs(gh_runs, *, force=False):
"""并发同步一批在途任务(列表页/仪表盘),整页只承受一次平台往返的耗时。
Gitea 优先用 runs 列表接口一次取回全部状态(在途任务再补 jobs 探测);
其他后端或批量接口不可用时退化为并发逐个查询。
"""
due = []
for run in gh_runs:
if run.is_finished() or not run.github_run_id:
continue
if _refresh_due(run.pk) or force:
_refresh_stamps[run.pk] = time.monotonic()
due.append(run)
if not due:
return
# 按后端分组批量
groups = {}
for run in due:
groups.setdefault(run.backend or 'github', []).append(run)
for backend_name, runs in groups.items():
backend = build_backends.get_backend(backend_name)
get_runs_batch = getattr(backend, 'get_runs_batch', None)
infos = None
if get_runs_batch is not None:
try:
infos = get_runs_batch(
[r.github_run_id for r in runs], timeout=REFRESH_TIMEOUT)
except Exception as e:
print(f"批量查询构建状态出错:{e}")
infos = None
if infos is None:
from concurrent.futures import ThreadPoolExecutor
workers = min(8, len(runs))
with ThreadPoolExecutor(max_workers=workers) as pool:
list(pool.map(lambda r: refresh_active_run(r, force=force), runs))
continue
missing = [run for run in runs if infos.get(str(run.github_run_id)) is None]
if missing:
# 列表窗口里没有的运行(已删除或超出最近 50 条):并发逐个查询兜底
from concurrent.futures import ThreadPoolExecutor
workers = min(8, len(missing))
with ThreadPoolExecutor(max_workers=workers) as pool:
list(pool.map(lambda r: refresh_active_run(r, force=True), missing))
for run in runs:
info = infos.get(str(run.github_run_id))
if info is not None:
_apply_run_info(run, info)
def _webhook_denied(request): def _webhook_denied(request):
"""配置了 WEBHOOK_SECRET 时,校验 GitHub Actions 回调请求头;未配置则放行(兼容旧工作流)。""" """配置了 WEBHOOK_SECRET 时,校验 GitHub Actions 回调请求头;未配置则放行(兼容旧工作流)。"""
secret = app_settings.get_value('WEBHOOK_SECRET') secret = app_settings.get_value('WEBHOOK_SECRET')
@@ -502,7 +422,8 @@ def _get_run_status(uuid_val):
backend = build_backends.get_backend(gh_run.backend or 'github') backend = build_backends.get_backend(gh_run.backend or 'github')
github_log_url = backend.log_url(gh_run.github_run_id) if gh_run.github_run_id else '' github_log_url = backend.log_url(gh_run.github_run_id) if gh_run.github_run_id else ''
refresh_active_run(gh_run, force=True, timeout=12) # 状态以构建脚本结束回调为准;这里只做纯本地的 6 小时超时兜底,不请求构建平台
mark_run_stale_if_needed(gh_run)
return { return {
"found": True, "found": True,
+20 -46
View File
@@ -32,9 +32,9 @@ def staff_required(view_func):
@login_required @login_required
def my_builds(request): def my_builds(request):
builds = list(GithubRun.objects.filter(created_by=request.user)) builds = list(GithubRun.objects.filter(created_by=request.user))
# 列表打开时并发同步在途任务的真实状态(带短节流,避免频繁刷页面拖慢) # 仅做纯本地的 6 小时超时兜底,不向构建平台发起任何请求,页面秒开
from .views import refresh_active_runs from .views import mark_stale_builds
refresh_active_runs(builds) mark_stale_builds(builds)
return render(request, 'my_builds.html', {'builds': builds}) return render(request, 'my_builds.html', {'builds': builds})
@@ -62,48 +62,24 @@ def delete_my_build(request, run_id):
@login_required @login_required
@require_POST @require_POST
def stop_build(request, run_id): def stop_build(request, run_id):
"""停止自己的在途构建:调用构建平台取消运行,并把任务标记为已取消。""" """停止构建:仅在本平台把任务标记为已取消,不调用 GitHub/Gitea 的远程取消接口。
构建平台上的流水线可能仍在继续运行,需要真正停止时请到对应平台的运行页手动 Cancel。
"""
run = get_object_or_404(GithubRun, pk=run_id, created_by=request.user) run = get_object_or_404(GithubRun, pk=run_id, created_by=request.user)
if run.is_finished(): if run.is_finished():
messages.info(request, "该任务已经结束,无需停止。") messages.info(request, "该任务已经结束,无需停止。")
return redirect('my_builds') else:
if not run.github_run_id: run.status = 'cancelled'
messages.error(request, "该任务尚未获得构建平台的运行编号,暂时无法停止,请稍后重试。") run.save(update_fields=['status', 'updated_at'])
return redirect('my_builds') tip = "已在本平台停止构建任务"
if run.github_run_id:
from . import build_backends from . import build_backends
from .build_backends.base import BackendError backend = build_backends.get_backend(run.backend or 'github')
backend = build_backends.get_backend(run.backend or 'github') tip += f"(仅本地标记;{run.backend_label}上的流水线若仍在运行,请前往运行页手动 Cancel:{backend.log_url(run.github_run_id)})"
try:
backend.cancel_run(run.github_run_id)
except BackendError as e:
if e.kind == 'finished':
# 平台侧已结束:立即拉一次真实状态,避免本地仍显示构建中
from .views import refresh_active_run
refresh_active_run(run, force=True)
if run.is_finished():
messages.info(request, f"构建平台上该任务实际已结束({run.status_label()}),列表状态已同步。")
else:
messages.error(request, str(e))
elif e.kind == 'missing':
# 平台上运行已被删除:本地直接标记取消,避免永远卡在构建中
run.status = 'cancelled'
run.save(update_fields=['status', 'updated_at'])
messages.info(request, "Gitea 上该运行已不存在(可能已被删除),已在本平台标记为已取消。")
elif e.kind == 'unsupported':
# 平台版本不支持远程取消(如 Gitea ≤ 1.27):给出运行页,本地状态保持构建中
log_link = backend.log_url(run.github_run_id)
messages.error(
request,
f"无法远程停止:{e.message} 运行页面:{log_link}",
)
else: else:
messages.error(request, f"停止失败:{e.message}") tip += "(仅本地标记)"
return redirect('my_builds') messages.success(request, tip)
run.status = 'cancelled'
run.save(update_fields=['status', 'updated_at'])
messages.success(request, f"已请求{run.backend_label}停止构建任务「{run.filename or run.short_uuid()}」。")
return redirect('my_builds') return redirect('my_builds')
@@ -170,9 +146,9 @@ def delete_token(request, key):
@staff_required @staff_required
def dashboard(request): def dashboard(request):
builds = list(GithubRun.objects.select_related('created_by').all()[:100]) builds = list(GithubRun.objects.select_related('created_by').all()[:100])
# 打开仪表盘时并发同步在途任务的真实状态(带短节流) # 仅做纯本地的 6 小时超时兜底,不向构建平台发起任何请求,页面秒开
from .views import refresh_active_runs from .views import mark_stale_builds
refresh_active_runs(builds) mark_stale_builds(builds)
stats = { stats = {
'total': GithubRun.objects.count(), 'total': GithubRun.objects.count(),
'in_progress': GithubRun.objects.exclude( 'in_progress': GithubRun.objects.exclude(
@@ -318,7 +294,6 @@ _PLAIN_FIELDS = [
('GITEA_OWNER', 'gitea_owner'), ('GITEA_OWNER', 'gitea_owner'),
('GITEA_REPO', 'gitea_repo'), ('GITEA_REPO', 'gitea_repo'),
('GITEA_BRANCH', 'gitea_branch'), ('GITEA_BRANCH', 'gitea_branch'),
('GITEA_WEB_USERNAME', 'gitea_web_username'),
('GHUSER', 'ghuser'), ('GHUSER', 'ghuser'),
('REPONAME', 'reponame'), ('REPONAME', 'reponame'),
('GHBRANCH', 'ghbranch'), ('GHBRANCH', 'ghbranch'),
@@ -328,7 +303,6 @@ _PLAIN_FIELDS = [
# 密钥配置项:设置键 -> (输入字段名, 清除复选框字段名) # 密钥配置项:设置键 -> (输入字段名, 清除复选框字段名)
_SECRET_FIELDS = [ _SECRET_FIELDS = [
('GITEA_TOKEN', ('gitea_token', 'clear_gitea_token')), ('GITEA_TOKEN', ('gitea_token', 'clear_gitea_token')),
('GITEA_WEB_PASSWORD', ('gitea_web_password', 'clear_gitea_web_password')),
('GITEA_PROXY', ('gitea_proxy', 'clear_gitea_proxy')), ('GITEA_PROXY', ('gitea_proxy', 'clear_gitea_proxy')),
('GHBEARER', ('ghbearer', 'clear_ghbearer')), ('GHBEARER', ('ghbearer', 'clear_ghbearer')),
('ZIP_PASSWORD', ('zip_password', 'clear_zip_password')), ('ZIP_PASSWORD', ('zip_password', 'clear_zip_password')),