diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ee300d0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Go tests + run: go test ./... + + - name: Build server + run: go build ./cmd/server + + - name: JavaScript syntax + shell: bash + run: | + set -euo pipefail + for file in web/js/*.js; do + case "$file" in + *jszip.min.js) continue ;; + esac + node --input-type=module --check < "$file" + done diff --git a/cmd/server/api.go b/cmd/server/api.go index e79ab15..ebeb7e4 100644 --- a/cmd/server/api.go +++ b/cmd/server/api.go @@ -4214,14 +4214,14 @@ func sanitizeIPForPath(ip string) string { return strings.ReplaceAll(ip, ":", "-") } -// BatchConfig pushes configuration changes to multiple devices +// BatchConfig pushes configuration changes to multiple devices. func (a *API) BatchConfig(w http.ResponseWriter, r *http.Request) { if !a.requireEdit(w, r) { return } claims := getClaims(r) if claims == nil { - http.Error(w, "unauthorized", 401) + http.Error(w, "unauthorized", http.StatusUnauthorized) return } @@ -4229,15 +4229,63 @@ func (a *API) BatchConfig(w http.ResponseWriter, r *http.Request) { DeviceIDs []int `json:"device_ids"` Changes map[string]any `json:"changes"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request", 400) + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256<<10)) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + if len(req.DeviceIDs) == 0 || len(req.DeviceIDs) > 2500 { + http.Error(w, "device_ids must contain 1-2500 devices", http.StatusBadRequest) return } + if len(req.Changes) == 0 { + http.Error(w, "at least one configuration change is required", http.StatusBadRequest) + return + } + for key, value := range req.Changes { + switch key { + case "ssid": + v, ok := value.(string) + if !ok || strings.TrimSpace(v) == "" || len([]byte(v)) > 64 { + 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 { + http.Error(w, "invalid channel", http.StatusBadRequest) + return + } + case "tx_power": + v, ok := value.(float64) + if !ok || math.IsNaN(v) || math.IsInf(v, 0) { + http.Error(w, "invalid tx_power", http.StatusBadRequest) + return + } + case "password": + v, ok := value.(string) + if !ok || v == "" || len(v) > 4096 { + http.Error(w, "invalid password", http.StatusBadRequest) + return + } + default: + http.Error(w, "unsupported configuration field: "+key, http.StatusBadRequest) + return + } + } - var results []map[string]any + changeKeys := make([]string, 0, len(req.Changes)) + for key := range req.Changes { + changeKeys = append(changeKeys, key) + } + sort.Strings(changeKeys) + changeSummary := "Batch config applied: " + strings.Join(changeKeys, ", ") + + results := make([]map[string]any, 0, len(req.DeviceIDs)) for _, deviceID := range req.DeviceIDs { var ip, mac, username, password string - err := a.DB.QueryRow(` + err := a.DB.QueryRowContext(r.Context(), ` SELECT host(ip_address), mac, COALESCE(username, ''), COALESCE(password, '') FROM devices WHERE id = $1 `, deviceID).Scan(&ip, &mac, &username, &password) @@ -4245,19 +4293,13 @@ 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 } - - err = a.Firmware.ApplyConfig(ip, username, password, req.Changes) - if err != nil { + if err := a.Firmware.ApplyConfig(deviceID, ip, username, password, req.Changes); err != nil { results = append(results, map[string]any{"device_id": deviceID, "status": "failed", "error": err.Error()}) continue } - - // Log - a.logChangelogDevice(mac, fmt.Sprintf("Batch config applied: %v", req.Changes), claims.UserID) - + a.logChangelogDevice(mac, changeSummary, claims.UserID) results = append(results, map[string]any{"device_id": deviceID, "status": "success"}) } - writeJSON(w, map[string]any{"results": results}) } diff --git a/cmd/server/main.go b/cmd/server/main.go index edc261f..adfc2d9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -241,10 +241,10 @@ func main() { devicePoller := poller.NewPoller(db, statsStore, wsHub, pollerCfg) // Create scheduler - jobScheduler := scheduler.NewScheduler(db, fwService, wsHub) + jobScheduler := scheduler.NewScheduler(db, fwService, wsHub, devicePoller) // Create job runner for async operations - jobRunner := jobs.NewRunner(db, fwService, wsHub) + jobRunner := jobs.NewRunner(db, fwService, wsHub, devicePoller) jobRunner.Start() // Recover pending jobs from previous runs // Create bulk operations controller diff --git a/internal/firmware/service.go b/internal/firmware/service.go index 4490ec6..0bd383f 100644 --- a/internal/firmware/service.go +++ b/internal/firmware/service.go @@ -2283,7 +2283,7 @@ func (s *Service) PushConfig(ip, username, password string, config []byte) error } // ApplyConfig applies specific configuration changes to a device -func (s *Service) ApplyConfig(ip, username, password string, changes map[string]any) error { +func (s *Service) ApplyConfig(deviceID int, ip, username, password string, changes map[string]any) error { credential, err := s.resolveCredential(username, password, true) if err != nil { return err @@ -2314,7 +2314,15 @@ func (s *Service) ApplyConfig(ip, username, password string, changes map[string] } payload["wireless"].(map[string]any)["txPower"] = power } + var storedNewPassword string if pass, ok := changes["password"].(string); ok && pass != "" { + if s.secretStore == nil { + return errors.New("cannot change password: secret store is unavailable") + } + storedNewPassword, err = s.secretStore.Encrypt(pass) + if err != nil { + return fmt.Errorf("encrypt new device password: %w", err) + } payload["users"] = []map[string]any{ {"name": username, "password": pass}, } @@ -2352,5 +2360,10 @@ func (s *Service) ApplyConfig(ip, username, password string, changes map[string] return fmt.Errorf("apply returned status %d", resp.StatusCode) } + if storedNewPassword != "" { + if _, err := s.db.Exec(`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) + } + } return nil } diff --git a/internal/jobs/runner.go b/internal/jobs/runner.go index ed4b909..85c81fa 100644 --- a/internal/jobs/runner.go +++ b/internal/jobs/runner.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "log" "sort" @@ -100,11 +101,28 @@ type BackupParams struct { IncludeConfig bool `json:"include_config"` } +type DeviceRefresher interface { + RefreshDeviceByID(deviceID int64) error +} + +type RefreshDeviceResult struct { + DeviceID int `json:"device_id"` + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +type RefreshJobResult struct { + Queued int `json:"queued"` + Failed int `json:"failed"` + Results []RefreshDeviceResult `json:"results"` +} + // Runner manages async job execution type Runner struct { db *sql.DB fwService *firmware.Service wsHub *websocket.Hub + refresher DeviceRefresher mu sync.Mutex running map[string]context.CancelFunc // Active jobs with cancel functions @@ -140,13 +158,14 @@ func sanitizeIPForPath(ip string) string { } // NewRunner creates a new job runner -func NewRunner(db *sql.DB, fwService *firmware.Service, wsHub *websocket.Hub) *Runner { +func NewRunner(db *sql.DB, fwService *firmware.Service, wsHub *websocket.Hub, refresher DeviceRefresher) *Runner { maxJobs := 10 ctx, cancel := context.WithCancel(context.Background()) return &Runner{ db: db, fwService: fwService, wsHub: wsHub, + refresher: refresher, running: make(map[string]context.CancelFunc), jobSem: make(chan struct{}, maxJobs), maxJobs: maxJobs, @@ -331,6 +350,7 @@ func (r *Runner) executeJob(jobID string) { // Check if upgrade was skipped (already at target version) finalStatus := StatusCompleted finalMessage := "Job completed successfully" + var finalError *string // Check result for skipped status or upgrade success if result != nil { @@ -348,6 +368,16 @@ func (r *Runner) executeJob(jobID string) { } } + if refreshResult, ok := result.(*RefreshJobResult); ok { + if refreshResult.Failed > 0 { + finalStatus = StatusFailed + finalMessage = fmt.Sprintf("Refresh queued for %d device(s); %d failed to queue", refreshResult.Queued, refreshResult.Failed) + finalError = &finalMessage + } else { + finalMessage = fmt.Sprintf("Refresh queued for %d device(s)", refreshResult.Queued) + } + } + // Bulk/fanout upgrade results if results, ok := result.([]*firmware.UpgradeResult); ok { allSkipped := len(results) > 0 @@ -378,7 +408,7 @@ func (r *Runner) executeJob(jobID string) { } log.Printf("Job %s: marking as %s", jobID, finalStatus) - r.updateStatus(jobID, finalStatus, nil) + r.updateStatus(jobID, finalStatus, finalError) if result != nil { resultJSON, _ := json.Marshal(result) dbExecIgnore(r.db, `UPDATE job_runs SET result = $1 WHERE id = $2`, resultJSON, jobID) @@ -766,12 +796,35 @@ func (r *Runner) runRebootJob(ctx context.Context, job *JobRun) (interface{}, er return results, nil } -// runRefreshJob triggers device refresh +// runRefreshJob queues real immediate polls for the requested inventory devices. func (r *Runner) runRefreshJob(ctx context.Context, job *JobRun) (interface{}, error) { - // This would integrate with the poller to force immediate refresh - r.logEvent(job.ID, EventProgress, nil, fmt.Sprintf("Refreshing %d devices", len(job.DeviceIDs)), nil) - // TODO: integrate with poller.RefreshDevices() - return map[string]int{"refreshed": len(job.DeviceIDs)}, nil + if r.refresher == nil { + return nil, errors.New("device refresher is unavailable") + } + + result := &RefreshJobResult{Results: make([]RefreshDeviceResult, 0, len(job.DeviceIDs))} + r.logEvent(job.ID, EventProgress, nil, fmt.Sprintf("Queueing refresh for %d devices", len(job.DeviceIDs)), nil) + + for i, deviceID := range job.DeviceIDs { + if err := ctx.Err(); err != nil { + return result, err + } + refreshResult := RefreshDeviceResult{DeviceID: deviceID} + if err := r.refresher.RefreshDeviceByID(int64(deviceID)); err != nil { + refreshResult.Status = "failed" + refreshResult.Message = err.Error() + result.Failed++ + r.logEvent(job.ID, EventWarning, &deviceID, "Refresh could not be queued: "+err.Error(), nil) + } else { + refreshResult.Status = "queued" + refreshResult.Message = "Immediate poll queued" + result.Queued++ + r.logEvent(job.ID, EventStepComplete, &deviceID, "Immediate poll queued", nil) + } + result.Results = append(result.Results, refreshResult) + r.updateProgress(job.ID, i+1, len(job.DeviceIDs)) + } + return result, nil } // CancelJob cancels a running job diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 814fba5..1af9c32 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -71,6 +71,10 @@ type UpgradeParams struct { Fanout bool `json:"fanout"` // For APs: upgrade STAs first } +type DeviceRefresher interface { + RefreshDeviceByID(deviceID int64) error +} + // MaintenanceWindow represents a maintenance window type MaintenanceWindow struct { ID int `json:"id"` @@ -92,6 +96,7 @@ type Scheduler struct { db *sql.DB fwService *firmware.Service wsHub *websocket.Hub + refresher DeviceRefresher mu sync.Mutex running bool @@ -111,11 +116,12 @@ type Scheduler struct { } // NewScheduler creates a new scheduler -func NewScheduler(db *sql.DB, fwService *firmware.Service, wsHub *websocket.Hub) *Scheduler { +func NewScheduler(db *sql.DB, fwService *firmware.Service, wsHub *websocket.Hub, refresher DeviceRefresher) *Scheduler { s := &Scheduler{ db: db, fwService: fwService, wsHub: wsHub, + refresher: refresher, maxConcurrentJobs: 5, checkInterval: 10 * time.Second, respectMaintenance: true, @@ -719,11 +725,25 @@ func (s *Scheduler) runRebootJob(ctx context.Context, job ScheduledJob) error { return nil } -// runRefreshJob triggers a poll refresh for devices +// runRefreshJob queues immediate polls for the scheduled devices. func (s *Scheduler) runRefreshJob(ctx context.Context, job ScheduledJob) error { - // This would trigger the poller to refresh specific devices - // For now, just log it - actual implementation would call poller.RefreshDevice() - log.Printf("Job %d: refresh job for %d devices", job.ID, len(job.DeviceIDs)) + if s.refresher == nil { + return errors.New("device refresher is unavailable") + } + failed := 0 + for _, deviceID := range job.DeviceIDs { + if err := ctx.Err(); err != nil { + return err + } + if err := s.refresher.RefreshDeviceByID(int64(deviceID)); err != nil { + failed++ + log.Printf("Job %d: refresh device %d could not be queued: %v", job.ID, deviceID, err) + } + } + if failed > 0 { + return fmt.Errorf("%d of %d device refreshes failed to queue", failed, len(job.DeviceIDs)) + } + log.Printf("Job %d: queued refresh for %d devices", job.ID, len(job.DeviceIDs)) return nil } diff --git a/web/index.html b/web/index.html index 4adedf6..419a22b 100644 --- a/web/index.html +++ b/web/index.html @@ -467,6 +467,6 @@

Batch Configuration

- + diff --git a/web/js/app.js b/web/js/app.js index 11b79a1..c45855b 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -1,11 +1,11 @@ import { api, auth, ws, sync } from './api.js?v=28' -import { store } from './store.js?v=16' +import { store } from './store.js?v=17' import { renderDevices, renderTree, renderLogs, renderDeviceDetail, renderDirectionalCell, showToast, updateWarningsPanel, showJobPanel, hideJobPanel, toggleJobPanel, updateJobProgress, updateJobStatus, - addJobEvent, startTrackedJob, trackJob, getActiveJobCount, cleanupVirtualTable } from './components.js?v=66' + addJobEvent, startTrackedJob, trackJob, getActiveJobCount, cleanupVirtualTable } from './components.js?v=68' import { wsBatcher, shouldUseVirtualTable, setUpdateCountsCallback, scrollToDeviceById -} from './virtual-integration.js?v=12' +} from './virtual-integration.js?v=14' // Debounced renderTree - prevents excessive re-renders with many devices let renderTreeTimeout = null @@ -5652,6 +5652,7 @@ function formatSize(bytes) { let mapInstance = null let mapLinksLayer = null // Layer group for links - can be toggled without reinit let mapMarkersLayer = null +let mapMarkerByDeviceID = new Map() let mapFilterMode = 'all' // 'all', 'aps', 'stas' function updateMapMarkerSizing() { @@ -5710,6 +5711,53 @@ function setMapFilter(mode) { initMap() } +function getMapFilteredDevices() { + const filter = (store.treeFilter || '').trim().toLowerCase() + const devices = Array.isArray(store.devices) ? store.devices : [] + const directMatches = new Set() + if (filter) { + devices.forEach(d => { + const matches = (d.hostname || '').toLowerCase().includes(filter) || + (d.ip_address || '').toLowerCase().includes(filter) || + (d.site_name || '').toLowerCase().includes(filter) + if (matches) directMatches.add(d.id) + }) + } + return devices.filter(d => { + const isAP = !d.parent_id + if (mapFilterMode === 'aps' && !isAP) return false + if (mapFilterMode === 'stas' && isAP) return false + const status = store.getStatus(d) + if (status === 'online' && !store.filters.online) return false + if (status === 'offline' && !store.filters.offline) return false + if (status === 'unknown' && !store.filters.unknown) return false + if (!filter) return true + if (directMatches.has(d.id)) return true + if (d.parent_id && directMatches.has(d.parent_id)) return true + if (isAP && store.getSTAs(d.id).some(sta => directMatches.has(sta.id))) return true + return false + }) +} + +function focusMapDevice(deviceId, retry = 0) { + if (store.currentPage !== 'map') return false + const marker = mapMarkerByDeviceID.get(Number(deviceId)) + if (!mapInstance || !marker) { + if (retry < 4) setTimeout(() => focusMapDevice(deviceId, retry + 1), 100) + return false + } + const latLng = marker.getLatLng() + const zoom = Math.max(mapInstance.getZoom?.() || 0, 15) + mapInstance.setView(latLng, zoom, { animate: true }) + marker.openPopup() + const element = marker.getElement?.() + if (element) { + element.classList.add('search-current') + setTimeout(() => element.classList.remove('search-current'), 2000) + } + return true +} + function initMap() { const container = document.getElementById('mapContainer') if (!container) { @@ -5729,51 +5777,10 @@ function initMap() { mapLinksLayer = null mapMarkersLayer = null } + mapMarkerByDeviceID.clear() - // Get filter from store const filter = store.treeFilter || '' - - // Filter devices based on tree filter, status filters, and AP/STA mode - const filteredDevices = store.devices.filter(d => { - // Apply AP/STA filter mode - const isAP = !d.parent_id - if (mapFilterMode === 'aps' && !isAP) return false - if (mapFilterMode === 'stas' && isAP) return false - - // Apply status filter - const status = store.getStatus(d) - if (status === 'online' && !store.filters.online) return false - if (status === 'offline' && !store.filters.offline) return false - if (status === 'unknown' && !store.filters.unknown) return false - - // Apply search filter - if (filter) { - const matchesDevice = (d.hostname || '').toLowerCase().includes(filter) || - (d.ip_address || '').includes(filter) || - (d.site_name || '').toLowerCase().includes(filter) - // Also include if parent AP matches (show AP and all its STAs) - if (d.parent_id) { - const parent = store.devices.find(p => p.id === d.parent_id) - if (parent) { - const parentMatches = (parent.hostname || '').toLowerCase().includes(filter) || - (parent.ip_address || '').includes(filter) - if (parentMatches) return true - } - } - // Include APs if any of their STAs match - if (!d.parent_id) { - const stas = store.devices.filter(s => s.parent_id === d.id) - const staMatches = stas.some(s => - (s.hostname || '').toLowerCase().includes(filter) || - (s.ip_address || '').includes(filter) - ) - if (staMatches) return true - } - return matchesDevice - } - - return true - }) + const filteredDevices = getMapFilteredDevices() console.log('Map: filtered devices =', filteredDevices.length, 'of', store.devices.length) @@ -5858,6 +5865,7 @@ function initMap() { `) markers.push(marker) + mapMarkerByDeviceID.set(Number(device.id), marker) if (isAP) apMarkers[device.id] = marker }) @@ -11377,6 +11385,7 @@ store.on(() => { setTimeout(() => window.dispatchEvent(new Event('resize')), 50) } } + updateBulkToolbar() }) // Nav link handlers @@ -11384,6 +11393,8 @@ document.querySelectorAll('.header-tabs a[data-page]').forEach(link => { link.addEventListener('click', e => { e.preventDefault() const page = link.dataset.page + store.clearBulkSelection() + document.getElementById('bulkToolbar')?.classList.add('hidden') store.set({ currentPage: page, selectedDevice: null }) renderCurrentPage() }) @@ -11394,6 +11405,8 @@ document.querySelectorAll('.sidebar-link[data-page]').forEach(link => { link.addEventListener('click', e => { e.preventDefault() const page = link.dataset.page + store.clearBulkSelection() + document.getElementById('bulkToolbar')?.classList.add('hidden') store.set({ currentPage: page, selectedDevice: null }) renderCurrentPage() }) @@ -11478,11 +11491,36 @@ const searchInfo = document.getElementById('searchInfo') let searchDebounce = null let searchMatches = [] // Array of matched element IDs let currentMatchIndex = -1 +let hiddenSearchMatchCount = 0 + +function deviceMatchesHeaderSearch(device, query) { + const hostname = (device.hostname || '').toLowerCase() + const ip = (device.ip_address || '').toLowerCase() + const mac = (device.mac || '').toLowerCase() + const product = (device.product || device.model || '').toLowerCase() + return hostname.includes(query) || ip.includes(query) || mac.includes(query) || product.includes(query) +} + +// Header search is an in-view navigator. Keep its result set aligned with +// devices the Dashboard can actually reveal after status/scope filters. +function getDashboardSearchCandidates() { + const visibleDevices = Array.isArray(store.filteredDevices) ? store.filteredDevices : [] + const visibleRootIds = new Set( + (store.aps || []) + .filter(device => store.filters?.[store.getStatus(device)] !== false) + .map(device => device.id) + ) + + return visibleDevices.filter(device => + !device.parent_id || device.managed || visibleRootIds.has(device.parent_id) + ) +} function performSearch() { const query = (searchInput?.value || '').trim().toLowerCase() searchMatches = [] currentMatchIndex = -1 + hiddenSearchMatchCount = 0 // Clear all highlights document.querySelectorAll('.search-highlight').forEach(el => el.classList.remove('search-highlight')) @@ -11490,6 +11528,11 @@ function performSearch() { if (!query) { if (searchInfo) searchInfo.textContent = '' + // Header search temporarily reveals its target in the host tree. When the + // query is cleared, restore the independent sidebar tree filter. + if (store.currentPage === 'dashboard' || store.currentPage === 'devices') { + renderTree(store.treeFilter || '') + } return } @@ -11497,17 +11540,21 @@ function performSearch() { const page = store.currentPage if (page === 'dashboard' || page === 'devices') { - // Search ALL devices in store, not just DOM - store.devices.forEach(d => { - const hostname = (d.hostname || '').toLowerCase() - const ip = (d.ip_address || '').toLowerCase() - const mac = (d.mac || '').toLowerCase() - const product = (d.product || d.model || '').toLowerCase() - - if (hostname.includes(query) || ip.includes(query) || mac.includes(query) || product.includes(query)) { + // Search only devices the current Dashboard scope/status filters can reveal. + // Keep track of inventory matches hidden by those filters so the UI does not + // claim a navigable match and then fail to scroll to it. + const candidates = getDashboardSearchCandidates() + const candidateIds = new Set(candidates.map(d => d.id)) + candidates.forEach(d => { + if (deviceMatchesHeaderSearch(d, query)) { searchMatches.push({ type: 'device', id: d.id, device: d }) } }) + store.devices.forEach(d => { + if (!candidateIds.has(d.id) && deviceMatchesHeaderSearch(d, query)) { + hiddenSearchMatchCount++ + } + }) } else if (page === 'topology') { // Topology view - search all devices, will highlight cards/chips store.devices.forEach(d => { @@ -11519,14 +11566,13 @@ function performSearch() { } }) } else if (page === 'map') { - // Map view - match devices for map focus - store.devices.forEach(d => { - const hostname = (d.hostname || '').toLowerCase() - const ip = (d.ip_address || '').toLowerCase() - if (hostname.includes(query) || ip.includes(query)) { - searchMatches.push({ type: 'device', id: d.id, device: d }) - } - }) + getMapFilteredDevices() + .filter(d => Number.isFinite(Number(d.gps_lat)) && Number.isFinite(Number(d.gps_lon))) + .forEach(d => { + if (deviceMatchesHeaderSearch(d, query)) { + searchMatches.push({ type: 'device', id: d.id, device: d }) + } + }) } else if (page === 'drilldown') { // Drilldown view - search ONLY the currently displayed drilldown table rows // (not the full device inventory). @@ -11557,7 +11603,11 @@ function updateSearchInfo() { if (searchMatches.length === 0) { const query = (searchInput?.value || '').trim() - searchInfo.textContent = query ? 'No matches' : '' + if (query && hiddenSearchMatchCount > 0) { + searchInfo.textContent = `${hiddenSearchMatchCount} ${hiddenSearchMatchCount === 1 ? 'match' : 'matches'} hidden by filters` + } else { + searchInfo.textContent = query ? 'No matches' : '' + } searchInfo.className = 'search-info' + (query ? ' no-matches' : '') } else { searchInfo.textContent = `${currentMatchIndex + 1} of ${searchMatches.length}` @@ -11580,14 +11630,18 @@ function highlightCurrentMatch() { const device = match.device if (page === 'dashboard' || page === 'devices') { - // Expand parent if this is a STA - if (device.parent_id) { + // Expand the real parent for nested STAs. Managed STAs are root nodes. + if (device.parent_id && !device.managed) { store.treeExpanded[device.parent_id] = true - // Re-render tree to show expanded state - renderTree() } - - // Find and highlight the tree node + + // Selection updates the detail pane. Rebuild the host tree after selection + // so its selected state is current and so a header search can temporarily + // reveal a target hidden by the independent sidebar text filter. + store.set({ selectedDevice: deviceId }) + renderTree() + + // Find, highlight, and center the host in the scrollable sidebar. const treeNode = document.querySelector(`.tree-node[data-id="${deviceId}"]`) if (treeNode) { const content = treeNode.querySelector('.tree-node-content') @@ -11596,9 +11650,8 @@ function highlightCurrentMatch() { treeNode.scrollIntoView({ behavior: 'smooth', block: 'center' }) } - // Scroll to and highlight device in the table (works with virtual table too) - // Also select the device to show in detail panel - store.set({ selectedDevice: deviceId }) + // Scroll to and highlight the device table row too. The small delay lets + // the detail pane finish changing the available virtual-table viewport. setTimeout(() => scrollToDevice(deviceId), 100) } else if (page === 'topology') { // Find the card or STA chip @@ -11628,8 +11681,7 @@ function highlightCurrentMatch() { } } } else if (page === 'map') { - // Could pan map to marker - for now just show in info - // TODO: integrate with Leaflet map panning + focusMapDevice(deviceId) } else if (page === 'drilldown') { // Find and highlight the drilldown row const row = document.querySelector(`#drilldownBody tr[data-id="${deviceId}"]`) @@ -12779,13 +12831,15 @@ contextMenu?.querySelectorAll('.context-item').forEach(item => { const bulkToolbar = document.getElementById('bulkToolbar') function getSelectedDeviceIds() { - const checkboxes = document.querySelectorAll('.device-table tbody input[type="checkbox"]:checked') - return Array.from(checkboxes).map(cb => parseInt(cb.dataset.id)).filter(id => !isNaN(id)) + return store.bulkSelectedDeviceIds.map(id => Number(id)).filter(id => Number.isFinite(id)) } function updateBulkToolbar() { const selected = getSelectedDeviceIds() const count = selected.length + document.querySelectorAll('.device-table tbody input[type="checkbox"][data-id]').forEach(cb => { + cb.checked = store.isBulkSelected(cb.dataset.id) + }) if (count > 0) { bulkToolbar?.classList.remove('hidden') @@ -12798,16 +12852,115 @@ function updateBulkToolbar() { // Listen for checkbox changes document.addEventListener('change', e => { - if (e.target.matches('.device-table input[type="checkbox"]')) { + if (e.target.matches('.device-table input[type="checkbox"][data-id]')) { + store.setBulkSelected(e.target.dataset.id, e.target.checked) updateBulkToolbar() } }) document.getElementById('bulkCancel')?.addEventListener('click', () => { - document.querySelectorAll('.device-table input[type="checkbox"]').forEach(cb => { - cb.checked = false + store.clearBulkSelection() + updateBulkToolbar() +}) + +function resetBatchConfigForm() { + ;['cfgSSID', 'cfgChannel', 'cfgPower', 'cfgPassword'].forEach(id => { + const checkbox = document.getElementById(id) + if (checkbox) checkbox.checked = false + }) + ;['cfgSSIDValue', 'cfgChannelValue', 'cfgPowerValue', 'cfgPasswordValue'].forEach(id => { + const input = document.getElementById(id) + if (input) input.value = '' }) - bulkToolbar?.classList.add('hidden') + document.getElementById('batchConfigResults')?.replaceChildren() +} + +function openBatchConfigForSelection() { + const ids = getSelectedDeviceIds() + if (ids.length === 0) return + const roles = store.user?.roles || [] + if (!roles.includes('editor') && !roles.includes('administrator')) { + showToast('Batch configuration requires editor access', 'error') + return + } + const select = document.getElementById('batchDevices') + if (!select) return + select.replaceChildren() + ids.forEach(id => { + const device = store.getDeviceById(id) + if (!device) return + const option = document.createElement('option') + option.value = String(id) + option.selected = true + option.textContent = device.hostname || device.ip_address || device.mac || `Device ${id}` + select.appendChild(option) + }) + resetBatchConfigForm() + openModalElement('batchConfigModal') +} + +document.getElementById('bulkConfig')?.addEventListener('click', openBatchConfigForSelection) + +document.getElementById('confirmBatchConfig')?.addEventListener('click', async () => { + const button = document.getElementById('confirmBatchConfig') + const select = document.getElementById('batchDevices') + const resultHost = document.getElementById('batchConfigResults') + const ids = Array.from(select?.selectedOptions || []).map(option => Number(option.value)).filter(Number.isFinite) + if (ids.length === 0) { + showToast('Select at least one target device', 'error') + return + } + const changes = {} + if (document.getElementById('cfgSSID')?.checked) { + const value = document.getElementById('cfgSSIDValue')?.value || '' + if (!value.trim()) { showToast('SSID cannot be empty', 'error'); return } + changes.ssid = value + } + if (document.getElementById('cfgChannel')?.checked) { + const value = Number(document.getElementById('cfgChannelValue')?.value) + if (!Number.isFinite(value) || value <= 0) { showToast('Enter a valid channel', 'error'); return } + changes.channel = value + } + if (document.getElementById('cfgPower')?.checked) { + const value = Number(document.getElementById('cfgPowerValue')?.value) + if (!Number.isFinite(value)) { showToast('Enter a valid TX power', 'error'); return } + changes.tx_power = value + } + if (document.getElementById('cfgPassword')?.checked) { + const value = document.getElementById('cfgPasswordValue')?.value || '' + if (!value || value.length > 4096) { showToast(value ? 'Password is too long' : 'Password cannot be empty', 'error'); return } + changes.password = value + } + if (Object.keys(changes).length === 0) { + showToast('Enable at least one configuration change', 'error') + return + } + button.disabled = true + if (resultHost) resultHost.textContent = `Applying changes to ${ids.length} device${ids.length === 1 ? '' : 's'}...` + try { + const response = await api.batchConfig(ids, changes) + const results = Array.isArray(response?.results) ? response.results : [] + if (resultHost) { + resultHost.replaceChildren() + results.forEach(result => { + const row = document.createElement('div') + row.className = `batch-result ${result.status === 'success' ? 'success' : 'failed'}` + const device = store.getDeviceById(result.device_id) + const name = device?.hostname || device?.ip_address || `Device ${result.device_id}` + row.textContent = result.status === 'success' ? `${name}: applied` : `${name}: ${result.error || 'failed'}` + resultHost.appendChild(row) + }) + } + const succeeded = results.filter(result => result.status === 'success').map(result => Number(result.device_id)) + await Promise.allSettled(succeeded.map(id => api.refreshDevice(id))) + const failed = results.length - succeeded.length + showToast(failed === 0 ? `Configuration applied to ${succeeded.length} device${succeeded.length === 1 ? '' : 's'}` : `Configuration applied to ${succeeded.length}; ${failed} failed`, failed === 0 ? 'success' : 'warning') + } catch (e) { + if (resultHost) resultHost.textContent = 'Batch configuration failed: ' + e.message + showToast('Batch configuration failed: ' + e.message, 'error') + } finally { + button.disabled = false + } }) document.getElementById('bulkRefresh')?.addEventListener('click', async () => { @@ -12982,9 +13135,10 @@ document.getElementById('bulkDelete')?.addEventListener('click', async () => { const devices = await api.devices() store.set({ devices, selectedDevice: null }) + store.clearBulkSelection() renderTree() renderCurrentPage() - bulkToolbar?.classList.add('hidden') + updateBulkToolbar() showToast(`Deleted ${deleted} devices`, 'success') }) diff --git a/web/js/components.js b/web/js/components.js index a9e7342..c35d636 100644 --- a/web/js/components.js +++ b/web/js/components.js @@ -1,4 +1,4 @@ -import { store } from './store.js?v=16' +import { store } from './store.js?v=17' import { api } from './api.js?v=28' import { VirtualTable, @@ -6,8 +6,9 @@ import { getSortedFilteredDevices, setVirtualTableRef, VIRTUAL_THRESHOLD, - triggerUpdateCounts -} from './virtual-integration.js?v=12' + triggerUpdateCounts, + scrollToDeviceById +} from './virtual-integration.js?v=14' async function requestConfirmation(message, options = {}) { @@ -491,6 +492,10 @@ export function renderTree(filter = '') { // Scroll to device in table function scrollToDevice(id) { + // Large fleets use a virtual table, so the target row may not exist in the + // DOM until the virtual scroller is moved to it. + if (scrollToDeviceById(id)) return + const row = document.querySelector(`tr[data-id="${id}"]`) if (row) { row.scrollIntoView({ behavior: 'smooth', block: 'center' }) @@ -1101,7 +1106,7 @@ function renderDeviceRowContent(device, cols) { const escapedProduct = escapeHTML(device.product || device.model || '-') return ` - + ${cols.status !== false ? `` : ''} ${cols.name !== false ? ` @@ -1451,7 +1456,7 @@ function renderDeviceRow(device, cols = {}) { return ` - + ${cols.status !== false ? `` : ''} ${cols.name !== false ? ` diff --git a/web/js/store.js b/web/js/store.js index f8a9e1c..6520a16 100644 --- a/web/js/store.js +++ b/web/js/store.js @@ -219,6 +219,7 @@ let state = { devices: [], devicesVersion: 0, // Increments when devices array changes selectedDevice: null, + bulkSelected: new Set(), // Persists across virtual row recycling/live updates currentPage: 'dashboard', filters: { online: true, @@ -269,6 +270,7 @@ export const store = { get devices() { return state.devices }, get devicesVersion() { return state.devicesVersion }, get selectedDevice() { return state.selectedDevice }, + get bulkSelectedDeviceIds() { return Array.from(state.bulkSelected) }, get currentPage() { return state.currentPage }, get filters() { return state.filters }, get searchQuery() { return state.searchQuery }, @@ -302,6 +304,8 @@ export const store = { if (updates.devices !== undefined) { next.devicesVersion = state.devicesVersion + 1 rebuildDeviceIndexes(next.devices) + const validIDs = new Set((Array.isArray(next.devices) ? next.devices : []).map(d => Number(d.id))) + next.bulkSelected = new Set(Array.from(state.bulkSelected).filter(id => validIDs.has(id))) } state = next if (updates.dashboardExclusions !== undefined) { @@ -316,6 +320,24 @@ export const store = { state = { ...state, columns } listeners.forEach(fn => fn(state)) }, + + isBulkSelected(id) { + return state.bulkSelected.has(Number(id)) + }, + + setBulkSelected(id, selected) { + const deviceID = Number(id) + if (!Number.isFinite(deviceID)) return + const bulkSelected = new Set(state.bulkSelected) + if (selected) bulkSelected.add(deviceID) + else bulkSelected.delete(deviceID) + state = { ...state, bulkSelected } + }, + + clearBulkSelection() { + if (state.bulkSelected.size === 0) return + state = { ...state, bulkSelected: new Set() } + }, on(fn) { listeners.push(fn) diff --git a/web/js/virtual-integration.js b/web/js/virtual-integration.js index 61a83bb..1477464 100644 --- a/web/js/virtual-integration.js +++ b/web/js/virtual-integration.js @@ -9,8 +9,8 @@ // 4. Re-exports VirtualTable class // -import { VirtualTable } from './virtual-table.js?v=6' -import { store } from './store.js?v=16' +import { VirtualTable } from './virtual-table.js?v=7' +import { store } from './store.js?v=17' // Re-export for components.js export { VirtualTable } diff --git a/web/js/virtual-table.js b/web/js/virtual-table.js index b293760..6194a30 100644 --- a/web/js/virtual-table.js +++ b/web/js/virtual-table.js @@ -440,22 +440,30 @@ export class VirtualTable { scrollToId(id) { const item = this.dataById.get(id) - if (!item) return false + if (!item || !this.scrollContainer) return false const index = this.data.indexOf(item) if (index === -1) return false - - const targetY = index * this.rowHeight - (this.viewportHeight / 2) + + // Update our internal scroll position immediately instead of waiting for the + // browser's scroll event/RAF path. Search navigation can otherwise race the + // virtual renderer and try to highlight a row that has not been created yet. + const viewportHeight = this.scrollContainer.clientHeight || this.viewportHeight || this.rowHeight + this.viewportHeight = viewportHeight + const targetY = index * this.rowHeight - Math.max(0, (viewportHeight - this.rowHeight) / 2) this.scrollContainer.scrollTop = Math.max(0, targetY) + this.scrollTop = this.scrollContainer.scrollTop + this._renderViewport() - // Highlight after scroll - setTimeout(() => { + // The target row is now in the rendered buffer. Highlight on the next frame + // so the scroll position is painted before the animation begins. + requestAnimationFrame(() => { const cached = this.rowCache.get(id) if (cached) { cached.element.classList.add('highlighted') setTimeout(() => cached.element.classList.remove('highlighted'), 2000) } - }, 100) + }) return true }