mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
✨ feat(updates): ход обновления виден в интерфейсе
Установка перезапускает сам бэкенд, который о ней и рассказывает, — и до сих пор человек об этом не узнавал: признак «идёт установка» жил в useRef и исчезал при перезагрузке страницы, прогресса не было вовсе (одна строка текста), а готовый результат карточка «Обновления» не показывала совсем. Запустив обновление, человек оставался перед экраном без единого признака того, что что-то происходит. Теперь ход установки — отдельный блок: шаги (запуск, файл релиза, образы, перезапуск) с отметками пройденного, текущий шаг с пульсом, номер шага и время с начала. Молчание бэкенда — это и есть перезапуск, так он и подписан. Метка установки живёт в sessionStorage, поэтому перезагрузка страницы (в том числе во время лежащих сервисов) ход не теряет, а время считается от момента запроса, а не от перезагрузки. Когда версия встала, карточка и полоса показывают итог с кнопкой «Обновить страницу» — и молчат, если страница уже открыта в новой версии. Прогресс появляется по нажатию кнопки, а не по ответу сервера. Полоса наверху молчит, пока карточка «Обновления» на экране. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3c01486aa3
commit
27091c0234
12 files changed
+586
-29
No files matched your search
@@ -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<string | null>(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 (
|
||||
<div className="demo-install-banner update-banner">
|
||||
<LogoSpinner size={17} />
|
||||
<span>{t("updates.installing", { version })} {t(`updates.stage_${stageKey}`)}</span>
|
||||
</div>
|
||||
<UpdateBannerProgress info={info} unreachable={unreachable} version={version} startedAt={startedAt} />
|
||||
);
|
||||
}
|
||||
|
||||
if (finished && info?.install.status === "DONE" && dismissed !== `done:${version}`) {
|
||||
if (finished) {
|
||||
const installed = info?.currentVersion ?? version;
|
||||
return (
|
||||
<div className="demo-install-banner update-banner is-done">
|
||||
<Icon name="check" size={15} strokeWidth={2.2} />
|
||||
<span>{t("updates.done", { version })}</span>
|
||||
<button type="button" onClick={() => window.location.reload()}>{t("common.show")}</button>
|
||||
<button className="demo-install-close" type="button" onClick={() => setDismissed(`done:${version}`)} aria-label={t("common.hide")}>
|
||||
<span>{t("updates.done", { version: installed })}</span>
|
||||
<button type="button" onClick={() => window.location.reload()}>{t("updates.reload")}</button>
|
||||
<button className="demo-install-close" type="button" onClick={forget} aria-label={t("common.hide")}>
|
||||
<Icon name="close" size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div
|
||||
className="demo-install-banner update-banner"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={STAGES.length}
|
||||
aria-valuenow={stageNumber(stage)}
|
||||
aria-valuetext={`${t("updates.installing", { version })} ${t(`updates.stage_${stage}`)}`}
|
||||
>
|
||||
<LogoSpinner size={17} />
|
||||
<span>
|
||||
{t("updates.installing", { version })} {t(`updates.stage_${stage}`)}
|
||||
</span>
|
||||
<span className="update-banner-bar" aria-hidden="true">
|
||||
<span style={{ width: `${stageProgress(stage)}%` }} />
|
||||
</span>
|
||||
<span className="update-banner-step">
|
||||
{t("updates.progress_step", { step: String(stageNumber(stage)), total: String(STAGES.length) })} · {clockLabel(elapsed)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="update-progress" role="status" aria-live="polite">
|
||||
<div className="update-progress-head">
|
||||
<LogoSpinner size={18} />
|
||||
<strong>{t("updates.progress_title", { version })}</strong>
|
||||
<span className="update-progress-clock">
|
||||
{t("updates.progress_step", { step: String(current), total: String(STAGES.length) })} · {clockLabel(elapsed)}
|
||||
</span>
|
||||
</div>
|
||||
<ol className="update-progress-steps">
|
||||
{STAGES.map((item, index) => {
|
||||
const state = index + 1 < current ? "is-done" : index + 1 === current ? "is-current" : "is-waiting";
|
||||
return (
|
||||
<li key={item} className={`update-progress-step ${state}`}>
|
||||
<span className="update-progress-mark" aria-hidden="true">
|
||||
{state === "is-done" && <Icon name="check" size={11} strokeWidth={2.6} />}
|
||||
</span>
|
||||
<span>{t(`updates.step_${item}`)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<p className="update-progress-hint">{t("updates.progress_hint")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof info>) {
|
||||
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 && <div className="settings-section-error">{t("updates.check_error", { error: info.checkError })}</div>}
|
||||
{errorText && <div className="settings-section-error">{errorText}</div>}
|
||||
{installing && (
|
||||
<p className="settings-card-note">{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"}`)}</p>
|
||||
<UpdateProgress
|
||||
info={info}
|
||||
unreachable={unreachable}
|
||||
version={targetVersion ?? latest ?? ""}
|
||||
startedAt={startedAt}
|
||||
/>
|
||||
)}
|
||||
{finished && (
|
||||
<div className="update-done" role="status">
|
||||
<Icon name="check" size={15} strokeWidth={2.2} />
|
||||
<div>
|
||||
<strong>{t("updates.done_title", { version: info?.currentVersion ?? targetVersion ?? "" })}</strong>
|
||||
<small>{t("updates.done_hint")}</small>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => window.location.reload()}>{t("updates.reload")}</Button>
|
||||
<Button variant="secondary" onClick={forget}>{t("common.hide")}</Button>
|
||||
</div>
|
||||
)}
|
||||
{!installing && status === "FAILED" && info?.install.version && (
|
||||
<div className="settings-section-error">{t("updates.failed", { version: info.install.version })}{info.install.message ? `: ${info.install.message}` : ""}</div>
|
||||
)}
|
||||
{!installing && info?.available && (
|
||||
{!installing && !finished && info?.available && (
|
||||
<div className="settings-card-actions">
|
||||
{canManage
|
||||
? <Button variant="primary" disabled={busy || !info.updaterOnline} onClick={() => void run(installUpdate)}>{t("updates.install_version", { version: latest ?? "" })}</Button>
|
||||
? <Button variant="primary" disabled={busy || !info.updaterOnline} onClick={install}>{t("updates.install_version", { version: latest ?? "" })}</Button>
|
||||
: <span className="settings-card-note">{t("updates.available_title", { version: latest ?? "" })}</span>}
|
||||
{!info.updaterOnline && <span className="settings-card-note">{t("updates.updater_offline")}</span>}
|
||||
</div>
|
||||
)}
|
||||
{!installing && info && !info.available && latest && !info.checkError && (
|
||||
{!installing && !finished && info && !info.available && latest && !info.checkError && (
|
||||
<p className="settings-card-note">{t("updates.up_to_date")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<UpdateInfo | null>(null);
|
||||
const [unreachable, setUnreachable] = useState(false);
|
||||
const wasInstalling = useRef(false);
|
||||
// Метка переживает перезагрузку страницы, состояние — нет; нужны обе.
|
||||
const [pending, setPending] = useState<Mark | null>(() => readMark());
|
||||
// Версия, которую бэкенд назвал при загрузке этой страницы. Если она потом
|
||||
// изменилась — в браузере лежит код прошлой версии, и его надо перечитать.
|
||||
const loadedVersion = useRef<string | null>(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),
|
||||
};
|
||||
}
|
||||
@@ -1288,6 +1288,16 @@ export const en: Record<MessageKey, Message> = {
|
||||
"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}?",
|
||||
|
||||
@@ -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}?",
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user