feat: implement board device service with CRUD operations and validation

- Add boardDeviceService for managing device pairing, status, claiming, and revocation.
- Introduce boardDeviceSettings for default settings and validation logic.
- Create integration tests for device lifecycle, concurrent claims, and updates.
- Add unit tests for device pairing, claiming, and settings validation.
- Implement API tests for device endpoints with JWT authentication.
- Develop in-memory repository for testing purposes.
- Create frontend components for managing devices, including a form for device settings.
- Style DevicesPage with CSS for better user experience.
This commit is contained in:
2026-10-10 00:47:26 +03:00
parent 249c338c2a
commit 360b684019
17 changed files with 448 additions and 2 deletions

View File

@@ -372,3 +372,27 @@ SELECT PostGIS_Version();
## 📄 Лицензия
MIT License - используйте свободно!
## Устройства RideBus Board
В админке появился раздел **Устройства** (только для администратора). Он работает с обновлённым проектом `ridebus-board`: одноразовый код из четырёх цифр на экране привязывает отдельный браузер телевизора. В форме выбираются площадка и маршрут, тема/палитры, профиль читаемости, число строк, интервалы, информационные блоки и рекламный предпросмотр. Город задаётся переключателем транспортной сети в админке; устройства и каталоги изолированы по выбранному городу.
Перед запуском примените **только новую** миграцию `backend/migrations/016_board_devices.sql` к существующей базе данных (обычным способом миграций при развёртывании). Не перезапускайте общий `npm run migrate` на уже созданной базе: существующий скрипт повторяет все старые SQL-файлы. Таблицы `board_pairings` и `board_devices` содержат одноразовые сессии и зарегистрированные устройства. Миграция 016 повторяемая.
В сервере Board включите `BOARD_REMOTE_SETTINGS=true`, укажите обновлённый API в `RIDEBUS_API_BASE_URL` и адрес этой админки в необязательном `BOARD_ADMIN_URL`. Серверный RideBus API-ключ остаётся на Board и не выдаётся телевизору. Используйте HTTPS с Secure cookie.
Новый узкий API монтируется до общей API-key авторизации:
- `POST /api/v1/board-devices/pairings` — создать сессию, TTL 5 минут, до 10 запросов за 10 минут с одного IP.
- `GET /api/v1/board-devices/self` и `POST /self/heartbeat` — только индивидуальный `X-Board-Token`.
- `GET /api/v1/board-devices/admin` и `/admin/routes?stopId=…` — администратор + контекст города.
- `POST /admin/claim` — код, название, настройки; до 5 попыток за 5 минут с одного IP.
- `PUT /admin/:id` — настройки с ожидаемой `revision`; конфликт даёт 409.
- `POST /admin/:id/revoke` — отозвать устройство в выбранном городе.
Короткий код не является токеном доступа. Секрет — 32 случайных байта, в базе только хеш; занятие кода и создание устройства атомарны в одной транзакции. Повторный запрос устройства после потерянного HTTP-ответа узнаёт уже созданную привязку. В списках и конфигурации секреты не возвращаются.
Табло опрашивает настройки раз в 15 секунд, отправляет сигнал работы раз в 30 и подтверждает применённую версию. «В сети» означает связь за последние 90 секунд. Офлайн-устройство продолжает работать со своим кешем, изменения остаются «Ожидают применения». Отзыв доступа действует после следующей связи с сервером. Команд перезагрузки ОС, удалённого shell или установки ПО нет.
Проверки: `npm test --prefix backend`, `npm run build --prefix frontend`. Отдельный PostgreSQL-тест устройств запускается с `BOARD_DEVICE_TEST_DATABASE_URL` (имя БД должно содержать `test`); он создаёт и удаляет изолированную схему, не использует рабочие таблицы. Совместный визуальный стенд находится в `ridebus-board/test/fixtures/device-flow-server.mjs` и использует явные тестовые данные.

View File

