diff --git a/.env.example b/.env.example index f20abd4..297fa84 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,6 @@ HOST=127.0.0.1 # Docker Compose: port published on the server for an HTTPS reverse proxy. PWA_PORT=8080 PWA_BIND_ADDRESS=127.0.0.1 + +# Public status hosting URL, embedded in the production bundle. +# VITE_STATUS_URL=https://your-status-domain.example diff --git a/Dockerfile b/Dockerfile index a70ac5d..b2767ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,8 @@ RUN npm ci COPY index.html tsconfig.json vite.config.ts ./ COPY src ./src COPY public ./public +ARG VITE_STATUS_URL= +ENV VITE_STATUS_URL=$VITE_STATUS_URL RUN npm run build FROM node:24-alpine diff --git a/README.md b/README.md index b2602aa..a6acd83 100644 --- a/README.md +++ b/README.md @@ -332,3 +332,11 @@ npm run icons Эта команда пересоздаёт PNG-иконки из исходного логотипа. Иконки и шрифты поставляются вместе с приложением и не требуют сети после первого открытия. + +## Виджет статуса RideBus + +Встроенный iframe показывает свежий статус этого сервиса и API. При недоступности мониторинга отображается отсутствие данных. Адрес хостинга задаётся публичной переменной сборки: `VITE_STATUS_URL`. Если адрес не задан, production-виджет скрыт; в development используется http://127.0.0.1:5188. После изменения переменной нужна новая сборка. + +Виджет не получает сессию пользователя, API-ключи или данные приложения. Состояния, оформление, русская/английская локализация и обновления обслуживаются проектом ridebus-status. + +При запуске через Docker Compose укажите переменную URL в корневом `.env` проекта и пересоберите образ (`docker compose up -d --build`). URL передаётся как build argument; смена только runtime environment не обновляет уже собранный интерфейс. diff --git a/docker-compose.yml b/docker-compose.yml index 053deec..5fc7fe8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: . dockerfile: Dockerfile + args: + VITE_STATUS_URL: ${VITE_STATUS_URL:-} restart: unless-stopped init: true environment: diff --git a/src/App.tsx b/src/App.tsx index 43dfdaa..0ce937f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { useAppTheme } from './lib/theme'; import { Dialog } from './components/Dialog'; import { FeatureDiscovery } from './components/FeatureDiscovery'; import { StopList, StopListItem } from './components/StopList'; +import StatusWidget from './components/StatusWidget'; import { positiveParam } from './lib/links'; import { isTravelDate, localDate } from './lib/time'; import { countLabel, groupStops, type StopGroup } from './lib/stopGroups'; @@ -225,7 +226,7 @@ export default function App() { {pickerOpen && setPickerOpen(false)}>{stopCatalog()}} } - + {navigation(true)} {notice &&
{notice}
} diff --git a/src/components/StatusWidget.tsx b/src/components/StatusWidget.tsx new file mode 100644 index 0000000..a55415b --- /dev/null +++ b/src/components/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 + +