diff --git a/cmd/server/api.go b/cmd/server/api.go index ebeb7e4..f9d7dc4 100644 --- a/cmd/server/api.go +++ b/cmd/server/api.go @@ -4247,13 +4247,13 @@ func (a *API) BatchConfig(w http.ResponseWriter, r *http.Request) { switch key { case "ssid": v, ok := value.(string) - if !ok || strings.TrimSpace(v) == "" || len([]byte(v)) > 64 { + if !ok || strings.TrimSpace(v) == "" || len([]byte(v)) > 32 { http.Error(w, "invalid ssid", http.StatusBadRequest) return } case "channel": v, ok := value.(float64) - if !ok || math.IsNaN(v) || math.IsInf(v, 0) || v <= 0 { + if !ok || math.IsNaN(v) || math.IsInf(v, 0) || v <= 0 || math.Trunc(v) != v { http.Error(w, "invalid channel", http.StatusBadRequest) return } @@ -4293,7 +4293,7 @@ func (a *API) BatchConfig(w http.ResponseWriter, r *http.Request) { results = append(results, map[string]any{"device_id": deviceID, "status": "failed", "error": "not found"}) continue } - if err := a.Firmware.ApplyConfig(deviceID, ip, username, password, req.Changes); err != nil { + if err := a.Firmware.ApplyConfigContext(r.Context(), deviceID, ip, username, password, req.Changes); err != nil { results = append(results, map[string]any{"device_id": deviceID, "status": "failed", "error": err.Error()}) continue } diff --git a/internal/firmware/service.go b/internal/firmware/service.go index 0bd383f..f22c294 100644 --- a/internal/firmware/service.go +++ b/internal/firmware/service.go @@ -1255,9 +1255,13 @@ func (s *Service) doUpgradeAirMAX(ctx context.Context, deviceID int64, ip, usern return nil } -// login authenticates to a Wave device and returns the auth token -// host should be just the IP/hostname, not a full URL +// login authenticates to a Wave device and returns the auth token. func (s *Service) login(host, username, password string) (string, error) { + return s.loginContext(context.Background(), host, username, password) +} + +// loginContext is the cancellation-aware form used by long-running callers. +func (s *Service) loginContext(ctx context.Context, host, username, password string) (string, error) { baseURL := fmt.Sprintf("https://%s", host) loginURL := baseURL + "/api/v1.0/user/login" @@ -1266,7 +1270,7 @@ func (s *Service) login(host, username, password string) (string, error) { "password": password, }) - req, err := http.NewRequest("POST", loginURL, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, "POST", loginURL, bytes.NewReader(body)) if err != nil { return "", fmt.Errorf("create login request: %w", err) } @@ -2282,8 +2286,13 @@ func (s *Service) PushConfig(ip, username, password string, config []byte) error return nil } -// ApplyConfig applies specific configuration changes to a device +// ApplyConfig applies specific configuration changes to a device. func (s *Service) ApplyConfig(deviceID int, ip, username, password string, changes map[string]any) error { + return s.ApplyConfigContext(context.Background(), deviceID, ip, username, password, changes) +} + +// ApplyConfigContext is the cancellation-aware form used by request/job callers. +func (s *Service) ApplyConfigContext(ctx context.Context, deviceID int, ip, username, password string, changes map[string]any) error { credential, err := s.resolveCredential(username, password, true) if err != nil { return err @@ -2291,7 +2300,7 @@ func (s *Service) ApplyConfig(deviceID int, ip, username, password string, chang username, password = credential.Username, credential.Password // Login to Wave device - token, err := s.login(ip, username, password) + token, err := s.loginContext(ctx, ip, username, password) if err != nil { return fmt.Errorf("login failed: %w", err) } @@ -2338,7 +2347,7 @@ func (s *Service) ApplyConfig(deviceID int, ip, username, password string, chang } url := fmt.Sprintf("https://%s/api/v1.0/system/config", ip) - req, err := http.NewRequest("PATCH", url, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, "PATCH", url, bytes.NewReader(body)) if err != nil { return fmt.Errorf("create request: %w", err) } @@ -2361,7 +2370,13 @@ func (s *Service) ApplyConfig(deviceID int, ip, username, password string, chang } if storedNewPassword != "" { - if _, err := s.db.Exec(`UPDATE devices SET username = $2, password = $3 WHERE id = $1`, deviceID, username, storedNewPassword); err != nil { + // The device has already accepted the new password at this point. Do not + // let a client disconnect cancel persistence of the credential we now + // need for future management access. Preserve context values, but bound + // this post-accept commit independently. + persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if _, err := s.db.ExecContext(persistCtx, `UPDATE devices SET username = $2, password = $3 WHERE id = $1`, deviceID, username, storedNewPassword); err != nil { return fmt.Errorf("configuration applied but new device credential could not be persisted: %w", err) } } diff --git a/web/css/styles.css b/web/css/styles.css index f13580c..d90ed6d 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -847,6 +847,7 @@ body.page-devices #app.main-content { .device-table .cell-site { width: 60px; min-width: 50px; } .device-table .cell-signal { width: 65px; min-width: 55px; text-align: center; } .device-table .cell-health { width: 60px; min-width: 50px; } +.device-table .cell-dir { width: 60px; min-width: 50px; } .device-table .cell-distance { width: 60px; min-width: 50px; } .device-table .cell-capacity { width: 75px; min-width: 65px; } .device-table .cell-firmware { width: 100px; min-width: 80px; font-size: 0.8rem; } @@ -7687,9 +7688,14 @@ html[data-theme="dark"] #mapContainer .leaflet-control-attribution a { overflow: hidden; } -/* Header table - fixed at top, not scrolled */ -.virtual-header-table { +/* Header stays fixed vertically while its viewport mirrors body scrollLeft. */ +.virtual-header-viewport { flex-shrink: 0; + width: 100%; + overflow: hidden; +} + +.virtual-header-table { width: 100%; table-layout: fixed; border-collapse: collapse; @@ -7705,8 +7711,7 @@ html[data-theme="dark"] #mapContainer .leaflet-control-attribution a { .virtual-scroll-container { flex: 1; min-height: 0; - overflow-y: auto; - overflow-x: hidden; + overflow: auto; position: relative; -webkit-overflow-scrolling: touch; } @@ -7723,7 +7728,6 @@ html[data-theme="dark"] #mapContainer .leaflet-control-attribution a { position: absolute; top: 0; left: 0; - right: 0; width: 100%; table-layout: fixed; border-collapse: collapse; @@ -7781,6 +7785,9 @@ html[data-theme="dark"] #mapContainer .leaflet-control-attribution a { .virtual-header-table .cell-health, .virtual-body-table .cell-health { width: 60px; min-width: 50px; max-width: 70px; } +.virtual-header-table .cell-dir, +.virtual-body-table .cell-dir { width: 60px; min-width: 50px; max-width: 70px; } + .virtual-header-table .cell-distance, .virtual-body-table .cell-distance { width: 60px; min-width: 50px; max-width: 70px; } @@ -9565,3 +9572,17 @@ body.drilldown-active .main-content { min-width: 760px; } } + + +.virtual-empty-state { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + align-items: flex-start; + justify-content: center; + pointer-events: none; +} +.virtual-empty-state.hidden { + display: none; +} diff --git a/web/index.html b/web/index.html index 419a22b..009a7a8 100644 --- a/web/index.html +++ b/web/index.html @@ -467,6 +467,6 @@