@@ -0,0 +1,28 @@
BEGIN;
CREATE TABLE IF NOT EXISTS board_pairings (
code CHAR(4) PRIMARY KEY CHECK (code ~ '^[0-9]{4}$'),
token_hash CHAR(64) NOT NULL UNIQUE,
device_info JSONB NOT NULL DEFAULT '{}',
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS board_pairings_expiry ON board_pairings(expires_at);
CREATE TABLE IF NOT EXISTS board_devices (
id UUID PRIMARY KEY,
name VARCHAR(100) NOT NULL,
city_id INTEGER NOT NULL REFERENCES cities(id) ON DELETE RESTRICT,
stop_id INTEGER NOT NULL REFERENCES stops(id) ON DELETE RESTRICT,
token_hash CHAR(64) NOT NULL UNIQUE,
settings JSONB NOT NULL,
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
applied_revision INTEGER NOT NULL DEFAULT 0 CHECK (applied_revision >= 0 AND applied_revision <= revision),
last_seen_at TIMESTAMPTZ,
device_info JSONB NOT NULL DEFAULT '{}',
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS board_devices_city ON board_devices(city_id, created_at);
COMMIT;

View File

@@ -79,6 +79,12 @@ try {
logger.warn('Upload routes disabled - multer not installed. Run: npm install multer');
}
// Pairing and per-device credentials have a separate narrow authentication contract.
const { createBoardDevicesRouter } = require('./routes/boardDevices');
const { createBoardDeviceRepository } = require('./services/boardDeviceRepository');
const { createBoardDeviceService } = require('./services/boardDeviceService');
app.use('/api/v1/board-devices', createBoardDevicesRouter(createBoardDeviceService(createBoardDeviceRepository(pool))));
// Passenger telemetry has its own Firebase ID-token authentication inside apiRoutes.
// All other API routes keep the existing API-key/custom-JWT contract.
app.use('/api/v1',

View File

@@ -0,0 +1,25 @@
const express = require('express');
const { createHash } = require('node:crypto');
const rateLimit = require('express-rate-limit');
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const { resolveCity } = require('../middleware/city');
function createBoardDevicesRouter(service, { cityMiddleware = resolveCity } = {}) {
const router = express.Router();
router.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
const limited = (max, windowMs, keyGenerator) => rateLimit({ max, windowMs, ...(keyGenerator ? { keyGenerator } : {}), standardHeaders: true, legacyHeaders: false, message: { error: 'device_rate_limit', message: 'Слишком много попыток. Попробуйте позже' } });
const handle = fn => async (req, res) => { try { res.json(await fn(req)); } catch (error) { res.status(error.status || 503).json({ error: error.code || 'devices_unavailable', message: error.status ? error.message : 'Управление устройствами временно недоступно' }); } };
router.use(limited(20000, 600000));
const deviceKey = req => createHash('sha256').update(req.get('X-Board-Token') || req.ip).digest('hex');
router.post('/pairings', limited(10, 600000), handle(req => service.start(req.body)));
// Device routes use only individual device credentials, never an admin JWT/API key.
router.get('/self', limited(600, 600000, deviceKey), handle(req => service.status(req.get('X-Board-Token'))));
router.post('/self/heartbeat', limited(300, 600000, deviceKey), handle(req => service.heartbeat(req.get('X-Board-Token'), req.body)));
router.use('/admin', authenticateToken, requireAdmin, cityMiddleware);
router.get('/admin/routes', handle(req => service.routes(req.city.id, Number(req.query.stopId))));
router.get('/admin', handle(req => service.list(req.city.id)));
router.post('/admin/claim', limited(5, 300000), handle(req => service.claim(req.city.id, req.body, req.user.id)));
router.put('/admin/:id', handle(req => service.update(req.params.id, req.city.id, req.body, req.user.id)));
router.post('/admin/:id/revoke', handle(req => service.revoke(req.params.id, req.city.id, req.user.id)));
return router;
}
module.exports = { createBoardDevicesRouter };

View File

@@ -0,0 +1,39 @@
// All claims are transactional. A lost HTTP reply can be retried using the same token.
function createBoardDeviceRepository(pool) {
const projection = 'd.*, c.name AS city_name, s.name AS stop_name, s.description AS stop_direction';
const joins = 'FROM board_devices d JOIN cities c ON c.id=d.city_id JOIN stops s ON s.id=d.stop_id';
return {
async createPairing(pairing) {
await pool.query('DELETE FROM board_pairings WHERE expires_at <= now()');
await pool.query('INSERT INTO board_pairings(code,token_hash,device_info,expires_at) VALUES($1,$2,$3,$4)', [pairing.code, pairing.token_hash, pairing.device_info, pairing.expires_at]);
return pairing;
},
async pairing(hash) { return (await pool.query('SELECT * FROM board_pairings WHERE token_hash=$1', [hash])).rows[0]; },
async device(hash) { return (await pool.query(`SELECT ${projection} ${joins} WHERE d.token_hash=$1`, [hash])).rows[0]; },
async routes(cityId, stopId) { return (await pool.query('SELECT DISTINCT r.id,r.route_number,r.name FROM routes r JOIN route_stops rs ON rs.route_id=r.id WHERE r.city_id=$1 AND rs.stop_id=$2 ORDER BY r.route_number,r.name', [cityId, stopId])).rows; },
async catalog(cityId, settings) {
const stop = (await pool.query('SELECT s.id FROM stops s JOIN cities c ON c.id=s.city_id WHERE s.id=$1 AND s.city_id=$2 AND c.is_active=true', [settings.stopId, cityId])).rows[0];
if (!stop) return false;
return !settings.routeFilter || Boolean((await pool.query('SELECT r.id FROM routes r JOIN route_stops rs ON rs.route_id=r.id WHERE r.id=$1 AND r.city_id=$2 AND rs.stop_id=$3', [Number(settings.routeFilter), cityId, settings.stopId])).rows[0]);
},
async claim(code, record) {
const db = await pool.connect();
try {
await db.query('BEGIN');
const pairing = (await db.query('DELETE FROM board_pairings WHERE code=$1 AND expires_at>now() RETURNING *', [code])).rows[0];
if (!pairing) { await db.query('ROLLBACK'); return null; }
const device = (await db.query('INSERT INTO board_devices(id,name,city_id,stop_id,token_hash,settings,device_info,created_by,updated_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$8) RETURNING *', [record.id, record.name, record.city_id, record.stop_id, pairing.token_hash, record.settings, pairing.device_info, record.created_by])).rows[0];
await db.query('COMMIT'); return device;
} catch (error) { await db.query('ROLLBACK'); throw error; } finally { db.release(); }
},
async list(cityId) { return (await pool.query(`SELECT ${projection} ${joins} WHERE d.city_id=$1 ORDER BY d.created_at DESC`, [cityId])).rows; },
async update(id, cityId, input) {
return (await pool.query('UPDATE board_devices SET name=$3,stop_id=$4,settings=$5,revision=revision+1,updated_at=now(),updated_by=$6 WHERE id=$1 AND city_id=$2 AND revision=$7 AND revoked_at IS NULL RETURNING *', [id, cityId, input.name, input.settings.stopId, input.settings, input.userId, input.revision])).rows[0];
},
async revoke(id, cityId, userId) { return (await pool.query('UPDATE board_devices SET revoked_at=COALESCE(revoked_at,now()),updated_by=$3,updated_at=now() WHERE id=$1 AND city_id=$2 RETURNING id', [id, cityId, userId])).rows[0]; },
async heartbeat(hash, revision, info) {
return (await pool.query('UPDATE board_devices SET last_seen_at=now(),applied_revision=GREATEST(applied_revision,LEAST(revision,$2)),device_info=$3 WHERE token_hash=$1 AND revoked_at IS NULL RETURNING id', [hash, revision, info])).rows[0];
},
};
}
module.exports = { createBoardDeviceRepository };

View File

@@ -0,0 +1,74 @@
const { randomBytes, randomInt, randomUUID, createHash } = require('node:crypto');
const { validSettings, deviceInfo, DeviceError } = require('./boardDeviceSettings');
const tokenHash = token => createHash('sha256').update(token).digest('hex');
function credential(token) {
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw new DeviceError(401, 'invalid_device_token');
return tokenHash(token);
}
function name(value) {
if (typeof value !== 'string' || !value.trim() || value.trim().length > 100) throw new DeviceError(400, 'invalid_name', 'Укажите название устройства до 100 символов');
return value.trim();
}
function deviceId(value) { if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) throw new DeviceError(400, 'invalid_device_id'); return value; }
function publicDevice(row, now) {
return { id: row.id, name: row.name, cityId: row.city_id, cityName: row.city_name, stopName: row.stop_name, stopDirection: row.stop_direction || '', settings: row.settings,
revision: row.revision, appliedRevision: row.applied_revision, lastSeenAt: row.last_seen_at, deviceInfo: row.device_info, revokedAt: row.revoked_at,
online: !row.revoked_at && Boolean(row.last_seen_at && now - Date.parse(row.last_seen_at) < 90000) };
}
function createBoardDeviceService(repository, { now = Date.now, makeCode = () => String(randomInt(10000)).padStart(4, '0') } = {}) {
async function checked(cityId, input) {
const settings = validSettings(input);
if (!await repository.catalog(cityId, settings)) throw new DeviceError(400, 'invalid_stop_or_route', 'Остановка или маршрут не принадлежат выбранному городу и площадке');
return settings;
}
return {
async start(info) {
const token = randomBytes(32).toString('base64url');
for (let attempt = 0; attempt < 30; attempt++) {
const code = makeCode(), expiresAt = new Date(now() + 300000).toISOString();
try { await repository.createPairing({ code, token_hash: tokenHash(token), device_info: deviceInfo(info), expires_at: expiresAt }); return { token, state: 'pending', code, expiresAt, pollSeconds: 3 }; }
catch (error) { if (error.code !== '23505') throw error; }
}
throw new DeviceError(503, 'pairing_busy', 'Попробуйте подключить табло позже');
},
async status(token) {
const hash = credential(token), row = await repository.device(hash);
if (row) {
if (row.revoked_at) throw new DeviceError(410, 'device_revoked');
return { state: 'paired', device: publicDevice(row, now()), pollSeconds: 15 };
}
const pairing = await repository.pairing(hash);
if (!pairing) throw new DeviceError(401, 'invalid_device_token');
if (Date.parse(pairing.expires_at) <= now()) return { state: 'expired' };
return { state: 'pending', code: pairing.code, expiresAt: pairing.expires_at, pollSeconds: 3 };
},
async claim(cityId, input, userId) {
if (typeof input?.code !== 'string' || !/^\d{4}$/.test(input.code)) throw new DeviceError(400, 'invalid_code', 'Введите четыре цифры с экрана табло');
const settings = await checked(cityId, input.settings), label = name(input.name);
const row = await repository.claim(input.code, { id: randomUUID(), name: label, city_id: cityId, stop_id: settings.stopId, settings, created_by: userId });
if (!row) throw new DeviceError(409, 'code_expired', 'Код истёк или уже использован. Проверьте код на табло');
return { id: row.id };
},
async routes(cityId, stopId) { await checked(cityId, { stopId }); return repository.routes(cityId, stopId); },
async list(cityId) { return (await repository.list(cityId)).map(row => publicDevice(row, now())); },
async update(id, cityId, input, userId) {
deviceId(id);
if (!Number.isInteger(input?.revision) || input.revision < 1) throw new DeviceError(400, 'invalid_revision');
const settings = await checked(cityId, input.settings);
const row = await repository.update(id, cityId, { name: name(input.name), settings, revision: input.revision, userId });
if (!row) throw new DeviceError(409, 'revision_conflict', 'Устройство изменено или отвязано. Обновите список и откройте настройки заново');
return { id: row.id, revision: row.revision };
},
async revoke(id, cityId, userId) { if (!await repository.revoke(deviceId(id), cityId, userId)) throw new DeviceError(404, 'device_not_found'); return { revoked: true }; },
async heartbeat(token, input) {
const hash = credential(token);
if (!Number.isInteger(input?.appliedRevision) || input.appliedRevision < 0) throw new DeviceError(400, 'invalid_revision');
const row = await repository.device(hash);
if (!row || row.revoked_at) throw new DeviceError(410, 'device_revoked');
if (input.appliedRevision > row.revision) throw new DeviceError(400, 'invalid_revision');
if (!await repository.heartbeat(hash, input.appliedRevision, deviceInfo(input))) throw new DeviceError(410, 'device_revoked');
return { ok: true };
},
};
}
module.exports = { createBoardDeviceService, tokenHash, publicDevice };

View File

@@ -0,0 +1,28 @@
const defaults = {
stopId: 1, demo: false, mainSeconds: 25, infoSeconds: 8, holdNear: true, rowCount: 4, routeFilter: '',
theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue',
infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true,
showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near',
adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.',
};
class DeviceError extends Error {
constructor(status, code, message) { super(message || code); this.status = status; this.code = code; }
}
function validSettings(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new DeviceError(400, 'invalid_settings', 'Проверьте настройки табло');
const value = { ...defaults, ...input, demo: false };
const fail = key => { throw new DeviceError(400, 'invalid_settings', `Некорректное поле: ${key}`); };
for (const key of Object.keys(input)) if (!Object.hasOwn(defaults, key)) fail(key);
for (const [key, min, max] of [['stopId', 1, 1000000], ['mainSeconds', 5, 120], ['infoSeconds', 5, 60], ['rowCount', 2, 6], ['adSeconds', 5, 30]]) {
if (!Number.isInteger(value[key]) || value[key] < min || value[key] > max) fail(key);
}
for (const key of ['holdNear', 'infoServiceEnabled', 'infoWeatherEnabled', 'infoQrEnabled', 'showNextSameRoute', 'longWaitEnabled', 'adsEnabled']) if (typeof value[key] !== 'boolean') fail(key);
for (const [key, choices] of Object.entries({ theme: ['dark', 'light'], colorScheme: ['lime', 'amber', 'cyan', 'mint', 'lavender', 'white'], lightColorScheme: ['blue', 'forest', 'crimson', 'violet', 'teal', 'graphite'], displayProfile: ['tv', 'street'], viewingDistance: ['near', 'medium', 'far'] })) if (!choices.includes(value[key])) fail(key);
if (typeof value.routeFilter !== 'string' || !/^(|[1-9]\d{0,8})$/.test(value.routeFilter)) fail('routeFilter');
for (const [key, max] of [['adTitle', 80], ['adText', 200]]) if (typeof value[key] !== 'string' || value[key].length > max) fail(key);
return value;
}
function deviceInfo(input = {}) {
return { width: Math.max(0, Math.min(16384, Math.round(Number(input.width) || 0))), height: Math.max(0, Math.min(16384, Math.round(Number(input.height) || 0))), version: typeof input.version === 'string' ? input.version.slice(0, 40) : '' };
}
module.exports = { defaults, validSettings, deviceInfo, DeviceError };

View File

@@ -233,7 +233,7 @@ class TelegramImportService {
await this.deactivate(db, row.id);
const bounds = dayBounds(input.service_date);
await this.saveAlert(db, row.id, { city_id: cityId, ...targets, alert_type: alertType, title,
message: `${message}\nИсточник: ${sourceUrl(row)}`, start_time: new Date(Math.max(Date.parse(bounds.start), this.now().getTime())).toISOString(), end_time: bounds.end, is_active: true });
message: `${message}`, start_time: new Date(Math.max(Date.parse(bounds.start), this.now().getTime())).toISOString(), end_time: bounds.end, is_active: true });
} else await this.deactivate(db, row.id);
const parsed = input.action === 'publish' ? { ...row.parsed, serviceDate: input.service_date, alertType, manual: true, selectedRouteNumbers: input.route_numbers } : row.parsed;
await db.query(`UPDATE telegram_import_messages SET status=$2, reviewed_by=$3, parsed=$4::jsonb, updated_at=now() WHERE id=$1`, [row.id, input.action === 'publish' ? 'published' : 'dismissed', userId, JSON.stringify(parsed)]);

