feat: add RideBus status widget integration

- Implemented StatusWidget component for embedding RideBus service status.
- Created widget-client.js for client-side interaction with the status widget.
- Developed install-widget-integrations script to automate integration into PWA, website, and admin projects.
- Introduced server-side widget rendering in widget.mjs for generating status widget HTML.
- Added comprehensive tests for widget functionality and integration.
This commit is contained in:
2026-10-14 00:24:40 +03:00
parent 191b80b6b1
commit cb1977b65f
18 changed files with 477 additions and 0 deletions

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -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)); });

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -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 при реализации не изменялись.

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

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>;
}

48
public/widget-client.js Normal file
View File

@@ -0,0 +1,48 @@
(() => {
const { summary, serviceIds, locale, copy, states } = JSON.parse(document.getElementById('widget-data').textContent);
const link = document.querySelector('.widget'), label = document.querySelector('.label');
let expiry, expiryTimer, pollTimer, active = false;
link.href = new URL(`/?lang=${locale}`, location.origin).href;
const notify = () => { if (parent !== window) parent.postMessage({ type: 'ridebus-status-widget', version: 1, status: link.dataset.status }, '*'); };
function show(status) {
link.dataset.status = status;
label.textContent = copy[status];
link.setAttribute('aria-label', `${copy.title}: ${copy[status]}`);
notify();
}
function apply(data, elapsedMs = 0) {
if (data?.version !== 1 || !states.includes(data.overall) || !Number.isFinite(data.generatedAt) || !Number.isFinite(data.expiresAt)) throw new Error('Invalid status');
clearTimeout(expiryTimer);
expiry = Date.now() + Math.max(0, Math.min(90000, data.expiresAt - data.generatedAt - elapsedMs));
show(expiry > Date.now() ? data.overall : 'unknown');
expiryTimer = setTimeout(() => show('unknown'), Math.max(0, expiry - Date.now()));
}
async function refresh() {
if (active || document.hidden) return;
active = true;
clearTimeout(pollTimer);
const started = performance.now();
try {
const url = new URL('/api/widget', location.origin);
url.searchParams.set('services', serviceIds.join(','));
const response = await fetch(url, { cache: 'no-store', credentials: 'omit', signal: AbortSignal.timeout(5000) });
if (!response.ok) throw new Error('Status unavailable');
const data = await response.json();
apply(data, performance.now() - started);
} catch { clearTimeout(expiryTimer); show('unknown'); }
finally { active = false; if (!document.hidden) pollTimer = setTimeout(refresh, 30000); }
}
window.addEventListener('message', event => {
if (event.source === parent && event.data?.type === 'ridebus-status-widget-request') {
if (Date.now() >= expiry) show('unknown'); else notify();
}
});
document.addEventListener('visibilitychange', () => {
clearTimeout(pollTimer);
if (!document.hidden) { if (Date.now() >= expiry) show('unknown'); void refresh(); }
});
window.addEventListener('online', () => void refresh());
window.addEventListener('offline', () => { clearTimeout(expiryTimer); show('unknown'); });
apply(summary, performance.now());
pollTimer = setTimeout(refresh, 30000);
})();

View File

