atomspectra-waterfall-esp32

Known Issues / Известные проблемы

🇷🇺 Русская версия · 🇬🇧 English

Список известных багов, ограничений и исправленных проблем AtomSpectra ESP32 Gateway.

Открытые

BUG-AS-08: ⚠ Шлюз не резервирует заводскую DSP-настройку прибора

Статус: ограничение by design + предупреждение (не баг прошивки шлюза).

Прибор «Atom Spectra» хранит внутри себя настройку цифровой обработки импульсов (DSP-tuning): пороги формы импульса RISE / FALL / NOISE, MAX / HYST / MODE / STEP, частоту дискретизации F, аппаратные потенциометры HV/усиления POT / POT2, таблицу pile-up и термокомпенсацию.

Бо́льшая часть этих значений видна в ответе прибора на команду -inf — включая таблицу pile-up (PileUp […] / PileUpThr) и таблицу термокомпенсации MAX (Tco […]). Но таблица термокомпенсации baseline (зависимость POT2 / V от температуры, точки -t_pot) в -inf НЕ возвращается — там присутствует лишь флаг TCpot ON/OFF. Полный слепок baseline-термокомпенсации даёт отдельная команда -tc_pot?.

Что наблюдалось. Зафиксирован случай, когда у прибора обнулилась вся эта настройка: -inf стал отдавать RISE 0 FALL 0 NOISE 0 F 1.00 MAX 0 HYST 0 ... POT 0 POT2 0 при том, что сам прибор остаётся на связи и отвечает VERSION. С нулевыми порогами прибор физически не выделяет импульсы — счёт пропадает (total_counts = 0, cps = 0), хотя соединение USB/CDC исправно. Переподключение USB настройку НЕ восстанавливает.

Влияние: до восстановления DSP-настройки прибор не набирает спектр (ноль отсчётов). Шлюз, WiFi, веб-интерфейс, TCP-мост и energy-калибровка (полином E(ch), хранится на шлюзе в calib.bin) при этом не затронуты — это состояние самого прибора, а не прошивки шлюза.

⚠ Предупреждение — НЕ восстанавливать «вслепую» командами тюнинга. Хотя протокол прибора имеет команды настройки (-ris, -fall, -nos, -max, -hyst, -U, -V, -step и др.), записывать их наугад нельзя:

Обходной путь / восстановление: обратиться к производителю прибора (KB Radar) — восстановление заводского профиля детектора выполняет производитель. Самостоятельно, «вслепую» записывать параметры настройки нельзя (см. предупреждение выше).

Рекомендация на будущее: шлюз умеет читать -inf (показывает поля в Web UI), но не делает их резервную копию. Имеет смысл при настроенном приборе один раз сохранить два ответа прибора как эталонный «слепок» DSP-конфигурации:

  1. ответ на -inf — основные пороги, POT / POT2, таблицу термокомпенсации MAX (Tco […]) и pile-up;
  2. ответ на -tc_pot? — таблицу термокомпенсации baseline (POT2 / V от температуры); её в -inf нет (см. выше).

Тогда повторный сброс можно будет зафиксировать и передать производителю (KB Radar) оба слепка как референс заводской настройки.

BUG-AS-03: Серийный номер не считывается

Статус: открыт

Серийный номер прибора (serial_number) остаётся пустым после подключения.

Причина: прибор в ответ на команду -inf возвращает менее 40 строк текста. Код ожидает серийный номер в строке 39 (process_info_response), но реальный ответ короче. Калибровка (строки 0–10) считывается корректно; серийный номер — нет.

Влияние: в Web UI, XML и CSV экспорте поле серийного номера пустое. Функциональность спектрометра не затронута.

Обходной путь: серийный номер можно задать вручную через Web UI (панель калибровки).


Исправленные

#UI-43: тумблер шкалы X (с/м/ч) на «Мониторинге» не давал видимого эффекта

Статус: исправлен, прошивка firmware-v1.0.11.

На графике «Мониторинг» переключатель единиц оси времени (секунды / минуты / часы) не менял видимый шаг подписей — все три положения давали одинаковую сетку.

Причина: старый xTickStep (web/monitor.html) вычислял целевой шаг как target = spanMs/6, игнорируя выбранную единицу — на реальном спане (напр. 199 мин) все единицы округлялись к одному и тому же «красивому» шагу (3 600 000 мс), сетка получалась идентичной (all_equal).

