diff --git a/VERSION b/VERSION index 9dbb0c0..943f9cb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.0 \ No newline at end of file +1.7.1 diff --git a/apps/internal-ui/src/features/updates/UpdateBanner.tsx b/apps/internal-ui/src/features/updates/UpdateBanner.tsx index bdd4409..7500a35 100644 --- a/apps/internal-ui/src/features/updates/UpdateBanner.tsx +++ b/apps/internal-ui/src/features/updates/UpdateBanner.tsx @@ -3,7 +3,9 @@ import { useState } from "react"; import { DecisionDialog } from "../../shared/DecisionDialog"; import { Icon, LogoSpinner } from "../../shared/icons"; import { Button } from "../../shared/ui-controls"; -import { installUpdate } from "./api"; +import { installUpdate, type UpdateInfo } from "./api"; +import { useUpdatesCardVisible } from "./cardPresence"; +import { STAGES, clockLabel, stageNumber, stageOf, stageProgress, useElapsed } from "./progress"; import { useUpdateInfo } from "./useUpdateInfo"; import { t } from "../../i18n"; @@ -12,47 +14,50 @@ import { t } from "../../i18n"; // что у установки демо-данных (ADR-CHATBALLS-0049). export function UpdateBanner({ enabled }: { enabled: boolean }) { - const { info, setInfo, installing, unreachable, finished } = useUpdateInfo(enabled); + const { info, setInfo, installing, unreachable, finished, forget, markPending, targetVersion, startedAt } = + useUpdateInfo(enabled); const [dismissed, setDismissed] = useState(null); const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); const [errorText, setErrorText] = useState(""); + const cardVisible = useUpdatesCardVisible(); - if (!enabled) return null; + // Карточка «Обновления» открыта — она и рассказывает всё то же самое. + if (!enabled || cardVisible) return null; async function install() { setBusy(true); setErrorText(""); + // Полоса хода появляется по нажатию, а не по ответу сервера: запрос идёт + // около секунды, и всё это время экран не должен выглядеть безучастным. + markPending(info?.latestVersion ?? null); try { setInfo(await installUpdate()); setConfirming(false); } catch (error) { + forget(); setErrorText(error instanceof Error ? error.message : t("common.could_not_complete_action")); } finally { setBusy(false); } } - const version = info?.install.version ?? info?.latestVersion ?? ""; + const version = targetVersion ?? info?.latestVersion ?? ""; if (installing) { - const stage = unreachable ? "restarting" : info?.install.message || "starting"; - const stageKey = (["downloading", "pulling", "restarting"].includes(stage) ? stage : "starting") as "downloading" | "pulling" | "restarting" | "starting"; return ( -
- - {t("updates.installing", { version })} {t(`updates.stage_${stageKey}`)} -
+ ); } - if (finished && info?.install.status === "DONE" && dismissed !== `done:${version}`) { + if (finished) { + const installed = info?.currentVersion ?? version; return (
- {t("updates.done", { version })} - - +
@@ -106,3 +111,44 @@ export function UpdateBanner({ enabled }: { enabled: boolean }) { ); } + +// Ход установки в полосе: место есть только на одну строку, поэтому шаги +// сворачиваются в тонкую линию с подписью «шаг N из M» и временем — то же +// знание, что и в списке шагов карточки «Обновления». + +function UpdateBannerProgress({ + info, + unreachable, + version, + startedAt, +}: { + info: UpdateInfo | null; + unreachable: boolean; + version: string; + startedAt: string | null; +}) { + const stage = stageOf(info, unreachable); + const elapsed = useElapsed(startedAt, true); + + return ( +
+ + + {t("updates.installing", { version })} {t(`updates.stage_${stage}`)} + +
+ ); +} diff --git a/apps/internal-ui/src/features/updates/UpdateProgress.tsx b/apps/internal-ui/src/features/updates/UpdateProgress.tsx new file mode 100644 index 0000000..faf8771 --- /dev/null +++ b/apps/internal-ui/src/features/updates/UpdateProgress.tsx @@ -0,0 +1,51 @@ +import { Icon, LogoSpinner } from "../../shared/icons"; +import type { UpdateInfo } from "./api"; +import { STAGES, clockLabel, stageNumber, stageOf, useElapsed } from "./progress"; +import { t } from "../../i18n"; + +// Ход установки списком шагов. Сколько времени займёт обновление, заранее не +// знает никто (образы тянутся с разной скоростью), поэтому полосы с процентами +// здесь нет: человеку показывается, какой шаг идёт сейчас, сколько их всего и +// сколько уже идёт установка. Этого достаточно, чтобы понять, что всё живо. + +export function UpdateProgress({ + info, + unreachable, + version, + startedAt, +}: { + info: UpdateInfo | null; + unreachable: boolean; + version: string; + startedAt: string | null; +}) { + const stage = stageOf(info, unreachable); + const current = stageNumber(stage); + const elapsed = useElapsed(startedAt, true); + + return ( +
+
+ + {t("updates.progress_title", { version })} + + {t("updates.progress_step", { step: String(current), total: String(STAGES.length) })} · {clockLabel(elapsed)} + +
+
    + {STAGES.map((item, index) => { + const state = index + 1 < current ? "is-done" : index + 1 === current ? "is-current" : "is-waiting"; + return ( +
  1. + + {t(`updates.step_${item}`)} +
  2. + ); + })} +
