From ee2fb0a6c9458eb8a2cc1f47a3a3791b0d8d7064 Mon Sep 17 00:00:00 2001 From: benya Date: Wed, 22 Jul 2026 23:18:09 +0300 Subject: [PATCH] fix: polish remote access and responsive UI --- UI_AUDIT_CHECKLIST.md | 57 +++++++++++ server/internal/httpapi/luci_access.go | 64 +++++++----- web/app.js | 29 +++++- web/index.html | 5 +- web/landing-motion.js | 35 +++++++ web/landing.html | 3 +- web/styles.css | 134 +++++++++++++++++++++++++ 7 files changed, 298 insertions(+), 29 deletions(-) create mode 100644 UI_AUDIT_CHECKLIST.md create mode 100644 web/landing-motion.js diff --git a/UI_AUDIT_CHECKLIST.md b/UI_AUDIT_CHECKLIST.md new file mode 100644 index 0000000..9bbeae3 --- /dev/null +++ b/UI_AUDIT_CHECKLIST.md @@ -0,0 +1,57 @@ +# Полный UI/UX-аудит + +Дата аудита: 2026-07-21. Production проверен на реальной учётной записи, изменения — на изолированной локальной Docker-сборке с фиктивным роутером и длинными тестовыми значениями. + +## Авторизация и общий каркас + +- [x] Вход, выход и понятное состояние ошибки авторизации. +- [x] Личный кабинет, профиль, e-mail, смена пароля и восстановление доступа представлены единообразно. +- [x] Header, боковая и мобильная навигация, отступы, фокус и зоны нажатия. +- [x] Loading, empty, offline, reconnecting, forbidden, timeout и server error используют общий визуальный язык. +- [x] Нет горизонтального overflow или недоступных действий в основном каркасе. + +## Список и карточка роутера + +- [x] Поиск, фильтры, обновление и длинные имена/ID/IPv6 не ломают сетку. +- [x] Статусы online/offline/reconnecting понятны по цвету и тексту. +- [x] Все шесть вкладок карточки проверены в 4:3 и на телефоне. +- [x] Широкие таблицы превращаются в карточки либо остаются внутри локального scroll-контейнера. +- [x] Мобильные действия и нижняя навигация остаются видимыми и доступными. + +## Облачный LuCI + +- [x] Верхняя и карточная кнопки используют один сценарий и не зависят от состояния друг друга. +- [x] Повторное нажатие блокируется на время запроса; открытая вкладка показывает состояние подключения. +- [x] Проверены starting, offline и expired; backend-тесты покрывают grant, cookie и проксирование. +- [x] Ошибки 401/403/404/409/429/502/504 имеют понятный текст, возврат в RMM и request ID. +- [x] Отдельная страница ошибки приведена к стилю кабинета и проверена на телефоне. + +## Лендинг и motion + +- [x] Последовательное появление hero без блокировки управления. +- [x] Scroll-reveal для возможностей, партнёров, безопасности и CTA. +- [x] Спокойные hover/press-переходы для CTA, навигации и партнёров. +- [x] Ненавязчивое движение фоновых градиентов без layout shift. +- [x] `prefers-reduced-motion: reduce` отключает декоративное движение. +- [x] Контент остаётся видимым при отключённом JavaScript. + +## Матрица экранов + +- [x] 1920×1080 — широкий desktop. +- [x] 1366×768 — ноутбук. +- [x] 1024×768 — формат 4:3. +- [x] 768×1024 — tablet portrait. +- [x] 390×844 — современный телефон. +- [x] 360×800 — узкий телефон. + +## Качество + +- [x] Видимый keyboard focus, логичный tab order и зоны нажатия не меньше 42 px для ключевых действий. +- [x] Диалоги ограничены viewport, прокручиваются внутри и предсказуемо закрываются. +- [x] Нет синтаксических ошибок JavaScript и необработанного горизонтального overflow. +- [x] Go tests/vet, Compose config, Docker build и проверка diff проходят. + +## Ограничения production-проверки + +- Смена пароля и e-mail на production намеренно не выполнялись, чтобы не изменять реальную учётную запись; формы и API покрыты локальной проверкой и автоматическими тестами. +- Исправления становятся видны на `rmm.daemonlord.ru` после развёртывания нового Docker-образа. diff --git a/server/internal/httpapi/luci_access.go b/server/internal/httpapi/luci_access.go index 4eb808e..b36a874 100644 --- a/server/internal/httpapi/luci_access.go +++ b/server/internal/httpapi/luci_access.go @@ -34,38 +34,52 @@ var luciErrorPageTemplate = template.Must(template.New("luci-error").Parse(` {{.Title}} · OpenWrt RMM
-
R OpenWrt RMM
-
RMM защищённый туннель LuCI
-
{{.Code}}
-

