initial commit
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
playwright-report
|
||||
test-results
|
||||
artifacts
|
||||
test
|
||||
README.md
|
||||
*.tsbuildinfo
|
||||
50
.env.example
Normal file
@@ -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
|
||||
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
data/
|
||||
*.tsbuildinfo
|
||||
playwright-report/
|
||||
test-results/
|
||||
.DS_Store
|
||||
26
Dockerfile
Normal file
@@ -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"]
|
||||
208
README.md
Normal file
@@ -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`: появления, отклик на нажатие, стрелки ссылок и диалоги. При уменьшенном движении анимации и смещения отключены.
|
||||
BIN
artifacts/desktop-live.jpg
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
artifacts/expanded-statistics-desktop.jpg
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
artifacts/expanded-statistics-mobile.jpg
Normal file
|
After Width: | Height: | Size: 141 KiB |
27
artifacts/expanded-statistics-verification.md
Normal file
@@ -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 проверена. Внешняя публикация не выполнялась.
|
||||
BIN
artifacts/methodology.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
artifacts/mobile-dark-demo.jpg
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
artifacts/next-stage-maintenance.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
artifacts/next-stage-mobile.jpg
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
artifacts/next-stage-scenario-live.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
31
artifacts/next-stage-verification.md
Normal file
@@ -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`.
|
||||
BIN
artifacts/paired-board-check.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
25
artifacts/paired-board-fix-verification.md
Normal file
@@ -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 на сервер не отправлялись. Это локальное исправление; публикация на внешний хостинг не выполнялась.
|
||||
BIN
artifacts/public-updates-rss-mobile.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
artifacts/public-updates-rss.jpg
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
artifacts/public-updates-timeline.jpg
Normal file
|
After Width: | Height: | Size: 170 KiB |
29
artifacts/public-updates-verification.md
Normal file
@@ -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.
|
||||
BIN
artifacts/pwa-footer-production.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
15
artifacts/verification.md
Normal file
@@ -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`.
|
||||
21
compose.yaml
Normal file
@@ -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:
|
||||
140
config/services.json
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
202
deploy/PLAYWRIGHT_LICENSE
Normal file
@@ -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.
|
||||
831
deploy/chromium-seccomp.json
Normal file
@@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#faf9f6" />
|
||||
<meta name="description" content="Статус сервисов RideBus: API, сайт, веб-приложение, электронное табло и панель управления. История доступности и инцидентов." />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<link rel="alternate" type="application/rss+xml" title="RideBus — обновления сервисов" href="/feed.xml?lang=ru" />
|
||||
<title>RideBus — статус сервисов</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1793
package-lock.json
generated
Normal file
36
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
37
public/logo.svg
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 24.1.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Слой_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 59 58" style="enable-background:new 0 0 59 58;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{clip-path:url(#SVGID_2_);}
|
||||
.st1{fill:#455E91;}
|
||||
.st2{display:none;fill:none;stroke:#FFFFFF;stroke-width:1.9625;}
|
||||
.st3{fill:#FFFFFF;}
|
||||
</style>
|
||||
<g>
|
||||
<defs>
|
||||
<rect id="SVGID_1_" x="0.5" width="58" height="58"/>
|
||||
</defs>
|
||||
<clipPath id="SVGID_2_">
|
||||
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
|
||||
</clipPath>
|
||||
<g class="st0">
|
||||
<path class="st1" d="M21.9,1.3l19.7,1.6C51,3.6,58,11.8,57.2,21.2l-1.6,19.7c-0.7,9.4-8.9,16.3-18.3,15.6l-19.7-1.6
|
||||
C8.4,54.2,1.4,46,2.1,36.6l1.6-19.7C4.4,7.6,12.6,0.6,21.9,1.3z"/>
|
||||
<path class="st2" d="M23.6,38.6C0.3,23.3,37,15.1,40.3,16 M23.6,38.6C46.1,53.4,41.7,19.3,40.3,16 M23.6,38.6
|
||||
c10.2,3,15.1-17,16.7-22.6"/>
|
||||
</g>
|
||||
</g>
|
||||
<path id="path" class="st3" d="M20.3,44.4c-1.6,0-2.2-0.5-2.3-1.7c0-0.8,0-1.6,0-2.4c0-0.2-0.1-0.3-0.2-0.5c-1-1.2-1.6-2.7-1.5-4.2
|
||||
c0.1-5.1,0-10.1,0-15.2c0-0.8,0.1-1.6,0.3-2.3c0.6-1.9,2-3,3.8-3.5c2.1-0.7,4.3-1,6.5-0.9c2.7,0,5.5,0,8.2,0.2
|
||||
c2.1,0.2,4.2,0.7,5.8,2.3c0.3,0.2,0.5,0.5,0.6,0.8c0.8,1.3,1.2,3,1,4.5c0,2.3,0,4.6-0.1,6.8l0,0c0,2.6,0,5.1,0,7.8
|
||||
c0,1.3-0.5,2.7-1.4,3.7c-0.3,0.3-0.3,0.6-0.3,0.9c0,0.8,0,1.5,0,2.1c0.1,0.6-0.3,1.2-0.8,1.5c-0.2,0.2-0.5,0.3-0.8,0.3
|
||||
c-0.6,0-1.2,0-1.9,0c-0.9,0-1.6-0.7-1.7-1.6c0-0.4,0-0.8,0-1.2c0-0.2-0.2-0.3-0.3-0.4l0,0H23.3c-0.3,0-0.4,0.1-0.3,0.4
|
||||
c0,0.3,0,0.8,0,1.1c0,0.9-0.8,1.7-1.6,1.7 M29.4,20.2L29.4,20.2c-3.1,0-6.2,0-9.2,0c-0.3,0-0.5,0.1-0.5,0.5c0,2.3,0,4.6,0,7
|
||||
c0,0.3,0.1,0.5,0.5,0.5c6.2,0,12.2,0,18.4,0c0.4,0,0.5-0.1,0.5-0.5c0-2.3,0-4.6,0-6.8c0-0.3-0.3-0.6-0.6-0.6l0,0
|
||||
C35.5,20.2,32.5,20.2,29.4,20.2z M37,37.9c1.3-0.2,2.3-1.3,2.1-2.6c-0.2-1.3-1.3-2.3-2.6-2.1c-0.2,0-0.3,0.1-0.5,0.1
|
||||
c-1.3,0.4-1.9,1.8-1.5,3C34.9,37.3,35.9,38,37,37.9L37,37.9z M21.7,37.9c1.3,0.2,2.5-0.6,2.7-1.9c0.2-1.3-0.6-2.5-1.9-2.7
|
||||
c-1.2-0.2-2.5,0.6-2.7,1.9c-0.1,0.2-0.1,0.3-0.1,0.5C19.8,36.8,20.7,37.7,21.7,37.9L21.7,37.9z M26.6,36.2c-0.1,1,0.5,2,1.4,2.4
|
||||
c0.9,0.5,2.1,0.3,3-0.2c0.8-0.5,1.2-1.3,1.1-2.2h-1.2c-0.3,1-0.8,1.5-1.6,1.5c-0.8,0-1.3-0.4-1.6-1.5C27.8,36.2,26.6,36.2,26.6,36.2
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
3
public/star.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="290" height="290" fill="none" viewBox="0 0 290 290">
|
||||
<path fill="currentColor" d="M110 43.6a46 46 0 0 1 70.7 0l16.7 20a46 46 0 0 0 19.4 13.8l22.8 8.4a46 46 0 0 1 22.2 68.9L249.4 173a46 46 0 0 0-7.8 23.3l-1.2 23.4a46 46 0 0 1-57.6 42L157 255a46 46 0 0 0-23.3 0l-25.8 6.8a46 46 0 0 1-57.6-42.1L49 196.4a46 46 0 0 0-7.8-23.3l-12.4-18.4A46 46 0 0 1 51 85.8l22.8-8.4a46 46 0 0 0 19.4-13.7l16.7-20Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 451 B |
69
server/browser-checks.mjs
Normal file
@@ -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; } };
|
||||
}
|
||||
78
server/config.mjs
Normal file
@@ -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',
|
||||
};
|
||||
}
|
||||
73
server/demo.mjs
Normal file
@@ -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' },
|
||||
],
|
||||
};
|
||||
}
|
||||
55
server/feed.mjs
Normal file
@@ -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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '').replace(/[&<>"']/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)}</${name}>`;
|
||||
// Descriptions are HTML in RSS readers: escape user text before XML encoding.
|
||||
const descriptionHtml = value => value.split('\n\n').map(text => `<p>${xmlEscape(text).replaceAll('\n', '<br/>')}</p>`).join('');
|
||||
const items = visible.map(event => `<item>${tag('title', event.title)}${tag('link', event.link)}<guid isPermaLink="false">${xmlEscape(event.id)}</guid>${tag('pubDate', new Date(event.time).toUTCString())}${tag('description', descriptionHtml(event.description))}${tag('category', event.category)}</item>`).join('\n');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel>${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())}<ttl>5</ttl><atom:link href="${xmlEscape(self.toString())}" rel="self" type="application/rss+xml"/>\n${items}\n</channel></rss>`;
|
||||
}
|
||||
17
server/incident-updates.mjs
Normal file
@@ -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() };
|
||||
}
|
||||
40
server/incidents-cli.mjs
Normal file
@@ -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(); }
|
||||
96
server/index.mjs
Normal file
@@ -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); });
|
||||
}
|
||||
23
server/maintenance-cli.mjs
Normal file
@@ -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(); }
|
||||
37
server/maintenance.mjs
Normal file
@@ -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;
|
||||
}
|
||||
60
server/monitor.mjs
Normal file
@@ -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)); },
|
||||
};
|
||||
}
|
||||
63
server/notifications.mjs
Normal file
@@ -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; },
|
||||
};
|
||||
}
|
||||
17
server/notify-cli.mjs
Normal file
@@ -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; }
|
||||
85
server/probe.mjs
Normal file
@@ -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') || !/<html[\s>]/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 };
|
||||
}
|
||||
63
server/reliability.mjs
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
96
server/scenarios.mjs
Normal file
@@ -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() };
|
||||
}
|
||||
83
server/snapshot.mjs
Normal file
@@ -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 };
|
||||
}
|
||||
261
server/store.mjs
Normal file
@@ -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(); },
|
||||
};
|
||||
}
|
||||
3
shared/impact.d.mts
Normal file
@@ -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;
|
||||
19
shared/impact.mjs
Normal file
@@ -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;
|
||||
}
|
||||
168
src/App.tsx
Normal file
@@ -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<Locale>(() => (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<View>(getView);
|
||||
const [range, setRange] = useState(getRange);
|
||||
const [demo, setDemo] = useState(() => new URLSearchParams(location.search).get('demo') === '1');
|
||||
const [data, setData] = useState<Snapshot | null>(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<string | null>(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<HTMLLinkElement>('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 <div className="app">
|
||||
<a className="skipLink" href="#main">{t.skip}</a>
|
||||
<header className="header">
|
||||
<button className="brand" onClick={() => changeView('status')} aria-label="RideBus Status"><img src="/logo.svg" width="40" height="40" alt="" /><span>ridebus</span><span className="webTag">status</span></button>
|
||||
<nav className="desktopNav" aria-label={t.statusTab}>
|
||||
<button onClick={() => changeView('status')} aria-current={view === 'status' ? 'page' : undefined} className={view === 'status' ? 'active' : ''}><Statistics size={18} /><span>{t.statusTab}</span></button>
|
||||
<button onClick={() => changeView('incidents')} aria-current={view === 'incidents' ? 'page' : undefined} className={view === 'incidents' ? 'active' : ''}><History size={18} /><span>{t.incidentsTab}</span>{activeIncidents > 0 && <span className="navCount">{activeIncidents}</span>}</button>
|
||||
<button onClick={() => changeView('maintenance')} aria-current={view === 'maintenance' ? 'page' : undefined} className={view === 'maintenance' ? 'active' : ''}><Wrench size={18} /><span>{t.maintenanceTab}</span></button>
|
||||
</nav>
|
||||
<div className="headerActions"><button className="iconButton" aria-label={t.theme} title={t.theme} onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>{theme === 'dark' ? <Sun size={21} /> : <Moon size={21} />}</button><button className="languageButton" aria-label={t.language} onClick={() => { const next = locale === 'ru' ? 'en' : 'ru'; setLocale(next); updateUrl('lang', next); }}><Globe size={17} />{locale.toUpperCase()}</button></div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
{demo && <div className="notice demoNotice"><Info size={20} /><p><strong>{t.demoLabel}.</strong> {t.demoText}</p><button onClick={() => changeMode(false)}>{t.backLive}<ArrowRight size={17} /></button></div>}
|
||||
<section className="hero">
|
||||
<div className="heroCopy"><span className="eyebrow"><span className="eyebrowDot" />{t.eyebrow}</span><h1>{t.heroTitle}<br /><span>{t.heroAccent}</span></h1><p>{t.heroDescription}</p></div>
|
||||
<aside key={status} className={`heroNote ${status}`} aria-live="polite"><div className="noteTop"><span className="statusDot" /><HeroIcon size={23} /></div><strong>{t[heroTitle]}</strong><p>{t[heroSub]}</p><div className="noteBottom"><Clock size={16} /><span>{t.every} {snapshot?.intervalSeconds || 60} {t.seconds}</span></div><img className="heroStar" src="/star.svg" alt="" /></aside>
|
||||
</section>
|
||||
|
||||
{(!online || error) && <div className="notice errorNotice" role="alert"><Warning size={20} /><p>{!online ? t.offline : t.fetchError}</p><button onClick={refresh} disabled={busy || !online}>{t.retry}</button></div>}
|
||||
{activeMaintenance.length > 0 && view !== 'maintenance' && <div className="notice maintenanceNotice"><Wrench size={20} /><p><strong>{t.maintenanceBanner}.</strong> {activeMaintenance.map(item => item.title[locale]).join(' · ')}</p><button onClick={() => changeView('maintenance')}>{t.maintenanceMore}<ArrowRight size={17} /></button></div>}
|
||||
<div className="updateBar"><div><span className={`updateDot ${!online || error ? 'offline' : ''}`} /><span>{t.updated}: <strong>{formatTime(snapshot?.lastCheckAt || null, locale)}</strong></span></div><button className="refreshButton" onClick={refresh} disabled={busy || !online}><Refresh size={18} className={busy ? 'spinning' : ''} />{busy ? t.refreshing : t.refresh}</button></div>
|
||||
|
||||
{view === 'status' && <>
|
||||
<div className="dashboard">
|
||||
<section className="servicesCard" aria-labelledby="services-title">
|
||||
<div className="servicesHeading"><div><h2 id="services-title">{t.services}</h2><p>{t.servicesDescription} {displayedRange} {t.days}</p></div><div className="rangePicker" role="group" aria-label={t.history}>{[7, 30, 90].map(days => <button key={days} onClick={() => { setRange(days); updateUrl('range', String(days)); }} aria-pressed={range === days} className={range === days ? 'selected' : ''}>{days}<span> {t.days}</span></button>)}</div></div>
|
||||
{!snapshot ? <div className="loadingState" role="status"><Refresh className={busy ? 'spinning' : ''} /><p>{busy ? t.loading : t.noChecks}</p></div> : <div className="serviceList">{services.map(service => {
|
||||
const Icon = serviceIcons[service.icon] || Globe;
|
||||
return <article className="serviceRow" key={service.id}>
|
||||
<div className="serviceHeading"><span className={`serviceIcon ${service.id}`}><Icon /></span><div className="serviceTitle"><h3><button onClick={() => setSelected(service.id)}>{service.name[locale]}<ArrowUpRight size={16} /></button></h3><p>{service.description[locale]}</p></div><StatusBadge status={service.status} locale={locale} /></div>
|
||||
<div className="serviceHistory"><HistoryBars days={service.history} locale={locale} /><div className="serviceMetrics"><span>{serviceNote(service, locale) || (service.latencyMs !== null ? `${service.latencyMs} ${t.ms}` : t.noMeasurements)}</span><button onClick={() => setSelected(service.id)} aria-label={`${t.detailsFor}: ${service.name[locale]}`}>{formatPercent(service.uptime, locale)}<ArrowRight size={15} /></button></div></div>
|
||||
<ImpactNotice service={service} services={services} locale={locale} compact />
|
||||
</article>;
|
||||
})}<div className="historyLabels"><span>{displayedRange} {t.days}</span><span>{t.today}</span></div></div>}
|
||||
<div className="legend">{(['operational', 'degraded', 'down', 'unknown'] as const).map((state, index) => <span key={state}><i className={`legendDot ${state}`} />{[t.legendGood, t.legendDegraded, t.legendDown, t.legendUnknown][index]}</span>)}<span><i className="legendDot partial" />{t.partial}</span></div>
|
||||
</section>
|
||||
<aside className="dashboardSide">
|
||||
<section className="metricCard availabilityCard"><div className="metricLabel"><Statistics size={20} />{t.availability}</div><strong>{formatPercent(uptime, locale)}</strong><p>{t.availabilityNote}</p><div className="metricFoot"><span className="statusDot" />{available} / {services.length || 5} {t.serviceCount}</div></section>
|
||||
<section className="metricCard responseCard"><div className="metricLabel"><Clock size={20} />{t.response}</div><strong>{average === null ? '—' : average}<span>{average !== null && t.ms}</span></strong><p>{t.responseNote}</p><div className="metricFoot">{displayedRange} {t.days} · {checks.toLocaleString(locale)} {t.measurements}</div></section>
|
||||
<section className="explainCard"><span className="explainIcon"><Info size={24} /></span><h3>{t.howCheck}</h3><p>{t.howCheckSub}</p><button className="textButton" onClick={() => changeView('about')}>{t.knowMore}<ArrowRight size={17} /></button></section>
|
||||
</aside>
|
||||
</div>
|
||||
{snapshot && <StatisticsPanel services={services} locale={locale} range={snapshot.range} onDetails={setSelected} />}
|
||||
{hasUpcomingMaintenance && <section className="incidentsSection"><div className="sectionHeading"><h2>{t.maintenanceTab}</h2><button className="textButton" onClick={() => changeView('maintenance')}>{t.maintenanceMore}<ArrowRight size={17} /></button></div><MaintenanceList items={snapshot?.maintenance || []} services={services} locale={locale} now={tick} compact /></section>}
|
||||
<section className="incidentsSection"><div className="sectionHeading"><h2>{t.recentIncidents}</h2><button className="textButton" onClick={() => changeView('incidents')}>{t.allIncidents}<ArrowRight size={17} /></button></div><Incidents incidents={snapshot?.incidents || []} locale={locale} compact /></section>
|
||||
<section className="tripBanner"><span className="tripIcon"><Devices size={28} /></span><div><h3>{t.tripTitle}</h3><p>{t.tripSub}</p></div><a className="primaryButton" href="https://pwa.ridebus.by" target="_blank" rel="noopener noreferrer">{t.openPwa}<ArrowUpRight size={18} /></a></section>
|
||||
</>}
|
||||
|
||||
{view === 'incidents' && <section className="pageSection"><div className="pageHeading"><span className="eyebrow"><History size={18} />{t.incidentsTab}</span><h2>{t.incidents}</h2><p>{t.incidentsDescription}</p><button className="textButton subscribeLink" onClick={() => changeView('subscriptions')}>{t.subscribe}<ArrowRight size={17} /></button></div><Incidents incidents={snapshot?.incidents || []} locale={locale} /></section>}
|
||||
{view === 'maintenance' && <section className="pageSection"><div className="pageHeading"><span className="eyebrow"><Wrench size={18} />{t.maintenanceTab}</span><h2>{t.maintenanceTitle}</h2><p>{t.maintenanceDescription}</p></div>{snapshot ? <MaintenanceList items={snapshot.maintenance} services={services} locale={locale} now={tick} /> : <div className="loadingState" role="status"><p>{busy ? t.loading : t.noChecks}</p></div>}</section>}
|
||||
{view === 'subscriptions' && <section className="pageSection"><div className="pageHeading"><span className="eyebrow"><History size={18} />RSS</span><h2>{t.rssTitle}</h2><p>{t.rssDescription}</p></div>{snapshot ? <Subscriptions services={services} locale={locale} /> : <div className="loadingState" role="status"><p>{busy ? t.loading : t.noChecks}</p></div>}</section>}
|
||||
|
||||
{view === 'about' && <section className="pageSection"><div className="pageHeading"><span className="eyebrow"><Info size={18} />{t.aboutTab}</span><h2>{t.methodTitle}</h2><p>{t.methodIntro}</p></div><div className="methodGrid">{[
|
||||
[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 <article className={`methodCard method${index}`} key={index}><div className="methodTop"><MethodIcon size={27} /><span>{value as string}</span></div><h3>{title as string}</h3><p>{text as string}</p></article>; })}</div><div className="scopeCard"><Info size={25} /><div><h3>{t.scopeTitle}</h3><p>{t.scenarioScopeText}</p><p>{t.scenarioAccessText}</p><p>{t.scenarioFrequency} {snapshot?.scenarioIntervalSeconds || 300} {t.seconds}. {t.scenarioNight}</p><p className="muted">{t.timezone}</p></div></div><div className="scopeCard"><Wrench size={25} /><div><h3>{t.maintenanceMethod}</h3><p>{t.maintenanceMethodText}</p></div></div></section>}
|
||||
{view === 'about' && <div className="monitoringLinks"><button className="textButton" onClick={() => changeView('subscriptions')}>{t.subscribe}<ArrowRight size={18} /></button><a href={`/api/status?range=${range}${demo ? '&demo=1' : ''}`} target="_blank" rel="noopener noreferrer">{t.publicApi}<ArrowUpRight size={18} /></a></div>}
|
||||
</main>
|
||||
|
||||
<footer className="footer"><span>ridebus<small>{t.footer}</small></span><div><button onClick={() => changeView('about')}>{t.aboutTab}<ArrowRight size={18} /></button><a href="https://ridebus.by" target="_blank" rel="noopener noreferrer">{t.website}<ArrowUpRight size={20} /></a>{(import.meta.env.DEV || demo) && <button onClick={() => changeMode(!demo)}>{demo ? t.backLive : t.demoLink}</button>}</div></footer>
|
||||
{selectedService && <ServiceDetails key={selectedService.id} service={selectedService} services={services} locale={locale} onClose={() => setSelected(null)} />}
|
||||
</div>;
|
||||
}
|
||||
57
src/_motion.scss
Normal file
@@ -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; }
|
||||
}
|
||||
}
|
||||
81
src/_tokens.scss
Normal file
@@ -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;
|
||||
}
|
||||
63
src/components/Charts.tsx
Normal file
@@ -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<number | null>(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 <div className="historyWrap">
|
||||
<div className={`historyBars ${days.length > 30 ? 'dense' : ''}`} role="group" aria-label={t.history}>
|
||||
{days.map((day, index) => <button
|
||||
key={day.date} className={`dayBar ${day.status} ${day.coverage < 95 && day.checks ? 'partial' : ''}`}
|
||||
aria-label={describe(day)} tabIndex={index === Math.min(focusIndex, days.length - 1) ? 0 : -1}
|
||||
onMouseEnter={() => setSelected(index)} onMouseLeave={() => setSelected(null)}
|
||||
onFocus={() => { setFocusIndex(index); setSelected(index); }} onBlur={() => setSelected(null)}
|
||||
onClick={() => setSelected(index)}
|
||||
onKeyDown={event => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? days.length - 1 : Math.max(0, Math.min(days.length - 1, index + (event.key === 'ArrowRight' ? 1 : -1)));
|
||||
setFocusIndex(next);
|
||||
(event.currentTarget.parentElement?.children[next] as HTMLElement)?.focus();
|
||||
}}><span /></button>)}
|
||||
</div>
|
||||
{selected !== null && days[selected] && <div className="historyTooltip" role="status">{describe(days[selected])}</div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
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 <div className="chartEmpty">{t.noMeasurements}</div>;
|
||||
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 <div className="responseChart">
|
||||
<div className="chartCeiling">{Math.round(max)} {t.ms}</div>
|
||||
<svg viewBox={`0 0 ${width} ${height + 5}`} role="img" aria-label={t.latencyTrend} preserveAspectRatio="none">
|
||||
<defs><linearGradient id={id} x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor="var(--blue)" stopOpacity=".18" /><stop offset="100%" stopColor="var(--blue)" stopOpacity="0" /></linearGradient></defs>
|
||||
{[20, 65, 130].map(y => <line key={y} x1="0" y1={y} x2={width} y2={y} stroke="var(--subtle-line)" strokeDasharray="3 5" />)}
|
||||
{groups.map((points, index) => {
|
||||
const line = points.map(([x, y], i) => `${i ? 'L' : 'M'}${x},${y}`).join(' ');
|
||||
return <g key={index}><path d={`${line} L${points.at(-1)![0]},${height} L${points[0][0]},${height} Z`} fill={`url(#${id})`} /><path d={line} fill="none" stroke="var(--blue)" strokeWidth="2.5" vectorEffect="non-scaling-stroke" />{points.length === 1 && <circle cx={points[0][0]} cy={points[0][1]} r="3" fill="var(--blue)" />}</g>;
|
||||
})}
|
||||
{interactive && point.latencyMs !== null && <g><line x1={selectedX} x2={selectedX} y1="0" y2={height} stroke="var(--blue)" opacity=".25" /><circle cx={selectedX} cy={height - point.latencyMs / max * (height - 15)} r="4" fill="var(--blue)" /></g>}
|
||||
</svg>
|
||||
<div className="chartLabels"><span>{date.format(points[0].time)}</span><span>{date.format(points.at(-1)!.time + (bucketMs < 86400000 ? bucketMs : 0))}</span></div>
|
||||
{interactive && <><input className="chartScrubber" type="range" min="0" max={points.length - 1} step="1" value={selectedIndex} onChange={event => setSelected(Number(event.target.value))} aria-label={t.chartSelect} aria-valuetext={`${date.format(point.time)}: ${point.latencyMs === null ? t.noMeasurements : `${point.latencyMs} ${t.ms}`}`} /><div className="chartReadout" role="status"><strong>{date.format(point.time)}</strong><span>{point.latencyMs === null ? t.noMeasurements : `${t.chartAverage}: ${point.latencyMs} ${t.ms} · ${point.checks.toLocaleString(locale)} ${t.measurements}`}</span></div></>}
|
||||
</div>;
|
||||
}
|
||||
35
src/components/Icons.tsx
Normal file
@@ -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 <Icon width={size} height={size} className={className} aria-hidden="true" />; };
|
||||
}
|
||||
export const ArrowUpRight = adapt(Icon24ArrowUpRightOutline);
|
||||
export const ArrowRight = adapt(Icon24ArrowRightOutline);
|
||||
export const Close = adapt(Icon24Cancel);
|
||||
export const Check = adapt(Icon24CheckCircleOutline);
|
||||
export const Clock = adapt(Icon24ClockOutline);
|
||||
export const Monitor = adapt(Icon24ComputerOutline);
|
||||
export const Devices = adapt(Icon24ComputerSmartphoneOutline);
|
||||
export const Server = adapt(Icon24ComputerStackOutline);
|
||||
export const Globe = adapt(Icon24GlobeOutline);
|
||||
export const History = adapt(Icon24HistoryBackwardOutline);
|
||||
export const Info = adapt(Icon24InfoCircleOutline);
|
||||
export const Refresh = adapt(Icon24RefreshOutline);
|
||||
export const Statistics = adapt(Icon24StatisticsOutline);
|
||||
export const Warning = adapt(Icon24WarningTriangleOutline);
|
||||
export const Moon = adapt(Icon20MoonOutline);
|
||||
export const Sun = adapt(Icon24SunOutline);
|
||||
export const Wrench = adapt(Icon24WrenchOutline);
|
||||
export const Rss = adapt(Icon28RssFeedOutline);
|
||||
export const Copy = adapt(Icon24CopyOutline);
|
||||
export const serviceIcons: Record<string, ReturnType<typeof adapt>> = { api: Server, website: Globe, pwa: Devices, board: Monitor, admin: Wrench };
|
||||
11
src/components/ImpactNotice.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Locale, Service } from '../types';
|
||||
import { serviceImpact } from '../../shared/impact.mjs';
|
||||
import { translations } from '../lib/i18n';
|
||||
import { ArrowUpRight, Info } from './Icons';
|
||||
|
||||
export function ImpactNotice({ service, services, locale, compact = false }: { service: Service; services: Service[]; locale: Locale; compact?: boolean }) {
|
||||
const impact = serviceImpact(service, services);
|
||||
if (!impact) return null;
|
||||
const t = translations[locale];
|
||||
return <div className={`impactNotice ${compact ? 'compact' : ''}`}><Info size={18} /><div><strong>{t.impactTitle}</strong><p>{impact.message[locale]}</p>{!compact && impact.advice && <p className="impactAdvice">{impact.advice[locale]}</p>}{!compact && impact.alternatives.length > 0 && <div className="impactAlternatives"><span>{t.impactAlternative}:</span>{impact.alternatives.map(item => <a href={item.url} key={item.id} target="_blank" rel="noopener noreferrer">{item.name[locale]}<ArrowUpRight size={15} /></a>)}</div>}</div></div>;
|
||||
}
|
||||
26
src/components/Incidents.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import type { Incident, IncidentStage, Locale } from '../types';
|
||||
import { formatTime, translations } from '../lib/i18n';
|
||||
import { Check, Clock, History, Warning } from './Icons';
|
||||
|
||||
export function Incidents({ incidents, locale, compact = false }: { incidents: Incident[]; locale: Locale; compact?: boolean }) {
|
||||
const t = translations[locale];
|
||||
const stages: Record<IncidentStage, string> = { detected: t.stageDetected, reported: t.stageReported, investigating: t.stageInvestigating, identified: t.stageIdentified, monitoring: t.stageMonitoring, resolved: t.stageResolved, postmortem: t.stagePostmortem };
|
||||
const [filter, setFilter] = useState('all');
|
||||
const filtered = incidents.filter(incident => filter === 'all' || (filter === 'active' ? !incident.resolvedAt : Boolean(incident.resolvedAt)));
|
||||
const visible = compact ? filtered.slice(0, 3) : filtered;
|
||||
return <>
|
||||
{!compact && <div className="incidentFilters" role="group" aria-label={t.incidentsTab}>{(['all', 'active', 'resolved'] as const).map(key => <button key={key} className={filter === key ? 'selected' : ''} aria-pressed={filter === key} onClick={() => setFilter(key)}>{key === 'all' ? t.allFilter : key === 'active' ? t.activeFilter : t.resolvedFilter}</button>)}</div>}
|
||||
{!visible.length ? <div className="emptyState"><span className="emptyIcon"><History size={30} /></span><h3>{filter === 'all' ? t.noIncidents : t.noFiltered}</h3><p>{t.noIncidentsDescription}</p></div> : <div className="incidentList">{visible.map(incident => {
|
||||
const latest = incident.updates.at(-1);
|
||||
const stage = incident.resolvedAt ? 'resolved' : latest?.stage || (incident.source === 'monitor' ? 'detected' : 'reported');
|
||||
return <article className="incidentCard" key={incident.id} id={`incident-${incident.id}`}>
|
||||
<div className="incidentTop"><span className={`incidentPill ${incident.resolvedAt ? 'resolved' : 'active'}`}>{incident.resolvedAt ? <Check size={17} /> : <Warning size={17} />}{stages[stage]}</span><time className="incidentDate" dateTime={new Date(incident.startedAt).toISOString()}>{formatTime(incident.startedAt, locale, true)}</time></div>
|
||||
<h3>{incident.title[locale]}</h3><p>{latest?.message[locale] || incident.message[locale]}</p>
|
||||
{incident.impact && <div className="incidentImpact"><strong>{incident.resolvedAt ? t.impactPast : t.impactTitle}</strong><p>{incident.impact[locale]}</p></div>}
|
||||
<div className="incidentMeta"><span><Clock size={16} />{t.duration}: {incident.resolvedAt ? `${Math.max(1, Math.round((incident.resolvedAt - incident.startedAt) / 60000))} ${t.minutes}` : t.ongoing}</span><span>{incident.scope === 'scenario' ? t.scenarioTitle : incident.source === 'monitor' ? t.automated : t.teamUpdate}</span></div>
|
||||
<details className="incidentTimeline" open={!compact && !incident.resolvedAt}><summary><History size={17} />{t.timeline}<span>{t.updates}: {incident.updates.length}</span></summary><ol>{incident.updates.map(update => <li key={update.id} className={update.source}><div className="timelineMeta"><time dateTime={new Date(update.createdAt).toISOString()}>{formatTime(update.createdAt, locale, true)}</time><span>{update.source === 'monitor' ? t.automated : t.teamUpdate}</span></div><h4>{stages[update.stage]}</h4><p>{update.message[locale]}</p></li>)}</ol></details>
|
||||
</article>;
|
||||
})}</div>}
|
||||
</>;
|
||||
}
|
||||
32
src/components/Maintenance.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useState } from 'react';
|
||||
import type { Locale, Maintenance, Service } from '../types';
|
||||
import { formatTime, translations } from '../lib/i18n';
|
||||
import { maintenancePhase } from '../lib/status';
|
||||
import { Clock, Wrench } from './Icons';
|
||||
|
||||
export function MaintenanceList({ items, services, locale, now, compact = false }: { items: Maintenance[]; services: Service[]; locale: Locale; now: number; compact?: boolean }) {
|
||||
const t = translations[locale];
|
||||
const [filter, setFilter] = useState(() => {
|
||||
const target = items.find(item => location.hash === `#maintenance-${item.id}`);
|
||||
return target && !['scheduled', 'active'].includes(maintenancePhase(target, now)) ? 'history' : 'upcoming';
|
||||
});
|
||||
const phaseLabel = { scheduled: t.maintenanceScheduled, active: t.maintenanceActive, ended: t.maintenanceEnded, completed: t.maintenanceCompleted, cancelled: t.maintenanceCancelled };
|
||||
const sorted = [...items].sort((a, b) => Number(maintenancePhase(b, now) === 'active') - Number(maintenancePhase(a, now) === 'active') || (filter === 'history' && !compact ? b.startsAt - a.startsAt : a.startsAt - b.startsAt));
|
||||
const visible = sorted.filter(item => {
|
||||
const upcoming = ['scheduled', 'active'].includes(maintenancePhase(item, now));
|
||||
return compact || filter === 'upcoming' ? upcoming : !upcoming;
|
||||
}).slice(0, compact ? 2 : 100);
|
||||
return <>
|
||||
{!compact && <div className="incidentFilters" role="group" aria-label={t.maintenanceTab}>{['upcoming', 'history'].map(value => <button key={value} className={filter === value ? 'selected' : ''} aria-pressed={filter === value} onClick={() => setFilter(value)}>{value === 'upcoming' ? t.maintenanceUpcomingFilter : t.maintenanceHistoryFilter}</button>)}</div>}
|
||||
{!visible.length && !compact ? <div className="emptyState"><span className="emptyIcon"><Wrench size={30} /></span><h3>{filter === 'history' ? t.maintenanceHistoryEmpty : t.maintenanceEmpty}</h3><p>{filter === 'history' ? t.maintenanceHistoryEmptyText : t.maintenanceEmptyText}</p></div> : <div className="maintenanceList">{visible.map(item => {
|
||||
const phase = maintenancePhase(item, now);
|
||||
const names = item.serviceIds.length ? item.serviceIds.map(id => services.find(service => service.id === id)?.name[locale] || id).join(' · ') : t.maintenanceAllServices;
|
||||
return <article className={`maintenanceCard ${phase}`} key={item.id} id={`maintenance-${item.id}`}>
|
||||
<div className="incidentTop"><span className={`maintenancePill ${phase}`}><Wrench size={16} />{phaseLabel[phase]}</span></div>
|
||||
<h3>{item.title[locale]}</h3><p>{item.message[locale]}</p>
|
||||
<div className="maintenanceWindow"><Clock size={17} /><span>{formatTime(item.startsAt, locale, true)} — {formatTime(item.endsAt, locale, true)}</span></div>
|
||||
<div className="maintenanceServices"><span>{t.maintenanceAffects}</span><strong>{names}</strong></div>
|
||||
</article>;
|
||||
})}</div>}
|
||||
</>;
|
||||
}
|
||||
14
src/components/ScenarioChecks.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Locale, Scenario } from '../types';
|
||||
import { formatTime, translations } from '../lib/i18n';
|
||||
import { Check, Clock, Warning } from './Icons';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { scenarioNote } from '../lib/scenario';
|
||||
|
||||
export function ScenarioChecks({ scenario, locale }: { scenario: Scenario; locale: Locale }) {
|
||||
const t = translations[locale];
|
||||
const names: Record<string, string> = { cities: t.stepCities, config: t.stepConfig, device: t.stepDevice, stops: t.stepStops, departures: t.stepDepartures, render: t.stepRender };
|
||||
return <section className="detailSection scenarioSection"><div className="scenarioHeading"><h3>{t.scenarioTitle}</h3><StatusBadge status={scenario.status} locale={locale} /></div><p className="scenarioNote">{scenarioNote(scenario, locale)}</p>
|
||||
<ul className="scenarioSteps">{scenario.steps.map((step, index) => <li key={`${step.id}:${index}`} className={step.status}><span>{step.status === 'passed' ? <Check size={18} /> : step.status === 'failed' ? <Warning size={18} /> : <Clock size={18} />}{names[step.id] || step.id}</span><span>{step.status === 'passed' ? t.stepPassed : step.status === 'failed' ? t.stepFailed : t.stepWaiting}</span></li>)}</ul>
|
||||
<p className="detailScope">{t.updated}: {formatTime(scenario.lastCheckedAt, locale)} · {t.every} {scenario.intervalSeconds} {t.seconds}<br />{scenario.type !== 'api' && scenario.mode === 'http' && !scenario.reason && scenario.steps.some(step => step.id === 'render') ? <>{t.scenarioHttpOnly}<br /></> : null}{t.scenarioNight}</p>
|
||||
</section>;
|
||||
}
|
||||
33
src/components/ServiceDetails.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { Locale, Service } from '../types';
|
||||
import { formatPercent, formatTime, translations } from '../lib/i18n';
|
||||
import { ArrowUpRight, Check, Close, Globe, Warning, serviceIcons } from './Icons';
|
||||
import { HistoryBars, ResponseChart } from './Charts';
|
||||
import { StatusBadge, serviceNote } from './StatusBadge';
|
||||
import { ScenarioChecks } from './ScenarioChecks';
|
||||
import { ImpactNotice } from './ImpactNotice';
|
||||
import { ReliabilityMetrics } from './Statistics';
|
||||
|
||||
export function ServiceDetails({ service, services, locale, onClose }: { service: Service; services: Service[]; locale: Locale; onClose: () => void }) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
const t = translations[locale];
|
||||
const Icon = serviceIcons[service.icon] || Globe;
|
||||
useEffect(() => { const dialog = ref.current!; if (!dialog.open) dialog.showModal(); return () => dialog.close(); }, []);
|
||||
return <dialog ref={ref} className="serviceDialog" aria-labelledby="detail-title" onCancel={event => { event.preventDefault(); onClose(); }} onClick={event => { if (event.target === event.currentTarget) onClose(); }}>
|
||||
<div className="dialogContent">
|
||||
<div className="dialogHeading"><span className="serviceIcon"><Icon size={28} /></span><button className="iconButton" aria-label={t.close} onClick={onClose} autoFocus><Close /></button></div>
|
||||
<h2 id="detail-title">{service.name[locale]}</h2><p className="muted">{service.description[locale]}</p>
|
||||
<div className="detailStatus"><StatusBadge status={service.status} locale={locale} />{serviceNote(service, locale) && <span className="muted">{serviceNote(service, locale)}</span>}</div>
|
||||
<ImpactNotice service={service} services={services} locale={locale} />
|
||||
<a className="externalLink" href={service.publicUrl} target="_blank" rel="noopener noreferrer">{new URL(service.publicUrl).hostname}<ArrowUpRight size={18} /></a>
|
||||
<div className="availabilityScope"><span>{t.availabilityScope}</span><StatusBadge status={service.availabilityStatus} locale={locale} /></div>
|
||||
<div className="detailStats"><div><span>{t.availability}</span><strong>{formatPercent(service.uptime, locale)}</strong></div><div><span>{t.response}</span><strong>{service.latencyMs === null ? '—' : `${service.latencyMs} ${t.ms}`}</strong></div><div><span>{t.updated}</span><strong className="detailTime">{formatTime(service.lastCheckedAt, locale)}</strong></div></div>
|
||||
<section className="detailSection"><h3>{t.statisticsTitle}</h3><ReliabilityMetrics service={service} locale={locale} /><p className="statisticsMethod">{t.statisticsMethod}</p></section>
|
||||
<section className="detailSection"><h3>{t.history}</h3><HistoryBars days={service.history} locale={locale} /><div className="historyLabels"><span>{service.history[0].date}</span><span>{t.today}</span></div></section>
|
||||
<section className="detailSection"><h3>{t.responseHistory}</h3><ResponseChart points={service.responseHistory} locale={locale} /></section>
|
||||
{service.scenario && <ScenarioChecks scenario={service.scenario} locale={locale} />}
|
||||
<section className="detailSection"><h3>{t.checks}</h3>{!service.recentChecks.length ? <p className="muted">{t.noChecks}</p> : <div className="tableScroll"><table><thead><tr><th>{t.time}</th><th>{t.result}</th><th>{t.response}</th></tr></thead><tbody>{service.recentChecks.map(check => <tr key={check.time}><td>{formatTime(check.time, locale)}</td><td><span className={`checkResult ${check.ok ? 'good' : 'bad'}`}>{check.ok ? <Check size={16} /> : <Warning size={16} />}{check.httpCode === null ? '—' : `HTTP ${check.httpCode}`}</span></td><td>{check.latencyMs === null ? '—' : `${check.latencyMs} ${t.ms}`}</td></tr>)}</tbody></table></div>}</section>
|
||||
<p className="detailScope"><span>{service.functional ? t.scopeApiData : service.kind === 'json' ? t.scopeJson : service.kind === 'html' ? t.scopeHtml : t.scopeHttp}</span><br />{t.timezone}</p>
|
||||
</div>
|
||||
</dialog>;
|
||||
}
|
||||
23
src/components/Statistics.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import type { Locale, Service } from '../types';
|
||||
import { formatDuration, formatPercent, formatTime, translations } from '../lib/i18n';
|
||||
import { ArrowRight, Statistics as StatisticsIcon } from './Icons';
|
||||
import { ResponseChart } from './Charts';
|
||||
|
||||
export function ReliabilityMetrics({ service, locale }: { service: Service; locale: Locale }) {
|
||||
const t = translations[locale], stats = service.reliability;
|
||||
const duration = (value: number | null) => `${stats.downtimeIncomplete && value !== null ? '≥ ' : ''}${formatDuration(value, locale)}`;
|
||||
return <><dl className="reliabilityGrid"><div><dt>{t.measuredDowntime}</dt><dd>{duration(stats.downtimeMs)}</dd><small>{t.confirmedIncidents}: {stats.incidentCount}</small></div><div><dt>{t.longestOutage}</dt><dd>{duration(stats.longestDowntimeMs)}</dd></div><div><dt>{t.lastRecovery}</dt><dd className="recoveryTime">{stats.lastRecoveryAt === null ? t.noRecovery : formatTime(stats.lastRecoveryAt, locale, true)}</dd>{stats.lastRecoveryScope && <small>{stats.lastRecoveryScope === 'scenario' ? t.scenarioTitle : t.basicCheck}</small>}</div></dl><p className="statisticsCoverage">{t.timeCoverage}: {stats.observedMs > 0 && stats.coverage < .1 ? `<${formatPercent(.1, locale)}` : formatPercent(stats.coverage, locale)}{stats.downtimeIncomplete && <> · {t.incompleteDowntime}</>}</p></>;
|
||||
}
|
||||
|
||||
export function StatisticsPanel({ services, locale, range, onDetails }: { services: Service[]; locale: Locale; range: number; onDetails: (id: string) => void }) {
|
||||
const t = translations[locale];
|
||||
const [selected, setSelected] = useState(services[0]?.id || 'api');
|
||||
const [period, setPeriod] = useState<'24h' | 'range'>('24h');
|
||||
const service = services.find(item => item.id === selected) || services[0];
|
||||
if (!service) return null;
|
||||
const latency = period === '24h' ? service.latency24h : service.latency;
|
||||
const points = period === '24h' ? service.responseHistory : service.latencyHistory;
|
||||
const change = latency.changePercent;
|
||||
return <section className="statisticsSection" aria-labelledby="statistics-title"><div className="sectionHeading"><div><h2 id="statistics-title">{t.statisticsTitle}</h2><p>{t.statisticsDescription}</p></div><span className="statisticsRange">{range} {t.days}</span></div><div className="statisticsCard"><div className="servicePicker" role="group" aria-label={t.serviceSelection}>{services.map(item => <button key={item.id} aria-pressed={item.id === service.id} className={item.id === service.id ? 'selected' : ''} onClick={() => setSelected(item.id)}>{item.name[locale]}</button>)}</div><ReliabilityMetrics service={service} locale={locale} /><p className="statisticsMethod">{t.statisticsMethod}</p><div className="latencyHeading"><h3><StatisticsIcon size={19} />{t.latencyTrend}</h3><div className="incidentFilters" role="group" aria-label={t.latencyTrend}><button aria-pressed={period === '24h'} className={period === '24h' ? 'selected' : ''} onClick={() => setPeriod('24h')}>{t.last24hours}</button><button aria-pressed={period === 'range'} className={period === 'range' ? 'selected' : ''} onClick={() => setPeriod('range')}>{range} {t.days}</button></div></div><div className="latencySummary"><div><span>{t.periodAverage}</span><strong>{latency.averageMs === null ? '—' : `${latency.averageMs} ${t.ms}`}</strong></div><div title={t.latencyPercentileHint}><span>{t.latencyPercentile}</span><strong>{latency.p95Ms === null ? '—' : `${latency.p95Ms} ${t.ms}`}</strong></div><div className={`latencyChange ${change === null || change === 0 ? '' : change > 0 ? 'slower' : 'faster'}`}><span>{t.latencyComparison}</span><strong>{change === null ? '—' : `${change > 0 ? '+' : ''}${change.toLocaleString(locale)}%`}</strong>{change === null && <small>{t.latencyNoComparison}</small>}</div></div><ResponseChart key={`${service.id}:${period}:${range}`} points={points} locale={locale} bucketMs={period === '24h' ? 3600000 : 86400000} interactive /><div className="statisticsFoot"><span>{latency.samples.toLocaleString(locale)} {t.measurements}</span><button className="textButton" onClick={() => onDetails(service.id)}>{t.details}<ArrowRight size={17} /></button></div></div></section>;
|
||||
}
|
||||
18
src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Locale, Service, Status } from '../types';
|
||||
import { statusKey, translations } from '../lib/i18n';
|
||||
import { scenarioNote } from '../lib/scenario';
|
||||
|
||||
export function StatusBadge({ status, locale }: { status: Status; locale: Locale }) {
|
||||
return <span key={status} className={`statusBadge ${status}`}><span className="statusDot" />{translations[locale][statusKey[status]]}</span>;
|
||||
}
|
||||
|
||||
export function serviceNote(service: Service, locale: Locale) {
|
||||
const t = translations[locale];
|
||||
if (service.status === 'maintenance') return t.maintenanceActive;
|
||||
if (!service.configured) return t.unconfigured;
|
||||
if (service.stale) return t.stale;
|
||||
if (service.pending) return service.status === 'down' && service.reason === null ? t.recovering : t.confirming;
|
||||
if (service.availabilityStatus === 'operational' && service.scenario && service.scenario.status !== 'operational') return scenarioNote(service.scenario, locale);
|
||||
const reasons: Record<string, string> = { network: t.reasonNetwork, timeout: t.reasonTimeout, http: t.reasonHttp, content: t.reasonContent, redirect: t.reasonRedirect };
|
||||
return service.reason ? reasons[service.reason] || t.stale : '';
|
||||
}
|
||||
21
src/components/Subscriptions.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Locale, Service } from '../types';
|
||||
import { translations } from '../lib/i18n';
|
||||
import { ArrowUpRight, Check, Copy, Rss } from './Icons';
|
||||
|
||||
export function Subscriptions({ services, locale }: { services: Service[]; locale: Locale }) {
|
||||
const t = translations[locale];
|
||||
const [selected, setSelected] = useState<string[]>(() => services.map(service => service.id));
|
||||
const [types, setTypes] = useState<string[]>(['incidents', 'maintenance']);
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle');
|
||||
const ids = selected.filter(id => services.some(service => service.id === id));
|
||||
const valid = ids.length > 0 && types.length > 0;
|
||||
const params = new URLSearchParams({ lang: locale, services: [...ids].sort().join(','), types: [...types].sort().join(',') });
|
||||
const url = new URL(`/feed.xml?${params}`, location.origin).toString();
|
||||
useEffect(() => { setCopyState('idle'); }, [url]);
|
||||
function toggle(items: string[], value: string) { setCopyState('idle'); return items.includes(value) ? items.filter(item => item !== value) : [...items, value]; }
|
||||
async function copy() {
|
||||
try { await navigator.clipboard.writeText(url); setCopyState('copied'); } catch { setCopyState('failed'); }
|
||||
}
|
||||
return <section className="subscriptionCard"><div className="subscriptionIntro"><span className="explainIcon"><Rss size={26} /></span><p>{t.rssPrivacy}</p></div><fieldset><legend>{t.rssServices}</legend><div className="feedChoices">{services.map(service => <label key={service.id}><input type="checkbox" checked={ids.includes(service.id)} onChange={() => setSelected(toggle(selected, service.id))} /><span>{service.name[locale]}</span></label>)}</div><button className="textButton" onClick={() => { setSelected(services.map(service => service.id)); setCopyState('idle'); }}>{t.rssAll}</button></fieldset><fieldset><legend>{t.rssTypes}</legend><div className="feedChoices">{(['incidents', 'maintenance'] as const).map(type => <label key={type}><input type="checkbox" checked={types.includes(type)} onChange={() => setTypes(toggle(types, type))} /><span>{type === 'incidents' ? t.rssIncidents : t.rssMaintenance}</span></label>)}</div></fieldset><label className="feedUrl"><span>{t.rssLink}</span><input type="text" readOnly value={valid ? url : ''} onFocus={event => event.currentTarget.select()} /></label>{!valid && <p className="feedValidation" role="status">{t.rssEmpty}</p>}<div className="feedActions">{valid && <a className="primaryButton" href={url} target="_blank" rel="noopener noreferrer"><Rss size={18} />{t.rssOpen}<ArrowUpRight size={17} /></a>}<button className="secondaryButton" disabled={!valid} onClick={copy}>{copyState === 'copied' ? <Check size={18} /> : <Copy size={18} />}{copyState === 'copied' ? t.rssCopied : t.rssCopy}</button></div><p className="feedStatus" role="status">{copyState === 'failed' ? t.rssCopyFailed : t.rssLiveOnly}</p></section>;
|
||||
}
|
||||
134
src/lib/i18n.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { Locale, Status } from '../types';
|
||||
|
||||
const ru = {
|
||||
statisticsTitle: 'Работа в цифрах', statisticsDescription: 'Подтверждённые сбои и время ответа каждого сервиса.', measuredDowntime: 'Время сбоев', longestOutage: 'Самый долгий инцидент', lastRecovery: 'Последнее восстановление', noRecovery: 'Не зафиксировано', confirmedIncidents: 'Подтверждённых инцидентов', timeCoverage: 'Покрытие времени', statisticsMethod: 'Учитываем подтверждённые сбои доступности и сценариев. Пересечения считаем один раз; плановые работы и время без свежих проверок исключены.', incompleteDowntime: 'Часть инцидента не покрыта проверками. Показана минимальная измеренная длительность.', latencyTrend: 'Динамика отклика', last24hours: '24 часа', periodAverage: 'Среднее время ответа', latencyPercentile: '95% измерений', latencyPercentileHint: '95% успешных измерений не превышают это время.', latencyComparison: 'К предыдущему периоду', latencyNoComparison: 'Недостаточно данных для сравнения', chartSelect: 'Выбрать точку на графике', chartAverage: 'Средний ответ', durationDays: 'д', durationHours: 'ч', serviceSelection: 'Сервис для статистики', basicCheck: 'Доступность',
|
||||
impactTitle: 'Что это значит для вас', impactPast: 'Влияние во время инцидента', impactAlternative: 'Можно попробовать',
|
||||
timeline: 'Хронология', updates: 'обновления', stageDetected: 'Сбой обнаружен', stageReported: 'Сообщение команды', stageInvestigating: 'Проверяем причину', stageIdentified: 'Причина найдена', stageMonitoring: 'Наблюдаем за работой', stageResolved: 'Восстановлено', stagePostmortem: 'Причина и итоги',
|
||||
subscribe: 'Подписаться на обновления', rssTitle: 'Будьте в курсе', rssDescription: 'Получайте сообщения об инцидентах и плановых работах в своём RSS-приложении.', rssServices: 'Выберите сервисы', rssTypes: 'Какие события получать', rssAll: 'Выбрать все', rssIncidents: 'Инциденты и их обновления', rssMaintenance: 'Плановые работы', rssOpen: 'Открыть RSS', rssCopy: 'Скопировать ссылку', rssCopied: 'Ссылка скопирована', rssCopyFailed: 'Не удалось скопировать. Выделите ссылку в поле ниже.', rssLink: 'Ссылка на вашу ленту', rssEmpty: 'Выберите хотя бы один сервис и один тип событий.', rssPrivacy: 'Регистрация не нужна. Список подписок хранится в вашем RSS-приложении; общие сообщения включаются в любую выбранную ленту.', rssLiveOnly: 'RSS содержит реальные события. Демонстрационные примеры в ленту не попадают.',
|
||||
maintenanceTab: 'Плановые работы', maintenanceTitle: 'Заботимся о сервисах', maintenanceDescription: 'Окна обслуживания и ожидаемые ограничения в работе RideBus.',
|
||||
maintenanceHero: 'Плановые работы', maintenanceHeroSub: 'Обновляем сервисы. Время и подробности работ — ниже.',
|
||||
maintenanceScheduled: 'Запланировано', maintenanceActive: 'Работы идут', maintenanceEnded: 'Плановое окно закончилось', maintenanceCompleted: 'Завершено командой', maintenanceCancelled: 'Отменено',
|
||||
maintenanceEmpty: 'Плановых работ нет', maintenanceEmptyText: 'Здесь появятся сообщения о запланированном обслуживании.', maintenancePeriod: 'Плановое окно', maintenanceAffects: 'Затронутые сервисы', maintenanceAllServices: 'Все сервисы',
|
||||
maintenanceHistoryEmpty: 'История работ пока пуста', maintenanceHistoryEmptyText: 'Завершённые и отменённые работы появятся здесь.',
|
||||
maintenanceUpcomingFilter: 'Предстоящие', maintenanceHistoryFilter: 'История', maintenanceBanner: 'Сейчас идут плановые работы', maintenanceMore: 'Подробнее о работах',
|
||||
scenarioTitle: 'Пользовательский сценарий', scenarioDescription: 'Проверяем получение данных и их отображение в приложении.', scenarioPassed: 'Сценарий проходит', scenarioWaiting: 'Сценарий ещё не проверен', scenarioFailed: 'Есть проблема со сценарием',
|
||||
scenarioUnconfigured: 'Проверка данных не подключена', scenarioAuth: 'Нужен тестовый доступ', scenarioBrowserMissing: 'Браузерная проверка недоступна', scenarioRenderFailed: 'Не удалось отобразить данные', scenarioNotRun: 'Нет свежей проверки сценария',
|
||||
stepCities: 'Список городов', stepConfig: 'Настройки табло', stepDevice: 'Настройки устройства', stepStops: 'Список остановок', stepDepartures: 'Расписание остановки', stepRender: 'Данные на экране', stepPassed: 'Пройдено', stepFailed: 'Ошибка', stepWaiting: 'Ожидает проверки',
|
||||
availabilityScope: 'Доступность страницы или API', scenarioHttpOnly: 'Проверены ответы с данными. Проверка отображения в браузере выключена.', scenarioNight: 'Пустой список ближайших рейсов ночью считается нормальным ответом.',
|
||||
scenarioFrequency: 'Сценарии каждые', scenarioScopeText: 'Для API проверяем города, остановки и ответ расписания. Для PWA и табло также проверяем получение и отображение данных в отдельном браузере. Каждая проверка начинается без сохранённого расписания.',
|
||||
scenarioAccessText: 'Если нет тестового доступа или браузер недоступен, сценарий помечается как непроверенный. Проверки не изменяют транспортные данные и не создают новые устройства табло.',
|
||||
maintenanceMethod: 'Учитываем плановые работы', maintenanceMethodText: 'Во время объявленного окна сервис отмечен как находящийся на обслуживании. Ожидаемые сбои не открывают новые инциденты. После окна снова подтверждаем возможную проблему повторными проверками.',
|
||||
measurements: 'измерений',
|
||||
pageTitle: 'RideBus — статус сервисов', skip: 'Перейти к содержимому', statusTab: 'Статус сервисов', incidentsTab: 'Инциденты', aboutTab: 'О мониторинге',
|
||||
theme: 'Изменить тему', language: 'Switch to English', eyebrow: 'На связи с городом', heroTitle: 'Всё о работе', heroAccent: 'сервисов RideBus.',
|
||||
heroDescription: 'Расписание, табло и данные о транспорте. Здесь можно узнать, как работают наши сервисы прямо сейчас.',
|
||||
allGood: 'Всё работает', allGoodSub: 'Сервисы готовы к вашей следующей поездке.',
|
||||
someIssues: 'Есть перебои', someIssuesSub: 'Некоторые сервисы работают с ограничениями. Подробности — ниже.',
|
||||
allDown: 'Сервисы недоступны', allDownSub: 'Мониторинг подтвердил проблемы с доступностью.',
|
||||
unknownTitle: 'Ожидаем данные', unknownSub: 'Работа части сервисов пока не подтверждена. Подробности — в карточках сервисов.',
|
||||
every: 'Проверки каждые', seconds: 'с', minutes: 'мин', updated: 'Последняя проверка', never: 'Ещё не проверяли', refresh: 'Обновить', refreshing: 'Обновляем',
|
||||
services: 'Сервисы RideBus', servicesDescription: 'Доступность за последние', days: 'дней', today: 'Сегодня',
|
||||
availability: 'Доступность', response: 'Время ответа', ms: 'мс', serviceCount: 'сервисов на связи', noMeasurements: 'Пока нет измерений',
|
||||
availabilityNote: 'Доля успешных проверок за выбранный период.', responseNote: 'Среднее время ответа доступных сервисов при последней проверке.',
|
||||
details: 'Подробнее', detailsFor: 'Подробнее о сервисе', close: 'Закрыть', openService: 'Открыть сервис',
|
||||
history: 'История доступности', responseHistory: 'Время ответа за 24 часа', checks: 'Последние проверки', time: 'Время', result: 'Результат',
|
||||
noChecks: 'История появится после первых проверок.', noHistory: 'История ещё собирается', observed: 'Покрытие проверками', partial: 'Неполные данные',
|
||||
legendGood: 'Работает', legendDegraded: 'Перебои', legendDown: 'Недоступен', legendUnknown: 'Нет данных',
|
||||
confirming: 'Уточняем результат', recovering: 'Проверяем восстановление', stale: 'Нет свежих данных', unconfigured: 'Проверки не подключены',
|
||||
reasonNetwork: 'Не удалось подключиться', reasonTimeout: 'Превышено время ожидания', reasonHttp: 'Ошибка ответа сервиса', reasonContent: 'Неожиданный ответ сервиса', reasonRedirect: 'Ошибка перенаправления',
|
||||
demoLabel: 'Демонстрация', demoText: 'Пример интерфейса с тестовыми данными. Эти показатели не отражают работу сервисов RideBus.', backLive: 'К реальным статусам', demoLink: 'Пример с историей',
|
||||
fetchError: 'Не удалось обновить статусы. Показываем последние полученные данные; они могут быть устаревшими.', offline: 'Нет подключения к интернету. Последние полученные результаты могут быть устаревшими.', retry: 'Повторить', loading: 'Получаем статусы сервисов…',
|
||||
incidents: 'Что происходило', incidentsDescription: 'Проблемы с доступностью и обновления об их решении.', recentIncidents: 'Последние инциденты', allIncidents: 'Все инциденты',
|
||||
noIncidents: 'Зарегистрированных инцидентов нет', noIncidentsDescription: 'Когда мониторинг подтвердит сбой, здесь появится запись.',
|
||||
resolved: 'Решено', investigating: 'Проблема подтверждена', duration: 'Продолжительность', started: 'Начало', ended: 'Восстановлено', ongoing: 'Продолжается',
|
||||
allFilter: 'Все', activeFilter: 'Активные', resolvedFilter: 'Решённые', noFiltered: 'Нет инцидентов в этой категории.', automated: 'Автоматическая проверка', teamUpdate: 'Сообщение команды',
|
||||
methodTitle: 'Проверяем, чтобы вы знали', methodIntro: 'Мониторинг работает независимо от открытой страницы. Мы проверяем сервисы по их публичным адресам и сохраняем результаты.',
|
||||
methodFrequency: 'Регулярные проверки', methodFrequencyText: 'Каждый сервис проверяется через заданный интервал. Открытая страница автоматически получает свежие результаты.',
|
||||
methodConfirm: 'Подтверждаем сбой', methodConfirmText: 'Недоступность подтверждается после нескольких неудачных проверок подряд. Во время проверки возможной проблемы отображаются перебои.',
|
||||
methodRecovery: 'Проверяем восстановление', methodRecoveryText: 'После сбоя сервис возвращается в рабочее состояние только после успешных повторных проверок.',
|
||||
methodHonest: 'Показываем пробелы', methodHonestText: 'Если проверки не выполнялись, на графике остаются пропуски. Процент доступности рассчитан только по выполненным проверкам.',
|
||||
scopeTitle: 'Что означает статус', scopeText: 'Для сайта, PWA, админки и табло проверяется загрузка HTML. Для API — корректный ответ проверки здоровья. Это подтверждает доступность, но не проверяет каждое действие внутри приложения.',
|
||||
scopeFunctional: 'Для API также включена проверка получения транспортных данных.',
|
||||
scopeHtml: 'Проверка загрузки страницы', scopeJson: 'Проверка здоровья API', scopeHttp: 'Проверка HTTP', scopeApiData: 'Здоровье API и получение данных',
|
||||
timezone: 'Дни на графике — по UTC. Время проверок — в вашем часовом поясе.',
|
||||
howCheck: 'Как мы проверяем', howCheckSub: 'Понятно о проверках и истории.', knowMore: 'Узнать больше', tripTitle: 'А дальше — в путь', tripSub: 'Расписание и остановки в веб-приложении RideBus.', openPwa: 'Открыть приложение',
|
||||
footer: 'Город ближе, когда всё работает.', website: 'Сайт RideBus', publicApi: 'Статусы в JSON',
|
||||
} as const;
|
||||
|
||||
const en: Record<keyof typeof ru, string> = {
|
||||
statisticsTitle: 'Service performance', statisticsDescription: 'Confirmed outages and response times for each service.', measuredDowntime: 'Outage time', longestOutage: 'Longest incident', lastRecovery: 'Latest recovery', noRecovery: 'Not recorded', confirmedIncidents: 'Confirmed incidents', timeCoverage: 'Time coverage', statisticsMethod: 'Includes confirmed availability and journey outages. Overlaps are counted once; maintenance and periods without fresh checks are excluded.', incompleteDowntime: 'Part of an incident has no monitoring coverage. Showing the minimum measured duration.', latencyTrend: 'Response time trend', last24hours: '24 hours', periodAverage: 'Average response', latencyPercentile: '95% of measurements', latencyPercentileHint: '95% of successful measurements do not exceed this response time.', latencyComparison: 'Vs previous period', latencyNoComparison: 'Not enough data to compare', chartSelect: 'Select a chart point', chartAverage: 'Average response', durationDays: 'd', durationHours: 'h', serviceSelection: 'Service for statistics', basicCheck: 'Availability',
|
||||
impactTitle: 'What this means for you', impactPast: 'Impact during this incident', impactAlternative: 'You can try',
|
||||
timeline: 'Timeline', updates: 'updates', stageDetected: 'Outage detected', stageReported: 'Team report', stageInvestigating: 'Investigating', stageIdentified: 'Cause identified', stageMonitoring: 'Monitoring', stageResolved: 'Resolved', stagePostmortem: 'Cause and outcome',
|
||||
subscribe: 'Subscribe to updates', rssTitle: 'Stay informed', rssDescription: 'Receive incident and maintenance updates in your RSS app.', rssServices: 'Choose services', rssTypes: 'Which events to receive', rssAll: 'Select all', rssIncidents: 'Incidents and their updates', rssMaintenance: 'Planned maintenance', rssOpen: 'Open RSS', rssCopy: 'Copy link', rssCopied: 'Link copied', rssCopyFailed: 'Could not copy. Select the link in the field below.', rssLink: 'Your feed link', rssEmpty: 'Choose at least one service and one event type.', rssPrivacy: 'No registration needed. Subscriptions are stored in your RSS app; general announcements are included in any selected feed.', rssLiveOnly: 'RSS contains real events. Demo examples are excluded from the feed.',
|
||||
maintenanceTab: 'Maintenance', maintenanceTitle: 'Taking care of our services', maintenanceDescription: 'Maintenance windows and expected limitations across RideBus.',
|
||||
maintenanceHero: 'Planned maintenance', maintenanceHeroSub: 'We are updating services. See the schedule and details below.',
|
||||
maintenanceScheduled: 'Scheduled', maintenanceActive: 'In progress', maintenanceEnded: 'Scheduled window ended', maintenanceCompleted: 'Completed by the team', maintenanceCancelled: 'Cancelled',
|
||||
maintenanceEmpty: 'No planned maintenance', maintenanceEmptyText: 'Announcements about scheduled maintenance will appear here.', maintenancePeriod: 'Scheduled window', maintenanceAffects: 'Affected services', maintenanceAllServices: 'All services',
|
||||
maintenanceHistoryEmpty: 'No maintenance history yet', maintenanceHistoryEmptyText: 'Completed and cancelled maintenance will appear here.',
|
||||
maintenanceUpcomingFilter: 'Upcoming', maintenanceHistoryFilter: 'History', maintenanceBanner: 'Planned maintenance is in progress', maintenanceMore: 'Maintenance details',
|
||||
scenarioTitle: 'User journey', scenarioDescription: 'Checking data retrieval and its presentation in the app.', scenarioPassed: 'Journey passes', scenarioWaiting: 'Journey not checked yet', scenarioFailed: 'Journey has a problem',
|
||||
scenarioUnconfigured: 'Data checks not connected', scenarioAuth: 'Test access is required', scenarioBrowserMissing: 'Browser check unavailable', scenarioRenderFailed: 'Could not display data', scenarioNotRun: 'No recent journey check',
|
||||
stepCities: 'City list', stepConfig: 'Board configuration', stepDevice: 'Device settings', stepStops: 'Stop list', stepDepartures: 'Stop departures', stepRender: 'Data on screen', stepPassed: 'Passed', stepFailed: 'Failed', stepWaiting: 'Awaiting check',
|
||||
availabilityScope: 'Page or API availability', scenarioHttpOnly: 'Data responses have been checked. Browser rendering checks are disabled.', scenarioNight: 'An empty list of upcoming departures at night is a valid response.',
|
||||
scenarioFrequency: 'Journey checks every', scenarioScopeText: 'For the API, we check cities, stops, and the departure response. For the PWA and board, we also check data retrieval and presentation in a separate browser. Each check starts without a saved timetable.',
|
||||
scenarioAccessText: 'When test access or a browser is unavailable, the journey is marked as unchecked. Checks do not change transport data or create new board devices.',
|
||||
maintenanceMethod: 'Accounting for maintenance', maintenanceMethodText: 'During an announced window, the service is marked as under maintenance. Expected failures do not open new incidents. After the window, possible problems are confirmed through repeat checks.',
|
||||
measurements: 'measurements',
|
||||
pageTitle: 'RideBus — service status', skip: 'Skip to content', statusTab: 'Service status', incidentsTab: 'Incidents', aboutTab: 'About monitoring',
|
||||
theme: 'Change theme', language: 'Переключить на русский', eyebrow: 'Connected to the city', heroTitle: 'Stay in the loop', heroAccent: 'with RideBus.',
|
||||
heroDescription: 'Timetables, departure boards, and transport data. See how our services are doing right now.',
|
||||
allGood: 'All systems go', allGoodSub: 'Our services are ready for your next journey.',
|
||||
someIssues: 'Some disruptions', someIssuesSub: 'Some services are experiencing problems. See the details below.',
|
||||
allDown: 'Services unavailable', allDownSub: 'Monitoring has confirmed availability problems.',
|
||||
unknownTitle: 'Waiting for data', unknownSub: 'Some services have not been verified yet. See their cards for details.',
|
||||
every: 'Checks every', seconds: 's', minutes: 'min', updated: 'Last checked', never: 'Not checked yet', refresh: 'Refresh', refreshing: 'Refreshing',
|
||||
services: 'RideBus services', servicesDescription: 'Availability over the last', days: 'days', today: 'Today',
|
||||
availability: 'Availability', response: 'Response time', ms: 'ms', serviceCount: 'services responding', noMeasurements: 'No measurements yet',
|
||||
availabilityNote: 'Percentage of successful checks during the selected period.', responseNote: 'Average response time of available services in the latest check.',
|
||||
details: 'Details', detailsFor: 'Service details', close: 'Close', openService: 'Open service',
|
||||
history: 'Availability history', responseHistory: 'Response time over 24 hours', checks: 'Recent checks', time: 'Time', result: 'Result',
|
||||
noChecks: 'History will appear after the first checks.', noHistory: 'History is still being collected', observed: 'Monitoring coverage', partial: 'Partial data',
|
||||
legendGood: 'Operational', legendDegraded: 'Disrupted', legendDown: 'Unavailable', legendUnknown: 'No data',
|
||||
confirming: 'Confirming result', recovering: 'Confirming recovery', stale: 'No recent data', unconfigured: 'Checks not connected',
|
||||
reasonNetwork: 'Could not connect', reasonTimeout: 'Request timed out', reasonHttp: 'Service returned an error', reasonContent: 'Unexpected service response', reasonRedirect: 'Redirect error',
|
||||
demoLabel: 'Demo', demoText: 'An interface example with sample data. These metrics do not represent the real status of RideBus services.', backLive: 'Back to live status', demoLink: 'Example with history',
|
||||
fetchError: 'Could not refresh statuses. Showing the last received results, which may be outdated.', offline: 'You are offline. The last received results may be outdated.', retry: 'Retry', loading: 'Loading service status…',
|
||||
incidents: 'What happened', incidentsDescription: 'Availability problems and updates on their resolution.', recentIncidents: 'Recent incidents', allIncidents: 'All incidents',
|
||||
noIncidents: 'No recorded incidents', noIncidentsDescription: 'An entry will appear here when monitoring confirms an outage.',
|
||||
resolved: 'Resolved', investigating: 'Problem confirmed', duration: 'Duration', started: 'Started', ended: 'Recovered', ongoing: 'Ongoing',
|
||||
allFilter: 'All', activeFilter: 'Active', resolvedFilter: 'Resolved', noFiltered: 'No incidents in this category.', automated: 'Automatic monitoring', teamUpdate: 'Team update',
|
||||
methodTitle: 'We check, so you know', methodIntro: 'Monitoring runs independently of this page. We check services at their public addresses and save the results.',
|
||||
methodFrequency: 'Regular checks', methodFrequencyText: 'Each service is checked at the configured interval. This page automatically receives fresh results.',
|
||||
methodConfirm: 'Confirming outages', methodConfirmText: 'An outage is confirmed after repeated failed checks. A possible problem is shown as a disruption while being confirmed.',
|
||||
methodRecovery: 'Confirming recovery', methodRecoveryText: 'After an outage, a service is marked operational only after successful repeat checks.',
|
||||
methodHonest: 'Showing the gaps', methodHonestText: 'When checks were not running, the chart shows gaps. Availability is calculated only from completed checks.',
|
||||
scopeTitle: 'What a status means', scopeText: 'For the website, PWA, admin panel, and board, we check HTML page loading. For the API, we check its health response. This confirms availability but does not test every interaction within the app.',
|
||||
scopeFunctional: 'A transport data retrieval check is also enabled for the API.',
|
||||
scopeHtml: 'Page loading check', scopeJson: 'API health check', scopeHttp: 'HTTP check', scopeApiData: 'API health and data retrieval',
|
||||
timezone: 'Chart days use UTC. Check times use your local timezone.',
|
||||
howCheck: 'How we check', howCheckSub: 'A clear explanation of checks and history.', knowMore: 'Learn more', tripTitle: 'Next stop: your journey', tripSub: 'Find timetables and stops in the RideBus web app.', openPwa: 'Open the app',
|
||||
footer: 'The city feels closer when things work.', website: 'RideBus website', publicApi: 'Status as JSON',
|
||||
};
|
||||
|
||||
export const translations = { ru, en };
|
||||
export const statusKey: Record<Status, keyof typeof ru> = { operational: 'legendGood', degraded: 'legendDegraded', down: 'legendDown', unknown: 'legendUnknown', maintenance: 'maintenanceTab' };
|
||||
export const overallKeys: Record<Status, [keyof typeof ru, keyof typeof ru]> = { operational: ['allGood', 'allGoodSub'], degraded: ['someIssues', 'someIssuesSub'], down: ['allDown', 'allDownSub'], unknown: ['unknownTitle', 'unknownSub'], maintenance: ['maintenanceHero', 'maintenanceHeroSub'] };
|
||||
export type Copy = Record<keyof typeof ru, string>;
|
||||
|
||||
export function formatPercent(value: number | null, locale: Locale) {
|
||||
return value === null ? '—' : `${new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value)}%`;
|
||||
}
|
||||
export function formatTime(time: number | null, locale: Locale, full = false) {
|
||||
if (!time) return translations[locale].never;
|
||||
return new Intl.DateTimeFormat(locale, full ? { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' } : { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(time);
|
||||
}
|
||||
|
||||
export function formatDuration(value: number | null, locale: Locale) {
|
||||
if (value === null) return '—';
|
||||
const t = translations[locale], seconds = Math.max(0, Math.floor(value / 1000));
|
||||
const number = (value: number) => value.toLocaleString(locale);
|
||||
if (seconds >= 86400) return `${number(Math.floor(seconds / 86400))} ${t.durationDays} ${Math.floor(seconds % 86400 / 3600)} ${t.durationHours}`;
|
||||
if (seconds >= 3600) return `${number(Math.floor(seconds / 3600))} ${t.durationHours} ${Math.floor(seconds % 3600 / 60)} ${t.minutes}`;
|
||||
if (seconds >= 60) return `${number(Math.floor(seconds / 60))} ${t.minutes} ${seconds % 60} ${t.seconds}`;
|
||||
return `${number(seconds)} ${t.seconds}`;
|
||||
}
|
||||
6
src/lib/preferences.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function readPreference(key: string): string | null {
|
||||
try { return localStorage.getItem(`ridebus-status:${key}`); } catch { return null; }
|
||||
}
|
||||
export function savePreference(key: string, value: string) {
|
||||
try { localStorage.setItem(`ridebus-status:${key}`, value); } catch { /* Device preferences are optional. */ }
|
||||
}
|
||||
10
src/lib/scenario.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Locale, Scenario } from '../types';
|
||||
import { translations } from './i18n';
|
||||
|
||||
export function scenarioNote(scenario: Scenario, locale: Locale) {
|
||||
const t = translations[locale];
|
||||
const reason: Record<string, string> = { unconfigured: t.scenarioUnconfigured, auth_required: t.scenarioAuth, browser_unavailable: t.scenarioBrowserMissing, render: t.scenarioRenderFailed, not_run: t.scenarioNotRun, network: t.reasonNetwork, timeout: t.reasonTimeout, content: t.reasonContent, http: t.reasonHttp };
|
||||
if (scenario.stale) return t.scenarioNotRun;
|
||||
if (scenario.pending) return scenario.status === 'down' && !scenario.reason ? t.recovering : t.confirming;
|
||||
return scenario.reason ? reason[scenario.reason] || t.scenarioWaiting : scenario.status === 'operational' ? t.scenarioPassed : t.scenarioWaiting;
|
||||
}
|
||||
30
src/lib/status.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Maintenance, Service, Status } from '../types';
|
||||
|
||||
export function maintenancePhase(item: Maintenance, now = Date.now()): Maintenance['phase'] {
|
||||
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 liveService(service: Service, maintenance: Maintenance[], staleAfterSeconds: number, now: number): Service {
|
||||
const stale = Boolean(service.lastCheckedAt && now - service.lastCheckedAt > staleAfterSeconds * 1000);
|
||||
const availabilityStatus = stale ? 'unknown' : service.availabilityStatus;
|
||||
const scenarioStale = Boolean(service.scenario?.lastCheckedAt && now - service.scenario.lastCheckedAt > service.scenario.staleAfterSeconds * 1000);
|
||||
const scenario = service.scenario && scenarioStale ? { ...service.scenario, status: 'unknown' as const, stale: true, pending: false, reason: 'not_run' } : service.scenario;
|
||||
const activeMaintenance = maintenance.filter(item => maintenancePhase(item, now) === 'active' && (!item.serviceIds.length || item.serviceIds.includes(service.id))).map(item => item.id);
|
||||
let status = availabilityStatus;
|
||||
if (activeMaintenance.length) status = 'maintenance';
|
||||
else if (!['down', 'unknown'].includes(availabilityStatus)) {
|
||||
if (scenario?.status === 'unknown') status = 'unknown';
|
||||
else if (scenario && ['down', 'degraded'].includes(scenario.status)) status = 'degraded';
|
||||
}
|
||||
return { ...service, availabilityStatus, scenario, activeMaintenance, status, stale: service.stale || stale, pending: stale ? false : service.pending };
|
||||
}
|
||||
export function overall(services: Service[]): Status {
|
||||
if (services.every(service => service.status === 'unknown')) return 'unknown';
|
||||
if (services.every(service => service.status === 'down')) return 'down';
|
||||
if (services.some(service => service.status === 'down' || service.status === 'degraded')) return 'degraded';
|
||||
if (services.some(service => service.status === 'unknown')) return 'unknown';
|
||||
if (services.some(service => service.status === 'maintenance')) return 'maintenance';
|
||||
return 'operational';
|
||||
}
|
||||
7
src/main.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@fontsource-variable/manrope';
|
||||
import './styles.scss';
|
||||
import App from './App';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<StrictMode><App /></StrictMode>);
|
||||
351
src/styles.scss
Normal file
@@ -0,0 +1,351 @@
|
||||
@use './tokens';
|
||||
@use './motion';
|
||||
|
||||
:root { --success: #739c61; --success-soft: #e5f3dc; --danger-soft: #fce8e4; --warning-dot: #c99744; --unknown: #dce0e5; }
|
||||
:root[data-theme='dark'] { --success: #9cc488; --success-soft: #293d2a; --danger-soft: #452b2a; --warning-dot: #e0b35a; --unknown: #3c4656; }
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; scroll-padding-top: 20px; }
|
||||
body { margin: 0; background: var(--page); color: var(--ink); font-family: 'Manrope Variable', 'Manrope', sans-serif; font-size: 15px; line-height: 1.6; font-weight: 500; -webkit-font-smoothing: antialiased; }
|
||||
button, input { font: inherit; }
|
||||
button { cursor: pointer; color: inherit; }
|
||||
button, a { -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
|
||||
button:disabled { cursor: default; opacity: .55; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button:focus-visible, a:focus-visible { outline: 3px solid var(--focus); outline-offset: 4px; }
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
svg, .vkuiIcon { flex-shrink: 0; }
|
||||
.app { max-width: 1280px; padding: 0 48px; margin: auto; }
|
||||
.muted { color: var(--muted); }
|
||||
.skipLink { position: fixed; z-index: 20; top: -80px; left: 16px; background: var(--blue); color: var(--on-accent); border-radius: 12px; padding: 12px; }
|
||||
.skipLink:focus { top: 12px; }
|
||||
.header { height: 108px; display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--line); }
|
||||
.brand { display: flex; align-items: center; gap: 10px; padding: 0; border: 0; background: none; }
|
||||
.brand > span:first-of-type { font-size: 27px; font-weight: 800; letter-spacing: -.8px; }
|
||||
.brandDot { color: var(--blue); }
|
||||
.webTag { padding: 3px 8px; font-size: 13px; font-weight: 750; color: var(--blue); background: var(--blue-soft); border-radius: 7px; }
|
||||
.desktopNav { display: flex; gap: 4px; background: var(--nav-surface); padding: 5px; border-radius: 30px; }
|
||||
.desktopNav button { border: 0; background: none; display: flex; align-items: center; gap: 8px; border-radius: 25px; padding: 10px 17px; font-size: 14px; font-weight: 750; color: var(--muted); min-height: 44px; }
|
||||
.desktopNav .active { background: var(--surface); color: var(--blue); box-shadow: 0 2px 6px #172d6009; }
|
||||
.navCount { color: var(--error); background: var(--danger-soft); padding: 0 6px; border-radius: 12px; font-size: 12px; }
|
||||
.headerActions { display: flex; align-items: center; gap: 12px; }
|
||||
.iconButton { display: grid; place-items: center; min-width: 44px; min-height: 44px; padding: 10px; border-radius: 50%; border: 0; background: none; color: var(--blue); }
|
||||
.iconButton:hover { background: var(--lavender); }
|
||||
.languageButton { display: flex; align-items: center; gap: 7px; background: none; border: 0; min-height: 44px; padding: 8px; font-size: 13px; font-weight: 800; }
|
||||
.hero { display: flex; align-items: center; justify-content: space-between; gap: 50px; padding: 55px 0 43px; }
|
||||
.heroCopy { max-width: 660px; }
|
||||
.eyebrow { display: inline-flex; align-items: center; gap: 8px; color: var(--blue); font-size: 13px; font-weight: 800; }
|
||||
.eyebrowDot { width: 8px; height: 8px; border-radius: 50%; background: var(--blue); box-shadow: 0 0 0 4px var(--blue-soft); margin-right: 4px; }
|
||||
.hero h1 { font-size: clamp(38px, 4.4vw, 57px); letter-spacing: -1.6px; line-height: 1.16; font-weight: 780; margin: 19px 0 17px; }
|
||||
.hero h1 span { color: var(--blue); }
|
||||
.heroCopy > p { font-size: 16px; max-width: 490px; color: var(--muted); line-height: 1.8; }
|
||||
.heroNote { position: relative; isolation: isolate; width: 285px; padding: 25px 25px 23px; min-height: 247px; border-radius: 30px; background: var(--blue-soft); color: var(--blue); transform: rotate(3deg); flex-shrink: 0; overflow: hidden; }
|
||||
.heroNote.operational { background: var(--green); color: var(--green-ink); }
|
||||
.heroNote.degraded { background: var(--warning-bg); color: var(--warning-ink); }
|
||||
.heroNote.down { background: var(--danger-soft); color: var(--error); }
|
||||
.noteTop { display: flex; gap: 8px; align-items: center; font-size: 12px; font-weight: 750; margin-bottom: 27px; }
|
||||
.noteTop > .vkuiIcon { margin-left: auto; }
|
||||
.heroNote > strong { font-size: 26px; font-weight: 800; line-height: 1.2; letter-spacing: -.6px; display: block; }
|
||||
.heroNote > p { font-size: 13px; max-width: 235px; margin-top: 8px; line-height: 1.6; }
|
||||
.noteBottom { display: flex; gap: 7px; align-items: center; margin-top: 25px; font-size: 12px; font-weight: 700; }
|
||||
.heroStar { position: absolute; z-index: -1; opacity: .1; width: 140px; height: 140px; right: -43px; bottom: -41px; }
|
||||
.statusDot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
|
||||
.notice { display: flex; gap: 12px; align-items: center; border-radius: 16px; padding: 15px 18px; font-size: 13px; line-height: 1.7; }
|
||||
.notice p { flex: 1; }
|
||||
.notice button { display: flex; align-items: center; justify-content: center; gap: 8px; border: 0; color: inherit; border-radius: 20px; min-height: 40px; padding: 5px 10px; font-size: 13px; font-weight: 800; background: var(--hover-surface); }
|
||||
.demoNotice { background: var(--blue-soft); color: var(--blue); margin-top: 22px; }
|
||||
.errorNotice { color: var(--warning-ink); background: var(--warning-bg); margin-bottom: 12px; }
|
||||
.updateBar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 2px 20px; color: var(--muted); font-size: 12px; }
|
||||
.updateBar > div { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
|
||||
.updateBar strong { font-weight: 700; color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||
.updateDot { width: 6px; height: 6px; background: var(--success); border-radius: 50%; }
|
||||
.updateDot.offline { background: var(--warning-dot); }
|
||||
|
||||
.refreshButton { border: 0; background: none; color: var(--blue); display: flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 750; padding: 8px 0 8px 8px; min-height: 40px; }
|
||||
.dashboard { display: grid; grid-template-columns: minmax(0, 1fr) 270px; gap: 24px; align-items: start; }
|
||||
.servicesCard { background: var(--surface); border: 1px solid var(--line); border-radius: 32px; padding: 27px 28px 21px; min-width: 0; }
|
||||
.servicesHeading { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding-bottom: 22px; }
|
||||
.servicesHeading h2, .sectionHeading h2 { font-size: 23px; font-weight: 800; letter-spacing: -.6px; }
|
||||
.servicesHeading p { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
||||
.rangePicker { display: flex; flex-shrink: 0; gap: 3px; background: var(--lavender); padding: 4px; border-radius: 15px; }
|
||||
.rangePicker button { border: 0; background: none; border-radius: 11px; padding: 8px 11px; min-height: 40px; font-size: 12px; color: var(--blue-muted); font-weight: 750; }
|
||||
.rangePicker .selected { background: var(--surface); color: var(--blue); box-shadow: 0 1px 5px #172d600a; }
|
||||
.rangePicker button span { display: none; }
|
||||
.serviceRow { padding: 20px 0 19px; border-top: 1px solid var(--line); }
|
||||
.serviceHeading { display: flex; gap: 12px; align-items: center; }
|
||||
.serviceIcon { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 14px; background: var(--lavender); color: var(--blue); flex-shrink: 0; }
|
||||
.serviceIcon.api { background: var(--blue-soft); }
|
||||
.serviceIcon.board { background: var(--green); color: var(--green-ink); }
|
||||
.serviceIcon.admin { background: var(--warning-bg); color: var(--warning-ink); }
|
||||
.serviceTitle { min-width: 0; flex: 1; }
|
||||
.serviceTitle h3 button { display: flex; align-items: center; gap: 5px; border: 0; background: none; padding: 0; min-height: 27px; font-size: 15px; font-weight: 800; letter-spacing: -.3px; text-align: left; }
|
||||
.serviceTitle h3 .vkuiIcon { opacity: 0; transition: opacity .15s; color: var(--blue); }
|
||||
.serviceTitle h3 button:hover .vkuiIcon, .serviceTitle h3 button:focus-visible .vkuiIcon { opacity: 1; }
|
||||
.serviceTitle p { color: var(--muted); font-size: 12px; margin-top: 1px; }
|
||||
.statusBadge { display: inline-flex; gap: 6px; align-items: center; border-radius: 20px; padding: 4px 9px; font-size: 11px; font-weight: 750; white-space: nowrap; flex-shrink: 0; }
|
||||
.statusBadge.operational { color: var(--green-ink); background: var(--success-soft); }
|
||||
.statusBadge.degraded { color: var(--warning-ink); background: var(--warning-bg); }
|
||||
.statusBadge.down { color: var(--error); background: var(--danger-soft); }
|
||||
.statusBadge.unknown { color: var(--muted); background: var(--past-bg); }
|
||||
.statusBadge.maintenance { color: var(--blue); background: var(--blue-soft); }
|
||||
.heroNote.maintenance { color: var(--blue); background: var(--blue-soft); }
|
||||
.maintenanceNotice { color: var(--blue); background: var(--blue-soft); margin-bottom: 14px; }
|
||||
.maintenanceList { display: grid; gap: 14px; }
|
||||
.maintenanceCard { background: var(--lavender); color: var(--blue); padding: 24px 28px; border-radius: 26px; border: 1px solid var(--subtle-line); }
|
||||
.maintenanceCard.active { background: var(--blue-soft); }
|
||||
.maintenanceCard h3 { font-size: 18px; font-weight: 800; letter-spacing: -.4px; }
|
||||
.maintenanceCard > p { font-size: 13px; margin-top: 7px; line-height: 1.8; max-width: 850px; }
|
||||
.maintenancePill { display: inline-flex; gap: 6px; align-items: center; border-radius: 18px; padding: 5px 9px; background: var(--surface); font-size: 11px; font-weight: 750; }
|
||||
.maintenanceWindow { display: flex; align-items: center; gap: 7px; margin-top: 18px; font-size: 13px; font-weight: 750; }
|
||||
.maintenanceServices { display: flex; gap: 5px 12px; flex-wrap: wrap; border-top: 1px solid var(--subtle-line); padding-top: 13px; margin-top: 16px; font-size: 11px; }
|
||||
.maintenanceServices > span { opacity: .8; }
|
||||
.maintenanceServices > strong { font-weight: 750; }
|
||||
.availabilityScope { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); font-size: 11px; padding-top: 12px; }
|
||||
.scenarioSection { background: var(--lavender); border-radius: 18px; padding: 18px; color: var(--blue); }
|
||||
.scenarioHeading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.scenarioHeading h3 { margin: 0; }
|
||||
.scenarioNote { font-size: 12px; margin-top: 9px; }
|
||||
.scenarioSteps { list-style: none; margin: 15px 0 0; padding: 0; }
|
||||
.scenarioSteps li { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 11px 0; border-top: 1px solid var(--subtle-line); font-size: 12px; }
|
||||
.scenarioSteps li > span:first-child { display: flex; align-items: center; gap: 7px; }
|
||||
.scenarioSteps li > span:last-child { font-size: 10px; font-weight: 750; }
|
||||
.scenarioSteps .passed > span:last-child, .scenarioSteps .passed .vkuiIcon { color: var(--green-ink); }
|
||||
.scenarioSteps .failed > span:last-child, .scenarioSteps .failed .vkuiIcon { color: var(--error); }
|
||||
.scenarioSection .detailScope { font-size: 10px; color: var(--blue-muted); border-color: var(--subtle-line); }
|
||||
.serviceHistory { margin-top: 12px; }
|
||||
.historyWrap { position: relative; }
|
||||
.historyBars { display: flex; gap: 4px; width: 100%; }
|
||||
.historyBars.dense { gap: 1px; }
|
||||
.dayBar { display: block; min-width: 0; flex: 1; height: 25px; border: 0; padding: 4px 0; background: none; border-radius: 3px; position: relative; }
|
||||
.dayBar span { width: 100%; height: 100%; display: block; border-radius: 3px; background: var(--unknown); transition: transform .15s; }
|
||||
.dayBar:hover span, .dayBar:focus-visible span { transform: scaleY(1.25); }
|
||||
.dayBar.operational span, .legendDot.operational { background: var(--success); }
|
||||
.dayBar.degraded span, .legendDot.degraded { background: var(--warning-dot); }
|
||||
.dayBar.down span, .legendDot.down { background: var(--error); }
|
||||
.dayBar.partial span, .legendDot.partial { background-image: repeating-linear-gradient(135deg, transparent 0 3px, var(--surface) 3px 5px); }
|
||||
.historyTooltip { position: absolute; width: max-content; max-width: 100%; z-index: 2; bottom: calc(100% + 7px); right: 0; padding: 8px 11px; color: var(--on-accent); background: var(--blue); border-radius: 10px; box-shadow: 0 5px 14px #0001; font-size: 11px; pointer-events: none; }
|
||||
.serviceMetrics { display: flex; justify-content: space-between; align-items: center; gap: 14px; font-size: 11px; color: var(--muted); margin-top: 1px; }
|
||||
.serviceMetrics button { display: flex; align-items: center; gap: 7px; padding: 5px 0 3px 8px; min-height: 30px; border: 0; background: none; font-weight: 750; color: var(--blue); font-variant-numeric: tabular-nums; }
|
||||
.historyLabels { display: flex; justify-content: space-between; font-size: 11px; color: var(--muted); }
|
||||
.serviceList > .historyLabels { padding-top: 1px; }
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 7px 15px; font-size: 10px; padding-top: 17px; margin-top: 14px; border-top: 1px solid var(--line); color: var(--muted); }
|
||||
.legend > span { display: flex; align-items: center; gap: 5px; }
|
||||
.legendDot { display: block; width: 7px; height: 7px; border-radius: 2px; background: var(--unknown); }
|
||||
.legendDot.partial { background-color: var(--muted); }
|
||||
.dashboardSide { display: flex; flex-direction: column; gap: 18px; }
|
||||
.metricCard { padding: 26px 25px 20px; border-radius: 30px; background: var(--lavender); color: var(--blue); }
|
||||
.availabilityCard { background: var(--green); color: var(--green-ink); }
|
||||
.metricLabel { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 750; }
|
||||
.metricCard > strong { display: block; font-size: 45px; line-height: 1.25; letter-spacing: -1.7px; font-weight: 800; margin: 15px 0 9px; font-variant-numeric: tabular-nums; }
|
||||
.metricCard > strong > span { font-size: 17px; letter-spacing: 0; margin-left: 7px; }
|
||||
.metricCard p { font-size: 12px; line-height: 1.8; opacity: .8; }
|
||||
.metricFoot { display: flex; align-items: center; gap: 7px; font-size: 11px; font-weight: 650; padding-top: 15px; margin-top: 18px; border-top: 1px solid var(--subtle-line); }
|
||||
.availabilityCard .metricFoot { border-color: var(--green-line); }
|
||||
.explainCard { border-radius: 30px; border: 1px solid var(--line); padding: 23px 25px; }
|
||||
.explainIcon { display: inline-flex; color: var(--blue); margin-bottom: 11px; }
|
||||
.explainCard h3 { font-size: 17px; font-weight: 800; letter-spacing: -.4px; }
|
||||
.explainCard p { color: var(--muted); font-size: 12px; margin: 5px 0 11px; }
|
||||
.textButton { display: flex; align-items: center; gap: 7px; border: 0; background: none; color: var(--blue); font-size: 12px; font-weight: 800; min-height: 40px; padding: 5px 0; }
|
||||
.incidentsSection { margin-top: 39px; }
|
||||
.sectionHeading { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-bottom: 18px; }
|
||||
.emptyState { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; border-radius: 28px; border: 1px solid var(--line); padding: 36px 24px; background: var(--surface); }
|
||||
.emptyIcon { display: grid; place-items: center; width: 57px; height: 57px; border-radius: 17px; transform: rotate(-5deg); color: var(--blue); background: var(--lavender); margin-bottom: 16px; }
|
||||
.emptyState h3 { font-size: 16px; font-weight: 800; }
|
||||
.emptyState p { font-size: 13px; color: var(--muted); margin-top: 6px; max-width: 430px; }
|
||||
.incidentFilters { display: flex; gap: 7px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.incidentFilters button { padding: 10px 20px; border: 0; border-radius: 24px; background: var(--lavender); color: var(--blue); font-size: 13px; font-weight: 750; min-height: 42px; }
|
||||
.incidentFilters .selected { background: var(--blue); color: var(--on-accent); }
|
||||
.incidentList { display: grid; gap: 14px; }
|
||||
.incidentCard { padding: 23px 28px; border-radius: 25px; background: var(--surface); border: 1px solid var(--line); }
|
||||
.incidentTop { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 11px; }
|
||||
.incidentPill { display: flex; gap: 6px; align-items: center; border-radius: 20px; padding: 5px 9px; font-size: 11px; font-weight: 750; color: var(--green-ink); background: var(--green); }
|
||||
.incidentPill.active { color: var(--error); background: var(--danger-soft); }
|
||||
.incidentDate { font-size: 12px; color: var(--muted); }
|
||||
.incidentCard h3 { font-size: 17px; font-weight: 800; letter-spacing: -.3px; }
|
||||
.incidentCard > p { margin-top: 7px; font-size: 13px; color: var(--muted); max-width: 850px; }
|
||||
.incidentMeta { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; color: var(--muted); font-size: 11px; margin-top: 18px; }
|
||||
.incidentMeta > span { display: flex; align-items: center; gap: 6px; }
|
||||
.tripBanner { display: flex; align-items: center; gap: 18px; background: var(--benefit-bg); padding: 23px 28px; border-radius: 25px; margin: 25px 0 42px; }
|
||||
.tripIcon { display: grid; place-items: center; width: 49px; height: 49px; color: var(--blue); background: var(--surface); border-radius: 15px; transform: rotate(-5deg); }
|
||||
.tripBanner h3 { font-size: 17px; font-weight: 800; letter-spacing: -.4px; }
|
||||
.tripBanner p { font-size: 12px; color: var(--muted); margin-top: 3px; }
|
||||
.primaryButton { display: inline-flex; align-items: center; justify-content: center; gap: 9px; min-height: 44px; padding: 11px 18px; background: var(--blue); color: var(--on-accent); border: 0; border-radius: 26px; font-size: 12px; font-weight: 800; margin-left: auto; flex-shrink: 0; }
|
||||
.primaryButton:hover { background: var(--blue-muted); }
|
||||
.footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 25px 0 34px; border-top: 1px solid var(--line); }
|
||||
.footer > span { font-weight: 800; font-size: 20px; letter-spacing: -.5px; }
|
||||
.footer > span small { display: block; font-weight: 500; font-size: 13px; letter-spacing: 0; color: var(--muted); margin-top: 3px; }
|
||||
.footer > div { display: flex; align-items: center; gap: 22px; font-size: 14px; color: var(--muted); }
|
||||
.footer a, .footer button { display: flex; gap: 4px; align-items: center; }
|
||||
.footer button { border: 0; background: transparent; font-size: inherit; color: inherit; padding: 7px 0; }
|
||||
.pageSection { margin-bottom: 45px; }
|
||||
.pageHeading { padding: 25px 0; }
|
||||
.pageHeading h2 { font-size: 34px; font-weight: 800; letter-spacing: -1px; margin: 9px 0; }
|
||||
.pageHeading > p { max-width: 710px; color: var(--muted); line-height: 1.8; }
|
||||
.methodGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
|
||||
.methodCard { background: var(--lavender); color: var(--blue); padding: 30px; border-radius: 30px; }
|
||||
.method1 { background: var(--warning-bg); color: var(--warning-ink); }
|
||||
.method2 { background: var(--green); color: var(--green-ink); }
|
||||
.method3 { background: var(--benefit-bg); color: var(--ink); }
|
||||
.methodTop { display: flex; align-items: center; justify-content: space-between; margin-bottom: 22px; }
|
||||
.methodTop span { font-size: 18px; font-weight: 800; }
|
||||
.methodCard h3 { font-size: 20px; font-weight: 800; letter-spacing: -.4px; }
|
||||
.methodCard p { font-size: 13px; margin-top: 7px; opacity: .85; line-height: 1.9; }
|
||||
.scopeCard { display: flex; align-items: flex-start; gap: 17px; background: var(--surface); border: 1px solid var(--line); padding: 27px; border-radius: 27px; margin-top: 24px; }
|
||||
.scopeCard > .vkuiIcon { color: var(--blue); }
|
||||
.scopeCard h3 { font-size: 18px; font-weight: 800; }
|
||||
.scopeCard p { font-size: 13px; color: var(--muted); margin-top: 8px; line-height: 1.8; }
|
||||
.loadingState { display: flex; align-items: center; justify-content: center; gap: 12px; min-height: 440px; color: var(--muted); }
|
||||
.spinning { animation: spin 1.3s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.serviceDialog { border: 1px solid var(--line); padding: 0; border-radius: 30px; width: min(640px, calc(100% - 32px)); max-height: calc(100dvh - 48px); background: var(--surface); color: var(--ink); overflow-y: auto; box-shadow: 0 22px 100px #0003; }
|
||||
.serviceDialog::backdrop { background: #17243b80; backdrop-filter: blur(4px); }
|
||||
.dialogContent { padding: 28px 32px; }
|
||||
.dialogHeading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
|
||||
.serviceDialog h2 { font-size: 27px; font-weight: 800; letter-spacing: -.6px; }
|
||||
.serviceDialog > p { font-size: 13px; }
|
||||
.detailStatus { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin: 17px 0 8px; font-size: 12px; }
|
||||
.externalLink { display: inline-flex; align-items: center; gap: 5px; font-size: 13px; color: var(--blue); min-height: 34px; }
|
||||
.detailStats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; background: var(--lavender); padding: 18px; margin: 20px 0; border-radius: 18px; }
|
||||
.detailStats > div { display: flex; flex-direction: column; gap: 4px; }
|
||||
.detailStats span { font-size: 10px; color: var(--blue-muted); }
|
||||
.detailStats strong { font-size: 22px; font-weight: 800; color: var(--blue); letter-spacing: -.5px; }
|
||||
.detailStats .detailTime { font-size: 18px; line-height: 35px; }
|
||||
.detailSection { margin-top: 23px; }
|
||||
.detailSection h3 { font-size: 14px; font-weight: 800; margin-bottom: 10px; }
|
||||
.responseChart { margin-top: 10px; }
|
||||
.responseChart svg { width: 100%; height: 130px; display: block; }
|
||||
.chartLabels { display: flex; justify-content: space-between; color: var(--muted); font-size: 10px; margin-top: 5px; }
|
||||
.chartCeiling { color: var(--muted); font-size: 10px; text-align: right; }
|
||||
.chartEmpty { display: grid; place-items: center; min-height: 145px; border-radius: 15px; background: var(--lavender); color: var(--muted); font-size: 13px; }
|
||||
.tableScroll { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
th { text-align: left; color: var(--muted); font-size: 10px; font-weight: 650; }
|
||||
td, th { padding: 9px 5px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
||||
th:last-child, td:last-child { text-align: right; }
|
||||
.checkResult { display: flex; align-items: center; gap: 5px; }
|
||||
.checkResult.good { color: var(--green-ink); }
|
||||
.checkResult.bad { color: var(--error); }
|
||||
.detailScope { border-top: 1px solid var(--line); padding-top: 17px; margin-top: 22px; font-size: 11px; color: var(--muted); line-height: 1.8; }
|
||||
.detailScope > span { font-weight: 750; color: var(--blue); }
|
||||
@media (min-width: 1350px) { .rangePicker button span { display: inline; } }
|
||||
@media (max-width: 1050px) { .app { padding: 0 30px; } .dashboard { grid-template-columns: minmax(0, 1fr) 230px; gap: 18px; } .servicesCard { padding: 23px 22px; } .metricCard { padding: 23px 22px 18px; } .metricCard > strong { font-size: 40px; } .hero { gap: 30px; } .heroNote { width: 255px; } .serviceHeading { gap: 9px; } .serviceTitle p { font-size: 11px; } .header { gap: 15px; } .desktopNav button { padding: 10px 12px; font-size: 12px; } .brand > span:first-of-type { font-size: 23px; } .headerActions { gap: 4px; } }
|
||||
@media (max-width: 800px) { .dashboard { grid-template-columns: 1fr; } .dashboardSide { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } .explainCard { padding: 20px; } .metricCard { padding: 20px; } .metricCard > strong { font-size: 34px; } .metricFoot { margin-top: 12px; font-size: 10px; } .hero h1 { font-size: 42px; } .heroCopy > p { font-size: 14px; } .heroNote { width: 230px; padding: 22px; } .heroNote > strong { font-size: 23px; } .noteTop { font-size: 11px; } .desktopNav .vkuiIcon { display: none; } .desktopNav button { padding: 10px 11px; } .webTag { display: none; } }
|
||||
@media (max-width: 640px) { .app { padding: 0 20px; } .header { min-height: 132px; height: auto; flex-wrap: wrap; gap: 9px; padding: 18px 0 14px; } .brand { gap: 8px; } .brand img { width: 34px; height: 34px; } .brand > span:first-of-type { font-size: 25px; } .webTag { display: block; font-size: 11px; } .headerActions { margin-left: auto; gap: 2px; } .desktopNav { order: 3; width: 100%; justify-content: center; } .desktopNav button { flex: 1; justify-content: center; font-size: 12px; min-height: 38px; padding: 8px; } .desktopNav .vkuiIcon { display: block; } .hero { padding: 32px 0 27px; gap: 23px; flex-direction: column; align-items: stretch; } .hero h1 { font-size: clamp(35px, 8.3vw, 45px); margin-top: 15px; letter-spacing: -1.1px; } .heroCopy > p { font-size: 14px; max-width: 400px; } .heroNote { width: 100%; min-height: 0; transform: rotate(0); padding: 19px 21px; border-radius: 22px; } .noteTop { margin-bottom: 12px; } .heroNote > strong { font-size: 23px; } .heroNote > p { max-width: none; } .noteBottom { margin-top: 16px; font-size: 11px; } .heroStar { width: 165px; height: 165px; right: -25px; bottom: -44px; } .updateBar { padding-bottom: 15px; font-size: 11px; gap: 7px; } .refreshButton { font-size: 11px; } .servicesCard { padding: 22px 18px 19px; border-radius: 25px; } .servicesHeading { align-items: flex-start; padding-bottom: 16px; gap: 10px; } .servicesHeading h2 { font-size: 20px; } .servicesHeading p { font-size: 10px; margin-top: 5px; } .rangePicker { padding: 3px; border-radius: 12px; gap: 1px; } .rangePicker button { padding: 6px 8px; min-height: 34px; font-size: 11px; } .serviceRow { padding: 18px 0 16px; } .serviceHeading { flex-wrap: wrap; gap: 8px; } .serviceIcon { width: 36px; height: 36px; border-radius: 11px; } .serviceIcon > .vkuiIcon { width: 21px; height: 21px; } .serviceTitle h3 button { font-size: 13px; } .serviceTitle p { font-size: 10px; line-height: 1.55; } .serviceTitle h3 .vkuiIcon { display: none; } .statusBadge { font-size: 9px; padding: 4px 6px; gap: 4px; } .statusBadge .statusDot { width: 5px; height: 5px; } .serviceHeading > .statusBadge { margin-left: 44px; } .serviceTitle { flex-basis: calc(100% - 44px); } .serviceHistory { margin-top: 7px; } .historyBars { gap: 3px; } .dayBar span { border-radius: 2px; } .serviceMetrics { font-size: 10px; } .legend { gap: 7px 12px; font-size: 9px; } .dashboardSide { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 13px; } .metricCard { border-radius: 23px; padding: 19px 17px; } .metricLabel { gap: 5px; font-size: 11px; } .metricLabel > .vkuiIcon { width: 17px; height: 17px; } .metricCard > strong { font-size: 31px; margin: 12px 0 8px; letter-spacing: -1px; } .metricCard > strong > span { font-size: 12px; margin-left: 4px; } .metricCard p { font-size: 10px; } .metricFoot { font-size: 9px; line-height: 1.6; padding-top: 12px; } .explainCard { grid-column: 1 / -1; padding: 17px 19px; display: flex; align-items: center; gap: 13px; flex-wrap: wrap; border-radius: 22px; } .explainIcon { margin-bottom: 0; } .explainCard h3 { font-size: 15px; } .explainCard p { display: none; } .explainCard .textButton { margin-left: auto; font-size: 11px; } .incidentsSection { margin-top: 30px; } .sectionHeading h2 { font-size: 19px; } .sectionHeading .textButton { font-size: 11px; } .emptyState { padding: 29px 18px; border-radius: 23px; } .emptyState h3 { font-size: 14px; } .emptyState p { font-size: 12px; } .tripBanner { flex-wrap: wrap; padding: 20px; border-radius: 23px; gap: 12px; margin-bottom: 29px; } .tripBanner h3 { font-size: 16px; } .tripBanner p { font-size: 11px; } .tripBanner > div { flex: 1; } .tripBanner .primaryButton { margin-left: 61px; font-size: 11px; } .notice { flex-wrap: wrap; align-items: flex-start; gap: 9px; padding: 14px; } .notice p { flex-basis: calc(100% - 31px); font-size: 11px; } .notice button { margin-left: 29px; font-size: 11px; } .pageHeading { padding: 12px 0 22px; } .pageHeading h2 { font-size: 28px; } .pageHeading > p { font-size: 13px; } .methodGrid { grid-template-columns: 1fr; gap: 14px; } .methodCard { padding: 25px; border-radius: 25px; } .methodCard h3 { font-size: 18px; } .scopeCard { padding: 20px; gap: 12px; } .scopeCard h3 { font-size: 16px; } .scopeCard p { font-size: 12px; } .incidentCard { padding: 19px 20px; border-radius: 22px; } .incidentTop { flex-wrap: wrap; gap: 8px; } .incidentCard h3 { font-size: 15px; } .incidentCard > p { font-size: 12px; } .incidentMeta { font-size: 10px; } .dialogContent { padding: 22px; } .serviceDialog { border-radius: 23px; } .serviceDialog h2 { font-size: 23px; } .detailStats { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } .detailStats > div:last-child { grid-column: 1 / -1; } .loadingState { min-height: 300px; font-size: 12px; } }
|
||||
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } *, *::before, *::after { animation: none !important; transition: none !important; } }
|
||||
@media (max-width: 640px) {
|
||||
.desktopNav button { min-width: 0; gap: 5px; padding: 8px 5px; font-size: 11px; }
|
||||
.desktopNav button > span { line-height: 1.3; }
|
||||
.desktopNav button > .vkuiIcon { width: 16px; height: 16px; }
|
||||
.maintenanceCard { padding: 20px; border-radius: 23px; }
|
||||
.maintenanceCard h3 { font-size: 16px; }
|
||||
.maintenanceCard > p { font-size: 12px; }
|
||||
.maintenanceWindow { font-size: 11px; align-items: flex-start; }
|
||||
.maintenanceServices { flex-direction: column; }
|
||||
.availabilityScope { flex-wrap: wrap; }
|
||||
.scenarioSection { padding: 15px; }
|
||||
.scenarioSteps li { font-size: 11px; gap: 8px; }
|
||||
}
|
||||
.impactNotice { display: flex; gap: 11px; margin: 18px 0; padding: 17px; border-radius: 18px; color: var(--ink); background: var(--danger-soft); font-size: 13px; }
|
||||
.impactNotice > svg { flex-shrink: 0; color: var(--error); margin-top: 2px; }
|
||||
.impactNotice strong, .incidentImpact strong { font-size: 12px; font-weight: 800; }
|
||||
.impactNotice p { margin-top: 5px; line-height: 1.65; }
|
||||
.impactNotice.compact { margin: 13px 0 0 43px; padding: 12px 15px; font-size: 12px; }
|
||||
.impactAdvice { color: var(--muted); }
|
||||
.impactAlternatives { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 12px; }
|
||||
.impactAlternatives a { display: inline-flex; align-items: center; gap: 5px; color: var(--blue); font-weight: 750; }
|
||||
.incidentImpact { margin-top: 15px; padding: 13px 16px; border-radius: 16px; background: var(--lavender); }
|
||||
.incidentImpact p { font-size: 13px; margin-top: 4px; color: var(--muted); }
|
||||
.incidentTimeline { margin-top: 18px; border-top: 1px solid var(--line); }
|
||||
.incidentTimeline summary { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; cursor: pointer; color: var(--blue); font-size: 13px; font-weight: 750; padding: 15px 0 2px; min-height: 44px; }
|
||||
.incidentTimeline summary::after { content: '+'; margin-left: auto; font-size: 20px; }
|
||||
.incidentTimeline[open] summary::after { content: '−'; }
|
||||
.incidentTimeline summary span { font-size: 11px; font-weight: 500; color: var(--muted); }
|
||||
.incidentTimeline ol { list-style: none; padding: 7px 0 0 18px; }
|
||||
.incidentTimeline li { position: relative; padding: 15px 0 15px 24px; border-left: 1px solid var(--line); }
|
||||
.incidentTimeline li::before { content: ''; position: absolute; left: -5px; top: 20px; width: 9px; height: 9px; border-radius: 50%; background: var(--blue); box-shadow: 0 0 0 4px var(--surface); }
|
||||
.incidentTimeline li.monitor::before { background: var(--muted); }
|
||||
.timelineMeta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 14px; font-size: 11px; color: var(--muted); }
|
||||
.incidentTimeline h4 { font-size: 13px; margin: 5px 0; }
|
||||
.incidentTimeline p { font-size: 13px; color: var(--muted); white-space: pre-line; overflow-wrap: anywhere; }
|
||||
.incidentCard, .maintenanceCard { scroll-margin-top: 22px; }
|
||||
.subscribeLink { margin-top: 16px; }
|
||||
.subscriptionCard { padding: 30px; border-radius: 28px; background: var(--surface); border: 1px solid var(--line); max-width: 880px; }
|
||||
.subscriptionIntro { display: flex; gap: 15px; align-items: center; margin-bottom: 26px; }
|
||||
.subscriptionIntro p { font-size: 13px; color: var(--muted); }
|
||||
.subscriptionIntro .explainIcon { flex-shrink: 0; margin: 0; }
|
||||
.subscriptionCard fieldset { border: 0; padding: 0; margin: 24px 0; min-width: 0; }
|
||||
.subscriptionCard legend { font-size: 15px; font-weight: 800; margin-bottom: 12px; }
|
||||
.feedChoices { display: flex; flex-wrap: wrap; gap: 9px; margin-bottom: 13px; }
|
||||
.feedChoices label { display: inline-flex; align-items: center; gap: 9px; min-height: 46px; padding: 10px 15px; border-radius: 15px; background: var(--lavender); color: var(--blue); font-size: 13px; cursor: pointer; }
|
||||
.feedChoices label:has(input:checked) { background: var(--blue-soft); }
|
||||
.feedChoices input { width: 17px; height: 17px; accent-color: var(--blue); flex-shrink: 0; }
|
||||
.feedUrl { display: block; margin: 23px 0 14px; }
|
||||
.feedUrl span { display: block; font-size: 12px; font-weight: 750; margin-bottom: 8px; }
|
||||
.feedUrl input { width: 100%; min-width: 0; padding: 13px 15px; border-radius: 14px; background: var(--page); color: var(--ink); border: 1px solid var(--line); font-size: 13px; }
|
||||
.feedActions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
|
||||
.feedActions button, .feedActions a { display: inline-flex; align-items: center; gap: 9px; min-height: 46px; }
|
||||
.secondaryButton { padding: 12px 20px; border: 0; border-radius: 28px; background: var(--lavender); color: var(--blue); font-size: 13px; font-weight: 750; }
|
||||
.feedValidation { font-size: 13px; color: var(--error); }
|
||||
.feedStatus { margin-top: 15px; font-size: 12px; color: var(--muted); min-height: 18px; }
|
||||
@media (max-width: 600px) {
|
||||
.impactNotice.compact { margin-left: 0; }
|
||||
.subscriptionCard { padding: 22px 18px; }
|
||||
.subscriptionIntro { align-items: flex-start; }
|
||||
.feedChoices { display: grid; grid-template-columns: minmax(0, 1fr); }
|
||||
.feedActions > * { justify-content: center; flex: 1 1 auto; }
|
||||
}
|
||||
|
||||
@include motion.components;
|
||||
|
||||
.statisticsSection { margin-top: 38px; margin-bottom: 34px; }
|
||||
.statisticsSection .sectionHeading > div > p { font-size: 13px; color: var(--muted); margin-top: 5px; }
|
||||
.statisticsRange { font-size: 12px; color: var(--muted); white-space: nowrap; }
|
||||
.statisticsCard { padding: 28px; border-radius: 28px; background: var(--surface); border: 1px solid var(--line); }
|
||||
.servicePicker { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 24px; }
|
||||
.servicePicker button { padding: 9px 16px; min-height: 40px; border: 0; border-radius: 24px; font-size: 13px; font-weight: 750; background: var(--lavender); color: var(--blue); }
|
||||
.servicePicker button.selected { background: var(--blue); color: var(--on-accent); }
|
||||
.reliabilityGrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 0; }
|
||||
.reliabilityGrid > div { padding: 19px; border-radius: 19px; background: var(--lavender); }
|
||||
.reliabilityGrid dt { font-size: 12px; color: var(--muted); }
|
||||
.reliabilityGrid dd { margin: 7px 0 0; font-size: 26px; font-weight: 800; letter-spacing: -.6px; line-height: 1.4; }
|
||||
.reliabilityGrid .recoveryTime { font-size: 20px; letter-spacing: -.3px; }
|
||||
.reliabilityGrid small { display: block; margin-top: 6px; font-size: 11px; color: var(--muted); }
|
||||
.statisticsCoverage { color: var(--muted); font-size: 11px; margin-top: 13px; }
|
||||
.statisticsMethod { font-size: 12px; color: var(--muted); line-height: 1.8; margin-top: 10px; }
|
||||
.latencyHeading { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--line); }
|
||||
.latencyHeading h3 { display: flex; align-items: center; gap: 9px; font-size: 16px; }
|
||||
.latencyHeading .incidentFilters { margin: 0; }
|
||||
.latencyHeading .incidentFilters button { font-size: 12px; padding: 9px 16px; }
|
||||
.latencySummary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; margin: 24px 0 13px; }
|
||||
.latencySummary span { display: block; color: var(--muted); font-size: 12px; }
|
||||
.latencySummary strong { display: block; font-size: 22px; margin-top: 5px; }
|
||||
.latencySummary small { display: block; font-size: 11px; color: var(--muted); margin-top: 3px; }
|
||||
.latencyChange.slower strong { color: var(--error); }
|
||||
.latencyChange.faster strong { color: var(--green-ink); }
|
||||
.chartScrubber { width: 100%; margin: 14px 0 4px; accent-color: var(--blue); cursor: pointer; }
|
||||
.chartReadout { display: flex; align-items: center; flex-wrap: wrap; gap: 5px 12px; min-height: 32px; color: var(--muted); font-size: 12px; }
|
||||
.chartReadout strong { color: var(--blue); font-weight: 750; }
|
||||
.statisticsFoot { display: flex; align-items: center; justify-content: space-between; gap: 15px; border-top: 1px solid var(--line); margin-top: 19px; padding-top: 17px; font-size: 12px; color: var(--muted); }
|
||||
.serviceDialog .reliabilityGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; }
|
||||
.serviceDialog .reliabilityGrid > div:last-child { grid-column: 1 / -1; }
|
||||
.serviceDialog .reliabilityGrid dd { font-size: 22px; }
|
||||
.monitoringLinks { display: flex; align-items: center; flex-wrap: wrap; gap: 14px 28px; margin-bottom: 35px; font-size: 14px; color: var(--blue); }
|
||||
.monitoringLinks > a { display: inline-flex; align-items: center; gap: 6px; }
|
||||
@media (max-width: 760px) { .footer { flex-wrap: wrap; padding: 24px 0 20px; } .footer > div { gap: 18px; flex-wrap: wrap; } }
|
||||
@media (max-width: 640px) {
|
||||
.statisticsCard { padding: 22px 18px; border-radius: 25px; }
|
||||
.servicePicker { gap: 7px; }
|
||||
.servicePicker button { font-size: 12px; padding: 8px 12px; }
|
||||
.reliabilityGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; }
|
||||
.reliabilityGrid > div { padding: 15px; }
|
||||
.reliabilityGrid > div:last-child { grid-column: 1 / -1; }
|
||||
.reliabilityGrid dt { font-size: 11px; }
|
||||
.reliabilityGrid dd { font-size: 20px; }
|
||||
.reliabilityGrid .recoveryTime { font-size: 19px; }
|
||||
.latencyHeading { align-items: flex-start; flex-direction: column; gap: 12px; }
|
||||
.latencySummary { gap: 13px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.latencySummary > div:last-child { grid-column: 1 / -1; }
|
||||
.latencySummary span { font-size: 11px; }
|
||||
.latencySummary strong { font-size: 20px; }
|
||||
.chartReadout { font-size: 11px; }
|
||||
}
|
||||
@media (max-width: 360px) { .footer > div { gap: 12px; } }
|
||||
29
src/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type Locale = 'ru' | 'en';
|
||||
export type Status = 'operational' | 'degraded' | 'down' | 'unknown' | 'maintenance';
|
||||
export type Scenario = { type: 'api' | 'pwa' | 'board'; status: Status; lastCheckedAt: number | null; staleAfterSeconds: number; intervalSeconds: number; stale: boolean; reason: string | null; mode: 'http' | 'browser'; pending: boolean; steps: { id: string; status: 'passed' | 'failed' | 'waiting'; latencyMs: number | null; httpCode: number | null }[] };
|
||||
export type Maintenance = { id: string; title: Localized; message: Localized; serviceIds: string[]; startsAt: number; endsAt: number; createdAt: number; cancelledAt: number | null; completedAt: number | null; phase: 'scheduled' | 'active' | 'ended' | 'completed' | 'cancelled' };
|
||||
export type Localized = Record<Locale, string>;
|
||||
export type ImpactPolicy = { availability?: Localized; scenario?: Localized; advice?: Localized; fallbacks?: string[] };
|
||||
export type Impact = { scope: 'availability' | 'scenario'; message: Localized; advice: Localized | null; alternatives: { id: string; name: Localized; url: string }[] };
|
||||
export type IncidentStage = 'detected' | 'reported' | 'investigating' | 'identified' | 'monitoring' | 'resolved' | 'postmortem';
|
||||
export type IncidentUpdate = { id: string; source: 'manual' | 'monitor'; stage: IncidentStage; createdAt: number; message: Localized };
|
||||
export type Day = { date: string; status: Status; checks: number; uptime: number | null; latencyMs: number | null; coverage: number };
|
||||
export type ResponsePoint = { time: number; checks: number; uptime: number | null; latencyMs: number | null };
|
||||
export type Reliability = { downtimeMs: number | null; longestDowntimeMs: number | null; incidentCount: number; downtimeIncomplete: boolean; lastRecoveryAt: number | null; lastRecoveryScope: 'availability' | 'scenario' | null; observedMs: number; coverage: number; since: number; asOf: number };
|
||||
export type LatencyStats = { averageMs: number | null; p95Ms: number | null; samples: number; previousAverageMs: number | null; changePercent: number | null };
|
||||
export type Service = {
|
||||
id: string; icon: string; name: Localized; description: Localized; publicUrl: string;
|
||||
kind: 'html' | 'json' | 'http'; functional: boolean; configured: boolean;
|
||||
impactPolicy: ImpactPolicy | null; impact: Impact | null;
|
||||
status: Status; availabilityStatus: Status; scenario: Scenario | null; activeMaintenance: string[]; stale: boolean; pending: boolean; lastCheckedAt: number | null;
|
||||
latencyMs: number | null; avgLatencyMs: number | null; httpCode: number | null; reason: string | null;
|
||||
uptime: number | null; checks: number; history: Day[]; responseHistory: ResponsePoint[];
|
||||
reliability: Reliability; latency: LatencyStats; latency24h: LatencyStats; latencyHistory: ResponsePoint[];
|
||||
recentChecks: { time: number; ok: boolean; latencyMs: number | null; httpCode: number | null; reason: string | null }[];
|
||||
};
|
||||
export type Incident = { id: string; source: 'manual' | 'monitor'; scope: 'availability' | 'scenario'; serviceId: string | null; startedAt: number; resolvedAt: number | null; title: Localized; message: Localized; impact: Localized | null; updates: IncidentUpdate[] };
|
||||
export type Snapshot = {
|
||||
generatedAt: number; lastCheckAt: number | null; intervalSeconds: number; staleAfterSeconds: number;
|
||||
failureThreshold: number; recoveryThreshold: number; slowMs: number; range: number; demo: boolean;
|
||||
overall: Status; services: Service[]; incidents: Incident[]; maintenance: Maintenance[]; scenarioIntervalSeconds: number;
|
||||
};
|
||||
99
test/board-scenario.test.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { loadConfig } from '../server/config.mjs';
|
||||
import { createScenarioRunner } from '../server/scenarios.mjs';
|
||||
|
||||
const cookie = 'ridebus_board_device=test-device-secret';
|
||||
const publicSettings = { cityId: 1, defaultStopId: 1, configured: true };
|
||||
const managedSettings = { cityId: 2, defaultStopId: 28, configured: true };
|
||||
|
||||
function fixture({ device = { enabled: true, state: 'paired', config: managedSettings }, deviceStatus = 200, env = { STATUS_BOARD_TEST_COOKIE: cookie } } = {}) {
|
||||
const config = loadConfig(env);
|
||||
const service = config.services.find(item => item.id === 'board');
|
||||
const requests = [], renders = [];
|
||||
const runner = createScenarioRunner(config, {
|
||||
fetchImpl: async (url, options) => {
|
||||
assert.equal(options.method || 'GET', 'GET');
|
||||
const { pathname, search } = new URL(url);
|
||||
requests.push({ path: pathname + search, cookie: options.headers.Cookie });
|
||||
if (pathname === '/api/config') return Response.json(publicSettings);
|
||||
if (pathname === '/api/device/status') return Response.json(device, { status: deviceStatus });
|
||||
if (pathname === '/api/stops') return Response.json([{ id: 1, name: 'Default stop' }, { id: 28, name: 'Assigned stop' }, { id: 99, name: 'Override stop' }]);
|
||||
if (pathname === '/api/board') {
|
||||
const stopId = Number(new URL(url).searchParams.get('stopId'));
|
||||
if (device.enabled && env.STATUS_BOARD_TEST_COOKIE && stopId !== managedSettings.defaultStopId) return Response.json({ error: 'settings_changed' }, { status: 409 });
|
||||
return Response.json({ mode: 'live', stop: { id: stopId }, departures: [], partial: false });
|
||||
}
|
||||
throw new Error('Unexpected request');
|
||||
},
|
||||
browserChecks: { run: async (_spec, target) => { renders.push({ ...target }); return { ok: true, latencyMs: 20, httpCode: 200 }; }, close: async () => {} },
|
||||
});
|
||||
return { runner, service, requests, renders };
|
||||
}
|
||||
|
||||
test('paired board uses its assigned city and stop instead of public defaults or env override', async () => {
|
||||
const item = fixture({ env: { STATUS_BOARD_TEST_COOKIE: cookie, STATUS_TEST_CITY_ID: '1', STATUS_BOARD_TEST_STOP_ID: '99' } });
|
||||
try {
|
||||
const result = await item.runner.run(item.service);
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(item.renders, [{ cityId: 2, stopId: 28 }]);
|
||||
assert.deepEqual(item.requests.map(value => value.path), ['/api/config', '/api/device/status', '/api/stops', '/api/board?stopId=28']);
|
||||
assert.ok(item.requests.every(value => value.cookie === cookie));
|
||||
assert.deepEqual(result.steps.map(value => value.id), ['config', 'device', 'stops', 'departures', 'render']);
|
||||
assert.doesNotMatch(JSON.stringify(result), /test-device-secret|Assigned stop/);
|
||||
} finally { await item.runner.close(); }
|
||||
});
|
||||
|
||||
test('standalone board keeps its explicit test stop and needs no device endpoint', async () => {
|
||||
const item = fixture({ env: { STATUS_BOARD_TEST_STOP_ID: '99' } });
|
||||
try {
|
||||
assert.equal((await item.runner.run(item.service)).ok, true);
|
||||
assert.deepEqual(item.renders, [{ cityId: 1, stopId: 99 }]);
|
||||
assert.ok(item.requests.every(value => value.path !== '/api/device/status' && value.cookie === undefined));
|
||||
} finally { await item.runner.close(); }
|
||||
});
|
||||
|
||||
test('cookie on a board with remote settings disabled retains standalone configuration', async () => {
|
||||
const item = fixture({ device: { enabled: false } });
|
||||
try {
|
||||
assert.equal((await item.runner.run(item.service)).ok, true);
|
||||
assert.deepEqual(item.renders, [{ cityId: 1, stopId: 1 }]);
|
||||
} finally { await item.runner.close(); }
|
||||
});
|
||||
|
||||
test('unpaired, pending and expired test sessions are missing access rather than outages', async () => {
|
||||
for (const state of ['unpaired', 'pending', 'expired']) {
|
||||
const item = fixture({ device: { enabled: true, state } });
|
||||
try {
|
||||
const result = await item.runner.run(item.service);
|
||||
assert.equal(result.ok, null);
|
||||
assert.equal(result.reason, 'auth_required');
|
||||
assert.equal(result.steps.at(-1).id, 'device');
|
||||
assert.equal(result.steps.at(-1).status, 'failed');
|
||||
assert.equal(item.renders.length, 0);
|
||||
assert.equal(item.requests.length, 2);
|
||||
} finally { await item.runner.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('revoked device access is unknown while malformed paired configuration remains a failure', async () => {
|
||||
for (const options of [{ device: { error: 'device_revoked' }, deviceStatus: 410 }, { device: { enabled: true, state: 'paired', config: { ...managedSettings, defaultStopId: null } } }]) {
|
||||
const item = fixture(options);
|
||||
try {
|
||||
const result = await item.runner.run(item.service);
|
||||
assert.equal(result.ok, options.deviceStatus === 410 ? null : false);
|
||||
assert.equal(result.reason, options.deviceStatus === 410 ? 'auth_required' : 'content');
|
||||
assert.equal(item.renders.length, 0);
|
||||
} finally { await item.runner.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('only board scenario history is separated from the old public-default probe', () => {
|
||||
for (const service of loadConfig({ STATUS_BOARD_TEST_COOKIE: cookie }).services.filter(item => item.scenario)) {
|
||||
const { signature: _signature, ...scenario } = service.scenario;
|
||||
const previousSignature = createHash('sha256').update(JSON.stringify({ ...scenario, apiKey: Boolean(scenario.apiKey), cookie: Boolean(scenario.cookie) })).digest('hex').slice(0, 24);
|
||||
if (service.id === 'board') assert.notEqual(service.scenario.signature, previousSignature);
|
||||
else assert.equal(service.scenario.signature, previousSignature);
|
||||
}
|
||||
});
|
||||
70
test/browser-checks.test.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { chromium } from 'playwright';
|
||||
import { compile } from 'sass';
|
||||
import { loadConfig } from '../server/config.mjs';
|
||||
import { createBrowserChecks } from '../server/browser-checks.mjs';
|
||||
|
||||
const available = existsSync('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome') || existsSync(chromium.executablePath());
|
||||
|
||||
test('real browser journey blocks writes, applies reduced motion and handles night-time empty data', { skip: !available }, async () => {
|
||||
let writes = 0, observedMotion = null;
|
||||
const css = compile('src/styles.scss').css;
|
||||
const fixture = createServer((req, res) => {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
if (req.method !== 'GET') { writes++; res.end('write'); return; }
|
||||
if (url.pathname === '/api/board') { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ departures: [] })); return; }
|
||||
if (url.pathname === '/motion-report') { observedMotion = url.searchParams.get('motion'); res.end('ok'); return; }
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.end(`<!doctype html><html><head><style>${css}</style></head><body><div class="board"><header class="board-header"><h1>Test stop</h1></header></div><section class="servicesCard">Test</section><script>
|
||||
(async()=>{ await fetch('/api/device/pair',{method:'POST'}).catch(()=>{}); await fetch('/api/board?stopId=1').then(r=>r.json()); await fetch('/motion-report?motion='+getComputedStyle(document.querySelector('.servicesCard')).animationName); const main=document.createElement('main');main.className='scene-container';main.textContent='No departures tonight';document.querySelector('.board').append(main); })();
|
||||
</script></body></html>`);
|
||||
});
|
||||
await new Promise((resolve, reject) => { fixture.once('error', reject); fixture.listen(0, '127.0.0.1', resolve); });
|
||||
const config = loadConfig({ STATUS_SCENARIO_TIMEOUT_MS: '2000' });
|
||||
const browser = createBrowserChecks(config);
|
||||
try {
|
||||
const result = await browser.run({ type: 'board', baseUrl: `http://127.0.0.1:${fixture.address().port}`, cookie: null }, { cityId: 1, stopId: 1 });
|
||||
assert.equal(result.ok, true); assert.equal(writes, 0); assert.equal(observedMotion, 'none');
|
||||
} finally { await browser.close(); fixture.closeIdleConnections(); await new Promise(resolve => fixture.close(resolve)); }
|
||||
});
|
||||
|
||||
test('paired board browser uses real cookies while writes and requests to another origin are blocked', { skip: !available }, async () => {
|
||||
const cookie = 'ridebus_board_device=test-session-value';
|
||||
let authenticatedReads = 0, unauthenticatedReads = 0, writes = 0, otherOriginRequests = 0;
|
||||
const otherOrigin = createServer((_req, res) => { otherOriginRequests++; res.end('unexpected'); });
|
||||
await new Promise(resolve => otherOrigin.listen(0, '127.0.0.1', resolve));
|
||||
const fixture = createServer((req, res) => {
|
||||
if (req.method !== 'GET') { writes++; res.end('write'); return; }
|
||||
if (req.url.startsWith('/api/')) {
|
||||
if (req.headers.cookie !== cookie) { unauthenticatedReads++; res.writeHead(401); res.end('{}'); return; }
|
||||
authenticatedReads++; res.setHeader('Content-Type', 'application/json'); res.end('{}'); return;
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.end(`<!doctype html><html><body><div class="device-setup">Connecting</div><script>
|
||||
(async()=>{
|
||||
await fetch('/api/device/heartbeat',{method:'POST'}).catch(()=>{});
|
||||
await fetch('http://127.0.0.1:${otherOrigin.address().port}/api/private').catch(()=>{});
|
||||
if(!(await fetch('/api/device/status')).ok)return;
|
||||
if(!(await fetch('/api/board?stopId=28')).ok)return;
|
||||
document.body.innerHTML='<div class="board"><header class="board-header"><h1>Assigned stop</h1></header><main class="scene-container">Live departures</main></div>';
|
||||
})();
|
||||
</script></body></html>`);
|
||||
});
|
||||
await new Promise(resolve => fixture.listen(0, '127.0.0.1', resolve));
|
||||
const browser = createBrowserChecks(loadConfig({ STATUS_SCENARIO_TIMEOUT_MS: '3000' }));
|
||||
try {
|
||||
const result = await browser.run({ type: 'board', baseUrl: `http://127.0.0.1:${fixture.address().port}`, cookie }, { cityId: 1, stopId: 28 });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(authenticatedReads, 2);
|
||||
assert.equal(unauthenticatedReads, 0);
|
||||
assert.equal(writes, 0);
|
||||
assert.equal(otherOriginRequests, 0);
|
||||
} finally {
|
||||
await browser.close();
|
||||
fixture.closeAllConnections(); otherOrigin.closeAllConnections();
|
||||
await Promise.all([new Promise(resolve => fixture.close(resolve)), new Promise(resolve => otherOrigin.close(resolve))]);
|
||||
}
|
||||
});
|
||||
178
test/monitor.test.mjs
Normal file
@@ -0,0 +1,178 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadConfig } from '../server/config.mjs';
|
||||
import { runProbe, checkService, nextState } from '../server/probe.mjs';
|
||||
import { openStore } from '../server/store.mjs';
|
||||
import { makeSnapshot, summarize, overallStatus } from '../server/snapshot.mjs';
|
||||
import { createMonitor } from '../server/monitor.mjs';
|
||||
import { demoSnapshot } from '../server/demo.mjs';
|
||||
|
||||
const config = loadConfig({});
|
||||
const service = config.services[0];
|
||||
const healthy = { ok: true, latencyMs: 120, httpCode: 200, reason: null };
|
||||
const failed = { ok: false, latencyMs: null, httpCode: 503, reason: 'http' };
|
||||
const fetchHtml = async () => new Response('<!doctype html><html><title>RideBus</title></html>', { headers: { 'content-type': 'text/html' } });
|
||||
|
||||
test('configured services match the five RideBus domains and remain server-side', () => {
|
||||
assert.equal(config.services.length, 5);
|
||||
assert.deepEqual(config.services.map(item => new URL(item.publicUrl).hostname).sort(), ['admin.ridebus.by', 'api.ridebus.by', 'board.ridebus.by', 'pwa.ridebus.by', 'ridebus.by']);
|
||||
const keyed = loadConfig({ RIDEBUS_API_KEY: 'test-secret' });
|
||||
assert.equal(keyed.services[0].probes.length, 2);
|
||||
assert.equal(keyed.services[0].probes[1].headers['X-API-Key'], 'test-secret');
|
||||
assert.equal(loadConfig({ STATUS_API_URL: 'off' }).services[0].probes.length, 0);
|
||||
assert.throws(() => loadConfig({ STATUS_INTERVAL_SECONDS: '0' }));
|
||||
assert.throws(() => loadConfig({ STATUS_API_URL: 'https://user:secret@example.com' }));
|
||||
});
|
||||
|
||||
test('HTML probes check content as well as HTTP status', async () => {
|
||||
const probe = { url: 'https://example.com', kind: 'html', headers: {} };
|
||||
assert.equal((await runProbe(probe, { timeoutMs: 100, fetchImpl: fetchHtml })).ok, true);
|
||||
const invalid = await runProbe(probe, { timeoutMs: 100, fetchImpl: async () => new Response('{}', { headers: { 'content-type': 'application/json' } }) });
|
||||
assert.equal(invalid.reason, 'content');
|
||||
const failure = await runProbe(probe, { timeoutMs: 100, fetchImpl: async () => new Response('gateway', { status: 503 }) });
|
||||
assert.equal(failure.reason, 'http'); assert.equal(failure.httpCode, 503);
|
||||
});
|
||||
|
||||
test('API probes reject invalid health JSON and require data in the optional functional probe', async () => {
|
||||
assert.equal((await runProbe(service.probes[0], { timeoutMs: 100, fetchImpl: async () => Response.json({ status: 'healthy' }) })).ok, true);
|
||||
assert.equal((await runProbe(service.probes[0], { timeoutMs: 100, fetchImpl: async () => Response.json({ status: 'unhealthy' }) })).reason, 'content');
|
||||
const keyed = loadConfig({ RIDEBUS_API_KEY: 'test-secret' }).services[0];
|
||||
const result = await checkService(keyed, { timeoutMs: 100, fetchImpl: async url => url.endsWith('/health') ? Response.json({ status: 'healthy' }) : Response.json([]) });
|
||||
assert.equal(result.ok, false); assert.equal(result.reason, 'content');
|
||||
});
|
||||
|
||||
test('timeouts and network errors are sanitized without exposing URLs or secrets', async () => {
|
||||
const probe = { url: 'https://example.com?secret=value', kind: 'http', headers: {} };
|
||||
const timeout = await runProbe(probe, { timeoutMs: 15, fetchImpl: (_url, { signal }) => new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(new Error('private timeout detail')))) });
|
||||
assert.equal(timeout.reason, 'timeout');
|
||||
const network = await runProbe(probe, { timeoutMs: 100, fetchImpl: async () => { throw new Error('secret credentials and URL'); } });
|
||||
assert.equal(network.reason, 'network'); assert.doesNotMatch(JSON.stringify(network), /secret|credentials/);
|
||||
});
|
||||
|
||||
test('custom credentials are removed on cross-origin redirects', async () => {
|
||||
const calls = [];
|
||||
const result = await runProbe({ url: 'https://api.example.com/health', kind: 'json', headers: { 'X-API-Key': 'secret' } }, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async (url, options) => { calls.push({ url, headers: options.headers }); return calls.length === 1 ? new Response(null, { status: 302, headers: { location: 'https://other.example.com/health' } }) : Response.json({ status: 'healthy' }); },
|
||||
});
|
||||
assert.equal(result.ok, true); assert.equal(calls[0].headers['X-API-Key'], 'secret'); assert.equal(calls[1].headers['X-API-Key'], undefined);
|
||||
});
|
||||
|
||||
test('a very large response is rejected rather than kept in memory', async () => {
|
||||
const result = await runProbe(service.probes[0], { timeoutMs: 100, fetchImpl: async () => new Response('x'.repeat(1024 * 1024 + 1)) });
|
||||
assert.equal(result.reason, 'content');
|
||||
});
|
||||
|
||||
test('outage confirmation and recovery survive across checks without claiming a failed probe is healthy', () => {
|
||||
let state = nextState(null, healthy, config);
|
||||
assert.equal(state.status, 'operational');
|
||||
state = nextState(state, failed, config); assert.equal(state.status, 'degraded');
|
||||
state = nextState(state, failed, config); assert.equal(state.status, 'degraded');
|
||||
state = nextState(state, failed, config); assert.equal(state.status, 'down');
|
||||
state = nextState(state, healthy, config); assert.equal(state.status, 'down');
|
||||
state = nextState(state, healthy, config); assert.equal(state.status, 'operational');
|
||||
assert.equal(nextState(state, { ...healthy, latencyMs: 2000 }, config).status, 'degraded');
|
||||
});
|
||||
|
||||
test('SQLite persists history and creates one incident only after confirmation, then resolves it', () => {
|
||||
const folder = mkdtempSync(join(tmpdir(), 'ridebus-status-store-'));
|
||||
const file = join(folder, 'status.sqlite');
|
||||
let store = openStore(file);
|
||||
try {
|
||||
for (let index = 1; index <= 3; index++) store.record(service, failed, nextState(store.state(service), failed, config), 1000 * index);
|
||||
assert.equal(store.incidents().length, 1); assert.equal(store.incidents()[0].resolvedAt, null);
|
||||
store.close(); store = openStore(file);
|
||||
assert.equal(store.state(service).status, 'down');
|
||||
store.record(service, healthy, nextState(store.state(service), healthy, config), 4000);
|
||||
assert.equal(store.incidents()[0].resolvedAt, null);
|
||||
store.record(service, healthy, nextState(store.state(service), healthy, config), 5000);
|
||||
assert.equal(store.incidents()[0].resolvedAt, 5000);
|
||||
assert.equal(store.checks(service, 0).length, 5);
|
||||
} finally { store.close(); rmSync(folder, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('stale results become unknown while historical gaps remain visible', () => {
|
||||
const store = openStore(':memory:');
|
||||
const now = Date.UTC(2026, 9, 12, 12);
|
||||
try {
|
||||
store.record(service, healthy, nextState(null, healthy, config), now - 181000);
|
||||
const snapshot = makeSnapshot(store, config, 30, now);
|
||||
assert.equal(snapshot.services[0].status, 'unknown'); assert.equal(snapshot.services[0].stale, true);
|
||||
assert.equal(snapshot.overall, 'unknown');
|
||||
assert.equal(snapshot.services[0].history[0].status, 'unknown'); assert.equal(snapshot.services[0].history[0].uptime, null);
|
||||
assert.equal(snapshot.services[0].history.at(-1).uptime, 100); assert.ok(snapshot.services[0].history.at(-1).coverage < 95);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('sample availability excludes unconfigured observations and does not fill missing hours', () => {
|
||||
assert.equal(summarize([]).uptime, null);
|
||||
const result = summarize([{ ok: 1, latencyMs: 120, reason: null }, { ok: 0, latencyMs: null, reason: 'http' }, { ok: 0, latencyMs: null, reason: 'unconfigured' }]);
|
||||
assert.equal(result.uptime, 50); assert.equal(result.checks, 2);
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
const now = Date.UTC(2026, 9, 12, 12);
|
||||
store.record(service, healthy, nextState(null, healthy, config), now);
|
||||
const item = makeSnapshot(store, config, 30, now).services[0];
|
||||
assert.equal(item.responseHistory[0].latencyMs, null); assert.equal(item.history[0].checks, 0);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('changing a probe target starts separate history and no sensitive config is published', () => {
|
||||
const keyedConfig = loadConfig({ RIDEBUS_API_KEY: 'not-public', STATUS_API_URL: 'https://api.ridebus.by/private-check?credential=hidden' });
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
store.record(service, healthy, nextState(null, healthy, config));
|
||||
const snapshot = makeSnapshot(store, keyedConfig);
|
||||
assert.equal(snapshot.services[0].checks, 0);
|
||||
assert.doesNotMatch(JSON.stringify(snapshot), /not-public|private-check|credential|hidden|X-API-Key/);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('database aggregation preserves sample availability, daily severity, latency and hour gaps', () => {
|
||||
const store = openStore(':memory:');
|
||||
const now = Date.UTC(2026, 9, 12, 12);
|
||||
try {
|
||||
store.record(service, healthy, { status: 'operational', failures: 0, successes: 1 }, now - 300000);
|
||||
store.record(service, failed, { status: 'degraded', failures: 1, successes: 0 }, now - 240000);
|
||||
const item = makeSnapshot(store, config, 30, now).services[0];
|
||||
assert.equal(item.uptime, 50); assert.equal(item.avgLatencyMs, 120);
|
||||
assert.equal(item.latencyMs, null); assert.equal(item.history.at(-1).status, 'degraded');
|
||||
assert.equal(item.history.at(-1).uptime, 50); assert.equal(item.responseHistory.at(-1).checks, 2);
|
||||
assert.equal(item.responseHistory[0].checks, 0); assert.equal(item.recentChecks.length, 2);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('overlapping monitoring cycles coalesce and perform only one check per service', async () => {
|
||||
const store = openStore(':memory:');
|
||||
let calls = 0, resolve;
|
||||
const result = new Promise(done => { resolve = done; });
|
||||
const monitor = createMonitor(store, config, { check: async () => { calls++; return result; } });
|
||||
try {
|
||||
const first = monitor.cycle(), second = monitor.cycle();
|
||||
assert.equal(calls, 5);
|
||||
resolve(healthy); await Promise.all([first, second]);
|
||||
for (const item of config.services) assert.equal(store.checks(item, 0).length, 1);
|
||||
} finally { await monitor.stop(); store.close(); }
|
||||
});
|
||||
|
||||
test('overall operational status requires fresh successful data for every service', () => {
|
||||
assert.equal(overallStatus([{ status: 'operational' }, { status: 'unknown' }]), 'unknown');
|
||||
assert.equal(overallStatus([{ status: 'operational' }, { status: 'down' }]), 'degraded');
|
||||
assert.equal(overallStatus([{ status: 'down' }, { status: 'down' }]), 'down');
|
||||
});
|
||||
|
||||
test('manual incidents have an explicit lifecycle and demo data is marked', () => {
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
const id = store.createIncident({ titleRu: 'Проверяем', titleEn: 'Investigating', messageRu: 'Сообщение', messageEn: 'Update' }, 1000);
|
||||
assert.equal(store.incidents()[0].source, 'manual');
|
||||
assert.equal(store.resolveIncident(id, 2000), true); assert.equal(store.resolveIncident(id, 3000), false);
|
||||
assert.equal(store.incidents()[0].resolvedAt, 2000);
|
||||
const demo = demoSnapshot(30);
|
||||
assert.equal(demo.demo, true); assert.equal(demo.services.length, 5);
|
||||
assert.equal(makeSnapshot(store, config).demo, false); assert.equal(makeSnapshot(store, config).services[0].checks, 0);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
226
test/next-stage.test.mjs
Normal file
@@ -0,0 +1,226 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadConfig } from '../server/config.mjs';
|
||||
import { openStore } from '../server/store.mjs';
|
||||
import { createMonitor } from '../server/monitor.mjs';
|
||||
import { nextState } from '../server/probe.mjs';
|
||||
import { makeSnapshot } from '../server/snapshot.mjs';
|
||||
import { affectsService, maintenancePhase, parseInstant, validateMaintenance } from '../server/maintenance.mjs';
|
||||
import { createScenarioRunner, validDepartures, validEta, validBoard } from '../server/scenarios.mjs';
|
||||
import { createNotifier, sendTelegram, notificationText } from '../server/notifications.mjs';
|
||||
|
||||
const config = loadConfig({});
|
||||
const service = config.services[0];
|
||||
const good = { ok: true, latencyMs: 120, httpCode: 200, reason: null };
|
||||
const bad = { ok: false, latencyMs: null, httpCode: 503, reason: 'http' };
|
||||
const windowInput = { titleRu: 'Работы', titleEn: 'Maintenance', messageRu: 'Обновление', messageEn: 'Update', serviceIds: ['api'], startsAt: 1000, endsAt: 5000 };
|
||||
|
||||
test('maintenance validates times, service IDs and explicit timezones', () => {
|
||||
assert.equal(validateMaintenance(windowInput, ['api']).endsAt, 5000);
|
||||
assert.throws(() => validateMaintenance({ ...windowInput, endsAt: 1000 }, ['api']));
|
||||
assert.throws(() => validateMaintenance({ ...windowInput, serviceIds: ['missing'] }, ['api']));
|
||||
assert.throws(() => parseInstant('2026-10-13T02:00:00'));
|
||||
assert.throws(() => parseInstant('2026-02-31T02:00:00+03:00'));
|
||||
assert.equal(parseInstant('2026-10-13T02:00:00+03:00'), Date.UTC(2026, 9, 12, 23));
|
||||
});
|
||||
|
||||
test('maintenance transitions at exact boundaries and early completion truncates its effect', () => {
|
||||
const item = { ...windowInput, cancelledAt: null, completedAt: null };
|
||||
assert.equal(maintenancePhase(item, 999), 'scheduled'); assert.equal(maintenancePhase(item, 1000), 'active');
|
||||
assert.equal(maintenancePhase(item, 5000), 'ended');
|
||||
assert.equal(affectsService(item, 'api', 4999), true); assert.equal(affectsService(item, 'website', 4999), false);
|
||||
const completed = { ...item, completedAt: 3000 };
|
||||
assert.equal(affectsService(completed, 'api', 2999), true); assert.equal(affectsService(completed, 'api', 3000), false);
|
||||
assert.equal(maintenancePhase(completed, 3000), 'completed');
|
||||
});
|
||||
|
||||
test('planned outages are stored honestly but open no incident or notification', () => {
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
store.createMaintenance(windowInput, 500);
|
||||
for (let time = 1000; time <= 3000; time += 1000) store.record(service, bad, nextState(store.state(service), bad, config), time, { notify: true });
|
||||
assert.equal(store.state(service).status, 'down'); assert.equal(store.incidents().length, 0); assert.equal(store.dueNotifications(3000).length, 0);
|
||||
const snapshot = makeSnapshot(store, config, 30, 3000);
|
||||
assert.equal(snapshot.services[0].status, 'maintenance'); assert.equal(snapshot.services[0].availabilityStatus, 'down');
|
||||
assert.equal(snapshot.services[0].uptime, 0); assert.equal(snapshot.maintenance[0].phase, 'active');
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('after maintenance ends, outage confirmation starts over', async () => {
|
||||
const store = openStore(':memory:');
|
||||
let time = 1000;
|
||||
const one = { ...config, services: [service] };
|
||||
const monitor = createMonitor(store, one, { check: async () => bad, now: () => time });
|
||||
try {
|
||||
store.createMaintenance(windowInput, 500);
|
||||
for (time of [1000, 2000, 3000]) await monitor.cycle();
|
||||
assert.equal(store.state(service).status, 'down');
|
||||
time = 5000; await monitor.cycle();
|
||||
assert.equal(store.state(service).status, 'degraded'); assert.equal(store.incidents().length, 0);
|
||||
time = 6000; await monitor.cycle(); time = 7000; await monitor.cycle();
|
||||
assert.equal(store.incidents().length, 1); assert.equal(store.incidents()[0].startedAt, 7000);
|
||||
} finally { await monitor.stop(); store.close(); }
|
||||
});
|
||||
|
||||
test('maintenance is scoped and cancelling a future window does not suppress real outages', () => {
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
const id = store.createMaintenance(windowInput, 500);
|
||||
assert.equal(store.inMaintenance(config.services[1], 2000), false);
|
||||
assert.equal(store.endMaintenance(id, 'cancel', 900), true);
|
||||
assert.equal(store.endMaintenance(id, 'finish', 950), false);
|
||||
assert.equal(store.inMaintenance(service, 2000), false);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('a pre-existing incident is resolved by real recovery during maintenance', () => {
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
for (const time of [100, 200, 300]) store.record(service, bad, nextState(store.state(service), bad, config), time);
|
||||
store.createMaintenance(windowInput, 400);
|
||||
for (const time of [2000, 3000]) store.record(service, good, nextState(store.state(service), good, config), time);
|
||||
assert.equal(store.incidents()[0].resolvedAt, 3000);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('legacy MVP SQLite database is upgraded without dropping incidents', () => {
|
||||
const folder = mkdtempSync(join(tmpdir(), 'ridebus-status-migration-'));
|
||||
const path = join(folder, 'status.sqlite');
|
||||
const db = new DatabaseSync(path);
|
||||
db.exec("CREATE TABLE 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); INSERT INTO incidents VALUES (1, 'manual', NULL, NULL, 1000, NULL, 'Сообщение', 'Message', 'Текст', 'Text');");
|
||||
db.close();
|
||||
const store = openStore(path);
|
||||
try { assert.equal(store.incidents()[0].title.en, 'Message'); assert.equal(store.incidents()[0].scope, 'availability'); assert.equal(store.maintenances().length, 0); }
|
||||
finally { store.close(); rmSync(folder, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('night-time empty departures are healthy; malformed and partial responses are failures', () => {
|
||||
assert.equal(Boolean(validDepartures({ routes: [], departures: [], partial: false })), true);
|
||||
assert.equal(Boolean(validEta({ routes: [{ id: 1, arrivals: [] }] })), true);
|
||||
assert.equal(Boolean(validBoard({ mode: 'live', stop: { id: 1 }, departures: [], partial: false })), true);
|
||||
assert.equal(Boolean(validDepartures({ routes: [], departures: [], partial: true })), false);
|
||||
assert.equal(Boolean(validEta({ routes: [{ id: 1, error: 'unavailable', arrivals: [] }] })), false);
|
||||
assert.equal(Boolean(validBoard({ mode: 'demo', stop: { id: 1 }, departures: [] })), false);
|
||||
});
|
||||
|
||||
test('PWA journey obtains real data and requires a successful render check', async () => {
|
||||
const pwa = config.services.find(service => service.id === 'pwa');
|
||||
const calls = [];
|
||||
const runner = createScenarioRunner(config, {
|
||||
fetchImpl: async url => { const resource = new URL(url).searchParams.get('resource'); calls.push(resource); return Response.json(resource === 'cities' ? [{ id: 1, name: 'Город', slug: 'polotsk' }] : resource === 'stops' ? [{ id: 2, name: 'Остановка', routesCount: 1 }] : { routes: [], departures: [], partial: false }); },
|
||||
browserChecks: { run: async (_spec, target) => { assert.equal(target.stopId, 2); return { ok: true, latencyMs: 200, httpCode: 200 }; }, close: async () => {} },
|
||||
});
|
||||
try { const result = await runner.run(pwa); assert.equal(result.ok, true); assert.equal(result.mode, 'browser'); assert.deepEqual(calls, ['cities', 'stops', 'departures']); assert.equal(result.steps.at(-1).status, 'passed'); }
|
||||
finally { await runner.close(); }
|
||||
});
|
||||
|
||||
test('HTML success cannot hide a broken journey, and missing access is unknown', async () => {
|
||||
const pwa = config.services.find(service => service.id === 'pwa');
|
||||
const runner = createScenarioRunner(config, { fetchImpl: async () => new Response('<html>error</html>'), browserChecks: { run: async () => { throw new Error('must not render'); }, close: async () => {} } });
|
||||
try { assert.equal((await runner.run(pwa)).ok, false); assert.equal((await runner.run(service)).reason, 'unconfigured'); }
|
||||
finally { await runner.close(); }
|
||||
});
|
||||
|
||||
test('API journey uses city headers and publishes no key or transport payload', async () => {
|
||||
const keyed = loadConfig({ RIDEBUS_API_KEY: 'private-key', STATUS_BROWSER_CHECKS: 'off' });
|
||||
const api = keyed.services[0];
|
||||
const requests = [];
|
||||
const runner = createScenarioRunner(keyed, { fetchImpl: async (url, options) => { requests.push({ url, headers: options.headers }); return Response.json(url.endsWith('/cities') ? [{ id: 3, name: 'City' }] : url.endsWith('/stops') ? [{ id: 7, name: 'Private test stop', routes_count: 1 }] : { routes: [{ id: 1, arrivals: [] }] }); } });
|
||||
try {
|
||||
const result = await runner.run(api); assert.equal(result.ok, true);
|
||||
assert.equal(requests[0].headers['X-City-Id'], undefined); assert.equal(requests[1].headers['X-City-Id'], '3');
|
||||
assert.equal(requests[2].headers['X-API-Key'], 'private-key'); assert.doesNotMatch(JSON.stringify(result), /private-key|Private test stop/);
|
||||
} finally { await runner.close(); }
|
||||
});
|
||||
|
||||
test('scenario polling is independent and respects its saved cadence after restart', async () => {
|
||||
const store = openStore(':memory:');
|
||||
let time = 1000000, calls = 0;
|
||||
const one = { ...config, services: [service] };
|
||||
const runScenario = async () => { calls++; return { ...good, mode: 'http', steps: [] }; };
|
||||
const monitor = createMonitor(store, one, { check: async () => good, runScenario, now: () => time });
|
||||
try {
|
||||
await monitor.cycle(); await monitor.scenarioCycle(); await monitor.scenarioCycle(); assert.equal(calls, 1);
|
||||
time += config.scenarioIntervalSeconds * 1000; await monitor.scenarioCycle(); assert.equal(calls, 2);
|
||||
const next = createMonitor(store, one, { runScenario, now: () => time }); await next.scenarioCycle(); assert.equal(calls, 2); await next.stop();
|
||||
time += config.scenarioIntervalSeconds * 3000 + 1;
|
||||
assert.equal(makeSnapshot(store, one, 30, time).services[0].scenario.status, 'unknown');
|
||||
} finally { await monitor.stop(); store.close(); }
|
||||
});
|
||||
|
||||
test('scenario failures open a separate incident after confirmation and recover after repeat success', () => {
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
for (const time of [1000, 2000, 3000]) store.recordScenario(service, { ...bad, steps: [], mode: 'http' }, nextState(store.scenario(service), bad, config), time);
|
||||
assert.equal(store.incidents()[0].scope, 'scenario');
|
||||
for (const time of [4000, 5000]) store.recordScenario(service, { ...good, steps: [], mode: 'http' }, nextState(store.scenario(service), good, config), time);
|
||||
assert.equal(store.incidents()[0].resolvedAt, 5000);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('restart schedules the next journey at its saved deadline without an extra full interval', async () => {
|
||||
const store = openStore(':memory:');
|
||||
const delays = [];
|
||||
let calls = 0;
|
||||
const recordedAt = 1000000;
|
||||
store.recordScenario(service, { ...good, mode: 'http', steps: [] }, nextState(null, good, config), recordedAt);
|
||||
const monitor = createMonitor(store, { ...config, services: [service] }, {
|
||||
now: () => recordedAt + config.scenarioIntervalSeconds * 1000 - 10000,
|
||||
check: async () => good,
|
||||
runScenario: async () => { calls++; return { ...good, mode: 'http', steps: [] }; },
|
||||
setTimer: (callback, delay) => { delays.push(delay); return delays.length; },
|
||||
clearTimer: () => {},
|
||||
});
|
||||
try {
|
||||
monitor.start();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(calls, 0);
|
||||
assert.ok(delays.includes(10000));
|
||||
assert.ok(delays.includes(config.intervalSeconds * 1000));
|
||||
} finally { await monitor.stop(); store.close(); }
|
||||
});
|
||||
|
||||
test('notification outbox queues one outage and one recovery and holds ordering across retries', async () => {
|
||||
const store = openStore(':memory:');
|
||||
const enabled = { ...config, telegram: { ...config.telegram, enabled: true } };
|
||||
let time = 100000, sent = 0;
|
||||
try {
|
||||
for (let index = 0; index < 4; index++) store.record(service, bad, nextState(store.state(service), bad, config), time + index, { notify: true });
|
||||
for (let index = 0; index < 2; index++) store.record(service, good, nextState(store.state(service), good, config), time + 10 + index, { notify: true });
|
||||
time += 20;
|
||||
assert.equal(store.dueNotifications(time).length, 1);
|
||||
const notifier = createNotifier(store, enabled, { now: () => time, send: async () => { sent++; return sent === 1 ? { ok: false, retryable: true, reason: 'network' } : { ok: true }; } });
|
||||
await notifier.flush(); assert.equal(sent, 1);
|
||||
time += 15000; await notifier.flush(); assert.equal(sent, 1);
|
||||
time += 15000; await notifier.flush(); assert.equal(sent, 2);
|
||||
await notifier.flush(); assert.equal(sent, 3); await notifier.flush(); assert.equal(sent, 3);
|
||||
assert.deepEqual(store.notificationStats().map(row => ({ ...row })), [{ status: 'sent', count: 2 }]);
|
||||
await notifier.stop();
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('Telegram transport uses POST and sanitizes rate limits and credential errors', async () => {
|
||||
const telegram = { ...config.telegram, token: '123:private-token', chatId: '1234' };
|
||||
const success = await sendTelegram(telegram, 'Test', { fetchImpl: async (url, options) => { assert.match(url, /^https:\/\/api.telegram.org/); assert.equal(options.method, 'POST'); assert.equal(JSON.parse(options.body).chat_id, '1234'); assert.equal(options.redirect, 'error'); return Response.json({ ok: true }); } });
|
||||
assert.equal(success.ok, true);
|
||||
const limited = await sendTelegram(telegram, 'Test', { fetchImpl: async () => Response.json({ ok: false, error_code: 429, parameters: { retry_after: 45 } }, { status: 429 }) });
|
||||
assert.equal(limited.retryAfterSeconds, 45); assert.equal(limited.retryable, true);
|
||||
const failed = await sendTelegram(telegram, 'Test', { fetchImpl: async () => { throw new Error('123:private-token'); } });
|
||||
assert.doesNotMatch(JSON.stringify(failed), /private-token/);
|
||||
const text = notificationText({ kind: 'outage', scope: 'scenario', created_at: 100000 }, service, telegram);
|
||||
assert.match(text, /Пользовательский сценарий/); assert.doesNotMatch(text, /private-token|1234/);
|
||||
});
|
||||
|
||||
test('notification delivery stays disabled until both Telegram settings are present', async () => {
|
||||
const store = openStore(':memory:');
|
||||
let called = false;
|
||||
try {
|
||||
const notifier = createNotifier(store, config, { send: async () => { called = true; return { ok: true }; } });
|
||||
await notifier.flush(); assert.equal(called, false); await notifier.stop();
|
||||
assert.equal(loadConfig({ TELEGRAM_BOT_TOKEN: 'private' }).telegram.enabled, false);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
156
test/public-updates.test.mjs
Normal file
@@ -0,0 +1,156 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadConfig } from '../server/config.mjs';
|
||||
import { openStore } from '../server/store.mjs';
|
||||
import { nextState } from '../server/probe.mjs';
|
||||
import { makeSnapshot } from '../server/snapshot.mjs';
|
||||
import { createStatusServer } from '../server/index.mjs';
|
||||
import { buildFeed, feedOptions, xmlEscape } from '../server/feed.mjs';
|
||||
import { serviceImpact } from '../shared/impact.mjs';
|
||||
|
||||
const config = loadConfig({});
|
||||
const api = config.services[0];
|
||||
const good = { ok: true, latencyMs: 100, httpCode: 200, reason: null };
|
||||
const bad = { ok: false, latencyMs: null, httpCode: 503, reason: 'http' };
|
||||
const manual = { serviceId: 'api', titleRu: 'Сбой & <расписания>', titleEn: 'Timetable & <issue>', messageRu: 'Проверяем расписание.', messageEn: 'Checking the timetable.' };
|
||||
|
||||
test('automatic timeline separates detection, team investigation and genuine recovery without duplicate events', () => {
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
for (const time of [1000, 2000, 3000, 4000]) store.record(api, bad, nextState(store.state(api), bad, config), time);
|
||||
let incident = store.incidents()[0];
|
||||
assert.equal(incident.updates.length, 1);
|
||||
assert.deepEqual(incident.updates.map(update => [update.source, update.stage]), [['monitor', 'detected']]);
|
||||
store.updateIncident(incident.id, { stage: 'investigating', messageRu: 'Команда проверяет.', messageEn: 'The team is investigating.' }, 5000);
|
||||
assert.equal(store.resolveIncident(incident.id, 6000), false);
|
||||
for (const time of [6000, 7000, 8000]) store.record(api, good, nextState(store.state(api), good, config), time);
|
||||
incident = store.incidents()[0];
|
||||
assert.deepEqual(incident.updates.map(update => [update.source, update.stage]), [['monitor', 'detected'], ['manual', 'investigating'], ['monitor', 'resolved']]);
|
||||
assert.equal(incident.resolvedAt, 7000);
|
||||
assert.throws(() => store.updateIncident(incident.id, { stage: 'identified', messageRu: 'Причина', messageEn: 'Cause' }, 9000), /Closed incidents/);
|
||||
store.updateIncident(incident.id, { stage: 'postmortem', messageRu: 'Итоги: восстановили источник.', messageEn: 'Outcome: restored the source.' }, 9000);
|
||||
const closed = store.incidents()[0];
|
||||
assert.equal(closed.resolvedAt, 7000);
|
||||
assert.equal(closed.updates.at(-1).stage, 'postmortem');
|
||||
assert.equal(closed.updates.at(-1).source, 'manual');
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('manual reports keep their original text and store a final explanation without accepting invalid updates', () => {
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
const id = store.createIncident(manual, 1000);
|
||||
assert.equal(store.incidents()[0].updates[0].stage, 'reported');
|
||||
assert.throws(() => store.updateIncident(id, { stage: 'postmortem', messageRu: 'Итоги', messageEn: 'Outcome' }, 2000), /Resolve/);
|
||||
assert.throws(() => store.updateIncident(id, { stage: 'resolved', messageRu: 'Нет', messageEn: 'No' }, 2000), /Stage/);
|
||||
assert.throws(() => store.updateIncident(id, { stage: 'identified', messageRu: 'Причина' }, 2000), /message/);
|
||||
store.updateIncident(id, { stage: 'monitoring', messageRu: 'Наблюдаем.', messageEn: 'Monitoring.' }, 3000);
|
||||
assert.throws(() => store.updateIncident(id, { stage: 'investigating', messageRu: 'Текст', messageEn: 'Text' }, 2000), /timeline/);
|
||||
assert.equal(store.resolveIncident(id, 4000, { messageRu: 'Причина: ошибка источника. Исправлено.', messageEn: 'Cause: source error. Fixed.' }), true);
|
||||
assert.equal(store.resolveIncident(id, 5000), false);
|
||||
const incident = store.incidents()[0];
|
||||
assert.equal(incident.message.ru, manual.messageRu);
|
||||
assert.match(incident.updates.at(-1).message.en, /source error/);
|
||||
assert.equal(incident.updates.length, 3);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('legacy incidents gain only recorded timeline facts and repeated startup is idempotent', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ridebus-updates-'));
|
||||
const path = join(dir, 'legacy.sqlite');
|
||||
const db = new DatabaseSync(path);
|
||||
db.exec(`CREATE TABLE 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);
|
||||
INSERT INTO incidents VALUES (1, 'monitor', 'api', 'old', 1000, 2000, 'Сбой', 'Outage', 'Старое сообщение', 'Original message');`);
|
||||
db.close();
|
||||
try {
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const store = openStore(path);
|
||||
const incident = store.incidents()[0];
|
||||
assert.deepEqual(incident.updates.map(update => update.stage), ['detected', 'resolved']);
|
||||
assert.equal(incident.updates[0].message.en, 'Original message');
|
||||
assert.equal(incident.resolvedAt, 2000);
|
||||
store.close();
|
||||
}
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('impact explains the failed scope and suggests only a currently verified alternative', () => {
|
||||
const board = { ...config.services.find(service => service.id === 'board'), status: 'degraded', availabilityStatus: 'operational', scenario: { status: 'down' } };
|
||||
const pwa = { ...config.services.find(service => service.id === 'pwa'), status: 'operational' };
|
||||
assert.equal(serviceImpact(board, [pwa]).scope, 'scenario');
|
||||
assert.deepEqual(serviceImpact(board, [pwa]).alternatives.map(item => item.id), ['pwa']);
|
||||
assert.equal(serviceImpact(board, [{ ...pwa, status: 'unknown' }]).alternatives.length, 0);
|
||||
assert.equal(serviceImpact({ ...board, status: 'unknown' }, [pwa]), null);
|
||||
assert.equal(serviceImpact({ ...board, status: 'maintenance' }, [pwa]), null);
|
||||
assert.equal(serviceImpact({ ...board, status: 'down', availabilityStatus: 'down' }, [pwa]).scope, 'availability');
|
||||
});
|
||||
|
||||
test('RSS selections are strict and deterministic, including empty, duplicate and unknown parameters', () => {
|
||||
assert.deepEqual(feedOptions(new URLSearchParams('lang=en&services=pwa,api,pwa&types=maintenance'), config.services), { locale: 'en', serviceIds: ['api', 'pwa'], types: ['maintenance'] });
|
||||
for (const query of ['services=', 'services=missing', 'types=', 'types=spam', 'lang=zz', 'lang=', 'lang=ru&lang=en', 'demo=1']) assert.throws(() => feedOptions(new URLSearchParams(query), config.services));
|
||||
});
|
||||
|
||||
test('RSS contains each incident update, escapes XML and keeps event IDs stable across polling', () => {
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
const id = store.createIncident(manual, 1000);
|
||||
store.updateIncident(id, { stage: 'identified', messageRu: 'Причина <ошибки> & исправление', messageEn: 'Cause <error> & fix' }, 2000);
|
||||
const options = feedOptions(new URLSearchParams('lang=en&services=api&types=incidents'), config.services);
|
||||
const first = buildFeed(makeSnapshot(store, config, 7, 3000), options, 'https://status.example');
|
||||
const second = buildFeed(makeSnapshot(store, config, 7, 4000), options, 'https://status.example');
|
||||
assert.equal((first.match(/<item>/g) || []).length, 2);
|
||||
assert.match(first, /Cause &lt;error&gt; &amp; fix/);
|
||||
assert.doesNotMatch(first, /<error>|signature|apiKey|cookie/);
|
||||
assert.deepEqual(first.match(/<guid.*?<\/guid>/g), second.match(/<guid.*?<\/guid>/g));
|
||||
assert.match(first, /#incident-1/);
|
||||
assert.equal(xmlEscape('🙂\u0000\ud800 <'), '🙂 <');
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('RSS filtering includes global announcements but excludes other services and event types', () => {
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
store.createIncident(manual, 1000);
|
||||
store.createIncident({ ...manual, serviceId: 'board', titleEn: 'Board only' }, 2000);
|
||||
store.createIncident({ ...manual, serviceId: null, titleEn: 'Global report' }, 3000);
|
||||
store.createMaintenance({ titleRu: 'Работы', titleEn: 'Maintenance only', messageRu: 'Обновление', messageEn: 'Update', serviceIds: ['pwa'], startsAt: 5000, endsAt: 9000 }, 4000);
|
||||
const snapshot = makeSnapshot(store, config, 7, 6000);
|
||||
const incidentXml = buildFeed(snapshot, feedOptions(new URLSearchParams('lang=en&services=api&types=incidents'), config.services), 'https://status.example');
|
||||
assert.match(incidentXml, /Global report/); assert.doesNotMatch(incidentXml, /Board only|Maintenance only/);
|
||||
const maintenanceXml = buildFeed(snapshot, feedOptions(new URLSearchParams('lang=en&services=pwa&types=maintenance'), config.services), 'https://status.example');
|
||||
assert.match(maintenanceXml, /Maintenance only/); assert.doesNotMatch(maintenanceXml, /Global report|Timetable/);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('maintenance RSS records announcement and cancellation without inventing a start or completion', () => {
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
const id = store.createMaintenance({ titleRu: 'Работы', titleEn: 'Cancelled window', messageRu: 'Текст', messageEn: 'Text', serviceIds: [], startsAt: 5000, endsAt: 9000 }, 1000);
|
||||
store.endMaintenance(id, 'cancel', 2000);
|
||||
const xml = buildFeed(makeSnapshot(store, config, 7, 10000), feedOptions(new URLSearchParams('lang=en&types=maintenance'), config.services), 'https://status.example');
|
||||
assert.equal((xml.match(/<item>/g) || []).length, 2);
|
||||
assert.match(xml, /Maintenance cancelled/); assert.doesNotMatch(xml, /window started|window ended|Completed by/);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('feed endpoint is read-only XML, uses the configured canonical URL and excludes demo events', async () => {
|
||||
const app = await createStatusServer(loadConfig({ STATUS_DB_PATH: ':memory:', STATUS_PUBLIC_URL: 'https://status.example', PORT: '0', HOST: '127.0.0.1' }), { monitorEnabled: false });
|
||||
try {
|
||||
await new Promise(resolve => app.server.listen(0, '127.0.0.1', resolve));
|
||||
const origin = `http://127.0.0.1:${app.server.address().port}`;
|
||||
app.store.createIncident(manual, Date.now());
|
||||
const response = await fetch(`${origin}/feed.xml?lang=en&services=api`);
|
||||
assert.match(response.headers.get('content-type'), /^application\/rss\+xml/);
|
||||
assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||
const xml = await response.text();
|
||||
assert.match(xml, /https:\/\/status.example/); assert.doesNotMatch(xml, /example-active|demo-active/);
|
||||
assert.equal((await fetch(`${origin}/feed.xml?services=missing`)).status, 400);
|
||||
assert.equal((await fetch(`${origin}/feed.xml?demo=1`)).status, 400);
|
||||
assert.equal((await fetch(`${origin}/feed.xml`, { method: 'POST' })).status, 405);
|
||||
assert.equal(await (await fetch(`${origin}/feed.xml`, { method: 'HEAD' })).text(), '');
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
23
test/server.test.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadConfig } from '../server/config.mjs';
|
||||
import { createStatusServer } from '../server/index.mjs';
|
||||
|
||||
test('public HTTP API is read-only, uncached, strict on ranges and isolated from demo data', async () => {
|
||||
const config = loadConfig({ STATUS_DB_PATH: ':memory:', PORT: '0', HOST: '127.0.0.1' });
|
||||
const app = await createStatusServer(config, { monitorEnabled: false });
|
||||
try {
|
||||
await new Promise((resolve, reject) => { app.server.once('error', reject); app.server.listen(0, '127.0.0.1', resolve); });
|
||||
const origin = `http://127.0.0.1:${app.server.address().port}`;
|
||||
const response = await fetch(`${origin}/api/status?range=30`);
|
||||
assert.equal(response.status, 200); assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||
const live = await response.json(); assert.equal(live.overall, 'unknown'); assert.equal(live.demo, false);
|
||||
assert.equal((await fetch(`${origin}/api/status?range=31`)).status, 400);
|
||||
assert.equal((await fetch(`${origin}/api/status`, { method: 'POST' })).status, 405);
|
||||
assert.equal((await fetch(`${origin}/api/private`)).status, 404);
|
||||
assert.equal((await (await fetch(`${origin}/api/status?demo=1`)).json()).demo, true);
|
||||
assert.equal((await (await fetch(`${origin}/api/status`)).json()).services[0].checks, 0);
|
||||
assert.equal((await fetch(`${origin}/healthz`)).status, 200);
|
||||
assert.equal((await fetch(`${origin}/../.env`)).status, 404);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
198
test/statistics.test.mjs
Normal file
@@ -0,0 +1,198 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadConfig } from '../server/config.mjs';
|
||||
import { openStore } from '../server/store.mjs';
|
||||
import { nextState } from '../server/probe.mjs';
|
||||
import { makeSnapshot } from '../server/snapshot.mjs';
|
||||
import { demoSnapshot } from '../server/demo.mjs';
|
||||
import { createStatusServer } from '../server/index.mjs';
|
||||
import { unionIntervals, intersectIntervals, subtractIntervals } from '../server/reliability.mjs';
|
||||
|
||||
const base = loadConfig({});
|
||||
const config = { ...base, failureThreshold: 1, recoveryThreshold: 1, staleAfterSeconds: 3, scenarioIntervalSeconds: 1 };
|
||||
const website = base.services.find(service => service.id === 'website');
|
||||
const api = base.services.find(service => service.id === 'api');
|
||||
const good = { ok: true, latencyMs: 100, httpCode: 200, reason: null };
|
||||
const bad = { ok: false, latencyMs: null, httpCode: 503, reason: 'http' };
|
||||
function record(store, service, result, time, settings = config) { store.record(service, result, nextState(store.state(service), result, settings), time); }
|
||||
function scenario(store, result, time) { store.recordScenario(api, { ...result, steps: [], mode: 'http' }, nextState(store.scenario(api), result, config), time); }
|
||||
|
||||
test('interval arithmetic merges overlaps once and preserves excluded gaps', () => {
|
||||
assert.deepEqual(unionIntervals([[4, 8], [1, 5], [8, 10], [15, 14], [12, 13]]), [[1, 10], [12, 13]]);
|
||||
assert.deepEqual(intersectIntervals([[0, 10]], [[1, 3], [5, 12]]), [[1, 3], [5, 10]]);
|
||||
assert.deepEqual(subtractIntervals([[0, 10], [20, 30]], [[-1, 2], [4, 6], [8, 22]]), [[2, 4], [6, 8], [22, 30]]);
|
||||
});
|
||||
|
||||
test('outage time starts at confirmation and includes recovery confirmation', () => {
|
||||
const store = openStore(':memory:');
|
||||
const thresholds = { ...config, failureThreshold: 3, recoveryThreshold: 2 };
|
||||
try {
|
||||
for (const time of [1000, 2000, 3000]) record(store, website, bad, time, thresholds);
|
||||
for (const time of [4000, 5000]) record(store, website, good, time, thresholds);
|
||||
const stats = store.reliability(website, 0, 6000, config);
|
||||
assert.equal(stats.downtimeMs, 2000); assert.equal(stats.longestDowntimeMs, 2000);
|
||||
assert.equal(stats.incidentCount, 1); assert.equal(stats.lastRecoveryAt, 5000);
|
||||
assert.equal(stats.downtimeIncomplete, false);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('availability and journey outages overlap without double counting', () => {
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
record(store, api, bad, 1000); record(store, api, bad, 2000); record(store, api, good, 4000);
|
||||
scenario(store, good, 1000); scenario(store, bad, 2000); scenario(store, bad, 3000); scenario(store, good, 5000);
|
||||
const stats = store.reliability(api, 0, 6000, config);
|
||||
assert.equal(stats.downtimeMs, 4000); assert.equal(stats.longestDowntimeMs, 3000);
|
||||
assert.equal(stats.incidentCount, 2); assert.equal(stats.lastRecoveryAt, 5000); assert.equal(stats.lastRecoveryScope, 'scenario');
|
||||
assert.equal(stats.observedMs, 5000);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('monitoring gaps expire at the freshness boundary instead of becoming downtime', () => {
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
record(store, website, bad, 1000); record(store, website, bad, 10000); record(store, website, good, 11000);
|
||||
const stats = store.reliability(website, 0, 12000, config);
|
||||
assert.equal(stats.downtimeMs, 4000); assert.equal(stats.longestDowntimeMs, 4000);
|
||||
assert.equal(stats.downtimeIncomplete, true); assert.equal(stats.observedMs, 5000);
|
||||
const clipped = store.reliability(website, 2000, 3000, config);
|
||||
assert.equal(clipped.downtimeMs, 1000);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('maintenance is subtracted from a genuine incident and planned-only failures do not create outage time', () => {
|
||||
const store = openStore(':memory:');
|
||||
const window = { titleRu: 'Работы', titleEn: 'Work', messageRu: 'Текст', messageEn: 'Text', serviceIds: ['website'], startsAt: 2000, endsAt: 4000 };
|
||||
try {
|
||||
store.createMaintenance(window, 0);
|
||||
for (const time of [1000, 2000, 3000, 4000, 5000]) record(store, website, bad, time);
|
||||
record(store, website, good, 6000);
|
||||
const stats = store.reliability(website, 0, 7000, config);
|
||||
assert.equal(stats.downtimeMs, 3000); assert.equal(stats.longestDowntimeMs, 3000);
|
||||
assert.equal(stats.downtimeIncomplete, false);
|
||||
const other = openStore(':memory:');
|
||||
try {
|
||||
other.createMaintenance({ ...window, startsAt: 1000, endsAt: 3000 }, 0);
|
||||
record(other, website, bad, 1000); record(other, website, bad, 2000);
|
||||
assert.equal(other.reliability(website, 0, 4000, config).downtimeMs, 0);
|
||||
assert.equal(other.reliability(website, 0, 4000, config).incidentCount, 0);
|
||||
} finally { other.close(); }
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('an unresolved outage stops accumulating measured time when monitoring stops', () => {
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
record(store, website, bad, 1000);
|
||||
assert.equal(store.reliability(website, 0, 50000, config).downtimeMs, 3000);
|
||||
assert.equal(store.reliability(website, 0, 90000, config).downtimeMs, 3000);
|
||||
assert.equal(store.reliability(website, 0, 90000, config).downtimeIncomplete, true);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('manual reports, unconfigured checks and a changed signature cannot fabricate measured outages', () => {
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
store.createIncident({ serviceId: 'website', titleRu: 'Сообщение', titleEn: 'Report', messageRu: 'Текст', messageEn: 'Text' }, 1000);
|
||||
record(store, website, good, 1000);
|
||||
assert.equal(store.reliability(website, 0, 2000, config).incidentCount, 0);
|
||||
assert.equal(store.reliability({ ...website, signature: 'new' }, 0, 2000, config).downtimeMs, null);
|
||||
const unknown = { ...bad, reason: 'unconfigured' };
|
||||
const other = { ...website, signature: 'unconfigured' };
|
||||
record(store, other, unknown, 1000);
|
||||
const stats = store.reliability(other, 0, 2000, config);
|
||||
assert.equal(stats.downtimeMs, null); assert.equal(stats.observedMs, 0);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('an unchecked required journey cannot be presented as zero downtime', () => {
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
record(store, api, good, 1000);
|
||||
scenario(store, { ...bad, ok: null, reason: 'auth_required' }, 1000);
|
||||
const stats = store.reliability(api, 0, 2000, config);
|
||||
assert.equal(stats.downtimeMs, null); assert.equal(stats.longestDowntimeMs, null);
|
||||
assert.equal(stats.incidentCount, 0); assert.equal(stats.observedMs, 0);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('old journey state is retained but historical scenario measurements are not invented on migration', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ridebus-statistics-'));
|
||||
const path = join(dir, 'legacy.sqlite');
|
||||
try {
|
||||
const old = openStore(path);
|
||||
scenario(old, bad, 1000); scenario(old, good, 2000); old.close();
|
||||
const db = new DatabaseSync(path); db.exec('DROP TABLE scenario_checks'); db.close();
|
||||
const store = openStore(path);
|
||||
try {
|
||||
record(store, api, good, 1000);
|
||||
const stats = store.reliability(api, 0, 3000, config);
|
||||
assert.equal(store.scenario(api).status, 'operational');
|
||||
assert.equal(stats.incidentCount, 1); assert.equal(stats.downtimeMs, null);
|
||||
assert.equal(stats.downtimeIncomplete, true); assert.equal(stats.lastRecoveryAt, 2000);
|
||||
} finally { store.close(); }
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('latency percentile uses successful samples and comparison needs populated windows', () => {
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
for (let index = 0; index < 100; index++) {
|
||||
record(store, website, good, index * 100);
|
||||
record(store, website, { ...good, latencyMs: index + 1 }, 10000 + index * 100);
|
||||
}
|
||||
record(store, website, bad, 19999);
|
||||
record(store, website, { ...good, latencyMs: 9999 }, 30000);
|
||||
const stats = store.latencyDetails(website, 10000, 20000);
|
||||
assert.equal(stats.samples, 100); assert.equal(stats.averageMs, 51); assert.equal(stats.p95Ms, 95);
|
||||
assert.equal(stats.previousAverageMs, 100); assert.equal(stats.changePercent, -49.5);
|
||||
assert.equal(store.latencyDetails(website, 30000, 30001).changePercent, null);
|
||||
assert.equal(store.latencyDetails({ ...website, signature: 'missing' }, 0, 30000).p95Ms, null);
|
||||
for (const time of [0, 500000, 999999, 1000000, 1500000, 1999999]) record(store, website, good, time);
|
||||
assert.equal(store.latencyDetails(website, 1000000, 2000000).changePercent, null);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('public statistics remain scoped, bounded and present for all demo ranges', () => {
|
||||
const store = openStore(':memory:');
|
||||
try {
|
||||
const snapshot = makeSnapshot(store, base, 30, Date.UTC(2026, 9, 13));
|
||||
assert.equal(snapshot.services[0].reliability.downtimeMs, null);
|
||||
assert.equal(snapshot.services[0].latencyHistory.length, 30);
|
||||
assert.equal(snapshot.services[0].latency24h.samples, 0);
|
||||
assert.doesNotMatch(JSON.stringify(snapshot), /signature|apiKey|cookie/);
|
||||
for (const range of [7, 30, 90]) {
|
||||
const demo = demoSnapshot(range, Date.UTC(2026, 9, 13));
|
||||
assert.equal(demo.demo, true);
|
||||
assert.equal(demo.services[0].latencyHistory.length, range);
|
||||
assert.equal(demo.services.find(service => service.id === 'pwa').reliability.downtimeMs, 2100000);
|
||||
}
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('internal snapshot cache expires immediately after local and CLI database writes', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ridebus-statistics-cache-'));
|
||||
const path = join(dir, 'status.sqlite');
|
||||
const app = await createStatusServer(loadConfig({ STATUS_DB_PATH: path, PORT: '0', HOST: '127.0.0.1' }), { monitorEnabled: false });
|
||||
const text = { serviceId: 'website', titleRu: 'Сообщение', titleEn: 'Report', messageRu: 'Текст', messageEn: 'Text' };
|
||||
try {
|
||||
await new Promise(resolve => app.server.listen(0, '127.0.0.1', resolve));
|
||||
const url = `http://127.0.0.1:${app.server.address().port}/api/status?range=30`;
|
||||
const initial = await (await fetch(url)).json();
|
||||
assert.deepEqual(await (await fetch(url)).json(), initial);
|
||||
record(app.store, website, good, Date.now(), base);
|
||||
assert.equal((await (await fetch(url)).json()).services.find(service => service.id === 'website').checks, 1);
|
||||
scenario(app.store, good, Date.now());
|
||||
assert.equal((await (await fetch(url)).json()).services.find(service => service.id === 'api').scenario.status, 'operational');
|
||||
app.store.createIncident(text);
|
||||
assert.equal((await (await fetch(url)).json()).incidents.length, 1);
|
||||
const cli = openStore(path); cli.createIncident(text); cli.close();
|
||||
const response = await fetch(url);
|
||||
assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||
assert.equal((await response.json()).incidents.length, 2);
|
||||
} finally { await app.close(); rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
10
tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx",
|
||||
"strict": true, "skipLibCheck": true, "noEmit": true,
|
||||
"allowImportingTsExtensions": true, "resolveJsonModule": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
4
vite.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({ plugins: [react()], build: { target: 'es2022' } });
|
||||