diff --git a/README.md b/README.md index 2046fdf..fd899d0 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ Stop отключает приём новых соединений, сохран ## Конфигурация и состояние +Пошаговая инструкция для ForcAD и локального демо: [docs/configuration.md](docs/configuration.md). В панели кнопка «Команды» добавляет и переименовывает команды; ⓘ показывает состояние scoreboard и краткую справку. + `mirror.toml` задаёт настройки процесса и начальную топологию. После первого изменения сохранённый `state_file` содержит авторитетный список команд/сервисов; TOML продолжает задавать сеть, мониторинг и scoreboard. Путь состояния отсчитывается от каталога TOML. Чтобы применить начальную топологию заново, остановите процесс и **переместите** state-файл в резервную копию. Файл не исполняется, версия и содержимое валидируются. Запись состояния: временный файл на той же ФС → fsync → atomic rename; права `0600`. Сбой сохранения вызывает попытку отката runtime. Публикация topology сериализована с ручными и автоматическими изменениями. Runtime и ФС не образуют распределённую транзакцию: клиент, принятый в коротком окне до неудачного сохранения, может сохранить временно применённый маршрут. Необходимость отката выводится как ошибка. После допуска к транзакции она завершается независимо от HTTP-соединения: закрытие вкладки не оставляет сохранение/очистку наполовину. Если ответ потерян, обновите snapshot перед повтором — изменение могло сохраниться. @@ -95,7 +97,7 @@ cargo run --release --example benchmark -- 64 isolate Benchmark сравнивает direct loopback с proxy в том же процессе, проверяет каждый байт, выполняет три чередующихся прогона и 500 latency-проб. Это воспроизводимый локальный baseline, не прогноз скорости реальной сети. Результаты и практические ограничения: [docs/verification.md](docs/verification.md). -Для ручного теста UI на изолированном loopback: `cargo build`, затем `python3 scripts/ui_fixture.py target/debug/mirror-tool`. Это необязательная тестовая утилита; её публичный тестовый пароль указан в начале файла, конфиг/состояние временные. Не используйте fixture для реального доступа. +Для ручного теста UI на изолированном loopback: `cargo build`, затем `python3 scripts/ui_fixture.py target/debug/mirror-tool`. Это необязательная тестовая утилита; её публичный тестовый пароль указан в начале файла, конфиг/состояние по умолчанию временные. Флаг `--data-dir data/demo` сохраняет их между запусками, `--port-offset 100` изолирует порты тестового стенда. Не используйте fixture для реального доступа. Сравнение с прежними шаблонами: установите `jinja2` в отдельный dev-venv и запустите `python scripts/legacy_ui_reference.py ../ad_scripts/mirror_tool`. Утилита проверяет сохранность исходных CSS-правил и рендерит старые страницы на синтетических loopback-данных, не читая `config.py`. Для дополнительной ForcAD-проверки есть `scripts/verify_forcad_library.py`, использующий настоящие версии Socket.IO/Engine.IO из upstream. diff --git a/agent.md b/agent.md index 7e9114b..f041926 100644 --- a/agent.md +++ b/agent.md @@ -31,3 +31,8 @@ No completion/performance claims without fresh output. Use loopback and Orb Linu - `docs/verification.md`: final gates, browser/ForcAD evidence and measured performance; raw benchmark JSON in `docs/benchmarks/`. - Generated, git-ignored `dist/` contains macOS arm64 and Linux amd64 binaries plus SHA256SUMS. Linux binary is glibc-linked from Orb Ubuntu; not an all-distribution static build. - Legacy tracked files remain unchanged and all12 match Downloads. Local-only rewrite; configure real teams/ports/scoreboard manually before deployment. No service installed. + +## UI follow-up — 2026-10-23 +User asks to repair screenshot bugs, improve small workflows autonomously and explain configuration/ForcAD. No redesign and no load test. Browser reproduced add dialog at x=0,y=0 (1002×1035 viewport); explicit native-dialog margin is missing after Bootstrap reset. Selected-team text lacks flex/nowrap constraints. Current demo is a GUI-session launchd fixture; copy temporary config/state before stopping wrapper. Planned persistent ignored data/demo directory, team add/rename through existing API, inline error/draft retention and configuration help. + +UI follow-up completed: centered/scrollable dialogs, stable full-width selected-team row, dark integer inputs/tabular counters, IP error/draft retention and Enter/Escape, add-form draft retention, team add/rename and ForcAD help/status. Direct-IP edits preserve unrelated replicas. Existing routes/backend unchanged. See docs/ui-polish-verification.md for evidence and docs/configuration.md for operator setup. Fresh native40 tests/fmt/Clippy/JS checks, Orb41 tests and both final release artifacts verified; browser/256KiB half-close/stale recovery/persistence checks passed. Live GUI-session demo now uses ignored data/demo/mirror.toml + state.json, original topology preserved, panel16700/proxy18081. Isolated fixture stopped; no load benchmark run. User Zen tab not reloaded; current demo opened separately in Chrome. diff --git a/docs/api.md b/docs/api.md index a61d31e..3147363 100644 --- a/docs/api.md +++ b/docs/api.md @@ -50,3 +50,11 @@ Configured/saved topology containing a non-null `custom_ip` must also contain it Compute displayed bandwidth from positive byte-counter deltas / `now_ms` deltas, reset baseline when counters decrease. Show unknown health until checked. Scoreboard badges must indicate stale data; never imply stale checker state is current. Replica counters mean explicit disconnections/errors, not dropped arbitrary packets. `replica_policy`: `strict` uses backpressure, `isolate` disconnects a lagging replica per session. The restored panel retains the legacy four connection counters; byte totals and replica counters are available in their tooltip and the snapshot API. TCP freshness uses `health_max_age_ms`; the UI additionally marks the whole snapshot stale and disables mutations after five seconds without updates. The top service strip shows the configured `main_team_ip` checker status, independently of the selected upstream, as in the legacy panel. Unset main IP gives an uncolored strip. Checker names and lost-flag chips retain the original color/sort behavior. + +UI follow-up: the Teams dialog exposes existing team PUT (create/rename, preserving +hidden status; existing address read-only). Configuration help displays existing +scoreboard snapshot fields without adding endpoints. Invalid custom-IP drafts stay +local across snapshots; Enter retries save and Escape restores the saved address. +Changing a direct IP preserves other replicas; the backend removes any new-primary +overlap. Add-proxy drafts persist across dialog cancel/reopen, until successful save +or page reload. Process configuration remains TOML-only and requires a restart. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..327bb07 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,74 @@ +# Конфигурация и ForcAD + +## Где хранится + +Обычный запуск: `./target/release/mirror-tool --config mirror.toml`. +Шаблон — `mirror.example.toml`; файл выбирается флагом `--config`. + +Локальное демо этого проекта использует `data/demo/mirror.toml`, а изменения +из панели — `data/demo/state.json`. Оба файла исключены из Git. Это тестовая +конфигурация loopback с публичным тестовым паролем, не конфигурация для игры. + +В `[web]` задаётся `state_file`. Относительный путь считается от каталога TOML. +После первого сохранения из UI список команд/сервисов загружается из state-файла, +а сеть, мониторинг, web и scoreboard — по-прежнему из TOML. Изменение `[[teams]]` +или `[[services]]` в TOML не заменяет существующее сохранённое состояние. +Чтобы заново импортировать стартовую топологию, остановите процесс, переместите +state-файл в резервную копию и запустите процесс снова. Не удаляйте рабочее +состояние во время работы. + +## ForcAD + +1. В начало TOML, **перед секциями**, добавьте адрес своей команды: + + ```toml + main_team_ip = "192.0.2.10" + ``` + +2. Добавьте секцию с адресом scoreboard вашей авторизованной игры: + + ```toml + [scoreboard] + kind = "forcad" + url = "http://scoreboard.example:8080" + poll_interval_ms = 3000 + stale_after_ms = 15000 + ``` + + Это примеры адресов, их нужно заменить. Используется Engine.IO 4 / Socket.IO + namespace `/game_events`; URL задаётся корневой, без `/socket.io`, query и токенов. + ForcAD-соединение постоянное; `poll_interval_ms` не означает новый handshake + каждые 3 секунды. После изменения TOML нужен перезапуск процесса. + +3. Через «Команды» добавьте IP каждой команды и **точное имя из scoreboard**. + Название сервиса тоже должно совпадать с именем задания в scoreboard. + IP существующей команды менять нельзя: добавьте новую и выберите её в маршруте. + Для прямого IP у custom-прокси отдельная команда не нужна. + +4. В настройках сервиса (клик по названию) задайте адрес/порт слушателя и + реальный upstream-порт. Например, прокси `0.0.0.0:18080`, сервис команд `8080`. + Кружок выбирает primary; строка — реплику. Затем включите прокси. + +5. Робот включает автоматический выбор **из назначенных реплик**. Статус TCP, + свежесть scoreboard и cooldown влияют на решение. Открытые TCP-сессии сохраняют + прежний upstream, новые используют выбранный. Системные маршруты не меняются. + +Кнопка ⓘ показывает состояние scoreboard и краткую инструкцию. Верхняя полоска +сервиса относится к своей команде (`main_team_ip`), а не к выбранному upstream. +Серый статус означает отсутствие свежих данных, а не успех checker. + +Пароль обычного процесса задаётся в `MIRROR_PASSWORD` (минимум 12 символов), +не в TOML. Тестовый fixture для настоящей игры не используйте; запускайте бинарник +напрямую с собственным конфигом и паролем. Реальная игра этой проверкой не затрагивалась. + +## Повторяемое локальное демо + +```sh +python3 scripts/ui_fixture.py target/release/mirror-tool --data-dir data/demo +``` + +Утилита сохраняет существующий TOML/state и не перезаписывает их. Без `--data-dir` +всё состояние временное. Для отдельной проверки используйте `--port-offset 100`: +панель 16800, proxy 18181, echo upstream 18182. Не запускайте второй fixture на +тех же портах или с той же папкой состояния. Смещение применяется к новому шаблону; +у существующего TOML порты остаются как записано в файле. diff --git a/docs/ui-polish-verification.md b/docs/ui-polish-verification.md new file mode 100644 index 0000000..8e49025 --- /dev/null +++ b/docs/ui-polish-verification.md @@ -0,0 +1,76 @@ +# UI follow-up — 2026-10-23 + +Scope: targeted improvements on the legacy layout and light functional testing. +No redesign, no load test, no legacy credentials or game-host traffic. No backend +HTTP contracts or forwarding modules changed. + +## Layout and readability + +| Before | After | +| --- | --- | +| Bootstrap reset placed native dialog at x=0,y=0. | `web/app.css`: explicit auto margin/inset; centered, bounded, scrollable dialogs. Reproduced before and measured after in browser. | +| Header controls squeezed selected-team IP and replica count onto extra lines. | Header uses a full-width second row; name/IP ellipsis and stable replica suffix with full tooltip. | +| Bright native number spinners and shifting digit widths. | Dark color scheme, spinner removal, numeric input mode/step and tabular counters. | +| Adding toolbar actions could crowd the mobile title. | Compact action spacing and small-screen title size, preserving original columns. | + +## Input and operator workflow + +| Before | After | +| --- | --- | +| Reopening add-proxy cleared its draft. | Draft retained until successful creation or page reload. | +| Rejected custom IP could disappear on next snapshot. | Local dirty draft survives blur and snapshots; inline IPv4/IPv6 validation, Enter saves/retries, Escape restores saved value. | +| Changing direct IP discarded all replicas. | Preserve replicas; backend removes only overlap with the new primary. | +| Whitespace-only names passed required validation. | Trim/validate names before requests; settings reset stale custom-validity messages. | +| Team PUT was only reachable through API. | Compact Teams dialog adds/renames teams; existing address is read-only and hidden state preserved. | +| Renamed team retained the old primary-control label. | Accessible name and tooltip update with team name. | +| No in-panel configuration/scoreboard guidance. | Compact help dialog with live existing scoreboard state, TOML/ForcAD example and route semantics. | +| Non-JSON API failure detail was discarded. | Show bounded plain-text errors when returned by the server. | +| Demo wrapper deleted config/state after each exit. | Optional persistent `--data-dir`, existing config never overwritten; `--port-offset` isolates new test fixtures. Echo cleanup tolerates reset peers. | + +## Fresh verification + +- Native `cargo fmt --check`, `cargo clippy --all-targets --locked -- -D warnings`, + `cargo test --all-targets --locked`: pass, 40 tests. Repeated after final frontend + edits; `node --check web/app.js` and `git diff --check` pass. +- Orb Ubuntu amd64: 41 tests including Linux loopback alias behavior; release + build passes. Linux tests preceded the last frontend-only label/layout/validation + changes; final Linux release embeds those final assets. No Linux-specific code changed. +- macOS arm64 release built after final assets. Both local `dist/` artifacts have + refreshed and verified SHA256SUMS. Initial checksum command used wrong cwd; + rerun from dist verified both files. +- Browser Use on isolated fixture `127.0.0.1:16800`: login, add IPv6 proxy, port + range rejection, draft cancel/reopen, invalid IP via real keyboard, inline error + retention through refresh, Escape restoration, valid IPv4 save, team create and + rename, read-only existing address, port/policy editing, start, primary promotion + retaining former primary as replica, robot toggle and logout. +- Created test proxy deletion confirmed through native Chrome after Browser Use + could not prepare popup headers for `window.confirm`; persisted state and a fresh + browser tab confirmed the proxy was gone. This was a browser-control issue. +- `SIGSTOP` only on isolated fixture: five-second watchdog disables mutations and + shows stale warning; `SIGCONT` restores live state/buttons. Live demo unaffected. +- Persistence: isolated fixture restart preserved created service/team and rename. +- Actual 390×844 CSS viewport measured (accounting for existing browser 80% zoom): + add-dialog rectangle x≈19,y≈203.55,w≈351.99,h≈436.64; no page horizontal overflow. + Long help dialog bounded at y=20,h≈803.75 with scrolling. Override cleared. +- Byte-exact 256 KiB echo with TCP half-close; replica bytes also 262144, no + connect/I/O/replica errors or replica disconnections. This is a functional check, + not a speed benchmark. +- Console samples contained unrelated extension `content.js` errors; no application + JavaScript exception observed. Does not establish absence of every possible bug. + +## Live demo and artifacts + +GUI-session launchd job `local.codex.mirror-tool-demo` runs current macOS release. +Plist is ignored `test-results/mirror-ui-demo.plist`, outside LaunchAgents; not an +installed login service. Existing temporary config/state were copied before stopping +old fixture; live authenticated snapshot confirmed identical teams/service settings. +Current config `data/demo/mirror.toml`, state `data/demo/state.json`, both ignored. +Panel `http://127.0.0.1:16700`, Echo listener 18081, echo upstreams 18082 (IPv4/IPv6). +User's primary B / replica A / enabled state preserved. A fresh 21504-byte echo passed +on updated demo, plus readiness, embedded assets and actual GUI verification. + +Ignored screenshot evidence: `test-results/ui-polish-final.png`, +`test-results/ui-polish-mobile.png`. Isolated test fixture stopped. The user's Zen +window was not reloaded; updated demo is open in a separate Chrome tab. Instructions +are in `docs/configuration.md` and linked from README. Real ForcAD tournament access, +load/soak tests and network-failure losslessness are not claimed. diff --git a/progress.md b/progress.md index 2d3a345..c2651ab 100644 --- a/progress.md +++ b/progress.md @@ -15,3 +15,5 @@ 2026-10-23: fixed initial R1-R5 plus accept retry, early engine quiesce and absolute heartbeat deadline. Root fresh native35/Orb36 tests passed, native fmt/Clippy and both release builds passed. Real ForcAD library interop passed. Six benchmark runs saved under docs/benchmarks; strict replica byte totals complete, isolate saturation recorded3 explicit replica disconnections on each platform. Final reviewer found R6 transaction cancellation; fix/review in progress. 2026-10-23: R6 resolved with admitted owned transaction worker shared by service/team/automatic changes. Five cancellation regressions, original HTTP red/green and independent re-review accepted. Root fresh final gates: native40/Orb41 tests, native fmt/Clippy, both release builds, ForcAD library interoperability and sample config/JS checks all passed. Final release UI start/primary/replica/robot/custom creation and exact1MiB half-close rechecked. Six final benchmark records committed; isolate disconnections3macOS/4Linux documented explicitly. Prepared git-ignored dist binaries/checksums; no deployment. + +2026-10-23 UI follow-up: reproduced off-center modal at(0,0), repaired Bootstrap/native-dialog interaction and header wrapping without redesign. Added local draft/input improvements, team add/rename and configuration help. Fresh40 native/41 Orb tests and release builds pass; browser validates390px layout, CRUD, keyboard invalid IP, restart persistence, stale/recovery and exact256KiB half-close with replica integrity. Live demo migrated from temporary state to ignored data/demo, preserving primaryB/replicaA/enabled; readiness/assets and21504-byte echo verified after restart. See docs/ui-polish-verification.md. Test fixture stopped, live GUI-session job remains running. diff --git a/scripts/ui_fixture.py b/scripts/ui_fixture.py index 9be90af..d3a885b 100644 --- a/scripts/ui_fixture.py +++ b/scripts/ui_fixture.py @@ -2,13 +2,15 @@ """Disposable loopback-only UI fixture. Never reads legacy configuration. Usage: python3 scripts/ui_fixture.py target/debug/mirror-tool -Public test-only login: local-ui-fixture-only. Ctrl-C removes fixture state. +Public test-only login: local-ui-fixture-only. Default state is disposable. +Use --data-dir data/demo for persistent state; --port-offset 100 isolates a test. """ +import argparse import asyncio +from contextlib import nullcontext import os from pathlib import Path import signal -import sys import tempfile @@ -21,16 +23,31 @@ async def echo(reader, writer): pass finally: writer.close() - await writer.wait_closed() + try: + await writer.wait_closed() + except OSError: + pass async def main(): - executable = str(Path(sys.argv[1]).resolve()) - with tempfile.TemporaryDirectory(prefix="mirror-ui-") as folder: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("executable", type=Path) + parser.add_argument("--data-dir", type=Path, help="Preserve config/state here; never overwrite an existing config") + parser.add_argument("--port-offset", type=int, default=0, help="Add to all fixture ports (0..30000)") + args = parser.parse_args() + if not 0 <= args.port_offset <= 30000: + parser.error("port offset must be in 0..30000") + executable = str(args.executable.resolve()) + ui_port, proxy_port, echo_port = (port + args.port_offset for port in (16700, 18081, 18082)) + if args.data_dir: + args.data_dir.mkdir(parents=True, exist_ok=True) + context = nullcontext(args.data_dir.resolve()) if args.data_dir else tempfile.TemporaryDirectory(prefix="mirror-ui-") + with context as folder: root = Path(folder) config = root / "mirror.toml" - config.write_text('''[web] -listen = "127.0.0.1:16700" + if not config.exists(): + config.write_text(f'''[web] +listen = "127.0.0.1:{ui_port}" state_file = "state.json" [network] drain_timeout_ms = 500 @@ -45,22 +62,22 @@ address = "::1" [[services]] id = "echo" name = "Echo" -listen = "127.0.0.1:18081" -upstream_port = 18082 +listen = "127.0.0.1:{proxy_port}" +upstream_port = {echo_port} primary = "a" replicas = ["b"] enabled = false auto_switch = false replica_policy = "isolate" ''') - servers = [await asyncio.start_server(echo, address, 18082) for address in ("127.0.0.1", "::1")] + servers = [await asyncio.start_server(echo, address, echo_port) for address in ("127.0.0.1", "::1")] env = os.environ.copy() env["MIRROR_PASSWORD"] = "local-ui-fixture-only" process = await asyncio.create_subprocess_exec(executable, "--config", str(config), env=env) stop = asyncio.Event() for sig in (signal.SIGINT, signal.SIGTERM): asyncio.get_running_loop().add_signal_handler(sig, stop.set) - print("UI fixture: http://127.0.0.1:16700 (loopback only)", flush=True) + print(f"UI fixture: http://127.0.0.1:{ui_port}; config: {config} (loopback only)", flush=True) waiter = asyncio.create_task(process.wait()) stopper = asyncio.create_task(stop.wait()) await asyncio.wait([waiter, stopper], return_when=asyncio.FIRST_COMPLETED) diff --git a/task_plan.md b/task_plan.md index ba03c14..cc291b7 100644 --- a/task_plan.md +++ b/task_plan.md @@ -29,3 +29,8 @@ One native executable with an async TCP engine and embedded web UI. No Python/ru - Initial CTF skill path under `.agents` was wrong; correct installed path is `.codex/skills/ctf-attack-defense`. - Guessed C4T-BuT-S4D ForcAD repository does not exist; discover canonical upstream before implementing its protocol. - Integrated build briefly blocked by not-yet-created UI assets while UI implementer was working; not a Rust source failure. + +## UI follow-up — 2026-10-23 +- [complete] Fix dialog centering/overflow, selected-team wrapping and dark numeric inputs. +- [complete] Preserve invalid IP/add-form drafts; expose team add/rename and compact configuration/scoreboard help through existing API. +- [complete] Lightweight browser/functional checks and required Rust gates; preserve live demo state and migrate to data/demo before restart. diff --git a/web/app.css b/web/app.css index 4ae71c9..f115e72 100644 --- a/web/app.css +++ b/web/app.css @@ -894,7 +894,7 @@ .task-column { position: relative; } .task-title-button { border: 0; padding: 0; background: none; text-align: left; } .task-title-button:hover { text-decoration: underline; text-underline-offset: 3px; } -.legacy-modal { color: #fff; background: transparent; border: 0; padding: 0; width: min(100% - 24px, 500px); max-height: calc(100dvh - 40px); } +.legacy-modal { margin: auto; inset: 0; overflow-y: auto; overscroll-behavior: contain; color: #fff; background: transparent; border: 0; padding: 0; width: min(100% - 24px, 500px); max-height: calc(100dvh - 40px); } .legacy-modal::backdrop { background: rgba(0,0,0,.5); } .legacy-modal .modal-dialog { margin: 0; } .cancel-button { background: rgba(44,44,46,.8); } @@ -912,3 +912,29 @@ button:focus-visible, [role=button]:focus-visible { outline: 2px solid #0a84ff; .task-title-wrap, .task-title-row { min-width: 0; } .task-title-button { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .task-port { flex-shrink: 0; } + +/* Preserve the legacy board; keep long labels and native dialogs predictable. */ +:root { color-scheme: dark; } +.legacy-modal .modal-dialog { pointer-events: auto; } +.task-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 12px; } +.task-title-wrap { display: contents; } +.task-title-row { grid-column: 1; grid-row: 1; align-self: center; } +.task-controls { grid-column: 2; grid-row: 1; } +.selected-team { grid-column: 1 / -1; grid-row: 2; } +.task-controls { flex-shrink: 0; } +.selected-team { display: flex; align-items: baseline; min-width: 0; white-space: nowrap; } +.selected-team-name { overflow: hidden; text-overflow: ellipsis; min-width: 0; } +.selected-team-ip { overflow: hidden; text-overflow: ellipsis; min-width: 0; } +.selected-team-tail { flex-shrink: 0; margin-left: 4px; } +.stat-value, .task-port, .flag-chip { font-variant-numeric: tabular-nums; } +.input-modern[type=number] { appearance: textfield; -moz-appearance: textfield; } +.input-modern::-webkit-inner-spin-button, .input-modern::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } +.input-modern[aria-invalid=true] { border-color: #ff453a; } +.input-modern:read-only { color: rgba(255,255,255,.55); } +.form-hint { color: rgba(255,255,255,.65); font-size: 12px; line-height: 1.5; } +.form-hint:empty { display: none; } +.help-body { font-size: 13px; line-height: 1.6; } +.help-body h6 { margin-top: 20px; } +.help-body pre { padding: 12px; border-radius: 8px; background: rgba(0,0,0,.25); color: #ddd; font-size: 12px; } +.header-actions { gap: 8px; flex-shrink: 0; } +@media (max-width: 420px) { .section-title { font-size: 19px; } .header-actions { gap: 5px; } } diff --git a/web/app.js b/web/app.js index 3e58ae5..a6a8cce 100644 --- a/web/app.js +++ b/web/app.js @@ -9,6 +9,11 @@ const serviceById = (id) => state.snapshot?.services.find((service) => service.id === id); const teamById = (id) => state.snapshot?.teams.find((team) => team.id === id); const portOf = (service) => Number(service.listen.slice(service.listen.lastIndexOf(":") + 1)); + function isIp(value) { + if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(value)) return value.split(".").every((part) => Number(part) <= 255 && String(Number(part)) === part); + if (!value.includes(":") || !/^[\da-f:.]+$/i.test(value)) return false; + try { return new URL(`http://[${value}]/`).hostname.startsWith("["); } catch { return false; } + } function button(cls, title, icon, action) { const node = el("button", cls); node.type = "button"; node.title = title; node.setAttribute("aria-label", title); if (icon) { const i = el("i", `fas fa-${icon}`); i.setAttribute("aria-hidden", "true"); node.append(i); } @@ -32,7 +37,10 @@ let response; try { response = await fetch(path, { ...options, headers, credentials: "same-origin" }); } catch { throw new Error("Нет связи с сервером. Проверьте подключение и обновите страницу."); } - const data = await response.json().catch(() => ({})); + const raw = await response.text(); + let data; + try { data = JSON.parse(raw); } + catch { data = { error: response.headers.get("content-type")?.startsWith("text/plain") ? raw.slice(0, 400) : null }; } if (!response.ok) { if (response.status === 401 && path !== "/api/login") showLogin(); const error = new Error(data.error || `Ошибка сервера (${response.status})`); error.status = response.status; throw error; @@ -98,7 +106,7 @@ function openSettings(id) { const service = serviceById(id); if (!service || state.busy || state.stale) return; state.editId = id; $("settings-error").textContent = ""; - $("service-name").value = service.name; $("service-listen").value = service.listen; + $("service-name").value = service.name; $("service-name").setCustomValidity(""); $("service-listen").value = service.listen; $("service-port").value = service.upstream_port; $("service-policy").value = service.replica_policy; $("settingsModal").showModal(); } @@ -126,8 +134,30 @@ }); controls.append(auto, open, toggle); header.append(wrap, controls); const customRow = el("div", "custom-ip-row"); const customIp = el("input", "input-modern"); - customIp.placeholder = "IP"; customIp.setAttribute("aria-label", `IP: ${service.name}`); customIp.autocomplete = "off"; - customIp.addEventListener("change", () => updateService(id, { custom_ip: customIp.value.trim() || null, replicas: [] })); + customIp.placeholder = "IP"; customIp.setAttribute("aria-label", `IP: ${service.name}`); customIp.autocomplete = "off"; customIp.title = "IPv4 / IPv6 · Enter — сохранить, Escape — отменить ввод"; + const ipError = el("p", "service-error"); ipError.id = `ip-error-${id}`; ipError.setAttribute("role", "alert"); + customIp.setAttribute("aria-describedby", ipError.id); + const draft = { dirty: false }; + customIp.addEventListener("input", () => { draft.dirty = true; ipError.textContent = ""; customIp.removeAttribute("aria-invalid"); }); + async function saveIp() { + if (!draft.dirty || state.busy || state.stale) return; + const value = customIp.value.trim(); + if (value && !isIp(value)) { + ipError.textContent = "Введите IPv4 или IPv6 без порта. Escape — отменить ввод."; + customIp.setAttribute("aria-invalid", "true"); return; + } + if (await updateService(id, { custom_ip: value || null }, ipError.id)) { + draft.dirty = false; customIp.removeAttribute("aria-invalid"); customIp.value = serviceById(id)?.custom_ip || ""; render(); + } else if (ipError.textContent) customIp.setAttribute("aria-invalid", "true"); + } + customIp.addEventListener("change", saveIp); + customIp.addEventListener("keydown", (event) => { + if (event.key === "Enter") { event.preventDefault(); saveIp(); } + if (event.key === "Escape") { + draft.dirty = false; ipError.textContent = ""; customIp.removeAttribute("aria-invalid"); + customIp.value = serviceById(id)?.custom_ip || ""; customIp.blur(); + } + }); const remove = button("btn-delete", "Удалить", "trash", () => removeCustom(id)); customRow.append(customIp, remove); const stats = el("div", "task-stats"); const values = {}; for (const [key, label, cls] of [["accepted", "Всего:", ""], ["active", "Активные:", "stat-active"], ["completed", "Закрыто:", ""], ["errors", "Ошибки:", "stat-error"]]) { @@ -136,8 +166,8 @@ } const error = el("p", "service-error"); error.setAttribute("role", "status"); const panel = el("div", "command-panel"); const list = el("div", "command-list"); panel.append(el("div", "panel-title", "Команды"), list); - root.append(strip, header, customRow, stats, error, panel); $("service-board").append(root); - return { root, strip, title, port, selected, auto, toggle, customRow, customIp, remove, stats, values, error, list, rows: new Map() }; + root.append(strip, header, customRow, ipError, stats, error, panel); $("service-board").append(root); + return { root, strip, title, port, selected, auto, toggle, customRow, customIp, draft, remove, stats, values, error, list, rows: new Map() }; } function createRow(card, id, team) { const root = el("div", "command-item"); root.dataset.teamId = team.id; root.dataset.value = team.address; @@ -160,22 +190,24 @@ if (!state.snapshot) return; const current = state.snapshot; const board = current.scoreboard || {}; const scoreFresh = !state.stale && board.connected && !board.stale; const locked = state.busy || state.stale; - $("add-service").disabled = locked; $("logout").disabled = state.busy; - for (const form of [$("customProxyForm"), $("settings-form")]) for (const node of form.querySelectorAll("input, select, button")) node.disabled = locked; + $("board-status").textContent = !board.enabled ? "Scoreboard: выключен" : `Scoreboard (${board.kind}): ${scoreFresh ? "подключён" : "нет свежих данных"}${board.round == null ? "" : ` · раунд ${board.round}`}`; + $("board-error").textContent = board.last_error || ""; + $("add-service").disabled = locked; $("manage-teams").disabled = locked; $("logout").disabled = state.busy; + for (const form of [$("customProxyForm"), $("settings-form"), $("team-form")]) for (const node of form.querySelectorAll("input, select, button")) node.disabled = locked; for (const node of document.querySelectorAll("[data-close]")) node.disabled = state.busy; const ids = new Set(current.services.map((service) => service.id)); for (const [id, card] of state.cards) if (!ids.has(id)) { card.root.remove(); state.cards.delete(id); } for (const service of current.services) { let card = state.cards.get(service.id); if (!card) { card = createCard(service); state.cards.set(service.id, card); } - card.title.textContent = service.name; card.port.textContent = `:${portOf(service)}`; + card.title.textContent = service.name; card.title.title = `Настройки: ${service.name}`; card.port.textContent = `:${portOf(service)}`; card.title.setAttribute("aria-label", `Настройки сервиса ${service.name}`); card.title.disabled = locked; card.auto.classList.toggle("active", service.auto_switch); card.auto.setAttribute("aria-pressed", String(service.auto_switch)); card.auto.title = service.auto_switch ? "Авто-переключение включено" : "Авто-переключение"; card.auto.disabled = locked; card.toggle.classList.toggle("active", Boolean(service.engine?.running)); card.toggle.disabled = locked; card.toggle.setAttribute("aria-label", `${service.enabled ? "Остановить" : "Запустить"} ${service.name}`); card.toggle.setAttribute("aria-pressed", String(Boolean(service.engine?.running))); card.customRow.hidden = !service.custom; card.customIp.disabled = locked; card.remove.disabled = locked; - if (document.activeElement !== card.customIp) card.customIp.value = service.custom_ip || ""; + if (!card.draft.dirty && document.activeElement !== card.customIp) card.customIp.value = service.custom_ip || ""; const primary = teamById(service.primary); card.selected.replaceChildren(); if (primary) { @@ -183,6 +215,7 @@ if (primary.name !== primary.address) card.selected.append(el("span", "selected-team-ip", primary.address)); if (service.replicas.length) card.selected.append(el("span", "selected-team-tail", ` · дубли: ${service.replicas.length}`)); } else card.selected.textContent = "Команда не выбрана"; + card.selected.title = primary ? `${primary.name} · ${primary.address}${service.replicas.length ? ` · дубли: ${service.replicas.length}` : ""}` : "Команда не выбрана"; const stats = service.engine?.stats || {}; card.stats.style.visibility = service.engine?.running || stats.active ? "visible" : "hidden"; for (const key of ["accepted", "active", "completed"]) card.values[key].textContent = stats[key] || 0; @@ -206,6 +239,7 @@ row.root.setAttribute("aria-label", `Реплика ${team.name}: ${service.name}`); row.root.setAttribute("aria-disabled", String(locked || service.primary === team.id)); row.root.setAttribute("aria-pressed", String(service.replicas.includes(team.id))); row.primary.disabled = locked; row.primary.setAttribute("aria-pressed", String(service.primary === team.id)); + row.primary.setAttribute("aria-label", `Основной маршрут ${team.name}`); row.primary.title = `Основной маршрут ${team.name}`; const h = service.health?.[team.id]; const recent = !state.stale && h?.checked_at_ms && current.now_ms - h.checked_at_ms <= (current.health_max_age_ms || 12000); row.health.className = `option-status ${recent ? h.reachable ? "available" : "unavailable" : "unknown"}`; row.health.title = recent ? `TCP: ${h.reachable ? "доступен" : "недоступен"}, ${h.latency_ms} мс` : "TCP: нет свежих данных"; @@ -232,19 +266,54 @@ }); $("logout").addEventListener("click", async () => { if (state.busy) return; try { await api("/api/logout", { method: "POST" }); showLogin(); alert(""); } catch (error) { alert(error.message); } }); $("refresh").addEventListener("click", async () => { try { await refresh(); alert(""); if (!state.source) startStream(); } catch (error) { alert(error.message); } }); - $("add-service").addEventListener("click", () => { $("customProxyForm").reset(); $("custom-error").textContent = ""; $("addCustomModal").showModal(); }); + $("add-service").addEventListener("click", () => { $("custom-error").textContent = ""; $("addCustomModal").showModal(); }); for (const node of document.querySelectorAll("[data-close]")) node.addEventListener("click", () => { if (!state.busy) $(node.dataset.close).close(); }); for (const dialog of document.querySelectorAll("dialog")) dialog.addEventListener("cancel", (event) => { if (state.busy) event.preventDefault(); }); $("customProxyForm").addEventListener("submit", async (event) => { event.preventDefault(); const port = Number($("customPort").value); + if (!$("customName").value.trim()) { $("custom-error").textContent = "Введите название"; return; } + if ($("customIp").value.trim() && !isIp($("customIp").value.trim())) { $("custom-error").textContent = "Введите IPv4 или IPv6 без порта"; return; } const id = `custom-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; const body = { id, name: $("customName").value.trim(), listen: `0.0.0.0:${port}`, upstream_port: port, primary: null, replicas: [], enabled: false, auto_switch: false, replica_policy: "isolate", custom: true, custom_ip: $("customIp").value.trim() || null }; - if (await mutate(`/api/services/${encodeURIComponent(id)}`, "PUT", body, "custom-error")) $("addCustomModal").close(); + if (await mutate(`/api/services/${encodeURIComponent(id)}`, "PUT", body, "custom-error")) { $("addCustomModal").close(); $("customProxyForm").reset(); } }); $("settings-form").addEventListener("submit", async (event) => { event.preventDefault(); + if (!$("service-name").value.trim()) { $("settings-error").textContent = "Введите название"; return; } if (await updateService(state.editId, { name: $("service-name").value.trim(), listen: $("service-listen").value.trim(), upstream_port: Number($("service-port").value), replica_policy: $("service-policy").value }, "settings-error")) $("settingsModal").close(); }); + // Whitespace-only names pass HTML required validation; reject before submission. + for (const id of ["customName", "service-name", "team-name"]) { + const input = $(id); + input.addEventListener("input", () => input.setCustomValidity(input.value.trim() ? "" : "Введите название")); + } + function teamChoices(selected = "") { + const select = $("team-choice"); select.replaceChildren(new Option("Новая команда", "")); + for (const team of state.snapshot.teams) select.add(new Option(`${team.name} · ${team.address}`, team.id)); + select.value = selected; + } + function editTeam() { + const team = teamById($("team-choice").value); + $("team-name").value = team?.name || ""; $("team-name").setCustomValidity(""); + $("team-address").value = team?.address || ""; $("team-address").readOnly = Boolean(team); + $("team-error").textContent = ""; $("team-result").textContent = ""; + } + $("help").addEventListener("click", () => $("helpModal").showModal()); + $("manage-teams").addEventListener("click", () => { + teamChoices($("team-choice").value); $("teamsModal").showModal(); + }); + $("team-choice").addEventListener("change", editTeam); + $("team-form").addEventListener("submit", async (event) => { + event.preventDefault(); $("team-result").textContent = ""; + if (!$("team-name").value.trim()) { $("team-error").textContent = "Введите название команды"; return; } + if (!isIp($("team-address").value.trim())) { $("team-error").textContent = "Введите IPv4 или IPv6 без порта"; return; } + const old = teamById($("team-choice").value); + const id = old?.id || `team-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + const team = { id, name: $("team-name").value.trim(), address: $("team-address").value.trim(), hidden: old?.hidden || false }; + if (await mutate(`/api/teams/${encodeURIComponent(id)}`, "PUT", team, "team-error")) { + teamChoices(id); editTeam(); $("team-result").textContent = "Команда сохранена"; + } + }); window.addEventListener("pagehide", closeStream); window.addEventListener("pageshow", (event) => { if (event.persisted && state.csrf) startStream(); }); (async () => { try { await enter(await api("/api/session")); } catch (error) { showLogin(); if (error.status !== 401) $("login-error").textContent = error.message; } })(); diff --git a/web/index.html b/web/index.html index 5aa6db0..762dcfb 100644 --- a/web/index.html +++ b/web/index.html @@ -24,6 +24,8 @@ + + + + + +