Files
HY2XS_flamy/docs/admin/15-ui-contracts.md
T
founder 162759c599 fix(admin): достроить вторые половины отзыва доступа, лимита и журнала
Разбор кода на c0a43ae со сверкой с официальной документацией Hysteria 2.
Общая тема: операции, у которых была только одна из двух необходимых половин.

Отзыв доступа. Запись disabled=1 видит лишь выборка в Hysteria2Auth, то есть
закрывает БУДУЩИЕ обращения к HTTP-auth; установленная QUIC-сессия живёт своей
жизнью и сама не разрывается. После «Отключить» пир пользовался доступом сколько
угодно долго, а панель показывала его отключённым. Появился DisconnectPeers —
только официальный Traffic Stats /kick, без записи в базу; прежний Hysteria2Kick
вместе с разрывом проставлял banned_until и потому для отключения не годился.
Порядок «запись, затем разрыв» обратному не подлежит и доказан снимком базы в
момент прихода /kick. Неудача разрыва не откатывает disabled и сообщается кодом
peer_disconnect_failed: обычная ошибка означала бы для оператора вывод, прямо
противоположный истине. KickPeer переведён на тот же примитив — он писал
banned_until дважды и мог ответить чистым отказом уже в применённом состоянии.

Ограничение устройств. Отказ /online обрабатывался возвратом успеха
авторизации, то есть недоступность 127.0.0.1 превращала объявленный лимит в
безлимит. Вторая половина дыры была тише: Hysteria2Online отдавал пустую карту
БЕЗ ошибки, когда systemd отвечал «служба неактивна», — а этот ответ не
отличается от «спросить systemctl не удалось». Пути разделены: терпимый для
отображения, строгий для решения о доступе. Hysteria2IsRunning убран с путей
принятия решений совсем.

Журнал. entry.Info() вызывался без аргумента, и logrus писал "msg":"" для
каждого запроса — пустой столбец на экране был точным отражением файла. Ветка
«файла ещё нет» отвечала голым массивом вместо {records,total}, поэтому на
свежей установке страница системных логов не работала вовсе. Битая строка
вызывала vo.Fail И continue: клиент получал два JSON-документа подряд.

Панель. Общий LogViewer и utils/download.ts (копий скачивания было четыре, две
ставили запрос вне try и глушили причину); меню на command с быстрым
включением/отключением; popper-style у подсказки; kick с подтверждением и
названным сроком; отмена подтверждений перестала быть ошибкой. Отдельно:
skipErrorToast гасил и транспортный отказ, при том что страницы писали
«перехватчик уже показал» и молчали, — обрыв связи не показывал ничего.

Закреплено go-тестами против настоящего HTTP, контрактными тестами панели и
двумя гейтами приёмки. Ручная часть — в
docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md.
2026-09-01 17:17:17 +05:00

14 KiB
Raw Blame History

Контракты панели

Свойства HY2XS admin, которые не проверяются ни типами, ни сборкой bundle и потому ломались молча. Каждое из них закреплено тестом (tools/test/frontend-*.test.ts) и гейтом приёмки.

Общее описание панели — 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. Структурированные ошибки

Правило. Панель не разбирает текст ответа. Отказ несёт код, а отказ по полю — ещё и имя поля.

Форма ответа:

{
  "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:

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. Атрибуция

Адрес атрибуции объявлен один раз в apps/frontend/src/constants/branding.ts и принадлежит приложению. Он не является операторской настройкой: ни hy2xs.env, ни config API, ни таблица config, ни настройки панели его не содержат и не могут переопределить.