fix(ui): уведомления шли без остановки и закрывали интерфейс
Владелец: «справа постоянно выходят статусы, прям без остановки. я вообще ничего не вижу за ними». Это был не таймер, а замкнутый круг. Отрисовка настроек запускала опрос состояния сжатия; executeAction на успехе вызывал fetchSnapshot; тот снова перерисовывал настройки — и так без конца. Интерфейс сам себя кормил запросами к серверу, показывая на каждом обороте два тоста: на запрос и на ответ. Тем же путём заливал «poll_native_auth» во время входа. Опросы, которые запускает сам интерфейс, а не владелец, теперь молчат и не дёргают снапшот — второе и разрывает круг. Отказ опроса показывается там, где его запросили: у мастера входа для этого своя область сообщений. Состояние сжатия запрашивается при открытии экрана настроек и по кнопке, а не при каждой отрисовке. 700 passed, 2 skipped; ruff чисто; релизный гейт пройден. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
fa7bbef8af
commit
90aeceb9fd
5 changed files with 114 additions and 7 deletions
|
|
@ -18,7 +18,7 @@ namespace HermesHubSetup
|
||||||
// Подставляется сборщиком из фактического git-коммита. Раньше здесь
|
// Подставляется сборщиком из фактического git-коммита. Раньше здесь
|
||||||
// жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из
|
// жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из
|
||||||
// какого кода собран установщик.
|
// какого кода собран установщик.
|
||||||
public const string BuildCommit = "28f35f8";
|
public const string BuildCommit = "fa7bbef";
|
||||||
public const string MIN_HERMES_VERSION = "0.20.0";
|
public const string MIN_HERMES_VERSION = "0.20.0";
|
||||||
public const string MAX_TESTED_HERMES = "0.20.4";
|
public const string MAX_TESTED_HERMES = "0.20.4";
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -117,6 +117,10 @@ function switchView(viewName) {
|
||||||
|
|
||||||
if (viewName === 'settings') {
|
if (viewName === 'settings') {
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
|
// Состояние сжатия спрашиваем при открытии экрана, а не при каждой
|
||||||
|
// отрисовке настроек: раньше опрос запускался на каждом обновлении
|
||||||
|
// снапшота и замыкал круг сам на себя.
|
||||||
|
checkCompressionStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentSnapshot) {
|
if (currentSnapshot) {
|
||||||
|
|
@ -535,8 +539,23 @@ async function saveAuthTokenFromPrompt() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ACTIONS EXECUTION (POST /api/action) ──
|
// ── ACTIONS EXECUTION (POST /api/action) ──
|
||||||
|
|
||||||
|
// Действия-опросы: их запускает сам интерфейс, а не владелец.
|
||||||
|
//
|
||||||
|
// Показывать их тостами нельзя: опрос состояния сжатия шёл при каждом
|
||||||
|
// обновлении снапшота, и владелец не видел за уведомлениями самой программы.
|
||||||
|
// Хуже того, успешное действие вызывало fetchSnapshot, тот перерисовывал
|
||||||
|
// настройки, а перерисовка снова запускала опрос — круг замыкался и кормил
|
||||||
|
// сам себя. Поэтому опросы не только молчат, но и не дёргают снапшот.
|
||||||
|
const SILENT_ACTIONS = new Set([
|
||||||
|
'get_compression_status',
|
||||||
|
'poll_native_auth', 'poll_native_agy_login', 'poll_terminal_auth',
|
||||||
|
'poll_redirect_auth', 'poll_device_auth',
|
||||||
|
]);
|
||||||
|
|
||||||
async function executeAction(actionName, actionData = {}) {
|
async function executeAction(actionName, actionData = {}) {
|
||||||
showToast(`Выполняется «${actionName}»...`, 'info');
|
const silent = SILENT_ACTIONS.has(actionName);
|
||||||
|
if (!silent) showToast(`Выполняется «${actionName}»...`, 'info');
|
||||||
try {
|
try {
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
if (authToken) headers['X-Hub-Token'] = authToken;
|
if (authToken) headers['X-Hub-Token'] = authToken;
|
||||||
|
|
@ -555,16 +574,20 @@ async function executeAction(actionName, actionData = {}) {
|
||||||
const result = await res.json().catch(() => ({ ok: false, message: `Ошибка парсинга ответа (${res.status})` }));
|
const result = await res.json().catch(() => ({ ok: false, message: `Ошибка парсинга ответа (${res.status})` }));
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
|
if (!silent) {
|
||||||
showToast(result.message || 'Действие выполнено успешно', 'success');
|
showToast(result.message || 'Действие выполнено успешно', 'success');
|
||||||
fetchSnapshot();
|
fetchSnapshot();
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
} else {
|
} else {
|
||||||
showToast(result.message || 'Отказ выполнения действия', 'warning');
|
// Отказ опроса показываем в том месте, которое его запросило: у мастера
|
||||||
|
// входа для этого своя область сообщений. Тостом заливать нельзя.
|
||||||
|
if (!silent) showToast(result.message || 'Отказ выполнения действия', 'warning');
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Action ${actionName} failed:`, err);
|
console.error(`Action ${actionName} failed:`, err);
|
||||||
showToast(`Ошибка сети: ${err.message}`, 'error');
|
if (!silent) showToast(`Ошибка сети: ${err.message}`, 'error');
|
||||||
return { ok: false, message: `Ошибка сети: ${err.message}` };
|
return { ok: false, message: `Ошибка сети: ${err.message}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1801,7 +1824,9 @@ function renderSettingsView() {
|
||||||
if (compKeepRecentSel && s.compression_keep_recent_messages !== undefined) {
|
if (compKeepRecentSel && s.compression_keep_recent_messages !== undefined) {
|
||||||
compKeepRecentSel.value = String(s.compression_keep_recent_messages);
|
compKeepRecentSel.value = String(s.compression_keep_recent_messages);
|
||||||
}
|
}
|
||||||
checkCompressionStatus();
|
// checkCompressionStatus() отсюда убран: отрисовка настроек происходит на
|
||||||
|
// каждом обновлении снапшота, и опрос замыкал круг сам на себя. Состояние
|
||||||
|
// запрашивается при открытии экрана настроек и по кнопке.
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateCompressorProfiles(s) {
|
function populateCompressorProfiles(s) {
|
||||||
|
|
|
||||||
82
tests/test_ui_polling_is_silent.py
Normal file
82
tests/test_ui_polling_is_silent.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""Опросы интерфейса не должны заливать владельца уведомлениями и кормить себя.
|
||||||
|
|
||||||
|
Владелец: «справа постоянно выходят статусы, прям без остановки. я вообще
|
||||||
|
ничего не вижу за ними».
|
||||||
|
|
||||||
|
Это был не таймер, а замкнутый круг: отрисовка настроек запускала опрос
|
||||||
|
состояния сжатия, executeAction на успехе вызывал fetchSnapshot, тот снова
|
||||||
|
перерисовывал настройки — и так без конца. Заодно каждый оборот показывал два
|
||||||
|
тоста: на запрос и на ответ.
|
||||||
|
|
||||||
|
Тем же путём заливали «poll_native_auth» во время входа.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
APP_JS = (
|
||||||
|
Path(__file__).resolve().parent.parent
|
||||||
|
/ "src" / "antigravity_provider" / "router" / "web" / "static" / "app.js"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def source() -> str:
|
||||||
|
return APP_JS.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_polling_actions_are_listed_as_silent(source):
|
||||||
|
assert "SILENT_ACTIONS" in source
|
||||||
|
for action in (
|
||||||
|
"get_compression_status",
|
||||||
|
"poll_native_auth",
|
||||||
|
"poll_redirect_auth",
|
||||||
|
"poll_device_auth",
|
||||||
|
):
|
||||||
|
assert f"'{action}'" in source, f"{action} обязан быть среди молчаливых"
|
||||||
|
|
||||||
|
|
||||||
|
def test_silent_actions_show_no_toasts(source):
|
||||||
|
"""Все четыре места, где executeAction показывает тост, под охраной silent."""
|
||||||
|
body = source.split("async function executeAction")[1].split("// ── GLOBAL HEADER")[0]
|
||||||
|
|
||||||
|
start, success, failure, network = (
|
||||||
|
body.split("try {")[0],
|
||||||
|
body.split("if (result.ok)")[1].split("} else {")[0],
|
||||||
|
body.split("} else {")[1].split("} catch")[0],
|
||||||
|
body.split("} catch")[1],
|
||||||
|
)
|
||||||
|
for part, where in (
|
||||||
|
(start, "начало действия"),
|
||||||
|
(success, "успех"),
|
||||||
|
(failure, "отказ"),
|
||||||
|
(network, "сбой сети"),
|
||||||
|
):
|
||||||
|
assert "showToast(" in part, f"место не найдено: {where}"
|
||||||
|
assert part.index("if (!silent)") < part.index("showToast("), (
|
||||||
|
f"тост без проверки молчаливости: {where}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_silent_actions_do_not_refetch_the_snapshot(source):
|
||||||
|
"""Круг замыкался именно здесь: успех опроса тянул за собой снапшот."""
|
||||||
|
body = source.split("async function executeAction")[1].split("// ── GLOBAL HEADER")[0]
|
||||||
|
success_block = body.split("if (result.ok)")[1].split("} else {")[0]
|
||||||
|
assert "if (!silent)" in success_block
|
||||||
|
assert success_block.index("if (!silent)") < success_block.index("fetchSnapshot(")
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_render_does_not_start_a_poll(source):
|
||||||
|
"""Опрос из отрисовки убран — она идёт на каждом обновлении снапшота."""
|
||||||
|
render = source.split("function populateCompressorProfiles")[0]
|
||||||
|
render_tail = render[-2000:]
|
||||||
|
calls = re.findall(r"^\s*checkCompressionStatus\(\);", render_tail, re.MULTILINE)
|
||||||
|
assert not calls, "опрос из отрисовки настроек запускать нельзя"
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_is_requested_when_the_screen_opens(source):
|
||||||
|
opening = source.split("if (viewName === 'settings')")[1][:400]
|
||||||
|
assert "checkCompressionStatus()" in opening
|
||||||
Loading…
Reference in a new issue