From 4fcce5bff9437abee5fc3588e97d5aa334997a5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BD=D1=8F=20=D0=90=D1=80=D1=85=D0=B8=D0=BF?= =?UTF-8?q?=D0=BE=D0=B2?= Date: Wed, 15 Jul 2026 22:21:13 +0000 Subject: [PATCH] ops: finalize production domain and hardware benchmark tooling Add /version.json build identity, deploy 16e7930 release markers, DNS/Named Tunnel prep docs, portable browser benchmark, and replay stability probe. Keep Quick Tunnel until permanent domain cutover. Co-authored-by: Cursor --- .gitignore | 2 + Dockerfile | 7 + docs/CLOUDFLARE_NAMED_TUNNEL_SETUP.md | 100 ++++++++ docs/DEMO_RUNBOOK.md | 1 + docs/DNS_MIGRATION_INVENTORY.md | 78 ++++++ docs/FINAL_DEMO_READINESS.md | 52 ++-- docs/HARDWARE_BENCHMARK_RUNBOOK.md | 58 +++++ docs/HEADED_GPU_PERFORMANCE_REPORT.md | 14 +- docs/PERFORMANCE_AFTER_MAXIMUM_DEMO.md | 45 ++-- docs/PRODUCTION_DEPLOYMENT_REPORT.md | 83 +++---- docs/PUBLIC_DOMAIN_DIAGNOSTIC.md | 6 +- docs/REPLAY_RESOURCE_STABILITY.md | 21 ++ docs/RISK_REGISTER.md | 31 +-- e2e/production.smoke.spec.ts | 90 ++++++- .../10-perf-overlay-chromium-linux.png | Bin 10303 -> 11820 bytes nginx.conf | 5 + package.json | 5 +- releases/CURRENT_BUNDLE.txt | 2 +- releases/CURRENT_COMMIT.txt | 1 + releases/CURRENT_RELEASE.txt | 2 +- releases/CURRENT_VERSION.json | 6 + scripts/deploy-production.sh | 29 ++- scripts/portable-browser-benchmark.mjs | 233 ++++++++++++++++++ scripts/replay-stability.mjs | 154 ++++++++++++ src/components/BuildIdentityBadge.tsx | 51 ++++ src/components/ThreeD/PerfOverlay.tsx | 24 ++ src/pages/MainPage.tsx | 3 + src/styles.css | 36 ++- vite.config.ts | 67 ++++- 29 files changed, 1068 insertions(+), 138 deletions(-) create mode 100644 docs/CLOUDFLARE_NAMED_TUNNEL_SETUP.md create mode 100644 docs/DNS_MIGRATION_INVENTORY.md create mode 100644 docs/HARDWARE_BENCHMARK_RUNBOOK.md create mode 100644 docs/REPLAY_RESOURCE_STABILITY.md create mode 100644 releases/CURRENT_COMMIT.txt create mode 100644 releases/CURRENT_VERSION.json create mode 100644 scripts/portable-browser-benchmark.mjs create mode 100644 scripts/replay-stability.mjs create mode 100644 src/components/BuildIdentityBadge.tsx 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 e211f37bac20c911d26fe6ed11afacdcca4c4ea4..4a0359a41d91b0d1dca10fd8abd4b9e01b96c79f 100644 GIT binary patch literal 11820 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

y%Ax(5T@=FEV_`1#9 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 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,