From cd77c12c23a53c3a45ac1fa362a3182de15ff831 Mon Sep 17 00:00:00 2001 From: benya Date: Tue, 21 Jul 2026 03:16:33 +0300 Subject: [PATCH] feat: add public landing and live updates --- docs/docker-compose.md | 5 ++ server/README.md | 1 + server/internal/httpapi/accounts.go | 1 + server/internal/httpapi/auth.go | 2 +- server/internal/httpapi/events.go | 79 ++++++++++++++++++++ server/internal/httpapi/events_test.go | 22 ++++++ server/internal/httpapi/security_test.go | 2 +- server/internal/httpapi/server.go | 44 +++++++++--- server/internal/httpapi/server_test.go | 76 +++++++++++++++++++- web/README.md | 6 +- web/app.js | 78 +++++++++++++++++++- web/index.html | 16 +++-- web/landing.html | 81 +++++++++++++++++++++ web/styles.css | 92 ++++++++++++++++++++++++ 14 files changed, 479 insertions(+), 26 deletions(-) create mode 100644 server/internal/httpapi/events.go create mode 100644 server/internal/httpapi/events_test.go create mode 100644 web/landing.html diff --git a/docs/docker-compose.md b/docs/docker-compose.md index 2ffe9c9..b6a4675 100644 --- a/docs/docker-compose.md +++ b/docs/docker-compose.md @@ -122,6 +122,11 @@ temporary password from the profile dialog. Every user receives routers through own one-time enrollment grants. A router can be transferred from its Expert tab after the current user confirms their password; active LuCI access is closed during transfer. +The public landing page is served at `/`, sign-in and password recovery at `/login`, and +the authenticated cabinet at `/app`. Live updates use authenticated Server-Sent Events +at `/api/events`. The server disables proxy buffering and sends keep-alives every 20 +seconds; if a proxy interrupts the stream, the browser falls back to a 30-second poll. + View logs: ```powershell diff --git a/server/README.md b/server/README.md index 8ba823a..2bbbfcd 100644 --- a/server/README.md +++ b/server/README.md @@ -60,6 +60,7 @@ Operator API: - `POST /api/enrollment-grants` - `GET /api/devices` +- `GET /api/events` (authenticated SSE stream; the client reloads user-scoped data on change) - `GET /api/devices/{id}` - `POST /api/devices/{id}/transfer` - `POST /api/devices/{id}/commands` diff --git a/server/internal/httpapi/accounts.go b/server/internal/httpapi/accounts.go index 321cfe0..44bc87a 100644 --- a/server/internal/httpapi/accounts.go +++ b/server/internal/httpapi/accounts.go @@ -206,6 +206,7 @@ func (a *App) handleTransferDevice(w http.ResponseWriter, r *http.Request) { "target_user_id": target.ID, "request_id": requestID(r.Context()), })) + a.events.publish("devices") writeJSON(w, http.StatusOK, device) } diff --git a/server/internal/httpapi/auth.go b/server/internal/httpapi/auth.go index f3d5ea1..23b03b8 100644 --- a/server/internal/httpapi/auth.go +++ b/server/internal/httpapi/auth.go @@ -214,7 +214,7 @@ func (a *App) handlePasswordResetRequest(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusInternalServerError, "failed to start password recovery") return } - resetURL := a.publicURL + "/#password-reset=" + url.QueryEscape(token) + resetURL := a.publicURL + "/login#password-reset=" + url.QueryEscape(token) go func(userID, recipient, targetURL string) { if err := a.passwordResetSender.SendPasswordReset(context.Background(), recipient, targetURL); err != nil { log.Printf("password reset email delivery failed for user %s: %v", userID, err) diff --git a/server/internal/httpapi/events.go b/server/internal/httpapi/events.go new file mode 100644 index 0000000..93577ca --- /dev/null +++ b/server/internal/httpapi/events.go @@ -0,0 +1,79 @@ +package httpapi + +import ( + "fmt" + "net/http" + "sync" + "time" +) + +type eventHub struct { + mu sync.Mutex + subscribers map[chan string]struct{} +} + +func newEventHub() *eventHub { + return &eventHub{subscribers: make(map[chan string]struct{})} +} + +func (h *eventHub) subscribe() (<-chan string, func()) { + updates := make(chan string, 1) + h.mu.Lock() + h.subscribers[updates] = struct{}{} + h.mu.Unlock() + + return updates, func() { + h.mu.Lock() + delete(h.subscribers, updates) + h.mu.Unlock() + } +} + +func (h *eventHub) publish(event string) { + h.mu.Lock() + defer h.mu.Unlock() + for subscriber := range h.subscribers { + select { + case subscriber <- event: + default: + } + } +} + +func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache, no-transform") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + controller := http.NewResponseController(w) + writeEvent := func(payload string) bool { + _ = controller.SetWriteDeadline(time.Now().Add(30 * time.Second)) + if _, err := fmt.Fprint(w, payload); err != nil { + return false + } + return controller.Flush() == nil + } + if !writeEvent("event: ready\ndata: {}\n\n") { + return + } + + updates, unsubscribe := a.events.subscribe() + defer unsubscribe() + keepAlive := time.NewTicker(20 * time.Second) + defer keepAlive.Stop() + + for { + select { + case <-r.Context().Done(): + return + case event := <-updates: + if !writeEvent(fmt.Sprintf("event: %s\ndata: {}\n\n", event)) { + return + } + case <-keepAlive.C: + if !writeEvent(": keepalive\n\n") { + return + } + } + } +} diff --git a/server/internal/httpapi/events_test.go b/server/internal/httpapi/events_test.go new file mode 100644 index 0000000..25c9a33 --- /dev/null +++ b/server/internal/httpapi/events_test.go @@ -0,0 +1,22 @@ +package httpapi + +import ( + "testing" + "time" +) + +func TestEventHubPublishesToSubscribers(t *testing.T) { + hub := newEventHub() + updates, unsubscribe := hub.subscribe() + defer unsubscribe() + + hub.publish("devices") + select { + case event := <-updates: + if event != "devices" { + t.Fatalf("unexpected event %q", event) + } + case <-time.After(time.Second): + t.Fatal("event was not published") + } +} diff --git a/server/internal/httpapi/security_test.go b/server/internal/httpapi/security_test.go index 72335ad..bbe1923 100644 --- a/server/internal/httpapi/security_test.go +++ b/server/internal/httpapi/security_test.go @@ -147,7 +147,7 @@ func TestPasswordResetIsOneTimeAndRevokesSessions(t *testing.T) { if err != nil { t.Fatal(err) } - if message.recipient != "owner@example.test" || !strings.HasPrefix(parsed.Fragment, "password-reset=") { + if message.recipient != "owner@example.test" || parsed.Path != "/login" || !strings.HasPrefix(parsed.Fragment, "password-reset=") { t.Fatalf("unexpected password reset message: %#v", message) } token, err := url.QueryUnescape(strings.TrimPrefix(parsed.Fragment, "password-reset=")) diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 534c69b..000777f 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -14,6 +14,7 @@ import ( "net/http/httputil" "net/url" "os" + "path" "path/filepath" "strconv" "strings" @@ -114,6 +115,7 @@ type App struct { loginLimiter *loginRateLimiter passwordResetLimiter *loginRateLimiter loginSlots chan struct{} + events *eventHub } type contextKey string @@ -273,6 +275,7 @@ func NewHandler(s Store, cfg Config) http.Handler { loginLimiter: newLoginRateLimiter(5, 5*time.Minute), passwordResetLimiter: newLoginRateLimiter(3, time.Hour), loginSlots: make(chan struct{}, 4), + events: newEventHub(), } if a.tunnelHTTPHost == "" { a.tunnelHTTPHost = "tunnel-ssh" @@ -303,6 +306,7 @@ func NewHandler(s Store, cfg Config) http.Handler { mux.HandleFunc("POST /api/agent/commands/next", a.handleNextCommand) mux.HandleFunc("POST /api/agent/commands/", a.handleCommandResult) mux.Handle("GET /api/devices", a.operatorAuth(http.HandlerFunc(a.handleListDevices))) + mux.Handle("GET /api/events", a.operatorAuth(http.HandlerFunc(a.handleEvents))) mux.Handle("POST /api/devices/bulk-commands", a.operatorAuth(http.HandlerFunc(a.handleCreateBulkCommand))) mux.Handle("GET /api/devices/", a.operatorAuth(http.HandlerFunc(a.handleDeviceSubtree))) mux.Handle("POST /api/devices/", a.operatorAuth(http.HandlerFunc(a.handleDeviceSubtree))) @@ -377,6 +381,7 @@ func (a *App) handleHeartbeat(w http.ResponseWriter, r *http.Request) { if _, _, err := a.refreshDeviceAlerts(r.Context(), req.DeviceID); err != nil { log.Printf("refresh alerts for %s: %v", req.DeviceID, err) } + a.events.publish("devices") writeJSON(w, http.StatusOK, heartbeatResponse{Commands: commands}) } @@ -451,6 +456,7 @@ func (a *App) handleCommandResult(w http.ResponseWriter, r *http.Request) { "status": status, "request_id": requestID(r.Context()), })) + a.events.publish("devices") writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } @@ -516,6 +522,7 @@ func (a *App) handleDeleteDevice(w http.ResponseWriter, r *http.Request) { _, _ = a.store.AddAuditEvent(r.Context(), actorName(r), "device.delete", id, "", mustJSON(map[string]string{ "request_id": requestID(r.Context()), })) + a.events.publish("devices") writeJSON(w, http.StatusOK, map[string]any{"deleted": true}) } @@ -640,6 +647,7 @@ func (a *App) handleUpdateDeviceFleet(w http.ResponseWriter, r *http.Request) { "request_id": requestID(r.Context()), })) a.decorateDevice(&d) + a.events.publish("devices") writeJSON(w, http.StatusOK, d) } @@ -719,6 +727,7 @@ func (a *App) handleAcknowledgeAlert(w http.ResponseWriter, r *http.Request) { "type": alert.Type, "request_id": requestID(r.Context()), })) + a.events.publish("devices") writeJSON(w, http.StatusOK, alert) } @@ -745,6 +754,7 @@ func (a *App) handlePurgeDeviceAlerts(w http.ResponseWriter, r *http.Request) { "deleted": deleted, "request_id": requestID(r.Context()), })) + a.events.publish("devices") writeJSON(w, http.StatusOK, map[string]any{"deleted": deleted}) } @@ -1037,6 +1047,7 @@ func (a *App) handleCreateCommand(w http.ResponseWriter, r *http.Request) { "type": req.Type, "request_id": requestID(r.Context()), })) + a.events.publish("devices") writeJSON(w, http.StatusCreated, c) } @@ -1090,6 +1101,7 @@ func (a *App) handleCreateBulkCommand(w http.ResponseWriter, r *http.Request) { "request_id": requestID(r.Context()), })) } + a.events.publish("devices") writeJSON(w, http.StatusCreated, map[string]any{"commands": commands}) } @@ -1155,6 +1167,7 @@ func (a *App) handleCancelCommand(w http.ResponseWriter, r *http.Request) { _, _ = a.store.AddAuditEvent(r.Context(), actorName(r), "command.cancel", deviceID, commandID, mustJSON(map[string]string{ "request_id": requestID(r.Context()), })) + a.events.publish("devices") writeJSON(w, http.StatusOK, c) } @@ -1181,6 +1194,7 @@ func (a *App) handlePurgeDeviceCommands(w http.ResponseWriter, r *http.Request) "deleted": deleted, "request_id": requestID(r.Context()), })) + a.events.publish("devices") writeJSON(w, http.StatusOK, map[string]any{"deleted": deleted}) } @@ -1918,6 +1932,10 @@ type statusRecorder struct { status int } +func (r *statusRecorder) Unwrap() http.ResponseWriter { + return r.ResponseWriter +} + func (r *statusRecorder) WriteHeader(status int) { r.status = status r.ResponseWriter.WriteHeader(status) @@ -1986,20 +2004,24 @@ func logStructured(fields map[string]any) { func staticHandler(dir string) http.Handler { fs := http.FileServer(http.Dir(dir)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/")) - if path == "." { - path = "index.html" + requestPath := path.Clean("/" + strings.TrimPrefix(r.URL.Path, "/")) + assetPath := strings.TrimPrefix(requestPath, "/") + switch requestPath { + case "/": + assetPath = "landing.html" + case "/login", "/app": + assetPath = "index.html" } - fullPath := filepath.Join(dir, path) + fullPath := filepath.Join(dir, filepath.FromSlash(assetPath)) if info, err := os.Stat(fullPath); err == nil && !info.IsDir() { - fs.ServeHTTP(w, r) + if strings.HasSuffix(assetPath, ".html") { + w.Header().Set("Cache-Control", "no-cache") + http.ServeFile(w, r, fullPath) + } else { + fs.ServeHTTP(w, r) + } return } - indexPath := filepath.Join(dir, "index.html") - if _, err := os.Stat(indexPath); err != nil { - writeError(w, http.StatusNotFound, "not found") - return - } - http.ServeFile(w, r, indexPath) + writeError(w, http.StatusNotFound, "not found") }) } diff --git a/server/internal/httpapi/server_test.go b/server/internal/httpapi/server_test.go index 3fc74b8..195a3d9 100644 --- a/server/internal/httpapi/server_test.go +++ b/server/internal/httpapi/server_test.go @@ -642,7 +642,10 @@ func TestLuCIProxyRequiresActiveSessionAndRewritesPaths(t *testing.T) { func TestServesStaticWebUI(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("RMM UI"), 0644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("RMM App"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "landing.html"), []byte("RMM Landing"), 0644); err != nil { t.Fatal(err) } @@ -669,9 +672,78 @@ func TestServesStaticWebUI(t *testing.T) { if err != nil { t.Fatal(err) } - if resp.StatusCode != http.StatusOK || !strings.Contains(string(data), "RMM UI") { + if resp.StatusCode != http.StatusOK || !strings.Contains(string(data), "RMM Landing") { t.Fatalf("unexpected static response %d: %s", resp.StatusCode, data) } + + for _, path := range []string{"/login", "/app"} { + appResp, err := http.Get(srv.URL + path) + if err != nil { + t.Fatal(err) + } + appData, readErr := io.ReadAll(appResp.Body) + _ = appResp.Body.Close() + if readErr != nil { + t.Fatal(readErr) + } + if appResp.StatusCode != http.StatusOK || !strings.Contains(string(appData), "RMM App") { + t.Fatalf("unexpected app response for %s: %d %s", path, appResp.StatusCode, appData) + } + } + + missingResp, err := http.Get(srv.URL + "/missing-page") + if err != nil { + t.Fatal(err) + } + _ = missingResp.Body.Close() + if missingResp.StatusCode != http.StatusNotFound { + t.Fatalf("unexpected missing page response: %d", missingResp.StatusCode) + } +} + +func TestLiveEventsRequireAuthenticationAndStream(t *testing.T) { + st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "events.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + srv := httptest.NewServer(httpapi.NewHandler(st, httpapi.Config{ + OperatorToken: "operator-test", + })) + defer srv.Close() + + unauthorized, err := http.Get(srv.URL + "/api/events") + if err != nil { + t.Fatal(err) + } + _ = unauthorized.Body.Close() + if unauthorized.StatusCode != http.StatusUnauthorized { + t.Fatalf("unexpected unauthorized status: %d", unauthorized.StatusCode) + } + + ctx, cancel := context.WithCancel(context.Background()) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/api/events", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer operator-test") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") { + _ = resp.Body.Close() + cancel() + t.Fatalf("unexpected event stream response: %d %q", resp.StatusCode, resp.Header.Get("Content-Type")) + } + buffer := make([]byte, 64) + n, readErr := resp.Body.Read(buffer) + cancel() + _ = resp.Body.Close() + if readErr != nil || !strings.Contains(string(buffer[:n]), "event: ready") { + t.Fatalf("unexpected initial event: %q (%v)", buffer[:n], readErr) + } } func TestOperatorCookieAuth(t *testing.T) { diff --git a/web/README.md b/web/README.md index 1b6e259..cd64459 100644 --- a/web/README.md +++ b/web/README.md @@ -11,7 +11,9 @@ go run ./server/cmd/rmm-server Then open: ```text -http://127.0.0.1:8080/ +http://127.0.0.1:8080/ public landing page +http://127.0.0.1:8080/login sign in and password recovery +http://127.0.0.1:8080/app authenticated RMM cabinet ``` The UI uses a revocable HttpOnly server-side session. It does not store operator tokens in @@ -29,7 +31,7 @@ Current MVP features: - isolated LuCI access through one-time device-domain links; - per-user router enrollment and administrator-created accounts; - responsive desktop/mobile navigation and account panel; -- automatic device refresh every 30 seconds and when the browser tab becomes visible; +- live device refresh over authenticated Server-Sent Events (SSE), with 30-second polling fallback and refresh when the tab becomes visible; - friendly LuCI access states for expired, unavailable, and timed-out sessions; - separate online, recently seen, and DHCP-only client presence states. diff --git a/web/app.js b/web/app.js index 5b56ac7..2a3320a 100644 --- a/web/app.js +++ b/web/app.js @@ -28,8 +28,13 @@ const state = { luciAction: "setup", mobileRoute: "fleet", previousMobileRoute: "fleet", + liveConnected: false, + lastUpdatedAt: null, }; +let eventSource = null; +let liveRefreshTimer = null; + const EXPECTED_AGENT_VERSION = "0.5.2"; const els = { @@ -79,6 +84,7 @@ const els = { fleetAlertCount: document.querySelector("#fleetAlertCount"), navAlertCount: document.querySelector("#navAlertCount"), statusLine: document.querySelector("#statusLine"), + liveState: document.querySelector("#liveState"), pageTitle: document.querySelector("#pageTitle"), emptyState: document.querySelector("#emptyState"), emptyStateTitle: document.querySelector("#emptyState h2"), @@ -274,6 +280,63 @@ function reportError(error) { showToast(message, "error"); } +function setLiveState(mode, label) { + state.liveConnected = mode === "live"; + els.liveState.className = `live-state is-${mode}`; + els.liveState.querySelector("span").textContent = label; +} + +function lastUpdatedLabel() { + if (!state.lastUpdatedAt) return state.liveConnected ? "Живые данные" : "Ожидание данных"; + const elapsed = Math.max(0, Math.floor((Date.now() - state.lastUpdatedAt.getTime()) / 1000)); + if (elapsed < 10) return state.liveConnected ? "Живые данные · сейчас" : "Polling · сейчас"; + if (elapsed < 60) return `${state.liveConnected ? "Живые данные" : "Polling"} · ${elapsed} сек.`; + return `${state.liveConnected ? "Живые данные" : "Polling"} · ${Math.floor(elapsed / 60)} мин.`; +} + +function updateLiveStateLabel() { + if (els.appShell.classList.contains("is-hidden")) return; + setLiveState(state.liveConnected ? "live" : "polling", lastUpdatedLabel()); +} + +function disconnectLiveUpdates() { + if (eventSource) eventSource.close(); + if (liveRefreshTimer) window.clearTimeout(liveRefreshTimer); + eventSource = null; + liveRefreshTimer = null; + state.liveConnected = false; +} + +function scheduleLiveRefresh() { + if (liveRefreshTimer) window.clearTimeout(liveRefreshTimer); + liveRefreshTimer = window.setTimeout(() => { + liveRefreshTimer = null; + refreshDevicesIfIdle().catch(() => setLiveState("polling", "Polling · 30 сек.")); + }, 750); +} + +function connectLiveUpdates() { + disconnectLiveUpdates(); + if (!("EventSource" in window) || els.appShell.classList.contains("is-hidden")) { + setLiveState("polling", "Polling · 30 сек."); + return; + } + setLiveState("connecting", "Подключение"); + eventSource = new EventSource("/api/events"); + eventSource.addEventListener("ready", () => { + state.liveConnected = true; + setLiveState("live", lastUpdatedLabel()); + }); + eventSource.addEventListener("devices", () => { + if (document.visibilityState !== "visible") return; + scheduleLiveRefresh(); + }); + eventSource.onerror = () => { + state.liveConnected = false; + setLiveState("polling", "Polling · 30 сек."); + }; +} + function inlineStateMarkup(title, description = "", tone = "neutral") { return `
@@ -314,6 +377,8 @@ async function api(path, options = {}) { } function showLogin(message = "") { + disconnectLiveUpdates(); + document.title = "Вход — OpenWrt RMM"; els.appShell.classList.add("is-hidden"); els.appShell.hidden = true; els.appShell.setAttribute("aria-hidden", "true"); @@ -322,10 +387,12 @@ function showLogin(message = "") { els.loginView.removeAttribute("aria-hidden"); els.loginError.textContent = message; els.loginPassword.value = ""; + if (location.pathname === "/app") history.replaceState(null, "", `/login${location.hash}`); if (message) document.querySelector("#login")?.scrollIntoView({ behavior: "smooth", block: "center" }); } function showApp(user) { + document.title = "Кабинет — OpenWrt RMM"; state.user = user || null; state.username = user && user.username ? user.username : "operator"; const accountName = user && user.display_name ? user.display_name : state.username; @@ -344,6 +411,8 @@ function showApp(user) { els.appShell.hidden = false; els.appShell.removeAttribute("aria-hidden"); els.loginError.textContent = ""; + if (location.pathname !== "/app") history.replaceState(null, "", `/app${location.hash}`); + connectLiveUpdates(); } function formatLoadAverage(value) { @@ -1224,7 +1293,9 @@ async function loadDevices() { if (state.selectedDeviceId) { await Promise.all([loadCommands(), loadAudit(), loadMetricsHistory(), loadAlerts(), loadRemoteSessions()]); } - setStatus(`Обновлено ${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`); + state.lastUpdatedAt = new Date(); + updateLiveStateLabel(); + setStatus(`Обновлено ${state.lastUpdatedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`); } async function selectDevice(id) { @@ -2448,14 +2519,17 @@ checkHealth(); checkSession(); setInterval(() => { checkHealth(); - if (!els.appShell.classList.contains("is-hidden")) { + if (!els.appShell.classList.contains("is-hidden") && !state.liveConnected) { refreshDevicesIfIdle().catch(reportError); } }, 30000); +setInterval(updateLiveStateLabel, 10000); + document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible" && !els.appShell.classList.contains("is-hidden")) { checkHealth(); refreshDevicesIfIdle().catch(reportError); + if (!eventSource || eventSource.readyState === EventSource.CLOSED) connectLiveUpdates(); } }); diff --git a/web/index.html b/web/index.html index 9d379aa..67b5a6b 100644 --- a/web/index.html +++ b/web/index.html @@ -4,24 +4,25 @@ - OpenWrt RMM + + Вход — OpenWrt RMM - + - +
- + OpenWrt RMMRemote network management
@@ -136,6 +137,7 @@

Объекты

+ Подключение Готово @@ -882,6 +884,6 @@
- + diff --git a/web/landing.html b/web/landing.html new file mode 100644 index 0000000..480bc7a --- /dev/null +++ b/web/landing.html @@ -0,0 +1,81 @@ + + + + + + + + + + + OpenWrt RMM — удалённое управление роутерами + + + + + + + + + +
+
+ + + OpenWrt RMMRemote network management + + +
+ +
+
+ Роутеры под контролем из любой точки +

Единый центр управления OpenWrt

+

Следите за состоянием сети, клиентами и обновлениями агента. Открывайте LuCI через временный защищённый доступ без публикации панели роутера в интернете.

+ +
+ Живые данные + Изоляция аккаунтов + Временные LuCI-сессии +
+
+ +
+
fleet / overview
+
+
На связи12
+
Требуют внимания2
+
Средний uptime18 дн.
+
+
Домашняя сетьOpenWrt 25.12 · 8 клиентовНа связи
+
ОфисOpenWrt 24.10 · 24 клиентаНа связи
+
Загородный домВысокая загрузка процессораПроверить
+
+
+ +
+
01

Состояние сети

WAN, нагрузка, память, интерфейсы и действительно активные клиенты в одном понятном обзоре.

+
02

Безопасный LuCI

Доступ создаётся по запросу, ограничен по времени и привязан к конкретному роутеру.

+
03

Свои устройства

Каждый пользователь видит только добавленные ему роутеры и управляет собственным парком.

+
+ +
+
Безопасность по умолчанию

Панель управления не должна становиться новой точкой входа

+
  • Пароли защищены Argon2id
  • Сессии хранятся в HttpOnly cookie
  • Важные действия попадают в аудит
+
+ + + +
+ + diff --git a/web/styles.css b/web/styles.css index cb1023d..79fc36b 100644 --- a/web/styles.css +++ b/web/styles.css @@ -205,6 +205,24 @@ .landing-login-section > div p { max-width: 500px; color: var(--muted); line-height: 1.6; } .landing-login-section .login-form { width: 100%; } .landing-footer { display: flex; justify-content: space-between; border-top: 1px solid var(--line); color: var(--muted); padding-block: 26px; } +.landing-cta .landing-actions { justify-content: flex-end; margin-top: 0; } + +.account-page .landing-view { + display: grid; + grid-template-rows: auto 1fr auto; +} + +.account-page .landing-hero, +.account-page .landing-features, +.account-page .landing-security { + display: none; +} + +.account-page .landing-login-section { + width: min(920px, calc(100% - 40px)); + min-height: calc(100vh - 150px); + padding-block: 48px; +} .profile-dialog { max-height: min(820px, calc(100dvh - 32px)); overflow-y: auto; } .profile-form { display: grid; gap: 12px; border-top: 1px solid var(--line); padding-top: 18px; } @@ -260,6 +278,10 @@ .profile-security-form, .profile-session-actions { grid-template-columns: 1fr; } .profile-security-form > * { grid-column: 1 !important; } + .account-page .landing-login-section { min-height: calc(100vh - 130px); padding-block: 28px; } + .landing-cta .landing-actions { justify-content: stretch; } + .live-state span { display: none; } + .live-state { width: 30px; justify-content: center; padding: 0; } } * { @@ -493,6 +515,34 @@ select { font-size: 14px; } +.live-state { + display: inline-flex; + min-height: 30px; + gap: 7px; + align-items: center; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font-size: 12px; + padding: 0 10px; + white-space: nowrap; +} + +.live-state i { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--warn); +} + +.live-state.is-live i { + background: var(--good); + box-shadow: 0 0 0 4px rgb(115 200 62 / 10%); +} + +.live-state.is-polling i { background: var(--warn); } +.live-state.is-offline i { background: var(--bad); } + .topbar-actions { display: flex; align-items: center; @@ -3243,6 +3293,48 @@ select:focus-visible, } } +@media (min-width: 721px) and (max-width: 1150px) { + .fleet-table-shell { + overflow: visible; + border: 0; + background: transparent; + } + + .fleet-table-head { display: none; } + .device-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } + .device-item { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 11px 14px; + min-width: 0; + min-height: auto; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--surface-raised); + padding: 15px; + } + .device-item > .fleet-status, + .device-item > .device-identity, + .device-item > .device-context { grid-column: 1 / -1; } + .device-item > .problem-count::before, + .device-item > .last-contact::before { + display: block; + margin-bottom: 4px; + color: var(--muted); + font-size: 10px; + font-weight: 750; + letter-spacing: .04em; + text-transform: uppercase; + } + .device-item > .problem-count::before { content: "Проблемы"; } + .device-item > .last-contact::before { content: "Последняя связь"; } + .device-item > .last-contact { display: block; } + .row-chevron { display: none; } +} + +@media (min-width: 721px) and (max-width: 900px) { + .device-list { grid-template-columns: minmax(0, 1fr); } +} + @media (max-width: 720px) { .device-tabs { display: flex;