Модуль «Приложения»: хостинг Vue и Nuxt прямо из git

До этого размещение нового приложения выглядело так: написал Dockerfile, написал конфиг Traefik, положил в нужную папку, запустил docker compose up. Один раз — нормально. Когда проектов становится несколько, и у каждого своя ветка, свои переменные окружения и своя версия Node — управлять этим руками начинает раздражать. Я сделал механизм хостинга частью платформы, а для управления им — модуль «Приложения» в admin-панели.
Платформа сама клонирует репозиторий, устанавливает зависимости, собирает проект, упаковывает в Docker-образ и запускает контейнер. Traefik автоматически получает маршрут и начинает отдавать трафик на нужный домен. При следующем деплое старый контейнер останавливается, новый запускается — без простоя в пределах нескольких секунд. Можно включить автодеплой: платформа сама отслеживает ветку в git и пересобирает при появлении нового коммита.
Главный экран

Открывается модуль списком всех приложений в виде сетки карточек. Карточки появляются сразу — данные загружаются параллельно: список приложений и список git-доступов приходят одним запросом.
Каждая карточка — одно приложение. Слева — аватарка: по умолчанию это первая буква названия на цветном фоне. Цвет не случайный — он вычисляется из имени по детерминированному алгоритму, поэтому у одного и того же приложения всегда один и тот же оттенок при любых перезагрузках. Можно загрузить собственную аватарку — PNG, JPG, WebP или SVG до 2 МБ.
Справа от аватарки — три строки. Сверху имя приложения и маленькая цветная точка статуса. Зелёная — контейнер работает, синяя — идёт сборка, красная — последняя сборка упала, серая — остановлено. Под именем — тип приложения моноширинным шрифтом: vue-static, nuxt-static или nuxt-ssr. Внизу — домен приложения тоже моноширинным шрифтом, синим цветом.
Точка статуса живая: обновляется в реальном времени через WebSocket. Запустил пересборку на экране приложения, вернулся на список — точка сразу начинает моргать синим. Сборка завершилась успешно — без перезагрузки страницы переключается в зелёный. Если кто-то открыл второй браузер и добавил приложение — первый браузер автоматически подтянет новую карточку.
Кнопка «+ Добавить приложение» в шапке блока ведёт на форму создания. Клик по карточке — на экран управления конкретным приложением.
Git-доступы

Под карточками — блок git-доступов. Это хранилище учётных данных для приватных репозиториев. Поддерживаются два способа аутентификации: PAT-токен для HTTPS и SSH deploy key. Токен — это персональный токен GitHub или GitLab с правами на чтение репозитория. SSH deploy key — приватная часть пары ключей, которую добавляют в настройки репозитория.
Каждый доступ в таблице — название, тип и логин. Секрет — сам токен или приватный ключ — хранится в базе данных и никогда не возвращается на клиент после сохранения. Добавить новый доступ — кнопка «+ Добавить доступ». Откроется модальное окно: название, тип (выпадающий список), логин (для HTTPS) и поле для секрета. Секрет принимает как однострочный токен ghp_..., так и многострочный блок приватного SSH-ключа -----BEGIN OPENSSH PRIVATE KEY-----.... Удалить — крестик в строке, без отдельного подтверждения, но со всплывающим предупреждением что привязанные приложения потеряют доступ.
Смысл разделить доступы и приложения в том, что один токен можно использовать для нескольких репозиториев одной организации. Не нужно вводить один и тот же ключ заново для каждого приложения — выбрал из списка при создании и всё.
Шапка панели приложения

