This commit is contained in:
2026-10-11 14:17:03 +07:00
parent c37d76937a
commit 2b7b95fe2b
6 changed files with 121 additions and 9 deletions
+20 -1
View File
@@ -139,7 +139,26 @@ Venue автоматически учитывает браузеры игрок
`POST /api/devices/heartbeat` публично регистрирует подключение, а
`GET /api/devices?gameID=<ID>` возвращает сгруппированные устройства организатору,
администратору или разработчику. Фильтр необязателен; при его использовании
список содержит только подключения выбранной игры. Панель состояния — этап 13.
список содержит только подключения выбранной игры, без непривязанных браузеров.
Вкладка «Состояние» (`/games/:id?tab=state`, этап 13) доступна в venue
организатору, администратору и разработчику. Она показывает число доступных
устройств, устройств без связи и подключений, UUID профиля и последнее
обращение; раскрытие устройства показывает тип подключения, ID команды/экрана,
статус, IP и User-Agent. Устройства без связи идут первыми, далее сортировка
по UUID. Статусы и время обновления берутся из серверного снимка.
Снимок запрашивается сразу и раз в 5 секунд только на активной вкладке,
без параллельных запросов; поздние ответы после ухода игнорируются.
При ошибке предыдущий снимок и время остаются видимыми, доступен повтор.
«Нет связи» означает отсутствие heartbeat сайта, а не диагностику Wi-Fi.
Разработчик без роли организатора/администратора/автора в venue получает
список игр и только вкладку состояния: сведения игры ограничены ID, названием,
описанием, форматом, местом, плановым началом и статусом. Команды, пароли,
сценарий, материалы и игровой прогресс не выдаются и не загружаются интерфейсом.
Создание игр и результаты скрыты; существующий импорт сохраняется.
Heartbeat разработчика остаётся браузерным без контекста организатора.
В prod этот доступ и вкладка состояния не открываются.
UUID браузерного профиля хранится отдельно от авторизации в localStorage;
каждая вкладка и перезагрузка страницы создают новый UUID подключения.
+6 -3
View File
@@ -27,13 +27,13 @@
### ED-029 — Автономная площадка и панель организатора
- Статус: `IN_PROGRESS` — этап 3 принят пользователем без WAN; этапы 4–12 завершены; аппаратная приёмка этапа 8 подтверждена пользователем.
- Статус: `IN_PROGRESS` — этап 3 принят пользователем без WAN; этапы 4–13 завершены; аппаратная приёмка этапа 8 подтверждена пользователем.
- Репозитории: корневой, `evening_detective_server`, `evening_detective_frontend`.
- Цель: реализовать принятый пользователем план из 15 последовательных этапов: автономное окружение; локальные организаторы; прежние адреса через Cudy; пакет игры; экспорт со статусом офлайн-режима; локальный импорт; интерфейс передачи; полный билетный и игровой flow; пакет результатов; возврат онлайн; интерфейс возврата; устройства; панель состояния; сбор данных Cudy; Wi-Fi и испытание площадки.
- Критерии: проверяемый результат каждого этапа; цикл «онлайн → ноутбук → проведение → онлайн» через интерфейс с сохранением QR, билетов и связей; повторные импорты без дублей; мониторинг устройств и Wi-Fi; полный прогон без WAN, 100 клиентов и проверка покрытия.
- Ограничения: только тестовые игры до завершения возврата; существующие правила игры и формат `offline` сохраняются; без несогласованных новых зависимостей и отклонений от стандартов; без персональных списков игроков, CPU/памяти Cudy и автоматического переключения серверов; настройки роутера менять только по явному запросу пользователя; секреты и пакеты вне Git; commit/push выполняет пользователь.
- Проверка: Go-стандарт и `go test ./...`; PostgreSQL-интеграционные тесты для атомарности; frontend-стандарт, type-check, lint, lint:css и build; штатная генерация protobuf; ручные контрольные точки после этапов 3, 8, 11, 15.
- Прогресс: этапы 1–2 реализованы и проверены в локальном Docker-окружении. Добавлены отдельный Compose, подготовка образов заранее, постоянные тома, миграции перед стартом, healthchecks и прокси. Явный `APP_MODE=venue` отключает SMTP, фоновые письма и внешний webhook; почтовые операции аккаунта возвращают безопасную ошибку до изменения данных. Локальные аккаунты создаются штатным `dev-user.sql`, роли и правила автоматического подтверждения команд сохраняются. Этап 3 принят пользователем после проверки без WAN; формат и код пакета игры (этап 4) реализованы и проверены. Экспорт со статусом офлайн-режима (этап 5) и локальный импорт (этап 6) реализованы и проверены. Этап 7 реализован и проверен; этап 8 проверен на изолированном стенде и принят пользователем на аппаратной контрольной точке; этапы 9–11 реализованы и проверены; этапы 12–15 ещё не завершены.
- Прогресс: этапы 1–2 реализованы и проверены в локальном Docker-окружении. Добавлены отдельный Compose, подготовка образов заранее, постоянные тома, миграции перед стартом, healthchecks и прокси. Явный `APP_MODE=venue` отключает SMTP, фоновые письма и внешний webhook; почтовые операции аккаунта возвращают безопасную ошибку до изменения данных. Локальные аккаунты создаются штатным `dev-user.sql`, роли и правила автоматического подтверждения команд сохраняются. Этап 3 принят пользователем после проверки без WAN; формат и код пакета игры (этап 4) реализованы и проверены. Экспорт со статусом офлайн-режима (этап 5) и локальный импорт (этап 6) реализованы и проверены. Этап 7 реализован и проверен; этап 8 проверен на изолированном стенде и принят пользователем на аппаратной контрольной точке; этапы 9–13 реализованы и проверены; этапы 14–15 ещё не завершены.
- Дополнение по запросу пользователя: структура `venue` приведена к `prod` (`backend/`, `frontend/dist/`, `migrations/`, `Dockerfile.backend`, `docker-compose.yml`, `deploy.sh`); имена основных Compose-сервисов совпадают. Общая генерация `make build` / `make prod-build` готовит артефакты обеих папок; frontend собирается отдельно с настройками своего окружения. Текущие файлы `prod` при этой работе не изменяются; сборка проверяется с production-выводом в `/tmp`.
- Упрощение запуска: отдельные `init.sh` и `prepare.sh` удалены, всё собрано в `deploy.sh`. Единственная команда запуска — `make venue`: создаёт `.env` только при его отсутствии, при необходимости готовит зависимости/образы и автоматически создаёт организатора. Повторный готовый запуск не собирает и не скачивает образы. `make venue-update` явно пересобирает окружение с интернетом. Аккаунт организатора синхронизируется со значениями `.env` при каждом запуске. Первый запуск, повторный запуск без интернета и обновление проверяются `python3 tests/venue/deploy.py` с подставными командами; реальный готовый запуск проверен через Docker и API-smoke.
- Настройки площадки: по запросу пользователя необязательный `venue/.env.example` удалён; настройки создаются автоматически через `make venue`. Убраны ссылка и проверка примера; `python3 tests/venue/deploy.py` прошёл. Существующая `.env` не изменялась.
@@ -81,7 +81,10 @@
- Результат этапа 12 (2026-10-11): добавлены потокобезопасный сервис присутствия в памяти, wiring, protobuf/OpenAPI `POST /api/devices/heartbeat` и `GET /api/devices` с фильтром игры. Проверяются UUID, взаимоисключающие контексты, JWT/существующие права игры, пароль команды и неотменённое участие без загрузки сценария/прогресса, существующая сессия экрана; ID игры команды/экрана определяет сервер. Реестр хранит только проверенные связи, UUID, серверные времена и последние IP/User-Agent; credentials не сохраняются и не выдаются. Полная замена/очистка контекста, граница связи строго менее 60 секунд, удаление через 15 минут при обновлении/чтении, предел 1024 с `ResourceExhausted` без вытеснения существующих записей. В prod heartbeat явно возвращает `enabled=false`, список недоступен.
- Браузер этапа 12: общий heartbeat в App сразу/каждые 20 секунд без параллельных запросов, при видимости/восстановлении сети и смене контекста. UUID профиля отдельно от авторизации в localStorage с временным fallback, UUID подключения на экземпляр приложения. Контекст страницы очищается при уходе, отозванный lifecycle не может восстановить старые связи; смена игры/пароля пересоздаёт page-компонент. Generated client и обновление JWT организатора переиспользованы; публичные credentials команды/экрана не запускают обновление JWT. После `enabled=false` polling прекращается. Ошибки не показывают toast и повторяются по интервалу. UI списка устройств не добавлен.
- Проверки этапа 12: штатная генерация обоих репозиториев, gofmt, `go test ./...`, race для presence service/app, frontend type-check/lint/lint:css/build, self-check UUID/store/composable и diff-проверки прошли. Unit-тесты с управляемыми часами покрывают повтор/вкладки/смену и очистку связей, границы 60 секунд/15 минут, лимит и конкурентное чтение/обновление; API-тесты — роли, режимы, safe errors и отсутствие секретов. `python3 tests/venue/flow.py --browser` прошёл изолированные реальные API/PostgreSQL/RustFS/Firefox: credentials/отменённое участие, UUID/поля, группировку вкладок игрока/организатора/экрана, фильтр и привязку экрана, SPA-навигацию, прекращение heartbeat и восстановление сети, повторную регистрацию штатным интервалом после перезапуска backend. Независимое ревью выявило два замечания (публичный heartbeat не должен обновлять JWT, обрезка User-Agent должна сохранять UTF-8); оба исправлены и покрыты регрессиями. Итоговое ручное ревью по Go/Frontend стандартам выполнено; замечаний не осталось.
- Следующий шаг: этап 13 — панель состояния площадки. Этап 12 завершён; общая задача остаётся `IN_PROGRESS` и не переносится в архив. ZIP остаётся неподписанным. Фактический ZIP 1 ГиБ, нагрузка на 100 клиентов и покрытие Wi-Fi не проверялись; развёртывание, commit/push не выполнялись.
- Результат этапа 13 (2026-10-11): venue-вкладка «Состояние» `/games/:id?tab=state` показывает серверные connected/serverTime, количество доступных устройств/устройств без связи/подключений, UUID профиля, последние обращения и раскрываемые подключения с типом, ID команды/экрана, статусом, IP и User-Agent. Нет связи идёт первым, затем UUID; непривязанные браузеры исключает фильтр игры. Generated GetDevices вызывается сразу и каждые 5 секунд через useSimplePolling только на активной вкладке, без параллельных запросов; поздние ответы после смены игры, ухода и уничтожения игнорируются. Первичная загрузка, пустой список, встроенная ошибка, сохранение предыдущего снимка/времени и повтор реализованы без повторных toast. Вкладка доступна с клавиатуры. Игровые показатели, Cudy и история присутствия не добавлялись.
- Доступ этапа 13: GetGames/GetGame и UI permissions расширены для разработчика только в venue. Разработчик без организатора/администратора/автора получает явный whitelist ID/названия/описания/формата/места/планового начала/статуса; GetGame использует краткое чтение без сценария/команд. UI скрывает создание и результаты, показывает название и только состояние, нормализует другие tab, не загружает экраны/музыку/план/карту/ответы; heartbeat остаётся браузерным. Дополнительные роли, импорт и запреты игровых действий сохранены; prod новые права не получает. При смене роли полный поздний GetGame не применяется, приватные баннер/модалки скрываются.
- Проверки этапа 13: gofmt, полный go test ./..., race-тесты device_presence_service/app с управляемыми часами этапа 12; frontend type-check/lint/lint:css/build и self-check gameState прошли. Self-check покрывает сводку/сортировку, активность, отсутствие параллельных запросов, поздние ответы, ошибку/восстановление. Изолированные PostgreSQL/RustFS API-регрессии и Firefox проверили организатора, три подключения одного профиля, клавиатуру, смену контекста, пустой список, потерю/восстановление GetDevices, остановку запросов на другой вкладке, разработчика venue/prod, безопасные GetGame/GetGames и отсутствие закрытых фоновых запросов. API/protobuf/миграции/зависимости не менялись. Ручное ревью diff по Go/frontend-стандартам и независимое read-only ревью выполнены; пользовательские изменения prod/reproxy/backend-бинарника сохранены. Работающие окружения и Cudy не менялись.
- Следующий шаг: этап 14 — сбор данных Cudy. Этап 13 завершён; общая задача остаётся `IN_PROGRESS` и не переносится в архив. ZIP остаётся неподписанным. Фактический ZIP 1 ГиБ, нагрузка на 100 клиентов и покрытие Wi-Fi не проверялись; развёртывание, commit/push не выполнялись.
### ED-024 — Общая дизайн-система фронтенда
+1 -1
Submodule prod updated: 46a9864c8c...218ed05fd5
+18
View File
@@ -137,3 +137,21 @@ Firefox проверяет игрока, организатора и экран
отозванные lifecycle-контексты, отсутствие параллельных heartbeat,
остановка после `enabled=false`, повтор ошибок по интервалу и независимость
публичных credentials от JWT. Нагрузочный и аппаратный прогоны не включены.
### Панель состояния (этап 13)
`python3 tests/venue/flow.py --browser` дополнительно проверяет Firefox:
сводку и серверное время, встроенную ошибку с сохранением снимка, повтор,
пустой список, остановку запросов после переключения вкладки; отдельного
разработчика в venue/prod, безопасный состав GetGame/GetGames, скрытые создание
и результаты, нормализацию `tab=answers` в `state`, единственную вкладку и
обычный браузерный heartbeat. Проверки нескольких вкладок и смены контекста
этапа 12 остаются частью прогона. Потеря API и пустой ответ воспроизводятся
перехватом только GetDevices в браузере; остальные API реальные.
`node src/composables/__tests__/gameState.test.ts` (из frontend) проверяет
счётчики, сортировку, отсутствие параллельных запросов, активность вкладки,
ошибку/восстановление и отбрасывание ответов после смены игры, ухода и unmount.
Временные границы connected остаются в тестах сервиса этапа 12 с управляемыми
часами; клиент пороги не пересчитывает.
+2
View File
@@ -117,6 +117,7 @@ class Stand:
def handle_request(self):
if self.path.startswith('/api/'):
stand.request_paths.append((stand.current, urllib.parse.urlparse(self.path).path))
if self.command == 'POST' and self.path.endswith('/results-package/apply'):
time.sleep(stand.delay_results)
if self.command == 'POST' and self.path.endswith('/actions'):
@@ -159,6 +160,7 @@ class Stand:
# Navigation may cancel an in-flight browser polling response.
pass
self.request_paths = []
self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
self.public = f'http://127.0.0.1:{self.server.server_port}'
self.server_thread = threading.Thread(target=self.server.serve_forever, daemon=True)
+74 -4
View File
@@ -63,7 +63,7 @@ def check_browser_flow(stand, fixtures):
sock.sendall(str(len(payload)).encode() + b':' + payload)
result = read_message()
if result[2]:
raise RuntimeError('Firefox command failed: ' + name)
raise RuntimeError('Firefox command failed: ' + name + ': ' + result[2].get('message', 'unknown error'))
return result[3]
def js(script, *args):
@@ -87,13 +87,13 @@ def check_browser_flow(stand, fixtures):
def input_value(selector, value):
assert js("const input=document.querySelector(arguments[0]);if(!input)return false;input.value=arguments[1];input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}));return true;", selector, value), 'Missing input: ' + selector
def login(mode):
def login(mode, email='organizer@test.invalid'):
stand.current = mode
navigate('/login')
js('localStorage.clear();return true;')
navigate('/login')
wait("document.querySelector('input[type=password]')!==null", 'login form')
input_value('input[placeholder="detective@mail.ru"]', 'organizer@test.invalid')
input_value('input[placeholder="detective@mail.ru"]', email)
input_value('input[type=password]', 'test-only-password')
click('Вход')
wait("location.pathname==='/office'", 'login')
@@ -132,6 +132,11 @@ def check_browser_flow(stand, fixtures):
js("window.dispatchEvent(new Event('online'));return true;")
wait_for(lambda: any(c['ID'] == screen_connection['ID'] and c.get('gameID') == data['game'] for c in connections()), 'bound screen presence')
assert {c['kind'] for c in connections()} >= {'organizer', 'team', 'screen'}
command('WebDriver:SwitchToWindow', {'handle': organizer_window})
assert js("const tab=[...document.querySelectorAll('.n-tabs-tab[data-name]')].find(t=>t.textContent.trim()==='Состояние');tab.focus();tab.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}));return true;")
wait("document.body.innerText.includes('Подключения: 3')", 'three connections in one device panel')
assert js("return [...document.querySelectorAll('.device-details strong')].filter(e=>e.textContent===arguments[0]).length===1", device_id)
command('WebDriver:SwitchToWindow', {'handle': screen_window})
command('WebDriver:CloseWindow')
command('WebDriver:SwitchToWindow', {'handle': team_window})
command('WebDriver:CloseWindow')
@@ -157,13 +162,75 @@ def check_browser_flow(stand, fixtures):
assert new_id != old_id and js("return localStorage.getItem('evening-detective-device-ID')") == device_id
print('PASS: stage 12 real Firefox organizer/player/screen, grouped tabs, SPA context clearing, reload UUID, stopped heartbeat/network recovery and interval re-registration after restart', flush=True)
def check_state(data):
from flow import API, ROOT, run
game = data['game']
navigate(f'/games/{game}?tab=state')
wait("document.body.innerText.includes('Последнее обновление:')", 'state initial snapshot')
assert js("return document.body.innerText.includes('Доступны:') && document.body.innerText.includes('Подключения:')")
# Track only list requests; all other requests use the real server.
js("window.stateFetch=window.fetch;window.stateCalls=0;window.stateMode='real';window.fetch=(url,options)=>{if(String(url).includes('/api/devices?')){window.stateCalls++;if(window.stateMode==='error')return Promise.reject(new Error('offline'));if(window.stateMode==='empty')return Promise.resolve(new Response(JSON.stringify({devices:[],serverTime:new Date().toISOString()}),{headers:{'Content-Type':'application/json'}}));}return window.stateFetch(url,options)};window.stateMode='error';return true;")
wait("document.body.innerText.includes('Не удалось обновить состояние')", 'inline API failure', 10)
assert js("return document.body.innerText.includes('Последнее обновление:')")
js("window.stateMode='real';return true;")
click('Повторить')
wait("!document.body.innerText.includes('Не удалось обновить состояние')", 'state recovery')
js("window.stateMode='empty';return true;")
wait("document.body.innerText.includes('Устройства этой игры пока не зарегистрированы')", 'empty devices', 10)
assert js("const tab=[...document.querySelectorAll('.n-tabs-tab[data-name]')].find(t=>t.textContent.trim()==='Игра');tab.click();return true;")
wait("location.search.includes('tab=game')", 'leave state tab')
calls = js('return window.stateCalls')
time.sleep(6)
assert js('return window.stateCalls') == calls, 'Inactive state still polls'
js('window.fetch=window.stateFetch;return true;')
# Create a separate developer in each disposable database.
bootstrap = (ROOT / 'evening_detective_server/scripts/dev-user.sql').read_text()
for mode in ('venue', 'prod'):
run('docker', 'exec', '-i', stand.name + '-' + mode + '-pg', 'psql', '-U', 'test', '-d', 'test', '-v', 'ON_ERROR_STOP=1', '-v', 'dev_username=TEST developer', '-v', 'dev_email=developer@test.invalid', '-v', 'dev_password=test-only-password', input=bootstrap)
stand.sql(mode, "UPDATE users SET roles='[\"developer\"]'::jsonb WHERE email='developer@test.invalid';")
api = API(stand.apis[mode].base)
api.token = api.request('/api/auth/login', {'email':'developer@test.invalid','password':'test-only-password'})['accessToken']
if mode == 'prod':
api.request('/api/games', expected=403)
api.request(f'/api/games/{game}', expected=403)
else:
summary = api.request(f'/api/games/{game}')['game']
public_fields = {'ID','name','description','format','location','startAt','status'}
# Gateway may emit unset protobuf fields as null/empty collections.
assert all(not value for key, value in summary.items() if key not in public_fields)
games = api.request('/api/games')['games']
assert games and all(not value for g in games for key, value in g.items() if key not in public_fields)
old_connections = {c['ID'] for d in stand.apis['venue'].request('/api/devices')['devices'] for c in d['connections']}
login('venue', 'developer@test.invalid')
navigate('/games')
wait("document.body.innerText.includes('Импорт игры')", 'developer games')
assert js("return !document.body.innerText.includes('Создать игру') && !document.body.innerText.includes('Результаты')")
first_request = len(stand.request_paths)
navigate(f'/games/{game}?tab=answers')
wait("location.search.includes('tab=state') && document.body.innerText.includes('Последнее обновление:')", 'developer state default')
assert js("return [...document.querySelectorAll('.n-tabs-tab[data-name]')].map(t=>t.textContent.trim()).join(',')==='Состояние'")
time.sleep(6)
assert not any(mode == 'venue' and path.startswith(f'/api/games/{game}/') for mode, path in stand.request_paths[first_request:]), 'Developer loads private game data'
# Heartbeat is browser-only for this role.
device_id = js("return localStorage.getItem('evening-detective-device-ID')")
devices = stand.apis['venue'].request('/api/devices')['devices']
current_connections = [c for d in devices if d['ID'] == device_id for c in d['connections'] if c['ID'] not in old_connections]
assert current_connections and all(c['kind'] == 'browser' for c in current_connections)
login('prod', 'developer@test.invalid')
navigate(f'/games/{game}?tab=state')
time.sleep(1)
assert js("return ![...document.querySelectorAll('.n-tabs-tab[data-name]')].some(t=>t.textContent.trim()==='Состояние')")
login('venue')
print('PASS: stage 13 Firefox state counts, inline failure/recovery/empty, inactive polling, developer restricted games/default tab/browser context and prod denial', flush=True)
command('WebDriver:NewSession', {'capabilities': {'alwaysMatch': {'acceptInsecureCerts': True}}})
for width, data in zip((1440, 390), fixtures, strict=True):
game, captain = data['game'], data['teams'][0]
command('WebDriver:SetWindowRect', {'width': width, 'height': 900})
login('prod')
navigate(f'/games/{game}?tab=settings')
wait("document.body.innerText.includes('Подготовить пакет')", 'export button')
wait("[...document.querySelectorAll('button')].some(b=>b.textContent.trim()==='Подготовить пакет' && !b.disabled)", 'export button')
click('Подготовить пакет')
wait("document.body.innerText.includes('Скачать пакет')", 'export ready', 60)
previous_downloads = {p.name for p in downloads.glob('*.zip')}
@@ -193,6 +260,7 @@ def check_browser_flow(stand, fixtures):
wait(f"location.pathname==='/games/{game}'", 'open imported game')
if width == 1440:
check_presence(data)
check_state(data)
navigate(f'/games/{game}?tab=payment')
wait("document.body.innerText.includes('Проверить билет')", 'ticket checking')
click('Проверить билет')
@@ -246,6 +314,7 @@ def check_browser_flow(stand, fixtures):
navigate(f'/games/{game}?tab=game')
wait("document.querySelector('[aria-label=\"Завершить игру\"]')!==null", 'finish control')
click('Завершить игру')
first_request = len(stand.request_paths)
navigate(f'/games/{game}?tab=answers')
wait("document.querySelector('.answers-score-input input')!==null", 'grading form')
input_value('.answers-score-input input', '5')
@@ -293,6 +362,7 @@ def check_browser_flow(stand, fixtures):
assert stand.apis['prod'].request('/api' + urllib.parse.urlparse(data['tickets'][1]['URL']).path)['ticket']['status'] == 'used'
assert stand.apis['prod'].request(f'/api/games/{game}/results')['teams'][0]['totalScore'] == 5
login('venue')
first_request = len(stand.request_paths)
navigate(f'/games/{game}?tab=answers')
wait("document.querySelector('.answers-score-input input')!==null", 'corrected grading')
input_value('.answers-score-input input', '0')