Фронтенд

Интерфейс — серверный рендеринг Django-шаблонами плюс SCSS и vanilla JS. Ассеты npm-пакетов копирует scripts/copy-assets.mjs, линтеры вызываются своими CLI через npm scripts, сжатием на стороне Django занимается django-compressor.

Структура

Путь

Содержимое

network/templates/

Django-шаблоны

network/static/js/

Собственные скрипты

network/static/css/

Исходники SCSS

network/static/lib/

Ассеты из npm-пакетов, копируются scripts/copy-assets.mjs

staticfiles/

Результат collectstatic, в репозитории не хранится

Слои SCSS

base.html подключает три файла в этом порядке, и порядок значим — каждый следующий рассчитывает на объявления предыдущего:

Файл

Что в нём

css/bootstrap.scss

переменные Bootstrap, слой семантических токенов и те компоненты Bootstrap, которые подключены

css/theme.scss

оформление проекта: кнопки, таблицы, типографика, модалки, компоненты страниц

css/app.scss

шапка, подвал, «тело», движение; подключает components/

Страничные css/pages/*.scss подключаются своими шаблонами в блоке {% block css %}, то есть после общих трёх.

Внутри bootstrap.scss три яруса:

  1. Пигменты — css/variables.scss: $orange-400, $blue-400, $dark-300 и остальная рампа. Отображаются на $primary, $info, $success, $danger до того, как Bootstrap прочитает свои умолчания.

  2. Токены — --surface, --surface-raised, --border, --text, --accent, --interactive, --status-*. Это роли, а не цвета: значение меняется вместе с [data-theme], поэтому правилу, написанному через токен, не нужен парный блок в светлой теме. --bs-* перенаправлены на них, чтобы вендорный CSS (тема bootstrap5 у Tom Select) читал палитру проекта.

  3. Компоненты Bootstrap — подключаются по одному, каждый со своим визуальным разбором: reboot, grid, transitions, forms/{labels,form-text,form-control,form-select,form-check,validation}, button-group, nav, card, pagination, close, tooltip, modal, utilities и utilities/api.

Кнопки, таблицы и типографика Bootstrap не подключены сознательно: это оформление проекта, а не недоделанный Bootstrap. Подключить их значило бы подложить чужие правила под свои и переопределять обратно.

Новое правило пишется через токен. Пигмент по имени ($dark-600) требует второго правила в блоке [data-theme='light'], и именно эти пары постепенно схлопываются по мере перевода страничных файлов.

Фокус с клавиатуры

Кольцо фокуса — одно правило в theme.scss и токен --focus-ring в обеих темах. Два обстоятельства объясняют его вид:

  • кольцо рисуется box-shadow, а не outline: outline у вариантов btn-outline-* работает рамкой, и десяток страничных правил его снимает;

  • кольцо двухслойное — 2 px --surface, затем 2 px --focus-ring. Один цвет не может пройти 3:1 и к фону страницы, и к заливке кнопки: почти белое кольцо даёт 16,9:1 на фоне и 2,6:1 на синей заливке. Внутренний слой цвета фона даёт 6,5:1 к этой же заливке, так что граница видна с любой стороны.

Правило перечисляет четыре селектора, потому что трижды фокус берёт не тот элемент, который виден: обёртка Tom Select (:has(:focus-visible)), label вокруг спрятанного радио и .btn-check рядом с таким же label.

Контрол, который не должен быть виден, прячется миксином visually-hidden-control из variables.scss, не display: none: display: none выкидывает его и из последовательности табуляции — так фильтры статуса четырёх каталогов, оба расписания станции и форма нового наблюдения были недоступны с клавиатуры вообще. Запрет проверяется тестом test_no_stylesheet_hides_a_control_outright.

Почему bootstrap.scss сохраняет @import

Остальное дерево на @use. Исходники Bootstrap 5.3 написаны через @import, а Sass запрещает @use файла, который сам импортирует, — то есть @use 'bootstrap' with (...) для настройки переменных недоступен. Отсюда три следствия:

  • .stylelintrc запрещает @import правилом at-rule-disallowed-list и освобождает от него один этот файл по имени;

  • в COMPRESS_PRECOMPILERS стоит --silence-deprecation=import,color-functions,if-function,global-builtin — все четыре категории приходят из вендорных исходников, настоящие ошибки при этом видны;

  • пути ведут в ../lib/bootstrap/scss/, куда файлы кладёт scripts/copy-assets.mjs, а не в node_modules: в образе node_modules нет, из него копируется только бинарь dart-sass.

Когда Bootstrap перейдёт на модули, это станет @use 'bootstrap' with (...), а исключение в .stylelintrc уйдёт.

Визуальная проверка

Скриншот-диффового харнесса в проекте нет. Вместо него — список страниц, который прогоняется руками (Playwright MCP годится) на 375, 768 и 1440 px, в обеих темах, до и после правки, меняющей вид.

Примечание

Компрессор читает файлы через staticfiles storage, то есть из staticfiles/, а не из исходников. После правки JS или CSS: rm -rf staticfiles, manage.py collectstatic --noinput и перезапуск сервера — манифест кэшируется в процессе.

Раздел

Страницы

Витрина

/, /rko/, /launches/, /launches/?scope=past, /launches/?scope=calendar, /launches/<id>/

Наблюдения

/observations/, /observations/<id>/, /observations/new/, /vet-observations/

Станции

/stations/, /stations/<id>/, /stations/edit/<id>/, /stations/register/step1/, /stations_all/

Спутники

/satellites/, /satellites/<norad>/, /decay/, /transmitters/, /frequency-picker/, /orbit-sandbox/

Данные

/gallery/, /gallery/?satellite=<sat_id>, /gallery/?station=<id>, /spectrum/, /analytics/

Статистика

/statistics/, /satellite_statistics/, /station_statistics/

Аккаунт

/accounts/login/, /accounts/password/reset/, /users/<username>/

Ошибки

/404, /500

Отдельно, потому что разметку рисует JS и на скриншоте страницы её нет: модалка спутника (клик по спутнику в любом списке), модалка станции на /satellites/<norad>/, модалки предложения спутника и передатчика на /transmitters/.

Страница спутника переключает первый экран по состоянию аппарата, поэтому одним скриншотом не проверяется. Нужны три: аппарат, который сеть наблюдала за последние 30 дней (карта плюс живая сводка), аппарат на орбите без свежих наблюдений (карта плюс паспортная сводка) и сошедший с орбиты (вместо карты — фотография). Вкладки открываются и по адресу: /satellites/<norad>/#tab-orbit.

Страница запуска устроена так же и тоже требует трёх скриншотов: до старта (фотография и отсчёт), живой режим — у отслеживаемого запуска от T−1 ч до T+72 ч, для проверки без пуска /launches/<id>/?live=1 под is_staff, — и архив. /launches/ проверяется в каждом из трёх сегментов; герой-афиша и плитки при переключении остаются на месте.

Предупреждение

Видимость столбцов DataTables хранится в cookie T_<столбец> и переживает перезагрузку. Пустая или урезанная таблица после отладки — состояние браузера, а не регрессия.

Команды

npm ci
npm run assets     # скопировать ассеты пакетов в network/static/lib
npm run lint:js    # eslint по network/static/js и scripts/
npm run lint:css   # stylelint по network/static/css/**/*.{scss,css}
npm run lint       # оба линтера
npm test           # jest, tests/js/**/*.test.js

