diff --git a/agent/README.md b/agent/README.md index 116a48d..479d231 100644 --- a/agent/README.md +++ b/agent/README.md @@ -48,7 +48,7 @@ The Go agent lives at: agent/go/cmd/rmm-agent ``` -It is protocol-compatible with the server for enrollment, heartbeat, inventory, metrics, command polling, command result reporting, lock handling, backoff, graceful shutdown, and result spooling. Its inventory payload includes system metadata, interfaces, routes, WAN IP, DHCP leases, Wi-Fi clients, memory, disk, interface counters, package manager metadata, and connectivity checks. +It is protocol-compatible with the server for enrollment, heartbeat, inventory, metrics, command polling, command result reporting, lock handling, backoff, graceful shutdown, and result spooling. Its inventory payload includes system metadata, interfaces, routes, WAN IP, DHCP leases, kernel neighbor state, Wi-Fi clients, memory, disk, interface counters, package manager metadata, and connectivity checks. The UI uses live Wi-Fi and neighbor state to distinguish connected clients from static or infinite DHCP leases. The Go agent is available as the production OpenWrt package and supports the migrated command allowlist: diff --git a/agent/go/cmd/rmm-agent/main.go b/agent/go/cmd/rmm-agent/main.go index 088d998..cb4fd3e 100644 --- a/agent/go/cmd/rmm-agent/main.go +++ b/agent/go/cmd/rmm-agent/main.go @@ -301,6 +301,7 @@ func buildInventory(cfg config) map[string]any { "default_route": firstLine(commandOutput("ip", "route", "show", "default")), "wan_ip": wanIP(), "dhcp_leases": dhcpLeases(), + "neighbors": neighbors(), "wifi_clients": wifiClients(), } } @@ -1357,6 +1358,37 @@ func dhcpLeases() []map[string]string { return leases } +func neighbors() []map[string]string { + return parseIPNeighbors(commandOutput("ip", "neigh", "show")) +} + +func parseIPNeighbors(output string) []map[string]string { + result := make([]map[string]string, 0) + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + neighbor := map[string]string{ + "ip": fields[0], + "state": strings.ToUpper(fields[len(fields)-1]), + } + for index := 1; index+1 < len(fields); index++ { + switch fields[index] { + case "dev": + neighbor["interface"] = fields[index+1] + case "lladdr": + neighbor["mac"] = strings.ToUpper(fields[index+1]) + } + } + if neighbor["interface"] == "" { + continue + } + result = append(result, neighbor) + } + return result +} + func wifiClients() []map[string]string { if _, err := exec.LookPath("iwinfo"); err != nil { return []map[string]string{} diff --git a/agent/go/cmd/rmm-agent/main_test.go b/agent/go/cmd/rmm-agent/main_test.go index 159366b..79291ad 100644 --- a/agent/go/cmd/rmm-agent/main_test.go +++ b/agent/go/cmd/rmm-agent/main_test.go @@ -78,6 +78,26 @@ func TestLooksLikeMAC(t *testing.T) { } } +func TestParseIPNeighbors(t *testing.T) { + output := `10.10.10.2 dev br-lan lladdr 10:ff:e0:21:bc:b9 REACHABLE +10.10.10.190 dev br-lan FAILED +fe80::1234 dev br-lan lladdr aa:bb:cc:dd:ee:ff router STALE` + + neighbors := parseIPNeighbors(output) + if len(neighbors) != 3 { + t.Fatalf("expected 3 neighbors, got %d", len(neighbors)) + } + if neighbors[0]["mac"] != "10:FF:E0:21:BC:B9" || neighbors[0]["state"] != "REACHABLE" { + t.Fatalf("unexpected reachable neighbor: %#v", neighbors[0]) + } + if neighbors[1]["ip"] != "10.10.10.190" || neighbors[1]["state"] != "FAILED" { + t.Fatalf("unexpected failed neighbor: %#v", neighbors[1]) + } + if neighbors[2]["interface"] != "br-lan" || neighbors[2]["state"] != "STALE" { + t.Fatalf("unexpected stale neighbor: %#v", neighbors[2]) + } +} + func TestParsePacketLossBusyBox(t *testing.T) { output := `PING 10.10.10.10 (10.10.10.10): 56 data bytes 64 bytes from 10.10.10.10: seq=0 ttl=64 time=1.157 ms diff --git a/server/README.md b/server/README.md index d410851..554f715 100644 --- a/server/README.md +++ b/server/README.md @@ -64,4 +64,6 @@ Authorization: Bearer ``` See [KeenDNS-like cloud mode](../docs/keendns.md) for wildcard DNS, wildcard TLS, and the -isolated LuCI access flow. +isolated LuCI access flow. Browser requests to an unavailable LuCI route receive a safe HTML +status page while API clients continue to receive JSON. The reverse proxy waits up to 15 seconds +for LuCI response headers before returning `504 Gateway Timeout`. diff --git a/server/internal/httpapi/luci_access.go b/server/internal/httpapi/luci_access.go index 08b9042..a1b0b7c 100644 --- a/server/internal/httpapi/luci_access.go +++ b/server/internal/httpapi/luci_access.go @@ -1,6 +1,8 @@ package httpapi import ( + "fmt" + "html/template" "net/http" "strings" "time" @@ -13,6 +15,118 @@ const ( deviceAccessSessionTTL = 2 * time.Hour ) +type luciErrorPageData struct { + Code string + Title string + Description string + ActionLabel string + ActionURL string + ControlURL string + RequestID string +} + +var luciErrorPageTemplate = template.Must(template.New("luci-error").Parse(` + + + + + {{.Title}} · OpenWrt RMM + + + +
+
R OpenWrt RMM
+
RMM защищённый туннель LuCI
+
{{.Code}}
+

