Polish legacy UI, add team controls and document persistent demo setup

This commit is contained in:
mrvasil 2026-10-23 13:48:07 +03:00
parent dbbbb8b255
commit b697ba233f
11 changed files with 352 additions and 27 deletions

View File

@ -73,6 +73,8 @@ Stop отключает приём новых соединений, сохран
## Конфигурация и состояние ## Конфигурация и состояние
Пошаговая инструкция для ForcAD и локального демо: [docs/configuration.md](docs/configuration.md). В панели кнопка «Команды» добавляет и переименовывает команды; ⓘ показывает состояние scoreboard и краткую справку.
`mirror.toml` задаёт настройки процесса и начальную топологию. После первого изменения сохранённый `state_file` содержит авторитетный список команд/сервисов; TOML продолжает задавать сеть, мониторинг и scoreboard. Путь состояния отсчитывается от каталога TOML. Чтобы применить начальную топологию заново, остановите процесс и **переместите** state-файл в резервную копию. Файл не исполняется, версия и содержимое валидируются. `mirror.toml` задаёт настройки процесса и начальную топологию. После первого изменения сохранённый `state_file` содержит авторитетный список команд/сервисов; TOML продолжает задавать сеть, мониторинг и scoreboard. Путь состояния отсчитывается от каталога TOML. Чтобы применить начальную топологию заново, остановите процесс и **переместите** state-файл в резервную копию. Файл не исполняется, версия и содержимое валидируются.
Запись состояния: временный файл на той же ФС → fsync → atomic rename; права `0600`. Сбой сохранения вызывает попытку отката runtime. Публикация topology сериализована с ручными и автоматическими изменениями. Runtime и ФС не образуют распределённую транзакцию: клиент, принятый в коротком окне до неудачного сохранения, может сохранить временно применённый маршрут. Необходимость отката выводится как ошибка. После допуска к транзакции она завершается независимо от HTTP-соединения: закрытие вкладки не оставляет сохранение/очистку наполовину. Если ответ потерян, обновите snapshot перед повтором — изменение могло сохраниться. Запись состояния: временный файл на той же ФС → 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). 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. Сравнение с прежними шаблонами: установите `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.

View File

@ -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/`. - `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. - 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. - 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.

View File

@ -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. 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. 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.

74
docs/configuration.md Normal file
View File

@ -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 порты остаются как записано в файле.

View File

@ -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.

View File

@ -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: 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: 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.

View File

@ -2,13 +2,15 @@
"""Disposable loopback-only UI fixture. Never reads legacy configuration. """Disposable loopback-only UI fixture. Never reads legacy configuration.
Usage: python3 scripts/ui_fixture.py target/debug/mirror-tool 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 import asyncio
from contextlib import nullcontext
import os import os
from pathlib import Path from pathlib import Path
import signal import signal
import sys
import tempfile import tempfile
@ -21,16 +23,31 @@ async def echo(reader, writer):
pass pass
finally: finally:
writer.close() writer.close()
await writer.wait_closed() try:
await writer.wait_closed()
except OSError:
pass
async def main(): async def main():
executable = str(Path(sys.argv[1]).resolve()) parser = argparse.ArgumentParser(description=__doc__)
with tempfile.TemporaryDirectory(prefix="mirror-ui-") as folder: 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) root = Path(folder)
config = root / "mirror.toml" config = root / "mirror.toml"
config.write_text('''[web] if not config.exists():
listen = "127.0.0.1:16700" config.write_text(f'''[web]
listen = "127.0.0.1:{ui_port}"
state_file = "state.json" state_file = "state.json"
[network] [network]
drain_timeout_ms = 500 drain_timeout_ms = 500
@ -45,22 +62,22 @@ address = "::1"
[[services]] [[services]]
id = "echo" id = "echo"
name = "Echo" name = "Echo"
listen = "127.0.0.1:18081" listen = "127.0.0.1:{proxy_port}"
upstream_port = 18082 upstream_port = {echo_port}
primary = "a" primary = "a"
replicas = ["b"] replicas = ["b"]
enabled = false enabled = false
auto_switch = false auto_switch = false
replica_policy = "isolate" 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 = os.environ.copy()
env["MIRROR_PASSWORD"] = "local-ui-fixture-only" env["MIRROR_PASSWORD"] = "local-ui-fixture-only"
process = await asyncio.create_subprocess_exec(executable, "--config", str(config), env=env) process = await asyncio.create_subprocess_exec(executable, "--config", str(config), env=env)
stop = asyncio.Event() stop = asyncio.Event()
for sig in (signal.SIGINT, signal.SIGTERM): for sig in (signal.SIGINT, signal.SIGTERM):
asyncio.get_running_loop().add_signal_handler(sig, stop.set) 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()) waiter = asyncio.create_task(process.wait())
stopper = asyncio.create_task(stop.wait()) stopper = asyncio.create_task(stop.wait())
await asyncio.wait([waiter, stopper], return_when=asyncio.FIRST_COMPLETED) await asyncio.wait([waiter, stopper], return_when=asyncio.FIRST_COMPLETED)

View File

@ -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`. - 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. - 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. - 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.