+

{t("updates.progress_hint")}

+
+ ); +} diff --git a/apps/internal-ui/src/features/updates/UpdatesCard.tsx b/apps/internal-ui/src/features/updates/UpdatesCard.tsx index d300a21..595fe89 100644 --- a/apps/internal-ui/src/features/updates/UpdatesCard.tsx +++ b/apps/internal-ui/src/features/updates/UpdatesCard.tsx @@ -1,19 +1,27 @@ import { useState } from "react"; +import { Icon } from "../../shared/icons"; import { Button } from "../../shared/ui-controls"; import { shortDateTime } from "../../shared/utils"; import { checkUpdates, installUpdate } from "./api"; +import { useUpdatesCardPresence } from "./cardPresence"; +import { UpdateProgress } from "./UpdateProgress"; import { useUpdateInfo } from "./useUpdateInfo"; import { t } from "../../i18n"; // Карточка «Обновления» в разделе «Платформа»: версия установки, последняя // версия на канале релизов, проверка и установка по кнопке. Сама установка -// идёт снаружи приложения (ADR-CHATBALLS-0049), здесь только её состояние. +// идёт снаружи приложения (ADR-CHATBALLS-0049), здесь только её состояние — +// но состояние подробное: обновление перезапускает сервисы, и всё это время +// человек должен видеть, что идёт и на каком оно шаге. export function UpdatesCard({ canManage }: { canManage: boolean }) { - const { info, setInfo, installing, unreachable } = useUpdateInfo(true); + const { info, setInfo, installing, unreachable, finished, forget, markPending, targetVersion, startedAt } = + useUpdateInfo(true); const [busy, setBusy] = useState(false); const [errorText, setErrorText] = useState(""); + // Пока карточка на экране, полоса наверху о том же не говорит. + useUpdatesCardPresence(); async function run(action: () => Promise) { setBusy(true); @@ -28,6 +36,13 @@ export function UpdatesCard({ canManage }: { canManage: boolean }) { } } + function install() { + // Прогресс появляется по нажатию, а не по ответу сервера: запрос на + // установку идёт секунду, и всё это время экран не должен молчать. + markPending(info?.latestVersion ?? null); + void run(installUpdate); + } + if (!info && !unreachable) return null; const latest = info?.latestVersion; @@ -61,20 +76,36 @@ export function UpdatesCard({ canManage }: { canManage: boolean }) { {info?.checkError &&
{t("updates.check_error", { error: info.checkError })}
} {errorText &&
{errorText}
} {installing && ( -

{t("updates.installing", { version: info?.install.version ?? latest ?? "" })} {t(`updates.stage_${unreachable ? "restarting" : (["downloading", "pulling", "restarting"].includes(info?.install.message ?? "") ? info?.install.message : "starting") as "downloading" | "pulling" | "restarting" | "starting"}`)}

+ + )} + {finished && ( +
+ +
+ {t("updates.done_title", { version: info?.currentVersion ?? targetVersion ?? "" })} + {t("updates.done_hint")} +
+ + +
)} {!installing && status === "FAILED" && info?.install.version && (
{t("updates.failed", { version: info.install.version })}{info.install.message ? `: ${info.install.message}` : ""}
)} - {!installing && info?.available && ( + {!installing && !finished && info?.available && (
{canManage - ? + ? : {t("updates.available_title", { version: latest ?? "" })}} {!info.updaterOnline && {t("updates.updater_offline")}}
)} - {!installing && info && !info.available && latest && !info.checkError && ( + {!installing && !finished && info && !info.available && latest && !info.checkError && (

{t("updates.up_to_date")}

)} diff --git a/apps/internal-ui/src/features/updates/cardPresence.ts b/apps/internal-ui/src/features/updates/cardPresence.ts new file mode 100644 index 0000000..cb0575d --- /dev/null +++ b/apps/internal-ui/src/features/updates/cardPresence.ts @@ -0,0 +1,33 @@ +import { useEffect, useSyncExternalStore } from "react"; + +// Полоса поверх рабочей области и карточка «Обновления» рассказывают одно и то +// же. Когда карточка на экране, полоса молчит: два сообщения об одном событии +// рядом — это не «заметнее», а шум, и человек перестаёт читать оба. + +let mounted = 0; +const listeners = new Set<() => void>(); + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** Карточка на экране, пока смонтирована. */ +export function useUpdatesCardPresence(): void { + useEffect(() => { + mounted += 1; + listeners.forEach((listener) => listener()); + return () => { + mounted -= 1; + listeners.forEach((listener) => listener()); + }; + }, []); +} + +export function useUpdatesCardVisible(): boolean { + return useSyncExternalStore( + subscribe, + () => mounted > 0, + () => false, + ); +} diff --git a/apps/internal-ui/src/features/updates/progress.ts b/apps/internal-ui/src/features/updates/progress.ts new file mode 100644 index 0000000..7c475bd --- /dev/null +++ b/apps/internal-ui/src/features/updates/progress.ts @@ -0,0 +1,60 @@ +import { useEffect, useState } from "react"; + +import type { UpdateInfo } from "./api"; + +// Ход установки для интерфейса. Сервис обновления пишет стадию словом в +// status.json (starting → downloading → pulling → restarting → updated), и это +// единственное, что о ходе вообще известно: процента у docker pull здесь нет. +// Поэтому прогресс показывается стадиями — человек видит, что именно идёт +// сейчас и сколько таких шагов осталось, а не безымянную полосу. + +export const STAGES = ["starting", "downloading", "pulling", "restarting"] as const; + +export type Stage = (typeof STAGES)[number]; + +function isStage(value: string): value is Stage { + return (STAGES as readonly string[]).includes(value); +} + +/** + * Текущая стадия. + * + * Молчание бэкенда во время установки — это и есть перезапуск: он сам входит в + * перезапускаемый стек и последнюю стадию сообщить уже не может. + */ +export function stageOf(info: UpdateInfo | null, unreachable: boolean): Stage { + if (unreachable) return "restarting"; + const message = info?.install.message ?? ""; + return isStage(message) ? message : "starting"; +} + +export function stageNumber(stage: Stage): number { + return STAGES.indexOf(stage) + 1; +} + +/** Доля выполненного: последняя стадия не доводится до края, пока не готово. */ +export function stageProgress(stage: Stage): number { + return Math.round(((stageNumber(stage) - 0.35) / STAGES.length) * 100); +} + +/** Секунды с момента запуска установки; тикает раз в секунду. */ +export function useElapsed(startedAt: string | null, running: boolean): number { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!running) return undefined; + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [running]); + + if (!startedAt) return 0; + const started = new Date(startedAt).getTime(); + if (Number.isNaN(started)) return 0; + return Math.max(0, Math.floor((now - started) / 1000)); +} + +/** м:сс — установка идёт минуты, часы здесь не нужны. */ +export function clockLabel(seconds: number): string { + const minutes = Math.floor(seconds / 60); + return `${minutes}:${String(seconds % 60).padStart(2, "0")}`; +} diff --git a/apps/internal-ui/src/features/updates/styles.css b/apps/internal-ui/src/features/updates/styles.css new file mode 100644 index 0000000..2695feb --- /dev/null +++ b/apps/internal-ui/src/features/updates/styles.css @@ -0,0 +1,177 @@ +/* Ход обновления установки. Обновление перезапускает сервисы и длится минуты, + поэтому его состояние показывается не строкой в общем потоке, а отдельным + блоком: видно, какой шаг идёт, сколько их всего и сколько уже длится. */ + +.update-progress { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 14px; + padding: 14px 16px; + border: 1px solid var(--primary-border); + border-radius: var(--radius-xl); + background: var(--primary-bg); +} + +.update-progress-head { + display: flex; + align-items: center; + gap: 9px; + color: var(--primary-text); + font-size: 13.5px; +} + +.update-progress-head strong { + flex: 1; + min-width: 0; + font-weight: 600; +} + +.update-progress-clock { + flex: none; + color: var(--text-secondary); + font-size: 12.5px; + font-variant-numeric: tabular-nums; +} + +.update-progress-steps { + display: flex; + flex-direction: column; + gap: 7px; + margin: 0; + padding: 0; + list-style: none; +} + +.update-progress-step { + display: flex; + align-items: center; + gap: 9px; + color: var(--text-secondary); + font-size: 13px; +} + +/* Кружок шага: пустой — впереди, с пульсом — идёт сейчас, с галкой — пройден. */ +.update-progress-mark { + display: flex; + width: 16px; + height: 16px; + flex: none; + align-items: center; + justify-content: center; + border: 1.5px solid var(--n-6); + border-radius: 50%; + background: var(--surface-card); + color: #ffffff; +} + +.update-progress-step.is-done { + color: var(--text-body); +} + +.update-progress-step.is-done .update-progress-mark { + border-color: var(--success); + background: var(--success); +} + +.update-progress-step.is-current { + color: var(--primary-text); + font-weight: 600; +} + +.update-progress-step.is-current .update-progress-mark { + border-color: var(--primary); + box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent); + animation: update-step-pulse 1.6s ease-out infinite; +} + +.update-progress-step.is-current .update-progress-mark::after { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--primary); + content: ""; +} + +@keyframes update-step-pulse { + 70% { + box-shadow: 0 0 0 5px transparent; + } + 100% { + box-shadow: 0 0 0 0 transparent; + } +} + +@media (prefers-reduced-motion: reduce) { + .update-progress-step.is-current .update-progress-mark { + animation: none; + } +} + +.update-progress-hint { + margin: 0; + color: var(--text-secondary); + font-size: 12.5px; +} + +/* Итог установки: версия встала, а страница всё ещё на прошлой. */ +.update-done { + display: flex; + align-items: center; + gap: 11px; + margin-top: 14px; + padding: 12px 14px; + border: 1px solid var(--success-border); + border-radius: var(--radius-xl); + background: var(--success-bg); + color: var(--success-text); +} + +.update-done > svg { + flex: none; +} + +.update-done > div { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 2px; +} + +.update-done strong { + font-size: 13.5px; + font-weight: 600; +} + +.update-done small { + color: var(--text-secondary); + font-size: 12.5px; +} + +/* В полосе поверх рабочей области места на список шагов нет — то же знание + сворачивается в тонкую линию и подпись «шаг N из M». */ +.update-banner-bar { + display: block; + overflow: hidden; + width: 132px; + height: 4px; + flex: none; + border-radius: var(--radius-pill); + background: color-mix(in srgb, currentColor 22%, transparent); +} + +.update-banner-bar > span { + display: block; + height: 100%; + border-radius: inherit; + background: currentColor; + transition: width 0.4s ease; +} + +.update-banner-step { + flex: none; + font-size: 12.5px; + font-variant-numeric: tabular-nums; + opacity: 0.8; +} diff --git a/apps/internal-ui/src/features/updates/useUpdateInfo.ts b/apps/internal-ui/src/features/updates/useUpdateInfo.ts index 3eaeb00..4722207 100644 --- a/apps/internal-ui/src/features/updates/useUpdateInfo.ts +++ b/apps/internal-ui/src/features/updates/useUpdateInfo.ts @@ -2,24 +2,71 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { fetchUpdate, installInProgress, type UpdateInfo } from "./api"; -// Состояние обновления с опросом, пока идёт установка. Во время перезапуска -// бэкенд не отвечает — это не ошибка, а стадия: запросы продолжают уходить, -// пока новая версия не ответит статусом DONE или FAILED. +// Состояние обновления с опросом, пока идёт установка. +// +// Обновление перезапускает тот самый бэкенд, который о нём и рассказывает, — +// поэтому «не отвечает» здесь не ошибка, а стадия. По той же причине признак +// «идёт установка» нельзя держать только в памяти вкладки: страница может +// перезагрузиться (руками или потому что человек ждёт) ровно тогда, когда +// сервисы лежат, и вернуться в интерфейс, где про обновление нет ни слова. +// Метка живёт в sessionStorage до подтверждения от поднявшейся версии. const POLL_INTERVAL_MS = 3000; +const MARK_KEY = "chatballs.update.pending"; +// Обновление занимает минуты; за час оно либо кончилось, либо не кончится уже +// никогда — забытая метка не должна вечно показывать мнимую установку. +const MARK_TTL_MS = 60 * 60 * 1000; + +type Mark = { version: string | null; at: number }; + +function readMark(): Mark | null { + try { + const raw = sessionStorage.getItem(MARK_KEY); + if (!raw) return null; + const mark = JSON.parse(raw) as Mark; + if (typeof mark?.at !== "number" || Date.now() - mark.at > MARK_TTL_MS) { + sessionStorage.removeItem(MARK_KEY); + return null; + } + return mark; + } catch { + return null; + } +} + +function writeMark(mark: Mark): void { + try { + sessionStorage.setItem(MARK_KEY, JSON.stringify(mark)); + } catch { + /* приватное окно — переживём и без метки */ + } +} + +function clearMark(): void { + try { + sessionStorage.removeItem(MARK_KEY); + } catch { + /* см. выше */ + } +} export function useUpdateInfo(enabled: boolean) { const [info, setInfo] = useState(null); const [unreachable, setUnreachable] = useState(false); - const wasInstalling = useRef(false); + // Метка переживает перезагрузку страницы, состояние — нет; нужны обе. + const [pending, setPending] = useState(() => readMark()); + // Версия, которую бэкенд назвал при загрузке этой страницы. Если она потом + // изменилась — в браузере лежит код прошлой версии, и его надо перечитать. + const loadedVersion = useRef(null); const load = useCallback(async () => { try { const next = await fetchUpdate(); + if (loadedVersion.current === null) loadedVersion.current = next.currentVersion; setInfo(next); setUnreachable(false); } catch { - // Нет прав, сеть моргнула или бэкенд перезапускается. + // Нет прав, сеть моргнула или бэкенд перезапускается — различить нельзя. setUnreachable(true); } }, []); @@ -28,8 +75,39 @@ export function useUpdateInfo(enabled: boolean) { if (enabled) void load(); }, [enabled, load]); - const installing = installInProgress(info) || (unreachable && wasInstalling.current); - if (installInProgress(info)) wasInstalling.current = true; + const running = installInProgress(info); + // Пока бэкенд молчит, верим метке: молчит он как раз потому, что обновляется. + const installing = running || (pending !== null && (unreachable || info === null)); + + useEffect(() => { + if (running && info) { + // Начало берётся от сервера: после перезагрузки страницы бэкенд уже может + // не отвечать, и счётчик времени иначе пошёл бы заново. + const requested = Date.parse(info.install.requestedAt ?? ""); + const mark = { version: info.install.version, at: Number.isNaN(requested) ? Date.now() : requested }; + writeMark(mark); + setPending(mark); + } + }, [running, info]); + + // Ответ поднявшегося бэкенда — единственное надёжное «всё кончилось». + const settled = pending !== null && !unreachable && info !== null && !running; + // Страница уже открыта в той версии, что назвал бэкенд, — значит код в + // браузере свежий и перечитывать его незачем. + const pageIsCurrent = loadedVersion.current !== null && info?.currentVersion === loadedVersion.current; + // Единственный итог, который надо держать на экране до действия человека: + // версия встала, а в браузере всё ещё код прошлой. Об остальном (сорвалось, + // страницу уже перезагрузили) рассказывает сам ответ, метка больше не нужна. + const needsReload = settled && info?.install.status === "DONE" && !pageIsCurrent; + + const forget = useCallback(() => { + clearMark(); + setPending(null); + }, []); + + useEffect(() => { + if (settled && !needsReload) forget(); + }, [settled, needsReload, forget]); useEffect(() => { if (!enabled || !installing) return undefined; @@ -37,5 +115,28 @@ export function useUpdateInfo(enabled: boolean) { return () => window.clearInterval(timer); }, [enabled, installing, load]); - return { info, setInfo, reload: load, installing, unreachable, finished: wasInstalling.current && !installing }; + return { + info, + setInfo, + reload: load, + installing, + unreachable, + /** Установка завершилась, а страница всё ещё на прошлой версии. */ + finished: needsReload, + /** Убрать сообщение о результате: показано и больше не нужно. */ + forget, + /** + * Пометить установку запущенной, не дожидаясь ответа сервера: прогресс + * обязан появиться в тот же миг, когда нажали кнопку. + */ + markPending: useCallback((version: string | null) => { + const mark = { version, at: Date.now() }; + writeMark(mark); + setPending(mark); + }, []), + /** Версия, до которой идёт установка, даже когда бэкенд молчит. */ + targetVersion: info?.install.version ?? pending?.version ?? null, + /** Момент запуска установки — от него считается прошедшее время. */ + startedAt: info?.install.requestedAt ?? (pending ? new Date(pending.at).toISOString() : null), + }; } diff --git a/apps/internal-ui/src/i18n/en.ts b/apps/internal-ui/src/i18n/en.ts index 8c394bc..b20a1b0 100644 --- a/apps/internal-ui/src/i18n/en.ts +++ b/apps/internal-ui/src/i18n/en.ts @@ -1288,6 +1288,16 @@ export const en: Record = { "updates.stage_downloading": "downloading the release file", "updates.stage_pulling": "pulling images", "updates.stage_restarting": "restarting services", + "updates.step_starting": "Starting the update", + "updates.step_downloading": "Downloading the release file", + "updates.step_pulling": "Pulling the images", + "updates.step_restarting": "Restarting the services", + "updates.progress_title": "Updating to {version}", + "updates.progress_step": "Step {step} of {total}", + "updates.progress_hint": "On the last step the application stops responding for a short while — that is expected. You can reload the page: the progress is not lost.", + "updates.done_title": "Version {version} is installed", + "updates.done_hint": "This page is still running the previous version. Reload it to use the new one.", + "updates.reload": "Reload the page", "updates.done": "Version {version} is installed. Reload the page.", "updates.failed": "Update to {version} failed", "updates.confirm_title": "Update to {version}?", diff --git a/apps/internal-ui/src/i18n/ru.ts b/apps/internal-ui/src/i18n/ru.ts index 6c7b60a..4c4a242 100644 --- a/apps/internal-ui/src/i18n/ru.ts +++ b/apps/internal-ui/src/i18n/ru.ts @@ -1289,6 +1289,16 @@ export const ru = { "updates.stage_downloading": "загрузка файла релиза", "updates.stage_pulling": "загрузка образов", "updates.stage_restarting": "перезапуск сервисов", + "updates.step_starting": "Запуск обновления", + "updates.step_downloading": "Загрузка файла релиза", + "updates.step_pulling": "Загрузка образов", + "updates.step_restarting": "Перезапуск сервисов", + "updates.progress_title": "Обновление до {version}", + "updates.progress_step": "Шаг {step} из {total}", + "updates.progress_hint": "На последнем шаге приложение ненадолго перестанет отвечать — так и должно быть. Страницу можно перезагрузить: ход обновления не потеряется.", + "updates.done_title": "Установлена версия {version}", + "updates.done_hint": "Страница открыта в прошлой версии. Обновите её, чтобы работать в новой.", + "updates.reload": "Обновить страницу", "updates.done": "Установлена версия {version}. Обновите страницу.", "updates.failed": "Обновление до {version} не удалось", "updates.confirm_title": "Обновить до {version}?", diff --git a/apps/internal-ui/src/styles.css b/apps/internal-ui/src/styles.css index e098cf9..14d75c2 100644 --- a/apps/internal-ui/src/styles.css +++ b/apps/internal-ui/src/styles.css @@ -28,4 +28,5 @@ @import "./features/settings/styles.css"; @import "./features/onboarding/styles.css"; @import "./features/notifications/styles.css"; +@import "./features/updates/styles.css"; @import "./shared/state.css"; diff --git a/tests/e2e/internal-ui.spec.ts b/tests/e2e/internal-ui.spec.ts index 0292f43..9ff3665 100644 --- a/tests/e2e/internal-ui.spec.ts +++ b/tests/e2e/internal-ui.spec.ts @@ -423,6 +423,43 @@ test("администратор установки видит баннер о await expect(page.locator(".update-banner")).toContainText("Обновление до 1.5.0"); }); +// Обновление перезапускает сам бэкенд, который о нём и рассказывает, поэтому +// ход установки обязан пережить и молчание сервера, и перезагрузку страницы: +// иначе человек остаётся без единого признака того, что обновление идёт. +test("ход установки показывается по шагам и переживает перезагрузку страницы", async ({ page }) => { + await mockSession(page, OWNER_IDENTITY); + await mockInstance(page); + await mockEmployees(page); + await page.route("**/api/v1/instance/settings/", (route) => route.fulfill({ + json: { instance: { + publicHost: "support.example.ru", publicScheme: "https", publicUrl: "https://support.example.ru", + updatedAt: null, defaultLanguage: "ru", languages: [{ code: "ru", label: "Русский" }], + email: { host: "", port: 587, user: "", useTls: true, from: "", updatedAt: null }, + turn: { urls: [], ttlSeconds: 3600, secretReady: false }, + } }, + })); + let backendDown = false; + await page.route("**/api/v1/instance/update/", (route) => backendDown ? route.abort() : route.fulfill({ + json: { update: { + currentVersion: "1.4.0", latestVersion: "1.5.0", latestName: "v1.5.0", latestNotes: "", latestPublishedAt: null, + latestPageUrl: "", available: true, checkedAt: null, checkError: "", updaterOnline: true, + install: { version: "1.5.0", status: "RUNNING", message: "pulling", requestedAt: "2026-09-14T09:00:00Z", updatedAt: null }, + } }, + })); + + await page.goto(`/organizations/${ORGANIZATION_PUBLIC_ID}/settings/platform`); + + const progress = page.locator(".update-progress"); + await expect(progress).toContainText("Обновление до 1.5.0"); + await expect(progress).toContainText("Шаг 3 из 4"); + await expect(progress.locator(".update-progress-step.is-current")).toHaveText("Загрузка образов"); + + // Сервисы перезапускаются, бэкенд молчит, человек перезагружает страницу. + backendDown = true; + await page.reload(); + await expect(progress.locator(".update-progress-step.is-current")).toHaveText("Перезапуск сервисов"); +}); + test("с несколькими организациями вход открывает первую, переключатель ведёт во вторую", async ({ page }) => { const secondMembership = membershipFor("OWNER", { id: 3,