@@ -0,0 +1,55 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const projects = fileURLToPath(new URL('../../', import.meta.url));
const roots = { pwa: resolve(projects, 'ridebus-pwa'), website: resolve(projects, 'ridebus-website/ridebus-website'), admin: resolve(projects, 'polotsk-transit-api/frontend') };
// The landing uses the TypeScript JS compiler API; the status project uses
// the native TypeScript CLI, which does not expose transpileModule.
const ts = createRequire(resolve(roots.website, 'package.json'))('typescript');
const source = readFileSync(new URL('../integrations/StatusWidget.tsx', import.meta.url), 'utf8');
function save(root, path, value) { const target = resolve(root, path); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, value); }
function replace(root, path, before, after) {
const target = resolve(root, path), content = readFileSync(target, 'utf8');
if (content.includes(after)) return;
if (!content.includes(before)) throw new Error(`Integration anchor missing: ${target}`);
save(root, path, content.replace(before, after));
}
function append(root, path, value) {
const target = resolve(root, path), content = existsSync(target) ? readFileSync(target, 'utf8') : '';
if (!content.includes(value.trim())) save(root, path, `${content.trimEnd()}\n\n${value.trim()}\n`);
}
save(roots.pwa, 'src/components/StatusWidget.tsx', source);
replace(roots.pwa, 'src/App.tsx', "import { StopList, StopListItem } from './components/StopList';", "import { StopList, StopListItem } from './components/StopList';\nimport StatusWidget from './components/StatusWidget';");
replace(roots.pwa, 'src/App.tsx', '<div><NavLink to={`/about${urlSuffix}`}>', '<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}`}>');
append(roots.pwa, 'src/styles.scss', '.footer > div { flex-wrap: wrap; row-gap: 12px; max-width: 100%; }');
append(roots.pwa, '.env.example', '# Public status hosting URL, embedded in the production bundle.\n# VITE_STATUS_URL=https://your-status-domain.example');
save(roots.website, 'components/statusWidget/StatusWidget.tsx', source);
replace(roots.website, 'components/footer/footer.tsx', "import { CursorEffect } from '@/components/cursor';", "import { CursorEffect } from '@/components/cursor';\nimport StatusWidget from '@/components/statusWidget/StatusWidget';");
replace(roots.website, 'components/footer/footer.tsx', ' <p className={styles.author}>', ' <StatusWidget url={process.env.NEXT_PUBLIC_STATUS_URL || (process.env.NODE_ENV === \'development\' ? \'http://127.0.0.1:5188\' : undefined)} locale={locale === \'ru\' ? \'ru\' : \'en\'} theme="light" services={[\'website\', \'api\']} />\n <p className={styles.author}>');
append(roots.website, '.env.example', '# Public status hosting URL, embedded at build time.\n# NEXT_PUBLIC_STATUS_URL=https://your-status-domain.example');
const jsx = ts.transpileModule(source, { compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ESNext } }).outputText;
save(roots.admin, 'src/components/StatusWidget.jsx', jsx);
replace(roots.admin, 'src/components/AppLayout.jsx', "import { useCity } from '../contexts/CityContext';", "import { useCity } from '../contexts/CityContext';\nimport StatusWidget from './StatusWidget';");
replace(roots.admin, 'src/components/AppLayout.jsx', ' <div className="sidebar-footer">', ' <div className="sidebar-footer">\n <StatusWidget url={import.meta.env.VITE_STATUS_URL || (import.meta.env.DEV ? \'http://127.0.0.1:5188\' : undefined)} services={[\'admin\', \'api\']} locale="ru" theme="light" />');
append(roots.admin, '.env.example', '# Public status hosting URL, embedded in the production bundle.\n# VITE_STATUS_URL=https://your-status-domain.example');
const docs = `## Виджет статуса RideBus\n\nВстроенный iframe показывает свежий статус этого сервиса и API. При недоступности мониторинга отображается отсутствие данных. Адрес хостинга задаётся публичной переменной сборки: ${'{ENV}'}. Если адрес не задан, production-виджет скрыт; в development используется http://127.0.0.1:5188. После изменения переменной нужна новая сборка.\n\nВиджет не получает сессию пользователя, API-ключи или данные приложения. Состояния, оформление, русская/английская локализация и обновления обслуживаются проектом ridebus-status.\n`;
append(roots.pwa, 'README.md', docs.replace('{ENV}', '`VITE_STATUS_URL`'));
append(roots.website, 'README.md', docs.replace('{ENV}', '`NEXT_PUBLIC_STATUS_URL`'));
append(roots.admin, 'README.md', docs.replace('{ENV}', '`VITE_STATUS_URL`'));
replace(roots.pwa, 'Dockerfile', 'COPY public ./public\nRUN npm run build', 'COPY public ./public\nARG VITE_STATUS_URL=\nENV VITE_STATUS_URL=$VITE_STATUS_URL\nRUN npm run build');
replace(roots.pwa, 'docker-compose.yml', ' dockerfile: Dockerfile\n restart:', ' dockerfile: Dockerfile\n args:\n VITE_STATUS_URL: ${VITE_STATUS_URL:-}\n restart:');
replace(roots.website, 'Dockerfile', 'COPY . .\nRUN yarn build', 'COPY . .\nARG NEXT_PUBLIC_STATUS_URL=\nENV NEXT_PUBLIC_STATUS_URL=$NEXT_PUBLIC_STATUS_URL\nRUN yarn build');
replace(roots.website, 'docker-compose.yml', ' build: .', ' build:\n context: .\n args:\n NEXT_PUBLIC_STATUS_URL: ${NEXT_PUBLIC_STATUS_URL:-}');
replace(roots.admin, 'Dockerfile', 'ENV VITE_API_URL=$VITE_API_URL', 'ENV VITE_API_URL=$VITE_API_URL\nARG VITE_STATUS_URL=\nENV VITE_STATUS_URL=$VITE_STATUS_URL');
replace(dirname(roots.admin), 'docker-compose.yml', ' VITE_API_URL: ${API_URL:-/api/v1}', ' VITE_API_URL: ${API_URL:-/api/v1}\n VITE_STATUS_URL: ${VITE_STATUS_URL:-}');
append(dirname(roots.admin), '.env.example', '# Public status hosting URL passed to the frontend build.\n# VITE_STATUS_URL=https://your-status-domain.example');
for (const root of Object.values(roots)) append(root, 'README.md', 'При запуске через Docker Compose укажите переменную URL в корневом `.env` проекта и пересоберите образ (`docker compose up -d --build`). URL передаётся как build argument; смена только runtime environment не обновляет уже собранный интерфейс.');
append(dirname(roots.admin), 'README.md', '## Виджет статуса\n\nАдминка включает виджет сервиса `admin` и API. Для Docker Compose задайте публичный `VITE_STATUS_URL` в корневом `.env` перед сборкой frontend. Для запуска без Docker — в `frontend/.env`. Подробности: [frontend/README.md](frontend/README.md).');
console.log('Status widget integrated into PWA, website and admin sources.');

