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 <cursoragent@cursor.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
100
docs/CLOUDFLARE_NAMED_TUNNEL_SETUP.md
Normal file
100
docs/CLOUDFLARE_NAMED_TUNNEL_SETUP.md
Normal file
@@ -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/<TUNNEL_UUID>.json # credentials — chmod 600
|
||||
```
|
||||
|
||||
```yaml
|
||||
tunnel: <TUNNEL_UUID>
|
||||
credentials-file: /home/<user>/.cloudflared/<TUNNEL_UUID>.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 <tunnel> 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=<sha> 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
|
||||
```
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
78
docs/DNS_MIGRATION_INVENTORY.md
Normal file
78
docs/DNS_MIGRATION_INVENTORY.md
Normal file
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
|
||||
58
docs/HARDWARE_BENCHMARK_RUNBOOK.md
Normal file
58
docs/HARDWARE_BENCHMARK_RUNBOOK.md
Normal file
@@ -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://<working-url>/
|
||||
|
||||
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
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:<release>`), порт `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-<release>/`.
|
||||
## 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**.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
21
docs/REPLAY_RESOURCE_STABILITY.md
Normal file
21
docs/REPLAY_RESOURCE_STABILITY.md
Normal file
@@ -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`
|
||||
@@ -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.
|
||||
- Инцидент на живом показе.
|
||||
|
||||
---
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 12 KiB |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1 +1 @@
|
||||
index-ncgt6PBL.js
|
||||
index-AagIOJbd.js
|
||||
|
||||
1
releases/CURRENT_COMMIT.txt
Normal file
1
releases/CURRENT_COMMIT.txt
Normal file
@@ -0,0 +1 @@
|
||||
16e7930
|
||||
@@ -1 +1 @@
|
||||
20260715-1712
|
||||
20260715-2215
|
||||
|
||||
6
releases/CURRENT_VERSION.json
Normal file
6
releases/CURRENT_VERSION.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"commit": "16e7930",
|
||||
"branch": "feature/maximum-demo-realism",
|
||||
"builtAt": "2026-07-15T22:17:42.644Z",
|
||||
"release": "20260715-2215"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
233
scripts/portable-browser-benchmark.mjs
Normal file
233
scripts/portable-browser-benchmark.mjs
Normal file
@@ -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);
|
||||
});
|
||||
154
scripts/replay-stability.mjs
Normal file
154
scripts/replay-stability.mjs
Normal file
@@ -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);
|
||||
});
|
||||
51
src/components/BuildIdentityBadge.tsx
Normal file
51
src/components/BuildIdentityBadge.tsx
Normal file
@@ -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<VersionInfo | null>(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 (
|
||||
<div className="build-identity" data-testid="build-identity" title={`${info.branch} · ${info.builtAt}`}>
|
||||
<span className="build-identity-label">build</span>
|
||||
<span className="build-identity-commit">{info.commit}</span>
|
||||
<span className="build-identity-release">{info.release}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className="perf-overlay"
|
||||
@@ -86,6 +102,14 @@ export default function PerfOverlay({ enabled }: PerfOverlayProps) {
|
||||
{soft ? 'SW' : 'GPU'} {snap.renderer.slice(0, 42)}
|
||||
{snap.renderer.length > 42 ? '…' : ''}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="perf-export-btn"
|
||||
data-testid="perf-export"
|
||||
onClick={exportBenchmark}
|
||||
>
|
||||
Export benchmark
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
</button>
|
||||
)}
|
||||
|
||||
<BuildIdentityBadge />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user