cb20d8d28f
Отзыв секрета не сходился: `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
262 lines
17 KiB
Markdown
262 lines
17 KiB
Markdown
# Контракты панели
|
||
|
||
Свойства 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`, ни настройки панели его не содержат и не
|
||
могут переопределить.
|