Исправление: новый density-based xTickStep — целевая плотность тиков задаётся по единице (k = 15 / 8 / 4 для с / м / ч) на едином nice-list шагов; удалён guard-частокол округления. Теперь с / м / ч дают три разных шага на любом спане.

Проверка (2026-07-11): на живой плате (/api/system fw=v1.0.11) на спанах 1 мин…12 ч единицы дают distinct=3 разных шага; на 199-мин спане с=900 с / м=1800 с / ч=3600 с → 13 / 7 / 3 тика (было — все одинаково).

#UI-42 + #FW-49: адаптивная ширина Web UI и перенос поля префикса на вкладки экспорта

Статус: исправлены, прошивка firmware-v1.0.11.

#FW-41: температура детектора добавлена в формат водопада (ASWF v5)

Статус: реализовано, прошивка firmware-v1.0.11 (внутренний бамп формата на стендовой сборке v1.0.7).

В сегментный формат водопада .aswf добавлено поле температуры детектора (t1 из ответа прибора -inf). Версия формата поднята v4 → v5: заголовок version:5, row_stride:16406, новое поле temperature со смещением 16402 (float32, °C). При отсутствии валидной температуры (di->valid = false) пишется NaN-guard (0x7FC00000).

Совместимость: читатели ASWF v4 продолжают работать (поле добавлено в хвост строки); v5-читатели получают температуру покадрово.

#FW-42 / #FW-43 / #FW-44 / #FW-46 / #FW-48: пакет прошивки firmware-v1.0.11

Статус: исправлены, прошивка firmware-v1.0.11.

#MON-1: мониторинг перенесён из браузера на плату

Статус: исправлен, прошивка firmware-v1.0.6.

Ранее запись «Мониторинга» (усреднённый CPS по интервалу) велась в браузере — при закрытой или свёрнутой вкладке данные не накапливались (дыры), плюс дублировалась с записью водопада.

Исправление: сбор мониторинга перенесён на плату (board-side) — накопление идёт независимо от состояния браузерной вкладки.

#FW-39: артефакт склейки на границах сегментов водопада

Статус: исправлен, прошивка firmware-v1.0.6.

При сшивке сегментов .aswf в единый водопад на границах появлялись полосы / неравномерность времени по краям.

Причина: в заголовке сегмента поле row_stride записывалось как 16406 вместо фактического 16402 — рассинхрон шага строки при склейке (main/web_waterfall.c).

Исправление: row_stride в заголовке приведён к фактическому 16402. Файлы, снятые прежней прошивкой, чинятся пересчётом шага (утилита сверки на ПК).

#UI-39 / #UI-40 / #UI-41: перекрестие водопада и быстрые тумблеры линий нуклидов

Статус: реализованы, прошивка firmware-v1.0.6.

#BRIDGE-3: гонка ответов -inf/-cal в bridge-режиме портила serial и температуру

Статус: исправлен, прошивка firmware-v1.0.5.

В режиме TCP-моста (порт 8234) при одновременном опросе прибора командами -inf и -cal ответы перемешивались: серийный номер вырождался в FFFFFFFF, температура читалась как 0, поля разных ответов слипались.

Исправление: разделение и сериализация ответных потоков -inf и -cal в bridge-пути.