View File

@@ -0,0 +1,24 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const { Pool } = require('pg');
const { createBoardDeviceRepository } = require('../src/services/boardDeviceRepository');
const { createBoardDeviceService } = require('../src/services/boardDeviceService');
const { defaults } = require('../src/services/boardDeviceSettings');
test('PostgreSQL device lifecycle, concurrent claims/updates, city isolation and retry after a lost reply', { skip: !process.env.BOARD_DEVICE_TEST_DATABASE_URL }, async () => {
const url = process.env.BOARD_DEVICE_TEST_DATABASE_URL; assert.match(new URL(url).pathname, /test/i, 'Use a dedicated test database');
const admin = new Pool({ connectionString: url }); const schema = `board_device_test_${Date.now()}`; let pool;
try {
await admin.query(`CREATE SCHEMA ${schema}`); pool = new Pool({ connectionString: url, options: `-c search_path=${schema},public` });
await pool.query('CREATE TABLE cities(id INTEGER PRIMARY KEY,name TEXT,is_active BOOLEAN); CREATE TABLE users(id INTEGER PRIMARY KEY); CREATE TABLE stops(id INTEGER PRIMARY KEY,city_id INTEGER REFERENCES cities(id),name TEXT,description TEXT); CREATE TABLE routes(id INTEGER PRIMARY KEY,city_id INTEGER,route_number TEXT,name TEXT); CREATE TABLE route_stops(route_id INTEGER,stop_id INTEGER); INSERT INTO cities VALUES(1,\'Полоцк\',true),(2,\'Новополоцк\',true); INSERT INTO users VALUES(1); INSERT INTO stops VALUES(1,1,\'Швейная фабрика\',\'на центр\'),(2,2,\'Другая\',\'конечная\');');
const sql = await fs.readFile(path.join(__dirname, '../migrations/016_board_devices.sql'), 'utf8'); await pool.query(sql); await pool.query(sql);
const service = createBoardDeviceService(createBoardDeviceRepository(pool)); const p = await service.start({ width: 1920 });
const claims = await Promise.allSettled([service.claim(1,{code:p.code,name:'A',settings:defaults},1),service.claim(1,{code:p.code,name:'B',settings:defaults},1)]);
assert.equal(claims.filter(r=>r.status==='fulfilled').length,1); const row=(await service.status(p.token)).device;
assert.equal((await service.status(p.token)).device.id,row.id); assert.equal((await service.list(2)).length,0);
const updates=await Promise.allSettled([service.update(row.id,1,{name:'C',settings:defaults,revision:1},1),service.update(row.id,1,{name:'D',settings:defaults,revision:1},1)]);assert.equal(updates.filter(r=>r.status==='fulfilled').length,1);
await service.heartbeat(p.token,{appliedRevision:2});assert.equal((await service.list(1))[0].appliedRevision,2);
await assert.rejects(service.revoke(row.id,2,1),e=>e.status===404);await service.revoke(row.id,1,1);await assert.rejects(service.status(p.token),e=>e.status===410);
} finally { if(pool) await pool.end();await admin.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);await admin.end(); }
});