View File

@ -894,7 +894,7 @@
.task-column { position: relative; } .task-column { position: relative; }
.task-title-button { border: 0; padding: 0; background: none; text-align: left; } .task-title-button { border: 0; padding: 0; background: none; text-align: left; }
.task-title-button:hover { text-decoration: underline; text-underline-offset: 3px; } .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::backdrop { background: rgba(0,0,0,.5); }
.legacy-modal .modal-dialog { margin: 0; } .legacy-modal .modal-dialog { margin: 0; }
.cancel-button { background: rgba(44,44,46,.8); } .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-wrap, .task-title-row { min-width: 0; }
.task-title-button { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .task-title-button { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-port { flex-shrink: 0; } .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; } }

View File

@ -9,6 +9,11 @@
const serviceById = (id) => state.snapshot?.services.find((service) => service.id === id); const serviceById = (id) => state.snapshot?.services.find((service) => service.id === id);
const teamById = (id) => state.snapshot?.teams.find((team) => team.id === id); const teamById = (id) => state.snapshot?.teams.find((team) => team.id === id);
const portOf = (service) => Number(service.listen.slice(service.listen.lastIndexOf(":") + 1)); 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) { function button(cls, title, icon, action) {
const node = el("button", cls); node.type = "button"; node.title = title; node.setAttribute("aria-label", title); 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); } if (icon) { const i = el("i", `fas fa-${icon}`); i.setAttribute("aria-hidden", "true"); node.append(i); }
@ -32,7 +37,10 @@
let response; let response;
try { response = await fetch(path, { ...options, headers, credentials: "same-origin" }); } try { response = await fetch(path, { ...options, headers, credentials: "same-origin" }); }
catch { throw new Error("Нет связи с сервером. Проверьте подключение и обновите страницу."); } 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.ok) {
if (response.status === 401 && path !== "/api/login") showLogin(); if (response.status === 401 && path !== "/api/login") showLogin();
const error = new Error(data.error || `Ошибка сервера (${response.status})`); error.status = response.status; throw error; const error = new Error(data.error || `Ошибка сервера (${response.status})`); error.status = response.status; throw error;
@ -98,7 +106,7 @@
function openSettings(id) { function openSettings(id) {
const service = serviceById(id); if (!service || state.busy || state.stale) return; const service = serviceById(id); if (!service || state.busy || state.stale) return;
state.editId = id; $("settings-error").textContent = ""; 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; $("service-port").value = service.upstream_port; $("service-policy").value = service.replica_policy;
$("settingsModal").showModal(); $("settingsModal").showModal();
} }
@ -126,8 +134,30 @@
}); });
controls.append(auto, open, toggle); header.append(wrap, controls); controls.append(auto, open, toggle); header.append(wrap, controls);
const customRow = el("div", "custom-ip-row"); const customIp = el("input", "input-modern"); 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.placeholder = "IP"; customIp.setAttribute("aria-label", `IP: ${service.name}`); customIp.autocomplete = "off"; customIp.title = "IPv4 / IPv6 · Enter — сохранить, Escape — отменить ввод";
customIp.addEventListener("change", () => updateService(id, { custom_ip: customIp.value.trim() || null, replicas: [] })); 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 remove = button("btn-delete", "Удалить", "trash", () => removeCustom(id)); customRow.append(customIp, remove);
const stats = el("div", "task-stats"); const values = {}; const stats = el("div", "task-stats"); const values = {};
for (const [key, label, cls] of [["accepted", "Всего:", ""], ["active", "Активные:", "stat-active"], ["completed", "Закрыто:", ""], ["errors", "Ошибки:", "stat-error"]]) { 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 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); 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); root.append(strip, header, customRow, ipError, 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() }; return { root, strip, title, port, selected, auto, toggle, customRow, customIp, draft, remove, stats, values, error, list, rows: new Map() };
} }
function createRow(card, id, team) { function createRow(card, id, team) {
const root = el("div", "command-item"); root.dataset.teamId = team.id; root.dataset.value = team.address; const root = el("div", "command-item"); root.dataset.teamId = team.id; root.dataset.value = team.address;
@ -160,22 +190,24 @@
if (!state.snapshot) return; if (!state.snapshot) return;
const current = state.snapshot; const board = current.scoreboard || {}; const scoreFresh = !state.stale && board.connected && !board.stale; const current = state.snapshot; const board = current.scoreboard || {}; const scoreFresh = !state.stale && board.connected && !board.stale;
const locked = state.busy || state.stale; const locked = state.busy || state.stale;
$("add-service").disabled = locked; $("logout").disabled = state.busy; $("board-status").textContent = !board.enabled ? "Scoreboard: выключен" : `Scoreboard (${board.kind}): ${scoreFresh ? "подключён" : "нет свежих данных"}${board.round == null ? "" : ` · раунд ${board.round}`}`;
for (const form of [$("customProxyForm"), $("settings-form")]) for (const node of form.querySelectorAll("input, select, button")) node.disabled = locked; $("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; for (const node of document.querySelectorAll("[data-close]")) node.disabled = state.busy;
const ids = new Set(current.services.map((service) => service.id)); 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 [id, card] of state.cards) if (!ids.has(id)) { card.root.remove(); state.cards.delete(id); }
for (const service of current.services) { for (const service of current.services) {
let card = state.cards.get(service.id); let card = state.cards.get(service.id);
if (!card) { card = createCard(service); state.cards.set(service.id, card); } 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.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.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.auto.title = service.auto_switch ? "Авто-переключение включено" : "Авто-переключение"; card.auto.disabled = locked;
card.toggle.classList.toggle("active", Boolean(service.engine?.running)); card.toggle.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.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; 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); const primary = teamById(service.primary);
card.selected.replaceChildren(); card.selected.replaceChildren();
if (primary) { if (primary) {
@ -183,6 +215,7 @@
if (primary.name !== primary.address) card.selected.append(el("span", "selected-team-ip", primary.address)); 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}`)); if (service.replicas.length) card.selected.append(el("span", "selected-team-tail", ` · дубли: ${service.replicas.length}`));
} else card.selected.textContent = "Команда не выбрана"; } else card.selected.textContent = "Команда не выбрана";
card.selected.title = primary ? `${primary.name} · ${primary.address}${service.replicas.length ? ` · дубли: ${service.replicas.length}` : ""}` : "Команда не выбрана";
const stats = service.engine?.stats || {}; const stats = service.engine?.stats || {};
card.stats.style.visibility = service.engine?.running || stats.active ? "visible" : "hidden"; 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; 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-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.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-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); 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.className = `option-status ${recent ? h.reachable ? "available" : "unavailable" : "unknown"}`;
row.health.title = recent ? `TCP: ${h.reachable ? "доступен" : "недоступен"}, ${h.latency_ms} мс` : "TCP: нет свежих данных"; 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); } }); $("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); } }); $("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 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(); }); for (const dialog of document.querySelectorAll("dialog")) dialog.addEventListener("cancel", (event) => { if (state.busy) event.preventDefault(); });
$("customProxyForm").addEventListener("submit", async (event) => { $("customProxyForm").addEventListener("submit", async (event) => {
event.preventDefault(); const port = Number($("customPort").value); 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 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 }; 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) => { $("settings-form").addEventListener("submit", async (event) => {
event.preventDefault(); 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(); 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("pagehide", closeStream);
window.addEventListener("pageshow", (event) => { if (event.persisted && state.csrf) startStream(); }); 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; } })(); (async () => { try { await enter(await api("/api/session")); } catch (error) { showLogin(); if (error.status !== 401) $("login-error").textContent = error.message; } })();

View File

@ -24,6 +24,8 @@
<div id="header" class="d-flex justify-content-between align-items-center header-row" hidden> <div id="header" class="d-flex justify-content-between align-items-center header-row" hidden>
<h1 class="section-title">Mirror Tool</h1> <h1 class="section-title">Mirror Tool</h1>
<div class="header-actions"> <div class="header-actions">
<button id="help" class="refresh-button" title="Настройка / ForcAD" aria-label="Настройка / ForcAD"><i class="fas fa-circle-info" aria-hidden="true"></i></button>
<button id="manage-teams" class="refresh-button" title="Команды" aria-label="Команды"><i class="fas fa-users" aria-hidden="true"></i></button>
<button id="refresh" class="refresh-button" title="Обновить" aria-label="Обновить"><i class="fas fa-sync-alt" aria-hidden="true"></i></button> <button id="refresh" class="refresh-button" title="Обновить" aria-label="Обновить"><i class="fas fa-sync-alt" aria-hidden="true"></i></button>
<button id="logout" class="logout-button" title="Выход" aria-label="Выход"><i class="fas fa-sign-out-alt" aria-hidden="true"></i></button> <button id="logout" class="logout-button" title="Выход" aria-label="Выход"><i class="fas fa-sign-out-alt" aria-hidden="true"></i></button>
<button id="add-service" class="add-button" title="Добавить прокси" aria-label="Добавить прокси"><i class="fas fa-plus" aria-hidden="true"></i></button> <button id="add-service" class="add-button" title="Добавить прокси" aria-label="Добавить прокси"><i class="fas fa-plus" aria-hidden="true"></i></button>
@ -37,7 +39,7 @@
<form id="customProxyForm"> <form id="customProxyForm">
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"><label for="customName" class="form-label">Название</label><input type="text" class="input-modern w-100" id="customName" placeholder="Название сервиса" maxlength="128" required></div> <div class="mb-3"><label for="customName" class="form-label">Название</label><input type="text" class="input-modern w-100" id="customName" placeholder="Название сервиса" maxlength="128" required></div>
<div class="mb-3"><label for="customPort" class="form-label">Порт</label><input type="number" class="input-modern w-100" id="customPort" min="1" max="65535" placeholder="8080" required></div> <div class="mb-3"><label for="customPort" class="form-label">Порт</label><input type="number" step="1" inputmode="numeric" class="input-modern w-100" id="customPort" min="1" max="65535" placeholder="8080" required></div>
<div class="mb-3"><label for="customIp" class="form-label">IP (опционально)</label><input type="text" class="input-modern w-100" id="customIp" placeholder="192.168.1.100" autocomplete="off"></div> <div class="mb-3"><label for="customIp" class="form-label">IP (опционально)</label><input type="text" class="input-modern w-100" id="customIp" placeholder="192.168.1.100" autocomplete="off"></div>
<p id="custom-error" class="form-error" role="alert"></p> <p id="custom-error" class="form-error" role="alert"></p>
</div> </div>
@ -52,7 +54,7 @@
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"><label for="service-name" class="form-label">Название</label><input id="service-name" class="input-modern w-100" maxlength="128" required></div> <div class="mb-3"><label for="service-name" class="form-label">Название</label><input id="service-name" class="input-modern w-100" maxlength="128" required></div>
<div class="mb-3"><label for="service-listen" class="form-label">Адрес и порт прокси</label><input id="service-listen" class="input-modern w-100" required></div> <div class="mb-3"><label for="service-listen" class="form-label">Адрес и порт прокси</label><input id="service-listen" class="input-modern w-100" required></div>
<div class="mb-3"><label for="service-port" class="form-label">Порт сервиса команды</label><input id="service-port" type="number" min="1" max="65535" class="input-modern w-100" required></div> <div class="mb-3"><label for="service-port" class="form-label">Порт сервиса команды</label><input id="service-port" type="number" step="1" inputmode="numeric" min="1" max="65535" class="input-modern w-100" required></div>
<div class="mb-3"><label for="service-policy" class="form-label">При медленной реплике</label><select id="service-policy" class="input-modern w-100"><option value="isolate">Отключить реплику, сохранить скорость primary</option><option value="strict">Ждать реплику, замедлить общий поток</option></select></div> <div class="mb-3"><label for="service-policy" class="form-label">При медленной реплике</label><select id="service-policy" class="input-modern w-100"><option value="isolate">Отключить реплику, сохранить скорость primary</option><option value="strict">Ждать реплику, замедлить общий поток</option></select></div>
<p id="settings-error" class="form-error" role="alert"></p> <p id="settings-error" class="form-error" role="alert"></p>
</div> </div>
@ -60,5 +62,44 @@
</form> </form>
</div></div> </div></div>
</dialog> </dialog>
<dialog id="teamsModal" class="legacy-modal" aria-labelledby="teams-title">
<div class="modal-dialog"><div class="modal-content">
<div class="modal-header"><h5 class="modal-title" id="teams-title">Команды</h5><button type="button" class="btn-close btn-close-white" data-close="teamsModal" aria-label="Закрыть"></button></div>
<form id="team-form">
<div class="modal-body">
<div class="mb-3"><label for="team-choice" class="form-label">Команда</label><select id="team-choice" class="input-modern w-100"><option value="">Новая команда</option></select></div>
<div class="mb-3"><label for="team-name" class="form-label">Название команды</label><input id="team-name" class="input-modern w-100" maxlength="128" required></div>
<div class="mb-3"><label for="team-address" class="form-label">IP команды</label><input id="team-address" class="input-modern w-100" placeholder="192.0.2.10 или ::1" autocomplete="off" required aria-describedby="team-hint"></div>
<p id="team-hint" class="form-hint">Для ForcAD укажите точное название из scoreboard. IP существующей команды неизменяемый — для другого адреса добавьте новую.</p>
<p id="team-error" class="form-error" role="alert"></p>
<p id="team-result" class="form-hint" role="status"></p>
</div>
<div class="modal-footer"><button type="button" class="add-button cancel-button" data-close="teamsModal">Закрыть</button><button type="submit" class="add-button">Сохранить команду</button></div>
</form>
</div></div>
</dialog>
<dialog id="helpModal" class="legacy-modal" aria-labelledby="help-title">
<div class="modal-dialog"><div class="modal-content">
<div class="modal-header"><h5 class="modal-title" id="help-title">Настройка / ForcAD</h5><button type="button" class="btn-close btn-close-white" data-close="helpModal" aria-label="Закрыть"></button></div>
<div class="modal-body help-body">
<p id="board-status" role="status"></p><p id="board-error" class="form-error"></p>
<h6>Конфигурация</h6>
<p>Файл задаётся при запуске: <code>--config mirror.toml</code>. Команды и сервисы из панели сохраняются в <code>[web].state_file</code>; относительный путь считается от каталога TOML. Если state-файл уже существует, топология загружается из него.</p>
<h6>Подключение ForcAD</h6>
<p>В начало TOML, перед секциями, добавьте IP своей команды. Затем добавьте секцию scoreboard:</p>
<pre>main_team_ip = "192.0.2.10"
[scoreboard]
kind = "forcad"
url = "http://scoreboard.example:8080"
poll_interval_ms = 3000
stale_after_ms = 15000</pre>
<p>Адрес замените на URL вашей игры. Имена команд и сервисов должны совпадать с scoreboard. После правки TOML перезапустите процесс. Настройки маршрутов из панели применяются сразу.</p>
<h6>Управление маршрутами</h6>
<p>Кружок выбирает primary, строка — реплику. Робот включает автоматический выбор из назначенных реплик. Новые соединения используют новый маршрут; открытые остаются на прежнем.</p>
<p>Название сервиса открывает настройки портов и режима реплик. При <code>isolate</code> медленная реплика отключается; при <code>strict</code> общий поток ждёт её.</p>
</div>
</div></div>
</dialog>
</body> </body>
</html> </html>