Проверка (#TEST-2 Block A, 2026-07-09): 40 итераций интерливинга -inf/-cal через TCP :8234 — 0 испорченных serial / t1 / version.

#BRIDGE-1 + #DATA-1: развязка USB-приёма и сквозной контроль целостности (формат v4)

Статус: реализованы, прошивка firmware-v1.0.4.

Проверка (#TEST-2 Block A): rx_ring_drops дельта = 0; per-row CRC 64/64; n42 ↔ aswf 192/192.

#UI-38: временная ось X на «Мониторинге» + часовой пояс

Статус: реализован, прошивка firmware-v1.0.4.

График «Мониторинг» получил временную ось X (секунды / минуты / часы + реальное время); часовой пояс задаётся на странице «Система».

#UI-36 / #UI-37: масштаб спектра «как AtomSpectra» и кнопка проверки обновления

Статус: реализованы, прошивка firmware-v1.0.4 (стендовая сборка v1.0.3).

#FW-23: n42-экспорт — StartDateTime 1970 на каждой строке при автозапуске после ребута

Статус: исправлен в 79eff24, в main. Прошивка firmware-v1.0.1.

При автозапуске записи водопада после ребута/включения n42-экспорт показывал <StartDateTime>1970-01-01T...Z</StartDateTime> на каждом сегменте — время старта не синхронизировано с реальными часами.

Причина: порядок инициализации в app_main()usb_host_cdc_init() (boot-автозапуск водопада) вызывается раньше init_sntp(). spectrogram_start() латчит started_at = time(NULL), пока RTC ещё у epoch-0 (SNTP-ответ не пришёл) → started_at фиксируется near-1970. Переподключение USB/WiFi значение не правит.

Исправление: добавлена SNTP-callback spectrogram_time_synced() (main/spectrogram.c:831). При приходе первого SNTP-ответа, если запись активна и started_at < WF_SANE_EPOCH, started_at пересчитывается назад по прошедшему времени: started_at = time(NULL) − elapsed. Реальное начало записи восстанавливается ретроактивно, не теряя уже записанные сегменты.

Проверка (2026-07-08): полевая выгрузка waterfall (30).n42 с платы (прошивка firmware-v1.0.1, залита прошивальщиком) — 60 сегментов, ноль 1970, все 2026-07-08; boot-autostart сегмент датирован корректно (12:24:51Z), StartDateTime монотонно возрастает. Баг устранён на железе.

#WF-2: /ws/waterfall отдаёт 404 → бесконечный цикл reconnect

Статус: исправлен в 6c81c37, в main.

WebSocket-эндпоинт водопада /ws/waterfall периодически отдавал 404 Not Found, браузер уходил в цикл переподключения — стрим водопада не поднимался.

Причина: переполнен реестр URI-хендлеров ESP-IDF httpd. CONFIG_HTTPD_MAX_URI_HANDLERS = 45, а суммарное число регистрируемых хендлеров (страницы, ассеты, REST API, сегментные эндпоинты) выросло выше 45. Регистрация /ws/waterfall падала с ESP_ERR_HTTPD_HANDLERS_FULL → сервер не знал маршрут → 404.

Исправление: CONFIG_HTTPD_MAX_URI_HANDLERS поднят 45 → 60 в sdkconfig.defaults — запас под текущие и будущие эндпоинты.

#WF-1: Плата уходила в краш-луп при активной записи водопада

Статус: исправлен в 33fb4a4 (+ 2b36737), в main.

Под нагрузкой активной записи водопада (запись в .aswf-сегменты + одновременный USB-приём от детектора + WiFi-опрос) плата уходила в краш-луп — ребут в среднем каждые ~15 минут.

Причина: CONFIG_SPI_FLASH_AUTO_SUSPEND был включён на flash-чипе, не входящем в whitelist ESP-IDF для этой опции — под конкурентной нагрузкой SPI flash уходил в auto-suspend в неподходящий момент записи, что приводило к краху.

Исправление: CONFIG_SPI_FLASH_AUTO_SUSPEND отключён.

Проверка (#STAB-2, 2026-07-04): ретест 9.40 ч непрерывной записи водопада через плату (не эмуляция) — 0 ребутов, 0 потерянных flash-сегментов (seg_dropped) за весь прогон, 52 чистых SEG_ROLLOVER. До фикса — ребут каждые ~15 мин под той же нагрузкой. Полный отчёт с телеметрией и графиками: docs/stab2_report.md (веб-версия с графиками).

BUG-AS-07: Сборка падает на чистом клоне — undefined spectrogram_is_recording

Статус: исправлен в 1b21d61

GitHub Actions job build (ESP-IDF v5.4) падал на чистом клоне репозитория за ~2 минуты. Локально сборка проходила — баг проявлялся только на свежем clone (CI, чужая машина).

Причина: в публичный репозиторий попал незавершённый фрагмент функции реконнекта USB. В main/usb_host_cdc.c (после восстановления CDC-соединения с прибором) был вызов spectrogram_is_recording() — чтобы при активной записи водопада возобновить набор спектра командой -sta. Сама функция определена в файлах подсистемы записи водопада, которые на момент того коммита не были закоммичены (фича в незавершённой локальной разработке). На машине автора всё компилировалось, потому что определение лежало в рабочей копии; на чистом клоне компилятор видел только вызов без объявления → implicit declaration of function 'spectrogram_is_recording'. В ESP-IDF действует -Werror=all, поэтому implicit declaration — не предупреждение, а жёсткая ошибка сборки.

Исправление: незавершённый хук возобновления записи убран из usb_host_cdc.c в публичном репозитории — сборка снова замкнута только на закоммиченные символы. Сама функция автоматического возобновления набора спектра при реконнекте остаётся в неопубликованной локальной ветке и будет внесена единым целостным коммитом вместе со всей подсистемой записи водопада на диск (а не отдельным «висящим» вызовом).

Обновление: обещанный единый коммит внесён — подсистема автономной записи сегментами (spectrogram.[ch], эндпоинты /segments /segment, хук реконнекта в usb_host_cdc.c, авто-возобновление через spectrogram_restore()) опубликована целостно в ветке rec-11-autonomous-recording. Сборка на чистом клоне снова самодостаточна — символ spectrogram_is_recording() теперь определён в составе того же коммита, что и его вызов.

Проверка: CI job build на коммите 1b21d61 — зелёный (до этого b0b8856 и 958ac19 падали с тем же implicit declaration). Профилактика на будущее: перед push ветку проверять «как чистый клон» — собирать только из закоммиченных файлов, а не из рабочей копии с незакоммиченными изменениями.

BUG-AS-06: Запись стрима водопада на диск самопроизвольно отключается

Статус: исправлен (см. web/waterfall.html, сериализатор записи dskEnqueue)

При активной записи водопада в файл («💾 Стрим на диск», формат .aswf через File System Access API браузера) запись через некоторое время самопроизвольно прекращалась — индикатор записи гас, файл переставал расти, хотя поток WebSocket продолжал поступать. Воспроизводилось тем надёжнее, чем чаще приходили строки (меньший интервал водопада); при больших интервалах — реже, но всё равно систематически.

Причина: входящие WS-строки записывались в FileSystemWritableFileStream вызовом dskWritable.write(...) без await, а каждые 30 секунд таймер flushDiskHeader перезаписывал заголовок файла (ещё несколько write()). FileSystemWritableFileStream.write() асинхронный: попытка начать новую запись, пока предыдущая ещё не завершилась, бросает исключение синхронно (поток заблокирован, The stream is locked). На стыке «запись строки данных ↔ перезапись заголовка» исключение возникало систематически (примерно раз в 30 секунд); оно ловилось обработчиком ошибок записи, который вызывал stopDiskStream() — поэтому запись «сама» отключалась.

Исправление: все записи в поток сериализованы через единую Promise-очередь (dskEnqueue) — следующая запись стартует только после завершения предыдущей, ни одного конкурентного write(). Позиции записи заданы явными байтовыми смещениями ({type:"write",position:…}) вместо неявного курсора: заголовок (позиции 0 и 8) и строки данных (позиция ≥ 8 + резерв заголовка) больше не пересекаются. stopDiskStream() дожидается опустошения очереди (await dskQueue) перед финальной записью заголовка и close(); добавлен реентранси-гард dskStopping.

Проверка: сборка прошивки (ESP-IDF v5.4) без ошибок; node --check встроенного JS — без синтаксических ошибок; правка зеркально продублирована в демо (demo/waterfall.html). Функциональный тест на плате: запись водопада с интервалом 5 с (строки приходят чаще, чем срабатывает 30-секундный флаш заголовка) — запись держится без самоотключения.

BUG-AS-05: Web UI обрушивается при многократном Ctrl+F5 (HTTP 431)

Статус: исправлен (см. sdkconfig.defaults, ключ CONFIG_HTTPD_MAX_REQ_HDR_LEN)

При многократной жёсткой перезагрузке (Ctrl+F5) страницы водопада интерфейс мог обрушиться — часть ассетов не загружалась, графики/стрим переставали обновляться.

Причина: при жёстком reload браузер не использует кэш и шлёт запрос с полным набором HTTP-заголовков (Authorization: Basic от web-auth + Sec-Fetch-* + Accept* + Cache-Control: no-cache). Суммарный блок заголовков превышал лимит ESP-IDF httpd CONFIG_HTTPD_MAX_REQ_HDR_LEN (по умолчанию 512 Б), и сервер отвечал 431 Request Header Fields Too Large вместо страницы. Прошивка при этом оставалась полностью стабильной (heap/CPU без изменений, без перезагрузок) — обрушивался только UI в браузере.

Исправление: в sdkconfig.defaults подняты лимиты httpd: CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024 и CONFIG_HTTPD_MAX_URI_LEN=1024 (512 → 1024 Б).

Проверка: стресс-тест Ctrl+F5 с захватом UART — после фикса ответы 431 исчезли (было: на каждый F5 → стало: 0), без перезагрузок прошивки, WS-реконнекты проходят. Методика и сырые логи до/после: tests/stress/.

BUG-AS-04: Калибровка отсутствует в XML после перезагрузки

Статус: исправлен в 3ab490f

После перезагрузки ESP32 калибровка могла отсутствовать в JSON API и XML-экспорте, несмотря на сохранённый calib.bin.

Причина: в main.c вызов spectrum_load_calibration() стоял перед spectrum_restore_autosave(). Autosave восстанавливал полную структуру s_spectrum через fread(), затирая калибровку, загруженную из calib.bin.

Исправление: порядок вызовов в app_main() изменён — сначала spectrum_restore_autosave(), затем spectrum_load_calibration().

BUG-AS-02: Курсор keV показывает неверную энергию

Статус: исправлен в a9dab5e

При наведении курсора на спектр значение keV не соответствовало реальной энергии канала.

Причина: в Web UI функция расчёта энергии неправильно применяла полиномиальные коэффициенты — порядок коэффициентов был инвертирован (BecqMoni UI показывает коэффициенты в нисходящем порядке: a=ch⁴, b=ch³, …, e=offset, а внутренний формат — восходящий: c₀=offset, c₁=ch¹, …).

Исправление: функция channelToKeV() в index.html пересчитывает по восходящему полиному E(ch) = c₀ + c₁·ch + c₂·ch² + c₃·ch³ + c₄·ch⁴.

BUG-AS-01: Web UI — русские подписи и логарифмическая шкала

Статус: исправлен в a9dab5e

Исправление: полная переработка Web UI — русские метки, защита Math.log10() от нулей, корректный device name.

calibEditing: Поля калибровки перезаписываются во время редактирования

Статус: исправлен в 7cf0beb

При ручном вводе калибровочных коэффициентов через Web UI цикл update() (1 раз в секунду) перезаписывал содержимое полей текущими значениями из API.

Исправление: добавлен флаг calibEditing — пока пользователь редактирует форму, автообновление полей калибровки приостанавливается.


Ограничения

Flash-память: износ при автосохранении

Автосохранение текущего спектра (current.bin, ~33 КБ) выполняется каждые 60 секунд. Это ~1440 записей в сутки.

Тип flash-памяти Ресурс Оценка срока
Winbond (типовой, 100K циклов) 100 000 стираний/сектор 15–30 лет
Дешёвый no-name (10K циклов) 10 000 стираний/сектор 1.5–5 лет

LittleFS использует copy-on-write и wear leveling по всему разделу (12.9 МБ), что распределяет нагрузку. При использовании оригинальных плат ESP32-S3-DevKitC-1 с Winbond flash проблем не ожидается.

Возможные оптимизации (не реализованы, не требуются при текущих сроках):

Один TCP-клиент

TCP-мост (порт 8234) поддерживает одно одновременное подключение. При попытке второго соединения оно будет отклонено. BecqMoni или AtomSpectra на ПК работают, пока не откроется второй экземпляр.

ESP32 поддерживает только WiFi 2.4 GHz

Сети 5 GHz не поддерживаются аппаратно. Убедитесь, что роутер вещает на 2.4 GHz.

Максимум каналов — 8192

Прибор Atom Spectra передаёт 8192 канала. Это аппаратное ограничение спектрометра, не прошивки.

Автономная запись: кольцо keep-last при переполнении флеша не прошло длинный soak

Контекст: фича + фикс #WF-1 (33fb4a4) уже в main; ветка rec-11-autonomous-recording как единое целое ещё не влита (лишние несвязанные коммиты сверху).

Автономная запись водопада сегментами (seg_NNNNN.aswf) и ротация сегментов при достижении WF_SEG_MAX_ROWS (64 строки) подтверждены на плате: seg_00000.aswf финализируется при заполнении и автоматически создаётся seg_00001.aswf.

Режим keep-last ring (при заполнении флеша затирается самый старый ещё не выгруженный сегмент; счётчики seg_count / seg_dropped) реализован, но не проверен длинным soak-тестом до фактического исчерпания раздела (≈763 строки, ≈5 суток при интервале 10 мин). До прохождения этого теста граница «флеш полон → перезапись старого» считается непротестированной — этот конкретный сценарий (реальное исчерпание раздела) ещё не прогнан, хотя сам код записи уже в main.

Обновление 2026-07-04 (#STAB-2): 9.40-часовой soak-тест записи через плату подтвердил надёжность самой записи сегментов — 0 ребутов, 0 seg_dropped, 52 чистых SEG_ROLLOVER (см. фикс #WF-1 выше). Это не тот же тест, что soak до исчерпания 763-строчного раздела (кольцо keep-last по сегментам всё ещё не прогнано до реального переполнения) — но попутно обнаружено отдельное ограничение экспорта: поле ring_capacity в /api/waterfall/status фиксировано на 256 строк (~4.25 ч при кадансе ~60 с) — меньше, чем оценка ёмкости раздела (~763 строки) выше. При выгрузке n42-экспорта запись длиннее ~4.25 ч оказывается обрезанной (см. #FW-19 ниже). Полный разбор: docs/stab2_report.md §6.

#FW-19: экспорт n42 обрезан ёмкостью flash-кольца (256 строк ≈ 4.25 ч)

Статус: открыт (задача #32).

При выгрузке n42-экспорта записи, шедшей дольше ~4.25 ч, файл содержит только последние 256 строк — независимо от того, сколько строк реально записано (total_rows). Причина — ring_capacity=256 (/api/waterfall/status): кольцо физически хранит только последние 256 строк, более старые перезаписываются новыми до выгрузки.

Это не регрессия #WF-1 — счётчик seg_dropped (потери на записи) остаётся 0, т.е. сама запись данные не теряет; ограничение — в объёме хранения экспортируемого кольца, не в надёжности записи. Событие RING_OVERFLOW не детектирует сам факт перезаписи (его условие — лаг total_rows − flash_rows ≥ ring_capacity, а не факт wraparound).

Обходной путь: для записей длиннее ~4.25 ч — периодически забирать сегменты через /api/waterfall/segment (см. раздел «Автономная запись сегментами» в WATERFALL.md), не дожидаясь конца записи для единого экспорта. Подробности и телеметрия теста, в котором обнаружена проблема: docs/stab2_report.md §6.

Pull-опрос (wf_pull_client.py, #REC-12): нет пина сегмента от кольца keep-last

Кольцо keep-last (make_room(), main/spectrogram.c:304-321) при нехватке места на Flash удаляет самый старый ЗАВЕРШЁННЫЙ сегмент — кроме текущего открытого и запиненного (s_seg_pinned). Пин ставит только push-выгрузка (#REC-11-A2, wf_offload.c); pull-путь (GET /api/waterfall/segment, main/web_waterfall.c:468) сегмент во время скачивания не пинит.

Если PC-клиент опрашивает РЕЖЕ, чем плата успевает заполнить Flash необработанными сегментами — кольцо может стереть сегмент раньше, чем wf_pull_client.py успеет его забрать. Следующий GET .../segment?name=... вернёт ошибку (error:get:... в логе клиента), delete/ack не отправится, но сегмент физически уже удалён — данные потеряны безвозвратно, дыра в едином .aswf. Это НЕ детектируется как «пауза платы» (gap, wf_pull_client.py:161-169 ловит только паузы записи, не съеденные кольцом сегменты).

Обходной путь: держать --interval клиента заметно короче времени, за которое кольцо успевает съесть непрочитанный сегмент при текущей скорости записи. Автоматической защиты (pin, как у push) для pull-скачивания пока нет — не реализовано.


Совместимость

Компонент Версия
ESP-IDF v5.1+ (протестировано на v5.4)
Целевой чип ESP32-S3 (USB OTG Host обязателен)
Спектрометр KB Radar «Atom Spectra» (FTDI FT232R, 600000 бод)
BecqMoni XML FormatVersion 120920
Web UI Современные браузеры (Chrome, Firefox, Safari, Edge)