Клик по карточке открывает экран приложения. В левом верхнем углу — кнопка «←» для возврата к списку, оформленная как небольшая квадратная кнопка, а не просто ссылка — чтобы не теряться среди текста заголовка. Рядом — аватарка, уже крупнее, чем на карточке: 60 пикселей. Затем название и, если указано, описание под ним — второй строкой, серым. В правом краю строки — точка статуса.
Под шапкой — плашка со сводной информацией. Восемь ячеек в сетке: домен кликабельной ссылкой, тип, ветка, статус в виде цветного бейджа, флаг автодеплоя, дата последней сборки, размер Docker-образа в мегабайтах и uptime контейнера. Домен — именно ссылка, открывается в новой вкладке: удобно быстро проверить, что приложение отвечает после деплоя. Размер образа и uptime подтягиваются отдельным запросом к Docker API при открытии страницы — они не хранятся в базе и всегда актуальны.
Uptime отображается в человекочитаемом формате: «12 мин», «3 ч 45 мин», «2 дн 6 ч». Не просто дата запуска, из которой нужно самому считать сколько прошло.
Справа от сводки — кнопки «Пересобрать» и «Остановить». Пересборка — полный цикл с нуля: клонирование свежей версии ветки, npm ci, команда сборки, docker build, остановка старого контейнера, запуск нового. Пока хоть одна операция не завершена, обе кнопки блокируются и иконка на «Пересобрать» начинает вращаться. Статус приходит по WebSocket, поэтому разблокировка происходит автоматически — не нужно перезагружать страницу или ждать таймера.
Есть одна тонкость: между нажатием «Пересобрать» и первым статусным событием по WebSocket есть небольшой зазор — ответ API пришёл 202, но сборка ещё не началась. Чтобы кнопки не мигали разблокированными в этот момент, ставится внутренний флаг queued, который держит их заблокированными ровно до прихода первого обновления статуса.
Если последняя сборка завершилась с ошибкой, под сводкой появляется красный баннер с текстом ошибки — он берётся из поля lastError, куда пишется последняя строка stderr сборки.
Вкладка «Статистика»

Открывается по умолчанию при входе на экран приложения. Три временны́х ряда: CPU в процентах, RAM в мегабайтах и сетевой трафик. Метрики снимаются раз в 30 секунд со всех запущенных контейнеров через Docker Stats API и пишутся в базу данных.
Переключение периода — четыре кнопки: 1 час, 6 часов, 24 часа, 7 дней. Активная кнопка выделена акцентным цветом. При переключении данные загружаются заново, следующая точка из WebSocket добавляется к уже загруженным.
Графики построены на ECharts: плавные линии с заливкой под ними. CPU — сине-фиолетовый, RAM — зелёный. Оси и сетка намеренно приглушены под тёмную тему панели — тонкие полупрозрачные линии, не конкурирующие с данными. При наведении — тултип с временем и значением.
Сетевой трафик — два ряда на одном графике: входящий (синий) и исходящий (оранжевый). Docker считает трафик кумулятивными счётчиками от запуска контейнера, поэтому на клиенте берётся дельта между соседними точками и делится на интервал — получаем байты в секунду. При рестарте контейнера счётчик обнуляется, дельта уходит в отрицательную зону — такие выбросы молча пропускаются, чтобы не рисовать ложные пики.
Если данных за период нет — вместо пустого графика показывается плейсхолдер с пояснением: «Метрики собираются раз в 30 сек для запущенных приложений». Значит, либо контейнер остановлен, либо приложение создано только что и данных ещё нет.
Вкладка «Трафик»

Отдельная вкладка для HTTP-метрик. Они приходят не из Docker, а из Prometheus-экспортёра Traefik — это метрики на уровне прокси, не контейнера. Три графика: запросы в секунду, распределение кодов ответов и средняя задержка в миллисекундах.
График RPS — плавная линия, та же сине-фиолетовая заливка что и на CPU. Видно пики нагрузки и провалы ночью. Коды ответов — стекованные бары: 2xx зелёным, 3xx синим, 4xx оранжевым, 5xx красным. Из этого графика сразу видно, если вдруг начали расти 4xx или появились 5xx — без поднятия логов. Задержка — средняя по всем запросам за интервал, фиолетовая линия.
Тот же переключатель периода, что и на «Статистике»: 1 час, 6 часов, 24 часа, 7 дней.
Если к домену приложения ещё не приходил реальный трафик — Traefik не создаёт записей в Prometheus и данных нет. Вместо пустых графиков — плейсхолдер: «Метрики Traefik накапливаются после первых запросов к домену приложения». Для нового приложения это нормально.
Вкладка «Сборка»

