diff --git a/.gitignore b/.gitignore index 83357fa..2e5e4f6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,10 +7,12 @@ playwright.config.js playwright.config.d.ts .env .env.* +public/version.json npm-debug.log* input_info/extracted/ agent/state/ agent/reports/ +docs/HARDWARE_BROWSER_BENCHMARK_LATEST.md .agent/worktrees/ .demo-preview.pid .demo-preview.log diff --git a/Dockerfile b/Dockerfile index 902a789..9cd4afd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,13 @@ WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . +# .git is dockerignored — pass identity from host deploy script +ARG BUILD_COMMIT=unknown +ARG BUILD_BRANCH=unknown +ARG BUILD_RELEASE=unknown +ENV VITE_BUILD_COMMIT=$BUILD_COMMIT \ + VITE_BUILD_BRANCH=$BUILD_BRANCH \ + VITE_BUILD_RELEASE=$BUILD_RELEASE RUN npm run build FROM nginx:alpine AS runtime diff --git a/docs/CLOUDFLARE_NAMED_TUNNEL_SETUP.md b/docs/CLOUDFLARE_NAMED_TUNNEL_SETUP.md new file mode 100644 index 0000000..41fce10 --- /dev/null +++ b/docs/CLOUDFLARE_NAMED_TUNNEL_SETUP.md @@ -0,0 +1,100 @@ +# CLOUDFLARE_NAMED_TUNNEL_SETUP + +## Goal + +Постоянный HTTPS для `arhipovdan.ru` / `www.arhipovdan.ru` → production `http://127.0.0.1:3100` (`owl-web-1`), без открытия 80/443 на OwlPrime. + +Quick Tunnel (`*.trycloudflare.com`) **сохранять** до полного PASS постоянного домена. + +## Architecture choice + +```text +REG.RU = registrar only +Cloudflare = authoritative DNS + Named Tunnel + managed TLS +``` + +## Current blocker in this environment + +```text +cloudflared tunnel list → no origin cert (cert.pem) +CLOUDFLARE_API_TOKEN → unset +``` + +Named Tunnel **нельзя** создать/привязать DNS из coder без `cloudflared login` или API token. + +## Recommended tunnel name + +```text +owlprime-sorter-production +``` + +## Config template (do not commit credentials) + +Path suggestion (host, outside git): + +```text +~/.cloudflared/config.yml +~/.cloudflared/.json # credentials — chmod 600 +``` + +```yaml +tunnel: +credentials-file: /home//.cloudflared/.json + +ingress: + - hostname: arhipovdan.ru + service: http://127.0.0.1:3100 + - hostname: www.arhipovdan.ru + service: http://127.0.0.1:3100 + - service: http_status:404 +``` + +Service target assumes cloudflared on **host network namespace** with publish `127.0.0.1:3100` (current). If cloudflared runs in Docker, use `http://host.docker.internal:3100` or shared network alias `http://owl-web-1:80` — never a ephemeral container IP. + +## Operator steps (when Cloudflare access available) + +1. `cloudflared login` (or set API token with Tunnel + Zone DNS edit). +2. `cloudflared tunnel list` — reuse existing sorter tunnel if present. +3. Else: `cloudflared tunnel create owlprime-sorter-production` +4. Write config (template above). Validate: `cloudflared tunnel ingress validate` +5. Run connector (systemd preferred): `cloudflared tunnel run owlprime-sorter-production` +6. Add domain to Cloudflare → note **exact** assigned nameservers. +7. Import **all** DNS from `DNS_MIGRATION_INVENTORY.md` / REG.RU panel. +8. Create public hostnames / `cloudflared tunnel route dns arhipovdan.ru` (+ www). +9. Remove conflicting A `185.160.137.162` for apex/www in Cloudflare zone. +10. Only then change NS at REG.RU. +11. Keep Quick Tunnel until: + +```bash +curl -s https://arhipovdan.ru/version.json +# expected commit from production +PLAYWRIGHT_BASE_URL=https://arhipovdan.ru EXPECTED_COMMIT= npm run test:e2e:production +``` + +## Coexistence with Quick Tunnel + +| Process | Role | +| ------- | ---- | +| `cloudflared tunnel --url http://127.0.0.1:3100` | Temporary public URL | +| Named Tunnel connector | Permanent hostnames (after CF zone) | + +They can run in parallel; different Cloudflare edge bindings. + +## Secrets policy + +Never commit: + +* `*.json` credentials +* `cert.pem` +* API tokens +* tunnel tokens in systemd unit Environment= + +## Status + +```text +Named Tunnel created: NO (auth missing) +Ingress prepared as docs: YES +Connector healthy: N/A +DNS route: N/A +NS cutover: WAITING_USER +``` diff --git a/docs/DEMO_RUNBOOK.md b/docs/DEMO_RUNBOOK.md index fd84dc2..a4f473d 100644 --- a/docs/DEMO_RUNBOOK.md +++ b/docs/DEMO_RUNBOOK.md @@ -5,6 +5,7 @@ **Рабочий публичный URL (временный):** Quick Tunnel — см. `PRODUCTION_DEPLOYMENT_REPORT.md` **Ожидаемый постоянный домен:** https://arhipovdan.ru/ — **BLOCKED_EXTERNAL** (`PUBLIC_DOMAIN_DIAGNOSTIC.md`) **Локальный production:** http://127.0.0.1:3100/ +**Проверка версии:** `curl -s http://127.0.0.1:3100/version.json` **Репозиторий:** `/home/coder/arhipovdan/app` --- diff --git a/docs/DNS_MIGRATION_INVENTORY.md b/docs/DNS_MIGRATION_INVENTORY.md new file mode 100644 index 0000000..e70545d --- /dev/null +++ b/docs/DNS_MIGRATION_INVENTORY.md @@ -0,0 +1,78 @@ +# DNS_MIGRATION_INVENTORY + +Дата: 2026-07-15 +Домен: `arhipovdan.ru` +Источник: публичные `dig` запросы с OwlPrime/coder. + +## Текущие authoritative NS + +```text +ns1.reg.ru. +ns2.reg.ru. +``` + +REG.RU = регистратор **и** DNS provider. Cloudflare zone пока **не** создана из этого окружения (нет `cert.pem` / API token). + +## Публичные записи + +| Type | Name | Current value | TTL (approx) | Must migrate | Purpose | +| ---- | ---- | ------------- | -----------: | ------------ | ------- | +| NS | @ | ns1.reg.ru / ns2.reg.ru | — | Yes (delegation) | Authoritative DNS | +| SOA | @ | ns1.reg.ru. hostmaster… | 10800 | Auto in CF | Zone SOA | +| A | @ | 185.160.137.162 | 21600 | **Replace** with CF tunnel route | Broken openresty (404 / TLS SNI fail) | +| A | www | 185.160.137.162 | — | **Replace** with CF tunnel route | Same broken endpoint | +| AAAA | @ | *(none)* | — | No | — | +| CNAME | @ | *(none)* | — | No | Apex CNAME not used | +| MX | @ | *(none publicly visible)* | — | **Check in REG.RU panel** | Mail may be absent | +| TXT | @ | *(none publicly visible)* | — | **Check in REG.RU panel** | SPF / domain verify | +| TXT | `_dmarc` | *(none)* | — | **Check panel** | DMARC | +| TXT | `default._domainkey` | *(none)* | — | **Check panel** | DKIM | +| CAA | @ | *(none)* | — | Optional | TLS CA policy | + +### Subdomain probe (public A) + +Many common labels resolve to the **same** `185.160.137.162` (likely REG.RU catch-all / parking): + +| Name | A | Notes | +| ---- | - | ----- | +| mail | 185.160.137.162 | Confirm real mail usage in panel | +| smtp | 185.160.137.162 | | +| api | 185.160.137.162 | | +| app | 185.160.137.162 | | +| staging | 185.160.137.162 | | +| blog | 185.160.137.162 | | +| ftp | 185.160.137.162 | | + +**Перед сменой NS** откройте панель REG.RU → DNS и перенесите **все** реальные записи (особенно MX/TXT/DKIM), не только A для сайта. + +## Что нельзя потерять + +1. Любые MX / SPF / DKIM / DMARC (если почта используется). +2. TXT верификации (Google, Yandex, Cloudflare, etc.). +3. Поддомены других сервисов, если отличаются от parking IP. +4. CAA, если планируется нестандартный CA. + +## Рекомендуемый target после Cloudflare zone + +| Hostname | Type on Cloudflare | Target | +| -------- | ------------------ | ------ | +| `arhipovdan.ru` | Cloudflare Tunnel CNAME / route | Named Tunnel `owlprime-sorter-production` | +| `www.arhipovdan.ru` | same | same tunnel ingress | + +Не создавать «A = Named Tunnel» — A принимает только IPv4. + +## Сравнение вариантов публикации + +| Вариант | Требования | Риск | Рекомендация | +| ------- | ---------- | ---- | ------------ | +| Cloudflare Named Tunnel + NS на CF | Cloudflare zone + миграция всех DNS | Низкий | **Предпочтительно** | +| Direct nginx на OwlPrime :80/:443 | Public IP, NAT, firewall, TLS | Средний/высокий | Не делать автоматически | +| Quick Tunnel | Нет постоянного hostname | Высокий для demo | Только temporary fallback | + +## Статус миграции + +```text +Inventory: DONE (public) +Cloudflare zone: NOT CREATED (needs user / API login) +NS changed: NO +``` diff --git a/docs/FINAL_DEMO_READINESS.md b/docs/FINAL_DEMO_READINESS.md index 4a336f3..52967d6 100644 --- a/docs/FINAL_DEMO_READINESS.md +++ b/docs/FINAL_DEMO_READINESS.md @@ -1,40 +1,40 @@ # FINAL_DEMO_READINESS Дата: 2026-07-15 -Ветка: `feature/maximum-demo-realism` -Production release (не тронут этим коммитом): `20260715-1712` / `index-ncgt6PBL.js` ## Status board | Область | Статус | Комментарий | | ------- | ------ | ----------- | -| Git | PASS | Feature branch clean after commit; no push | -| Production container | PASS | `owl-web-1` на `:3100`, bundle `index-ncgt6PBL.js` | -| Permanent domain | BLOCKED_EXTERNAL | `arhipovdan.ru` → openresty 404 / TLS SNI fail | -| TLS | BLOCKED_EXTERNAL | Нет SNI-сертификата на destination IP | -| Main route | PASS | Local + Quick Tunnel | -| Details route | PASS | SPA fallback 200 | -| Unit tests | PASS | 166 | -| E2E | PASS | 15 (excl. production) | -| Visual regression | PASS | 10 snapshots | -| GPU performance | PARTIAL | SwiftShader/xvfb only; hardware baseline blocked in coder | -| Safety scenarios | PASS | Jam + E-stop e2e | -| Rollback | PASS | Tag `backup/pre-maximum-demo-realism-20260715` + docker rename | -| Agent safety | PASS | Worktree / dry-run / no auto DNS | +| Git | PASS | Feature branch; no push | +| Production container | PASS | `20260715-2215` / `index-AagIOJbd.js` / `version.json` → `16e7930` | +| Permanent domain | BLOCKED_EXTERNAL | REG.RU → openresty; CF Named Tunnel not auth'd | +| TLS (permanent) | BLOCKED_EXTERNAL | SNI fail on 185.160.137.162 | +| Quick Tunnel | PASS | Temporary public URL OK | +| Main / details | PASS | Local + tunnel | +| Unit / E2E / visual | PASS | 166 / 15 / 10 | +| GPU hardware (server) | BLOCKED_BY_DISPLAY_ENVIRONMENT | SwiftShader only | +| Portable benchmark | PASS tooling | `npm run perf:browser` on laptop | +| Replay stability | STABLE | See `REPLAY_RESOURCE_STABILITY.md` | +| Rollback | PASS | docker rename backup | +| Agent / secrets | PASS | no credentials in git | + +## Application readiness + +```text +READY +``` + +## Public domain readiness + +```text +WAITING — Cloudflare login + NS cutover (see CLOUDFLARE_NAMED_TUNNEL_SETUP.md) +``` ## Verdict ```text -READY WITH EXTERNAL DOMAIN BLOCKER +READY FOR LIVE DEMO VIA QUICK TUNNEL ``` -Live demo **можно** вести с: - -- `http://127.0.0.1:3100/` (локально), или -- актуального Quick Tunnel `https://invitations-based-characters-accent.trycloudflare.com/` - -Постоянный домен `https://arhipovdan.ru/` **не готов** до DNS/proxy фикса пользователем (см. `PUBLIC_DOMAIN_DIAGNOSTIC.md`). - -## Next deploy note - -Новый код (perf overlay, twin layout, visual snapshots) собран как `index-DEbogfOZ.js` в `dist/`, но **не** задеплоен в `owl-web-1`, чтобы не нарушить работающий production. Redeploy — отдельный шаг после доменного фикса или явного запроса. +After user completes Cloudflare zone + NS change → `READY, WAITING FOR NS CUTOVER` then permanent PASS. diff --git a/docs/HARDWARE_BENCHMARK_RUNBOOK.md b/docs/HARDWARE_BENCHMARK_RUNBOOK.md new file mode 100644 index 0000000..306a58b --- /dev/null +++ b/docs/HARDWARE_BENCHMARK_RUNBOOK.md @@ -0,0 +1,58 @@ +# HARDWARE_BENCHMARK_RUNBOOK + +## Server status (OwlPrime / coder) + +```text +GPU devices: NVIDIA GTX 1080 ×2 present (/dev/nvidia*, /dev/dri) +DISPLAY: unset (unless xvfb) +Chromium WebGL in this env: SwiftShader / software +Hardware acceleration for demo metrics: BLOCKED_BY_DISPLAY_ENVIRONMENT +``` + +Do **not** install a display server or change NVIDIA drivers for benchmark. + +## Portable benchmark (presentation laptop) + +On the machine that will show the demo to the jury: + +```bash +cd /path/to/app +npm ci +npm run build +npx vite preview --host 127.0.0.1 --port 3100 +# or point at production / Quick Tunnel: +# export PLAYWRIGHT_BASE_URL=https:/// + +npm run perf:browser +# writes agent/reports/browser-benchmark.json +# and docs/HARDWARE_BROWSER_BENCHMARK_LATEST.md (gitignored) + +npm run perf:browser:export # same +``` + +Requirements: + +* Real headed Chrome/Chromium (`DISPLAY` / macOS / Windows) +* **No** forced GPU flags (measures the jury machine as-is) +* Modes: low / medium / high / demo +* Includes playlist seeks + 3 replay cycles + +### Manual export from UI + +Open `/?perf=1` → button **Export benchmark** downloads JSON locally. + +## Acceptance (Demo mode) + +| Grade | Avg FPS | Min FPS | p95 | +| ----- | ------: | ------: | --: | +| Excellent | ≥55 | ≥40 | ≤20 ms | +| Acceptable | ≥30 | ≥24 | ≤33 ms | +| Not for live demo | <30 or p95 >40 ms | + +Hardware run is valid only if renderer string does **not** contain SwiftShader / llvmpipe / Software. + +## Server diagnostic (optional) + +```bash +npm run perf:gpu # may re-exec under xvfb; labels software renderers +``` diff --git a/docs/HEADED_GPU_PERFORMANCE_REPORT.md b/docs/HEADED_GPU_PERFORMANCE_REPORT.md index 7fea684..e84e9f2 100644 --- a/docs/HEADED_GPU_PERFORMANCE_REPORT.md +++ b/docs/HEADED_GPU_PERFORMANCE_REPORT.md @@ -7,13 +7,15 @@ Display: headed (:99) Software renderer detected: **YES** Verdict: **NOT A GPU BASELINE — software renderer (SwiftShader/llvmpipe/etc.)** -## Notes +## Server hardware WebGL re-check (2026-07-15) -- Headed Chromium with GPU-preferring flags. -- PerfCollector only with `?perf=1`; quality forced via `?quality=`. -- Prior headless ~9.6 FPS is labeled software/headless WebGL diagnostic baseline. -- **Host has NVIDIA GTX 1080 ×2**, but Playwright Chromium in this coder/Xvfb session resolves WebGL to **SwiftShader** — numbers below are **not** a hardware GPU baseline. -- For true GPU baseline: run `PLAYWRIGHT_BASE_URL=… npm run perf:gpu` on the presentation machine with a real `DISPLAY` and confirm renderer does **not** contain SwiftShader/llvmpipe. +Tried Chromium flags independently (`--use-gl=egl`, `--use-angle=gl-egl`, `--use-angle=gl`, Vulkan ANGLE) with NVIDIA EGL vendor file present. All resolved to **SwiftShader**. + +```text +BLOCKED_BY_DISPLAY_ENVIRONMENT +``` + +Portable path: `npm run perf:browser` / `docs/HARDWARE_BENCHMARK_RUNBOOK.md`. ## Mode table (after Play) diff --git a/docs/PERFORMANCE_AFTER_MAXIMUM_DEMO.md b/docs/PERFORMANCE_AFTER_MAXIMUM_DEMO.md index e64e380..eeda9a9 100644 --- a/docs/PERFORMANCE_AFTER_MAXIMUM_DEMO.md +++ b/docs/PERFORMANCE_AFTER_MAXIMUM_DEMO.md @@ -1,38 +1,33 @@ # PERFORMANCE_AFTER_MAXIMUM_DEMO -Measured: 2026-07-15 -Commits referenced: `985f7c3` / tooling on current branch +## Production identity -## Bundle (production container, unchanged) - -| Метрика | Значение | -| ------- | -------: | -| Main chunk | `index-ncgt6PBL.js` | -| Release | `20260715-1712` | +| Field | Value | +| ----- | ----- | +| Release | `20260715-2215` | +| Bundle | `index-AagIOJbd.js` | +| Commit | `16e7930` (via `/version.json`) | ## software/headless WebGL diagnostic baseline -**Не является характеристикой живой демонстрации.** +**Not a live-demo GPU characteristic.** ```text -Average FPS: 9.6 -Minimum FPS: 3.3 -p95 frame time: 200 ms +Average FPS: ~8.9–9.6 +Renderer: SwiftShader +Status: BLOCKED_BY_DISPLAY_ENVIRONMENT on server ``` -Повторено под xvfb/SwiftShader (~8.9 FPS demo) — см. `HEADED_GPU_PERFORMANCE_REPORT.md`. +## Replay geometries -## Headed / GPU tooling +Earlier probe showed +12 geometries after 3 replays under SwiftShader. +Re-run on production (`npm run perf:replay-stability`): **STABLE** (147 geos flat through 10 replays; +3 after GC pause only). -| Item | Status | -| ---- | ------ | -| `?perf=1` overlay | Implemented | -| `?quality=low\|medium\|high\|demo` | Implemented | -| `npm run perf:gpu` | Writes report + JSON | -| Hardware NVIDIA WebGL in coder | **Not available** (Chromium → SwiftShader) | -| True GPU baseline | Run on presentation laptop with DISPLAY + real GPU | +## Tooling -## Notes - -- Demo quality keeps `shadows=false` for stable FPS. -- Do not quote SwiftShader FPS to jury as demo capability. +| Command | Purpose | +| ------- | ------- | +| `npm run perf:gpu` | Server/xvfb diagnostic | +| `npm run perf:browser` | Portable headed laptop benchmark | +| `npm run perf:replay-stability` | Geometry/heap plateau check | +| `/?perf=1` → Export benchmark | Local JSON download | diff --git a/docs/PRODUCTION_DEPLOYMENT_REPORT.md b/docs/PRODUCTION_DEPLOYMENT_REPORT.md index 4eeb46e..ca21b4a 100644 --- a/docs/PRODUCTION_DEPLOYMENT_REPORT.md +++ b/docs/PRODUCTION_DEPLOYMENT_REPORT.md @@ -2,71 +2,60 @@ Дата: 2026-07-15 Ветка: `feature/maximum-demo-realism` -Базовый commit этапа 1: `985f7c3` -Production bundle после этапа 2: `index-ncgt6PBL.js` -## Способ деплоя - -Docker-контейнер `owl-web-1` (image `owl-web:`), порт `127.0.0.1:3100→80`. -Docker API: `DOCKER_HOST=tcp://127.0.0.1:2375` (из coder-контейнера). -Compose-файл: `docker-compose.server.yml` (исторически `-p owl`). -Скрипт: `scripts/deploy-production.sh`. - -Публичный доступ в этой среде: - -- Loopback: `http://127.0.0.1:3100/` -- Quick tunnel cloudflared: `https://invitations-based-characters-accent.trycloudflare.com/` -- `https://arhipovdan.ru/` из контейнера даёт TLS SNI / openresty 404 — DNS/прокси домена вне текущего coder-окружения; проверяйте снаружи. - -## Releases +## Current production | Поле | Значение | | ---- | -------- | -| Old release container | `owl-web-1-backup-20260715-165347` / `…-1712` | -| Old bundle | `index-CCdZzxPJ.js` | -| New release | `20260715-1712` | -| New image | `owl-web:20260715-1712` | -| New bundle | `index-ncgt6PBL.js` | -| Backup HTML | `releases/backup-pre-*` | +| Container | `owl-web-1` | +| Image | `owl-web:20260715-2215` | +| Bundle | `index-AagIOJbd.js` | +| Version endpoint | `/version.json` | +| Deployed commit (identity) | `16e7930` | +| Release id | `20260715-2215` | +| Publish | `127.0.0.1:3100→80` | +| Backup container | `owl-web-1-backup-20260715-2215` | -## Команды +## Previous + +| Поле | Значение | +| ---- | -------- | +| Release | `20260715-1712` | +| Bundle | `index-ncgt6PBL.js` | +| Commit identity | *(pre-version.json)* | + +## Public access + +| URL | Status | +| --- | ------ | +| `http://127.0.0.1:3100/` | PASS — commit `16e7930` | +| Quick Tunnel | PASS — same commit (kept running) | +| `https://arhipovdan.ru/` | BLOCKED_EXTERNAL — openresty / TLS SNI | + +## Deploy command ```bash export DOCKER_HOST=tcp://127.0.0.1:2375 -cd /home/coder/arhipovdan/app -npm test && npm run build -bash scripts/deploy-production.sh 20260715-1712 -curl -s http://127.0.0.1:3100/ | grep -Eo 'index-[A-Za-z0-9_-]+\.js' +EXPECTED_COMMIT=16e7930 bash scripts/deploy-production.sh 20260715-2215 ``` -## Healthcheck - -| Check | Result | -| ----- | ------ | -| `GET /` | 200 | -| `GET /details` (SPA) | 200 | -| Bundle | `index-ncgt6PBL.js` | -| Tunnel HTML | same bundle | -| `https://arhipovdan.ru/` | **BLOCKED_EXTERNAL** — `PUBLIC_DOMAIN_DIAGNOSTIC.md` | - -## Public smoke - -```bash -PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 npm run test:e2e:production -PLAYWRIGHT_BASE_URL=https://invitations-based-characters-accent.trycloudflare.com/ npm run test:e2e:production -``` +Build args inject `BUILD_COMMIT` / `BUILD_BRANCH` / `BUILD_RELEASE` because `.git` is dockerignored. ## Rollback ```bash export DOCKER_HOST=tcp://127.0.0.1:2375 docker stop owl-web-1 && docker rm owl-web-1 -docker rename owl-web-1-backup-20260715-1712 owl-web-1 +docker rename owl-web-1-backup-20260715-2215 owl-web-1 docker start owl-web-1 ``` -Или восстановить HTML из `releases/backup-pre-/`. +## Smoke -## Smoke (tunnel) +```bash +curl -s http://127.0.0.1:3100/version.json +PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 EXPECTED_COMMIT=16e7930 npm run test:e2e:production +PLAYWRIGHT_BASE_URL=https://invitations-based-characters-accent.trycloudflare.com EXPECTED_COMMIT=16e7930 npm run test:e2e:production +``` -Подтверждено: CASE 1/10, Jump to case 1–10, Previous/Next, Present, Play Demo, `/details`. +Both local and Quick Tunnel: **PASS**. diff --git a/docs/PUBLIC_DOMAIN_DIAGNOSTIC.md b/docs/PUBLIC_DOMAIN_DIAGNOSTIC.md index 60f10b8..1b1e8d4 100644 --- a/docs/PUBLIC_DOMAIN_DIAGNOSTIC.md +++ b/docs/PUBLIC_DOMAIN_DIAGNOSTIC.md @@ -9,7 +9,11 @@ BLOCKED_EXTERNAL ``` -Постоянный домен `https://arhipovdan.ru/` **не обслуживается** текущим production-контейнером `owl-web-1`. Приложение на `127.0.0.1:3100` здорово и отдаёт актуальный bundle; проблема на уровне DNS → чужой openresty на публичном IP. +Постоянный домен `https://arhipovdan.ru/` **не** обслуживает `owl-web-1`. +Production на `:3100` и Quick Tunnel обновлены до commit `16e7930` (`/version.json`). + +Named Tunnel: **не создан** из этого окружения — нет Cloudflare origin cert / API token. +См. `CLOUDFLARE_NAMED_TUNNEL_SETUP.md` и `DNS_MIGRATION_INVENTORY.md`. ## DNS diff --git a/docs/REPLAY_RESOURCE_STABILITY.md b/docs/REPLAY_RESOURCE_STABILITY.md new file mode 100644 index 0000000..c029b42 --- /dev/null +++ b/docs/REPLAY_RESOURCE_STABILITY.md @@ -0,0 +1,21 @@ +# REPLAY_RESOURCE_STABILITY + +Measured: 2026-07-15T22:18:59.976Z +Verdict: **STABLE** + +| Cycle | Heap MB | Geometries | Textures | Programs | Canvas | +| ----: | ------: | ---------: | -------: | -------: | -----: | +| 0 (initial) | 14.5 | 147 | 1 | 4 | 1 | +| 1 (after_1_replays) | 14.5 | 147 | 1 | 4 | 1 | +| 3 (after_3_replays) | 14.5 | 147 | 1 | 4 | 1 | +| 5 (after_5_replays) | 14.5 | 147 | 1 | 4 | 1 | +| 10 (after_10_replays) | 14.5 | 147 | 1 | 4 | 1 | +| 10 (after_gc_pause) | 14.5 | 150 | 1 | 4 | 1 | + +## Interpretation + +- **STABLE** — no meaningful growth. +- **LAZY_ALLOCATION_PLATEAU** — early growth then flat (expected for first-seen assets). +- **POSSIBLE_LEAK / CONFIRMED_LEAK** — investigate dispose paths. + +Raw: `agent/reports/replay-stability.json` diff --git a/docs/RISK_REGISTER.md b/docs/RISK_REGISTER.md index 46cddb3..299d7d9 100644 --- a/docs/RISK_REGISTER.md +++ b/docs/RISK_REGISTER.md @@ -1,10 +1,10 @@ # Реестр рисков (Risk Register) -**Проект:** OZON Tech Sorter Simulation -**Дата:** 2026-07-15 +**Проект:** OZON Tech Sorter Simulation +**Дата:** 2026-07-15 **Ветка:** `feature/maximum-demo-realism` -Шкала: вероятность / влияние = Low · Medium · High · Critical. +Шкала: вероятность / влияние = Low · Medium · High · Critical. Статус: Open · Mitigated · Accepted. --- @@ -13,7 +13,10 @@ | ID | Риск | Вероятность | Влияние | Статус | Митигация | | -- | ---- | ----------- | ------- | ------ | --------- | -| R01 | Production :3100 / https://arhipovdan.ru/ отдаёт старый dist / домен 404 | High | Critical | Open | Local+tunnel PASS; domain BLOCKED_EXTERNAL — `PUBLIC_DOMAIN_DIAGNOSTIC.md` | +| R01 | Production :3100 / arhipovdan.ru | Medium | Critical | Partially mitigated | Local+tunnel on `16e7930`+version.json; domain still BLOCKED_EXTERNAL | +| R18 | Tunnel/cloudflared outage / permanent DNS | High | Critical | Open | Quick Tunnel kept; Named Tunnel needs CF login — `CLOUDFLARE_NAMED_TUNNEL_SETUP.md` | +| R21 | Hardware GPU baseline недоступен в coder | High | Medium | Accepted | `BLOCKED_BY_DISPLAY_ENVIRONMENT`; portable `perf:browser` | +| R22 | DNS NS cutover теряет MX/TXT | Medium | High | Open | `DNS_MIGRATION_INVENTORY.md` — check REG.RU panel before NS change | | R02 | Визуальный разрыв `/` vs `/details` путает жюри | Medium | High | Mitigated | `sharedLayout` + routeConsistency tests | | R03 | Жюри воспринимает кинематику как «фейк» | Medium | High | Mitigated | Proof HUD + честные формулировки; jam/e-stop | | R04 | Pseudo-CV раскрыт как «обман» | Medium | Medium | Accepted | RULE overlay + confidence; не обещать ML | @@ -40,15 +43,15 @@ ### R01 — Устаревший production dist -**Симптом:** локально proof/hotkeys есть, на https://arhipovdan.ru/ — нет. -**Детектор:** сравнить UI; `demo:health`; hash файлов в контейнере (если доступен docker на хосте). -**Реакция:** ручной redeploy; на показе переключиться на проверенный preview. +**Симптом:** локально proof/hotkeys есть, на https://arhipovdan.ru/ — нет. +**Детектор:** сравнить UI; `demo:health`; hash файлов в контейнере (если доступен docker на хосте). +**Реакция:** ручной redeploy; на показе переключиться на проверенный preview. **Тип данных о риске:** подтверждён аудитом как **процессный** факт («may still serve OLD dist»). ### R09 — Автономный агент vs production -**Симптом:** гипотетический merge/deploy без человека. -**Текущий контроль:** `forbidMergeToMain`, `forbidProductionDeploy`, нет Implementer auto-patch. +**Симптом:** гипотетический merge/deploy без человека. +**Текущий контроль:** `forbidMergeToMain`, `forbidProductionDeploy`, нет Implementer auto-patch. **Остаточный риск:** появится при расширении MVP без обновления safety caps. ### R10 — Секреты @@ -93,11 +96,11 @@ ## 6. Триггеры пересмотра реестра -- Смена хоста/GPU/RAM. -- Включение Implementer auto-patch. -- Добавление physics engine. -- Подключение реального CV inference. -- Появление CI e2e. +- Смена хоста/GPU/RAM. +- Включение Implementer auto-patch. +- Добавление physics engine. +- Подключение реального CV inference. +- Появление CI e2e. - Инцидент на живом показе. --- diff --git a/e2e/production.smoke.spec.ts b/e2e/production.smoke.spec.ts index 5d9c9ef..184e897 100644 --- a/e2e/production.smoke.spec.ts +++ b/e2e/production.smoke.spec.ts @@ -1,13 +1,13 @@ import { test, expect } from '@playwright/test'; /** - * Production smoke — run manually: - * PLAYWRIGHT_BASE_URL=https://arhipovdan.ru/ npm run test:e2e:production - * or against the working tunnel / local prod: - * PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 npm run test:e2e:production + * Production smoke — manual only: + * PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 EXPECTED_COMMIT=16e7930 npm run test:e2e:production */ test.describe('production smoke @production', () => { - test('serves current app shell and demo controls', async ({ page }) => { + test('version, routes, controls, safety, webgl', async ({ page }) => { + test.setTimeout(120_000); + const expectedCommit = process.env.EXPECTED_COMMIT?.trim(); const pageErrors: string[] = []; const failed: string[] = []; page.on('pageerror', (e) => pageErrors.push(String(e))); @@ -23,22 +23,94 @@ test.describe('production smoke @production', () => { expect(bundle).toMatch(/^index-/); expect(bundle).not.toBe('index-CCdZzxPJ.js'); + const version = await page.evaluate(async () => { + const r = await fetch('/version.json', { cache: 'no-store' }); + if (!r.ok) return null; + return r.json(); + }); + expect(version, 'version.json missing').toBeTruthy(); + expect(version.commit).toMatch(/^[0-9a-f]{7,40}$/i); + if (expectedCommit) { + expect(version.commit).toBe(expectedCommit); + } + await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 }); - await expect(page.getByTestId('demo-play')).toBeVisible(); + const webgl = await page.evaluate(() => { + const c = document.createElement('canvas'); + const gl = c.getContext('webgl2') || c.getContext('webgl'); + if (!gl) return { ok: false, renderer: null as string | null }; + const dbg = gl.getExtension('WEBGL_debug_renderer_info'); + return { + ok: true, + renderer: dbg + ? (gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) as string) + : (gl.getParameter(gl.RENDERER) as string), + }; + }); + expect(webgl.ok).toBe(true); + + await expect(page.getByTestId('demo-play').or(page.getByTestId('demo-pause'))).toBeVisible(); await expect(page.getByTestId('demo-case-9')).toBeVisible(); - await page.getByTestId('demo-play').click(); - await page.waitForTimeout(800); + // Play / seek + const pauseBtn = page.getByTestId('demo-pause'); + if (!(await pauseBtn.isVisible().catch(() => false))) { + await page.getByTestId('demo-play').click(); + } + await page.waitForTimeout(600); + await page.getByTestId('demo-case-0').click(); + await expect(page.getByTestId('demo-case-label')).toHaveText('1/10'); + + // Jam + await page.getByTestId('demo-speed-2').click(); await page.getByTestId('demo-case-8').click(); - await expect(page.getByTestId('demo-case-label')).toHaveText('9/10'); + await expect + .poll(async () => { + const status = (await page.getByTestId('demo-status').textContent()) ?? ''; + const command = (await page.getByTestId('demo-command').textContent()) ?? ''; + return `${status} ${command}`; + }, { timeout: 30_000 }) + .toMatch(/FAULT|JAM/i); + + // E-stop + await page.getByTestId('demo-case-9').click(); + await expect + .poll(async () => { + const status = (await page.getByTestId('demo-status').textContent()) ?? ''; + const command = (await page.getByTestId('demo-command').textContent()) ?? ''; + return `${status} ${command}`; + }, { timeout: 30_000 }) + .toMatch(/EMERGENCY/i); + + // Reset / recover toward case 0 + const stop = page.getByTestId('demo-stop'); + if (await stop.isVisible().catch(() => false)) { + await stop.click({ force: true }); + } + await page.getByTestId('demo-case-0').click({ force: true }); + await expect(page.getByTestId('demo-case-label')).toHaveText('1/10'); await page.goto('/details'); await expect(page.locator('#root')).toBeVisible(); await page.reload(); await expect(page.locator('#root')).toBeVisible(); + // Back home — version still matches + await page.goto('/'); + const version2 = await page.evaluate(async () => { + const r = await fetch('/version.json', { cache: 'no-store' }); + return r.ok ? r.json() : null; + }); + expect(version2?.commit).toBe(version.commit); + expect(pageErrors, pageErrors.join('\n')).toEqual([]); const critical = failed.filter((f) => !f.includes('favicon')); expect(critical, critical.join('\n')).toEqual([]); + + // Attach renderer for report (not a hard fail if software) + test.info().annotations.push({ + type: 'webgl-renderer', + description: webgl.renderer ?? 'unknown', + }); }); }); diff --git a/e2e/visual.spec.ts-snapshots/10-perf-overlay-chromium-linux.png b/e2e/visual.spec.ts-snapshots/10-perf-overlay-chromium-linux.png index e211f37..4a0359a 100644 Binary files a/e2e/visual.spec.ts-snapshots/10-perf-overlay-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/10-perf-overlay-chromium-linux.png differ diff --git a/nginx.conf b/nginx.conf index 165d6e0..266f726 100644 --- a/nginx.conf +++ b/nginx.conf @@ -4,6 +4,11 @@ server { root /usr/share/nginx/html; index index.html; + location = /version.json { + add_header Cache-Control "no-store, no-cache, must-revalidate"; + try_files $uri =404; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/package.json b/package.json index 108ac7e..7247227 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,10 @@ "demo:start": "bash scripts/demo-start.sh", "demo:stop": "bash scripts/demo-stop.sh", "demo:health": "bash scripts/demo-health.sh", - "perf:gpu": "node scripts/headed-gpu-baseline.mjs" + "perf:gpu": "node scripts/headed-gpu-baseline.mjs", + "perf:browser": "node scripts/portable-browser-benchmark.mjs", + "perf:browser:export": "node scripts/portable-browser-benchmark.mjs --export", + "perf:replay-stability": "node scripts/replay-stability.mjs" }, "dependencies": { "@react-three/drei": "^10.7.7", diff --git a/releases/CURRENT_BUNDLE.txt b/releases/CURRENT_BUNDLE.txt index 9ac2b03..6e7b408 100644 --- a/releases/CURRENT_BUNDLE.txt +++ b/releases/CURRENT_BUNDLE.txt @@ -1 +1 @@ -index-ncgt6PBL.js +index-AagIOJbd.js diff --git a/releases/CURRENT_COMMIT.txt b/releases/CURRENT_COMMIT.txt new file mode 100644 index 0000000..b7952aa --- /dev/null +++ b/releases/CURRENT_COMMIT.txt @@ -0,0 +1 @@ +16e7930 diff --git a/releases/CURRENT_RELEASE.txt b/releases/CURRENT_RELEASE.txt index 03de410..da0c49a 100644 --- a/releases/CURRENT_RELEASE.txt +++ b/releases/CURRENT_RELEASE.txt @@ -1 +1 @@ -20260715-1712 +20260715-2215 diff --git a/releases/CURRENT_VERSION.json b/releases/CURRENT_VERSION.json new file mode 100644 index 0000000..2ca9a38 --- /dev/null +++ b/releases/CURRENT_VERSION.json @@ -0,0 +1,6 @@ +{ + "commit": "16e7930", + "branch": "feature/maximum-demo-realism", + "builtAt": "2026-07-15T22:17:42.644Z", + "release": "20260715-2215" +} diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh index 01e1f6a..2b4c13b 100755 --- a/scripts/deploy-production.sh +++ b/scripts/deploy-production.sh @@ -13,12 +13,21 @@ echo "[deploy] release=$RELEASE" OLD_BUNDLE=$(curl -s http://127.0.0.1:3100/ | grep -Eo 'index-[A-Za-z0-9_-]+\.js' | head -1 || true) echo "[deploy] old bundle: ${OLD_BUNDLE:-unknown}" +BUILD_COMMIT="${BUILD_COMMIT:-$(git rev-parse --short HEAD 2>/dev/null || echo unknown)}" +BUILD_BRANCH="${BUILD_BRANCH:-$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)}" +export BUILD_COMMIT BUILD_BRANCH +echo "[deploy] commit=$BUILD_COMMIT branch=$BUILD_BRANCH" + if docker ps --format '{{.Names}}' | grep -qx owl-web-1; then docker cp owl-web-1:/usr/share/nginx/html/. "$BACKUP_DIR/" || true fi echo "[deploy] building image..." -docker build -t "owl-web:${RELEASE}" -t owl-web:latest . +docker build \ + --build-arg "BUILD_COMMIT=${BUILD_COMMIT}" \ + --build-arg "BUILD_BRANCH=${BUILD_BRANCH}" \ + --build-arg "BUILD_RELEASE=${RELEASE}" \ + -t "owl-web:${RELEASE}" -t owl-web:latest . if docker ps -a --format '{{.Names}}' | grep -qx owl-web-1; then NET=$(docker inspect owl-web-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}' 2>/dev/null || echo owl_default) @@ -40,10 +49,22 @@ sleep 2 NEW_BUNDLE=$(curl -s http://127.0.0.1:3100/ | grep -Eo 'index-[A-Za-z0-9_-]+\.js' | head -1 || true) CODE=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3100/) DETAILS=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3100/details) +VERSION_JSON=$(curl -s http://127.0.0.1:3100/version.json || true) +VERSION_COMMIT=$(printf '%s' "$VERSION_JSON" | grep -Eo '"commit"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/' || true) -echo "[deploy] http=$CODE details=$DETAILS bundle=$NEW_BUNDLE" +echo "[deploy] http=$CODE details=$DETAILS bundle=$NEW_BUNDLE version.commit=${VERSION_COMMIT:-missing}" -if [[ "$CODE" != "200" || -z "$NEW_BUNDLE" || "$NEW_BUNDLE" == "$OLD_BUNDLE" && "$OLD_BUNDLE" == "index-CCdZzxPJ.js" ]]; then +HEALTH_OK=1 +[[ "$CODE" == "200" ]] || HEALTH_OK=0 +[[ -n "$NEW_BUNDLE" ]] || HEALTH_OK=0 +[[ "$DETAILS" == "200" ]] || HEALTH_OK=0 +[[ -n "$VERSION_COMMIT" && "$VERSION_COMMIT" != "unknown" ]] || HEALTH_OK=0 +if [[ -n "${EXPECTED_COMMIT:-}" && "$VERSION_COMMIT" != "$EXPECTED_COMMIT" ]]; then + echo "[deploy] expected commit $EXPECTED_COMMIT but got ${VERSION_COMMIT:-empty}" + HEALTH_OK=0 +fi + +if [[ "$HEALTH_OK" != "1" ]]; then echo "[deploy] health failed — attempting rollback" docker stop owl-web-1 && docker rm owl-web-1 || true if docker ps -a --format '{{.Names}}' | grep -qx "owl-web-1-backup-${RELEASE}"; then @@ -55,5 +76,7 @@ fi echo "$RELEASE" > releases/CURRENT_RELEASE.txt echo "$NEW_BUNDLE" > releases/CURRENT_BUNDLE.txt +echo "$VERSION_COMMIT" > releases/CURRENT_COMMIT.txt +printf '%s\n' "$VERSION_JSON" > releases/CURRENT_VERSION.json echo "[deploy] SUCCESS" echo "Rollback: docker stop owl-web-1 && docker rm owl-web-1 && docker rename owl-web-1-backup-${RELEASE} owl-web-1 && docker start owl-web-1" diff --git a/scripts/portable-browser-benchmark.mjs b/scripts/portable-browser-benchmark.mjs new file mode 100644 index 0000000..4040bfc --- /dev/null +++ b/scripts/portable-browser-benchmark.mjs @@ -0,0 +1,233 @@ +#!/usr/bin/env node +/** + * Portable headed browser benchmark for the presentation laptop. + * Uses the system Chrome/Chromium channel when available — no forced GPU flags. + * + * npm run perf:browser + * npm run perf:browser:export + * + * Env: PLAYWRIGHT_BASE_URL (default http://127.0.0.1:3100) + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { chromium } from 'playwright'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const MODES = ['low', 'medium', 'high', 'demo']; +const exportOnly = process.argv.includes('--export') || process.env.PERF_EXPORT === '1'; + +function baseUrl() { + const raw = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:3100'; + return new URL(raw.includes('://') ? raw : `http://${raw}`); +} + +function urlFor(mode) { + const u = baseUrl(); + u.searchParams.set('perf', '1'); + u.searchParams.set('quality', mode); + return u.toString(); +} + +function isSoft(renderer = '') { + return /swiftshader|llvmpipe|softpipe|software|mesa offscreen|microsoft basic render/i.test( + renderer, + ); +} + +async function sample(page) { + return ( + (await page.evaluate(() => { + const s = window.__PERF_SNAPSHOT__; + return s ? { ...s } : null; + })) || { + mode: 'demo', + renderer: 'unknown', + averageFps: 0, + minimumFps: 0, + p95FrameTimeMs: 0, + p99FrameTimeMs: 0, + longFramesOver33: 0, + longFramesOver50: 0, + drawCalls: 0, + triangles: 0, + geometries: 0, + textures: 0, + programs: 0, + heapMb: 0, + dpr: 1, + shadows: false, + antialias: false, + hardwareAccelerated: false, + } + ); +} + +async function measureMode(browser, mode, notes) { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + try { + await page.goto(urlFor(mode), { waitUntil: 'domcontentloaded', timeout: 90_000 }); + await page.waitForSelector('canvas', { timeout: 60_000 }); + await page.waitForTimeout(2000); + await page.evaluate(() => window.__PERF_RESET__?.()); + await page.waitForTimeout(2500); + const idle = await sample(page); + + await page.evaluate(() => window.__PERF_RESET__?.()); + await page.getByTestId('demo-play').click({ timeout: 8_000 }).catch(() => notes.push(`${mode}: play skipped`)); + await page.waitForTimeout(5000); + const play = await sample(page); + + // Full-ish playlist: jump through cases quickly + for (const idx of [0, 2, 4, 8, 9]) { + await page.getByTestId(`demo-case-${idx}`).click().catch(() => undefined); + await page.waitForTimeout(900); + } + const afterPlaylist = await sample(page); + + // Three replays (Replay Demo overlay or seek case 0 + play) + for (let i = 0; i < 3; i++) { + const finished = page.getByTestId('demo-finished'); + if (await finished.isVisible().catch(() => false)) { + await finished.getByRole('button', { name: /Replay/i }).click().catch(() => undefined); + } else { + await page.getByTestId('demo-case-0').click().catch(() => undefined); + await page.getByTestId('demo-play').click().catch(() => undefined); + } + await page.waitForTimeout(1500); + } + const afterReplay = await sample(page); + + const meta = await page.evaluate(() => ({ + userAgent: navigator.userAgent, + viewport: { width: window.innerWidth, height: window.innerHeight }, + webgl: (() => { + const c = document.createElement('canvas'); + const gl = c.getContext('webgl2') || c.getContext('webgl'); + if (!gl) return { ok: false }; + const dbg = gl.getExtension('WEBGL_debug_renderer_info'); + return { + ok: true, + vendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : null, + renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER), + }; + })(), + })); + + return { + mode, + idle, + play, + afterPlaylist, + afterReplay, + meta, + software: isSoft(play.renderer || meta.webgl?.renderer || ''), + }; + } finally { + await page.close(); + } +} + +function toMarkdown(report) { + const rows = report.modes + .map((m) => { + const s = m.play; + return `| ${m.mode} | ${s.renderer} | ${s.averageFps} | ${s.minimumFps} | ${s.p95FrameTimeMs} | ${s.drawCalls} | ${s.triangles} | ${s.heapMb} |`; + }) + .join('\n'); + const soft = report.softwareRendererDetected; + return `# HARDWARE_BROWSER_BENCHMARK + +Measured: ${report.measuredAt} +Commit probe URL: ${report.baseUrl} +Browser: ${report.browserVersion} +Headed: yes (no forced GPU flags) +Software renderer: **${soft ? 'YES' : 'no'}** + +## Mode table (after Play) + +| Mode | Renderer | Avg FPS | Min FPS | p95 | Calls | Triangles | Heap | +| ---- | -------- | ------: | ------: | --: | ----: | --------: | ---: | +${rows} + +## Notes + +${report.notes.map((n) => `- ${n}`).join('\n')} + +Raw JSON: \`agent/reports/browser-benchmark.json\` +`; +} + +async function main() { + const notes = []; + if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { + notes.push( + 'No DISPLAY — attempting headed launch may fail. Run on the presentation laptop with a real display.', + ); + } + + let browser; + try { + browser = await chromium.launch({ + headless: false, + channel: process.env.PERF_CHROME_CHANNEL || undefined, + // Intentionally NO GPU override flags — measure the machine as the jury sees it. + }); + } catch (err) { + notes.push(`channel launch failed (${err.message}); falling back to bundled Chromium headed`); + browser = await chromium.launch({ headless: false }); + } + + const modes = []; + try { + for (const mode of MODES) { + modes.push(await measureMode(browser, mode, notes)); + } + } finally { + await browser.close(); + } + + const commit = spawnSync('git', ['rev-parse', '--short', 'HEAD'], { + cwd: ROOT, + encoding: 'utf8', + }).stdout.trim(); + + const report = { + measuredAt: new Date().toISOString(), + commit, + baseUrl: baseUrl().toString(), + browserVersion: modes[0]?.meta?.userAgent ?? 'unknown', + softwareRendererDetected: modes.some((m) => m.software), + modes, + notes, + }; + + mkdirSync(join(ROOT, 'agent', 'reports'), { recursive: true }); + const jsonPath = join(ROOT, 'agent', 'reports', 'browser-benchmark.json'); + const mdPath = join(ROOT, 'docs', 'HARDWARE_BROWSER_BENCHMARK_LATEST.md'); + writeFileSync(jsonPath, JSON.stringify(report, null, 2)); + writeFileSync(mdPath, toMarkdown(report)); + + console.log( + JSON.stringify( + { + ok: true, + exportOnly, + softwareRendererDetected: report.softwareRendererDetected, + demoFps: modes.find((m) => m.mode === 'demo')?.play.averageFps, + renderer: modes.find((m) => m.mode === 'demo')?.play.renderer, + jsonPath, + mdPath, + }, + null, + 2, + ), + ); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/replay-stability.mjs b/scripts/replay-stability.mjs new file mode 100644 index 0000000..ed6a5dd --- /dev/null +++ b/scripts/replay-stability.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node +/** + * Replay resource stability probe — geometries/textures/programs/heap across N replays. + * + * PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 npm run perf:replay-stability + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const CYCLES = [0, 1, 3, 5, 10]; // cumulative replay counts to sample after + +function targetUrl() { + const raw = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:3100'; + const u = new URL(raw.includes('://') ? raw : `http://${raw}`); + u.searchParams.set('perf', '1'); + u.searchParams.set('quality', 'demo'); + return u.toString(); +} + +async function snap(page) { + return page.evaluate(() => { + const s = window.__PERF_SNAPSHOT__; + const canvasCount = document.querySelectorAll('canvas').length; + return { + at: new Date().toISOString(), + geometries: s?.geometries ?? null, + textures: s?.textures ?? null, + programs: s?.programs ?? null, + heapMb: s?.heapMb ?? null, + drawCalls: s?.drawCalls ?? null, + triangles: s?.triangles ?? null, + renderer: s?.renderer ?? null, + canvasCount, + }; + }); +} + +async function dismissFinished(page) { + const finished = page.getByTestId('demo-finished'); + if (await finished.isVisible().catch(() => false)) { + await finished.getByRole('button', { name: /Replay/i }).click(); + await page.waitForTimeout(500); + } +} + +async function doReplay(page) { + await dismissFinished(page); + await page.getByTestId('demo-case-0').click().catch(() => undefined); + await page.waitForTimeout(200); + const pause = page.getByTestId('demo-pause'); + if (!(await pause.isVisible().catch(() => false))) { + await page.getByTestId('demo-play').click().catch(() => undefined); + } + await page.waitForTimeout(1200); +} + +function classify(rows) { + const geos = rows.map((r) => r.geometries).filter((n) => typeof n === 'number'); + if (geos.length < 3) return 'INCONCLUSIVE'; + const first = geos[0]; + const last = geos[geos.length - 1]; + const mid = geos[Math.floor(geos.length / 2)]; + // plateau: late samples within +2 of mid + const late = geos.slice(-2); + const plateau = late.every((g) => Math.abs(g - mid) <= 2); + const monotonic = geos.every((g, i) => i === 0 || g >= geos[i - 1]); + const growth = last - first; + if (plateau && growth <= 20) return 'LAZY_ALLOCATION_PLATEAU'; + if (monotonic && growth > 20 && !plateau) return 'POSSIBLE_LEAK'; + if (growth > 50) return 'CONFIRMED_LEAK'; + if (growth <= 5) return 'STABLE'; + return plateau ? 'LAZY_ALLOCATION_PLATEAU' : 'POSSIBLE_LEAK'; +} + +async function main() { + const headed = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); + const browser = await chromium.launch({ + headless: !headed, + args: ['--enable-webgl', '--ignore-gpu-blocklist', '--enable-unsafe-swiftshader'], + }); + const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); + const samples = []; + + try { + await page.goto(targetUrl(), { waitUntil: 'domcontentloaded', timeout: 90_000 }); + await page.waitForSelector('canvas', { timeout: 60_000 }); + await page.waitForTimeout(3000); + samples.push({ cycle: 0, label: 'initial', ...(await snap(page)) }); + + let done = 0; + for (const target of CYCLES.slice(1)) { + while (done < target) { + await doReplay(page); + done += 1; + } + samples.push({ cycle: target, label: `after_${target}_replays`, ...(await snap(page)) }); + } + + // GC pause + await page.evaluate(() => { + // @ts-expect-error + if (typeof gc === 'function') gc(); + }).catch(() => undefined); + await page.waitForTimeout(3000); + samples.push({ cycle: 10, label: 'after_gc_pause', ...(await snap(page)) }); + } finally { + await browser.close(); + } + + const verdict = classify(samples); + const report = { + measuredAt: new Date().toISOString(), + url: targetUrl(), + headed, + verdict, + samples, + }; + + mkdirSync(join(ROOT, 'agent', 'reports'), { recursive: true }); + writeFileSync(join(ROOT, 'agent', 'reports', 'replay-stability.json'), JSON.stringify(report, null, 2)); + + const md = `# REPLAY_RESOURCE_STABILITY + +Measured: ${report.measuredAt} +Verdict: **${verdict}** + +| Cycle | Heap MB | Geometries | Textures | Programs | Canvas | +| ----: | ------: | ---------: | -------: | -------: | -----: | +${samples + .map( + (s) => + `| ${s.cycle} (${s.label}) | ${s.heapMb ?? '—'} | ${s.geometries ?? '—'} | ${s.textures ?? '—'} | ${s.programs ?? '—'} | ${s.canvasCount ?? '—'} |`, + ) + .join('\n')} + +## Interpretation + +- **STABLE** — no meaningful growth. +- **LAZY_ALLOCATION_PLATEAU** — early growth then flat (expected for first-seen assets). +- **POSSIBLE_LEAK / CONFIRMED_LEAK** — investigate dispose paths. + +Raw: \`agent/reports/replay-stability.json\` +`; + writeFileSync(join(ROOT, 'docs', 'REPLAY_RESOURCE_STABILITY.md'), md); + console.log(JSON.stringify({ ok: true, verdict, samples: samples.length }, null, 2)); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/components/BuildIdentityBadge.tsx b/src/components/BuildIdentityBadge.tsx new file mode 100644 index 0000000..a13a4ae --- /dev/null +++ b/src/components/BuildIdentityBadge.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; + +export interface VersionInfo { + commit: string; + branch: string; + builtAt: string; + release: string; +} + +/** + * Engineering-only build chip. Hidden in presentation mode. + * Visible with ?perf=1 or ?build=1. + */ +export default function BuildIdentityBadge() { + const [info, setInfo] = useState(null); + const [show, setShow] = useState(false); + const [inPresentation, setInPresentation] = useState(false); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + setShow(params.get('perf') === '1' || params.get('build') === '1'); + }, []); + + useEffect(() => { + if (!show) return; + let cancelled = false; + fetch('/version.json', { cache: 'no-store' }) + .then((r) => (r.ok ? r.json() : null)) + .then((j) => { + if (!cancelled && j?.commit) setInfo(j as VersionInfo); + }) + .catch(() => undefined); + const id = window.setInterval(() => { + setInPresentation(!!document.querySelector('.presentation-mode')); + }, 500); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [show]); + + if (!show || !info || inPresentation) return null; + + return ( +
+ build + {info.commit} + {info.release} +
+ ); +} diff --git a/src/components/ThreeD/PerfOverlay.tsx b/src/components/ThreeD/PerfOverlay.tsx index cb95a25..6844cb7 100644 --- a/src/components/ThreeD/PerfOverlay.tsx +++ b/src/components/ThreeD/PerfOverlay.tsx @@ -49,6 +49,22 @@ export default function PerfOverlay({ enabled }: PerfOverlayProps) { const soft = !snap.hardwareAccelerated; + const exportBenchmark = () => { + const payload = { + exportedAt: new Date().toISOString(), + url: window.location.href, + userAgent: navigator.userAgent, + viewport: { width: window.innerWidth, height: window.innerHeight }, + snapshot: snap, + }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `sorter-benchmark-${Date.now()}.json`; + a.click(); + URL.revokeObjectURL(a.href); + }; + return (
42 ? '…' : ''}
+ ); } diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index da0ed09..ae8c6ee 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -9,6 +9,7 @@ import { getMeasurementData, shouldShowMeasurement } from '../domain/measurement import { getViewportType, type ViewportType } from '../domain/cinematicCamera'; import { detectQualityMode } from '../domain/qualityMode'; import { resolveItem } from '../data/resolveItem'; +import BuildIdentityBadge from '../components/BuildIdentityBadge'; import CVInspectionOverlay from '../components/CVInspectionOverlay'; const SorterDigitalTwinContinuous = lazy(() => import('../components/ThreeD/SorterDigitalTwinContinuous')); @@ -391,6 +392,8 @@ export default function MainPage({ Exit presentation (P) )} + + ); } diff --git a/src/styles.css b/src/styles.css index ddd90d7..6f8ee4f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2992,7 +2992,7 @@ button:disabled { color: #e8f0f7; background: rgba(8, 14, 22, 0.78); border: 1px solid rgba(120, 160, 200, 0.35); - pointer-events: none; + pointer-events: auto; user-select: none; } .perf-overlay-title { @@ -3004,7 +3004,39 @@ button:disabled { .perf-overlay .perf-muted { color: rgba(200, 220, 240, 0.55); } .perf-overlay .perf-soft { color: #f0b4a0; margin-top: 4px; } .perf-overlay .perf-hw { color: #a8e0b8; margin-top: 4px; } -.main-page.presentation-mode .perf-overlay { display: none; } +.perf-export-btn { + margin-top: 8px; + width: 100%; + padding: 4px 6px; + font: inherit; + font-size: 10px; + color: #0b1220; + background: #9fd0ff; + border: none; + cursor: pointer; +} +.perf-export-btn:hover { background: #c5e4ff; } +.build-identity { + position: absolute; + bottom: 10px; + left: 10px; + z-index: 35; + display: flex; + gap: 8px; + align-items: baseline; + padding: 4px 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10px; + color: #c8d8e8; + background: rgba(8, 14, 22, 0.7); + border: 1px solid rgba(120, 160, 200, 0.3); + pointer-events: none; +} +.build-identity-label { text-transform: uppercase; letter-spacing: 0.08em; color: #7aa8cc; } +.build-identity-commit { color: #e8f4ff; font-weight: 600; } +.build-identity-release { color: rgba(200, 220, 240, 0.55); } +.main-page.presentation-mode .perf-overlay, +.main-page.presentation-mode .build-identity { display: none; } .main-page.presentation-mode .main-hud, .main-page.presentation-mode .main-progress, diff --git a/vite.config.ts b/vite.config.ts index 948011f..22c79fe 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,71 @@ -import { defineConfig } from 'vite'; +import { defineConfig, type Plugin } from 'vite'; import react from '@vitejs/plugin-react'; +import { execSync } from 'node:child_process'; +import { writeFileSync, mkdirSync } from 'node:fs'; +import { resolve } from 'node:path'; + +export interface BuildVersionInfo { + commit: string; + branch: string; + builtAt: string; + release: string; +} + +function git(cmd: string): string { + try { + return execSync(cmd, { encoding: 'utf8' }).trim(); + } catch { + return ''; + } +} + +export function resolveBuildVersion(): BuildVersionInfo { + const commit = + process.env.VITE_BUILD_COMMIT || + process.env.BUILD_COMMIT || + git('git rev-parse --short HEAD') || + 'unknown'; + const branch = + process.env.VITE_BUILD_BRANCH || + process.env.BUILD_BRANCH || + git('git rev-parse --abbrev-ref HEAD') || + 'unknown'; + const release = + process.env.VITE_BUILD_RELEASE || + process.env.BUILD_RELEASE || + process.env.RELEASE || + ''; + return { + commit: commit.replace(/^v/, '').slice(0, 40), + branch, + builtAt: new Date().toISOString(), + release: release || new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14), + }; +} + +/** Emits /version.json into dist (and public during build) — no secrets/paths. */ +function versionJsonPlugin(): Plugin { + return { + name: 'emit-version-json', + apply: 'build', + buildStart() { + const info = resolveBuildVersion(); + const publicDir = resolve(process.cwd(), 'public'); + mkdirSync(publicDir, { recursive: true }); + writeFileSync(resolve(publicDir, 'version.json'), `${JSON.stringify(info, null, 2)}\n`); + }, + closeBundle() { + const info = resolveBuildVersion(); + const out = resolve(process.cwd(), 'dist', 'version.json'); + writeFileSync(out, `${JSON.stringify(info, null, 2)}\n`); + // eslint-disable-next-line no-console + console.log(`[version] ${info.commit} @ ${info.branch} release=${info.release}`); + }, + }; +} export default defineConfig({ - plugins: [react()], + plugins: [react(), versionJsonPlugin()], server: { host: '127.0.0.1', port: 3100,