Список копируемых файлов — массив assets в package.json. Путь, которому ничего не соответствует, роняет npm run assets с кодом возврата 1, а не пропускается молча.

Обновление фронтенд-зависимостей — ./soniks.sh update.

satellite.js намеренно остаётся на 6: седьмая версия выходит только как ES-модуль ("type": "module", без UMD-сборки и dist/satellite.min.js). Страницы грузят её обычным <script> и ждут глобальный satellite, компрессор склеивает её в бандл, а jest берёт через require(). Переход возможен вместе с бандлером, не раньше.

В CI линтеры, копирование ассетов и jest выполняет джоба js_css_linting на образе node:24-bookworm; её артефакт network/static/lib передаётся дальше в джобы build и test.

Сборка статики в контейнере

djangoctl.sh prepare (вызывается при старте web) выполняет:

  1. collectstatic --noinput --clear — собирает статику в STATIC_ROOT;

  2. compress --force — офлайн-сборка блоков django-compressor, только при COMPRESS_OFFLINE=True;

  3. migrate --noinput.

Предупреждение

--clear очищает STATIC_ROOT целиком при каждом старте. Файлы, положенные туда в обход collectstatic, не переживут перезапуск.

Сжатие управляется настройками COMPRESS_ENABLED, COMPRESS_OFFLINE, COMPRESS_CACHE_BACKEND. На сервере первые две включаются парой: без офлайн-сборки имена бандлов живут в кэше дольше, чем файлы, которые стирает --clear. По умолчанию обе выключены — так работают разработка и тесты.

Предупреждение