Полный лог последней сборки. Это именно то, что летит в консоль при запуске npm ci && npm run build — вывод в реальном времени прямо в браузер через WebSocket, без перезагрузки страницы.
Лог рендерится в блоке с тёмно-синим фоном моноширинным шрифтом. ANSI-escape-последовательности обрабатываются корректно: цветные строки Vite, прогресс-бары Nuxt, зелёные ✓ npm — всё отображается так же, как в нормальном терминале. Под капотом это библиотека ansi_up, которая конвертирует ANSI-коды в безопасный HTML без XSS.
Автоскролл работает умно. Пока пользователь находится у нижнего края лога — новые строки подтягиваются туда автоматически. Стоит проскроллить вверх, чтобы прочитать что-то раньше — автоскролл выключается, лог перестаёт прыгать. Вернулся к самому низу — снова включается. Порог срабатывания — 24 пикселя от нижнего края, чтобы не выключаться от одного случайного движения колеса мыши. Если автоскролл выключен, над логом появляется подсказка «автоскролл выключен — листайте вниз, чтобы включить».
Над логом — тулбар с двумя кнопками. «Скопировать» — копирует текст лога в буфер обмена, предварительно вырезая все ANSI-управляющие символы. Пригодится, если нужно вставить чистый текст куда-то ещё. После копирования надпись на кнопке меняется на «Скопировано ✓» на полторы секунды. «Очистить» — стирает лог с подтверждением. Полезно, когда накопилось несколько сборок и нужно начать с чистого листа перед следующей.
Вкладка «Логи рантайма»

Live-поток из контейнера: то, что приложение пишет в stdout и stderr во время работы. Эквивалент docker logs --follow — но прямо в браузере, без открытия терминала.
При переходе на вкладку бэкенд включает стриминг: начинает читать поток Docker и проталкивать строки через WebSocket. При уходе с вкладки стриминг выключается — не гоняем трафик впустую. Всё это происходит мгновенно: переключил вкладку — лог пошёл, переключил обратно на «Статистику» — остановился.
Тот же автоскролл и тот же тулбар, что на вкладке «Сборка». Кнопка очистки здесь не нужна — рантайм-лог не хранится в базе, он идёт только через WebSocket. Кнопка «Скопировать» работает так же: чистый текст без ANSI в буфер.
Это особенно удобно для SSR-приложений на Nuxt: видно серверные ошибки, запросы, предупреждения гидратации — всё в реальном времени без переключения в терминал.
Вкладка «Настройки»