View File

@@ -0,0 +1,51 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { createBoardDeviceService, tokenHash } = require('../src/services/boardDeviceService');
const { defaults, validSettings } = require('../src/services/boardDeviceSettings');
const { memoryRepository } = require('./helpers/boardDeviceMemoryRepository');
test('device pairing expires, preserves leading zeroes, retries collisions and has a secret distinct from the display code', async () => {
let now = Date.now(), codes = ['0847', '0847', '0009']; const repo = memoryRepository({ now: () => now });
const service = createBoardDeviceService(repo, { now: () => now, makeCode: () => codes.shift() });
const a = await service.start({ width: 879, height: 593, version: 'test' });
assert.equal(a.code, '0847'); assert.match(a.token, /^[A-Za-z0-9_-]{43}$/); assert.equal(repo.pairings.get('0847').token_hash, tokenHash(a.token));
assert.equal((await service.start({})).code, '0009');
await assert.rejects(service.status('0847'), e => e.status === 401);
now += 300001; assert.equal((await service.status(a.token)).state, 'expired');
await assert.rejects(service.claim(1, { code: '0847', name: 'A', settings: defaults }, 1), e => e.status === 409);
});
test('only one concurrent claim succeeds and the same device credential survives retry/restart', async () => {
const service = createBoardDeviceService(memoryRepository()); const p = await service.start({});
const input = { code: p.code, name: 'Улица', settings: defaults };
const claims = await Promise.allSettled([service.claim(1, input, 1), service.claim(1, input, 2)]);
assert.equal(claims.filter(c => c.status === 'fulfilled').length, 1);
assert.equal((await service.status(p.token)).state, 'paired'); assert.equal((await service.status(p.token)).device.name, 'Улица');
const json = JSON.stringify(await service.list(1)); assert.ok(!json.includes(p.token)); assert.ok(!json.includes(tokenHash(p.token)));
});
test('city/stop/route validation happens before consuming a pairing code', async () => {
const service = createBoardDeviceService(memoryRepository()); const p = await service.start({});
for (const settings of [{ ...defaults, stopId: 3 }, { ...defaults, routeFilter: '2' }, { ...defaults, rowCount: 7 }, { ...defaults, colorScheme: 'sky' }]) await assert.rejects(service.claim(1, { code: p.code, name: 'A', settings }, 1), e => e.status === 400);
assert.equal((await service.status(p.token)).state, 'pending'); await service.claim(2, { code: p.code, name: 'B', settings: { ...defaults, stopId: 3, routeFilter: '2' } }, 1);
assert.equal((await service.list(1)).length, 0); assert.equal((await service.list(2)).length, 1);
assert.deepEqual(await service.routes(2, 3), [{ id: 2, city_id: 2, route_number: '1', name: 'Новополоцк', stops: [3] }]);
});
test('configuration revisions are optimistic and applied/online state comes from device acknowledgement', async () => {
let now = Date.now(); const service = createBoardDeviceService(memoryRepository({ now: () => now }), { now: () => now }); const p = await service.start({}); const d = await service.claim(1, { code: p.code, name: 'A', settings: defaults }, 1);
const input = { name: 'A', revision: 1, settings: { ...defaults, theme: 'light', lightColorScheme: 'forest', infoQrEnabled: false } };
await assert.rejects(service.update(d.id, 2, { ...input, settings: { ...defaults, stopId: 3 } }, 1), e => e.status === 409);
const r = await Promise.allSettled([service.update(d.id, 1, input, 1), service.update(d.id, 1, input, 1)]); assert.equal(r.filter(x => x.status === 'fulfilled').length, 1);
let row = (await service.list(1))[0]; assert.equal(row.revision, 2); assert.equal(row.appliedRevision, 0); assert.equal(row.online, false);
await service.heartbeat(p.token, { appliedRevision: 2, width: 1920, height: 1080 }); row = (await service.list(1))[0]; assert.equal(row.appliedRevision, 2); assert.equal(row.online, true);
await service.heartbeat(p.token, { appliedRevision: 1 }); assert.equal((await service.list(1))[0].appliedRevision, 2);
await assert.rejects(service.heartbeat(p.token, { appliedRevision: 999 }), e => e.status === 400);
now += 90001; assert.equal((await service.list(1))[0].online, false);
});
test('revocation rejects device access and is scoped to the selected city', async () => {
const service = createBoardDeviceService(memoryRepository()); const p = await service.start({}); const d = await service.claim(1, { code: p.code, name: 'A', settings: defaults }, 1);
await assert.rejects(service.revoke(d.id, 2, 1), e => e.status === 404); await service.revoke(d.id, 1, 1);
await assert.rejects(service.status(p.token), e => e.status === 410); await assert.rejects(service.heartbeat(p.token, { appliedRevision: 1 }), e => e.status === 410);
});
test('remote settings are bounded, preserve supported palettes and cannot enable demo or accept unknown properties', () => {
assert.equal(validSettings({ ...defaults, demo: true }).demo, false);
for (const bad of [{ ...defaults, holdNear: 'yes' }, { ...defaults, adText: 'x'.repeat(201) }, { ...defaults, toString: 'bad' }, { ...defaults, stopId: NaN }, { ...defaults, routeFilter: '-1' }]) assert.throws(() => validSettings(bad), e => e.status === 400);
assert.equal(validSettings({ ...defaults, colorScheme: 'cyan' }).colorScheme, 'cyan');
});