В {% block javascript %} и {% block css %} нет ни {% if %}, ни {{ }} — только {% static %} и {% include %}. base.html оборачивает эти блоки в {% compress %}, а офлайн-манифест ключуется тем, во что блок отрендерился: compress рендерит его один раз с пустым контекстом, и запрос, у которого вышло иначе, получает OfflineGenerationError — 500 вместо страницы. Условие переносится в сам скрипт (map-satellite.js выходит, если на странице нет #sat-map). Сторожат test_a_bundled_block_renders_the_same_for_every_request и OfflineCompressionTest.

Тег <script> вне этих блоков — отдельный запрос на каждой загрузке; все такие теги перечислены с причиной в tests/js/frontend_perf.test.js.

SCSS компилируется dart-sass: бинарь приезжает из пакета sass-embedded и кладётся образом на PATH; SASS_BINARY переопределяет путь.

Карты

Карты станций и наблюдений рендерятся через Mapbox: нужны MAPBOX_TOKEN и MAPBOX_MAP_ID. Без токена страницы работают, но карты не отображаются.

Локализация

Интерфейс на трёх языках: русский, английский, китайский (упрощённый). Весь текст, видимый пользователю, в шаблонах и моделях оборачивается в gettext_lazy:

from django.utils.translation import gettext_lazy as _

name = _("Наблюдения")

Каталоги переводов лежат в locale/ (LOCALE_PATHS): ru, en, zh_Hans. Исходные строки (msgid) — русские, поэтому ru заполнен только там, где msgid написан по-английски (приложение analytics, страницы админки).

Язык выбирается по поддомену: sonik.space — русский, en.sonik.space — английский, zh.sonik.space — китайский. Это делает SubdomainLanguageMiddleware (network/middleware.py); она стоит в MIDDLEWARE последней и перекрывает выбор LocaleMiddleware, так что /i18n/setlang/ на язык не влияет. Новый язык — строка в SubdomainLanguageMiddleware.LANGUAGES, строка в settings.LANGUAGES, ссылка в шапке (templates/base.html) и каталог в locale/; поддомен заводится в DNS и nginx отдельно, вне этого репозитория. JS-каталог отдаётся по /jsi18n/.

.po и скомпилированные .mo оба лежат в репозитории, CI их не пересобирает. Каталоги не правятся руками: после добавления или удаления строк в шаблонах и коде из корня репозитория запускается

make messages

— цель прогоняет makemessages по обоим доменам и следом compilemessages, остаётся закоммитить .po и .mo. Смысл цели — список --ignore: без него makemessages обходит node_modules, вендоренный JS в network/static/lib и сгенерированный API-клиент, и djangojs-каталог распухает на порядок. Правка .po в обход этого один раз уже развела каталоги в обе стороны сразу: 31 перевод удалённой разметки остался лежать мёртвым грузом, а две живые строки шаблонов в каталог так и не попали. Новый перевод после make messages дописывается в msgstr — и цель гоняется ещё раз, чтобы пересобрать .mo.

Что .mo не отстал от .po, что перевод не потерял плейсхолдеры, что в .mo не уехала fuzzy-догадка и что китайский каталог покрывает все msgid английского — стережёт network/base/test_locale_catalogs.py.

make messages запускается в том же коммите, что и правка строк. Функции, пропустившие его, к сентябрю 2026 года оставили форму конфигурации станции без единого перевода — 114 строк Django и 28 JS показывались по-русски на en. и zh.. Строка, которую makemessages не видит вовсе, не ловится и прогоном: кириллицу вне {% trans %} в шаблонах ищет test_no_russian_text_outside_a_translation_tag (network/base/test_template_markup.py), а gettext() над переменной вместо литерала — tests/js/autoschedule.test.js.

Комментарии и docstring’и в коде пишутся на английском, пользовательские строки — через перевод.

Политика безопасности контента

CSP задаётся настройками CSP_DEFAULT_SRC, CSP_SCRIPT_SRC, CSP_IMG_SRC, CSP_STYLE_SRC, CSP_WORKER_SRC, CSP_FRAME_SRC, CSP_CHILD_SRC, CSP_FRAME_ANCESTORS. При добавлении внешнего ресурса (карты, шрифты, аналитика) его источник нужно внести в соответствующую директиву, иначе браузер заблокирует загрузку.

Каждая переменная заменяет значение по умолчанию целиком, а не дополняет его. Практическое следствие: вкладка «Телеметрия» на странице спутника встраивает дашборд из Satellite.dashboard_url, и https://dashboard.sonik.space есть в умолчании CSP_FRAME_SRC — но окружение, которое эту переменную переопределяет, обязано перечислить хост само. Иначе вкладка покажет пустую рамку, и в логе сервера не будет ничего.

В script-src и style-src нет ни 'unsafe-inline', ни 'unsafe-eval', поэтому:

  • Никакого style="…" в разметке — ни в шаблоне, ни в строке, которую JS отдаёт в innerHTML/insertAdjacentHTML. Браузер такой атрибут отбрасывает молча, и nonce его не спасёт: nonce пропускает только элементы <style>. Гейт — test_no_inline_style_attribute (network/base/test_template_markup.py).

    • постоянное значение — класс в SCSS;

    • начальное скрытие — атрибут hidden (Bootstrap делает его display: none !important, поэтому показывать элемент надо снятием hidden, а не style.display);

    • вычисленная ширина или высота в процентах — data-width/data-height и apply_sizes(root) из app.js после вставки разметки. Для шаблонов его один раз вызывает сам app.js, для опций Tom Select — рендер в init_selects;

    • всё остальное — через el.style.x = … после вставки: CSSOM политика не ограничивает.

  • eval, new Function, строковый setTimeout не работают. mapbox-gl 2 без них обходится, его воркеру нужен только blob: в worker-src.

  • nonce выдаётся только wavesurfer. Плеер пишет свой <style> в shadow root; observation_view.html и vet_observation_container.html кладут nonce в <meta name="csp-nonce"> в блоке meta, audio.js передаёт его в cspNonce. В блок javascript его ставить нельзя: блок сжимается, а COMPRESS_OFFLINE не собирает разметку, которая меняется от запроса к запросу. Читать надо свойство .nonce, атрибут браузер очищает.