63d0699cdd
Remove invalid _mirror_manager from daemon.json that prevented Docker from starting. Add 2-minute test apply with auto-rollback, final apply confirmation, emergency-restore CLI/UI, and post-restart Docker health checks. Co-authored-by: Cursor <cursoragent@cursor.com>
218 lines
7.5 KiB
Python
218 lines
7.5 KiB
Python
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
|
|
from flask_login import login_required
|
|
|
|
from app.guides import GUIDES
|
|
from app.models import ApplyLog, Profile, Setting, SystemState
|
|
from app.models import utcnow
|
|
from app.services.applier import (
|
|
APPLY_MODE_FINAL,
|
|
APPLY_MODE_TEST,
|
|
apply_profile,
|
|
confirm_apply,
|
|
emergency_restore_last_apply,
|
|
restore_initial,
|
|
rollback_apply,
|
|
)
|
|
from app.services.backup import get_latest_apply_backup, list_backups, restore_backup
|
|
|
|
apply_bp = Blueprint("apply", __name__, url_prefix="/apply")
|
|
|
|
|
|
@apply_bp.route("/")
|
|
@login_required
|
|
def index():
|
|
profiles = Profile.query.order_by(Profile.name).all()
|
|
state = SystemState.query.first()
|
|
pending = None
|
|
if state and state.pending_apply_log_id:
|
|
pending = ApplyLog.query.get(state.pending_apply_log_id)
|
|
|
|
test_minutes = int(Setting.get("test_rollback_minutes", "2") or "2")
|
|
recent_logs = ApplyLog.query.order_by(ApplyLog.started_at.desc()).limit(10).all()
|
|
return render_template(
|
|
"apply/index.html",
|
|
profiles=profiles,
|
|
pending=pending,
|
|
recent_logs=recent_logs,
|
|
test_minutes=test_minutes,
|
|
guide=GUIDES.get("profiles", {}),
|
|
)
|
|
|
|
|
|
def _handle_apply(profile_id: int, mode: str):
|
|
profile = Profile.query.get_or_404(profile_id)
|
|
state = SystemState.query.first()
|
|
if state and state.pending_apply_log_id:
|
|
flash("یک تست در انتظار تأیید است. ابتدا آن را تأیید یا rollback کنید.", "warning")
|
|
return redirect(url_for("apply.status", log_id=state.pending_apply_log_id))
|
|
|
|
log = apply_profile(profile, mode=mode)
|
|
if log.status == "failed":
|
|
flash("اعمال پروفایل با خطا مواجه شد. تنظیمات قبلی خودکار بازگردانده شد.", "danger")
|
|
elif mode == APPLY_MODE_FINAL:
|
|
flash("پروفایل بهصورت نهایی اعمال و ثبت شد.", "success")
|
|
return redirect(url_for("main.dashboard"))
|
|
else:
|
|
test_minutes = log.get_details().get("rollback_minutes", 2)
|
|
flash(
|
|
f"تست موقت اعمال شد. {test_minutes} دقیقه برای بررسی دارید — در صورت عدم تأیید، rollback خودکار انجام میشود.",
|
|
"warning",
|
|
)
|
|
return redirect(url_for("apply.status", log_id=log.id))
|
|
|
|
|
|
@apply_bp.route("/profile/<int:profile_id>/test", methods=["POST"])
|
|
@login_required
|
|
def apply_test(profile_id):
|
|
return _handle_apply(profile_id, APPLY_MODE_TEST)
|
|
|
|
|
|
@apply_bp.route("/profile/<int:profile_id>/final", methods=["POST"])
|
|
@login_required
|
|
def apply_final(profile_id):
|
|
return _handle_apply(profile_id, APPLY_MODE_FINAL)
|
|
|
|
|
|
@apply_bp.route("/status/<int:log_id>")
|
|
@login_required
|
|
def status(log_id):
|
|
log = ApplyLog.query.get_or_404(log_id)
|
|
details = log.get_details()
|
|
remaining_seconds = 0
|
|
if log.rollback_at and not log.confirmed:
|
|
remaining_seconds = max(0, int((log.rollback_at - utcnow()).total_seconds()))
|
|
return render_template(
|
|
"apply/status.html",
|
|
log=log,
|
|
details=details,
|
|
remaining_seconds=remaining_seconds,
|
|
apply_mode=details.get("mode", APPLY_MODE_TEST),
|
|
)
|
|
|
|
|
|
@apply_bp.route("/confirm/<int:log_id>", methods=["POST"])
|
|
@login_required
|
|
def confirm(log_id):
|
|
confirm_apply(log_id)
|
|
flash("تست موفق بود — تغییرات بهصورت نهایی ثبت شد.", "success")
|
|
return redirect(url_for("main.dashboard"))
|
|
|
|
|
|
@apply_bp.route("/rollback/<int:log_id>", methods=["POST"])
|
|
@login_required
|
|
def rollback(log_id):
|
|
result = rollback_apply(log_id)
|
|
if result.get("success"):
|
|
flash("تنظیمات قبلی بازگردانده شد.", "info")
|
|
else:
|
|
flash(result.get("error", "خطا در rollback"), "danger")
|
|
return redirect(url_for("main.dashboard"))
|
|
|
|
|
|
@apply_bp.route("/api/pending/<int:log_id>")
|
|
@login_required
|
|
def pending_api(log_id):
|
|
log = ApplyLog.query.get_or_404(log_id)
|
|
remaining = 0
|
|
if log.rollback_at and not log.confirmed:
|
|
remaining = max(0, int((log.rollback_at - utcnow()).total_seconds()))
|
|
return jsonify(
|
|
{
|
|
"status": log.status,
|
|
"confirmed": log.confirmed,
|
|
"remaining_seconds": remaining,
|
|
}
|
|
)
|
|
|
|
|
|
restore_bp = Blueprint("restore", __name__, url_prefix="/restore")
|
|
|
|
|
|
@restore_bp.route("/")
|
|
@login_required
|
|
def index():
|
|
backups = list_backups()
|
|
state = SystemState.query.first()
|
|
latest_apply = get_latest_apply_backup()
|
|
return render_template(
|
|
"restore/index.html",
|
|
backups=backups,
|
|
state=state,
|
|
latest_apply_backup=str(latest_apply) if latest_apply else None,
|
|
guide=GUIDES.get("restore", {}),
|
|
)
|
|
|
|
|
|
@restore_bp.route("/emergency", methods=["POST"])
|
|
@login_required
|
|
def emergency_restore_view():
|
|
result = emergency_restore_last_apply()
|
|
if result.get("success"):
|
|
flash(f"بازگردانی فوری انجام شد از: {result.get('backup_path', '')}", "success")
|
|
else:
|
|
flash(result.get("error", "خطا در بازگردانی"), "danger")
|
|
return redirect(url_for("restore.index"))
|
|
|
|
|
|
@restore_bp.route("/initial", methods=["POST"])
|
|
@login_required
|
|
def restore_initial_view():
|
|
result = restore_initial()
|
|
if result.get("success"):
|
|
flash("تنظیمات اولیه بازگردانده شد.", "success")
|
|
else:
|
|
flash(result.get("error", "خطا"), "danger")
|
|
return redirect(url_for("restore.index"))
|
|
|
|
|
|
@restore_bp.route("/backup", methods=["POST"])
|
|
@login_required
|
|
def restore_backup_view():
|
|
path = request.form.get("backup_path", "").strip()
|
|
if not path:
|
|
flash("مسیر backup انتخاب نشده.", "danger")
|
|
return redirect(url_for("restore.index"))
|
|
result = restore_backup(path)
|
|
if result.get("success"):
|
|
flash("backup بازگردانده شد.", "success")
|
|
else:
|
|
flash(result.get("error", "خطا"), "danger")
|
|
return redirect(url_for("restore.index"))
|
|
|
|
|
|
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
|
|
|
|
|
|
@settings_bp.route("/", methods=["GET", "POST"])
|
|
@login_required
|
|
def index():
|
|
if request.method == "POST":
|
|
for key in (
|
|
"auto_switch_dns",
|
|
"auto_switch_apt",
|
|
"auto_switch_docker",
|
|
"auto_switch_github",
|
|
"auto_switch_pip",
|
|
"auto_switch_npm",
|
|
):
|
|
Setting.set(key, "true" if request.form.get(key) == "on" else "false")
|
|
|
|
Setting.set("rollback_minutes", request.form.get("rollback_minutes", "15"))
|
|
Setting.set("test_rollback_minutes", request.form.get("test_rollback_minutes", "2"))
|
|
Setting.set("auto_test_interval_minutes", request.form.get("auto_test_interval_minutes", "30"))
|
|
Setting.set("max_switches_per_day", request.form.get("max_switches_per_day", "3"))
|
|
Setting.set(
|
|
"rollback_on_all_fail",
|
|
"true" if request.form.get("rollback_on_all_fail") == "on" else "false",
|
|
)
|
|
flash("تنظیمات ذخیره شد.", "success")
|
|
return redirect(url_for("settings.index"))
|
|
|
|
settings = {key: Setting.get(key) for key in (
|
|
"auto_switch_dns", "auto_switch_apt", "auto_switch_docker",
|
|
"auto_switch_github", "auto_switch_pip", "auto_switch_npm",
|
|
"rollback_minutes", "test_rollback_minutes", "auto_test_interval_minutes",
|
|
"max_switches_per_day", "rollback_on_all_fail",
|
|
)}
|
|
return render_template("settings/index.html", settings=settings, guide=GUIDES.get("settings", {}))
|