View File

@@ -0,0 +1,28 @@
const test = require('node:test');
const assert = require('node:assert/strict');
process.env.JWT_SECRET = 'board-device-test-secret-only';
require.cache[require.resolve('../src/config/database')] = { exports: { query: async () => ({ rows: [] }) } };
const express = require('express');
const jwt = require('jsonwebtoken');
const { createBoardDevicesRouter } = require('../src/routes/boardDevices');
const { createBoardDeviceService } = require('../src/services/boardDeviceService');
const { defaults } = require('../src/services/boardDeviceSettings');
const { memoryRepository } = require('./helpers/boardDeviceMemoryRepository');
test('public pairing and device credentials cannot access admin endpoints; JWT administrator and city are required', async t => {
const service = createBoardDeviceService(memoryRepository()); const app = express(); app.use(express.json());
app.use('/api/v1/board-devices', createBoardDevicesRouter(service, { cityMiddleware: (req, _res, next) => { req.city = { id: Number(req.get('X-City-Id') || 1) }; next(); } }));
const server = await new Promise(resolve => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); }); t.after(() => new Promise(resolve => server.close(resolve)));
const base = `http://127.0.0.1:${server.address().port}/api/v1/board-devices`;
const post = (path, body, headers = {}) => fetch(base + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) });
const admin = { Authorization: `Bearer ${jwt.sign({ id: 7, role: 'admin' }, process.env.JWT_SECRET)}`, 'X-City-Id': '1' };
const pair = await (await post('/pairings', { width: 879, height: 593 })).json();
assert.match(pair.code, /^\d{4}$/); assert.equal((await fetch(base + '/admin')).status, 401);
assert.equal((await fetch(base + '/admin', { headers: { 'X-Board-Token': pair.token } })).status, 401);
assert.equal((await fetch(base + '/admin', { headers: { Authorization: `Bearer ${jwt.sign({ id: 7, role: 'user' }, process.env.JWT_SECRET)}` } })).status, 403);
assert.equal((await fetch(base + '/self', { headers: admin })).status, 401);
assert.equal((await post('/admin/claim', { code: pair.code, name: 'A', settings: defaults }, admin)).status, 200);
const status = await fetch(base + '/self', { headers: { 'X-Board-Token': pair.token } }); assert.equal(status.headers.get('cache-control'), 'no-store'); assert.equal((await status.json()).state, 'paired');
assert.equal((await post('/admin/claim', { code: pair.code, name: 'A', settings: defaults }, admin)).status, 409);
const list = await (await fetch(base + '/admin', { headers: admin })).json(); assert.equal(list.length, 1); assert.equal(list[0].token_hash, undefined);
assert.equal((await (await fetch(base + '/admin', { headers: { ...admin, 'X-City-Id': '2' } })).json()).length, 0);
});

View File

@@ -0,0 +1,21 @@
// Explicit test-only repository; production always uses PostgreSQL.
function memoryRepository({ now = Date.now } = {}) {
const pairings = new Map(), devices = new Map();
const stops = [{ id: 1, city_id: 1, name: 'Швейная фабрика', description: 'на Богатырскую' }, { id: 2, city_id: 1, name: 'Автовокзал', description: 'в центр' }, { id: 3, city_id: 2, name: 'Новополоцк', description: 'конечная' }];
const routes = [{ id: 1, city_id: 1, route_number: '1', name: 'Швейная фабрика — Богатырская', stops: [1, 2] }, { id: 2, city_id: 2, route_number: '1', name: 'Новополоцк', stops: [3] }];
const join = row => ({ ...row, city_name: row.city_id === 1 ? 'Полоцк' : 'Новополоцк', stop_name: stops.find(s => s.id === row.stop_id)?.name, stop_direction: stops.find(s => s.id === row.stop_id)?.description });
return {
stops, routesData: routes, devices, pairings,
async createPairing(p) { for (const [code, item] of pairings) if (Date.parse(item.expires_at) <= now()) pairings.delete(code); if (pairings.has(p.code)) { const e = new Error('collision'); e.code = '23505'; throw e; } pairings.set(p.code, p); return p; },
async pairing(hash) { return [...pairings.values()].find(p => p.token_hash === hash); },
async device(hash) { const row = [...devices.values()].find(d => d.token_hash === hash); return row && join(row); },
async routes(cityId, stopId) { return routes.filter(r => r.city_id === cityId && r.stops.includes(stopId)); },
async catalog(cityId, s) { return stops.some(stop => stop.id === s.stopId && stop.city_id === cityId) && (!s.routeFilter || routes.some(r => r.id === Number(s.routeFilter) && r.city_id === cityId && r.stops.includes(s.stopId))); },
async claim(code, record) { const p = pairings.get(code); if (!p || Date.parse(p.expires_at) <= now()) return null; pairings.delete(code); const row = { ...record, token_hash: p.token_hash, device_info: p.device_info, revision: 1, applied_revision: 0, last_seen_at: null, revoked_at: null }; devices.set(row.id, row); return row; },
async list(cityId) { return [...devices.values()].filter(d => d.city_id === cityId).map(join); },
async update(id, cityId, input) { const d = devices.get(id); if (!d || d.city_id !== cityId || d.revision !== input.revision || d.revoked_at) return null; Object.assign(d, { name: input.name, stop_id: input.settings.stopId, settings: input.settings, revision: d.revision + 1 }); return d; },
async revoke(id, cityId) { const d = devices.get(id); if (!d || d.city_id !== cityId) return null; d.revoked_at ||= new Date(now()).toISOString(); return d; },
async heartbeat(hash, revision, info) { const d = [...devices.values()].find(d => d.token_hash === hash && !d.revoked_at); if (!d) return null; d.applied_revision = Math.max(d.applied_revision, Math.min(d.revision, revision)); d.last_seen_at = new Date(now()).toISOString(); d.device_info = info; return d; },
};
}
module.exports = { memoryRepository };