{{.Title}}

-

{{.Description}}

-
Настройки роутера не изменялись. Можно безопасно вернуться в RMM и проверить состояние агента или запустить диагностику.
-
- {{.ActionLabel}} - Вернуться в RMM +
+
R OpenWrt RMM
+
RMMоблакоLuCI
+
+
+ +
{{.Code}}
+

{{.Title}}

+

{{.Description}}

+
Настройки роутера не изменялись. Можно безопасно вернуться в RMM и проверить состояние агента или запустить диагностику.
+ + {{if .RequestID}}
ID запроса: {{.RequestID}}
{{end}}
- {{if .RequestID}}
ID запроса: {{.RequestID}}
{{end}}
`)) diff --git a/web/app.js b/web/app.js index 6a81491..63bfce2 100644 --- a/web/app.js +++ b/web/app.js @@ -1850,10 +1850,29 @@ function renderCloudAccessState(sessions) { els.openCloudAccessBtn.textContent = copy[1]; } +function prepareCloudAccessPopup(popup) { + if (!popup) return; + try { + popup.document.open(); + popup.document.write(`Подключение к LuCI — OpenWrt RMM
R

Защищённый доступ

Подключаемся к LuCI

Создаём временный туннель и проверяем ответ роутера. Эта вкладка откроется автоматически.

