fix(update): recover from stalled panel self-update instead of hanging at 80%
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:
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1324,6 +1324,7 @@
|
||||
"update_confirm": "Запустить обновление? Панель скачает последнюю версию, пересоберётся и перезапустится — на короткое время будет недоступна.",
|
||||
"update_started": "Обновление запущено",
|
||||
"update_in_progress": "Идёт обновление",
|
||||
"update_failed": "Обновление не удалось",
|
||||
"update_reset": "Сбросить статус",
|
||||
"send_test": "Тест",
|
||||
"test_email_placeholder": "test@example.com",
|
||||
|
||||
@@ -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 ───────────────────────────────────────
|
||||
# Быстрый: образы уже готовы, простой минимален.
|
||||
|
||||
Reference in New Issue
Block a user