View File

@@ -10,6 +10,7 @@ import { demoSnapshot } from './demo.mjs';
import { createScenarioRunner } from './scenarios.mjs';
import { createNotifier } from './notifications.mjs';
import { buildFeed, feedOptions } from './feed.mjs';
import { widgetOptions, widgetSummary, widgetDocument } from './widget.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' };
@@ -39,6 +40,29 @@ export async function createStatusServer(config, { dev = false, monitorEnabled =
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 === '/api/widget' || url.pathname === '/embed') {
let options;
try { options = widgetOptions(url.searchParams, config.services, { embed: url.pathname === '/embed' }); }
catch (error) { json(res, 400, { error: error.message }); return; }
const summary = widgetSummary(store, config, options.serviceIds);
if (url.pathname === '/api/widget') { json(res, 200, summary); return; }
res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors *");
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(req.method === 'HEAD' ? undefined : widgetDocument(summary, options));
return;
}
const widgetAssets = {
'/widget-client.js': [resolve('public/widget-client.js'), 'text/javascript; charset=utf-8'],
'/widget/manrope-latin.woff2': [resolve('node_modules/@fontsource-variable/manrope/files/manrope-latin-wght-normal.woff2'), 'font/woff2'],
'/widget/manrope-cyrillic.woff2': [resolve('node_modules/@fontsource-variable/manrope/files/manrope-cyrillic-wght-normal.woff2'), 'font/woff2'],
};
if (widgetAssets[url.pathname]) {
const [path, mime] = widgetAssets[url.pathname];
res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': url.pathname.endsWith('.woff2') ? 'public, max-age=86400' : 'no-cache' });
if (req.method === 'HEAD') res.end();
else createReadStream(path).on('error', () => res.destroy()).pipe(res);
return;
}
if (url.pathname === '/healthz') {
// This is the status application's health, independent of target outages.
json(res, 200, { status: 'ok' }); return;

59
server/widget.mjs Normal file
View File

@@ -0,0 +1,59 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { Icon24ArrowUpRightOutline } from '@vkontakte/icons';
import { combinedStatus, maintenanceEnd } from './maintenance.mjs';
import { overallStatus } from './snapshot.mjs';
const arrow = renderToStaticMarkup(createElement(Icon24ArrowUpRightOutline, { width: 16, height: 16 }));
const states = ['operational', 'degraded', 'down', 'unknown', 'maintenance'];
export const widgetCopy = {
ru: { title: 'Статус сервисов RideBus', operational: 'Сервисы работают', degraded: 'Есть перебои', down: 'Сервисы недоступны', unknown: 'Нет свежих данных', maintenance: 'Плановые работы' },
en: { title: 'RideBus service status', operational: 'Services operational', degraded: 'Service disruption', down: 'Services unavailable', unknown: 'No fresh status data', maintenance: 'Scheduled maintenance' },
};
export function widgetOptions(params, services, { embed = false } = {}) {
const allowed = embed ? ['services', 'lang', 'theme'] : ['services'];
for (const key of params.keys()) if (!allowed.includes(key) || params.getAll(key).length !== 1) throw new Error('Invalid widget option');
const raw = params.get('services');
const ids = raw === null ? services.map(service => service.id) : raw.split(',');
if (!ids.length || new Set(ids).size !== ids.length || ids.some(id => !services.some(service => service.id === id))) throw new Error('Unknown or duplicate widget service');
const locale = params.get('lang') || 'ru', theme = params.get('theme') || 'auto';
if (!['ru', 'en'].includes(locale) || !['light', 'dark', 'auto'].includes(theme)) throw new Error('Invalid widget locale or theme');
return { serviceIds: ids, locale, theme };
}
export function widgetSummary(store, config, serviceIds, now = Date.now()) {
const deadlines = [];
const services = config.services.filter(service => serviceIds.includes(service.id)).map(service => {
const state = store.state(service), scenario = store.scenario(service);
const availabilityUntil = state ? state.checked_at + config.staleAfterSeconds * 1000 + 1 : 0;
const scenarioUntil = scenario ? scenario.checked_at + config.scenarioIntervalSeconds * 3000 + 1 : 0;
if (availabilityUntil > now) deadlines.push(availabilityUntil);
if (scenarioUntil > now) deadlines.push(scenarioUntil);
const availability = availabilityUntil > now ? state.status : 'unknown';
const journey = service.scenario ? { status: scenarioUntil > now ? scenario.status : 'unknown' } : null;
return { id: service.id, status: combinedStatus(availability, journey, store.inMaintenance(service, now)) };
});
for (const window of store.maintenances()) {
if (window.cancelledAt !== null || window.serviceIds.length && !window.serviceIds.some(id => serviceIds.includes(id))) continue;
for (const boundary of [window.startsAt, maintenanceEnd(window)]) if (boundary > now) deadlines.push(boundary);
}
return { version: 1, generatedAt: now, expiresAt: Math.min(now + 90000, ...deadlines), overall: overallStatus(services), services };
}
export function widgetDocument(summary, { serviceIds, locale, theme }) {
const copy = widgetCopy[locale];
const payload = JSON.stringify({ summary, serviceIds, locale, copy, states }).replaceAll('<', '\\u003c');
return `<!doctype html><html lang="${locale}" data-theme="${theme}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${copy.title}</title><style>
@font-face{font-family:Manrope;font-style:normal;font-weight:200 800;font-display:swap;src:url('/widget/manrope-latin.woff2') format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:Manrope;font-style:normal;font-weight:200 800;font-display:swap;src:url('/widget/manrope-cyrillic.woff2') format('woff2');unicode-range:U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}
*{box-sizing:border-box}html,body{margin:0;background:transparent;color-scheme:light;font-family:Manrope,system-ui,sans-serif}
:root{--neutral-bg:#f0f1f5;--neutral:#636b80;--green-bg:#edf6e4;--green:#477545;--amber-bg:#fff1d8;--amber:#936b1a;--red-bg:#ffe9e6;--red:#b34a43;--blue-bg:#eeecfc;--blue:#6263aa}
html[data-theme=dark]{color-scheme:dark;--neutral-bg:#202636;--neutral:#bac2d5;--green-bg:#213425;--green:#abd296;--amber-bg:#40331f;--amber:#f1d18f;--red-bg:#402829;--red:#efa5a0;--blue-bg:#2a2d49;--blue:#c0bdf0}
@media(prefers-color-scheme:dark){html[data-theme=auto]{color-scheme:dark;--neutral-bg:#202636;--neutral:#bac2d5;--green-bg:#213425;--green:#abd296;--amber-bg:#40331f;--amber:#f1d18f;--red-bg:#402829;--red:#efa5a0;--blue-bg:#2a2d49;--blue:#c0bdf0}}
.widget{display:flex;align-items:center;gap:9px;width:100%;height:44px;padding:0 12px;border:1px solid transparent;border-radius:14px;background:var(--neutral-bg);color:var(--neutral);text-decoration:none;font-size:13px;font-weight:700;transition:background-color 160ms ease,color 160ms ease,box-shadow 160ms ease;animation:appear 160ms ease-out}
.widget[data-status=operational]{background:var(--green-bg);color:var(--green)}.widget[data-status=degraded]{background:var(--amber-bg);color:var(--amber)}.widget[data-status=down]{background:var(--red-bg);color:var(--red)}.widget[data-status=maintenance]{background:var(--blue-bg);color:var(--blue)}
.widget:hover{box-shadow:inset 0 0 0 1px currentColor}.widget:focus-visible{outline:2px solid currentColor;outline-offset:-3px}.dot{width:7px;height:7px;flex-shrink:0;border-radius:50%;background:currentColor;box-shadow:0 0 0 3px color-mix(in srgb,currentColor 12%,transparent)}.label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.widget svg{margin-left:auto;flex-shrink:0;transition:transform 160ms cubic-bezier(.2,.75,.25,1)}.widget:hover svg{transform:translate(1px,-1px)}
@keyframes appear{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:translateY(0)}}@media(prefers-reduced-motion:reduce){.widget,.widget svg{animation:none;transition:none}.widget:hover svg{transform:none}}
</style></head><body><a class="widget" data-status="unknown" href="/?lang=${locale}" target="_blank" rel="noopener noreferrer" aria-label="${copy.title}"><span class="dot" aria-hidden="true"></span><span class="label" role="status" aria-live="polite">${copy.unknown}</span>${arrow}</a><script type="application/json" id="widget-data">${payload}</script><script src="/widget-client.js" defer></script></body></html>`;
}

145
test/widget.test.mjs Normal file
View File

@@ -0,0 +1,145 @@
import test from 'node:test';
import assert from 'node:assert/strict';
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 { widgetOptions, widgetSummary } from '../server/widget.mjs';
import { createStatusServer } from '../server/index.mjs';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { widgetCopy } from '../server/widget.mjs';
const config = loadConfig({ RIDEBUS_API_KEY: 'private-api-key', STATUS_BOARD_TEST_COOKIE: 'private-cookie', STATUS_FAILURE_THRESHOLD: '1' });
const website = config.services.find(service => service.id === 'website');
const api = config.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' };
const now = Date.now();
function record(store, service, result, time = now) { store.record(service, result, nextState(store.state(service), result, config), time); }
test('widget uses only selected services and never publishes credentials or transport data', () => {
const store = openStore(':memory:');
try {
record(store, website, good);
record(store, config.services.find(service => service.id === 'board'), bad);
const result = widgetSummary(store, config, ['website'], now);
assert.equal(result.overall, 'operational');
assert.deepEqual(result.services, [{ id: 'website', status: 'operational' }]);
assert.doesNotMatch(JSON.stringify(result), /private-api-key|private-cookie|signature|baseUrl|stopId|latencyMs|httpCode/);
assert.equal(widgetSummary(store, config, ['board'], now).overall, 'down');
} finally { store.close(); }
});
test('widget cannot keep a stale successful check green and advertises its next expiry', () => {
const store = openStore(':memory:');
try {
record(store, website, good);
const deadline = now + config.staleAfterSeconds * 1000;
assert.equal(widgetSummary(store, config, ['website'], deadline).overall, 'operational');
assert.equal(widgetSummary(store, config, ['website'], deadline).expiresAt, deadline + 1);
assert.equal(widgetSummary(store, config, ['website'], deadline + 1).overall, 'unknown');
assert.equal(widgetSummary(store, config, ['website'], now).expiresAt, now + 90000);
} finally { store.close(); }
});
test('journey failure and maintenance in the widget agree with the full status page', () => {
const store = openStore(':memory:');
try {
record(store, api, good);
store.recordScenario(api, { ...bad, steps: [], mode: 'http' }, nextState(null, bad, config), now);
assert.equal(widgetSummary(store, config, ['api'], now).overall, 'degraded');
assert.equal(widgetSummary(store, config, ['api'], now).services[0].status, makeSnapshot(store, config, 7, now).services.find(service => service.id === 'api').status);
store.createMaintenance({ serviceIds: ['api'], titleRu: 'Работы', titleEn: 'Work', messageRu: 'Текст', messageEn: 'Text', startsAt: now + 1000, endsAt: now + 5000 }, now);
assert.equal(widgetSummary(store, config, ['api'], now).expiresAt, now + 1000);
assert.equal(widgetSummary(store, config, ['api'], now + 1000).overall, 'maintenance');
assert.equal(widgetSummary(store, config, ['api'], now + 1000).expiresAt, now + 5000);
assert.equal(widgetSummary(store, config, ['api'], now + 5000).overall, 'degraded');
} finally { store.close(); }
});
test('widget option validation rejects unknown services, duplicates, demo and unsafe presentation', () => {
assert.deepEqual(widgetOptions(new URLSearchParams('services=website,api&lang=en&theme=dark'), config.services, { embed: true }), { serviceIds: ['website', 'api'], locale: 'en', theme: 'dark' });
for (const query of ['services=missing', 'services=', 'services=api,api', 'services=api&services=website', 'demo=1', 'lang=fr', 'theme=<script>']) assert.throws(() => widgetOptions(new URLSearchParams(query), config.services, { embed: true }));
assert.throws(() => widgetOptions(new URLSearchParams('lang=en'), config.services));
});
test('public embed and assets work independently of the host app and provide real, read-only status', async () => {
const app = await createStatusServer(loadConfig({ STATUS_DB_PATH: ':memory:', PORT: '0', HOST: '127.0.0.1' }), { monitorEnabled: false });
try {
record(app.store, website, good);
await new Promise(resolve => 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/widget?services=website`);
assert.equal(response.headers.get('cache-control'), 'no-store');
const result = await response.json();
assert.equal(result.overall, 'operational');
assert.equal(result.services.length, 1);
const embed = await fetch(`${origin}/embed?services=website&lang=en&theme=dark`);
assert.equal(embed.status, 200);
assert.match(embed.headers.get('content-security-policy'), /frame-ancestors \*/);
const html = await embed.text();
assert.match(html, /lang="en" data-theme="dark"/);
assert.match(html, /vkuiIcon/);
assert.match(html, /"overall":"operational"/);
assert.doesNotMatch(html, /private-api-key|private-cookie/);
for (const asset of ['/widget-client.js', '/widget/manrope-latin.woff2', '/widget/manrope-cyrillic.woff2']) assert.equal((await fetch(origin + asset)).status, 200);
assert.equal((await fetch(`${origin}/api/widget?demo=1`)).status, 400);
assert.equal((await fetch(`${origin}/embed?services=website`, { method: 'POST' })).status, 405);
} finally { await app.close(); }
});
function widgetClientFixture({ serverNow = 1000, navigationElapsed = 0, slowSuccessfulRefresh = false } = {}) {
let clock = 1000, timerId = 0;
const events = new Map(), timers = new Map(), messages = [], label = {}, link = { dataset: {}, setAttribute() {} };
const parent = { postMessage: value => messages.push(value) };
const window = { addEventListener: (name, callback) => events.set(name, callback) };
const bootstrap = { summary: { version: 1, generatedAt: serverNow, expiresAt: serverNow + 1000, overall: 'operational' }, serviceIds: ['website'], locale: 'ru', copy: widgetCopy.ru, states: ['operational', 'degraded', 'down', 'unknown', 'maintenance'] };
const document = { hidden: false, getElementById: () => ({ textContent: JSON.stringify(bootstrap) }), querySelector: selector => selector === '.widget' ? link : label, addEventListener: (name, callback) => events.set(name, callback) };
runInNewContext(readFileSync(new URL('../public/widget-client.js', import.meta.url), 'utf8'), {
document, window, parent, location: { origin: 'https://status.example' }, Date: { now: () => clock }, performance: { now: () => clock - 1000 + navigationElapsed }, URL, AbortSignal,
setTimeout: (callback, delay) => { const id = ++timerId; timers.set(id, { callback, delay }); return id; }, clearTimeout: id => timers.delete(id),
fetch: async () => { if (slowSuccessfulRefresh) { clock += 1001; return Response.json(bootstrap.summary); } throw new Error('Monitoring unavailable'); },
});
return { events, timers, messages, parent, link, label, setClock: value => { clock = value; } };
}
test('a delayed browser timer cannot reply with an expired green status', () => {
const fixture = widgetClientFixture();
assert.equal(fixture.link.dataset.status, 'operational');
fixture.setClock(2001);
fixture.events.get('message')({ source: fixture.parent, data: { type: 'ridebus-status-widget-request' } });
assert.equal(fixture.link.dataset.status, 'unknown');
assert.equal(fixture.label.textContent, widgetCopy.ru.unknown);
assert.equal(fixture.messages.at(-1).status, 'unknown');
});
test('offline and a failed refresh replace the visible green status immediately', async () => {
const offline = widgetClientFixture();
offline.events.get('offline')();
assert.equal(offline.link.dataset.status, 'unknown');
const refresh = widgetClientFixture();
await [...refresh.timers.values()].find(timer => timer.delay === 30000).callback();
assert.equal(refresh.link.dataset.status, 'unknown');
assert.equal(refresh.messages.at(-1).status, 'unknown');
});
test('browser freshness uses remaining lifetime rather than requiring matching server clocks', () => {
const fixture = widgetClientFixture({ serverNow: 9000000 });
assert.equal(fixture.link.dataset.status, 'operational');
const count = fixture.messages.length;
fixture.events.get('message')({ source: {}, data: { type: 'ridebus-status-widget-request' } });
assert.equal(fixture.messages.length, count);
fixture.setClock(2001);
fixture.events.get('message')({ source: fixture.parent, data: { type: 'ridebus-status-widget-request' } });
assert.equal(fixture.link.dataset.status, 'unknown');
});
test('a snapshot expiring while the page or response loads cannot become green on receipt', async () => {
const navigation = widgetClientFixture({ navigationElapsed: 1001 });
assert.equal(navigation.link.dataset.status, 'unknown');
const response = widgetClientFixture({ slowSuccessfulRefresh: true });
assert.equal(response.link.dataset.status, 'operational');
await [...response.timers.values()].find(timer => timer.delay === 30000).callback();
assert.equal(response.link.dataset.status, 'unknown');
});