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
-
+
-
+
@@ -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
+
Следите за состоянием сети, клиентами и обновлениями агента. Открывайте LuCI через временный защищённый доступ без публикации панели роутера в интернете.
+
+
+ ● Живые данные
+ ✓ Изоляция аккаунтов
+ ↗ Временные LuCI-сессии
+
+
+
+
+
fleet / overview
+
+
На связи 12
+
Требуют внимания 2
+
Средний uptime 18 дн.
+
+
Домашняя сеть 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;