commit 191b80b6b139ff43611b7abc56e754d95ae05a90 Author: Zakhar Timoshenko Date: Tue Oct 13 02:24:39 2026 +0300 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8c0eb53 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +dist +data +.git +.env +.env.* +playwright-report +test-results +artifacts +test +README.md +*.tsbuildinfo diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..63be8cd --- /dev/null +++ b/.env.example @@ -0,0 +1,50 @@ +# Node.js 24+. All five services have public HTTPS defaults in config/services.json. +PORT=5188 +HOST=0.0.0.0 +STATUS_DB_PATH=./data/status.sqlite +STATUS_INTERVAL_SECONDS=60 +STATUS_TIMEOUT_MS=5000 +STATUS_SLOW_MS=1500 +STATUS_FAILURE_THRESHOLD=3 +STATUS_RECOVERY_THRESHOLD=2 +STATUS_RETENTION_DAYS=90 +STATUS_SCENARIO_INTERVAL_SECONDS=300 +STATUS_SCENARIO_TIMEOUT_MS=20000 +STATUS_SCENARIO_SLOW_MS=30000 +STATUS_BROWSER_CHECKS=true +# Defaults to installed Chrome on macOS, or Playwright Chromium on hosting. +# STATUS_BROWSER_EXECUTABLE_PATH=/path/to/chromium + +# Optional per-service probe URL overrides. These values stay on the server. +# STATUS_WEBSITE_URL=https://ridebus.by/ +# STATUS_ADMIN_URL=https://admin.ridebus.by/ +# STATUS_PWA_URL=https://pwa.ridebus.by/ +# STATUS_API_URL=https://api.ridebus.by/health +# STATUS_BOARD_URL=https://board.ridebus.by/ + +# Optional functional API probe in addition to /health (never sent to the browser). +# RIDEBUS_API_KEY= +# STATUS_API_DATA_URL=https://api.ridebus.by/api/v1/cities +# STATUS_TEST_CITY_ID=1 +# STATUS_API_TEST_STOP_ID=1 +# STATUS_PWA_TEST_STOP_ID=1 +# Only for a standalone board; paired boards use their assigned city and stop. +# STATUS_BOARD_TEST_STOP_ID=1 +# Optional Cookie header from a paired test device: name=value; another=value +# Keep the full header value in quotes, never copy production-device credentials. +# STATUS_BOARD_TEST_COOKIE= +# Scenario origins can be overridden independently of basic probes. +# STATUS_API_SCENARIO_BASE_URL=https://api.ridebus.by/ +# STATUS_PWA_SCENARIO_BASE_URL=https://pwa.ridebus.by/ +# STATUS_BOARD_SCENARIO_BASE_URL=https://board.ridebus.by/ + +# Alerts remain disabled until both settings are present. Keep them server-side. +# TELEGRAM_BOT_TOKEN= +# TELEGRAM_CHAT_ID= +STATUS_NOTIFY_LANG=ru +STATUS_TIMEZONE=Europe/Minsk +# Set after selecting hosting, for canonical links in RSS and notification messages. +# STATUS_PUBLIC_URL=https://your-status-domain.example/ + +# Relative or absolute path to a replacement service configuration. +# STATUS_SERVICES_FILE=./config/services.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7eb150f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +.env +.env.* +!.env.example +data/ +*.tsbuildinfo +playwright-report/ +test-results/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..99542ce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM node:24-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY index.html tsconfig.json vite.config.ts ./ +COPY src ./src +COPY shared ./shared +COPY public ./public +RUN npm run build + +FROM node:24-bookworm-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production PORT=5188 HOST=0.0.0.0 STATUS_DB_PATH=/app/data/status.sqlite +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund && npx playwright install --with-deps chromium && chmod -R a+rX /opt/playwright +COPY --from=build /app/dist ./dist +COPY server ./server +COPY shared ./shared +COPY config ./config +RUN mkdir -p /app/data && chown -R node:node /app/data +USER node +EXPOSE 5188 +VOLUME /app/data +HEALTHCHECK --interval=30s --timeout=3s --start-period=15s CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +CMD ["node", "server/index.mjs"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b23fb5f --- /dev/null +++ b/README.md @@ -0,0 +1,208 @@ +# RideBus Status + +Независимый сайт статуса RideBus в визуальном стиле `ridebus-pwa` и `ridebus-website`: Manrope, общая палитра, логотип, округлые карточки, иконки `@vkontakte/icons`, светлая и тёмная темы, русский и английский языки. + +## Запуск + +Нужен **Node.js 24+**. SQLite встроен в Node; внешняя база для первого запуска не требуется. + +```sh +npm ci +cp .env.example .env +npm run dev +``` + +Для браузерных проверок нужен Chromium: `npm run browsers:install`. На macOS автоматически используется установленный Google Chrome; на другом хостинге можно указать `STATUS_BROWSER_EXECUTABLE_PATH`. Dockerfile устанавливает Chromium и системные зависимости. Если браузер недоступен, сценарий становится непроверенным, а доступность страницы остаётся отдельным показателем. + +Страница: `http://localhost:5188`. Все пять реальных адресов уже настроены. Проверки выполняются сервером даже без открытого браузера. История начинается с первого запуска; пропущенные дни остаются без данных. + +```sh +npm run build +npm start +``` + +В production один Node-процесс обслуживает собранный интерфейс, публичный API и мониторинг. При завершении процесса очередная проверка дожидается окончания, затем закрывается база. + +## Сервисы и настройки + +| Сервис | Проверка по умолчанию | +| --- | --- | +| API | `https://api.ridebus.by/health`: HTTP 200 и JSON `status: healthy` | +| Сайт | `https://ridebus.by/`: HTTP 200 и HTML | +| PWA | `https://pwa.ridebus.by/`: HTTP 200 и HTML | +| Табло | `https://board.ridebus.by/`: HTTP 200 и HTML | +| Админка | `https://admin.ridebus.by/`: HTTP 200 и HTML | + +`config/services.json` позволяет добавлять сервисы, задавать переводы, публичные ссылки и правила проверки. Поддерживаются виды `html`, `json`, `http`; для JSON — `expectedJson: { "path": "status", "value": "healthy" }`. Для защищённого запроса укажите `headerEnv: { "X-API-Key": "MY_SECRET_VARIABLE" }`, а значение храните в переменной окружения. + +URL можно переопределить через `STATUS_API_URL`, `STATUS_WEBSITE_URL`, `STATUS_PWA_URL`, `STATUS_BOARD_URL`, `STATUS_ADMIN_URL`. Значение `off` отключает проверки сервиса и показывает отсутствие данных. Публичная ссылка остаётся из конфигурации. Любые URL проверок и секретные заголовки остаются на сервере; публичный API возвращает только безопасную сводку. При перенаправлении на другой origin пользовательские заголовки удаляются. + +Для дополнительной проверки данных API задайте `RIDEBUS_API_KEY`. Тогда кроме `/health` проверяется непустой список городов по `STATUS_API_DATA_URL` (по умолчанию `/api/v1/cities`). Без ключа проверяется только здоровье API: это **не проверка подключения к БД**. HTML-проверки подтверждают загрузку страницы, а не полный пользовательский сценарий, авторизацию или получение отправлений. + +### Пользовательские сценарии + +В конфигурации для API, PWA и табло задан `scenario`. Их проверки выполняются отдельным циклом раз в 300 секунд и не задерживают обычные проверки доступности: + +- **API:** с серверным `RIDEBUS_API_KEY` получить города, остановки выбранного города и ETA контрольной остановки. `X-City-Id` отправляется только городским запросам. +- **PWA:** через публичный `/api/data` получить города, остановки и отправления, затем открыть приложение с выбранной остановкой в новом браузерном контексте и дождаться отображения данных. +- **Табло:** получить `/api/config`, остановки и `/api/board`, затем проверить отображение в браузере. Если табло требует сопряжения, нужна cookie **отдельного существующего тестового устройства** в `STATUS_BOARD_TEST_COOKIE`. С этой cookie монитор также получает `/api/device/status` и использует город и остановку, назначенные устройству в админке. Общая `/api/config` содержит настройки автономного табло и может указывать другую остановку. Отсутствие доступа, незавершённое сопряжение или отозванное устройство отмечаются как непроверенный сценарий, не как сбой табло. + +Контрольный город выбирается автоматически (Полоцк при наличии), остановка — из каталога. Можно закрепить их через `STATUS_TEST_CITY_ID` и `STATUS_API_TEST_STOP_ID`, `STATUS_PWA_TEST_STOP_ID`, `STATUS_BOARD_TEST_STOP_ID`. Для сопряжённого табло город и остановка всегда берутся из настроек устройства; `STATUS_BOARD_TEST_STOP_ID` применяется только к автономному табло. Пустые ближайшие отправления, в том числе ночью, допустимы. Некорректный ответ, ошибки маршрутов или неполные данные проваливают проверку. + +В `STATUS_BOARD_TEST_COOKIE` нужно значение заголовка Cookie целиком: `"имя=value; другое_имя=value"`, без префикса `Cookie:`. Храните его в кавычках в `.env`; в чат и публичную конфигурацию cookie не добавляйте. После добавления или обновления перезапустите сервер. Истёкший тестовый сеанс снова отображается как неподключённая проверка. + +Переход к проверке настроек сопряжённого устройства начинает отдельную историю сценария табло. Результаты прежней проверки общей остановки сохраняются в базе, но не считаются сбоями нового сценария; история обычной доступности сохраняется. + +Браузерные контексты каждый раз новые: сохранённое расписание и service worker не маскируют проблему. Сеанс тестового табло загружается через `browserContext.addCookies()`. POST/PUT/PATCH/DELETE заблокированы, сопряжение новых устройств не выполняется; при использовании cookie запросы на другой origin также блокируются. Проверки браузера используют `prefers-reduced-motion: reduce` и включённый sandbox Chromium. PWA/табло проверяются без авторизации администратора; вход и CRUD админки не тестируются. + +Интерфейс отдельно показывает доступность и результат сценария, этапы, время и причину неподключённой проверки. Подтверждённая проблема сценария создаёт инцидент. Проценты и дневные графики по-прежнему описывают обычные проверки доступности; успешный HTTP не делает сценарий автоматически рабочим. + +`STATUS_BROWSER_CHECKS=off` оставляет проверки данных без проверки отображения и явно обозначается в подробностях. Настройки интервала, таймаута браузера и порога медленного полного сценария: `STATUS_SCENARIO_INTERVAL_SECONDS`, `STATUS_SCENARIO_TIMEOUT_MS`, `STATUS_SCENARIO_SLOW_MS`. Изменение `.env` требует перезапуска процесса. + +По умолчанию: интервал 60 секунд, таймаут 5 секунд, замедление выше 1500 мс, сбой после 3 неудачных проверок подряд, восстановление после 2 успешных. Первая неудача отображается как перебои. При отсутствии свежей проверки более 3 интервалов статус становится неизвестным, в том числе в уже открытом браузере. + +Проверки выполняются параллельно, новый цикл не накладывается на предыдущий. Следующий цикл запускается через заданный интервал после окончания текущего. Кнопка обновления перечитывает сводку, не позволяет посетителям запускать дополнительные запросы к сервисам. Общий статус становится полностью рабочим только при свежих успешных данных по всем сервисам. + +## История и инциденты + +SQLite находится в `data/status.sqlite` (настраивается `STATUS_DB_PATH`). Проверки хранятся 90 дней. Графики показывают 7/30/90 календарных дней по UTC; конкретное время отображается в часовом поясе посетителя. Процент доступности — **доля успешных выполненных проверок**, а не SLA по длительности: периоды без мониторинга исключены. Частично покрытые дни заштрихованы. Изменение URL или правил создаёт новую историю для этого сервиса. + +Подтверждённый сбой автоматически создаёт инцидент, восстановление закрывает его. Реальные инциденты не означают, что команда уже взяла проблему в работу. Ручные сообщения доступны через CLI на хостинге, без публичного административного API: + +```sh +npm run incident -- list +npm run incident -- create service=api "title-ru=Проверяем задержки API" "title-en=Investigating API delays" "message-ru=Команда проверяет причину задержек." "message-en=The team is investigating response delays." +npm run incident -- resolve 1 +``` + +Параметр `service` можно опустить для общего сообщения. CLI использует ту же БД и переменные окружения. При запуске в контейнере: `docker compose exec status node server/incidents-cli.mjs list`. + +### Влияние и хронология + +Для сбоев карточка и подробности объясняют, что ограничено для пассажира: загрузка приложения, расписания или отправлений. Тексты RU/EN задаются в `impact` у сервиса в `config/services.json`. Сохранённые данные описаны как возможная помощь, с предупреждением об их актуальности. Альтернативный сервис предлагается только при его текущем рабочем статусе. Непроверенный сценарий и отсутствие тестового доступа не считаются подтверждённым сбоем. + +Инцидент сохраняет начальное сообщение и все последующие обновления. Автоматическое обнаружение и восстановление подписаны как результаты мониторинга. Стадии работы команды добавляются явно через CLI: + +```sh +npm run incident -- update 1 stage=investigating "message-ru=Проверяем загрузку расписания." "message-en=Investigating timetable loading." +npm run incident -- update 1 stage=identified "message-ru=Причина найдена, готовим исправление." "message-en=The cause is identified; preparing a fix." +npm run incident -- update 1 stage=monitoring "message-ru=Исправление применено, наблюдаем." "message-en=The fix is applied; monitoring." +``` + +Обновить можно ручной или автоматически созданный инцидент. Автоматический инцидент закрывается только после подтверждённого восстановления проверок; ручные стадии не изменяют результаты мониторинга. Ручной инцидент можно закрыть с пояснением: + +```sh +npm run incident -- resolve 1 "message-ru=Работа восстановлена. Причина — ошибка источника данных." "message-en=Service restored. The cause was a data source error." +``` + +После закрытия любого инцидента можно добавить отдельный разбор, не меняя время восстановления: + +```sh +npm run incident -- update 1 stage=postmortem "message-ru=Причина и меры для предотвращения повторения." "message-en=The cause and measures to prevent recurrence." +``` + +Публичного API записи нет. Старая история получает только события, уже зафиксированные в БД; промежуточные действия команды не придумываются. + +### Расширенная статистика + +Блок «Работа в цифрах» на главной странице позволяет выбрать сервис. Период 7/30/90 календарных дней задаётся общим переключателем истории. В подробностях сервиса также показаны длительность сбоев и последнее восстановление. + +- **Время сбоев:** измеренная длительность автоматически подтверждённых инцидентов доступности и пользовательских сценариев в выбранном периоде. Начало — момент подтверждения, окончание — подтверждённое восстановление. Пересечения двух видов проверок считаются один раз. +- **Самый долгий инцидент:** наибольшая измеренная длительность одного инцидента в выбранном периоде. Ручные сообщения не являются измеренными сбоями. +- **Последнее восстановление:** последнее автоматическое восстановление по текущим настройкам проверок, в том числе за пределами выбранного периода. Указано, восстановилась доступность или сценарий. При отсутствии записи показано «Не зафиксировано». +- **Покрытие времени:** доля периода со свежими результатами всех подключённых видов проверок. Для сценария без тестового доступа покрытие отсутствует. Непроверенный сценарий не получает фиктивные `0` сбоев. + +Длительность оценивается по сохранённым результатам: состояние действует до следующей проверки, максимум до границы свежести в 3 интервала. Плановые окна и более длинные пропуски исключены. Если часть инцидента не покрыта проверками, `≥` обозначает минимальную измеренную длительность. Это не SLA и не точное время начала сетевой аварии. + +Для сценариев добавлена отдельная история `scenario_checks`. Старые последние состояния и инциденты сохраняются; отсутствовавшие измерения не восстанавливаются предположениями. Эта часть статистики начинает накапливаться после обновления. История сценариев очищается с той же политикой хранения, что и обычные проверки. + +График отклика переключается между 24 часами и выбранным периодом. Ползунок показывает конкретный час или день, среднее время ответа и число проверок; пробелы остаются пробелами. Среднее и 95-й процентиль рассчитаны по успешным обычным проверкам. Изменение относительно предыдущего периода показывается только при достаточном количестве измерений и заполнении обоих окон. Для сравнения двух полных 90-дневных окон потребуется хранить не менее 180 дней (`STATUS_RETENTION_DAYS=180`); текущая политика по умолчанию остаётся 90 дней. + +Сводка имеет внутренний кэш до 5 секунд, сбрасываемый при новых проверках и изменениях SQLite через CLI. Публичные HTTP-ответы по-прежнему `Cache-Control: no-store`. + +### RSS для посетителей + +На странице «Подписаться на обновления» выберите сервисы, язык и типы событий, затем скопируйте ссылку в своё RSS-приложение. Лента включает каждое обновление инцидента, разборы, анонсы работ, начало и окончание планового окна, отмену и завершение командой. Окончание окна не означает фактического завершения работ. + +- `/feed.xml` — все сервисы и оба типа событий, RU по умолчанию. +- `/feed.xml?lang=en&services=api,pwa&types=incidents` — инциденты API/PWA на английском. +- `/feed.xml?lang=ru&services=board&types=maintenance` — работы табло. + +Общие сообщения без конкретного сервиса включаются в любую выбранную ленту. Возвращаются последние 100 событий с постоянными GUID; повторное чтение не создаёт новое событие. Неизвестные сервисы, типы и пустой выбор отклоняются. RSS всегда содержит реальные данные; `demo=1` в ленте не поддерживается. Регистрации и серверной базы подписчиков нет. Частоту получения определяет RSS-приложение посетителя. + +Задайте `STATUS_PUBLIC_URL` после выбора домена: он используется для постоянных ссылок в RSS и Telegram. За reverse proxy это должен быть публичный HTTPS-адрес сайта статуса. XML экранируется, тексты внутри описаний также экранируются для HTML в RSS-приложениях. + +### Плановые работы + +Работы хранятся отдельно от инцидентов. Укажите тексты RU/EN, сервисы и точное плановое окно с часовым поясом: + +```sh +npm run maintenance -- schedule services=api,pwa starts=2026-10-13T02:00:00+03:00 ends=2026-10-13T02:30:00+03:00 "title-ru=Обслуживание API" "title-en=API maintenance" "message-ru=Возможны короткие перерывы в загрузке расписания." "message-en=Brief interruptions to timetable loading are expected." +npm run maintenance -- list +npm run maintenance -- finish 1 +npm run maintenance -- cancel 2 +``` + +Это пример команды, а не созданный анонс. Если `services` опущен, окно относится ко всем сервисам. `finish` завершает уже начавшиеся работы, `cancel` отменяет ещё не истёкшее окно. По окончании планового времени показывается «Плановое окно закончилось»; это не заявление команды о фактическом завершении работ. + +Во время активного окна соответствующие карточки получают статус обслуживания. Проверки продолжаются, неудачные результаты остаются в истории и влияют на её процент. Ожидаемая недоступность не открывает новый инцидент и не создаёт уведомление. После окна подтверждение нового сбоя начинается заново. Уже существовавший инцидент закрывается только при настоящем восстановлении. Старая БД обновляется автоматически, без удаления истории. + +### Уведомления владельцу в Telegram + +Отправка выключена, пока не заданы **обе** переменные `TELEGRAM_BOT_TOKEN` и `TELEGRAM_CHAT_ID` в `.env` или секретах хостинга. Бот должен иметь доступ к выбранному чату; для личного чата сначала откройте бота и нажмите Start. Добавьте `STATUS_PUBLIC_URL` после выбора хостинга, чтобы сообщения содержали ссылку на сайт. Язык и часовой пояс: `STATUS_NOTIFY_LANG`, `STATUS_TIMEZONE`. + +```sh +npm run notify -- status +npm run notify -- test +``` + +`test` **отправляет реальное тестовое сообщение** в настроенный чат. Проверка настройки самостоятельно ничего не отправляет. Уведомления создаются только для подтверждённого сбоя и восстановления доступности/сценария. Новых подписок посетителей этот этап не добавляет. + +Очередь сохраняется в SQLite и продолжает доставку после перезапуска. При сетевых ошибках, Telegram 5xx или ограничении частоты отправка повторяется с задержкой, с учётом `retry_after`; восстановление не обгоняет уведомление о своём сбое. После 8 попыток запись помечается как неотправленная. Неактуальные события старше суток или от прежней конфигурации проверки не отправляются. Сбой доставки не останавливает мониторинг. Сетевой разрыв после приёма сообщения Telegram может привести к повторной доставке. + +Секреты, cookie, ответы транспортного API и детали ошибок Telegram не попадают в публичную сводку, логи или сообщения. Автоматические тесты Telegram используют подставной транспорт. + +`/?demo=1` — явно обозначенный пример с тестовыми данными. Он не пишет в рабочую БД и не выдаёт показатели за реальные. Основная страница всегда открывает реальные статусы. Настройки языка и темы сохраняются только локально в браузере; аналитики и сторонних трекеров нет. + +Нижняя панель повторяет разметку, типографику, отступы и адаптацию `ridebus-pwa`; подпись — «Город ближе, когда всё работает». В обычной production-странице ссылка на пример скрыта. В разработке она доступна, а в явно открытой демонстрации остаётся возврат к реальным статусам. RSS и публичная JSON-сводка доступны из раздела «О мониторинге»; подписка также доступна из раздела инцидентов. + +## Другой хостинг + +Проект не привязан к Sites или инфраструктуре RideBus. Нужен хостинг, который поддерживает **постоянно работающий Node.js 24+ или Docker**, Chromium с sandbox, исходящий HTTPS и **постоянный диск**. Задайте команду сборки `npm ci && npm run build`, установите Chromium (`npx playwright install --with-deps chromium`, если он не установлен), команду запуска `npm start`, платформенный `PORT`, `HOST=0.0.0.0` и путь `STATUS_DB_PATH` внутри постоянного диска. HTTPS обеспечивается хостингом. + +Используйте **один экземпляр** процесса для этой SQLite-БД. Не размещайте базу на сетевой файловой системе. Бесплатный тариф, который усыпляет процесс, оставит пробелы в мониторинге. Чисто статический или обычный serverless-хостинг потребует другого backend/планировщика и базы: фоновые проверки этой реализации там не выполняются. + +### Netlify + +Для **всего текущего проекта** Netlify не является прямой заменой Node/Docker-хостингу. [Functions выполняются во временной среде](https://docs.netlify.com/build/functions/overview/): текущий цикл таймеров и локальный SQLite-файл не являются постоянными сервисом и хранилищем. [Scheduled Functions ограничены 30 секундами](https://docs.netlify.com/build/functions/scheduled-functions/); [Background Functions — 15 минутами](https://docs.netlify.com/build/functions/background-functions/), поэтому они также требуют отдельного запуска и архитектуры мониторинга. + +Netlify подходит для интерфейса `dist`, если мониторинг, SQLite и Chromium работают на отдельном независимом хостинге. Потребуется направить `/api/status` и `/feed.xml` на этот backend; простая публикация `dist` без него не даст работающие статусы и RSS. Полный вариант на Netlify возможен после переноса хранилища, например в [Netlify Database / PostgreSQL](https://docs.netlify.com/build/data-and-storage/netlify-database/), и адаптации проверок, блокировок и очереди к отдельным функциям. Такая миграция в этот этап не входит. + +Для первого полноценного запуска проще разместить весь проект на независимом хостинге с постоянным Node-процессом/контейнером и диском. Netlify-конфигурация и публикация пока не создавались. + +Docker: + +```sh +docker compose up -d --build +``` + +История сохраняется в volume `status-data`. Не удаляйте volume при обновлении. Для резервной копии используйте SQLite backup либо остановите процесс и скопируйте БД вместе с WAL/SHM-файлами. Размещайте мониторинг на независимом от сервисов RideBus хостинге. + +Compose запускает контейнер от пользователя `node`, использует init, отдельную shared memory и [официальный seccomp-профиль Playwright](https://github.com/microsoft/playwright/blob/v1.63.0/utils/docker/seccomp_profile.json) для sandbox Chromium. Профиль и его лицензия лежат в `deploy/`. На Linux-хосте должны быть разрешены непривилегированные user namespaces; запуск контейнера и браузера нужно проверить на выбранном хостинге. При недоступном sandbox сайт продолжит обычные HTTP-проверки и покажет непроверенный сценарий. + +## API и проверка + +- `GET /healthz`: здоровье самого приложения статуса, HTTP 200 независимо от сбоев RideBus. +- `GET /api/status?range=30`: реальная сводка без секретов, `Cache-Control: no-store`. +- `GET /api/status?range=30&demo=1`: явно помеченный пример. Поддерживаются только 7/30/90 дней. +- `GET /feed.xml?lang=ru&services=api,pwa&types=incidents,maintenance`: RSS 2.0 реальных событий, только GET/HEAD, `Cache-Control: no-store`. + +```sh +npm test +npm run typecheck +npm run build +``` + +Серверные тесты проверяют реальные локальные HTTP-маршруты, подтверждение сбоев и восстановления, сохранение SQLite, отсутствие секретов в публичном ответе, таймауты и пробелы в истории. Для проверки интерфейса можно использовать реальные статусы и явно обозначенный пример `/?demo=1`. + +Дополнительно проверяются плановые окна и миграция БД, сценарии данных, очередь и повторы Telegram. Браузерный тест использует настоящий Chromium/Chrome, локальный стенд, запрет записывающих запросов и CSS reduced motion; без установленного браузера он явно пропускается. Микроанимации интерфейса повторяют длительности и кривую движения из `ridebus-pwa`: появления, отклик на нажатие, стрелки ссылок и диалоги. При уменьшенном движении анимации и смещения отключены. diff --git a/artifacts/desktop-live.jpg b/artifacts/desktop-live.jpg new file mode 100644 index 0000000..48e5f97 Binary files /dev/null and b/artifacts/desktop-live.jpg differ diff --git a/artifacts/expanded-statistics-desktop.jpg b/artifacts/expanded-statistics-desktop.jpg new file mode 100644 index 0000000..5a1c788 Binary files /dev/null and b/artifacts/expanded-statistics-desktop.jpg differ diff --git a/artifacts/expanded-statistics-mobile.jpg b/artifacts/expanded-statistics-mobile.jpg new file mode 100644 index 0000000..20e47af Binary files /dev/null and b/artifacts/expanded-statistics-mobile.jpg differ diff --git a/artifacts/expanded-statistics-verification.md b/artifacts/expanded-statistics-verification.md new file mode 100644 index 0000000..9008ff1 --- /dev/null +++ b/artifacts/expanded-statistics-verification.md @@ -0,0 +1,27 @@ +# Нижняя панель и расширенная статистика + +Дата: 13 октября 2026, Europe/Minsk. + +## Интерфейс + +Нижняя панель использует структуру и размеры footer из `ridebus-pwa/src/App.tsx` и его стилей: текст ridebus, подпись 13px, ссылки 14px, отступы 25px 0 34px; на мобильном экране — 24px 0 20px. Подпись: «Город ближе, когда всё работает.» Действия: «О мониторинге» и «Сайт RideBus». В production вход в демо скрыт; прямой деморежим остаётся явно обозначенным и позволяет вернуться к мониторингу. + +Убраны подписи «Реальные проверки» и «Обновляется автоматически». RSS и JSON доступны на странице описания мониторинга. Сохранены микроанимации и поддержка reduced motion. + +Добавлены длительность подтверждённых сбоев, длительность самого долгого инцидента, последнее восстановление, среднее время ответа, P95 и сравнение с предыдущим периодом. Статистика доступна для каждого сервиса и в его подробностях. На графике можно выбрать точку; пропуски измерений отображаются явно. + +Проверены desktop 1280px, mobile 390px, английский и русский языки, тёмная тема, отсутствие горизонтального переполнения и клавиатурный выбор точек графика. После исправления смены диапазона переход 7 → 90 дней сохраняет выбранное табло и открывает последнюю точку: max=89, value=89. На момент проверки нового диапазона горизонтального переполнения нет. + +Скриншоты: [desktop](expanded-statistics-desktop.jpg), [mobile](expanded-statistics-mobile.jpg), [production footer](pwa-footer-production.jpg). На снимках зафиксировано состояние мониторинга до подключения cookie; успешный сценарий сопряжённого табло показан в [отдельном отчёте](paired-board-fix-verification.md). + +## Расчёт и проверка + +Длительность включает только измеренные интервалы внутри подтверждённых автоматических инцидентов. Пересечения сбоев доступности и сценария объединяются; плановые работы и интервалы без свежих данных исключаются. Ручные сообщения не подменяют измерения. Если полная длительность неизвестна, показывается минимальное измеренное время со знаком ≥ или отсутствие данных. + +Для сценариев создана отдельная история измерений. Старые состояния сохранены, исторические измерения задним числом не создаются. Последнее восстановление берётся из фактически записанного завершения автоматического инцидента и не ограничивается выбранным периодом. + +В статистику задержек входят успешные измерения; сравнение требует достаточного покрытия обоих периодов. По умолчанию хранится 90 дней: для сравнения двух полных периодов по 90 дней нужно увеличить хранение до 180 дней. + +Проверка на временной SQLite с 648 000 измерений (5 сервисов × 90 дней × 60 секунд): сводка 90 дней построена примерно за 2113ms; размер JSON — 96 353 байта. Временная база удалена. Добавлен внутренний кеш на 5 секунд с немедленным сбросом при новых проверках и изменениях через CLI; публичный ответ сохраняет `Cache-Control: no-store`. Время ответа кеша отдельно не измерялось. + +Итоговая сборка успешна. Все 62 теста, включая расчёты статистики и последующее исправление сопряжённого табло, прошли без ошибок и пропусков. Docker-образ не собирался, поскольку daemon недоступен; конфигурация Compose проверена. Внешняя публикация не выполнялась. diff --git a/artifacts/methodology.jpg b/artifacts/methodology.jpg new file mode 100644 index 0000000..6778cfe Binary files /dev/null and b/artifacts/methodology.jpg differ diff --git a/artifacts/mobile-dark-demo.jpg b/artifacts/mobile-dark-demo.jpg new file mode 100644 index 0000000..60055ba Binary files /dev/null and b/artifacts/mobile-dark-demo.jpg differ diff --git a/artifacts/next-stage-maintenance.jpg b/artifacts/next-stage-maintenance.jpg new file mode 100644 index 0000000..1e64940 Binary files /dev/null and b/artifacts/next-stage-maintenance.jpg differ diff --git a/artifacts/next-stage-mobile.jpg b/artifacts/next-stage-mobile.jpg new file mode 100644 index 0000000..f5055c2 Binary files /dev/null and b/artifacts/next-stage-mobile.jpg differ diff --git a/artifacts/next-stage-scenario-live.jpg b/artifacts/next-stage-scenario-live.jpg new file mode 100644 index 0000000..36dceb1 Binary files /dev/null and b/artifacts/next-stage-scenario-live.jpg differ diff --git a/artifacts/next-stage-verification.md b/artifacts/next-stage-verification.md new file mode 100644 index 0000000..5c21d43 --- /dev/null +++ b/artifacts/next-stage-verification.md @@ -0,0 +1,31 @@ +# Проверка следующего этапа + +Дата: 12 октября 2026, Europe/Minsk. Локальный проект `/Users/xtimms/Projects/ridebus-status`. + +## Реализовано + +- Сценарии API, PWA и табло с отдельной периодичностью, состоянием, причинами и этапами. API проверяет города, остановки и ETA; PWA и табло дополнительно проверяют отображение в свежем контексте Chromium. Пустые отправления допустимы, сохранённые и частичные данные не подтверждают работу. +- Плановые работы: тексты RU/EN, затронутые сервисы, точное окно, история, отмена и досрочное завершение через CLI. Ожидаемые сбои не открывают новый инцидент; измерения продолжают влиять на историю доступности. +- Отключённая до настройки интеграция Telegram: подтверждённый сбой и восстановление, постоянная очередь SQLite, повторы и порядок доставки. +- Микроанимации в стиле PWA: карточки, строки, нажатия, стрелки, смена статуса и диалоги. Reduced motion отключает анимации и смещения. +- Миграция прежней SQLite без удаления истории; корректное возобновление сроков сценариев после перезапуска. + +## Подтверждено + +- `npm test`: **34/34**, без пропусков. Включён настоящий локальный Chromium: запрет POST, показ данных и CSS reduced motion. Telegram проверен подставным транспортом; реальные сообщения не отправлялись. +- `npm run build`: TypeScript и production-сборка успешны. +- `docker compose config --quiet`: конфигурация корректна. В Compose предусмотрены пользователь node, init, shared memory и официальный seccomp-профиль Playwright 1.63.0. +- Production-сервер `npm start` на `127.0.0.1:5188`: HTTP 200, публичная сводка `Cache-Control: no-store`, демонстрация отделена от реальных измерений. +- Реальные API и PWA прошли все этапы 12 октября около 22:27 по Минску. PWA проверен браузером с включённым sandbox. Все пять базовых проверок доступности были успешны. +- Браузерная проверка интерфейса: RU/EN, светлая/тёмная темы, плановые окна и пустая история, диалог сценария; ширина 1280 и 390 px без горизонтального переполнения. На телефоне все три раздела доступны в навигации. + +## Что требует подключения + +- Табло: `/api/config` и доступность страницы успешны; `/api/stops` возвращает 401 без cookie сопряжённого тестового устройства. Поэтому сценарий `unknown / auth_required`. Поле `STATUS_BOARD_TEST_COOKIE` подготовлено в `.env`, при завершении проверки ещё пусто. Устройство не создавалось и сопряжение не выполнялось. +- Telegram: бот ещё не создан; token/chat ID не настроены, отправка отключена, очередь пуста. +- Docker: daemon локально недоступен, образ и контейнер не запускались. Браузерный sandbox нужно подтвердить на выбранном хостинге. +- Хостинг не выбран, публикация не выполнялась. Нужны постоянный процесс и диск; инструкции находятся в README. + +Скриншоты: `next-stage-maintenance.jpg` (production, явно обозначенная демонстрация), `next-stage-mobile.jpg` (мобильная тёмная тема), `next-stage-scenario-live.jpg` (реальная проверка PWA). + +Рабочие окна обслуживания не создавались; примеры показаны только в демонстрации. Локальный процесс проверки остановлен после завершения; для просмотра запустите `npm run dev`. diff --git a/artifacts/paired-board-check.png b/artifacts/paired-board-check.png new file mode 100644 index 0000000..4a1fe1f Binary files /dev/null and b/artifacts/paired-board-check.png differ diff --git a/artifacts/paired-board-fix-verification.md b/artifacts/paired-board-fix-verification.md new file mode 100644 index 0000000..8e3d53a --- /dev/null +++ b/artifacts/paired-board-fix-verification.md @@ -0,0 +1,25 @@ +# Проверка сопряжённого табло + +Дата: 13 октября 2026, Europe/Minsk. + +## Причина и исправление + +Cookie из локального `.env` успешно авторизует существующее тестовое устройство на `board.ridebus.by`. `/api/device/status` вернул состояние `paired`, город 1 и назначенную остановку 28. Общая `/api/config` указывает остановку 1; прежний монитор запрашивал её и получал `409 settings_changed`. Запрос назначенной остановки 28 вернул HTTP 200 и расписание в режиме `live`. + +Монитор теперь получает настройки устройства и использует его назначенные город и остановку. `STATUS_BOARD_TEST_STOP_ID` продолжает применяться к автономному табло. Незавершённое сопряжение, истёкший или отозванный тестовый доступ дают неизвестный статус сценария, без подтверждения сбоя сервиса. + +Отдельно исправлена браузерная авторизация: cookie загружаются в новый контекст через `browserContext.addCookies()`. Изменение заголовка Cookie в `route.continue()` Chromium игнорирует; это описано в [документации Playwright](https://playwright.dev/docs/api/class-route#route-continue). Запросы записи и запросы на другой origin в таком контексте заблокированы. + +Правила нового сценария имеют отдельную сигнатуру. Прежние результаты и инциденты сохранены в SQLite, но не смешиваются с проверками назначенной остановки. История обычной доступности сохранена. Перед перезапуском создана резервная копия `data/status.before-paired-board-fix-20261013.sqlite` в игнорируемом каталоге. + +## Результат + +- `npm test`: 62 успешных теста, 0 ошибок, 0 пропусков. Включены проверки назначенной остановки, автономного табло, состояния сеанса и настоящий Chromium с cookie, заблокированными POST и другим origin. +- `npm run build`: успешная проверка TypeScript и production-сборка Vite. +- Реальный полный сценарий: HTTP 200, режим `browser`, все шаги пройдены: общая конфигурация, настройки устройства, остановки, расписание, отображение. +- Временная ошибка локального DNS `ENOTFOUND` исчезла без изменения сетевых настроек; после этого выполнена успешная проверка реального табло. +- Существовавший локальный сервер перезапущен в прежнем режиме разработки на `127.0.0.1:5188`. Новый цикл мониторинга записал успешный сценарий в 02:18:40. +- Публичный `/api/status` вернул `board.status=operational`, `scenario.status=operational`, `reason=null`, пять пройденных шагов и отсутствие активных инцидентов текущего сценария табло. Cookie в JSON отсутствует. +- В интерфейсе подробностей табло отображаются «Работает» и «Сценарий проходит». Скриншот: [paired-board-check.png](paired-board-check.png). + +Значение cookie не изменялось, не выводилось и не сохранялось в отчёте. Новое устройство не создавалось, сопряжение и heartbeat на сервер не отправлялись. Это локальное исправление; публикация на внешний хостинг не выполнялась. diff --git a/artifacts/public-updates-rss-mobile.jpg b/artifacts/public-updates-rss-mobile.jpg new file mode 100644 index 0000000..27eedd0 Binary files /dev/null and b/artifacts/public-updates-rss-mobile.jpg differ diff --git a/artifacts/public-updates-rss.jpg b/artifacts/public-updates-rss.jpg new file mode 100644 index 0000000..67e246a Binary files /dev/null and b/artifacts/public-updates-rss.jpg differ diff --git a/artifacts/public-updates-timeline.jpg b/artifacts/public-updates-timeline.jpg new file mode 100644 index 0000000..9e4f1d5 Binary files /dev/null and b/artifacts/public-updates-timeline.jpg differ diff --git a/artifacts/public-updates-verification.md b/artifacts/public-updates-verification.md new file mode 100644 index 0000000..b7058cc --- /dev/null +++ b/artifacts/public-updates-verification.md @@ -0,0 +1,29 @@ +# Проверка влияния, хронологии и RSS + +12 октября 2026, Europe/Minsk. Проверен проект `/Users/xtimms/Projects/ridebus-status`. + +## Результат + +- Описание влияния в карточках и подробностях сервиса: различаются проблемы загрузки страницы и сценария данных. Альтернативные сервисы предлагаются только при текущем рабочем статусе; сохранённое расписание описано как возможный вариант с оговоркой об актуальности. +- Хронология сохраняет исходное сообщение, автоматическое обнаружение, сообщения команды и восстановление. После закрытия доступен отдельный разбор `postmortem`; время восстановления не меняется. Автоматический инцидент закрывается по результатам мониторинга. +- RSS 2.0: выбор RU/EN, сервисов и типов событий, копирование ссылки, постоянные GUID, общие сообщения, отдельные события обслуживания. Лента не содержит демонстрацию и не хранит базу подписчиков. +- Старая SQLite мигрирует без удаления истории и выдуманных промежуточных стадий. Перед миграцией сохранён локальный backup `data/status.before-public-updates.sqlite`. + +## Проверки + +- `npm test`: **43/43**, без пропусков, включая настоящий Chromium. Новые проверки охватывают источники событий и стадии, разбор после закрытия, запрет принудительного закрытия автоматического инцидента, миграцию при повторном запуске, RSS-фильтры, стабильность GUID, экранирование и HTTP-маршрут. +- `npm run build`: TypeScript и production-сборка успешны. +- RSS дополнительно разобран XML-парсером Python; проверены уникальные GUID. HTML-парсер подтвердил, что пользовательский текст не создаёт внедрённые теги внутри описания RSS. +- CLI проверен на отдельной временной БД: создание, обновление, закрытие с пояснением и последующий разбор. Рабочая БД тестовыми инцидентами не заполнялась. +- Браузер, production: ширина 1280 и 390 px, RU/EN, светлая/тёмная темы, отсутствие горизонтального переполнения, раскрытие хронологии и фильтр решённых инцидентов, описание влияния в окне PWA, выбор/пустой выбор RSS, копирование ссылки и смена языка. +- Проверен переход по ссылке на завершившееся обслуживание: после загрузки данных автоматически выбрана история и показана нужная карточка. Этот стенд работал на отдельном порту с БД в памяти, мониторинг отключён; стенд остановлен. +- Реальная RSS-лента на локальном production-сервере отвечает HTTP 200, `application/rss+xml`, `Cache-Control: no-store`. Примеры исключены. +- `docker compose config --quiet`: успешно. Docker-образ здесь не собирался: daemon недоступен. + +## Развёртывание + +Для текущего постоянного Node-мониторинга, SQLite и Chromium нужен независимый хостинг с постоянно работающим процессом и диском. Netlify рассмотрен по официальной документации: подходит для интерфейса с отдельным backend; полный перенос требует адаптации планировщика, хранилища и очереди к Functions. Публикация и миграция на Netlify не выполнялись. + +Cookie тестового табло и Telegram остаются неподключёнными на момент проверки. Новых реальных сообщений или подписок не создавалось. Локальные процессы QA остановлены после проверки; запуск проекта — `npm run dev`. + +Скриншоты: `public-updates-timeline.jpg` — хронология в явно обозначенной демонстрации; `public-updates-rss.jpg` — реальная страница подписки; `public-updates-rss-mobile.jpg` — мобильная тёмная тема, EN. diff --git a/artifacts/pwa-footer-production.jpg b/artifacts/pwa-footer-production.jpg new file mode 100644 index 0000000..834a5da Binary files /dev/null and b/artifacts/pwa-footer-production.jpg differ diff --git a/artifacts/verification.md b/artifacts/verification.md new file mode 100644 index 0000000..a24b363 --- /dev/null +++ b/artifacts/verification.md @@ -0,0 +1,15 @@ +# Проверка RideBus Status + +Дата: 12 октября 2026. + +- `npm test`: 16 тестов, все проходят. Покрыты подтверждение сбоя и восстановления, SQLite после перезапуска, агрегация по дням/часам, пропуски данных, таймауты, защита заголовков при редиректе и публичные HTTP-маршруты. +- `npm run build`: TypeScript и production-сборка Vite проходят. Собранная версия запущена через `npm start` и открыта в браузере. +- Пять публичных адресов реально проверены сервером: сайт, админка, PWA, API `/health`, табло. В момент проверки все ответили HTTP 200 с ожидаемым содержимым. Полные пользовательские сценарии этих внешних приложений не тестировались. +- В браузере проверены ширина 1280 px и 390 px: нет горизонтальной прокрутки, видны все пять сервисов. На мобильном экране 90 дневных сегментов помещаются, диалог помещается по ширине. +- Проверены RU/EN, светлая/тёмная темы, диапазон 90 дней, подробности API, закрытие диалога Escape, фильтры инцидентов, явно помеченная демонстрация и описание границ мониторинга. Ошибок JavaScript в проверенной production-странице нет. +- `docker compose config --quiet`: проходит. Образ **не собран и не запущен**, поскольку Docker daemon на машине не работает. +- На внешнем хостинге проект **не опубликован**: хостинг ещё не выбран. Для текущей реализации нужны постоянный Node/Docker-процесс и постоянный диск. + +`desktop-live.jpg` — реальные статусы с историей, накопленной во время разработки. `mobile-dark-demo.jpg` — явно помеченный пример с тестовыми данными. `methodology.jpg` — описание проверок и их ограничений. + +Локальный процесс, использованный для проверки, остановлен после завершения. Мониторинг продолжится при следующем `npm run dev` / `npm start`; ранее записанная история останется в `data/status.sqlite`. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..e48f601 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,21 @@ +services: + status: + build: . + restart: unless-stopped + init: true + shm_size: 256mb + security_opt: + - seccomp=./deploy/chromium-seccomp.json + ports: + - "5188:5188" + env_file: + - path: .env + required: false + environment: + PORT: 5188 + HOST: 0.0.0.0 + STATUS_DB_PATH: /app/data/status.sqlite + volumes: + - status-data:/app/data +volumes: + status-data: diff --git a/config/services.json b/config/services.json new file mode 100644 index 0000000..5921f01 --- /dev/null +++ b/config/services.json @@ -0,0 +1,140 @@ +[ + { + "id": "api", + "icon": "api", + "name": { + "ru": "API", + "en": "API" + }, + "description": { + "ru": "Основа сервисов RideBus", + "en": "The foundation of RideBus services" + }, + "publicUrl": "https://api.ridebus.by", + "url": "https://api.ridebus.by/health", + "kind": "json", + "expectedJson": { + "path": "status", + "value": "healthy" + }, + "scenario": "api", + "impact": { + "availability": { + "ru": "Расписание и ближайшие отправления в приложении и на табло могут быть недоступны.", + "en": "Timetables and upcoming departures in the app and board may be unavailable." + }, + "scenario": { + "ru": "API отвечает, но получение транспортных данных работает с перебоями. Расписание может не обновляться.", + "en": "The API responds, but transport data retrieval is disrupted. Timetables may not refresh." + }, + "advice": { + "ru": "Если приложение ранее сохранило расписание, оно может быть доступно. Сохранённые данные могут быть устаревшими.", + "en": "Previously saved timetables may be available in the app. Saved data may be outdated." + } + } + }, + { + "id": "website", + "icon": "website", + "name": { + "ru": "Основной сайт", + "en": "Website" + }, + "description": { + "ru": "Всё о RideBus и наших проектах", + "en": "Everything about RideBus and our projects" + }, + "publicUrl": "https://ridebus.by", + "url": "https://ridebus.by/", + "kind": "html", + "impact": { + "availability": { + "ru": "Основной сайт может не открываться. Работа расписания в приложении проверяется отдельно.", + "en": "The main website may not open. Timetable availability in the app is checked separately." + }, + "fallbacks": [ + "pwa" + ] + } + }, + { + "id": "pwa", + "icon": "pwa", + "name": { + "ru": "Веб-приложение", + "en": "Web app" + }, + "description": { + "ru": "Расписание всегда под рукой", + "en": "Timetables always at your fingertips" + }, + "publicUrl": "https://pwa.ridebus.by", + "url": "https://pwa.ridebus.by/", + "kind": "html", + "scenario": "pwa", + "impact": { + "availability": { + "ru": "Веб-приложение может не открываться: поиск остановок и просмотр расписания ограничены.", + "en": "The web app may not open, limiting stop search and timetable access." + }, + "scenario": { + "ru": "Приложение открывается, но расписание остановки может не загрузиться или не отобразиться.", + "en": "The app opens, but the stop timetable may fail to load or display." + }, + "advice": { + "ru": "Ранее сохранённое расписание может быть доступно в установленном приложении. Проверяйте отметку о сохранённых данных.", + "en": "Previously saved timetables may be available in the installed app. Check the saved-data notice." + } + } + }, + { + "id": "board", + "icon": "board", + "name": { + "ru": "Электронное табло", + "en": "Departure board" + }, + "description": { + "ru": "Ближайшие отправления на остановках", + "en": "Upcoming departures at your stop" + }, + "publicUrl": "https://board.ridebus.by", + "url": "https://board.ridebus.by/", + "kind": "html", + "scenario": "board", + "impact": { + "availability": { + "ru": "Электронное табло может не показывать ближайшие отправления.", + "en": "The departure board may not show upcoming departures." + }, + "scenario": { + "ru": "Страница табло открывается, но актуальные отправления могут не загрузиться или не появиться на экране.", + "en": "The board page opens, but live departures may fail to load or appear on screen." + }, + "fallbacks": [ + "pwa" + ] + } + }, + { + "id": "admin", + "icon": "admin", + "name": { + "ru": "Панель управления", + "en": "Admin panel" + }, + "description": { + "ru": "Управление транспортными данными", + "en": "Managing transport data" + }, + "publicUrl": "https://admin.ridebus.by", + "url": "https://admin.ridebus.by/", + "kind": "html", + "impact": { + "availability": { + "ru": "Сотрудники могут временно не иметь доступа к управлению транспортными данными. Статусы пассажирских сервисов показаны отдельно.", + "en": "Staff may temporarily be unable to manage transport data. Passenger service status is shown separately." + } + } + } +] diff --git a/deploy/PLAYWRIGHT_LICENSE b/deploy/PLAYWRIGHT_LICENSE new file mode 100644 index 0000000..df11237 --- /dev/null +++ b/deploy/PLAYWRIGHT_LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Portions Copyright (c) Microsoft Corporation. + Portions Copyright 2017 Google Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/deploy/chromium-seccomp.json b/deploy/chromium-seccomp.json new file mode 100644 index 0000000..fddc05f --- /dev/null +++ b/deploy/chromium-seccomp.json @@ -0,0 +1,831 @@ +{ + "defaultAction": "SCMP_ACT_ERRNO", + "archMap": [ + { + "architecture": "SCMP_ARCH_X86_64", + "subArchitectures": [ + "SCMP_ARCH_X86", + "SCMP_ARCH_X32" + ] + }, + { + "architecture": "SCMP_ARCH_AARCH64", + "subArchitectures": [ + "SCMP_ARCH_ARM" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64" + ] + }, + { + "architecture": "SCMP_ARCH_S390X", + "subArchitectures": [ + "SCMP_ARCH_S390" + ] + } + ], + "syscalls": [ + { + "comment": "Allow create user namespaces", + "names": [ + "clone", + "setns", + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "includes": {}, + "excludes": {} + }, + { + "names": [ + "accept", + "accept4", + "access", + "adjtimex", + "alarm", + "bind", + "brk", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "chown32", + "clock_adjtime", + "clock_adjtime64", + "clock_getres", + "clock_getres_time64", + "clock_gettime", + "clock_gettime64", + "clock_nanosleep", + "clock_nanosleep_time64", + "close", + "connect", + "copy_file_range", + "creat", + "dup", + "dup2", + "dup3", + "epoll_create", + "epoll_create1", + "epoll_ctl", + "epoll_ctl_old", + "epoll_pwait", + "epoll_wait", + "epoll_wait_old", + "eventfd", + "eventfd2", + "execve", + "execveat", + "exit", + "exit_group", + "faccessat", + "fadvise64", + "fadvise64_64", + "fallocate", + "fanotify_mark", + "fchdir", + "fchmod", + "fchmodat", + "fchown", + "fchown32", + "fchownat", + "fcntl", + "fcntl64", + "fdatasync", + "fgetxattr", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsetxattr", + "fstat", + "fstat64", + "fstatat64", + "fstatfs", + "fstatfs64", + "fsync", + "ftruncate", + "ftruncate64", + "futex", + "futex_time64", + "futimesat", + "getcpu", + "getcwd", + "getdents", + "getdents64", + "getegid", + "getegid32", + "geteuid", + "geteuid32", + "getgid", + "getgid32", + "getgroups", + "getgroups32", + "getitimer", + "getpeername", + "getpgid", + "getpgrp", + "getpid", + "getppid", + "getpriority", + "getrandom", + "getresgid", + "getresgid32", + "getresuid", + "getresuid32", + "getrlimit", + "get_robust_list", + "getrusage", + "getsid", + "getsockname", + "getsockopt", + "get_thread_area", + "gettid", + "gettimeofday", + "getuid", + "getuid32", + "getxattr", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "io_cancel", + "ioctl", + "io_destroy", + "io_getevents", + "io_pgetevents", + "io_pgetevents_time64", + "ioprio_get", + "ioprio_set", + "io_setup", + "io_submit", + "io_uring_enter", + "io_uring_register", + "io_uring_setup", + "ipc", + "kill", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listxattr", + "llistxattr", + "_llseek", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "lstat64", + "madvise", + "membarrier", + "memfd_create", + "mincore", + "mkdir", + "mkdirat", + "mknod", + "mknodat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mmap2", + "mprotect", + "mq_getsetattr", + "mq_notify", + "mq_open", + "mq_timedreceive", + "mq_timedreceive_time64", + "mq_timedsend", + "mq_timedsend_time64", + "mq_unlink", + "mremap", + "msgctl", + "msgget", + "msgrcv", + "msgsnd", + "msync", + "munlock", + "munlockall", + "munmap", + "nanosleep", + "newfstatat", + "_newselect", + "open", + "openat", + "pause", + "pipe", + "pipe2", + "poll", + "ppoll", + "ppoll_time64", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "pselect6", + "pselect6_time64", + "pwrite64", + "pwritev", + "pwritev2", + "read", + "readahead", + "readlink", + "readlinkat", + "readv", + "recv", + "recvfrom", + "recvmmsg", + "recvmmsg_time64", + "recvmsg", + "remap_file_pages", + "removexattr", + "rename", + "renameat", + "renameat2", + "restart_syscall", + "rmdir", + "rseq", + "rt_sigaction", + "rt_sigpending", + "rt_sigprocmask", + "rt_sigqueueinfo", + "rt_sigreturn", + "rt_sigsuspend", + "rt_sigtimedwait", + "rt_sigtimedwait_time64", + "rt_tgsigqueueinfo", + "sched_getaffinity", + "sched_getattr", + "sched_getparam", + "sched_get_priority_max", + "sched_get_priority_min", + "sched_getscheduler", + "sched_rr_get_interval", + "sched_rr_get_interval_time64", + "sched_setaffinity", + "sched_setattr", + "sched_setparam", + "sched_setscheduler", + "sched_yield", + "seccomp", + "select", + "semctl", + "semget", + "semop", + "semtimedop", + "semtimedop_time64", + "send", + "sendfile", + "sendfile64", + "sendmmsg", + "sendmsg", + "sendto", + "setfsgid", + "setfsgid32", + "setfsuid", + "setfsuid32", + "setgid", + "setgid32", + "setgroups", + "setgroups32", + "setitimer", + "setpgid", + "setpriority", + "setregid", + "setregid32", + "setresgid", + "setresgid32", + "setresuid", + "setresuid32", + "setreuid", + "setreuid32", + "setrlimit", + "set_robust_list", + "setsid", + "setsockopt", + "set_thread_area", + "set_tid_address", + "setuid", + "setuid32", + "setxattr", + "shmat", + "shmctl", + "shmdt", + "shmget", + "shutdown", + "sigaltstack", + "signalfd", + "signalfd4", + "sigprocmask", + "sigreturn", + "socket", + "socketcall", + "socketpair", + "splice", + "stat", + "stat64", + "statfs", + "statfs64", + "statx", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "syncfs", + "sysinfo", + "tee", + "tgkill", + "time", + "timer_create", + "timer_delete", + "timer_getoverrun", + "timer_gettime", + "timer_gettime64", + "timer_settime", + "timer_settime64", + "timerfd_create", + "timerfd_gettime", + "timerfd_gettime64", + "timerfd_settime", + "timerfd_settime64", + "times", + "tkill", + "truncate", + "truncate64", + "ugetrlimit", + "umask", + "uname", + "unlink", + "unlinkat", + "utime", + "utimensat", + "utimensat_time64", + "utimes", + "vfork", + "vmsplice", + "wait4", + "waitid", + "waitpid", + "write", + "writev" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "args": null, + "comment": "", + "includes": { + "minKernel": "4.8" + }, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 0, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 8, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131072, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131080, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 4294967295, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "sync_file_range2" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "ppc64le" + ] + }, + "excludes": {} + }, + { + "names": [ + "arm_fadvise64_64", + "arm_sync_file_range", + "sync_file_range2", + "breakpoint", + "cacheflush", + "set_tls" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "arm", + "arm64" + ] + }, + "excludes": {} + }, + { + "names": [ + "arch_prctl" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "amd64", + "x32" + ] + }, + "excludes": {} + }, + { + "names": [ + "modify_ldt" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "amd64", + "x32", + "x86" + ] + }, + "excludes": {} + }, + { + "names": [ + "s390_pci_mmio_read", + "s390_pci_mmio_write", + "s390_runtime_instr" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "s390", + "s390x" + ] + }, + "excludes": {} + }, + { + "names": [ + "open_by_handle_at" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_DAC_READ_SEARCH" + ] + }, + "excludes": {} + }, + { + "names": [ + "bpf", + "clone", + "fanotify_init", + "lookup_dcookie", + "mount", + "name_to_handle_at", + "perf_event_open", + "quotactl", + "setdomainname", + "sethostname", + "setns", + "syslog", + "umount", + "umount2", + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + }, + "excludes": {} + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2114060288, + "valueTwo": 0, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ], + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 1, + "value": 2114060288, + "valueTwo": 0, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "comment": "s390 parameter ordering for clone is different", + "includes": { + "arches": [ + "s390", + "s390x" + ] + }, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "reboot" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_BOOT" + ] + }, + "excludes": {} + }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_CHROOT" + ] + }, + "excludes": {} + }, + { + "names": [ + "delete_module", + "init_module", + "finit_module" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_MODULE" + ] + }, + "excludes": {} + }, + { + "names": [ + "acct" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_PACCT" + ] + }, + "excludes": {} + }, + { + "names": [ + "kcmp", + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_PTRACE" + ] + }, + "excludes": {} + }, + { + "names": [ + "iopl", + "ioperm" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_RAWIO" + ] + }, + "excludes": {} + }, + { + "names": [ + "settimeofday", + "stime", + "clock_settime" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_TIME" + ] + }, + "excludes": {} + }, + { + "names": [ + "vhangup" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_TTY_CONFIG" + ] + }, + "excludes": {} + }, + { + "names": [ + "get_mempolicy", + "mbind", + "set_mempolicy" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_NICE" + ] + }, + "excludes": {} + }, + { + "names": [ + "syslog" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYSLOG" + ] + }, + "excludes": {} + } + ] +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..8a7ae6d --- /dev/null +++ b/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + RideBus — статус сервисов + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9916823 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1793 @@ +{ + "name": "ridebus-status", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ridebus-status", + "version": "0.1.0", + "dependencies": { + "@fontsource-variable/manrope": "5.3.0", + "@vkontakte/icons": "3.75.0", + "playwright": "1.63.0", + "react": "19.3.0", + "react-dom": "19.3.0" + }, + "devDependencies": { + "@types/node": "26.6.4", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.2", + "sass": "1.105.1", + "typescript": "7.0.2", + "vite": "8.3.3" + }, + "engines": { + "node": ">=24.0.0" + } + }, + "node_modules/@fontsource-variable/manrope": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.3.0.tgz", + "integrity": "sha512-6D5dgokHsWDDMtmXHznKa0hK229NN+1a4BLPmUCLqcO1Pw5EEhWY5RFt0AcXnVRAljFFPfRtLkJePQj6LSsV6g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vkontakte/icons": { + "version": "3.75.0", + "resolved": "https://registry.npmjs.org/@vkontakte/icons/-/icons-3.75.0.tgz", + "integrity": "sha512-0OLk/CiQU7T2HEtvP7/oGHDPZedqNhJgn3vTkySRKLh9Rg7haNySnoUf4bRBVdnl42WvF94Kq/ZMeixaaorKpA==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.23", + "@vkontakte/icons-sprite": "^3.2.2" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@vkontakte/icons-sprite": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@vkontakte/icons-sprite/-/icons-sprite-3.2.2.tgz", + "integrity": "sha512-+xsBe3NsriQfRY3JkLdPtr3Owoz7B3eUffEiZ1pLngdmqymjKVHQr0oo3K3qABjb0vUKh/+N3orKxJa/NA2u3Q==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.23" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/immutable": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/sass": { + "version": "1.105.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.105.1.tgz", + "integrity": "sha512-1CnAWPyrrnVQzXGB/DUep1vYCKCm1CIBr+YxjjiW6wk9qtzemnGP4qicH56T2F9S4Hjtv/Ne3jDz8JqfdDU4oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", + "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a425c85 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "ridebus-status", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=24.0.0" + }, + "scripts": { + "dev": "node --env-file-if-exists=.env server/index.mjs --dev", + "build": "tsc -b && vite build", + "start": "node --env-file-if-exists=.env server/index.mjs", + "typecheck": "tsc -b", + "test": "node --test test/*.test.mjs", + "incident": "node --env-file-if-exists=.env server/incidents-cli.mjs", + "maintenance": "node --env-file-if-exists=.env server/maintenance-cli.mjs", + "notify": "node --env-file-if-exists=.env server/notify-cli.mjs", + "browsers:install": "playwright install chromium" + }, + "dependencies": { + "@fontsource-variable/manrope": "5.3.0", + "@vkontakte/icons": "3.75.0", + "playwright": "1.63.0", + "react": "19.3.0", + "react-dom": "19.3.0" + }, + "devDependencies": { + "@types/node": "26.6.4", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.2", + "sass": "1.105.1", + "typescript": "7.0.2", + "vite": "8.3.3" + } +} diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..35cfd44 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/star.svg b/public/star.svg new file mode 100644 index 0000000..91ea73d --- /dev/null +++ b/public/star.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/server/browser-checks.mjs b/server/browser-checks.mjs new file mode 100644 index 0000000..6cd5d9b --- /dev/null +++ b/server/browser-checks.mjs @@ -0,0 +1,69 @@ +import { existsSync } from 'node:fs'; +import { chromium } from 'playwright'; + +export function createBrowserChecks(config) { + let browser = null, launching = null, active = 0; + const queue = []; + async function getBrowser() { + if (browser?.isConnected()) return browser; + if (!launching) { + const installedChrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + const executablePath = config.browser.executablePath || (process.platform === 'darwin' && existsSync(installedChrome) ? installedChrome : undefined); + launching = chromium.launch({ headless: true, chromiumSandbox: true, executablePath, timeout: 15000 }).then(value => { browser = value; return value; }).finally(() => { launching = null; }); + } + return launching; + } + async function run(spec, target) { + if (active >= 2) await new Promise(resolve => queue.push(resolve)); else active++; + let context; + const started = performance.now(); + try { + let currentBrowser; + try { currentBrowser = await getBrowser(); } catch { return { ok: null, reason: 'browser_unavailable', latencyMs: null }; } + context = await currentBrowser.newContext({ viewport: { width: 1280, height: 800 }, locale: 'ru-RU', timezoneId: 'Europe/Minsk', reducedMotion: 'reduce', serviceWorkers: 'block' }); + const origin = new URL(spec.baseUrl).origin; + if (spec.cookie) { + const cookies = spec.cookie.split(';').map(item => { + const separator = item.indexOf('='); + if (separator <= 0) return null; + return { name: item.slice(0, separator).trim(), value: item.slice(separator + 1).trim(), url: `${origin}/`, httpOnly: true, secure: origin.startsWith('https:'), sameSite: 'Strict' }; + }); + if (cookies.some(item => !item || !item.name || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(item.name))) return { ok: null, reason: 'auth_required', latencyMs: null }; + // Chromium ignores a Cookie override in route.continue(). Load the + // existing test session into this fresh browser context instead. + await context.addCookies(cookies); + } + // Probes run in fresh contexts, with no offline cache or write requests. + await context.route('**/*', async route => { + const request = route.request(); + if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) { await route.abort(); return; } + if (spec.cookie && new URL(request.url()).origin !== origin) { await route.abort(); return; } + await route.continue(); + }); + if (spec.type === 'board') await context.addInitScript(({ stopId }) => { localStorage.setItem('ridebus-board-settings-v1', JSON.stringify({ stopId, demo: false, nightMode: 'off' })); }, target); + const page = await context.newPage(); + page.setDefaultTimeout(config.scenarioTimeoutMs); + const path = spec.type === 'pwa' ? `/?city=${target.cityId}&stop=${target.stopId}` : '/'; + const dataResponse = page.waitForResponse(response => { + const url = new URL(response.url()); + return url.origin === new URL(spec.baseUrl).origin && (spec.type === 'pwa' ? url.pathname === '/api/data' && url.searchParams.get('resource') === 'departures' && url.searchParams.get('stopId') === String(target.stopId) : url.pathname === '/api/board' && url.searchParams.get('stopId') === String(target.stopId)); + }); + // Consume early rejections even if navigation itself fails. + void dataResponse.catch(() => {}); + const navigation = await page.goto(new URL(path, spec.baseUrl).toString(), { waitUntil: 'domcontentloaded', timeout: config.scenarioTimeoutMs }); + if (!navigation || navigation.status() !== 200) return { ok: false, reason: 'render', httpCode: navigation?.status() ?? null, latencyMs: null }; + const response = await dataResponse; + if (!response.ok()) return { ok: [401, 403].includes(response.status()) ? null : false, reason: [401, 403].includes(response.status()) ? 'auth_required' : 'render', httpCode: response.status(), latencyMs: null }; + if (spec.type === 'pwa') { + await page.waitForFunction(() => Boolean(document.querySelector('.boardCard .departureList')) || /Пока нет отправлений|Нет ближайших отправлений/.test(document.querySelector('.boardCard .boardEmpty h3')?.textContent || '')); + if (await page.locator('.boardCard .savedNotice, .boardCard .partialNotice').count()) return { ok: false, reason: 'render', httpCode: 200, latencyMs: null }; + } else { + await page.waitForFunction(() => Boolean(document.querySelector('.board .scene-container')) && !document.querySelector('.offline-banner') && !document.querySelector('.device-setup')); + if (await page.locator('.board-header h1').count() !== 1) return { ok: false, reason: 'render', httpCode: 200, latencyMs: null }; + } + return { ok: true, reason: null, httpCode: 200, latencyMs: Math.round(performance.now() - started) }; + } catch { return { ok: false, reason: 'render', latencyMs: null }; } + finally { await context?.close().catch(() => {}); const next = queue.shift(); if (next) next(); else active--; } + } + return { run, async close() { const current = browser || await launching?.catch(() => null); await current?.close(); browser = null; } }; +} diff --git a/server/config.mjs b/server/config.mjs new file mode 100644 index 0000000..f1c700e --- /dev/null +++ b/server/config.mjs @@ -0,0 +1,78 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { createHash } from 'node:crypto'; + +function numberSetting(env, key, fallback, min, max) { + const value = Number(env[key] ?? fallback); + if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${key} must be an integer between ${min} and ${max}`); + return value; +} + +function checkedUrl(value) { + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('Service URLs must use HTTP(S), without embedded credentials'); + return url.toString(); +} + +export function loadConfig(env = process.env) { + const file = resolve(env.STATUS_SERVICES_FILE || './config/services.json'); + const definitions = JSON.parse(readFileSync(file, 'utf8')); + if (!Array.isArray(definitions) || !definitions.length || definitions.length > 30) throw new Error('Configure between 1 and 30 services'); + const ids = new Set(); + const services = definitions.map(definition => { + if (!/^[a-z][a-z0-9-]{0,39}$/.test(definition.id) || ids.has(definition.id)) throw new Error('Service IDs must be unique lowercase slugs'); + ids.add(definition.id); + if (!definition.name?.ru || !definition.name?.en || !definition.description?.ru || !definition.description?.en) throw new Error(`Missing translations for ${definition.id}`); + if (!['html', 'json', 'http'].includes(definition.kind)) throw new Error(`Invalid check kind for ${definition.id}`); + const override = env[`STATUS_${definition.id.toUpperCase().replaceAll('-', '_')}_URL`]; + const url = override === 'off' ? null : checkedUrl(override || definition.url); + const headers = {}; + for (const [header, envKey] of Object.entries(definition.headerEnv || {})) { + if (env[envKey]) headers[header] = env[envKey]; + } + const probes = url ? [{ url, kind: definition.kind, expectedJson: definition.expectedJson, headers }] : []; + if (definition.id === 'api' && url && env.RIDEBUS_API_KEY) { + probes.push({ url: checkedUrl(env.STATUS_API_DATA_URL || 'https://api.ridebus.by/api/v1/cities'), kind: 'json', headers: { 'X-API-Key': env.RIDEBUS_API_KEY }, requireDataArray: true }); + } + // Hash the target and rules so a changed probe starts a separate history. + const signature = createHash('sha256').update(JSON.stringify(probes.map(({ headers: _headers, ...probe }) => probe))).digest('hex').slice(0, 24); + const publicUrl = checkedUrl(definition.publicUrl); + if (new URL(publicUrl).search || new URL(publicUrl).hash) throw new Error('Public links must not include query parameters or fragments'); + if (definition.scenario && !['api', 'pwa', 'board'].includes(definition.scenario)) throw new Error('Unsupported scenario'); + const scenario = definition.scenario && url ? { + type: definition.scenario, + baseUrl: checkedUrl(env[`STATUS_${definition.id.toUpperCase()}_SCENARIO_BASE_URL`] || new URL(url).origin), + apiKey: definition.scenario === 'api' ? env.RIDEBUS_API_KEY || null : null, + cookie: definition.scenario === 'board' ? env.STATUS_BOARD_TEST_COOKIE || null : null, + cityId: env.STATUS_TEST_CITY_ID ? numberSetting(env, 'STATUS_TEST_CITY_ID', 1, 1, 2147483647) : null, + stopId: env[`STATUS_${definition.id.toUpperCase()}_TEST_STOP_ID`] ? numberSetting(env, `STATUS_${definition.id.toUpperCase()}_TEST_STOP_ID`, 1, 1, 2147483647) : null, + } : null; + if (scenario) scenario.signature = createHash('sha256').update(JSON.stringify({ ...scenario, apiKey: Boolean(scenario.apiKey), cookie: Boolean(scenario.cookie), ...(scenario.type === 'board' ? { settingsSource: 'paired-device-v1' } : {}) })).digest('hex').slice(0, 24); + const impactPolicy = definition.impact || null; + if (impactPolicy) { + for (const key of ['availability', 'scenario', 'advice']) if (impactPolicy[key] && ['ru', 'en'].some(locale => typeof impactPolicy[key][locale] !== 'string' || !impactPolicy[key][locale].trim() || impactPolicy[key][locale].length > 2000)) throw new Error(`Invalid impact translations for ${definition.id}`); + if (impactPolicy.fallbacks && (!Array.isArray(impactPolicy.fallbacks) || impactPolicy.fallbacks.some(id => !definitions.some(item => item.id === id)))) throw new Error('Unknown fallback service'); + } + return { id: definition.id, icon: definition.icon || definition.id, name: definition.name, description: definition.description, publicUrl, kind: definition.kind, functional: probes.length > 1 || Boolean(scenario), probes, signature, scenario, impactPolicy }; + }); + const intervalSeconds = numberSetting(env, 'STATUS_INTERVAL_SECONDS', 60, 10, 3600); + return { + services, intervalSeconds, staleAfterSeconds: intervalSeconds * 3, publicUrl: env.STATUS_PUBLIC_URL ? checkedUrl(env.STATUS_PUBLIC_URL) : null, + timeoutMs: numberSetting(env, 'STATUS_TIMEOUT_MS', 5000, 100, 30000), + slowMs: numberSetting(env, 'STATUS_SLOW_MS', 1500, 50, 30000), + failureThreshold: numberSetting(env, 'STATUS_FAILURE_THRESHOLD', 3, 1, 10), + recoveryThreshold: numberSetting(env, 'STATUS_RECOVERY_THRESHOLD', 2, 1, 10), + retentionDays: numberSetting(env, 'STATUS_RETENTION_DAYS', 90, 30, 365), + scenarioIntervalSeconds: numberSetting(env, 'STATUS_SCENARIO_INTERVAL_SECONDS', 300, 10, 3600), + scenarioTimeoutMs: numberSetting(env, 'STATUS_SCENARIO_TIMEOUT_MS', 20000, 1000, 60000), + scenarioSlowMs: numberSetting(env, 'STATUS_SCENARIO_SLOW_MS', 30000, 1000, 120000), + browser: { enabled: !['off', 'false', '0'].includes(env.STATUS_BROWSER_CHECKS || ''), executablePath: env.STATUS_BROWSER_EXECUTABLE_PATH || null }, + telegram: { + enabled: Boolean(env.TELEGRAM_BOT_TOKEN && env.TELEGRAM_CHAT_ID), token: env.TELEGRAM_BOT_TOKEN || '', chatId: env.TELEGRAM_CHAT_ID || '', + locale: env.STATUS_NOTIFY_LANG === 'en' ? 'en' : 'ru', publicUrl: env.STATUS_PUBLIC_URL ? checkedUrl(env.STATUS_PUBLIC_URL) : null, + timezone: env.STATUS_TIMEZONE || 'Europe/Minsk', + }, + dbPath: env.STATUS_DB_PATH || './data/status.sqlite', + port: numberSetting(env, 'PORT', 5188, 0, 65535), host: env.HOST || '0.0.0.0', + }; +} diff --git a/server/demo.mjs b/server/demo.mjs new file mode 100644 index 0000000..39b4538 --- /dev/null +++ b/server/demo.mjs @@ -0,0 +1,73 @@ +import { loadConfig } from './config.mjs'; +import { incidentImpact, serviceImpact } from '../shared/impact.mjs'; + +// Deterministic illustrative data, isolated from monitoring and live storage. +export function demoSnapshot(range, now = Date.now()) { + const config = loadConfig({}); + const dayMs = 86400000; + const today = Math.floor(now / dayMs) * dayMs; + const services = config.services.map((service, serviceIndex) => { + const history = Array.from({ length: range }, (_, index) => { + const start = today - (range - index - 1) * dayMs; + const failed = service.id === 'board' && start === today - 8 * dayMs; + return { date: new Date(start).toISOString().slice(0, 10), status: failed ? 'down' : 'operational', uptime: failed ? 95.83 : 100, checks: Math.max(1, Math.floor((Math.min(now, start + dayMs) - start) / 60000)), latencyMs: 110 + serviceIndex * 40, coverage: 100 }; + }); + const responseHistory = Array.from({ length: 24 }, (_, index) => ({ time: now - (24 - index) * 3600000, checks: 60, uptime: 100, latencyMs: 80 + serviceIndex * 42 + Math.round((1 + Math.sin(index / 2 + serviceIndex)) * 65) })); + const checks = history.reduce((sum, item) => sum + item.checks, 0); + const uptime = Number((history.reduce((sum, item) => sum + item.checks * item.uptime, 0) / checks).toFixed(2)); + return { + id: service.id, icon: service.icon, name: service.name, description: service.description, + publicUrl: service.publicUrl, kind: service.kind, functional: false, impactPolicy: service.impactPolicy, + configured: true, status: service.id === 'board' ? 'maintenance' : service.id === 'pwa' ? 'degraded' : 'operational', availabilityStatus: 'operational', activeMaintenance: service.id === 'board' ? ['demo-maintenance-active'] : [], + scenario: service.scenario ? { type: service.scenario.type, status: 'operational', lastCheckedAt: now, staleAfterSeconds: 900, intervalSeconds: 300, stale: false, reason: null, mode: service.id === 'api' ? 'http' : 'browser', pending: false, steps: (service.id === 'board' ? ['config', 'stops', 'departures', 'render'] : service.id === 'api' ? ['cities', 'stops', 'departures'] : ['cities', 'stops', 'departures', 'render']).map(id => ({ id, status: 'passed', latencyMs: 120, httpCode: 200 })) } : null, + stale: false, pending: false, + lastCheckedAt: now, latencyMs: 110 + serviceIndex * 40, avgLatencyMs: 110 + serviceIndex * 40, + httpCode: 200, reason: null, uptime, checks, history, responseHistory, + recentChecks: Array.from({ length: 12 }, (_, index) => ({ time: now - index * 60000, ok: true, latencyMs: 110 + serviceIndex * 40 + index * 2, httpCode: 200, reason: null })), + }; + }); + const pwa = services.find(service => service.id === 'pwa'); + pwa.scenario.status = 'down'; pwa.scenario.reason = 'content'; + pwa.scenario.steps = pwa.scenario.steps.map(step => ({ ...step, status: step.id === 'departures' ? 'failed' : step.id === 'render' ? 'waiting' : 'passed' })); + services.forEach(service => { service.impact = serviceImpact(service, services); }); + services.forEach((service, index) => { + const since = new Date(service.history[0].date).getTime(); + const boardRecovery = today - 8 * dayMs + 12 * 3600000; + const duration = service.id === 'pwa' ? 35 * 60000 : service.id === 'board' && range > 8 ? 3600000 : 0; + service.reliability = { downtimeMs: duration, longestDowntimeMs: duration, incidentCount: Number(duration > 0), downtimeIncomplete: false, lastRecoveryAt: service.id === 'board' ? boardRecovery : null, lastRecoveryScope: service.id === 'board' ? 'availability' : null, observedMs: now - since, coverage: 100, since, asOf: now }; + service.latencyHistory = service.history.map((day, i) => ({ time: new Date(day.date).getTime(), checks: day.checks, uptime: day.uptime, latencyMs: Math.round(day.latencyMs * (1 + Math.sin(i / 4 + index) * .18)) })); + const stats = (points, change) => { + const samples = points.reduce((sum, point) => sum + point.checks, 0); + const averageMs = Math.round(points.reduce((sum, point) => sum + point.latencyMs * point.checks, 0) / Math.max(1, samples)); + return { averageMs, p95Ms: Math.round(Math.max(...points.map(point => point.latencyMs)) * 1.1), samples, previousAverageMs: Math.round(averageMs / (1 + change / 100)), changePercent: change }; + }; + service.latency = stats(service.latencyHistory, index % 2 ? 12.5 : -8.2); + service.latency24h = stats(service.responseHistory, index % 2 ? 7.1 : -4.6); + }); + const active = { + id: 'example-active', source: 'monitor', scope: 'scenario', serviceId: 'pwa', startedAt: now - 35 * 60000, resolvedAt: null, + title: { ru: 'Расписание в приложении загружается с перебоями', en: 'Timetable loading disrupted in the app' }, + message: { ru: 'Пример инцидента: проверка расписания не проходит.', en: 'Sample incident: the timetable check fails.' }, + updates: [ + { id: 'demo-active-1', source: 'monitor', stage: 'detected', createdAt: now - 35 * 60000, message: { ru: 'Пример: повторные проверки подтвердили проблему с загрузкой расписания.', en: 'Sample: repeated checks confirmed a timetable loading problem.' } }, + { id: 'demo-active-2', source: 'manual', stage: 'investigating', createdAt: now - 25 * 60000, message: { ru: 'Пример: команда проверяет загрузку отправлений для остановок.', en: 'Sample: the team is investigating stop departure loading.' } }, + { id: 'demo-active-3', source: 'manual', stage: 'identified', createdAt: now - 10 * 60000, message: { ru: 'Пример: найдена причина задержек. Готовим исправление; сохранённое расписание может быть доступно.', en: 'Sample: the cause of delays has been identified. A fix is being prepared; saved timetables may be available.' } }, + ], + }; + active.impact = incidentImpact(active, services); + return { + generatedAt: now, lastCheckAt: now, intervalSeconds: config.intervalSeconds, staleAfterSeconds: config.staleAfterSeconds, + failureThreshold: config.failureThreshold, recoveryThreshold: config.recoveryThreshold, slowMs: config.slowMs, + scenarioIntervalSeconds: 300, range, demo: true, overall: 'degraded', services, + incidents: [active, { id: 'example-1', source: 'monitor', scope: 'availability', serviceId: 'board', startedAt: today - 8 * dayMs + 11 * 3600000, resolvedAt: today - 8 * dayMs + 12 * 3600000, title: { ru: 'Задержки в работе электронного табло', en: 'Departure board delays' }, message: { ru: 'Пример инцидента: табло временно не получало данные.', en: 'Sample incident: the board temporarily could not retrieve data.' }, impact: config.services.find(service => service.id === 'board').impactPolicy.availability, updates: [ + { id: 'demo-closed-1', source: 'monitor', stage: 'detected', createdAt: today - 8 * dayMs + 11 * 3600000, message: { ru: 'Пример: проверки подтвердили задержки обновления табло.', en: 'Sample: checks confirmed delays in board updates.' } }, + { id: 'demo-closed-2', source: 'manual', stage: 'monitoring', createdAt: today - 8 * dayMs + 11.75 * 3600000, message: { ru: 'Пример: исправление применено, наблюдаем за обновлением расписания.', en: 'Sample: a fix has been applied; watching timetable updates.' } }, + { id: 'demo-closed-3', source: 'monitor', stage: 'resolved', createdAt: today - 8 * dayMs + 12 * 3600000, message: { ru: 'Пример: проверки подтвердили восстановление обновления данных.', en: 'Sample: checks confirmed that data updates were restored.' } }, + { id: 'demo-closed-4', source: 'manual', stage: 'postmortem', createdAt: today - 8 * dayMs + 12 * 3600000 + 10 * 60000, message: { ru: 'Пример разбора: причиной были задержки соединения с источником расписания. Добавили проверку времени ожидания, чтобы раньше замечать повторение проблемы.', en: 'Sample review: the cause was a delayed connection to the timetable source. A timeout check was added to detect recurrence sooner.' } }, + ] }], + maintenance: [ + { id: 'demo-maintenance-active', title: { ru: 'Обновляем электронное табло', en: 'Updating the departure board' }, message: { ru: 'Пример плановых работ: устанавливаем обновление. Возможны короткие перерывы в отображении расписания.', en: 'Sample maintenance: installing an update. Brief interruptions to timetable display are expected.' }, serviceIds: ['board'], startsAt: now - 15 * 60000, endsAt: now + 45 * 60000, createdAt: now - 86400000, cancelledAt: null, completedAt: null, phase: 'active' }, + { id: 'demo-maintenance-scheduled', title: { ru: 'Плановое обслуживание API', en: 'Scheduled API maintenance' }, message: { ru: 'Пример анонса: обновление транспортных сервисов. Расписание может быть временно недоступно.', en: 'Sample announcement: a transport service update. Timetables may be temporarily unavailable.' }, serviceIds: ['api', 'pwa'], startsAt: now + 86400000, endsAt: now + 86400000 + 30 * 60000, createdAt: now, cancelledAt: null, completedAt: null, phase: 'scheduled' }, + ], + }; +} diff --git a/server/feed.mjs b/server/feed.mjs new file mode 100644 index 0000000..1d2e8c6 --- /dev/null +++ b/server/feed.mjs @@ -0,0 +1,55 @@ +import { stageNames } from './incident-updates.mjs'; +import { maintenanceEnd } from './maintenance.mjs'; + +export function xmlEscape(value) { + return String(value).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/g, '').replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]); +} +export function feedOptions(params, services) { + if (Array.from(params.keys()).some(key => !['lang', 'services', 'types'].includes(key)) || ['lang', 'services', 'types'].some(key => params.getAll(key).length > 1)) throw new Error('Use lang, services and types only'); + const locale = params.has('lang') ? params.get('lang') : 'ru'; + if (!['ru', 'en'].includes(locale)) throw new Error('Language must be ru or en'); + function list(key, allowed) { + const values = params.has(key) ? params.get(key).split(',') : allowed; + if (!values.length || values.some(value => !allowed.includes(value))) throw new Error(`Invalid ${key}`); + return [...new Set(values)].sort(); + } + return { locale, serviceIds: list('services', services.map(service => service.id)), types: list('types', ['incidents', 'maintenance']) }; +} + +export function buildFeed(snapshot, options, publicUrl) { + const { locale, serviceIds, types } = options; + const ru = locale === 'ru'; + const events = []; + const link = (view, id) => { const url = new URL('/', publicUrl); url.searchParams.set('view', view); url.searchParams.set('lang', locale); if (id) url.hash = id; return url.toString(); }; + if (types.includes('incidents')) for (const incident of snapshot.incidents) { + if (incident.serviceId && !serviceIds.includes(incident.serviceId)) continue; + for (const update of incident.updates) { + const details = [update.source === 'monitor' ? (ru ? 'Автоматическая проверка.' : 'Automatic monitoring.') : (ru ? 'Сообщение команды.' : 'Team update.'), update.message[locale], incident.impact ? `${ru ? 'Влияние' : 'Impact'}: ${incident.impact[locale]}` : null].filter(Boolean).join('\n\n'); + events.push({ id: `incident:${incident.id}:${incident.startedAt}:${update.id}`, time: update.createdAt, title: `${incident.title[locale]} — ${stageNames[update.stage][locale]}`, description: details, category: ru ? 'Инциденты' : 'Incidents', link: link('incidents', `incident-${incident.id}`) }); + } + } + if (types.includes('maintenance')) for (const item of snapshot.maintenance) { + if (item.serviceIds.length && !item.serviceIds.some(id => serviceIds.includes(id))) continue; + const dates = new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' }); + const affected = item.serviceIds.length ? snapshot.services.filter(service => item.serviceIds.includes(service.id)).map(service => service.name[locale]).join(' · ') : (ru ? 'Все сервисы' : 'All services'); + const description = `${item.message[locale]}\n\n${ru ? 'Плановое окно' : 'Scheduled window'}: ${dates.format(item.startsAt)} — ${dates.format(item.endsAt)} (UTC).\n${ru ? 'Затронутые сервисы' : 'Affected services'}: ${affected}.`; + function event(phase, time, title) { + if (time !== null && time <= snapshot.generatedAt) events.push({ id: `maintenance:${item.id}:${item.createdAt}:${phase}`, time, title: `${item.title[locale]} — ${title}`, description, category: ru ? 'Плановые работы' : 'Maintenance', link: link('maintenance', `maintenance-${item.id}`) }); + } + event('announced', item.createdAt, ru ? 'Анонс работ' : 'Maintenance announced'); + if (maintenanceEnd(item) > item.startsAt) event('started', item.startsAt, ru ? 'Плановое окно началось' : 'Scheduled window started'); + if (maintenanceEnd(item) >= item.endsAt) event('ended', item.endsAt, ru ? 'Плановое окно закончилось' : 'Scheduled window ended'); + event('cancelled', item.cancelledAt, ru ? 'Работы отменены' : 'Maintenance cancelled'); + event('completed', item.completedAt, ru ? 'Завершено командой' : 'Completed by the team'); + } + const visible = events.filter(event => event.time <= snapshot.generatedAt).sort((a, b) => b.time - a.time || b.id.localeCompare(a.id)).slice(0, 100); + const self = new URL('/feed.xml', publicUrl); + self.searchParams.set('lang', locale); + self.searchParams.set('services', serviceIds.join(',')); + self.searchParams.set('types', types.join(',')); + const tag = (name, value) => `<${name}>${xmlEscape(value)}`; + // Descriptions are HTML in RSS readers: escape user text before XML encoding. + const descriptionHtml = value => value.split('\n\n').map(text => `