Вкладка делится на три части: аватарка, форма настроек и зона удаления.
Аватарка
Небольшой блок вверху: слева — превью аватарки 64×64 с скруглёнными углами и лёгкой тенью, справа — кнопки «Загрузить» и «Убрать». Загрузка открывает системный диалог выбора файла. Принимаются PNG, JPG, WebP и SVG до 2 МБ. Загруженное изображение сохраняется на сервере и отображается на карточке в списке и в шапке этого экрана. «Убрать» — удалить загруженную аватарку и вернуться к букве на цветном фоне.
Форма настроек
Форма та же, что и при создании приложения — просто заполненная текущими значениями. Поля разбиты на смысловые группы.
Имя и тип — первая строка. Тип выбирается из трёх вариантов: «Vue (статика)», «Nuxt (статика, generate)» и «Nuxt (SSR, Node)». При смене типа команда сборки, каталог вывода и порт автоматически переключаются на стандартные значения для этого типа — npm run build / dist / 80 для Vue, npm run generate / .output/public / 80 для Nuxt static, npm run build / .output / 3000 для Nuxt SSR. Но только в том случае, если значения ещё не редактировались вручную: если поле buildCmd уже содержит что-то нестандартное — трогать его не будем.
Описание — необязательное текстовое поле. Если заполнено, показывается под именем в шапке экрана.
URL репозитория и домен — обязательные поля. Домен вводится без схемы: app.example.ru, а не https://app.example.ru.
Git-доступ — выпадающий список. Сверху — пункт «Публичный (без доступа)» для открытых репозиториев, ниже — все сохранённые git-доступы с именем и типом в скобках. Если репозиторий приватный и доступа нет — форма сохранится, но сборка упадёт при клонировании.
Ветка — поле с кнопкой «Загрузить ветки». По умолчанию ввод вручную: просто написал master или main. Нажал кнопку — форма отправляет запрос на сервер, тот делает git ls-remote к репозиторию с выбранным git-доступом и возвращает список веток. После этого поле переключается с текстового ввода на выпадающий список из реальных веток. Если репозиторий недоступен или URL неверный — под полем появляется сообщение об ошибке. Кнопка меняется на «↻ Обновить», если ветки уже загружались.
Автодеплой — переключатель с пояснением. Когда включён, платформа с заданным интервалом проверяет HEAD ветки через git ls-remote и, если появился новый коммит, запускает пересборку. Интервал не захардкожен: берётся из переменной окружения оркестратора AUTODEPLOY_POLL_MS. Форма при загрузке запрашивает системную информацию через API и подставляет реальное значение в подсказку — «Платформа раз в 5 мин проверяет ветку...». Если переменная не задана — дефолт пять минут.
Сборочные параметры — Node.js версия (18, 20, 22 в выпадающем списке), порт, лимит оперативной памяти контейнера (например, 1536m), команда установки зависимостей (по умолчанию npm ci) и команда сборки (по умолчанию npm run build или npm run generate в зависимости от типа).
Каталог сборки — куда Vite или Nuxt кладёт готовые файлы: dist, .output/public или .output. Платформа монтирует именно этот каталог в nginx-контейнер для статики или запускает из него Node для SSR.
Переменные окружения
Два отдельных блока — Build env и Runtime env — каждый в рамке с заголовком.
Build env — это переменные, которые запекаются в бандл на этапе npm run build. Vite берёт всё, что начинается на VITE_, и подставляет значения прямо в код при сборке. Nuxt делает то же для NUXT_PUBLIC_*. Если нужно передать URL API или флаг фичи в клиентский код — это сюда. После сборки значения буквально вшиты в JS-файлы.
Runtime env — переменные, которые передаются контейнеру при запуске через docker run -e. Нужны для SSR: секреты, ключи API, строки подключения к базе, которые не должны попасть в бандл и публичный JS. Nuxt в SSR-режиме читает их через process.env на сервере.
Оба блока работают одинаково. Список переменных — таблица с двумя колонками: имя и значение. Если переменных нет — пустое состояние с примером: «Нет переменных. Пример: VITE_API_URL=https://…». Кнопка «+ Добавить» в заголовке блока открывает модальное окно.
Модальное окно — два поля: имя и значение. Имя валидируется: только буквы, цифры и подчёркивание, должно начинаться с буквы или подчёркивания. При редактировании существующей переменной имя заблокировано для изменения — чтобы не создавать дубли. В каждой строке таблицы — кнопка редактирования (карандаш) и кнопка удаления (крестик). Закрыть модалку можно кликом на фон или по Escape.
Если имя переменной содержит одно из слов TOKEN, SECRET, PASSWORD, KEY, PRIVATE, CREDENTIAL, AUTH, CERT или SALT — она автоматически помечается как чувствительная. В таблице вместо полного значения показываются первые четыре символа и многоточие. Рядом появляется кнопка-замок: нажал — значение раскрылось для этой сессии, нажал снова — скрылось обратно. Это не шифрование на сервере, а просто защита от случайного взгляда на экран.
При любом изменении формы — смене поля, добавлении переменной — форма помечается как «грязная». Если попытаться уйти со страницы, не сохранив, появится стандартный браузерный диалог с предупреждением. При нажатии «Сохранить» форма уходит на сервер, и если всё прошло успешно — помечается «ОК», предупреждение при уходе не показывается.
Удаление приложения
Внизу — блок с красной рамкой и красным фоном. Название «Удалить приложение» красным цветом, под ним серая подсказка о последствиях. Кнопка «Удалить приложение» справа.
При нажатии появляется браузерный диалог подтверждения с именем приложения и доменом: «Удалить «toolz» (toolz.example.ru)? Контейнер, образ и все связанные данные будут удалены.» Подтвердил — приложение удаляется полностью: контейнер останавливается, образ удаляется из Docker, запись удаляется из базы, задача автодеплоя отменяется. После этого происходит редирект на список приложений.
Обновдённый Dashboard

Так же был обновлён общий Dashboard, добалвен блок со статусами всех приложений.