Files
HY2XS_flamy/docs/admin/15-ui-contracts.md
T
founder cb20d8d28f fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному
Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним,
поэтому сессия, установленная по отозванным учётным данным, была неотличима от
законной, и цикл учёта не имел признака, по которому её следовало завершить. У
состояния есть путь без единой неудачи — Hysteria регистрирует соединение в
Traffic Stats API только после возврата backend-auth, поэтому успешный /kick
может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт
по старому, пережившая сессия становится orphan.

Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4,
админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала
лимит устройств, учёт трафика и принудительное отключение разом. Адрес
зафиксирован, а расхождение файла с ним админка называет.

Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при
ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним
значением, а доступность Traffic Stats API выводилась из него же. Журнал
Hysteria разбирается в фактическом формате upstream (time — дробное число),
страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в
браузер, санитайзер выгрузки следует по YAML-якорям.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
2026-09-02 23:24:01 +05:00

262 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Контракты панели
Свойства HY2XS admin, которые не проверяются ни типами, ни сборкой bundle и
потому ломались молча. Каждое из них закреплено тестом
(`tools/test/frontend-*.test.ts`) и гейтом приёмки.
Общее описание панели — [04-admin-panel.md](04-admin-panel.md).
---
## 1. Отрисовка иконок
**Правило.** Монохромная UI-иконка получает цвет ровно одним способом —
наследованием `currentColor` от компонента и темы. Ассет не содержит
литеральных цветов; цвет объявляется в CSS один раз, на `.svg-icon`.
**Запрещено:**
* литеральный `fill` / `stroke` / `stop-color` в монохромном ассете;
* цвет в инлайновом `style` внутри ассета;
* непустой `<style>` внутри ассета — его селекторы глобальны и красят чужие
иконки;
* растровое `<image>` — оно не подчиняется `currentColor` никогда;
* CSS-фильтр на `.svg-icon`;
* селектор по имени конкретной иконки (`[icon-class="…"]`);
* передача цвета параметром компонента.
**Многоцветные ассеты** объявляются явным списком `MULTICOLOR_ICONS` в
`SvgIcon/symbol.ts`. Их палитра — часть ассета, и проверка цвета к ним не
применяется. «Многоцветность» обязана быть решением, а не следствием того, что
иконку скачали с готовыми значениями `fill`.
**Система координат.** У каждого ассета обязан быть `viewBox` либо пара
`width`/`height`, из которой он синтезируется. Без неё `<use>` рисует иконку в
натуральную величину и обрезает её по размеру родительского `<svg>`.
**Почему цвета не переписываются в рантайме.** Источник истины — файл. Молчаливая
нормализация при сборке спрайта скрывала бы ровно тот дефект, который контракт
обязан делать видимым: добавленная с чёрным `fill` иконка выглядела бы
правильно и оставалась бы сломанной в исходниках.
**Что машина не докажет.** Фактический цвет на экране. Визуальная проверка
светлой и тёмной темы, состояний hover/active и свёрнутого меню остаётся ручной
и фиксируется в отчёте приёмки.
---
## 2. Структурированные ошибки
**Правило.** Панель не разбирает текст ответа. Отказ несёт код, а отказ по полю
— ещё и имя поля.
Форма ответа:
```json
{
"code": 50001,
"type": "no",
"message": "проверка данных не пройдена",
"errors": [
{ "code": "min_length", "field": "secret", "message": "…", "params": { "min": "6" } }
],
"data": null
}
```
* `code` — числовой код ответа (`model/constant/code.go`);
* `errors[].code` — причина (`model/constant/error.go`, `ErrCode*`);
* `errors[].field` — имя поля из JSON-тега; пусто для отказов уровня операции;
* `errors[].params` — числа правила, чтобы панель не заводила их вторую копию;
* `message` — человекочитаемый ответ для клиента без UI и запасной вариант для
кода, которого панель ещё не знает.
Границы числа и границы длины строки различаются кодом (`min` против
`min_length`), хотя тег валидатора у них один: оператору это разные фразы.
**Запрещено:**
* выводить код ответа сравнением текста сообщения;
* отдавать обобщённое `invalid` вместо описания полей;
* разбирать сообщение сервера на стороне панели.
**Локализация** строится по ключу `error.code.<код>` с параметрами правила.
Наборы ключей `ru` и `en` обязаны совпадать: забытый ключ не ломает ни типы, ни
сборку — vue-i18n молча отдаёт сам ключ, и оператор видит `error.code.min_length`
вместо фразы.
**Состояние сессии** сообщается кодами `unauthorized`, `session_expired`,
`token_invalid`, `account_disabled` при `code = 50401`. Панель по ним
показывает диалог и возвращает на форму входа.
**Вход администратора.** Неверные учётные данные всегда дают один код
`invalid_credentials`: «такого администратора нет» и «пароль не тот» обязаны
быть неразличимы снаружи. Отказ хранилища при этом остаётся системной ошибкой —
выдавать «неверный логин или пароль» при недоступной базе значит отправить
оператора искать несуществующую опечатку.
---
## 3. Необязательные поля и генерация секретов
**Правило.** Поле, объявленное необязательным, обязано принимать три
неразличимых состояния: отсутствует, пустая строка, одни пробелы.
Это НЕ следует автоматически из тега `omitempty`. В `go-playground/validator`
он не пропускает правило, если поле объявлено указателем и указатель не nil —
`hasValue` считает указатель на пустую строку «значением». Поэтому DTO,
у которых есть необязательные строковые поля, реализуют `dto.Normalizable`, и
слой контроллеров вызывает `Normalize()` между разбором тела и проверкой
правил.
**Граница проходит по каждому полю отдельно.** Общее правило «пусто → не
задано» молча ломает смысл:
| Поле | Пустое значение означает |
| --- | --- |
| `secret` при создании | сгенерировать |
| `secret` при изменении | не менять |
| `name` при изменении | не менять |
| `remark` | убрать пометку |
| `disabled = 0` | включён |
| `quotaBytes = 0` | нулевая квота |
**Генерация секрета принадлежит серверу.** Панель, подставляющая значение в
пустое поле, выполняла бы обещание «сгенерируем автоматически» ровно для одной
двери из четырёх: остаются прямой вызов API, импорт и будущие клиенты.
Единственная реализация — `service.GeneratePeerSecret`, на базе
`util.RandomString` (`crypto/rand` с отбрасыванием смещённых байтов). Тот же
вызов используется импортом.
**Имя пира** проверяется одним правилом `service.IsValidPeerName` на весь
продукт: в таблицу пиров ведут две двери, и они не имеют права требовать
разного.
---
## 4. Таблицы журнала
**Правило.** Колонка журнала объявляет свою ширину: служебные — через `width`,
содержательная — через `min-width`.
Без этого Element Plus делит доступную ширину между колонками практически
поровну. У журнала колонок три, поэтому уровень и время получали по трети
строки, а сообщение — единственное содержимое журнала — тоже треть.
**Сообщение переносится, а не обрезается.** У Hysteria в `msg` приезжает
диагностический JSON; строка, обрезанная многоточием, не отвечает ни на один
вопрос, ради которого страницу открыли.
**Обе страницы журнала построены на одном компоненте**
(`components/LogViewer`). Они были побайтово одинаковы и несли одни и те же три
дефекта в двух экземплярах — ширины, обработку отказа выгрузки и форму ответа.
Собственная `el-table-column` на странице журнала запрещена гейтом приёмки.
---
## 5. Выгрузка файлов
**Правило.** Сборка ссылки на скачивание существует в панели в единственном
экземпляре — `utils/download.ts`. Единственность проверяется контрактным
тестом по вхождению `createObjectURL`.
Копий было четыре, и все успели разойтись. Две из них ставили сетевой запрос
ПЕРЕД `try`:
```ts
const response = await exportApi(...); // отказ сюда не попадает
try { ... } catch (e) { /* empty */ }
```
то есть отказ самого запроса не ловился вовсе, а всё внутри глушилось молча:
оператор не получал ни файла, ни причины. Третья падала на `split(...)` при
отсутствующем `Content-Disposition` — и это исключение тоже глушилось.
**Отказ выгрузки показывает сама страница.** Бинарный ответ не проходит через
общий разбор конверта: у `Blob` нет полей `code` и `errors`, поэтому
перехватчик по нему фразы не даст.
---
## 6. Меню действий над строкой
**Правило.** Пункты `el-dropdown` объявляют `command`; обработчик — один, на
`el-dropdown`.
`@click` на каждом пункте не запрещён самим Element Plus, но `command` является
штатным контрактом именно для меню действий, и при нём невозможно добавить
пункт, забыв его подключить. Обе половины проверяются контрактным тестом:
наличие `@command` и отсутствие `@click` на пунктах.
**Частичный результат операции отличается от отказа кодом.** Отзыв доступа к
VPN состоит из двух половин — записи в базе и разрыва активной сессии, — и
первая может примениться без второй. Панель обязана распознать
`peer_disconnect_failed` по коду, показать его предупреждением, а не ошибкой, и
ОБНОВИТЬ строку: состояние в базе уже изменилось. Показ его как обычной ошибки
подтолкнул бы оператора к выводу, прямо противоположному истине.
---
## 7. Подтверждения
**Правило.** Отмена подтверждения — это ответ оператора, а не ошибка.
`ElMessageBox` отклоняет промис при нажатии «Отмена». `await
ElMessageBox.confirm(...)` без разбора отказа оставляет необработанное
отклонение промиса на каждую отмену. Единственный прямой вызов на странице
пиров живёт внутри `confirmAction`, переводящей отмену в обычное `false`; это
закреплено тестом.
---
## 8. Панель не выдумывает состояние
**Правило.** Отсутствие данных показывается как отсутствие данных.
Нарушений было три, и все три давали оператору ответ, противоположный истине.
**Страница конфигурации** строила форму merge'ем ответа сервера поверх полного
объекта значений по умолчанию, поэтому отсутствующая секция `trafficStats`
показывалась как `listen: :9999`, а явное `speedTest: false` считалось
ненастроенным и прятало свою вкладку. Экран, существующий ради диагностики
дрейфа, этот дрейф скрывал. Теперь ответ отличает «не задано» (`null`) от
значения, а секции вне production-профиля перечисляются отдельным списком
расхождений.
**Список пиров** получал `onlineUsers, _ := Hysteria2Online()` и при любом сбое
control plane показывал всех пиров офлайн. Теперь страница несёт
`onlineState: ok | unavailable` — один признак на ответ, а не флаг в каждой
строке, — и при `unavailable` показывает «онлайн неизвестен», а число устройств
как `?`.
**Дашборд** выводил доступность Traffic Stats API из ответа `systemctl` и умел
утверждать «служба остановлена» и «API доступен» одновременно. Теперь это два
независимых факта, а у состояния службы три значения: `active`, `inactive`,
`unknown`.
**Запрещено:**
* подставлять значение по умолчанию вместо отсутствующего в ответе;
* показывать `0`, `false` или «офлайн» там, где данные не получены;
* выводить один факт из другого, если их можно спросить по отдельности.
**Секреты на читающем экране.** Read-only страница не имеет права быть щедрее
санитизированной выгрузки того же документа. Вместо значения показывается
диагностический факт: «задан» / «не задан» для паролей и секретов, имена
параметров без значений для `acme.dns.config`, адрес с вырезанным
`access_token` для auth-URL.
**Редакторы без сохранения запрещены.** Конфигом владеет оркестратор, маршрутов
записи в API нет, поэтому поля ввода на странице конфигурации обещают действие,
которого не существует. Проверяется контрактным тестом: в шаблоне нет ни
`el-input`, ни `el-switch`, ни `el-select`, ни `v-model`.
---
## 9. Атрибуция
Адрес атрибуции объявлен один раз в `apps/frontend/src/constants/branding.ts` и
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
ни config API, ни таблица `config`, ни настройки панели его не содержат и не
могут переопределить.