Initial commit: rdgen with GitHub/Gitea dual build backend
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
"""登录后的业务页面:我的构建、接口令牌、管理仪表盘、用户管理。"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Count
|
||||
from django.http import (
|
||||
JsonResponse, HttpResponseRedirect, HttpResponse, Http404, FileResponse,
|
||||
)
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from . import app_settings, build_backends, maintenance
|
||||
from .forms import (
|
||||
AdminUserCreateForm, AdminUserEditForm, ApiTokenForm, GenerateForm, GitHubSettingsForm,
|
||||
MaintenanceSettingsForm,
|
||||
)
|
||||
from .models import ApiToken, GithubRun, SavedConfig
|
||||
|
||||
|
||||
def staff_required(view_func):
|
||||
"""仅管理员(is_staff)可访问。"""
|
||||
return user_passes_test(lambda u: u.is_authenticated and u.is_staff)(view_func)
|
||||
|
||||
|
||||
@login_required
|
||||
def my_builds(request):
|
||||
builds = GithubRun.objects.filter(created_by=request.user)
|
||||
return render(request, 'my_builds.html', {'builds': builds})
|
||||
|
||||
|
||||
def _remove_run_artifacts(run):
|
||||
"""删除任务对应的本地安装包与图片目录(忽略不存在的情况)。"""
|
||||
if run.uuid:
|
||||
shutil.rmtree(os.path.join('exe', run.uuid), ignore_errors=True)
|
||||
shutil.rmtree(os.path.join('png', run.uuid), ignore_errors=True)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
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 中取消)后再删除。")
|
||||
else:
|
||||
_remove_run_artifacts(run)
|
||||
run.delete()
|
||||
messages.success(request, f"构建任务「{run.filename or run.short_uuid()}」及本地产物已删除。")
|
||||
return redirect('my_builds')
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def retry_build(request, run_id):
|
||||
"""用任务提交时保存的配置快照重新派发一个新构建(原任务保留作历史)。"""
|
||||
qs = GithubRun.objects.all() if request.user.is_staff else GithubRun.objects.filter(created_by=request.user)
|
||||
run = get_object_or_404(qs, pk=run_id)
|
||||
|
||||
if not run.is_finished():
|
||||
messages.error(request, "该任务尚未结束,无需重试。")
|
||||
return redirect('my_builds')
|
||||
if not run.config_data:
|
||||
messages.error(request, "该任务提交时未保存配置快照(较早的历史任务),无法一键重试,请重新填写配置后提交。")
|
||||
return redirect('my_builds')
|
||||
|
||||
# 局部导入避免与 views 模块在导入期形成耦合
|
||||
from .views import generate_custom_client
|
||||
full_url = f"{app_settings.get_value('PROTOCOL')}://{request.get_host()}"
|
||||
# 新任务归属原提交者(管理员代重试时也保持原归属);匿名历史任务归当前操作者
|
||||
owner = run.created_by or request.user
|
||||
result = generate_custom_client(dict(run.config_data), full_url, user=owner)
|
||||
if result.get('success'):
|
||||
messages.success(
|
||||
request,
|
||||
f"已用原配置重新提交构建(新任务:{result.get('filename', '')}),可在下方查看进度。",
|
||||
)
|
||||
else:
|
||||
messages.error(request, f"重试失败:{result.get('error', '未知错误')}")
|
||||
return redirect('my_builds')
|
||||
|
||||
|
||||
@login_required
|
||||
def api_tokens(request):
|
||||
new_plain_key = None
|
||||
if request.method == 'POST':
|
||||
form = ApiTokenForm(request.POST)
|
||||
if form.is_valid():
|
||||
token = ApiToken.objects.create(user=request.user, name=form.cleaned_data['name'])
|
||||
new_plain_key = token.key
|
||||
messages.success(request, "接口令牌创建成功,请立即复制保存(离开本页后将不再完整显示)。")
|
||||
form = ApiTokenForm()
|
||||
else:
|
||||
form = ApiTokenForm()
|
||||
|
||||
tokens = ApiToken.objects.filter(user=request.user)
|
||||
return render(request, 'tokens.html', {
|
||||
'form': form,
|
||||
'tokens': tokens,
|
||||
'new_plain_key': new_plain_key,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def delete_token(request, key):
|
||||
token = get_object_or_404(ApiToken, key=key, user=request.user)
|
||||
token.delete()
|
||||
messages.success(request, "接口令牌已删除。")
|
||||
return redirect('api_tokens')
|
||||
|
||||
|
||||
@staff_required
|
||||
def dashboard(request):
|
||||
builds = GithubRun.objects.select_related('created_by').all()[:100]
|
||||
stats = {
|
||||
'total': GithubRun.objects.count(),
|
||||
'in_progress': GithubRun.objects.exclude(
|
||||
status__in=['success', 'failure', 'cancelled', 'timed_out', 'skipped']
|
||||
).count(),
|
||||
'success': GithubRun.objects.filter(status='success').count(),
|
||||
'failure': GithubRun.objects.filter(status__in=['failure', 'timed_out', 'cancelled']).count(),
|
||||
'users': User.objects.count(),
|
||||
'tokens': ApiToken.objects.filter(is_active=True).count(),
|
||||
}
|
||||
return render(request, 'dashboard.html', {'builds': builds, 'stats': stats})
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_POST
|
||||
def delete_build(request, run_id):
|
||||
run = get_object_or_404(GithubRun, pk=run_id)
|
||||
_remove_run_artifacts(run)
|
||||
run.delete()
|
||||
messages.success(request, "构建任务及本地产物已删除。")
|
||||
return redirect('dashboard')
|
||||
|
||||
|
||||
# ===== 维护设置(定时清理 + 数据库备份) =====
|
||||
|
||||
@staff_required
|
||||
def maintenance_settings(request):
|
||||
if request.method == 'POST':
|
||||
form = MaintenanceSettingsForm(request.POST)
|
||||
if form.is_valid():
|
||||
app_settings.set_value(
|
||||
'BUILD_RETENTION_DAYS', str(form.cleaned_data['build_retention_days'])
|
||||
)
|
||||
app_settings.set_value(
|
||||
'DB_BACKUP_ENABLED', '1' if form.cleaned_data['db_backup_enabled'] else '0'
|
||||
)
|
||||
app_settings.set_value(
|
||||
'DB_BACKUP_KEEP', str(form.cleaned_data['db_backup_keep'])
|
||||
)
|
||||
messages.success(request, "维护设置已保存,定时任务下一轮检查时自动生效。")
|
||||
return redirect('maintenance_settings')
|
||||
else:
|
||||
form = MaintenanceSettingsForm(initial={
|
||||
'build_retention_days': maintenance.retention_days(),
|
||||
'db_backup_enabled': maintenance.backup_enabled(),
|
||||
'db_backup_keep': maintenance.backup_keep(),
|
||||
})
|
||||
|
||||
return render(request, 'maintenance_settings.html', {
|
||||
'form': form,
|
||||
'status': maintenance.maintenance_status(),
|
||||
})
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_POST
|
||||
def maintenance_run_now(request):
|
||||
"""手动立即执行一轮维护(清理过期任务 + 立即备份数据库)。"""
|
||||
result = maintenance.run_maintenance(force_backup=True)
|
||||
if result['backup_created']:
|
||||
backup_name = os.path.basename(result['backup_created'])
|
||||
messages.success(
|
||||
request,
|
||||
f"维护已执行:清理过期任务 {result['deleted_builds']} 条、"
|
||||
f"临时文件 {result['deleted_temp_files']} 个,并已创建新备份 {backup_name}。",
|
||||
)
|
||||
else:
|
||||
messages.success(
|
||||
request,
|
||||
f"维护已执行:清理过期任务 {result['deleted_builds']} 条、"
|
||||
f"临时文件 {result['deleted_temp_files']} 个;本轮未创建数据库备份。",
|
||||
)
|
||||
return redirect('maintenance_settings')
|
||||
|
||||
|
||||
@staff_required
|
||||
def download_backup(request, filename):
|
||||
"""下载指定的数据库备份文件(文件名严格白名单,防路径穿越)。"""
|
||||
if not re.fullmatch(r'db-\d{8}-\d{6}\.sqlite3', filename):
|
||||
raise Http404("非法的备份文件名")
|
||||
file_path = maintenance.backup_dir() / filename
|
||||
if not file_path.is_file():
|
||||
raise Http404("备份文件不存在")
|
||||
return FileResponse(
|
||||
open(file_path, 'rb'),
|
||||
content_type='application/octet-stream',
|
||||
as_attachment=True,
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
|
||||
@staff_required
|
||||
def user_list(request):
|
||||
if request.method == 'POST':
|
||||
form = AdminUserCreateForm(request.POST)
|
||||
if form.is_valid():
|
||||
User.objects.create_user(
|
||||
username=form.cleaned_data['username'],
|
||||
email=form.cleaned_data.get('email', ''),
|
||||
password=form.cleaned_data['password'],
|
||||
is_staff=form.cleaned_data.get('is_staff', False),
|
||||
)
|
||||
messages.success(request, f"用户「{form.cleaned_data['username']}」创建成功。")
|
||||
return redirect('user_list')
|
||||
else:
|
||||
form = AdminUserCreateForm()
|
||||
|
||||
users = User.objects.annotate(build_count=Count('builds')).order_by('-date_joined')
|
||||
return render(request, 'user_list.html', {'users': users, 'form': form})
|
||||
|
||||
|
||||
@staff_required
|
||||
def user_edit(request, user_id):
|
||||
target = get_object_or_404(User, pk=user_id)
|
||||
if request.method == 'POST':
|
||||
form = AdminUserEditForm(request.POST)
|
||||
if form.is_valid():
|
||||
target.email = form.cleaned_data.get('email', '')
|
||||
target.is_active = form.cleaned_data.get('is_active', False)
|
||||
target.is_staff = form.cleaned_data.get('is_staff', False)
|
||||
new_password = form.cleaned_data.get('new_password')
|
||||
if new_password:
|
||||
target.set_password(new_password)
|
||||
# 管理员修改自己的密码后保持登录状态
|
||||
if request.user.pk == target.pk:
|
||||
update_session_auth_hash(request, target)
|
||||
target.save()
|
||||
messages.success(request, f"用户「{target.username}」已更新。")
|
||||
return redirect('user_list')
|
||||
else:
|
||||
form = AdminUserEditForm(initial={
|
||||
'email': target.email,
|
||||
'is_active': target.is_active,
|
||||
'is_staff': target.is_staff,
|
||||
})
|
||||
return render(request, 'user_form.html', {'form': form, 'target': target})
|
||||
|
||||
|
||||
# 非密钥配置项:设置键 -> 表单字段名
|
||||
_PLAIN_FIELDS = [
|
||||
('BUILD_PLATFORM', 'build_platform'),
|
||||
('GITEA_SERVER_URL', 'gitea_server_url'),
|
||||
('GITEA_OWNER', 'gitea_owner'),
|
||||
('GITEA_REPO', 'gitea_repo'),
|
||||
('GITEA_BRANCH', 'gitea_branch'),
|
||||
('GHUSER', 'ghuser'),
|
||||
('REPONAME', 'reponame'),
|
||||
('GHBRANCH', 'ghbranch'),
|
||||
('GENURL', 'genurl'),
|
||||
('PROTOCOL', 'protocol'),
|
||||
]
|
||||
# 密钥配置项:设置键 -> (输入字段名, 清除复选框字段名)
|
||||
_SECRET_FIELDS = [
|
||||
('GITEA_TOKEN', ('gitea_token', 'clear_gitea_token')),
|
||||
('GITEA_PROXY', ('gitea_proxy', 'clear_gitea_proxy')),
|
||||
('GHBEARER', ('ghbearer', 'clear_ghbearer')),
|
||||
('ZIP_PASSWORD', ('zip_password', 'clear_zip_password')),
|
||||
('SH_SECRET', ('sh_secret', 'clear_sh_secret')),
|
||||
('WEBHOOK_SECRET', ('webhook_secret', 'clear_webhook_secret')),
|
||||
('GH_PROXY', ('gh_proxy', 'clear_gh_proxy')),
|
||||
]
|
||||
|
||||
|
||||
def _settings_rows(form):
|
||||
"""把配置定义与绑定后的表单字段组合成渲染行。"""
|
||||
rows = []
|
||||
for item in app_settings.all_settings():
|
||||
field_name = item['key'].lower()
|
||||
# 仅渲染本表单包含的设置组(maintenance 组在独立维护页处理)
|
||||
if field_name not in form.fields:
|
||||
continue
|
||||
row = dict(item)
|
||||
row['field'] = form[field_name]
|
||||
row['source_label'] = app_settings.SOURCE_LABELS[row['source']]
|
||||
if item['sensitive']:
|
||||
row['clear_field'] = form[f'clear_{field_name}']
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
@staff_required
|
||||
def github_settings(request):
|
||||
"""后台 GitHub 构建配置:DB 覆盖优先,环境变量继续作为回退。"""
|
||||
if request.method == 'POST':
|
||||
form = GitHubSettingsForm(request.POST)
|
||||
if form.is_valid():
|
||||
cleaned = form.cleaned_data
|
||||
|
||||
# 非密钥项:提交值与环境变量(或默认值)一致时不写覆盖,保持来源可追溯
|
||||
for key, field_name in _PLAIN_FIELDS:
|
||||
value = cleaned.get(field_name, '').strip()
|
||||
env_value = os.environ.get(app_settings.DEFS_BY_KEY[key]['env'])
|
||||
if env_value is not None:
|
||||
fallback = env_value.strip()
|
||||
else:
|
||||
fallback = app_settings.DEFS_BY_KEY[key]['default']
|
||||
if value == fallback:
|
||||
app_settings.delete_value(key)
|
||||
else:
|
||||
app_settings.set_value(key, value)
|
||||
|
||||
# 密钥项:勾选清除则回退;填写新值则覆盖;留空保持不变
|
||||
for key, (field_name, clear_name) in _SECRET_FIELDS:
|
||||
if cleaned.get(clear_name):
|
||||
app_settings.delete_value(key)
|
||||
elif cleaned.get(field_name):
|
||||
app_settings.set_value(key, cleaned[field_name])
|
||||
|
||||
messages.success(request, "构建平台配置已保存。")
|
||||
return redirect('github_settings')
|
||||
else:
|
||||
# 仅回填非密钥项;密钥绝不回显明文
|
||||
initial = {
|
||||
item['key'].lower(): item['value']
|
||||
for item in app_settings.all_settings()
|
||||
if not item['sensitive']
|
||||
}
|
||||
form = GitHubSettingsForm(initial=initial)
|
||||
|
||||
rows = _settings_rows(form)
|
||||
groups = [
|
||||
('platform', '构建平台', '选择实际执行构建任务的平台;切换后仅影响新提交的任务,历史任务仍在原平台查询。'),
|
||||
('gitea', 'Gitea 连接', '自建 Gitea(建议 1.27 或更高版本)的服务器、令牌与仓库信息;构建机(Linux/Windows/macOS runner)需自行准备。'),
|
||||
('basic', 'GitHub 基础连接', 'GitHub 仓库与访问令牌,用于触发 Actions 构建。'),
|
||||
('callback', '回调与加密', '构建产物回传地址与配置包加密,两个平台共用,需与仓库 Secret 保持一致。'),
|
||||
('advanced', '高级 / 安全', '回调签名校验两平台共用;自托管 Runner 密钥仅 GitHub 自托管构建需要,均按需配置。'),
|
||||
('network', 'GitHub 网络代理', '服务器访问 GitHub(触发构建、查询状态、拉取版本列表)的网络出口;能直连则无需配置。'),
|
||||
]
|
||||
return render(request, 'site_settings.html', {
|
||||
'rows': rows,
|
||||
'groups': groups,
|
||||
'source_labels': app_settings.SOURCE_LABELS,
|
||||
'build_platform': build_backends.current_platform(),
|
||||
'github_ready': app_settings.is_github_configured(),
|
||||
'warnings': app_settings.config_warnings(),
|
||||
'gh_user': app_settings.get_value('GHUSER'),
|
||||
'gh_repo': app_settings.get_value('REPONAME'),
|
||||
'gitea_server': app_settings.get_value('GITEA_SERVER_URL').strip().rstrip('/'),
|
||||
'gitea_owner': app_settings.get_value('GITEA_OWNER'),
|
||||
'gitea_repo': app_settings.get_value('GITEA_REPO'),
|
||||
})
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_POST
|
||||
def test_build(request):
|
||||
"""测试当前所选(或表单中刚选的)构建平台的连接。"""
|
||||
platform = (request.POST.get('platform') or '').strip().lower()
|
||||
if platform not in build_backends.BACKENDS:
|
||||
platform = build_backends.current_platform()
|
||||
result = build_backends.get_backend(platform).test_connection()
|
||||
return JsonResponse(result)
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_POST
|
||||
def test_github(request):
|
||||
"""兼容旧路由:直接测试 GitHub 连接。"""
|
||||
return JsonResponse(build_backends.get_backend('github').test_connection())
|
||||
|
||||
|
||||
# ===== 构建配置方案(与账号绑定,可载入/导出/导入) =====
|
||||
|
||||
# 允许进入方案 JSON 的字段(即 GenerateForm 全部字段;图片以 data URL 字符串存储)
|
||||
_PROFILE_KEYS = set(GenerateForm.base_fields.keys())
|
||||
_PROFILE_MAX_BYTES = 4 * 1024 * 1024 # 含三张 base64 图片,上限 4MB
|
||||
|
||||
|
||||
def _sanitize_profile_data(raw):
|
||||
"""校验并清洗方案 JSON:仅保留表单已知字段的原始值。"""
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("配置内容必须是 JSON 对象。")
|
||||
cleaned = {}
|
||||
for key, value in raw.items():
|
||||
if key not in _PROFILE_KEYS:
|
||||
continue
|
||||
if isinstance(value, bool) or value is None:
|
||||
if value is not None:
|
||||
cleaned[key] = value
|
||||
elif isinstance(value, (str, int, float)):
|
||||
cleaned[key] = value
|
||||
if len(json.dumps(cleaned, ensure_ascii=False).encode('utf-8')) > _PROFILE_MAX_BYTES:
|
||||
raise ValueError("配置文件过大(含图片上限 4MB)。")
|
||||
return cleaned
|
||||
|
||||
|
||||
def _parse_profile_upload(upload):
|
||||
try:
|
||||
raw_text = upload.read().decode('utf-8')
|
||||
except (UnicodeDecodeError, AttributeError):
|
||||
raise ValueError("配置文件编码无效,请上传本平台导出的 UTF-8 JSON 文件。")
|
||||
if len(raw_text.encode('utf-8')) > _PROFILE_MAX_BYTES:
|
||||
raise ValueError("配置文件过大(上限 4MB)。")
|
||||
try:
|
||||
raw = json.loads(raw_text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
raise ValueError("配置文件不是有效的 JSON。")
|
||||
return _sanitize_profile_data(raw)
|
||||
|
||||
|
||||
@login_required
|
||||
def saved_configs(request):
|
||||
"""配置方案管理页:列表、删除、导出、导入。"""
|
||||
if request.method == 'POST':
|
||||
upload = request.FILES.get('config_file')
|
||||
name = request.POST.get('name', '').strip()
|
||||
if not upload:
|
||||
messages.error(request, "请选择要导入的 .json 配置文件。")
|
||||
return redirect('saved_configs')
|
||||
if not name:
|
||||
name = os.path.splitext(upload.name)[0].strip()
|
||||
name = name[:100]
|
||||
if not name:
|
||||
messages.error(request, "请填写方案名称。")
|
||||
return redirect('saved_configs')
|
||||
if SavedConfig.objects.filter(owner=request.user, name=name).exists():
|
||||
messages.error(request, f"已存在同名方案「{name}」,请改名后再导入,或先删除原方案。")
|
||||
return redirect('saved_configs')
|
||||
try:
|
||||
data = _parse_profile_upload(upload)
|
||||
except ValueError as e:
|
||||
messages.error(request, str(e))
|
||||
return redirect('saved_configs')
|
||||
SavedConfig.objects.create(owner=request.user, name=name, data=data)
|
||||
messages.success(request, f"配置方案「{name}」导入成功,可在客户端定制页直接载入。")
|
||||
return redirect('saved_configs')
|
||||
|
||||
configs = request.user.saved_configs.all()
|
||||
return render(request, 'saved_configs.html', {'configs': configs})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def save_config(request):
|
||||
"""定制页「另存为方案」:AJAX 提交 {name, data, overwrite?}。"""
|
||||
try:
|
||||
payload = json.loads(request.body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return JsonResponse({'ok': False, 'message': '请求内容不是有效的 JSON。'}, status=400)
|
||||
|
||||
name = (payload.get('name') or '').strip()[:100]
|
||||
if not name:
|
||||
return JsonResponse({'ok': False, 'message': '请填写方案名称。'}, status=400)
|
||||
try:
|
||||
data = _sanitize_profile_data(payload.get('data'))
|
||||
except ValueError as e:
|
||||
return JsonResponse({'ok': False, 'message': str(e)}, status=400)
|
||||
|
||||
existing = SavedConfig.objects.filter(owner=request.user, name=name).first()
|
||||
if existing and not payload.get('overwrite'):
|
||||
return JsonResponse({'ok': False, 'message': f'已存在同名方案「{name}」,是否覆盖?', 'duplicate': True}, status=409)
|
||||
if existing:
|
||||
existing.data = data
|
||||
existing.save(update_fields=['data', 'updated_at'])
|
||||
return JsonResponse({'ok': True, 'id': existing.id, 'overwritten': True})
|
||||
|
||||
cfg = SavedConfig.objects.create(owner=request.user, name=name, data=data)
|
||||
return JsonResponse({'ok': True, 'id': cfg.id, 'overwritten': False})
|
||||
|
||||
|
||||
@login_required
|
||||
def config_data(request, pk):
|
||||
"""供定制页下拉载入:返回方案完整 JSON。"""
|
||||
cfg = get_object_or_404(SavedConfig, pk=pk, owner=request.user)
|
||||
return JsonResponse({'name': cfg.name, 'data': cfg.data})
|
||||
|
||||
|
||||
@login_required
|
||||
def export_config(request, pk):
|
||||
"""把方案导出为 .json 文件(格式与定制页本地导出一致,可互导/供 cli 使用)。"""
|
||||
cfg = get_object_or_404(SavedConfig, pk=pk, owner=request.user)
|
||||
body = json.dumps(cfg.data, ensure_ascii=False, indent=2)
|
||||
response = HttpResponse(body, content_type='application/json; charset=utf-8')
|
||||
response['Content-Disposition'] = f"attachment; filename*=UTF-8''{quote(cfg.name)}.json"
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def delete_config(request, pk):
|
||||
cfg = get_object_or_404(SavedConfig, pk=pk, owner=request.user)
|
||||
name = cfg.name
|
||||
cfg.delete()
|
||||
messages.success(request, f"配置方案「{name}」已删除。")
|
||||
return redirect('saved_configs')
|
||||
|
||||
|
||||
def healthz(request):
|
||||
"""容器健康检查端点,无需登录。"""
|
||||
return JsonResponse({"status": "ok"})
|
||||
Reference in New Issue
Block a user