Резервные копии по расписанию: каждый день или раз в неделю, все машины или отмеченные, хранятся последние N автокопий; копии делает служба virtuality-backup, а не панель
Этот коммит содержится в:
+2
-1
@@ -2083,7 +2083,7 @@ def api_health(request: Request):
|
||||
|
||||
|
||||
# Feature modules register template globals into app.templates, so they are imported once it exists.
|
||||
from features import backups, downloads, settings, setup, snapshots, vmops # noqa: E402
|
||||
from features import backup_schedule, backups, downloads, settings, setup, snapshots, vmops # noqa: E402
|
||||
|
||||
@app.on_event("startup")
|
||||
def close_orphaned_operations() -> None:
|
||||
@@ -2095,6 +2095,7 @@ def close_orphaned_operations() -> None:
|
||||
app.include_router(setup.router)
|
||||
app.include_router(settings.router)
|
||||
app.include_router(snapshots.router)
|
||||
app.include_router(backup_schedule.router)
|
||||
app.include_router(backups.router)
|
||||
app.include_router(downloads.router)
|
||||
app.include_router(vmops.router)
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Резервные копии по расписанию.
|
||||
|
||||
Настройка лежит в CONFIG_DIR/backup_schedule.json, итог последнего запуска —
|
||||
в CONFIG_DIR/backup_schedule_state.json. Копии делает не панель, а таймер
|
||||
virtuality-backup.timer раз в 15 минут:
|
||||
`/opt/virtuality/venv/bin/python /opt/virtuality/web/features/backup_schedule.py`.
|
||||
Так ночной перезапуск панели при обновлении не обрывает копию на середине
|
||||
и не оставляет машину выключенной.
|
||||
|
||||
Автокопии помечены в meta.json (auto: true); после каждой удачной копии
|
||||
у машины остаются последние N автокопий. Ручные копии расписание не трогает.
|
||||
"""
|
||||
import fcntl
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
if __name__ == "__main__": # запуск как скрипта: нужен web/ в sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from fastapi import APIRouter, Form, Request # noqa: E402
|
||||
|
||||
import core # noqa: E402
|
||||
from features import backups # noqa: E402
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TIME_RE = re.compile(r"([01]\d|2[0-3]):([0-5]\d)")
|
||||
CATCH_UP = timedelta(hours=2) # сервер был выключен в назначенное время: догоняем не позже чем через 2 часа, иначе ждём следующего раза
|
||||
KEEP_MIN, KEEP_MAX = 1, 60
|
||||
WEEKDAYS = ("по понедельникам", "по вторникам", "по средам", "по четвергам", "по пятницам", "по субботам", "по воскресеньям")
|
||||
WEEKDAY_NAMES = ("Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье")
|
||||
DEFAULT: dict[str, Any] = {"enabled": False, "frequency": "daily", "weekday": 6, "time": "03:00", "all_vms": True, "vms": [], "keep": 7}
|
||||
NOTE = "По расписанию"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- хранение
|
||||
def schedule_path() -> Path:
|
||||
return core.CONFIG_DIR / "backup_schedule.json"
|
||||
|
||||
|
||||
def state_path() -> Path:
|
||||
return core.CONFIG_DIR / "backup_schedule_state.json"
|
||||
|
||||
|
||||
def lock_path() -> Path:
|
||||
return core.CONFIG_DIR / "backup_schedule.lock"
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(".tmp")
|
||||
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
tmp_path.replace(path)
|
||||
|
||||
|
||||
def is_int(value: Any) -> bool:
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def clean_schedule(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Настройка всегда полная и корректная: испорченные значения заменяются значениями по умолчанию."""
|
||||
schedule = dict(DEFAULT)
|
||||
schedule["enabled"] = raw.get("enabled") is True
|
||||
if raw.get("frequency") in ("daily", "weekly"):
|
||||
schedule["frequency"] = raw["frequency"]
|
||||
if is_int(raw.get("weekday")) and 0 <= raw["weekday"] <= 6:
|
||||
schedule["weekday"] = raw["weekday"]
|
||||
if isinstance(raw.get("time"), str) and TIME_RE.fullmatch(raw["time"]):
|
||||
schedule["time"] = raw["time"]
|
||||
schedule["all_vms"] = raw.get("all_vms") is not False
|
||||
vms = raw.get("vms")
|
||||
schedule["vms"] = sorted({vm for vm in vms if isinstance(vm, str) and core.valid_vm_name(vm)}) if isinstance(vms, list) else []
|
||||
if is_int(raw.get("keep")) and KEEP_MIN <= raw["keep"] <= KEEP_MAX:
|
||||
schedule["keep"] = raw["keep"]
|
||||
return schedule
|
||||
|
||||
|
||||
def load_schedule() -> dict[str, Any]:
|
||||
return clean_schedule(read_json(schedule_path()))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- когда копировать
|
||||
def step(schedule: dict[str, Any]) -> timedelta:
|
||||
return timedelta(days=7 if schedule["frequency"] == "weekly" else 1)
|
||||
|
||||
|
||||
def last_slot(schedule: dict[str, Any], now: datetime) -> datetime:
|
||||
"""Последний назначенный момент копирования, не позже now (время сервера)."""
|
||||
hour, minute = map(int, schedule["time"].split(":"))
|
||||
slot = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if schedule["frequency"] == "weekly":
|
||||
slot -= timedelta(days=(slot.weekday() - schedule["weekday"]) % 7)
|
||||
if slot > now:
|
||||
slot -= step(schedule)
|
||||
return slot
|
||||
|
||||
|
||||
def next_slot(schedule: dict[str, Any], now: datetime) -> datetime:
|
||||
return last_slot(schedule, now) + step(schedule)
|
||||
|
||||
|
||||
def slot_key(slot: datetime) -> str:
|
||||
return slot.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def due(schedule: dict[str, Any], state: dict[str, Any], now: datetime) -> tuple[str, datetime] | None:
|
||||
"""("run", момент) — пора копировать; ("missed", момент) — время прошло, пока сервер был выключен; None — нечего делать."""
|
||||
if not schedule["enabled"]:
|
||||
return None
|
||||
slot = last_slot(schedule, now)
|
||||
if state.get("slot") == slot_key(slot):
|
||||
return None
|
||||
return ("run" if now - slot <= CATCH_UP else "missed"), slot
|
||||
|
||||
|
||||
def selected_vms(schedule: dict[str, Any]) -> list[str]:
|
||||
existing = backups.existing_vm_names()
|
||||
return sorted(existing if schedule["all_vms"] else existing & set(schedule["vms"]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- запуск
|
||||
def prune(vm: str, keep: int) -> list[str]:
|
||||
"""Удалить автокопии машины сверх последних keep. Ручные копии и копии, с которыми идёт работа, остаются."""
|
||||
autos = [item for item in backups.list_backups(vm) if item["auto"] and item["complete"]] # новые сверху
|
||||
removed = []
|
||||
for item in autos[keep:]:
|
||||
try:
|
||||
backups.delete_backup(vm, item["id"])
|
||||
except backups.BackupError:
|
||||
continue
|
||||
removed.append(item["id"])
|
||||
return removed
|
||||
|
||||
|
||||
def run_due(now: datetime | None = None) -> dict[str, Any] | None:
|
||||
"""Сделать копии, если подошло время. Возвращает итог запуска или None, если делать нечего."""
|
||||
lock_path().parent.mkdir(parents=True, exist_ok=True)
|
||||
with lock_path().open("w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
return None # предыдущий запуск ещё копирует
|
||||
now = now or datetime.now()
|
||||
schedule = load_schedule()
|
||||
state = read_json(state_path())
|
||||
found = due(schedule, state, now)
|
||||
if not found:
|
||||
return None
|
||||
kind, slot = found
|
||||
if kind == "missed":
|
||||
state.update(slot=slot_key(slot), missed=slot_key(slot))
|
||||
write_json(state_path(), state)
|
||||
return None
|
||||
state = {"slot": slot_key(slot), "status": "running", "started_at": core.utc_now(), "results": []}
|
||||
write_json(state_path(), state)
|
||||
for vm in selected_vms(schedule):
|
||||
result: dict[str, Any] = {"vm": vm}
|
||||
try:
|
||||
operation = backups.create_backup(vm, NOTE, wait=True, auto=True)
|
||||
except Exception as exc: # noqa: BLE001 — одна машина не должна сорвать копии остальных
|
||||
result.update(ok=False, message=str(exc)[:240])
|
||||
else:
|
||||
result.update(ok=operation.get("status") == "success", message=str(operation.get("message") or "")[:240], operation_id=operation["id"])
|
||||
if result["ok"]:
|
||||
result["removed"] = prune(vm, schedule["keep"])
|
||||
state["results"].append(result)
|
||||
write_json(state_path(), state)
|
||||
state.update(status="done", finished_at=core.utc_now())
|
||||
write_json(state_path(), state)
|
||||
return state
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- как показать
|
||||
def format_slot(slot: datetime) -> str:
|
||||
return f"{slot.day} {backups.MONTHS[slot.month - 1]}, {slot:%H:%M}"
|
||||
|
||||
|
||||
def format_key(key: str) -> str:
|
||||
try:
|
||||
return format_slot(datetime.strptime(key, "%Y-%m-%d %H:%M"))
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
def when_text(schedule: dict[str, Any]) -> str:
|
||||
day = "каждый день" if schedule["frequency"] == "daily" else WEEKDAYS[schedule["weekday"]]
|
||||
return f"{day} в {schedule['time']}"
|
||||
|
||||
|
||||
def overview(now: datetime | None = None) -> dict[str, Any]:
|
||||
"""Всё для карточки на странице «Резервные копии» и на странице машины."""
|
||||
now = now or datetime.now()
|
||||
schedule = load_schedule()
|
||||
state = read_json(state_path())
|
||||
results = [item for item in state.get("results") or [] if isinstance(item, dict)]
|
||||
failed = [item for item in results if not item.get("ok")]
|
||||
last = None
|
||||
if state.get("started_at"):
|
||||
last = {
|
||||
"when": format_key(str(state.get("slot") or "")),
|
||||
"running": state.get("status") == "running",
|
||||
"count": len(results),
|
||||
"ok": len(results) - len(failed),
|
||||
"failed": failed,
|
||||
}
|
||||
return {
|
||||
**schedule,
|
||||
"when": when_text(schedule),
|
||||
"next": format_slot(next_slot(schedule, now)) if schedule["enabled"] else "",
|
||||
"last": last,
|
||||
"missed": format_key(str(state.get("missed") or "")) if state.get("missed") and state.get("missed") == state.get("slot") else "",
|
||||
}
|
||||
|
||||
|
||||
def covers(schedule: dict[str, Any], vm: str) -> bool:
|
||||
return schedule["enabled"] and (schedule["all_vms"] or vm in schedule["vms"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- страницы
|
||||
def form_page(request: Request, form: dict[str, Any], error: str = "", status_code: int = 200):
|
||||
names = sorted(backups.existing_vm_names() | set(form["vms"]))
|
||||
context = {"page_title": "Расписание копий", "form": form, "vm_names": names, "weekdays": WEEKDAY_NAMES, "keep_max": KEEP_MAX, "error": error}
|
||||
return core.render(request, "backup_schedule.html", context, status_code=status_code)
|
||||
|
||||
|
||||
@router.get("/backups/schedule")
|
||||
def schedule_page(request: Request):
|
||||
auth = core.require_auth(request)
|
||||
if auth:
|
||||
return auth
|
||||
return form_page(request, load_schedule())
|
||||
|
||||
|
||||
def read_form(enabled: str, frequency: str, weekday: str, time: str, machines: str, vms: list[str], keep: str) -> dict[str, Any]:
|
||||
return {
|
||||
"enabled": enabled == "1",
|
||||
"frequency": frequency if frequency in ("daily", "weekly") else "daily",
|
||||
"weekday": int(weekday) if weekday.isdigit() and int(weekday) <= 6 else DEFAULT["weekday"],
|
||||
"time": time.strip(),
|
||||
"all_vms": machines != "chosen",
|
||||
"vms": sorted({vm for vm in vms if core.valid_vm_name(vm)}),
|
||||
"keep": int(keep) if keep.strip().isdigit() else keep.strip(),
|
||||
}
|
||||
|
||||
|
||||
def form_error(form: dict[str, Any]) -> str:
|
||||
if not TIME_RE.fullmatch(form["time"]):
|
||||
return "Укажите время в виде ЧЧ:ММ, например 03:00"
|
||||
if not is_int(form["keep"]) or not KEEP_MIN <= form["keep"] <= KEEP_MAX:
|
||||
return f"Сколько копий хранить: число от {KEEP_MIN} до {KEEP_MAX}"
|
||||
if form["enabled"] and not form["all_vms"] and not form["vms"]:
|
||||
return "Отметьте хотя бы одну машину или выберите «Все машины»"
|
||||
return ""
|
||||
|
||||
|
||||
def same_timing(old: dict[str, Any], new: dict[str, Any]) -> bool:
|
||||
return old["enabled"] and new["enabled"] and all(old[key] == new[key] for key in ("frequency", "weekday", "time"))
|
||||
|
||||
|
||||
@router.post("/backups/schedule")
|
||||
def schedule_save(
|
||||
request: Request,
|
||||
enabled: str = Form(""),
|
||||
frequency: str = Form("daily"),
|
||||
weekday: str = Form("6"),
|
||||
time: str = Form("03:00"),
|
||||
machines: str = Form("all"),
|
||||
vms: list[str] = Form([]),
|
||||
keep: str = Form("7"),
|
||||
):
|
||||
auth = core.require_auth(request)
|
||||
if auth:
|
||||
return auth
|
||||
form = read_form(enabled, frequency, weekday, time, machines, vms, keep)
|
||||
error = form_error(form)
|
||||
if error:
|
||||
return form_page(request, form, error, status_code=400)
|
||||
old, schedule = load_schedule(), clean_schedule(form)
|
||||
write_json(schedule_path(), schedule)
|
||||
now = datetime.now()
|
||||
if not same_timing(old, schedule):
|
||||
# Уже прошедший момент нового расписания не догоняем: первая копия — в следующее назначенное время.
|
||||
state = read_json(state_path())
|
||||
state["slot"] = slot_key(last_slot(schedule, now))
|
||||
write_json(state_path(), state)
|
||||
if not schedule["enabled"]:
|
||||
return core.redirect_with_message("/backups", "backup_message", "Автоматические копии выключены")
|
||||
message = f"Расписание сохранено: {when_text(schedule)}, следующая копия — {format_slot(next_slot(schedule, now))}"
|
||||
return core.redirect_with_message("/backups", "backup_message", message)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Точка входа virtuality-backup.service: итог пишется в журнал службы."""
|
||||
outcome = run_due()
|
||||
if outcome:
|
||||
done = sum(1 for item in outcome["results"] if item.get("ok"))
|
||||
print(f"Копии по расписанию {outcome['slot']}: {done} из {len(outcome['results'])} успешно")
|
||||
for item in outcome["results"]:
|
||||
if not item.get("ok"):
|
||||
print(f" {item['vm']}: {item.get('message')}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # запускает virtuality-backup.timer
|
||||
main()
|
||||
@@ -227,6 +227,7 @@ def describe_backup(vm: str, backup_id: str, path: Path, meta: dict[str, Any]) -
|
||||
"size_bytes": size,
|
||||
"size": format_bytes(size),
|
||||
"version": str(meta.get("version") or ""),
|
||||
"auto": meta.get("auto") is True,
|
||||
"complete": complete,
|
||||
}
|
||||
|
||||
@@ -358,12 +359,13 @@ def reserve_backup_dir(vm_name: str) -> str:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- создание копии
|
||||
def create_backup(vm_name: str, note: str = "", wait: bool = False) -> dict[str, Any]:
|
||||
def create_backup(vm_name: str, note: str = "", wait: bool = False, auto: bool = False) -> dict[str, Any]:
|
||||
"""Поставить создание копии в очередь и вернуть операцию.
|
||||
|
||||
Проверки (машина есть, диски есть, места хватает) выполняются сразу и
|
||||
поднимают BackupError с понятным текстом. С wait=True копия делается
|
||||
в текущем потоке — удобно для расписания."""
|
||||
в текущем потоке — так её делает расписание (auto=True: такие копии
|
||||
расписание само удаляет, оставляя последние N)."""
|
||||
cancel_late_restart(vm_name)
|
||||
if not core.valid_vm_name(vm_name):
|
||||
raise BackupError("Некорректное имя машины")
|
||||
@@ -387,7 +389,8 @@ def create_backup(vm_name: str, note: str = "", wait: bool = False) -> dict[str,
|
||||
raise BackupError(f"Недостаточно места для копии: нужно около {format_bytes(need)}, свободно {format_bytes(free)}. Удалите старые копии или освободите место на сервере.", "space")
|
||||
backup_id = reserve_backup_dir(vm_name)
|
||||
try:
|
||||
operation = core.new_operation("backup", f"Резервная копия {vm_name}", "Ожидает начала", vm_name=vm_name, backup_id=backup_id, note=note, disks=plan, size_bytes=need)
|
||||
title = f"Резервная копия {vm_name}" + (" по расписанию" if auto else "")
|
||||
operation = core.new_operation("backup", title, "Ожидает начала", vm_name=vm_name, backup_id=backup_id, note=note, disks=plan, size_bytes=need, auto=auto)
|
||||
except Exception:
|
||||
remove_backup_dir(backup_root() / vm_name / backup_id)
|
||||
raise
|
||||
@@ -555,7 +558,7 @@ def backup_worker(operation: dict[str, Any]) -> None:
|
||||
log(f"qemu-img завершился с кодом {code}.")
|
||||
raise BackupError(f"Не удалось скопировать диск {disk['target']} — подробности в журнале задачи")
|
||||
size = sum(item.stat().st_size for item in dest.iterdir() if item.is_file())
|
||||
meta = {"vm": vm, "created_at": core.utc_now(), "disks": plan, "size_bytes": size, "version": core.APP_VERSION, "note": operation.get("note", "")}
|
||||
meta = {"vm": vm, "created_at": core.utc_now(), "disks": plan, "size_bytes": size, "version": core.APP_VERSION, "note": operation.get("note", ""), "auto": operation.get("auto") is True}
|
||||
(dest / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
log(f"Копия готова: {format_bytes(size)} (сжатие включено).")
|
||||
except Exception as exc: # noqa: BLE001 — любая ошибка = операция с понятным текстом
|
||||
@@ -777,8 +780,11 @@ def backups_page(request: Request):
|
||||
auth = core.require_auth(request)
|
||||
if auth:
|
||||
return auth
|
||||
from features import backup_schedule # расписание само импортирует этот модуль
|
||||
|
||||
items = list_backups()
|
||||
context = {
|
||||
"schedule": backup_schedule.overview(),
|
||||
"groups": group_by_vm(items, existing_vm_names()),
|
||||
"storage": storage_summary(items),
|
||||
"running": [op for op in core.running_operations() if op.get("type") in ("backup", "restore")],
|
||||
@@ -841,8 +847,11 @@ def vm_backups_page(request: Request, name: str):
|
||||
return auth
|
||||
if not core.valid_vm_name(name) or not core.vm_exists(name):
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
from features import backup_schedule
|
||||
|
||||
items = list_backups(name)
|
||||
context = {"vm": vm_summary(name), "backups": items, "storage": storage_summary(), "active": active_operation(name), **page_messages(request)}
|
||||
schedule = backup_schedule.overview()
|
||||
context = {"vm": vm_summary(name), "backups": items, "storage": storage_summary(), "active": active_operation(name), "schedule": schedule, "scheduled": backup_schedule.covers(schedule, name), **page_messages(request)}
|
||||
return core.render(request, "vm_backups.html", context)
|
||||
|
||||
|
||||
|
||||
@@ -333,6 +333,9 @@ hr { border: 0; border-top: 1px solid var(--border); margin: 20px 0; }
|
||||
.option input:checked + .option-body .option-check { border: 5px solid var(--brand); }
|
||||
.option-title { font-weight: 650; font-size: 14px; }
|
||||
.option-text { color: var(--text-2); font-size: 12.5px; }
|
||||
/* Расписание копий: день недели и список машин видны, только когда нужны (без :has — видны всегда). */
|
||||
.schedule-form:has(input[name=frequency][value=daily]:checked) [data-when=weekly],
|
||||
.schedule-form:has(input[name=machines][value=all]:checked) [data-when=chosen] { display: none; }
|
||||
.option-specs { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
||||
|
||||
/* segmented */
|
||||
|
||||
@@ -61,4 +61,6 @@
|
||||
<symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /><path d="M12 7v5l4 2" /></symbol>
|
||||
<symbol id="i-notebook-pen" viewBox="0 0 24 24"><path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4" /><path d="M2 6h4" /><path d="M2 10h4" /><path d="M2 14h4" /><path d="M2 18h4" /><path d="M21.378 5.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z" /></symbol>
|
||||
<symbol id="i-expand" viewBox="0 0 24 24"><path d="m15 15 6 6" /><path d="m15 9 6-6" /><path d="M21 16v5h-5" /><path d="M21 8V3h-5" /><path d="M3 16v5h5" /><path d="m3 21 6-6" /><path d="M3 8V3h5" /><path d="M9 9 3 3" /></symbol>
|
||||
<symbol id="i-calendar" viewBox="0 0 24 24"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /></symbol>
|
||||
<symbol id="i-calendar-days" viewBox="0 0 24 24"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /><path d="M8 13h.01" /><path d="M12 13h.01" /><path d="M16 13h.01" /><path d="M8 17h.01" /><path d="M12 17h.01" /><path d="M16 17h.01" /></symbol>
|
||||
</svg>
|
||||
|
||||
|
До Ширина: | Высота: | Размер: 12 KiB После Ширина: | Высота: | Размер: 12 KiB |
@@ -0,0 +1,109 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_ui.html" import icon, badge, alert %}
|
||||
|
||||
{% block page_header %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="eyebrow"><a href="/backups">{{ icon('arrow-left', 'icon-sm') }} Резервные копии</a></div>
|
||||
<h1>Расписание копий</h1>
|
||||
<p class="lead">Сервер сам делает копии машин в выбранное время и хранит только последние, чтобы копии не заняли весь диск.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block alerts %}
|
||||
{% if error %}{{ alert('danger', error) }}{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="grid grid-main">
|
||||
<article class="card">
|
||||
<form method="post" action="/backups/schedule" class="schedule-form">
|
||||
<div class="card-body form-grid">
|
||||
<label class="switch-row" style="cursor:pointer">
|
||||
<span class="switch-text"><strong>Делать копии автоматически</strong><span>Выключите, чтобы приостановить расписание. Уже сделанные копии останутся.</span></span>
|
||||
<input type="checkbox" name="enabled" value="1" {% if form.enabled %}checked{% endif %} style="width:20px;height:20px;accent-color:var(--brand)">
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Как часто</span>
|
||||
<div class="options" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr))">
|
||||
<label class="option">
|
||||
<input type="radio" name="frequency" value="daily" {% if form.frequency == 'daily' %}checked{% endif %}>
|
||||
<span class="option-body"><span class="option-top"><span class="option-icon">{{ icon('calendar') }}</span></span><span class="option-title">Каждый день</span><span class="option-text">Для машин, в которых что-то меняется каждый день: сайты, базы, файлы.</span></span>
|
||||
</label>
|
||||
<label class="option">
|
||||
<input type="radio" name="frequency" value="weekly" {% if form.frequency == 'weekly' %}checked{% endif %}>
|
||||
<span class="option-body"><span class="option-top"><span class="option-icon">{{ icon('calendar-days') }}</span></span><span class="option-title">Раз в неделю</span><span class="option-text">Если машины меняются редко или места под копии немного.</span></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-2" style="align-items:start">
|
||||
<div class="field" data-when="weekly">
|
||||
<label for="weekday">День недели</label>
|
||||
<select class="select" id="weekday" name="weekday">
|
||||
{% for day in weekdays %}<option value="{{ loop.index0 }}" {% if form.weekday == loop.index0 %}selected{% endif %}>{{ day }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="time">Время</label>
|
||||
<input class="input" id="time" type="time" name="time" value="{{ form.time }}" required>
|
||||
<span class="hint">По часам сервера. Лучше ночью, когда машинами никто не пользуется.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Какие машины копировать</span>
|
||||
<div class="options" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr))">
|
||||
<label class="option">
|
||||
<input type="radio" name="machines" value="all" {% if form.all_vms %}checked{% endif %}>
|
||||
<span class="option-body"><span class="option-title">Все машины</span><span class="option-text">Новые машины тоже попадут в копии сами.</span></span>
|
||||
</label>
|
||||
<label class="option">
|
||||
<input type="radio" name="machines" value="chosen" {% if not form.all_vms %}checked{% endif %}>
|
||||
<span class="option-body"><span class="option-title">Только отмеченные</span><span class="option-text">Например, без тестовых машин, которые не жалко.</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="stack-sm" data-when="chosen">
|
||||
{% for name in vm_names %}
|
||||
<label class="check"><input type="checkbox" name="vms" value="{{ name }}" {% if name in form.vms %}checked{% endif %}><span><strong>{{ name }}</strong></span></label>
|
||||
{% else %}
|
||||
<p class="subtle small">Машин на сервере пока нет.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" style="max-width:280px">
|
||||
<label for="keep">Сколько копий хранить</label>
|
||||
<input class="input" id="keep" type="number" name="keep" value="{{ form.keep }}" min="1" max="{{ keep_max }}" required>
|
||||
<span class="hint">Для каждой машины. Более старые автоматические копии удаляются сами.</span>
|
||||
</div>
|
||||
|
||||
{{ alert('info', 'На время копирования работающая машина аккуратно выключается, а потом запускается снова. Машины копируются по очереди, так что каждая стоит только пока копируется она сама.') }}
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<a class="btn" href="/backups">Отмена</a>
|
||||
<button type="submit" class="btn btn-primary">{{ icon('check') }}Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<aside class="stack">
|
||||
<article class="card">
|
||||
<div class="card-header"><h2>Как это работает</h2></div>
|
||||
<div class="checklist">
|
||||
{% set steps = [
|
||||
('Старые копии удаляются сами', 'После удачной новой копии у машины остаются последние копии по расписанию, сколько вы указали. Копии, сделанные вручную, не удаляются.'),
|
||||
('Неудачная копия ничего не удаляет', 'Если места не хватило или машина не выключилась, прежние копии остаются. Итог виден на странице «Резервные копии».'),
|
||||
('Если сервер был выключен', 'Пропущенную копию сервер сделает в течение двух часов после включения. Если включили позже, копия будет в следующий раз.'),
|
||||
('Обновления не мешают', 'Копии делает отдельная служба сервера, а не панель. Перезапуск панели их не прерывает.'),
|
||||
] %}
|
||||
{% for title, text in steps %}
|
||||
<div class="check-item success">{{ icon('circle-check') }}<div><strong>{{ title }}</strong><span>{{ text }}</span></div></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -39,6 +39,53 @@
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<article class="card mt-3" id="schedule">
|
||||
<div class="card-header">
|
||||
<div class="row">
|
||||
<span class="vm-avatar">{{ icon('clock') }}</span>
|
||||
<div>
|
||||
<h2>Автоматические копии {{ badge('включены', 'success', cls='hide-sm') if schedule.enabled else badge('выключены', 'neutral', False, cls='hide-sm') }}</h2>
|
||||
{% if schedule.enabled %}
|
||||
<p>{{ schedule.when|capitalize }} · {{ 'все машины' if schedule.all_vms else schedule.vms|join(', ') }} · хранить {{ schedule.keep }} {{ plural(schedule.keep, 'последнюю копию', 'последние копии', 'последних копий') }}</p>
|
||||
{% else %}
|
||||
<p>Сервер может сам копировать машины каждую ночь или раз в неделю и удалять старые копии.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn btn-sm" href="/backups/schedule">{{ icon('settings', 'icon-sm') }}{{ 'Изменить' if schedule.enabled else 'Настроить' }}</a>
|
||||
</div>
|
||||
{% if schedule.enabled or schedule.last %}
|
||||
<div class="list">
|
||||
{% if schedule.enabled %}
|
||||
<div class="list-item">
|
||||
<span class="list-icon brand">{{ icon('clock') }}</span>
|
||||
<div class="grow"><div class="title">Следующая копия: {{ schedule.next }}</div><div class="meta">Работающие машины на время копирования выключаются и потом запускаются снова</div></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if schedule.missed %}
|
||||
<div class="list-item">
|
||||
<span class="list-icon info">{{ icon('info') }}</span>
|
||||
<div class="grow"><div class="title">Копия {{ schedule.missed }} пропущена</div><div class="meta">В это время сервер был выключен, копия будет сделана в следующий раз</div></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if schedule.last %}
|
||||
{% set last = schedule.last %}
|
||||
<div class="list-item">
|
||||
<span class="list-icon {{ 'info' if last.running else 'danger' if last.failed else 'success' }}">{{ icon('activity' if last.running else 'triangle-alert' if last.failed else 'circle-check') }}</span>
|
||||
<div class="grow">
|
||||
<div class="title">
|
||||
{% if last.running %}Идёт копирование по расписанию {{ last.when }}: готово {{ last.count }}
|
||||
{% elif not last.count %}Запуск {{ last.when }}: машин для копирования не было
|
||||
{% else %}Запуск {{ last.when }}: скопировано {{ last.ok }} из {{ last.count }} {{ plural(last.count, 'машины', 'машин', 'машин') }}{% endif %}
|
||||
</div>
|
||||
{% for item in last.failed %}<div class="meta"><strong>{{ item.vm }}</strong>: {{ item.message }}</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
{% if groups %}
|
||||
<div class="stack mt-3">
|
||||
{% for g in groups %}
|
||||
@@ -75,7 +122,7 @@
|
||||
<article class="card">
|
||||
<div class="card-body stack-sm">
|
||||
<div class="row">{{ icon('info') }}<h3>Как это работает</h3></div>
|
||||
<p class="muted small">Работающая машина на время копирования аккуратно выключается и потом запускается снова. При восстановлении машина создаётся выключенной: под новым названием — с новыми сетевыми адресами, чтобы не мешать оригиналу; при замене — с прежними. Копии хранятся в папке <code>/var/lib/virtuality/backups</code>.</p>
|
||||
<p class="muted small">Работающая машина на время копирования аккуратно выключается и потом запускается снова. При восстановлении машина создаётся выключенной: под новым названием — с новыми сетевыми адресами, чтобы не мешать оригиналу; при замене — с прежними. Копии хранятся в папке <code>/var/lib/virtuality/backups</code>. Копии по расписанию сервер удаляет сам, оставляя последние. Копии, сделанные вручную, лежат, пока вы их не удалите.</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -70,6 +70,17 @@
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
<article class="card">
|
||||
<div class="card-body stack-sm">
|
||||
<div class="row">{{ icon('history') }}<h3>Автоматические копии</h3></div>
|
||||
{% if scheduled %}
|
||||
<p class="muted small">Машина копируется {{ schedule.when }}, сервер хранит {{ schedule.keep }} {{ plural(schedule.keep, 'последнюю копию', 'последние копии', 'последних копий') }}. Следующая: {{ schedule.next }}.</p>
|
||||
{% else %}
|
||||
<p class="muted small">Эта машина не копируется автоматически.</p>
|
||||
{% endif %}
|
||||
<a class="btn btn-sm" href="/backups/schedule">{{ icon('settings', 'icon-sm') }}Расписание копий</a>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card">
|
||||
<div class="card-body stack-sm">
|
||||
<div class="row">{{ icon('clock') }}<h3>Сколько это займёт</h3></div>
|
||||
|
||||
Ссылка в новой задаче
Block a user