${xmlEscape(text).replaceAll('\n', '
')}

`).join(''); + const items = visible.map(event => `${tag('title', event.title)}${tag('link', event.link)}${xmlEscape(event.id)}${tag('pubDate', new Date(event.time).toUTCString())}${tag('description', descriptionHtml(event.description))}${tag('category', event.category)}`).join('\n'); + return `\n${tag('title', ru ? 'RideBus — обновления сервисов' : 'RideBus — service updates')}${tag('link', link('incidents'))}${tag('description', ru ? 'Инциденты и плановые работы выбранных сервисов RideBus.' : 'Incidents and maintenance for your selected RideBus services.')}${tag('language', locale)}${tag('lastBuildDate', new Date(visible[0]?.time || snapshot.generatedAt).toUTCString())}5\n${items}\n`; +} diff --git a/server/incident-updates.mjs b/server/incident-updates.mjs new file mode 100644 index 0000000..bb3a2a0 --- /dev/null +++ b/server/incident-updates.mjs @@ -0,0 +1,17 @@ +export const incidentStages = ['detected', 'reported', 'investigating', 'identified', 'monitoring', 'resolved', 'postmortem']; +export const teamStages = ['investigating', 'identified', 'monitoring', 'postmortem']; +export const stageNames = { + detected: { ru: 'Сбой обнаружен', en: 'Outage detected' }, + reported: { ru: 'Сообщение команды', en: 'Team report' }, + investigating: { ru: 'Проверяем причину', en: 'Investigating' }, + identified: { ru: 'Причина найдена', en: 'Cause identified' }, + monitoring: { ru: 'Наблюдаем за работой', en: 'Monitoring' }, + resolved: { ru: 'Восстановлено', en: 'Resolved' }, + postmortem: { ru: 'Причина и итоги', en: 'Cause and outcome' }, +}; + +export function validateUpdate({ stage, messageRu, messageEn }) { + if (!teamStages.includes(stage)) throw new Error('Stage must be investigating, identified, monitoring or postmortem'); + for (const text of [messageRu, messageEn]) if (typeof text !== 'string' || !text.trim() || text.trim().length > 2000) throw new Error('Provide message-ru and message-en, 1–2000 characters each'); + return { stage, messageRu: messageRu.trim(), messageEn: messageEn.trim() }; +} diff --git a/server/incidents-cli.mjs b/server/incidents-cli.mjs new file mode 100644 index 0000000..ec79b8b --- /dev/null +++ b/server/incidents-cli.mjs @@ -0,0 +1,40 @@ +import { loadConfig } from './config.mjs'; +import { openStore } from './store.mjs'; + +const config = loadConfig(); +const args = process.argv.slice(2); +function optionsFrom(items) { + const result = {}; + for (const item of items) { + const at = item.indexOf('='); + if (at <= 0 || Object.hasOwn(result, item.slice(0, at))) throw new Error('Use unique key=value options'); + result[item.slice(0, at)] = item.slice(at + 1); + } + return result; +} +const store = openStore(config.dbPath); +try { + if (args[0] === 'list') { + console.log(JSON.stringify(store.incidents().map(({ signature: _signature, ...incident }) => incident), null, 2)); + } else if (args[0] === 'resolve' && /^\d+$/.test(args[1] || '')) { + const options = optionsFrom(args.slice(2)); + const input = Object.keys(options).length ? { messageRu: options['message-ru'], messageEn: options['message-en'] } : null; + if (!store.resolveIncident(args[1], Date.now(), input)) throw new Error('Open manual incident not found; monitor incidents close after recovery'); + console.log(`Resolved incident ${args[1]}`); + } else if (args[0] === 'update' && /^\d+$/.test(args[1] || '')) { + const options = optionsFrom(args.slice(2)); + store.updateIncident(args[1], { stage: options.stage, messageRu: options['message-ru'], messageEn: options['message-en'] }); + console.log(`Updated incident ${args[1]}`); + } else if (args[0] === 'create') { + const options = optionsFrom(args.slice(1)); + const required = ['title-ru', 'title-en', 'message-ru', 'message-en']; + if (required.some(key => !options[key]?.trim() || options[key].length > 2000)) throw new Error('Provide title-ru, title-en, message-ru, message-en, 1–2000 characters'); + if (options.service && !config.services.some(service => service.id === options.service)) throw new Error('Unknown service'); + const id = store.createIncident({ serviceId: options.service || null, titleRu: options['title-ru'], titleEn: options['title-en'], messageRu: options['message-ru'], messageEn: options['message-en'] }); + console.log(`Created incident ${id}`); + } else { + console.log('Usage: npm run incident -- list | resolve ID ["message-ru=..." "message-en=..."] | update ID stage=investigating|identified|monitoring|postmortem "message-ru=..." "message-en=..." | create service=api "title-ru=..." "title-en=..." "message-ru=..." "message-en=..."'); + process.exitCode = 1; + } +} catch (error) { console.error(error.message); process.exitCode = 1; } +finally { store.close(); } diff --git a/server/index.mjs b/server/index.mjs new file mode 100644 index 0000000..0416487 --- /dev/null +++ b/server/index.mjs @@ -0,0 +1,96 @@ +import { createServer } from 'node:http'; +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { resolve, extname, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadConfig } from './config.mjs'; +import { openStore } from './store.mjs'; +import { createMonitor } from './monitor.mjs'; +import { makeSnapshot } from './snapshot.mjs'; +import { demoSnapshot } from './demo.mjs'; +import { createScenarioRunner } from './scenarios.mjs'; +import { createNotifier } from './notifications.mjs'; +import { buildFeed, feedOptions } from './feed.mjs'; + +const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.png': 'image/png', '.json': 'application/json' }; + +export async function createStatusServer(config, { dev = false, monitorEnabled = true } = {}) { + const store = openStore(config.dbPath); + const scenarios = createScenarioRunner(config); + const monitor = createMonitor(store, config, { runScenario: service => scenarios.run(service) }); + const notifier = createNotifier(store, config); + const vite = dev ? await (await import('vite')).createServer({ server: { middlewareMode: true }, appType: 'spa' }) : null; + const root = resolve('dist'); + const demoCache = new Map(); + const snapshotCache = new Map(); + function liveSnapshot(range) { + const now = Date.now(), revision = store.dataRevision(), cached = snapshotCache.get(range); + if (cached && cached.revision === revision && now >= cached.snapshot.generatedAt && now - cached.snapshot.generatedAt < 5000 && Math.floor(now / 86400000) === Math.floor(cached.snapshot.generatedAt / 86400000)) return cached.snapshot; + const snapshot = makeSnapshot(store, config, range, now); + snapshotCache.set(range, { snapshot, revision }); + return snapshot; + } + function json(res, status, data) { + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' }); + res.end(JSON.stringify(data)); + } + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, 'http://localhost'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + if (!['GET', 'HEAD'].includes(req.method)) { res.setHeader('Allow', 'GET, HEAD'); json(res, 405, { error: 'Method not allowed' }); return; } + if (url.pathname === '/healthz') { + // This is the status application's health, independent of target outages. + json(res, 200, { status: 'ok' }); return; + } + if (url.pathname === '/api/status') { + const range = Number(url.searchParams.get('range') || 30); + if (![7, 30, 90].includes(range)) { json(res, 400, { error: 'Range must be 7, 30, or 90 days' }); return; } + if (url.searchParams.get('demo') === '1') { + const cached = demoCache.get(range); + if (!cached || Date.now() - cached.generatedAt > 30000) demoCache.set(range, demoSnapshot(range)); + json(res, 200, demoCache.get(range)); + } else json(res, 200, liveSnapshot(range)); + return; + } + if (url.pathname === '/feed.xml') { + let options; + try { options = feedOptions(url.searchParams, config.services); } catch (error) { json(res, 400, { error: error.message }); return; } + const host = new URL(`http://${req.headers.host || 'localhost'}`); + const local = ['localhost', '127.0.0.1', '[::1]'].includes(host.hostname); + const publicUrl = config.publicUrl || `${local ? 'http' : 'https'}://${host.host}`; + const xml = buildFeed(liveSnapshot(7), options, publicUrl); + res.writeHead(200, { 'Content-Type': 'application/rss+xml; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Disposition': 'inline' }); + res.end(req.method === 'HEAD' ? undefined : xml); + return; + } + if (url.pathname.startsWith('/api/')) { json(res, 404, { error: 'Not found' }); return; } + if (vite) { vite.middlewares(req, res); return; } + let decoded; + try { decoded = decodeURIComponent(url.pathname); } catch { json(res, 400, { error: 'Invalid path' }); return; } + const path = resolve(root, `.${decoded}`); + if (path !== root && !path.startsWith(root + sep)) { json(res, 404, { error: 'Not found' }); return; } + const file = decoded === '/' ? resolve(root, 'index.html') : path; + if (!existsSync(file) || !statSync(file).isFile()) { json(res, 404, { error: 'Not found' }); return; } + res.setHeader('Content-Type', MIME[extname(file)] || 'application/octet-stream'); + res.setHeader('Cache-Control', decoded.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache'); + if (req.method === 'HEAD') { res.end(); return; } + createReadStream(file).on('error', () => res.destroy()).pipe(res); + } catch { if (!res.headersSent) json(res, 500, { error: 'Status is temporarily unavailable' }); else res.destroy(); } + }); + if (monitorEnabled) { monitor.start(); notifier.start(); } + return { + server, store, monitor, + async close() { await monitor.stop(); await notifier.stop(); await scenarios.close(); await vite?.close(); server.closeIdleConnections(); if (server.listening) await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); store.close(); }, + }; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + const config = loadConfig(); + const app = await createStatusServer(config, { dev: process.argv.includes('--dev') }); + app.server.listen(config.port, config.host, () => { + console.log(`RideBus Status: http://localhost:${app.server.address().port}`); + console.log(`Monitoring ${config.services.length} services every ${config.intervalSeconds}s`); + }); + for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, async () => { await app.close(); process.exit(0); }); +} diff --git a/server/maintenance-cli.mjs b/server/maintenance-cli.mjs new file mode 100644 index 0000000..b0de0d0 --- /dev/null +++ b/server/maintenance-cli.mjs @@ -0,0 +1,23 @@ +import { loadConfig } from './config.mjs'; +import { openStore } from './store.mjs'; +import { parseInstant, validateMaintenance, maintenancePhase } from './maintenance.mjs'; + +const config = loadConfig(); +const [command, ...args] = process.argv.slice(2); +const store = openStore(config.dbPath); +try { + if (command === 'list') console.log(JSON.stringify(store.maintenances().map(item => ({ ...item, phase: maintenancePhase(item) })), null, 2)); + else if (['cancel', 'finish'].includes(command) && /^\d+$/.test(args[0] || '')) { + if (!store.endMaintenance(args[0], command)) throw new Error('Maintenance window not found or already closed'); + console.log(`Maintenance ${args[0]}: ${command}`); + } else if (command === 'schedule') { + const options = Object.fromEntries(args.map(arg => { const at = arg.indexOf('='); if (at < 1) throw new Error('Use key=value arguments'); return [arg.slice(0, at), arg.slice(at + 1)]; })); + const input = validateMaintenance({ + titleRu: options['title-ru'], titleEn: options['title-en'], messageRu: options['message-ru'], messageEn: options['message-en'], + serviceIds: options.services ? options.services.split(',') : [], + startsAt: parseInstant(options.starts), endsAt: parseInstant(options.ends), + }, config.services.map(service => service.id)); + console.log(`Scheduled maintenance ${store.createMaintenance(input)}`); + } else throw new Error('Usage: npm run maintenance -- list | cancel ID | finish ID | schedule services=api,pwa starts=ISO ends=ISO "title-ru=..." "title-en=..." "message-ru=..." "message-en=..."'); +} catch (error) { console.error(error.message); process.exitCode = 1; } +finally { store.close(); } diff --git a/server/maintenance.mjs b/server/maintenance.mjs new file mode 100644 index 0000000..facd789 --- /dev/null +++ b/server/maintenance.mjs @@ -0,0 +1,37 @@ +export function maintenanceEnd(item) { + return Math.min(item.endsAt, item.cancelledAt ?? Infinity, item.completedAt ?? Infinity); +} + +export function maintenancePhase(item, now = Date.now()) { + if (item.cancelledAt !== null && item.cancelledAt <= now) return 'cancelled'; + if (item.completedAt !== null && item.completedAt <= now) return 'completed'; + if (now >= item.endsAt) return 'ended'; + return now < item.startsAt ? 'scheduled' : 'active'; +} + +export function affectsService(item, serviceId, time = Date.now()) { + return (!item.serviceIds.length || item.serviceIds.includes(serviceId)) && item.startsAt <= time && time < maintenanceEnd(item); +} + +export function validateMaintenance(input, serviceIds) { + for (const key of ['titleRu', 'titleEn', 'messageRu', 'messageEn']) { + if (typeof input[key] !== 'string' || !input[key].trim() || input[key].length > 2000) throw new Error(`Provide ${key} (1–2000 characters)`); + } + if (!Number.isSafeInteger(input.startsAt) || !Number.isSafeInteger(input.endsAt) || input.endsAt <= input.startsAt) throw new Error('End must be after start'); + if (!Array.isArray(input.serviceIds) || input.serviceIds.some(id => !serviceIds.includes(id)) || new Set(input.serviceIds).size !== input.serviceIds.length) throw new Error('Unknown or duplicate service'); + return input; +} + +export function parseInstant(value) { + const valid = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) && Number.isFinite(Date.parse(value)); + if (!valid || new Date(`${value.slice(0, 10)}T00:00:00Z`).toISOString().slice(0, 10) !== value.slice(0, 10)) throw new Error('Use a valid ISO timestamp with an explicit timezone, for example 2026-10-13T02:00:00+03:00'); + return Date.parse(value); +} + +export function combinedStatus(availability, scenario, maintenance = false) { + if (maintenance) return 'maintenance'; + if (availability === 'down' || availability === 'unknown') return availability; + if (scenario && ['down', 'degraded'].includes(scenario.status)) return 'degraded'; + if (scenario?.status === 'unknown') return 'unknown'; + return availability; +} diff --git a/server/monitor.mjs b/server/monitor.mjs new file mode 100644 index 0000000..4e6d949 --- /dev/null +++ b/server/monitor.mjs @@ -0,0 +1,60 @@ +import { checkService, nextState } from './probe.mjs'; + +export function createMonitor(store, config, { check = checkService, runScenario = null, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout } = {}) { + let active = null; + let stopped = true; + let timer; + let lastPruned = 0; + let scenarioActive = null, scenarioTimer; + function previousState(service, scope, current) { + // Expected failures during maintenance do not count toward a new outage. + if (current && store.inMaintenance(service, current.checked_at) && !store.inMaintenance(service, now()) && !store.hasOpenIncident(service, scope)) return null; + return current; + } + async function cycle() { + if (active) return active; + active = (async () => { + await Promise.all(config.services.map(async service => { + const result = await check(service, config); + store.record(service, result, nextState(previousState(service, 'availability', store.state(service)), result, config), now(), { notify: config.telegram?.enabled }); + })); + if (now() - lastPruned > 86400000) { store.prune(now() - config.retentionDays * 86400000); lastPruned = now(); } + })(); + try { await active; } finally { active = null; } + } + async function scenarioCycle() { + if (!runScenario) return; + if (scenarioActive) return scenarioActive; + scenarioActive = (async () => { + await Promise.all(config.services.filter(service => service.scenario).map(async service => { + const previous = store.scenario(service); + if (previous && now() - previous.checked_at < config.scenarioIntervalSeconds * 1000) return; + const result = await runScenario(service); + store.recordScenario(service, result, nextState(previousState(service, 'scenario', previous), result, { ...config, slowMs: config.scenarioSlowMs }), now(), { notify: config.telegram?.enabled }); + })); + })(); + try { await scenarioActive; } finally { scenarioActive = null; } + } + async function loop() { + try { await cycle(); } catch { console.error('Monitoring cycle failed; previous results will expire.'); } + if (!stopped) timer = setTimer(loop, config.intervalSeconds * 1000); + } + async function scenarioLoop() { + const interval = config.scenarioIntervalSeconds * 1000; + let delay = interval; + try { + await scenarioCycle(); + // Resume each saved deadline after a restart, even when a check is not due yet. + for (const service of config.services.filter(service => service.scenario)) { + const saved = store.scenario(service); + if (saved) delay = Math.min(delay, Math.max(100, saved.checked_at + interval - now())); + } + } catch { console.error('User journey checks failed; previous results will expire.'); } + if (!stopped) scenarioTimer = setTimer(scenarioLoop, delay); + } + return { + cycle, scenarioCycle, + start() { if (!stopped) return; stopped = false; void loop(); if (runScenario) void scenarioLoop(); }, + async stop() { stopped = true; clearTimer(timer); clearTimer(scenarioTimer); await Promise.all([active, scenarioActive].filter(Boolean)); }, + }; +} diff --git a/server/notifications.mjs b/server/notifications.mjs new file mode 100644 index 0000000..851e073 --- /dev/null +++ b/server/notifications.mjs @@ -0,0 +1,63 @@ +export function notificationText(event, service, config) { + const en = config.locale === 'en'; + const name = service.name[en ? 'en' : 'ru']; + const scope = event.scope === 'scenario' ? (en ? 'User journey' : 'Пользовательский сценарий') : (en ? 'Availability' : 'Доступность'); + const recovered = event.kind === 'recovery'; + const title = recovered ? (en ? 'Recovered' : 'Восстановлено') : (en ? 'Outage confirmed' : 'Сбой подтверждён'); + const date = new Intl.DateTimeFormat(en ? 'en' : 'ru', { dateStyle: 'short', timeStyle: 'short', timeZone: config.timezone }).format(event.created_at); + const duration = recovered && event.resolved_at ? Math.max(1, Math.round((event.resolved_at - event.started_at) / 60000)) : null; + return [`RideBus Status · ${title}`, name, scope, date, + ...(duration === null ? [] : [`${en ? 'Confirmed incident duration' : 'Длительность подтверждённого инцидента'}: ${duration} ${en ? 'min' : 'мин'}`]), + ...(config.publicUrl ? [config.publicUrl] : []), + ].join('\n').slice(0, 3500); +} + +export async function sendTelegram(config, text, { fetchImpl = fetch } = {}) { + try { + const response = await fetchImpl(`https://api.telegram.org/bot${config.token}/sendMessage`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, redirect: 'error', + body: JSON.stringify({ chat_id: config.chatId, text, link_preview_options: { is_disabled: true } }), + signal: AbortSignal.timeout(5000), + }); + let body; + try { body = await response.json(); } catch { body = null; } + if (response.ok && body?.ok === true) return { ok: true }; + const code = body?.error_code || response.status; + const retryAfter = Number(body?.parameters?.retry_after); + return { ok: false, retryable: code === 429 || code >= 500, reason: code === 429 ? 'rate_limited' : code === 401 || code === 403 ? 'access_denied' : 'telegram_error', retryAfterSeconds: code === 429 && Number.isFinite(retryAfter) ? Math.max(1, Math.min(3600, retryAfter)) : null }; + } catch { return { ok: false, retryable: true, reason: 'network', retryAfterSeconds: null }; } +} + +export function createNotifier(store, config, { send = sendTelegram, now = Date.now } = {}) { + let active = null, stopped = true, timer; + async function flush() { + if (!config.telegram.enabled) return; + if (active) return active; + active = (async () => { + for (const event of store.dueNotifications(now())) { + const service = config.services.find(service => service.id === event.service_id); + const signature = event.scope === 'scenario' ? service?.scenario?.signature : service?.signature; + if (!service || signature !== event.signature || now() - event.created_at > 86400000) { + store.notificationResult(event.id, { status: 'discarded', reason: 'obsolete' }, now()); continue; + } + const result = await send(config.telegram, notificationText(event, service, config.telegram)); + if (result.ok) store.notificationResult(event.id, { status: 'sent' }, now()); + else { + const retry = result.retryable && event.attempts < 7; + const delaySeconds = result.retryAfterSeconds || Math.min(3600, 30 * 2 ** event.attempts); + store.notificationResult(event.id, { status: retry ? 'pending' : 'failed', retryAt: now() + delaySeconds * 1000, reason: result.reason }, now()); + // The database holds this incident's recovery until its outage is sent. + } + } + })(); + try { await active; } finally { active = null; } + } + async function loop() { + try { await flush(); } catch { console.error('Notification delivery failed; pending events remain in the database.'); } + if (!stopped) timer = setTimeout(loop, 15000); + } + return { flush, + start() { if (!stopped || !config.telegram.enabled) return; stopped = false; void loop(); }, + async stop() { stopped = true; clearTimeout(timer); if (active) await active; }, + }; +} diff --git a/server/notify-cli.mjs b/server/notify-cli.mjs new file mode 100644 index 0000000..8acb674 --- /dev/null +++ b/server/notify-cli.mjs @@ -0,0 +1,17 @@ +import { loadConfig } from './config.mjs'; +import { openStore } from './store.mjs'; +import { sendTelegram } from './notifications.mjs'; + +const config = loadConfig(); +const command = process.argv[2] || 'status'; +if (command === 'status') { + const store = openStore(config.dbPath); + try { console.log(JSON.stringify({ enabled: config.telegram.enabled, queue: store.notificationStats() }, null, 2)); } finally { store.close(); } +} else if (command === 'test') { + if (!config.telegram.enabled) { console.error('Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID in .env first.'); process.exitCode = 1; } + else { + const result = await sendTelegram(config.telegram, config.telegram.locale === 'en' ? 'RideBus Status · Test notification. Monitoring alerts are connected.' : 'RideBus Status · Тестовое уведомление. Отправка сообщений подключена.'); + console.log(result.ok ? 'Test notification delivered.' : `Delivery failed: ${result.reason}`); + if (!result.ok) process.exitCode = 1; + } +} else { console.error('Usage: npm run notify -- status | test'); process.exitCode = 1; } diff --git a/server/probe.mjs b/server/probe.mjs new file mode 100644 index 0000000..6dfd91b --- /dev/null +++ b/server/probe.mjs @@ -0,0 +1,85 @@ +const MAX_BODY_BYTES = 1024 * 1024; + +async function bodyText(response) { + if (!response.body) return ''; + const reader = response.body.getReader(); + const chunks = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.length; + if (size > MAX_BODY_BYTES) { await reader.cancel(); throw new Error('body-limit'); } + chunks.push(value); + } + } finally { reader.releaseLock(); } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; } + return new TextDecoder().decode(bytes); +} + +export async function runProbe(probe, { timeoutMs, fetchImpl = fetch }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const started = performance.now(); + let httpCode = null; + try { + let url = probe.url; + let headers = { 'User-Agent': 'RideBusStatus/1.0', ...probe.headers }; + let response; + for (let redirects = 0; redirects <= 3; redirects++) { + response = await fetchImpl(url, { signal: controller.signal, redirect: 'manual', headers }); + httpCode = response.status; + if (![301, 302, 303, 307, 308].includes(response.status)) break; + const location = response.headers.get('location'); + await response.body?.cancel(); + if (!location || redirects === 3) return { ok: false, latencyMs: null, httpCode, reason: 'redirect' }; + const next = new URL(location, url); + if (!['http:', 'https:'].includes(next.protocol) || next.username || next.password) return { ok: false, latencyMs: null, httpCode, reason: 'redirect' }; + // Never forward secret/custom headers to another origin. + if (next.origin !== new URL(url).origin) headers = { 'User-Agent': 'RideBusStatus/1.0' }; + url = next.toString(); + } + if (httpCode !== 200) { await response.body?.cancel(); return { ok: false, latencyMs: null, httpCode, reason: 'http' }; } + const text = await bodyText(response); + if (probe.kind === 'html' && (!response.headers.get('content-type')?.toLowerCase().includes('text/html') || !/]/i.test(text))) { + return { ok: false, latencyMs: null, httpCode, reason: 'content' }; + } + if (probe.kind === 'json') { + let json; + try { json = JSON.parse(text); } catch { return { ok: false, latencyMs: null, httpCode, reason: 'content' }; } + if (probe.expectedJson) { + const actual = probe.expectedJson.path.split('.').reduce((value, part) => value?.[part], json); + if (actual !== probe.expectedJson.value) return { ok: false, latencyMs: null, httpCode, reason: 'content' }; + } + if (probe.requireDataArray) { + const data = Array.isArray(json) ? json : json?.data; + if (!Array.isArray(data) || !data.length) return { ok: false, latencyMs: null, httpCode, reason: 'content' }; + } + if (probe.validateJson && !probe.validateJson(json)) return { ok: false, latencyMs: null, httpCode, reason: 'content' }; + } + return { ok: true, latencyMs: Math.round(performance.now() - started), httpCode, reason: null }; + } catch (error) { + return { ok: false, latencyMs: null, httpCode, reason: controller.signal.aborted ? 'timeout' : error.message === 'body-limit' ? 'content' : 'network' }; + } finally { clearTimeout(timer); } +} + +export async function checkService(service, options) { + if (!service.probes.length) return { ok: false, latencyMs: null, httpCode: null, reason: 'unconfigured' }; + const results = await Promise.all(service.probes.map(probe => runProbe(probe, options))); + const failed = results.find(result => !result.ok); + return failed || { ok: true, latencyMs: Math.max(...results.map(result => result.latencyMs)), httpCode: results[0].httpCode, reason: null }; +} + +export function nextState(previous, result, config) { + if (['unconfigured', 'auth_required', 'browser_unavailable', 'not_run'].includes(result.reason)) return { status: 'unknown', failures: 0, successes: 0 }; + const failures = result.ok ? 0 : (previous?.failures || 0) + 1; + const successes = result.ok ? (previous?.successes || 0) + 1 : 0; + let status; + if (!result.ok) status = previous?.status === 'down' || failures >= config.failureThreshold ? 'down' : 'degraded'; + else if (previous?.status === 'down' && successes < config.recoveryThreshold) status = 'down'; + else status = result.latencyMs > config.slowMs ? 'degraded' : 'operational'; + return { status, failures, successes }; +} diff --git a/server/reliability.mjs b/server/reliability.mjs new file mode 100644 index 0000000..c1b5d61 --- /dev/null +++ b/server/reliability.mjs @@ -0,0 +1,63 @@ +export function unionIntervals(intervals) { + const sorted = intervals.filter(([start, end]) => end > start).sort((a, b) => a[0] - b[0] || a[1] - b[1]); + const result = []; + for (const [start, end] of sorted) { + const last = result.at(-1); + if (last && start <= last[1]) last[1] = Math.max(last[1], end); + else result.push([start, end]); + } + return result; +} +export function intersectIntervals(left, right) { + const a = unionIntervals(left), b = unionIntervals(right), result = []; + let i = 0, j = 0; + while (i < a.length && j < b.length) { + const start = Math.max(a[i][0], b[j][0]), end = Math.min(a[i][1], b[j][1]); + if (end > start) result.push([start, end]); + if (a[i][1] <= b[j][1]) i++; else j++; + } + return result; +} +export function subtractIntervals(intervals, excluded) { + const result = [], exclusions = unionIntervals(excluded); + for (const [start, end] of unionIntervals(intervals)) { + let cursor = start; + for (const [a, b] of exclusions) { + if (b <= cursor) continue; + if (a >= end) break; + if (a > cursor) result.push([cursor, Math.min(a, end)]); + cursor = Math.max(cursor, b); + if (cursor >= end) break; + } + if (cursor < end) result.push([cursor, end]); + } + return result; +} +export const intervalDuration = intervals => unionIntervals(intervals).reduce((sum, [start, end]) => sum + end - start, 0); + +export function reliabilitySummary({ availability, scenario, incidents, maintenance, since, now, lastRecovery }) { + const excluded = unionIntervals(maintenance); + const observed = subtractIntervals(scenario ? intersectIntervals(availability.known, scenario.known) : availability.known, excluded); + const outages = []; + let incomplete = false; + for (const incident of incidents) { + const expected = subtractIntervals([[Math.max(since, incident.startedAt), Math.min(now, incident.resolvedAt ?? now)]], excluded); + if (!expected.length) continue; + const data = incident.scope === 'scenario' ? scenario : availability; + const measured = intersectIntervals(expected, data?.down || []); + if (intervalDuration(intersectIntervals(expected, data?.known || [])) < intervalDuration(expected)) incomplete = true; + outages.push({ intervals: measured, duration: intervalDuration(measured) }); + } + const measured = unionIntervals(outages.flatMap(item => item.intervals)); + const duration = intervalDuration(measured); + const hasData = observed.length > 0 || duration > 0; + const usable = hasData && !(incomplete && duration === 0); + return { + downtimeMs: usable ? duration : null, + longestDowntimeMs: usable ? outages.reduce((longest, item) => Math.max(longest, item.duration), 0) : null, + incidentCount: outages.length, downtimeIncomplete: incomplete, + lastRecoveryAt: lastRecovery?.time ?? null, lastRecoveryScope: lastRecovery?.scope ?? null, + observedMs: intervalDuration(observed), coverage: now > since ? Number((intervalDuration(observed) / (now - since) * 100).toFixed(4)) : 0, + since, asOf: now, + }; +} diff --git a/server/scenarios.mjs b/server/scenarios.mjs new file mode 100644 index 0000000..446f9af --- /dev/null +++ b/server/scenarios.mjs @@ -0,0 +1,96 @@ +import { runProbe } from './probe.mjs'; +import { createBrowserChecks } from './browser-checks.mjs'; + +const validId = id => Number.isSafeInteger(Number(id)) && Number(id) > 0; +const validBoardConfig = value => validId(value?.cityId) && validId(value?.defaultStopId) && value.configured === true; +const validDeviceState = value => value?.enabled === false || value?.enabled === true && (['unpaired', 'pending', 'expired'].includes(value.state) || value.state === 'paired' && validBoardConfig(value.config)); +export const validCatalog = value => Array.isArray(value) && value.length > 0 && value.every(item => validId(item?.id) && typeof item.name === 'string' && Boolean(item.name.trim())); +export const validDepartures = value => value && Array.isArray(value.routes) && Array.isArray(value.departures) && value.departures.every(item => validId(item.routeId) && typeof item.scheduledTime === 'string' && Number.isFinite(Date.parse(item.scheduledAt))) && value.partial !== true; +export const validEta = value => value && Array.isArray(value.routes) && value.routes.every(item => !item.error && validId(item.id) && Array.isArray(item.arrivals)); +export const validBoard = value => value && validId(value.stop?.id) && value.mode === 'live' && Array.isArray(value.departures) && value.departures.every(item => validId(item.routeId) && Number.isFinite(Date.parse(item.scheduledAt))) && value.partial !== true && value.demo !== true; + +function selectCity(cities, configuredId) { + const city = configuredId ? cities.find(item => Number(item.id) === configuredId) : cities.find(item => item.slug === 'polotsk') || cities[0]; + if (!city) throw { reason: 'content', httpCode: 200 }; + return Number(city.id); +} +function selectStop(stops, configuredId) { + const stop = configuredId ? stops.find(item => Number(item.id) === configuredId) : stops.find(item => Number(item.routesCount ?? item.routes_count) > 0) || stops[0]; + if (!stop) throw { reason: 'content', httpCode: 200 }; + return Number(stop.id); +} + +export function createScenarioRunner(config, { fetchImpl = fetch, browserChecks = null } = {}) { + const browser = browserChecks || createBrowserChecks(config); + async function run(service) { + const spec = service.scenario; + const steps = []; + const started = performance.now(); + let mode = 'http', current = 'cities'; + const target = {}; + const result = (ok, reason = null) => ({ ok, reason, latencyMs: ok === true ? Math.round(performance.now() - started) : null, httpCode: steps.find(step => step.status === 'failed')?.httpCode ?? (ok === true ? 200 : null), mode, steps }); + async function get(step, path, validator, headers = {}) { + current = step; + let data; + const response = await runProbe({ url: new URL(path, `${spec.baseUrl.replace(/\/+$/, '')}/`).toString(), kind: 'json', headers, validateJson: json => { data = json; return validator(json); } }, { timeoutMs: config.timeoutMs, fetchImpl }); + steps.push({ id: step, status: response.ok ? 'passed' : 'failed', latencyMs: response.latencyMs, httpCode: response.httpCode }); + if (!response.ok) throw { reason: [401, 403].includes(response.httpCode) || spec.type === 'board' && response.httpCode === 410 ? 'auth_required' : response.reason, httpCode: response.httpCode }; + return data; + } + if (!spec || spec.type === 'api' && !spec.apiKey) return result(null, 'unconfigured'); + try { + if (spec.type === 'api') { + const headers = { 'X-API-Key': spec.apiKey }; + const cities = await get('cities', '/api/v1/cities', validCatalog, headers); + target.cityId = selectCity(cities, spec.cityId); + const scoped = { ...headers, 'X-City-Id': String(target.cityId) }; + const stops = await get('stops', '/api/v1/stops', validCatalog, scoped); + target.stopId = selectStop(stops, spec.stopId); + await get('departures', `/api/v1/eta/stop/${target.stopId}`, validEta, scoped); + } else if (spec.type === 'pwa') { + const cities = await get('cities', '/api/data?resource=cities', validCatalog); + target.cityId = selectCity(cities, spec.cityId); + const stops = await get('stops', `/api/data?resource=stops&cityId=${target.cityId}`, validCatalog); + target.stopId = selectStop(stops, spec.stopId); + await get('departures', `/api/data?resource=departures&cityId=${target.cityId}&stopId=${target.stopId}`, validDepartures); + } else { + const headers = spec.cookie ? { Cookie: spec.cookie } : {}; + let settings = await get('config', '/api/config', validBoardConfig, headers); + let managed = false; + if (spec.cookie) { + const device = await get('device', '/api/device/status', validDeviceState, headers); + if (device.enabled) { + if (device.state !== 'paired') { + steps.at(-1).status = 'failed'; + throw { reason: 'auth_required', httpCode: 200 }; + } + // A paired board can only request the stop assigned by the admin. + // /api/config describes the standalone defaults, not this device. + settings = device.config; + managed = true; + } + } + target.cityId = Number(settings.cityId); + const stops = await get('stops', '/api/stops', validCatalog, headers); + target.stopId = managed ? Number(settings.defaultStopId) : spec.stopId || Number(settings.defaultStopId); + if (!stops.some(stop => Number(stop.id) === target.stopId)) throw { reason: 'content' }; + await get('departures', `/api/board?stopId=${target.stopId}`, value => validBoard(value) && Number(value.stop.id) === target.stopId, headers); + } + if (spec.type !== 'api') { + current = 'render'; + if (!config.browser.enabled) steps.push({ id: 'render', status: 'waiting', latencyMs: null, httpCode: null }); + else { + const rendered = await browser.run(spec, target); + steps.push({ id: 'render', status: rendered.ok === null ? 'waiting' : rendered.ok ? 'passed' : 'failed', latencyMs: rendered.latencyMs ?? null, httpCode: rendered.httpCode ?? null }); + if (rendered.ok !== true) return result(rendered.ok, rendered.reason); + mode = 'browser'; + } + } + return result(true); + } catch (error) { + if (steps.at(-1)?.status !== 'failed') steps.push({ id: current, status: 'failed', latencyMs: null, httpCode: error.httpCode ?? null }); + return result(error.reason === 'auth_required' ? null : false, error.reason || 'content'); + } + } + return { run, close: () => browser.close() }; +} diff --git a/server/snapshot.mjs b/server/snapshot.mjs new file mode 100644 index 0000000..fd60c24 --- /dev/null +++ b/server/snapshot.mjs @@ -0,0 +1,83 @@ +const DAY = 86400000; +import { affectsService, combinedStatus, maintenancePhase } from './maintenance.mjs'; +import { incidentImpact, serviceImpact } from '../shared/impact.mjs'; +export function summarize(checks) { + const known = checks.filter(check => check.reason !== 'unconfigured'); + const successful = known.filter(check => check.ok); + return { + checks: known.length, + uptime: known.length ? Number((successful.length / known.length * 100).toFixed(2)) : null, + latencyMs: successful.length ? Math.round(successful.reduce((sum, check) => sum + check.latencyMs, 0) / successful.length) : null, + }; +} + +export function overallStatus(services) { + const states = services.map(service => service.status); + if (states.every(status => status === 'unknown')) return 'unknown'; + if (states.every(status => status === 'down')) return 'down'; + if (states.some(status => status === 'down' || status === 'degraded')) return 'degraded'; + if (states.some(status => status === 'unknown')) return 'unknown'; + if (states.some(status => status === 'maintenance')) return 'maintenance'; + return 'operational'; +} + +export function makeSnapshot(store, config, range = 30, now = Date.now()) { + // UTC day boundaries are stable across the monitor and visitor timezones. + const today = Math.floor(now / DAY) * DAY; + const since = today - (range - 1) * DAY; + const maintenance = store.maintenances().map(item => ({ ...item, phase: maintenancePhase(item, now) })); + const services = config.services.map(service => { + const state = store.state(service); + const statistics = store.statistics(service, since, now); + const latest = statistics.recent[0]; + const days = new Map(statistics.days.map(day => [day.start, day])); + const hours = new Map(statistics.hours.map(hour => [hour.bucket, hour])); + const summaryOf = summary => ({ checks: summary?.checks || 0, uptime: summary?.checks ? Number((summary.successes / summary.checks * 100).toFixed(2)) : null, latencyMs: summary?.latencyMs == null ? null : Math.round(summary.latencyMs) }); + const summary = summaryOf(statistics.summary); + const stale = !state || now - state.checked_at > config.staleAfterSeconds * 1000; + const scenarioState = store.scenario(service); + const scenarioStale = !scenarioState || now - scenarioState.checked_at > config.scenarioIntervalSeconds * 3000; + const scenario = service.scenario ? { + type: service.scenario.type, status: scenarioStale ? 'unknown' : scenarioState.status, + lastCheckedAt: scenarioState?.checked_at || null, staleAfterSeconds: config.scenarioIntervalSeconds * 3, + intervalSeconds: config.scenarioIntervalSeconds, stale: Boolean(scenarioState && scenarioStale), + reason: scenarioStale ? 'not_run' : scenarioState.reason, mode: scenarioState?.mode || 'http', + pending: Boolean(scenarioState && (scenarioState.failures > 0 && scenarioState.failures < config.failureThreshold || scenarioState.status === 'down' && scenarioState.successes > 0)), + steps: scenarioState?.steps || [], + } : null; + const activeMaintenance = maintenance.filter(item => affectsService(item, service.id, now)).map(item => item.id); + const availabilityStatus = stale ? 'unknown' : state.status; + const history = Array.from({ length: range }, (_, index) => { + const start = since + index * DAY; + const samples = days.get(start); + const summary = summaryOf(samples); + const status = samples ? samples.severity === 2 ? 'down' : samples.severity === 1 ? 'degraded' : 'operational' : 'unknown'; + // A sparse or interrupted day must not appear to have complete coverage. + const observableDuration = Math.max(0, Math.min(now, start + DAY) - start); + const expected = Math.max(1, Math.floor(observableDuration / (config.intervalSeconds * 1000))); + const coverage = Math.min(100, Number((summary.checks / expected * 100).toFixed(1))); + return { date: new Date(start).toISOString().slice(0, 10), status, ...summary, coverage }; + }); + const responseHistory = Array.from({ length: 24 }, (_, index) => { + const start = now - (24 - index) * 3600000; + return { time: start, ...summaryOf(hours.get(index)) }; + }); + return { + id: service.id, icon: service.icon, name: service.name, description: service.description, + publicUrl: service.publicUrl, kind: service.kind, functional: service.functional, impactPolicy: service.impactPolicy, + configured: service.probes.length > 0, status: combinedStatus(availabilityStatus, scenario, activeMaintenance.length > 0), availabilityStatus, scenario, activeMaintenance, + stale: Boolean(state && stale), pending: Boolean(state && (state.failures > 0 && state.failures < config.failureThreshold || state.status === 'down' && state.successes > 0)), + lastCheckedAt: state?.checked_at || null, latencyMs: latest?.latencyMs ?? null, + httpCode: latest?.httpCode ?? null, reason: latest?.reason ?? null, + uptime: summary.uptime, checks: summary.checks, + avgLatencyMs: summary.latencyMs, history, responseHistory, + reliability: store.reliability(service, since, now, config), latency: store.latencyDetails(service, since, now, config.intervalSeconds), latency24h: store.latencyDetails(service, now - DAY, now, config.intervalSeconds), + latencyHistory: history.map(day => ({ time: new Date(day.date).getTime(), checks: day.checks, uptime: day.uptime, latencyMs: day.latencyMs })), + recentChecks: statistics.recent.map(({ time, ok, latencyMs, httpCode, reason }) => ({ time, ok: Boolean(ok), latencyMs, httpCode, reason })), + }; + }); + services.forEach(service => { service.impact = serviceImpact(service, services); }); + const times = services.map(service => service.lastCheckedAt).filter(Boolean); + const incidents = store.incidents().filter(incident => incident.source === 'manual' || config.services.some(service => service.id === incident.serviceId && (incident.scope === 'scenario' ? service.scenario?.signature : service.signature) === incident.signature)).map(({ signature: _signature, ...incident }) => ({ ...incident, impact: incidentImpact(incident, services) })); + return { generatedAt: now, lastCheckAt: times.length ? Math.min(...times) : null, intervalSeconds: config.intervalSeconds, scenarioIntervalSeconds: config.scenarioIntervalSeconds, staleAfterSeconds: config.staleAfterSeconds, failureThreshold: config.failureThreshold, recoveryThreshold: config.recoveryThreshold, slowMs: config.slowMs, range, demo: false, overall: overallStatus(services), services, incidents, maintenance }; +} diff --git a/server/store.mjs b/server/store.mjs new file mode 100644 index 0000000..0e38474 --- /dev/null +++ b/server/store.mjs @@ -0,0 +1,261 @@ +import { DatabaseSync } from 'node:sqlite'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { affectsService } from './maintenance.mjs'; +import { validateUpdate } from './incident-updates.mjs'; +import { maintenanceEnd } from './maintenance.mjs'; +import { reliabilitySummary } from './reliability.mjs'; + +export function openStore(path) { + if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true }); + const db = new DatabaseSync(path); + let revision = 0; + db.exec(` + PRAGMA journal_mode = WAL; + PRAGMA busy_timeout = 5000; + CREATE TABLE IF NOT EXISTS checks ( + id INTEGER PRIMARY KEY, service_id TEXT NOT NULL, signature TEXT NOT NULL, + checked_at INTEGER NOT NULL, ok INTEGER NOT NULL, status TEXT NOT NULL, + latency_ms INTEGER, http_code INTEGER, reason TEXT + ); + CREATE INDEX IF NOT EXISTS checks_service_time ON checks(service_id, signature, checked_at); + CREATE TABLE IF NOT EXISTS service_state ( + service_id TEXT NOT NULL, signature TEXT NOT NULL, status TEXT NOT NULL, + failures INTEGER NOT NULL, successes INTEGER NOT NULL, checked_at INTEGER NOT NULL, + PRIMARY KEY (service_id, signature) + ); + CREATE TABLE IF NOT EXISTS incidents ( + id INTEGER PRIMARY KEY, source TEXT NOT NULL, service_id TEXT, + signature TEXT, started_at INTEGER NOT NULL, resolved_at INTEGER, + title_ru TEXT NOT NULL, title_en TEXT NOT NULL, + message_ru TEXT NOT NULL, message_en TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS maintenance ( + id INTEGER PRIMARY KEY, title_ru TEXT NOT NULL, title_en TEXT NOT NULL, + message_ru TEXT NOT NULL, message_en TEXT NOT NULL, service_ids TEXT NOT NULL, + starts_at INTEGER NOT NULL, ends_at INTEGER NOT NULL, created_at INTEGER NOT NULL, + cancelled_at INTEGER, completed_at INTEGER + ); + CREATE TABLE IF NOT EXISTS scenario_state ( + service_id TEXT NOT NULL, signature TEXT NOT NULL, status TEXT NOT NULL, + failures INTEGER NOT NULL, successes INTEGER NOT NULL, checked_at INTEGER NOT NULL, + ok INTEGER, latency_ms INTEGER, reason TEXT, mode TEXT NOT NULL, steps TEXT NOT NULL, + PRIMARY KEY (service_id, signature) + ); + CREATE TABLE IF NOT EXISTS notification_outbox ( + id INTEGER PRIMARY KEY, event_key TEXT NOT NULL UNIQUE, incident_id INTEGER NOT NULL, + service_id TEXT NOT NULL, signature TEXT NOT NULL, scope TEXT NOT NULL, kind TEXT NOT NULL, + created_at INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at INTEGER NOT NULL, delivered_at INTEGER, last_error TEXT + ); + CREATE TABLE IF NOT EXISTS incident_updates ( + id INTEGER PRIMARY KEY, incident_id INTEGER NOT NULL REFERENCES incidents(id), + source TEXT NOT NULL, stage TEXT NOT NULL, created_at INTEGER NOT NULL, + message_ru TEXT NOT NULL, message_en TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS incident_updates_time ON incident_updates(incident_id, created_at, id); + CREATE TABLE IF NOT EXISTS scenario_checks ( + id INTEGER PRIMARY KEY, service_id TEXT NOT NULL, signature TEXT NOT NULL, + checked_at INTEGER NOT NULL, status TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS scenario_checks_time ON scenario_checks(service_id, signature, checked_at); + `); + // Existing MVP databases are upgraded once at startup without losing history. + if (!db.prepare('PRAGMA table_info(incidents)').all().some(column => column.name === 'scope')) db.exec("ALTER TABLE incidents ADD COLUMN scope TEXT NOT NULL DEFAULT 'availability'"); + // Backfill only facts present in the old database; never invent team actions. + db.exec(` + INSERT INTO incident_updates(incident_id, source, stage, created_at, message_ru, message_en) + SELECT i.id, i.source, CASE i.source WHEN 'monitor' THEN 'detected' ELSE 'reported' END, i.started_at, i.message_ru, i.message_en + FROM incidents i WHERE NOT EXISTS (SELECT 1 FROM incident_updates u WHERE u.incident_id = i.id); + INSERT INTO incident_updates(incident_id, source, stage, created_at, message_ru, message_en) + SELECT i.id, i.source, 'resolved', i.resolved_at, + CASE i.source WHEN 'monitor' THEN 'Повторные проверки подтвердили восстановление.' ELSE 'Команда отметила инцидент как решённый.' END, + CASE i.source WHEN 'monitor' THEN 'Repeated checks confirmed recovery.' ELSE 'The team marked the incident as resolved.' END + FROM incidents i WHERE i.resolved_at IS NOT NULL AND NOT EXISTS (SELECT 1 FROM incident_updates u WHERE u.incident_id = i.id AND u.stage = 'resolved'); + `); + const insertUpdate = db.prepare('INSERT INTO incident_updates(incident_id, source, stage, created_at, message_ru, message_en) VALUES (?, ?, ?, ?, ?, ?)'); + function transaction(action) { + db.exec('BEGIN IMMEDIATE'); + try { const value = action(); db.exec('COMMIT'); revision++; return value; } catch (error) { db.exec('ROLLBACK'); throw error; } + } + const stateStatement = db.prepare('SELECT * FROM service_state WHERE service_id = ? AND signature = ?'); + const saveState = db.prepare('INSERT INTO service_state VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(service_id, signature) DO UPDATE SET status = excluded.status, failures = excluded.failures, successes = excluded.successes, checked_at = excluded.checked_at'); + const insertCheck = db.prepare('INSERT INTO checks(service_id, signature, checked_at, ok, status, latency_ms, http_code, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); + const openIncident = db.prepare("SELECT id FROM incidents WHERE source = 'monitor' AND service_id = ? AND signature = ? AND scope = ? AND resolved_at IS NULL"); + const createIncident = db.prepare('INSERT INTO incidents(source, service_id, signature, started_at, title_ru, title_en, message_ru, message_en, scope) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'); + const saveScenario = db.prepare('INSERT INTO scenario_state VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(service_id, signature) DO UPDATE SET status = excluded.status, failures = excluded.failures, successes = excluded.successes, checked_at = excluded.checked_at, ok = excluded.ok, latency_ms = excluded.latency_ms, reason = excluded.reason, mode = excluded.mode, steps = excluded.steps'); + const insertScenarioCheck = db.prepare('INSERT INTO scenario_checks(service_id, signature, checked_at, status) VALUES (?, ?, ?, ?)'); + function spanQuery(table) { + return db.prepare(`WITH ordered AS ( + SELECT checked_at, status, LEAD(checked_at, 1, ?4) OVER (ORDER BY checked_at, id) AS next_at + FROM ${table} WHERE service_id = ?1 AND signature = ?2 AND checked_at >= ?3 - ?5 AND checked_at <= ?4 + ), spans AS ( + SELECT kind, MAX(checked_at, ?3) AS start, MIN(next_at, checked_at + ?5, ?4) AS end + FROM ordered CROSS JOIN (SELECT 'known' AS kind UNION ALL SELECT 'down') + WHERE status IN ('operational', 'degraded', 'down') AND (kind = 'known' OR status = 'down') + ), positive AS (SELECT * FROM spans WHERE end > start), previous AS ( + SELECT *, MAX(end) OVER (PARTITION BY kind ORDER BY start, end ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) AS previous_end FROM positive + ), islands AS ( + SELECT *, SUM(CASE WHEN previous_end IS NULL OR start > previous_end THEN 1 ELSE 0 END) OVER (PARTITION BY kind ORDER BY start, end) AS island FROM previous + ) SELECT kind, MIN(start) AS start, MAX(end) AS end FROM islands GROUP BY kind, island ORDER BY kind, start`); + } + const availabilitySpans = spanQuery('checks'), scenarioSpans = spanQuery('scenario_checks'); + function readSpans(query, id, signature, since, now, staleMs) { + const result = { known: [], down: [] }; + for (const row of query.all(id, signature, since, now, staleMs)) result[row.kind].push([row.start, row.end]); + return result; + } + function maintenanceRows() { + return db.prepare('SELECT * FROM maintenance ORDER BY starts_at DESC, id DESC LIMIT 100').all().map(row => ({ + id: String(row.id), title: { ru: row.title_ru, en: row.title_en }, message: { ru: row.message_ru, en: row.message_en }, + serviceIds: JSON.parse(row.service_ids), startsAt: row.starts_at, endsAt: row.ends_at, createdAt: row.created_at, + cancelledAt: row.cancelled_at, completedAt: row.completed_at, + })); + } + function inMaintenance(service, time) { return maintenanceRows().some(item => affectsService(item, service.id, time)); } + function enqueue(id, service, signature, scope, kind, time) { + db.prepare('INSERT OR IGNORE INTO notification_outbox(event_key, incident_id, service_id, signature, scope, kind, created_at, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(`${id}:${kind}`, id, service.id, signature, scope, kind, time, time); + } + function updateIncident(service, signature, scope, result, state, time, notify) { + const incident = openIncident.get(service.id, signature, scope); + if (state.status === 'down' && !incident && !inMaintenance(service, time)) { + const scenario = scope === 'scenario'; + const created = createIncident.run('monitor', service.id, signature, time, + `${service.name.ru}: ${scenario ? 'сценарий недоступен' : 'сервис недоступен'}`, + `${service.name.en}: ${scenario ? 'user journey unavailable' : 'service unavailable'}`, + scenario ? 'Повторные проверки подтвердили проблему с получением или отображением данных.' : 'Мониторинг подтвердил проблему после нескольких проверок. Ожидаем восстановления.', + scenario ? 'Repeated checks confirmed a problem retrieving or displaying data.' : 'Monitoring confirmed a problem after repeated checks. Waiting for recovery.', scope); + const id = Number(created.lastInsertRowid); + insertUpdate.run(id, 'monitor', 'detected', time, 'Повторные проверки подтвердили сбой. Сообщений команды пока нет.', 'Repeated checks confirmed an outage. No team update yet.'); + if (notify) enqueue(id, service, signature, scope, 'outage', time); + } else if (incident && (state.status === 'operational' || state.status === 'degraded' && result.ok)) { + db.prepare('UPDATE incidents SET resolved_at = ? WHERE id = ?').run(time, incident.id); + insertUpdate.run(incident.id, 'monitor', 'resolved', time, 'Повторные проверки подтвердили восстановление.', 'Repeated checks confirmed recovery.'); + if (notify) enqueue(incident.id, service, signature, scope, 'recovery', time); + } + } + return { + dataRevision() { return `${revision}:${db.prepare('PRAGMA data_version').get().data_version}`; }, + state(service) { return stateStatement.get(service.id, service.signature); }, + record(service, result, state, time = Date.now(), { notify = false } = {}) { + db.exec('BEGIN IMMEDIATE'); + try { + insertCheck.run(service.id, service.signature, time, Number(result.ok), state.status, result.latencyMs, result.httpCode, result.reason); + saveState.run(service.id, service.signature, state.status, state.failures, state.successes, time); + updateIncident(service, service.signature, 'availability', result, state, time, notify); + db.exec('COMMIT'); + revision++; + } catch (error) { db.exec('ROLLBACK'); throw error; } + }, + scenario(service) { + if (!service.scenario) return null; + const row = db.prepare('SELECT * FROM scenario_state WHERE service_id = ? AND signature = ?').get(service.id, service.scenario.signature); + return row ? { ...row, steps: JSON.parse(row.steps) } : null; + }, + recordScenario(service, result, state, time = Date.now(), { notify = false } = {}) { + db.exec('BEGIN IMMEDIATE'); + try { + saveScenario.run(service.id, service.scenario.signature, state.status, state.failures, state.successes, time, result.ok === null ? null : Number(result.ok), result.latencyMs, result.reason, result.mode || 'http', JSON.stringify(result.steps || [])); + insertScenarioCheck.run(service.id, service.scenario.signature, time, state.status); + updateIncident(service, service.scenario.signature, 'scenario', result, state, time, notify); + db.exec('COMMIT'); + revision++; + } catch (error) { db.exec('ROLLBACK'); throw error; } + }, + hasOpenIncident(service, scope = 'availability') { return Boolean(openIncident.get(service.id, scope === 'scenario' ? service.scenario?.signature : service.signature, scope)); }, + inMaintenance, + maintenances: maintenanceRows, + createMaintenance({ titleRu, titleEn, messageRu, messageEn, serviceIds, startsAt, endsAt }, time = Date.now()) { + const result = db.prepare('INSERT INTO maintenance(title_ru, title_en, message_ru, message_en, service_ids, starts_at, ends_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(titleRu, titleEn, messageRu, messageEn, JSON.stringify(serviceIds), startsAt, endsAt, time); + revision++; + return String(result.lastInsertRowid); + }, + endMaintenance(id, kind, time = Date.now()) { + if (!['cancel', 'finish'].includes(kind)) throw new Error('Use cancel or finish'); + const field = kind === 'cancel' ? 'cancelled_at' : 'completed_at'; + const boundary = kind === 'finish' ? 'starts_at <= ?' : 'ends_at > ?'; + const changed = db.prepare(`UPDATE maintenance SET ${field} = ? WHERE id = ? AND cancelled_at IS NULL AND completed_at IS NULL AND ${boundary}`).run(time, id, time).changes > 0; + if (changed) revision++; + return changed; + }, + dueNotifications(time = Date.now()) { return db.prepare("SELECT n.*, i.started_at, i.resolved_at FROM notification_outbox n JOIN incidents i ON i.id = n.incident_id WHERE n.status = 'pending' AND n.next_attempt_at <= ? AND NOT EXISTS (SELECT 1 FROM notification_outbox earlier WHERE earlier.incident_id = n.incident_id AND earlier.id < n.id AND earlier.status = 'pending') ORDER BY n.id LIMIT 20").all(time); }, + notificationResult(id, { status, retryAt = null, reason = null }, time = Date.now()) { + db.prepare("UPDATE notification_outbox SET status = ?, attempts = attempts + 1, next_attempt_at = COALESCE(?, next_attempt_at), delivered_at = CASE WHEN ? = 'sent' THEN ? ELSE NULL END, last_error = ? WHERE id = ?").run(status, retryAt, status, time, reason, id); + }, + notificationStats() { return db.prepare('SELECT status, COUNT(*) AS count FROM notification_outbox GROUP BY status').all(); }, + checks(service, since) { return db.prepare('SELECT checked_at AS time, ok, status, latency_ms AS latencyMs, http_code AS httpCode, reason FROM checks WHERE service_id = ? AND signature = ? AND checked_at >= ? ORDER BY checked_at, id').all(service.id, service.signature, since); }, + reliability(service, since, now, config) { + const availability = readSpans(availabilitySpans, service.id, service.signature, since, now, config.staleAfterSeconds * 1000); + const scenario = service.scenario ? readSpans(scenarioSpans, service.id, service.scenario.signature, since, now, config.scenarioIntervalSeconds * 3000) : null; + const incidents = db.prepare("SELECT scope, started_at AS startedAt, resolved_at AS resolvedAt FROM incidents WHERE source = 'monitor' AND service_id = ? AND ((scope = 'availability' AND signature = ?) OR (scope = 'scenario' AND signature = ?)) AND started_at < ? AND (resolved_at IS NULL OR resolved_at > ?)").all(service.id, service.signature, service.scenario?.signature || '', now, since); + const lastRecovery = db.prepare("SELECT resolved_at AS time, scope FROM incidents WHERE source = 'monitor' AND service_id = ? AND ((scope = 'availability' AND signature = ?) OR (scope = 'scenario' AND signature = ?)) AND resolved_at <= ? ORDER BY resolved_at DESC, id DESC LIMIT 1").get(service.id, service.signature, service.scenario?.signature || '', now); + const maintenance = db.prepare('SELECT * FROM maintenance WHERE starts_at < ? AND ends_at > ?').all(now, since).flatMap(row => { + const ids = JSON.parse(row.service_ids); + return !ids.length || ids.includes(service.id) ? [[row.starts_at, maintenanceEnd({ endsAt: row.ends_at, cancelledAt: row.cancelled_at, completedAt: row.completed_at })]] : []; + }); + return reliabilitySummary({ availability, scenario, incidents, maintenance, since, now, lastRecovery }); + }, + latencyDetails(service, since, now, intervalSeconds = 60) { + const summary = db.prepare('SELECT COUNT(*) AS samples, AVG(latency_ms) AS average, MIN(checked_at) AS firstAt, MAX(checked_at) AS lastAt FROM checks WHERE service_id = ? AND signature = ? AND checked_at >= ? AND checked_at <= ? AND ok = 1 AND latency_ms IS NOT NULL'); + const current = summary.get(service.id, service.signature, since, now); + const baseline = summary.get(service.id, service.signature, since - (now - since), since - 1); + const p95 = current.samples ? db.prepare('SELECT latency_ms FROM checks WHERE service_id = ? AND signature = ? AND checked_at >= ? AND checked_at <= ? AND ok = 1 AND latency_ms IS NOT NULL ORDER BY latency_ms LIMIT 1 OFFSET ?').get(service.id, service.signature, since, now, Math.ceil(current.samples * .95) - 1).latency_ms : null; + // Compare populated windows, rather than a handful of samples against a month. + const minimumSamples = Math.max(3, Math.floor((now - since) / (intervalSeconds * 1000) * .5)); + const comparable = [current, baseline].every(item => item.samples >= minimumSamples && item.lastAt - item.firstAt >= (now - since) * .5); + const percent = comparable && baseline.average > 0 ? Number(((current.average / baseline.average - 1) * 100).toFixed(1)) : null; + return { averageMs: current.average === null ? null : Math.round(current.average), p95Ms: p95, samples: current.samples, previousAverageMs: comparable ? Math.round(baseline.average) : null, changePercent: percent }; + }, + statistics(service, since, now) { + const params = [service.id, service.signature, since]; + const summary = db.prepare("SELECT COUNT(*) AS checks, SUM(ok) AS successes, AVG(CASE WHEN ok = 1 THEN latency_ms END) AS latencyMs FROM checks WHERE service_id = ? AND signature = ? AND checked_at >= ? AND reason IS NOT 'unconfigured'").get(...params); + const days = db.prepare("SELECT (checked_at / 86400000) * 86400000 AS start, COUNT(*) AS checks, SUM(ok) AS successes, AVG(CASE WHEN ok = 1 THEN latency_ms END) AS latencyMs, MAX(CASE status WHEN 'down' THEN 2 WHEN 'degraded' THEN 1 ELSE 0 END) AS severity FROM checks WHERE service_id = ? AND signature = ? AND checked_at >= ? AND reason IS NOT 'unconfigured' GROUP BY start").all(...params); + const responseSince = now - 24 * 3600000; + const hours = db.prepare("SELECT CAST((checked_at - ?) / 3600000 AS INTEGER) AS bucket, COUNT(*) AS checks, SUM(ok) AS successes, AVG(CASE WHEN ok = 1 THEN latency_ms END) AS latencyMs FROM checks WHERE service_id = ? AND signature = ? AND checked_at >= ? AND checked_at < ? AND reason IS NOT 'unconfigured' GROUP BY bucket").all(responseSince, service.id, service.signature, responseSince, now); + const recent = db.prepare('SELECT checked_at AS time, ok, status, latency_ms AS latencyMs, http_code AS httpCode, reason FROM checks WHERE service_id = ? AND signature = ? ORDER BY checked_at DESC, id DESC LIMIT 12').all(service.id, service.signature); + return { summary, days, hours, recent }; + }, + incidents() { + return db.prepare('SELECT * FROM incidents ORDER BY started_at DESC, id DESC LIMIT 100').all().map(row => ({ + id: String(row.id), source: row.source, serviceId: row.service_id, + signature: row.signature, scope: row.scope, startedAt: row.started_at, resolvedAt: row.resolved_at, + title: { ru: row.title_ru, en: row.title_en }, message: { ru: row.message_ru, en: row.message_en }, + updates: db.prepare('SELECT * FROM incident_updates WHERE incident_id = ? ORDER BY created_at, id').all(row.id).map(update => ({ id: String(update.id), source: update.source, stage: update.stage, createdAt: update.created_at, message: { ru: update.message_ru, en: update.message_en } })), + })); + }, + createIncident({ serviceId = null, titleRu, titleEn, messageRu, messageEn }, time = Date.now()) { + return transaction(() => { + const result = createIncident.run('manual', serviceId, null, time, titleRu, titleEn, messageRu, messageEn, 'availability'); + insertUpdate.run(result.lastInsertRowid, 'manual', 'reported', time, messageRu, messageEn); + return String(result.lastInsertRowid); + }); + }, + updateIncident(id, input, time = Date.now()) { + const value = validateUpdate(input); + return transaction(() => { + const incident = db.prepare('SELECT * FROM incidents WHERE id = ?').get(id); + if (!incident) throw new Error('Incident not found'); + if (incident.resolved_at !== null && value.stage !== 'postmortem') throw new Error('Closed incidents accept postmortem updates only'); + if (incident.resolved_at === null && value.stage === 'postmortem') throw new Error('Resolve the incident before adding a postmortem'); + const latest = db.prepare('SELECT MAX(created_at) AS time FROM incident_updates WHERE incident_id = ?').get(id); + if (!Number.isSafeInteger(time) || time < Math.max(incident.started_at, latest.time || 0)) throw new Error('Update must follow the incident timeline'); + const result = insertUpdate.run(id, 'manual', value.stage, time, value.messageRu, value.messageEn); + return String(result.lastInsertRowid); + }); + }, + resolveIncident(id, time = Date.now(), input = null) { + return transaction(() => { + const row = db.prepare("SELECT * FROM incidents WHERE id = ? AND source = 'manual' AND resolved_at IS NULL").get(id); + if (!row) return false; + const latest = db.prepare('SELECT MAX(created_at) AS time FROM incident_updates WHERE incident_id = ?').get(id); + if (time < Math.max(row.started_at, latest.time || 0)) throw new Error('Resolution must follow the incident timeline'); + const text = input ? validateUpdate({ ...input, stage: 'monitoring' }) : { messageRu: 'Команда отметила инцидент как решённый.', messageEn: 'The team marked the incident as resolved.' }; + db.prepare('UPDATE incidents SET resolved_at = ? WHERE id = ?').run(time, id); + insertUpdate.run(id, 'manual', 'resolved', time, text.messageRu, text.messageEn); + return true; + }); + }, + prune(since) { db.prepare('DELETE FROM checks WHERE checked_at < ?').run(since); db.prepare('DELETE FROM scenario_checks WHERE checked_at < ?').run(since); revision++; }, + close() { db.close(); }, + }; +} diff --git a/shared/impact.d.mts b/shared/impact.d.mts new file mode 100644 index 0000000..c3733b0 --- /dev/null +++ b/shared/impact.d.mts @@ -0,0 +1,3 @@ +import type { Impact, Incident, Localized, Service } from '../src/types'; +export function serviceImpact(service: Service, services?: Service[]): Impact | null; +export function incidentImpact(incident: Incident, services: Service[]): Localized | null; diff --git a/shared/impact.mjs b/shared/impact.mjs new file mode 100644 index 0000000..011c574 --- /dev/null +++ b/shared/impact.mjs @@ -0,0 +1,19 @@ +export function serviceImpact(service, services = []) { + if (!['down', 'degraded'].includes(service.status)) return null; + const scope = service.availabilityStatus === 'operational' && ['down', 'degraded'].includes(service.scenario?.status) ? 'scenario' : 'availability'; + const message = service.impactPolicy?.[scope] || { + ru: 'Возможны ограничения в работе сервиса. Подробности уточняются.', + en: 'The service may have limitations. Details are being confirmed.', + }; + const advice = service.impactPolicy?.advice || null; + const alternatives = (service.impactPolicy?.fallbacks || []).flatMap(id => { + const target = services.find(item => item.id === id); + return target?.status === 'operational' ? [{ id: target.id, name: target.name, url: target.publicUrl }] : []; + }); + return { scope, message, advice, alternatives }; +} + +export function incidentImpact(incident, services) { + const service = services.find(item => item.id === incident.serviceId); + return service?.impactPolicy?.[incident.scope || 'availability'] || null; +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..5d0fc9c --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,168 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { Locale, Snapshot } from './types'; +import { formatPercent, formatTime, overallKeys, translations } from './lib/i18n'; +import { readPreference, savePreference } from './lib/preferences'; +import { ArrowRight, ArrowUpRight, Check, Clock, Devices, Globe, History, Info, Moon, Refresh, Statistics, Sun, Warning, Wrench, serviceIcons } from './components/Icons'; +import { HistoryBars } from './components/Charts'; +import { Incidents } from './components/Incidents'; +import { ServiceDetails } from './components/ServiceDetails'; +import { StatusBadge, serviceNote } from './components/StatusBadge'; +import { MaintenanceList } from './components/Maintenance'; +import { liveService, maintenancePhase, overall } from './lib/status'; +import { ImpactNotice } from './components/ImpactNotice'; +import { Subscriptions } from './components/Subscriptions'; +import { StatisticsPanel } from './components/Statistics'; + +type View = 'status' | 'incidents' | 'maintenance' | 'about' | 'subscriptions'; +function getView(): View { const value = new URLSearchParams(location.search).get('view'); return value === 'incidents' || value === 'about' || value === 'maintenance' || value === 'subscriptions' ? value : 'status'; } +function getRange() { const value = Number(new URLSearchParams(location.search).get('range')); return [7, 30, 90].includes(value) ? value : 30; } + +export default function App() { + const [locale, setLocale] = useState(() => (new URLSearchParams(location.search).get('lang') || readPreference('locale')) === 'en' ? 'en' : 'ru'); + const [theme, setTheme] = useState(() => readPreference('theme') || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')); + const [view, setView] = useState(getView); + const [range, setRange] = useState(getRange); + const [demo, setDemo] = useState(() => new URLSearchParams(location.search).get('demo') === '1'); + const [data, setData] = useState(null); + const [busy, setBusy] = useState(true); + const [error, setError] = useState(false); + const [online, setOnline] = useState(navigator.onLine); + const [tick, setTick] = useState(Date.now()); + const [refreshCount, setRefreshCount] = useState(0); + const [selected, setSelected] = useState(null); + const appliedHash = useRef(''); + const t = translations[locale]; + + const updateUrl = (key: string, value: string | null, push = false) => { + const url = new URL(location.href); + if (value) url.searchParams.set(key, value); else url.searchParams.delete(key); + if (push) history.pushState(null, '', url); else history.replaceState(null, '', url); + }; + const changeView = (next: View) => { setView(next); updateUrl('view', next === 'status' ? null : next, true); setSelected(null); }; + const changeMode = (next: boolean) => { setDemo(next); updateUrl('demo', next ? '1' : null); setData(null); setSelected(null); }; + const refresh = useCallback(() => setRefreshCount(count => count + 1), []); + + useEffect(() => { + document.documentElement.lang = locale; document.title = t.pageTitle; savePreference('locale', locale); + document.querySelector('meta[name="description"]')?.setAttribute('content', t.heroDescription); + const feed = document.querySelector('link[type="application/rss+xml"]'); + if (feed) { feed.href = `/feed.xml?lang=${locale}`; feed.title = `${t.pageTitle} · RSS`; } + }, [locale, t]); + useEffect(() => { document.documentElement.dataset.theme = theme; savePreference('theme', theme); }, [theme]); + useEffect(() => { + const updateOnline = () => { setOnline(navigator.onLine); if (navigator.onLine) refresh(); }; + const pop = () => { setView(getView()); setRange(getRange()); setDemo(new URLSearchParams(location.search).get('demo') === '1'); setSelected(null); }; + window.addEventListener('online', updateOnline); window.addEventListener('offline', updateOnline); window.addEventListener('popstate', pop); + const clock = window.setInterval(() => setTick(Date.now()), 10000); + const interval = window.setInterval(() => { if (navigator.onLine && !document.hidden) refresh(); }, 30000); + const visible = () => { if (!document.hidden && navigator.onLine) refresh(); }; + document.addEventListener('visibilitychange', visible); + return () => { clearInterval(clock); clearInterval(interval); window.removeEventListener('online', updateOnline); window.removeEventListener('offline', updateOnline); window.removeEventListener('popstate', pop); document.removeEventListener('visibilitychange', visible); }; + }, [refresh]); + useEffect(() => { + const controller = new AbortController(); + let active = true; + const timeout = window.setTimeout(() => controller.abort(), 10000); + setBusy(true); + fetch(`/api/status?range=${range}${demo ? '&demo=1' : ''}`, { signal: controller.signal, cache: 'no-store' }) + .then(async response => { if (!response.ok) throw new Error('fetch'); const payload = await response.json() as Snapshot; if (!Array.isArray(payload.services) || payload.demo !== demo) throw new Error('payload'); return payload; }) + .then(payload => { if (active) { setData(payload); setError(false); setTick(Date.now()); } }) + .catch(() => { if (active) setError(true); }) + .finally(() => { clearTimeout(timeout); if (active) setBusy(false); }); + return () => { active = false; clearTimeout(timeout); controller.abort(); }; + }, [range, demo, refreshCount]); + useEffect(() => { + const hash = location.hash; + if (!hash || hash === appliedHash.current) return; + let id; + try { id = decodeURIComponent(hash.slice(1)); } catch { return; } + const element = document.getElementById(id); + if (element) { appliedHash.current = hash; element.scrollIntoView({ block: 'start' }); } + }, [data, view]); + + const snapshot = useMemo(() => { + if (!data || data.demo !== demo) return null; + const services = data.demo ? data.services : data.services.map(service => liveService(service, data.maintenance, data.staleAfterSeconds, tick)); + return { ...data, services, overall: overall(services) }; + }, [data, demo, tick]); + const services = snapshot?.services || []; + const displayedRange = snapshot?.range ?? range; + const status = snapshot?.overall || 'unknown'; + const [heroTitle, heroSub] = overallKeys[status]; + const HeroIcon = status === 'operational' ? Check : status === 'unknown' ? Clock : status === 'maintenance' ? Wrench : Warning; + const available = services.filter(service => service.status === 'operational').length; + const measured = services.filter(service => service.latencyMs !== null && service.status !== 'unknown'); + const average = measured.length ? Math.round(measured.reduce((sum, service) => sum + service.latencyMs!, 0) / measured.length) : null; + const checks = services.reduce((sum, service) => sum + service.checks, 0); + const uptime = checks ? services.reduce((sum, service) => sum + service.checks * (service.uptime || 0), 0) / checks : null; + const activeIncidents = snapshot?.incidents.filter(incident => !incident.resolvedAt).length || 0; + const selectedService = services.find(service => service.id === selected); + const activeMaintenance = snapshot?.maintenance.filter(item => maintenancePhase(item, tick) === 'active') || []; + const hasUpcomingMaintenance = snapshot?.maintenance.some(item => ['scheduled', 'active'].includes(maintenancePhase(item, tick))); + + return
+ {t.skip} +
+ + +
+
+ +
+ {demo &&

{t.demoLabel}. {t.demoText}

} +
+
{t.eyebrow}

{t.heroTitle}
{t.heroAccent}

{t.heroDescription}

+ +
+ + {(!online || error) &&

{!online ? t.offline : t.fetchError}

} + {activeMaintenance.length > 0 && view !== 'maintenance' &&

{t.maintenanceBanner}. {activeMaintenance.map(item => item.title[locale]).join(' · ')}

} +
{t.updated}: {formatTime(snapshot?.lastCheckAt || null, locale)}
+ + {view === 'status' && <> +
+
+

{t.services}

{t.servicesDescription} {displayedRange} {t.days}

{[7, 30, 90].map(days => )}
+ {!snapshot ?

{busy ? t.loading : t.noChecks}

:
{services.map(service => { + const Icon = serviceIcons[service.icon] || Globe; + return
+

{service.description[locale]}

+
{serviceNote(service, locale) || (service.latencyMs !== null ? `${service.latencyMs} ${t.ms}` : t.noMeasurements)}
+ +
; + })}
{displayedRange} {t.days}{t.today}
} +
{(['operational', 'degraded', 'down', 'unknown'] as const).map((state, index) => {[t.legendGood, t.legendDegraded, t.legendDown, t.legendUnknown][index]})}{t.partial}
+
+ +
+ {snapshot && } + {hasUpcomingMaintenance &&

{t.maintenanceTab}

} +

{t.recentIncidents}

+

{t.tripTitle}

{t.tripSub}

{t.openPwa}
+ } + + {view === 'incidents' &&
{t.incidentsTab}

{t.incidents}

{t.incidentsDescription}

} + {view === 'maintenance' &&
{t.maintenanceTab}

{t.maintenanceTitle}

{t.maintenanceDescription}

{snapshot ? :

{busy ? t.loading : t.noChecks}

}
} + {view === 'subscriptions' &&
RSS

{t.rssTitle}

{t.rssDescription}

{snapshot ? :

{busy ? t.loading : t.noChecks}

}
} + + {view === 'about' &&
{t.aboutTab}

{t.methodTitle}

{t.methodIntro}

{[ + [Clock, t.methodFrequency, t.methodFrequencyText, `${snapshot?.intervalSeconds || 60} ${t.seconds}`], + [Warning, t.methodConfirm, t.methodConfirmText, `× ${snapshot?.failureThreshold || 3}`], + [Check, t.methodRecovery, t.methodRecoveryText, `× ${snapshot?.recoveryThreshold || 2}`], + [History, t.methodHonest, t.methodHonestText, '7 / 30 / 90'], + ].map(([Icon, title, text, value], index) => { const MethodIcon = Icon as typeof Clock; return
{value as string}

{title as string}

{text as string}

; })}

{t.scopeTitle}

{t.scenarioScopeText}

{t.scenarioAccessText}

{t.scenarioFrequency} {snapshot?.scenarioIntervalSeconds || 300} {t.seconds}. {t.scenarioNight}

{t.timezone}

{t.maintenanceMethod}

{t.maintenanceMethodText}

} + {view === 'about' &&
{t.publicApi}
} +
+ +
ridebus{t.footer}
{t.website}{(import.meta.env.DEV || demo) && }
+ {selectedService && setSelected(null)} />} +
; +} diff --git a/src/_motion.scss b/src/_motion.scss new file mode 100644 index 0000000..17a810c --- /dev/null +++ b/src/_motion.scss @@ -0,0 +1,57 @@ +// RideBus PWA motion: short entrances, small press feedback, and calm hover. +@mixin components { + :root { --motion-fast: 160ms; --motion-enter: 280ms; --motion-ease: cubic-bezier(.2, .75, .25, 1); } + .heroCopy, .servicesCard, .metricCard, .explainCard, .tripBanner, + .pageHeading, .methodCard, .scopeCard, .emptyState, .maintenanceCard, .incidentCard, .subscriptionCard, .impactNotice, .statisticsCard { + animation: surfaceEnter var(--motion-enter) var(--motion-ease) backwards; + } + .servicesCard { animation-delay: 35ms; } + .dashboardSide .metricCard:nth-child(1) { animation-delay: 75ms; } + .dashboardSide .metricCard:nth-child(2) { animation-delay: 105ms; } + .heroNote { --note-rotation: 3deg; animation: noteEnter 360ms var(--motion-ease) 60ms backwards; } + .serviceRow, .scenarioSteps li, .maintenanceWindow, .incidentFilters, .detailSection, .incidentTimeline li { + animation: rowEnter 220ms var(--motion-ease) backwards; + } + @for $index from 2 through 6 { + .serviceRow:nth-child(#{$index}), .scenarioSteps li:nth-child(#{$index}) { animation-delay: ($index - 1) * 25ms; } + } + .notice { animation: contentEnter 200ms ease-out; } + .statusBadge, .navCount { animation: badgePop 250ms var(--motion-ease); } + .serviceDialog[open] { animation: dialogEnter 240ms var(--motion-ease) backwards; } + .serviceDialog::backdrop { animation: contentEnter 160ms ease-out; } + .primaryButton, .iconButton, .textButton, .refreshButton, .languageButton, + .desktopNav button, .rangePicker button, .incidentFilters button, .servicePicker button, .notice button, + .serviceTitle button, .serviceMetrics button, .footer a, .footer button { + transition: background-color var(--motion-fast) ease, color var(--motion-fast) ease, + border-color var(--motion-fast) ease, box-shadow var(--motion-fast) ease, + transform var(--motion-fast) var(--motion-ease); + } + .primaryButton:active, .iconButton:not(:disabled):active, .textButton:active, + .refreshButton:not(:disabled):active, .languageButton:active, .desktopNav button:active, + .rangePicker button:active, .incidentFilters button:active, .servicePicker button:active, .notice button:not(:disabled):active, + .serviceTitle button:active, .serviceMetrics button:active { transform: scale(.97); } + .primaryButton > .vkuiIcon, .textButton > .vkuiIcon, .footer a > .vkuiIcon, .footer button > .vkuiIcon, + .serviceMetrics button > .vkuiIcon, .serviceTitle button > .vkuiIcon { + transition: transform var(--motion-fast) var(--motion-ease), opacity var(--motion-fast) ease; + } + @media (hover: hover) and (pointer: fine) { + .primaryButton:hover { transform: translateY(-2px); } + .primaryButton:active { transform: scale(.97); } + .primaryButton:hover > .vkuiIcon, .textButton:hover > .vkuiIcon, + .footer a:hover > .vkuiIcon, .footer button:hover > .vkuiIcon, .serviceMetrics button:hover > .vkuiIcon { transform: translateX(3px); } + } + @keyframes surfaceEnter { from { opacity: 0; transform: translateY(7px); } to { opacity: 1; transform: translateY(0); } } + @keyframes noteEnter { from { opacity: 0; transform: translateY(7px) rotate(1deg); } to { opacity: 1; transform: translateY(0) rotate(var(--note-rotation)); } } + @keyframes rowEnter { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } + @keyframes contentEnter { from { opacity: 0; } to { opacity: 1; } } + @keyframes badgePop { 0% { opacity: 0; transform: scale(.8); } 65% { opacity: 1; transform: scale(1.06); } 100% { opacity: 1; transform: scale(1); } } + @keyframes dialogEnter { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } } + @media (max-width: 640px) { .heroNote { --note-rotation: 0deg; } } + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } + .primaryButton, .iconButton, .textButton, .refreshButton, .languageButton, + .desktopNav button, .rangePicker button, .incidentFilters button, .servicePicker button, .notice button, + .serviceTitle button, .serviceMetrics button, .footer a > .vkuiIcon, .footer button > .vkuiIcon, + .primaryButton > .vkuiIcon, .textButton > .vkuiIcon { transform: none !important; } + } +} diff --git a/src/_tokens.scss b/src/_tokens.scss new file mode 100644 index 0000000..23921c5 --- /dev/null +++ b/src/_tokens.scss @@ -0,0 +1,81 @@ +// RideBus landing palette, card shapes and typography. +:root { + --page: #faf9f6; + --ink: #242424; + --muted: #6f737c; + --line: #e9e9e3; + --blue: #2c4678; + --blue-muted: #455e91; + --blue-soft: #d8e2ff; + --lavender: #f3f3fa; + --green: #ddf9d0; + --green-ink: #254817; + --surface: #fff; + --nav-surface: #eeeee9; + --nav-glass: #fffffff0; + --subtle-line: #dce0ee; + --control-border: #c3d3f5; + --focus: #638ce1; + --focus-soft: #aec6ff; + --on-accent: #fff; + --hover-surface: #ffffff90; + --green-overlay: #ffffff70; + --green-overlay-strong: #ffffff90; + --green-overlay-subtle: #ffffff65; + --green-line: #bdd4b6; + --warning-bg: #fff7e9; + --warning-ink: #735421; + --warning-line: #eadcc6; + --warning-badge-bg: #fff1d8; + --warning-badge-ink: #876022; + --error: #a34239; + --scrollbar: #c7d1e8; + --scrollbar-hover: #aebedc; + --past-bg: #f4f4f1; + --past-ink: #69758c; + --muted-icon: #8390a9; + --benefit-bg: #eeede6; + --note-dot: #6e955f; + --card-radius: 32px; + color-scheme: light; +} + +:root[data-theme='dark'] { + --page: #141820; + --ink: #f0f1f5; + --muted: #abb3c2; + --line: #343d4c; + --blue: #b5cbfc; + --blue-muted: #b8c9ed; + --blue-soft: #2d3b5b; + --lavender: #202735; + --green: #243c2b; + --green-ink: #c6edb8; + --surface: #1c2330; + --nav-surface: #252d3b; + --nav-glass: #1c2330f0; + --subtle-line: #3b465c; + --control-border: #4b5e83; + --focus: #adc7ff; + --focus-soft: #3c5079; + --on-accent: #18243e; + --hover-surface: #ffffff0c; + --green-overlay: #ffffff0b; + --green-overlay-strong: #ffffff14; + --green-overlay-subtle: #ffffff0a; + --green-line: #49644d; + --warning-bg: #3a3020; + --warning-ink: #f1d49e; + --warning-line: #5b4930; + --warning-badge-bg: #3e311e; + --warning-badge-ink: #f1d49e; + --error: #ffaaa2; + --scrollbar: #56637a; + --scrollbar-hover: #728199; + --past-bg: #202630; + --past-ink: #a2aabd; + --muted-icon: #98a8c3; + --benefit-bg: #242d2a; + --note-dot: #a7c792; + color-scheme: dark; +} diff --git a/src/components/Charts.tsx b/src/components/Charts.tsx new file mode 100644 index 0000000..1732f51 --- /dev/null +++ b/src/components/Charts.tsx @@ -0,0 +1,63 @@ +import { useId, useState } from 'react'; +import type { Day, Locale, ResponsePoint } from '../types'; +import { formatPercent, statusKey, translations } from '../lib/i18n'; + +export function HistoryBars({ days, locale }: { days: Day[]; locale: Locale }) { + const t = translations[locale]; + const [selected, setSelected] = useState(null); + const [focusIndex, setFocusIndex] = useState(days.length - 1); + const describe = (day: Day) => `${new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'long', timeZone: 'UTC' }).format(new Date(day.date))}: ${t[statusKey[day.status]]}, ${t.availability.toLowerCase()} ${formatPercent(day.uptime, locale)}, ${t.observed.toLowerCase()} ${formatPercent(day.coverage, locale)}`; + return
+
30 ? 'dense' : ''}`} role="group" aria-label={t.history}> + {days.map((day, index) => )} +
+ {selected !== null && days[selected] &&
{describe(days[selected])}
} +
; +} + +export function ResponseChart({ points, locale, bucketMs = 3600000, interactive = false }: { points: ResponsePoint[]; locale: Locale; bucketMs?: number; interactive?: boolean }) { + const id = useId(); + const [selected, setSelected] = useState(points.length - 1); + const t = translations[locale]; + const known = points.filter(point => point.latencyMs !== null); + if (!known.length) return
{t.noMeasurements}
; + const max = Math.max(100, ...known.map(point => point.latencyMs!)) * 1.15; + const width = 480, height = 130; + const groups: [number, number][][] = []; + let group: [number, number][] = []; + points.forEach((point, index) => { + if (point.latencyMs === null) { if (group.length) groups.push(group); group = []; } + else group.push([index / Math.max(1, points.length - 1) * width, height - point.latencyMs / max * (height - 15)]); + }); + if (group.length) groups.push(group); + const selectedIndex = Math.max(0, Math.min(selected, points.length - 1)); + const point = points[selectedIndex]; + const date = new Intl.DateTimeFormat(locale, bucketMs >= 86400000 ? { day: 'numeric', month: 'short', timeZone: 'UTC' } : { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); + const selectedX = selectedIndex / Math.max(1, points.length - 1) * width; + return
+
{Math.round(max)} {t.ms}
+ + + {[20, 65, 130].map(y => )} + {groups.map((points, index) => { + const line = points.map(([x, y], i) => `${i ? 'L' : 'M'}${x},${y}`).join(' '); + return {points.length === 1 && }; + })} + {interactive && point.latencyMs !== null && } + +
{date.format(points[0].time)}{date.format(points.at(-1)!.time + (bucketMs < 86400000 ? bucketMs : 0))}
+ {interactive && <> setSelected(Number(event.target.value))} aria-label={t.chartSelect} aria-valuetext={`${date.format(point.time)}: ${point.latencyMs === null ? t.noMeasurements : `${point.latencyMs} ${t.ms}`}`} />
{date.format(point.time)}{point.latencyMs === null ? t.noMeasurements : `${t.chartAverage}: ${point.latencyMs} ${t.ms} · ${point.checks.toLocaleString(locale)} ${t.measurements}`}
} +
; +} diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx new file mode 100644 index 0000000..fdeeeee --- /dev/null +++ b/src/components/Icons.tsx @@ -0,0 +1,35 @@ +import { + Icon24ArrowUpRightOutline, Icon24ArrowRightOutline, Icon24Cancel, + Icon24CheckCircleOutline, Icon24ClockOutline, Icon24ComputerOutline, + Icon24ComputerSmartphoneOutline, Icon24ComputerStackOutline, + Icon24GlobeOutline, Icon24HistoryBackwardOutline, Icon24InfoCircleOutline, + Icon24RefreshOutline, Icon24StatisticsOutline, Icon24WarningTriangleOutline, + Icon20MoonOutline, Icon24SunOutline, Icon24WrenchOutline, Icon28RssFeedOutline, Icon24CopyOutline, +} from '@vkontakte/icons'; +import type { ComponentType } from 'react'; + +type Props = { size?: number; className?: string }; +type VkIcon = ComponentType<{ width?: number; height?: number; className?: string }>; +function adapt(Icon: VkIcon) { + return function AppIcon({ size = 24, className }: Props) { return