feat: add StatusWidget component and integrate it into the app with public status URL support

This commit is contained in:
2026-10-14 00:20:12 +03:00
parent 6fe039cefa
commit c50a85938b
7 changed files with 82 additions and 1 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 не обновляет уже собранный интерфейс.

View File

@@ -5,6 +5,8 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
VITE_STATUS_URL: ${VITE_STATUS_URL:-}
restart: unless-stopped
init: true
environment:

View File

@@ -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 && <Dialog title="Выбрать остановку" onClose={() => setPickerOpen(false)}>{stopCatalog()}</Dialog>}
</>}
</main>
<footer className="footer"><span>ridebus<small>Расписание в вашем кармане</small></span><div><NavLink to={`/about${urlSuffix}`}>О проекте <ArrowRight size={18} /></NavLink><a href="https://ridebus.by" target="_blank" rel="noreferrer">Сайт RideBus <ArrowUpRight size={20} /></a>{(import.meta.env.DEV || mode === 'demo') && <button onClick={() => switchMode(mode !== 'demo')}>{mode === 'demo' ? 'Реальное расписание' : 'Посмотреть пример'}</button>}</div></footer>
<footer className="footer"><span>ridebus<small>Расписание в вашем кармане</small></span><div><StatusWidget url={import.meta.env.VITE_STATUS_URL || (import.meta.env.DEV ? 'http://127.0.0.1:5188' : undefined)} services={['pwa', 'api']} locale="ru" theme={theme.resolved} /><NavLink to={`/about${urlSuffix}`}>О проекте <ArrowRight size={18} /></NavLink><a href="https://ridebus.by" target="_blank" rel="noreferrer">Сайт RideBus <ArrowUpRight size={20} /></a>{(import.meta.env.DEV || mode === 'demo') && <button onClick={() => switchMode(mode !== 'demo')}>{mode === 'demo' ? 'Реальное расписание' : 'Посмотреть пример'}</button>}</div></footer>
{navigation(true)}
<FeatureDiscovery home={homePage} restartKey={discoveryRestart} />
{notice && <div className="toast" role="status">{notice}<button aria-label="Закрыть уведомление" onClick={() => setNotice('')}><X size={16} /></button></div>}

View File

@@ -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<HTMLIFrameElement>(null);
const [ready, setReady] = useState(false);
const [status, setStatus] = useState<WidgetStatus>('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<WidgetStatus, string> = 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 <span className={className} data-ridebus-status-widget style={{ display: 'inline-block', position: 'relative', width: 260, maxWidth: '100%', height: 44, flexShrink: 0, verticalAlign: 'middle' }}>
<style>{'[data-ridebus-status-widget] iframe{transition:transform 160ms cubic-bezier(.2,.75,.25,1)}[data-ridebus-status-widget]:hover iframe{transform:translateY(-1px)}[data-ridebus-status-widget]>a:focus-visible{outline:2px solid #7777aa;outline-offset:2px}@media(prefers-reduced-motion:reduce){[data-ridebus-status-widget] iframe{transition:none}[data-ridebus-status-widget]:hover iframe{transform:none}}'}</style>
<iframe ref={frame} src={source} title={title} width="260" height="44" loading="lazy" referrerPolicy="no-referrer" sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" aria-hidden="true" tabIndex={-1} onLoad={() => frame.current?.contentWindow?.postMessage({ type: 'ridebus-status-widget-request' }, origin)} style={{ display: 'block', width: '100%', height: 44, border: 0, visibility: ready ? 'visible' : 'hidden', borderRadius: 14, pointerEvents: 'none' }} />
<a href={destination} target="_blank" rel="noopener noreferrer" aria-label={`${title}: ${ready ? labels[status] : fallback}`} aria-live="polite" style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', gap: 9, padding: '0 12px', borderRadius: 14, background: ready ? 'transparent' : theme === 'dark' ? '#202636' : '#f0f1f5', color: theme === 'dark' ? '#bac2d5' : '#636b80', fontSize: 13, fontWeight: 700, textDecoration: 'none', whiteSpace: 'nowrap' }}>{!ready && <><span aria-hidden="true" style={{ width: 7, height: 7, borderRadius: '50%', background: 'currentColor', flexShrink: 0 }} />{fallback}<span aria-hidden="true" style={{ marginLeft: 'auto' }}>↗</span></>}</a>
</span>;
}

View File

@@ -276,3 +276,5 @@ svg, .vkuiIcon { flex-shrink: 0; }
@include discovery.components;
@include stops.components;
@include motion.components;
.footer > div { flex-wrap: wrap; row-gap: 12px; max-width: 100%; }