diff --git a/Dockerfile b/Dockerfile index 99542ce..8eee643 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ COPY --from=build /app/dist ./dist COPY server ./server COPY shared ./shared COPY config ./config +COPY public/widget-client.js ./public/widget-client.js RUN mkdir -p /app/data && chown -R node:node /app/data USER node EXPOSE 5188 diff --git a/README.md b/README.md index b23fb5f..b5cfa77 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,24 @@ npm start ## Сервисы и настройки +### Встраиваемый виджет + +Виджет показывает текущую доступность и состояние сценариев выбранных сервисов. Используются те же правила свежести и плановых работ, что и на основной странице. Зелёный статус исчезает при истечении измерений, сетевой ошибке или отсутствии ответа сайта мониторинга. История и транспортные данные для виджета не загружаются. + +Публичный iframe: `/embed?services=pwa,api&lang=ru&theme=auto`; параметры `services`, `lang` (`ru`/`en`) и `theme` (`light`/`dark`/`auto`) необязательны. Без списка проверяются все сервисы. Публичный `/api/widget?services=pwa,api` возвращает компактную сводку. Демо-режим виджетом не поддерживается. Обновление — каждые 30 секунд, в скрытой вкладке запросы приостанавливаются. + +В исходники трёх проектов добавлен компонент из `integrations/StatusWidget.tsx`: основной сайт (`website,api`), PWA (`pwa,api`), админка (`admin,api`). Он проверяет origin и источник сообщений iframe и показывает запасную ссылку, если мониторинг недоступен. Iframe не получает ключи API или токены приложения; запросы данных идут с `credentials: omit`, referrer отключён. + +После выбора хостинга задайте URL сайта статуса перед сборкой клиентов: + +| Проект | Переменная | +| --- | --- | +| ridebus-website | `NEXT_PUBLIC_STATUS_URL=https://your-status-domain.example` | +| ridebus-pwa | `VITE_STATUS_URL=https://your-status-domain.example` | +| polotsk-transit-api/frontend | `VITE_STATUS_URL=https://your-status-domain.example` | + +Без адреса production-виджет скрыт; в development используется `http://127.0.0.1:5188`. Значения URL публичные и не являются секретами. После изменения необходима новая сборка. Если у хостинга задан CSP, разрешите домен мониторинга в `frame-src`. Виджет также можно вставить в другой сайт обычным iframe шириной 260px и высотой 44px с `title`, `referrerpolicy="no-referrer"` и sandbox `allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox`. + | Сервис | Проверка по умолчанию | | --- | --- | | API | `https://api.ridebus.by/health`: HTTP 200 и JSON `status: healthy` | diff --git a/artifacts/widget-admin-desktop.png b/artifacts/widget-admin-desktop.png new file mode 100644 index 0000000..e19bafd Binary files /dev/null and b/artifacts/widget-admin-desktop.png differ diff --git a/artifacts/widget-admin-fixture.mjs b/artifacts/widget-admin-fixture.mjs new file mode 100644 index 0000000..94b7c0f --- /dev/null +++ b/artifacts/widget-admin-fixture.mjs @@ -0,0 +1,17 @@ +// Local UI fixture only. No real login, database, credentials or write API. +import { createServer } from 'node:http'; +const user = { id: 1, username: 'widget-review', full_name: 'Проверка виджета', role: 'admin' }; +const server = createServer((req, res) => { + res.setHeader('Access-Control-Allow-Origin', 'http://127.0.0.1:3001'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, X-City-Id'); + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + const path = new URL(req.url, 'http://localhost').pathname; + if (req.method === 'POST' && path === '/api/v1/auth/login') { res.end(JSON.stringify({ user, accessToken: 'local-widget-fixture-only', refreshToken: 'local-widget-fixture-refresh' })); return; } + if (req.method !== 'GET') { res.writeHead(405); res.end('{}'); return; } + const data = path === '/api/v1/auth/me' ? user : path === '/api/v1/cities' ? [{ id: 1, name: 'Тестовый город', slug: 'widget-fixture', is_active: true }] : path.endsWith('/sync/status') ? { status: 'ready', database: { schedules_count: 0 } } : []; + res.end(JSON.stringify(data)); +}); +server.listen(5191, '127.0.0.1', () => console.log('Admin widget fixture: http://127.0.0.1:5191')); +for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { server.closeAllConnections(); server.close(() => process.exit(0)); }); diff --git a/artifacts/widget-admin-mobile.png b/artifacts/widget-admin-mobile.png new file mode 100644 index 0000000..39a1c4a Binary files /dev/null and b/artifacts/widget-admin-mobile.png differ diff --git a/artifacts/widget-pwa-desktop.png b/artifacts/widget-pwa-desktop.png new file mode 100644 index 0000000..d644c42 Binary files /dev/null and b/artifacts/widget-pwa-desktop.png differ diff --git a/artifacts/widget-pwa-mobile.png b/artifacts/widget-pwa-mobile.png new file mode 100644 index 0000000..6923342 Binary files /dev/null and b/artifacts/widget-pwa-mobile.png differ diff --git a/artifacts/widget-unavailable.png b/artifacts/widget-unavailable.png new file mode 100644 index 0000000..82adb6e Binary files /dev/null and b/artifacts/widget-unavailable.png differ diff --git a/artifacts/widget-verification.md b/artifacts/widget-verification.md new file mode 100644 index 0000000..31993d5 --- /dev/null +++ b/artifacts/widget-verification.md @@ -0,0 +1,47 @@ +# Виджет статуса RideBus + +Проверки: 13–14 октября 2026, Europe/Minsk. Изменения выполнены в локальных исходниках. + +## Подключение + +| Проект | Размещение | Проверяемые сервисы | Публичная переменная сборки | +| --- | --- | --- | --- | +| ridebus-website/ridebus-website | Footer, рядом с авторством | website, api | NEXT_PUBLIC_STATUS_URL | +| ridebus-pwa | Footer, перед ссылками | pwa, api | VITE_STATUS_URL | +| polotsk-transit-api/frontend | Низ sidebar и раскрытого мобильного меню | admin, api | VITE_STATUS_URL | + +В проекте статуса добавлены `/embed`, компактный `/api/widget`, общий клиент виджета, Manrope и SVG из @vkontakte/icons. Скрипт не загружает историю или транспортные ответы. Используются текущие состояния обычной доступности, сценариев и плановых работ. Демо-данные не принимаются. Отсутствие измерений, просроченный статус, отключение сети и недоступность самого мониторинга не отображаются как зелёное состояние. + +Обновления идут каждые 30 секунд; в скрытой вкладке запросы приостанавливаются. Остаток времени свежести считается относительно часов клиента, с вычетом времени загрузки страницы или ответа. При задержке браузерных таймеров ответ на запрос родителя проверяет срок измерений заново. + +Iframe изолирует оформление, поддерживает light/dark/auto и ru/en. В React-интеграции iframe декоративный, а поверх него находится одна обычная ссылка с текущим состоянием в доступном имени. Проверяется origin и источник сообщений. Поддерживаются фокус с клавиатуры и reduced motion. Запросы данных используют credentials: omit; referrer отключён. Ключи API и сессии приложения не передаются компоненту. + +## Проверки + +- `ridebus-status`: 71 тест, 0 ошибок, 0 пропусков; production-сборка успешна. +- `ridebus-pwa`: TypeScript и production-сборка успешны. +- `ridebus-website`: TypeScript, Webpack production-сборка и статическая генерация успешны. Для QA в сборку передан локальный NEXT_PUBLIC_STATUS_URL. +- Админка: production-сборка Vite успешна. +- `git diff --check` прошёл во всех четырёх checkout. Автоматическое изменение next-env.d.ts от Next build удалено; генерация OG не оставила изменений изображений. +- Конфигурации Compose трёх клиентов проверены командой `docker compose config --quiet` с тестовым публичным URL. Docker-образы на этом этапе не собирались. + +В браузере проверены desktop 1280px и mobile 390px. Виджет отображался с реальными успешными состояниями выбранных сервисов во всех трёх проектах. На мобильных страницах горизонтального переполнения нет. Английская страница `/en` вывела “Services operational”, правильное доступное имя и ссылку с lang=en. Shift+Tab перевёл фокус с авторства на виджет. + +Админка проверена на локальном API-стенде с тестовым городом и аккаунтом widget-review. Production-аккаунты и CRUD не использовались. Сеанс завершён через обычное действие «Выйти»; локальные тестовые токены очищены этим действием. + +Ссылка на подробности имеет правильный href и target=_blank; обычное нажатие родительской ссылки выполнено без ошибки. Регистрация новой внешней вкладки отдельно не подтверждена: список вкладок in-app browser её не показал. + +Остановка локального сайта мониторинга и перезагрузка PWA подтвердили запасное состояние «Статус не получен». Все запущенные для QA серверы и вкладки закрыты. Telegram в QA-процессе был отключён; сообщения не отправлялись. Публикация на внешние хостинги не выполнялась. + +## Скриншоты + +- PWA: [desktop](widget-pwa-desktop.png), [mobile](widget-pwa-mobile.png). +- Основной сайт: [desktop](widget-website-desktop.png), [mobile](widget-website-mobile.png), [English](widget-website-en.png). +- Админка на локальном стенде: [desktop](widget-admin-desktop.png), [mobile](widget-admin-mobile.png). +- Мониторинг недоступен: [запасное состояние](widget-unavailable.png). + +## Подключение к будущему хостингу + +Укажите URL сайта статуса в перечисленной переменной перед сборкой. Без URL production-виджет скрыт. При обычном локальном development по умолчанию используется http://127.0.0.1:5188; для режима NODE_ENV=production нужен явный URL. При наличии старого service worker браузер может показывать предыдущую версию PWA до обновления приложения; QA проведён на отдельном origin 5174. + +Dockerfile и Compose передают URL через build args. Для Compose задавайте переменную в корневом .env проекта; для Vite без Docker — в .env соответствующего frontend. После изменения адреса пересоберите интерфейс. При CSP хостинга разрешите домен мониторинга в frame-src. Секретные .env при реализации не изменялись. diff --git a/artifacts/widget-website-desktop.png b/artifacts/widget-website-desktop.png new file mode 100644 index 0000000..15295b0 Binary files /dev/null and b/artifacts/widget-website-desktop.png differ diff --git a/artifacts/widget-website-en.png b/artifacts/widget-website-en.png new file mode 100644 index 0000000..7c68380 Binary files /dev/null and b/artifacts/widget-website-en.png differ diff --git a/artifacts/widget-website-mobile.png b/artifacts/widget-website-mobile.png new file mode 100644 index 0000000..55e9d48 Binary files /dev/null and b/artifacts/widget-website-mobile.png differ diff --git a/integrations/StatusWidget.tsx b/integrations/StatusWidget.tsx new file mode 100644 index 0000000..a55415b --- /dev/null +++ b/integrations/StatusWidget.tsx @@ -0,0 +1,63 @@ +import { useEffect, useRef, useState } from 'react'; + +type Props = { + url?: string; + locale?: 'ru' | 'en'; + theme?: 'light' | 'dark' | 'auto'; + services?: string[]; + className?: string; +}; +type WidgetStatus = 'operational' | 'degraded' | 'down' | 'unknown' | 'maintenance'; + +/** Embed the public RideBus status without sharing app sessions or API keys. */ +export default function StatusWidget({ url, locale = 'ru', theme = 'auto', services = [], className }: Props) { + const frame = useRef(null); + const [ready, setReady] = useState(false); + const [status, setStatus] = useState('unknown'); + let source = '', origin = '', destination = ''; + try { + if (url) { + const target = new URL(url); + if (!['https:', 'http:'].includes(target.protocol) || target.username || target.password) throw new Error('Invalid status URL'); + origin = target.origin; + destination = new URL(`/?lang=${locale}`, origin).href; + const embed = new URL('/embed', origin); + embed.searchParams.set('lang', locale); + embed.searchParams.set('theme', theme); + if (services.length) embed.searchParams.set('services', services.join(',')); + source = embed.href; + } + } catch { /* An unset or invalid deployment URL leaves the widget disabled. */ } + + const title = locale === 'ru' ? 'Статус сервисов RideBus' : 'RideBus service status'; + const fallback = locale === 'ru' ? 'Статус не получен' : 'Status unavailable'; + const labels: Record = locale === 'ru' + ? { operational: 'Сервисы работают', degraded: 'Есть перебои', down: 'Сервисы недоступны', unknown: 'Нет свежих данных', maintenance: 'Плановые работы' } + : { operational: 'Services operational', degraded: 'Service disruption', down: 'Services unavailable', unknown: 'No fresh status data', maintenance: 'Scheduled maintenance' }; + useEffect(() => { + setReady(false); + if (!source) return; + let lastUpdate = 0; + const request = () => frame.current?.contentWindow?.postMessage({ type: 'ridebus-status-widget-request' }, origin); + function receive(event: MessageEvent) { + if (event.origin !== origin || event.source !== frame.current?.contentWindow || event.data?.type !== 'ridebus-status-widget' || event.data.version !== 1 || !['operational', 'degraded', 'down', 'unknown', 'maintenance'].includes(event.data.status)) return; + lastUpdate = Date.now(); + setStatus(event.data.status); + setReady(true); + } + function visible() { if (!document.hidden) { if (Date.now() - lastUpdate >= 90000) setReady(false); request(); } } + const timer = setInterval(() => { if (lastUpdate && Date.now() - lastUpdate >= 90000) setReady(false); }, 5000); + window.addEventListener('message', receive); + window.addEventListener('online', request); + document.addEventListener('visibilitychange', visible); + request(); + return () => { clearInterval(timer); window.removeEventListener('message', receive); window.removeEventListener('online', request); document.removeEventListener('visibilitychange', visible); }; + }, [source, origin]); + + if (!source) return null; + return + +