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

218 lines
14 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. Атрибуция
Адрес атрибуции объявлен один раз в `apps/frontend/src/constants/branding.ts` и
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
ни config API, ни таблица `config`, ни настройки панели его не содержат и не
могут переопределить.