diff --git a/rdgen/urls.py b/rdgen/urls.py index c0ace87..3bd37f8 100644 --- a/rdgen/urls.py +++ b/rdgen/urls.py @@ -20,6 +20,7 @@ urlpatterns = [ path('', views.generator_view, name='generator'), path('my-builds/', web_views.my_builds, name='my_builds'), path('my-builds//retry/', web_views.retry_build, name='retry_build'), + path('my-builds//stop/', web_views.stop_build, name='stop_build'), path('my-builds//delete/', web_views.delete_my_build, name='delete_my_build'), path('configs/', web_views.saved_configs, name='saved_configs'), path('configs/save/', web_views.save_config, name='save_config'), diff --git a/rdgenerator/build_backends/base.py b/rdgenerator/build_backends/base.py index 3c54092..fe4fea2 100644 --- a/rdgenerator/build_backends/base.py +++ b/rdgenerator/build_backends/base.py @@ -61,6 +61,10 @@ class BuildBackend: """查询单次运行;网络异常或非 200 返回 None(调用方按「仍在进行」处理)。""" raise NotImplementedError + def cancel_run(self, run_id, *, timeout=15): + """请求平台取消一次运行;成功无返回,已结束时抛 BackendError(kind='finished')。""" + raise NotImplementedError + def log_url(self, run_id) -> str: raise NotImplementedError diff --git a/rdgenerator/build_backends/gitea.py b/rdgenerator/build_backends/gitea.py index 6ad34cd..e2cd815 100644 --- a/rdgenerator/build_backends/gitea.py +++ b/rdgenerator/build_backends/gitea.py @@ -12,7 +12,9 @@ from .. import app_settings, ghnet from .base import BackendError, DispatchResult, RunInfo, WORKFLOW_FILES, BuildBackend # Gitea 未结束状态(blocked=等待审批,waiting=等待 runner) -_GITEA_ACTIVE = {'queued', 'in_progress', 'waiting', 'blocked', 'requested', 'pending'} +_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', @@ -20,7 +22,11 @@ _CONCLUSION_MAP = { 'cancelled': 'cancelled', 'skipped': 'skipped', 'timed_out': 'timed_out', + 'stopped': 'cancelled', + 'dead': 'failure', } +# 作业出现这些结论时,整个运行最终必然失败(cleanup 等收尾作业除外) +_JOB_BAD_CONCLUSIONS = {'failure', 'cancelled', 'timed_out', 'dead'} class GiteaBackend(BuildBackend): @@ -163,9 +169,73 @@ class GiteaBackend(BuildBackend): 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, + ) + # Gitea 已知行为:前置作业失败后,可复用工作流的父作业/等待匹配标签的 + # 后续作业可能长期停留在 in_progress/waiting,运行永远不进入 completed。 + # 这里检查作业级结论:只要非收尾作业已失败,即可判定整个运行失败。 + bad = self._detect_failed_job(run_id) + 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) + def _detect_failed_job(self, run_id, *, timeout=10): + """检查运行下各作业:有非 cleanup 作业失败/取消/超时时返回对应终态,否则 None。""" + url = self._api(f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}/jobs') + try: + resp = ghnet.get( + url, token=app_settings.get_value('GITEA_TOKEN'), + timeout=timeout, flavor='gitea', proxy=self._proxy(), + ) + 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 cancel_run(self, run_id, *, timeout=15): + """请求平台取消运行。成功无返回;409 表示运行已结束(调用方应改为拉取真实状态)。""" + 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: + raise BackendError( + '当前 Gitea 版本(1.27 及更早)未开放取消运行的 API,' + '请在 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 log_url(self, run_id): if not run_id: diff --git a/rdgenerator/build_backends/github.py b/rdgenerator/build_backends/github.py index 885c57d..a9632f3 100644 --- a/rdgenerator/build_backends/github.py +++ b/rdgenerator/build_backends/github.py @@ -75,6 +75,21 @@ class GitHubBackend(BuildBackend): 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): if not run_id: diff --git a/rdgenerator/static/rdgenerator/css/app.css b/rdgenerator/static/rdgenerator/css/app.css index 48a14b9..bf8d48c 100644 --- a/rdgenerator/static/rdgenerator/css/app.css +++ b/rdgenerator/static/rdgenerator/css/app.css @@ -367,6 +367,8 @@ input[type="file"].form-control::-webkit-file-upload-button { .btn-secondary:hover { border-color: var(--primary-border); color: var(--primary); } .btn-danger { background: #fff; color: var(--danger); border-color: #f3c2c2; } .btn-danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); } +.btn-warning { background: #fff; color: var(--warning); border-color: #f3d9a6; } +.btn-warning:hover { background: var(--warning); color: #fff; border-color: var(--warning); } .btn-sm { min-height: 30px; padding: 0 12px; font-size: 12.5px; border-radius: 6px; } .btn-sm .icon { font-size: 13px; } .btn-block { width: 100%; } diff --git a/rdgenerator/templates/base.html b/rdgenerator/templates/base.html index 378fd99..4b39576 100644 --- a/rdgenerator/templates/base.html +++ b/rdgenerator/templates/base.html @@ -76,6 +76,9 @@ + + + diff --git a/rdgenerator/templates/my_builds.html b/rdgenerator/templates/my_builds.html index 3d54dd3..be70287 100644 --- a/rdgenerator/templates/my_builds.html +++ b/rdgenerator/templates/my_builds.html @@ -51,6 +51,15 @@ {% endif %} + {% if not run.is_finished %} +
+ {% csrf_token %} + +
+ {% endif %} {% if run.is_finished %}
diff --git a/rdgenerator/views.py b/rdgenerator/views.py index 698b60a..e6a125e 100644 --- a/rdgenerator/views.py +++ b/rdgenerator/views.py @@ -3,6 +3,7 @@ import json import os import re import uuid +from datetime import timedelta from pathlib import Path from urllib.parse import quote @@ -22,12 +23,45 @@ from django.http import ( Http404, ) from django.shortcuts import render +from django.utils import timezone from django.views.decorators.csrf import csrf_exempt from . import app_settings, build_backends, versions as rdeskVersions from .forms import GenerateForm from .models import GithubRun, STATUS_LABELS, STATUS_BADGE +# 终态状态集合 +TERMINAL_STATUSES = ('success', 'failure', 'cancelled', 'timed_out', 'skipped') +# 正常构建约 30~45 分钟;超过该时长平台仍显示进行中的,视为 runner 离线/作业僵死 +STALE_BUILD_HOURS = 6 + + +def refresh_active_run(gh_run): + """向构建平台同步一次任务状态,状态落库后返回 True;网络异常或仍在进行则 False。 + + 同时兜底两种平台侧的僵死: + - Gitea 前置作业失败后可复用工作流父作业/无匹配标签的作业长期挂起; + - 自建 runner 离线导致运行一直 in_progress(超过 STALE_BUILD_HOURS 判超时)。 + """ + if gh_run.is_finished() or not gh_run.github_run_id: + return False + backend = build_backends.get_backend(gh_run.backend or 'github') + try: + info = backend.get_run(gh_run.github_run_id) + except Exception as e: + print(f"查询构建状态出错:{e}") + return False + if info is None: + return False + new_status = info.status if info.finished else None + if new_status is None and timezone.now() - gh_run.created_at > timedelta(hours=STALE_BUILD_HOURS): + new_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']) + return True + return False + def _webhook_denied(request): """配置了 WEBHOOK_SECRET 时,校验 GitHub Actions 回调请求头;未配置则放行(兼容旧工作流)。""" @@ -390,14 +424,7 @@ def _get_run_status(uuid_val): 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 '' - if gh_run.status not in ['success', 'failure', 'cancelled', 'timed_out', 'skipped'] and gh_run.github_run_id: - try: - info = backend.get_run(gh_run.github_run_id) - if info is not None and info.finished: - gh_run.status = info.status - gh_run.save() - except Exception as e: - print(f"查询构建状态出错:{e}") + refresh_active_run(gh_run) return { "found": True, diff --git a/rdgenerator/web_views.py b/rdgenerator/web_views.py index e1e0828..c2aae03 100644 --- a/rdgenerator/web_views.py +++ b/rdgenerator/web_views.py @@ -31,7 +31,12 @@ def staff_required(view_func): @login_required def my_builds(request): - builds = GithubRun.objects.filter(created_by=request.user) + builds = list(GithubRun.objects.filter(created_by=request.user)) + # 列表打开时向构建平台同步在途任务的真实状态(Gitea 可能不主动回调终态) + from .views import refresh_active_run + for run in builds: + if not run.is_finished(): + refresh_active_run(run) return render(request, 'my_builds.html', {'builds': builds}) @@ -48,7 +53,7 @@ def delete_my_build(request, run_id): """用户删除自己的已结束任务;进行中的任务不允许删除(避免与 GitHub 回调竞态)。""" run = get_object_or_404(GithubRun, pk=run_id, created_by=request.user) if not run.is_finished(): - messages.error(request, "该任务仍在进行中,请等待结束(或先在 GitHub Actions 中取消)后再删除。") + messages.error(request, "该任务仍在进行中,请先点「停止」取消构建(或等待结束)后再删除。") else: _remove_run_artifacts(run) run.delete() @@ -56,6 +61,49 @@ def delete_my_build(request, run_id): return redirect('my_builds') +@login_required +@require_POST +def stop_build(request, run_id): + """停止自己的在途构建:调用构建平台取消运行,并把任务标记为已取消。""" + run = get_object_or_404(GithubRun, pk=run_id, created_by=request.user) + if run.is_finished(): + messages.info(request, "该任务已经结束,无需停止。") + return redirect('my_builds') + if not run.github_run_id: + messages.error(request, "该任务尚未获得构建平台的运行编号,暂时无法停止,请稍后重试。") + return redirect('my_builds') + + from . import build_backends + from .build_backends.base import BackendError + backend = build_backends.get_backend(run.backend or 'github') + 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) + if run.is_finished(): + messages.info(request, f"构建平台上该任务实际已结束({run.status_label()}),列表状态已同步。") + else: + messages.error(request, str(e)) + 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: + messages.error(request, f"停止失败:{e.message}") + return redirect('my_builds') + + 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') + + @login_required @require_POST def retry_build(request, run_id): @@ -118,7 +166,12 @@ def delete_token(request, key): @staff_required def dashboard(request): - builds = GithubRun.objects.select_related('created_by').all()[:100] + builds = list(GithubRun.objects.select_related('created_by').all()[:100]) + # 打开仪表盘时同步在途任务的真实状态 + from .views import refresh_active_run + for run in builds: + if not run.is_finished(): + refresh_active_run(run) stats = { 'total': GithubRun.objects.count(), 'in_progress': GithubRun.objects.exclude( diff --git a/setup.md b/setup.md index 52b3e64..6e03c79 100644 --- a/setup.md +++ b/setup.md @@ -104,7 +104,9 @@ action)。 ### 1. Gitea 服务器与仓库 1. 部署 Gitea **1.27 或更高版本**(最低 1.23;1.27 随附 act_runner 2.0,对 - artifact/cache 与第三方 action 的兼容性最好)。 + artifact/cache 与第三方 action 的兼容性最好)。注意:列表页的一键「停止」依赖 + Gitea 1.28+ 的取消运行 API;1.27 及更早版本点击停止会给出运行页链接,需到 + Gitea 界面手动 Cancel。 2. 把本仓库整体推送到 Gitea(保留 `.gitea/` 目录与默认分支名,后台填写的 GITEA_BRANCH 必须与实际分支一致)。 3. 进入仓库 **Settings**,勾选 **Enable Repository Actions**。