{{.Title}}

+

{{.Description}}

+
Настройки роутера не изменялись. Можно безопасно вернуться в RMM и проверить состояние агента или запустить диагностику.
+ + {{if .RequestID}}
ID запроса: {{.RequestID}}
{{end}} +
+ +`)) + +func luciErrorCopy(status int) (title, description, action string) { + switch status { + case http.StatusUnauthorized: + return "Срок доступа истёк", "Временная ссылка или сессия LuCI больше не действует. Создайте новый безопасный доступ из RMM.", "Вернуться в RMM" + case http.StatusForbidden: + return "Доступ отклонён", "У вашей учётной записи нет разрешения на этот запрос к LuCI.", "Вернуться в RMM" + case http.StatusNotFound: + return "Сессия больше не активна", "Удалённая сессия закрыта или роутер ещё не подтвердил её запуск.", "Вернуться в RMM" + case http.StatusConflict: + return "Туннель ещё запускается", "Роутер получил команду, но защищённый канал пока не готов. Обычно это занимает несколько секунд.", "Проверить ещё раз" + case http.StatusTooManyRequests: + return "Слишком много запросов", "Лимит временных ссылок достигнут. Подождите минуту и повторите попытку.", "Вернуться в RMM" + case http.StatusBadGateway: + return "LuCI недоступен", "Туннель работает, но веб-интерфейс роутера не отвечает.", "Повторить подключение" + case http.StatusGatewayTimeout: + return "LuCI не отвечает", "Роутер на связи, но веб-интерфейс не ответил вовремя.", "Повторить подключение" + default: + return "Не удалось открыть LuCI", "На сервере произошла ошибка при подготовке удалённого доступа.", "Вернуться в RMM" + } +} + +func (a *App) writeLuCIError(w http.ResponseWriter, r *http.Request, status int, message string) { + if !strings.Contains(strings.ToLower(r.Header.Get("Accept")), "text/html") { + writeError(w, status, message) + return + } + title, description, actionLabel := luciErrorCopy(status) + controlURL := "/" + if a.deviceDomain != "" { + controlURL = a.publicScheme + "://" + a.deviceDomain + "/" + } + actionURL := controlURL + if status == http.StatusBadGateway || status == http.StatusGatewayTimeout || status == http.StatusConflict { + actionURL = r.URL.Path + if actionURL == "" { + actionURL = "/" + } + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Language", "ru") + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(status) + if err := luciErrorPageTemplate.Execute(w, luciErrorPageData{ + Code: fmt.Sprintf("%d · %s", status, strings.ToUpper(http.StatusText(status))), + Title: title, + Description: description, + ActionLabel: actionLabel, + ActionURL: actionURL, + ControlURL: controlURL, + RequestID: requestID(r.Context()), + }); err != nil { + // Headers are already sent; keep the failure observable without exposing internals to the browser. + logStructured(map[string]any{"event": "luci.error_page_failed", "request_id": requestID(r.Context()), "error": err.Error()}) + } +} + func (a *App) handleCreateDeviceAccess(w http.ResponseWriter, r *http.Request) { if a.deviceDomain == "" { writeError(w, http.StatusConflict, "device domain is not configured") @@ -99,20 +213,20 @@ func (a *App) handleDeviceHost(w http.ResponseWriter, r *http.Request, dnsLabel } cookie, err := r.Cookie(deviceAccessCookie) if err != nil || cookie.Value == "" { - writeError(w, http.StatusUnauthorized, "LuCI access grant is required") + a.writeLuCIError(w, r, http.StatusUnauthorized, "LuCI access grant is required") return } route, found, err := a.store.AuthorizeDeviceAccessSession(r.Context(), store.TokenHash(cookie.Value), dnsLabel) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to authorize LuCI access") + a.writeLuCIError(w, r, http.StatusInternalServerError, "failed to authorize LuCI access") return } if !found { - writeError(w, http.StatusUnauthorized, "LuCI access session has expired") + a.writeLuCIError(w, r, http.StatusUnauthorized, "LuCI access session has expired") return } if unsafeMethod(r.Method) && !sameOrigin(r) { - writeError(w, http.StatusForbidden, "cross-origin request rejected") + a.writeLuCIError(w, r, http.StatusForbidden, "cross-origin request rejected") return } a.proxyLuCIWithPrefix(w, r, route.DeviceID, route.RemoteSessionID, r.URL.Path, "") @@ -120,26 +234,26 @@ func (a *App) handleDeviceHost(w http.ResponseWriter, r *http.Request, dnsLabel func (a *App) consumeDeviceAccess(w http.ResponseWriter, r *http.Request, dnsLabel string) { if r.Method != http.MethodGet { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") + a.writeLuCIError(w, r, http.StatusMethodNotAllowed, "method not allowed") return } rawGrant := strings.TrimSpace(r.URL.Query().Get("token")) if rawGrant == "" { - writeError(w, http.StatusBadRequest, "access token is required") + a.writeLuCIError(w, r, http.StatusBadRequest, "access token is required") return } rawSession, err := randomToken(32) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to create LuCI access session") + a.writeLuCIError(w, r, http.StatusInternalServerError, "failed to create LuCI access session") return } route, found, err := a.store.ConsumeDeviceAccessGrant(r.Context(), store.TokenHash(rawGrant), store.TokenHash(rawSession), dnsLabel, time.Now().UTC().Add(deviceAccessSessionTTL)) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to consume LuCI access grant") + a.writeLuCIError(w, r, http.StatusInternalServerError, "failed to consume LuCI access grant") return } if !found || route.DNSLabel != dnsLabel { - writeError(w, http.StatusUnauthorized, "invalid or expired LuCI access grant") + a.writeLuCIError(w, r, http.StatusUnauthorized, "invalid or expired LuCI access grant") return } http.SetCookie(w, &http.Cookie{ diff --git a/server/internal/httpapi/luci_access_test.go b/server/internal/httpapi/luci_access_test.go new file mode 100644 index 0000000..723c791 --- /dev/null +++ b/server/internal/httpapi/luci_access_test.go @@ -0,0 +1,56 @@ +package httpapi + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestWriteLuCIErrorRendersSafeBrowserPage(t *testing.T) { + app := &App{deviceDomain: "rmm.example", publicScheme: "https"} + req := httptest.NewRequest(http.MethodGet, "https://router.rmm.example/cgi-bin/luci/?token=must-not-leak", nil) + req.Header.Set("Accept", "text/html,application/xhtml+xml") + req = req.WithContext(context.WithValue(req.Context(), requestIDContextKey, "req-test-123")) + recorder := httptest.NewRecorder() + + app.writeLuCIError(recorder, req, http.StatusGatewayTimeout, "internal proxy details") + + if recorder.Code != http.StatusGatewayTimeout { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusGatewayTimeout) + } + if got := recorder.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") { + t.Fatalf("content type = %q, want HTML", got) + } + body := recorder.Body.String() + for _, expected := range []string{"LuCI не отвечает", "req-test-123", `href="/cgi-bin/luci/"`, `href="https://rmm.example/"`} { + if !strings.Contains(body, expected) { + t.Errorf("response does not contain %q", expected) + } + } + for _, unexpected := range []string{"must-not-leak", "internal proxy details"} { + if strings.Contains(body, unexpected) { + t.Errorf("response leaked %q", unexpected) + } + } +} + +func TestWriteLuCIErrorKeepsJSONForAPIClients(t *testing.T) { + app := &App{deviceDomain: "rmm.example", publicScheme: "https"} + req := httptest.NewRequest(http.MethodPost, "/api/devices/device-1/access", nil) + req.Header.Set("Accept", "application/json") + recorder := httptest.NewRecorder() + + app.writeLuCIError(recorder, req, http.StatusConflict, "LuCI session is not active") + + if recorder.Code != http.StatusConflict { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusConflict) + } + if got := recorder.Header().Get("Content-Type"); got != "application/json" { + t.Fatalf("content type = %q, want application/json", got) + } + if !strings.Contains(recorder.Body.String(), `"error":"LuCI session is not active"`) { + t.Fatalf("unexpected JSON response: %s", recorder.Body.String()) + } +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 65a3685..847c1fb 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -1351,11 +1351,11 @@ func (a *App) proxyLuCI(w http.ResponseWriter, r *http.Request, deviceID, sessio func (a *App) proxyLuCIWithPrefix(w http.ResponseWriter, r *http.Request, deviceID, sessionID, upstreamPath, prefix string) { session, found, err := a.store.GetRemoteSession(r.Context(), deviceID, sessionID) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to load LuCI session") + a.writeLuCIError(w, r, http.StatusInternalServerError, "failed to load LuCI session") return } if !found || session.Status != "active" || session.LuCIPort <= 0 { - writeError(w, http.StatusNotFound, "LuCI session is not active") + a.writeLuCIError(w, r, http.StatusNotFound, "LuCI session is not active") return } @@ -1365,11 +1365,12 @@ func (a *App) proxyLuCIWithPrefix(w http.ResponseWriter, r *http.Request, device } upstream, _ := url.Parse(scheme + "://" + a.tunnelHTTPHost + ":" + strconv.Itoa(session.LuCIPort)) proxy := httputil.NewSingleHostReverseProxy(upstream) + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.ResponseHeaderTimeout = 15 * time.Second if scheme == "https" { - transport := http.DefaultTransport.(*http.Transport).Clone() transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // LuCI commonly uses a router-local self-signed certificate. - proxy.Transport = transport } + proxy.Transport = transport originalDirector := proxy.Director proxy.Director = func(req *http.Request) { originalDirector(req) @@ -1407,7 +1408,7 @@ func (a *App) proxyLuCIWithPrefix(w http.ResponseWriter, r *http.Request, device rewriteLuCIHeaders(resp.Header, prefix, a.cookieSecure) return nil } - proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) { + proxy.ErrorHandler = func(w http.ResponseWriter, proxyRequest *http.Request, err error) { logStructured(map[string]any{ "event": "luci.proxy_failed", "request_id": requestID(r.Context()), @@ -1415,7 +1416,11 @@ func (a *App) proxyLuCIWithPrefix(w http.ResponseWriter, r *http.Request, device "session_id": sessionID, "error": err.Error(), }) - writeError(w, http.StatusBadGateway, "LuCI is unreachable through this session") + status := http.StatusBadGateway + if errors.Is(err, context.DeadlineExceeded) || os.IsTimeout(err) { + status = http.StatusGatewayTimeout + } + a.writeLuCIError(w, proxyRequest, status, "LuCI is unreachable through this session") } proxy.ServeHTTP(w, r) } diff --git a/web/README.md b/web/README.md index 0acc237..1b6e259 100644 --- a/web/README.md +++ b/web/README.md @@ -27,7 +27,11 @@ Current MVP features: - UCI backup, preview, staged set, commit, confirmed commit, revert, and restore; - UCI presets for LAN IP, hostname, Wi-Fi SSID/password, and LAN DHCP toggle. - isolated LuCI access through one-time device-domain links; -- per-user router enrollment and administrator-created accounts. +- 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; +- friendly LuCI access states for expired, unavailable, and timed-out sessions; +- separate online, recently seen, and DHCP-only client presence states. ## Check diff --git a/web/app.js b/web/app.js index 461facf..0e537ca 100644 --- a/web/app.js +++ b/web/app.js @@ -23,6 +23,9 @@ const state = { remoteSessions: [], selectedCommand: null, presetReview: null, + luciAction: "setup", + mobileRoute: "fleet", + previousMobileRoute: "fleet", }; const EXPECTED_AGENT_VERSION = "0.5.0"; @@ -35,6 +38,10 @@ const els = { loginError: document.querySelector("#loginError"), appShell: document.querySelector("#appShell"), operatorName: document.querySelector("#operatorName"), + fleetNavBtn: document.querySelector("#fleetNavBtn"), + problemsNavBtn: document.querySelector("#problemsNavBtn"), + operationsNavBtn: document.querySelector("#operationsNavBtn"), + profileBtn: document.querySelector("#profileBtn"), logoutBtn: document.querySelector("#logoutBtn"), apiState: document.querySelector("#apiState"), refreshBtn: document.querySelector("#refreshBtn"), @@ -103,6 +110,8 @@ const els = { fleetSearch: document.querySelector("#fleetSearch"), fleetGroupFilter: document.querySelector("#fleetGroupFilter"), fleetTagFilter: document.querySelector("#fleetTagFilter"), + fleetFilterToggle: document.querySelector("#fleetFilterToggle"), + fleetAdvancedFilters: document.querySelector("#fleetAdvancedFilters"), bulkCommandType: document.querySelector("#bulkCommandType"), bulkCommandTarget: document.querySelector("#bulkCommandTarget"), sendBulkCommandBtn: document.querySelector("#sendBulkCommandBtn"), @@ -170,6 +179,21 @@ const els = { clearAuditBtn: document.querySelector("#clearAuditBtn"), deleteDeviceBtn: document.querySelector("#deleteDeviceBtn"), toastRegion: document.querySelector("#toastRegion"), + profileDialog: document.querySelector("#profileDialog"), + closeProfileBtn: document.querySelector("#closeProfileBtn"), + profileUsername: document.querySelector("#profileUsername"), + profileRole: document.querySelector("#profileRole"), + profileAvatar: document.querySelector("#profileAvatar"), + profileLogoutBtn: document.querySelector("#profileLogoutBtn"), + luciStateDialog: document.querySelector("#luciStateDialog"), + luciStateCode: document.querySelector("#luciStateCode"), + luciStateTitle: document.querySelector("#luciStateTitle"), + luciStateDescription: document.querySelector("#luciStateDescription"), + luciStateContext: document.querySelector("#luciStateContext"), + luciStatePrimaryBtn: document.querySelector("#luciStatePrimaryBtn"), + luciStateDiagnosticBtn: document.querySelector("#luciStateDiagnosticBtn"), + closeLuciStateBtn: document.querySelector("#closeLuciStateBtn"), + luciStateRequestId: document.querySelector("#luciStateRequestId"), }; if (els.remoteServerHost) { @@ -247,7 +271,10 @@ async function api(path, options = {}) { } const message = data && data.error ? data.error : `HTTP ${response.status}`; const suffix = requestId ? ` (${requestId})` : ""; - throw new Error(`${message}${suffix}`); + const error = new Error(`${message}${suffix}`); + error.status = response.status; + error.requestId = requestId || ""; + throw error; } return data; } @@ -263,6 +290,11 @@ function showApp(user) { state.user = user || null; state.username = user && user.username ? user.username : "operator"; els.operatorName.textContent = state.username; + els.profileUsername.textContent = state.username; + els.profileRole.textContent = user && user.role === "admin" ? "Администратор" : "Пользователь"; + const initial = state.username.trim().charAt(0).toUpperCase() || "О"; + els.profileAvatar.textContent = initial; + document.querySelector(".operator-avatar").textContent = initial; els.addUserBtn.classList.toggle("is-hidden", !user || user.role !== "admin"); els.loginView.classList.add("is-hidden"); els.appShell.classList.remove("is-hidden"); @@ -625,7 +657,7 @@ function renderDevices() { ${escapeHtml(wan.label)}
- Клиенты + Известно клиентов ${clients.total}
@@ -640,6 +672,7 @@ function renderDevices() { } function renderDeviceDetail(device) { + els.appShell.classList.toggle("has-selected-device", Boolean(device)); if (!device) { els.fleetView.classList.remove("is-hidden"); els.deviceView.classList.add("is-hidden"); @@ -654,7 +687,7 @@ function renderDeviceDetail(device) { els.pageTitle.textContent = displayName; els.deviceName.textContent = displayName; els.deviceMeta.textContent = `${device.id} - ${device.openwrt_version || "unknown"}${device.domain_name ? ` - ${device.domain_name}` : ""}`; - els.deviceBadge.textContent = device.online ? "online" : "offline"; + els.deviceBadge.textContent = device.online ? "На связи" : "Не на связи"; els.deviceBadge.className = `badge ${device.online ? "online" : "offline"}`; els.lastSeen.textContent = formatDate(device.last_seen_at); els.loadAvg.textContent = device.metrics && device.metrics.loadavg ? device.metrics.loadavg : "-"; @@ -665,7 +698,7 @@ function renderDeviceDetail(device) { els.diskUsage.textContent = formatDisk(device.metrics && device.metrics.disk); const leaseCount = Array.isArray(device.inventory && device.inventory.dhcp_leases) ? device.inventory.dhcp_leases.length : 0; const wifiCount = Array.isArray(device.inventory && device.inventory.wifi_clients) ? device.inventory.wifi_clients.length : 0; - els.clientCounts.textContent = `${leaseCount} DHCP / ${wifiCount} Wi-Fi`; + els.clientCounts.textContent = `${leaseCount} DHCP-аренды / ${wifiCount} Wi-Fi онлайн`; els.connectivityStatus.textContent = formatConnectivity(device.metrics && device.metrics.connectivity_checks); els.fleetGroup.value = device.group || ""; els.fleetTags.value = Array.isArray(device.tags) ? device.tags.join(", ") : ""; @@ -764,17 +797,25 @@ function formatBytes(value) { function normalizedClients(device) { const leases = Array.isArray(device.inventory && device.inventory.dhcp_leases) ? device.inventory.dhcp_leases : []; const wifi = Array.isArray(device.inventory && device.inventory.wifi_clients) ? device.inventory.wifi_clients : []; + const neighbors = Array.isArray(device.inventory && device.inventory.neighbors) ? device.inventory.neighbors : []; + const neighborsByMAC = new Map(neighbors.filter((neighbor) => neighbor.mac).map((neighbor) => [String(neighbor.mac).toLowerCase(), neighbor])); + const neighborsByIP = new Map(neighbors.filter((neighbor) => neighbor.ip).map((neighbor) => [String(neighbor.ip), neighbor])); + const activeNeighborStates = new Set(["REACHABLE", "DELAY", "PROBE", "PERMANENT", "NOARP"]); const byMac = new Map(); for (const lease of leases) { const mac = String(lease.mac || "").toLowerCase(); const key = mac || `ip:${lease.ip || Math.random()}`; + const neighbor = neighborsByMAC.get(mac) || neighborsByIP.get(String(lease.ip || "")); + const neighborState = String(neighbor && neighbor.state || "").toUpperCase(); + const presence = activeNeighborStates.has(neighborState) ? "online" : (neighborState === "STALE" ? "recent" : "reserved"); byMac.set(key, { name: lease.hostname && lease.hostname !== "*" ? lease.hostname : "", ip: lease.ip || "-", mac: lease.mac || "-", connection: "Проводное / DHCP", type: "wired", - online: true, + online: presence === "online", + presence, }); } for (const station of wifi) { @@ -790,6 +831,7 @@ function normalizedClients(device) { rate: [station.rx_rate ? `RX ${station.rx_rate}` : "", station.tx_rate ? `TX ${station.tx_rate}` : ""].filter(Boolean).join(" / "), type: "wifi", online: true, + presence: "online", }); } return [...byMac.values()]; @@ -810,6 +852,8 @@ function renderClients(device) { return; } for (const client of filtered) { + const presence = client.presence || (client.online ? "online" : "reserved"); + const presenceLabel = presence === "online" ? "В сети" : (presence === "recent" ? "Недавно" : "DHCP-аренда"); const row = document.createElement("div"); row.className = "client-row"; row.innerHTML = ` @@ -818,7 +862,7 @@ function renderClients(device) { ${escapeHtml(client.mac)} ${escapeHtml(client.connection)} ${escapeHtml([client.signal, client.rate].filter(Boolean).join(" · ") || "-")} - В сети + ${presenceLabel} `; els.clientList.appendChild(row); } @@ -977,6 +1021,8 @@ async function selectDevice(id) { state.auditEvents = []; renderDevices(); renderDeviceDetail(currentDevice()); + setMobileRoute("fleet"); + window.scrollTo({ top: 0, behavior: "auto" }); await Promise.all([loadCommands(), loadAudit(), loadMetricsHistory(), loadAlerts(), loadRemoteSessions()]); } @@ -990,11 +1036,14 @@ function showFleet() { state.auditEvents = []; renderDevices(); renderDeviceDetail(null); + setMobileRoute("fleet"); + window.scrollTo({ top: 0, behavior: "auto" }); } function selectDeviceTab(tab) { state.deviceTab = tab; renderDeviceTab(); + if (tab === "operations") setMobileRoute("operations"); } function renderDeviceTab() { @@ -1020,21 +1069,118 @@ async function openLuCIAccess(session) { } } catch (error) { if (popup) popup.close(); - throw error; + showLuCIState(error, "retry"); } } function openLuciOrRemoteAccess() { const activeSession = state.remoteSessions.find((session) => session.status === "active" && session.luci_port); if (activeSession) { - openLuCIAccess(activeSession).catch(reportError); + openLuCIAccess(activeSession); return; } - selectDeviceTab("operations"); - els.remoteAccessPanel.scrollIntoView({ behavior: "smooth", block: "start" }); - setStatus("Создайте удаленную сессию, затем откройте LuCI"); + showLuCIState({ status: 409 }, "setup"); } +function luciStateCopy(status, mode) { + if (mode === "setup") { + return { + code: "409 · SESSION REQUIRED", + title: "Удалённый доступ не запущен", + description: "Создайте временную сессию, чтобы безопасно открыть LuCI этого роутера.", + action: "Настроить доступ", + }; + } + return { + 401: { code: "401 · SESSION EXPIRED", title: "Срок доступа истёк", description: "Временная ссылка больше не действует. Создайте новый безопасный доступ из RMM.", action: "Создать новый доступ" }, + 403: { code: "403 · ACCESS DENIED", title: "Доступ отклонён", description: "У вашей учётной записи нет разрешения на эту сессию LuCI.", action: "Вернуться к настройке" }, + 404: { code: "404 · SESSION NOT FOUND", title: "Сессия больше не активна", description: "Удалённая сессия закрыта или роутер ещё не подтвердил её запуск.", action: "Настроить доступ" }, + 409: { code: "409 · SESSION STARTING", title: "Туннель ещё запускается", description: "Роутер получил команду, но защищённый канал пока не готов. Обычно это занимает несколько секунд.", action: "Повторить подключение" }, + 429: { code: "429 · TOO MANY ATTEMPTS", title: "Слишком много запросов", description: "Лимит временных ссылок достигнут. Подождите минуту и повторите попытку.", action: "Повторить позже" }, + 502: { code: "502 · LUCI UNREACHABLE", title: "LuCI недоступен", description: "Туннель работает, но веб-интерфейс роутера не отвечает. Настройки роутера не изменялись.", action: "Повторить подключение" }, + 504: { code: "504 · GATEWAY TIMEOUT", title: "LuCI не отвечает", description: "Роутер на связи, но веб-интерфейс не ответил вовремя. Настройки роутера не изменялись.", action: "Повторить подключение" }, + }[status] || { code: `${status || 500} · CONNECTION ERROR`, title: "Не удалось открыть LuCI", description: "Проверьте состояние роутера и удалённой сессии, затем повторите попытку.", action: "Повторить подключение" }; +} + +function showLuCIState(error = {}, mode = "retry") { + const status = Number(error.status || 0); + const copy = luciStateCopy(status, mode); + const device = currentDevice(); + const activeSession = state.remoteSessions.find((session) => session.status === "active" && session.luci_port); + state.luciAction = mode; + els.luciStateCode.textContent = copy.code; + els.luciStateTitle.textContent = copy.title; + els.luciStateDescription.textContent = copy.description; + els.luciStatePrimaryBtn.textContent = copy.action; + els.luciStateContext.innerHTML = ` +
Объект${escapeHtml(deviceDisplayName(device))}
+
RMM-агент${device && device.online ? "На связи" : "Не на связи"}
+
Сессия${activeSession ? escapeHtml(statusLabel(activeSession.status)) : "Не запущена"}
+ `; + els.luciStateRequestId.textContent = error.requestId ? `ID запроса: ${error.requestId}` : ""; + if (!els.luciStateDialog.open) els.luciStateDialog.showModal(); +} + +function setMobileRoute(route) { + state.mobileRoute = route; + for (const button of document.querySelectorAll(".mobile-nav-item")) { + button.classList.toggle("is-active", button.dataset.mobileRoute === route); + } + els.fleetNavBtn.classList.toggle("is-active", route === "fleet"); + els.problemsNavBtn.classList.toggle("is-active", route === "problems"); + els.operationsNavBtn.classList.toggle("is-active", route === "operations"); +} + +async function openDeviceArea(tab, selector, route) { + let device = currentDevice(); + if (!device) { + device = route === "problems" + ? state.devices.find((item) => Number(item.active_alerts || 0) > 0) || state.devices[0] + : state.devices[0]; + } + if (!device) { + notify("Сначала добавьте роутер", "info"); + return; + } + if (device.id !== state.selectedDeviceId) await selectDevice(device.id); + selectDeviceTab(tab); + setMobileRoute(route); + window.requestAnimationFrame(() => document.querySelector(selector)?.scrollIntoView({ behavior: "smooth", block: "start" })); +} + +function showProfile() { + state.previousMobileRoute = state.mobileRoute === "profile" ? "fleet" : state.mobileRoute; + setMobileRoute("profile"); + if (!els.profileDialog.open) els.profileDialog.showModal(); +} + +function closeProfile() { + if (els.profileDialog.open) els.profileDialog.close(); + setMobileRoute(state.previousMobileRoute || "fleet"); +} + +async function runLuCIPrimaryAction() { + const activeSession = state.remoteSessions.find((session) => session.status === "active" && session.luci_port); + if (els.luciStateDialog.open) els.luciStateDialog.close(); + if (state.luciAction === "retry" && activeSession) { + await openLuCIAccess(activeSession); + return; + } + await openDeviceArea("operations", "#remoteAccessPanel", "operations"); +} + +async function refreshDevicesIfIdle() { + if (refreshDevicesIfIdle.inFlight) return; + refreshDevicesIfIdle.inFlight = true; + try { + await loadDevices(); + } finally { + refreshDevicesIfIdle.inFlight = false; + } +} + +refreshDevicesIfIdle.inFlight = false; + async function loadCommands(options = {}) { if (!state.selectedDeviceId) return; const append = Boolean(options.append); @@ -1723,6 +1869,47 @@ els.loginForm.addEventListener("submit", (event) => { login().catch((error) => showLogin(error.message)); }); els.logoutBtn.addEventListener("click", () => logout().catch(reportError)); +els.fleetNavBtn.addEventListener("click", showFleet); +els.problemsNavBtn.addEventListener("click", () => openDeviceArea("overview", "#alertList", "problems").catch(reportError)); +els.operationsNavBtn.addEventListener("click", () => openDeviceArea("operations", "#remoteAccessPanel", "operations").catch(reportError)); +els.profileBtn.addEventListener("click", showProfile); + +for (const button of document.querySelectorAll(".mobile-nav-item")) { + button.addEventListener("click", () => { + const route = button.dataset.mobileRoute; + if (route === "fleet") showFleet(); + if (route === "problems") openDeviceArea("overview", "#alertList", "problems").catch(reportError); + if (route === "operations") openDeviceArea("operations", "#remoteAccessPanel", "operations").catch(reportError); + if (route === "profile") showProfile(); + }); +} + +els.closeProfileBtn.addEventListener("click", closeProfile); +els.profileDialog.addEventListener("cancel", (event) => { + event.preventDefault(); + closeProfile(); +}); +els.profileLogoutBtn.addEventListener("click", () => { + closeProfile(); + logout().catch(reportError); +}); + +els.closeLuciStateBtn.addEventListener("click", () => els.luciStateDialog.close()); +els.luciStatePrimaryBtn.addEventListener("click", () => runLuCIPrimaryAction().catch(reportError)); +els.luciStateDiagnosticBtn.addEventListener("click", () => { + els.luciStateDialog.close(); + openDeviceArea("operations", "#diagnosticStatus", "operations").catch(reportError); +}); +els.luciStateDialog.addEventListener("cancel", (event) => { + event.preventDefault(); + els.luciStateDialog.close(); +}); + +els.fleetFilterToggle.addEventListener("click", () => { + const isOpen = els.fleetAdvancedFilters.classList.toggle("is-open"); + els.fleetFilterToggle.setAttribute("aria-expanded", String(isOpen)); + els.fleetFilterToggle.textContent = isOpen ? "Скрыть" : "Фильтры"; +}); els.refreshBtn.addEventListener("click", () => loadDevices().catch(reportError)); els.addRouterBtn.addEventListener("click", () => createEnrollmentGrant().catch(reportError)); @@ -1859,6 +2046,13 @@ checkSession(); setInterval(() => { checkHealth(); if (!els.appShell.classList.contains("is-hidden")) { - loadDevices().catch(reportError); + refreshDevicesIfIdle().catch(reportError); } }, 30000); + +document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible" && !els.appShell.classList.contains("is-hidden")) { + checkHealth(); + refreshDevicesIfIdle().catch(reportError); + } +}); diff --git a/web/index.html b/web/index.html index e0f2088..c9cfab8 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ OpenWrt RMM - +
@@ -13,18 +13,18 @@
R

OpenWrt RMM

-

Operator access

+

Управление вашими роутерами

- +
@@ -40,16 +40,16 @@