fix(update): recover from stalled panel self-update instead of hanging at 80%
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Backend Tests / pytest (Python 3.12) (push) Has been cancelled

The panel update status lived only in-memory and was cleared solely by the
host watchdog restarting the container. If the rebuild failed mid-way (e.g. the
build OOMs the host when no swap is configured), the container never restarted
and the UI stayed at "restarting - 80%" forever.

- update_service: get_update_status() now self-heals — after UPDATE_TIMEOUT_SECONDS
  (default 900s) of "is_updating" it transitions to a terminal "failed" state with
  a clear error pointing at the host update service / logs.
- use-settings: poll update status while the server reports is_updating (not only
  right after the click in the same tab), so reloading a stuck page keeps refreshing.
- SettingsPage: banner now renders the "failed" state with the error message.
- update_host.sh: build images sequentially (app, then frontend) to lower peak
  memory and avoid the OOM that killed the build mid-run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
markmorado
2026-07-23 22:57:41 +05:00
parent ceec83d2fb
commit e67f6b7249
6 changed files with 85 additions and 14 deletions

View File

@@ -26,6 +26,13 @@ GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN', None)
# Отключить проверку обновлений (для приватных репозиториев без токена)
DISABLE_UPDATE_CHECK = os.environ.get('DISABLE_UPDATE_CHECK', 'false').lower() == 'true'
# Максимальное время «идёт обновление» (сек). Успешное обновление перезапускает
# сам контейнер app, обнуляя in-memory статус, поэтому если процесс живёт с
# is_updating=True дольше этого срока — хостовый watchdog не смог пересобрать/
# перезапустить панель (OOM при сборке, ребут хоста, сервис лёг). По истечении
# статус переводится в терминальный "failed", чтобы UI не висел на 80% вечно.
UPDATE_TIMEOUT_SECONDS = int(os.environ.get('UPDATE_TIMEOUT_SECONDS', '900'))
# Статус обновления (in-memory)
update_status = {
"is_updating": False,
@@ -353,7 +360,41 @@ async def check_for_updates() -> Dict[str, Any]:
def get_update_status() -> Dict[str, Any]:
"""Получить текущий статус обновления"""
"""Получить текущий статус обновления.
Самовосстановление: если обновление «идёт» дольше UPDATE_TIMEOUT_SECONDS,
значит контейнер так и не был перезапущен (успешное обновление перезапускает
процесс и обнуляет статус). Переводим в терминальный "failed" с понятной
ошибкой — иначе UI застревает на «restarting — 80%» навсегда.
"""
global update_status
if update_status["is_updating"] and update_status.get("started_at"):
try:
started = datetime.fromisoformat(update_status["started_at"])
elapsed = (datetime.now() - started).total_seconds()
if elapsed > UPDATE_TIMEOUT_SECONDS:
update_status = {
"is_updating": False,
"started_at": update_status["started_at"],
"stage": "failed",
"progress": update_status.get("progress", 0),
"error": (
f"Update timed out after {UPDATE_TIMEOUT_SECONDS // 60} min: "
"the panel was not rebuilt/restarted. The host update service "
"(pvemanager-update.service) likely failed — check "
"logs/update_host.log on the host (a common cause is the build "
"running out of memory when no swap is configured)."
),
"completed": False,
}
logger.warning(
"Update marked as failed: exceeded %ss timeout without restart",
UPDATE_TIMEOUT_SECONDS,
)
except (ValueError, TypeError) as e:
logger.warning(f"Could not parse update started_at: {e}")
return update_status.copy()

View File

@@ -668,11 +668,24 @@ function AboutTab() {
</div>
)}
{/* Update in progress banner */}
{status?.is_updating && (
<div className="rounded-md border border-warning/40 bg-warning/10 p-3 text-sm">
<p className="font-medium">{t('settings.update_in_progress')}</p>
<p className="text-xs text-muted-foreground">{status.stage} — {status.progress}%</p>
{/* Update in progress / failed banner */}
{status && (status.is_updating || status.stage === 'failed' || status.error) && (
<div className={`rounded-md border p-3 text-sm ${
status.stage === 'failed' || status.error
? 'border-destructive/40 bg-destructive/10'
: 'border-warning/40 bg-warning/10'
}`}>
{status.stage === 'failed' || status.error ? (
<>
<p className="font-medium text-destructive">{t('settings.update_failed')}</p>
{status.error && <p className="mt-1 text-xs text-muted-foreground">{status.error}</p>}
</>
) : (
<>
<p className="font-medium">{t('settings.update_in_progress')}</p>
<p className="text-xs text-muted-foreground">{status.stage} — {status.progress}%</p>
</>
)}
<Button size="sm" variant="ghost" className="mt-2" onClick={() => resetUpdate.mutate()} disabled={resetUpdate.isPending}>
{t('settings.update_reset')}
</Button>

View File

@@ -217,7 +217,12 @@ export function useUpdateStatus(poll = false) {
return useQuery({
queryKey: settingsKeys.updateStatus,
queryFn: () => apiClient.get<UpdateStatus>('/settings/api/updates/status'),
refetchInterval: poll ? 3000 : false,
// Poll while an update is actually running (server-driven), so a page reload
// on a stuck update keeps refreshing until it succeeds or times out — not
// only right after the user clicked "Update" in this tab. `poll` starts the
// loop immediately after clicking, before the first status arrives.
refetchInterval: (query) =>
poll || query.state.data?.is_updating ? 3000 : false,
});
}

View File

@@ -1324,6 +1324,7 @@
"update_confirm": "Start the update? The panel will pull the latest version, rebuild and restart — it will be briefly unavailable.",
"update_started": "Update started",
"update_in_progress": "Update in progress",
"update_failed": "Update failed",
"update_reset": "Reset status",
"send_test": "Send test",
"test_email_placeholder": "test@example.com",

View File

@@ -1324,6 +1324,7 @@
"update_confirm": "Запустить обновление? Панель скачает последнюю версию, пересоберётся и перезапустится — на короткое время будет недоступна.",
"update_started": "Обновление запущено",
"update_in_progress": "Идёт обновление",
"update_failed": "Обновление не удалось",
"update_reset": "Сбросить статус",
"send_test": "Тест",
"test_email_placeholder": "test@example.com",

View File

@@ -92,13 +92,23 @@ while true; do
# пересобираются, но без повторного apt/pip/npm на каждый деплой.
# --pull обновляет базовые образы. --no-cache НЕ используем: он молотил
# сборку с нуля каждый пуш и за пару недель раздул build cache до десятков ГБ.
log "[2/4] docker compose build --pull app frontend..."
if $COMPOSE_CMD build --pull app frontend >> "$LOG_FILE" 2>&1; then
log "[2/4] build OK"
else
log "[2/4] ERROR: build failed — стек НЕ трогаем, остаётся на старой версии"
continue
fi
# Собираем ПОСЛЕДОВАТЕЛЬНО (по одному сервису), а не разом: параллельная
# сборка app (компиляция pydantic-core/pynacl) и frontend (vite/npm —
# главный пожиратель RAM) даёт пиковую нагрузку, которая на хостах без
# swap уводит машину в OOM/ребут посреди сборки. Один сервис за раз —
# заметно ниже пик памяти при том же кэшировании слоёв.
log "[2/4] docker compose build --pull (sequential: app, then frontend)..."
build_ok=1
for svc in app frontend; do
log "[2/4] building $svc..."
if ! $COMPOSE_CMD build --pull "$svc" >> "$LOG_FILE" 2>&1; then
log "[2/4] ERROR: build of $svc failed — стек НЕ трогаем, остаётся на старой версии"
build_ok=0
break
fi
done
[ "$build_ok" -eq 1 ] || continue
log "[2/4] build OK"
# ── Step 3: docker compose down ───────────────────────────────────────
# Быстрый: образы уже готовы, простой минимален.