mirror of
https://github.com/mrvasil/ctf-ad-mirror-tool-rust.git
synced 2026-10-23 14:31:02 +00:00
Polish legacy UI, add team controls and document persistent demo setup
This commit is contained in:
parent
dbbbb8b255
commit
b697ba233f
@ -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.
|
||||
|
||||
|
||||
5
agent.md
5
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.
|
||||
|
||||
@ -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.
|
||||
|
||||
74
docs/configuration.md
Normal file
74
docs/configuration.md
Normal 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 порты остаются как записано в файле.
|
||||
76
docs/ui-polish-verification.md
Normal file
76
docs/ui-polish-verification.md
Normal 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.
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
28
web/app.css
28
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; } }
|
||||
|
||||
93
web/app.js
93
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; } })();
|
||||
|
||||
@ -24,6 +24,8 @@
|
||||
<div id="header" class="d-flex justify-content-between align-items-center header-row" hidden>
|
||||
<h1 class="section-title">Mirror Tool</h1>
|
||||
<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="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>
|
||||
@ -37,7 +39,7 @@
|
||||
<form id="customProxyForm">
|
||||
<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="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>
|
||||
<p id="custom-error" class="form-error" role="alert"></p>
|
||||
</div>
|
||||
@ -52,7 +54,7 @@
|
||||
<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-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>
|
||||
<p id="settings-error" class="form-error" role="alert"></p>
|
||||
</div>
|
||||
@ -60,5 +62,44 @@
|
||||
</form>
|
||||
</div></div>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user