`); + popup.document.close(); + popup.opener = null; + } catch { + // The popup may be restricted by the browser; navigation below still works. + } +} + async function openCloudAccess() { - if (!state.selectedDeviceId || els.openCloudAccessBtn.disabled) return; + if (!state.selectedDeviceId || openCloudAccess.inFlight) return; + openCloudAccess.inFlight = true; const popup = window.open("", "_blank"); + prepareCloudAccessPopup(popup); + els.openLuciBtn.disabled = true; els.openCloudAccessBtn.disabled = true; + els.openLuciBtn.classList.add("is-loading"); + els.openCloudAccessBtn.classList.add("is-loading"); + els.openLuciBtn.textContent = "Подключаемся…"; + els.openCloudAccessBtn.textContent = "Подключаемся…"; els.cloudAccessCard.dataset.state = "starting"; els.cloudAccessStatus.textContent = "Связываемся с агентом и проверяем LuCI…"; try { @@ -1883,10 +1902,18 @@ async function openCloudAccess() { showLuCIState(error, "retry"); await loadRemoteSessions().catch(() => {}); } finally { + openCloudAccess.inFlight = false; + els.openLuciBtn.classList.remove("is-loading"); + els.openCloudAccessBtn.classList.remove("is-loading"); + els.openLuciBtn.textContent = "Открыть LuCI"; + renderCloudAccessState(state.remoteSessions || []); + els.openLuciBtn.disabled = !state.selectedDeviceId; els.openCloudAccessBtn.disabled = false; } } +openCloudAccess.inFlight = false; + async function sendCommand() { if (!state.selectedDeviceId) return; if (!confirmDanger(els.commandType.value)) return; diff --git a/web/index.html b/web/index.html index 0dd39b4..e2a53e4 100644 --- a/web/index.html +++ b/web/index.html @@ -11,7 +11,7 @@ - +
@@ -905,6 +905,7 @@ - + + diff --git a/web/landing-motion.js b/web/landing-motion.js new file mode 100644 index 0000000..5eeaa1b --- /dev/null +++ b/web/landing-motion.js @@ -0,0 +1,35 @@ +(() => { + const landing = document.querySelector(".landing-view"); + if (!landing) return; + + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const revealTargets = [ + ...landing.querySelectorAll( + ".landing-features article, .landing-partners-copy, .partner-logo, .landing-security > *, .landing-login-section > *, .landing-footer > *", + ), + ]; + + landing.classList.add("motion-ready"); + revealTargets.forEach((element, index) => { + element.classList.add("landing-reveal"); + element.style.setProperty("--reveal-order", String(index % 3)); + }); + + if (reducedMotion || !("IntersectionObserver" in window)) { + revealTargets.forEach((element) => element.classList.add("is-revealed")); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + entry.target.classList.add("is-revealed"); + observer.unobserve(entry.target); + } + }, + { rootMargin: "0px 0px -8%", threshold: 0.12 }, + ); + + revealTargets.forEach((element) => observer.observe(element)); +})(); diff --git a/web/landing.html b/web/landing.html index 3e281c1..a47dc26 100644 --- a/web/landing.html +++ b/web/landing.html @@ -15,7 +15,7 @@ - +
@@ -90,5 +90,6 @@
OpenWrt RMMБезопасное управление вашей сетью
+ diff --git a/web/styles.css b/web/styles.css index 3339c2c..1e7a851 100644 --- a/web/styles.css +++ b/web/styles.css @@ -21,6 +21,7 @@ } .landing-view { + position: relative; min-height: 100vh; overflow: hidden; background: @@ -29,6 +30,27 @@ #071019; } +.landing-view::before, +.landing-view::after { + content: ""; + position: absolute; + z-index: 0; + width: min(52vw, 720px); + aspect-ratio: 1; + border-radius: 50%; + filter: blur(90px); + opacity: .13; + pointer-events: none; +} + +.landing-view::before { top: -18rem; left: -14rem; background: #27b6e6; } +.landing-view::after { top: 24rem; right: -22rem; background: #435ff6; } + +.landing-view > * { + position: relative; + z-index: 1; +} + .landing-header, .landing-hero, .landing-features, @@ -146,6 +168,7 @@ font-weight: 750; padding: 0 20px; text-decoration: none; + transition: border-color 180ms ease, background-color 180ms ease, box-shadow 180ms ease, color 180ms ease, transform 180ms ease; } .landing-primary { background: var(--accent); color: #fff; } .landing-secondary { border: 1px solid var(--line); color: var(--text); } @@ -162,6 +185,26 @@ transform: perspective(1000px) rotateY(-3deg) rotateX(2deg); } +@media (hover: hover) and (pointer: fine) { + .landing-actions a:hover, + .landing-header .landing-login-link:hover { + transform: translateY(-2px); + } + + .landing-primary:hover { + background: #29b6e7; + box-shadow: 0 12px 34px rgb(33 168 216 / 24%); + } + + .landing-secondary:hover { + border-color: rgb(39 182 230 / 54%); + background: rgb(39 182 230 / 7%); + } +} + +.landing-actions a:active, +.landing-header .landing-login-link:active { transform: translateY(0) scale(.985); } + .landing-console::after { content: ""; position: absolute; @@ -323,6 +366,48 @@ .live-state { width: 30px; justify-content: center; padding: 0; } } +@keyframes landing-enter { + from { opacity: 0; transform: translateY(18px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes landing-console-enter { + from { opacity: 0; transform: perspective(1000px) rotateY(-3deg) rotateX(2deg) translateY(24px) scale(.975); } + to { opacity: 1; transform: perspective(1000px) rotateY(-3deg) rotateX(2deg) translateY(0) scale(1); } +} + +@keyframes landing-ambient-left { + 0%, 100% { transform: translate3d(0, 0, 0) scale(1); } + 50% { transform: translate3d(3rem, 2rem, 0) scale(1.08); } +} + +@keyframes landing-ambient-right { + 0%, 100% { transform: translate3d(0, 0, 0) scale(1); } + 50% { transform: translate3d(-3rem, 3rem, 0) scale(.94); } +} + +.motion-ready::before { animation: landing-ambient-left 16s ease-in-out infinite; } +.motion-ready::after { animation: landing-ambient-right 19s ease-in-out infinite; } +.motion-ready .landing-header { animation: landing-enter 520ms cubic-bezier(.2, .75, .25, 1) both; } +.motion-ready .landing-kicker { animation: landing-enter 540ms 70ms cubic-bezier(.2, .75, .25, 1) both; } +.motion-ready .landing-copy h1 { animation: landing-enter 580ms 130ms cubic-bezier(.2, .75, .25, 1) both; } +.motion-ready .landing-copy > p { animation: landing-enter 580ms 190ms cubic-bezier(.2, .75, .25, 1) both; } +.motion-ready .landing-copy .landing-actions { animation: landing-enter 580ms 250ms cubic-bezier(.2, .75, .25, 1) both; } +.motion-ready .landing-trust { animation: landing-enter 580ms 310ms cubic-bezier(.2, .75, .25, 1) both; } +.motion-ready .landing-console { animation: landing-console-enter 700ms 170ms cubic-bezier(.2, .75, .25, 1) both; } + +.motion-ready .landing-reveal { + opacity: 0; + transform: translateY(22px); + transition: opacity 560ms ease, transform 560ms cubic-bezier(.2, .75, .25, 1); + transition-delay: calc(var(--reveal-order, 0) * 70ms); +} + +.motion-ready .landing-reveal.is-revealed { + opacity: 1; + transform: translateY(0); +} + * { box-sizing: border-box; } @@ -3724,6 +3809,22 @@ select:focus-visible, } } +@media (min-width: 721px) and (max-width: 900px) { + .topbar-actions { + grid-template-columns: auto minmax(0, 1fr) auto auto; + } + + .live-state { grid-column: 1; grid-row: 1; } + .status-line { grid-column: 2 / 4; grid-row: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + #refreshBtn { grid-column: 4; grid-row: 1; } + #addRouterBtn { grid-column: 1 / 3; grid-row: 2; } + #addUserBtn { grid-column: 3 / 5; grid-row: 2; } + + .motion-ready .landing-console { + animation-name: landing-enter; + } +} + /* Motion system: short, functional transitions with an accessibility fallback. */ @keyframes ui-fade-up { from { opacity: 0; transform: translateY(8px); } @@ -3770,6 +3871,39 @@ button:not(:disabled):active { transform: translateY(1px) scale(.99); } +button.is-loading { + cursor: progress; + opacity: .78; +} + +.cloud-wait-page { + min-height: 100dvh; + display: grid; + place-items: center; + padding: 20px; + background: radial-gradient(circle at 18% 8%, rgb(39 182 230 / 15%), transparent 30rem), var(--bg); +} + +.cloud-wait-page main { + width: min(100%, 520px); + padding: clamp(28px, 7vw, 48px); + border: 1px solid var(--line); + border-radius: 22px; + background: var(--surface); + animation: ui-dialog-in 220ms ease-out both; +} + +.cloud-wait-page h1 { margin: 9px 0 14px; font-size: clamp(30px, 7vw, 44px); letter-spacing: -.04em; } +.cloud-wait-page p:not(.eyebrow) { color: var(--muted); line-height: 1.6; } +.cloud-wait-mark { width: 42px; height: 42px; display: grid; place-items: center; margin-bottom: 28px; border-radius: 12px; color: #fff; background: linear-gradient(145deg, #27b6e6, #435ff6); font-weight: 800; } +.cloud-wait-progress { height: 4px; overflow: hidden; margin-top: 28px; border-radius: 999px; background: var(--surface-muted); } +.cloud-wait-progress i { display: block; width: 42%; height: 100%; border-radius: inherit; background: var(--accent); animation: cloud-wait-progress 1.25s ease-in-out infinite alternate; } + +@keyframes cloud-wait-progress { + from { transform: translateX(-20%); } + to { transform: translateX(160%); } +} + @media (hover: hover) and (pointer: fine) { .device-item:hover, .cloud-access-card:hover {