View File

@@ -21,6 +21,7 @@ import AlertsPage from './pages/AlertsPage';
import UsersPage from './pages/UsersPage';
import ProfilePage from './pages/ProfilePage';
import CitiesPage from './pages/CitiesPage';
import DevicesPage from './pages/DevicesPage';
function App() {
return (
@@ -75,6 +76,7 @@ function AnimatedRoutes({ cityId }) {
<Route path="/live-map" element={<ProtectedRoute><LiveMapPage /></ProtectedRoute>} />
<Route path="/schedule-center" element={<ProtectedRoute requireAdmin><ScheduleCenterPage /></ProtectedRoute>} />
<Route path="/schedule-import" element={<ProtectedRoute requireAdmin><ScheduleImportPage /></ProtectedRoute>} />
<Route path="/devices" element={<ProtectedRoute requireAdmin><DevicesPage /></ProtectedRoute>} />
<Route path="/quality" element={<ProtectedRoute requireAdmin><QualityPage /></ProtectedRoute>} />
<Route path="/holidays" element={<ProtectedRoute><HolidaysPage /></ProtectedRoute>} />
<Route path="/alerts" element={<ProtectedRoute><AlertsPage /></ProtectedRoute>} />

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { Link, NavLink, useLocation } from 'react-router-dom';
import { Bus, Menu, X, LayoutDashboard, MapPin, CalendarDays, Gift, Bell, Users, User, Download, ShieldCheck, CalendarRange, Radio } from 'lucide-react';
import { Bus, Menu, X, LayoutDashboard, MapPin, CalendarDays, Gift, Bell, Users, User, Download, ShieldCheck, CalendarRange, Radio, Monitor } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import BrandLogo from './BrandLogo';
import ProfileMenu from './ProfileMenu';
@@ -21,6 +21,7 @@ const navigationGroups = [
{ label: 'Управление', items: [
{ to: '/quality', label: 'Контроль качества', icon: ShieldCheck, adminOnly: true },
{ to: '/alerts', label: 'Уведомления', icon: Bell },
{ to: '/devices', label: 'Устройства', icon: Monitor, adminOnly: true },
{ to: '/users', label: 'Пользователи', icon: Users, adminOnly: true },
{ to: '/profile', label: 'Профиль', icon: User },
] },

View File

@@ -0,0 +1 @@
.devices-page{max-width:1200px;margin:auto;color:#17251d}.devices-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:28px}.devices-heading h1{font-size:32px;font-weight:700;margin:4px 0 8px}.devices-heading p{color:#657068}.devices-heading>div:last-child{display:flex;gap:10px}.devices-page button,.device-dialog button{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid #d8dfda;border-radius:12px;background:white;padding:10px 16px;font-weight:600;min-height:44px}.devices-page button:disabled,.device-dialog button:disabled{opacity:.45;cursor:not-allowed}.devices-page .device-primary,.device-dialog .device-primary{background:#1c6c46;color:white;border-color:#1c6c46}.device-kicker{font-size:11px;font-weight:700;letter-spacing:.14em;color:#6a7c70}.devices-empty{text-align:center;background:white;border:1px dashed #c6d3ca;border-radius:24px;padding:64px 24px}.devices-empty svg{margin:auto;color:#478a62}.devices-empty h2{font-weight:650;font-size:22px;margin:16px}.devices-empty p{color:#657068;margin:0 0 24px}.device-list{display:grid;gap:14px}.device-card{display:flex;align-items:center;gap:20px;background:white;border:1px solid #dfe7e1;border-radius:20px;padding:24px}.device-card-icon{background:#edf5ef;border-radius:14px;padding:16px;color:#276347}.device-card-body{flex:1;min-width:0}.device-card-title{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.device-card h2{font-weight:650;font-size:20px;overflow-wrap:anywhere}.device-card p{color:#58675d;margin:6px 0 12px}.device-state{background:#f1f3f1;color:#6d766f;font-size:12px;border-radius:999px;padding:5px 10px}.device-state.online{background:#e0f3e5;color:#176439}.device-card-meta{display:flex;gap:16px;flex-wrap:wrap;font-size:12px;color:#69766d}.device-card-meta>span,.device-notice{display:flex;align-items:center;gap:6px}.device-card[data-revoked=true]{opacity:.65}.device-notice,.device-error{border-radius:12px;padding:14px 18px;margin:16px 0;background:#e8f4eb;color:#22633e}.device-error{background:#fff0ec;color:#973b22}.device-dialog{padding:0;border:1px solid #dde5df;border-radius:24px;width:min(660px,calc(100% - 24px));max-height:calc(100dvh - 32px);color:#17251d;box-shadow:0 24px 100px #17251d33}.device-dialog::backdrop{background:#11211880;backdrop-filter:blur(4px)}.device-dialog form{display:flex;flex-direction:column;max-height:calc(100dvh - 34px)}.device-dialog header,.device-dialog footer{padding:22px 26px;display:flex;align-items:center;justify-content:space-between;gap:14px;flex-shrink:0}.device-dialog header{border-bottom:1px solid #e4ebe6}.device-dialog h2{font-size:23px;font-weight:700;margin-top:4px}.device-form-content{padding:24px 26px;overflow:auto}.device-form-content>label{margin-bottom:16px}.device-dialog label{display:flex;flex-direction:column;gap:8px;font-size:13px;font-weight:600}.device-dialog input,.device-dialog select,.device-dialog textarea{border:1px solid #d0dbd3;border-radius:10px;padding:11px 12px;font-size:15px;background:#fafcfb;min-width:0;width:100%}.device-dialog input:focus,.device-dialog select:focus,.device-dialog textarea:focus{outline:2px solid #478a62;outline-offset:2px}.device-dialog .device-code-input{font-size:34px;letter-spacing:.35em;text-align:center;font-variant-numeric:tabular-nums}.device-dialog small{color:#718076;font-weight:400}.device-dialog h3{font-size:16px;font-weight:700;margin:24px 0 14px}.device-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:18px}.device-dialog .device-check{flex-direction:row;align-items:center;padding:12px 0;font-weight:500}.device-check input{width:18px;height:18px;accent-color:#1c6c46}.device-dialog footer{border-top:1px solid #e4ebe6;justify-content:flex-end}.device-dialog .device-revoke{margin-right:auto;color:#963e2d;background:#fff8f5}.device-revoke-prompt{border:1px solid #eccabd;border-radius:12px;padding:16px;background:#fff8f5}.device-revoke-prompt p{font-size:14px;margin-bottom:12px}.device-revoke-prompt button{margin-right:8px}@media(max-width:700px){.devices-heading{align-items:flex-start;flex-direction:column}.device-card{flex-wrap:wrap;padding:18px;gap:12px}.device-card-icon{padding:12px}.device-card-body{flex-basis:calc(100% - 65px)}.device-card>button{width:100%}.device-form-grid{grid-template-columns:1fr}.device-dialog header,.device-dialog footer,.device-form-content{padding:18px}.device-card-meta{gap:8px}}

View File

@@ -0,0 +1,86 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Monitor, Plus, RefreshCw, X, Link2, Save, Unplug, CheckCircle2, Clock3 } from 'lucide-react';
import { boardDevicesApi, stopsApi } from '../services/api';
import { useCity } from '../contexts/CityContext';
import './DevicesPage.css';
const defaults = { stopId: 1, demo: false, mainSeconds: 25, infoSeconds: 8, holdNear: true, rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue', infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true, showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near', adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
const darkColors = [['lime', 'Лайм'], ['amber', 'Янтарь'], ['cyan', 'Голубой'], ['mint', 'Мята'], ['lavender', 'Лаванда'], ['white', 'Белый']];
const lightColors = [['blue', 'Синий'], ['forest', 'Зелёный'], ['crimson', 'Кармин'], ['violet', 'Фиолетовый'], ['teal', 'Бирюзовый'], ['graphite', 'Графит']];
const date = value => value ? new Date(value).toLocaleString('ru-RU', { timeZone: 'Europe/Minsk', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', second: '2-digit' }) : 'Ещё не выходило на связь';
const message = error => error.response?.data?.message || 'Не удалось выполнить запрос. Попробуйте ещё раз';
function DeviceForm({ device, stops, onClose, onSaved }) {
const dialog = useRef(null);
const [code, setCode] = useState(''), [name, setName] = useState(device?.name || '');
const [settings, setSettings] = useState(device?.settings || { ...defaults, stopId: stops[0]?.id || 1 });
const [error, setError] = useState(''), [busy, setBusy] = useState(false);
const [detached, setDetached] = useState(false), [routes, setRoutes] = useState([]);
useEffect(() => { let alive = true; setRoutes([]); boardDevicesApi.routes(settings.stopId).then(({ data }) => { if (alive) setRoutes(data); }).catch(() => { if (alive) setError('Не удалось загрузить маршруты площадки'); }); return () => { alive = false; }; }, [settings.stopId]);
useEffect(() => { const previous = document.activeElement; dialog.current?.showModal(); return () => { dialog.current?.close(); previous?.focus?.(); }; }, []);
const set = (key, value) => setSettings(previous => ({ ...previous, [key]: value }));
async function save(event) {
event.preventDefault(); if (busy) return; setBusy(true); setError('');
try {
const payload = { name, settings, ...(device ? { revision: device.revision } : { code }) };
if (device) await boardDevicesApi.update(device.id, payload); else await boardDevicesApi.claim(payload);
onSaved(device ? 'Настройки сохранены' : 'Устройство подключено');
} catch (e) { setError(message(e)); } finally { setBusy(false); }
}
async function revoke() {
setBusy(true); setError('');
try { await boardDevicesApi.revoke(device.id); onSaved('Устройство отвязано. Табло узнает об этом при следующем подключении'); }
catch (e) { setError(message(e)); setBusy(false); }
}
const select = (key, label, values, numeric = false) => <label>{label}<select value={settings[key]} onChange={e => set(key, numeric ? Number(e.target.value) : e.target.value)}>{values.map(([value, text]) => <option key={value} value={value}>{text}</option>)}</select></label>;
const check = (key, label) => <label className="device-check"><input type="checkbox" checked={settings[key]} onChange={e => set(key, e.target.checked)} />{label}</label>;
return <dialog ref={dialog} className="device-dialog" aria-labelledby="device-dialog-title" onCancel={e => { e.preventDefault(); if (!busy) onClose(); }}>
<form onSubmit={save}>
<header><div><span className="device-kicker">RIDEBUS BOARD</span><h2 id="device-dialog-title">{device ? 'Настройки устройства' : 'Подключить табло'}</h2></div><button type="button" aria-label="Закрыть" disabled={busy} onClick={onClose}><X size={22} /></button></header>
<div className="device-form-content">
{!device && <label>Код на экране табло<input autoFocus className="device-code-input" inputMode="numeric" autoComplete="off" pattern="[0-9]{4}" maxLength={4} required value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 4))} placeholder="0000" /><small>Четыре цифры. Код действует 5 минут</small></label>}
<label>Название устройства<input autoFocus={Boolean(device)} maxLength={100} required value={name} onChange={e => setName(e.target.value)} placeholder="Швейная фабрика · улица" /></label>
<label>Остановка и площадка<select value={settings.stopId} onChange={e => setSettings(previous => ({ ...previous, stopId: Number(e.target.value), routeFilter: '' }))}>{stops.map(stop => <option key={stop.id} value={stop.id}>{stop.name}{stop.description ? ` · ${stop.description}` : ''}</option>)}</select></label>
{select('routeFilter', 'Маршрут', [['', 'Все маршруты остановки'], ...routes.map(route => [String(route.id), `№${route.route_number} · ${route.name}`])])}
<h3>Оформление и читаемость</h3><div className="device-form-grid">
{select('theme', 'Тема', [['dark', 'Тёмная'], ['light', 'Светлая']])}
{settings.theme === 'light' ? select('lightColorScheme', 'Цветовая схема', lightColors) : select('colorScheme', 'Цветовая схема', darkColors)}
{select('displayProfile', 'Профиль экрана', [['tv', 'Телевизор'], ['street', 'Уличное табло']])}
{select('viewingDistance', 'Расстояние просмотра', [['near', 'Близко'], ['medium', 'Средне'], ['far', 'Далеко']])}
{select('rowCount', 'Следующие рейсы', [2, 3, 4, 5, 6].map(n => [n, n]), true)}
</div>
<h3>Экраны</h3><div className="device-form-grid">{[['mainSeconds', 'Главный экран, сек', 5, 120], ['infoSeconds', 'Информация, сек', 5, 60]].map(([key, label, min, max]) => <label key={key}>{label}<input type="number" required min={min} max={max} value={settings[key]} onChange={e => set(key, Number(e.target.value))} /></label>)}</div>
{check('holdNear', 'Удерживать ближайший рейс за 2 минуты')}{check('showNextSameRoute', 'Следующий рейс того же направления')}{check('longWaitEnabled', 'Крупное время при долгом ожидании')}
<h3>Блоки информации</h3>{check('infoServiceEnabled', 'Первый и последний рейс')}{check('infoWeatherEnabled', 'Погода')}{check('infoQrEnabled', 'QR-код расписания')}
<h3>Реклама · предпросмотр</h3>{check('adsEnabled', 'Добавить рекламу в цикл')}
{settings.adsEnabled && <><label>Длительность, сек<input type="number" min={5} max={30} required value={settings.adSeconds} onChange={e => set('adSeconds', Number(e.target.value))} /></label><label>Заголовок<input maxLength={80} value={settings.adTitle} onChange={e => set('adTitle', e.target.value)} /></label><label>Текст<textarea maxLength={200} value={settings.adText} onChange={e => set('adText', e.target.value)} /></label></>}
{error && <p className="device-error" role="alert">{error}</p>}
{detached && <div className="device-revoke-prompt"><p>Отвязать «{device.name}»? Табло прекратит показ при следующей связи с сервером. Для повторного подключения понадобится новый код.</p><button type="button" disabled={busy} onClick={revoke}>Да, отвязать</button><button type="button" disabled={busy} onClick={() => setDetached(false)}>Отмена</button></div>}
</div>
<footer>{device && <button type="button" className="device-revoke" disabled={busy} onClick={() => setDetached(true)}><Unplug size={17} />Отвязать</button>}<button type="submit" className="device-primary" disabled={busy || !stops.length}>{device ? <Save size={18} /> : <Link2 size={18} />}{busy ? 'Сохраняем…' : device ? 'Сохранить настройки' : 'Подключить'}</button></footer>
</form>
</dialog>;
}
export default function DevicesPage() {
const { activeCity } = useCity();
const [devices, setDevices] = useState([]), [stops, setStops] = useState([]);
const [editor, setEditor] = useState(null), [error, setError] = useState(''), [loading, setLoading] = useState(true), [notice, setNotice] = useState('');
const request = useRef(0);
const load = useCallback(async () => {
const id = ++request.current;
try { const { data } = await boardDevicesApi.list(); if (id === request.current) { setDevices(data); setError(''); } }
catch (e) { if (id === request.current) setError(message(e)); }
finally { if (id === request.current) setLoading(false); }
}, []);
useEffect(() => { void load(); const interval = setInterval(load, 15000); return () => { clearInterval(interval); request.current++; }; }, [load]);
useEffect(() => { let alive = true; stopsApi.getAll().then(s => { if (alive) setStops(s.data); }).catch(() => { if (alive) setError('Не удалось загрузить остановки и маршруты'); }); return () => { alive = false; }; }, []);
return <section className="devices-page">
<header className="devices-heading"><div><span className="device-kicker">RIDEBUS BOARD · {activeCity.name}</span><h1>Устройства</h1><p>Табло остановок и телевизоры. Подключение по коду с экрана.</p></div><div><button onClick={load} aria-label="Обновить устройства"><RefreshCw size={18} /></button><button className="device-primary" disabled={!stops.length} onClick={() => { setNotice(''); setEditor({}); }}><Plus size={18} />Добавить устройство</button></div></header>
{notice && <p className="device-notice" role="status"><CheckCircle2 size={18} />{notice}</p>}{error && <p className="device-error" role="alert">{error}</p>}
{!loading && !devices.length && <div className="devices-empty"><Monitor size={42} /><h2>Подключите первое табло</h2><p>Откройте RideBus Board на телевизоре и введите появившийся код здесь.</p><button className="device-primary" disabled={!stops.length} onClick={() => setEditor({})}>Подключить по коду</button></div>}
{loading && <p>Загружаем устройства…</p>}
<div className="device-list">{devices.map(device => <article key={device.id} className="device-card" data-revoked={Boolean(device.revokedAt)}><div className="device-card-icon"><Monitor size={26} /></div><div className="device-card-body"><div className="device-card-title"><h2>{device.name}</h2><span className={`device-state ${device.online ? 'online' : ''}`}>{device.revokedAt ? 'Отвязано' : device.online ? 'В сети' : 'Нет связи'}</span></div><p>{device.stopName}{device.stopDirection ? ` · ${device.stopDirection}` : ''}</p><div className="device-card-meta"><span><Clock3 size={15} />{date(device.lastSeenAt)}</span><span>{device.deviceInfo.width && device.deviceInfo.height ? `${device.deviceInfo.width}×${device.deviceInfo.height}` : 'Размер экрана неизвестен'}</span><span>{device.revokedAt ? 'Доступ отозван' : device.appliedRevision === device.revision ? 'Настройки применены' : 'Ожидают применения'} · v{device.revision}</span></div></div><button disabled={Boolean(device.revokedAt) || !stops.length} onClick={() => { setNotice(''); setEditor(device); }}>Настроить</button></article>)}</div>
{editor && <DeviceForm device={editor.id ? editor : null} stops={stops} onClose={() => setEditor(null)} onSaved={text => { setEditor(null); setNotice(text); void load(); }} />}
</section>;
}

View File

@@ -222,4 +222,12 @@ export const realtimeApi = {
getVehicle: (vehicleId, params) => api.get(`/realtime/vehicles/${vehicleId}`, { params })
};
export const boardDevicesApi = {
list: () => api.get('/board-devices/admin'),
routes: stopId => api.get('/board-devices/admin/routes', { params: { stopId } }),
claim: data => api.post('/board-devices/admin/claim', data),
update: (id, data) => api.put(`/board-devices/admin/${id}`, data),
revoke: id => api.post(`/board-devices/admin/${id}/revoke`),
};
export default api;