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-файл в резервную копию. Файл не исполняется, версия и содержимое валидируются.
Запись состояния: временный файл на той же ФС → 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.

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

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

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

View File

@ -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; } }

View File

@ -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; } })();

View File

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